diff --git a/docs/ADAPTER_SPEC.md b/docs/ADAPTER_SPEC.md index b3b2337..930b039 100644 --- a/docs/ADAPTER_SPEC.md +++ b/docs/ADAPTER_SPEC.md @@ -107,7 +107,9 @@ adapter 应优先用 `events.poll` 观察 `action.updated`,再用 `actions.res 这些样例使用通用应用名,不绑定任何第三方项目。测试会校验样例的 manifest、checksum、权限和 payload 文件。 -本机应用接入 local bridge 的最小请求流程见 [generic-adapter](examples/generic-adapter/)。 +本机应用接入 local bridge 的最小请求流程见 [generic-adapter](examples/generic-adapter/)。那里有一个可执行的 `generic-adapter.mjs` 样板,直接串起 `export -> bundle.detail -> events.poll/actions.results -> bundle.import -> receipt -> rollback`。 + +样板里 `bundle.detail` 的只读预览状态用 `saved` / `imported`,`actions.results` 和 `events.poll` 里的动作状态用同一组生命周期词:`queued`、`running`、`succeeded`、`failed`、`conflict`、`cancelled`。 ## 仍未实现 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ddc5c07..1db5bb8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -98,6 +98,7 @@ NekoDrop 已经有一个可用的 macOS / Windows 桌面互传主线: 目标:给上层数据传输建立统一包格式,不把 skills、session、agent profile 当作普通散文件乱传。 规格文档:[BUNDLE_SPEC.md](BUNDLE_SPEC.md)。当前已有协议模型、校验、staging、手动创建、收到后查看、删除、过期清理和导入到 NekoDrop 本机导入区;自动导出、导入计划预览和上层应用真实导入还没有完成。 +`docs/examples/generic-adapter/` 现在提供了一个可执行的本机样板,串起 export、bundle.detail、events.poll、actions.results、bundle.import、receipt 和 rollback。它是适配边界的参照,不是对真实第三方应用的自动接入。 候选包类型: @@ -152,7 +153,7 @@ local application - 更完整的事件订阅,不只依赖短等待轮询 - 本机接入 UI 对待授权、待执行和失败原因的展示 -- 通用 adapter 样例,让上层应用知道怎么导出和导入 bundle +- 通用 adapter 样例已经落成,后续继续补真实导入计划和冲突策略 - 导入计划和冲突策略 不做: diff --git a/docs/STATUS.md b/docs/STATUS.md index 042fb5d..a8e0168 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -84,8 +84,8 @@ | iroh transport | 实验中 | 只有类型预留和明确错误,未接入 iroh runtime。 | | Relay / P2P transport | 实验中 | 只有类型预留和明确错误。 | | NekoLink bundle manifest | 部分接入 | [BUNDLE_SPEC.md](BUNDLE_SPEC.md) 已定义包结构、权限、校验和导入边界;`nekolink-protocol` 已有 bundle manifest、checksums、permissions 类型和校验,`nekodrop-storage` 已能识别、校验、保存到 staging,也能把用户选择的目录打成 v1 bundle;`nekodrop-service` 已有接收完成后的 staged bundle report。桌面端的资料包创建入口已收进发送页,收到的 staged bundle 在收件流程里查看、删除和手动导入到本机导入区;导入使用临时目录落盘,失败不留下半成品目标目录;桌面端会清理过期暂存,删除和导入失败状态会留在收件流程里。`bundle.send` 的本机 local bridge 执行入口现在可以消费待执行动作并交给桌面发送主线;`bundle.import` 可以消费待执行动作并把 staged bundle 导入本机导入区;同名导入会拒绝覆盖并返回 `bundle_import_conflict`。上层应用自动导出 session / skill / workspace 还没有接入。 | -| Adapter 规范和 bundle 样例 | 部分接入 | [ADAPTER_SPEC.md](ADAPTER_SPEC.md) 已定义上层应用导出/导入 bundle 的边界;[bundle-samples](bundle-samples/) 提供 `skill`、`session`、`workspace`、`agent_profile`、`config_snapshot` 五类可校验样例。真实上层应用 adapter 还没有接入,但本机 local bridge 的动作结果和短等待轮询已经有了。 | -| 本机 local bridge 协议模型 | 部分接入 | `nekolink-protocol` 已定义 `LocalBridgeRequest` / `LocalBridgeEvent` 的 JSON 模型,覆盖查询设备、申请本机授权、查询 staged bundle 详情、发送 bundle、收到 bundle 通知、请求导入、查询传输状态、查询动作结果和 `events.poll` 事件轮询;请求可以带本机 `client` 标识,授权申请已有通用 scope:`device.read`、`transfer.status.read`、`bundle.read`、`bundle.send`、`bundle.import.request`。桌面端内部 handler 可以把只读请求映射到可信设备、staged bundle 列表/详情和 transfer status,并区分 `read_only` / `requires_user_confirmation`、`anonymous` / `identified`;设置页可以触发一次内部 `devices.list` 只读自测,并显示 localhost runtime 的真实监听状态、地址、待确认授权、已授权数量、待执行动作数量和最近结果。桌面端启动时会开启只绑定 `127.0.0.1` 的 localhost runtime,只接受 `POST /bridge/request`,请求体有大小上限;只读请求和授权申请走同一套 handler。用户确认授权码后,runtime 会记录该 client 的限时权限并写入本机授权文件;下次启动会恢复未过期授权。已授权 client 调用 `bundle.send` / `bundle.import` 时,runtime 会把请求写入内存待执行队列,后台 worker 会自动消费;设置页可以查看概要并移除这些待执行动作;`bundle.send` 会先做 preflight,再复用现有 authenticated send 主线发送到目标设备;`bundle.import` 可以按 FIFO 消费待执行动作,并把 staged bundle 导入本机导入区,同名导入返回 `bundle_import_conflict`。动作生命周期会写入 `queued`、`running`、`succeeded`、`failed`、`conflict`、`cancelled`,授权 client 可通过 `events.poll` 的 `action.updated` 持续观察,也可用 `actions.results` 补偿查询自己的最新动作结果。普通列表、事件和结果都不暴露本机 `bundle_root`。runtime 现在有内存事件队列,真实发送/接收主流程会写入 `transfer.updated`,收到 staged bundle 会写入 `bundle.received`;已授权 client 可用 `events.poll` 轮询快照或短等待新事件。 | +| Adapter 规范和 bundle 样例 | 部分接入 | [ADAPTER_SPEC.md](ADAPTER_SPEC.md) 已定义上层应用导出/导入 bundle 的边界;[bundle-samples](bundle-samples/) 提供 `skill`、`session`、`workspace`、`agent_profile`、`config_snapshot` 五类可校验样例;[generic-adapter](examples/generic-adapter/) 现在提供一个可执行的本机样板,串起导出、只读预览、事件轮询、动作结果、导入、receipt 和 rollback。真实上层应用 adapter 还没有接入,但本机 local bridge 的动作结果和短等待轮询已经有了。 | +| 本机 local bridge 协议模型 | 部分接入 | `nekolink-protocol` 已定义 `LocalBridgeRequest` / `LocalBridgeEvent` 的 JSON 模型,覆盖查询设备、申请本机授权、查询 staged bundle 详情、发送 bundle、收到 bundle 通知、请求导入、查询传输状态、查询动作结果和 `events.poll` 事件轮询;请求可以带本机 `client` 标识,授权申请已有通用 scope:`device.read`、`transfer.status.read`、`bundle.read`、`bundle.send`、`bundle.import.request`。桌面端内部 handler 可以把只读请求映射到可信设备、staged bundle 列表/详情和 transfer status,并区分 `read_only` / `requires_user_confirmation`、`anonymous` / `identified`;设置页可以触发一次内部 `devices.list` 只读自测,并显示 localhost runtime 的真实监听状态、地址、待确认授权、已授权数量、待执行动作数量和最近结果。桌面端启动时会开启只绑定 `127.0.0.1` 的 localhost runtime,只接受 `POST /bridge/request`,请求体有大小上限;只读请求和授权申请走同一套 handler。用户确认授权码后,runtime 会记录该 client 的限时权限并写入本机授权文件;下次启动会恢复未过期授权。已授权 client 调用 `bundle.send` / `bundle.import` 时,runtime 会把请求写入内存待执行队列,后台 worker 会自动消费;设置页可以查看概要并移除这些待执行动作;`bundle.send` 会先做 preflight,再复用现有 authenticated send 主线发送到目标设备;`bundle.import` 可以按 FIFO 消费待执行动作,并把 staged bundle 导入本机导入区,同名导入返回 `bundle_import_conflict`。动作生命周期会写入 `queued`、`running`、`succeeded`、`failed`、`conflict`、`cancelled`,授权 client 可通过 `events.poll` 的 `action.updated` 持续观察,也可用 `actions.results` 补偿查询自己的最新动作结果。`bundle.detail` 只返回只读预览,不改变本机 staging;普通列表、事件和结果都不暴露本机 `bundle_root`。runtime 现在有内存事件队列,真实发送/接收主流程会写入 `transfer.updated`,收到 staged bundle 时会写入 `bundle.received`;已授权 client 可用 `events.poll` 轮询快照或短等待新事件。 | ## 当前不能宣传为已完成 @@ -114,7 +114,7 @@ V0.7 V0.8 NekoLink 上层包格式: - 定义 bundle manifest,为 skills、session、agent profile、workspace 这类上层数据传输提供统一校验、权限和兼容边界;桌面端已有 staging、预览、删除、过期清理和手动导入到本机导入区;local bridge 已有 localhost runtime、授权、待执行队列、动作结果和事件轮询。下一步补导入计划、冲突策略和真实上层应用适配。 + 定义 bundle manifest,为 skills、session、agent profile、workspace 这类上层数据传输提供统一校验、权限和兼容边界;桌面端已有 staging、预览、删除、过期清理和手动导入到本机导入区;local bridge 已有 localhost runtime、授权、待执行队列、动作结果和事件轮询。generic adapter 样板已经落到可执行脚本,但真实上层应用接入、导入计划和冲突策略还要继续补。 V0.9 transport 技术验证: diff --git a/docs/examples/generic-adapter/README.md b/docs/examples/generic-adapter/README.md index e806f67..245f6cb 100644 --- a/docs/examples/generic-adapter/README.md +++ b/docs/examples/generic-adapter/README.md @@ -1,157 +1,38 @@ -# 通用 Adapter 示例 +# 通用 Adapter 样板 -这个示例说明一个本机应用如何接入 NekoDrop / NekoLink bundle。它不绑定任何具体应用。 +这个目录提供一个可执行的最小样板,说明本机应用怎样围绕 NekoLink bundle 走完整流程。 -## 导出 - -adapter 先把自己的数据导出成一个 bundle 目录: +## 文件 ```text -exported-bundle/ - bundle.json - checksums.json - permissions.json - files/ - session.json -``` - -导出前必须做两件事: - -- 移除 token、cookie、密钥、机器本地路径和账号私密标识。 -- 如果不能确认已经脱敏,把 `permissions.json` 里的 `contains_secrets` 设为 `true`,这样接收端只能保存,不能导入。 - -## 授权 - -本机应用第一次发送或请求导入前,先申请权限: - -```json -{ - "kind": "authorization.request", - "payload": { - "request_id": "adapter-auth-001", - "client": { - "client_id": "generic.adapter", - "display_name": "Generic Adapter", - "app_kind": "agent" - }, - "requested_scopes": [ - "device.read", - "bundle.send", - "bundle.import.request", - "transfer.status.read" - ], - "reason": "Send and import user-selected bundles", - "ttl_seconds": 3600 - } -} -``` - -NekoDrop 会返回短授权码。用户在设置 -> 接入里确认后,后续请求才会进入待执行队列。 - -## 发送 - -```json -{ - "kind": "bundle.send", - "payload": { - "request_id": "adapter-send-001", - "client": { - "client_id": "generic.adapter", - "display_name": "Generic Adapter", - "app_kind": "agent" - }, - "target_device_id": "neko-device-target", - "bundle_root": "/absolute/path/to/exported-bundle", - "bundle_type": "session", - "require_trusted_device": true - } -} +generic-adapter.mjs +generic-adapter.test.mjs ``` -请求成功只代表动作入队,不代表已经发送完成。桌面端后台 worker 会自动做 preflight 和真实发送。adapter 优先用 `events.poll` 里的 `action.updated` 观察进度,再用 `actions.results` 查最新结果。 +## 流程 -## 查询结果 +1. 导出一个已经校验过的 bundle 目录。 +2. 生成 `authorization.request`、`bundle.send`、`bundle.detail`、`events.poll`、`actions.results`、`bundle.import` 请求。 +3. 先看 `bundle.detail` 的只读预览,再看 `actions.results` 和 `events.poll` 的状态词。 +4. 导入后写 receipt。 +5. 需要撤销时删除导出目录。 -```json -{ - "kind": "actions.results", - "payload": { - "request_id": "adapter-results-001", - "client": { - "client_id": "generic.adapter", - "display_name": "Generic Adapter", - "app_kind": "agent" - }, - "after_claimed_at_ms": null, - "limit": 20 - } -} -``` - -结果里的 `lifecycle_status` 可能是: - -- `queued` -- `running` -- `succeeded` -- `failed` -- `conflict` -- `cancelled` - -旧字段 `status` 仍会保留给兼容代码。新 adapter 应优先读 `lifecycle_status`。 - -常见 `reason`: - -- `bundle_root_missing` -- `bundle_invalid` -- `bundle_type_mismatch` -- `trusted_target_missing` -- `bundle_send_failed` -- `bundle_import_conflict` -- `bundle_import_failed` +## 运行 -## 等待事件 - -`events.poll` 默认立即返回快照。需要减少轮询时,可以加 `timeout_ms`: - -```json -{ - "kind": "events.poll", - "payload": { - "request_id": "adapter-events-001", - "client": { - "client_id": "generic.adapter", - "display_name": "Generic Adapter", - "app_kind": "agent" - }, - "after_event_id": null, - "limit": 20, - "timeout_ms": 15000 - } -} +```bash +node docs/examples/generic-adapter/generic-adapter.mjs export --out /tmp/generic-adapter-bundle +node docs/examples/generic-adapter/generic-adapter.mjs plan --bundle /tmp/generic-adapter-bundle +node docs/examples/generic-adapter/generic-adapter.mjs receipt --bundle /tmp/generic-adapter-bundle --receipt-out /tmp/generic-receipt.json +node docs/examples/generic-adapter/generic-adapter.mjs rollback --bundle /tmp/generic-adapter-bundle ``` -`action.updated` 事件会带 `request_id`、`action_kind`、`status`、`reason`、`bundle_id`、`bundle_type` 和 `target_device_id`。事件不会返回本机 `bundle_root`。 - -这只是本机短等待,不是公网长连接。 +## 样板约定 -## 导入 +- `bundle.detail` 只做只读预览,预览状态是 `saved`;导入后的 receipt 用 `imported`。 +- `actions.results` 和 `events.poll` 共享同一组动作状态词:`queued`、`running`、`succeeded`、`failed`、`conflict`、`cancelled`。 +- `bundle.import` 只接受已经暂存的 bundle,不直接写第三方应用目录。 +- `rollback` 只清理导出目录,不撤销 NekoDrop 侧已经完成的导入动作。 -接收端 adapter 不直接从任意路径导入。它先请求 NekoDrop 导入 staged bundle 到本机导入区: - -```json -{ - "kind": "bundle.import", - "payload": { - "request_id": "adapter-import-001", - "client": { - "client_id": "generic.adapter", - "display_name": "Generic Adapter", - "app_kind": "agent" - }, - "staged_bundle_id": "bundle_1234567890", - "expected_bundle_type": "session" - } -} -``` +## 说明 -如果同名 bundle 已经存在,NekoDrop 不覆盖,会返回 `bundle_import_conflict`。adapter 需要让用户选择重命名、跳过或合并。 +这个样板只用通用应用名,不绑定任何第三方项目。它的目标是让真实 adapter 的最小实现有一个稳定参照,不是新增一套协议。 diff --git a/docs/examples/generic-adapter/generic-adapter.mjs b/docs/examples/generic-adapter/generic-adapter.mjs new file mode 100755 index 0000000..a51e4d0 --- /dev/null +++ b/docs/examples/generic-adapter/generic-adapter.mjs @@ -0,0 +1,504 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(SCRIPT_DIR, "..", "..", ".."); +const SAMPLE_ROOTS = { + "session-summary": path.resolve(REPO_ROOT, "docs/bundle-samples/session-summary"), +}; + +export const GENERIC_ADAPTER_CLIENT = { + client_id: "generic.adapter", + display_name: "Generic Adapter", + app_kind: "agent", +}; + +export const ACTION_LIFECYCLE_STATUSES = [ + "queued", + "running", + "succeeded", + "failed", + "conflict", + "cancelled", +]; + +export const BUNDLE_DETAIL_STATUSES = ["saved", "imported"]; +export const BRIDGE_RESPONSE_STATUSES = [ + "ok", + "unsupported", + "pending_auth", + "pending_runtime", +]; + +export function resolveSampleRoot(sampleName = "session-summary") { + const sampleRoot = SAMPLE_ROOTS[sampleName]; + if (!sampleRoot) { + throw new Error(`unsupported sample: ${sampleName}`); + } + return sampleRoot; +} + +export function exportSampleBundle({ + sampleName = "session-summary", + outputRoot = mkdtempSync(path.join(os.tmpdir(), "generic-adapter-")), +} = {}) { + const sampleRoot = resolveSampleRoot(sampleName); + const bundleRoot = path.resolve(outputRoot); + rmSync(bundleRoot, { recursive: true, force: true }); + mkdirSync(bundleRoot, { recursive: true }); + cpSync(sampleRoot, bundleRoot, { recursive: true }); + return readBundleSnapshot(bundleRoot); +} + +export function readBundleSnapshot(bundleRoot) { + const root = path.resolve(bundleRoot); + const manifest = readJson(path.join(root, "bundle.json")); + const checksums = readJson(path.join(root, "checksums.json")); + const permissions = readJson(path.join(root, "permissions.json")); + const files = manifest.files.map((file) => readBundleFile(root, file)); + + assert.equal(manifest.summary.file_count, files.length); + assert.equal( + manifest.summary.total_bytes, + files.reduce((total, file) => total + file.size, 0), + ); + + return { + bundle_root: root, + manifest, + checksums, + permissions, + files, + import_allowed: !permissions.secrets.contains_secrets, + staging_status: "saved", + }; +} + +export function buildAuthorizationRequest({ + requestId = "adapter-auth-001", + client = GENERIC_ADAPTER_CLIENT, + requestedScopes = [ + "device.read", + "bundle.send", + "bundle.import.request", + "transfer.status.read", + ], + reason = "Send and import the selected session bundle", + ttlSeconds = 3600, +} = {}) { + return { + kind: "authorization.request", + payload: { + request_id: requestId, + client, + requested_scopes: requestedScopes, + reason, + ttl_seconds: ttlSeconds, + }, + }; +} + +export function buildSendRequest({ + requestId = "adapter-send-001", + client = GENERIC_ADAPTER_CLIENT, + targetDeviceId = "neko-device-target", + bundleRoot, + bundleType = "session", + requireTrustedDevice = true, +} = {}) { + assertBundleRoot(bundleRoot); + return { + kind: "bundle.send", + payload: { + request_id: requestId, + client, + target_device_id: targetDeviceId, + bundle_root: path.resolve(bundleRoot), + bundle_type: bundleType, + require_trusted_device: requireTrustedDevice, + }, + }; +} + +export function buildBundleDetailRequest({ + requestId = "adapter-detail-001", + client = GENERIC_ADAPTER_CLIENT, + stagedBundleId, +} = {}) { + assertNonEmpty("stagedBundleId", stagedBundleId); + return { + kind: "bundle.detail", + payload: { + request_id: requestId, + client, + staged_bundle_id: stagedBundleId, + }, + }; +} + +export function buildEventsPollRequest({ + requestId = "adapter-events-001", + client = GENERIC_ADAPTER_CLIENT, + afterEventId = null, + limit = 20, + timeoutMs = 15_000, +} = {}) { + return { + kind: "events.poll", + payload: { + request_id: requestId, + client, + after_event_id: afterEventId, + limit, + timeout_ms: timeoutMs, + }, + }; +} + +export function buildActionResultsRequest({ + requestId = "adapter-results-001", + client = GENERIC_ADAPTER_CLIENT, + afterClaimedAtMs = null, + limit = 20, +} = {}) { + return { + kind: "actions.results", + payload: { + request_id: requestId, + client, + after_claimed_at_ms: afterClaimedAtMs, + limit, + }, + }; +} + +export function buildImportRequest({ + requestId = "adapter-import-001", + client = GENERIC_ADAPTER_CLIENT, + stagedBundleId, + expectedBundleType = "session", +} = {}) { + assertNonEmpty("stagedBundleId", stagedBundleId); + return { + kind: "bundle.import", + payload: { + request_id: requestId, + client, + staged_bundle_id: stagedBundleId, + expected_bundle_type: expectedBundleType, + }, + }; +} + +export function buildBundleDetailPreview(snapshot, { status = "saved" } = {}) { + assertStatus(status, BUNDLE_DETAIL_STATUSES, "bundle detail status"); + return { + bundle_id: snapshot.manifest.bundle_id, + bundle_type: bundleTypeLabel(snapshot.manifest.bundle_type), + display_name: snapshot.manifest.display_name, + source_app: snapshot.manifest.source_app, + file_count: snapshot.manifest.summary.file_count, + total_bytes: snapshot.manifest.summary.total_bytes, + staging_path: snapshot.bundle_root, + import_allowed: snapshot.import_allowed, + staging_status: status, + can_import_now: snapshot.import_allowed, + import_path: null, + }; +} + +export function buildReceipt(snapshot, { + detailStatus = "imported", + actionLifecycleStatus = "succeeded", + eventLifecycleStatus = "succeeded", +} = {}) { + assertStatus(detailStatus, BUNDLE_DETAIL_STATUSES, "receipt staging status"); + assertStatus(actionLifecycleStatus, ACTION_LIFECYCLE_STATUSES, "action lifecycle status"); + assertStatus(eventLifecycleStatus, ACTION_LIFECYCLE_STATUSES, "event lifecycle status"); + return { + receipt_id: `receipt_${snapshot.manifest.bundle_id}`, + bundle_id: snapshot.manifest.bundle_id, + bundle_type: bundleTypeLabel(snapshot.manifest.bundle_type), + display_name: snapshot.manifest.display_name, + source_app: snapshot.manifest.source_app, + bundle_detail_staging_status: detailStatus, + action_lifecycle_status: actionLifecycleStatus, + event_lifecycle_status: eventLifecycleStatus, + bundle_detail_status_words: BUNDLE_DETAIL_STATUSES, + action_status_words: ACTION_LIFECYCLE_STATUSES, + }; +} + +export function buildWorkflowPlan({ + bundleRoot, + sampleName = "session-summary", + targetDeviceId = "neko-device-target", + stagedBundleId, + bundleType = "session", + requestIds = {}, +} = {}) { + const snapshot = bundleRoot + ? readBundleSnapshot(bundleRoot) + : exportSampleBundle({ sampleName }); + const bundleId = stagedBundleId ?? snapshot.manifest.bundle_id; + const requestClient = GENERIC_ADAPTER_CLIENT; + + return { + sample_name: sampleName, + bundle: { + bundle_root: snapshot.bundle_root, + bundle_id: snapshot.manifest.bundle_id, + bundle_type: bundleTypeLabel(snapshot.manifest.bundle_type), + display_name: snapshot.manifest.display_name, + source_app: snapshot.manifest.source_app, + file_count: snapshot.manifest.summary.file_count, + total_bytes: snapshot.manifest.summary.total_bytes, + import_allowed: snapshot.import_allowed, + staging_status: snapshot.staging_status, + }, + requests: { + authorization: buildAuthorizationRequest({ + requestId: requestIds.authorization ?? "adapter-auth-001", + client: requestClient, + }), + send: buildSendRequest({ + requestId: requestIds.send ?? "adapter-send-001", + client: requestClient, + targetDeviceId, + bundleRoot: snapshot.bundle_root, + bundleType, + }), + detail: buildBundleDetailRequest({ + requestId: requestIds.detail ?? "adapter-detail-001", + client: requestClient, + stagedBundleId: bundleId, + }), + events: buildEventsPollRequest({ + requestId: requestIds.events ?? "adapter-events-001", + client: requestClient, + }), + results: buildActionResultsRequest({ + requestId: requestIds.results ?? "adapter-results-001", + client: requestClient, + }), + import: buildImportRequest({ + requestId: requestIds.import ?? "adapter-import-001", + client: requestClient, + stagedBundleId: bundleId, + expectedBundleType: bundleType, + }), + }, + preview: buildBundleDetailPreview(snapshot, { status: "saved" }), + receipt: buildReceipt(snapshot, { detailStatus: "imported" }), + rollback: { + kind: "bundle.rollback", + bundle_root: snapshot.bundle_root, + action: "delete_export_root", + }, + status_words: { + bundle_detail: BUNDLE_DETAIL_STATUSES, + action_lifecycle: ACTION_LIFECYCLE_STATUSES, + bridge_response: BRIDGE_RESPONSE_STATUSES, + }, + }; +} + +export function writeReceipt(bundleRoot, receiptPath, options = {}) { + const snapshot = readBundleSnapshot(bundleRoot); + const receipt = buildReceipt(snapshot, options); + if (receiptPath) { + writeJsonFile(path.resolve(receiptPath), receipt); + } + return receipt; +} + +export function rollbackExportRoot(bundleRoot) { + const resolved = path.resolve(bundleRoot); + rmSync(resolved, { recursive: true, force: true }); + return { + status: "rolled_back", + bundle_root: resolved, + }; +} + +function readBundleFile(root, file) { + assertSafeBundlePath(file.path); + const filePath = path.join(root, file.path); + const bytes = readFileSync(filePath); + const sha256 = sha256Hex(bytes); + assert.equal(bytes.length, file.size); + assert.equal(sha256, file.sha256); + return { + path: file.path, + size: file.size, + sha256, + role: file.role, + }; +} + +function bundleTypeLabel(bundleType) { + return String(bundleType); +} + +function assertBundleRoot(bundleRoot) { + assertNonEmpty("bundleRoot", bundleRoot); + if (!existsSync(bundleRoot)) { + throw new Error(`bundle root does not exist: ${bundleRoot}`); + } +} + +function assertNonEmpty(name, value) { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${name} must be a non-empty string`); + } +} + +function assertStatus(status, allowed, label) { + if (!allowed.includes(status)) { + throw new Error(`${label} must be one of: ${allowed.join(", ")}`); + } +} + +function assertSafeBundlePath(relativePath) { + assertNonEmpty("bundle file path", relativePath); + const normalized = relativePath.replaceAll("\\", "/"); + if ( + normalized.startsWith("/") || + /^[A-Za-z]:/.test(normalized) || + normalized.includes("\0") || + normalized.split("/").some((segment) => segment === ".." || segment === ".") + ) { + throw new Error(`unsafe bundle file path: ${relativePath}`); + } +} + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, "utf8")); +} + +function writeJsonFile(filePath, value) { + writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function sha256Hex(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function parseArgs(argv) { + const result = { _: [] }; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (!token.startsWith("--")) { + result._.push(token); + continue; + } + const key = token.slice(2).replaceAll("-", "_"); + const next = argv[index + 1]; + if (next === undefined || next.startsWith("--")) { + result[key] = true; + continue; + } + result[key] = next; + index += 1; + } + return result; +} + +function printJson(value) { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function usage() { + process.stderr.write( + [ + "Usage:", + " node docs/examples/generic-adapter/generic-adapter.mjs export --out /tmp/generic-adapter-bundle", + " node docs/examples/generic-adapter/generic-adapter.mjs plan --bundle /tmp/generic-adapter-bundle", + " node docs/examples/generic-adapter/generic-adapter.mjs receipt --bundle /tmp/generic-adapter-bundle --out /tmp/receipt.json", + " node docs/examples/generic-adapter/generic-adapter.mjs rollback --bundle /tmp/generic-adapter-bundle", + ].join("\n"), + ); +} + +function main(argv) { + const [command, ...rest] = argv; + if (!command || command === "help" || command === "--help" || command === "-h") { + usage(); + return 0; + } + + const args = parseArgs(rest); + if (command === "export") { + const outputRoot = args.out + ? path.resolve(String(args.out)) + : mkdtempSync(path.join(os.tmpdir(), "generic-adapter-")); + const snapshot = exportSampleBundle({ + sampleName: String(args.sample ?? "session-summary"), + outputRoot, + }); + printJson(snapshot); + return 0; + } + + if (command === "plan") { + const bundleRoot = args.bundle ?? args.out; + const plan = buildWorkflowPlan({ + bundleRoot: bundleRoot ? path.resolve(String(bundleRoot)) : undefined, + sampleName: String(args.sample ?? "session-summary"), + targetDeviceId: String(args.target_device_id ?? "neko-device-target"), + stagedBundleId: args.staged_bundle_id ? String(args.staged_bundle_id) : undefined, + bundleType: String(args.bundle_type ?? "session"), + }); + printJson(plan); + return 0; + } + + if (command === "receipt") { + const bundleRoot = args.bundle ?? args.out; + if (!bundleRoot) { + throw new Error("receipt requires --bundle"); + } + const receipt = writeReceipt(path.resolve(String(bundleRoot)), args.receipt_out, { + detailStatus: String(args.detail_status ?? "imported"), + actionLifecycleStatus: String(args.action_status ?? "succeeded"), + eventLifecycleStatus: String(args.event_status ?? "succeeded"), + }); + printJson(receipt); + return 0; + } + + if (command === "rollback") { + const bundleRoot = args.bundle ?? args.out; + if (!bundleRoot) { + throw new Error("rollback requires --bundle"); + } + printJson(rollbackExportRoot(path.resolve(String(bundleRoot)))); + return 0; + } + + throw new Error(`unknown command: ${command}`); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { + process.exitCode = main(process.argv.slice(2)); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} + diff --git a/docs/examples/generic-adapter/generic-adapter.test.mjs b/docs/examples/generic-adapter/generic-adapter.test.mjs new file mode 100644 index 0000000..c2a600d --- /dev/null +++ b/docs/examples/generic-adapter/generic-adapter.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + ACTION_LIFECYCLE_STATUSES, + BUNDLE_DETAIL_STATUSES, + buildActionResultsRequest, + buildBundleDetailPreview, + buildBundleDetailRequest, + buildEventsPollRequest, + buildImportRequest, + buildReceipt, + buildSendRequest, + buildWorkflowPlan, + exportSampleBundle, + rollbackExportRoot, + writeReceipt, +} from "./generic-adapter.mjs"; + +test("exportSampleBundle copies the session summary fixture", () => { + const outputRoot = mkdtempSync(path.join(os.tmpdir(), "generic-adapter-export-")); + const snapshot = exportSampleBundle({ outputRoot }); + + assert.equal(snapshot.manifest.bundle_type, "session"); + assert.equal(snapshot.manifest.bundle_id, "sample_session_summary"); + assert.equal(snapshot.import_allowed, true); + assert.equal(snapshot.staging_status, "saved"); + assert.equal(snapshot.files.length, 1); + assert.equal(snapshot.files[0].path, "files/session.json"); + + const receipt = writeReceipt(outputRoot, path.join(outputRoot, "receipt.json")); + assert.equal(receipt.bundle_detail_staging_status, "imported"); + assert.equal(receipt.action_lifecycle_status, "succeeded"); + assert.equal(receipt.event_lifecycle_status, "succeeded"); + + const rollback = rollbackExportRoot(outputRoot); + assert.equal(rollback.status, "rolled_back"); + assert.equal(existsSync(outputRoot), false); +}); + +test("workflow plan uses the same lifecycle words across preview and results", () => { + const outputRoot = mkdtempSync(path.join(os.tmpdir(), "generic-adapter-plan-")); + const snapshot = exportSampleBundle({ outputRoot }); + + const plan = buildWorkflowPlan({ + bundleRoot: outputRoot, + targetDeviceId: "neko-device-target", + stagedBundleId: snapshot.manifest.bundle_id, + }); + + assert.equal(plan.requests.send.kind, "bundle.send"); + assert.equal(plan.requests.detail.kind, "bundle.detail"); + assert.equal(plan.requests.events.kind, "events.poll"); + assert.equal(plan.requests.results.kind, "actions.results"); + assert.deepEqual(plan.status_words.action_lifecycle, ACTION_LIFECYCLE_STATUSES); + assert.deepEqual(plan.status_words.bundle_detail, BUNDLE_DETAIL_STATUSES); + assert.equal(plan.preview.staging_status, "saved"); + assert.equal(plan.receipt.bundle_detail_staging_status, "imported"); + assert.equal(plan.rollback.action, "delete_export_root"); + + rollbackExportRoot(outputRoot); +}); + +test("request builders keep the protocol shapes stable", () => { + const bundleRoot = mkdtempSync(path.join(os.tmpdir(), "generic-adapter-request-")); + assert.equal( + buildSendRequest({ bundleRoot }).payload.bundle_root, + path.resolve(bundleRoot), + ); + assert.equal( + buildBundleDetailRequest({ stagedBundleId: "bundle_1234567890" }).kind, + "bundle.detail", + ); + assert.equal(buildEventsPollRequest().kind, "events.poll"); + assert.equal(buildActionResultsRequest().kind, "actions.results"); + assert.equal(buildImportRequest({ stagedBundleId: "bundle_1234567890" }).kind, "bundle.import"); + assert.equal( + buildBundleDetailPreview( + { + manifest: { + bundle_id: "bundle_1234567890", + bundle_type: "session", + display_name: "planning_notes_session", + source_app: "Generic Adapter", + summary: { file_count: 1, total_bytes: 10 }, + }, + bundle_root: "/tmp/exported-bundle", + import_allowed: true, + }, + { status: "saved" }, + ).staging_status, + "saved", + ); + assert.equal(buildReceipt( + { + manifest: { + bundle_id: "bundle_1234567890", + bundle_type: "session", + display_name: "planning_notes_session", + source_app: "Generic Adapter", + }, + }, + { detailStatus: "imported" }, + ).bundle_detail_staging_status, "imported"); + + rollbackExportRoot(bundleRoot); +});