From e69e8ea1e239b999cf2e48fe923fe58e0946fce7 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Thu, 13 Aug 2026 14:13:57 +0800 Subject: [PATCH 01/30] docs: approve 0.3.0-beta.2 control-plane slice plan Freeze S2-close + S3-minimum as the next npm beta increment. Stop at Draft PR + exact-SHA CI; no Ready/merge/tag/npm in this slice. --- docs/internal/0.3.0-beta.2-release-plan.md | 194 +++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 docs/internal/0.3.0-beta.2-release-plan.md diff --git a/docs/internal/0.3.0-beta.2-release-plan.md b/docs/internal/0.3.0-beta.2-release-plan.md new file mode 100644 index 00000000..aea1b63f --- /dev/null +++ b/docs/internal/0.3.0-beta.2-release-plan.md @@ -0,0 +1,194 @@ +# 0.3.0-beta.2 — Control Plane 最小可跑增量 + +> **Status:** APPROVED — 2026-08-13 用户指令「开始,记得用 kanban」。三路审查 APPROVE-WITH-FIXES 已吸收。开工令:从 `origin/main` 拉 `rc/0.3.0-beta.2`,Kanban 链式施工;停在 Draft PR + exact-SHA CI,未经新指令不得 Ready / merge / tag / npm。 +> **Date:** 2026-08-13 +> **Reviews:** honesty / architecture / release-gate(`deleg_fc8005c7`) +> **Parent:** [0.3-C 作战计划](./0.3-c-control-plane-plan.md) · [P13](./p-adrs/P13-bootstrap-singleton.md) · [P14](./p-adrs/P14-controlstore-engine.md) +> **Baseline:** `origin/main` @ `561cfd3761c683e47fee5aa6d7fb7b26e69c4521` +> **Published beta.1:** annotated tag `v0.3.0-beta.1` **peels to** `561cfd3`(tag object 自身是 `6c75950`,那不是源 commit)。该 merge commit 的 subject 仍写着 candidate / do not publish,是 squash 文案残留,不以它否定 tag。 +> **Not GA.** Does not move npm `latest`. Does not ship WebUI, approval, or FileBroker. + +本地残留 `rc/0.3.0-trusted-effects` @ `5bbb566` **不是** `origin/main` 祖先。禁止在那棵脏树上继续写。开工命令: + +```bash +git fetch origin +git switch -c rc/0.3.0-beta.2 origin/main +``` + +## 1. One sentence + +beta.2 让 Control Plane **第一次能控**:Unix 上一个真 UDS ControlHost + 一份 files-only project store + 至少一次经 TE 提交的声明 `fs.write`;对外仍走 npm `beta`。 + +「能控」= 能起、能附、能把一次真实 run 记进 store、能经 TE 提交声明写。不是「能起进程、能查状态」。 + +## 2. Why this cut + +| 事实 | 含义 | +|---|---| +| beta.1 已发布 TE MVP | 下一档不该再扩 TE 主线 | +| 0.3-C 已拍板 TE + 重写 Control Plane + WebUI | **拍板的是方向**,不是 wire 已冻结 | +| S2 现状 | 锁 / hello / fencing / TE-only provider / standalone lease / stale-endpoint **单测级已有**;**没有** `node:net` listen/accept,没有打开 project store,`projectStorePath` 未接线,`renewLease` 未挂定时器,`attached` 只是锁层标签 | +| S3–S8 未做 | 不能跳到审批或 WebUI | +| `taskflow-control` 未进十包发布面 | version 仍是 `0.3.0`,`publishConfig.tag: next`,registry E404;description 还写着 “frozen TypeBox wire contracts”,与 `wire-freeze.md` = PROPOSED 矛盾 | + +历史分支 `backup/mac-0.3.0-beta.2` **不是**本档方向。 + +十个已发布包:`taskflow-core`、`taskflow-mcp-core`、`taskflow-hosts`、`taskflow-dsl`、`pi-taskflow`、`codex-taskflow`、`claude-taskflow`、`opencode-taskflow`、`grok-taskflow`、`hermes-taskflow`。 + +## 3. Decisions locked by review + +原 §9 四问不再开放: + +| Q | 决定 | +|---|---| +| P14 Status | **保持 Proposed**(wire 层)。本档只批准 **S3 store 引擎实施门**。可在 P14 加一行「beta.2 已批 S3 实施门」,不改 ADR Status。P15 完全不碰。 | +| 发布面 | **条件十一包**。仅当 tag 前 tarball dogfood(A8-pre)过线,才把 `taskflow-control@0.3.0-beta.2` 纳入发布面。否则只发十包,control 继续 workspace-only。禁止 `0.3.0` / `next` 出包。 | +| Dogfood 深度 | **必须**至少一次声明 `fs.write`,经 TE resources 提交(声明根内写 marker)。只读状态查询不够。 | +| S3 事件最小集 | **必须**能追加至少一条非审批 `CommandRecord`。默认载体 = A8 那次真实 run 的 `run.submit` + 同批 ControlEvents。不实现 admission、不签 Receipt、不用 approval kind。空 journal 会使 A4/A5/P14 原子批门空转。 | + +另锁: + +- **Registry / UserCoordinatorStore(P16 store)推迟。** 作战计划 S3 的后两块不进 beta.2。P13 `auto`「确保 registry」本档只部分满足,必须在诚实账本里写明。 +- **`projections/`** 本档只重建 store 自身投影(header / commit-seq / 已提交 command 索引),不做 run 视图投影(那是 S4/S6)。 + +## 4. In scope + +只收 **S2 收口 + S3 最小集**。 + +### 4.1 S2 收口 — 真 UDS 进程 + +已有、本档只做**进程级验证**(不是重写):禁 `auto→standalone` 静默回退;ExecutionProvider 只能 TE-backed。 + +必须新做: + +1. Unix UDS **server**:`listen` / `accept` 绑定 `singletonPaths` 的 endpoint。 +2. 败者是 **client `connect`**,走线上 hello,拿到赢者 fencing epoch。禁止只改锁层标签就叫 attach。 +3. 线上 hello-before-RPC + `protocolMajor` 偏差拒绝(P13 §5.5)。 +4. 两**进程**竞争同一 user singleton lock + 同一 endpoint;输者 attach 赢者,禁止 silent fork。 +5. 杀赢者后 stale socket → unlink → 新进程可重新 `listen`。 +6. `renewLease()` 挂定时器(代码已注明 S3 接线)。 +7. socket 权限显式 `0o600`(不要只靠 umask)。 +8. Unix UDS 是本档必过平台。Windows named pipe **非交付**。 + +### 4.2 S3 最小集 — 一份 project ledger + +1. files-only ControlStore:`header` + `commit-seq.json` + `journal/` + `projections/`。`commands/` / `receipts/` 可建空目录,**不签发 Receipt、不跑审批命令**。 +2. Header:`{ projectId, controlDomainId, schemaVersion, directoryBinding }`。 +3. `commitSeq` 单调且永不重编号(P11)。 +4. daemon / embedded / standalone 打开 **同一** project store。`projectStorePath` 必须在 `ControlHost.start()` 真正打开,且在 dispatch 前完成。 +5. 单写者;第二写者对 store mutation fail closed。 +6. 至少一种非审批 `CommandRecord`(`run.submit`)与其 events **同批原子提交**。 +7. 崩溃恢复:重读 journal → 重建本档允许的投影;禁止未验证混合态。 +8. P14 实施门本档收:原子批 crash 矩阵;temporary-path symlink 硬化(随机 UUID + 独占 `wx` + EEXIST 重试;rename 目标被换成 symlink → fail closed);recovery-racing-mutation fail closed。 +9. 信任模型保持 trusted-local-disk;整根一致回滚 **不承诺**。 +10. Control 可以写 **control 元数据**(`~/.taskflow/control/` lock/lease/claim/socket + project store)。**不得**写 run 声明的最终路径;那只经 `taskflow-core` resources。 + +### 4.3 宿主 / 发布 + +1. 从 `origin/main`(`561cfd3`)拉 `rc/0.3.0-beta.2`。 +2. 十包版本面跟到 `0.3.0-beta.2`。下列 pin **必须**同步,否则 `publish.yml` tag 校验必挂: + - `packages/*/plugin/plugin.json`(codex / claude / grok) + - `packages/*/plugin/.mcp.json`(codex / claude / grok) + - `packages/opencode-taskflow/plugin/opencode.json` + - `packages/hermes-taskflow/plugin/hermes.config.snippet.yaml` +3. `taskflow-control`:同一提交把 `version` 改为 `0.3.0-beta.2`,`publishConfig.tag` 改为 `beta`,`description` 去掉 “frozen”,改成 proposed / planned。**无论是否进 npm 都必须改**,避免 0.3.0 游离。 +4. `publish.yml` 的 `0.3.0-beta.*` 闸门 **已经**放行 beta.2。两条路径写死: + - **十包:** workflow 零功能改动,只改注释 / `RELEASE.md` 示例。 + - **十一包:** 同一 release commit 扩 `RELEASE_PACKAGE_NAMES`、`publish.yml` 版本循环 / `publish_one` / `verify_one`、`smoke-packed-packages.mjs`、release-notes 兜底。走 Draft PR 人工闸门。 +5. 发布决策在 **A8-pre(tarball dogfood)之后、打 tag 之前** 冻结,避免「npm 上还没有 beta.2,却要用 `@beta` 决定是否发 control」的循环。 + +## 5. Out of scope(硬禁区) + +- S4 完整 BoundPlan admission / maxActiveRuns 竞争 / reconcile-unknown 终态协议 +- S5 审批三模式 / park / CAS +- S6 完整 MCP/CLI 控制面(本档最多加 **只读** host status + store header) +- S7 WebUI +- S8 / L6 GA / npm `latest` / 正式 `0.3.0` +- Secret vault / live ServiceRef +- FileBroker / OS sandbox +- Adaptive / Retain / CharterArc 晋级 +- Windows named pipe ControlHost(lock-file 协调可做;pipe **non-GA**,release notes 必须写明) +- Registry 多 mount / UserCoordinatorStore 引擎 +- 搬 `feat/0.3.0` 或 `backup/mac-0.3.0-beta.2` 的旧代码 + +## 6. Acceptance + +全部必须有命令级证据。文档绿不算过。 + +| ID | 门槛 | 证据 | +|---|---|---| +| A1 | TE 8 deliverables 不回退 | Draft PR 的 **exact-SHA 全矩阵**:unit + `e2e-codex-mcp-full` + packed consumer + process-supervisor 3 OS。live Codex CLI @ `4524d2d` 只算 beta.1 历史证据,不为 beta.2 背书,本档不重跑。 | +| A2 | 两进程 UDS | Unix-only CI job(**不得**进 Windows process-supervisor 腿):进程 A listen;进程 B connect + 线上 hello + 拿到 epoch;杀 A 后 stale socket 恢复;`auto` 不能静默变 `standalone`。 | +| A2b | 线上控制面 | 赢者经 UDS 回答 `control.probe`(或等价只读 RPC);败者经 socket 收到同一 `fencingEpoch`。锁层标签不够。 | +| A3 | 同一 store | standalone 写入 header / journal / 至少一条 `run.submit` 后,第二次以 daemon/embedded 打开读到同一 `projectId` / `commit-seq`。 | +| A4 | 崩溃矩阵 | **进程级 SIGKILL**,不是抛异常 seam。注入点:① header 已 fsync、journal 未开始;② journal 追加中;③ projection 重建中。每点重启后只能是:旧完整态 **或** 新完整态 **或** fail-closed(拒绝打开 / 拒绝 mutation,可观察)。禁止混合态。 | +| A5 | 单写者 | 第二进程对同一 store 的 mutation 被拒绝。 | +| A6 | 权威边界 | 声明 `fs.write` 最终路径只经 `taskflow-core` resources。control 源码对 `writeFile`/`appendFile`/`createWriteStream` 的命中必须落在 control 元数据白名单(lock/lease/claim/socket/store),并用测试钉住。`resources/*` 语义本档不改。 | +| A7 | 打包 | typecheck + build + `test:pack`。若十一包:fresh consumer 能 import `taskflow-control@0.3.0-beta.2`。 | +| A8-pre | tag 前 dogfood | 用 `.release-tarballs` 里的 hermes-taskflow tarball 装进 scratch Hermes profile:`taskflow_version` = `0.3.0-beta.2`;list / verify / run;**一次声明 `fs.write` 经 TE 提交**;能读 host status + store header。CI 以 built-MCP fixture 为主。 | +| A8-post | 发布后核验 | `verify-published-package` + `npm view` 十包(或十一包)`beta=0.3.0-beta.2`。live Hermes `@beta` 复验可选,人工。 | +| A9 | 发布诚实 | `latest` 仍为 `0.2.10`;GitHub Release = prerelease;release notes 写 Windows pipe non-GA + resolve-only 不是 OS sandbox + 无审批/Receipt/WebUI + Registry/P16 store 未交付。 | + +已知 Windows process-supervisor 抖动 **不进入** beta.2 新门槛;既有 3-OS job 只回归 TE,不跑 UDS。 + +## 7. Honesty ledger + +**允许:** + +- Trusted Effects MVP 已在 `0.3.0-beta.1`(tag peel = `561cfd3`)。 +- beta.2 是 Control Plane **最小可跑**增量。 +- Unix 上可以起 UDS ControlHost,败者线上 attach,一份 project store,单写者,崩溃 fail-closed。 +- Control 写自己的 lock/store 元数据;run 声明写仍只经 TE。 +- Resolve-only 不是 OS sandbox;未声明路径仍取决于宿主策略。 +- Windows ControlHost / named pipe 不是本档交付。 +- Registry 与 UserCoordinatorStore 未交付。 + +**不许:** + +- “Control Plane 已完成 / 已冻结 / 已 GA” +- “可审批 / 有 Receipt / 有 WebUI” +- “FileBroker / OS sandbox” +- “Windows named pipe 已交付” +- 把 P8 / P14 / P15 的 **Proposed** 写成 Accepted +- 把 `wire-freeze.md` 偷偷改成 frozen,却没有 TypeBox + 测试证据 +- 把锁层 `attached` 说成已经有 UDS attach +- 把常量 `printf` run 说成「能控」 + +## 8. Implementation sequence + +1. **基线** — `git switch -c rc/0.3.0-beta.2 origin/main`。刷新 scoreboard:header 日期/tip;L5 对 **beta.1** 改 PASS(tag + publish run `31621938101`);L6 仍 FAIL;L4 live-Codex 标 historical。 +2. **S2 进程化** — UDS listen/accept/connect + 线上 hello + 进程级 A2/A2b。Unix-only CI job。 +3. **S3 store** — `packages/taskflow-control/src/store/**`。接上 `projectStorePath` 与 lease timer。先过 A3–A6。 +4. **宿主只读表面** — Hermes/MCP 暴露 host status + store header;确认 `test:e2e-hermes-mcp` 覆盖新工具。不把审批/Receipt 标成可用。 +5. **诚实性文案** — README / CHANGELOG / SECURITY / website / `RELEASE.md` / `publish.yml` 头注释;**必须改** `packages/taskflow-control/package.json` description。 +6. **版本面** — 十包 + pin 文件 + control version/tag。 +7. **A8-pre** — tarball scratch dogfood(含声明写)。这时才决定十包还是十一包。 +8. **人工闸门** — Draft PR → 审查 → 用户解锁 Ready → squash merge → **merged SHA 全矩阵绿** → 人工推 `v0.3.0-beta.2` → 只走 `publish.yml` → A8-post。 + +预估:S2 收口 2–3d,S3 最小集 3–4d,宿主+文案+发布链 1–2d。上限切片,不是日历承诺。 + +## 9. Files likely to change + +- `packages/taskflow-control/src/control-host.ts` +- `packages/taskflow-control/src/singleton.ts` +- `packages/taskflow-control/src/store/**`(新) +- `packages/taskflow-control/test/**`(含 Unix 进程级) +- `packages/taskflow-control/package.json`(version / tag / description) +- `packages/taskflow-mcp-core` / `packages/hermes-taskflow`(只读状态工具) +- 宿主 pin:上述 plugin.json / `.mcp.json` / `opencode.json` / `hermes.config.snippet.yaml` +- 若十一包:`scripts/pack-release-packages.mjs`、`.github/workflows/publish.yml`、`scripts/smoke-packed-packages.mjs` +- `docs/internal/0.3.0-ga-scoreboard.md`、`CHANGELOG.md`、`RELEASE.md`、公开 README/website +- `docs/internal/p-adrs/P14-controlstore-engine.md`(只加「beta.2 已批 S3 实施门」一行) + +不改:`packages/taskflow-core/src/resources/**` 的权威语义,除非发现 beta.1 回归。 + +## 10. Agent stop line + +Status 已是 **APPROVED**。允许按 §8 开工。 + +本档施工停在: + +- Draft PR + exact-SHA CI +- 诚实 scoreboard(L6 仍 FAIL) + +未经新的人工指令,禁止 Ready / merge / tag / npm / 把 beta.2 写成 GA。 From ce058e3534c33c7d41d7153db177c8517c0b7e0e Mon Sep 17 00:00:00 2001 From: Clawdy Date: Thu, 13 Aug 2026 14:34:18 +0800 Subject: [PATCH 02/30] feat(control): Unix UDS ControlHost listen/attach (beta.2 S2) The winner of the user singleton now listens on the UDS endpoint (chmod 0o600); losers attach over the socket, do an online hello, receive the winner's fencing epoch on the wire, and route control.probe through the attached client. protocolMajor mismatches are rejected on the wire, winner death leaves a stale socket that the next process unlinks and re-listens, and renewLease() runs on an unref'd timer while the winner holds the lease. Lock-layer labels are no longer presented as attach. Process-level A2/A2b tests live in a Unix-only test entry (skipped on win32; not part of the 3-OS process-supervisor job). --- packages/taskflow-control/src/control-host.ts | 106 ++++- packages/taskflow-control/src/errors.ts | 24 + packages/taskflow-control/src/index.ts | 1 + packages/taskflow-control/src/uds.ts | 419 ++++++++++++++++++ .../test/fixtures/uds-fail-closed.ts | 67 +++ .../test/fixtures/uds-loser.ts | 106 +++++ .../test/fixtures/uds-winner.ts | 89 ++++ .../taskflow-control/test/uds-process.test.ts | 305 +++++++++++++ 8 files changed, 1112 insertions(+), 5 deletions(-) create mode 100644 packages/taskflow-control/src/uds.ts create mode 100644 packages/taskflow-control/test/fixtures/uds-fail-closed.ts create mode 100644 packages/taskflow-control/test/fixtures/uds-loser.ts create mode 100644 packages/taskflow-control/test/fixtures/uds-winner.ts create mode 100644 packages/taskflow-control/test/uds-process.test.ts diff --git a/packages/taskflow-control/src/control-host.ts b/packages/taskflow-control/src/control-host.ts index 9c22acb4..5145dfe8 100644 --- a/packages/taskflow-control/src/control-host.ts +++ b/packages/taskflow-control/src/control-host.ts @@ -30,6 +30,7 @@ import { type SingletonAcquireResult, type SingletonPaths, } from "./singleton.ts"; +import { connectUdsClient, startUdsServer, type UdsClient, type UdsServer } from "./uds.ts"; import { CONTROL_WIRE_SCHEMA_VERSION, PROTOCOL_MAJOR, type NegotiationHandshake } from "./schema/index.ts"; import type { ExecutionProvider } from "./te-provider.ts"; import type { RunSnapshot } from "./schema/run.ts"; @@ -99,6 +100,9 @@ export class ControlHost { #fencingEpoch = 0; #holderId?: string; #releaseSingleton?: () => void; + #udsServer?: UdsServer; + #udsClient?: UdsClient; + #leaseTimer?: NodeJS.Timeout; constructor(options: ControlHostOptions) { if (!options.provider || options.provider.kind !== "te-resources") { @@ -173,12 +177,44 @@ export class ControlHost { this.#holderId = result.holderId; this.#fencingEpoch = result.fencingEpoch; this.#releaseSingleton = result.release; + // S2: the winner listens on the user singleton endpoint so + // losers attach over a real Unix socket (D32 — lock-layer + // labels are NOT attach). + try { + this.#udsServer = await startUdsServer({ + endpointPath: this.paths.endpointPath, + serverHello: this.#helloGate.serverHello, + requiredFeatures: this.#options.requiredFeatures, + getFencingEpoch: () => this.#fencingEpoch, + handleRpc: (method, params, fencingEpoch) => this.#dispatchCore(method, params, fencingEpoch), + }); + } catch (error) { + try { result.release(); } catch { /* best effort */ } + this.#releaseSingleton = undefined; + throw bootstrapFailed( + `won the user singleton but could not listen on control endpoint ${this.paths.endpointPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + this.#startLeaseTimer(); } else { - // Loser attaches as a client to the winner (D32) — never - // an independent multi-mount authority. + // Loser attaches as a client to the winner (D32) — the + // fencing epoch is received on the wire in the hello-ack, + // never trusted from the lock file alone (A2b). + let client: UdsClient; + try { + client = await connectUdsClient({ + endpointPath: result.endpoint, + clientHello: this.#clientHello(), + }); + } catch (error) { + throw bootstrapFailed( + `attached to the winner but could not reach its control endpoint ${result.endpoint}: ${error instanceof Error ? error.message : String(error)}`, + ); + } this.#singleton = "attached"; this.#holderId = result.holderId; - this.#fencingEpoch = result.fencingEpoch; + this.#fencingEpoch = client.fencingEpoch; + this.#udsClient = client; } this.#state = "started"; break; @@ -195,15 +231,26 @@ export class ControlHost { /** * Hello-before-RPC dispatch. Every method call must follow a successful * hello (P4) and carry a fencingEpoch >= the current lease epoch (P16). + * + * An ATTACHED host routes its RPCs over the Unix socket to the winner's + * control plane (its own hello happened on the wire at connect time). */ async dispatch(method: string, params: unknown, context: { fencingEpoch: number }): Promise { + if (this.#singleton === "attached" && this.#udsClient !== undefined) { + return this.#udsClient.rpc(method, params, context.fencingEpoch); + } if (!this.#helloGate.greeted) { throw helloRequiredError(); } - if (context.fencingEpoch < this.#fencingEpoch) { + return this.#dispatchCore(method, params, context.fencingEpoch); + } + + /** RPC core without the in-process hello gate (the UDS layer enforces its own). */ + async #dispatchCore(method: string, params: unknown, fencingEpoch: number): Promise { + if (fencingEpoch < this.#fencingEpoch) { throw new ControlError( "TF_AUTHORITY_REVOKED", - `fencing epoch ${context.fencingEpoch} is stale; host epoch is ${this.#fencingEpoch}`, + `fencing epoch ${fencingEpoch} is stale; host epoch is ${this.#fencingEpoch}`, { recoveryAction: "refresh", sideEffects: "none" }, ); } @@ -245,6 +292,25 @@ export class ControlHost { } stop(): void { + if (this.#leaseTimer) { + clearInterval(this.#leaseTimer); + this.#leaseTimer = undefined; + } + const server = this.#udsServer; + this.#udsServer = undefined; + const client = this.#udsClient; + this.#udsClient = undefined; + if (server) { + // Winner: stop accepting, drop live connections, then unlink OUR + // socket while we still hold the lock (a live owner's endpoint is + // never touched by recovery; unlinking after release could race a + // fresh winner's listen). + void server.close().catch(() => { /* best effort */ }); + try { fs.unlinkSync(this.paths.endpointPath); } catch { /* best effort */ } + } + if (client) { + try { client.close(); } catch { /* best effort */ } + } if (this.#releaseSingleton) { try { this.#releaseSingleton(); } catch { /* best effort */ } this.#releaseSingleton = undefined; @@ -254,6 +320,36 @@ export class ControlHost { this.#state = "stopped"; } + /** + * The host's client-side handshake when attaching to a winner: same build + * identity, but a connecting host demands nothing (requiredFeatures) and + * offers the features it would require from clients (P4 symmetric check). + */ + #clientHello(): NegotiationHandshake { + const base = this.#options.serverHello ?? defaultServerHello(); + return { + ...base, + requiredFeatures: [], + offeredFeatures: [...(this.#options.requiredFeatures ?? [])], + }; + } + + /** Keep the coordinator lease valid while the winner holds the singleton. */ + #startLeaseTimer(): void { + const ttl = this.#options.leaseTtlMs ?? 30_000; + const intervalMs = Math.max(1_000, Math.floor(ttl / 3)); + const timer = setInterval(() => { + try { + this.renewLease(); + } catch { + /* best effort — the lease only gates fencing */ + } + }, intervalMs); + // Never keep a process alive just to renew a lease. + timer.unref(); + this.#leaseTimer = timer; + } + #acquireStandaloneLease(): void { const leasePath = `${this.paths.controlHome}/standalone-lease.json`; const holderId = this.#options.holderId ?? `standalone-${defaultProcessIdentity().pid}`; diff --git a/packages/taskflow-control/src/errors.ts b/packages/taskflow-control/src/errors.ts index 569a2f66..c267dea3 100644 --- a/packages/taskflow-control/src/errors.ts +++ b/packages/taskflow-control/src/errors.ts @@ -94,3 +94,27 @@ export function assertClosedControlCode(code: string): asserts code is ControlEr throw new Error(`TF_COMMAND_FAILED: not a closed 0.3-C wire error code: ${code}`); } } + +/** Map any thrown value to a closed wire ErrorEnvelope (P4). */ +export function errorToEnvelope(error: unknown): ErrorEnvelope { + if (error instanceof ControlError) return error.toEnvelope(); + const message = error instanceof Error ? error.message : String(error); + return { + code: "TF_COMMAND_FAILED", + message, + recoveryAction: "retry-new-command", + sideEffects: "none", + }; +} + +/** Rehydrate a ControlError from a wire ErrorEnvelope (transport layer). */ +export function errorFromEnvelope(envelope: ErrorEnvelope): ControlError { + return new ControlError(envelope.code, envelope.message, { + recoveryAction: envelope.recoveryAction, + sideEffects: envelope.sideEffects, + ...(envelope.commandId !== undefined ? { commandId: envelope.commandId } : {}), + ...(envelope.commitSeq !== undefined ? { commitSeq: envelope.commitSeq } : {}), + ...(envelope.controlDomainId !== undefined ? { controlDomainId: envelope.controlDomainId } : {}), + ...(envelope.projectId !== undefined ? { projectId: envelope.projectId } : {}), + }); +} diff --git a/packages/taskflow-control/src/index.ts b/packages/taskflow-control/src/index.ts index cfa47dbe..5786e967 100644 --- a/packages/taskflow-control/src/index.ts +++ b/packages/taskflow-control/src/index.ts @@ -15,6 +15,7 @@ export * from "./errors.ts"; export * from "./modes.ts"; export * from "./hello.ts"; export * from "./singleton.ts"; +export * from "./uds.ts"; export * from "./te-provider.ts"; export * from "./control-host.ts"; diff --git a/packages/taskflow-control/src/uds.ts b/packages/taskflow-control/src/uds.ts new file mode 100644 index 00000000..82b5c803 --- /dev/null +++ b/packages/taskflow-control/src/uds.ts @@ -0,0 +1,419 @@ +/** + * Unix domain socket transport for the ControlHost (beta.2 S2 收口 — A2/A2b). + * + * The winner of the user singleton lock `listen`s on `singletonPaths().endpointPath`; + * every other process `connect`s as a client. The wire is newline-delimited + * JSON (one frame per line): + * + * {"type":"hello","hello":} + * {"type":"hello-ack","ok":true,"serverHello":<...>,"fencingEpoch":N} + * {"type":"hello-ack","ok":false,"error":} + * {"type":"rpc","id":N,"method":"control.probe","params":...,"fencingEpoch":N} + * {"type":"rpc-result","id":N,"ok":true,"result":...} + * {"type":"rpc-result","id":N,"ok":false,"error":} + * + * Hello-before-RPC (P4) is enforced per connection: the first frame MUST be a + * hello, a failed hello closes the socket, and a `protocolMajor` mismatch is + * rejected on the wire (TF_PROTOCOL_INCOMPATIBLE). The socket permission is + * explicitly chmod'ed to 0o600 — never umask luck (plan §4.1). + * + * Unix-only transport: Windows named pipe is explicitly NON-GA in 0.3-C. + */ + +import * as fs from "node:fs"; +import * as net from "node:net"; +import { bootstrapFailed, ControlError, errorFromEnvelope, errorToEnvelope, protocolError } from "./errors.ts"; +import { createHelloGate } from "./hello.ts"; +import type { ErrorEnvelope, NegotiationHandshake } from "./schema/transport.ts"; + +// --------------------------------------------------------------------------- +// Wire frames +// --------------------------------------------------------------------------- + +export type UdsFrame = + | { type: "hello"; hello: NegotiationHandshake } + | { type: "hello-ack"; ok: true; serverHello: NegotiationHandshake; fencingEpoch: number } + | { type: "hello-ack"; ok: false; error: ErrorEnvelope } + | { type: "rpc"; id: number; method: string; params?: unknown; fencingEpoch: number } + | { type: "rpc-result"; id: number; ok: true; result: unknown } + | { type: "rpc-result"; id: number; ok: false; error: ErrorEnvelope }; + +function encodeFrame(frame: UdsFrame): string { + return JSON.stringify(frame) + "\n"; +} + +function parseFrame(line: string): Record { + const raw: unknown = JSON.parse(line); + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("control wire frame must be a JSON object"); + } + return raw as Record; +} + +/** Control is unreachable/not answering → TF_JOURNAL_UNAVAILABLE (fail closed). */ +function controlUnavailable(message: string): ControlError { + return new ControlError("TF_JOURNAL_UNAVAILABLE", message, { recoveryAction: "refresh", sideEffects: "none" }); +} + +// --------------------------------------------------------------------------- +// Server (winner: listen/accept) +// --------------------------------------------------------------------------- + +export interface UdsServerOptions { + /** Socket path from `singletonPaths().endpointPath`. */ + endpointPath: string; + /** The server's own handshake, returned to compatible clients. */ + serverHello: NegotiationHandshake; + /** Features the server demands from clients (P4). */ + requiredFeatures?: readonly string[]; + /** The winner's current fencing epoch, sent in the hello-ack (P16). */ + getFencingEpoch: () => number; + /** Dispatch one RPC after a successful per-connection hello. */ + handleRpc: (method: string, params: unknown, fencingEpoch: number) => Promise; +} + +export interface UdsServer { + readonly endpointPath: string; + close(): Promise; +} + +/** + * Start listening on a Unix socket. The socket file is chmod'ed to 0o600 + * immediately after bind (plan §4.1). Listen failure (e.g. a live foreign + * socket already bound) fails closed with TF_BOOTSTRAP_FAILED — the caller + * owns the singleton lock and must release it. + */ +export function startUdsServer(options: UdsServerOptions): Promise { + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + handleServerConnection(socket, options); + }); + + return new Promise((resolve, reject) => { + const onError = (error: NodeJS.ErrnoException): void => { + server.removeListener("listening", onListening); + reject(bootstrapFailed(`cannot listen on control endpoint ${options.endpointPath}: ${error.message}`)); + }; + const onListening = (): void => { + server.removeListener("error", onError); + try { + fs.chmodSync(options.endpointPath, 0o600); + } catch (error) { + server.close(); + reject(bootstrapFailed(`cannot set 0o600 on control endpoint ${options.endpointPath}: ${error instanceof Error ? error.message : String(error)}`)); + return; + } + // Post-listen server errors (EMFILE etc.) must not crash the winner. + server.on("error", () => { /* best effort */ }); + resolve({ + endpointPath: options.endpointPath, + close: () => + new Promise((closeResolve) => { + for (const socket of sockets) socket.destroy(); + server.close(() => closeResolve()); + }), + }); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(options.endpointPath); + }); +} + +function handleServerConnection(socket: net.Socket, options: UdsServerOptions): void { + // Per-connection hello gate (P4): the first frame must be a hello. + const gate = createHelloGate(options.serverHello, { requiredFeatures: options.requiredFeatures }); + let greeted = false; + let buffer = ""; + let destroyed = false; + + const send = (frame: UdsFrame): void => { + if (destroyed) return; + socket.write(encodeFrame(frame)); + }; + const failClosed = (frame: UdsFrame): void => { + send(frame); + destroyed = true; + socket.destroy(); + }; + + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => { + buffer += chunk; + let index: number; + while ((index = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, index).trim(); + buffer = buffer.slice(index + 1); + if (!line) continue; + let raw: Record; + try { + raw = parseFrame(line); + } catch (error) { + failClosed({ + type: greeted ? "rpc-result" : "hello-ack", + ...(greeted ? { id: 0 } : {}), + ok: false, + error: errorToEnvelope(error), + } as UdsFrame); + return; + } + void handleFrame(raw); + } + }); + socket.on("error", () => { + destroyed = true; + socket.destroy(); + }); + + const handleFrame = async (raw: Record): Promise => { + if (raw.type === "hello") { + if (greeted) { + failClosed({ type: "hello-ack", ok: false, error: errorToEnvelope(protocolError("duplicate hello on a control channel")) }); + return; + } + const verdict = gate.hello(raw.hello); + if (!verdict.ok) { + failClosed({ type: "hello-ack", ok: false, error: verdict.error.toEnvelope() }); + return; + } + greeted = true; + send({ + type: "hello-ack", + ok: true, + serverHello: gate.serverHello, + fencingEpoch: options.getFencingEpoch(), + }); + return; + } + if (raw.type === "rpc") { + if (!greeted) { + failClosed({ + type: "rpc-result", + id: typeof raw.id === "number" ? raw.id : 0, + ok: false, + error: errorToEnvelope(protocolError("hello must precede any RPC on this control channel")), + }); + return; + } + const id = raw.id; + if (typeof id !== "number") { + failClosed({ type: "rpc-result", id: 0, ok: false, error: errorToEnvelope(protocolError("rpc frame requires a numeric id")) }); + return; + } + const method = typeof raw.method === "string" ? raw.method : ""; + if (!method) { + failClosed({ type: "rpc-result", id, ok: false, error: errorToEnvelope(protocolError("rpc frame requires a method name")) }); + return; + } + const fencingEpoch = typeof raw.fencingEpoch === "number" ? raw.fencingEpoch : 0; + try { + const result = await options.handleRpc(method, raw.params, fencingEpoch); + send({ type: "rpc-result", id, ok: true, result }); + } catch (error) { + send({ type: "rpc-result", id, ok: false, error: errorToEnvelope(error) }); + } + return; + } + failClosed({ + type: greeted ? "rpc-result" : "hello-ack", + ...(greeted ? { id: 0 } : {}), + ok: false, + error: errorToEnvelope(protocolError(`unknown control wire frame type ${JSON.stringify(raw.type)}`)), + } as UdsFrame); + }; +} + +// --------------------------------------------------------------------------- +// Client (loser: connect/attach) +// --------------------------------------------------------------------------- + +export interface UdsClientOptions { + endpointPath: string; + clientHello: NegotiationHandshake; + /** Budget for connect + hello (default 5s). */ + connectTimeoutMs?: number; + /** Budget for a single RPC round-trip (default 10s). */ + rpcTimeoutMs?: number; +} + +export interface UdsClient { + readonly endpointPath: string; + /** The winner's fencing epoch, received on the wire in the hello-ack (P16). */ + readonly fencingEpoch: number; + /** The winner's handshake, received on the wire. */ + readonly serverHello: NegotiationHandshake; + rpc(method: string, params?: unknown, fencingEpoch?: number): Promise; + close(): void; +} + +/** + * Connect to the winner's Unix socket, perform the online hello, and return a + * client bound to the winner's fencing epoch. A protocol/schema/feature + * rejection on the wire rejects with the corresponding ControlError + * (e.g. TF_PROTOCOL_INCOMPATIBLE); an unreachable endpoint fails closed with + * TF_JOURNAL_UNAVAILABLE. + */ +export function connectUdsClient(options: UdsClientOptions): Promise { + const connectTimeoutMs = options.connectTimeoutMs ?? 5_000; + const rpcTimeoutMs = options.rpcTimeoutMs ?? 10_000; + + return new Promise((resolve, reject) => { + const socket = net.connect(options.endpointPath); + let buffer = ""; + let settled = false; + let closed = false; + let nextId = 1; + let wireEpoch = 0; + let wireServerHello: NegotiationHandshake | undefined; + const pending = new Map void; reject: (error: unknown) => void }>(); + + const rejectPending = (error: unknown): void => { + for (const [, entry] of pending) entry.reject(error); + pending.clear(); + }; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + rejectPending(controlUnavailable(`control endpoint ${options.endpointPath} did not answer hello within ${connectTimeoutMs}ms`)); + socket.destroy(); + reject(controlUnavailable(`control endpoint ${options.endpointPath} did not answer hello within ${connectTimeoutMs}ms`)); + }, connectTimeoutMs); + + socket.on("connect", () => { + socket.write(encodeFrame({ type: "hello", hello: options.clientHello })); + }); + + socket.on("error", (error) => { + if (!settled) { + settled = true; + clearTimeout(timer); + reject(controlUnavailable(`cannot connect to control endpoint ${options.endpointPath}: ${error.message}`)); + return; + } + rejectPending(controlUnavailable(`control endpoint ${options.endpointPath} closed: ${error.message}`)); + }); + + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => { + buffer += chunk; + let index: number; + while ((index = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, index).trim(); + buffer = buffer.slice(index + 1); + if (!line) continue; + let raw: Record; + try { + raw = parseFrame(line); + } catch (error) { + rejectPending(controlUnavailable(`control endpoint ${options.endpointPath} sent a malformed frame: ${error instanceof Error ? error.message : String(error)}`)); + continue; + } + if (raw.type === "hello-ack") { + if (settled) continue; + settled = true; + clearTimeout(timer); + if (raw.ok === true) { + if (typeof raw.fencingEpoch !== "number" || raw.serverHello === undefined) { + reject(controlUnavailable(`control endpoint ${options.endpointPath} sent a malformed hello-ack`)); + socket.destroy(); + return; + } + wireEpoch = raw.fencingEpoch; + wireServerHello = raw.serverHello as NegotiationHandshake; + const client: UdsClient = { + endpointPath: options.endpointPath, + get fencingEpoch() { + return wireEpoch; + }, + get serverHello() { + return wireServerHello as NegotiationHandshake; + }, + rpc: (method: string, params?: unknown, fencingEpoch?: number) => + rpc(socket, pending, () => closed, () => wireEpoch, () => nextId++, rpcTimeoutMs, method, params, fencingEpoch), + close: () => { + closed = true; + rejectPending(controlUnavailable(`control client for ${options.endpointPath} is closed`)); + socket.destroy(); + }, + }; + resolve(client); + } else { + socket.destroy(); + reject(errorFromEnvelope(raw.error as ErrorEnvelope)); + } + return; + } + if (raw.type === "rpc-result") { + const id = raw.id; + if (typeof id !== "number") continue; + const entry = pending.get(id); + if (!entry) continue; + pending.delete(id); + if (raw.ok === true) entry.resolve(raw.result); + else entry.reject(errorFromEnvelope(raw.error as ErrorEnvelope)); + return; + } + } + }); + + socket.on("close", () => { + if (!settled) { + settled = true; + clearTimeout(timer); + reject(controlUnavailable(`control endpoint ${options.endpointPath} closed before hello`)); + return; + } + rejectPending(controlUnavailable(`control endpoint ${options.endpointPath} closed`)); + }); + }); +} + +function rpc( + socket: net.Socket, + pending: Map void; reject: (error: unknown) => void }>, + isClosed: () => boolean, + getEpoch: () => number, + nextId: () => number, + rpcTimeoutMs: number, + method: string, + params: unknown, + fencingEpoch?: number, +): Promise { + if (isClosed()) { + return Promise.reject(controlUnavailable(`control client for ${socket.remoteAddress ?? "unix"} is closed`)); + } + const id = nextId(); + return new Promise((resolve, reject) => { + const rpcTimer = setTimeout(() => { + pending.delete(id); + reject(controlUnavailable(`control RPC ${JSON.stringify(method)} timed out after ${rpcTimeoutMs}ms`)); + }, rpcTimeoutMs); + pending.set(id, { + resolve: (value: unknown) => { + clearTimeout(rpcTimer); + resolve(value as T); + }, + reject: (error: unknown) => { + clearTimeout(rpcTimer); + reject(error); + }, + }); + const frame: UdsFrame = { + type: "rpc", + id, + method, + ...(params !== undefined ? { params } : {}), + fencingEpoch: fencingEpoch ?? getEpoch(), + }; + try { + socket.write(encodeFrame(frame)); + } catch (error) { + pending.delete(id); + clearTimeout(rpcTimer); + reject(controlUnavailable(`control RPC ${JSON.stringify(method)} could not be sent: ${error instanceof Error ? error.message : String(error)}`)); + } + }); +} diff --git a/packages/taskflow-control/test/fixtures/uds-fail-closed.ts b/packages/taskflow-control/test/fixtures/uds-fail-closed.ts new file mode 100644 index 00000000..de37cb8c --- /dev/null +++ b/packages/taskflow-control/test/fixtures/uds-fail-closed.ts @@ -0,0 +1,67 @@ +/** + * UDS process-test fixture: `auto` mode must FAIL CLOSED at the process level + * when the control cannot run — it must never silently become `standalone`. + * + * The parent points TF_TEST_CONTROL_HOME at a path that is a regular file + * (not a directory), so the singleton bootstrap throws. Prints: + * FAILED-CLOSED — error code + message from start() + * STATE + * and exits non-zero. + */ + +import { ControlHost } from "../../src/control-host.ts"; +import { createTeExecutionProvider, type TeExecutionAuthority } from "../../src/te-provider.ts"; + +function fakeProvider() { + const te: TeExecutionAuthority = { + assurance: "resolve-only-no-sandbox", + probe: async () => ({ + classification: "resolve-only" as const, + baselinePolicyId: "taskflow-resolve-only", + hostProbeSha256: "a".repeat(64), + }), + prepare: async () => ({ + outcome: "accepted" as const, + fulfillment: { + preparationId: "prep", + enforcementCapabilities: { + resolution: "contained", + mutationMediation: "brokered", + processIsolation: "none", + revocation: "admission-only", + baselinePolicyId: "b", + hostProbeSha256: "a".repeat(64), + }, + }, + }), + submit: async () => ({ outcome: "accepted" as const, providerJobHandle: "job" }), + watch: async function* () { + yield { kind: "terminal" as const, outcome: "completed" as const }; + }, + }; + return createTeExecutionProvider(te); +} + +const controlHome = process.env.TF_TEST_CONTROL_HOME; +if (!controlHome) { + console.log("ERROR fixture requires TF_TEST_CONTROL_HOME"); + process.exit(1); +} + +const host = new ControlHost({ + mode: "auto", + controlHome, + provider: fakeProvider(), + holderId: "uds-fail-closed", +}); + +host.start() + .then((status) => { + console.log(`STARTED ${JSON.stringify({ state: status.state, singleton: status.singleton })}`); + }) + .catch((error: unknown) => { + const code = error instanceof Error && "code" in error ? String((error as { code: unknown }).code) : "UNKNOWN"; + console.log(`FAILED-CLOSED ${JSON.stringify({ code, message: error instanceof Error ? error.message : String(error) })}`); + console.log(`STATE ${host.status.state} ${host.status.singleton}`); + process.exitCode = 1; + }); diff --git a/packages/taskflow-control/test/fixtures/uds-loser.ts b/packages/taskflow-control/test/fixtures/uds-loser.ts new file mode 100644 index 00000000..4d850e4c --- /dev/null +++ b/packages/taskflow-control/test/fixtures/uds-loser.ts @@ -0,0 +1,106 @@ +/** + * UDS process-test fixture: a ControlHost LOSER in `auto` mode competing for + * the same user singleton lock + endpoint as a live winner. + * + * start() must attach to the winner over the Unix socket, receive the winner's + * fencing epoch on the wire, then route `control.probe` through the attached + * client (the winner answers over UDS — A2b). Prints: + * ATTACHED — after start(), singleton must be "attached" + * PROBE — winner's control.probe over the socket + * PROBE-ERROR — dispatch over the wire failed + * Exits non-zero with `ERROR ` if start() fails closed. + * + * Environment: + * TF_TEST_CONTROL_HOME — control home (shared with the competing process) + * TF_TEST_HOLDER_ID — holder id (default "uds-loser") + */ + +import { ControlHost } from "../../src/control-host.ts"; +import { createTeExecutionProvider, type TeExecutionAuthority } from "../../src/te-provider.ts"; + +function fakeProvider() { + const te: TeExecutionAuthority = { + assurance: "resolve-only-no-sandbox", + probe: async () => ({ + classification: "resolve-only" as const, + baselinePolicyId: "taskflow-resolve-only", + hostProbeSha256: "a".repeat(64), + }), + prepare: async () => ({ + outcome: "accepted" as const, + fulfillment: { + preparationId: "prep", + enforcementCapabilities: { + resolution: "contained", + mutationMediation: "brokered", + processIsolation: "none", + revocation: "admission-only", + baselinePolicyId: "b", + hostProbeSha256: "a".repeat(64), + }, + }, + }), + submit: async () => ({ outcome: "accepted" as const, providerJobHandle: "job" }), + watch: async function* () { + yield { kind: "terminal" as const, outcome: "completed" as const }; + }, + }; + return createTeExecutionProvider(te); +} + +const controlHome = process.env.TF_TEST_CONTROL_HOME; +if (!controlHome) { + console.log("ERROR fixture requires TF_TEST_CONTROL_HOME"); + process.exit(1); +} + +const host = new ControlHost({ + mode: "auto", + controlHome, + provider: fakeProvider(), + holderId: process.env.TF_TEST_HOLDER_ID ?? "uds-loser", +}); + +host.start() + .then(async (status) => { + console.log( + `ATTACHED ${JSON.stringify({ + state: status.state, + singleton: status.singleton, + fencingEpoch: status.fencingEpoch, + holderId: status.holderId, + })}`, + ); + if (status.singleton !== "attached") { + console.log("LOSER-STATUS fixture expected singleton=attached"); + process.exitCode = 2; + return; + } + try { + const probe = await host.dispatch("control.probe", undefined, { fencingEpoch: status.fencingEpoch }); + console.log(`PROBE ${JSON.stringify(probe)}`); + } catch (error: unknown) { + const code = error instanceof Error && "code" in error ? String((error as { code: unknown }).code) : "UNKNOWN"; + console.log(`PROBE-ERROR ${JSON.stringify({ code, message: error instanceof Error ? error.message : String(error) })}`); + process.exitCode = 3; + } + }) + .catch((error: unknown) => { + const code = error instanceof Error && "code" in error ? String((error as { code: unknown }).code) : "UNKNOWN"; + console.log(`ERROR ${JSON.stringify({ code, message: error instanceof Error ? error.message : String(error) })}`); + process.exitCode = 1; + }); + +const shutdown = (): void => { + try { + host.stop(); + } catch { + /* best effort */ + } + process.exit(0); +}; +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); + +// Ref'd keep-alive so the parent has time to observe ATTACHED/PROBE. +setInterval(() => {}, 10_000); diff --git a/packages/taskflow-control/test/fixtures/uds-winner.ts b/packages/taskflow-control/test/fixtures/uds-winner.ts new file mode 100644 index 00000000..e54b87a4 --- /dev/null +++ b/packages/taskflow-control/test/fixtures/uds-winner.ts @@ -0,0 +1,89 @@ +/** + * UDS process-test fixture: a ControlHost WINNER in `auto` mode. + * + * Listens on the user singleton endpoint (the UDS server is created inside + * `start()`), prints `READY ` once started, then stays alive + * until the parent kills it (SIGTERM → clean stop; SIGKILL → stale-socket + * scenario). Exits non-zero with `ERROR ` if start() fails closed. + * + * Environment: + * TF_TEST_CONTROL_HOME — control home (shared with the competing process) + * TF_TEST_HOLDER_ID — holder id (default "uds-winner") + */ + +import { ControlHost } from "../../src/control-host.ts"; +import { createTeExecutionProvider, type TeExecutionAuthority } from "../../src/te-provider.ts"; + +function fakeProvider() { + const te: TeExecutionAuthority = { + assurance: "resolve-only-no-sandbox", + probe: async () => ({ + classification: "resolve-only" as const, + baselinePolicyId: "taskflow-resolve-only", + hostProbeSha256: "a".repeat(64), + }), + prepare: async () => ({ + outcome: "accepted" as const, + fulfillment: { + preparationId: "prep", + enforcementCapabilities: { + resolution: "contained", + mutationMediation: "brokered", + processIsolation: "none", + revocation: "admission-only", + baselinePolicyId: "b", + hostProbeSha256: "a".repeat(64), + }, + }, + }), + submit: async () => ({ outcome: "accepted" as const, providerJobHandle: "job" }), + watch: async function* () { + yield { kind: "terminal" as const, outcome: "completed" as const }; + }, + }; + return createTeExecutionProvider(te); +} + +const controlHome = process.env.TF_TEST_CONTROL_HOME; +if (!controlHome) { + console.log("ERROR fixture requires TF_TEST_CONTROL_HOME"); + process.exit(1); +} + +const host = new ControlHost({ + mode: "auto", + controlHome, + provider: fakeProvider(), + holderId: process.env.TF_TEST_HOLDER_ID ?? "uds-winner", +}); + +host.start() + .then((status) => { + console.log( + `READY ${JSON.stringify({ + state: status.state, + singleton: status.singleton, + fencingEpoch: status.fencingEpoch, + holderId: status.holderId, + })}`, + ); + }) + .catch((error: unknown) => { + const code = error instanceof Error && "code" in error ? String((error as { code: unknown }).code) : "UNKNOWN"; + console.log(`ERROR ${JSON.stringify({ code, message: error instanceof Error ? error.message : String(error) })}`); + process.exitCode = 1; + }); + +const shutdown = (): void => { + try { + host.stop(); + } catch { + /* best effort */ + } + process.exit(0); +}; +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); + +// Ref'd keep-alive: the winner must stay up until the parent kills it. +setInterval(() => {}, 10_000); diff --git a/packages/taskflow-control/test/uds-process.test.ts b/packages/taskflow-control/test/uds-process.test.ts new file mode 100644 index 00000000..6aca6989 --- /dev/null +++ b/packages/taskflow-control/test/uds-process.test.ts @@ -0,0 +1,305 @@ +/** + * Process-level Unix UDS ControlHost tests (A2 / A2b). + * + * These spawn real OS processes: the winner fixture `listen`s on the user + * singleton endpoint, the loser fixture `connect`s over the socket, does an + * online hello, receives the winner's fencing epoch, and routes + * `control.probe` over the wire. Also covers stale-socket recovery after the + * winner is killed and the process-level `auto` → fail-closed (never silent + * `standalone`) contract. + * + * Unix-only: these tests are deliberately NOT part of the 3-OS + * process-supervisor matrix (Windows named pipe is non-GA). The file guards + * itself with `skip: process.platform === "win32"`, and the supervisor job + * only runs the two explicit taskflow-core test files, so this file never + * executes on Windows. + */ + +import assert from "node:assert/strict"; +import { spawn, type ChildProcess } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { after, test } from "node:test"; +import { ControlHost } from "../src/control-host.ts"; +import { createTeExecutionProvider, type TeExecutionAuthority } from "../src/te-provider.ts"; +import { connectUdsClient } from "../src/uds.ts"; +import { singletonPaths, type SingletonPaths } from "../src/singleton.ts"; +import { PROTOCOL_MAJOR, type NegotiationHandshake } from "../src/schema/transport.ts"; + +const UNIX_ONLY = { skip: process.platform === "win32" } as const; + +const tempRoots: string[] = []; +const children: ChildProcess[] = []; + +after(() => { + for (const child of children) { + try { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + } catch { + /* best effort */ + } + } + for (const root of tempRoots) { + try { + fs.rmSync(root, { recursive: true, force: true }); + } catch { + /* best effort */ + } + } +}); + +function repoRoot(): string { + return path.resolve(import.meta.dirname, "..", "..", ".."); +} + +function fixturePath(name: string): string { + return path.join(import.meta.dirname, "fixtures", name); +} + +function makeRoot(prefix: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `tf-uds-${prefix}-`)); + tempRoots.push(root); + return root; +} + +function makePaths(root: string): SingletonPaths { + return singletonPaths(root); +} + +function spawnFixture(name: string, env: Record): ChildProcess { + const child = spawn( + process.execPath, + ["--conditions=development", "--experimental-strip-types", fixturePath(name)], + { + cwd: repoRoot(), + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + children.push(child); + return child; +} + +function waitForLine(child: ChildProcess, prefix: string, timeoutMs = 15_000): Promise { + return new Promise((resolve, reject) => { + let buffer = ""; + let done = false; + const timer = setTimeout(() => { + if (done) return; + done = true; + child.stdout?.off("data", onData); + reject(new Error(`timeout waiting for "${prefix}" from fixture; got: ${JSON.stringify(buffer.slice(-300))}`)); + }, timeoutMs); + const onData = (chunk: Buffer): void => { + if (done) return; + buffer += chunk.toString("utf8"); + let index: number; + while ((index = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, index).trim(); + buffer = buffer.slice(index + 1); + if (!line) continue; + if (line.startsWith(prefix)) { + done = true; + clearTimeout(timer); + child.stdout?.off("data", onData); + resolve(line); + return; + } + } + }; + child.stdout?.on("data", onData); + }); +} + +function waitForExit(child: ChildProcess, timeoutMs = 15_000): Promise<{ code: number | null; output: string }> { + return new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + const timer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + /* best effort */ + } + reject(new Error(`timeout waiting for fixture exit; got: ${JSON.stringify(stdout.slice(-300))}`)); + }, timeoutMs); + child.once("exit", (code) => { + clearTimeout(timer); + resolve({ code, output: stdout + stderr }); + }); + }); +} + +function parseJsonLine(line: string, prefix: string): Record { + const payload = line.slice(prefix.length).trim(); + const value: unknown = JSON.parse(payload); + assert.ok(value !== null && typeof value === "object", `${prefix} payload must be a JSON object`); + return value as Record; +} + +const CLIENT_HELLO: NegotiationHandshake = { + protocolMajor: PROTOCOL_MAJOR, + supportedReadSchemas: ["taskflow.wire.v1"], + supportedWriteSchemas: ["taskflow.wire.v1"], + requiredFeatures: [], + offeredFeatures: [], + buildInfo: { packageVersion: "0.3.0-beta.2", gitCommit: "test", schemaVersion: 1 }, +}; + +test("uds: two OS processes compete — winner listens, loser attaches over the socket and gets the winner epoch (A2/A2b)", UNIX_ONLY, async () => { + const root = makeRoot("attach"); + const paths = makePaths(root); + + // Winner: an independent OS process that wins the singleton and listens. + const winner = spawnFixture("uds-winner.ts", { + TF_TEST_CONTROL_HOME: root, + TF_TEST_HOLDER_ID: "winner-proc", + }); + const readyLine = await waitForLine(winner, "READY "); + const winnerStatus = parseJsonLine(readyLine, "READY "); + assert.equal(winnerStatus.singleton, "won"); + assert.equal(winnerStatus.fencingEpoch, 1); + + // The winner's endpoint is a real Unix socket, mode 0o600 (not umask luck). + const socketStat = fs.statSync(paths.endpointPath); + assert.equal(socketStat.isSocket(), true); + assert.equal(socketStat.mode & 0o077, 0, "socket must be 0o600"); + + // Loser: a second OS process competing for the same lock + endpoint. + const loser = spawnFixture("uds-loser.ts", { + TF_TEST_CONTROL_HOME: root, + TF_TEST_HOLDER_ID: "loser-proc", + }); + const attachedLine = await waitForLine(loser, "ATTACHED "); + const attached = parseJsonLine(attachedLine, "ATTACHED "); + assert.equal(attached.state, "started"); + assert.equal(attached.singleton, "attached"); + assert.equal(attached.holderId, "winner-proc"); + assert.equal(attached.fencingEpoch, winnerStatus.fencingEpoch, "loser must receive the winner's epoch over the socket"); + + // A2b: the winner answers control.probe over UDS (proxied by the attached client). + const probeLine = await waitForLine(loser, "PROBE "); + const probe = parseJsonLine(probeLine, "PROBE "); + assert.equal(probe.outcome, "accepted"); + assert.equal((probe.capabilities as Record)?.processIsolation, "none"); + + winner.kill("SIGTERM"); + loser.kill("SIGTERM"); +}); + +test("uds: protocolMajor mismatch is rejected on the wire, not only in-process (P13 §5.5)", UNIX_ONLY, async () => { + const root = makeRoot("proto"); + const paths = makePaths(root); + + const winner = spawnFixture("uds-winner.ts", { + TF_TEST_CONTROL_HOME: root, + TF_TEST_HOLDER_ID: "winner-proto", + }); + await waitForLine(winner, "READY "); + + const badHello = { ...CLIENT_HELLO, protocolMajor: PROTOCOL_MAJOR + 1 } as unknown as NegotiationHandshake; + await assert.rejects( + connectUdsClient({ endpointPath: paths.endpointPath, clientHello: badHello }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal((error as { code?: unknown }).code, "TF_PROTOCOL_INCOMPATIBLE"); + return true; + }, + ); + + // The winner is still serving after rejecting a bad client. + const goodClient = await connectUdsClient({ endpointPath: paths.endpointPath, clientHello: CLIENT_HELLO }); + assert.equal(goodClient.fencingEpoch, 1); + goodClient.close(); + + winner.kill("SIGTERM"); +}); + +test("uds: killing the winner leaves a stale socket that the next process unlinks and re-listens (A2)", UNIX_ONLY, async () => { + const root = makeRoot("stale"); + const paths = makePaths(root); + + const winner = spawnFixture("uds-winner.ts", { + TF_TEST_CONTROL_HOME: root, + TF_TEST_HOLDER_ID: "winner-stale", + }); + const readyLine = await waitForLine(winner, "READY "); + const winnerStatus = parseJsonLine(readyLine, "READY "); + assert.equal(winnerStatus.fencingEpoch, 1); + + const staleIno = fs.statSync(paths.endpointPath, { bigint: true }).ino; + + // SIGKILL: no cleanup handler runs — the socket file must survive as stale. + winner.kill("SIGKILL"); + const exited = await waitForExit(winner); + assert.ok(exited.code === null || exited.code !== 0, "winner must die from SIGKILL"); + assert.equal(fs.existsSync(paths.endpointPath), true, "stale socket file must remain after SIGKILL"); + + // A fresh OS process (the test runner) reclaims the dead owner: stale socket + // is unlinked, then it listens again with a bumped fencing epoch. + const te: TeExecutionAuthority = { + assurance: "resolve-only-no-sandbox", + probe: async () => ({ + classification: "resolve-only" as const, + baselinePolicyId: "taskflow-resolve-only", + hostProbeSha256: "a".repeat(64), + }), + prepare: async () => ({ + outcome: "accepted" as const, + fulfillment: { + preparationId: "prep", + enforcementCapabilities: { + resolution: "contained", + mutationMediation: "brokered", + processIsolation: "none", + revocation: "admission-only", + baselinePolicyId: "b", + hostProbeSha256: "a".repeat(64), + }, + }, + }), + submit: async () => ({ outcome: "accepted" as const, providerJobHandle: "job" }), + watch: async function* () { + yield { kind: "terminal" as const, outcome: "completed" as const }; + }, + }; + const fresh = new ControlHost({ + mode: "auto", + controlHome: root, + provider: createTeExecutionProvider(te), + holderId: "winner-reborn", + }); + const status = await fresh.start(); + assert.equal(status.singleton, "won"); + assert.equal(status.fencingEpoch, 2, "reclaim must bump the fencing epoch"); + assert.equal(fs.existsSync(paths.endpointPath), true); + const freshIno = fs.statSync(paths.endpointPath, { bigint: true }).ino; + assert.notEqual(freshIno, staleIno, "the stale socket must be unlinked and a fresh one created"); + assert.equal(fs.statSync(paths.endpointPath).mode & 0o077, 0, "fresh socket must be 0o600"); + fresh.stop(); +}); + +test("uds: auto never silently becomes standalone at the process level (fail closed)", UNIX_ONLY, async () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "tf-uds-auto-")); + tempRoots.push(parent); + // The control home is a regular FILE — the singleton bootstrap cannot run. + const controlHomeFile = path.join(parent, "control-file"); + fs.writeFileSync(controlHomeFile, "not a directory"); + + const child = spawnFixture("uds-fail-closed.ts", { + TF_TEST_CONTROL_HOME: controlHomeFile, + }); + const exited = await waitForExit(child); + assert.notEqual(exited.code, 0, "auto mode with an un-runnable control must fail closed"); + assert.match(exited.output, /FAILED-CLOSED/); + assert.match(exited.output, /TF_BOOTSTRAP_FAILED/); + assert.match(exited.output, /STATE failed-closed none/); + assert.doesNotMatch(exited.output, /STARTED/); +}); From e4bc49b5a2e16e2689fd8d6bac0295baf50c60a4 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 18 Aug 2026 21:46:04 +0800 Subject: [PATCH 03/30] feat(control): files-only project ControlStore (beta.2 S3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the S3-minimum ledger: header, commit-seq, journal, store-self projections, exclusive writer lock, and hardened UUID/wx/fsync/rename. ControlHost.start() opens projectStorePath before dispatch (winner/standalone only). Acceptance A3–A6 covered by unit + process-level SIGKILL tests. P14 ADR Status stays Proposed; only the S3 engine implementation gate is noted. --- .../p-adrs/P14-controlstore-engine.md | 2 + packages/taskflow-control/src/control-host.ts | 58 ++ packages/taskflow-control/src/index.ts | 1 + packages/taskflow-control/src/store/index.ts | 14 + packages/taskflow-control/src/store/store.ts | 523 ++++++++++++++++++ .../test/control-host.test.ts | 50 ++ .../test/fixtures/store-mutate.ts | 67 +++ .../test/store-process.test.ts | 152 +++++ packages/taskflow-control/test/store.test.ts | 214 +++++++ 9 files changed, 1081 insertions(+) create mode 100644 packages/taskflow-control/src/store/index.ts create mode 100644 packages/taskflow-control/src/store/store.ts create mode 100644 packages/taskflow-control/test/fixtures/store-mutate.ts create mode 100644 packages/taskflow-control/test/store-process.test.ts create mode 100644 packages/taskflow-control/test/store.test.ts diff --git a/docs/internal/p-adrs/P14-controlstore-engine.md b/docs/internal/p-adrs/P14-controlstore-engine.md index 538c6a24..421d79af 100644 --- a/docs/internal/p-adrs/P14-controlstore-engine.md +++ b/docs/internal/p-adrs/P14-controlstore-engine.md @@ -42,6 +42,8 @@ ### 实施门(S3,非 wire-freeze 门) +> beta.2 已批 **S3 store 引擎实施门**(原子批、单写者、symlink 硬化、recovery-vs-mutation)。**不改**本 ADR Status:仍为 Proposed。 + - 原子批 crash/power-loss 矩阵(写前/写中/写后重开:旧完整态 | 新完整态 | fail-closed,绝无未验证混合态)。 - 并发写者/单写者证明;fencing 与 stale owner 拒绝。 - temporary-path symlink 硬化(archive Round 53 教训: 随机 UUID + 独占 `"wx"` 创建 + EEXIST 重试;rename 目标被并发替换为 symlink 的残余竞态必须 fail closed 或文档化为 OS 下限)。 diff --git a/packages/taskflow-control/src/control-host.ts b/packages/taskflow-control/src/control-host.ts index 5145dfe8..9c63b96f 100644 --- a/packages/taskflow-control/src/control-host.ts +++ b/packages/taskflow-control/src/control-host.ts @@ -31,7 +31,9 @@ import { type SingletonPaths, } from "./singleton.ts"; import { connectUdsClient, startUdsServer, type UdsClient, type UdsServer } from "./uds.ts"; +import { openControlStore, type ControlStore } from "./store/index.ts"; import { CONTROL_WIRE_SCHEMA_VERSION, PROTOCOL_MAJOR, type NegotiationHandshake } from "./schema/index.ts"; +import type { CommandRecord, ControlEvent } from "./schema/index.ts"; import type { ExecutionProvider } from "./te-provider.ts"; import type { RunSnapshot } from "./schema/run.ts"; @@ -103,6 +105,8 @@ export class ControlHost { #udsServer?: UdsServer; #udsClient?: UdsClient; #leaseTimer?: NodeJS.Timeout; + #store?: ControlStore; + #standaloneLeasePath?: string; constructor(options: ControlHostOptions) { if (!options.provider || options.provider.kind !== "te-resources") { @@ -220,7 +224,10 @@ export class ControlHost { break; } } + this.#openProjectStore(); } catch (error) { + try { this.#store?.close(); } catch { /* best effort */ } + this.#store = undefined; this.#state = "failed-closed"; if (error instanceof ControlError) throw error; throw bootstrapFailed(`ControlHost could not start in ${this.mode} mode: ${error instanceof Error ? error.message : String(error)}`); @@ -260,6 +267,23 @@ export class ControlHost { case "control.probe": { return await this.provider.probe() as unknown as T; } + case "control.store.header": { + return this.#requireStore().header as unknown as T; + } + case "control.store.status": { + return this.#requireStore().snapshot() as unknown as T; + } + case "commands.submit": { + const body = params as { command?: CommandRecord; events?: ControlEvent[] }; + if (!body || typeof body !== "object" || body.command === undefined || !Array.isArray(body.events)) { + throw new ControlError( + "TF_COMMAND_FAILED", + "commands.submit requires { command, events }", + { recoveryAction: "retry-new-command", sideEffects: "none" }, + ); + } + return this.#requireStore().appendBatch({ command: body.command, events: body.events }) as unknown as T; + } case "runs.status": { const runId = typeof params === "object" && params !== null && "runId" in params ? String((params as { runId: unknown }).runId) @@ -296,6 +320,14 @@ export class ControlHost { clearInterval(this.#leaseTimer); this.#leaseTimer = undefined; } + if (this.#store) { + try { this.#store.close(); } catch { /* best effort */ } + this.#store = undefined; + } + if (this.#standaloneLeasePath) { + try { fs.unlinkSync(this.#standaloneLeasePath); } catch { /* best effort */ } + this.#standaloneLeasePath = undefined; + } const server = this.#udsServer; this.#udsServer = undefined; const client = this.#udsClient; @@ -376,6 +408,32 @@ export class ControlHost { } this.#holderId = holderId; this.#fencingEpoch = 1; + this.#standaloneLeasePath = leasePath; + } + + #openProjectStore(): void { + const storePath = this.#options.projectStorePath; + if (!storePath) return; + // Attached clients read store state over UDS; only the writer opens it. + if (this.#singleton === "attached") return; + try { + this.#store = openControlStore(storePath); + } catch (error) { + throw bootstrapFailed( + `could not open project ControlStore at ${storePath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + #requireStore(): ControlStore { + if (!this.#store) { + throw new ControlError( + "TF_JOURNAL_UNAVAILABLE", + "ControlHost has no project store open (pass projectStorePath and start as winner/standalone)", + { recoveryAction: "none", sideEffects: "none" }, + ); + } + return this.#store; } } diff --git a/packages/taskflow-control/src/index.ts b/packages/taskflow-control/src/index.ts index 5786e967..17684550 100644 --- a/packages/taskflow-control/src/index.ts +++ b/packages/taskflow-control/src/index.ts @@ -18,6 +18,7 @@ export * from "./singleton.ts"; export * from "./uds.ts"; export * from "./te-provider.ts"; export * from "./control-host.ts"; +export * from "./store/index.ts"; // Convenience: the wire protocol major used by the negotiation gate. export { PROTOCOL_MAJOR } from "./schema/transport.ts"; diff --git a/packages/taskflow-control/src/store/index.ts b/packages/taskflow-control/src/store/index.ts new file mode 100644 index 00000000..392faffd --- /dev/null +++ b/packages/taskflow-control/src/store/index.ts @@ -0,0 +1,14 @@ +export { + CONTROL_CRASH_ENV, + ControlStore, + controlStorePaths, + maybeCrash, + openControlStore, + writeJsonAtomicHardened, +} from "./store.ts"; +export type { + CommitBatchInput, + ControlCrashPoint, + ControlStoreSnapshot, + OpenControlStoreOptions, +} from "./store.ts"; diff --git a/packages/taskflow-control/src/store/store.ts b/packages/taskflow-control/src/store/store.ts new file mode 100644 index 00000000..f73b6c42 --- /dev/null +++ b/packages/taskflow-control/src/store/store.ts @@ -0,0 +1,523 @@ +/** + * Files-only ControlStore (beta.2 S3-minimum). + * + * Layout: header + commit-seq.json + journal/ + projections/ + empty + * commands/ + receipts/. Journal is the authority; projections are + * store-self only (submitted-command index). Single writer via exclusive + * lock file. Atomic writes use UUID temp + wx + fsync + rename, and fail + * closed if the destination is a symlink. + * + * P14 ADR Status stays Proposed. This is the S3 engine implementation gate. + */ +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { Value } from "typebox/value"; +import { ControlError } from "../errors.ts"; +import { + CONTROL_WIRE_SCHEMA_VERSION, + CommandRecordSchema, + ControlEventSchema, + ControlStoreHeaderSchema, + type CommandRecord, + type ControlEvent, + type ControlStoreHeader, + type ControlStoreStatus, +} from "../schema/index.ts"; + +export const CONTROL_CRASH_ENV = "TASKFLOW_CONTROL_CRASH_AT"; + +export type ControlCrashPoint = + | "header-fsynced" + | "journal-append" + | "projection-rebuild"; + +const LOCK_NAME = "writer.lock"; +const HEADER_NAME = "header"; +const COMMIT_SEQ_NAME = "commit-seq.json"; +const JOURNAL_DIR = "journal"; +const JOURNAL_SEGMENT = "000001.jsonl"; +const PROJECTIONS_DIR = "projections"; +const COMMAND_INDEX = "commands.json"; +const COMMANDS_DIR = "commands"; +const RECEIPTS_DIR = "receipts"; + +export interface OpenControlStoreOptions { + projectId?: string; + controlDomainId?: string; + now?: () => number; +} + +export interface CommitBatchInput { + command: CommandRecord; + events: readonly ControlEvent[]; +} + +export interface ControlStoreSnapshot { + header: ControlStoreHeader; + commitSeq: number; + status: ControlStoreStatus; +} + +interface JournalBatch { + recordKind: "commit-batch"; + commitSeq: number; + command: CommandRecord; + events: ControlEvent[]; +} + +interface WriterLock { + pid: number; + acquiredAt: number; +} + +export class ControlStore { + readonly storePath: string; + #header: ControlStoreHeader; + #commitSeq: number; + #status: ControlStoreStatus = "healthy"; + #closed = false; + #commandIndex = new Map(); + + constructor(storePath: string, header: ControlStoreHeader, commitSeq: number, commands: readonly CommandRecord[]) { + this.storePath = storePath; + this.#header = header; + this.#commitSeq = commitSeq; + for (const command of commands) this.#commandIndex.set(command.commandId, command); + } + + get header(): ControlStoreHeader { + return this.#header; + } + + get commitSeq(): number { + return this.#commitSeq; + } + + get status(): ControlStoreStatus { + return this.#status; + } + + readCommand(commandId: string): CommandRecord | undefined { + this.#assertOpen(); + return this.#commandIndex.get(commandId); + } + + snapshot(): ControlStoreSnapshot { + this.#assertOpen(); + return { header: this.#header, commitSeq: this.#commitSeq, status: this.#status }; + } + + appendBatch(input: CommitBatchInput): { commitSeq: number; command: CommandRecord } { + this.#assertOpen(); + if (this.#status === "fail-closed") { + throw durabilityFailed("control store is fail-closed; refusing mutation"); + } + if (input.command.kind !== "run.submit") { + throw durabilityFailed(`beta.2 store only accepts run.submit; got ${input.command.kind}`); + } + if (input.events.length === 0) { + throw durabilityFailed("atomic batch requires at least one ControlEvent"); + } + for (const event of input.events) { + if (!Value.Check(ControlEventSchema, event)) { + throw durabilityFailed("ControlEvent failed schema check"); + } + } + if (!Value.Check(CommandRecordSchema, { + ...input.command, + projectId: this.#header.projectId, + controlDomainId: this.#header.controlDomainId, + })) { + // Validate after identity rewrite below; pre-check kind/required fields first. + } + + const nextSeq = this.#commitSeq + 1; + const command: CommandRecord = { + ...input.command, + projectId: this.#header.projectId, + controlDomainId: this.#header.controlDomainId, + firstCommitSeq: nextSeq, + lastCommitSeq: nextSeq, + }; + if (!Value.Check(CommandRecordSchema, command)) { + throw durabilityFailed("CommandRecord failed schema check"); + } + if (this.#commandIndex.has(command.commandId)) { + throw new ControlError( + "TF_IDEMPOTENCY_CONFLICT", + `command ${command.commandId} is already committed`, + { recoveryAction: "none", sideEffects: "none" }, + ); + } + const events: ControlEvent[] = input.events.map((event, index) => ({ + ...event, + projectId: this.#header.projectId, + controlDomainId: this.#header.controlDomainId, + commitSeq: nextSeq, + commandId: command.commandId, + commandEventIndex: index, + })); + + const record: JournalBatch = { + recordKind: "commit-batch", + commitSeq: nextSeq, + command, + events, + }; + crashDuringJournalAppend(journalPath(this.storePath)); + appendJsonLineDurable(journalPath(this.storePath), record); + writeJsonAtomicHardened(commitSeqPath(this.storePath), { commitSeq: nextSeq }); + this.#commandIndex.set(command.commandId, command); + maybeCrash("projection-rebuild"); + writeJsonAtomicHardened(commandIndexPath(this.storePath), Object.fromEntries(this.#commandIndex)); + this.#commitSeq = nextSeq; + this.#status = "healthy"; + return { commitSeq: nextSeq, command }; + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + releaseWriterLock(this.storePath); + } + + #assertOpen(): void { + if (this.#closed) throw durabilityFailed("control store is closed"); + } +} + +export function openControlStore(storePath: string, options: OpenControlStoreOptions = {}): ControlStore { + const resolved = path.resolve(storePath); + ensureDirectory(resolved); + acquireWriterLock(resolved); + + try { + const existingHeader = readHeader(resolved); + if (existingHeader === undefined) { + const header = createHeader(resolved, options); + writeJsonAtomicHardened(headerPath(resolved), header); + maybeCrash("header-fsynced"); + writeJsonAtomicHardened(commitSeqPath(resolved), { commitSeq: 0 }); + ensureDirectory(path.join(resolved, JOURNAL_DIR)); + ensureDirectory(path.join(resolved, PROJECTIONS_DIR)); + ensureDirectory(path.join(resolved, COMMANDS_DIR)); + ensureDirectory(path.join(resolved, RECEIPTS_DIR)); + fsyncDirectory(resolved); + return new ControlStore(resolved, header, 0, []); + } + + const recovered = recoverFromJournal(resolved, existingHeader); + return new ControlStore(resolved, existingHeader, recovered.commitSeq, recovered.commands); + } catch (error) { + releaseWriterLock(resolved); + throw error; + } +} + +function createHeader(storePath: string, options: OpenControlStoreOptions): ControlStoreHeader { + const st = fs.statSync(storePath); + return { + projectId: options.projectId ?? crypto.randomUUID(), + controlDomainId: options.controlDomainId ?? crypto.randomUUID(), + schemaVersion: CONTROL_WIRE_SCHEMA_VERSION, + directoryBinding: { + canonicalPath: fs.realpathSync(storePath), + device: String(st.dev), + inode: String(st.ino), + }, + }; +} + +function readHeader(storePath: string): ControlStoreHeader | undefined { + const file = headerPath(storePath); + if (!fs.existsSync(file)) return undefined; + assertNotSymlink(file); + const raw = JSON.parse(fs.readFileSync(file, "utf8")); + if (!Value.Check(ControlStoreHeaderSchema, raw)) { + throw durabilityFailed(`malformed control store header at ${file}`); + } + return raw; +} + +function recoverFromJournal( + storePath: string, + header: ControlStoreHeader, +): { commitSeq: number; commands: CommandRecord[] } { + ensureDirectory(path.join(storePath, JOURNAL_DIR)); + ensureDirectory(path.join(storePath, PROJECTIONS_DIR)); + ensureDirectory(path.join(storePath, COMMANDS_DIR)); + ensureDirectory(path.join(storePath, RECEIPTS_DIR)); + + const batches = readJournalBatches(journalPath(storePath)); + truncateTornJournal(journalPath(storePath)); + const commands: CommandRecord[] = []; + let commitSeq = 0; + for (const batch of batches) { + if (batch.recordKind !== "commit-batch") { + throw durabilityFailed("journal contains an unknown record kind"); + } + if (batch.commitSeq !== commitSeq + 1) { + throw durabilityFailed(`journal commitSeq gap: expected ${commitSeq + 1}, got ${batch.commitSeq}`); + } + if ( + batch.command.projectId !== header.projectId + || batch.command.controlDomainId !== header.controlDomainId + ) { + throw durabilityFailed("journal command identity does not match store header"); + } + commands.push(batch.command); + commitSeq = batch.commitSeq; + } + + const onDiskSeq = readCommitSeqFile(storePath); + if (onDiskSeq !== undefined && onDiskSeq > commitSeq) { + throw durabilityFailed( + `mixed state: commit-seq.json (${onDiskSeq}) is ahead of journal (${commitSeq})`, + ); + } + writeJsonAtomicHardened(commitSeqPath(storePath), { commitSeq }); + writeJsonAtomicHardened( + commandIndexPath(storePath), + Object.fromEntries(commands.map((command) => [command.commandId, command])), + ); + return { commitSeq, commands }; +} + +function readCommitSeqFile(storePath: string): number | undefined { + const file = commitSeqPath(storePath); + if (!fs.existsSync(file)) return undefined; + assertNotSymlink(file); + const raw = JSON.parse(fs.readFileSync(file, "utf8")) as { commitSeq?: unknown }; + if (typeof raw.commitSeq !== "number" || !Number.isInteger(raw.commitSeq) || raw.commitSeq < 0) { + throw durabilityFailed(`malformed commit-seq.json at ${file}`); + } + return raw.commitSeq; +} + +function readJournalBatches(filePath: string): JournalBatch[] { + if (!fs.existsSync(filePath)) return []; + assertNotSymlink(filePath); + const raw = fs.readFileSync(filePath); + const text = raw.toString("utf8"); + const lines = text.split("\n"); + const complete = text.endsWith("\n") ? lines.slice(0, -1) : lines.slice(0, -1); + // Torn tail (no terminating newline on last line) is discarded — old-complete. + const batches: JournalBatch[] = []; + for (const line of complete) { + if (!line) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + throw durabilityFailed("journal contains a complete but unreadable line"); + } + batches.push(parsed as JournalBatch); + } + return batches; +} + +function truncateTornJournal(filePath: string): void { + if (!fs.existsSync(filePath)) return; + assertNotSymlink(filePath); + const raw = fs.readFileSync(filePath); + if (raw.length === 0 || raw[raw.length - 1] === 0x0a) return; + const lastNewline = raw.lastIndexOf(0x0a); + const fd = fs.openSync(filePath, "r+"); + try { + fs.ftruncateSync(fd, lastNewline === -1 ? 0 : lastNewline + 1); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } +} + +function acquireWriterLock(storePath: string): void { + const lockPath = path.join(storePath, LOCK_NAME); + for (let attempt = 0; attempt < 3; attempt++) { + try { + const fd = fs.openSync(lockPath, "wx", 0o600); + try { + fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, acquiredAt: Date.now() } satisfies WriterLock)); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fsyncDirectory(storePath); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + if (!isLockHolderAlive(lockPath)) { + try { fs.unlinkSync(lockPath); } catch { /* retry wx */ } + continue; + } + throw durabilityFailed(`control store already has a live writer at ${storePath}`); + } + } + throw durabilityFailed(`could not acquire control store writer lock at ${storePath}`); +} + +function releaseWriterLock(storePath: string): void { + const lockPath = path.join(storePath, LOCK_NAME); + try { + const raw = JSON.parse(fs.readFileSync(lockPath, "utf8")) as WriterLock; + if (raw.pid !== process.pid) return; + fs.unlinkSync(lockPath); + } catch { + /* best effort */ + } +} + +function isLockHolderAlive(lockPath: string): boolean { + try { + const raw = JSON.parse(fs.readFileSync(lockPath, "utf8")) as WriterLock; + if (typeof raw.pid !== "number") return false; + process.kill(raw.pid, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + // EPERM: process exists but we cannot signal it — treat as live. + if ((error as NodeJS.ErrnoException).code === "EPERM") return true; + return false; + } +} + +export function maybeCrash(point: ControlCrashPoint): void { + if (process.env[CONTROL_CRASH_ENV] === point) { + process.kill(process.pid, "SIGKILL"); + } +} + +/** SIGKILL after a torn (unterminated) journal write — process-level A4. */ +function crashDuringJournalAppend(filePath: string): void { + if (process.env[CONTROL_CRASH_ENV] !== "journal-append") return; + ensureDirectory(path.dirname(filePath)); + const fd = fs.openSync(filePath, "a", 0o600); + try { + fs.writeSync(fd, Buffer.from("{\"recordKind\":\"commit-batch\"")); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + process.kill(process.pid, "SIGKILL"); +} + +export function writeJsonAtomicHardened(filePath: string, value: unknown): void { + assertNotSymlink(filePath); + const directory = path.dirname(filePath); + ensureDirectory(directory); + let temp = ""; + let fd = -1; + for (let attempt = 0; attempt < 8; attempt++) { + temp = path.join(directory, `.${crypto.randomUUID()}`); + try { + fd = fs.openSync(temp, "wx", 0o600); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") continue; + throw error; + } + } + if (fd < 0) throw durabilityFailed(`could not create exclusive temp file for ${filePath}`); + try { + fs.writeFileSync(fd, `${JSON.stringify(value)}\n`); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + try { + assertNotSymlink(filePath); + fs.renameSync(temp, filePath); + fsyncDirectory(directory); + } catch (error) { + try { fs.unlinkSync(temp); } catch { /* best effort */ } + throw error; + } +} + +function appendJsonLineDurable(filePath: string, record: unknown): void { + assertNotSymlink(filePath); + ensureDirectory(path.dirname(filePath)); + const existed = fs.existsSync(filePath); + if (existed) { + const raw = fs.readFileSync(filePath); + if (raw.length > 0 && raw[raw.length - 1] !== 0x0a) { + const lastNewline = raw.lastIndexOf(0x0a); + const repairFd = fs.openSync(filePath, "r+"); + try { + fs.ftruncateSync(repairFd, lastNewline === -1 ? 0 : lastNewline + 1); + fs.fsyncSync(repairFd); + } finally { + fs.closeSync(repairFd); + } + } + } + const fd = fs.openSync(filePath, "a", 0o600); + try { + const data = Buffer.from(`${JSON.stringify(record)}\n`); + let offset = 0; + while (offset < data.length) offset += fs.writeSync(fd, data, offset, data.length - offset); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + if (!existed) fsyncDirectory(path.dirname(filePath)); +} + +function assertNotSymlink(filePath: string): void { + try { + const st = fs.lstatSync(filePath); + if (st.isSymbolicLink()) { + throw durabilityFailed(`refusing to touch symlink at ${filePath}`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } +} + +function ensureDirectory(directory: string): void { + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); +} + +function fsyncDirectory(directory: string): void { + try { + const fd = fs.openSync(directory, "r"); + try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } + } catch { + /* some filesystems refuse directory fsync */ + } +} + +function durabilityFailed(message: string): ControlError { + return new ControlError("TF_DURABILITY_FAILED", message, { + recoveryAction: "none", + sideEffects: "unknown", + }); +} + +function headerPath(storePath: string): string { + return path.join(storePath, HEADER_NAME); +} + +function commitSeqPath(storePath: string): string { + return path.join(storePath, COMMIT_SEQ_NAME); +} + +function journalPath(storePath: string): string { + return path.join(storePath, JOURNAL_DIR, JOURNAL_SEGMENT); +} + +function commandIndexPath(storePath: string): string { + return path.join(storePath, PROJECTIONS_DIR, COMMAND_INDEX); +} + +export function controlStorePaths(storePath: string) { + return { + header: headerPath(storePath), + commitSeq: commitSeqPath(storePath), + journal: journalPath(storePath), + commandIndex: commandIndexPath(storePath), + lock: path.join(storePath, LOCK_NAME), + }; +} diff --git a/packages/taskflow-control/test/control-host.test.ts b/packages/taskflow-control/test/control-host.test.ts index fdcf8902..13e72d2e 100644 --- a/packages/taskflow-control/test/control-host.test.ts +++ b/packages/taskflow-control/test/control-host.test.ts @@ -190,3 +190,53 @@ test("control-host: stop releases the singleton so a fresh host can win", async assert.equal(status.singleton, "won"); second.stop(); }); + +test("control-host: standalone opens projectStorePath and a later host sees the same ledger (A3)", async () => { + const paths = makePaths(); + const storePath = path.join(paths.controlHome, "project-store"); + const first = new ControlHost(hostOptions(paths, { mode: "standalone", holderId: "s1", projectStorePath: storePath })); + await first.start(); + first.hello(clientHello); + const header = await first.dispatch<{ projectId: string }>("control.store.header", undefined, { fencingEpoch: 1 }); + assert.match(header.projectId, /^[0-9a-f-]{36}$/i); + const submitted = await first.dispatch<{ commitSeq: number }>("commands.submit", { + command: { + commandId: "00000000-0000-0000-0000-0000000000bb", + kind: "run.submit", + requestHash: "a".repeat(64), + callerPrincipal: "cli", + authorizationContextHash: "a".repeat(64), + projectId: header.projectId, + controlDomainId: header.projectId, + status: "accepted", + firstCommitSeq: 1, + lastCommitSeq: 1, + recordedAt: 1, + }, + events: [{ + eventId: "00000000-0000-0000-0000-0000000000cc", + schemaVersion: 1, + controlDomainId: header.projectId, + streamId: "command:00000000-0000-0000-0000-0000000000bb", + streamSeq: 1, + commitSeq: 1, + commandId: "00000000-0000-0000-0000-0000000000bb", + commandEventIndex: 0, + causationId: "00000000-0000-0000-0000-0000000000bb", + correlationId: "00000000-0000-0000-0000-0000000000bb", + projectId: header.projectId, + recordedAt: 1, + payload: { kind: "command.recorded", commandId: "00000000-0000-0000-0000-0000000000bb" }, + }], + }, { fencingEpoch: 1 }); + assert.equal(submitted.commitSeq, 1); + first.stop(); + + const second = new ControlHost(hostOptions(paths, { mode: "standalone", holderId: "s2", projectStorePath: storePath })); + await second.start(); + second.hello(clientHello); + const status = await second.dispatch<{ header: { projectId: string }; commitSeq: number }>("control.store.status", undefined, { fencingEpoch: 1 }); + assert.equal(status.header.projectId, header.projectId); + assert.equal(status.commitSeq, 1); + second.stop(); +}); diff --git a/packages/taskflow-control/test/fixtures/store-mutate.ts b/packages/taskflow-control/test/fixtures/store-mutate.ts new file mode 100644 index 00000000..80997727 --- /dev/null +++ b/packages/taskflow-control/test/fixtures/store-mutate.ts @@ -0,0 +1,67 @@ +/** + * ControlStore process-test fixture. + * + * Environment: + * TF_TEST_STORE_PATH — project store directory + * TF_TEST_ACTION — init | mutate + * TASKFLOW_CONTROL_CRASH_AT — header-fsynced | journal-append | projection-rebuild + */ +import { randomUUID } from "node:crypto"; +import { CONTROL_WIRE_SCHEMA_VERSION } from "../../src/schema/index.ts"; +import { openControlStore } from "../../src/store/index.ts"; + +const storePath = process.env.TF_TEST_STORE_PATH; +const action = process.env.TF_TEST_ACTION ?? "init"; +if (!storePath) { + console.log("ERROR fixture requires TF_TEST_STORE_PATH"); + process.exit(1); +} + +const SHA256 = "a".repeat(64); +const commandId = process.env.TF_TEST_COMMAND_ID ?? randomUUID(); +const projectId = process.env.TF_TEST_PROJECT_ID ?? "00000000-0000-0000-0000-000000000001"; +const domainId = process.env.TF_TEST_DOMAIN_ID ?? "00000000-0000-0000-0000-000000000001"; + +try { + const store = openControlStore(storePath, { projectId, controlDomainId: domainId }); + if (action === "mutate") { + store.appendBatch({ + command: { + commandId, + kind: "run.submit", + requestHash: SHA256, + callerPrincipal: "crash-fixture", + authorizationContextHash: SHA256, + projectId, + controlDomainId: domainId, + status: "accepted", + firstCommitSeq: 1, + lastCommitSeq: 1, + recordedAt: Date.now(), + }, + events: [ + { + eventId: randomUUID(), + schemaVersion: CONTROL_WIRE_SCHEMA_VERSION, + controlDomainId: domainId, + streamId: `command:${commandId}`, + streamSeq: 1, + commitSeq: 1, + commandId, + commandEventIndex: 0, + causationId: commandId, + correlationId: commandId, + projectId, + recordedAt: Date.now(), + payload: { kind: "command.recorded", commandId }, + }, + ], + }); + } + console.log(`READY ${JSON.stringify({ projectId: store.header.projectId, commitSeq: store.commitSeq })}`); + store.close(); + process.exit(0); +} catch (error) { + console.log(`ERROR ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +} diff --git a/packages/taskflow-control/test/store-process.test.ts b/packages/taskflow-control/test/store-process.test.ts new file mode 100644 index 00000000..4ea21c65 --- /dev/null +++ b/packages/taskflow-control/test/store-process.test.ts @@ -0,0 +1,152 @@ +/** + * Process-level ControlStore crash matrix (A4) + recovery-vs-mutation (A5). + * + * Unix-only: SIGKILL injection is not part of the 3-OS process-supervisor + * matrix. After restart the store must be old-complete, new-complete, or + * fail-closed — never a mixed journal/projection/commit-seq state. + */ +import assert from "node:assert/strict"; +import { spawn, type ChildProcess } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { after, test } from "node:test"; +import { ControlError } from "../src/errors.ts"; +import { CONTROL_CRASH_ENV, openControlStore } from "../src/store/index.ts"; + +const UNIX_ONLY = { skip: process.platform === "win32" } as const; + +const tempRoots: string[] = []; +const children: ChildProcess[] = []; + +after(() => { + for (const child of children) { + try { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + } catch { /* best effort */ } + } + for (const root of tempRoots) { + try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ } + } +}); + +function makeStorePath(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-store-crash-")); + tempRoots.push(root); + return path.join(root, "project-store"); +} + +function spawnFixture(env: Record): ChildProcess { + const child = spawn( + process.execPath, + ["--conditions=development", "--experimental-strip-types", path.join(import.meta.dirname, "fixtures", "store-mutate.ts")], + { + cwd: path.resolve(import.meta.dirname, "..", "..", ".."), + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + children.push(child); + return child; +} + +function waitForExit(child: ChildProcess, timeoutMs = 15_000): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + try { child.kill("SIGKILL"); } catch { /* best effort */ } + reject(new Error("timeout waiting for store fixture exit")); + }, timeoutMs); + child.once("exit", (code, signal) => { + clearTimeout(timer); + resolve({ code, signal }); + }); + }); +} + +function assertNotMixed(storePath: string, expected: "old" | "new"): void { + const store = openControlStore(storePath); + try { + assert.notEqual(store.status, "fail-closed"); + if (expected === "old") { + assert.equal(store.commitSeq, 0); + assert.equal(store.readCommand("00000000-0000-0000-0000-0000000000aa"), undefined); + } else { + assert.equal(store.commitSeq, 1); + assert.ok(store.readCommand("00000000-0000-0000-0000-0000000000aa")); + } + const journal = path.join(storePath, "journal", "000001.jsonl"); + if (fs.existsSync(journal)) { + const raw = fs.readFileSync(journal); + if (raw.length > 0) { + assert.equal(raw[raw.length - 1], 0x0a, "recovered journal must not keep a torn tail"); + } + } + const seq = JSON.parse(fs.readFileSync(path.join(storePath, "commit-seq.json"), "utf8")) as { commitSeq: number }; + assert.equal(seq.commitSeq, store.commitSeq, "commit-seq.json must match journal"); + } finally { + store.close(); + } +} + +test("store crash: SIGKILL after header fsync / before journal is old-complete (A4)", UNIX_ONLY, async () => { + const storePath = makeStorePath(); + const child = spawnFixture({ + TF_TEST_STORE_PATH: storePath, + TF_TEST_ACTION: "init", + [CONTROL_CRASH_ENV]: "header-fsynced", + }); + const result = await waitForExit(child); + assert.equal(result.signal, "SIGKILL"); + assert.ok(fs.existsSync(path.join(storePath, "header"))); + assert.equal(fs.existsSync(path.join(storePath, "journal", "000001.jsonl")), false); + assertNotMixed(storePath, "old"); +}); + +test("store crash: SIGKILL mid journal append is old-complete (A4)", UNIX_ONLY, async () => { + const storePath = makeStorePath(); + const primed = openControlStore(storePath); + primed.close(); + + const child = spawnFixture({ + TF_TEST_STORE_PATH: storePath, + TF_TEST_ACTION: "mutate", + TF_TEST_COMMAND_ID: "00000000-0000-0000-0000-0000000000aa", + [CONTROL_CRASH_ENV]: "journal-append", + }); + const result = await waitForExit(child); + assert.equal(result.signal, "SIGKILL"); + assertNotMixed(storePath, "old"); +}); + +test("store crash: SIGKILL during projection rebuild is new-complete (A4)", UNIX_ONLY, async () => { + const storePath = makeStorePath(); + const primed = openControlStore(storePath); + primed.close(); + + const child = spawnFixture({ + TF_TEST_STORE_PATH: storePath, + TF_TEST_ACTION: "mutate", + TF_TEST_COMMAND_ID: "00000000-0000-0000-0000-0000000000aa", + [CONTROL_CRASH_ENV]: "projection-rebuild", + }); + const result = await waitForExit(child); + assert.equal(result.signal, "SIGKILL"); + assertNotMixed(storePath, "new"); +}); + +test("store crash: recovery racing a second writer fails closed (A5)", UNIX_ONLY, async () => { + const storePath = makeStorePath(); + const holder = openControlStore(storePath); + try { + assert.throws( + () => openControlStore(storePath), + (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_DURABILITY_FAILED"); + return true; + }, + ); + } finally { + holder.close(); + } +}); diff --git a/packages/taskflow-control/test/store.test.ts b/packages/taskflow-control/test/store.test.ts new file mode 100644 index 00000000..f21bc221 --- /dev/null +++ b/packages/taskflow-control/test/store.test.ts @@ -0,0 +1,214 @@ +/** + * Files-only project ControlStore (beta.2 S3-minimum). + * + * A3: standalone write then reopen sees the same projectId + commit-seq. + * A5: a second open/mutation is rejected (single writer). + * P14 hardening: symlink dest fail-closed. + * + * Process-level SIGKILL crash matrix lives in store-process.test.ts. + */ +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { after, test } from "node:test"; +import { ControlError } from "../src/errors.ts"; +import { + CONTROL_WIRE_SCHEMA_VERSION, + type CommandRecord, + type ControlEvent, +} from "../src/schema/index.ts"; +import { openControlStore } from "../src/store/index.ts"; + +const tempRoots: string[] = []; + +function makeStorePath(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-control-store-")); + tempRoots.push(root); + return path.join(root, "project-store"); +} + +after(() => { + for (const root of tempRoots) { + try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ } + } +}); + +const UUID = "00000000-0000-0000-0000-000000000001"; +const UUID2 = "00000000-0000-0000-0000-000000000002"; +const SHA256 = "a".repeat(64); + +function submitBatch(overrides: Partial = {}): { + command: CommandRecord; + events: ControlEvent[]; +} { + const command: CommandRecord = { + commandId: UUID2, + kind: "run.submit", + requestHash: SHA256, + callerPrincipal: "cli", + authorizationContextHash: SHA256, + projectId: UUID, + controlDomainId: UUID, + status: "accepted", + firstCommitSeq: 1, + lastCommitSeq: 1, + recordedAt: 1, + ...overrides, + }; + const events: ControlEvent[] = [ + { + eventId: UUID, + schemaVersion: CONTROL_WIRE_SCHEMA_VERSION, + controlDomainId: command.controlDomainId, + streamId: `command:${command.commandId}`, + streamSeq: 1, + commitSeq: 1, + commandId: command.commandId, + commandEventIndex: 0, + causationId: command.commandId, + correlationId: command.commandId, + projectId: command.projectId, + recordedAt: 1, + payload: { kind: "command.recorded", commandId: command.commandId }, + }, + ]; + return { command, events }; +} + +test("store: opening a new path creates a header and empty ledger (A3)", () => { + const storePath = makeStorePath(); + const store = openControlStore(storePath); + try { + assert.equal(store.status, "healthy"); + assert.equal(store.commitSeq, 0); + assert.equal(store.header.schemaVersion, CONTROL_WIRE_SCHEMA_VERSION); + assert.match(store.header.projectId, /^[0-9a-f-]{36}$/i); + assert.equal(store.header.directoryBinding.canonicalPath, fs.realpathSync(storePath)); + assert.ok(fs.existsSync(path.join(storePath, "header"))); + assert.ok(fs.existsSync(path.join(storePath, "commit-seq.json"))); + assert.ok(fs.statSync(path.join(storePath, "journal")).isDirectory()); + assert.ok(fs.statSync(path.join(storePath, "projections")).isDirectory()); + assert.ok(fs.statSync(path.join(storePath, "commands")).isDirectory()); + assert.ok(fs.statSync(path.join(storePath, "receipts")).isDirectory()); + } finally { + store.close(); + } +}); + +test("store: reopen after close sees the same projectId and commit-seq (A3)", () => { + const storePath = makeStorePath(); + const first = openControlStore(storePath, { projectId: UUID, controlDomainId: UUID }); + const projectId = first.header.projectId; + const domainId = first.header.controlDomainId; + first.close(); + + const second = openControlStore(storePath); + try { + assert.equal(second.header.projectId, projectId); + assert.equal(second.header.controlDomainId, domainId); + assert.equal(second.commitSeq, 0); + assert.equal(second.status, "healthy"); + } finally { + second.close(); + } +}); + +test("store: run.submit + events commit atomically and survive reopen (A3)", () => { + const storePath = makeStorePath(); + const first = openControlStore(storePath, { projectId: UUID, controlDomainId: UUID }); + const { command, events } = submitBatch(); + const committed = first.appendBatch({ command, events }); + assert.equal(committed.commitSeq, 1); + assert.equal(committed.command.kind, "run.submit"); + assert.equal(committed.command.firstCommitSeq, 1); + assert.equal(committed.command.lastCommitSeq, 1); + assert.equal(first.readCommand(command.commandId)?.kind, "run.submit"); + first.close(); + + const second = openControlStore(storePath); + try { + assert.equal(second.commitSeq, 1); + assert.equal(second.header.projectId, UUID); + const loaded = second.readCommand(command.commandId); + assert.ok(loaded); + assert.equal(loaded.kind, "run.submit"); + assert.equal(loaded.requestHash, SHA256); + } finally { + second.close(); + } +}); + +test("store: a second live opener is rejected (A5 single writer)", () => { + const storePath = makeStorePath(); + const first = openControlStore(storePath); + try { + assert.throws( + () => openControlStore(storePath), + (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_DURABILITY_FAILED"); + return true; + }, + ); + } finally { + first.close(); + } +}); + +test("store: rename destination replaced by a symlink fails closed (P14)", () => { + const storePath = makeStorePath(); + const store = openControlStore(storePath); + store.close(); + + const header = path.join(storePath, "header"); + const decoy = path.join(path.dirname(storePath), "decoy-header"); + fs.writeFileSync(decoy, "stolen\n"); + fs.unlinkSync(header); + fs.symlinkSync(decoy, header); + + assert.throws( + () => openControlStore(storePath), + (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_DURABILITY_FAILED"); + assert.match((error as ControlError).message, /symlink/i); + return true; + }, + ); + assert.equal(fs.readFileSync(decoy, "utf8"), "stolen\n", "symlink target must not be overwritten"); +}); + +test("store: writeFile/appendFile/createWriteStream in control src stay on metadata paths (A6)", () => { + const srcRoot = path.resolve(import.meta.dirname, "../src"); + const hits: string[] = []; + const stack = [srcRoot]; + while (stack.length > 0) { + const current = stack.pop()!; + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(full); + continue; + } + if (!entry.name.endsWith(".ts")) continue; + const text = fs.readFileSync(full, "utf8"); + const re = /\b(?:writeFile(?:Sync)?|appendFile(?:Sync)?|createWriteStream)\s*\(/g; + let match: RegExpExecArray | null; + while ((match = re.exec(text)) !== null) { + const line = text.slice(0, match.index).split("\n").length; + hits.push(`${path.relative(srcRoot, full)}:${line}`); + } + } + } + const allowed = [ + "singleton.ts", + "control-host.ts", + "store/store.ts", + ]; + for (const hit of hits) { + const file = hit.split(":")[0]; + assert.ok(allowed.includes(file), `A6: unexpected write at ${hit}`); + } + assert.ok(hits.length > 0, "expected to find control-metadata writes"); +}); From bee8586d75f7910048803ba9d8cbe1512a3a8879 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 18 Aug 2026 21:51:06 +0800 Subject: [PATCH 04/30] docs: freeze 0.3.0-beta.2 and honest Control Plane slice claims Align publishable versions and pins to 0.3.0-beta.2. Rewrite the control package description and README banners so wire contracts stay PROPOSED, taskflow-control stays off the ten-package face (tag next), and #137/#95 are not claimed. ControlHost read-only store RPCs already landed with S3; MCP status tool deferred to avoid a hard dep on an unpublished package. --- CHANGELOG.md | 15 ++ README.md | 2 +- README.zh-CN.md | 2 +- RELEASE.md | 6 +- SECURITY.md | 2 +- docs/internal/0.3.0-ga-scoreboard.md | 2 + package.json | 2 +- packages/claude-taskflow/package.json | 2 +- .../plugin/.claude-plugin/plugin.json | 2 +- packages/claude-taskflow/plugin/.mcp.json | 2 +- .../claude-taskflow/test/mcp-server.test.ts | 2 +- packages/codex-taskflow/package.json | 2 +- .../plugin/.codex-plugin/plugin.json | 2 +- packages/codex-taskflow/plugin/.mcp.json | 2 +- .../codex-taskflow/test/mcp-server.test.ts | 2 +- packages/grok-taskflow/package.json | 2 +- .../plugin/.grok-plugin/plugin.json | 2 +- packages/grok-taskflow/plugin/.mcp.json | 2 +- .../grok-taskflow/test/mcp-server.test.ts | 2 +- packages/hermes-taskflow/package.json | 2 +- .../plugin/hermes.config.snippet.yaml | 2 +- .../hermes-taskflow/test/mcp-server.test.ts | 2 +- packages/opencode-taskflow/package.json | 2 +- .../opencode-taskflow/plugin/opencode.json | 2 +- .../opencode-taskflow/test/mcp-server.test.ts | 2 +- packages/pi-taskflow/package.json | 4 +- packages/taskflow-control/package.json | 174 +++++++++--------- packages/taskflow-control/src/index.ts | 8 +- packages/taskflow-core/package.json | 4 +- packages/taskflow-dsl/package.json | 2 +- packages/taskflow-hosts/package.json | 4 +- packages/taskflow-mcp-core/package.json | 4 +- website/app/[lang]/layout.tsx | 8 +- website/app/[lang]/page.tsx | 14 +- website/app/page.tsx | 2 +- .../en/compiler-runtime/typescript-dsl.mdx | 2 +- website/content/docs/en/getting-started.mdx | 2 +- website/content/docs/en/index.mdx | 2 +- website/content/docs/en/trusted-effects.mdx | 2 +- .../zh-cn/compiler-runtime/typescript-dsl.mdx | 2 +- .../content/docs/zh-cn/getting-started.mdx | 2 +- website/content/docs/zh-cn/index.mdx | 2 +- .../content/docs/zh-cn/trusted-effects.mdx | 2 +- 43 files changed, 162 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca1161b3..be4aff84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to taskflow are documented here. This project follows [Keep ## [Unreleased] +## [0.3.0-beta.2] — 2026-08-18 + +> npm `beta` dist-tag. **Not GA.** Control Plane slice: S2 Unix UDS + S3-min files-only store. Does **not** implement `#137` (`/tf web`) or `#95` (adaptive-authority isolation). TypeBox contracts and `wire-freeze.md` remain **PROPOSED**. `taskflow-control` is **not** in the ten-package publish set (`publishConfig.tag` stays `next`). + +### Added + +- **ControlHost Unix UDS (S2).** Winner listens on a user-scoped socket; loser attaches and uses the winner fencing epoch. Auto never silently degrades to standalone. Unix-only process tests; not part of the 3-OS process-supervisor matrix. +- **Files-only project ControlStore (S3-min).** Header + `commit-seq.json` + journal + store-self projections; exclusive writer lock; hardened UUID/`wx`/fsync/rename; process-level SIGKILL crash matrix (old-complete | new-complete | fail-closed). `ControlHost.start()` opens `projectStorePath` before dispatch on winner/standalone. Read-only RPCs: `control.store.header`, `control.store.status`. MCP `taskflow_control_status` is **not** added — that would force unpublished `taskflow-control` onto the ten-package face. + +### Notes + +- Rebased onto `0.3.0-beta.1.1` (`8fab2c9`). +- All publishable surfaces aligned to `0.3.0-beta.2`. +- Ready / merge / tag / npm still require a new instruction. + ## [0.3.0-beta.1.1] — 2026-08-18 > Hotfix on `0.3.0-beta.1`. npm `beta` dist-tag. **Not GA.** Does **not** implement `#137` (`/tf web`) or `#95` (adaptive-authority isolation). diff --git a/README.md b/README.md index d5b4cee1..2c9e01be 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ **taskflow is a declarative runtime for coding-agent workflows.** It turns a graph into a verifiable execution contract, runs phases in isolation, and keeps intermediate work out of the host conversation. In the 0.3 candidate, the contract also describes the effects a phase is allowed to propose. -> **Status: 0.3.0-beta.1.1 Trusted Effects beta — beta channel, not GA.** This release candidate is prepared for npm's `beta` channel; the beta ships the Trusted Effects MVP described below. The 0.3-C Control Plane remains a follow-on candidate track; it is not a shipped beta surface. +> **Status: 0.3.0-beta.2 — beta channel, not GA.** Ships the Trusted Effects MVP plus a Control Plane slice: Unix UDS ControlHost listen/attach (S2) and a files-only project ControlStore (S3-min). Wire contracts remain **PROPOSED**. `taskflow-control` is workspace-only until a later 11-package decision. Does **not** implement `#137` (`/tf web`) or `#95` (adaptive-authority isolation). ## The 0.3 idea diff --git a/README.zh-CN.md b/README.zh-CN.md index 9e60ec72..045e258d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -21,7 +21,7 @@ **taskflow 是面向 coding-agent 工作流的声明式运行时。** 它把任务图变成可验证的执行合同,让阶段隔离运行,并把中间过程留在宿主对话之外。在 0.3 candidate 中,这份合同还可以描述每个阶段被允许提出的副作用。 -> **状态:0.3.0-beta.1.1 Trusted Effects beta——beta channel,尚未 GA。** 当前 release candidate 已准备发布到 npm 的 `beta` channel;beta 包含下文所述的 Trusted Effects MVP。0.3-C Control Plane 仍是后续 candidate 轨道,不是 beta 已交付的产品表面。 +> **状态:0.3.0-beta.2——beta channel,尚未 GA。** 含 Trusted Effects MVP,以及 Control Plane 切片:Unix UDS ControlHost listen/attach(S2)和 files-only project ControlStore(S3 最小集)。Wire 合同仍为 **PROPOSED**。`taskflow-control` 在十一包决定之前只在 workspace。不实现 `#137`(`/tf web`)和 `#95`(权限隔离)。 ## 0.3 的核心想法 diff --git a/RELEASE.md b/RELEASE.md index 7fb69052..6dd1e3f1 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -19,7 +19,7 @@ Dependency order: `taskflow-mcp-core`, `taskflow-hosts`, `taskflow-dsl`, `pi-tas ## One-time repository setup -The beta release path is `v0.3.0-beta.1.1`: merge the reviewed release commit to `main`, then push the tag. `.github/workflows/publish.yml` validates the `0.3.0-beta.*` prerelease family, publishes the same ten packages to npm's `beta` dist-tag with provenance, and creates a prerelease GitHub Release. Do not publish from a workstation. +The beta release path is `v0.3.0-beta.2`: merge the reviewed release commit to `main`, then push the tag. `.github/workflows/publish.yml` validates the `0.3.0-beta.*` prerelease family, publishes the same ten packages to npm's `beta` dist-tag with provenance, and creates a prerelease GitHub Release. Do not publish from a workstation. ## Pre-flight (always) @@ -77,8 +77,8 @@ the matching annotated tag: ```sh git switch main git pull --ff-only origin main -git tag -a v0.3.0-beta.1.1 -m "Release v0.3.0-beta.1.1" -git push origin v0.3.0-beta.1.1 +git tag -a v0.3.0-beta.2 -m "Release v0.3.0-beta.2" +git push origin v0.3.0-beta.2 ``` `.github/workflows/publish.yml` then performs the complete release transaction: diff --git a/SECURITY.md b/SECURITY.md index e3e82b51..d4c2f144 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -28,7 +28,7 @@ The runtime has intentional hardening: `realpath`-based path containment, runId | Version | Support | |---------|---------| | Latest stable npm release (`v0.2.10`) | ✅ Active | -| `0.3.0-beta.1.1` beta channel | ⚠️ Pre-release; test-only support | +| `0.3.0-beta.2` beta channel | ⚠️ Pre-release; test-only support | | Earlier versions | ❌ Unsupported — upgrade to the latest npm release | ## Disclosure diff --git a/docs/internal/0.3.0-ga-scoreboard.md b/docs/internal/0.3.0-ga-scoreboard.md index 5d397102..141f62c8 100644 --- a/docs/internal/0.3.0-ga-scoreboard.md +++ b/docs/internal/0.3.0-ga-scoreboard.md @@ -1,6 +1,8 @@ # 0.3 Trusted Effects — GA Scoreboard > Living ledger. **Do not claim GA** unless L6 is PASS with human tag evidence. +> +> **beta.2 (2026-08-18):** `rc/0.3.0-beta.2` has S2 UDS + S3-min store + version freeze `0.3.0-beta.2`. L5 for `v0.3.0-beta.2` is still FAIL (no tag). Published beta remains `v0.3.0-beta.1.1`. `#137` / `#95` out of scope. **Last updated:** 2026-08-11 (tip `0551f62`; residual recheck + S-H2 closed) **Branch:** `rc/0.3.0-trusted-effects` @ `0551f62` diff --git a/package.json b/package.json index 27a26469..01e2710c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-taskflow-monorepo", - "version": "0.3.0-beta.1.1", + "version": "0.3.0-beta.2", "private": true, "description": "Monorepo for the Taskflow engine and DSL, host adapters, delivery packages, documentation, and the private CharterArc experiment.", "type": "module", diff --git a/packages/claude-taskflow/package.json b/packages/claude-taskflow/package.json index cffdd139..790a82bf 100644 --- a/packages/claude-taskflow/package.json +++ b/packages/claude-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "claude-taskflow", - "version": "0.3.0-beta.1.1", + "version": "0.3.0-beta.2", "description": "Run taskflow on Claude Code: the npm tarball provides the Claude subagent runner and MCP server; the plugin is distributed through the repository marketplace.", "keywords": [ "claude", diff --git a/packages/claude-taskflow/plugin/.claude-plugin/plugin.json b/packages/claude-taskflow/plugin/.claude-plugin/plugin.json index 9bb58e69..15a2aae1 100644 --- a/packages/claude-taskflow/plugin/.claude-plugin/plugin.json +++ b/packages/claude-taskflow/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "taskflow", - "version": "0.3.0-beta.1.1", + "version": "0.3.0-beta.2", "description": "Declarative, verifiable DAG orchestration for Claude Code subagents — fan-out, gates, loops, tournaments, approvals, resumable runs, and saveable commands, with intermediate transcripts kept out of your context.", "author": { "name": "heggria", diff --git a/packages/claude-taskflow/plugin/.mcp.json b/packages/claude-taskflow/plugin/.mcp.json index 81d506cd..1da1b8a4 100644 --- a/packages/claude-taskflow/plugin/.mcp.json +++ b/packages/claude-taskflow/plugin/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "taskflow": { "command": "npx", - "args": ["-y", "-p", "claude-taskflow@0.3.0-beta.1.1", "claude-taskflow-mcp"] + "args": ["-y", "-p", "claude-taskflow@0.3.0-beta.2", "claude-taskflow-mcp"] } } } diff --git a/packages/claude-taskflow/test/mcp-server.test.ts b/packages/claude-taskflow/test/mcp-server.test.ts index 0a64baf1..e2749d6f 100644 --- a/packages/claude-taskflow/test/mcp-server.test.ts +++ b/packages/claude-taskflow/test/mcp-server.test.ts @@ -45,7 +45,7 @@ test("claude mcp: initialize returns the protocol version + serverInfo", async ( assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-claude"); - assert.equal(res.result.serverInfo.version, "0.3.0-beta.1.1"); + assert.equal(res.result.serverInfo.version, "0.3.0-beta.2"); }); test("claude mcp: tools/list exposes the same taskflow tools as codex", async () => { diff --git a/packages/codex-taskflow/package.json b/packages/codex-taskflow/package.json index 8f951ff0..24f5c2dd 100644 --- a/packages/codex-taskflow/package.json +++ b/packages/codex-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "codex-taskflow", - "version": "0.3.0-beta.1.1", + "version": "0.3.0-beta.2", "description": "Run taskflow on OpenAI Codex: the npm tarball provides the Codex subagent runner and MCP server; the plugin is distributed through the repository marketplace.", "keywords": [ "codex", diff --git a/packages/codex-taskflow/plugin/.codex-plugin/plugin.json b/packages/codex-taskflow/plugin/.codex-plugin/plugin.json index a21a1d97..36a25385 100644 --- a/packages/codex-taskflow/plugin/.codex-plugin/plugin.json +++ b/packages/codex-taskflow/plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "taskflow", - "version": "0.3.0-beta.1.1", + "version": "0.3.0-beta.2", "description": "Declarative, verifiable DAG orchestration for Codex subagents — fan-out, gates, loops, tournaments, approvals, resumable runs, and saveable commands, with intermediate transcripts kept out of your context.", "author": { "name": "heggria", diff --git a/packages/codex-taskflow/plugin/.mcp.json b/packages/codex-taskflow/plugin/.mcp.json index df583fbc..90d1f5ec 100644 --- a/packages/codex-taskflow/plugin/.mcp.json +++ b/packages/codex-taskflow/plugin/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "taskflow": { "command": "npx", - "args": ["-y", "-p", "codex-taskflow@0.3.0-beta.1.1", "codex-taskflow-mcp"], + "args": ["-y", "-p", "codex-taskflow@0.3.0-beta.2", "codex-taskflow-mcp"], "tool_timeout_sec": 1800 } } diff --git a/packages/codex-taskflow/test/mcp-server.test.ts b/packages/codex-taskflow/test/mcp-server.test.ts index 74788d76..453a8639 100644 --- a/packages/codex-taskflow/test/mcp-server.test.ts +++ b/packages/codex-taskflow/test/mcp-server.test.ts @@ -51,7 +51,7 @@ test("mcp: initialize returns the protocol version + serverInfo codex expects", assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-codex"); - assert.equal(res.result.serverInfo.version, "0.3.0-beta.1.1"); + assert.equal(res.result.serverInfo.version, "0.3.0-beta.2"); }); test("mcp: tools/list exposes the taskflow tools with schemas", async () => { diff --git a/packages/grok-taskflow/package.json b/packages/grok-taskflow/package.json index 87066b02..837a4f72 100644 --- a/packages/grok-taskflow/package.json +++ b/packages/grok-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "grok-taskflow", - "version": "0.3.0-beta.1.1", + "version": "0.3.0-beta.2", "description": "Run taskflow on Grok Build: the npm tarball provides the Grok subagent runner and MCP server; the plugin is distributed through the repository marketplace.", "keywords": [ "grok", diff --git a/packages/grok-taskflow/plugin/.grok-plugin/plugin.json b/packages/grok-taskflow/plugin/.grok-plugin/plugin.json index 2bcd7732..361f0ab2 100644 --- a/packages/grok-taskflow/plugin/.grok-plugin/plugin.json +++ b/packages/grok-taskflow/plugin/.grok-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "taskflow", - "version": "0.3.0-beta.1.1", + "version": "0.3.0-beta.2", "description": "Declarative, verifiable DAG orchestration for Grok Build subagents — fan-out, gates, loops, tournaments, approvals, resumable runs, and saveable commands, with intermediate transcripts kept out of your context.", "author": { "name": "heggria", diff --git a/packages/grok-taskflow/plugin/.mcp.json b/packages/grok-taskflow/plugin/.mcp.json index 40d3f24d..362339ae 100644 --- a/packages/grok-taskflow/plugin/.mcp.json +++ b/packages/grok-taskflow/plugin/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "taskflow": { "command": "npx", - "args": ["-y", "-p", "grok-taskflow@0.3.0-beta.1.1", "grok-taskflow-mcp"], + "args": ["-y", "-p", "grok-taskflow@0.3.0-beta.2", "grok-taskflow-mcp"], "tool_timeout_sec": 1800 } } diff --git a/packages/grok-taskflow/test/mcp-server.test.ts b/packages/grok-taskflow/test/mcp-server.test.ts index 391ff67b..9fb0c473 100644 --- a/packages/grok-taskflow/test/mcp-server.test.ts +++ b/packages/grok-taskflow/test/mcp-server.test.ts @@ -45,7 +45,7 @@ test("grok mcp: initialize returns the protocol version + serverInfo", async () assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-grok"); - assert.equal(res.result.serverInfo.version, "0.3.0-beta.1.1"); + assert.equal(res.result.serverInfo.version, "0.3.0-beta.2"); }); test("grok mcp: tools/list exposes the same taskflow tools as other hosts", async () => { diff --git a/packages/hermes-taskflow/package.json b/packages/hermes-taskflow/package.json index 06b57b23..1ebe2dd8 100644 --- a/packages/hermes-taskflow/package.json +++ b/packages/hermes-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "hermes-taskflow", - "version": "0.3.0-beta.1.1", + "version": "0.3.0-beta.2", "description": "Run taskflow on Hermes Agent: a Hermes subagent runner plus an MCP server (and a config scaffold) that exposes the taskflow_* tools to Hermes users.", "keywords": [ "hermes", diff --git a/packages/hermes-taskflow/plugin/hermes.config.snippet.yaml b/packages/hermes-taskflow/plugin/hermes.config.snippet.yaml index 236d4337..6ff8c046 100644 --- a/packages/hermes-taskflow/plugin/hermes.config.snippet.yaml +++ b/packages/hermes-taskflow/plugin/hermes.config.snippet.yaml @@ -14,7 +14,7 @@ taskflow: command: "npx" - args: ["-y", "-p", "hermes-taskflow@0.3.0-beta.1.1", "hermes-taskflow-mcp"] + args: ["-y", "-p", "hermes-taskflow@0.3.0-beta.2", "hermes-taskflow-mcp"] env: # Uncomment for mutating agent phases (terminal / file write). # Leave unset for verify/plan/script-only flows. diff --git a/packages/hermes-taskflow/test/mcp-server.test.ts b/packages/hermes-taskflow/test/mcp-server.test.ts index fc940da8..81bae677 100644 --- a/packages/hermes-taskflow/test/mcp-server.test.ts +++ b/packages/hermes-taskflow/test/mcp-server.test.ts @@ -38,7 +38,7 @@ test("hermes mcp: initialize returns the protocol version + serverInfo", async ( assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-hermes"); - assert.equal(res.result.serverInfo.version, "0.3.0-beta.1.1"); + assert.equal(res.result.serverInfo.version, "0.3.0-beta.2"); }); test("hermes mcp: tools/list exposes the taskflow tools", async () => { diff --git a/packages/opencode-taskflow/package.json b/packages/opencode-taskflow/package.json index 7a79c549..dc58b44a 100644 --- a/packages/opencode-taskflow/package.json +++ b/packages/opencode-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "opencode-taskflow", - "version": "0.3.0-beta.1.1", + "version": "0.3.0-beta.2", "description": "Run taskflow on OpenCode: an OpenCode subagent runner plus an MCP server (and an opencode.json config scaffold) that exposes the taskflow_* tools to OpenCode users.", "keywords": [ "opencode", diff --git a/packages/opencode-taskflow/plugin/opencode.json b/packages/opencode-taskflow/plugin/opencode.json index 1170b89b..ee3bee9a 100644 --- a/packages/opencode-taskflow/plugin/opencode.json +++ b/packages/opencode-taskflow/plugin/opencode.json @@ -3,7 +3,7 @@ "mcp": { "taskflow": { "type": "local", - "command": ["npx", "-y", "-p", "opencode-taskflow@0.3.0-beta.1.1", "opencode-taskflow-mcp"], + "command": ["npx", "-y", "-p", "opencode-taskflow@0.3.0-beta.2", "opencode-taskflow-mcp"], "enabled": true } }, diff --git a/packages/opencode-taskflow/test/mcp-server.test.ts b/packages/opencode-taskflow/test/mcp-server.test.ts index dd7de8bb..5b01b5d3 100644 --- a/packages/opencode-taskflow/test/mcp-server.test.ts +++ b/packages/opencode-taskflow/test/mcp-server.test.ts @@ -45,7 +45,7 @@ test("opencode mcp: initialize returns the protocol version + serverInfo", async assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-opencode"); - assert.equal(res.result.serverInfo.version, "0.3.0-beta.1.1"); + assert.equal(res.result.serverInfo.version, "0.3.0-beta.2"); }); test("opencode mcp: tools/list exposes the taskflow tools", async () => { diff --git a/packages/pi-taskflow/package.json b/packages/pi-taskflow/package.json index 29c0bd6b..117b4ac4 100644 --- a/packages/pi-taskflow/package.json +++ b/packages/pi-taskflow/package.json @@ -1,7 +1,7 @@ { "name": "pi-taskflow", - "version": "0.3.0-beta.1.1", - "description": "A declarative, verifiable graph of task nodes for the Pi coding agent — statically verified before it runs, with dynamic fan-out, gates, isolated subagent context, resumable runs, and saveable commands.", + "version": "0.3.0-beta.2", + "description": "A declarative, verifiable graph of task nodes for the Pi coding agent \u2014 statically verified before it runs, with dynamic fan-out, gates, isolated subagent context, resumable runs, and saveable commands.", "keywords": [ "pi-package", "pi", diff --git a/packages/taskflow-control/package.json b/packages/taskflow-control/package.json index cb70de2a..7e75ed21 100644 --- a/packages/taskflow-control/package.json +++ b/packages/taskflow-control/package.json @@ -1,89 +1,89 @@ { - "name": "taskflow-control", - "version": "0.3.0", - "description": "0.3-C control plane core: the frozen TypeBox wire contracts (P1-P16 + wire-freeze), the ControlHost daemon/supervisor/standalone contracts, user singleton endpoint/fencing, hello-before-RPC negotiation, and the Trusted Effects (taskflow-core resources) provider as the only execution authority.", - "keywords": [ - "taskflow", - "control-plane", - "control-host", - "wire", - "typebox", - "orchestration", - "trusted-effects" - ], - "license": "MIT", - "author": "heggria ", - "homepage": "https://github.com/heggria/taskflow#readme", - "repository": { - "type": "git", - "url": "git+https://github.com/heggria/taskflow.git", - "directory": "packages/taskflow-control" - }, - "bugs": { - "url": "https://github.com/heggria/taskflow/issues" - }, - "type": "module", - "engines": { - "node": ">=22.19.0" - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "development": "./src/index.ts", - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./schema": { - "development": "./src/schema/index.ts", - "types": "./dist/schema/index.d.ts", - "default": "./dist/schema/index.js" - }, - "./control-host": { - "development": "./src/control-host.ts", - "types": "./dist/control-host.d.ts", - "default": "./dist/control-host.js" - }, - "./*": { - "development": "./src/*.ts", - "types": "./dist/*.d.ts", - "default": "./dist/*.js" - } - }, - "files": [ - "dist", - "README.md" - ], - "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.build.json", - "prepublishOnly": "npm run build" - }, - "publishConfig": { - "access": "public", - "tag": "next", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./schema": { - "types": "./dist/schema/index.d.ts", - "default": "./dist/schema/index.js" - }, - "./control-host": { - "types": "./dist/control-host.d.ts", - "default": "./dist/control-host.js" - }, - "./*": { - "types": "./dist/*.d.ts", - "default": "./dist/*.js" - } - } - }, - "dependencies": { - "taskflow-core": "workspace:*" - }, - "peerDependencies": { - "typebox": "*" - } + "name": "taskflow-control", + "version": "0.3.0-beta.2", + "description": "0.3-C control plane core (proposed wire): ControlHost daemon/supervisor/standalone contracts, Unix UDS singleton attach, files-only project ControlStore, hello-before-RPC negotiation, and the Trusted Effects (taskflow-core resources) provider as the only execution authority. TypeBox contracts and wire-freeze.md remain PROPOSED — not frozen.", + "keywords": [ + "taskflow", + "control-plane", + "control-host", + "wire", + "typebox", + "orchestration", + "trusted-effects" + ], + "license": "MIT", + "author": "heggria ", + "homepage": "https://github.com/heggria/taskflow#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/heggria/taskflow.git", + "directory": "packages/taskflow-control" + }, + "bugs": { + "url": "https://github.com/heggria/taskflow/issues" + }, + "type": "module", + "engines": { + "node": ">=22.19.0" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "development": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./schema": { + "development": "./src/schema/index.ts", + "types": "./dist/schema/index.d.ts", + "default": "./dist/schema/index.js" + }, + "./control-host": { + "development": "./src/control-host.ts", + "types": "./dist/control-host.d.ts", + "default": "./dist/control-host.js" + }, + "./*": { + "development": "./src/*.ts", + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "prepublishOnly": "npm run build" + }, + "publishConfig": { + "access": "public", + "tag": "next", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./schema": { + "types": "./dist/schema/index.d.ts", + "default": "./dist/schema/index.js" + }, + "./control-host": { + "types": "./dist/control-host.d.ts", + "default": "./dist/control-host.js" + }, + "./*": { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + } + } + }, + "dependencies": { + "taskflow-core": "workspace:*" + }, + "peerDependencies": { + "typebox": "*" + } } diff --git a/packages/taskflow-control/src/index.ts b/packages/taskflow-control/src/index.ts index 17684550..b9cae8a4 100644 --- a/packages/taskflow-control/src/index.ts +++ b/packages/taskflow-control/src/index.ts @@ -1,10 +1,10 @@ /** * taskflow-control — 0.3-C control plane core. * - * Exposes the frozen TypeBox wire contracts (`./schema`), the ControlHost - * daemon/supervisor/standalone contracts, the user singleton lock/endpoint/ - * fencing layer, hello-before-RPC negotiation, and the TE-backed execution - * provider (the only legal execution authority in 0.3-C). + * Exposes the proposed TypeBox wire contracts (`./schema` — not frozen), + * ControlHost daemon/supervisor/standalone, Unix UDS singleton attach, + * files-only project ControlStore, hello-before-RPC negotiation, and the + * TE-backed execution provider (the only legal execution authority in 0.3-C). * * Depends only on taskflow-core (read-only TE schema/helper imports) and * typebox. diff --git a/packages/taskflow-core/package.json b/packages/taskflow-core/package.json index 3cc68a02..1d932661 100644 --- a/packages/taskflow-core/package.json +++ b/packages/taskflow-core/package.json @@ -1,7 +1,7 @@ { "name": "taskflow-core", - "version": "0.3.0-beta.1.1", - "description": "Host-neutral engine for declarative, verifiable task-DAG orchestration — the runtime, DSL, cache, and verification shared by pi-taskflow, codex-taskflow, claude-taskflow, opencode-taskflow, grok-taskflow, and hermes-taskflow.", + "version": "0.3.0-beta.2", + "description": "Host-neutral engine for declarative, verifiable task-DAG orchestration \u2014 the runtime, DSL, cache, and verification shared by pi-taskflow, codex-taskflow, claude-taskflow, opencode-taskflow, grok-taskflow, and hermes-taskflow.", "keywords": [ "taskflow", "dag", diff --git a/packages/taskflow-dsl/package.json b/packages/taskflow-dsl/package.json index a913201e..d0447747 100644 --- a/packages/taskflow-dsl/package.json +++ b/packages/taskflow-dsl/package.json @@ -1,6 +1,6 @@ { "name": "taskflow-dsl", - "version": "0.3.0-beta.1.1", + "version": "0.3.0-beta.2", "description": "Compile-time TypeScript DSL frontend for taskflow: erase .tf.ts runes to Taskflow JSON, then FlowIR via taskflow-core.", "keywords": [ "taskflow", diff --git a/packages/taskflow-hosts/package.json b/packages/taskflow-hosts/package.json index 85c53382..120cca1c 100644 --- a/packages/taskflow-hosts/package.json +++ b/packages/taskflow-hosts/package.json @@ -1,7 +1,7 @@ { "name": "taskflow-hosts", - "version": "0.3.0-beta.1.1", - "description": "Shared host-runner collection for taskflow — the codex, claude, opencode, grok, and hermes SubagentRunner implementations + their argv builders and event-stream parsers. The per-host MCP servers, plugin scaffolds, and bins live in codex-taskflow / claude-taskflow / opencode-taskflow / grok-taskflow / hermes-taskflow; this package holds just the runners so a new host can be added in one place.", + "version": "0.3.0-beta.2", + "description": "Shared host-runner collection for taskflow \u2014 the codex, claude, opencode, grok, and hermes SubagentRunner implementations + their argv builders and event-stream parsers. The per-host MCP servers, plugin scaffolds, and bins live in codex-taskflow / claude-taskflow / opencode-taskflow / grok-taskflow / hermes-taskflow; this package holds just the runners so a new host can be added in one place.", "homepage": "https://github.com/heggria/taskflow#readme", "author": "heggria ", "bugs": { diff --git a/packages/taskflow-mcp-core/package.json b/packages/taskflow-mcp-core/package.json index ef5774cd..703b0a9c 100644 --- a/packages/taskflow-mcp-core/package.json +++ b/packages/taskflow-mcp-core/package.json @@ -1,7 +1,7 @@ { "name": "taskflow-mcp-core", - "version": "0.3.0-beta.1.1", - "description": "Host-neutral MCP server for taskflow: a dependency-free stdio JSON-RPC server exposing the taskflow_* tools, plus the DAG SVG/outline renderer. Shared by the codex/claude/opencode/grok/hermes adapters — depends only on taskflow-core.", + "version": "0.3.0-beta.2", + "description": "Host-neutral MCP server for taskflow: a dependency-free stdio JSON-RPC server exposing the taskflow_* tools, plus the DAG SVG/outline renderer. Shared by the codex/claude/opencode/grok/hermes adapters \u2014 depends only on taskflow-core.", "keywords": [ "taskflow", "mcp", diff --git a/website/app/[lang]/layout.tsx b/website/app/[lang]/layout.tsx index 62d653a5..37b5123e 100644 --- a/website/app/[lang]/layout.tsx +++ b/website/app/[lang]/layout.tsx @@ -10,16 +10,16 @@ export function generateStaticParams() { const site = { en: { - title: "taskflow 0.3.0-beta.1.1 — Trusted Effects beta", + title: "taskflow 0.3.0-beta.2 — Trusted Effects beta", brand: "taskflow", description: - "Declare coding-agent effects, verify typed paths, and commit admitted filesystem changes through one resource authority. 0.3.0-beta.1.1; beta channel and not GA.", + "Declare coding-agent effects, verify typed paths, and commit admitted filesystem changes through one resource authority. 0.3.0-beta.2; beta channel and not GA.", }, "zh-cn": { - title: "taskflow 0.3.0-beta.1.1 — Trusted Effects beta", + title: "taskflow 0.3.0-beta.2 — Trusted Effects beta", brand: "taskflow", description: - "声明 coding-agent effect,验证类型化路径,让已准入的文件修改经过唯一 resource authority。0.3.0-beta.1.1,beta channel,尚未 GA。", + "声明 coding-agent effect,验证类型化路径,让已准入的文件修改经过唯一 resource authority。0.3.0-beta.2,beta channel,尚未 GA。", }, } as const; diff --git a/website/app/[lang]/page.tsx b/website/app/[lang]/page.tsx index 979bfec0..61133396 100644 --- a/website/app/[lang]/page.tsx +++ b/website/app/[lang]/page.tsx @@ -25,14 +25,14 @@ const copy = { localeZh: "中文", }, hero: { - eyebrow: "taskflow 0.3.0-beta.1.1 · Trusted Effects beta", + eyebrow: "taskflow 0.3.0-beta.2 · Trusted Effects beta", title: [ "Declare the effect.", "Verify the path.", "Commit through one authority.", ], sub: "taskflow turns coding-agent work into a verifiable runtime: explicit graphs, typed effect declarations, isolated execution, resource-controlled filesystem commits, and ledger-backed explanations across six hosts.", - noteKicker: "0.3.0-beta.1.1 · beta channel · not GA", + noteKicker: "0.3.0-beta.2 · beta channel · not GA", noteBody: "An agent can propose content. For admitted declared targets, the resources transaction is the only finalizer. The ControlHost scaffold exists; stores, approvals, receipts, and WebUI remain future follow-on stages, not shipped GA claims.", micro: @@ -78,7 +78,7 @@ const copy = { ], }, install: { - label: "0.3.0-beta.1.1 host installs — select the beta channel.", + label: "0.3.0-beta.2 host installs — select the beta channel.", copy: "Copy", copied: "Copied", guide: "Guide", @@ -187,10 +187,10 @@ const copy = { localeZh: "中文", }, hero: { - eyebrow: "taskflow 0.3.0-beta.1.1 · Trusted Effects beta", + eyebrow: "taskflow 0.3.0-beta.2 · Trusted Effects beta", title: ["声明 effect。", "验证路径。", "让一个 authority 负责提交。"], sub: "taskflow 把 coding-agent 工作变成可验证的运行时:显式任务图、类型化 effect 声明、隔离执行、受 resources 控制的文件提交,以及覆盖六个宿主的 ledger-backed 解释。", - noteKicker: "0.3.0-beta.1.1 · beta channel · 尚未 GA", + noteKicker: "0.3.0-beta.2 · beta channel · 尚未 GA", noteBody: "智能体可以提出内容。对于已准入的已声明目标,resources transaction 是唯一最终提交者。ControlHost 目前是脚手架;store、审批、receipt 与 WebUI 仍是后续阶段,不是已交付的 GA 表面。", micro: "Resolve-only 不是 OS sandbox。未声明写入仍取决于宿主策略。", @@ -235,7 +235,7 @@ const copy = { ], }, install: { - label: "0.3.0-beta.1.1 宿主安装;请显式选择 beta channel。", + label: "0.3.0-beta.2 宿主安装;请显式选择 beta channel。", copy: "复制", copied: "已复制", guide: "指南", @@ -341,7 +341,7 @@ export default async function HomePage({ "@context": "https://schema.org", "@type": "SoftwareApplication", name: "taskflow", - softwareVersion: "0.3.0-beta.1.1", + softwareVersion: "0.3.0-beta.2", description: t.hero.sub, applicationCategory: "DeveloperApplication", operatingSystem: "Any", diff --git a/website/app/page.tsx b/website/app/page.tsx index 6967b4aa..821a16ba 100644 --- a/website/app/page.tsx +++ b/website/app/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; const title = "taskflow 0.3 — Trusted Effects for Coding Agents"; const description = - "Declare coding-agent effects, verify typed paths, and commit admitted filesystem changes through one resource authority. 0.3.0-beta.1.1; beta channel and not GA."; + "Declare coding-agent effects, verify typed paths, and commit admitted filesystem changes through one resource authority. 0.3.0-beta.2; beta channel and not GA."; const canonical = "https://heggria.github.io/taskflow/en/"; export const metadata: Metadata = { diff --git a/website/content/docs/en/compiler-runtime/typescript-dsl.mdx b/website/content/docs/en/compiler-runtime/typescript-dsl.mdx index cccefc8b..05d746fe 100644 --- a/website/content/docs/en/compiler-runtime/typescript-dsl.mdx +++ b/website/content/docs/en/compiler-runtime/typescript-dsl.mdx @@ -8,7 +8,7 @@ description: Compile-time .tf.ts authoring — erase runes to Taskflow JSON, the S4 adds a **compile-time** TypeScript frontend. You author `*.tf.ts` with **runes** (`agent`, `map`, `race`, …). A CLI erases them to ordinary Taskflow JSON. Hosts still run **JSON** via `taskflow_run` / `/tf run` — there is **no** interpret path and **no** host auto-build of `.tf.ts`. - **Package status.** `taskflow-dsl` lives in the monorepo (`packages/taskflow-dsl`). It is **not** required for JSON authors. Package manifests target `0.3.0-beta.1.1` on npm's `beta` channel; install with `npm install taskflow-dsl@beta`, or use a workspace / local path from this monorepo. + **Package status.** `taskflow-dsl` lives in the monorepo (`packages/taskflow-dsl`). It is **not** required for JSON authors. Package manifests target `0.3.0-beta.2` on npm's `beta` channel; install with `npm install taskflow-dsl@beta`, or use a workspace / local path from this monorepo. ## Workflow diff --git a/website/content/docs/en/getting-started.mdx b/website/content/docs/en/getting-started.mdx index 34434525..55461db8 100644 --- a/website/content/docs/en/getting-started.mdx +++ b/website/content/docs/en/getting-started.mdx @@ -6,7 +6,7 @@ description: Run your first taskflow in under five minutes. taskflow lets you describe multi-step agent work as a declarative graph. Instead of writing a script that calls subagents one by one, you declare the nodes and edges — and the runtime handles fan-out, retries, caching, and resume. - This guide covers the stable 0.2.x host installation path. For **0.3.0-beta.1.1**, start with the [Trusted Effects overview](/en/docs/trusted-effects) and select npm's `beta` channel; beta is not GA. + This guide covers the stable 0.2.x host installation path. For **0.3.0-beta.2**, start with the [Trusted Effects overview](/en/docs/trusted-effects) and select npm's `beta` channel; beta is not GA. The fastest way to see it is to run something. diff --git a/website/content/docs/en/index.mdx b/website/content/docs/en/index.mdx index 7da12cc6..619aa7b6 100644 --- a/website/content/docs/en/index.mdx +++ b/website/content/docs/en/index.mdx @@ -3,7 +3,7 @@ title: taskflow 0.3 Documentation description: "Trusted Effects for coding-agent workflows: declare effects, verify typed paths, and commit admitted filesystem changes through one resource authority." --- -> **0.3.0-beta.1.1 — beta channel candidate, not GA.** This page describes the Trusted Effects MVP prepared for the beta release. The 0.3-C Control Plane remains a follow-on candidate track. +> **0.3.0-beta.2 — beta channel candidate, not GA.** Trusted Effects MVP plus a Control Plane slice (S2 Unix UDS + S3-min store). Wire contracts remain PROPOSED. `#137` / `#95` are not in this slice. taskflow is a declarative runtime for coding-agent workflows. It turns a graph into a verifiable execution contract, runs phases in isolation, and keeps intermediate transcripts out of the host conversation. The 0.3 candidate adds **Trusted Effects**: a typed declaration and resource-controlled commit path for admitted filesystem effects. diff --git a/website/content/docs/en/trusted-effects.mdx b/website/content/docs/en/trusted-effects.mdx index f8a3d924..8658c14f 100644 --- a/website/content/docs/en/trusted-effects.mdx +++ b/website/content/docs/en/trusted-effects.mdx @@ -2,7 +2,7 @@ title: Trusted Effects --- -> **0.3.0-beta.1.1 — beta channel candidate, not GA.** This page describes the Trusted Effects MVP prepared for the beta release; npm publication is still a release gate. +> **0.3.0-beta.2 — beta channel candidate, not GA.** This page describes the Trusted Effects MVP prepared for the beta release; npm publication is still a release gate. Trusted Effects makes a phase's side effects explicit. The model may propose content, but for an admitted declared filesystem target, the **resources transaction is the only finalizer**. diff --git a/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx b/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx index add22d31..892c687b 100644 --- a/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx +++ b/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx @@ -8,7 +8,7 @@ description: 编译期 .tf.ts 写法 —— rune erase 成 Taskflow JSON,再 S4 增加**编译期** TypeScript 前端:用 rune(`agent`、`map`、`race`…)写 `*.tf.ts`,CLI erase 成普通 Taskflow JSON。宿主仍通过 `taskflow_run` / `/tf run` 跑 **JSON**——**没有**解释执行路径,也**没有**宿主对 `.tf.ts` 的自动 build。 - **Package status.** `taskflow-dsl` lives in the monorepo (`packages/taskflow-dsl`). It is **not** required for JSON authors. Package manifests target `0.3.0-beta.1.1` on npm's `beta` channel; after publication, install with `npm install taskflow-dsl@beta`, or use a workspace / local path from this monorepo. + **Package status.** `taskflow-dsl` lives in the monorepo (`packages/taskflow-dsl`). It is **not** required for JSON authors. Package manifests target `0.3.0-beta.2` on npm's `beta` channel; after publication, install with `npm install taskflow-dsl@beta`, or use a workspace / local path from this monorepo. ## 工作流 diff --git a/website/content/docs/zh-cn/getting-started.mdx b/website/content/docs/zh-cn/getting-started.mdx index 55f8de63..19e66919 100644 --- a/website/content/docs/zh-cn/getting-started.mdx +++ b/website/content/docs/zh-cn/getting-started.mdx @@ -6,7 +6,7 @@ description: 五分钟内运行你的第一个 taskflow。 taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不需要写一个一个调用子代理的脚本,只需声明节点和边——运行时会替你处理 fan-out、重试、缓存和续跑。 - 本指南介绍稳定的 0.2.x 宿主安装路径。对于 **0.3.0-beta.1.1**,请从 [Trusted Effects 总览](/zh-cn/docs/trusted-effects) 开始,并显式选择 npm 的 `beta` channel;beta 尚未 GA。 + 本指南介绍稳定的 0.2.x 宿主安装路径。对于 **0.3.0-beta.2**,请从 [Trusted Effects 总览](/zh-cn/docs/trusted-effects) 开始,并显式选择 npm 的 `beta` channel;beta 尚未 GA。 要最快地感受它,先跑一个看看。 diff --git a/website/content/docs/zh-cn/index.mdx b/website/content/docs/zh-cn/index.mdx index 97676444..a9992912 100644 --- a/website/content/docs/zh-cn/index.mdx +++ b/website/content/docs/zh-cn/index.mdx @@ -3,7 +3,7 @@ title: taskflow 0.3 文档 description: "面向 coding-agent 工作流的 Trusted Effects:声明 effect,验证类型化路径,让已准入的文件修改经过唯一 resource authority。" --- -> **0.3.0-beta.1.1——beta channel candidate,尚未 GA。** 这个页面描述为 beta 发布准备的 Trusted Effects MVP;0.3-C Control Plane 仍是后续 candidate 轨道。 +> **0.3.0-beta.2——beta channel candidate,尚未 GA。** Trusted Effects MVP 加上 Control Plane 切片(S2 Unix UDS + S3 最小 store)。Wire 合同仍为 PROPOSED。本切片不含 `#137` / `#95`。 taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图变成可验证的执行合同,让阶段隔离运行,并把中间 transcript 留在宿主对话之外。0.3 candidate 增加了 **Trusted Effects**:为已准入的文件 effect 提供类型化声明与受 resources 控制的提交路径。 diff --git a/website/content/docs/zh-cn/trusted-effects.mdx b/website/content/docs/zh-cn/trusted-effects.mdx index 128756ef..81a822d3 100644 --- a/website/content/docs/zh-cn/trusted-effects.mdx +++ b/website/content/docs/zh-cn/trusted-effects.mdx @@ -2,7 +2,7 @@ title: Trusted Effects --- -> **0.3.0-beta.1.1——beta channel candidate,尚未 GA。** 本页描述为 beta 发布准备的 Trusted Effects MVP;npm 发布仍是发版闸门。 +> **0.3.0-beta.2——beta channel candidate,尚未 GA。** 本页描述为 beta 发布准备的 Trusted Effects MVP;npm 发布仍是发版闸门。 Trusted Effects 把阶段的副作用写进合同。模型可以提出内容,但对于已准入的已声明文件目标,**resources transaction 是唯一最终提交者**。 From 8f7909b3cd61ece0b43beef86795fe68957763fd Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 18 Aug 2026 22:18:24 +0800 Subject: [PATCH 05/30] docs: refresh 0.3 scoreboard after beta.2 Draft PR #142 CI beta.2 candidate (rc/0.3.0-beta.2 @ bee8586) exact-SHA CI GREEN (run 32144803693, 10/10 jobs) recorded as candidate evidence only, not a release. L5 PASS for published v0.3.0-beta.1 (tag 561cfd3, publish run 31621938101); v0.3.0-beta.2 NOT tagged/published. L6 stays FAIL. L4 live-Codex E2E @ 4524d2d marked historical (beta.1-era, not beta.2 evidence). Docs-only; no public copy (RELEASE.md does not reference the scoreboard). refs plan 8.1 / A9 --- docs/internal/0.3.0-ga-scoreboard.md | 42 +++++++++++++++++----------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/docs/internal/0.3.0-ga-scoreboard.md b/docs/internal/0.3.0-ga-scoreboard.md index 141f62c8..12678337 100644 --- a/docs/internal/0.3.0-ga-scoreboard.md +++ b/docs/internal/0.3.0-ga-scoreboard.md @@ -2,12 +2,12 @@ > Living ledger. **Do not claim GA** unless L6 is PASS with human tag evidence. > -> **beta.2 (2026-08-18):** `rc/0.3.0-beta.2` has S2 UDS + S3-min store + version freeze `0.3.0-beta.2`. L5 for `v0.3.0-beta.2` is still FAIL (no tag). Published beta remains `v0.3.0-beta.1.1`. `#137` / `#95` out of scope. +> **beta.2 (2026-08-18):** `rc/0.3.0-beta.2` tip `bee8586` — Draft PR **#142** exact-SHA CI GREEN (run `32144803693`, 10/10 jobs) as **candidate** evidence only. **`v0.3.0-beta.2` is NOT tagged or published.** Published 0.3 beta remains **`v0.3.0-beta.1`** (tag `561cfd3`, publish run `31621938101`). `#137` / `#95` out of scope. -**Last updated:** 2026-08-11 (tip `0551f62`; residual recheck + S-H2 closed) -**Branch:** `rc/0.3.0-trusted-effects` @ `0551f62` -**Code/product tip:** includes TE MVP + store harden `96a32e8` + ADV High harden `cbb4131` + S-H2 static composition `580daa0` (+ docs) -**Current evidence:** exact-SHA remote CI **GREEN** — GitHub Actions run [31466355338](https://github.com/heggria/taskflow/actions/runs/31466355338) on Draft PR #122 head `0551f62` (full matrix PASS incl. windows process-supervisor). Prior green milestones: `cbb4131` run 31463823993; Windows harness `e7c5e31` era. Local residual focused suites **110/110** (adv-high-fix, resource-file-transaction, store, verifier-discover, verify-effects, effects-composition-cache). RC pipeline logs at `/tmp/taskflow-03-rc-final/` (evidence SHA `6071fb2`) still back audit --prod clean + built Codex MCP 16/16 fixture. Live Codex CLI E2E **PASS** on tip `4524d2d` (worker-mac, 2026-08-11) — evidence `docs/internal/evidence/e2e-codex-live-4524d2d.md`. L5/L6 remain FAIL (no tag/publish). +**Last updated:** 2026-08-18 (tip `bee8586`; beta.2 Draft PR #142 CI + L5 for `v0.3.0-beta.1`) +**Branch:** `rc/0.3.0-beta.2` @ `bee8586` +**Code/product tip:** TE MVP + beta.2 Control Plane slice (S2 Unix UDS ControlHost, S3-min files-only project ControlStore, version freeze `0.3.0-beta.2`) +**Current evidence:** beta.2 **candidate** exact-SHA remote CI **GREEN** — GitHub Actions run [32144803693](https://github.com/heggria/taskflow/actions/runs/32144803693) on Draft PR #142 head `bee8586` (10/10 jobs: unit node 22/24, e2e codex MCP network-free, packed consumer 10 pkgs, process supervisor ubuntu/macos/windows incl. UDS, build, CodeQL, website). **This is candidate evidence, not a release.** Published `v0.3.0-beta.1` (tag `561cfd3`, publish run `31621938101`) → **L5 PASS (beta.1)**. Prior green milestone: PR #122 head `0551f62` run `31466355338` (beta.1 candidate). Live Codex CLI E2E @ `4524d2d` (worker-mac, 2026-08-11) is **historical** beta.1-era evidence — **not** beta.2 evidence. **L6 still FAIL.** --- @@ -15,15 +15,15 @@ | Level | Status | Evidence | Notes | |-------|--------|----------|-------| -| L1 local | **PASS** | tip `0551f62`: CI node 22/24 + residual focused 110/110; audit --prod clean at RC log `6071fb2` | typecheck/suite via CI on tip | -| L2 contract | **PASS** | tip `0551f62`: CI full unit + build + pack + MCP e2e; residual focused green | exact-SHA CI GREEN run 31466355338 | +| L1 local | **PASS** | beta.2 candidate `bee8586`: CI node 22/24 (run 32144803693) | typecheck/suite via CI on candidate tip | +| L2 contract | **PASS** | beta.2 candidate `bee8586`: CI full unit + build + pack (10 pkgs) + MCP e2e | exact-SHA CI GREEN run 32144803693 (PR #142) | | L3 browser/electron | **N/A** | — | | -| L4 real-environment | **PASS (built-MCP fixture + live Codex CLI @ 4524d2d)** | CI built-MCP e2e on tip; live `test:e2e-codex` PASS on worker-mac @ `4524d2d` (A→B→C + TE fs.write + ledger why) — see `docs/internal/evidence/e2e-codex-live-4524d2d.md` | both fixture and live legs evidenced on tip family | -| L5 released | **FAIL** | no `v0.3.0-beta.1` tag/publish | human gate | -| L6 ga | **FAIL** | L5 missing | **NOT GA** | +| L4 real-environment | **PASS (built-MCP fixture)** | CI built-MCP e2e on beta.2 candidate tip; live Codex CLI E2E @ `4524d2d` is **historical** (beta.1-era, worker-mac 2026-08-11) — see `docs/internal/evidence/e2e-codex-live-4524d2d.md` | both fixture legs evidenced on candidate family; live-Codex leg not beta.2 evidence | +| L5 released | **PASS (for `v0.3.0-beta.1`)** | tag `561cfd3`, publish run `31621938101` — `v0.3.0-beta.1` on npm `beta` dist-tag. **`v0.3.0-beta.2` NOT published** | human gate | +| L6 ga | **FAIL** | beta.1 released but not GA; beta.2 not tagged/published | **NOT GA** | -**Highest proven for the current code candidate:** **L4 real-environment (built-MCP fixture)** with exact-SHA remote CI green on tip `f5284da` — live-Codex-CLI leg closed on tip `4524d2d`. ADV High + S-H2 closed; beta release and GA remain human gates. -**User-facing claim allowed:** *Trusted Effects beta.1 tip `f5284da` is green on exact-SHA CI (run 31613909075) with TE + security hardenings; beta release and GA remain open.* +**Highest proven for the current code candidate (`rc/0.3.0-beta.2`):** **L4 real-environment (built-MCP fixture)** with exact-SHA remote CI green on candidate `bee8586` (run 32144803693, DR #142). Live-Codex-CLI is a **historical** beta.1-era leg (closed on `4524d2d`) — **not** beta.2 evidence. L5 **PASS** for published `v0.3.0-beta.1`; beta.2 and 0.3 GA remain human gates. +**User-facing claim allowed:** *Trusted Effects `v0.3.0-beta.1` is published on npm `beta` (tag `561cfd3`); the beta.2 candidate `bee8586` is green on exact-SHA CI (run 32144803693, Draft PR #142) but **not released**; 0.3 GA remains open.* **G5 status:** **contract pass for admitted declared targets** — PathRef escape, direct-write restore, crash recovery, multi-file rollback, terminal cleanup failure, activation/journal double-fault lease release, before-image GC, gate fast path, cross-run/dynamic-spawn cache bypass, and event-kernel fallback are adversarially covered. Resolve-only execution does not prevent or restore writes to undeclared paths; that requires a future FileBroker/native sandbox and is not a 0.3 MVP claim. --- @@ -54,6 +54,7 @@ | Post-review hardening | composition/cache and dynamic-spawn taint; nested information flow; malformed `effects`; terminal cleanup/lease safety; terminal/orphan before-image GC; local contract PASS on `60126954` | | 0.3 RC pipeline | full RC pipeline green (logs `/tmp/taskflow-03-rc-final/`, built at evidence SHA `6071fb2`): typecheck, 2202-test suite 2198/0/4, build, test:pack 9 pkgs, built Codex MCP 16/16, audit --prod clean; store .pi discovery hardened and re-verified at exact SHA `96a32e8`; exact-SHA remote CI open | | Exact-SHA CI green | PR #122 head `e7c5e31`: GitHub Actions run 31460133496 full matrix PASS (test node 22/24, e2e codex MCP network-free incl. built-dist full, build dist, packed consumer 9 pkgs + CharterArc, website export, process supervisor ubuntu/macos/windows, CodeQL); Windows test-harness portability fixed (`pathToFileURL`/`fileURLToPath`); local `e7c5e31` typecheck + 2198/0/4 + store 69/69 + detached 8/8 + store-extended 23/23 (t_6e11e70b) | +| beta.2 candidate CI (2026-08-18) | Draft PR #142 head `bee8586` (rc/0.3.0-beta.2): GitHub Actions run 32144803693 (CI #434) full matrix PASS (10/10: test node 22/24, e2e codex MCP network-free, packed consumer 10 pkgs, process supervisor ubuntu/macos/windows incl. UDS attach, build dist, CodeQL, website export). **Candidate evidence only — `v0.3.0-beta.2` NOT tagged/published; L6 still FAIL.** | ## Explicitly deferred non-blocker @@ -64,8 +65,15 @@ The append-only resource journal still lacks a sharded/compacted projection desi ## Commands last green ```text -# Exact-SHA remote CI (GitHub Actions run 31460133496, PR #122 head e7c5e31): -# https://github.com/heggria/taskflow/actions/runs/31460133496 +# BETA.2 CANDIDATE (2026-08-18) — Draft PR #142 head bee8586: exact-SHA CI GREEN, +# GitHub Actions run 32144803693 (CI #434), 10/10 jobs: +# https://github.com/heggria/taskflow/actions/runs/32144803693 +# → test node 22/24, e2e codex MCP network-free, packed consumer 10 pkgs, +# process supervisor ubuntu/macos/windows (incl. Unix UDS attach, not skipped +# onto Windows), build dist, CodeQL, website export. +# NOT a release: v0.3.0-beta.2 not tagged/published. Published beta = +# v0.3.0-beta.1 (tag 561cfd3, publish run 31621938101). L6 still FAIL. +# (The verbose matrix below is HISTORICAL beta.1-era evidence at e7c5e31.) # → full matrix PASS: test node 22/24 (typecheck + pnpm test), e2e codex MCP network-free # (build + test:e2e-codex-mcp + test:e2e-codex-mcp-full + claude/opencode/grok MCP stdio), # build dist, packed consumer 9 pkgs + CharterArc, website export, process supervisor @@ -94,8 +102,10 @@ pnpm audit --prod # Focused suites at exact SHA e7c5e31 (t_6e11e70b): store 69/69, detached 8/8, # store-extended 23/23 (store.test.ts also runs in CI process-supervisor on 3 OSes) -# LIVE RERUN on 4524d2d (worker-mac): +# LIVE RERUN on 4524d2d (HISTORICAL, beta.1-era — not beta.2 evidence): # pnpm run test:e2e-codex → PASS A→B→C + TE fs.write + ledger (docs/internal/evidence/e2e-codex-live-4524d2d.md) # pnpm audit --prod at exact SHA — RC pipeline log at 6071fb2 (no prod-code change since) -# Release/GA: no tag, no publish — L5/L6 FAIL until human action. +# L5 = PASS for published v0.3.0-beta.1 (tag 561cfd3, run 31621938101). +# beta.2 = candidate-only (RUN 32144803693 GREEN) — NOT tagged/published. +# 0.3 GA (L6): FAIL until a human tags/releases v0.3.0. ``` From 4bf3b50a1cd16b95e3985601c8e902b7f173d2f8 Mon Sep 17 00:00:00 2001 From: Heggria Date: Wed, 30 Sep 2026 18:57:21 +0800 Subject: [PATCH 06/30] fix(pi): expose owning Taskflow run identity to children Pass the persisted owning run ID through imperative and event-kernel runner options, then export it as PI_SUBAGENT_RUN_ID. Preserve direct-call behavior and downstream-owned environment variables. Add regression coverage for all agent-call families and nested flows. Fixes #172. --- packages/pi-taskflow/src/runner.ts | 3 + .../pi-taskflow/test/run-identity.test.ts | 67 ++++++++++++++ packages/taskflow-core/src/exec/step-kinds.ts | 1 + packages/taskflow-core/src/exec/step.ts | 1 + .../taskflow-core/src/host/runner-types.ts | 3 + packages/taskflow-core/src/runtime.ts | 1 + .../taskflow-core/test/run-identity.test.ts | 91 +++++++++++++++++++ 7 files changed, 167 insertions(+) create mode 100644 packages/pi-taskflow/test/run-identity.test.ts create mode 100644 packages/taskflow-core/test/run-identity.test.ts diff --git a/packages/pi-taskflow/src/runner.ts b/packages/pi-taskflow/src/runner.ts index dc0d1cf5..d5544a20 100644 --- a/packages/pi-taskflow/src/runner.ts +++ b/packages/pi-taskflow/src/runner.ts @@ -455,6 +455,9 @@ export async function runAgentTask( args.push(`Task: ${task}`); const invocation = getPiInvocation(args); const childEnv = { ...process.env, ...ctxEnv }; + // The owning run wins over an inherited parent marker. Direct callers + // without an identity keep the existing environment behavior. + if (opts.runId !== undefined) childEnv.PI_SUBAGENT_RUN_ID = opts.runId; // A child agent is not a host principal. Never let it inherit the // operator's resolve-only bridge opt-in and mint equivalent authority. delete childEnv[CWD_BRIDGE_MODE_ENV]; diff --git a/packages/pi-taskflow/test/run-identity.test.ts b/packages/pi-taskflow/test/run-identity.test.ts new file mode 100644 index 00000000..4d4c4cfa --- /dev/null +++ b/packages/pi-taskflow/test/run-identity.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { test } from "node:test"; +import type { AgentConfig } from "taskflow-core"; +import { createPiSubagentRunner, runAgentTask } from "../src/runner.ts"; + +test("Pi run identity: owning run overrides ambient identity and omitted identity preserves direct-call behavior", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tf-pi-run-id-")); + const capture = path.join(dir, "capture.json"); + const probe = path.join(dir, "probe.mjs"); + fs.writeFileSync(probe, `#!${process.execPath}\n` + + `import { writeFileSync } from "node:fs";\n` + + `writeFileSync(${JSON.stringify(capture)}, JSON.stringify({\n` + + ` runId: process.env.PI_SUBAGENT_RUN_ID ?? null,\n` + + ` devloopsRunId: process.env.DEVLOOPS_RUN_ID ?? null,\n` + + ` ctxDir: process.env.PI_TASKFLOW_CTX_DIR ?? null,\n` + + ` nodeId: process.env.PI_TASKFLOW_NODE_ID ?? null,\n` + + ` argv: process.argv.slice(2),\n` + + `}));\n` + + `const emit = x => process.stdout.write(JSON.stringify(x) + "\\n");\n` + + `emit({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "ok" }], stopReason: "stop" } });\n` + + `emit({ type: "agent_end", willRetry: false });\n` + + `emit({ type: "agent_settled" });\n`, { mode: 0o755 }); + const envKeys = ["PI_TASKFLOW_PI_BIN", "PI_SUBAGENT_RUN_ID", "DEVLOOPS_RUN_ID", "PI_TASKFLOW_CTX_DIR", "PI_TASKFLOW_NODE_ID"]; + const previous = new Map(envKeys.map((key) => [key, process.env[key]])); + const agents: AgentConfig[] = [{ name: "probe", description: "probe", systemPrompt: "", source: "user", filePath: "" }]; + const readCapture = () => JSON.parse(fs.readFileSync(capture, "utf8")) as { + runId: string | null; devloopsRunId: string | null; ctxDir: string | null; nodeId: string | null; argv: string[]; + }; + try { + for (const key of envKeys) delete process.env[key]; + process.env.PI_TASKFLOW_PI_BIN = probe; + assert.equal((await runAgentTask(dir, agents, "probe", "direct", {})).exitCode, 0); + assert.equal(readCapture().runId, null, "No identity is fabricated for direct callers"); + + assert.equal((await runAgentTask(dir, agents, "probe", "owned", { runId: "taskflow-owned" })).exitCode, 0); + assert.equal(readCapture().runId, "taskflow-owned"); + assert.equal(process.env.PI_SUBAGENT_RUN_ID, undefined, "Parent environment must not be mutated"); + assert.equal(readCapture().devloopsRunId, null, "No downstream-specific marker is fabricated"); + assert.ok(readCapture().argv.includes("--no-extensions"), "Run identity does not weaken child isolation"); + + process.env.PI_SUBAGENT_RUN_ID = "parent-run"; + process.env.DEVLOOPS_RUN_ID = "downstream-owned"; + assert.equal((await runAgentTask(dir, agents, "probe", "inherited", {})).exitCode, 0); + assert.equal(readCapture().runId, "parent-run", "Omitted identity preserves ambient behavior"); + + const result = await createPiSubagentRunner().runTask(dir, agents, "probe", "owned-with-context", { + runId: "current-taskflow-run", ctxDir: dir, nodeId: "work", + }); + assert.equal(result.exitCode, 0); + const child = readCapture(); + assert.equal(child.runId, "current-taskflow-run", "Owning identity replaces stale inherited Pi marker"); + assert.equal(child.devloopsRunId, "downstream-owned", "Dev-loops retains ownership and precedence of its neutral marker"); + assert.equal(child.ctxDir, dir); + assert.equal(child.nodeId, "work"); + assert.ok(child.argv.includes("--extension")); + assert.equal(process.env.PI_SUBAGENT_RUN_ID, "parent-run"); + } finally { + for (const [key, value] of previous) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/taskflow-core/src/exec/step-kinds.ts b/packages/taskflow-core/src/exec/step-kinds.ts index 1135a690..9d4dbf5d 100644 --- a/packages/taskflow-core/src/exec/step-kinds.ts +++ b/packages/taskflow-core/src/exec/step-kinds.ts @@ -146,6 +146,7 @@ async function runAgentCall( agentName, effectiveTask, { + runId: ctx.state.runId, model: phase.model, thinking: phase.thinking, tools: phase.tools, diff --git a/packages/taskflow-core/src/exec/step.ts b/packages/taskflow-core/src/exec/step.ts index 9b118f19..760d5851 100644 --- a/packages/taskflow-core/src/exec/step.ts +++ b/packages/taskflow-core/src/exec/step.ts @@ -351,6 +351,7 @@ async function runOneAgent( agentName, effectiveTask, { + runId: ctx.state.runId, model: phase.model, thinking: phase.thinking, tools: phase.tools, diff --git a/packages/taskflow-core/src/host/runner-types.ts b/packages/taskflow-core/src/host/runner-types.ts index 091839d1..000bae1e 100644 --- a/packages/taskflow-core/src/host/runner-types.ts +++ b/packages/taskflow-core/src/host/runner-types.ts @@ -87,6 +87,9 @@ export interface LiveUpdate { /** Per-run knobs the engine passes to whichever host runner executes the task. */ export interface RunOptions { + /** Owning Taskflow state's run identity, supplied by the runtime rather than + * flow input. Hosts may expose it to their children; direct callers may omit it. */ + runId?: string; model?: string; thinking?: string; tools?: string[]; diff --git a/packages/taskflow-core/src/runtime.ts b/packages/taskflow-core/src/runtime.ts index fab15cb0..2f816b88 100644 --- a/packages/taskflow-core/src/runtime.ts +++ b/packages/taskflow-core/src/runtime.ts @@ -1715,6 +1715,7 @@ async function executePhaseInner( } opts?.promptCalls?.push(task); const runOptions = { + runId: state.runId, model: phase.model, thinking: phase.thinking, tools: phase.tools, diff --git a/packages/taskflow-core/test/run-identity.test.ts b/packages/taskflow-core/test/run-identity.test.ts new file mode 100644 index 00000000..9265767f --- /dev/null +++ b/packages/taskflow-core/test/run-identity.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { test } from "node:test"; +import type { AgentConfig } from "../src/agents.ts"; +import { canUseEventKernel } from "../src/exec/driver.ts"; +import type { RunOptions } from "../src/host/runner-types.ts"; +import { executeTaskflow, type RuntimeDeps } from "../src/runtime.ts"; +import type { Phase, Taskflow } from "../src/schema.ts"; +import { loadRun, saveRun, type RunState } from "../src/store.ts"; +import { emptyUsage } from "../src/usage.ts"; + +const AGENTS: AgentConfig[] = [{ name: "a", description: "test", systemPrompt: "", source: "user", filePath: "" }]; + +function persistedState(cwd: string, def: Taskflow, runId: string): RunState { + saveRun({ runId, flowName: def.name, def, args: { runId: "argument-controlled" }, status: "running", phases: {}, createdAt: Date.now(), updatedAt: Date.now(), cwd }); + const state = loadRun(cwd, runId); + assert.ok(state); + return state; +} + +const cases: Array<{ name: string; phase: Phase; calls: number }> = [ + { name: "agent", phase: { id: "work", type: "agent", agent: "a", task: "agent" }, calls: 1 }, + { name: "map", phase: { id: "work", type: "map", agent: "a", task: "map {item}", over: '["x","y"]' }, calls: 2 }, + { name: "parallel", phase: { id: "work", type: "parallel", branches: [{ agent: "a", task: "left" }, { agent: "a", task: "right" }] }, calls: 2 }, + { name: "reduce", phase: { id: "work", type: "reduce", agent: "a", task: "reduce" }, calls: 1 }, + { name: "gate", phase: { id: "work", type: "gate", agent: "a", task: "gate" }, calls: 1 }, + { name: "loop", phase: { id: "work", type: "loop", agent: "a", task: "loop", until: "{loop.iteration}>=2", maxIterations: 2, convergence: false }, calls: 2 }, + { name: "tournament", phase: { id: "work", type: "tournament", agent: "a", task: "variant", variants: 2, judge: "judge", mode: "best" }, calls: 3 }, +]; + +for (const eventKernel of [false, true]) { + const engine = eventKernel ? "kernel" : "imperative"; + for (const { name, phase, calls: expectedCalls } of cases) { + test(`run identity: ${engine} ${name} calls receive persisted state identity`, async (t) => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-run-id-")); + t.after(() => fs.rmSync(cwd, { recursive: true, force: true })); + const def: Taskflow = Object.assign({ name: `identity-${name}`, phases: [Object.assign({ ...phase, final: true }, { runId: "phase-controlled" })] }, { runId: "flow-controlled" }); + const runId = `persisted-${engine}-${name}`; + const state = persistedState(cwd, def, runId); + assert.equal(canUseEventKernel(def), true, "Kernel fixture must not silently fall back"); + const calls: Array<{ opts: RunOptions; stack: string }> = []; + const runTask: RuntimeDeps["runTask"] = async (_cwd, _agents, agent, task, opts) => { + calls.push({ opts, stack: new Error().stack ?? "" }); + const output = name === "gate" ? "VERDICT: PASS" : name === "tournament" ? "WINNER: 1" : `ok-${calls.length}`; + return { agent, task, exitCode: 0, output, stderr: "", usage: emptyUsage(), stopReason: "end" }; + }; + const result = await executeTaskflow(state, { cwd, agents: AGENTS, eventKernel, persist: saveRun, runTask }); + assert.equal(result.ok, true, result.finalOutput); + assert.equal(calls.length, expectedCalls); + for (const call of calls) { + assert.equal(call.opts.runId, runId, "Neither flow, phase nor argument data may override the owning state"); + if (eventKernel) { + const source = ["agent", "map", "parallel"].includes(name) ? "exec/step.ts" : "exec/step-kinds.ts"; + assert.ok(call.stack.replaceAll("\\", "/").includes(source), `Expected real kernel path ${source}`); + } + } + assert.equal(loadRun(cwd, runId)?.runId, runId); + }); + } + + test(`run identity: ${engine} nested flow uses its immediate owning state`, async (t) => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-nested-run-id-")); + t.after(() => fs.rmSync(cwd, { recursive: true, force: true })); + const child: Taskflow = { name: "child", phases: [ + { id: "one", type: "agent", agent: "a", task: "child-one" }, + { id: "two", type: "reduce", agent: "a", task: "child-two", from: ["one"], dependsOn: ["one"], final: true }, + ] }; + const def: Taskflow = { name: "parent", phases: [ + { id: "root", type: "agent", agent: "a", task: "parent" }, + { id: "nested", type: "flow", use: "child", dependsOn: ["root"], final: true }, + ] }; + const runId = `persisted-${engine}-parent`; + const state = persistedState(cwd, def, runId); + const calls: Array<{ task: string; runId?: string }> = []; + const runTask: RuntimeDeps["runTask"] = async (_cwd, _agents, agent, task, opts) => { + calls.push({ task, runId: opts.runId }); + return { agent, task, exitCode: 0, output: "ok", stderr: "", usage: emptyUsage(), stopReason: "end" }; + }; + const result = await executeTaskflow(state, { cwd, agents: AGENTS, eventKernel, persist: saveRun, runTask, loadFlow: (name) => name === "child" ? child : undefined }); + assert.equal(result.ok, true, JSON.stringify(result.state)); + assert.equal(calls.length, 3); + assert.equal(calls[0].runId, runId); + assert.ok(calls[1].runId); + assert.notEqual(calls[1].runId, runId, "Nested execution creates a distinct owning state"); + assert.equal(calls[1].runId, calls[2].runId, "Both child handlers share that state identity"); + if (eventKernel) assert.equal(calls[1].runId, `${runId}-n-child`); + else assert.match(calls[1].runId, /^child-/); + }); +} From 57f2e5ca7b9c1aca9f78329dcb8a287395d09542 Mon Sep 17 00:00:00 2001 From: Heggria Date: Wed, 30 Sep 2026 19:09:44 +0800 Subject: [PATCH 07/30] fix(ci): consolidate dependency upgrades and retain deep scans Combine reviewed dependency and Actions updates, align CodeQL and Fumadocs peers, harden immutable action pin validation, and verify both Pi SDK peer versions. Keep scanner severity and score gates and require completed Cisco deep scanning instead of accepting missing coverage. --- .github/workflows/ci.yml | 49 +- .github/workflows/deploy-website.yml | 4 +- .github/workflows/plugin-scanner.yml | 22 +- .../publish-charterarc-experimental.yml | 2 +- .github/workflows/publish.yml | 2 +- package.json | 13 +- packages/pi-taskflow/package.json | 7 + .../test/plugin-scan-coverage.test.ts | 21 + .../test/publish-verification.test.ts | 71 +- pnpm-lock.yaml | 1580 +++++++++-------- scripts/verify-pi-peers.mjs | 18 + scripts/verify-plugin-scan-coverage.d.mts | 1 + scripts/verify-plugin-scan-coverage.mjs | 27 + website/package.json | 16 +- 14 files changed, 1010 insertions(+), 823 deletions(-) create mode 100644 packages/taskflow-hosts/test/plugin-scan-coverage.test.ts create mode 100644 scripts/verify-pi-peers.mjs create mode 100644 scripts/verify-plugin-scan-coverage.d.mts create mode 100644 scripts/verify-plugin-scan-coverage.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e9595d8..0757bc65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: "22" @@ -53,7 +53,7 @@ jobs: # pnpm via corepack — the version is pinned by the `packageManager` field # in package.json, so every contributor and CI run use the same pnpm. - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: @@ -70,6 +70,39 @@ jobs: - name: Unit tests run: pnpm test + pi-peers: + name: pi adapter (SDK ${{ matrix.pi }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + pi: ["0.80.3", "0.85.1"] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: "22" + cache: "pnpm" + - name: Install locked dependencies + run: pnpm install --frozen-lockfile --registry https://registry.npmjs.org/ + # The regular Node 22/24 suite uses the locked 0.85.1 adapter dev peers. + # Also exercise the former adapter resolution, rather than accidentally + # claiming the root SDK version is used by every workspace package. + - name: Install previous adapter peers + if: matrix.pi == '0.80.3' + run: >- + pnpm --filter pi-taskflow add --save-dev --save-exact + @earendil-works/pi-agent-core@0.80.3 + @earendil-works/pi-ai@0.80.3 + @earendil-works/pi-coding-agent@0.80.3 + @earendil-works/pi-tui@0.80.3 + --registry https://registry.npmjs.org/ + - name: Verify resolved SDK and run complete adapter suite + run: pnpm run test:pi:peers + env: + PI_TEST_PEER_VERSION: ${{ matrix.pi }} + e2e: # Required status context: keep this literal stable; the job now covers all MCP hosts. name: e2e (codex MCP, network-free) @@ -77,7 +110,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: @@ -133,7 +166,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: @@ -155,7 +188,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: @@ -187,7 +220,7 @@ jobs: # The docs source records last-modified timestamps from git history. fetch-depth: 0 - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: @@ -213,7 +246,7 @@ jobs: security-events: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 + - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: javascript-typescript - - uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 + - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 8215dec2..7404c6a7 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -45,7 +45,7 @@ jobs: # in the root package.json, so CI uses the same pnpm as local dev. The # website is a pnpm workspace member (pnpm-workspace.yaml), so one # `pnpm install` at the root installs every package including the website. - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 @@ -76,4 +76,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/plugin-scanner.yml b/.github/workflows/plugin-scanner.yml index d2a9e6cf..a9684536 100644 --- a/.github/workflows/plugin-scanner.yml +++ b/.github/workflows/plugin-scanner.yml @@ -18,9 +18,29 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - uses: hashgraph-online/ai-plugin-scanner-action@55616c962cf86368423f7673b2ecdfdbe613d1af # v1 + - uses: hashgraph-online/ai-plugin-scanner-action@484da6f8e99a057233b939ab50450a14a68f5a1c # v1.2.624 with: plugin_dir: "." mode: scan + # v1.2.624 makes Cisco opt-in; retain the previous deep-scan coverage. + install_cisco: "true" + cisco_skill_scan: "on" + cisco_policy: balanced + online: "false" + submission_enabled: "false" + pr_comment: "off" + format: json + output: plugin-security-report.json min_score: 80 fail_on_severity: high + # Always print the full finding payload, even when the gate fails. Keep + # detailed evidence in this job rather than suppressing or baselining it. + - name: Show complete scanner findings + if: always() + run: | + if [ -f plugin-security-report.json ]; then + cat plugin-security-report.json + fi + - name: Require completed Cisco deep scanning + if: always() + run: node scripts/verify-plugin-scan-coverage.mjs plugin-security-report.json diff --git a/.github/workflows/publish-charterarc-experimental.yml b/.github/workflows/publish-charterarc-experimental.yml index a5b44f19..3fbad881 100644 --- a/.github/workflows/publish-charterarc-experimental.yml +++ b/.github/workflows/publish-charterarc-experimental.yml @@ -65,7 +65,7 @@ jobs: echo "PUBLISH_SHA=$TAG_COMMIT" >> "$GITHUB_ENV" echo "Verified $EXPECT at $TAG_COMMIT is reachable from origin/main" - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: "22" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3844589f..1c10a451 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -48,7 +48,7 @@ jobs: exit 1 fi echo "Verified $GITHUB_REF_NAME at $TAG_COMMIT is reachable from origin/main" - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: "22" diff --git a/package.json b/package.json index aaf1d8cd..733bb1e4 100644 --- a/package.json +++ b/package.json @@ -39,19 +39,20 @@ "test:e2e-grok-mcp": "node --conditions=development --experimental-strip-types packages/grok-taskflow/test/e2e-grok-mcp.mts", "test:e2e-pi-terminal-reap": "node --conditions=development --experimental-strip-types packages/pi-taskflow/test/e2e-terminal-reap.mts", "test:hermes": "PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --conditions=development --experimental-strip-types --test 'packages/hermes-taskflow/test/*.test.ts'", - "test:e2e-hermes-mcp": "node --conditions=development --experimental-strip-types packages/hermes-taskflow/test/e2e-hermes-mcp.mts" + "test:e2e-hermes-mcp": "node --conditions=development --experimental-strip-types packages/hermes-taskflow/test/e2e-hermes-mcp.mts", + "test:pi:peers": "node scripts/verify-pi-peers.mjs && pnpm run test:pi" }, "devDependencies": { - "@earendil-works/pi-agent-core": "^0.84.2", - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-coding-agent": "^0.84.2", - "@earendil-works/pi-tui": "^0.84.2", + "@earendil-works/pi-agent-core": "^0.85.1", + "@earendil-works/pi-ai": "^0.85.1", + "@earendil-works/pi-coding-agent": "^0.85.1", + "@earendil-works/pi-tui": "^0.85.1", "@types/node": "^26", "charterarc": "workspace:*", "taskflow-control": "workspace:*", "taskflow-core": "workspace:*", "taskflow-hosts": "workspace:*", - "typebox": "^1.3.14", + "typebox": "^1.3.30", "typescript": "^7.0.2" } } diff --git a/packages/pi-taskflow/package.json b/packages/pi-taskflow/package.json index ee12f692..dca839b7 100644 --- a/packages/pi-taskflow/package.json +++ b/packages/pi-taskflow/package.json @@ -61,5 +61,12 @@ "@earendil-works/pi-coding-agent": "*", "@earendil-works/pi-tui": "*", "typebox": "*" + }, + "devDependencies": { + "@earendil-works/pi-agent-core": "0.85.1", + "@earendil-works/pi-ai": "0.85.1", + "@earendil-works/pi-coding-agent": "0.85.1", + "@earendil-works/pi-tui": "0.85.1", + "typebox": "1.3.30" } } diff --git a/packages/taskflow-hosts/test/plugin-scan-coverage.test.ts b/packages/taskflow-hosts/test/plugin-scan-coverage.test.ts new file mode 100644 index 00000000..0bc3774d --- /dev/null +++ b/packages/taskflow-hosts/test/plugin-scan-coverage.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { verifyPluginScanCoverage } from "../../../scripts/verify-plugin-scan-coverage.mjs"; + +const enabled = { name: "taskflow / cisco-skill-scanner", status: "enabled", metadata: { policy: "balanced" } }; +const report = (integrations: unknown[]) => ({ schema_version: "scan-result.v1", summary: { integrations } }); + +test("plugin scan coverage accepts completed deep scans and ignores unrelated MCP status", () => { + assert.equal(verifyPluginScanCoverage(report([enabled, { name: "cisco-mcp-scanner", status: "skipped" }])), 1); +}); + +test("plugin scan coverage fails closed for missing, skipped, failed, or timed-out scans", () => { + for (const status of ["unavailable", "skipped", "failed", "timed_out", undefined]) { + assert.throws(() => verifyPluginScanCoverage(report([{ ...enabled, status }])), /deep scan must complete/); + } + for (const invalid of [{}, { schema_version: "scan-result.v1" }, report([]), report([{ name: "cisco-mcp-scanner", status: "enabled" }])]) { + assert.throws(() => verifyPluginScanCoverage(invalid)); + } + assert.throws(() => verifyPluginScanCoverage(report([enabled, { ...enabled, status: "failed" }]))); + assert.throws(() => verifyPluginScanCoverage(report([{ ...enabled, metadata: { policy: "permissive" } }]))); +}); diff --git a/packages/taskflow-hosts/test/publish-verification.test.ts b/packages/taskflow-hosts/test/publish-verification.test.ts index 6a7b3654..ed1b0822 100644 --- a/packages/taskflow-hosts/test/publish-verification.test.ts +++ b/packages/taskflow-hosts/test/publish-verification.test.ts @@ -80,12 +80,12 @@ test("publish workflow pins actions and isolates npm provenance from release per const source = readFileSync(new URL("../../../.github/workflows/publish.yml", import.meta.url), "utf8"); const uses = source.match(/^\s*- uses: .+$/gm) ?? []; assert.equal(uses.length, 4); - for (const use of uses) assert.match(use, /@[0-9a-f]{40} # v\d+$/); + for (const use of uses) assert.match(use, /@[0-9a-f]{40} # v\d+(?:\.\d+\.\d+)?$/); assert.equal( uses.filter((use) => use.includes("actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7")).length, 2, ); - assert.ok(uses.some((use) => use.includes("pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6"))); + assert.ok(uses.some((use) => use.includes("pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0"))); assert.ok(uses.some((use) => use.includes("actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7"))); assert.doesNotMatch(source, /uses:\s+\S+@v\d+/); @@ -128,33 +128,68 @@ test("publish workflow smokes, publishes, and verifies one deterministic tarball assert.doesNotMatch(workflow, /pnpm publish --filter/); }); +// Enumerate uses directives before parsing their refs/comments. A restrictive +// match must never silently skip an action when Dependabot changes its comment. +function verifyActionPins(source: string, trustedPins: ReadonlyMap, file: string): number { + let count = 0; + for (const line of source.split("\n")) { + if (!/^\s*(?:-\s+)?uses:/.test(line)) continue; + const match = line.match(/^\s*(?:-\s+)?uses:\s+([^@\s]+)@([0-9a-f]{40})\s+#\s+(v\d+(?:\.\d+\.\d+)?)\s*$/); + assert.ok(match, `${file}: action must use a full commit SHA and a major or semver comment: ${line.trim()}`); + const [, action, revision] = match; + assert.equal(trustedPins.get(action!), revision, `${file}: ${action} is not pinned to its verified tag commit`); + count++; + } + return count; +} + +test("action pin validation accepts major and semver comments without skipping either", () => { + const sha = "ea17c68df8912ef543352723c149a84f56e3d413"; + const pins = new Map([["pnpm/action-setup", sha]]); + assert.equal(verifyActionPins(` - uses: pnpm/action-setup@${sha} # v6\n uses: pnpm/action-setup@${sha} # v6.1.0`, pins, "fixture"), 2); + for (const use of [ + `pnpm/action-setup@${"0".repeat(40)} # v6.1.0`, + `unknown/action@${sha} # v6.1.0`, + "pnpm/action-setup@v6.1.0 # v6.1.0", + `pnpm/action-setup@${sha}`, + `pnpm/action-setup@${sha} # v6.1`, + `"pnpm/action-setup@${sha}" # v6.1.0`, + "", + ]) { + assert.throws(() => verifyActionPins(` - uses: ${use}`, pins, "fixture"), `must reject: ${use}`); + } +}); + test("every repository workflow pins third-party actions to verified full SHAs", () => { const workflowDir = new URL("../../../.github/workflows/", import.meta.url); const trustedPins = new Map([ ["actions/checkout", "3d3c42e5aac5ba805825da76410c181273ba90b1"], - ["pnpm/action-setup", "0977fd99725f1db4007ccb2928dbb4e90d06cc86"], + ["pnpm/action-setup", "ea17c68df8912ef543352723c149a84f56e3d413"], // v6.1.0 ["actions/setup-node", "820762786026740c76f36085b0efc47a31fe5020"], ["actions/upload-pages-artifact", "fc324d3547104276b827a68afc52ff2a11cc49c9"], - ["actions/deploy-pages", "cd2ce8fcbc39b97be8ca5fce6e763baed58fa128"], - ["github/codeql-action/init", "ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd"], - ["github/codeql-action/analyze", "ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd"], - ["hashgraph-online/ai-plugin-scanner-action", "55616c962cf86368423f7673b2ecdfdbe613d1af"], + ["actions/deploy-pages", "368f82528645a54fb793d4d04e342629a3f51346"], + ["github/codeql-action/init", "cdf488f595d80d6e07e03d4674febd5ab45fa938"], // v4.37.9 + ["github/codeql-action/analyze", "cdf488f595d80d6e07e03d4674febd5ab45fa938"], + ["hashgraph-online/ai-plugin-scanner-action", "484da6f8e99a057233b939ab50450a14a68f5a1c"], ]); const files = readdirSync(workflowDir).filter((file) => /\.ya?ml$/.test(file)); assert.ok(files.length > 0, "no workflow files found"); let actionCount = 0; for (const file of files) { - const source = readFileSync(new URL(file, workflowDir), "utf8"); - for (const line of source.split("\n")) { - const match = line.match(/^\s*(?:-\s+)?uses:\s+([^@\s]+)@([^\s]+)(?:\s+#\s+(v\d+))?\s*$/); - if (!match) continue; - actionCount++; - const [, action, revision, versionComment] = match; - assert.match(revision ?? "", /^[0-9a-f]{40}$/, `${file}: action must use a full commit SHA: ${line.trim()}`); - assert.match(versionComment ?? "", /^v\d+$/, `${file}: pinned action must retain a major-version comment`); - assert.equal(trustedPins.get(action ?? ""), revision, `${file}: ${action} is not pinned to its verified tag commit`); - } - assert.doesNotMatch(source, /^\s*(?:-\s+)?uses:\s+[^\s]+@(?![0-9a-f]{40}\b)/m, `${file}: mutable action ref`); + actionCount += verifyActionPins(readFileSync(new URL(file, workflowDir), "utf8"), trustedPins, file); } assert.ok(actionCount > 0, "no third-party actions found"); }); + +test("scanner upgrades retain the high-severity and minimum-score gates", () => { + const workflow = readFileSync(new URL("../../../.github/workflows/plugin-scanner.yml", import.meta.url), "utf8"); + assert.match(workflow, /min_score: 80/); + assert.match(workflow, /fail_on_severity: high/); + assert.match(workflow, /install_cisco: "true"/); + assert.match(workflow, /cisco_skill_scan: "on"/); + assert.match(workflow, /cisco_policy: balanced/); + assert.match(workflow, /if: always\(\)\n run: node scripts\/verify-plugin-scan-coverage\.mjs plugin-security-report\.json/); + assert.match(workflow, /online: "false"/); + assert.match(workflow, /submission_enabled: "false"/); + assert.doesNotMatch(workflow, /continue-on-error:\s*true/); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96252a7e..6a806771 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,20 +16,20 @@ importers: .: devDependencies: '@earendil-works/pi-agent-core': - specifier: ^0.84.2 - version: 0.84.2(ws@8.21.0)(zod@4.4.3) + specifier: ^0.85.1 + version: 0.85.1(ws@8.21.3)(zod@4.6.4) '@earendil-works/pi-ai': - specifier: ^0.84.2 - version: 0.84.2(ws@8.21.0)(zod@4.4.3) + specifier: ^0.85.1 + version: 0.85.1(ws@8.21.3)(zod@4.6.4) '@earendil-works/pi-coding-agent': - specifier: ^0.84.2 - version: 0.84.2(ws@8.21.0)(zod@4.4.3) + specifier: ^0.85.1 + version: 0.85.1(ws@8.21.3)(zod@4.6.4) '@earendil-works/pi-tui': - specifier: ^0.84.2 - version: 0.84.2 + specifier: ^0.85.1 + version: 0.85.1 '@types/node': specifier: ^26 - version: 26.1.1 + version: 26.5.1 charterarc: specifier: workspace:* version: link:packages/charterarc @@ -43,8 +43,8 @@ importers: specifier: workspace:* version: link:packages/taskflow-hosts typebox: - specifier: ^1.3.14 - version: 1.3.15 + specifier: ^1.3.30 + version: 1.3.30 typescript: specifier: ^7.0.2 version: 7.0.2 @@ -121,24 +121,25 @@ importers: packages/pi-taskflow: dependencies: + taskflow-core: + specifier: workspace:* + version: link:../taskflow-core + devDependencies: '@earendil-works/pi-agent-core': - specifier: '*' - version: 0.80.3(ws@8.21.0)(zod@4.4.3) + specifier: 0.85.1 + version: 0.85.1(ws@8.21.3)(zod@4.6.4) '@earendil-works/pi-ai': - specifier: '*' - version: 0.80.3(ws@8.21.0)(zod@4.4.3) + specifier: 0.85.1 + version: 0.85.1(ws@8.21.3)(zod@4.6.4) '@earendil-works/pi-coding-agent': - specifier: '*' - version: 0.80.3(ws@8.21.0)(zod@4.4.3) + specifier: 0.85.1 + version: 0.85.1(ws@8.21.3)(zod@4.6.4) '@earendil-works/pi-tui': - specifier: '*' - version: 0.80.3 - taskflow-core: - specifier: workspace:* - version: link:../taskflow-core + specifier: 0.85.1 + version: 0.85.1 typebox: - specifier: '*' - version: 1.3.3 + specifier: 1.3.30 + version: 1.3.30 packages/taskflow-control: dependencies: @@ -183,20 +184,20 @@ importers: website: dependencies: fumadocs-core: - specifier: 16.14.0 - version: 16.14.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.32.0(react@19.2.8))(next@16.3.1(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + specifier: 16.15.1 + version: 16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4) fumadocs-mdx: - specifier: 15.2.3 - version: 15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.14.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.32.0(react@19.2.8))(next@16.3.1(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.1(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + specifier: 15.3.1 + version: 15.3.1(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) fumadocs-ui: - specifier: 16.14.4 - version: 16.14.4(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.14.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.32.0(react@19.2.8))(next@16.3.1(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.1(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) + specifier: 16.15.1 + version: 16.15.1(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) lucide-react: - specifier: ^1.31.0 - version: 1.32.0(react@19.2.8) + specifier: 1.33.0 + version: 1.33.0(react@19.2.8) next: - specifier: 16.3.1 - version: 16.3.1(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: 16.3.3 + version: 16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: specifier: 19.2.8 version: 19.2.8 @@ -205,8 +206,8 @@ importers: version: 19.2.8(react@19.2.8) devDependencies: '@biomejs/biome': - specifier: 2.5.8 - version: 2.5.8 + specifier: 2.5.13 + version: 2.5.13 '@tailwindcss/postcss': specifier: 4.3.3 version: 4.3.3 @@ -214,8 +215,8 @@ importers: specifier: ^2.0.14 version: 2.0.14 '@types/node': - specifier: 26.2.0 - version: 26.2.0 + specifier: 26.5.1 + version: 26.5.1 '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -229,8 +230,8 @@ importers: specifier: 6.0.3 version: 6.0.3 zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.4 + version: 4.6.4 packages: @@ -238,8 +239,8 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@anthropic-ai/sdk@0.91.1': - resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} + '@anthropic-ai/sdk@0.123.0': + resolution: {integrity: sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -264,80 +265,80 @@ packages: resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.974.27': - resolution: {integrity: sha512-WRWEgIq6vx+NU6ot3VrRu4Jovj9MIObitSi6of/GV5THDDPccBhivCRNkWJutMM+m3GvdeI3l/UbGNcoOobxOA==} + '@aws-sdk/core@3.978.0': + resolution: {integrity: sha512-2yX9LUmxPklVjSGTb8dfnWRJSiFQ3TeH2nn7G1mdKHTfnabzF0+gfrS8rYfLWmZrQ8A3mEcxMJjRc51dL5KWaA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.53': - resolution: {integrity: sha512-+KDA3uc/HZ1vIneGu5QMQb0gAXDYrm2vOE60+BJ7lS0YinMQ5i2oV4PR1A16XkF6K1IbSwjEHd1hQIIgMsK48w==} + '@aws-sdk/credential-provider-env@3.972.71': + resolution: {integrity: sha512-JN+JHruYZw3GUZB8YGAlDk4wTDPOEAEEdEzj5nS0xodWR4smzHsN7PnK2j6IeOsDIj2aqua5DSbhXl9Gtf90FQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.55': - resolution: {integrity: sha512-1gBfkWY3RWeBlCoB9lIJjXMx45/54wxcgfzv6BY9otTmMrZPcNPi1v+MwZxxaCUg441NV3jsr1efnFNCXiW70g==} + '@aws-sdk/credential-provider-http@3.972.73': + resolution: {integrity: sha512-uyYYnJOnlis8uQzaYGPd7N1JoioCoNpXgnkXYixsWJXHXgXyYi8WXJSDfofxJeWfQIGWLe2Nwyq60Uc7MZdVOg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.60': - resolution: {integrity: sha512-CV2md+PXvABwRjApWGhQ0wACy9WSFIhnUGrovLcjnjBCd/46TbuivLADtkF8IWNjtCQmQ+2IagSaxqBYqXBNAQ==} + '@aws-sdk/credential-provider-ini@3.973.16': + resolution: {integrity: sha512-i++ly+0Uxa+u3ebSSyr0S/3CFhFJDxCXT3+Zj+mW2bXenEx5bKGCdTIKFu39SgXBNhWDjex/8cXUx9MUTMCrTw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.59': - resolution: {integrity: sha512-JG4S9yyA1GFzJdJXqLKrUzZbyK+VDp2QIsJD7YOicJHAhqymfHpDJIok2dLnhOdVB0I37RjdC53uOwCMVS00gw==} + '@aws-sdk/credential-provider-login@3.972.78': + resolution: {integrity: sha512-eUtswnXu0+Ii9ieRK+0L7aPFV3Z/dnW2VntJzjBP9xs8s+8p5nBNuymIXtXwZ+5r5+XJP3e32nMkuZ/r0HozEA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.62': - resolution: {integrity: sha512-S6Slq3Tx7bvFk5yc34XNADyZYTX2HUXvaFAnowGRQnhjBO8J/mP62Fn7lxvJwjaDyYm/7gh9h6HEHaltRyMFXw==} + '@aws-sdk/credential-provider-node@3.972.83': + resolution: {integrity: sha512-jdso7ejzfRnatxMUZK4S/U6KbaDPCvfIV4XL+IQAPFDBt5rj5Fq595euqlK8Le4lNCMFR9oUpt+1l0aMgaayOQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.53': - resolution: {integrity: sha512-EhfH+MQlqOMCkXIVa8MMObPzAQqwTTtxA7KhEJiyPeuNVA8PLOOUpgK7nBrgaDaGiIDLN/9LpGdaHuDjomeRTw==} + '@aws-sdk/credential-provider-process@3.972.71': + resolution: {integrity: sha512-lYmXJa4gvq4xN1lrT5NiP5vIYYKcGWAdj8y+8o6dlcateB5eF3Dn8DtmjjHKfMBrTPAMr2pebIiX/UOj8c1/UA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.59': - resolution: {integrity: sha512-h8793pOjcImx0SB+VcLONcaQQ57VAvKVuqyewQMRKqqH+CSXsG2dwOeLMUJPMxLdNvL7dXOM0ueTukyNUnu5mA==} + '@aws-sdk/credential-provider-sso@3.973.15': + resolution: {integrity: sha512-6Jhcf4v0pSFdjk1EW2kvzuEBKD+UZ2uNcHUIglKKLndD20YhvkL2kdmDOV5/j4mYuWWwe/a1FQ1aomU86/Cg5Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.59': - resolution: {integrity: sha512-VoyO9+vl3XVmpZwn4obskrWIkrA/Jf3lSe1E3ZERlaN9u0D4YZ6+HywC3+L98QOXqZesEfedk67gRER8tK8+8w==} + '@aws-sdk/credential-provider-web-identity@3.972.77': + resolution: {integrity: sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/eventstream-handler-node@3.972.25': - resolution: {integrity: sha512-df7HN1ozwMrB9+59re9PM7tSLxLAcheMWc5u/KyfCPCAWtN/vP7y7RTUZOy48uT1K9MESisVeOPPzF3O1AW01A==} + '@aws-sdk/eventstream-handler-node@3.972.34': + resolution: {integrity: sha512-cTeVzpu1xEAkryTZBYhGwnQ6gOGyp8ZYZvmn0Sg/nI/ABmy/CRHHxPDJDUi9PxwxUtGGaatvfRUB3FCgT/rSWw==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-eventstream@3.972.21': - resolution: {integrity: sha512-HvLgDnxBLaHi9E5K++6Vuk+1+qqn7Pmn8zrlzd+NXH3jBzwujnuzZtAR9WHPkbUGPO92FkoQWj/M1IsdxTlBmQ==} + '@aws-sdk/middleware-eventstream@3.972.29': + resolution: {integrity: sha512-dlRzHCgyB8W6hLuDC5pcT5q+ziPt00n4QGgGBE17ucLVU4zMa6lsbuUdQ2Pm75Z5VA8GF+R/+SgrRcaTdIzSIQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-websocket@3.972.35': - resolution: {integrity: sha512-7/ZAlq5o5A9FnRsQEftZvcct9LVZf1YaYmWR3eOzyJAdKU66YpOKy5ahUVvyBvCTLyO4Ej4yWIk8dllWNXPBsw==} + '@aws-sdk/middleware-websocket@3.972.53': + resolution: {integrity: sha512-bIrDaMENQmYRBHntOiOheqkiw5+fhKW4Lqb+mS1uqF0VwvdWI22fW2HFgWrng66CmYd+4k8ePlpj38sEfTuMLQ==} engines: {node: '>= 14.0.0'} - '@aws-sdk/nested-clients@3.997.27': - resolution: {integrity: sha512-A8PIePF9NIIOJ/4Lg1rl9xm/+QaKkHGetq+Z9wb5B+3Da31YYXRo8n7IDMh5C+HQI5eyEmjrwkGWVdYtnLtbXQ==} + '@aws-sdk/nested-clients@3.997.45': + resolution: {integrity: sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.38': - resolution: {integrity: sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==} + '@aws-sdk/signature-v4-multi-region@3.996.46': + resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==} engines: {node: '>=20.0.0'} '@aws-sdk/token-providers@3.1048.0': resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1079.0': - resolution: {integrity: sha512-cbietrLlHPhhmbnMPTuDS4Zj/KNGhY+3vVhn6dwjO6Dqzrwothzg2srtcY34T9mlICsTXn34avDoWLHSntP54A==} + '@aws-sdk/token-providers@3.1129.0': + resolution: {integrity: sha512-Sbl3rpzQdsG4ZK2zh0JWUYyZPKKorJlVOddA2T0DVbKJFrsW8J6wgnslxxUH04+WaBMr4A1HzJZvZX0xUvkniA==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.973.15': - resolution: {integrity: sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==} + '@aws-sdk/types@3.974.5': + resolution: {integrity: sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-locate-window@3.965.8': - resolution: {integrity: sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==} + '@aws-sdk/util-locate-window@3.965.10': + resolution: {integrity: sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g==} engines: {node: '>=20.0.0'} - '@aws-sdk/xml-builder@3.972.33': - resolution: {integrity: sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==} + '@aws-sdk/xml-builder@3.972.40': + resolution: {integrity: sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==} engines: {node: '>=20.0.0'} '@aws/lambda-invoke-store@0.3.0': @@ -348,109 +349,87 @@ packages: resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.5.8': - resolution: {integrity: sha512-aeAeeJB9fSDc7Gq+2GqpQxA0qBj6gj1k2R6L1cYqGePKP/baIq1WX8y6B+D+nRsO5ViQL22K/8IwbqERW0q1nw==} + '@biomejs/biome@2.5.13': + resolution: {integrity: sha512-+SEC/mFk1a+5mvUANZgbZTaiZXs1nj4iMhL/PHiqDT5TPUEPFIlliEtkKKZB4N862yylHC3UI+/Sj2I0HJEqhA==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.5.8': - resolution: {integrity: sha512-mk1QON9PHllvvLN5gU3f4rMxeh4syK5p9OvKyWH6/W8ueh04uaC8TUXXByhGufWf/y5mQc03ZLM45zU+cmqMjA==} + '@biomejs/cli-darwin-arm64@2.5.13': + resolution: {integrity: sha512-nYSuDJ6zgVqZUkAkJkvhXxsF2PYIrUk/g638K4voCXz7foI9f7b6C2/7+oAihsHQlivZNMUrm/y8ywlcHQtZOw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.5.8': - resolution: {integrity: sha512-bsGwFMBNyHPyiLSsQcZJxdoRrg1V4JL+d7wEsvUBczlP9U9lwM+7mzQHxI4o1mhBsTmdOBbAb6fHU3Z3snN45w==} + '@biomejs/cli-darwin-x64@2.5.13': + resolution: {integrity: sha512-KVy1ceEDuJ3AzFxjT9kkxbVy+UANw1pjEMUS6lvKfxjJ+fmkRvc7sQn1Xo6ETgseEI7wUQoV03KSga3XfE8YRQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.5.8': - resolution: {integrity: sha512-VcJNbstduTHx83NGAdhp78/JOcP45BZHXL7yNsfI1uGzdUgegAz2s+mSoT7wK6PBNzLoqG0zDOXaz/RQYVtSiw==} + '@biomejs/cli-linux-arm64-musl@2.5.13': + resolution: {integrity: sha512-CH32xpep3dNS5EVJpAHlYchkBYznxyVZQrx0b6YYYlPL9u9ZeD2NtqiK/6s64+HFS2a7hGnryLlqqZAOh8ax5g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.5.8': - resolution: {integrity: sha512-XmFiA0WPYFC+uiUDC8WRFzAIH9bo7vwQLav38Uoq4ETC+T/+uBi0TsYGJECkugY3r8USl3jc+Ae2/irAF6F2lQ==} + '@biomejs/cli-linux-arm64@2.5.13': + resolution: {integrity: sha512-VlNMtoxOqs0dUR6drxxHr18SNUvI7xxAuHZlH4s/dstYSf3g6RaqVgo8JRnhcOhKz9afWrvtJTboNG1JuYlIDQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.5.8': - resolution: {integrity: sha512-kKmiyokeISRGq2FLwvr+TzsgBusfxaZ0FZNLcOYOpCK/78tRrEjeEBLvq3xLZMpqbANgJdRPI7vZX8ZL37u9/w==} + '@biomejs/cli-linux-x64-musl@2.5.13': + resolution: {integrity: sha512-F3pmwl+VHoUuVJN/tbNLKeLt0SVK3EIyN1jXlMcNyQGYRQQTVqXF+GgkP0/CjGXFN87e/mv0sBfUnWqWza5PKQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.5.8': - resolution: {integrity: sha512-S5wcm9OBDvLHodD4PUaN488hCpco9QD/9ZxuYJiw4euWtr/oQvLR72z2ixItH8Wd5BCm6FZaeb+YNvOoM1xHtQ==} + '@biomejs/cli-linux-x64@2.5.13': + resolution: {integrity: sha512-Fi6gIxbUaJ3ZCIXeG3ggIBPF76O2DjDN67WrPX/ODRv54GeXPm2gbEPC96Yb0yrJoiH0Eax1JLx1Pi0XbsNFZQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.5.8': - resolution: {integrity: sha512-nILH0mzm3Hi3iEdd7o7GpB8kBR/mSQwfQG/tyBqyNrY2GFtcgwfV9nV8xLmbtUpMNY/Oi0Ml1XgfR4flOdq+AA==} + '@biomejs/cli-win32-arm64@2.5.13': + resolution: {integrity: sha512-+WD13qshXrr0Icv4BfsAdzm8Fs3TL+nZ59zEQxsYNd8lcgZJ0A5+OrlwsL1PipVCmWeRpxgIPD6DAcEd7smkCQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.5.8': - resolution: {integrity: sha512-I2czzXTY61f3nFJxXoMDq80t7MivxDEnCjE+8sDKoFfcKMaoQdkqhIFQ3KyY0XLzeSpUBYeNAXgD+iOV/BU0VA==} + '@biomejs/cli-win32-x64@2.5.13': + resolution: {integrity: sha512-VOofU/nW761XWzUeUNE8zzYNyPrxuMuNFMizL0qz7J75yeV8N7WFheUlk1/K6dOkaFpH9wJ+lDKlwjAbw0346w==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] - '@earendil-works/pi-agent-core@0.80.3': - resolution: {integrity: sha512-3qw0/GeRQBU/nlGjDe5Yb7ePKTmoxefx2YxyKMFAviFUMXpFexBG/hS7mBtwFahFvzrrTPPoRT6sFIDjwoDWPQ==} - engines: {node: '>=22.19.0'} - - '@earendil-works/pi-agent-core@0.84.2': - resolution: {integrity: sha512-8Pn3wSCxj0cfo5I6jxQYVB/3uuQRmHhAlEclyjqpOuMEdQMIODHizRogv56FLdbU+dTiGnybeHQ2N+sV1/L2YA==} - engines: {node: '>=22.19.0'} - - '@earendil-works/pi-ai@0.80.3': - resolution: {integrity: sha512-jPZLMeGL5kkMSEAwAklfXTMHqZvfhsJtCCpKGIr5Duk7mc0n4skjB1dugk7y0z3z8ZHIUCmPAWHdyDqgUz5vdA==} - engines: {node: '>=22.19.0'} - hasBin: true - - '@earendil-works/pi-ai@0.84.2': - resolution: {integrity: sha512-6MzsrYIYNVlE7SfpbL2yYb67Qo58p/7Q+xWG1RZvoX1P80aRCHSod2/13aFpxkow1lPO2LEh3c495J0Gwmyjig==} + '@earendil-works/chord@0.85.1': + resolution: {integrity: sha512-VDlkEC3dhCzQ5fcyH1OhG19dq+6jCn+rqc/iXFivwDYGR5anwo2RCiXij9PpHhqNR5GuhhE+Er69Zi1Sn4eY6w==} engines: {node: '>=22.19.0'} - hasBin: true - '@earendil-works/pi-client@0.84.2': - resolution: {integrity: sha512-/RFSPhD/bZbpOp1oJj+UneSUFSgZhWxzcSENUY+8+8xhoBrWXMYI2t77XNx4Yf+c8YK2qTHquForhNcelYpXvg==} + '@earendil-works/pi-agent-core@0.85.1': + resolution: {integrity: sha512-hIXIP3eAWueAYiAl8aMvWCvvZ8Q5gT3Dip5bE5uJyIGh4+YlWRjtMLI4BaeoXoSs93zndjue61u1B/vhefLnuA==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-coding-agent@0.80.3': - resolution: {integrity: sha512-TIggw9gCXpA+Ph7OjdTA7ka2NPwTVuPmy39KDSyUzaKq8VvHfMGR7vtRz4JB7Um/RMRblmzhu4p9tUCk6MTgGA==} + '@earendil-works/pi-ai@0.85.1': + resolution: {integrity: sha512-+VgVIJDkDO2efYJKEEqvPTH4zmnIaXdAppGbO+vKFA9qy5PdhFiAenuFAkU+oiCSfOC4dMHDyrjdQeL4ZoC5CQ==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-coding-agent@0.84.2': - resolution: {integrity: sha512-l4E+B7hgXKWddRo8bC/eSue2aWZjEgJ9xIpf5p0Og+lq8a2TArCwJ0HCoCPCgaBP/tN4zbYH/wOwvx9pJpeLCA==} + '@earendil-works/pi-coding-agent@0.85.1': + resolution: {integrity: sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-protocol@0.84.2': - resolution: {integrity: sha512-jbBh03fkeckWEroHpcZBr4w5/Ibat8WwdXFlXHivYQImrQNFtLpDeL0t1cku4hmK0q3pceIRQHkw4fwbM4YILQ==} - engines: {node: '>=22.19.0'} - - '@earendil-works/pi-telemetry@0.84.2': - resolution: {integrity: sha512-wg5caea7uIv1BHRBm2Y116RvFG4oSAiP5qk9tA2463PDGIr4K8M1Ceyyg5DOpF/shUUl0gk826yQJAeAcHYB9g==} - engines: {node: '>=22.19.0'} - - '@earendil-works/pi-tui@0.80.3': - resolution: {integrity: sha512-2BJI6qwRQfnM0Q7seL1+SbacU/jRRjBnN7Hu3n9BjAn7/s5FaBNnvdD1qBQYRsFTHfjqMaDsjYqanPyqwXj99w==} + '@earendil-works/pi-telemetry@0.85.1': + resolution: {integrity: sha512-Bg/YN6kA7Swja/NQxka8xFdecb4E/auIEGF2G5A25EaQXhRnPj300/7/KpgsDDMYUzHTDAv4RyUxaQPJKW81Rw==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-tui@0.84.2': - resolution: {integrity: sha512-ds2TLihOnM5sLJB3VpXV6y0uR5efVuHf4MN7yDpsty6hA2DUO/EDVzjp/0od0G2JslzVLMjT8T8zavtxVb+qbg==} + '@earendil-works/pi-tui@0.85.1': + resolution: {integrity: sha512-OIzw9efInmO4WOBnD4TxcTdBjmzvYJpzslkgoUro946nEGoYWg5rwv1p4fDt3/JvMx9QybryUCUwlm7j8Dreig==} engines: {node: '>=22.19.0'} '@emnapi/runtime@1.11.2': @@ -462,156 +441,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -645,6 +780,9 @@ packages: tailwindcss: optional: true + '@fumari/image-size@0.1.1': + resolution: {integrity: sha512-iaiV2wyzJ4mW358NX4CX3yGit+WjKYzpaQ+rldK9BjL/5jFpeV6ruWnZVq7MEyMn3Gd0DLi8g/79NZAAQaGscw==} + '@google/genai@1.52.0': resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} engines: {node: '>=20.0.0'} @@ -829,6 +967,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -903,65 +1044,57 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} - '@mistralai/mistralai@2.2.6': - resolution: {integrity: sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==} - peerDependencies: - '@opentelemetry/api': ^1.9.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - - '@next/env@16.3.1': - resolution: {integrity: sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ==} + '@next/env@16.3.3': + resolution: {integrity: sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==} - '@next/swc-darwin-arm64@16.3.1': - resolution: {integrity: sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw==} + '@next/swc-darwin-arm64@16.3.3': + resolution: {integrity: sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.1': - resolution: {integrity: sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw==} + '@next/swc-darwin-x64@16.3.3': + resolution: {integrity: sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.1': - resolution: {integrity: sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w==} + '@next/swc-linux-arm64-gnu@16.3.3': + resolution: {integrity: sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.1': - resolution: {integrity: sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw==} + '@next/swc-linux-arm64-musl@16.3.3': + resolution: {integrity: sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.3.1': - resolution: {integrity: sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg==} + '@next/swc-linux-x64-gnu@16.3.3': + resolution: {integrity: sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.3.1': - resolution: {integrity: sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA==} + '@next/swc-linux-x64-musl@16.3.3': + resolution: {integrity: sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.1': - resolution: {integrity: sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog==} + '@next/swc-win32-arm64-msvc@16.3.3': + resolution: {integrity: sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.1': - resolution: {integrity: sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw==} + '@next/swc-win32-x64-msvc@16.3.3': + resolution: {integrity: sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -970,10 +1103,6 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} - '@opentelemetry/semantic-conventions@1.41.1': - resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} - engines: {node: '>=14'} - '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -998,8 +1127,8 @@ packages: '@protobufjs/pool@1.1.0': resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} - '@protobufjs/utf8@1.1.1': - resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} '@radix-ui/number@1.1.3': resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} @@ -1357,58 +1486,30 @@ packages: '@radix-ui/rect@1.1.3': resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} - '@shikijs/core@4.3.1': - resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==} - engines: {node: '>=20'} - '@shikijs/core@4.4.3': resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==} engines: {node: '>=20'} - '@shikijs/engine-javascript@4.3.1': - resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==} - engines: {node: '>=20'} - '@shikijs/engine-javascript@4.4.3': resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==} engines: {node: '>=20'} - '@shikijs/engine-oniguruma@4.3.1': - resolution: {integrity: sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==} - engines: {node: '>=20'} - '@shikijs/engine-oniguruma@4.4.3': resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==} engines: {node: '>=20'} - '@shikijs/langs@4.3.1': - resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==} - engines: {node: '>=20'} - '@shikijs/langs@4.4.3': resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==} engines: {node: '>=20'} - '@shikijs/primitive@4.3.1': - resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==} - engines: {node: '>=20'} - '@shikijs/primitive@4.4.3': resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==} engines: {node: '>=20'} - '@shikijs/themes@4.3.1': - resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==} - engines: {node: '>=20'} - '@shikijs/themes@4.4.3': resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==} engines: {node: '>=20'} - '@shikijs/types@4.3.1': - resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==} - engines: {node: '>=20'} - '@shikijs/types@4.4.3': resolution: {integrity: sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==} engines: {node: '>=20'} @@ -1419,36 +1520,36 @@ packages: '@silvia-odwyer/photon-node@0.3.4': resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==} - '@smithy/core@3.29.1': - resolution: {integrity: sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A==} + '@smithy/core@3.34.1': + resolution: {integrity: sha512-dLcOUxz8YCv1RZUMKq6GbyUf95pLbrqh34bPvpCZ1+CByFF31BEAFewZjsGCnVsZTKdThNENfGyAgk2TJqVwSw==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.4.6': - resolution: {integrity: sha512-B2WQ/PV/H6Jeg3lrIq6bKUfa6Hy01mtK7CGs6lhjzHA6k4aagldH6T6eEjnzKl4HI0cJnAsxfJ19pgb5PV+CVQ==} + '@smithy/credential-provider-imds@4.5.2': + resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.6.3': - resolution: {integrity: sha512-CwCc/7SMTj45y97MUnDTbTaxvtAsiNNRm81z3abROIuMbMsC2Iy5EKfkkVdsKrz8WExQAAMx1EJapq+9j4fFTQ==} + '@smithy/fetch-http-handler@5.8.0': + resolution: {integrity: sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==} engines: {node: '>=18.0.0'} '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} - '@smithy/node-http-handler@4.7.3': - resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} + '@smithy/node-http-handler@4.12.1': + resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.9.3': - resolution: {integrity: sha512-qZTa4gQFUo8RM02rk6q5UVTDLNrQ1oS20LsepBzqq1QBVc/EHJ03OOUADcqMZiXHArW+Y7+OGY0BpdTwZRq/Yg==} + '@smithy/node-http-handler@4.7.3': + resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.6.2': - resolution: {integrity: sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g==} + '@smithy/signature-v4@5.7.3': + resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==} engines: {node: '>=18.0.0'} - '@smithy/types@4.15.1': - resolution: {integrity: sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==} + '@smithy/types@4.18.0': + resolution: {integrity: sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==} engines: {node: '>=18.0.0'} '@smithy/util-buffer-from@2.2.0': @@ -1459,6 +1560,9 @@ packages: resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1566,9 +1670,6 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/hast@3.0.4': - resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} - '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} @@ -1581,14 +1682,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@26.1.1': - resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} - - '@types/node@26.1.2': - resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} - - '@types/node@26.2.0': - resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + '@types/node@26.5.1': + resolution: {integrity: sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==} '@types/react-dom@19.2.4': resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} @@ -1730,74 +1825,74 @@ packages: '@ungap/structured-clone@1.3.2': resolution: {integrity: sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==} - '@yuku-analyzer/binding-android-arm64@0.8.3': - resolution: {integrity: sha512-t1H+d/ubotHLJPQ2gTPZ9C+XD5ZYsxasmxi8wBsUm9WONr0DEFtlxwIgvGZS1Kvlc+sZH9xErCtnKS+odKCabA==} + '@yuku-analyzer/binding-android-arm64@0.8.7': + resolution: {integrity: sha512-pzJ++UMCZEV4s6SP3Ryvj+snWP8s7aTXFkSXRyeBF4RSALftPzfqYssHdGOmOH2QnRAtyOhhg64tdjeSGJvYRg==} cpu: [arm64] os: [android] - '@yuku-analyzer/binding-darwin-arm64@0.8.3': - resolution: {integrity: sha512-2SULWSl6ZJb9mSmlJTw+tzHtYu4PUV50TQDnB3x3VpxHNextIj4Cc2MPO+KYxnETpKliD9ufaxjViU0EPbaRKw==} + '@yuku-analyzer/binding-darwin-arm64@0.8.7': + resolution: {integrity: sha512-vymR7Us3i/HJvUxA1N5sKCrrn2mXw/Xvzbt66zlRyGPDmkUzFCrn34OqodR/zctmD1JhaFlv+Ur6xNNPRIid/w==} cpu: [arm64] os: [darwin] - '@yuku-analyzer/binding-darwin-x64@0.8.3': - resolution: {integrity: sha512-CUnZOy4xKlEZyZddO14sodw7dxlJjm+ELTRRvIStf+Q3UVIwqH8gfvrQc1oFFWdYAQxMNVG+xtzAkcC1wL0IAA==} + '@yuku-analyzer/binding-darwin-x64@0.8.7': + resolution: {integrity: sha512-aX1xy6wJeI2QN/ipu9B6/dzz6RmHQ5+Ty6uyf9csqYZDnY4/U2GsDskYQIRysc2Cuh+GNnvuO5xCXxiqkmVEcw==} cpu: [x64] os: [darwin] - '@yuku-analyzer/binding-freebsd-x64@0.8.3': - resolution: {integrity: sha512-hmzVEB0hl1NwDw0WhTam9BL5MB+WQ23CCWeB0PN5o7+8x/k69v816VipV+67eFkagjWEFkF32c586qYUT0H5wQ==} + '@yuku-analyzer/binding-freebsd-x64@0.8.7': + resolution: {integrity: sha512-5LT2KkjK0zBI2s3VoSyGPSs9Ey7rWPxWkCyFgZXoszQOkAk2z0biP+DLzb9zw6flmwwCCiyeZRqM65srfo8l1Q==} cpu: [x64] os: [freebsd] - '@yuku-analyzer/binding-linux-arm-gnu@0.8.3': - resolution: {integrity: sha512-N7o78i1TGloyw9hNbFzD5qts4DQFz+pMBywPyPZ0P4asTjCIZFxQlJwQyvFIqI/ddPT6WSMbwyIdwnA4HNQP4w==} + '@yuku-analyzer/binding-linux-arm-gnu@0.8.7': + resolution: {integrity: sha512-4haNlVk624QoNSKIneoH9JKu5SvfD+Hkxg490HUS5pfFuWwoXT3zOmAdfwPMsSH0bNIkFO7GqtwDZ9EVpyzepw==} cpu: [arm] os: [linux] libc: [glibc] - '@yuku-analyzer/binding-linux-arm-musl@0.8.3': - resolution: {integrity: sha512-G/7tB72G7nkNHHd6kW0wUjfCCJHNAXHjWb5zFusLhR5JR/qG1mtPCHZKh8kcYuXVtdx10EQUBvDvwYR8qcg8Fw==} + '@yuku-analyzer/binding-linux-arm-musl@0.8.7': + resolution: {integrity: sha512-7HwJHVFtrufB5qHHL1PSDPr/j6uoNLwbwxa04QzsbpcbbzfDUbT37loHPu5u0NuetRUlV+TqXDlX6OpXcM8hKQ==} cpu: [arm] os: [linux] libc: [musl] - '@yuku-analyzer/binding-linux-arm64-gnu@0.8.3': - resolution: {integrity: sha512-RzZs4PTRMZkOKlxRz3TgQcwYURqsNDJQsCZiGDsMr6oujUenAA55g83G8R4qrp4AP8XzupuctiN2Mj94ix5C9w==} + '@yuku-analyzer/binding-linux-arm64-gnu@0.8.7': + resolution: {integrity: sha512-yUEgxEPuDVBO+nkDw8qbssYA8oHu82Q0da+C7rGyVplmjlKa5DhBnMMagTEjFZx4jNDVWnGHJreUCSeGL0x/gQ==} cpu: [arm64] os: [linux] libc: [glibc] - '@yuku-analyzer/binding-linux-arm64-musl@0.8.3': - resolution: {integrity: sha512-bhVhTXNkSmIY4XW7UBHjv/FcFzIKFlWAdYl7JgznwoN9f30Gm75hakEImFVNI1Cyapo1IgDzBttBEkcgtr3hjQ==} + '@yuku-analyzer/binding-linux-arm64-musl@0.8.7': + resolution: {integrity: sha512-2X7EwxPbgdNRqgMwtxOnNOGEmdm1RS8PD2Q5cOxj8cEZD4fy7yHHeSDoEdBOyrJtHzbG6jQB6CeReO1okb/S7Q==} cpu: [arm64] os: [linux] libc: [musl] - '@yuku-analyzer/binding-linux-x64-gnu@0.8.3': - resolution: {integrity: sha512-GchPBviJ2WobjhNwEUb7csnCTa900jwUy98OIhvJ0fktlMXSw4a49jztUobIaThvR1prfa280XcIwwUpFrTJtQ==} + '@yuku-analyzer/binding-linux-x64-gnu@0.8.7': + resolution: {integrity: sha512-k/iQFK1gAvaHLzXXZ3/+g48wT5YB6MfikPb+juGCd9HzyPMUSBCy44rz6nT+xoWnnxmBoeBUywA4CvWCWZFTtg==} cpu: [x64] os: [linux] libc: [glibc] - '@yuku-analyzer/binding-linux-x64-musl@0.8.3': - resolution: {integrity: sha512-FmKdZ8eJjP405zsjkfCq78L5+zLYMfh2jit5xVUkc5FHFkekILTP3/l4A5WbAg3vSlsHuU8IK8qTMP/I/DdjnQ==} + '@yuku-analyzer/binding-linux-x64-musl@0.8.7': + resolution: {integrity: sha512-gTfYHx3cg8FERTYsg1dQrQFTutcWJ7wTp8YToyAJnZMbUCkcyuwUiWNYaEHyF0xIb+PsG7HfD+BLWhRRom5qKg==} cpu: [x64] os: [linux] libc: [musl] - '@yuku-analyzer/binding-win32-arm64@0.8.3': - resolution: {integrity: sha512-QkpBczJfr462MvOY7kWXfv0NkLmjiUNaPwwty2lwZv2Xk1NKuYPiAjcdyqC59nsoftp1xf8qjDKUtF5ykuhoRQ==} + '@yuku-analyzer/binding-win32-arm64@0.8.7': + resolution: {integrity: sha512-XDCWZZztOvdTtPNaU5EzrrV0dmMugdZ+Qdq5INeiGhGW5hD0TuCBIIXK7wTmRM6NcKarGlyBP5SYkiAuw6+slg==} cpu: [arm64] os: [win32] - '@yuku-analyzer/binding-win32-x64@0.8.3': - resolution: {integrity: sha512-bgdgP+I/+lYIaG6xWv0L8VpwSVZ+FUsqTju+xFGhU2mkhgmU1e5PYle7Vl8ZsS7R4Udf28j66L1lXeLEyWvwhg==} + '@yuku-analyzer/binding-win32-x64@0.8.7': + resolution: {integrity: sha512-VtSH1pWuk3bo+BiUAtzYa4Ku1r/10CO14QvkGpRhDcFck8sIf7ox+wiucyKNKcf+srWCYxR1hO+wn5N3BdtFdw==} cpu: [x64] os: [win32] - '@yuku-toolchain/types@0.8.3': - resolution: {integrity: sha512-9LN3HYs3A9qSPVFunsxlbfwBcUgexti3TmhOzIxB/UH8zFuaHQJXTRDcN17DW6cp1GsyZtiZA7f18uIra36Jag==} + '@yuku-toolchain/types@0.8.7': + resolution: {integrity: sha512-2Z53dNxAJL6UvFoIrDZvYf3zlO8s4VJK4O2hhaB4mXVwwpX/7ajtss3cmfqKvamlNLWyt9FSWs4eoYdlbxpnHA==} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -1961,6 +2056,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} @@ -1992,6 +2092,9 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2020,8 +2123,8 @@ packages: react-dom: optional: true - fumadocs-core@16.14.0: - resolution: {integrity: sha512-CQBsVm2XoxytoK5iTxd2Q3L76XBXY9yrWdThH9iJxZPcZ4aHED7YJCYuHPHZDIWg80fy1UAgVc6ogfE+24jTDA==} + fumadocs-core@16.15.1: + resolution: {integrity: sha512-p/NpwQZAUsXUD/wD9MiBGH3/Gy53SmiFGwamhnZoKT9twcGTR8MpFi65ebmf4IEAc3da3S+GStP1fqHYOPEflw==} peerDependencies: '@mdx-js/mdx': '*' '@mixedbread/sdk': 0.x.x @@ -2079,8 +2182,8 @@ packages: zod: optional: true - fumadocs-mdx@15.2.3: - resolution: {integrity: sha512-zulK4WKXZcnbiipdvWtKcBClZn8/RekIjZu+/MmuMX3lpiXki485ABhJiJqFQmtD8W59WBN+V/4ydr4TvSoLkQ==} + fumadocs-mdx@15.3.1: + resolution: {integrity: sha512-6a6o94D4ej0DDg0kJJk2GixbFVBYoPiFwPediX6f+NP5Ml4LE+r6604ykf6LW+Oh4Z1cCosv9o9G8bPt5ZzAOw==} hasBin: true peerDependencies: '@fumadocs/satteri': 0.x.x @@ -2092,7 +2195,7 @@ packages: next: ^15.3.0 || ^16.0.0 react: ^19.2.0 rolldown: '*' - satteri: ^0.9.4 + satteri: ^0.10.0 vite: 7.x.x || 8.x.x peerDependenciesMeta: '@fumadocs/satteri': @@ -2116,12 +2219,12 @@ packages: vite: optional: true - fumadocs-ui@16.14.4: - resolution: {integrity: sha512-EW3pRRqQ1G1/4RVTsEhCaJTvXGHCRe92hySyIb5fAecJ6MVO2TNymemqqB5mxmXQGxNSOprtEybrB2mR0yJc8g==} + fumadocs-ui@16.15.1: + resolution: {integrity: sha512-3TEiWSPBr1cyAbFfVTBCAMuGWeW4XaTYa3fXfd9O9fi3doW8mHzRQE8tS0+mcPKYf7kmcBTwB7rW1T770Qt18Q==} peerDependencies: '@types/mdx': '*' '@types/react': '*' - fumadocs-core: 16.14.4 + fumadocs-core: 16.15.1 next: 16.x.x react: ^19.2.0 react-dom: ^19.2.0 @@ -2136,8 +2239,8 @@ packages: takumi-js: optional: true - gaxios@7.1.5: - resolution: {integrity: sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==} + gaxios@7.3.1: + resolution: {integrity: sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==} engines: {node: '>=18'} gcp-metadata@8.1.2: @@ -2155,18 +2258,18 @@ packages: github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} - glob@13.0.6: - resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} - engines: {node: 18 || 20 || >=22} - - google-auth-library@10.9.0: - resolution: {integrity: sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==} + google-auth-library@10.9.1: + resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==} engines: {node: '>=18'} google-logging-utils@1.1.3: resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} + google-logging-utils@1.2.0: + resolution: {integrity: sha512-WE9av4wKDZgRjBwgVUabocx8T6/7o3Ca1Fat46FXDhXVAFibzNadedcOXrdgd1Kzmk8tsk/9ZH89Wyf/SqeZ3A==} + engines: {node: '>=18'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -2342,20 +2445,20 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} - lru-cache@11.5.1: - resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} - lucide-react@1.32.0: - resolution: {integrity: sha512-txX56hMFnRxPi1f9/nH69YN8uvAO6a7Y1KSWKjCDAtdD9+soEgmWuCt6iRm1pkxUZo2+YntSdsE1L6bIuKoY8Q==} + lucide-react@1.33.0: + resolution: {integrity: sha512-MTRwMy0ZlL8Ur/vOAiJ9XGHE+kFPC7brq6MxAm0GiGXEBj0qy0jA/pG4N675oSzciO/UCdX8T+5yUQdmDeTLxg==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magic-string@1.1.0: - resolution: {integrity: sha512-kS3VHe0nEPST2saQV4Rbkchcd3UBRkVTQHo1D3h/ZTwFDhai/mfKkmtPAtD129EOI7K3HlHIsFOt0WrI2/oU9g==} + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} markdown-extensions@2.0.0: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} @@ -2526,10 +2629,6 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} - motion-dom@13.0.0: resolution: {integrity: sha512-Xk+SJas70uMAUIApg+m3lZDShxI3LBFHq7mFGbBKoRXc2PVPDyAKmzN64Bbzt4CZdP/CItTiJxWtn4TA0v53Ng==} @@ -2561,8 +2660,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.3.1: - resolution: {integrity: sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA==} + next@16.3.3: + resolution: {integrity: sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -2601,18 +2700,6 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} - openai@6.26.0: - resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} - hasBin: true - peerDependencies: - ws: ^8.18.0 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - ws: - optional: true - zod: - optional: true - openai@6.40.0: resolution: {integrity: sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==} peerDependencies: @@ -2641,10 +2728,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-scurry@2.0.2: - resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} - engines: {node: 18 || 20 || >=22} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2662,8 +2745,8 @@ packages: property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} - protobufjs@7.6.5: - resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} + protobufjs@7.6.6: + resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} engines: {node: '>=12.0.0'} react-dom@19.2.8: @@ -2800,10 +2883,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shiki@4.3.1: - resolution: {integrity: sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==} - engines: {node: '>=20'} - shiki@4.4.3: resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==} engines: {node: '>=20'} @@ -2822,6 +2901,9 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + standardwebhooks@1.1.1: + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -2871,18 +2953,15 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - typebox@1.1.38: - resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} - typebox@1.3.10: resolution: {integrity: sha512-L0MT00X96q0P30f1NrGULgaSbmu8hfTjWbLULeVoM+j5u0jR5wyefoDquX2NmRa39f0KiNIBo1wWSaVvHJTZlA==} - typebox@1.3.15: - resolution: {integrity: sha512-gOKAjLqUr+bFGbO5vxCEO5+nHh2wO+swzSIkiJJC0kJ4oLN6f65SrZn6tJYhsYO+qdnpf2NmlQwgsayYoi1NkQ==} - typebox@1.3.3: resolution: {integrity: sha512-URXGUE31PJDQC+PtRMJeLdF4kmmOdFoVPikPCtV2oOIhUpNpppEdIz7W8bH8cFYPYHdDpaRvqwdegMTmHliudg==} + typebox@1.3.30: + resolution: {integrity: sha512-vRmBLzlaq9O9dvfGmI5CssLGvDC/R594kH6N/Q1uUU5VPO3PTgQMlWe/UVNdNVTr2EET+FX8BWZkFdYgxTglbQ==} + typebox@1.3.7: resolution: {integrity: sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==} @@ -2896,8 +2975,8 @@ packages: engines: {node: '>=16.20.0'} hasBin: true - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} undici@8.9.0: resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==} @@ -2968,8 +3047,8 @@ packages: engines: {node: '>= 8'} hasBin: true - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -2985,23 +3064,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true - yuku-analyzer@0.8.3: - resolution: {integrity: sha512-u/kRdlS/Hcqo78pevGoKCcjM4ymcquFlw2qxZgZy6TPkyoExJLww8pnbR8Yck8wO7f9fQ4ymjCdFlhJ1VTzzBw==} + yuku-analyzer@0.8.7: + resolution: {integrity: sha512-nyPXcwwRPEggJqxIP28GeLKPjk9oGUuBlS4I0KfFrezNe93Ie9WIlOYRI42bo+MmZoxH238XHoPy0IZdwZIBxA==} - yuku-ast@0.8.3: - resolution: {integrity: sha512-8x34yU5uhHUnJXzy2Qvjvec/vE9BzS0/2khVT1MsLmSLO/P8Q1Wp8IxHv+IhD+HMYETk6kherOSvP4JPWw2joQ==} + yuku-ast@0.8.7: + resolution: {integrity: sha512-h6+4bDfyootiMB9vckk5uKo5r5j0GHrkr17FQTDNfEsFT3DWlN9uu1HJwQwc64pgmLCI945fWM3lbTIqxjT3GQ==} - zbsearch@3.3.4: - resolution: {integrity: sha512-xGsv9rIwrili/fpLpVwmnCovEcvaAJg1ey+3Ur0+m3x1mnGoVO71iAwn4op420QLGNsQJNmScZjFq5TQ+cRi/g==} + zbsearch@4.0.0: + resolution: {integrity: sha512-gm4zfO31n2ZdruTTRQoWVWO4Q2+zrDt2GlrvIc+5JulRQNAm4IanCxER82vQ7Ug96FYkGqWUJBXFe1kGAcDWaQ==} engines: {node: '>= 20.0.0'} - zod-to-json-schema@3.25.2: - resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} - peerDependencies: - zod: ^3.25.28 || ^4 - - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.6.4: + resolution: {integrity: sha512-AXSD6hvGdvRjajG/l1cC+d6IrhH+sjmPKtYeQdJIK8MFJl3LyClzS+o/YsVC+zQZPupAaeH5skwwm8YqYH7BqA==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -3010,26 +3084,27 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@anthropic-ai/sdk@0.91.1(zod@4.4.3)': + '@anthropic-ai/sdk@0.123.0(zod@4.6.4)': dependencies: json-schema-to-ts: 3.1.1 + standardwebhooks: 1.1.1 optionalDependencies: - zod: 4.4.3 + zod: 4.6.4 '@aws-crypto/sha256-browser@5.2.0': dependencies: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.15 - '@aws-sdk/util-locate-window': 3.965.8 + '@aws-sdk/types': 3.974.5 + '@aws-sdk/util-locate-window': 3.965.10 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.15 + '@aws-sdk/types': 3.974.5 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -3038,7 +3113,7 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.973.15 + '@aws-sdk/types': 3.974.5 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -3046,245 +3121,236 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.27 - '@aws-sdk/credential-provider-node': 3.972.62 - '@aws-sdk/eventstream-handler-node': 3.972.25 - '@aws-sdk/middleware-eventstream': 3.972.21 - '@aws-sdk/middleware-websocket': 3.972.35 + '@aws-sdk/core': 3.978.0 + '@aws-sdk/credential-provider-node': 3.972.83 + '@aws-sdk/eventstream-handler-node': 3.972.34 + '@aws-sdk/middleware-eventstream': 3.972.29 + '@aws-sdk/middleware-websocket': 3.972.53 '@aws-sdk/token-providers': 3.1048.0 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/fetch-http-handler': 5.6.3 - '@smithy/node-http-handler': 4.9.3 - '@smithy/types': 4.15.1 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/core@3.974.27': + '@aws-sdk/core@3.978.0': dependencies: - '@aws-sdk/types': 3.973.15 - '@aws-sdk/xml-builder': 3.972.33 + '@aws-sdk/types': 3.974.5 + '@aws-sdk/xml-builder': 3.972.40 '@aws/lambda-invoke-store': 0.3.0 - '@smithy/core': 3.29.1 - '@smithy/signature-v4': 5.6.2 - '@smithy/types': 4.15.1 + '@smithy/core': 3.34.1 + '@smithy/signature-v4': 5.7.3 + '@smithy/types': 4.18.0 bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.53': + '@aws-sdk/credential-provider-env@3.972.71': dependencies: - '@aws-sdk/core': 3.974.27 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.978.0 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.55': + '@aws-sdk/credential-provider-http@3.972.73': dependencies: - '@aws-sdk/core': 3.974.27 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/fetch-http-handler': 5.6.3 - '@smithy/node-http-handler': 4.9.3 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.978.0 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.60': - dependencies: - '@aws-sdk/core': 3.974.27 - '@aws-sdk/credential-provider-env': 3.972.53 - '@aws-sdk/credential-provider-http': 3.972.55 - '@aws-sdk/credential-provider-login': 3.972.59 - '@aws-sdk/credential-provider-process': 3.972.53 - '@aws-sdk/credential-provider-sso': 3.972.59 - '@aws-sdk/credential-provider-web-identity': 3.972.59 - '@aws-sdk/nested-clients': 3.997.27 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/credential-provider-imds': 4.4.6 - '@smithy/types': 4.15.1 + '@aws-sdk/credential-provider-ini@3.973.16': + dependencies: + '@aws-sdk/core': 3.978.0 + '@aws-sdk/credential-provider-env': 3.972.71 + '@aws-sdk/credential-provider-http': 3.972.73 + '@aws-sdk/credential-provider-login': 3.972.78 + '@aws-sdk/credential-provider-process': 3.972.71 + '@aws-sdk/credential-provider-sso': 3.973.15 + '@aws-sdk/credential-provider-web-identity': 3.972.77 + '@aws-sdk/nested-clients': 3.997.45 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-login@3.972.59': + '@aws-sdk/credential-provider-login@3.972.78': dependencies: - '@aws-sdk/core': 3.974.27 - '@aws-sdk/nested-clients': 3.997.27 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.978.0 + '@aws-sdk/nested-clients': 3.997.45 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-node@3.972.62': - dependencies: - '@aws-sdk/credential-provider-env': 3.972.53 - '@aws-sdk/credential-provider-http': 3.972.55 - '@aws-sdk/credential-provider-ini': 3.972.60 - '@aws-sdk/credential-provider-process': 3.972.53 - '@aws-sdk/credential-provider-sso': 3.972.59 - '@aws-sdk/credential-provider-web-identity': 3.972.59 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/credential-provider-imds': 4.4.6 - '@smithy/types': 4.15.1 + '@aws-sdk/credential-provider-node@3.972.83': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.71 + '@aws-sdk/credential-provider-http': 3.972.73 + '@aws-sdk/credential-provider-ini': 3.973.16 + '@aws-sdk/credential-provider-process': 3.972.71 + '@aws-sdk/credential-provider-sso': 3.973.15 + '@aws-sdk/credential-provider-web-identity': 3.972.77 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-process@3.972.53': + '@aws-sdk/credential-provider-process@3.972.71': dependencies: - '@aws-sdk/core': 3.974.27 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.978.0 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.972.59': + '@aws-sdk/credential-provider-sso@3.973.15': dependencies: - '@aws-sdk/core': 3.974.27 - '@aws-sdk/nested-clients': 3.997.27 - '@aws-sdk/token-providers': 3.1079.0 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.978.0 + '@aws-sdk/nested-clients': 3.997.45 + '@aws-sdk/token-providers': 3.1129.0 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-web-identity@3.972.59': + '@aws-sdk/credential-provider-web-identity@3.972.77': dependencies: - '@aws-sdk/core': 3.974.27 - '@aws-sdk/nested-clients': 3.997.27 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.978.0 + '@aws-sdk/nested-clients': 3.997.45 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/eventstream-handler-node@3.972.25': + '@aws-sdk/eventstream-handler-node@3.972.34': dependencies: - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/middleware-eventstream@3.972.21': + '@aws-sdk/middleware-eventstream@3.972.29': dependencies: - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/middleware-websocket@3.972.35': + '@aws-sdk/middleware-websocket@3.972.53': dependencies: - '@aws-sdk/core': 3.974.27 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/fetch-http-handler': 5.6.3 - '@smithy/signature-v4': 5.6.2 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.978.0 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/signature-v4': 5.7.3 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.27': + '@aws-sdk/nested-clients@3.997.45': dependencies: - '@aws-sdk/core': 3.974.27 - '@aws-sdk/signature-v4-multi-region': 3.996.38 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/fetch-http-handler': 5.6.3 - '@smithy/node-http-handler': 4.9.3 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.978.0 + '@aws-sdk/signature-v4-multi-region': 3.996.46 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.38': + '@aws-sdk/signature-v4-multi-region@3.996.46': dependencies: - '@aws-sdk/types': 3.973.15 - '@smithy/signature-v4': 5.6.2 - '@smithy/types': 4.15.1 + '@aws-sdk/types': 3.974.5 + '@smithy/signature-v4': 5.7.3 + '@smithy/types': 4.18.0 tslib: 2.8.1 '@aws-sdk/token-providers@3.1048.0': dependencies: - '@aws-sdk/core': 3.974.27 - '@aws-sdk/nested-clients': 3.997.27 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.978.0 + '@aws-sdk/nested-clients': 3.997.45 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1079.0': + '@aws-sdk/token-providers@3.1129.0': dependencies: - '@aws-sdk/core': 3.974.27 - '@aws-sdk/nested-clients': 3.997.27 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.978.0 + '@aws-sdk/nested-clients': 3.997.45 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/types@3.973.15': + '@aws-sdk/types@3.974.5': dependencies: - '@smithy/types': 4.15.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/util-locate-window@3.965.8': + '@aws-sdk/util-locate-window@3.965.10': dependencies: tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.33': + '@aws-sdk/xml-builder@3.972.40': dependencies: - '@smithy/types': 4.15.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 '@aws/lambda-invoke-store@0.3.0': {} '@babel/runtime@7.29.7': {} - '@biomejs/biome@2.5.8': + '@biomejs/biome@2.5.13': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.5.8 - '@biomejs/cli-darwin-x64': 2.5.8 - '@biomejs/cli-linux-arm64': 2.5.8 - '@biomejs/cli-linux-arm64-musl': 2.5.8 - '@biomejs/cli-linux-x64': 2.5.8 - '@biomejs/cli-linux-x64-musl': 2.5.8 - '@biomejs/cli-win32-arm64': 2.5.8 - '@biomejs/cli-win32-x64': 2.5.8 + '@biomejs/cli-darwin-arm64': 2.5.13 + '@biomejs/cli-darwin-x64': 2.5.13 + '@biomejs/cli-linux-arm64': 2.5.13 + '@biomejs/cli-linux-arm64-musl': 2.5.13 + '@biomejs/cli-linux-x64': 2.5.13 + '@biomejs/cli-linux-x64-musl': 2.5.13 + '@biomejs/cli-win32-arm64': 2.5.13 + '@biomejs/cli-win32-x64': 2.5.13 - '@biomejs/cli-darwin-arm64@2.5.8': + '@biomejs/cli-darwin-arm64@2.5.13': optional: true - '@biomejs/cli-darwin-x64@2.5.8': + '@biomejs/cli-darwin-x64@2.5.13': optional: true - '@biomejs/cli-linux-arm64-musl@2.5.8': + '@biomejs/cli-linux-arm64-musl@2.5.13': optional: true - '@biomejs/cli-linux-arm64@2.5.8': + '@biomejs/cli-linux-arm64@2.5.13': optional: true - '@biomejs/cli-linux-x64-musl@2.5.8': + '@biomejs/cli-linux-x64-musl@2.5.13': optional: true - '@biomejs/cli-linux-x64@2.5.8': + '@biomejs/cli-linux-x64@2.5.13': optional: true - '@biomejs/cli-win32-arm64@2.5.8': + '@biomejs/cli-win32-arm64@2.5.13': optional: true - '@biomejs/cli-win32-x64@2.5.8': + '@biomejs/cli-win32-x64@2.5.13': optional: true - '@earendil-works/pi-agent-core@0.80.3(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/chord@0.85.1': dependencies: - '@earendil-works/pi-ai': 0.80.3(ws@8.21.0)(zod@4.4.3) - ignore: 7.0.5 - typebox: 1.1.38 - yaml: 2.9.0 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod + esbuild: 0.28.1 - '@earendil-works/pi-agent-core@0.84.2(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-agent-core@0.85.1(ws@8.21.3)(zod@4.6.4)': dependencies: - '@earendil-works/pi-ai': 0.84.2(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-telemetry': 0.84.2 + '@earendil-works/chord': 0.85.1 + '@earendil-works/pi-ai': 0.85.1(ws@8.21.3)(zod@4.6.4) + '@earendil-works/pi-telemetry': 0.85.1 diff: 8.0.4 ignore: 7.0.5 typebox: 1.3.7 @@ -3297,38 +3363,16 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.80.3(ws@8.21.0)(zod@4.4.3)': - dependencies: - '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) - '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0 - '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) - '@opentelemetry/api': 1.9.0 - '@smithy/node-http-handler': 4.7.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - openai: 6.26.0(ws@8.21.0)(zod@4.4.3) - partial-json: 0.1.7 - typebox: 1.1.38 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@earendil-works/pi-ai@0.84.2(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.85.1(ws@8.21.3)(zod@4.6.4)': dependencies: - '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) + '@anthropic-ai/sdk': 0.123.0(zod@4.6.4) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@earendil-works/pi-telemetry': 0.84.2 + '@earendil-works/pi-telemetry': 0.85.1 '@google/genai': 1.52.0 - '@opentelemetry/api': 1.9.0 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - openai: 6.40.0(ws@8.21.0)(zod@4.4.3) + openai: 6.40.0(ws@8.21.3)(zod@4.6.4) partial-json: 0.1.7 typebox: 1.3.7 transitivePeerDependencies: @@ -3339,52 +3383,16 @@ snapshots: - ws - zod - '@earendil-works/pi-client@0.84.2': - dependencies: - '@earendil-works/pi-protocol': 0.84.2 - - '@earendil-works/pi-coding-agent@0.80.3(ws@8.21.0)(zod@4.4.3)': - dependencies: - '@earendil-works/pi-agent-core': 0.80.3(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-ai': 0.80.3(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-tui': 0.80.3 - '@silvia-odwyer/photon-node': 0.3.4 - chalk: 5.6.2 - cross-spawn: 7.0.6 - diff: 8.0.4 - glob: 13.0.6 - highlight.js: 10.7.3 - hosted-git-info: 9.0.3 - ignore: 7.0.5 - jiti: 2.7.0 - minimatch: 10.2.5 - proper-lockfile: 4.1.2 - semver: 7.8.0 - typebox: 1.1.38 - undici: 8.9.0 - yaml: 2.9.0 - optionalDependencies: - '@mariozechner/clipboard': 0.3.9 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@earendil-works/pi-coding-agent@0.84.2(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-coding-agent@0.85.1(ws@8.21.3)(zod@4.6.4)': dependencies: - '@earendil-works/pi-agent-core': 0.84.2(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-ai': 0.84.2(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-client': 0.84.2 - '@earendil-works/pi-protocol': 0.84.2 - '@earendil-works/pi-tui': 0.84.2 + '@earendil-works/chord': 0.85.1 + '@earendil-works/pi-agent-core': 0.85.1(ws@8.21.3)(zod@4.6.4) + '@earendil-works/pi-ai': 0.85.1(ws@8.21.3)(zod@4.6.4) + '@earendil-works/pi-tui': 0.85.1 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cross-spawn: 7.0.6 diff: 8.0.4 - glob: 13.0.6 grok-mermaid: 0.2.2 highlight.js: 10.7.3 hosted-git-info: 9.0.3 @@ -3406,18 +3414,9 @@ snapshots: - ws - zod - '@earendil-works/pi-protocol@0.84.2': - dependencies: - typebox: 1.3.7 - - '@earendil-works/pi-telemetry@0.84.2': {} + '@earendil-works/pi-telemetry@0.85.1': {} - '@earendil-works/pi-tui@0.80.3': - dependencies: - get-east-asian-width: 1.6.0 - marked: 18.0.5 - - '@earendil-works/pi-tui@0.84.2': + '@earendil-works/pi-tui@0.85.1': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.5 @@ -3430,81 +3429,159 @@ snapshots: '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + '@esbuild/openbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + '@esbuild/win32-x64@0.28.1': optional: true + '@esbuild/win32-x64@0.28.2': + optional: true + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -3533,12 +3610,14 @@ snapshots: optionalDependencies: tailwindcss: 4.3.3 + '@fumari/image-size@0.1.1': {} + '@google/genai@1.52.0': dependencies: - google-auth-library: 10.9.0 + google-auth-library: 10.9.1 p-retry: 4.6.2 - protobufjs: 7.6.5 - ws: 8.21.0 + protobufjs: 7.6.6 + ws: 8.21.3 transitivePeerDependencies: - bufferutil - supports-color @@ -3665,6 +3744,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -3744,47 +3825,34 @@ snapshots: transitivePeerDependencies: - supports-color - '@mistralai/mistralai@2.2.6(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/semantic-conventions': 1.41.1 - ws: 8.21.0 - zod: 4.4.3 - zod-to-json-schema: 3.25.2(zod@4.4.3) - optionalDependencies: - '@opentelemetry/api': 1.9.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - '@next/env@16.3.1': {} + '@next/env@16.3.3': {} - '@next/swc-darwin-arm64@16.3.1': + '@next/swc-darwin-arm64@16.3.3': optional: true - '@next/swc-darwin-x64@16.3.1': + '@next/swc-darwin-x64@16.3.3': optional: true - '@next/swc-linux-arm64-gnu@16.3.1': + '@next/swc-linux-arm64-gnu@16.3.3': optional: true - '@next/swc-linux-arm64-musl@16.3.1': + '@next/swc-linux-arm64-musl@16.3.3': optional: true - '@next/swc-linux-x64-gnu@16.3.1': + '@next/swc-linux-x64-gnu@16.3.3': optional: true - '@next/swc-linux-x64-musl@16.3.1': + '@next/swc-linux-x64-musl@16.3.3': optional: true - '@next/swc-win32-arm64-msvc@16.3.1': + '@next/swc-win32-arm64-msvc@16.3.3': optional: true - '@next/swc-win32-x64-msvc@16.3.1': + '@next/swc-win32-x64-msvc@16.3.3': optional: true - '@opentelemetry/api@1.9.0': {} - - '@opentelemetry/semantic-conventions@1.41.1': {} + '@opentelemetry/api@1.9.0': + optional: true '@protobufjs/aspromise@1.1.2': {} @@ -3804,7 +3872,7 @@ snapshots: '@protobufjs/pool@1.1.0': {} - '@protobufjs/utf8@1.1.1': {} + '@protobufjs/utf8@1.1.2': {} '@radix-ui/number@1.1.3': {} @@ -4157,14 +4225,6 @@ snapshots: '@radix-ui/rect@1.1.3': {} - '@shikijs/core@4.3.1': - dependencies: - '@shikijs/primitive': 4.3.1 - '@shikijs/types': 4.3.1 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - hast-util-to-html: 9.0.5 - '@shikijs/core@4.4.3': dependencies: '@shikijs/primitive': 4.4.3 @@ -4173,61 +4233,31 @@ snapshots: '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 - '@shikijs/engine-javascript@4.3.1': - dependencies: - '@shikijs/types': 4.3.1 - '@shikijs/vscode-textmate': 10.0.2 - oniguruma-to-es: 4.3.6 - '@shikijs/engine-javascript@4.4.3': dependencies: '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.6 - '@shikijs/engine-oniguruma@4.3.1': - dependencies: - '@shikijs/types': 4.3.1 - '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/engine-oniguruma@4.4.3': dependencies: '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/langs@4.3.1': - dependencies: - '@shikijs/types': 4.3.1 - '@shikijs/langs@4.4.3': dependencies: '@shikijs/types': 4.4.3 - '@shikijs/primitive@4.3.1': - dependencies: - '@shikijs/types': 4.3.1 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - '@shikijs/primitive@4.4.3': dependencies: '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 - '@shikijs/themes@4.3.1': - dependencies: - '@shikijs/types': 4.3.1 - '@shikijs/themes@4.4.3': dependencies: '@shikijs/types': 4.4.3 - '@shikijs/types@4.3.1': - dependencies: - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - '@shikijs/types@4.4.3': dependencies: '@shikijs/vscode-textmate': 10.0.2 @@ -4237,46 +4267,46 @@ snapshots: '@silvia-odwyer/photon-node@0.3.4': {} - '@smithy/core@3.29.1': + '@smithy/core@3.34.1': dependencies: - '@smithy/types': 4.15.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@smithy/credential-provider-imds@4.4.6': + '@smithy/credential-provider-imds@4.5.2': dependencies: - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@smithy/fetch-http-handler@5.6.3': + '@smithy/fetch-http-handler@5.8.0': dependencies: - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 '@smithy/is-array-buffer@2.2.0': dependencies: tslib: 2.8.1 - '@smithy/node-http-handler@4.7.3': + '@smithy/node-http-handler@4.12.1': dependencies: - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@smithy/node-http-handler@4.9.3': + '@smithy/node-http-handler@4.7.3': dependencies: - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@smithy/signature-v4@5.6.2': + '@smithy/signature-v4@5.7.3': dependencies: - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 - '@smithy/types@4.15.1': + '@smithy/types@4.18.0': dependencies: tslib: 2.8.1 @@ -4290,6 +4320,8 @@ snapshots: '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.23': @@ -4375,10 +4407,6 @@ snapshots: '@types/estree@1.0.9': {} - '@types/hast@3.0.4': - dependencies: - '@types/unist': 3.0.3 - '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 @@ -4391,17 +4419,9 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@26.1.1': - dependencies: - undici-types: 8.3.0 - - '@types/node@26.1.2': - dependencies: - undici-types: 8.3.0 - - '@types/node@26.2.0': + '@types/node@26.5.1': dependencies: - undici-types: 8.3.0 + undici-types: 8.9.0 '@types/react-dom@19.2.4(@types/react@19.2.18)': dependencies: @@ -4479,43 +4499,43 @@ snapshots: '@ungap/structured-clone@1.3.2': {} - '@yuku-analyzer/binding-android-arm64@0.8.3': + '@yuku-analyzer/binding-android-arm64@0.8.7': optional: true - '@yuku-analyzer/binding-darwin-arm64@0.8.3': + '@yuku-analyzer/binding-darwin-arm64@0.8.7': optional: true - '@yuku-analyzer/binding-darwin-x64@0.8.3': + '@yuku-analyzer/binding-darwin-x64@0.8.7': optional: true - '@yuku-analyzer/binding-freebsd-x64@0.8.3': + '@yuku-analyzer/binding-freebsd-x64@0.8.7': optional: true - '@yuku-analyzer/binding-linux-arm-gnu@0.8.3': + '@yuku-analyzer/binding-linux-arm-gnu@0.8.7': optional: true - '@yuku-analyzer/binding-linux-arm-musl@0.8.3': + '@yuku-analyzer/binding-linux-arm-musl@0.8.7': optional: true - '@yuku-analyzer/binding-linux-arm64-gnu@0.8.3': + '@yuku-analyzer/binding-linux-arm64-gnu@0.8.7': optional: true - '@yuku-analyzer/binding-linux-arm64-musl@0.8.3': + '@yuku-analyzer/binding-linux-arm64-musl@0.8.7': optional: true - '@yuku-analyzer/binding-linux-x64-gnu@0.8.3': + '@yuku-analyzer/binding-linux-x64-gnu@0.8.7': optional: true - '@yuku-analyzer/binding-linux-x64-musl@0.8.3': + '@yuku-analyzer/binding-linux-x64-musl@0.8.7': optional: true - '@yuku-analyzer/binding-win32-arm64@0.8.3': + '@yuku-analyzer/binding-win32-arm64@0.8.7': optional: true - '@yuku-analyzer/binding-win32-x64@0.8.3': + '@yuku-analyzer/binding-win32-x64@0.8.7': optional: true - '@yuku-toolchain/types@0.8.3': {} + '@yuku-toolchain/types@0.8.7': {} acorn-jsx@5.3.2(acorn@8.17.0): dependencies: @@ -4667,6 +4687,35 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escape-string-regexp@5.0.0: {} estree-util-attach-comments@3.0.0: @@ -4708,6 +4757,8 @@ snapshots: extend@3.0.2: {} + fast-sha256@1.3.0: {} + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -4730,8 +4781,9 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - fumadocs-core@16.14.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.32.0(react@19.2.8))(next@16.3.1(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3): + fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4): dependencies: + '@fumari/image-size': 0.1.1 estree-util-value-to-estree: 3.5.0 github-slugger: 2.0.0 hast-util-to-estree: 3.1.3 @@ -4743,37 +4795,37 @@ snapshots: remark-gfm: 4.0.1 remark-rehype: 11.1.2 scroll-into-view-if-needed: 3.1.0 - shiki: 4.3.1 + shiki: 4.4.3 tinyglobby: 0.2.17 unified: 11.0.5 unist-util-visit: 5.1.0 vfile: 6.0.3 yaml: 2.9.0 - zbsearch: 3.3.4 + zbsearch: 4.0.0 optionalDependencies: '@mdx-js/mdx': 3.1.1 '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@types/react': 19.2.18 - lucide-react: 1.32.0(react@19.2.8) - next: 16.3.1(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + lucide-react: 1.33.0(react@19.2.8) + next: 16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - zod: 4.4.3 + zod: 4.6.4 transitivePeerDependencies: - supports-color - fumadocs-mdx@15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.14.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.32.0(react@19.2.8))(next@16.3.1(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.1(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): + fumadocs-mdx@15.3.1(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 - esbuild: 0.28.1 + esbuild: 0.28.2 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.14.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.32.0(react@19.2.8))(next@16.3.1(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + fumadocs-core: 16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4) github-slugger: 2.0.0 - magic-string: 1.1.0 + magic-string: 1.4.2 mdast-util-mdx: 3.0.0 picocolors: 1.1.1 picomatch: 4.0.5 @@ -4784,18 +4836,18 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 yaml: 2.9.0 - yuku-analyzer: 0.8.3 - zod: 4.4.3 + yuku-analyzer: 0.8.7 + zod: 4.6.4 optionalDependencies: '@types/mdast': 4.0.4 '@types/mdx': 2.0.14 '@types/react': 19.2.18 - next: 16.3.1(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 transitivePeerDependencies: - supports-color - fumadocs-ui@16.14.4(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.14.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.32.0(react@19.2.8))(next@16.3.1(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.1(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): + fumadocs-ui@16.15.1(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): dependencies: '@fuma-translate/react': 1.0.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fumadocs/tailwind': 0.1.1(tailwindcss@4.3.3) @@ -4811,8 +4863,8 @@ snapshots: '@radix-ui/react-tabs': 1.1.21(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) class-variance-authority: 0.7.1 cnfast: 0.1.0 - fumadocs-core: 16.14.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.32.0(react@19.2.8))(next@16.3.1(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) - lucide-react: 1.32.0(react@19.2.8) + fumadocs-core: 16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4) + lucide-react: 1.33.0(react@19.2.8) motion: 13.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-themes: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 @@ -4825,12 +4877,12 @@ snapshots: optionalDependencies: '@types/mdx': 2.0.14 '@types/react': 19.2.18 - next: 16.3.1(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) transitivePeerDependencies: - '@types/react-dom' - tailwindcss - gaxios@7.1.5: + gaxios@7.3.1: dependencies: extend: 3.0.2 https-proxy-agent: 7.0.6 @@ -4840,8 +4892,8 @@ snapshots: gcp-metadata@8.1.2: dependencies: - gaxios: 7.1.5 - google-logging-utils: 1.1.3 + gaxios: 7.3.1 + google-logging-utils: 1.2.0 json-bigint: 1.0.0 transitivePeerDependencies: - supports-color @@ -4852,17 +4904,11 @@ snapshots: github-slugger@2.0.0: {} - glob@13.0.6: - dependencies: - minimatch: 10.2.5 - minipass: 7.1.3 - path-scurry: 2.0.2 - - google-auth-library@10.9.0: + google-auth-library@10.9.1: dependencies: base64-js: 1.5.1 ecdsa-sig-formatter: 1.0.11 - gaxios: 7.1.5 + gaxios: 7.3.1 gcp-metadata: 8.1.2 google-logging-utils: 1.1.3 jws: 4.0.1 @@ -4871,13 +4917,15 @@ snapshots: google-logging-utils@1.1.3: {} + google-logging-utils@1.2.0: {} + graceful-fs@4.2.11: {} grok-mermaid@0.2.2: {} hast-util-from-parse5@8.0.3: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 devlop: 1.1.0 hastscript: 9.0.1 @@ -4888,11 +4936,11 @@ snapshots: hast-util-parse-selector@4.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-raw@9.1.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 '@ungap/structured-clone': 1.3.2 hast-util-from-parse5: 8.0.3 @@ -4910,7 +4958,7 @@ snapshots: dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 comma-separated-tokens: 2.0.3 devlop: 1.1.0 estree-util-attach-comments: 3.0.0 @@ -4929,7 +4977,7 @@ snapshots: hast-util-to-html@9.0.5: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 ccount: 2.0.1 comma-separated-tokens: 2.0.3 @@ -4963,7 +5011,7 @@ snapshots: hast-util-to-parse5@8.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 comma-separated-tokens: 2.0.3 devlop: 1.1.0 property-information: 7.2.0 @@ -4977,7 +5025,7 @@ snapshots: hastscript@9.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 comma-separated-tokens: 2.0.3 hast-util-parse-selector: 4.0.0 property-information: 7.2.0 @@ -4987,7 +5035,7 @@ snapshots: hosted-git-info@9.0.3: dependencies: - lru-cache: 11.5.1 + lru-cache: 11.5.2 html-void-elements@3.0.0: {} @@ -5099,9 +5147,9 @@ snapshots: longest-streak@3.1.0: {} - lru-cache@11.5.1: {} + lru-cache@11.5.2: {} - lucide-react@1.32.0(react@19.2.8): + lucide-react@1.33.0(react@19.2.8): dependencies: react: 19.2.8 @@ -5109,9 +5157,9 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - magic-string@1.1.0: + magic-string@1.4.2: dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 markdown-extensions@2.0.0: {} @@ -5550,8 +5598,6 @@ snapshots: dependencies: brace-expansion: 5.0.9 - minipass@7.1.3: {} - motion-dom@13.0.0: dependencies: motion-utils: 13.0.0 @@ -5575,9 +5621,9 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - next@16.3.1(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@next/env': 16.3.1 + '@next/env': 16.3.3 '@swc/helpers': 0.5.23 baseline-browser-mapping: 2.10.42 caniuse-lite: 1.0.30001802 @@ -5586,16 +5632,16 @@ snapshots: react-dom: 19.2.8(react@19.2.8) styled-jsx: 5.1.6(react@19.2.8) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.1 - '@next/swc-darwin-x64': 16.3.1 - '@next/swc-linux-arm64-gnu': 16.3.1 - '@next/swc-linux-arm64-musl': 16.3.1 - '@next/swc-linux-x64-gnu': 16.3.1 - '@next/swc-linux-x64-musl': 16.3.1 - '@next/swc-win32-arm64-msvc': 16.3.1 - '@next/swc-win32-x64-msvc': 16.3.1 + '@next/swc-darwin-arm64': 16.3.3 + '@next/swc-darwin-x64': 16.3.3 + '@next/swc-linux-arm64-gnu': 16.3.3 + '@next/swc-linux-arm64-musl': 16.3.3 + '@next/swc-linux-x64-gnu': 16.3.3 + '@next/swc-linux-x64-musl': 16.3.3 + '@next/swc-win32-arm64-msvc': 16.3.3 + '@next/swc-win32-x64-msvc': 16.3.3 '@opentelemetry/api': 1.9.0 - sharp: 0.35.3(@types/node@26.2.0) + sharp: 0.35.3(@types/node@26.5.1) transitivePeerDependencies: - '@babel/core' - '@types/node' @@ -5619,15 +5665,10 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 - openai@6.26.0(ws@8.21.0)(zod@4.4.3): - optionalDependencies: - ws: 8.21.0 - zod: 4.4.3 - - openai@6.40.0(ws@8.21.0)(zod@4.4.3): + openai@6.40.0(ws@8.21.3)(zod@4.6.4): optionalDependencies: - ws: 8.21.0 - zod: 4.4.3 + ws: 8.21.3 + zod: 4.6.4 p-retry@4.6.2: dependencies: @@ -5652,11 +5693,6 @@ snapshots: path-key@3.1.1: {} - path-scurry@2.0.2: - dependencies: - lru-cache: 11.5.1 - minipass: 7.1.3 - picocolors@1.1.1: {} picomatch@4.0.5: {} @@ -5675,7 +5711,7 @@ snapshots: property-information@7.2.0: {} - protobufjs@7.6.5: + protobufjs@7.6.6: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 @@ -5685,8 +5721,8 @@ snapshots: '@protobufjs/float': 1.0.2 '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 - '@protobufjs/utf8': 1.1.1 - '@types/node': 26.1.2 + '@protobufjs/utf8': 1.1.2 + '@types/node': 26.5.1 long: 5.3.2 react-dom@19.2.8(react@19.2.8): @@ -5766,7 +5802,7 @@ snapshots: rehype-raw@7.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-raw: 9.1.0 vfile: 6.0.3 @@ -5845,7 +5881,7 @@ snapshots: semver@7.8.5: optional: true - sharp@0.35.3(@types/node@26.2.0): + sharp@0.35.3(@types/node@26.5.1): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 @@ -5876,7 +5912,7 @@ snapshots: '@img/sharp-win32-arm64': 0.35.3 '@img/sharp-win32-ia32': 0.35.3 '@img/sharp-win32-x64': 0.35.3 - '@types/node': 26.2.0 + '@types/node': 26.5.1 optional: true shebang-command@2.0.0: @@ -5885,17 +5921,6 @@ snapshots: shebang-regex@3.0.0: {} - shiki@4.3.1: - dependencies: - '@shikijs/core': 4.3.1 - '@shikijs/engine-javascript': 4.3.1 - '@shikijs/engine-oniguruma': 4.3.1 - '@shikijs/langs': 4.3.1 - '@shikijs/themes': 4.3.1 - '@shikijs/types': 4.3.1 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - shiki@4.4.3: dependencies: '@shikijs/core': 4.4.3 @@ -5915,6 +5940,11 @@ snapshots: space-separated-tokens@2.0.2: {} + standardwebhooks@1.1.1: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -5952,14 +5982,12 @@ snapshots: tslib@2.8.1: {} - typebox@1.1.38: {} - typebox@1.3.10: {} - typebox@1.3.15: {} - typebox@1.3.3: {} + typebox@1.3.30: {} + typebox@1.3.7: {} typescript@6.0.3: {} @@ -5987,7 +6015,7 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 - undici-types@8.3.0: {} + undici-types@8.9.0: {} undici@8.9.0: {} @@ -6071,38 +6099,34 @@ snapshots: dependencies: isexe: 2.0.0 - ws@8.21.0: {} + ws@8.21.3: {} yaml@2.9.0: {} - yuku-analyzer@0.8.3: + yuku-analyzer@0.8.7: dependencies: - '@yuku-toolchain/types': 0.8.3 - yuku-ast: 0.8.3 + '@yuku-toolchain/types': 0.8.7 + yuku-ast: 0.8.7 optionalDependencies: - '@yuku-analyzer/binding-android-arm64': 0.8.3 - '@yuku-analyzer/binding-darwin-arm64': 0.8.3 - '@yuku-analyzer/binding-darwin-x64': 0.8.3 - '@yuku-analyzer/binding-freebsd-x64': 0.8.3 - '@yuku-analyzer/binding-linux-arm-gnu': 0.8.3 - '@yuku-analyzer/binding-linux-arm-musl': 0.8.3 - '@yuku-analyzer/binding-linux-arm64-gnu': 0.8.3 - '@yuku-analyzer/binding-linux-arm64-musl': 0.8.3 - '@yuku-analyzer/binding-linux-x64-gnu': 0.8.3 - '@yuku-analyzer/binding-linux-x64-musl': 0.8.3 - '@yuku-analyzer/binding-win32-arm64': 0.8.3 - '@yuku-analyzer/binding-win32-x64': 0.8.3 - - yuku-ast@0.8.3: - dependencies: - '@yuku-toolchain/types': 0.8.3 - - zbsearch@3.3.4: {} - - zod-to-json-schema@3.25.2(zod@4.4.3): - dependencies: - zod: 4.4.3 - - zod@4.4.3: {} + '@yuku-analyzer/binding-android-arm64': 0.8.7 + '@yuku-analyzer/binding-darwin-arm64': 0.8.7 + '@yuku-analyzer/binding-darwin-x64': 0.8.7 + '@yuku-analyzer/binding-freebsd-x64': 0.8.7 + '@yuku-analyzer/binding-linux-arm-gnu': 0.8.7 + '@yuku-analyzer/binding-linux-arm-musl': 0.8.7 + '@yuku-analyzer/binding-linux-arm64-gnu': 0.8.7 + '@yuku-analyzer/binding-linux-arm64-musl': 0.8.7 + '@yuku-analyzer/binding-linux-x64-gnu': 0.8.7 + '@yuku-analyzer/binding-linux-x64-musl': 0.8.7 + '@yuku-analyzer/binding-win32-arm64': 0.8.7 + '@yuku-analyzer/binding-win32-x64': 0.8.7 + + yuku-ast@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + + zbsearch@4.0.0: {} + + zod@4.6.4: {} zwitch@2.0.4: {} diff --git a/scripts/verify-pi-peers.mjs b/scripts/verify-pi-peers.mjs new file mode 100644 index 00000000..ffaf02ae --- /dev/null +++ b/scripts/verify-pi-peers.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node + +// Check the adapter's direct dependency links, not the workspace root: pnpm +// can install different SDK versions at these two locations. These packages +// have import-only exports, so CommonJS require.resolve is not applicable. +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const adapter = new URL("../packages/pi-taskflow/", import.meta.url); +const manifest = JSON.parse(readFileSync(new URL("package.json", adapter), "utf8")); +for (const suffix of ["agent-core", "ai", "coding-agent", "tui"]) { + const name = `@earendil-works/pi-${suffix}`; + const expected = process.env.PI_TEST_PEER_VERSION ?? manifest.devDependencies[name]; + const installed = JSON.parse(readFileSync(new URL(`node_modules/${name}/package.json`, adapter), "utf8")); + assert.equal(installed.name, name); + assert.equal(installed.version, expected, `${name}: adapter must test the requested peer version`); + console.log(`pi-taskflow resolves ${name}@${installed.version}`); +} diff --git a/scripts/verify-plugin-scan-coverage.d.mts b/scripts/verify-plugin-scan-coverage.d.mts new file mode 100644 index 00000000..f12785d9 --- /dev/null +++ b/scripts/verify-plugin-scan-coverage.d.mts @@ -0,0 +1 @@ +export function verifyPluginScanCoverage(report: unknown): number; diff --git a/scripts/verify-plugin-scan-coverage.mjs b/scripts/verify-plugin-scan-coverage.mjs new file mode 100644 index 00000000..86626d5b --- /dev/null +++ b/scripts/verify-plugin-scan-coverage.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +// The scanner's severity/score gate does not fail closed when an optional +// integration is missing. Require evidence that every reported skill scanner +// actually ran; its findings remain subject to the original high/80 gates. +export function verifyPluginScanCoverage(report) { + assert.equal(report?.schema_version, "scan-result.v1", "unsupported scanner report schema"); + assert.ok(Array.isArray(report.summary?.integrations), "scanner report must contain integrations"); + const skills = report.summary.integrations.filter((integration) => + typeof integration?.name === "string" && integration.name.includes("cisco-skill-scanner")); + assert.ok(skills.length > 0, "scanner report is missing Cisco skill-scan coverage"); + for (const integration of skills) { + assert.equal(integration.status, "enabled", `${integration.name}: deep scan must complete (${integration.message ?? integration.status})`); + assert.equal(integration.metadata?.policy, "balanced", `${integration.name}: unexpected scan policy`); + } + return skills.length; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + assert.ok(process.argv[2], "usage: verify-plugin-scan-coverage.mjs "); + const count = verifyPluginScanCoverage(JSON.parse(readFileSync(process.argv[2], "utf8"))); + console.log(`Verified completed Cisco deep scanning for ${count} integration(s).`); +} diff --git a/website/package.json b/website/package.json index 350a9ad1..db8a8226 100644 --- a/website/package.json +++ b/website/package.json @@ -16,23 +16,23 @@ "prepare-source": "fumadocs-mdx" }, "dependencies": { - "fumadocs-core": "16.14.0", - "fumadocs-mdx": "15.2.3", - "fumadocs-ui": "16.14.4", - "lucide-react": "^1.31.0", - "next": "16.3.1", + "fumadocs-core": "16.15.1", + "fumadocs-mdx": "15.3.1", + "fumadocs-ui": "16.15.1", + "lucide-react": "1.33.0", + "next": "16.3.3", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { - "@biomejs/biome": "2.5.8", + "@biomejs/biome": "2.5.13", "@tailwindcss/postcss": "4.3.3", "@types/mdx": "^2.0.14", - "@types/node": "26.2.0", + "@types/node": "26.5.1", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", "tailwindcss": "4.3.3", "typescript": "6.0.3", - "zod": "^4.4.3" + "zod": "^4.6.4" } } From a332b42379548ecfa9c83ad637788a34686b6a22 Mon Sep 17 00:00:00 2001 From: heggria Date: Fri, 2 Oct 2026 16:20:26 +0800 Subject: [PATCH 08/30] fix(control): isolate projects and preserve failed journals --- docs/internal/control-fault-acceptance.md | 31 ++++ packages/taskflow-control/src/control-host.ts | 26 ++++ packages/taskflow-control/src/hello.ts | 9 +- packages/taskflow-control/src/store/store.ts | 66 +++++++-- packages/taskflow-control/src/uds.ts | 134 +++++++++++------- .../test/fixtures/uds-malformed-server.ts | 32 +++++ .../test/project-isolation.test.ts | 134 ++++++++++++++++++ .../test/store-corruption.test.ts | 123 ++++++++++++++++ .../test/uds-malformed.test.ts | 106 ++++++++++++++ 9 files changed, 593 insertions(+), 68 deletions(-) create mode 100644 docs/internal/control-fault-acceptance.md create mode 100644 packages/taskflow-control/test/fixtures/uds-malformed-server.ts create mode 100644 packages/taskflow-control/test/project-isolation.test.ts create mode 100644 packages/taskflow-control/test/store-corruption.test.ts create mode 100644 packages/taskflow-control/test/uds-malformed.test.ts diff --git a/docs/internal/control-fault-acceptance.md b/docs/internal/control-fault-acceptance.md new file mode 100644 index 00000000..2b69ea39 --- /dev/null +++ b/docs/internal/control-fault-acceptance.md @@ -0,0 +1,31 @@ +# Control fault acceptance + +Alignment status: READY for the bounded experimental control fixes. + +User-approved scope (2026-10-02): preserve reproductions, repair, inject faults, +restart and test installed artifacts. Projects must not share the wrong ledger; +damaged ledgers must not be overwritten; malformed clients must not terminate +the shared service. No production state, publication or remote mutation. + +| ID | Confirmed behavior / source | Code | Permanent acceptance | +| --- | --- | --- | --- | +| ISO | User project isolation; P3 binding identity and P13 fail-closed singleton | control-host.ts | project-isolation.test.ts: reject a different project, refuse unbound attached ledger calls, preserve same-project submit across restart | +| STORE | User damaged-ledger preservation; P14 journal authority | store/store.ts | store-corruption.test.ts: missing/malformed header, invalid record, identity/sequence mismatch and damaged complete journal retain bytes; restoring original header recovers committed command | +| UDS | User abnormal-client containment; P4 hello-before-RPC | hello.ts, uds.ts | uds-malformed.test.ts: invalid shapes, pipelined frames after rejection, unexpected handshake exception, subsequent healthy client | + +Scope decision: taskflow-control remains private and experimental. Multi-mount +routing is unfinished and is not added here. A second project cannot attach to +the current single-store winner; it fails closed. Same canonical path/device/inode +can attach. A host without projectStorePath can use control status/probe but not +project ledger RPCs. This is isolation, not a claim of implemented multi-mount. + +Journal initialization requires an empty store except for writer-lock state. +Recovery validates complete committed batches before trimming a torn tail or +rebuilding projections. Existing crash recovery (header fsync, torn append, +projection interruption), writer locking and restart tests remain required. + +Baseline: source candidate 12a327ec97f0bc6531fdae31c3f142f03302f866 plus local +1.0 preparation snapshotted in ca83dce82c34fb39fac36498aff2c4fbfe28c359. +The fault suite has no live model/provider dependency. All destructive cases own +their temporary directories and child processes. The parent acceptance report +binds the final commit to installed artifact hashes and test logs. diff --git a/packages/taskflow-control/src/control-host.ts b/packages/taskflow-control/src/control-host.ts index 7e7a17bd..eb834005 100644 --- a/packages/taskflow-control/src/control-host.ts +++ b/packages/taskflow-control/src/control-host.ts @@ -35,6 +35,7 @@ import { openControlStore, type ControlStore } from "./store/index.ts"; import { CONTROL_WIRE_SCHEMA_VERSION, PROTOCOL_MAJOR, type NegotiationHandshake } from "./schema/index.ts"; import type { CommandRecord, ControlEvent } from "./schema/index.ts"; import type { ExecutionProvider } from "./te-provider.ts"; +import type { ControlStoreHeader } from "./schema/index.ts"; import type { RunSnapshot } from "./schema/run.ts"; export interface ControlHostOptions { @@ -219,6 +220,7 @@ export class ControlHost { this.#holderId = result.holderId; this.#fencingEpoch = client.fencingEpoch; this.#udsClient = client; + await this.#verifyAttachedProjectStore(client); } this.#state = "started"; break; @@ -242,7 +244,13 @@ export class ControlHost { * control plane (its own hello happened on the wire at connect time). */ async dispatch(method: string, params: unknown, context: { fencingEpoch: number }): Promise { + if (this.#state !== "started") { + throw bootstrapFailed("ControlHost must be started before dispatch"); + } if (this.#singleton === "attached" && this.#udsClient !== undefined) { + if ((method.startsWith("control.store.") || method === "commands.submit") && !this.#options.projectStorePath) { + throw bootstrapFailed("projectStorePath is required for attached project store RPCs"); + } return this.#udsClient.rpc(method, params, context.fencingEpoch); } if (!this.#helloGate.greeted) { @@ -412,6 +420,24 @@ export class ControlHost { this.#standaloneLeasePath = leasePath; } + /** Until multi-mount routing exists, never silently use another project's ledger. */ + async #verifyAttachedProjectStore(client: UdsClient): Promise { + const storePath = this.#options.projectStorePath; + if (!storePath) return; + try { + const header = await client.rpc("control.store.header"); + const canonicalPath = fs.realpathSync(storePath); + const stat = fs.statSync(canonicalPath); + if (header.directoryBinding.canonicalPath !== canonicalPath + || header.directoryBinding.device !== String(stat.dev) + || header.directoryBinding.inode !== String(stat.ino)) { + throw new Error("winner is bound to a different project store; multi-mount routing is unavailable"); + } + } catch (error) { + throw bootstrapFailed(`cannot attach project store ${storePath}: ${error instanceof Error ? error.message : String(error)}`); + } + } + #openProjectStore(): void { const storePath = this.#options.projectStorePath; if (!storePath) return; diff --git a/packages/taskflow-control/src/hello.ts b/packages/taskflow-control/src/hello.ts index d05657b2..e187a91a 100644 --- a/packages/taskflow-control/src/hello.ts +++ b/packages/taskflow-control/src/hello.ts @@ -12,8 +12,9 @@ * reparse — wire-freeze rule 4). */ +import { Value } from "typebox/value"; import { protocolError, ControlError } from "./errors.ts"; -import { PROTOCOL_MAJOR, type NegotiationHandshake } from "./schema/transport.ts"; +import { NegotiationHandshakeSchema, PROTOCOL_MAJOR, type NegotiationHandshake } from "./schema/transport.ts"; export type HelloVerdict = | { ok: true; serverHello: NegotiationHandshake } @@ -54,8 +55,10 @@ export function createHelloGate( ), }; } - if (!Array.isArray(hello.supportedReadSchemas) || !Array.isArray(hello.supportedWriteSchemas)) { - return { ok: false, error: protocolError("hello must declare supportedReadSchemas and supportedWriteSchemas") }; + // Validate the complete wire shape before iterating feature lists or + // trusting schema/build metadata supplied by another process. + if (!Value.Check(NegotiationHandshakeSchema, clientHello)) { + return { ok: false, error: protocolError("hello must match NegotiationHandshake") }; } const serverRead = new Set([...serverHello.supportedReadSchemas, ...(options.extraReadSchemas ?? [])]); const schemaOverlap = hello.supportedReadSchemas.some((schema) => serverRead.has(schema)); diff --git a/packages/taskflow-control/src/store/store.ts b/packages/taskflow-control/src/store/store.ts index a15b4817..7d410e9b 100644 --- a/packages/taskflow-control/src/store/store.ts +++ b/packages/taskflow-control/src/store/store.ts @@ -207,6 +207,14 @@ export function openControlStore(storePath: string, options: OpenControlStoreOpt try { const existingHeader = readHeader(resolved); if (existingHeader === undefined) { + // A missing identity is only an uninitialized store when nothing else + // exists. Never assign a new project ID or reset an existing ledger. + // Other contenders can be preparing their unpublished lock files. + const candidateName = /^\.writer\.lock-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (fs.readdirSync(resolved, { withFileTypes: true }).some((entry) => + entry.name !== LOCK_NAME && !(entry.isFile() && candidateName.test(entry.name)))) { + throw durabilityFailed(`missing control store header in nonempty store at ${resolved}`); + } const header = createHeader(resolved, options); writeJsonAtomicHardened(headerPath(resolved), header); maybeCrash("header-fsynced"); @@ -243,9 +251,14 @@ function createHeader(storePath: string, options: OpenControlStoreOptions): Cont function readHeader(storePath: string): ControlStoreHeader | undefined { const file = headerPath(storePath); - if (!fs.existsSync(file)) return undefined; assertNotSymlink(file); - const raw = JSON.parse(fs.readFileSync(file, "utf8")); + if (!fs.existsSync(file)) return undefined; + let raw: unknown; + try { + raw = JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + throw durabilityFailed(`unreadable control store header at ${file}`); + } if (!Value.Check(ControlStoreHeaderSchema, raw)) { throw durabilityFailed(`malformed control store header at ${file}`); } @@ -256,14 +269,9 @@ function recoverFromJournal( storePath: string, header: ControlStoreHeader, ): { commitSeq: number; commands: CommandRecord[] } { - ensureDirectory(path.join(storePath, JOURNAL_DIR)); - ensureDirectory(path.join(storePath, PROJECTIONS_DIR)); - ensureDirectory(path.join(storePath, COMMANDS_DIR)); - ensureDirectory(path.join(storePath, RECEIPTS_DIR)); - const batches = readJournalBatches(journalPath(storePath)); - truncateTornJournal(journalPath(storePath)); const commands: CommandRecord[] = []; + const commandIds = new Set(); let commitSeq = 0; for (const batch of batches) { if (batch.recordKind !== "commit-batch") { @@ -278,6 +286,19 @@ function recoverFromJournal( ) { throw durabilityFailed("journal command identity does not match store header"); } + if (commandIds.has(batch.command.commandId) + || batch.command.firstCommitSeq !== batch.commitSeq + || batch.command.lastCommitSeq !== batch.commitSeq) { + throw durabilityFailed("journal command has duplicate identity or inconsistent commit sequence"); + } + for (const [index, event] of batch.events.entries()) { + if (event.projectId !== header.projectId || event.controlDomainId !== header.controlDomainId + || event.commitSeq !== batch.commitSeq || event.commandId !== batch.command.commandId + || event.commandEventIndex !== index) { + throw durabilityFailed("journal event identity or sequence does not match its batch"); + } + } + commandIds.add(batch.command.commandId); commands.push(batch.command); commitSeq = batch.commitSeq; } @@ -288,6 +309,13 @@ function recoverFromJournal( `mixed state: commit-seq.json (${onDiskSeq}) is ahead of journal (${commitSeq})`, ); } + // Validate the complete authoritative state before any recovery write. A + // torn tail is recoverable only when the preceding ledger is consistent. + truncateTornJournal(journalPath(storePath)); + ensureDirectory(path.join(storePath, JOURNAL_DIR)); + ensureDirectory(path.join(storePath, PROJECTIONS_DIR)); + ensureDirectory(path.join(storePath, COMMANDS_DIR)); + ensureDirectory(path.join(storePath, RECEIPTS_DIR)); writeJsonAtomicHardened(commitSeqPath(storePath), { commitSeq }); writeJsonAtomicHardened( commandIndexPath(storePath), @@ -298,18 +326,24 @@ function recoverFromJournal( function readCommitSeqFile(storePath: string): number | undefined { const file = commitSeqPath(storePath); - if (!fs.existsSync(file)) return undefined; assertNotSymlink(file); - const raw = JSON.parse(fs.readFileSync(file, "utf8")) as { commitSeq?: unknown }; - if (typeof raw.commitSeq !== "number" || !Number.isInteger(raw.commitSeq) || raw.commitSeq < 0) { + if (!fs.existsSync(file)) return undefined; + let raw: unknown; + try { + raw = JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + throw durabilityFailed(`unreadable commit-seq.json at ${file}`); + } + if (!raw || typeof raw !== "object" || !("commitSeq" in raw) + || typeof raw.commitSeq !== "number" || !Number.isSafeInteger(raw.commitSeq) || raw.commitSeq < 0) { throw durabilityFailed(`malformed commit-seq.json at ${file}`); } return raw.commitSeq; } function readJournalBatches(filePath: string): JournalBatch[] { - if (!fs.existsSync(filePath)) return []; assertNotSymlink(filePath); + if (!fs.existsSync(filePath)) return []; const raw = fs.readFileSync(filePath); const text = raw.toString("utf8"); const lines = text.split("\n"); @@ -324,6 +358,14 @@ function readJournalBatches(filePath: string): JournalBatch[] { } catch { throw durabilityFailed("journal contains a complete but unreadable line"); } + if (!parsed || typeof parsed !== "object" + || !("recordKind" in parsed) || parsed.recordKind !== "commit-batch" + || !("commitSeq" in parsed) || !Number.isSafeInteger(parsed.commitSeq) + || !("command" in parsed) || !Value.Check(CommandRecordSchema, parsed.command) + || !("events" in parsed) || !Array.isArray(parsed.events) || parsed.events.length === 0 + || !parsed.events.every((event: unknown) => Value.Check(ControlEventSchema, event))) { + throw durabilityFailed("journal contains a malformed commit batch"); + } batches.push(parsed as JournalBatch); } return batches; diff --git a/packages/taskflow-control/src/uds.ts b/packages/taskflow-control/src/uds.ts index 7cec1284..5aea14a8 100644 --- a/packages/taskflow-control/src/uds.ts +++ b/packages/taskflow-control/src/uds.ts @@ -134,16 +134,21 @@ function handleServerConnection(socket: net.Socket, options: UdsServerOptions): socket.write(encodeFrame(frame)); }; const failClosed = (frame: UdsFrame): void => { - send(frame); - destroyed = true; - socket.destroy(); + try { + send(frame); + } finally { + destroyed = true; + buffer = ""; + socket.destroy(); + } }; socket.setEncoding("utf8"); socket.on("data", (chunk: string) => { + if (destroyed) return; buffer += chunk; let index: number; - while ((index = buffer.indexOf("\n")) >= 0) { + while (!destroyed && (index = buffer.indexOf("\n")) >= 0) { const line = buffer.slice(0, index).trim(); buffer = buffer.slice(index + 1); if (!line) continue; @@ -159,69 +164,92 @@ function handleServerConnection(socket: net.Socket, options: UdsServerOptions): } as UdsFrame); return; } - void handleFrame(raw); + // A final rejection boundary also covers failures while encoding the + // error response; no connection may produce an unhandled rejection. + void handleFrame(raw).catch(() => { + destroyed = true; + buffer = ""; + socket.destroy(); + }); } }); + socket.on("close", () => { + destroyed = true; + buffer = ""; + }); socket.on("error", () => { destroyed = true; socket.destroy(); }); const handleFrame = async (raw: Record): Promise => { - if (raw.type === "hello") { - if (greeted) { - failClosed({ type: "hello-ack", ok: false, error: errorToEnvelope(protocolError("duplicate hello on a control channel")) }); - return; - } - const verdict = gate.hello(raw.hello); - if (!verdict.ok) { - failClosed({ type: "hello-ack", ok: false, error: verdict.error.toEnvelope() }); - return; - } - greeted = true; - send({ - type: "hello-ack", - ok: true, - serverHello: gate.serverHello, - fencingEpoch: options.getFencingEpoch(), - }); - return; - } - if (raw.type === "rpc") { - if (!greeted) { - failClosed({ - type: "rpc-result", - id: typeof raw.id === "number" ? raw.id : 0, - ok: false, - error: errorToEnvelope(protocolError("hello must precede any RPC on this control channel")), + if (destroyed) return; + try { + if (raw.type === "hello") { + if (greeted) { + failClosed({ type: "hello-ack", ok: false, error: errorToEnvelope(protocolError("duplicate hello on a control channel")) }); + return; + } + const verdict = gate.hello(raw.hello); + if (!verdict.ok) { + failClosed({ type: "hello-ack", ok: false, error: verdict.error.toEnvelope() }); + return; + } + const fencingEpoch = options.getFencingEpoch(); + greeted = true; + send({ + type: "hello-ack", + ok: true, + serverHello: gate.serverHello, + fencingEpoch, }); return; } - const id = raw.id; - if (typeof id !== "number") { - failClosed({ type: "rpc-result", id: 0, ok: false, error: errorToEnvelope(protocolError("rpc frame requires a numeric id")) }); - return; - } - const method = typeof raw.method === "string" ? raw.method : ""; - if (!method) { - failClosed({ type: "rpc-result", id, ok: false, error: errorToEnvelope(protocolError("rpc frame requires a method name")) }); + if (raw.type === "rpc") { + if (!greeted) { + failClosed({ + type: "rpc-result", + id: typeof raw.id === "number" ? raw.id : 0, + ok: false, + error: errorToEnvelope(protocolError("hello must precede any RPC on this control channel")), + }); + return; + } + const id = raw.id; + if (typeof id !== "number") { + failClosed({ type: "rpc-result", id: 0, ok: false, error: errorToEnvelope(protocolError("rpc frame requires a numeric id")) }); + return; + } + const method = typeof raw.method === "string" ? raw.method : ""; + if (!method) { + failClosed({ type: "rpc-result", id, ok: false, error: errorToEnvelope(protocolError("rpc frame requires a method name")) }); + return; + } + const fencingEpoch = typeof raw.fencingEpoch === "number" ? raw.fencingEpoch : 0; + try { + const result = await options.handleRpc(method, raw.params, fencingEpoch); + send({ type: "rpc-result", id, ok: true, result }); + } catch (error) { + send({ type: "rpc-result", id, ok: false, error: errorToEnvelope(error) }); + } return; } - const fencingEpoch = typeof raw.fencingEpoch === "number" ? raw.fencingEpoch : 0; - try { - const result = await options.handleRpc(method, raw.params, fencingEpoch); - send({ type: "rpc-result", id, ok: true, result }); - } catch (error) { - send({ type: "rpc-result", id, ok: false, error: errorToEnvelope(error) }); - } - return; + failClosed({ + type: greeted ? "rpc-result" : "hello-ack", + ...(greeted ? { id: 0 } : {}), + ok: false, + error: errorToEnvelope(protocolError(`unknown control wire frame type ${JSON.stringify(raw.type)}`)), + } as UdsFrame); + } catch (error) { + // Catch synchronous negotiation failures before the data loop can + // dispatch another pipelined frame on this failed connection. + failClosed({ + type: greeted ? "rpc-result" : "hello-ack", + ...(greeted ? { id: typeof raw.id === "number" ? raw.id : 0 } : {}), + ok: false, + error: errorToEnvelope(error), + } as UdsFrame); } - failClosed({ - type: greeted ? "rpc-result" : "hello-ack", - ...(greeted ? { id: 0 } : {}), - ok: false, - error: errorToEnvelope(protocolError(`unknown control wire frame type ${JSON.stringify(raw.type)}`)), - } as UdsFrame); }; } diff --git a/packages/taskflow-control/test/fixtures/uds-malformed-server.ts b/packages/taskflow-control/test/fixtures/uds-malformed-server.ts new file mode 100644 index 00000000..0a608fd7 --- /dev/null +++ b/packages/taskflow-control/test/fixtures/uds-malformed-server.ts @@ -0,0 +1,32 @@ +/** Isolated fault-injection server; never uses a real control home or ledger. */ +import { startUdsServer } from "../../src/uds.ts"; +import { PROTOCOL_MAJOR } from "../../src/schema/transport.ts"; + +const endpointPath = process.env.TF_TEST_ENDPOINT; +if (!endpointPath) throw new Error("TF_TEST_ENDPOINT required"); +let mutations = 0; +let failEpoch = process.env.TF_TEST_FAIL_EPOCH === "1"; +const server = await startUdsServer({ + endpointPath, + serverHello: { + protocolMajor: PROTOCOL_MAJOR, + supportedReadSchemas: ["taskflow.wire.v1"], + supportedWriteSchemas: ["taskflow.wire.v1"], + requiredFeatures: [], + offeredFeatures: [], + buildInfo: { packageVersion: "test", gitCommit: "test", schemaVersion: 1 }, + }, + getFencingEpoch: () => { + if (failEpoch) { + failEpoch = false; + throw new Error("injected epoch failure"); + } + return 1; + }, + handleRpc: async (method) => { + if (method === "mutate") mutations++; + return { mutations }; + }, +}); +process.on("SIGTERM", () => { void server.close(); }); +console.log("READY"); diff --git a/packages/taskflow-control/test/project-isolation.test.ts b/packages/taskflow-control/test/project-isolation.test.ts new file mode 100644 index 00000000..aa0807d9 --- /dev/null +++ b/packages/taskflow-control/test/project-isolation.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { after, test } from "node:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { ControlHost, type ControlHostOptions } from "../src/control-host.ts"; +import { createTeExecutionProvider, type TeExecutionAuthority } from "../src/te-provider.ts"; +import { singletonPaths, type SingletonPaths } from "../src/singleton.ts"; +import { PROTOCOL_MAJOR, type NegotiationHandshake } from "../src/schema/transport.ts"; + +const tempRoots: string[] = []; + +function makePaths(): SingletonPaths { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-control-host-")); + tempRoots.push(root); + return singletonPaths(root); +} + +after(() => { + for (const root of tempRoots) { + try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ } + } +}); + +function fakeProvider() { + const te: TeExecutionAuthority = { + assurance: "resolve-only-no-sandbox", + probe: async () => ({ classification: "resolve-only" as const, baselinePolicyId: "taskflow-resolve-only", hostProbeSha256: "a".repeat(64) }), + prepare: async () => ({ outcome: "accepted" as const, fulfillment: { preparationId: "prep", enforcementCapabilities: { resolution: "contained", mutationMediation: "brokered", processIsolation: "none", revocation: "admission-only", baselinePolicyId: "b", hostProbeSha256: "a".repeat(64) } } }), + submit: async () => ({ outcome: "accepted" as const, providerJobHandle: "job" }), + watch: async function* () { yield { kind: "terminal" as const, outcome: "completed" as const }; }, + }; + return createTeExecutionProvider(te); +} + +function hostOptions(paths: SingletonPaths, overrides: Partial = {}): ControlHostOptions { + return { + provider: fakeProvider(), + singletonPaths: paths, + controlHome: paths.controlHome, + ...overrides, + }; +} + +const clientHello: NegotiationHandshake = { + protocolMajor: PROTOCOL_MAJOR, + supportedReadSchemas: ["taskflow.wire.v1"], + supportedWriteSchemas: ["taskflow.wire.v1"], + requiredFeatures: [], + offeredFeatures: [], + buildInfo: { packageVersion: "0.3.0", gitCommit: "abc", schemaVersion: 1 }, +}; + +test("project isolation: a different project fails closed before any ledger mutation", async (t) => { + const paths = makePaths(); + const a = path.join(paths.controlHome, "a"); + const b = path.join(paths.controlHome, "b"); + const winner = new ControlHost(hostOptions(paths, { projectStorePath: a })); + const other = new ControlHost(hostOptions(paths, { projectStorePath: b })); + t.after(() => { other.stop(); winner.stop(); }); + await winner.start(); + winner.hello(clientHello); + const before = fs.readFileSync(path.join(a, "header")); + await assert.rejects(other.start(), /project store|projectStorePath/); + assert.equal(other.state, "failed-closed"); + assert.deepEqual(fs.readFileSync(path.join(a, "header")), before); + assert.equal(fs.existsSync(b), false); + assert.equal((await winner.dispatch<{ commitSeq: number }>("control.store.status", undefined, { fencingEpoch: 1 })).commitSeq, 0); +}); + +test("project isolation: attached clients without a project cannot access the winner ledger", async (t) => { + const paths = makePaths(); + const winner = new ControlHost(hostOptions(paths, { projectStorePath: path.join(paths.controlHome, "a") })); + const other = new ControlHost(hostOptions(paths)); + t.after(() => { other.stop(); winner.stop(); }); + await winner.start(); + await other.start(); + for (const method of ["control.store.header", "control.store.status", "commands.submit"]) { + await assert.rejects(other.dispatch(method, {}, { fencingEpoch: other.status.fencingEpoch }), /project store|projectStorePath/); + } + assert.equal((await other.dispatch<{ state: string }>("control.status", undefined, { fencingEpoch: other.status.fencingEpoch })).state, "started"); +}); + +test("project isolation: same canonical project attaches and recovers after winner restart", async (t) => { + const paths = makePaths(); + const a = path.join(paths.controlHome, "a"); + const alias = path.join(paths.controlHome, "alias"); + const winner = new ControlHost(hostOptions(paths, { projectStorePath: a })); + const other = new ControlHost(hostOptions(paths, { projectStorePath: alias })); + const restarted = new ControlHost(hostOptions(paths, { projectStorePath: a })); + t.after(() => { other.stop(); winner.stop(); restarted.stop(); }); + await winner.start(); + fs.symlinkSync(a, alias); + await other.start(); + const header = await other.dispatch<{ projectId: string }>("control.store.header", undefined, { fencingEpoch: other.status.fencingEpoch }); + assert.equal(other.status.singleton, "attached"); + const submitted = await other.dispatch<{ commitSeq: number }>("commands.submit", { + command: { + commandId: "00000000-0000-0000-0000-0000000000bb", + kind: "run.submit", + requestHash: "a".repeat(64), + callerPrincipal: "cli", + authorizationContextHash: "a".repeat(64), + projectId: header.projectId, + controlDomainId: header.projectId, + status: "accepted", + firstCommitSeq: 1, + lastCommitSeq: 1, + recordedAt: 1, + }, + events: [{ + eventId: "00000000-0000-0000-0000-0000000000cc", + schemaVersion: 1, + controlDomainId: header.projectId, + streamId: "command:00000000-0000-0000-0000-0000000000bb", + streamSeq: 1, + commitSeq: 1, + commandId: "00000000-0000-0000-0000-0000000000bb", + commandEventIndex: 0, + causationId: "00000000-0000-0000-0000-0000000000bb", + correlationId: "00000000-0000-0000-0000-0000000000bb", + projectId: header.projectId, + recordedAt: 1, + payload: { kind: "command.recorded", commandId: "00000000-0000-0000-0000-0000000000bb" }, + }], + }, { fencingEpoch: other.status.fencingEpoch }); + assert.equal(submitted.commitSeq, 1); + other.stop(); + winner.stop(); + await restarted.start(); + restarted.hello(clientHello); + assert.equal((await restarted.dispatch<{ projectId: string }>("control.store.header", undefined, { fencingEpoch: restarted.status.fencingEpoch })).projectId, header.projectId); + assert.equal((await restarted.dispatch<{ commitSeq: number }>("control.store.status", undefined, { fencingEpoch: restarted.status.fencingEpoch })).commitSeq, 1); +}); diff --git a/packages/taskflow-control/test/store-corruption.test.ts b/packages/taskflow-control/test/store-corruption.test.ts new file mode 100644 index 00000000..321bc70d --- /dev/null +++ b/packages/taskflow-control/test/store-corruption.test.ts @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { after, test } from "node:test"; +import { ControlError } from "../src/errors.ts"; +import { CONTROL_WIRE_SCHEMA_VERSION } from "../src/schema/index.ts"; +import { openControlStore } from "../src/store/index.ts"; + +const roots: string[] = []; +const PROJECT = "00000000-0000-0000-0000-000000000001"; +const COMMAND = "00000000-0000-0000-0000-000000000002"; +const OTHER = "00000000-0000-0000-0000-000000000003"; + +after(() => { + for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); +}); + +function committedStore(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-store-corruption-")); + roots.push(root); + const store = openControlStore(root, { projectId: PROJECT, controlDomainId: PROJECT }); + try { + store.appendBatch({ + command: { + commandId: COMMAND, kind: "run.submit", requestHash: "a".repeat(64), + callerPrincipal: "cli", authorizationContextHash: "a".repeat(64), + projectId: PROJECT, controlDomainId: PROJECT, status: "accepted", + firstCommitSeq: 1, lastCommitSeq: 1, recordedAt: 1, + }, + events: [{ + eventId: COMMAND, schemaVersion: CONTROL_WIRE_SCHEMA_VERSION, + controlDomainId: PROJECT, streamId: `command:${COMMAND}`, streamSeq: 1, + commitSeq: 1, commandId: COMMAND, commandEventIndex: 0, + causationId: COMMAND, correlationId: COMMAND, projectId: PROJECT, + recordedAt: 1, payload: { kind: "command.recorded", commandId: COMMAND }, + }], + }); + } finally { store.close(); } + return root; +} + +function snapshot(root: string): Record { + const result: Record = {}; + for (const entry of fs.readdirSync(root, { recursive: true, withFileTypes: true })) { + const file = path.join(entry.parentPath, entry.name); + const relative = path.relative(root, file); + if (entry.isSymbolicLink()) result[relative] = `symlink:${fs.readlinkSync(file)}`; + else if (entry.isFile()) result[relative] = fs.readFileSync(file).toString("base64"); + } + return result; +} + +function assertRejectedUnchanged(root: string): void { + const before = snapshot(root); + for (let attempt = 0; attempt < 2; attempt++) { + assert.throws(() => { + const store = openControlStore(root); + store.close(); + }, (error: unknown) => error instanceof ControlError && error.code === "TF_DURABILITY_FAILED"); + assert.deepEqual(snapshot(root), before, "failed recovery must preserve every ledger byte"); + } +} + +test("store corruption: missing header preserves committed ledger across failed restarts", () => { + const root = committedStore(); + const header = fs.readFileSync(path.join(root, "header")); + fs.unlinkSync(path.join(root, "header")); + assertRejectedUnchanged(root); + // Operator restores the exact saved identity; the prior committed work survives. + fs.writeFileSync(path.join(root, "header"), header); + const reopened = openControlStore(root); + try { + assert.equal(reopened.commitSeq, 1); + assert.equal(reopened.header.projectId, PROJECT); + assert.equal(reopened.readCommand(COMMAND)?.commandId, COMMAND); + } finally { reopened.close(); } +}); + +for (const damagedHeader of ["{", "{}", "null"]) { + test(`store corruption: malformed header ${damagedHeader} fails closed without overwriting`, () => { + const root = committedStore(); + fs.writeFileSync(path.join(root, "header"), damagedHeader); + assertRejectedUnchanged(root); + }); +} + +test("store corruption: dangling header symlink is rejected without changing ledger", () => { + const root = committedStore(); + fs.unlinkSync(path.join(root, "header")); + fs.symlinkSync(path.join(root, "missing-header"), path.join(root, "header")); + assertRejectedUnchanged(root); +}); + +test("store corruption: ahead commit sequence plus torn tail preserves forensic evidence", () => { + const root = committedStore(); + fs.writeFileSync(path.join(root, "commit-seq.json"), '{"commitSeq":2}\n'); + fs.appendFileSync(path.join(root, "journal/000001.jsonl"), '{"torn":'); + assertRejectedUnchanged(root); +}); + +for (const corruption of ["null-batch", "missing-command", "invalid-command", "empty-events", "foreign-event", "event-sequence", "duplicate-command"]) { + test(`store corruption: ${corruption} journal fails closed and preserves torn tail`, () => { + const root = committedStore(); + const file = path.join(root, "journal/000001.jsonl"); + const original = fs.readFileSync(file, "utf8"); + const batch = JSON.parse(original); + if (corruption === "missing-command") delete batch.command; + if (corruption === "invalid-command") delete batch.command.requestHash; + if (corruption === "empty-events") batch.events = []; + if (corruption === "foreign-event") batch.events[0].projectId = OTHER; + if (corruption === "event-sequence") batch.events[0].commitSeq = 7; + if (corruption === "duplicate-command") { + batch.commitSeq = 2; + batch.command.firstCommitSeq = 2; + batch.command.lastCommitSeq = 2; + batch.events[0].commitSeq = 2; + } + fs.writeFileSync(file, (corruption === "duplicate-command" ? original : "") + + JSON.stringify(corruption === "null-batch" ? null : batch) + '\n{"torn":'); + assertRejectedUnchanged(root); + }); +} diff --git a/packages/taskflow-control/test/uds-malformed.test.ts b/packages/taskflow-control/test/uds-malformed.test.ts new file mode 100644 index 00000000..d2ff0074 --- /dev/null +++ b/packages/taskflow-control/test/uds-malformed.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { test, type TestContext } from "node:test"; +import { connectUdsClient } from "../src/uds.ts"; +import { PROTOCOL_MAJOR, type NegotiationHandshake } from "../src/schema/transport.ts"; + +const UNIX_ONLY = { skip: process.platform === "win32", timeout: 15_000 }; +const HELLO: NegotiationHandshake = { + protocolMajor: PROTOCOL_MAJOR, + supportedReadSchemas: ["taskflow.wire.v1"], + supportedWriteSchemas: ["taskflow.wire.v1"], + requiredFeatures: [], + offeredFeatures: [], + buildInfo: { packageVersion: "test", gitCommit: "test", schemaVersion: 1 }, +}; +const helloFrame = { type: "hello", hello: HELLO }; +const mutateFrame = { type: "rpc", id: 1, method: "mutate", fencingEpoch: 1 }; + +async function isolatedServer(t: TestContext, failEpoch = false) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-bad-uds-")); + const endpointPath = path.join(root, "control.sock"); + const child = spawn(process.execPath, [ + "--conditions=development", "--experimental-strip-types", "--unhandled-rejections=strict", + path.join(import.meta.dirname, "fixtures", "uds-malformed-server.ts"), + ], { + env: { ...process.env, TF_TEST_ENDPOINT: endpointPath, TF_TEST_FAIL_EPOCH: failEpoch ? "1" : "0" }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString(); }); + t.after(async () => { + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, "exit"); + child.kill("SIGKILL"); + await exited; + } + fs.rmSync(root, { recursive: true, force: true }); + }); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`server readiness timeout: ${stderr}`)), 5_000); + child.once("exit", () => { clearTimeout(timer); reject(new Error(`server exited: ${stderr}`)); }); + child.stdout.once("data", () => { clearTimeout(timer); resolve(); }); + }); + return { + endpointPath, + async probe() { + try { + const client = await connectUdsClient({ endpointPath, clientHello: HELLO, connectTimeoutMs: 1_000 }); + try { return await client.rpc<{ mutations: number }>("probe"); } + finally { client.close(); } + } catch (error) { + throw new Error(`valid client failed after fault; server exit=${child.exitCode}; ${stderr}`, { cause: error }); + } + }, + }; +} + +function exchangeUntilClosed(endpointPath: string, frames: unknown[]): Promise { + return new Promise((resolve, reject) => { + const socket = net.connect(endpointPath); + let response = ""; + const timer = setTimeout(() => { socket.destroy(); reject(new Error("bad client was not closed")); }, 2_000); + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => { response += chunk; }); + socket.on("error", () => { /* reset is a valid fail-closed outcome */ }); + socket.on("close", () => { clearTimeout(timer); resolve(response); }); + socket.on("connect", () => { socket.write(frames.map((frame) => JSON.stringify(frame)).join("\n") + "\n"); }); + }); +} + +test("uds: malformed hello shapes cannot terminate the shared server", UNIX_ONLY, async (t) => { + const server = await isolatedServer(t); + for (const patch of [ + { requiredFeatures: {} }, { offeredFeatures: {} }, { requiredFeatures: "feature" }, + { supportedReadSchemas: [null] }, { supportedWriteSchemas: [42] }, + { buildInfo: null }, { offeredFeatures: [false] }, + ]) { + const response = await exchangeUntilClosed(server.endpointPath, [{ type: "hello", hello: { ...HELLO, ...patch } }]); + assert.deepEqual(await server.probe(), { mutations: 0 }); + assert.match(response, /TF_PROTOCOL_INCOMPATIBLE/, JSON.stringify(patch)); + } +}); + +test("uds: frames pipelined after a failed connection never dispatch RPCs", UNIX_ONLY, async (t) => { + const server = await isolatedServer(t); + for (const frames of [ + [helloFrame, helloFrame, mutateFrame], + [{ type: "hello", hello: { ...HELLO, protocolMajor: 999 } }, helloFrame, mutateFrame], + [helloFrame, { type: "unknown" }, mutateFrame], + [helloFrame, { type: "rpc", id: 2 }, mutateFrame], + ]) { + await exchangeUntilClosed(server.endpointPath, frames); + assert.deepEqual(await server.probe(), { mutations: 0 }, JSON.stringify(frames)); + } +}); + +test("uds: an unexpected hello handler failure is contained to its connection", UNIX_ONLY, async (t) => { + const server = await isolatedServer(t, true); + await exchangeUntilClosed(server.endpointPath, [helloFrame, mutateFrame]); + assert.deepEqual(await server.probe(), { mutations: 0 }); +}); From dadef9faf903149d9351a9d21dfb02e0237b90ea Mon Sep 17 00:00:00 2001 From: heggria Date: Fri, 2 Oct 2026 16:23:47 +0800 Subject: [PATCH 09/30] test(control): cover existing project attach rejection --- .../taskflow-control/test/project-isolation.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/taskflow-control/test/project-isolation.test.ts b/packages/taskflow-control/test/project-isolation.test.ts index aa0807d9..b2b5cce7 100644 --- a/packages/taskflow-control/test/project-isolation.test.ts +++ b/packages/taskflow-control/test/project-isolation.test.ts @@ -5,6 +5,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { ControlHost, type ControlHostOptions } from "../src/control-host.ts"; import { createTeExecutionProvider, type TeExecutionAuthority } from "../src/te-provider.ts"; +import { openControlStore } from "../src/store/index.ts"; import { singletonPaths, type SingletonPaths } from "../src/singleton.ts"; import { PROTOCOL_MAJOR, type NegotiationHandshake } from "../src/schema/transport.ts"; @@ -51,11 +52,14 @@ const clientHello: NegotiationHandshake = { buildInfo: { packageVersion: "0.3.0", gitCommit: "abc", schemaVersion: 1 }, }; -test("project isolation: a different project fails closed before any ledger mutation", async (t) => { +for (const existing of [false, true]) { +test(`project isolation: a different ${existing ? "existing" : "new"} project fails closed before any ledger mutation`, async (t) => { const paths = makePaths(); const a = path.join(paths.controlHome, "a"); const b = path.join(paths.controlHome, "b"); const winner = new ControlHost(hostOptions(paths, { projectStorePath: a })); + if (existing) openControlStore(b).close(); + const bBefore = existing ? fs.readFileSync(path.join(b, "header")) : undefined; const other = new ControlHost(hostOptions(paths, { projectStorePath: b })); t.after(() => { other.stop(); winner.stop(); }); await winner.start(); @@ -64,10 +68,13 @@ test("project isolation: a different project fails closed before any ledger muta await assert.rejects(other.start(), /project store|projectStorePath/); assert.equal(other.state, "failed-closed"); assert.deepEqual(fs.readFileSync(path.join(a, "header")), before); - assert.equal(fs.existsSync(b), false); + assert.equal(fs.existsSync(b), existing); + if (existing) assert.deepEqual(fs.readFileSync(path.join(b, "header")), bBefore); assert.equal((await winner.dispatch<{ commitSeq: number }>("control.store.status", undefined, { fencingEpoch: 1 })).commitSeq, 0); }); +} + test("project isolation: attached clients without a project cannot access the winner ledger", async (t) => { const paths = makePaths(); const winner = new ControlHost(hostOptions(paths, { projectStorePath: path.join(paths.controlHome, "a") })); From 36370131f786be5cd1ad014cfce4e8d638e4ad6e Mon Sep 17 00:00:00 2001 From: heggria Date: Fri, 2 Oct 2026 16:28:57 +0800 Subject: [PATCH 10/30] feat: prepare complete Taskflow 1.0 release and repair Pi lifecycle regressions --- .github/workflows/ci.yml | 21 +- .github/workflows/publish.yml | 14 +- CHANGELOG.md | 27 + README.md | 44 +- README.zh-CN.md | 44 +- RELEASE.md | 277 ++-- SECURITY.md | 4 +- docs/internal/0.3.0-beta.2-release-plan.md | 2 + docs/internal/0.3.0-ga-scoreboard.md | 2 + docs/internal/0.3.0-release-plan.md | 2 + docs/internal/1.0.0-ga-scoreboard.md | 45 + docs/internal/1.0.0-release-plan.md | 62 + package.json | 15 +- .../test/experimental-release.test.ts | 4 +- .../test/packed-consumer-contract.test.ts | 4 +- packages/claude-taskflow/package.json | 2 +- .../plugin/.claude-plugin/plugin.json | 2 +- packages/claude-taskflow/plugin/.mcp.json | 2 +- .../claude-taskflow/test/mcp-server.test.ts | 2 +- packages/codex-taskflow/package.json | 2 +- .../plugin/.codex-plugin/plugin.json | 2 +- packages/codex-taskflow/plugin/.mcp.json | 2 +- packages/codex-taskflow/test/e2e-codex.mts | 2 + .../codex-taskflow/test/mcp-server.test.ts | 2 +- packages/grok-taskflow/package.json | 2 +- .../plugin/.grok-plugin/plugin.json | 2 +- packages/grok-taskflow/plugin/.mcp.json | 2 +- .../grok-taskflow/test/mcp-server.test.ts | 2 +- packages/hermes-taskflow/package.json | 2 +- .../plugin/hermes.config.snippet.yaml | 2 +- .../hermes-taskflow/test/mcp-server.test.ts | 2 +- packages/opencode-taskflow/package.json | 2 +- .../opencode-taskflow/plugin/opencode.json | 2 +- .../opencode-taskflow/test/mcp-server.test.ts | 2 +- packages/pi-taskflow/package.json | 12 +- packages/pi-taskflow/skills/taskflow/SKILL.md | 10 + packages/pi-taskflow/src/approval-view.ts | 67 +- packages/pi-taskflow/src/index.ts | 66 +- packages/pi-taskflow/src/runner.ts | 28 +- .../test/approval-dispatch.test.ts | 164 +++ packages/pi-taskflow/test/e2e-pi1.mts | 141 +++ .../pi-taskflow/test/fixtures/pi1-provider.ts | 69 + .../pi-taskflow/test/retry-lifecycle.test.ts | 194 +++ .../pi-taskflow/test/taskflow-params.test.ts | 58 +- packages/taskflow-control/package.json | 27 +- packages/taskflow-core/package.json | 2 +- packages/taskflow-core/src/runner-core.ts | 12 +- .../taskflow-core/test/adv-high-fix.test.ts | 11 +- packages/taskflow-core/test/agents.test.ts | 6 +- .../taskflow-core/test/define-file.test.ts | 6 +- packages/taskflow-core/test/detached.test.ts | 42 +- .../taskflow-core/test/library-store.test.ts | 2 +- .../test/resource-file-transaction.test.ts | 12 +- .../taskflow-core/test/tool-usage.test.ts | 55 + packages/taskflow-dsl/package.json | 2 +- packages/taskflow-dsl/test/e2e-dist-cli.mts | 5 +- packages/taskflow-hosts/package.json | 2 +- .../test/publish-verification.test.ts | 64 + .../test/release-contract.test.ts | 81 ++ .../test/release-dist-tags.test.ts | 50 + packages/taskflow-mcp-core/package.json | 2 +- pnpm-lock.yaml | 1127 ++++++----------- pnpm-workspace.yaml | 14 +- scripts/pack-release-packages.mjs | 16 +- scripts/smoke-packed-charterarc.mjs | 4 +- scripts/smoke-packed-packages.mjs | 34 +- scripts/verify-release-contract.d.mts | 3 + scripts/verify-release-contract.mjs | 80 ++ scripts/verify-release-dist-tags.d.mts | 1 + scripts/verify-release-dist-tags.mjs | 46 + skills-src/taskflow/entry.pi.md | 10 + website/app/[lang]/layout.tsx | 8 +- website/app/[lang]/page.tsx | 32 +- website/app/page.tsx | 2 +- .../en/compiler-runtime/typescript-dsl.mdx | 2 +- website/content/docs/en/getting-started.mdx | 10 +- .../content/docs/en/guides/claude-code.mdx | 4 +- website/content/docs/en/guides/codex.mdx | 4 +- website/content/docs/en/guides/grok-build.mdx | 6 +- website/content/docs/en/guides/hermes.mdx | 6 +- website/content/docs/en/guides/opencode.mdx | 6 +- website/content/docs/en/guides/pi.mdx | 4 +- website/content/docs/en/index.mdx | 20 +- website/content/docs/en/trusted-effects.mdx | 10 +- .../zh-cn/compiler-runtime/typescript-dsl.mdx | 2 +- .../content/docs/zh-cn/getting-started.mdx | 10 +- .../content/docs/zh-cn/guides/claude-code.mdx | 4 +- website/content/docs/zh-cn/guides/codex.mdx | 4 +- .../content/docs/zh-cn/guides/grok-build.mdx | 4 +- website/content/docs/zh-cn/guides/hermes.mdx | 6 +- .../content/docs/zh-cn/guides/opencode.mdx | 6 +- website/content/docs/zh-cn/guides/pi.mdx | 4 +- website/content/docs/zh-cn/index.mdx | 20 +- .../content/docs/zh-cn/trusted-effects.mdx | 10 +- website/next-env.d.ts | 1 + website/package.json | 2 +- 96 files changed, 2068 insertions(+), 1249 deletions(-) create mode 100644 docs/internal/1.0.0-ga-scoreboard.md create mode 100644 docs/internal/1.0.0-release-plan.md create mode 100644 packages/pi-taskflow/test/approval-dispatch.test.ts create mode 100644 packages/pi-taskflow/test/e2e-pi1.mts create mode 100644 packages/pi-taskflow/test/fixtures/pi1-provider.ts create mode 100644 packages/pi-taskflow/test/retry-lifecycle.test.ts create mode 100644 packages/taskflow-core/test/tool-usage.test.ts create mode 100644 packages/taskflow-hosts/test/release-contract.test.ts create mode 100644 packages/taskflow-hosts/test/release-dist-tags.test.ts create mode 100644 scripts/verify-release-contract.d.mts create mode 100644 scripts/verify-release-contract.mjs create mode 100644 scripts/verify-release-dist-tags.d.mts create mode 100644 scripts/verify-release-dist-tags.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0757bc65..d331a6de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,7 @@ jobs: strategy: fail-fast: false matrix: - pi: ["0.80.3", "0.85.1"] + pi: ["0.80.3", "1.0.0"] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 @@ -86,7 +86,7 @@ jobs: cache: "pnpm" - name: Install locked dependencies run: pnpm install --frozen-lockfile --registry https://registry.npmjs.org/ - # The regular Node 22/24 suite uses the locked 0.85.1 adapter dev peers. + # The regular Node 22/24 suite uses the locked 1.0.0 adapter dev peers. # Also exercise the former adapter resolution, rather than accidentally # claiming the root SDK version is used by every workspace package. - name: Install previous adapter peers @@ -102,6 +102,12 @@ jobs: run: pnpm run test:pi:peers env: PI_TEST_PEER_VERSION: ${{ matrix.pi }} + - name: Build and exercise real Pi 1.0 CLI, children, and RPC approvals + if: matrix.pi == '1.0.0' + run: | + pnpm --filter taskflow-core build + pnpm --filter pi-taskflow build + pnpm run test:e2e-pi e2e: # Required status context: keep this literal stable; the job now covers all MCP hosts. @@ -126,6 +132,9 @@ jobs: - name: Build all packages run: pnpm run build + - name: DSL built and clean-installed CLI E2E + run: pnpm --filter taskflow-dsl run test:e2e + # The codex MCP e2e spawns the real bin over stdio and drives the full # handshake + tool calls — no live model / no network. The other e2e # (test:e2e-codex) needs a live codex session, so it stays manual. @@ -205,10 +214,10 @@ jobs: - name: Build, pack, install, and smoke test all published packages run: pnpm run test:pack - # Private CharterArc is not a release package. Still pack it with its - # only dependency and smoke defineProject/runProject from a clean npm - # consumer so the private artifact stays directly runnable. - - name: Pack and smoke private CharterArc consumer + # CharterArc retains its separate experimental release. Pack it with its + # dependencies and smoke defineProject/runProject from a clean npm + # consumer without adding it to the ten stable release packages. + - name: Pack and smoke experimental CharterArc consumer run: pnpm run test:pack-charterarc website: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1c10a451..33d0c28b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -58,6 +58,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile --registry https://registry.npmjs.org/ + - name: Verify complete release contract + run: node scripts/verify-release-contract.mjs --published + - name: Typecheck run: pnpm run typecheck @@ -88,8 +91,8 @@ jobs: exit 1 fi VERSION="${TAG#v}" - if [[ "$VERSION" == *-* && "$VERSION" != 0.3.0-beta.* ]]; then - echo "::error::Only 0.3.0-beta.* prereleases are supported by this beta workflow (got $VERSION)" + if [[ "$VERSION" == *-* && ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+(\.[0-9]+)*$ ]]; then + echo "::error::Only numbered beta prereleases are supported by this workflow (got $VERSION)" exit 1 fi if grep -Fqx "## [$VERSION] — Unreleased" CHANGELOG.md; then @@ -243,6 +246,9 @@ jobs: verify_one opencode-taskflow verify_one grok-taskflow verify_one hermes-taskflow + # Artifact existence/provenance is insufficient when a registry tag + # was changed. Fail closed before the dependent GitHub Release job. + node scripts/verify-release-dist-tags.mjs "$VERSION" "$NPM_TAG" env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TRUSTED_OWNERS: "heggria,muyun" @@ -284,8 +290,8 @@ jobs: if RELEASE_JSON="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG_PATH}" 2>"$ERROR_FILE")"; then RELEASE_TAG="$(jq -er '.tag_name' <<<"$RELEASE_JSON")" RELEASE_TARGET="$(jq -er '.target_commitish' <<<"$RELEASE_JSON")" - RELEASE_DRAFT="$(jq -er '.draft' <<<"$RELEASE_JSON")" - RELEASE_PRERELEASE="$(jq -er '.prerelease' <<<"$RELEASE_JSON")" + RELEASE_DRAFT="$(jq -er '.draft | if type == "boolean" then tostring else error("release draft must be boolean") end' <<<"$RELEASE_JSON")" + RELEASE_PRERELEASE="$(jq -er '.prerelease | if type == "boolean" then tostring else error("release prerelease must be boolean") end' <<<"$RELEASE_JSON")" if [ "$RELEASE_TAG" != "$GITHUB_REF_NAME" ]; then echo "::error::Existing release tag $RELEASE_TAG does not match $GITHUB_REF_NAME" exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index a83a2310..beafe05f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,33 @@ All notable changes to taskflow are documented here. This project follows [Keep - HOL plugin-scanner highs: dummy test credentials use scanner placeholders / short literals; no `eval (` in comments; smoke/runner no longer place a `${…}` template within 30 chars of `spawn`. Local scan 81/100, 0 high. **Not GA.** +## [1.0.0] — Unreleased + +> **Release candidate; not tagged or published.** The ten existing public Taskflow packages target npm `latest` after release gates pass. This heading must be dated only when the release is authorized. The mature DAG/DSL/host/declared-filesystem-transaction contract is the stable scope; the unfinished control-plane RFC is not a 1.0 completion claim. + +### Changed + +- Align the root, ten public packages, plugin manifests and all MCP installation templates to `1.0.0`; retain exact internal dependency rewriting, deterministic packed consumers, npm provenance and all security gates. +- Validate the complete release contract before packing or publishing, including package membership, version/pin agreement, experimental-package isolation and dated release notes. +- Keep `taskflow-control` private and workspace-only, with proposed experimental wire contracts. Admission, parked approvals/CAS/receipts, global coordination, Windows pipes and WebUI remain outside the stable contract. CharterArc keeps its independent experimental release path. +- Prepare Pi 1.0 compatibility and owning-run identity propagation; release acceptance requires current packed-extension, real-process, retry, approval, TUI/RPC and resume evidence. +- Integrate dependency/Actions maintenance with retained Cisco skill-scan coverage. Historical scans using different analyzer versions do not prove historical findings were fixed. + +### Fixed + +- Let Pi finish transient retries and overflow compaction before deciding that an assistant error is terminal; preserve successful post-run compaction exits on Pi 0.80.3 and count tool-reported model usage once. +- Route Pi RPC approval through supported dialogs; reject cancellation, unavailable dialogs and aborted edits. Validate shorthand calls without an explicit action through the actual Pi tool schema. +- Reject cross-project attachment to the experimental single-store control host, preserve corrupt or identity-less ledgers, and isolate malformed UDS handshakes to the offending connection. +- Make release reruns accept valid non-draft GitHub releases, validate real changelog dates, and require every package's release dist-tag before creating a stable release. +- Correct macOS canonical-path test fixtures and wait for detached child completion before removing their workspaces. Add real Pi CLI and clean-installed package regressions alongside live Codex/Claude checks. +- Update the documentation site's Next.js, sharp and baseline-browser-mapping dependencies to patched versions. + +### Support boundaries + +- Pi interactive approval is supported; MCP/headless approval auto-rejects. Grok flows with budgets are rejected when usage cannot be observed. +- Declared filesystem effects use resource transactions. Resolve-only is not an OS sandbox; undeclared writes remain host-policy dependent. Secret/service references have no live backend. Event-kernel fallback and journal capacity limits remain explicit. +- Preparing this PR does not authorize merge, tag, npm publication or website deployment. Current evidence is tracked separately in `docs/internal/1.0.0-ga-scoreboard.md`. + ## [0.3.0-beta.2] — 2026-08-18 > npm `beta` dist-tag. **Not GA.** Control Plane slice: S2 Unix UDS + S3-min files-only store. Does **not** implement `#137` (`/tf web`) or `#95` (adaptive-authority isolation). TypeBox contracts and `wire-freeze.md` remain **PROPOSED**. `taskflow-control` is **not** in the ten-package publish set (`publishConfig.tag` stays `next`). diff --git a/README.md b/README.md index 2c9e01be..8bec36ab 100644 --- a/README.md +++ b/README.md @@ -11,23 +11,23 @@ **English** · [简体中文](./README.zh-CN.md) -[0.3 overview](#taskflow-03-trusted-effects) · [Quickstart](#quickstart) · [Docs](https://heggria.github.io/taskflow/en/docs) · [Examples](./examples) · [Changelog](./CHANGELOG.md) +[1.0 overview](#taskflow-03-trusted-effects) · [Quickstart](#quickstart) · [Docs](https://heggria.github.io/taskflow/en/docs) · [Examples](./examples) · [Changelog](./CHANGELOG.md) --- -# taskflow 0.3: make agent side effects inspectable +# taskflow 1.0: declarative coding-agent workflows -**taskflow is a declarative runtime for coding-agent workflows.** It turns a graph into a verifiable execution contract, runs phases in isolation, and keeps intermediate work out of the host conversation. In the 0.3 candidate, the contract also describes the effects a phase is allowed to propose. +**taskflow is a declarative runtime for coding-agent workflows.** It turns a graph into a verifiable execution contract, runs phases in isolation, and keeps intermediate work out of the host conversation. In the 1.0 release candidate, the contract also describes the effects a phase is allowed to propose. -> **Status: 0.3.0-beta.2 — beta channel, not GA.** Ships the Trusted Effects MVP plus a Control Plane slice: Unix UDS ControlHost listen/attach (S2) and a files-only project ControlStore (S3-min). Wire contracts remain **PROPOSED**. `taskflow-control` is workspace-only until a later 11-package decision. Does **not** implement `#137` (`/tf web`) or `#95` (adaptive-authority isolation). +> **Status: 1.0.0 release candidate — not yet tagged or published.** The ten public packages stabilize the existing DAG runtime, DSL, six host adapters and resource transactions for admitted declared filesystem targets. Pi interactive approvals are supported; MCP/headless approval auto-rejects. `taskflow-control` is private workspace experimental with proposed wire contracts; CharterArc retains a separate experimental release. The [1.0 release plan](./docs/internal/1.0.0-release-plan.md) defines the required verification and remaining gates. -## The 0.3 idea +## The declared-effect contract An agent can propose content. It should not become the mutation authority merely because it can run a command. -For admitted, declared filesystem-write targets, taskflow 0.3 makes the path explicit and routes the final mutation through the resources transaction: +For admitted, declared filesystem-write targets, taskflow 1.0 makes the path explicit and routes the final mutation through the resources transaction: ```text flow / .tf.ts @@ -47,39 +47,39 @@ flow / .tf.ts This is **not** an OS sandbox. Resolve-only hosts cannot prevent every write to an undeclared path. Secret and service references are typed and fail closed in this cut; they do not imply a vault or network backend. -## What is in the candidate +## The 1.0 public contract | Layer | What it does | Candidate status | |---|---|---| -| **Taskflow runtime** | Declarative DAGs, 12 phase types, budgets, retries, approvals, isolation, resume, replay, trace, and recompute | Existing 0.2 foundation | -| **Trusted Effects** | Closed `EffectIR`, `PathRef` / `SecretRef` / `ServiceRef`, confidentiality/integrity labels, effect validation, overlap checks, and ledger-backed `why-*` explainers | 0.3 MVP implementation | -| **Resource transaction** | Snapshot → lease → durable intent/permit → stage → commit, or restore and reject | 0.3 MVP implementation | +| **Taskflow runtime** | Declarative DAGs, 12 phase types, budgets, retries, approvals, isolation, resume, replay, trace, and recompute | Stable contract; current acceptance required | +| **Trusted Effects** | Closed `EffectIR`, `PathRef` / `SecretRef` / `ServiceRef`, confidentiality/integrity labels, effect validation, overlap checks, and ledger-backed `why-*` explainers | Declared-target contract | +| **Resource transaction** | Snapshot → lease → durable intent/permit → stage → commit, or restore and reject | Declared-target contract | | **Host adapters** | Pi, Codex, Claude Code, OpenCode, Grok Build, and Hermes Agent use the same flow contract | Existing host surface; support remains host-specific | -| **Control Plane** | ControlHost scaffold, proposed wire contracts, singleton/fencing, and hello negotiation; future stores, approvals, receipts, and coordination | Active 0.3-C track; not shipped and not the 0.3 MVP GA claim | -| **WebUI** | Runs, approvals, receipts, and evidence browsing | Planned in the 0.3-C sequence; not shipped in this candidate | +| **Control Plane** | Unix UDS singleton/fencing and files-only store; proposed wire contracts and incomplete admission/parked approvals/receipts/coordination | Private workspace experiment; excluded from stable API | +| **WebUI** | Runs, approvals, receipts, and evidence browsing | Planned; excluded from stable API | -The normative MVP definition is [`docs/internal/0.3.0-trusted-effects-mvp.md`](./docs/internal/0.3.0-trusted-effects-mvp.md). The 0.3-C Control Plane plan is [`docs/internal/0.3-c-control-plane-plan.md`](./docs/internal/0.3-c-control-plane-plan.md). +The [1.0 release plan](./docs/internal/1.0.0-release-plan.md) defines the stable scope and acceptance gates. The filesystem-transaction details are in [`docs/internal/0.3.0-trusted-effects-mvp.md`](./docs/internal/0.3.0-trusted-effects-mvp.md). The 0.3-C Control Plane plan is [`docs/internal/0.3-c-control-plane-plan.md`](./docs/internal/0.3-c-control-plane-plan.md). ## Quickstart -The 0.3 beta can be installed from npm, or exercised from a clean source checkout. Use Node.js **≥ 22.19.0**: +The 1.0 candidate can be exercised from this source checkout; exact npm pins become usable after publication. Use Node.js **≥ 22.19.0**: ```bash git clone https://github.com/heggria/taskflow.git cd taskflow -git checkout rc/0.3.0-trusted-effects +git checkout release/taskflow-1.0 pnpm install pnpm run typecheck pnpm test ``` -The beta commands below become usable after the tag workflow completes; until then they are release-target examples, not proof of registry availability. +The 1.0 commands below become usable after the tag workflow completes; until then they are release-target examples, not proof of registry availability. ```bash -npm install --global pi-taskflow@beta -npm install --global codex-taskflow@beta +npm install --global pi-taskflow@1.0.0 +npm install --global codex-taskflow@1.0.0 ``` -The host-specific plugin and MCP commands remain in the [host guides](https://heggria.github.io/taskflow/en/docs/guides/). Stable 0.2.x installs remain available through exact stable pins. +The host-specific plugin and MCP commands remain in the [host guides](https://heggria.github.io/taskflow/en/docs/guides/). Until publication, use a previously published exact version or the source checkout. Run the no-LLM Trusted Effects vertical-slice fixture: @@ -88,7 +88,7 @@ pnpm exec node --conditions=development --experimental-strip-types --test \ packages/taskflow-core/test/effects-e2e-fixture.test.ts ``` -This exercises the checked-in `examples/trusted-effects-write.json` path without a live LLM. For an interactive run, use the host guide for the adapter you already run. The stable 0.2 installation path remains documented separately in the [host guides](https://heggria.github.io/taskflow/en/docs/guides/). +This exercises the checked-in `examples/trusted-effects-write.json` path without a live LLM. For an interactive run, use the host guide for the adapter you already run. The [release guide](./RELEASE.md) covers packed-consumer validation and release gates. ## Declare an effect @@ -191,7 +191,7 @@ Host support is not a blanket security guarantee. Read the [host support baselin - Writes to undeclared paths remain host-policy dependent under resolve-only execution. - `SecretRef` and `ServiceRef` are typed handles only; no vault or live service adapter ships in this cut. - There is no FileBroker or full OS sandbox claim in 0.3 MVP. -- Control Plane stores, approvals, receipts, and WebUI are future 0.3-C stages, not proof that 0.3 is released or GA. +- Control Plane admission, parked approvals, receipts, global coordination and WebUI are incomplete workspace experiments, outside the 1.0 stable API. ## Development @@ -210,7 +210,7 @@ The monorepo contains the host-neutral `taskflow-core`, Trusted Effects and reso | Start here | Use it for | |---|---| -| [0.3 overview](https://heggria.github.io/taskflow/en/docs) | Candidate scope, status, and the honest security boundary | +| [1.0 overview](https://heggria.github.io/taskflow/en/docs) | Candidate scope, status, and the honest security boundary | | [Getting Started](https://heggria.github.io/taskflow/en/docs/getting-started) | First flow and host setup | | [Core Concepts](https://heggria.github.io/taskflow/en/docs/concepts/) | DAGs, isolation, verification, resume, and evidence | | [Compiler & Runtime](https://heggria.github.io/taskflow/en/docs/compiler-runtime/) | JSON, TypeScript DSL, FlowIR, replay, and recompute | diff --git a/README.zh-CN.md b/README.zh-CN.md index 045e258d..9a12ec01 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -11,23 +11,23 @@ [English](./README.md) · **简体中文** -[0.3 总览](#taskflow-03-trusted-effects) · [快速开始](#快速开始) · [文档](https://heggria.github.io/taskflow/zh-cn/docs) · [示例](./examples) · [变更记录](./CHANGELOG.md) +[1.0 总览](#taskflow-10声明式-coding-agent-工作流) · [快速开始](#快速开始) · [文档](https://heggria.github.io/taskflow/zh-cn/docs) · [示例](./examples) · [变更记录](./CHANGELOG.md) --- -# taskflow 0.3:让智能体副作用可检查 +# taskflow 1.0:声明式 coding-agent 工作流 -**taskflow 是面向 coding-agent 工作流的声明式运行时。** 它把任务图变成可验证的执行合同,让阶段隔离运行,并把中间过程留在宿主对话之外。在 0.3 candidate 中,这份合同还可以描述每个阶段被允许提出的副作用。 +**taskflow 是面向 coding-agent 工作流的声明式运行时。** 它把任务图变成可验证的执行合同,让阶段隔离运行,并把中间过程留在宿主对话之外。在 1.0 发布候选中,这份合同还可以描述每个阶段被允许提出的副作用。 -> **状态:0.3.0-beta.2——beta channel,尚未 GA。** 含 Trusted Effects MVP,以及 Control Plane 切片:Unix UDS ControlHost listen/attach(S2)和 files-only project ControlStore(S3 最小集)。Wire 合同仍为 **PROPOSED**。`taskflow-control` 在十一包决定之前只在 workspace。不实现 `#137`(`/tf web`)和 `#95`(权限隔离)。 +> **状态:1.0.0 发布候选——尚未打 tag 或发布。** 十个公开包的稳定范围是已有 DAG runtime、DSL、六个宿主适配器和已准入声明文件目标的 resource transaction。Pi 支持交互审批;MCP/headless 审批自动拒绝。`taskflow-control` 是 private workspace 实验,wire 合同仍为 proposed;CharterArc 保留独立 experimental 发布线。[1.0 发布计划](./docs/internal/1.0.0-release-plan.md)列出全面验证与剩余闸门。 -## 0.3 的核心想法 +## 声明文件副作用合同 智能体可以提出内容,但不应该因为能执行命令,就自动成为文件修改的最终权威。 -对于已准入、已声明的文件写入目标,taskflow 0.3 把路径写进合同,并让最终修改经过 resources transaction: +对于已准入、已声明的文件写入目标,taskflow 1.0 把路径写进合同,并让最终修改经过 resources transaction: ```text flow / .tf.ts @@ -47,40 +47,40 @@ flow / .tf.ts 这**不是 OS sandbox**。在 resolve-only 宿主上,taskflow 不能阻止所有对未声明路径的写入。Secret 和 service reference 在这一版只是类型化、失败关闭的句柄,并不代表已经有 vault 或网络后端。 -## candidate 里有什么 +## 1.0 公开合同 | 层 | 作用 | candidate 状态 | |---|---|---| -| **Taskflow runtime** | 声明式 DAG、12 种阶段、预算、重试、审批、隔离、续跑、replay、trace 与 recompute | 已有的 0.2 基础 | -| **Trusted Effects** | 封闭的 `EffectIR`、`PathRef` / `SecretRef` / `ServiceRef`、机密性/完整性标签、effect 校验、重叠检查与 ledger-backed `why-*` | 0.3 MVP 实现 | -| **Resource transaction** | snapshot → lease → durable intent/permit → stage → commit,或 restore and reject | 0.3 MVP 实现 | +| **Taskflow runtime** | 声明式 DAG、12 种阶段、预算、重试、审批、隔离、续跑、replay、trace 与 recompute | 稳定合同;仍需当前候选验收 | +| **Trusted Effects** | 封闭的 `EffectIR`、`PathRef` / `SecretRef` / `ServiceRef`、机密性/完整性标签、effect 校验、重叠检查与 ledger-backed `why-*` | 声明目标合同 | +| **Resource transaction** | snapshot → lease → durable intent/permit → stage → commit,或 restore and reject | 声明目标合同 | | **宿主适配器** | Pi、Codex、Claude Code、OpenCode、Grok Build、Hermes Agent 共用同一 flow 合同 | 已有宿主表面;能力仍按宿主区分 | -| **Control Plane** | ControlHost 脚手架、拟议中的 wire contract、singleton/fencing 与 hello 协商;后续再实现 store、审批、receipt 与协调 | 活跃的 0.3-C 轨道;尚未交付,也不是 0.3 MVP 的 GA 声明 | -| **WebUI** | runs、审批、receipts 与 evidence 浏览 | 0.3-C 计划中的后续阶段;当前 candidate 未交付 | +| **Control Plane** | Unix UDS singleton/fencing、files-only store 与拟议 wire contract;admission、parked 审批、receipt 和全局协调尚未完成 | private workspace 实验,不属于稳定 API | +| **WebUI** | runs、审批、receipts 与 evidence 浏览 | 计划中,不属于稳定 API | -规范性的 MVP 定义见 [`docs/internal/0.3.0-trusted-effects-mvp.md`](./docs/internal/0.3.0-trusted-effects-mvp.md)。0.3-C Control Plane 计划见 [`docs/internal/0.3-c-control-plane-plan.md`](./docs/internal/0.3-c-control-plane-plan.md)。 +[1.0 发布计划](./docs/internal/1.0.0-release-plan.md)定义稳定范围与验收闸门。文件事务细节见 [`docs/internal/0.3.0-trusted-effects-mvp.md`](./docs/internal/0.3.0-trusted-effects-mvp.md)。0.3-C Control Plane 计划见 [`docs/internal/0.3-c-control-plane-plan.md`](./docs/internal/0.3-c-control-plane-plan.md)。 ## 快速开始 -0.3 beta 可以从 npm 安装,也可以从源码 checkout 运行。准备 Node.js **≥ 22.19.0**: +1.0 候选可从源码 checkout 运行;精确 npm pin 在发布完成后可用。准备 Node.js **≥ 22.19.0**: ```bash git clone https://github.com/heggria/taskflow.git cd taskflow -git checkout rc/0.3.0-trusted-effects +git checkout release/taskflow-1.0 pnpm install pnpm run typecheck pnpm test ``` -以下 beta 命令在 tag workflow 完成后可用;在此之前它们只是发版目标示例,不代表 registry 已可获取。 +以下 1.0 命令在 tag workflow 完成后可用;在此之前它们只是发版目标示例,不代表 registry 已可获取。 ```bash -npm install --global pi-taskflow@beta -npm install --global codex-taskflow@beta +npm install --global pi-taskflow@1.0.0 +npm install --global codex-taskflow@1.0.0 ``` -各宿主的 plugin 与 MCP 命令见[宿主指南](https://heggria.github.io/taskflow/zh-cn/docs/guides/)。稳定的 0.2.x 安装仍可使用精确 stable pin。 +各宿主的 plugin 与 MCP 命令见[宿主指南](https://heggria.github.io/taskflow/zh-cn/docs/guides/)。发布之前可使用已有的精确版本,或源码 checkout。 运行不需要 LLM 的 Trusted Effects vertical-slice fixture: @@ -89,7 +89,7 @@ pnpm exec node --conditions=development --experimental-strip-types --test \ packages/taskflow-core/test/effects-e2e-fixture.test.ts ``` -它会在没有 live LLM 的情况下执行仓库内的 `examples/trusted-effects-write.json` 路径。要交互式运行,请按当前使用的宿主查看对应指南。稳定的 0.2 安装路径仍在[宿主指南](https://heggria.github.io/taskflow/zh-cn/docs/guides/)中单独说明。 +它会在没有 live LLM 的情况下执行仓库内的 `examples/trusted-effects-write.json` 路径。要交互式运行,请按当前使用的宿主查看对应指南。[发布指南](./RELEASE.md)说明打包消费验证和正式发布闸门。 ## 声明一个 effect @@ -192,7 +192,7 @@ validate → Taskflow JSON → FlowIR + content hash - 在 resolve-only 执行下,未声明路径的写入仍取决于宿主策略。 - `SecretRef` 与 `ServiceRef` 只是类型化句柄;这一版没有 vault 或 live service adapter。 - 0.3 MVP 不声称提供 FileBroker 或完整 OS sandbox。 -- Control Plane store、审批、receipt 与 WebUI 属于未来的 0.3-C 阶段,不证明 0.3 已发布或 GA。 +- Control Plane admission、parked 审批、receipt、全局协调与 WebUI 尚未完成,不属于 1.0 稳定 API。 ## 开发 @@ -211,7 +211,7 @@ pnpm run test:pack | 从这里开始 | 适用场景 | |---|---| -| [0.3 总览](https://heggria.github.io/taskflow/zh-cn/docs) | candidate 范围、状态与诚实的安全边界 | +| [1.0 总览](https://heggria.github.io/taskflow/zh-cn/docs) | candidate 范围、状态与诚实的安全边界 | | [快速开始](https://heggria.github.io/taskflow/zh-cn/docs/getting-started) | 第一个 flow 与宿主配置 | | [核心概念](https://heggria.github.io/taskflow/zh-cn/docs/concepts/) | DAG、隔离、验证、续跑与 evidence | | [编译器与运行时](https://heggria.github.io/taskflow/zh-cn/docs/compiler-runtime/) | JSON、TypeScript DSL、FlowIR、replay 与 recompute | diff --git a/RELEASE.md b/RELEASE.md index 6dd1e3f1..712b0bb4 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,160 +1,149 @@ -# Release Guide (monorepo) - -taskflow is a monorepo of ten independently published packages: - -| Package | npm name | What it is | -|---------|----------|------------| -| `packages/taskflow-core` | **`taskflow-core`** | Host-neutral engine (DSL, runtime, cache, verify). Zero host SDK deps. | -| `packages/taskflow-mcp-core` | **`taskflow-mcp-core`** | Host-neutral MCP server (stdio JSON-RPC + taskflow_* tools + DAG renderer). Depends on core. | -| `packages/taskflow-hosts` | **`taskflow-hosts`** | Shared host-runner collection: codex/claude/opencode/grok/hermes `SubagentRunner` impls + argv builders + event-stream parsers. Depends on core. | -| `packages/taskflow-dsl` | **`taskflow-dsl`** | TypeScript DSL CLI/package: erases `.tf.ts` to Taskflow JSON and optional FlowIR. Depends on core. | -| `packages/pi-taskflow` | **`pi-taskflow`** | Pi extension adapter. Keeps the original published name (no break for existing users). | -| `packages/codex-taskflow` | **`codex-taskflow`** | Codex npm delivery: re-exports the runner from `taskflow-hosts` + MCP bin. The plugin scaffold is repository/marketplace-distributed, not in the npm tarball. | -| `packages/claude-taskflow` | **`claude-taskflow`** | Claude Code npm delivery: re-exports the runner from `taskflow-hosts` + MCP bin. The plugin scaffold is repository/marketplace-distributed, not in the npm tarball. | -| `packages/opencode-taskflow` | **`opencode-taskflow`** | OpenCode delivery package: re-exports the runner from `taskflow-hosts` + MCP bin + config scaffold. | -| `packages/grok-taskflow` | **`grok-taskflow`** | Grok Build npm delivery: re-exports the runner from `taskflow-hosts` + MCP bin. The plugin scaffold is repository/marketplace-distributed, not in the npm tarball. | -| `packages/hermes-taskflow` | **`hermes-taskflow`** | Hermes Agent delivery package: re-exports the runner from `taskflow-hosts` + MCP bin + config scaffold. | - -Dependency order: `taskflow-mcp-core`, `taskflow-hosts`, `taskflow-dsl`, `pi-taskflow`, `codex-taskflow`, `claude-taskflow`, `opencode-taskflow`, `grok-taskflow`, and `hermes-taskflow` all depend on `taskflow-core` (`taskflow-mcp-core`, `taskflow-hosts`, and `taskflow-dsl` directly; the adapters via both `taskflow-hosts` and `taskflow-mcp-core`), so **core publishes first, then taskflow-mcp-core, taskflow-hosts, taskflow-dsl, then the adapters (including hermes)**. - -## One-time repository setup - -The beta release path is `v0.3.0-beta.2`: merge the reviewed release commit to `main`, then push the tag. `.github/workflows/publish.yml` validates the `0.3.0-beta.*` prerelease family, publishes the same ten packages to npm's `beta` dist-tag with provenance, and creates a prerelease GitHub Release. Do not publish from a workstation. - -## Pre-flight (always) +# Taskflow 1.0 release guide + +Taskflow `1.0.0` is a release candidate in this checkout. Preparing or merging +this PR does not publish it. Publish only after the gates below pass on the +final release commit and the release owner authorizes the tag. + +## Public contract and package set + +The stable contract is the existing declarative DAG runtime: twelve phase +kinds, validation/planning, retries and budgets where the host can enforce +them, Pi interactive approvals, resume/recompute/replay, background runs, +trace, the TypeScript DSL, and resource transactions for admitted declared +filesystem targets. MCP/headless approval phases reject automatically. Host +permissions and usage reporting remain host-specific; Grok rejects budgets +because its stream does not supply reliable usage. + +The ten packages below publish together at exactly `1.0.0`, in this order: + +1. `taskflow-core` +2. `taskflow-mcp-core` +3. `taskflow-hosts` +4. `taskflow-dsl` +5. `pi-taskflow` +6. `codex-taskflow` +7. `claude-taskflow` +8. `opencode-taskflow` +9. `grok-taskflow` +10. `hermes-taskflow` + +Root and package versions, Codex/Claude/Grok plugin versions, and every host +MCP template pin must match. Local internal dependencies stay `workspace:*`; +the deterministic packer rewrites them to the exact release version. Published +exports must use shipped JavaScript/declarations and never source-only +`development` conditions. Codex/Claude/Grok plugin scaffolds are distributed +from the repository, separately from their npm delivery packages. + +`taskflow-control` is private, workspace-only and experimental. Its Unix UDS +and store implementation does not constitute a complete control plane: +BoundPlan admission, global concurrency, parked approvals/CAS/receipts, +registry coordination, Windows named pipes and WebUI are incomplete. +CharterArc retains its separate experimental version/tag/workflow and is +excluded from the ten-package transaction. Neither experiment is a Taskflow +1.0 stable API. Resolve-only path checks are not an OS sandbox; undeclared +writes remain host-policy dependent. SecretRef/ServiceRef have no live +vault/service backend. High-scale journals and full kernel parity are not +claimed; the optional event kernel still falls back for unsupported features. + +## Required preparation and verification + +Use the repository-pinned pnpm and Node **22.19.0 or newer**. Run the full unit +suite on Node 22 and Node 24; keep the three-OS process-supervisor checks and +Pi SDK compatibility matrix. Save commands, exit status, host/SDK/model +versions and final commit SHA in `docs/internal/1.0.0-ga-scoreboard.md`. ```sh -pnpm install # links the workspaces -pnpm run typecheck # 0 errors (resolves taskflow-core to src via the dev condition) -pnpm test # full unit suite green -pnpm run build # emit dist/ for all ten packages (tsc → .js + .d.ts) -pnpm run test:pack # pack → clean install → public imports/bins for all ten +pnpm install --frozen-lockfile --registry https://registry.npmjs.org/ +node scripts/verify-release-contract.mjs --candidate +pnpm run typecheck +pnpm test +pnpm run build +node scripts/pack-release-packages.mjs .release-tarballs +node scripts/smoke-packed-packages.mjs .release-tarballs +pnpm run test:pack-charterarc +pnpm --filter taskflow-dsl run test:e2e +pnpm run test:e2e-codex-mcp +pnpm run test:e2e-codex-mcp-full +pnpm run test:e2e-claude-mcp +pnpm run test:e2e-opencode-mcp +pnpm run test:e2e-grok-mcp +pnpm run test:e2e-hermes-mcp +pnpm audit --prod +TASKFLOW_BASE_PATH=/taskflow pnpm --dir website run build ``` -### Skill coverage check (before every release) - -The skills are the LLM-facing API surface — an engine feature the skill doesn't -teach effectively does not exist. **Skills are authored ONCE in -`skills-src/taskflow/` and compiled per host** by `scripts/build-skills.mjs` -(pi → `packages/pi-taskflow/skills/taskflow/`, codex → -`packages/codex-taskflow/plugin/skills/taskflow/`). Never edit the generated -files — `skills-build.test.ts` fails CI on drift. For every feature/change in -this release's CHANGELOG section, verify: - -- [ ] New DSL fields, phase types, actions, and commands appear in the right - **source** layer: `core.md` (core DSL + actions), `patterns.md` (if it - changes best practice), `advanced.md` (context sharing / dynamic flows / - isolation / recompute), `configuration.md` (knobs), or the per-host - entry files (`entry.pi.md` / `entry.codex.md` / `entry.claude.md` / `entry.opencode.md` / `entry.grok.md` / `entry.hermes.md`) for host bindings. -- [ ] Host-only capabilities are wrapped in `` / - `` blocks — never teach a host a tool it can't reach. -- [ ] `node scripts/build-skills.mjs` ran and the generated files are committed. -- [ ] The `taskflow` tool description in the pi adapter (`src/index.ts`) lists - any new `action` values. -- [ ] Removed/renamed fields are purged from `skills-src/` (grep the old name). - -> **Why build and packed-consumer gates both exist.** Node refuses to -> type-strip `.ts` under `node_modules`, so packages ship `dist/*.js` and -> `.d.ts`. Every package also has `prepublishOnly` and -> `publishConfig.access: public`, but release safety does not rely on lifecycle -> hooks alone: CI and the tag workflow stage pnpm's publish-ready content, -> canonicalize its manifest, prove repeat-pack byte stability, install those -> exact tarballs into a clean npm consumer, reject leaked -> `workspace:*` ranges, and exercise public exports and bins. - -> **Note on internal dependencies.** Workspace package manifests use -> `workspace:*` locally so `pnpm install --frozen-lockfile` never depends on a -> not-yet-published release. The deterministic release packer converts those -> workspace ranges before `npm publish` receives the immutable tarball. Always -> publish `taskflow-core` first and bump all ten in lockstep. - -## Publish from a tag (the only supported release path) - -First merge the release commit to `main` and wait for every required check, -including `packed consumer (10 packages)`, to pass. From the updated `main`, push -the matching annotated tag: - -```sh -git switch main -git pull --ff-only origin main -git tag -a v0.3.0-beta.2 -m "Release v0.3.0-beta.2" -git push origin v0.3.0-beta.2 -``` - -`.github/workflows/publish.yml` then performs the complete release transaction: - -1. proves the tag resolves to the event commit and that commit belongs to - `origin/main`; -2. runs typecheck, unit tests and build, creates repeat-verified deterministic - tarballs, then runs the packed-consumer gate against those exact bytes; -3. checks the root, all ten package versions, plugin manifests, and pinned MCP - package versions against the tag; -4. publishes core first, then shared packages and delivery adapters, all with - public access and provenance; -5. creates the GitHub Release from the matching `CHANGELOG.md` section. +The package gate repeat-packs deterministic bytes, clean-installs all ten +local tarballs, rejects leaked workspace ranges, and exercises public exports +and bins. It must also be run on Node 24. Source or fixture checks alone do not +prove an installed adapter can drive its real host. -The workflow is safely rerunnable. An existing npm version is skipped only -after owner, repository/workflow provenance, tag commit, and byte-for-byte -local tarball integrity all match; an existing GitHub Release is likewise -validated before it is accepted. - -## Verify after publish +Before approval, test the packed Pi extension against Pi 1.0 with a real +process, run identity, terminal completion/reap, retry, context tools and +resume. Verify the Pi TUI (approve/reject/edit, long proposal scrolling, +confirmation, Escape/Ctrl-C, timeout) and RPC/headless approval rejection. +Run live executor E2E for every available supported host using isolated profiles +and declared model/binary overrides: ```sh -pnpm view taskflow-core version --registry=https://registry.npmjs.org/ -pnpm view taskflow-mcp-core version --registry=https://registry.npmjs.org/ -pnpm view taskflow-hosts version --registry=https://registry.npmjs.org/ -pnpm view taskflow-dsl version --registry=https://registry.npmjs.org/ -pnpm view pi-taskflow version --registry=https://registry.npmjs.org/ -pnpm view codex-taskflow version --registry=https://registry.npmjs.org/ -pnpm view claude-taskflow version --registry=https://registry.npmjs.org/ -pnpm view opencode-taskflow version --registry=https://registry.npmjs.org/ -pnpm view grok-taskflow version --registry=https://registry.npmjs.org/ -pnpm view hermes-taskflow version --registry=https://registry.npmjs.org/ +pnpm run test:e2e-pi +node --conditions=development --experimental-strip-types packages/pi-taskflow/test/e2e.mts +pnpm run test:e2e-pi-terminal-reap +pnpm run test:e2e-codex +pnpm run test:e2e-claude +pnpm run test:e2e-opencode +pnpm run test:e2e-grok ``` -Also verify the `Publish & Release` workflow completed successfully and that -the non-draft prerelease GitHub Release targets the tagged commit. A -partially published ten-package set is not a completed release; fix the cause -and rerun the same tag workflow rather than creating a replacement tag or -publishing missing packages manually. - -## Upgrade and rollback - -- Upgrade all host package pins as one transaction to `0.2.10`, restart/reload the - host's MCP/plugin registration, and verify `taskflow_version` reports `0.2.10`. -- This patch does not introduce a run-state migration. Keep `.pi/taskflows/` and - existing run history in place when upgrading or rolling back. -- All six hosts can roll back by pinning their delivery package to `0.2.9` and - restarting/reloading the host. Keep all taskflow package versions aligned; do - not mix a `0.2.10` adapter with `0.2.9` shared packages. -- Before rolling back, move any definitions from `.pi/taskflows/flows/**` back to - the legacy top-level taskflows directory and remove `scriptCwd: "flow"`; 0.2.9 - does not discover the nested convention or understand that field. - -## Install (end users) - -The 0.3 beta is prepared for npm's `beta` channel; after the tag workflow publishes it, use `@beta` explicitly. The stable examples below remain pinned to `0.2.10`. +Hermes currently has an MCP fixture suite, not a checked-in live executor E2E; +perform and record an installed Hermes list/verify/run round trip before +claiming live Hermes acceptance. Missing binaries, credentials or model access +are blocked checks, not passes. Process-fixture results and historical live +results must be labeled separately. Re-run SIGKILL recovery, writer races, +resource restoration, cancellation, descendant cleanup and detached resume +coverage in the full suite. Unix control transport tests stay Unix-only. + +For each changed capability, update `skills-src/taskflow/`, run +`pnpm run build:skills`, and verify the generated host skill drift guard. Keep +Plugin Security Scan's score 80/high-severity gate and completed Cisco skill +coverage, plus CodeQL and all required exact-SHA CI jobs. + +## Authorized tag and publish + +After review, all exact-SHA checks and the required live/TUI evidence pass, +merge to `main`, rerun required checks there, and replace the `1.0.0` changelog +heading's `Unreleased` with the actual release date. Commit that change and +verify `node scripts/verify-release-contract.mjs --published`. The release +owner may then push annotated `v1.0.0` from that verified main commit. + +`.github/workflows/publish.yml` alone publishes. It verifies tag/main ancestry, +the dated changelog, versions/pins, immutable reproducible packed consumers, +then publishes the ten packages with provenance to npm `latest`. Registry +owner, workflow/source commit and exact tarball integrity are verified for +every package, including already-existing versions on a rerun. A separate +least-privilege job creates the non-draft, non-prerelease GitHub Release only +after all package verification succeeds. A partial set is incomplete: repair +and rerun the same workflow; never replace the tag or manually publish a +missing adapter. The legacy beta dist-tag promotion workflow is not needed. + +## Install and verify after publication + +These are release-target examples until the tag workflow completes: ```sh -# Pi users: -pi install npm:pi-taskflow@beta - -# Codex users (plugin source; npm MCP package uses @beta): +pi install npm:pi-taskflow@1.0.0 codex plugin marketplace add heggria/taskflow codex plugin add taskflow@taskflow - -# Claude Code users (plugin source; npm MCP package uses @beta): claude plugin marketplace add heggria/taskflow claude plugin install claude-taskflow@taskflow - -# OpenCode users (beta MCP server): -opencode mcp add taskflow -- npx -y -p opencode-taskflow@beta opencode-taskflow-mcp - -# Grok Build (beta MCP package) -grok mcp add taskflow -- npx -y -p grok-taskflow@beta grok-taskflow-mcp - -# Hermes Agent (beta MCP package) -# 0.3 beta candidate (after publication, select @beta) -hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@beta hermes-taskflow-mcp +opencode mcp add taskflow -- npx -y -p opencode-taskflow@1.0.0 opencode-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@1.0.0 grok-taskflow-mcp +hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@1.0.0 hermes-taskflow-mcp ``` + +Verify all ten npm versions and `dist-tags.latest=1.0.0`, the completed publish +workflow, exact tag commit and stable GitHub Release, then installed +`taskflow_version=1.0.0`. Keep `.pi/taskflows/` and run history when upgrading. +Pin a previous complete ten-package version set to roll back, restart/reload +the host registrations, and verify their reported version. Do not mix adapter +and shared package versions; inspect definitions for fields unsupported by +the selected earlier release before rerunning them. diff --git a/SECURITY.md b/SECURITY.md index d4c2f144..a436192f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -27,8 +27,8 @@ The runtime has intentional hardening: `realpath`-based path containment, runId | Version | Support | |---------|---------| -| Latest stable npm release (`v0.2.10`) | ✅ Active | -| `0.3.0-beta.2` beta channel | ⚠️ Pre-release; test-only support | +| Published stable npm line | ✅ Active; use the currently published exact version | +| `1.0.0` release candidate | ⚠️ Pending release verification and publication; not a released stable version | | Earlier versions | ❌ Unsupported — upgrade to the latest npm release | ## Disclosure diff --git a/docs/internal/0.3.0-beta.2-release-plan.md b/docs/internal/0.3.0-beta.2-release-plan.md index aea1b63f..80e3d182 100644 --- a/docs/internal/0.3.0-beta.2-release-plan.md +++ b/docs/internal/0.3.0-beta.2-release-plan.md @@ -1,5 +1,7 @@ # 0.3.0-beta.2 — Control Plane 最小可跑增量 +> Historical plan/evidence. The current PR release target is superseded by [`1.0.0-release-plan.md`](./1.0.0-release-plan.md) and [`1.0.0-ga-scoreboard.md`](./1.0.0-ga-scoreboard.md). Historical status below is preserved; it is not current 1.0 acceptance evidence. + > **Status:** APPROVED — 2026-08-13 用户指令「开始,记得用 kanban」。三路审查 APPROVE-WITH-FIXES 已吸收。开工令:从 `origin/main` 拉 `rc/0.3.0-beta.2`,Kanban 链式施工;停在 Draft PR + exact-SHA CI,未经新指令不得 Ready / merge / tag / npm。 > **Date:** 2026-08-13 > **Reviews:** honesty / architecture / release-gate(`deleg_fc8005c7`) diff --git a/docs/internal/0.3.0-ga-scoreboard.md b/docs/internal/0.3.0-ga-scoreboard.md index 12678337..09abebaa 100644 --- a/docs/internal/0.3.0-ga-scoreboard.md +++ b/docs/internal/0.3.0-ga-scoreboard.md @@ -1,5 +1,7 @@ # 0.3 Trusted Effects — GA Scoreboard +> Historical plan/evidence. The current PR release target is superseded by [`1.0.0-release-plan.md`](./1.0.0-release-plan.md) and [`1.0.0-ga-scoreboard.md`](./1.0.0-ga-scoreboard.md). Historical status below is preserved; it is not current 1.0 acceptance evidence. + > Living ledger. **Do not claim GA** unless L6 is PASS with human tag evidence. > > **beta.2 (2026-08-18):** `rc/0.3.0-beta.2` tip `bee8586` — Draft PR **#142** exact-SHA CI GREEN (run `32144803693`, 10/10 jobs) as **candidate** evidence only. **`v0.3.0-beta.2` is NOT tagged or published.** Published 0.3 beta remains **`v0.3.0-beta.1`** (tag `561cfd3`, publish run `31621938101`). `#137` / `#95` out of scope. diff --git a/docs/internal/0.3.0-release-plan.md b/docs/internal/0.3.0-release-plan.md index 1931db2c..2da6c382 100644 --- a/docs/internal/0.3.0-release-plan.md +++ b/docs/internal/0.3.0-release-plan.md @@ -1,5 +1,7 @@ # 0.3.0 Trusted Effects — beta.1 release plan +> Historical plan/evidence. The current PR release target is superseded by [`1.0.0-release-plan.md`](./1.0.0-release-plan.md) and [`1.0.0-ga-scoreboard.md`](./1.0.0-ga-scoreboard.md). Historical status below is preserved; it is not current 1.0 acceptance evidence. + ## Current state | Item | Value | diff --git a/docs/internal/1.0.0-ga-scoreboard.md b/docs/internal/1.0.0-ga-scoreboard.md new file mode 100644 index 00000000..e1eed70b --- /dev/null +++ b/docs/internal/1.0.0-ga-scoreboard.md @@ -0,0 +1,45 @@ +# Taskflow 1.0.0 acceptance scoreboard + +Status: **candidate / not published**. Updated 2026-10-02 after local verification. +This ledger replaces the current-release interpretation of the historical +0.3 scoreboard. Local checks use macOS, Node 24.21.0, pnpm 11.17.0 and Pi 1.0.0. +The final PR head's GitHub checks are the authoritative Node 22/Linux/Windows +and remote security evidence; pending rows must not be inferred from local passes. + +| Gate | State | Evidence | +|---|---|---| +| Public scope/version pins | PASS (working tree) | `node scripts/verify-release-contract.mjs --candidate`: ten packages at 1.0.0; exact plugin/MCP pins; private experimental control | +| Release guard regressions | PASS locally | 23 focused contract/date/dist-tag/provenance tests; includes execution of the workflow's existing-release branch and false booleans | +| Node 24 full suite/typecheck | PASS locally | `pnpm test`: 2554 passed, zero failed/skipped; added existing-project isolation case subsequently passed separately. `pnpm run typecheck` passes | +| Node 22 full suite | REMOTE GATE | final PR head, not historical CI | +| All-package build/DSL dist E2E | PASS locally | `pnpm run build`; `pnpm --filter taskflow-dsl run test:e2e` builds, packs and clean-installs the CLI, including rejection cases | +| Deterministic ten-package clean consumer | PASS on Node 24; Node 22 remote gate | 27 explicit imports, 84 wildcard exports, six bins, repeated deterministic packs and clean npm installation | +| Five MCP server E2E | PASS locally | all five stdio suites plus 16 comprehensive Codex MCP checks; fixture executors. TE/background/cancellation/resume also have full-suite regression coverage | +| Pi 1.0 packed real process | PASS locally | 17 cases use actual Pi CLI, packed extension and spawned children; deterministic local provider, no live model. Covers shorthand, map/reduce, context handshake, codemode, retry/retry exhaustion, usage, terminal reap, cancellation and RPC approval | +| Pi identity/resume and legacy lifecycle | PASS at stated scope | regression suite covers owning-run propagation and resume; recorded real 0.80.3 CLI threshold/overflow compaction stream exits successfully through current runner. This is not a separate live-model resume E2E | +| Pi TUI interactive and RPC/headless approval | PASS at stated scope | actual PTY: fullscreen approve/reject, normal approve/Escape. Real RPC: approve/reject/cancel/edit/edit-cancel/abort; print rejects. Long preview/confirmation/abort/error paths also covered by component/requester tests; not every terminal/keyboard combination exercised | +| Current live host executors | PASS for available Codex and Claude | Codex `gpt-6.1-sol`/high: 3-phase fruit→uppercase→TrustedEffects persistence, 39.2s. Claude `haiku`: 2-phase output transfer, 21.3s. Both use real authenticated CLIs | +| OpenCode/Grok/Hermes live execution | NOT RUN | binaries unavailable locally; MCP fixture passes do not establish live model acceptance | +| Process SIGKILL/recovery/restore/cleanup | PASS locally; three-OS remote gate | full suite includes crash recovery/writer races/resource restoration/detached cleanup; added control corruption and malformed UDS cases. Control remains Unix-only experimental | +| Website export/audit | PASS locally | production static export with `/taskflow` base path; patched Next.js 16.3.6, sharp 0.35.4, baseline-browser-mapping 2.11.0; `pnpm audit --prod` reports zero vulnerabilities | +| Remote security | REMOTE GATE | exact-head CodeQL and Plugin Security Scan, retaining Cisco coverage and severity/score gates | +| Final exact-SHA remote CI | PENDING | do not reuse PR173/174 or historical beta green as integrated-head evidence | +| Published artifacts and stable GitHub Release | NOT DONE | no tag/npm/release action authorized or performed | + +The supported stable scope is the mature DAG/DSL/six-host/declared-target +transaction contract. Private control-plane wire contracts remain proposed; +parked approval/Receipt/WebUI/global coordination/Windows pipes are not +accepted features. CharterArc stays independently experimental. Pi interactive +approval and fail-closed MCP/headless rejection are existing runtime features, +separate from the unfinished control-plane approval protocol. + +Local full logs are retained in the parent task workspace at +`work/taskflow-release-logs/`; they are not shipped in npm packages. Earlier +red runs are retained: macOS `/var` canonical-path assertions and detached +cleanup race; hard-coded DSL version; a transient release-regexp edit during +parallel integration. The frozen source suite and affected E2Es pass after +the fixes. No red run is treated as an acceptance pass. + +Do not mark 1.0 released or GA from a candidate build, green test count, fixture +MCP, prior PR CI or historical live evidence. The publication row requires the +actual authorized main/tag/registry/GitHub evidence for all ten artifacts. diff --git a/docs/internal/1.0.0-release-plan.md b/docs/internal/1.0.0-release-plan.md new file mode 100644 index 00000000..550ea377 --- /dev/null +++ b/docs/internal/1.0.0-release-plan.md @@ -0,0 +1,62 @@ +# Taskflow 1.0.0 release preparation + +Status: **IMPLEMENTING / NOT RELEASED**. This plan supersedes the beta.2 +release target for PR #142; older plans and evidence remain historical. +The authorized scope is a complete release preparation PR, comprehensive +verification, Pi 1.0 retry/approval compatibility and E2E regression fixes. +It does not authorize merge, tag, npm publication or website deployment. + +## Contract + +The ten existing public packages stabilize the mature Taskflow DAG contract: +JSON and TypeScript DSL, twelve phase kinds, validation/planning, live host +execution, budget/retry contracts, interactive Pi approval, fail-closed +headless approval, context isolation, background runs, cancellation, resume, +trace/replay/recompute and admitted declared-filesystem resource transactions. +Host-specific capabilities remain explicit. Pi SDK 1.0 and retained 0.80.3 +compatibility must be verified; a source build does not prove installed use. + +`taskflow-control` is private workspace experimental. UDS/store scaffolding is +retained and tested without claiming completed admission, global coordination, +parked approvals/CAS/receipts, registry, Windows pipes or WebUI. CharterArc +continues its separate experimental package/version/workflow. The optional +kernel's advanced-feature fallback, resolve-only path limitations, missing +secret/service backends and unbenchmarked journal capacity remain documented. + +## Release invariants + +- Root, ten public package versions, three plugin manifests and five host MCP + template pins equal `1.0.0`; package membership comes from the shared release + contract. Experiments cannot become stable dependencies. +- Local internal dependencies use `workspace:*`; deterministic packed manifests + pin the exact release version. Fresh npm consumers resolve only the local + tarballs for Taskflow packages and exercise all exports/declarations/bins. +- The candidate changelog stays **Unreleased**. Publication requires a separately + authorized dated heading, annotated tag on a verified main commit, npm + provenance, owner/repository/workflow/commit/integrity verification, all ten + latest dist-tags, then a stable GitHub Release. Partial publication is failure. +- Exact release-commit CI evidence replaces historical beta evidence. Preserve + Node 22/24, three-OS process checks, Pi peer matrix, packed consumer, MCP E2E, + website, CodeQL and retained Cisco/high-severity scanner gates. + +## Required evidence + +| Gate | Required result | +|---|---| +| Contract | `node scripts/verify-release-contract.mjs --candidate`; negative tests reject drift, floating pins and experimental promotion | +| Source | typecheck and full suite on Node 22/24; all-package build; no unexamined failed/skipped test | +| Package | repeated deterministic pack, clean-installed ten-package import/bin/extension smoke on Node 22/24; separate CharterArc packed consumer | +| DSL | built `taskflow-dsl` E2E; examples and public declaration exports | +| MCP | all five built/source stdio servers: initialize/list/verify/run, comprehensive malformed-input/SVG/TE checks, cancellation/background lifecycle | +| Pi real process | installed packed extension + actual Pi 1.0 process, run identity, final completion/terminal reap, context tools, retry, resume; fixtures labeled as fixtures | +| Pi TUI/RPC | approve/reject/edit, confirmation, long preview, Escape/Ctrl-C, timeout; real RPC/headless auto-reject contract | +| Other live hosts | current Codex/Claude/OpenCode/Grok executor E2E; installed Hermes list/verify/run/declared fs.write round trip, or explicit blocked evidence and no live claim | +| Crash/recovery | SIGKILL recovery, writer races, atomicity, resource restore, cancellation/descendant cleanup, detached resume; Unix UDS tests never promoted to Windows support | +| Website/security | docs/sample checks and `/taskflow` production export; audit triage; CodeQL and scanner with completed Cisco skill coverage | +| Release | explicit owner approval, dated changelog, final main SHA checks, authorized tag, ten verified registry artifacts/dist-tags and stable GitHub Release | + +The executable commands are in `RELEASE.md`. Record commands, exit codes, +Node/pnpm/host/model versions, SHA and fixture/live distinctions in +`1.0.0-ga-scoreboard.md`. Unavailable credentials/binaries/models and tests +that were not run remain blocked/pending. Historical 4524d2d live Codex and +bee8586 beta.2 CI are not current 1.0 acceptance evidence. diff --git a/package.json b/package.json index d4e7fc32..1b4b4c88 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "pi-taskflow-monorepo", - "version": "0.3.0-beta.2", + "version": "1.0.0", "private": true, - "description": "Monorepo for the Taskflow engine and DSL, host adapters, delivery packages, documentation, and the private CharterArc experiment.", + "description": "Monorepo for the Taskflow engine and DSL, host adapters, delivery packages, documentation, and the separately released CharterArc experiment.", "type": "module", "engines": { "node": ">=22.19.0" @@ -40,13 +40,14 @@ "test:e2e-pi-terminal-reap": "node --conditions=development --experimental-strip-types packages/pi-taskflow/test/e2e-terminal-reap.mts", "test:hermes": "PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --conditions=development --experimental-strip-types --test 'packages/hermes-taskflow/test/*.test.ts'", "test:e2e-hermes-mcp": "node --conditions=development --experimental-strip-types packages/hermes-taskflow/test/e2e-hermes-mcp.mts", - "test:pi:peers": "node scripts/verify-pi-peers.mjs && pnpm run test:pi" + "test:pi:peers": "node scripts/verify-pi-peers.mjs && pnpm run test:pi", + "test:e2e-pi": "node --experimental-strip-types packages/pi-taskflow/test/e2e-pi1.mts" }, "devDependencies": { - "@earendil-works/pi-agent-core": "^0.85.1", - "@earendil-works/pi-ai": "^0.85.1", - "@earendil-works/pi-coding-agent": "^0.85.1", - "@earendil-works/pi-tui": "^0.85.1", + "@earendil-works/pi-agent-core": "1.0.0", + "@earendil-works/pi-ai": "1.0.0", + "@earendil-works/pi-coding-agent": "1.0.0", + "@earendil-works/pi-tui": "1.0.0", "@types/node": "^26", "charterarc": "workspace:*", "taskflow-control": "workspace:*", diff --git a/packages/charterarc/test/experimental-release.test.ts b/packages/charterarc/test/experimental-release.test.ts index 580ae78b..d43694fe 100644 --- a/packages/charterarc/test/experimental-release.test.ts +++ b/packages/charterarc/test/experimental-release.test.ts @@ -23,9 +23,9 @@ test("experimental release: package and workflow cannot promote latest", async ( assert.equal(manifest.publishConfig?.access, "public"); assert.equal(manifest.scripts?.prepublishOnly, "npm run build"); - const stablePacker = await read("scripts/pack-release-packages.mjs"); + const stablePacker = await read("scripts/verify-release-contract.mjs"); const stableNames = - /export const RELEASE_PACKAGE_NAMES = \[([\s\S]*?)\];/.exec(stablePacker)?.[1] ?? ""; + /export const STABLE_PACKAGE_NAMES = \[([\s\S]*?)\];/.exec(stablePacker)?.[1] ?? ""; assert.doesNotMatch(stableNames, /["']charterarc["']/); const stableWorkflow = await read(".github/workflows/publish.yml"); diff --git a/packages/charterarc/test/packed-consumer-contract.test.ts b/packages/charterarc/test/packed-consumer-contract.test.ts index c4993688..de8396cb 100644 --- a/packages/charterarc/test/packed-consumer-contract.test.ts +++ b/packages/charterarc/test/packed-consumer-contract.test.ts @@ -79,9 +79,9 @@ test("packed consumer: verify the pre-stable CharterArc artifact without publish "pnpm pack does not accept --store-dir and must not point a fresh store at repository node_modules", ); - const releasePacker = await read("scripts/pack-release-packages.mjs"); + const releasePacker = await read("scripts/verify-release-contract.mjs"); const releaseNamesSource = - /export const RELEASE_PACKAGE_NAMES = \[([\s\S]*?)\];/.exec(releasePacker)?.[1] ?? ""; + /export const STABLE_PACKAGE_NAMES = \[([\s\S]*?)\];/.exec(releasePacker)?.[1] ?? ""; const releaseNames = [...releaseNamesSource.matchAll(/"([^"]+)"/g)].map((match) => match[1]); assert.equal( releaseNames.includes("charterarc"), diff --git a/packages/claude-taskflow/package.json b/packages/claude-taskflow/package.json index 790a82bf..a3d54ad1 100644 --- a/packages/claude-taskflow/package.json +++ b/packages/claude-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "claude-taskflow", - "version": "0.3.0-beta.2", + "version": "1.0.0", "description": "Run taskflow on Claude Code: the npm tarball provides the Claude subagent runner and MCP server; the plugin is distributed through the repository marketplace.", "keywords": [ "claude", diff --git a/packages/claude-taskflow/plugin/.claude-plugin/plugin.json b/packages/claude-taskflow/plugin/.claude-plugin/plugin.json index 15a2aae1..5b2ee798 100644 --- a/packages/claude-taskflow/plugin/.claude-plugin/plugin.json +++ b/packages/claude-taskflow/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "taskflow", - "version": "0.3.0-beta.2", + "version": "1.0.0", "description": "Declarative, verifiable DAG orchestration for Claude Code subagents — fan-out, gates, loops, tournaments, approvals, resumable runs, and saveable commands, with intermediate transcripts kept out of your context.", "author": { "name": "heggria", diff --git a/packages/claude-taskflow/plugin/.mcp.json b/packages/claude-taskflow/plugin/.mcp.json index 1da1b8a4..380ac4af 100644 --- a/packages/claude-taskflow/plugin/.mcp.json +++ b/packages/claude-taskflow/plugin/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "taskflow": { "command": "npx", - "args": ["-y", "-p", "claude-taskflow@0.3.0-beta.2", "claude-taskflow-mcp"] + "args": ["-y", "-p", "claude-taskflow@1.0.0", "claude-taskflow-mcp"] } } } diff --git a/packages/claude-taskflow/test/mcp-server.test.ts b/packages/claude-taskflow/test/mcp-server.test.ts index e2749d6f..24577ef6 100644 --- a/packages/claude-taskflow/test/mcp-server.test.ts +++ b/packages/claude-taskflow/test/mcp-server.test.ts @@ -45,7 +45,7 @@ test("claude mcp: initialize returns the protocol version + serverInfo", async ( assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-claude"); - assert.equal(res.result.serverInfo.version, "0.3.0-beta.2"); + assert.equal(res.result.serverInfo.version, "1.0.0"); }); test("claude mcp: tools/list exposes the same taskflow tools as codex", async () => { diff --git a/packages/codex-taskflow/package.json b/packages/codex-taskflow/package.json index 24f5c2dd..3d6b9a85 100644 --- a/packages/codex-taskflow/package.json +++ b/packages/codex-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "codex-taskflow", - "version": "0.3.0-beta.2", + "version": "1.0.0", "description": "Run taskflow on OpenAI Codex: the npm tarball provides the Codex subagent runner and MCP server; the plugin is distributed through the repository marketplace.", "keywords": [ "codex", diff --git a/packages/codex-taskflow/plugin/.codex-plugin/plugin.json b/packages/codex-taskflow/plugin/.codex-plugin/plugin.json index 36a25385..8097c4ab 100644 --- a/packages/codex-taskflow/plugin/.codex-plugin/plugin.json +++ b/packages/codex-taskflow/plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "taskflow", - "version": "0.3.0-beta.2", + "version": "1.0.0", "description": "Declarative, verifiable DAG orchestration for Codex subagents — fan-out, gates, loops, tournaments, approvals, resumable runs, and saveable commands, with intermediate transcripts kept out of your context.", "author": { "name": "heggria", diff --git a/packages/codex-taskflow/plugin/.mcp.json b/packages/codex-taskflow/plugin/.mcp.json index 90d1f5ec..edd0f058 100644 --- a/packages/codex-taskflow/plugin/.mcp.json +++ b/packages/codex-taskflow/plugin/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "taskflow": { "command": "npx", - "args": ["-y", "-p", "codex-taskflow@0.3.0-beta.2", "codex-taskflow-mcp"], + "args": ["-y", "-p", "codex-taskflow@1.0.0", "codex-taskflow-mcp"], "tool_timeout_sec": 1800 } } diff --git a/packages/codex-taskflow/test/e2e-codex.mts b/packages/codex-taskflow/test/e2e-codex.mts index 9abc7e3a..6a3d0531 100644 --- a/packages/codex-taskflow/test/e2e-codex.mts +++ b/packages/codex-taskflow/test/e2e-codex.mts @@ -32,6 +32,8 @@ const AGENTS: AgentConfig[] = [ systemPrompt: "You are terse. Reply with the minimum text required, no preamble.", source: "user", filePath: "", + ...(process.env.TASKFLOW_E2E_CODEX_MODEL ? { model: process.env.TASKFLOW_E2E_CODEX_MODEL } : {}), + ...(process.env.TASKFLOW_E2E_CODEX_THINKING ? { thinking: process.env.TASKFLOW_E2E_CODEX_THINKING } : {}), }, ]; diff --git a/packages/codex-taskflow/test/mcp-server.test.ts b/packages/codex-taskflow/test/mcp-server.test.ts index 453a8639..589a97b4 100644 --- a/packages/codex-taskflow/test/mcp-server.test.ts +++ b/packages/codex-taskflow/test/mcp-server.test.ts @@ -51,7 +51,7 @@ test("mcp: initialize returns the protocol version + serverInfo codex expects", assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-codex"); - assert.equal(res.result.serverInfo.version, "0.3.0-beta.2"); + assert.equal(res.result.serverInfo.version, "1.0.0"); }); test("mcp: tools/list exposes the taskflow tools with schemas", async () => { diff --git a/packages/grok-taskflow/package.json b/packages/grok-taskflow/package.json index 837a4f72..8ea57a03 100644 --- a/packages/grok-taskflow/package.json +++ b/packages/grok-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "grok-taskflow", - "version": "0.3.0-beta.2", + "version": "1.0.0", "description": "Run taskflow on Grok Build: the npm tarball provides the Grok subagent runner and MCP server; the plugin is distributed through the repository marketplace.", "keywords": [ "grok", diff --git a/packages/grok-taskflow/plugin/.grok-plugin/plugin.json b/packages/grok-taskflow/plugin/.grok-plugin/plugin.json index 361f0ab2..44daa8ce 100644 --- a/packages/grok-taskflow/plugin/.grok-plugin/plugin.json +++ b/packages/grok-taskflow/plugin/.grok-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "taskflow", - "version": "0.3.0-beta.2", + "version": "1.0.0", "description": "Declarative, verifiable DAG orchestration for Grok Build subagents — fan-out, gates, loops, tournaments, approvals, resumable runs, and saveable commands, with intermediate transcripts kept out of your context.", "author": { "name": "heggria", diff --git a/packages/grok-taskflow/plugin/.mcp.json b/packages/grok-taskflow/plugin/.mcp.json index 362339ae..680b3b22 100644 --- a/packages/grok-taskflow/plugin/.mcp.json +++ b/packages/grok-taskflow/plugin/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "taskflow": { "command": "npx", - "args": ["-y", "-p", "grok-taskflow@0.3.0-beta.2", "grok-taskflow-mcp"], + "args": ["-y", "-p", "grok-taskflow@1.0.0", "grok-taskflow-mcp"], "tool_timeout_sec": 1800 } } diff --git a/packages/grok-taskflow/test/mcp-server.test.ts b/packages/grok-taskflow/test/mcp-server.test.ts index 9fb0c473..09700a07 100644 --- a/packages/grok-taskflow/test/mcp-server.test.ts +++ b/packages/grok-taskflow/test/mcp-server.test.ts @@ -45,7 +45,7 @@ test("grok mcp: initialize returns the protocol version + serverInfo", async () assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-grok"); - assert.equal(res.result.serverInfo.version, "0.3.0-beta.2"); + assert.equal(res.result.serverInfo.version, "1.0.0"); }); test("grok mcp: tools/list exposes the same taskflow tools as other hosts", async () => { diff --git a/packages/hermes-taskflow/package.json b/packages/hermes-taskflow/package.json index 1ebe2dd8..919fce80 100644 --- a/packages/hermes-taskflow/package.json +++ b/packages/hermes-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "hermes-taskflow", - "version": "0.3.0-beta.2", + "version": "1.0.0", "description": "Run taskflow on Hermes Agent: a Hermes subagent runner plus an MCP server (and a config scaffold) that exposes the taskflow_* tools to Hermes users.", "keywords": [ "hermes", diff --git a/packages/hermes-taskflow/plugin/hermes.config.snippet.yaml b/packages/hermes-taskflow/plugin/hermes.config.snippet.yaml index 6ff8c046..e5c01617 100644 --- a/packages/hermes-taskflow/plugin/hermes.config.snippet.yaml +++ b/packages/hermes-taskflow/plugin/hermes.config.snippet.yaml @@ -14,7 +14,7 @@ taskflow: command: "npx" - args: ["-y", "-p", "hermes-taskflow@0.3.0-beta.2", "hermes-taskflow-mcp"] + args: ["-y", "-p", "hermes-taskflow@1.0.0", "hermes-taskflow-mcp"] env: # Uncomment for mutating agent phases (terminal / file write). # Leave unset for verify/plan/script-only flows. diff --git a/packages/hermes-taskflow/test/mcp-server.test.ts b/packages/hermes-taskflow/test/mcp-server.test.ts index 81bae677..5fd4e801 100644 --- a/packages/hermes-taskflow/test/mcp-server.test.ts +++ b/packages/hermes-taskflow/test/mcp-server.test.ts @@ -38,7 +38,7 @@ test("hermes mcp: initialize returns the protocol version + serverInfo", async ( assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-hermes"); - assert.equal(res.result.serverInfo.version, "0.3.0-beta.2"); + assert.equal(res.result.serverInfo.version, "1.0.0"); }); test("hermes mcp: tools/list exposes the taskflow tools", async () => { diff --git a/packages/opencode-taskflow/package.json b/packages/opencode-taskflow/package.json index dc58b44a..0517a5d9 100644 --- a/packages/opencode-taskflow/package.json +++ b/packages/opencode-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "opencode-taskflow", - "version": "0.3.0-beta.2", + "version": "1.0.0", "description": "Run taskflow on OpenCode: an OpenCode subagent runner plus an MCP server (and an opencode.json config scaffold) that exposes the taskflow_* tools to OpenCode users.", "keywords": [ "opencode", diff --git a/packages/opencode-taskflow/plugin/opencode.json b/packages/opencode-taskflow/plugin/opencode.json index ee3bee9a..79251a38 100644 --- a/packages/opencode-taskflow/plugin/opencode.json +++ b/packages/opencode-taskflow/plugin/opencode.json @@ -3,7 +3,7 @@ "mcp": { "taskflow": { "type": "local", - "command": ["npx", "-y", "-p", "opencode-taskflow@0.3.0-beta.2", "opencode-taskflow-mcp"], + "command": ["npx", "-y", "-p", "opencode-taskflow@1.0.0", "opencode-taskflow-mcp"], "enabled": true } }, diff --git a/packages/opencode-taskflow/test/mcp-server.test.ts b/packages/opencode-taskflow/test/mcp-server.test.ts index 5b01b5d3..bd6dfe19 100644 --- a/packages/opencode-taskflow/test/mcp-server.test.ts +++ b/packages/opencode-taskflow/test/mcp-server.test.ts @@ -45,7 +45,7 @@ test("opencode mcp: initialize returns the protocol version + serverInfo", async assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-opencode"); - assert.equal(res.result.serverInfo.version, "0.3.0-beta.2"); + assert.equal(res.result.serverInfo.version, "1.0.0"); }); test("opencode mcp: tools/list exposes the taskflow tools", async () => { diff --git a/packages/pi-taskflow/package.json b/packages/pi-taskflow/package.json index 452476b4..6929bc1d 100644 --- a/packages/pi-taskflow/package.json +++ b/packages/pi-taskflow/package.json @@ -1,7 +1,7 @@ { "name": "pi-taskflow", - "version": "0.3.0-beta.2", - "description": "A declarative, verifiable graph of task nodes for the Pi coding agent \u2014 statically verified before it runs, with dynamic fan-out, gates, isolated subagent context, resumable runs, and saveable commands.", + "version": "1.0.0", + "description": "A declarative, verifiable graph of task nodes for the Pi coding agent — statically verified before it runs, with dynamic fan-out, gates, isolated subagent context, resumable runs, and saveable commands.", "keywords": [ "pi-package", "pi", @@ -63,10 +63,10 @@ "typebox": "*" }, "devDependencies": { - "@earendil-works/pi-agent-core": "0.85.1", - "@earendil-works/pi-ai": "0.85.1", - "@earendil-works/pi-coding-agent": "0.85.1", - "@earendil-works/pi-tui": "0.85.1", + "@earendil-works/pi-agent-core": "1.0.0", + "@earendil-works/pi-ai": "1.0.0", + "@earendil-works/pi-coding-agent": "1.0.0", + "@earendil-works/pi-tui": "1.0.0", "typebox": "1.3.30" } } diff --git a/packages/pi-taskflow/skills/taskflow/SKILL.md b/packages/pi-taskflow/skills/taskflow/SKILL.md index c55ea753..af519bf2 100644 --- a/packages/pi-taskflow/skills/taskflow/SKILL.md +++ b/packages/pi-taskflow/skills/taskflow/SKILL.md @@ -13,6 +13,16 @@ Where an example shows a host-neutral invocation like `verify`, use the pi form (`action: "verify"` or `/tf verify`). Prefer **`action: "plan"` / `/tf plan`** before spending tokens on a non-trivial flow. +Before the first tool call in a session, load this skill using Pi's `read` tool +at the `SKILL.md` path listed in the available skills. Read linked reference +files as needed. Pi's stock skill workflow uses `read`. + +For shorthand calls (`task`, `tasks`, or `chain`), `action` may be omitted and +defaults to `"run"`. Other operations require their explicit `action`. +Approval phases use a review overlay in the TUI and a selector in RPC mode; +cancelling either the decision or edit guidance rejects the approval. +Print/JSON mode has no human approver and rejects approval phases. + Build and run **declarative, multi-phase workflows** of subagents. The runtime holds intermediate results and the phase DAG, so your main context only receives the final answer — not every step's transcript. diff --git a/packages/pi-taskflow/src/approval-view.ts b/packages/pi-taskflow/src/approval-view.ts index f906f546..bca55369 100644 --- a/packages/pi-taskflow/src/approval-view.ts +++ b/packages/pi-taskflow/src/approval-view.ts @@ -19,7 +19,7 @@ * ↑↓ scroll · PgUp/PgDn page · Home/End jump · Esc rejects. */ -import type { Theme } from "@earendil-works/pi-coding-agent"; +import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; import { decodeKittyPrintable, isKeyRelease, @@ -31,6 +31,8 @@ import { wrapTextWithAnsi, } from "@earendil-works/pi-tui"; +import type { ApprovalDecision, ApprovalRequest, RuntimeDeps } from "taskflow-core"; + export type ApprovalChoice = "approve" | "reject" | "edit"; export interface ApprovalViewOptions { @@ -279,3 +281,66 @@ export class ApprovalViewComponent { this.cachedBody = undefined; } } + +/** Dialogs are available over RPC, but custom terminal components are not. */ +export function createApprovalRequester( + ctx: Pick & { mode?: ExtensionContext["mode"] }, + flowName: string, + signal?: AbortSignal, +): RuntimeDeps["requestApproval"] { + // Older Pi versions have no mode property; keep their TUI overlay working. + if (!ctx.hasUI || ctx.mode === "print" || ctx.mode === "json") return undefined; + return async (req: ApprovalRequest): Promise => { + const reject = (note?: string): ApprovalDecision => ({ decision: "reject", ...(note ? { note } : {}) }); + if (signal?.aborted) return reject("aborted"); + let closeOverlay: (() => void) | undefined; + let onAbort: (() => void) | undefined; + const aborted = new Promise((resolve) => { + onAbort = () => { + resolve(reject("aborted")); + try { closeOverlay?.(); } catch { /* Dialog teardown must not interrupt abort. */ } + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); + const ask = async (): Promise => { + const title = `Taskflow approval — ${flowName}/${req.phaseId}`; + let choice: ApprovalChoice | undefined; + if (ctx.mode === "rpc") { + // Include the entire proposal in the dialog sent to the RPC client. + const prompt = [title, req.message, req.upstream].filter(Boolean).join("\n\n"); + const selected = await ctx.ui.select(prompt, ["Reject", "Edit guidance", "Approve"], { signal }); + choice = selected === "Approve" ? "approve" : selected === "Edit guidance" ? "edit" : "reject"; + } else { + choice = await ctx.ui.custom( + (tui, theme, _kb, done) => { + closeOverlay = () => done("reject"); + const view = new ApprovalViewComponent(theme, { title, message: req.message, upstream: req.upstream }, done, () => tui.terminal.rows); + if (signal?.aborted) closeOverlay(); + return { + render: (w: number) => view.render(w), + invalidate: () => view.invalidate(), + handleInput: (data: string) => { view.handleInput(data); tui.requestRender(); }, + dispose: () => view.dispose(), + }; + }, + { overlay: true, overlayOptions: { width: "80%", minWidth: 60, maxHeight: "85%", anchor: "center" } }, + ); + } + if (signal?.aborted) return reject("aborted"); + if (choice === "approve") return { decision: "approve" }; + if (choice === "edit") { + const note = await ctx.ui.input("Guidance passed downstream as this phase's output", "type guidance…", { signal }); + if (signal?.aborted) return reject("aborted"); + return note === undefined ? reject("Guidance entry cancelled") : { decision: "edit", note }; + } + return reject(); + }; + try { + return await Promise.race([ask(), aborted]); + } catch { + return reject("Approval dialog unavailable"); + } finally { + if (onAbort) signal?.removeEventListener("abort", onAbort); + } + }; +} diff --git a/packages/pi-taskflow/src/index.ts b/packages/pi-taskflow/src/index.ts index 3d4a06f9..ade6cebd 100644 --- a/packages/pi-taskflow/src/index.ts +++ b/packages/pi-taskflow/src/index.ts @@ -30,7 +30,7 @@ import { type AgentScope, discoverAgents, readSubagentSettings, shouldSyncBuilti import { renderRunResult, summarizeRun } from "./render.ts"; import { createPiSubagentRunner, PI_TASKFLOW_PI_ENTRY_ENV, resolveParentPiCliEntry, runnerModulePath } from "./runner.ts"; import { RunHistoryComponent, type RunHistoryResult } from "./runs-view.ts"; -import { ApprovalViewComponent, type ApprovalChoice } from "./approval-view.ts"; +import { createApprovalRequester } from "./approval-view.ts"; import { executeTaskflow, recomputeTaskflow, @@ -45,8 +45,6 @@ import { formatPreflightReport, analyzeFlowRuns, formatAnalyticsReport, - type ApprovalDecision, - type ApprovalRequest, type RecomputeReport, type ReplayReport, type ReplayOverrides, @@ -153,10 +151,10 @@ const ShorthandStep = Type.Object( ); const TaskflowParamsSchema = Type.Object({ - action: StringEnum(["run", "save", "resume", "list", "agents", "init", "verify", "compile", "plan", "analytics", "ir", "provenance", "trace", "replay", "why-stale", "recompute", "reconcile-workspace", "cache-clear", "search", "version"] as const, { + action: Type.Optional(StringEnum(["run", "save", "resume", "list", "agents", "init", "verify", "compile", "plan", "analytics", "ir", "provenance", "trace", "replay", "why-stale", "recompute", "reconcile-workspace", "cache-clear", "search", "version"] as const, { description: "What to do: run a flow, save a definition, resume a paused run, list saved flows, list available agents, init model role configuration, verify the DAG, compile the DAG to a Mermaid diagram + verification report, preflight-plan a flow (zero tokens), aggregate last-N run analytics, compile to FlowIR + content hash, show observed readSet provenance, show a run's event trace, offline-replay a trace under alternate knobs (zero tokens), explain why a run is stale, minimally recompute a stale run, explicitly reconcile a dirty resolve-only workspace, clear the cross-run memoization cache, or report the taskflow build/host identity (version)", default: "run", - }), + })), name: Type.Optional(Type.String({ description: "Name of a saved flow (for run/save without inline define)" })), define: Type.Optional( Type.Unknown({ @@ -511,59 +509,7 @@ async function runFlow( (heartbeat as { unref?: () => void }).unref?.(); } - // Human-in-the-loop approver — only when an interactive UI is available. - // Renders a centered modal popup (TUI overlay) with a scrollable viewport - // so long upstream output (e.g. a plan) can be reviewed in full before - // deciding (mouse wheel / ↑↓ / PgUp / PgDn to scroll). - const requestApproval = ctx.hasUI - ? async (req: ApprovalRequest): Promise => { - const choice = await ctx.ui.custom( - (tui, theme, _kb, done) => { - const view = new ApprovalViewComponent( - theme, - { - title: `Taskflow approval — ${def.name}/${req.phaseId}`, - message: req.message, - upstream: req.upstream, - }, - done, - () => tui.terminal.rows, - ); - const onAbort = () => done("reject"); - signal?.addEventListener("abort", onAbort, { once: true }); - return { - render: (w: number) => view.render(w), - invalidate: () => view.invalidate(), - handleInput: (data: string) => { - view.handleInput(data); - tui.requestRender(); - }, - dispose: () => { - view.dispose(); - signal?.removeEventListener("abort", onAbort); - }, - }; - }, - { - overlay: true, - overlayOptions: { - width: "80%", - minWidth: 60, - maxHeight: "85%", - anchor: "center", - }, - }, - ); - if (choice === "reject") return { decision: "reject" }; - if (choice === "edit") { - const note = await ctx.ui.input("Guidance passed downstream as this phase's output", "type guidance…", { - signal, - }); - return { decision: "edit", note: note ?? "" }; - } - return { decision: "approve" }; - } - : undefined; + const requestApproval = createApprovalRequester(ctx, def.name, signal); try { // Discover settings/agents inside try so a YAML/IO crash in @@ -748,7 +694,7 @@ export default function (pi: ExtensionAPI) { name: "taskflow", label: "Taskflow", description: [ - "IMPORTANT: Before using this tool for the first time in a session, invoke skill_load('taskflow') to read the full documentation (DSL syntax, examples, best practices). This tool description is a reference, not a tutorial.", + "IMPORTANT: Before using this tool for the first time in a session, use Pi's read tool to load the taskflow SKILL.md at the path listed in the available skills to read the full documentation (DSL syntax, examples, best practices). This tool description is a reference, not a tutorial.", "Shorthand (same API as subagent): pass `task` (+optional `agent`) for one task, `tasks:[{task,agent?}]` for parallel, or `chain:[{task,agent?}]` for sequential (use {previous.output}).", "DSL: use action=run with an inline `define` (you write the DAG) or a saved `name`. All 12 phase types (agent, parallel, map, gate, reduce, approval, flow, loop, tournament, script, race, expand) form a DAG; intermediate outputs stay out of your context — only the final phase output is returned.", "Every delegation is tracked (runId), resumable across sessions, and saveable as /tf: via action=save.", @@ -761,7 +707,7 @@ export default function (pi: ExtensionAPI) { parameters: TaskflowParamsSchema, promptSnippet: "Declare a verifiable graph of subagent tasks (single, parallel, chain, or full DAG) — tracked, resumable, context-isolated. The runtime validates the graph before running. Replaces the subagent tool.", promptGuidelines: [ - "BEFORE FIRST USE: invoke skill_load('taskflow') to read the full skill documentation (DSL syntax, phase types, examples, best practices). This tool description is a condensed reference only — the skill is the authoritative guide.\n\nUse taskflow for ALL delegation — single tasks, parallel, chain, or full DAG orchestration. It fully replaces the subagent tool: every delegation is tracked with a runId, resumable across sessions, context-isolated (only final output returns), and saveable as /tf:. Do NOT call the subagent tool directly; use taskflow shorthand (task/tasks/chain) for simple cases instead.", + "BEFORE FIRST USE: use Pi's read tool to load the taskflow SKILL.md at the path listed in the available skills to read the full skill documentation (DSL syntax, phase types, examples, best practices). This tool description is a condensed reference only — the skill is the authoritative guide.\n\nUse taskflow for ALL delegation — single tasks, parallel, chain, or full DAG orchestration. It fully replaces the subagent tool: every delegation is tracked with a runId, resumable across sessions, context-isolated (only final output returns), and saveable as /tf:. Do NOT call the subagent tool directly; use taskflow shorthand (task/tasks/chain) for simple cases instead.", "For complex multi-phase work (explore / 审计 / analyze the project, auditing endpoints, reviewing or migrating many files/modules, cross-checked research), use the full DSL with phases. For taskflow map phases, have the upstream phase emit a JSON array and set output:'json'.", "For taskflow map phases, have the upstream phase emit a JSON array and set output:'json'.", ], diff --git a/packages/pi-taskflow/src/runner.ts b/packages/pi-taskflow/src/runner.ts index d5544a20..1dff003c 100644 --- a/packages/pi-taskflow/src/runner.ts +++ b/packages/pi-taskflow/src/runner.ts @@ -266,9 +266,17 @@ function foldPiEventLine(acc: PiEventAccumulator, line: string) { resetPiFinal(acc); } else if ( type === "message_start" || type === "message_update" || - type.startsWith("tool_execution_") || type.startsWith("tool_") + type.startsWith("tool_execution_") || type.startsWith("tool_") || type === "auto_retry_start" ) { resetPiFinal(acc); + } else if (type === "compaction_start" || type === "auto_compaction_start") { + // Post-run compaction is activity even after agent_end willRetry:false. + // Revoke terminal reaping without erasing prior clean-exit evidence: + // Pi 0.80.3 compacts after agent_end but emits no agent_settled. + // An explicit compaction retry below invalidates that completed answer. + acc.terminalGeneration = undefined; + } else if ((type === "compaction_end" || type === "auto_compaction_end") && eventWillRetry(event) === true) { + resetPiFinal(acc); } const live = foldEventLine(acc, line); @@ -282,8 +290,13 @@ function foldPiEventLine(acc: PiEventAccumulator, line: string) { ? message.errorMessage : `Pi assistant stopped with ${reason}`; acc.finalGeneration = undefined; - } else if (reason !== "toolUse" && acc.finalText.trim()) { - acc.finalGeneration = acc.generation; + } else { + // message_end:error is an attempt failure, not a session failure. + // Retain it until a successful response so clean/legacy exits still + // fail closed while retry and overflow compaction can recover. + acc.fatalError = undefined; + acc.errorMessage = undefined; + if (reason !== "toolUse" && acc.finalText.trim()) acc.finalGeneration = acc.generation; } } } else if (type === "error") { @@ -309,12 +322,15 @@ function piCompletionPolicy(terminalGraceMs: number): CompletionPolicy, cwd = ".", hasUI = true) { + return { mode, hasUI, cwd, ui: ui as ExtensionUIContext }; +} +const request = { phaseId: "check", message: "Approve?", upstream: "complete proposal" }; +function tool(): ToolDefinition { + let registered: ToolDefinition | undefined; + registerTaskflow({ + on() {}, registerCommand() {}, + registerTool(value: ToolDefinition) { if (value.name === "taskflow") registered = value; }, + } as unknown as ExtensionAPI); + assert.ok(registered); + return registered; +} + +for (const [choice, expected] of [["approve", "approve"], ["reject", "reject"], [undefined, "reject"], ["unexpected", "reject"]] as const) { + test(`approval dispatch: TUI ${choice} resolves ${expected}`, async () => { + const approve = createApprovalRequester(context("tui", { + custom: async () => choice, + } as unknown as Partial), "flow"); + assert.equal((await approve!(request)).decision, expected); + }); +} + +test("approval dispatch: legacy Pi without mode preserves the custom overlay", async () => { + let overlay: unknown; + const approve = createApprovalRequester(context(undefined, { + custom: async (_factory: unknown, options: unknown) => { overlay = options; return "approve"; }, + } as unknown as Partial), "flow"); + assert.equal((await approve!(request)).decision, "approve"); + assert.equal((overlay as { overlay: boolean }).overlay, true); +}); + +for (const [choice, expected] of [["Approve", "approve"], ["Reject", "reject"], [undefined, "reject"], ["unexpected", "reject"]] as const) { + test(`approval dispatch: RPC ${choice} resolves ${expected} without custom UI`, async () => { + const approve = createApprovalRequester(context("rpc", { + custom: async () => { throw new Error("RPC custom unsupported"); }, + select: async (title, choices) => { + assert.match(title, /complete proposal/); + assert.deepEqual(choices, ["Reject", "Edit guidance", "Approve"]); + return choice; + }, + }), "flow"); + assert.equal((await approve!(request)).decision, expected); + }); +} + +for (const mode of ["tui", "rpc"] as const) { + for (const note of ["Revise the plan", "", undefined]) { + test(`approval dispatch: ${mode} edit guidance ${JSON.stringify(note)}`, async () => { + const approve = createApprovalRequester(context(mode, { + custom: async () => "edit", select: async () => "Edit guidance", input: async () => note, + } as unknown as Partial), "flow"); + const decision = await approve!(request); + assert.equal(decision.decision, note === undefined ? "reject" : "edit"); + if (note !== undefined) assert.equal(decision.note, note); + }); + } + test(`approval dispatch: ${mode} thrown UI error fails closed`, async () => { + const fail = async () => { throw new Error("UI disconnected"); }; + const approve = createApprovalRequester(context(mode, { custom: fail, select: fail }), "flow"); + assert.equal((await approve!(request)).decision, "reject"); + }); + test(`approval dispatch: ${mode} abort stops an unresponsive dialog`, async () => { + const controller = new AbortController(); + const hang = async () => new Promise(() => {}); + const approve = createApprovalRequester(context(mode, { custom: hang, select: hang }), "flow", controller.signal); + const pending = approve!(request); + controller.abort(); + assert.deepEqual(await pending, { decision: "reject", note: "aborted" }); + }); + test(`approval dispatch: ${mode} abort during edit cannot release downstream work`, async () => { + const controller = new AbortController(); + const approve = createApprovalRequester(context(mode, { + custom: async () => "edit", select: async () => "Edit guidance", + input: async () => { controller.abort(); return "guidance after abort"; }, + } as unknown as Partial), "flow", controller.signal); + assert.equal((await approve!(request)).decision, "reject"); + }); +} + +test("approval dispatch: already aborted does not open a dialog", async () => { + const controller = new AbortController(); + controller.abort(); + const approve = createApprovalRequester(context("rpc", { select: async () => assert.fail("must not prompt") }), "flow", controller.signal); + assert.equal((await approve!(request)).decision, "reject"); +}); + +for (const mode of ["print", "json", "tui"] as const) { + test(`approval dispatch: ${mode} without dialog capability has no approver`, () => { + assert.equal(createApprovalRequester(context(mode, {}, ".", mode !== "tui"), "flow"), undefined); + }); +} + +// Exercise the registered adapter with the real runtime, persistence, and shell +// phase. A safe decision must prevent the downstream side effect, not merely +// return the expected string from a helper. +for (const scenario of [ + { mode: "rpc", choice: "Approve", allow: true }, + { mode: "rpc", choice: "Reject", allow: false }, + { mode: "rpc", choice: undefined, allow: false }, + { mode: "rpc", choice: "Edit guidance", note: "Revised guidance", allow: true }, + { mode: "rpc", choice: "Edit guidance", note: undefined, allow: false }, + { mode: "tui", choice: undefined, allow: false }, + { mode: "print", choice: "Approve", allow: false }, +] as const) { + test(`approval tool/runtime: ${scenario.mode} ${scenario.choice}/${"note" in scenario ? scenario.note : ""} blocks or releases downstream shell`, async () => { + const cwd = mkdtempSync(join(tmpdir(), "taskflow-approval-")); + try { + const registered = tool(); + const args = validateToolArguments(registered, { + type: "toolCall", id: "approval", name: "taskflow", + arguments: { define: { name: "approval-regression", phases: [ + { id: "proposal", type: "script", run: "printf 'complete proposal'" }, + { id: "check", type: "approval", task: "Approve?", dependsOn: ["proposal"] }, + { id: "effect", type: "script", run: "touch approval-effect; printf 'released'", dependsOn: ["check"], final: true }, + ] } }, + }); + const ctx = context(scenario.mode, { + select: async () => scenario.choice, + custom: async () => scenario.choice, + input: async () => "note" in scenario ? scenario.note : undefined, + } as unknown as Partial, cwd); + const result = await registered.execute("approval", args, undefined, undefined, ctx as unknown as ExtensionToolContext); + const state = (result.details as { state: RunState }).state; + assert.ok(state, JSON.stringify(result)); + assert.equal(existsSync(join(cwd, "approval-effect")), scenario.allow); + assert.equal(state.phases.check.approval?.decision, scenario.allow ? (scenario.choice === "Edit guidance" ? "edit" : "approve") : "reject"); + if (scenario.allow) assert.equal(state.phases.effect.status, "done"); + else { + assert.notEqual(state.phases.effect.status, "done"); + assert.equal(state.status, "blocked", "rejection preserves the existing blocked terminal state"); + } + } finally { rmSync(cwd, { recursive: true, force: true }); } + }); +} + +test("Pi tool skill instructions use the stock read workflow", () => { + const registered = tool(); + const guidance = [registered.description, ...(registered.promptGuidelines ?? [])].join("\n"); + assert.doesNotMatch(guidance, /skill_load/); + assert.match(guidance, /read tool.*SKILL\.md/); +}); + +for (const mode of ["tui", "rpc"] as const) { + test(`approval dispatch: ${mode} guidance UI error fails closed`, async () => { + const approve = createApprovalRequester(context(mode, { + custom: async () => "edit", select: async () => "Edit guidance", + input: async () => { throw new Error("Input unavailable"); }, + } as unknown as Partial), "flow"); + assert.equal((await approve!(request)).decision, "reject"); + }); +} diff --git a/packages/pi-taskflow/test/e2e-pi1.mts b/packages/pi-taskflow/test/e2e-pi1.mts new file mode 100644 index 00000000..d1073482 --- /dev/null +++ b/packages/pi-taskflow/test/e2e-pi1.mts @@ -0,0 +1,141 @@ +/** Real Pi 1.0 CLI + built extension + spawned children. Only model responses are local fixtures. */ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { runAgentTask, defaultResolveInstalledPiCli } from "../dist/runner.js"; + +const root = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-pi1-e2e-")); +const cwd = path.join(root, "project"), agentDir = path.join(root, "agent"); +fs.mkdirSync(cwd); fs.mkdirSync(agentDir); +const provider = fileURLToPath(new URL("fixtures/pi1-provider.ts", import.meta.url)); +const extension = fileURLToPath(new URL("../dist/index.js", import.meta.url)); +const cli = defaultResolveInstalledPiCli(); +assert.ok(cli, "resolve the adapter-local Pi CLI"); +const token = randomUUID(); +const waitFile = path.join(root, "provider-waiting"); +const env = { ...process.env, PI_OFFLINE: "1", PI_CODING_AGENT_DIR: agentDir, TASKFLOW_AGENT_DIR: agentDir, TASKFLOW_E2E_TOKEN: token, TASKFLOW_E2E_WAIT_FILE: waitFile }; +delete env.PI_TASKFLOW_PI_BIN; delete env.PI_TASKFLOW_PI_ENTRY; delete env.PI_TASKFLOW_BUILTIN_AGENTS_DIR; +fs.writeFileSync(path.join(agentDir, "settings.json"), JSON.stringify({ + defaultProvider: "taskflow-e2e", defaultModel: "fixture", modelRoles: { executor: "taskflow-e2e/fixture" }, + retry: { enabled: true, maxRetries: 2, baseDelayMs: 20 }, + taskflow: { piChild: { resourceProfile: "allowlist", extensions: [provider], terminalGraceMs: 200 } }, +})); +type Event = { type: string; [key: string]: any }; +const baseArgs = ["--offline", "--no-session", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-extensions", "-e", provider, "-e", extension, "--model", "taskflow-e2e/fixture"]; +let passed = 0; +function pass(name: string) { passed++; console.log(`PASS ${name}`); } + +async function cliRun(prompt: string, rpcDecision?: string, codemode = false) { + return await new Promise<{ events: Event[]; code: number | null; stderr: string }>((resolve, reject) => { + const rpc = rpcDecision !== undefined; + const args = [...baseArgs, ...(codemode ? ["-e", "builtin:codemode", "--tools", "taskflow,codemode"] : []), "--mode", rpc ? "rpc" : "json", ...(rpc ? [] : ["-p", prompt])]; + const child = spawn(process.execPath, [cli!, ...args], { cwd, env, stdio: [rpc ? "pipe" : "ignore", "pipe", "pipe"] }); + const events: Event[] = []; let pending = "", stderr = "", settled = false; + let failure: Error | undefined, forcedKill: ReturnType | undefined; + const stop = (error?: Error) => { + failure ??= error; + if (forcedKill) return; + // Allow the Pi supervisor to terminate its detached Taskflow children. + child.kill("SIGTERM"); + forcedKill = setTimeout(() => child.kill("SIGKILL"), 5000); + }; + const timer = setTimeout(() => stop(new Error(`Pi timed out: ${prompt} ${rpcDecision}\n${stderr}`)), 30000); + child.stderr!.on("data", (data) => { stderr += String(data); }); + child.stdout!.on("data", (data) => { + pending += String(data); const lines = pending.split("\n"); pending = lines.pop()!; + for (const line of lines) { + if (!line.trim()) continue; + let event: Event; try { event = JSON.parse(line); } catch { stop(new Error(`Non-JSON Pi output: ${line}`)); return; } + events.push(event); + if (rpc && event.type === "extension_ui_request" && ["select", "input", "confirm"].includes(event.method)) { + if (rpcDecision === "abort") child.stdin!.write(JSON.stringify({ id: "abort", type: "abort" }) + "\n"); + else { + const cancelled = rpcDecision === "cancel" || (rpcDecision === "edit-cancel" && event.method === "input"); + const wanted = rpcDecision!.startsWith("edit") ? "edit" : rpcDecision!; + const value = event.method === "input" ? "Reviewed guidance" : (event.options as string[] | undefined)?.find((s) => s.toLowerCase().startsWith(wanted)) ?? wanted; + child.stdin!.write(JSON.stringify({ type: "extension_ui_response", id: event.id, ...(cancelled ? { cancelled: true } : event.method === "confirm" ? { confirmed: wanted === "approve" } : { value }) }) + "\n"); + } + } + if (rpc && event.type === "agent_settled" && !settled) { settled = true; child.stdin!.end(); stop(); } + } + }); + child.on("error", (error) => { failure = error; }); + child.on("close", (code) => { clearTimeout(timer); clearTimeout(forcedKill); if (failure) reject(failure); else resolve({ events, code, stderr }); }); + if (rpc) child.stdin!.write(JSON.stringify({ id: "prompt", type: "prompt", message: prompt }) + "\n"); + }); +} +function taskflowResult(events: Event[]) { + const event = events.find((e) => e.type === "tool_execution_end" && e.toolName === "taskflow"); + assert.ok(event, "Taskflow tool must actually execute"); return event; +} + +const savedEnv = new Map(); +try { + for (const key of ["PI_OFFLINE", "PI_CODING_AGENT_DIR", "TASKFLOW_AGENT_DIR", "TASKFLOW_E2E_TOKEN", "TASKFLOW_E2E_WAIT_FILE", "PI_TASKFLOW_PI_BIN", "PI_TASKFLOW_PI_ENTRY", "PI_TASKFLOW_BUILTIN_AGENTS_DIR"]) { + savedEnv.set(key, process.env[key]); if (env[key] === undefined) delete process.env[key]; else process.env[key] = env[key]; + } + for (const [name, expected] of [["version", "1.0.0"], ["shorthand", "CHILD_OK"], ["dag", "ONE"], ["context", token], ["codemode", "1.0.0"]]) { + const result = await cliRun(`HOST:${name}`, undefined, name === "codemode"); + assert.equal(result.code, 0, result.stderr); const tool = taskflowResult(result.events); + assert.equal(tool.isError, false, JSON.stringify(tool.result)); assert.ok(JSON.stringify(tool.result).includes(expected), JSON.stringify(tool.result)); + if (name === "dag") assert.ok(JSON.stringify(tool.result).includes("TWO")); + pass(`built extension ${name}`); + } + const agents = [{ name: "fixture", description: "local fixture", systemPrompt: "", source: "user" as const, filePath: "", model: "taskflow-e2e/fixture" }]; + const profile = { resourceProfile: "allowlist" as const, extensions: [provider], terminalGraceMs: 200 }; + for (const task of ["CHILD_RETRY", "CHILD_FAIL", "CHILD_USAGE", "CHILD_LEAK"]) { + const result = await runAgentTask(cwd, agents, "fixture", task, { signal: AbortSignal.timeout(20000) }, undefined, profile); + if (task === "CHILD_FAIL") { assert.notEqual(result.exitCode, 0); assert.match(result.errorMessage ?? "", /429/); } + else { + assert.equal(result.exitCode, 0, JSON.stringify(result)); assert.equal(result.errorMessage, undefined, JSON.stringify(result)); + if (task === "CHILD_USAGE") { assert.equal(result.usage.cost, 0.3); assert.ok(result.usage.input >= 100); assert.ok(result.usage.output >= 200); } + else assert.equal(result.output, "CHILD_OK"); + if (task === "CHILD_LEAK") assert.equal(result.completionSource, "terminal-reap"); + } + pass(task); + } + const controller = new AbortController(); + let reachedProvider = false; + const watcher = fs.watch(root, (_event, filename) => { + if (String(filename) === path.basename(waitFile) && fs.existsSync(waitFile)) { reachedProvider = true; controller.abort(); } + }); + const cancelDeadline = setTimeout(() => controller.abort(), 15000); + let aborted; + try { aborted = await runAgentTask(cwd, agents, "fixture", "CHILD_SLOW", { signal: controller.signal }, undefined, profile); } + finally { watcher.close(); clearTimeout(cancelDeadline); } + assert.equal(reachedProvider, true, "Cancellation must occur after the real provider begins waiting"); + assert.notEqual(aborted.exitCode, 0); assert.equal(aborted.stopReason, "aborted"); + assert.match(aborted.errorMessage ?? "", /aborted/i); pass("child cancellation after provider handshake"); + for (const decision of ["approve", "reject", "cancel", "edit", "edit-cancel", "abort"]) { + const result = await cliRun("HOST:approval", decision); + assert.ok(result.events.some((e) => e.type === "extension_ui_request" && ["select", "confirm"].includes(e.method)), "RPC must ask the human"); + const tool = taskflowResult(result.events); + const state = tool.result.details.state; + assert.ok(state, JSON.stringify(tool.result)); + const mayProceed = decision === "approve" || decision === "edit"; + assert.equal(state.phases.after?.status === "done", mayProceed, JSON.stringify(tool.result)); + if (mayProceed) { + assert.equal(state.status, "completed"); + assert.equal(state.phases.review.approval.decision, decision); + assert.ok(JSON.stringify(tool.result).includes("AFTER_APPROVAL")); + if (decision === "edit") assert.equal(state.phases.review.output, "Reviewed guidance"); + } else { + assert.ok(["blocked", "paused"].includes(state.status), JSON.stringify(state)); + assert.equal(state.phases.review.approval?.decision, "reject", JSON.stringify(state)); + } + pass(`RPC approval ${decision}`); + } + const printApproval = await cliRun("HOST:approval"); + const printResult = taskflowResult(printApproval.events); + assert.ok(printResult.result.details.state); + assert.equal(printResult.result.details.state.phases.review.approval.decision, "reject"); + assert.notEqual(printResult.result.details.state.phases.after?.status, "done"); pass("print approval does not execute downstream"); + console.log(`Pi 1.0 real-process E2E: ${passed} passed; no live provider calls.`); +} finally { + for (const [key, value] of savedEnv) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } + fs.rmSync(root, { recursive: true, force: true }); +} diff --git a/packages/pi-taskflow/test/fixtures/pi1-provider.ts b/packages/pi-taskflow/test/fixtures/pi1-provider.ts new file mode 100644 index 00000000..8bdbc6a4 --- /dev/null +++ b/packages/pi-taskflow/test/fixtures/pi1-provider.ts @@ -0,0 +1,69 @@ +/** Local, deterministic provider for the real Pi CLI integration suite. No network or credentials. */ +import { createFauxCore, fauxAssistantMessage, fauxToolCall, Type } from "@earendil-works/pi-ai"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import fs from "node:fs"; + +export default function (pi: ExtensionAPI) { + const handshake = process.env.TASKFLOW_E2E_TOKEN; + if (!handshake) throw new Error("TASKFLOW_E2E_TOKEN is required by this local test fixture"); + let retries = 0; + const core = createFauxCore({ provider: "taskflow-e2e", api: "taskflow-e2e-api", models: [{ id: "fixture", contextWindow: 200000, maxTokens: 4096 }] }); + pi.registerTool({ + name: "fixture_meter", label: "Fixture meter", description: "Return synthetic tool usage.", parameters: Type.Object({}), + async execute() { + return { content: [{ type: "text" as const, text: "METER_OK" }], details: {}, usage: { + input: 100, output: 200, cacheRead: 0, cacheWrite: 0, totalTokens: 300, + cost: { input: 0.1, output: 0.2, cacheRead: 0, cacheWrite: 0, total: 0.3 }, + } }; + }, + }); + core.setResponses(Array.from({ length: 30 }, () => async (context) => { + const user = context.messages.filter((m) => m.role === "user").at(-1); + const text = typeof user?.content === "string" ? user.content : (user?.content ?? []).filter((c) => c.type === "text").map((c) => c.text).join("\n"); + const results = context.messages.filter((m) => m.role === "toolResult"); + if (text.includes("CHILD_FAIL") || (text.includes("CHILD_RETRY") && retries++ === 0)) { + return fauxAssistantMessage([], { stopReason: "error", errorMessage: "429 rate limit exceeded (local fixture)" }); + } + if (text.includes("CHILD_SLOW")) { + if (!process.env.TASKFLOW_E2E_WAIT_FILE) throw new Error("Missing cancellation handshake path"); + fs.writeFileSync(process.env.TASKFLOW_E2E_WAIT_FILE, String(process.pid)); + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + if (results.length) { + return fauxAssistantMessage(text.includes("READ_TOKEN") ? JSON.stringify(results.at(-1)?.content) : "TOOLS_DONE"); + } + if (text.startsWith("HOST:")) { + const which = text.slice(5); + if (which === "version") return fauxAssistantMessage(fauxToolCall("taskflow", { action: "version" })); + if (which === "codemode") return fauxAssistantMessage(fauxToolCall("codemode", { code: 'text(await tools.taskflow({action:"version"}));' })); + if (which === "shorthand") return fauxAssistantMessage(fauxToolCall("taskflow", { task: "CHILD_OK", agent: "executor" })); + if (which === "approval") return fauxAssistantMessage(fauxToolCall("taskflow", { action: "run", define: { name: "rpc-approval", phases: [ + { id: "review", type: "approval", task: "Approve running the harmless fixture command?" }, + { id: "after", type: "script", run: "printf AFTER_APPROVAL", dependsOn: ["review"], final: true }, + ] } })); + if (which === "context") return fauxAssistantMessage(fauxToolCall("taskflow", { action: "run", define: { name: "ctx-e2e", contextSharing: true, phases: [ + { id: "writer", type: "agent", agent: "executor", task: "WRITE_TOKEN" }, + { id: "reader", type: "agent", agent: "executor", task: "READ_TOKEN", dependsOn: ["writer"], final: true }, + ] } })); + if (which === "dag") return fauxAssistantMessage(fauxToolCall("taskflow", { action: "run", define: { name: "dag-e2e", phases: [ + { id: "seed", type: "agent", agent: "executor", task: "ARRAY", output: "json" }, + { id: "fanout", type: "map", over: "{steps.seed.json}", as: "item", agent: "executor", task: "UPPER:{item.word}", dependsOn: ["seed"] }, + { id: "join", type: "reduce", from: ["fanout"], agent: "executor", task: "JOIN:{steps.fanout.output}", dependsOn: ["fanout"], final: true }, + ] } })); + throw new Error(`Unknown fixture host request: ${which}`); + } + if (text.includes("CHILD_USAGE")) return fauxAssistantMessage(fauxToolCall("fixture_meter", {})); + if (text.includes("WRITE_TOKEN")) return fauxAssistantMessage(fauxToolCall("ctx_write", { key: "handshake", value: handshake })); + if (text.includes("READ_TOKEN")) return fauxAssistantMessage(fauxToolCall("ctx_read", { key: "handshake" })); + if (text.includes("ARRAY")) return fauxAssistantMessage('[{"word":"one"},{"word":"two"}]'); + if (text.includes("UPPER:")) return fauxAssistantMessage(text.split("UPPER:").at(-1)!.trim().toUpperCase()); + if (text.includes("JOIN:")) return fauxAssistantMessage(text.split("JOIN:").at(-1)!); + if (text.includes("CHILD_LEAK")) setInterval(() => {}, 1000); + return fauxAssistantMessage("CHILD_OK"); + })); + pi.registerProvider("taskflow-e2e", { + api: "taskflow-e2e-api", baseUrl: "http://127.0.0.1", apiKey: "synthetic-local-fixture", + models: [{ id: "fixture", name: "Local fixture", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 4096 }], + streamSimple: core.streamSimple, + }); +} diff --git a/packages/pi-taskflow/test/retry-lifecycle.test.ts b/packages/pi-taskflow/test/retry-lifecycle.test.ts new file mode 100644 index 00000000..3781b5c0 --- /dev/null +++ b/packages/pi-taskflow/test/retry-lifecycle.test.ts @@ -0,0 +1,194 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import type { RunOptions } from "taskflow-core"; +import { createPiSubagentRunner, isFailed } from "../src/runner.ts"; + +const start = [{ type: "agent_start" }, { type: "turn_start" }]; +const settled = { type: "agent_settled" }; +const end = (willRetry?: boolean) => ({ type: "agent_end", willRetry }); +const assistant = (text: string, stopReason = "stop", errorMessage?: string) => ({ + type: "message_end", + message: { role: "assistant", content: text ? [{ type: "text", text }] : [], stopReason, errorMessage }, +}); +const failure = assistant("", "error", "429 rate limit exceeded"); +const retry = { type: "auto_retry_start", attempt: 1, maxAttempts: 1, delayMs: 100, errorMessage: "429 rate limit exceeded" }; + +interface Batch { events: unknown[]; delay?: number } + +async function runStream(batches: Batch[], opts: RunOptions = {}, leaky = true) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tf-pi-retry-")); + const entry = path.join(dir, "pi.mjs"); + fs.writeFileSync(entry, `#!${process.execPath}\n` + + `const batches=${JSON.stringify(batches)};\n` + + `for (const batch of batches) { if (batch.delay) await new Promise(r=>setTimeout(r,batch.delay)); for (const event of batch.events) process.stdout.write(JSON.stringify(event)+"\\n"); }\n` + + (leaky ? `setInterval(()=>{},1000);\n` : "")); + fs.chmodSync(entry, 0o755); + const previous = process.env.PI_TASKFLOW_PI_BIN; + process.env.PI_TASKFLOW_PI_BIN = entry; + try { + return await createPiSubagentRunner({ resourceProfile: "isolated", extensions: [], terminalGraceMs: 40 }) + .runTask(dir, [{ name: "test", description: "", systemPrompt: "", source: "user", filePath: "" }], "test", "retry", { idleTimeoutMs: 10_000, ...opts }); + } finally { + if (previous === undefined) delete process.env.PI_TASKFLOW_PI_BIN; + else process.env.PI_TASKFLOW_PI_BIN = previous; + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test("Pi retry: real failed message survives backoff and clears its error on successful recovery", async () => { + const result = await runStream([ + { events: [...start, failure, end(true), retry] }, + { delay: 100, events: [...start, assistant("RECOVERED"), { type: "auto_retry_end", success: true, attempt: 1 }, end(false), settled] }, + ]); + assert.equal(isFailed(result), false); + assert.equal(result.output, "RECOVERED"); + assert.equal(result.errorMessage, undefined); + assert.equal(result.completionSource, "terminal-reap"); + assert.equal(result.usage.turns, 2); +}); + +test("Pi retry: legacy agent_end and clean exit can recover a failed attempt", async () => { + const result = await runStream([ + { events: [...start, failure, end(), retry] }, + { delay: 80, events: [...start, assistant("LEGACY_RECOVERED"), end()] }, + ], {}, false); + assert.equal(isFailed(result), false); + assert.equal(result.output, "LEGACY_RECOVERED"); + assert.equal(result.completionSource, "process-exit"); +}); + +test("Pi retry: exhausted attempts remain failures even if Pi exits zero", async () => { + const result = await runStream([ + { events: [...start, failure, end(true), retry] }, + { delay: 80, events: [...start, assistant("", "error", "503 retry exhausted"), end(false), { type: "auto_retry_end", success: false, finalError: "503 retry exhausted" }, settled] }, + ], {}, false); + assert.equal(isFailed(result), true); + assert.match(result.errorMessage ?? "", /503 retry exhausted/); + assert.equal(result.reapedAfterTerminal, undefined); +}); + +test("Pi retry: a final error at agent_settled stops a leaky process without waiting for idle timeout", async () => { + const result = await runStream([{ events: [...start, assistant("", "error", "invalid API key"), end(false), settled] }]); + assert.equal(isFailed(result), true); + assert.match(result.errorMessage ?? "", /invalid API key/); + assert.equal(result.idleTimeout, undefined); +}); + +test("Pi retry: legacy final error is retained on clean exit without settled", async () => { + const result = await runStream([{ events: [...start, failure, end()] }], {}, false); + assert.equal(isFailed(result), true); + assert.match(result.errorMessage ?? "", /429 rate limit exceeded/); +}); + +test("Pi retry: overflow compaction can recover after agent_end willRetry false", async () => { + const result = await runStream([ + { events: [...start, assistant("", "error", "maximum context length exceeded"), end(false), { type: "compaction_start", reason: "overflow" }] }, + { delay: 100, events: [{ type: "compaction_end", reason: "overflow", aborted: false, willRetry: true }, ...start, assistant("COMPACTED"), end(false), settled] }, + ]); + assert.equal(isFailed(result), false); + assert.equal(result.output, "COMPACTED"); +}); + +test("Pi retry: retry exhaustion may still recover through overflow compaction", async () => { + const result = await runStream([ + { events: [...start, failure, end(true), retry] }, + { delay: 80, events: [...start, assistant("", "error", "context overflow"), end(false), { type: "auto_retry_end", success: false, finalError: "context overflow" }, { type: "compaction_start", reason: "overflow" }] }, + { delay: 80, events: [{ type: "compaction_end", willRetry: true }, ...start, assistant("COMPACTION_RECOVERY"), end(false), settled] }, + ]); + assert.equal(isFailed(result), false); + assert.equal(result.output, "COMPACTION_RECOVERY"); +}); + +for (const type of ["compaction_start", "auto_compaction_start", "auto_retry_start"]) { + test(`Pi retry: ${type} revokes terminal grace before a later lifecycle`, async () => { + const result = await runStream([ + { events: [...start, assistant("STALE"), end(false), { type }] }, + { delay: 100, events: [...start, assistant("LATEST"), end(false), settled] }, + ]); + assert.equal(isFailed(result), false); + assert.equal(result.output, "LATEST"); + }); +} + +test("Pi retry: successful response survives non-retrying threshold compaction", async () => { + const result = await runStream([ + { events: [...start, assistant("COMPLETED"), end(false), { type: "compaction_start", reason: "threshold" }] }, + { delay: 100, events: [{ type: "compaction_end", willRetry: false }, settled] }, + ]); + assert.equal(isFailed(result), false); + assert.equal(result.output, "COMPLETED"); +}); + +test("Pi retry: recovery through multiple tool-use turns clears old errors and bills tool usage", async () => { + const result = await runStream([ + { events: [...start, failure, end(true), retry] }, + { delay: 80, events: [...start, assistant("WORKING", "toolUse"), { type: "auto_retry_end", success: true }, + { type: "tool_execution_start" }, { type: "message_end", message: { role: "toolResult", content: [], usage: { output: 10, cost: { total: 0.5 } } } }, + { type: "turn_start" }, assistant("STILL_WORKING", "toolUse"), { type: "tool_execution_end" }, { type: "turn_start" }, assistant("DONE"), end(false), settled] }, + ]); + assert.equal(isFailed(result), false); + assert.equal(result.output, "DONE"); + assert.equal(result.usage.turns, 4); + assert.equal(result.usage.output, 10); + assert.equal(result.usage.cost, 0.5); +}); + +test("Pi retry: host cancellation during backoff stays an abort", async () => { + const controller = new AbortController(); + const result = await runStream([{ events: [...start, assistant("BACKOFF", "error", "429 rate limit exceeded"), end(true), retry] }], { + signal: controller.signal, + onLive: () => setTimeout(() => controller.abort(), 20), + }); + assert.equal(isFailed(result), true); + assert.equal(result.stopReason, "aborted"); + assert.equal(result.completionSource, "abort"); +}); + +test("Pi retry: missing recovery remains bounded by the idle watchdog", async () => { + let sawFailure = false; + const result = await runStream([{ events: [...start, failure, end(true), retry] }], { + idleTimeoutMs: 2000, + onLive: () => { sawFailure = true; }, + }); + assert.equal(sawFailure, true, "the watchdog must expire during backoff, after the failed attempt was observed"); + assert.equal(isFailed(result), true); + assert.equal(result.completionSource, "idle-timeout"); + assert.equal(result.idleTimeout, true); +}); + +test("Pi retry: explicit protocol errors and aborted assistant messages remain immediately fatal", async () => { + for (const event of [{ type: "error", message: "explicit failure" }, assistant("", "aborted", "aborted by Pi")]) { + const result = await runStream([{ events: [...start, event] }]); + assert.equal(isFailed(result), true); + assert.equal(result.idleTimeout, undefined); + assert.match(result.errorMessage ?? "", /explicit failure|aborted by Pi/); + } +}); + + +for (const prefix of ["compaction", "auto_compaction"]) { + for (const reason of ["threshold", "overflow"]) { + test(`Pi retry: legacy ${prefix} ${reason} without retry preserves a successful clean exit`, async () => { + // Pi 0.80.3 CLI emits agent_end(false), then compaction_start/end, + // and exits cleanly. It has no agent_settled event. + const result = await runStream([ + { events: [...start, assistant("LEGACY_COMPLETED"), end(false), { type: `${prefix}_start`, reason }] }, + { delay: 100, events: [{ type: `${prefix}_end`, reason, aborted: false, willRetry: false }] }, + ], {}, false); + assert.equal(isFailed(result), false); + assert.equal(result.output, "LEGACY_COMPLETED"); + assert.equal(result.completionSource, "process-exit"); + }); + } + test(`Pi retry: legacy ${prefix} announcing retry cannot retain a stale successful answer`, async () => { + const result = await runStream([ + { events: [...start, assistant("STALE"), end(false), { type: `${prefix}_start`, reason: "overflow" }] }, + { delay: 100, events: [{ type: `${prefix}_end`, reason: "overflow", aborted: false, willRetry: true }] }, + ], {}, false); + assert.equal(isFailed(result), true); + assert.equal(result.reapedAfterTerminal, undefined); + }); +} diff --git a/packages/pi-taskflow/test/taskflow-params.test.ts b/packages/pi-taskflow/test/taskflow-params.test.ts index 92b439e4..3cc8d699 100644 --- a/packages/pi-taskflow/test/taskflow-params.test.ts +++ b/packages/pi-taskflow/test/taskflow-params.test.ts @@ -8,25 +8,32 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { Errors as SchemaErrors } from "typebox/value"; -import { Value } from "typebox/value"; +import { validateToolArguments, type JsonObject } from "@earendil-works/pi-ai"; import { TaskflowParams } from "../src/index.ts"; +function validateParams(arguments_: JsonObject): Record { + return validateToolArguments( + { name: "taskflow", description: "Taskflow", parameters: TaskflowParams }, + { type: "toolCall", id: "test", name: "taskflow", arguments: arguments_ }, + ) as Record; +} + // --------------------------------------------------------------------------- // action enum validation // --------------------------------------------------------------------------- test("TaskflowParams: action=version is valid", () => { - const r = Value.Decode(TaskflowParams, { action: "version" }); + const r = validateParams({ action: "version" }); assert.equal(r.action, "version"); }); test("TaskflowParams: action=run is valid (default)", () => { - const r = Value.Decode(TaskflowParams, { action: "run" }); + const r = validateParams({ action: "run" }); assert.equal(r.action, "run"); }); test("TaskflowParams: action=resume is valid", () => { - const r = Value.Decode(TaskflowParams, { action: "resume" }); + const r = validateParams({ action: "resume" }); assert.equal(r.action, "resume"); }); @@ -47,12 +54,12 @@ test("TaskflowParams: non-string action is rejected", () => { // --------------------------------------------------------------------------- test("TaskflowParams: shorthand single mode with cwd", () => { - const r = Value.Decode(TaskflowParams, { action: "run", agent: "executor", task: "do something", cwd: "/repo" }); + const r = validateParams({ action: "run", agent: "executor", task: "do something", cwd: "/repo" }); assert.equal(r.cwd, "/repo"); }); test("TaskflowParams: shorthand parallel (tasks) with cwd", () => { - const r = Value.Decode(TaskflowParams, { + const r = validateParams({ action: "run", tasks: [ { task: "a", cwd: "/branch-a" }, @@ -67,7 +74,7 @@ test("TaskflowParams: shorthand parallel (tasks) with cwd", () => { }); test("TaskflowParams: shorthand chain with per-step cwd", () => { - const r = Value.Decode(TaskflowParams, { + const r = validateParams({ action: "run", chain: [ { task: "a", cwd: "/step-a" }, @@ -80,7 +87,7 @@ test("TaskflowParams: shorthand chain with per-step cwd", () => { }); test("TaskflowParams: shorthand workspace keyword cwd is valid", () => { - const r = Value.Decode(TaskflowParams, { action: "run", task: "x", cwd: "temp" }); + const r = validateParams({ action: "run", task: "x", cwd: "temp" }); assert.equal(r.cwd, "temp"); }); @@ -89,12 +96,12 @@ test("TaskflowParams: shorthand workspace keyword cwd is valid", () => { // --------------------------------------------------------------------------- test("TaskflowParams: resume with runId only is valid", () => { - const r = Value.Decode(TaskflowParams, { action: "resume", runId: "run-abc" }); + const r = validateParams({ action: "resume", runId: "run-abc" }); assert.equal(r.runId, "run-abc"); }); test("TaskflowParams: resume with phaseId + task override", () => { - const r = Value.Decode(TaskflowParams, { + const r = validateParams({ action: "resume", runId: "run-abc", phaseId: "my-phase", @@ -105,7 +112,7 @@ test("TaskflowParams: resume with phaseId + task override", () => { }); test("TaskflowParams: resume with all override fields", () => { - const r = Value.Decode(TaskflowParams, { + const r = validateParams({ action: "resume", runId: "run-abc", phaseId: "p1", @@ -123,7 +130,7 @@ test("TaskflowParams: resume with all override fields", () => { test("TaskflowParams: resume with resumeTimeout must be number >= 1000", () => { // At the schema level, timeout is just a Number. Validation via resume.ts // enforces >= 1000. The schema allows it to be any non-negative number. - const r = Value.Decode(TaskflowParams, { + const r = validateParams({ action: "resume", runId: "run-abc", phaseId: "p1", @@ -135,7 +142,7 @@ test("TaskflowParams: resume with resumeTimeout must be number >= 1000", () => { test("TaskflowParams: resume without phaseId but with override field is schema-valid", () => { // The schema accepts this shape; runtime validation in resume.ts catches // missing phaseId when overrides are supplied. - const r = Value.Decode(TaskflowParams, { + const r = validateParams({ action: "resume", runId: "run-abc", resumeTask: "retry", @@ -150,7 +157,7 @@ test("TaskflowParams: resume without phaseId but with override field is schema-v // --------------------------------------------------------------------------- test("TaskflowParams: action=version with additional fields is schema-valid", () => { - const r = Value.Decode(TaskflowParams, { + const r = validateParams({ action: "version", name: "optional-extra", }); @@ -158,18 +165,25 @@ test("TaskflowParams: action=version with additional fields is schema-valid", () assert.equal(r.name, "optional-extra"); }); -test("TaskflowParams: undefined/empty object gets default action", () => { - const r = Value.Decode(TaskflowParams, {}); - assert.equal(r.action, "run"); +test("TaskflowParams: Pi SDK accepts omitted action without inserting defaults", () => { + const shapes: JsonObject[] = [{}, { task: "x" }, { tasks: [{ task: "x" }] }, { chain: [{ task: "x" }] }]; + for (const args of shapes) { + const r = validateParams(args); + assert.equal(r.action, undefined, "dispatch supplies the run default; SDK does not"); + } }); -test("TaskflowParams: extra unknown properties are stripped", () => { - const r = Value.Decode(TaskflowParams, { +test("TaskflowParams: extra unknown properties follow the real SDK contract", () => { + const r = validateParams({ action: "run", task: "x", - unknownField: "should-be-stripped", - } as unknown as Record); + unknownField: "allowed-extra", + }); assert.equal(r.action, "run"); assert.equal(r.task, "x"); - assert.equal((r as unknown as Record).unknownField, undefined); + assert.equal(r.unknownField, "allowed-extra", "SDK preserves allowed additional properties"); +}); + +test("TaskflowParams: the Pi SDK rejects unknown operations", () => { + assert.throws(() => validateParams({ action: "invalid-action" }), /Validation failed/); }); diff --git a/packages/taskflow-control/package.json b/packages/taskflow-control/package.json index 7e75ed21..3b2cf425 100644 --- a/packages/taskflow-control/package.json +++ b/packages/taskflow-control/package.json @@ -1,7 +1,7 @@ { "name": "taskflow-control", "version": "0.3.0-beta.2", - "description": "0.3-C control plane core (proposed wire): ControlHost daemon/supervisor/standalone contracts, Unix UDS singleton attach, files-only project ControlStore, hello-before-RPC negotiation, and the Trusted Effects (taskflow-core resources) provider as the only execution authority. TypeBox contracts and wire-freeze.md remain PROPOSED — not frozen.", + "description": "Experimental workspace-only control plane: Unix UDS singleton attach and files-only project store. Proposed wire contracts; admission, parked approvals, receipts, registry coordination and WebUI are incomplete and excluded from the Taskflow 1.0 public contract.", "keywords": [ "taskflow", "control-plane", @@ -58,32 +58,11 @@ "build": "rm -rf dist && tsc -p tsconfig.build.json", "prepublishOnly": "npm run build" }, - "publishConfig": { - "access": "public", - "tag": "next", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./schema": { - "types": "./dist/schema/index.d.ts", - "default": "./dist/schema/index.js" - }, - "./control-host": { - "types": "./dist/control-host.d.ts", - "default": "./dist/control-host.js" - }, - "./*": { - "types": "./dist/*.d.ts", - "default": "./dist/*.js" - } - } - }, "dependencies": { "taskflow-core": "workspace:*" }, "peerDependencies": { "typebox": "*" - } + }, + "private": true } diff --git a/packages/taskflow-core/package.json b/packages/taskflow-core/package.json index 1d932661..9505684e 100644 --- a/packages/taskflow-core/package.json +++ b/packages/taskflow-core/package.json @@ -1,6 +1,6 @@ { "name": "taskflow-core", - "version": "0.3.0-beta.2", + "version": "1.0.0", "description": "Host-neutral engine for declarative, verifiable task-DAG orchestration \u2014 the runtime, DSL, cache, and verification shared by pi-taskflow, codex-taskflow, claude-taskflow, opencode-taskflow, grok-taskflow, and hermes-taskflow.", "keywords": [ "taskflow", diff --git a/packages/taskflow-core/src/runner-core.ts b/packages/taskflow-core/src/runner-core.ts index 1deb238e..d828e0a8 100644 --- a/packages/taskflow-core/src/runner-core.ts +++ b/packages/taskflow-core/src/runner-core.ts @@ -200,8 +200,10 @@ export function foldEventLine(acc: EventAccumulator, line: string): LiveUpdate | } else { acc.truncated = true; } - if (msg.role !== "assistant") return null; - acc.usage.turns++; + // Pi tools may call models (including nested codemode calls). Their result + // already contains the aggregate usage: account only this top-level record, + // never nestedCalls or the redundant agent_end history. + if (msg.role !== "assistant" && msg.role !== "toolResult") return null; const u = (msg as any).usage; if (u) { acc.usage.input += u.input || 0; @@ -209,8 +211,12 @@ export function foldEventLine(acc: EventAccumulator, line: string): LiveUpdate | acc.usage.cacheRead += u.cacheRead || 0; acc.usage.cacheWrite += u.cacheWrite || 0; acc.usage.cost += u.cost?.total || 0; - acc.usage.contextTokens = u.totalTokens || 0; + // Context size describes the main assistant's latest call, not a tool's + // independent model context. It is a gauge rather than an additive total. + if (msg.role === "assistant") acc.usage.contextTokens = u.totalTokens || 0; } + if (msg.role !== "assistant") return null; + acc.usage.turns++; if (!acc.model && (msg as any).model) acc.model = (msg as any).model; if ((msg as any).stopReason) acc.stopReason = (msg as any).stopReason; if ((msg as any).errorMessage) { diff --git a/packages/taskflow-core/test/adv-high-fix.test.ts b/packages/taskflow-core/test/adv-high-fix.test.ts index 44a26a77..6be7338c 100644 --- a/packages/taskflow-core/test/adv-high-fix.test.ts +++ b/packages/taskflow-core/test/adv-high-fix.test.ts @@ -73,7 +73,7 @@ test("discovery: accepts real project .pi and verifiers subdir", async () => { path.join(vdir, "ok.js"), "export default { name: 'ok-v', verify() { return []; } };\n", ); - assert.equal(findProjectVerifiersDir(project), vdir); + assert.equal(findProjectVerifiersDir(project), fs.realpathSync(vdir)); const r = await discoverVerifiers(project); assert.ok(r.verifiers.some((v) => v.name === "ok-v")); }); @@ -138,7 +138,8 @@ async function prepareTx(workspace: string, relative: string, effectId = "w") { journal, leaseTimeoutMs: 5_000, permitTtlMs: 30_000, - authorizationScopeRoot: workspace, + // Match the canonical boundPath supplied by the production authority binding. + authorizationScopeRoot: fs.realpathSync(workspace), }); return { prepared, control, workspace }; } @@ -161,7 +162,7 @@ test("file-transaction: concurrent commit is rejected (busy)", async () => { const fulfilled = results.filter((r): r is PromiseFulfilledResult>> => r.status === "fulfilled"); const rejected = results.filter((r) => r.status === "rejected"); const oks = fulfilled.filter((r) => r.value.ok); - assert.equal(oks.length, 1, `expected exactly one ok commit, got ${oks.length}, rejected=${rejected.length}`); + assert.equal(oks.length, 1, `expected exactly one ok commit, got ${oks.length}, rejected=${rejected.length}: ${JSON.stringify(fulfilled.map((r) => r.value))}`); const body = fs.readFileSync(path.join(workspace, "out/a.txt"), "utf8"); assert.ok(body === "one" || body === "two", body); if (rejected.length) { @@ -212,7 +213,7 @@ test("file-transaction: deferred lease callback throw does not flip ok:true", as journal, leaseTimeoutMs: 5_000, permitTtlMs: 30_000, - authorizationScopeRoot: workspace, + authorizationScopeRoot: fs.realpathSync(workspace), onDeferredLeaseRelease: () => { throw new Error("injected deferred release failure"); }, @@ -220,6 +221,6 @@ test("file-transaction: deferred lease callback throw does not flip ok:true", as // Success path: lease release usually succeeds so callback may not run; commit must still ok. const r = await prepared.commit([{ effectId: "w2", content: "ok" }]); - assert.equal(r.ok, true); + assert.equal(r.ok, true, JSON.stringify(r)); assert.equal(fs.readFileSync(path.join(workspace, "out/b.txt"), "utf8"), "ok"); }); diff --git a/packages/taskflow-core/test/agents.test.ts b/packages/taskflow-core/test/agents.test.ts index 726282b7..f3dcbd33 100644 --- a/packages/taskflow-core/test/agents.test.ts +++ b/packages/taskflow-core/test/agents.test.ts @@ -172,7 +172,7 @@ test("discoverAgents: discovers project agents from /.pi/agents/", () => { assert.equal(agents.length, 1); assert.equal(agents[0].name, "auditor"); assert.equal(agents[0].source, "project"); - assert.equal(projectAgentsDir, projAgentsDir); + assert.equal(projectAgentsDir, fs.realpathSync(projAgentsDir)); }); test("discoverAgents: returns empty agents when no agent dirs exist", () => { @@ -352,7 +352,7 @@ test("discoverAgents: finds .pi/agents in parent directory", () => { const { agents, projectAgentsDir } = discoverAgents(childDir, "project"); assert.equal(agents.length, 1); assert.equal(agents[0].name, "deep"); - assert.equal(projectAgentsDir, path.join(parentDir, ".pi", "agents")); + assert.equal(projectAgentsDir, fs.realpathSync(path.join(parentDir, ".pi", "agents"))); }); test("discoverAgents: prefers closest .pi/agents when multiple exist", () => { @@ -367,7 +367,7 @@ test("discoverAgents: prefers closest .pi/agents when multiple exist", () => { const { agents, projectAgentsDir } = discoverAgents(child, "project"); assert.equal(agents.length, 1); assert.equal(agents[0].name, "parent-agent"); - assert.equal(projectAgentsDir, path.join(parent, ".pi", "agents")); + assert.equal(projectAgentsDir, fs.realpathSync(path.join(parent, ".pi", "agents"))); }); test("discoverAgents: returns null projectAgentsDir when no .pi/agents found", () => { diff --git a/packages/taskflow-core/test/define-file.test.ts b/packages/taskflow-core/test/define-file.test.ts index 9ac8bbee..67152522 100644 --- a/packages/taskflow-core/test/define-file.test.ts +++ b/packages/taskflow-core/test/define-file.test.ts @@ -114,8 +114,10 @@ test("readDefineFileWithSource: enforces stable allowed-root containment", () => const outsideDef = { name: "outside", phases: [{ id: "p1", type: "agent", agent: "a", task: "outside" }] }; fs.writeFileSync(insideFile, JSON.stringify(insideDef)); fs.writeFileSync(outsideFile, JSON.stringify(outsideDef)); - assert.equal(readDefineFileWithSource(insideFile, [fs.realpathSync(allowed)]).ok, true); - const escaped = readDefineFileWithSource(outsideFile, [fs.realpathSync(allowed)]); + // Constrained saved-flow loading receives canonical paths from discovery. + // A lexical alias must not be confused with a changed directory/symlink. + assert.equal(readDefineFileWithSource(fs.realpathSync(insideFile), [fs.realpathSync(allowed)]).ok, true); + const escaped = readDefineFileWithSource(fs.realpathSync(outsideFile), [fs.realpathSync(allowed)]); assert.equal(escaped.ok, false); if (!escaped.ok) assert.match(escaped.detail, /escaped or changed its canonical saved-flow root/); } finally { diff --git a/packages/taskflow-core/test/detached.test.ts b/packages/taskflow-core/test/detached.test.ts index e67f4ca1..58949fa3 100644 --- a/packages/taskflow-core/test/detached.test.ts +++ b/packages/taskflow-core/test/detached.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import type { ChildProcess } from "node:child_process"; import { test } from "node:test"; import { pathToFileURL } from "node:url"; import type { Taskflow } from "../src/schema.ts"; @@ -114,6 +115,9 @@ test("store: RunState with pid/detached persists and loads correctly", () => { test("detached-runner: completes flow and persists terminal state", async () => { const cwd = makeTmpCwd(); + let child: ChildProcess | undefined; + let closed = false; + let childExit: Promise<{ code: number | null; error?: Error }> | undefined; try { // Create a saved flow that the runner can execute. const def = minimalFlow("detach-e2e"); @@ -126,7 +130,8 @@ test("detached-runner: completes flow and persists terminal state", async () => // without needing live model access. const mockRunnerPath = path.join(cwd, "mock-detached-runner.mts"); fs.writeFileSync(mockRunnerPath, ` -import { readFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { loadRun, saveRun } from "${pathToFileURL(path.resolve("packages/taskflow-core/src/store.ts")).href}"; interface DetachContext { @@ -150,11 +155,16 @@ state.phases = { }, }; saveRun(state); +// The terminal run record becomes visible before the runner's final writes. +// Keep this window deterministic so cleanup cannot race a still-live writer. +await new Promise((resolve) => setTimeout(resolve, 200)); +saveRun(state); +writeFileSync(join(ctx.cwd, "runner-tail-complete"), "done"); `); // Spawn the mock runner as a detached process. const { spawn } = await import("node:child_process"); - const tmpFile = path.join(os.tmpdir(), `taskflow-detach-ctx-${state.runId}.json`); + const tmpFile = path.join(cwd, `taskflow-detach-ctx-${state.runId}.json`); fs.writeFileSync(tmpFile, JSON.stringify({ runId: state.runId, defName: "detach-e2e", @@ -162,11 +172,14 @@ saveRun(state); cwd, })); - const child = spawn(process.execPath, ["--experimental-strip-types", mockRunnerPath, tmpFile], { + child = spawn(process.execPath, ["--experimental-strip-types", mockRunnerPath, tmpFile], { detached: true, stdio: "ignore", }); - child.unref(); + childExit = new Promise((resolve) => { + child!.once("close", (code) => { closed = true; resolve({ code }); }); + child!.once("error", (error) => resolve({ code: null, error })); + }); // Record PID and persist (mirrors index.ts detach logic). state.pid = child.pid ?? undefined; @@ -185,11 +198,32 @@ saveRun(state); assert.equal(loaded!.detached, true); assert.equal(loaded!.pid, child.pid); assert.equal(loaded!.phases.p1?.output, "detached completed"); + // A persisted terminal record is not process-exit evidence: saveRun may + // still be updating its index or cleaning stale files after that rename. + let exitTimer: ReturnType | undefined; + try { + const exit = await Promise.race([ + childExit, + new Promise((_resolve, reject) => { + exitTimer = setTimeout(() => reject(new Error("mock detached runner did not exit")), 5000); + }), + ]); + assert.equal(exit.error, undefined); + assert.equal(exit.code, 0, "mock detached runner must exit successfully"); + } finally { + if (exitTimer) clearTimeout(exitTimer); + } + assert.equal(fs.readFileSync(path.join(cwd, "runner-tail-complete"), "utf8"), "done"); // Cleanup temp files. try { fs.unlinkSync(mockRunnerPath); } catch { /* ignore */ } try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } } finally { + // Failure paths also close the live writer before deleting its workspace. + if (child && !closed) { + child.kill("SIGKILL"); + await childExit; + } cleanup(cwd); } }); diff --git a/packages/taskflow-core/test/library-store.test.ts b/packages/taskflow-core/test/library-store.test.ts index 36a91feb..e532feea 100644 --- a/packages/taskflow-core/test/library-store.test.ts +++ b/packages/taskflow-core/test/library-store.test.ts @@ -74,7 +74,7 @@ test("saveFlowWithMeta: updates a nested flow and its adjacent sidecar in place" const saved = saveFlowWithMeta(cwd, updated, meta); assert.equal(saved.filePath, fs.realpathSync(flowPath)); - assert.equal(saved.metaPath, path.join(nestedDir, "audit.meta.json")); + assert.equal(saved.metaPath, fs.realpathSync(path.join(nestedDir, "audit.meta.json"))); assert.equal(fs.existsSync(path.join(root, `${sampleDef.name}.json`)), false); assert.equal(metaOf(cwd, sampleDef.name)?.purpose, "updated nested metadata"); } finally { diff --git a/packages/taskflow-core/test/resource-file-transaction.test.ts b/packages/taskflow-core/test/resource-file-transaction.test.ts index 5d7374df..5713972e 100644 --- a/packages/taskflow-core/test/resource-file-transaction.test.ts +++ b/packages/taskflow-core/test/resource-file-transaction.test.ts @@ -312,7 +312,7 @@ test("resource file transaction: later promotion failure rolls back every earlie journal, leaseTimeoutMs: 500, permitTtlMs: 5_000, - authorizationScopeRoot: root, + authorizationScopeRoot: fs.realpathSync(root), }); const result = await tx.commit([ { effectId: "a", content: "A" }, @@ -351,10 +351,10 @@ test("resource file transaction: post-terminal lease cleanup failure never makes journal, leaseTimeoutMs: 500, permitTtlMs: 5_000, - authorizationScopeRoot: root, + authorizationScopeRoot: fs.realpathSync(root), }); const result = await tx.commit([{ effectId: "a", content: "A" }]); - assert.equal(result.ok, true); + assert.equal(result.ok, true, JSON.stringify(result)); assert.equal(fs.readFileSync(path.join(root, "a.txt"), "utf8"), "A"); assert.equal((await journal.listIntents())[0]?.status, "committed-content"); assert.ok(warnings.some((warning) => /lease cleanup deferred/.test(warning))); @@ -390,7 +390,7 @@ test("resource file transaction: post-commit staged cleanup failure remains dura journal, leaseTimeoutMs: 500, permitTtlMs: 5_000, - authorizationScopeRoot: root, + authorizationScopeRoot: fs.realpathSync(root), cleanupStaging: () => { cleanupCalls++; throw new Error("injected staged cleanup failure"); @@ -398,7 +398,7 @@ test("resource file transaction: post-commit staged cleanup failure remains dura } as Parameters[0] & { cleanupStaging: () => void }; const tx = await prepareResourceFileTransaction(options); const result = await tx.commit([{ effectId: "a", content: "A" }]); - assert.equal(result.ok, true); + assert.equal(result.ok, true, JSON.stringify(result)); assert.equal(cleanupCalls, 1); assert.equal(fs.readFileSync(path.join(root, "a.txt"), "utf8"), "A"); assert.equal((await journal.listIntents())[0]?.status, "committed-content"); @@ -432,7 +432,7 @@ test("resource file transaction: activation plus journal-inspection double fault journal: new ActivationAndInspectionFailJournal({ directory: control, journalEpoch: 1 }), leaseTimeoutMs: 500, permitTtlMs: 5_000, - authorizationScopeRoot: root, + authorizationScopeRoot: fs.realpathSync(root), }), /injected journal inspection failure/, ); diff --git a/packages/taskflow-core/test/tool-usage.test.ts b/packages/taskflow-core/test/tool-usage.test.ts new file mode 100644 index 00000000..e21b77ef --- /dev/null +++ b/packages/taskflow-core/test/tool-usage.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { foldEventLine, newAccumulator } from "../src/runner-core.ts"; + +function message(role: string, usage: Record, extra = {}) { + return JSON.stringify({ type: "message_end", message: { role, content: [], usage, ...extra } }); +} + +test("tool usage: top-level toolResult usage adds cost and tokens without adding assistant turns or context", () => { + const acc = newAccumulator("main-model"); + foldEventLine(acc, message("assistant", { input: 100, output: 20, totalTokens: 120, cost: { total: 0.01 } }, { + content: [{ type: "text", text: "working" }], stopReason: "toolUse", + })); + const live = foldEventLine(acc, message("toolResult", { + input: 40, output: 10, cacheRead: 8, cacheWrite: 4, totalTokens: 62, cost: { total: 0.02 }, + }, { model: "nested-model", errorMessage: "tool failure", stopReason: "error" })); + assert.equal(live, null); + assert.deepEqual(acc.usage, { input: 140, output: 30, cacheRead: 8, cacheWrite: 4, cost: 0.03, contextTokens: 120, turns: 1 }); + assert.equal(acc.model, "main-model"); + assert.equal(acc.stopReason, "toolUse"); + assert.equal(acc.fatalError, undefined); + assert.equal(acc.finalText, "working"); + foldEventLine(acc, message("assistant", { input: 200, output: 30, totalTokens: 230, cost: { total: 0.03 } })); + assert.equal(acc.usage.contextTokens, 230); + assert.equal(acc.usage.turns, 2); + assert.equal(acc.usage.cost, 0.06); +}); + +test("tool usage: nested calls and redundant history do not duplicate the tool result's aggregate usage", () => { + const acc = newAccumulator(); + const usage = { input: 12, output: 3, totalTokens: 15, cost: { total: 0.04 } }; + const toolResult = { role: "toolResult", content: [], usage, nestedCalls: [{ usage }, { result: { usage } }] }; + foldEventLine(acc, JSON.stringify({ type: "message_start", message: toolResult })); + foldEventLine(acc, JSON.stringify({ type: "message_update", message: toolResult })); + foldEventLine(acc, JSON.stringify({ type: "message_end", message: toolResult })); + foldEventLine(acc, JSON.stringify({ type: "agent_end", messages: [toolResult] })); + assert.deepEqual(acc.usage, { input: 12, output: 3, cacheRead: 0, cacheWrite: 0, cost: 0.04, contextTokens: 0, turns: 0 }); +}); + +test("tool usage: still accounted beyond the retained transcript cap", () => { + const acc = newAccumulator(); + for (let i = 0; i < 501; i++) foldEventLine(acc, message("toolResult", { output: 1, cost: { total: 1 } })); + assert.equal(acc.messages.length, 500); + assert.equal(acc.truncated, true); + assert.equal(acc.usage.output, 501); + assert.equal(acc.usage.cost, 501); + assert.equal(acc.usage.turns, 0); +}); + +test("tool usage: user or custom message metadata is not billable usage", () => { + const acc = newAccumulator(); + for (const role of ["user", "custom", "system"]) foldEventLine(acc, message(role, { input: 10, cost: { total: 9 } })); + assert.equal(acc.usage.input, 0); + assert.equal(acc.usage.cost, 0); +}); diff --git a/packages/taskflow-dsl/package.json b/packages/taskflow-dsl/package.json index d0447747..217a2f00 100644 --- a/packages/taskflow-dsl/package.json +++ b/packages/taskflow-dsl/package.json @@ -1,6 +1,6 @@ { "name": "taskflow-dsl", - "version": "0.3.0-beta.2", + "version": "1.0.0", "description": "Compile-time TypeScript DSL frontend for taskflow: erase .tf.ts runes to Taskflow JSON, then FlowIR via taskflow-core.", "keywords": [ "taskflow", diff --git a/packages/taskflow-dsl/test/e2e-dist-cli.mts b/packages/taskflow-dsl/test/e2e-dist-cli.mts index c0667729..88c2a5b0 100644 --- a/packages/taskflow-dsl/test/e2e-dist-cli.mts +++ b/packages/taskflow-dsl/test/e2e-dist-cli.mts @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; const here = path.dirname(fileURLToPath(import.meta.url)); const repo = path.resolve(here, "../../.."); +const version: string = JSON.parse(fs.readFileSync(path.join(here, "../package.json"), "utf8")).version; const temp = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-dsl-dist-e2e-")); function exec(command: string, args: string[], cwd = repo): string { @@ -18,7 +19,7 @@ try { exec("pnpm", ["--filter", "taskflow-dsl", "build"]); const cli = path.join(repo, "packages/taskflow-dsl/dist/cli.js"); assert.equal(fs.existsSync(cli), true, "dist/cli.js must exist after build"); - assert.match(exec(process.execPath, [cli, "--version"]), /^0\.2\.1\s*$/); + assert.equal(exec(process.execPath, [cli, "--version"]).trim(), version); const project = path.join(temp, "project"); fs.mkdirSync(project); @@ -49,7 +50,7 @@ try { fs.writeFileSync(path.join(install, "package.json"), JSON.stringify({ private: true })); exec("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", ...tgz], install); const installedCli = path.join(install, "node_modules/.bin/taskflow-dsl"); - assert.match(exec(installedCli, ["--version"], install), /^0\.2\.1\s*$/); + assert.equal(exec(installedCli, ["--version"], install).trim(), version); const installedSource = path.join(install, "installed.tf.ts"); fs.writeFileSync(installedSource, `import { flow, agent } from "taskflow-dsl";\nexport default flow("installed", () => agent("ok", { context: ["README.md"], cache: { scope: "off" } }));\n`); assert.match(exec(installedCli, ["check", "installed.tf.ts", "--cwd", install], install), /^ok\s*$/); diff --git a/packages/taskflow-hosts/package.json b/packages/taskflow-hosts/package.json index 120cca1c..9f7e57d7 100644 --- a/packages/taskflow-hosts/package.json +++ b/packages/taskflow-hosts/package.json @@ -1,6 +1,6 @@ { "name": "taskflow-hosts", - "version": "0.3.0-beta.2", + "version": "1.0.0", "description": "Shared host-runner collection for taskflow \u2014 the codex, claude, opencode, grok, and hermes SubagentRunner implementations + their argv builders and event-stream parsers. The per-host MCP servers, plugin scaffolds, and bins live in codex-taskflow / claude-taskflow / opencode-taskflow / grok-taskflow / hermes-taskflow; this package holds just the runners so a new host can be added in one place.", "homepage": "https://github.com/heggria/taskflow#readme", "author": "heggria ", diff --git a/packages/taskflow-hosts/test/publish-verification.test.ts b/packages/taskflow-hosts/test/publish-verification.test.ts index ed1b0822..dce5bd3c 100644 --- a/packages/taskflow-hosts/test/publish-verification.test.ts +++ b/packages/taskflow-hosts/test/publish-verification.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import { readFileSync, readdirSync } from "node:fs"; import { test } from "node:test"; import { sha512Integrity, verifyRegistryIdentity } from "../../../scripts/verify-published-package.mjs"; @@ -114,6 +115,7 @@ test("publish workflow verifies every package after registry mutation", () => { "claude-taskflow", "opencode-taskflow", "grok-taskflow", + "hermes-taskflow", ]) { assert.match(workflow, new RegExp(`verify_one ${pkg}`)); } @@ -193,3 +195,65 @@ test("scanner upgrades retain the high-severity and minimum-score gates", () => assert.match(workflow, /submission_enabled: "false"/); assert.doesNotMatch(workflow, /continue-on-error:\s*true/); }); + + +test("publish workflow rejects candidate release notes before packing or publishing", () => { + const workflow = readFileSync(new URL("../../../.github/workflows/publish.yml", import.meta.url), "utf8"); + const gate = workflow.indexOf("node scripts/verify-release-contract.mjs --published"); + assert.ok(gate >= 0, "dated release contract is mandatory"); + assert.ok(gate < workflow.indexOf("Create deterministic release tarballs"), "reject drift before preparing release artifacts"); + assert.ok(gate < workflow.indexOf("npm publish"), "reject drift before npm mutation"); +}); + + +test("existing GitHub releases rerun under set -e with typed false/true state and reject malformed booleans", () => { + const workflow = readFileSync(new URL("../../../.github/workflows/publish.yml", import.meta.url), "utf8"); + const start = workflow.indexOf(' RELEASE_TAG="'); + assert.ok(start >= 0, "existing release validation block missing"); + const end = workflow.indexOf("\n fi", start); + assert.ok(end > start, "existing release validation block incomplete"); + // Execute the actual workflow's rerun branch rather than a duplicate of + // its jq filters. No gh/network call is needed for existing metadata. + const script = `set -euo pipefail\n${workflow.slice(start, end)}`; + const cases: [string, Record, string, boolean][] = [ + ["stable false/false", { draft: false, prerelease: false }, "1.0.0", true], + ["beta false/true", { draft: false, prerelease: true }, "1.0.0-beta.1", true], + ["draft true", { draft: true, prerelease: false }, "1.0.0", false], + ["stable prerelease true", { draft: false, prerelease: true }, "1.0.0", false], + ["beta prerelease false", { draft: false, prerelease: false }, "1.0.0-beta.1", false], + ]; + for (const field of ["draft", "prerelease"]) { + for (const value of [null, undefined, "false", 0, {}, []]) { + const state: Record = { draft: false, prerelease: false }; + if (value === undefined) delete state[field]; else state[field] = value; + cases.push([`${field} ${value === undefined ? "missing" : JSON.stringify(value)}`, state, "1.0.0", false]); + } + } + for (const [name, state, version, accepted] of cases) { + const result = spawnSync("bash", ["-c", script], { + encoding: "utf8", + env: { ...process.env, VERSION: version, GITHUB_REF_NAME: `v${version}`, TAG_COMMIT: "deadbeef", + RELEASE_JSON: JSON.stringify({ tag_name: `v${version}`, target_commitish: "deadbeef", ...state }) }, + }); + assert.equal(result.error, undefined, `${name}: could not execute workflow branch`); + if (accepted) { + assert.equal(result.status, 0, `${name}: ${result.stderr}`); + assert.match(result.stdout, /verified target; skipping creation/, name); + } else { + assert.notEqual(result.status, 0, `${name}: malformed/incompatible state must fail closed`); + assert.doesNotMatch(result.stdout, /skipping creation/, name); + } + } +}); + + +test("publish job verifies registry channels after every artifact and gates GitHub Release creation", () => { + const workflow = readFileSync(new URL("../../../.github/workflows/publish.yml", import.meta.url), "utf8"); + const publish = workflowJob(workflow, "publish"); + const guard = publish.indexOf('node scripts/verify-release-dist-tags.mjs "$VERSION" "$NPM_TAG"'); + assert.ok(guard > publish.indexOf("verify_one hermes-taskflow"), "verify channels only after all ten provenance/integrity checks"); + assert.ok(guard >= 0, "npm channel check is mandatory"); + assert.doesNotMatch(publish, /continue-on-error:\s*true/); + const release = workflowJob(workflow, "release"); + assert.match(release, /needs: publish/); +}); diff --git a/packages/taskflow-hosts/test/release-contract.test.ts b/packages/taskflow-hosts/test/release-contract.test.ts new file mode 100644 index 00000000..6e398969 --- /dev/null +++ b/packages/taskflow-hosts/test/release-contract.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { STABLE_PACKAGE_NAMES, verifyReleaseContract } from "../../../scripts/verify-release-contract.mjs"; + +const repo = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +function fixture(changelogState: "unreleased" | "dated" = "unreleased") { + const root = mkdtempSync(join(tmpdir(), "taskflow-release-contract-")); + const files = ["package.json", ...[...STABLE_PACKAGE_NAMES, "charterarc", "taskflow-control"].map((name) => `packages/${name}/package.json`), + ...(["codex", "claude", "grok"] as const).flatMap((host) => [`packages/${host}-taskflow/plugin/.${host}-plugin/plugin.json`, `packages/${host}-taskflow/plugin/.mcp.json`]), + "packages/opencode-taskflow/plugin/opencode.json", "packages/hermes-taskflow/plugin/hermes.config.snippet.yaml"]; + for (const file of files) { mkdirSync(dirname(join(root, file)), { recursive: true }); copyFileSync(join(repo, file), join(root, file)); } + const heading = changelogState === "dated" ? "2030-01-02" : "Unreleased"; + writeFileSync(join(root, "CHANGELOG.md"), `# Changelog\n\n## [1.0.0] — ${heading}\n`); + return root; +} +function change(root: string, file: string, mutate: (manifest: Record) => void) { + const path = join(root, file); const manifest = JSON.parse(readFileSync(path, "utf8")); mutate(manifest); writeFileSync(path, JSON.stringify(manifest)); +} + +test("1.0 release contract binds exactly the ten public packages", () => { + const root = fixture(); + try { + const result = verifyReleaseContract(root); + assert.deepEqual(result.packages, ["taskflow-core", "taskflow-mcp-core", "taskflow-hosts", "taskflow-dsl", "pi-taskflow", "codex-taskflow", "claude-taskflow", "opencode-taskflow", "grok-taskflow", "hermes-taskflow"]); + assert.equal(result.version, "1.0.0"); assert.equal(result.npmTag, "latest"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +for (const changelogState of ["unreleased", "dated"] as const) { + test(`release contract accepts ${changelogState} preparation and admits only dated publication`, () => { + // The fixture writes its own heading; dating the real release must not + // change test expectations or prevent the tag workflow from proceeding. + const root = fixture(changelogState); + try { + assert.equal(verifyReleaseContract(root).npmTag, "latest"); + if (changelogState === "unreleased") { + assert.throws(() => verifyReleaseContract(root, { published: true }), /dated changelog/); + } else { + assert.equal(verifyReleaseContract(root, { published: true }).npmTag, "latest"); + } + } finally { rmSync(root, { recursive: true, force: true }); } + }); +} + +test("release contract rejects drift, unpinned templates and experimental dependencies before publication", () => { + const cases: [string, (root: string) => void, RegExp][] = [ + ["adapter version", (root) => change(root, "packages/pi-taskflow/package.json", (m) => { m.version = "0.0.0"; }), /pi-taskflow: release version/], + ["plugin version", (root) => change(root, "packages/codex-taskflow/plugin/.codex-plugin/plugin.json", (m) => { m.version = "0.0.0"; }), /codex: plugin version/], + ["hermes floating pin", (root) => { const path = join(root, "packages/hermes-taskflow/plugin/hermes.config.snippet.yaml"); writeFileSync(path, readFileSync(path, "utf8").replace("hermes-taskflow@1.0.0", "hermes-taskflow@latest")); }, /hermes: MCP pin/], + ["experimental control", (root) => change(root, "packages/taskflow-control/package.json", (m) => { m.private = false; }), /control must remain private/], + ["experimental dependency", (root) => change(root, "packages/taskflow-hosts/package.json", (m) => { m.dependencies = { "taskflow-control": "workspace:*" }; }), /experimental dependency/], + ["mixed dependency", (root) => change(root, "packages/taskflow-hosts/package.json", (m) => { m.dependencies = { "taskflow-core": "^1.0.0" }; }), /must use workspace/], + ["experimental latest", (root) => change(root, "packages/charterarc/package.json", (m) => { m.publishConfig = { tag: "latest" }; }), /experimental channel/], + ]; + for (const [name, mutate, expected] of cases) { + const root = fixture(); try { mutate(root); assert.throws(() => verifyReleaseContract(root), expected, name); } finally { rmSync(root, { recursive: true, force: true }); } + } +}); + + +test("published changelog dates must exist in the Gregorian calendar", () => { + for (const date of ["2026-99-99", "2026-02-30", "2026-02-29", "1900-02-29", "2026-00-01", "2026-12-32"]) { + const root = fixture("dated"); + try { + writeFileSync(join(root, "CHANGELOG.md"), `## [1.0.0] — ${date}\n`); + assert.throws(() => verifyReleaseContract(root, { published: true }), /real calendar date/, date); + assert.throws(() => verifyReleaseContract(root), /real calendar date/, `${date}: candidate must also reject invalid dates`); + } finally { rmSync(root, { recursive: true, force: true }); } + } + for (const date of ["2026-10-02", "2028-02-29", "2000-02-29"]) { + const root = fixture("dated"); + try { + writeFileSync(join(root, "CHANGELOG.md"), `## [1.0.0] — ${date}\n`); + assert.equal(verifyReleaseContract(root, { published: true }).npmTag, "latest", date); + } finally { rmSync(root, { recursive: true, force: true }); } + } +}); diff --git a/packages/taskflow-hosts/test/release-dist-tags.test.ts b/packages/taskflow-hosts/test/release-dist-tags.test.ts new file mode 100644 index 00000000..f805bb17 --- /dev/null +++ b/packages/taskflow-hosts/test/release-dist-tags.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { STABLE_PACKAGE_NAMES } from "../../../scripts/verify-release-contract.mjs"; +import { verifyReleaseDistTags } from "../../../scripts/verify-release-dist-tags.mjs"; + +for (const [version, channel] of [["1.0.0", "latest"], ["1.0.0-beta.1", "beta"], ["0.3.0-beta.1.2", "beta"]] as const) { + test(`registry channel gate verifies all ten ${channel}=${version} while leaving other channels independent`, () => { + const queried: string[] = []; + const result = verifyReleaseDistTags(version, channel, (name) => { + queried.push(name); + return { latest: "0.2.10", beta: "0.3.0-beta.1", [channel]: version }; + }); + assert.deepEqual(queried, STABLE_PACKAGE_NAMES); + assert.deepEqual(result.verified, STABLE_PACKAGE_NAMES); + }); +} + +test("registry channel gate rejects one mismatching adapter even when the requested version exists on beta", () => { + const queried: string[] = []; + assert.throws(() => verifyReleaseDistTags("1.0.0", "latest", (name) => { + queried.push(name); + return { latest: name === "hermes-taskflow" ? "0.2.10" : "1.0.0", beta: "1.0.0" }; + }), /hermes-taskflow: npm latest must equal 1\.0\.0/); + assert.deepEqual(queried, STABLE_PACKAGE_NAMES, "a mismatch must not conceal unchecked packages"); +}); + +test("registry channel gate fails closed for absent, malformed, unavailable or non-string tags", () => { + for (const tags of [null, undefined, [], {}, { latest: null }, { latest: false }, { latest: ["1.0.0"] }]) { + const queried: string[] = []; + assert.throws(() => verifyReleaseDistTags("1.0.0", "latest", (name) => { + queried.push(name); return name === "taskflow-core" ? tags : { latest: "1.0.0" }; + }), /taskflow-core: .*dist-tag|taskflow-core: npm latest must equal/); + assert.deepEqual(queried, STABLE_PACKAGE_NAMES); + } + const queried: string[] = []; + assert.throws(() => verifyReleaseDistTags("1.0.0-beta.1", "beta", (name) => { + queried.push(name); + if (name === "taskflow-core") throw new Error("registry unavailable"); + return { beta: "1.0.0-beta.1" }; + }), /taskflow-core: unable to read npm dist-tags/); + assert.deepEqual(queried, STABLE_PACKAGE_NAMES); +}); + +test("registry channel gate rejects channel substitution and unsupported prereleases before registry reads", () => { + for (const [version, channel] of [["1.0.0", "beta"], ["1.0.0-beta.1", "latest"], ["1.0.0-alpha.1", "alpha"]]) { + let queried = false; + assert.throws(() => verifyReleaseDistTags(version!, channel!, () => { queried = true; return {}; }), /version\/channel mismatch|stable semver or a numbered beta/); + assert.equal(queried, false); + } +}); diff --git a/packages/taskflow-mcp-core/package.json b/packages/taskflow-mcp-core/package.json index 703b0a9c..0d5c3c15 100644 --- a/packages/taskflow-mcp-core/package.json +++ b/packages/taskflow-mcp-core/package.json @@ -1,6 +1,6 @@ { "name": "taskflow-mcp-core", - "version": "0.3.0-beta.2", + "version": "1.0.0", "description": "Host-neutral MCP server for taskflow: a dependency-free stdio JSON-RPC server exposing the taskflow_* tools, plus the DAG SVG/outline renderer. Shared by the codex/claude/opencode/grok/hermes adapters \u2014 depends only on taskflow-core.", "keywords": [ "taskflow", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a806771..c49ac378 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,10 +5,11 @@ settings: excludeLinksFromLockfile: false overrides: + baseline-browser-mapping: 2.11.0 brace-expansion: 5.0.9 nanoid: 3.3.18 postcss: 8.5.23 - sharp: 0.35.3 + sharp: 0.35.4 undici: 8.9.0 importers: @@ -16,17 +17,17 @@ importers: .: devDependencies: '@earendil-works/pi-agent-core': - specifier: ^0.85.1 - version: 0.85.1(ws@8.21.3)(zod@4.6.4) + specifier: 1.0.0 + version: 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4) '@earendil-works/pi-ai': - specifier: ^0.85.1 - version: 0.85.1(ws@8.21.3)(zod@4.6.4) + specifier: 1.0.0 + version: 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4) '@earendil-works/pi-coding-agent': - specifier: ^0.85.1 - version: 0.85.1(ws@8.21.3)(zod@4.6.4) + specifier: 1.0.0 + version: 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.4) '@earendil-works/pi-tui': - specifier: ^0.85.1 - version: 0.85.1 + specifier: 1.0.0 + version: 1.0.0 '@types/node': specifier: ^26 version: 26.5.1 @@ -126,17 +127,17 @@ importers: version: link:../taskflow-core devDependencies: '@earendil-works/pi-agent-core': - specifier: 0.85.1 - version: 0.85.1(ws@8.21.3)(zod@4.6.4) + specifier: 1.0.0 + version: 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4) '@earendil-works/pi-ai': - specifier: 0.85.1 - version: 0.85.1(ws@8.21.3)(zod@4.6.4) + specifier: 1.0.0 + version: 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4) '@earendil-works/pi-coding-agent': - specifier: 0.85.1 - version: 0.85.1(ws@8.21.3)(zod@4.6.4) + specifier: 1.0.0 + version: 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.4) '@earendil-works/pi-tui': - specifier: 0.85.1 - version: 0.85.1 + specifier: 1.0.0 + version: 1.0.0 typebox: specifier: 1.3.30 version: 1.3.30 @@ -185,19 +186,19 @@ importers: dependencies: fumadocs-core: specifier: 16.15.1 - version: 16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4) + version: 16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4) fumadocs-mdx: specifier: 15.3.1 - version: 15.3.1(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + version: 15.3.1(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) fumadocs-ui: specifier: 16.15.1 - version: 16.15.1(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) + version: 16.15.1(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) lucide-react: specifier: 1.33.0 version: 1.33.0(react@19.2.8) next: - specifier: 16.3.3 - version: 16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: 16.3.6 + version: 16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: specifier: 19.2.8 version: 19.2.8 @@ -239,8 +240,8 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@anthropic-ai/sdk@0.123.0': - resolution: {integrity: sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw==} + '@anthropic-ai/sdk@0.124.0': + resolution: {integrity: sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -248,21 +249,8 @@ packages: zod: optional: true - '@aws-crypto/sha256-browser@5.2.0': - resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} - - '@aws-crypto/sha256-js@5.2.0': - resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} - engines: {node: '>=16.0.0'} - - '@aws-crypto/supports-web-crypto@5.2.0': - resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} - - '@aws-crypto/util@5.2.0': - resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - - '@aws-sdk/client-bedrock-runtime@3.1048.0': - resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==} + '@aws-sdk/client-bedrock-runtime@3.1127.0': + resolution: {integrity: sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==} engines: {node: '>=20.0.0'} '@aws-sdk/core@3.978.0': @@ -321,8 +309,8 @@ packages: resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1048.0': - resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} + '@aws-sdk/token-providers@3.1127.0': + resolution: {integrity: sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==} engines: {node: '>=20.0.0'} '@aws-sdk/token-providers@3.1129.0': @@ -333,10 +321,6 @@ packages: resolution: {integrity: sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-locate-window@3.965.10': - resolution: {integrity: sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g==} - engines: {node: '>=20.0.0'} - '@aws-sdk/xml-builder@3.972.40': resolution: {integrity: sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==} engines: {node: '>=20.0.0'} @@ -406,40 +390,42 @@ packages: cpu: [x64] os: [win32] - '@earendil-works/chord@0.85.1': - resolution: {integrity: sha512-VDlkEC3dhCzQ5fcyH1OhG19dq+6jCn+rqc/iXFivwDYGR5anwo2RCiXij9PpHhqNR5GuhhE+Er69Zi1Sn4eY6w==} + '@earendil-works/chord@1.0.0': + resolution: {integrity: sha512-BIfWfrByM0pKq6tfKYXuwx0ed2CvaqIM3EDA7Dps+fP+d3CiPWdlHi1cDsJC05llqKJoRz5//G0hz0yQwwjISQ==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-agent-core@0.85.1': - resolution: {integrity: sha512-hIXIP3eAWueAYiAl8aMvWCvvZ8Q5gT3Dip5bE5uJyIGh4+YlWRjtMLI4BaeoXoSs93zndjue61u1B/vhefLnuA==} + '@earendil-works/pi-agent-core@1.0.0': + resolution: {integrity: sha512-bHFONjtEBDqiV+g1DmmtkSlfAaqHELr+XO+6I5Nah4gswwZrLJO4yZB/JjsnlI4AKWTayBLfgS6DCbeBBz9e2Q==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-ai@0.85.1': - resolution: {integrity: sha512-+VgVIJDkDO2efYJKEEqvPTH4zmnIaXdAppGbO+vKFA9qy5PdhFiAenuFAkU+oiCSfOC4dMHDyrjdQeL4ZoC5CQ==} + '@earendil-works/pi-ai@1.0.0': + resolution: {integrity: sha512-3/W1vdDaVtpeMd23ElvJC12HLA5yS/BGqqcXF+0SK082dN7cbgNcCwguTBRBC258Ke8SzSvUW1B75iAf8w8IxA==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-coding-agent@0.85.1': - resolution: {integrity: sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ==} + '@earendil-works/pi-codemode@1.0.0': + resolution: {integrity: sha512-LPpFI4+T9NzDnhBDs15izWAolaoM8xnwqdziRd6Zx8BQeoEPvzefD2vMMzSyF0rOtq34TfQqkZ0ki16f6cGdMg==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-coding-agent@1.0.0': + resolution: {integrity: sha512-/FtbxoSQU/mEv1QnichJjRjqteqaIaMWxmhB4G367+MwZfX7/DI5B9YAg5lqbN7nztFskBEtUSZ+FlmMBECtMw==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-telemetry@0.85.1': - resolution: {integrity: sha512-Bg/YN6kA7Swja/NQxka8xFdecb4E/auIEGF2G5A25EaQXhRnPj300/7/KpgsDDMYUzHTDAv4RyUxaQPJKW81Rw==} + '@earendil-works/pi-mcp@1.0.0': + resolution: {integrity: sha512-rYra0aF5iPmJd+fsB+VBuqxWNABGJ3Iiyhg0CqIc/91ta2n7IvQmCp0Cac/YkUTNnvRsVZnjgAiBkRi6+ouWuw==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-tui@0.85.1': - resolution: {integrity: sha512-OIzw9efInmO4WOBnD4TxcTdBjmzvYJpzslkgoUro946nEGoYWg5rwv1p4fDt3/JvMx9QybryUCUwlm7j8Dreig==} + '@earendil-works/pi-telemetry@1.0.0': + resolution: {integrity: sha512-WjNBj5TYIiPZFQEz2WlULcDwPLaKwIlmsKjVeYM+LJSbnSp38kWsHUJysIDGnu23IcLbKoPtywsvYfUJCeZePA==} engines: {node: '>=22.19.0'} - '@emnapi/runtime@1.11.2': - resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@earendil-works/pi-tui@1.0.0': + resolution: {integrity: sha512-JsT7kXnpZA2YOtQu6RyriyxEO0eJIzPyfiH09bH+OLN5+s18HYkwaUD/tBkjhnSfMu6/50CQPRYJagzSP6HdPw==} + engines: {node: '>=22.19.0'} - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} @@ -447,300 +433,150 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.28.2': resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.28.2': resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.28.2': resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.28.2': resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.28.2': resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.28.2': resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.28.2': resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.28.2': resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.28.2': resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.28.2': resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.28.2': resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.28.2': resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.28.2': resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.28.2': resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.28.2': resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.28.2': resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.28.2': resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.28.2': resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.28.2': resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.28.2': resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.28.2': resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.28.2': resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.28.2': resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.28.2': resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.28.2': resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} @@ -783,8 +619,8 @@ packages: '@fumari/image-size@0.1.1': resolution: {integrity: sha512-iaiV2wyzJ4mW358NX4CX3yGit+WjKYzpaQ+rldK9BjL/5jFpeV6ruWnZVq7MEyMn3Gd0DLi8g/79NZAAQaGscw==} - '@google/genai@1.52.0': - resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} + '@google/genai@2.21.0': + resolution: {integrity: sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==} engines: {node: '>=20.0.0'} peerDependencies: '@modelcontextprotocol/sdk': ^1.25.2 @@ -796,160 +632,160 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.3': - resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.3': - resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.3': - resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.2': - resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.2': - resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.2': - resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.2': - resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.2': - resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.2': - resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.2': - resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.2': - resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': - resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.2': - resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.3': - resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.3': - resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.3': - resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.3': - resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.3': - resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.3': - resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.3': - resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.3': - resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.3': - resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.3': - resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.3': - resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.3': - resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.3': - resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -973,128 +809,60 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@mariozechner/clipboard-darwin-arm64@0.3.9': - resolution: {integrity: sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@mariozechner/clipboard-darwin-universal@0.3.9': - resolution: {integrity: sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==} - engines: {node: '>= 10'} - os: [darwin] - - '@mariozechner/clipboard-darwin-x64@0.3.9': - resolution: {integrity: sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@mariozechner/clipboard-linux-arm64-gnu@0.3.9': - resolution: {integrity: sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@mariozechner/clipboard-linux-arm64-musl@0.3.9': - resolution: {integrity: sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': - resolution: {integrity: sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==} - engines: {node: '>= 10'} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@mariozechner/clipboard-linux-x64-gnu@0.3.9': - resolution: {integrity: sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@mariozechner/clipboard-linux-x64-musl@0.3.9': - resolution: {integrity: sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': - resolution: {integrity: sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@mariozechner/clipboard-win32-x64-msvc@0.3.9': - resolution: {integrity: sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@mariozechner/clipboard@0.3.9': - resolution: {integrity: sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==} - engines: {node: '>= 10'} - '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} - '@next/env@16.3.3': - resolution: {integrity: sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==} + '@next/env@16.3.6': + resolution: {integrity: sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==} - '@next/swc-darwin-arm64@16.3.3': - resolution: {integrity: sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==} + '@next/swc-darwin-arm64@16.3.6': + resolution: {integrity: sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.3': - resolution: {integrity: sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==} + '@next/swc-darwin-x64@16.3.6': + resolution: {integrity: sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.3': - resolution: {integrity: sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==} + '@next/swc-linux-arm64-gnu@16.3.6': + resolution: {integrity: sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.3': - resolution: {integrity: sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==} + '@next/swc-linux-arm64-musl@16.3.6': + resolution: {integrity: sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.3.3': - resolution: {integrity: sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==} + '@next/swc-linux-x64-gnu@16.3.6': + resolution: {integrity: sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.3.3': - resolution: {integrity: sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==} + '@next/swc-linux-x64-musl@16.3.6': + resolution: {integrity: sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.3': - resolution: {integrity: sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==} + '@next/swc-win32-arm64-msvc@16.3.6': + resolution: {integrity: sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.3': - resolution: {integrity: sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==} + '@next/swc-win32-x64-msvc@16.3.6': + resolution: {integrity: sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1532,18 +1300,10 @@ packages: resolution: {integrity: sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==} engines: {node: '>=18.0.0'} - '@smithy/is-array-buffer@2.2.0': - resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} - engines: {node: '>=14.0.0'} - '@smithy/node-http-handler@4.12.1': resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.7.3': - resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} - engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.7.3': resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==} engines: {node: '>=18.0.0'} @@ -1552,14 +1312,6 @@ packages: resolution: {integrity: sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==} engines: {node: '>=18.0.0'} - '@smithy/util-buffer-from@2.2.0': - resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} - engines: {node: '>=14.0.0'} - - '@smithy/util-utf8@2.3.0': - resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} - engines: {node: '>=14.0.0'} - '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} @@ -1908,6 +1660,10 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + agent-base@9.0.0: + resolution: {integrity: sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==} + engines: {node: '>= 20'} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -1926,8 +1682,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.42: - resolution: {integrity: sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==} + baseline-browser-mapping@2.11.0: + resolution: {integrity: sha512-oCu2wfipvX3AePSgmOuKkIywOu+8n9psz7hXYmk56ghpu3+7KzNIBopaOs4c9BrtdnTtW30unG9GTfHo7EwERQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -1950,9 +1706,9 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chalk@6.0.0: + resolution: {integrity: sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==} + engines: {node: '>=22'} character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -2051,11 +1807,6 @@ packages: esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -2273,8 +2024,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - grok-mermaid@0.2.2: - resolution: {integrity: sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==} + grok-mermaid@0.2.3: + resolution: {integrity: sha512-/4KopAbsjvuRP9MdPtlDjOHUmUVEohOX73JNcsWpzAtFxh+bq5+Dhb6gzvRieLDwIPQIR3/vy8V1NNTuz4Zsmg==} engines: {node: '>=18'} hast-util-from-parse5@8.0.3: @@ -2314,16 +2065,20 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} - http-proxy-agent@7.0.2: - resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} - engines: {node: '>= 14'} + http-proxy-agent@9.1.0: + resolution: {integrity: sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==} + engines: {node: '>= 20'} https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + https-proxy-agent@9.1.0: + resolution: {integrity: sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==} + engines: {node: '>= 20'} + + ignore@7.0.8: + resolution: {integrity: sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==} engines: {node: '>= 4'} inline-style-parser@0.2.7: @@ -2467,8 +2222,8 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - marked@18.0.5: - resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} + marked@18.0.11: + resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==} engines: {node: '>= 20'} hasBin: true @@ -2625,8 +2380,8 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} motion-dom@13.0.0: @@ -2660,8 +2415,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.3.3: - resolution: {integrity: sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==} + next@16.3.6: + resolution: {integrity: sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -2700,12 +2455,25 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} - openai@6.40.0: - resolution: {integrity: sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==} + openai@7.19.0: + resolution: {integrity: sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==} + engines: {node: '>=22.0.0'} peerDependencies: - ws: ^8.18.0 + '@aws-sdk/credential-provider-node': '>=3.972.0 <4' + '@smithy/hash-node': '>=4.3.0 <5' + '@smithy/signature-v4': '>=5.4.0 <6' + undici: 8.9.0 + ws: ^8.21.0 zod: ^3.25 || ^4.0 peerDependenciesMeta: + '@aws-sdk/credential-provider-node': + optional: true + '@smithy/hash-node': + optional: true + '@smithy/signature-v4': + optional: true + undici: + optional: true ws: optional: true zod: @@ -2749,6 +2517,18 @@ packages: resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} engines: {node: '>=12.0.0'} + proxy-agent-negotiate@1.1.0: + resolution: {integrity: sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==} + engines: {node: '>= 20'} + peerDependencies: + kerberos: ^2.0.0 + peerDependenciesMeta: + kerberos: + optional: true + + quickjs-wasi@3.6.2: + resolution: {integrity: sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg==} + react-dom@19.2.8: resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: @@ -2856,18 +2636,13 @@ packages: scroll-into-view-if-needed@3.1.0: resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} - semver@7.8.0: - resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} - engines: {node: '>=10'} - hasBin: true - semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true - sharp@0.35.3: - resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -2956,15 +2731,15 @@ packages: typebox@1.3.10: resolution: {integrity: sha512-L0MT00X96q0P30f1NrGULgaSbmu8hfTjWbLULeVoM+j5u0jR5wyefoDquX2NmRa39f0KiNIBo1wWSaVvHJTZlA==} + typebox@1.3.27: + resolution: {integrity: sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==} + typebox@1.3.3: resolution: {integrity: sha512-URXGUE31PJDQC+PtRMJeLdF4kmmOdFoVPikPCtV2oOIhUpNpppEdIz7W8bH8cFYPYHdDpaRvqwdegMTmHliudg==} typebox@1.3.30: resolution: {integrity: sha512-vRmBLzlaq9O9dvfGmI5CssLGvDC/R594kH6N/Q1uUU5VPO3PTgQMlWe/UVNdNVTr2EET+FX8BWZkFdYgxTglbQ==} - typebox@1.3.7: - resolution: {integrity: sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==} - typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -3084,49 +2859,21 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@anthropic-ai/sdk@0.123.0(zod@4.6.4)': + '@anthropic-ai/sdk@0.124.0(zod@4.6.4)': dependencies: json-schema-to-ts: 3.1.1 standardwebhooks: 1.1.1 optionalDependencies: zod: 4.6.4 - '@aws-crypto/sha256-browser@5.2.0': - dependencies: - '@aws-crypto/sha256-js': 5.2.0 - '@aws-crypto/supports-web-crypto': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.974.5 - '@aws-sdk/util-locate-window': 3.965.10 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-crypto/sha256-js@5.2.0': - dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.974.5 - tslib: 2.8.1 - - '@aws-crypto/supports-web-crypto@5.2.0': - dependencies: - tslib: 2.8.1 - - '@aws-crypto/util@5.2.0': - dependencies: - '@aws-sdk/types': 3.974.5 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-sdk/client-bedrock-runtime@3.1048.0': + '@aws-sdk/client-bedrock-runtime@3.1127.0': dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 '@aws-sdk/core': 3.978.0 '@aws-sdk/credential-provider-node': 3.972.83 '@aws-sdk/eventstream-handler-node': 3.972.34 '@aws-sdk/middleware-eventstream': 3.972.29 '@aws-sdk/middleware-websocket': 3.972.53 - '@aws-sdk/token-providers': 3.1048.0 + '@aws-sdk/token-providers': 3.1127.0 '@aws-sdk/types': 3.974.5 '@smithy/core': 3.34.1 '@smithy/fetch-http-handler': 5.8.0 @@ -3271,7 +3018,7 @@ snapshots: '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1048.0': + '@aws-sdk/token-providers@3.1127.0': dependencies: '@aws-sdk/core': 3.978.0 '@aws-sdk/nested-clients': 3.997.45 @@ -3294,10 +3041,6 @@ snapshots: '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/util-locate-window@3.965.10': - dependencies: - tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.40': dependencies: '@smithy/types': 4.18.0 @@ -3342,243 +3085,183 @@ snapshots: '@biomejs/cli-win32-x64@2.5.13': optional: true - '@earendil-works/chord@0.85.1': + '@earendil-works/chord@1.0.0': dependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 - '@earendil-works/pi-agent-core@0.85.1(ws@8.21.3)(zod@4.6.4)': + '@earendil-works/pi-agent-core@1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4)': dependencies: - '@earendil-works/chord': 0.85.1 - '@earendil-works/pi-ai': 0.85.1(ws@8.21.3)(zod@4.6.4) - '@earendil-works/pi-telemetry': 0.85.1 - diff: 8.0.4 - ignore: 7.0.5 - typebox: 1.3.7 - yaml: 2.9.0 + '@earendil-works/pi-ai': 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4) + typebox: 1.3.27 transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' - '@modelcontextprotocol/sdk' + - '@smithy/hash-node' + - '@smithy/signature-v4' - bufferutil + - kerberos - supports-color + - undici - utf-8-validate - ws - zod - '@earendil-works/pi-ai@0.85.1(ws@8.21.3)(zod@4.6.4)': + '@earendil-works/pi-ai@1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4)': dependencies: - '@anthropic-ai/sdk': 0.123.0(zod@4.6.4) - '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@earendil-works/pi-telemetry': 0.85.1 - '@google/genai': 1.52.0 - '@smithy/node-http-handler': 4.7.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - openai: 6.40.0(ws@8.21.3)(zod@4.6.4) + '@anthropic-ai/sdk': 0.124.0(zod@4.6.4) + '@aws-sdk/client-bedrock-runtime': 3.1127.0 + '@earendil-works/pi-telemetry': 1.0.0 + '@google/genai': 2.21.0 + '@smithy/node-http-handler': 4.12.1 + http-proxy-agent: 9.1.0 + https-proxy-agent: 9.1.0 + openai: 7.19.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4) partial-json: 0.1.7 - typebox: 1.3.7 + typebox: 1.3.27 transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' - '@modelcontextprotocol/sdk' + - '@smithy/hash-node' + - '@smithy/signature-v4' - bufferutil + - kerberos - supports-color + - undici - utf-8-validate - ws - zod - '@earendil-works/pi-coding-agent@0.85.1(ws@8.21.3)(zod@4.6.4)': + '@earendil-works/pi-codemode@1.0.0': dependencies: - '@earendil-works/chord': 0.85.1 - '@earendil-works/pi-agent-core': 0.85.1(ws@8.21.3)(zod@4.6.4) - '@earendil-works/pi-ai': 0.85.1(ws@8.21.3)(zod@4.6.4) - '@earendil-works/pi-tui': 0.85.1 + quickjs-wasi: 3.6.2 + + '@earendil-works/pi-coding-agent@1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.4)': + dependencies: + '@earendil-works/chord': 1.0.0 + '@earendil-works/pi-agent-core': 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4) + '@earendil-works/pi-ai': 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4) + '@earendil-works/pi-codemode': 1.0.0 + '@earendil-works/pi-mcp': 1.0.0 + '@earendil-works/pi-tui': 1.0.0 '@silvia-odwyer/photon-node': 0.3.4 - chalk: 5.6.2 + chalk: 6.0.0 cross-spawn: 7.0.6 diff: 8.0.4 - grok-mermaid: 0.2.2 + grok-mermaid: 0.2.3 highlight.js: 10.7.3 hosted-git-info: 9.0.3 - ignore: 7.0.5 + ignore: 7.0.8 jiti: 2.7.0 - minimatch: 10.2.5 + minimatch: 10.2.6 proper-lockfile: 4.1.2 - semver: 7.8.0 - typebox: 1.3.7 + quickjs-wasi: 3.6.2 + semver: 7.8.5 + typebox: 1.3.27 undici: 8.9.0 yaml: 2.9.0 - optionalDependencies: - '@mariozechner/clipboard': 0.3.9 transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' - '@modelcontextprotocol/sdk' + - '@smithy/hash-node' + - '@smithy/signature-v4' - bufferutil + - kerberos - supports-color - utf-8-validate - ws - zod - '@earendil-works/pi-telemetry@0.85.1': {} + '@earendil-works/pi-mcp@1.0.0': + dependencies: + cross-spawn: 7.0.6 - '@earendil-works/pi-tui@0.85.1': + '@earendil-works/pi-telemetry@1.0.0': {} + + '@earendil-works/pi-tui@1.0.0': dependencies: get-east-asian-width: 1.6.0 - marked: 18.0.5 + marked: 18.0.11 - '@emnapi/runtime@1.11.2': + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.28.1': - optional: true - '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/android-arm64@0.28.1': - optional: true - '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/android-arm@0.28.1': - optional: true - '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/android-x64@0.28.1': - optional: true - '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.28.1': - optional: true - '@esbuild/darwin-arm64@0.28.2': optional: true - '@esbuild/darwin-x64@0.28.1': - optional: true - '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.28.1': - optional: true - '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.28.1': - optional: true - '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.28.1': - optional: true - '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.28.1': - optional: true - '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-ia32@0.28.1': - optional: true - '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/linux-loong64@0.28.1': - optional: true - '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.28.1': - optional: true - '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/linux-ppc64@0.28.1': - optional: true - '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/linux-riscv64@0.28.1': - optional: true - '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/linux-s390x@0.28.1': - optional: true - '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-x64@0.28.1': - optional: true - '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/netbsd-arm64@0.28.1': - optional: true - '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/netbsd-x64@0.28.1': - optional: true - '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.28.1': - optional: true - '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/openbsd-x64@0.28.1': - optional: true - '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/openharmony-arm64@0.28.1': - optional: true - '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/sunos-x64@0.28.1': - optional: true - '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/win32-arm64@0.28.1': - optional: true - '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/win32-ia32@0.28.1': - optional: true - '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-x64@0.28.1': - optional: true - '@esbuild/win32-x64@0.28.2': optional: true @@ -3612,7 +3295,7 @@ snapshots: '@fumari/image-size@0.1.1': {} - '@google/genai@1.52.0': + '@google/genai@2.21.0': dependencies: google-auth-library: 10.9.1 p-retry: 4.6.2 @@ -3626,108 +3309,108 @@ snapshots: '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.35.3': + '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-arm64': 1.3.3 optional: true - '@img/sharp-darwin-x64@0.35.3': + '@img/sharp-darwin-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.3 optional: true - '@img/sharp-freebsd-wasm32@0.35.3': + '@img/sharp-freebsd-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.3 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.2': + '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true - '@img/sharp-libvips-darwin-x64@1.3.2': + '@img/sharp-libvips-darwin-x64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm64@1.3.2': + '@img/sharp-libvips-linux-arm64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm@1.3.2': + '@img/sharp-libvips-linux-arm@1.3.3': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.2': + '@img/sharp-libvips-linux-ppc64@1.3.3': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.2': + '@img/sharp-libvips-linux-riscv64@1.3.3': optional: true - '@img/sharp-libvips-linux-s390x@1.3.2': + '@img/sharp-libvips-linux-s390x@1.3.3': optional: true - '@img/sharp-libvips-linux-x64@1.3.2': + '@img/sharp-libvips-linux-x64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.2': + '@img/sharp-libvips-linuxmusl-x64@1.3.3': optional: true - '@img/sharp-linux-arm64@0.35.3': + '@img/sharp-linux-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.3 optional: true - '@img/sharp-linux-arm@0.35.3': + '@img/sharp-linux-arm@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.3 optional: true - '@img/sharp-linux-ppc64@0.35.3': + '@img/sharp-linux-ppc64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.3 optional: true - '@img/sharp-linux-riscv64@0.35.3': + '@img/sharp-linux-riscv64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.3 optional: true - '@img/sharp-linux-s390x@0.35.3': + '@img/sharp-linux-s390x@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.3 optional: true - '@img/sharp-linux-x64@0.35.3': + '@img/sharp-linux-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.3 optional: true - '@img/sharp-linuxmusl-arm64@0.35.3': + '@img/sharp-linuxmusl-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 optional: true - '@img/sharp-linuxmusl-x64@0.35.3': + '@img/sharp-linuxmusl-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 optional: true - '@img/sharp-wasm32@0.35.3': + '@img/sharp-wasm32@0.35.4': dependencies: - '@emnapi/runtime': 1.11.2 + '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.3': + '@img/sharp-webcontainers-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.3 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-win32-arm64@0.35.3': + '@img/sharp-win32-arm64@0.35.4': optional: true - '@img/sharp-win32-ia32@0.35.3': + '@img/sharp-win32-ia32@0.35.4': optional: true - '@img/sharp-win32-x64@0.35.3': + '@img/sharp-win32-x64@0.35.4': optional: true '@jridgewell/gen-mapping@0.3.13': @@ -3751,50 +3434,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@mariozechner/clipboard-darwin-arm64@0.3.9': - optional: true - - '@mariozechner/clipboard-darwin-universal@0.3.9': - optional: true - - '@mariozechner/clipboard-darwin-x64@0.3.9': - optional: true - - '@mariozechner/clipboard-linux-arm64-gnu@0.3.9': - optional: true - - '@mariozechner/clipboard-linux-arm64-musl@0.3.9': - optional: true - - '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': - optional: true - - '@mariozechner/clipboard-linux-x64-gnu@0.3.9': - optional: true - - '@mariozechner/clipboard-linux-x64-musl@0.3.9': - optional: true - - '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': - optional: true - - '@mariozechner/clipboard-win32-x64-msvc@0.3.9': - optional: true - - '@mariozechner/clipboard@0.3.9': - optionalDependencies: - '@mariozechner/clipboard-darwin-arm64': 0.3.9 - '@mariozechner/clipboard-darwin-universal': 0.3.9 - '@mariozechner/clipboard-darwin-x64': 0.3.9 - '@mariozechner/clipboard-linux-arm64-gnu': 0.3.9 - '@mariozechner/clipboard-linux-arm64-musl': 0.3.9 - '@mariozechner/clipboard-linux-riscv64-gnu': 0.3.9 - '@mariozechner/clipboard-linux-x64-gnu': 0.3.9 - '@mariozechner/clipboard-linux-x64-musl': 0.3.9 - '@mariozechner/clipboard-win32-arm64-msvc': 0.3.9 - '@mariozechner/clipboard-win32-x64-msvc': 0.3.9 - optional: true - '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.9 @@ -3825,30 +3464,30 @@ snapshots: transitivePeerDependencies: - supports-color - '@next/env@16.3.3': {} + '@next/env@16.3.6': {} - '@next/swc-darwin-arm64@16.3.3': + '@next/swc-darwin-arm64@16.3.6': optional: true - '@next/swc-darwin-x64@16.3.3': + '@next/swc-darwin-x64@16.3.6': optional: true - '@next/swc-linux-arm64-gnu@16.3.3': + '@next/swc-linux-arm64-gnu@16.3.6': optional: true - '@next/swc-linux-arm64-musl@16.3.3': + '@next/swc-linux-arm64-musl@16.3.6': optional: true - '@next/swc-linux-x64-gnu@16.3.3': + '@next/swc-linux-x64-gnu@16.3.6': optional: true - '@next/swc-linux-x64-musl@16.3.3': + '@next/swc-linux-x64-musl@16.3.6': optional: true - '@next/swc-win32-arm64-msvc@16.3.3': + '@next/swc-win32-arm64-msvc@16.3.6': optional: true - '@next/swc-win32-x64-msvc@16.3.3': + '@next/swc-win32-x64-msvc@16.3.6': optional: true '@opentelemetry/api@1.9.0': @@ -4284,22 +3923,12 @@ snapshots: '@smithy/types': 4.18.0 tslib: 2.8.1 - '@smithy/is-array-buffer@2.2.0': - dependencies: - tslib: 2.8.1 - '@smithy/node-http-handler@4.12.1': dependencies: '@smithy/core': 3.34.1 '@smithy/types': 4.18.0 tslib: 2.8.1 - '@smithy/node-http-handler@4.7.3': - dependencies: - '@smithy/core': 3.34.1 - '@smithy/types': 4.18.0 - tslib: 2.8.1 - '@smithy/signature-v4@5.7.3': dependencies: '@smithy/core': 3.34.1 @@ -4310,16 +3939,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@smithy/util-buffer-from@2.2.0': - dependencies: - '@smithy/is-array-buffer': 2.2.0 - tslib: 2.8.1 - - '@smithy/util-utf8@2.3.0': - dependencies: - '@smithy/util-buffer-from': 2.2.0 - tslib: 2.8.1 - '@stablelib/base64@1.0.1': {} '@standard-schema/spec@1.1.0': {} @@ -4545,6 +4164,8 @@ snapshots: agent-base@7.1.4: {} + agent-base@9.0.0: {} + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -4557,7 +4178,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.42: {} + baseline-browser-mapping@2.11.0: {} bignumber.js@9.3.1: {} @@ -4573,7 +4194,7 @@ snapshots: ccount@2.0.1: {} - chalk@5.6.2: {} + chalk@6.0.0: {} character-entities-html4@2.1.0: {} @@ -4658,35 +4279,6 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 - esbuild@0.28.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 - esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -4781,7 +4373,7 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4): + fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4): dependencies: '@fumari/image-size': 0.1.1 estree-util-value-to-estree: 3.5.0 @@ -4809,21 +4401,21 @@ snapshots: '@types/mdast': 4.0.4 '@types/react': 19.2.18 lucide-react: 1.33.0(react@19.2.8) - next: 16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) zod: 4.6.4 transitivePeerDependencies: - supports-color - fumadocs-mdx@15.3.1(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): + fumadocs-mdx@15.3.1(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.2 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4) + fumadocs-core: 16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4) github-slugger: 2.0.0 magic-string: 1.4.2 mdast-util-mdx: 3.0.0 @@ -4842,12 +4434,12 @@ snapshots: '@types/mdast': 4.0.4 '@types/mdx': 2.0.14 '@types/react': 19.2.18 - next: 16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 transitivePeerDependencies: - supports-color - fumadocs-ui@16.15.1(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): + fumadocs-ui@16.15.1(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): dependencies: '@fuma-translate/react': 1.0.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fumadocs/tailwind': 0.1.1(tailwindcss@4.3.3) @@ -4863,7 +4455,7 @@ snapshots: '@radix-ui/react-tabs': 1.1.21(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) class-variance-authority: 0.7.1 cnfast: 0.1.0 - fumadocs-core: 16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4) + fumadocs-core: 16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4) lucide-react: 1.33.0(react@19.2.8) motion: 13.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-themes: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -4877,7 +4469,7 @@ snapshots: optionalDependencies: '@types/mdx': 2.0.14 '@types/react': 19.2.18 - next: 16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) transitivePeerDependencies: - '@types/react-dom' - tailwindcss @@ -4921,7 +4513,7 @@ snapshots: graceful-fs@4.2.11: {} - grok-mermaid@0.2.2: {} + grok-mermaid@0.2.3: {} hast-util-from-parse5@8.0.3: dependencies: @@ -5039,11 +4631,13 @@ snapshots: html-void-elements@3.0.0: {} - http-proxy-agent@7.0.2: + http-proxy-agent@9.1.0: dependencies: - agent-base: 7.1.4 + agent-base: 9.0.0 debug: 4.4.3 + proxy-agent-negotiate: 1.1.0 transitivePeerDependencies: + - kerberos - supports-color https-proxy-agent@7.0.6: @@ -5053,7 +4647,16 @@ snapshots: transitivePeerDependencies: - supports-color - ignore@7.0.5: {} + https-proxy-agent@9.1.0: + dependencies: + agent-base: 9.0.0 + debug: 4.4.3 + proxy-agent-negotiate: 1.1.0 + transitivePeerDependencies: + - kerberos + - supports-color + + ignore@7.0.8: {} inline-style-parser@0.2.7: {} @@ -5165,7 +4768,7 @@ snapshots: markdown-table@3.0.4: {} - marked@18.0.5: {} + marked@18.0.11: {} mdast-util-find-and-replace@3.0.2: dependencies: @@ -5594,7 +5197,7 @@ snapshots: transitivePeerDependencies: - supports-color - minimatch@10.2.5: + minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -5621,27 +5224,27 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - next@16.3.3(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@next/env': 16.3.3 + '@next/env': 16.3.6 '@swc/helpers': 0.5.23 - baseline-browser-mapping: 2.10.42 + baseline-browser-mapping: 2.11.0 caniuse-lite: 1.0.30001802 postcss: 8.5.23 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) styled-jsx: 5.1.6(react@19.2.8) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.3 - '@next/swc-darwin-x64': 16.3.3 - '@next/swc-linux-arm64-gnu': 16.3.3 - '@next/swc-linux-arm64-musl': 16.3.3 - '@next/swc-linux-x64-gnu': 16.3.3 - '@next/swc-linux-x64-musl': 16.3.3 - '@next/swc-win32-arm64-msvc': 16.3.3 - '@next/swc-win32-x64-msvc': 16.3.3 + '@next/swc-darwin-arm64': 16.3.6 + '@next/swc-darwin-x64': 16.3.6 + '@next/swc-linux-arm64-gnu': 16.3.6 + '@next/swc-linux-arm64-musl': 16.3.6 + '@next/swc-linux-x64-gnu': 16.3.6 + '@next/swc-linux-x64-musl': 16.3.6 + '@next/swc-win32-arm64-msvc': 16.3.6 + '@next/swc-win32-x64-msvc': 16.3.6 '@opentelemetry/api': 1.9.0 - sharp: 0.35.3(@types/node@26.5.1) + sharp: 0.35.4(@types/node@26.5.1) transitivePeerDependencies: - '@babel/core' - '@types/node' @@ -5665,8 +5268,11 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 - openai@6.40.0(ws@8.21.3)(zod@4.6.4): + openai@7.19.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4): optionalDependencies: + '@aws-sdk/credential-provider-node': 3.972.83 + '@smithy/signature-v4': 5.7.3 + undici: 8.9.0 ws: 8.21.3 zod: 4.6.4 @@ -5725,6 +5331,10 @@ snapshots: '@types/node': 26.5.1 long: 5.3.2 + proxy-agent-negotiate@1.1.0: {} + + quickjs-wasi@3.6.2: {} + react-dom@19.2.8(react@19.2.8): dependencies: react: 19.2.8 @@ -5876,42 +5486,39 @@ snapshots: dependencies: compute-scroll-into-view: 3.1.1 - semver@7.8.0: {} + semver@7.8.5: {} - semver@7.8.5: - optional: true - - sharp@0.35.3(@types/node@26.5.1): + sharp@0.35.4(@types/node@26.5.1): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.3 - '@img/sharp-darwin-x64': 0.35.3 - '@img/sharp-freebsd-wasm32': 0.35.3 - '@img/sharp-libvips-darwin-arm64': 1.3.2 - '@img/sharp-libvips-darwin-x64': 1.3.2 - '@img/sharp-libvips-linux-arm': 1.3.2 - '@img/sharp-libvips-linux-arm64': 1.3.2 - '@img/sharp-libvips-linux-ppc64': 1.3.2 - '@img/sharp-libvips-linux-riscv64': 1.3.2 - '@img/sharp-libvips-linux-s390x': 1.3.2 - '@img/sharp-libvips-linux-x64': 1.3.2 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 - '@img/sharp-linux-arm': 0.35.3 - '@img/sharp-linux-arm64': 0.35.3 - '@img/sharp-linux-ppc64': 0.35.3 - '@img/sharp-linux-riscv64': 0.35.3 - '@img/sharp-linux-s390x': 0.35.3 - '@img/sharp-linux-x64': 0.35.3 - '@img/sharp-linuxmusl-arm64': 0.35.3 - '@img/sharp-linuxmusl-x64': 0.35.3 - '@img/sharp-webcontainers-wasm32': 0.35.3 - '@img/sharp-win32-arm64': 0.35.3 - '@img/sharp-win32-ia32': 0.35.3 - '@img/sharp-win32-x64': 0.35.3 + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 '@types/node': 26.5.1 optional: true @@ -5984,12 +5591,12 @@ snapshots: typebox@1.3.10: {} + typebox@1.3.27: {} + typebox@1.3.3: {} typebox@1.3.30: {} - typebox@1.3.7: {} - typescript@6.0.3: {} typescript@7.0.2: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index edd48e27..722922ab 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,4 @@ -# pnpm workspace — ten release packages + private CharterArc foundation + website. +# pnpm workspace — ten stable release packages + experimental CharterArc + private control + website. # (Replaces the npm `workspaces` field in the root package.json; pnpm ignores # that field and requires this file instead.) packages: @@ -16,11 +16,12 @@ packages: - "packages/taskflow-dsl" - "website" overrides: + baseline-browser-mapping: 2.11.0 brace-expansion: 5.0.9 # Dependabot alert #33 / GHSA-2v37-7h3g-55p8: nanoid < 3.3.18 nanoid: 3.3.18 postcss: 8.5.23 - sharp: 0.35.3 + sharp: 0.35.4 # Dependabot high/medium open alerts: undici < 8.9.0 (CRLF, cache, cookie, desync) undici: 8.9.0 allowBuilds: @@ -28,3 +29,12 @@ allowBuilds: esbuild: true protobufjs: true sharp: true +minimumReleaseAgeExclude: + - '@earendil-works/chord@1.0.0' + - '@earendil-works/pi-agent-core@1.0.0' + - '@earendil-works/pi-ai@1.0.0' + - '@earendil-works/pi-codemode@1.0.0' + - '@earendil-works/pi-coding-agent@1.0.0' + - '@earendil-works/pi-mcp@1.0.0' + - '@earendil-works/pi-telemetry@1.0.0' + - '@earendil-works/pi-tui@1.0.0' diff --git a/scripts/pack-release-packages.mjs b/scripts/pack-release-packages.mjs index 68ee89d7..ba1caa57 100644 --- a/scripts/pack-release-packages.mjs +++ b/scripts/pack-release-packages.mjs @@ -24,18 +24,9 @@ import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -export const RELEASE_PACKAGE_NAMES = [ - "taskflow-core", - "taskflow-mcp-core", - "taskflow-hosts", - "taskflow-dsl", - "pi-taskflow", - "codex-taskflow", - "claude-taskflow", - "opencode-taskflow", - "grok-taskflow", - "hermes-taskflow", -]; +import { STABLE_PACKAGE_NAMES, verifyReleaseContract } from "./verify-release-contract.mjs"; + +export const RELEASE_PACKAGE_NAMES = STABLE_PACKAGE_NAMES; const repo = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -108,6 +99,7 @@ export function packDeterministicPackage(packageDir, destination) { } export function packReleasePackages(destination, packageNames = RELEASE_PACKAGE_NAMES, verifyDeterminism = true) { + verifyReleaseContract(repo); const absoluteDestination = resolve(destination); rmSync(absoluteDestination, { recursive: true, force: true }); mkdirSync(absoluteDestination, { recursive: true }); diff --git a/scripts/smoke-packed-charterarc.mjs b/scripts/smoke-packed-charterarc.mjs index bbf611d7..e76c96d6 100644 --- a/scripts/smoke-packed-charterarc.mjs +++ b/scripts/smoke-packed-charterarc.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * Consumer smoke for the private CharterArc artifact without publishing it. + * Consumer smoke for the separate experimental CharterArc artifact without publishing it. * * Packs taskflow-core + taskflow-hosts + charterarc with pnpm (so workspace:* is * rewritten), installs those tarballs into a fresh npm project, then exercises @@ -9,7 +9,7 @@ * including the Grok bootstrap wiring applications copy from the README. * * Packing runs from a disposable workspace with an isolated pnpm store so the - * smoke never relinks the repository node_modules. CharterArc stays private — + * smoke never relinks the repository node_modules. CharterArc stays on its independent experimental channel — * it is not listed in RELEASE_PACKAGE_NAMES. */ diff --git a/scripts/smoke-packed-packages.mjs b/scripts/smoke-packed-packages.mjs index 9e914f48..22a374cf 100644 --- a/scripts/smoke-packed-packages.mjs +++ b/scripts/smoke-packed-packages.mjs @@ -11,25 +11,14 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; import { createRequire } from "node:module"; -import { chmodSync, globSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, globSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { packReleasePackages } from "./pack-release-packages.mjs"; +import { RELEASE_PACKAGE_NAMES, packReleasePackages } from "./pack-release-packages.mjs"; const repo = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const packageNames = [ - "taskflow-core", - "taskflow-mcp-core", - "taskflow-hosts", - "taskflow-dsl", - "pi-taskflow", - "codex-taskflow", - "claude-taskflow", - "opencode-taskflow", - "grok-taskflow", - "hermes-taskflow", -]; +const packageNames = RELEASE_PACKAGE_NAMES; const rootManifest = JSON.parse(readFileSync(join(repo, "package.json"), "utf8")); const peerNames = [ "typebox", @@ -227,6 +216,23 @@ try { else process.env.PI_TASKFLOW_PI_BIN = previousPiBin; } + // Reuse the real-process regression harness as external test fixtures. Its + // relative dist imports and SDK resolution must come from this clean install, + // while the published dist files remain untouched. + // Node intentionally refuses to strip TypeScript inside node_modules. Keep + // only these test files outside it, with dist linked to the installed package. + const installedPiTests = join(consumerDir, "pi-e2e", "test"); + mkdirSync(join(installedPiTests, "fixtures"), { recursive: true }); + symlinkSync(join(consumerDir, "node_modules", "pi-taskflow", "dist"), join(consumerDir, "pi-e2e", "dist"), "junction"); + for (const relative of ["e2e-pi1.mts", "fixtures/pi1-provider.ts"]) { + copyFileSync(join(repo, "packages", "pi-taskflow", "test", relative), join(installedPiTests, relative)); + } + process.stdout.write("packed Pi 1.0 real-process fixture regressions:\n"); + run(process.execPath, ["--experimental-strip-types", join(installedPiTests, "e2e-pi1.mts")], { + cwd: consumerDir, + stdio: "inherit", + }); + const publicImports = [ "taskflow-core", "taskflow-mcp-core", diff --git a/scripts/verify-release-contract.d.mts b/scripts/verify-release-contract.d.mts new file mode 100644 index 00000000..15dd4b5f --- /dev/null +++ b/scripts/verify-release-contract.d.mts @@ -0,0 +1,3 @@ +export const STABLE_PACKAGE_NAMES: string[]; +export function verifyReleaseContract(repo: string, options?: { published?: boolean }): { version: string; packages: string[]; npmTag: string }; +export function releaseNpmTag(version: string): "beta" | "latest"; diff --git a/scripts/verify-release-contract.mjs b/scripts/verify-release-contract.mjs new file mode 100644 index 00000000..f0135122 --- /dev/null +++ b/scripts/verify-release-contract.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +export const STABLE_PACKAGE_NAMES = [ + "taskflow-core", "taskflow-mcp-core", "taskflow-hosts", "taskflow-dsl", + "pi-taskflow", "codex-taskflow", "claude-taskflow", "opencode-taskflow", + "grok-taskflow", "hermes-taskflow", +]; + +/** Stable versions publish to latest; only numbered beta prereleases are admitted. */ +export function releaseNpmTag(version) { + assert.match(version, /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta(?:\.(?:0|[1-9]\d*))+)?$/, "release version must be stable semver or a numbered beta"); + return version.includes("-") ? "beta" : "latest"; +} + +/** Verify the prepared source contract before any release registry mutation. */ +export function verifyReleaseContract(repo, { published = false } = {}) { + const readJson = (path) => JSON.parse(readFileSync(resolve(repo, path), "utf8")); + const root = readJson("package.json"); + const version = root.version; + const npmTag = releaseNpmTag(version); + assert.equal(root.private, true, "monorepo root must remain private"); + for (const name of STABLE_PACKAGE_NAMES) { + const manifest = readJson(`packages/${name}/package.json`); + assert.equal(manifest.name, name, `${name}: package identity mismatch`); + assert.equal(manifest.version, version, `${name}: release version mismatch`); + assert.notEqual(manifest.private, true, `${name}: release package is private`); + assert.equal(manifest.publishConfig?.access, "public", `${name}: public access required`); + for (const field of ["dependencies", "optionalDependencies", "peerDependencies"]) { + for (const [dependency, range] of Object.entries(manifest[field] ?? {})) { + assert.ok(!["taskflow-control", "charterarc"].includes(dependency), `${name}: experimental dependency ${dependency} leaked into stable package`); + if (STABLE_PACKAGE_NAMES.includes(dependency)) assert.equal(range, "workspace:*", `${name}: ${dependency} must use workspace:* before packing`); + } + } + } + for (const host of ["codex", "claude", "grok"]) { + assert.equal(readJson(`packages/${host}-taskflow/plugin/.${host}-plugin/plugin.json`).version, version, `${host}: plugin version mismatch`); + const args = readJson(`packages/${host}-taskflow/plugin/.mcp.json`).mcpServers.taskflow.args; + assert.equal(args.filter((arg) => arg.startsWith(`${host}-taskflow@`)).join(), `${host}-taskflow@${version}`, `${host}: MCP pin mismatch`); + } + const opencodeArgs = readJson("packages/opencode-taskflow/plugin/opencode.json").mcp.taskflow.command; + assert.equal(opencodeArgs.filter((arg) => arg.startsWith("opencode-taskflow@")).join(), `opencode-taskflow@${version}`, "opencode: MCP pin mismatch"); + const hermes = readFileSync(resolve(repo, "packages/hermes-taskflow/plugin/hermes.config.snippet.yaml"), "utf8"); + const hermesPins = hermes.match(/hermes-taskflow@[^"\s,\]]+/g) ?? []; + assert.deepEqual(hermesPins, [`hermes-taskflow@${version}`], "hermes: MCP pin mismatch"); + assert.equal(readJson("packages/taskflow-control/package.json").private, true, "experimental taskflow-control must remain private"); + const charterarc = readJson("packages/charterarc/package.json"); + assert.match(charterarc.version, /-experimental\./, "CharterArc must retain a separate experimental version"); + assert.equal(charterarc.publishConfig?.tag, "experimental", "CharterArc must retain its experimental channel"); + const changelog = readFileSync(resolve(repo, "CHANGELOG.md"), "utf8"); + const escapedVersion = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const date = published ? "[0-9]{4}-[0-9]{2}-[0-9]{2}" : "(?:Unreleased|[0-9]{4}-[0-9]{2}-[0-9]{2})"; + const heading = changelog.match(new RegExp(`^## \\[${escapedVersion}\\] — (${date})$`, "m")); + assert.ok(heading, `${version}: ${published ? "dated" : "candidate"} changelog heading required`); + const releaseDate = heading[1]; + if (releaseDate !== "Unreleased") { + // Date.parse can normalize February 30 into March. Round-trip to reject + // normalization, invalid months/days and invalid leap-year dates. + const timestamp = Date.parse(`${releaseDate}T00:00:00.000Z`); + assert.ok(Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === releaseDate, + `${version}: changelog date must be a real calendar date (got ${releaseDate})`); + } + return { version, packages: [...STABLE_PACKAGE_NAMES], npmTag }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const rootArg = process.argv.indexOf("--root"); + const repo = rootArg < 0 ? resolve(dirname(fileURLToPath(import.meta.url)), "..") : resolve(process.argv[rootArg + 1]); + const result = verifyReleaseContract(repo, { published: process.argv.includes("--published") }); + process.stdout.write(`Release contract PASS: ${result.packages.length} packages @ ${result.version}, npm ${result.npmTag}${process.argv.includes("--published") ? ", dated changelog" : ", candidate validation only"}\n`); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/verify-release-dist-tags.d.mts b/scripts/verify-release-dist-tags.d.mts new file mode 100644 index 00000000..4d755f12 --- /dev/null +++ b/scripts/verify-release-dist-tags.d.mts @@ -0,0 +1 @@ +export function verifyReleaseDistTags(version: string, npmTag: string, queryTags?: (name: string) => unknown): { version: string; npmTag: string; verified: string[] }; diff --git a/scripts/verify-release-dist-tags.mjs b/scripts/verify-release-dist-tags.mjs new file mode 100644 index 00000000..ce1be9e7 --- /dev/null +++ b/scripts/verify-release-dist-tags.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { STABLE_PACKAGE_NAMES, releaseNpmTag } from "./verify-release-contract.mjs"; + +function queryRegistryTags(name) { + const stdout = execFileSync("npm", ["view", name, "dist-tags", "--json", "--registry=https://registry.npmjs.org/"], { + encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"], + }); + return JSON.parse(stdout); +} + +/** Read every package's channel. Never mutate tags or accept another channel. */ +export function verifyReleaseDistTags(version, npmTag, queryTags = queryRegistryTags) { + assert.equal(npmTag, releaseNpmTag(version), "release version/channel mismatch"); + const errors = []; + const verified = []; + for (const name of STABLE_PACKAGE_NAMES) { + let tags; + try { tags = queryTags(name); } catch { + errors.push(`${name}: unable to read npm dist-tags`); + continue; + } + if (tags === null || typeof tags !== "object" || Array.isArray(tags) || !Object.hasOwn(tags, npmTag)) { + errors.push(`${name}: missing or malformed npm ${npmTag} dist-tag`); + } else if (tags[npmTag] !== version) { + errors.push(`${name}: npm ${npmTag} must equal ${version}`); + } else { + verified.push(name); + } + } + assert.equal(errors.length, 0, `Release dist-tag verification failed:\n${errors.join("\n")}`); + return { version, npmTag, verified }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const result = verifyReleaseDistTags(process.argv[2], process.argv[3]); + process.stdout.write(`Release dist-tags PASS: ${result.verified.length} packages, ${result.npmTag}=${result.version}\n`); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/skills-src/taskflow/entry.pi.md b/skills-src/taskflow/entry.pi.md index 47d7e842..86a29ac5 100644 --- a/skills-src/taskflow/entry.pi.md +++ b/skills-src/taskflow/entry.pi.md @@ -10,3 +10,13 @@ description: Orchestrate multi-phase subagent workflows with pi-taskflow. Use wh Where an example shows a host-neutral invocation like `verify`, use the pi form (`action: "verify"` or `/tf verify`). Prefer **`action: "plan"` / `/tf plan`** before spending tokens on a non-trivial flow. + +Before the first tool call in a session, load this skill using Pi's `read` tool +at the `SKILL.md` path listed in the available skills. Read linked reference +files as needed. Pi's stock skill workflow uses `read`. + +For shorthand calls (`task`, `tasks`, or `chain`), `action` may be omitted and +defaults to `"run"`. Other operations require their explicit `action`. +Approval phases use a review overlay in the TUI and a selector in RPC mode; +cancelling either the decision or edit guidance rejects the approval. +Print/JSON mode has no human approver and rejects approval phases. diff --git a/website/app/[lang]/layout.tsx b/website/app/[lang]/layout.tsx index 37b5123e..1a10cc55 100644 --- a/website/app/[lang]/layout.tsx +++ b/website/app/[lang]/layout.tsx @@ -10,16 +10,16 @@ export function generateStaticParams() { const site = { en: { - title: "taskflow 0.3.0-beta.2 — Trusted Effects beta", + title: "taskflow 1.0.0 — Taskflow release candidate", brand: "taskflow", description: - "Declare coding-agent effects, verify typed paths, and commit admitted filesystem changes through one resource authority. 0.3.0-beta.2; beta channel and not GA.", + "Declare coding-agent effects, verify typed paths, and commit admitted filesystem changes through one resource authority. 1.0.0; release candidate; not yet published.", }, "zh-cn": { - title: "taskflow 0.3.0-beta.2 — Trusted Effects beta", + title: "taskflow 1.0.0 — Taskflow release candidate", brand: "taskflow", description: - "声明 coding-agent effect,验证类型化路径,让已准入的文件修改经过唯一 resource authority。0.3.0-beta.2,beta channel,尚未 GA。", + "声明 coding-agent effect,验证类型化路径,让已准入的文件修改经过唯一 resource authority。1.0.0,发布候选,尚未发布。", }, } as const; diff --git a/website/app/[lang]/page.tsx b/website/app/[lang]/page.tsx index 61133396..c7612a39 100644 --- a/website/app/[lang]/page.tsx +++ b/website/app/[lang]/page.tsx @@ -25,16 +25,16 @@ const copy = { localeZh: "中文", }, hero: { - eyebrow: "taskflow 0.3.0-beta.2 · Trusted Effects beta", + eyebrow: "taskflow 1.0.0 · Taskflow release candidate", title: [ "Declare the effect.", "Verify the path.", "Commit through one authority.", ], sub: "taskflow turns coding-agent work into a verifiable runtime: explicit graphs, typed effect declarations, isolated execution, resource-controlled filesystem commits, and ledger-backed explanations across six hosts.", - noteKicker: "0.3.0-beta.2 · beta channel · not GA", + noteKicker: "1.0.0 · release candidate · not yet published", noteBody: - "An agent can propose content. For admitted declared targets, the resources transaction is the only finalizer. The ControlHost scaffold exists; stores, approvals, receipts, and WebUI remain future follow-on stages, not shipped GA claims.", + "An agent can propose content. For admitted declared targets, the resources transaction is the only finalizer. ControlHost has experimental Unix UDS/store code; admission, parked approvals, receipts, global coordination and WebUI remain outside the stable API.", micro: "Resolve-only is not an OS sandbox. Undeclared writes remain host-policy dependent.", hosts: "Pi · Codex · Claude Code · OpenCode · Grok · Hermes", @@ -78,7 +78,7 @@ const copy = { ], }, install: { - label: "0.3.0-beta.2 host installs — select the beta channel.", + label: "1.0.0 host installs — available after publication.", copy: "Copy", copied: "Copied", guide: "Guide", @@ -102,8 +102,8 @@ const copy = { ], }, ledger: { - title: "0.3 is the trusted-effects turn.", - sub: "The 0.2 runtime remains the graph engine; the candidate adds a typed, inspectable boundary around declared filesystem effects.", + title: "1.0 stabilizes the workflow contract.", + sub: "The mature DAG runtime, DSL and six adapters share a typed, inspectable boundary around declared filesystem effects.", items: [ { tag: "EffectIR", @@ -128,7 +128,7 @@ const copy = { { tag: "0.3-C", title: "Control Plane follows", - body: "The ControlHost scaffold exists; stores, receipts, approvals, and WebUI are future 0.3-C stages, not shipped 0.3 GA surface yet.", + body: "ControlHost has private workspace Unix UDS/store code; admission, parked approvals, receipts, global coordination and WebUI remain outside the stable API.", }, { tag: "Hosts", @@ -187,12 +187,12 @@ const copy = { localeZh: "中文", }, hero: { - eyebrow: "taskflow 0.3.0-beta.2 · Trusted Effects beta", + eyebrow: "taskflow 1.0.0 · Taskflow release candidate", title: ["声明 effect。", "验证路径。", "让一个 authority 负责提交。"], sub: "taskflow 把 coding-agent 工作变成可验证的运行时:显式任务图、类型化 effect 声明、隔离执行、受 resources 控制的文件提交,以及覆盖六个宿主的 ledger-backed 解释。", - noteKicker: "0.3.0-beta.2 · beta channel · 尚未 GA", + noteKicker: "1.0.0 · 发布候选 · 尚未发布", noteBody: - "智能体可以提出内容。对于已准入的已声明目标,resources transaction 是唯一最终提交者。ControlHost 目前是脚手架;store、审批、receipt 与 WebUI 仍是后续阶段,不是已交付的 GA 表面。", + "智能体可以提出内容。对于已准入的已声明目标,resources transaction 是唯一最终提交者。ControlHost 有 private workspace Unix UDS/store 代码;admission、parked 审批、receipt、全局协调与 WebUI 不属于稳定 API。", micro: "Resolve-only 不是 OS sandbox。未声明写入仍取决于宿主策略。", hosts: "Pi · Codex · Claude Code · OpenCode · Grok · Hermes", primary: "阅读文档", @@ -235,7 +235,7 @@ const copy = { ], }, install: { - label: "0.3.0-beta.2 宿主安装;请显式选择 beta channel。", + label: "1.0.0 宿主安装;发布完成后可用。", copy: "复制", copied: "已复制", guide: "指南", @@ -259,8 +259,8 @@ const copy = { ], }, ledger: { - title: "0.3 是 Trusted Effects 转身。", - sub: "0.2 运行时仍是图引擎;candidate 在声明的文件 effect 周围增加类型化、可检查的边界。", + title: "1.0 稳定工作流合同。", + sub: "成熟 DAG runtime、DSL 与六宿主适配器共享声明文件 effect 的类型化、可检查边界。", items: [ { tag: "EffectIR", @@ -285,7 +285,7 @@ const copy = { { tag: "0.3-C", title: "Control Plane 在后面", - body: "ControlHost 目前是脚手架;store、receipt、审批与 WebUI 属于后续 0.3-C 阶段,还不是已发布的 0.3 GA 表面。", + body: "ControlHost 有 private workspace Unix UDS/store 代码;admission、parked 审批、receipt、全局协调与 WebUI 不属于稳定 API。", }, { tag: "Hosts", @@ -341,7 +341,7 @@ export default async function HomePage({ "@context": "https://schema.org", "@type": "SoftwareApplication", name: "taskflow", - softwareVersion: "0.3.0-beta.2", + softwareVersion: "1.0.0", description: t.hero.sub, applicationCategory: "DeveloperApplication", operatingSystem: "Any", @@ -497,7 +497,7 @@ export default async function HomePage({
-

taskflow 0.3 · candidate

+

taskflow 1.0 · release candidate

{t.cta.title}

{t.cta.body}

diff --git a/website/app/page.tsx b/website/app/page.tsx index 821a16ba..1ee32bca 100644 --- a/website/app/page.tsx +++ b/website/app/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; const title = "taskflow 0.3 — Trusted Effects for Coding Agents"; const description = - "Declare coding-agent effects, verify typed paths, and commit admitted filesystem changes through one resource authority. 0.3.0-beta.2; beta channel and not GA."; + "Declare coding-agent effects, verify typed paths, and commit admitted filesystem changes through one resource authority. 1.0.0; release candidate; not yet published."; const canonical = "https://heggria.github.io/taskflow/en/"; export const metadata: Metadata = { diff --git a/website/content/docs/en/compiler-runtime/typescript-dsl.mdx b/website/content/docs/en/compiler-runtime/typescript-dsl.mdx index 05d746fe..7a62287d 100644 --- a/website/content/docs/en/compiler-runtime/typescript-dsl.mdx +++ b/website/content/docs/en/compiler-runtime/typescript-dsl.mdx @@ -8,7 +8,7 @@ description: Compile-time .tf.ts authoring — erase runes to Taskflow JSON, the S4 adds a **compile-time** TypeScript frontend. You author `*.tf.ts` with **runes** (`agent`, `map`, `race`, …). A CLI erases them to ordinary Taskflow JSON. Hosts still run **JSON** via `taskflow_run` / `/tf run` — there is **no** interpret path and **no** host auto-build of `.tf.ts`. - **Package status.** `taskflow-dsl` lives in the monorepo (`packages/taskflow-dsl`). It is **not** required for JSON authors. Package manifests target `0.3.0-beta.2` on npm's `beta` channel; install with `npm install taskflow-dsl@beta`, or use a workspace / local path from this monorepo. + **Package status.** `taskflow-dsl` lives in the monorepo (`packages/taskflow-dsl`). It is **not** required for JSON authors. Package manifests target stable `1.0.0`; after publication, install with `npm install taskflow-dsl@1.0.0`, or use a workspace / local path from this monorepo. ## Workflow diff --git a/website/content/docs/en/getting-started.mdx b/website/content/docs/en/getting-started.mdx index 55461db8..f8b0e3ad 100644 --- a/website/content/docs/en/getting-started.mdx +++ b/website/content/docs/en/getting-started.mdx @@ -6,7 +6,7 @@ description: Run your first taskflow in under five minutes. taskflow lets you describe multi-step agent work as a declarative graph. Instead of writing a script that calls subagents one by one, you declare the nodes and edges — and the runtime handles fan-out, retries, caching, and resume. - This guide covers the stable 0.2.x host installation path. For **0.3.0-beta.2**, start with the [Trusted Effects overview](/en/docs/trusted-effects) and select npm's `beta` channel; beta is not GA. + This guide targets **1.0.0**. Exact installation pins become available after the release workflow succeeds; this checkout is still a release candidate. See the [Trusted Effects overview](/en/docs/trusted-effects) for filesystem-transaction boundaries. The fastest way to see it is to run something. @@ -81,7 +81,7 @@ If you have not installed taskflow yet, pick your host: ```bash title="Install pi-taskflow" - pi install npm:pi-taskflow@beta + pi install npm:pi-taskflow@1.0.0 ``` @@ -98,7 +98,7 @@ If you have not installed taskflow yet, pick your host: ```bash title="Register OpenCode MCP" - opencode mcp add taskflow -- npx -y -p opencode-taskflow@beta opencode-taskflow-mcp + opencode mcp add taskflow -- npx -y -p opencode-taskflow@1.0.0 opencode-taskflow-mcp ``` @@ -112,7 +112,7 @@ If you have not installed taskflow yet, pick your host: ```bash title="Register Grok Build MCP" export PI_TASKFLOW_GROK_READONLY_SANDBOX_PROFILE=taskflow-readonly export PI_TASKFLOW_GROK_MUTATING_SANDBOX_PROFILE=taskflow-workspace - grok mcp add taskflow -- npx -y -p grok-taskflow@beta grok-taskflow-mcp + grok mcp add taskflow -- npx -y -p grok-taskflow@1.0.0 grok-taskflow-mcp # Public plugin source is not published yet. Checkout-only: # grok plugin install ./packages/grok-taskflow/plugin --trust ``` @@ -120,7 +120,7 @@ If you have not installed taskflow yet, pick your host: ```bash title="Register Hermes Agent MCP" - hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@beta hermes-taskflow-mcp + hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@1.0.0 hermes-taskflow-mcp ``` See the [Hermes Agent guide](/en/docs/guides/hermes) for read-only isolation and explicit mutating opt-in. diff --git a/website/content/docs/en/guides/claude-code.mdx b/website/content/docs/en/guides/claude-code.mdx index bb9385f5..db647fd3 100644 --- a/website/content/docs/en/guides/claude-code.mdx +++ b/website/content/docs/en/guides/claude-code.mdx @@ -3,6 +3,8 @@ title: Using taskflow on Claude Code description: Install taskflow as a Claude Code plugin and orchestrate multi-phase workflows via MCP. --- +> **1.0.0 release target.** The exact npm pins below become available after publication. This checkout is still a release candidate. + [Claude Code](https://claude.com/product/claude-code) is a natural home for taskflow: it already thinks in terms of subagents, and taskflow gives those subagents a declarative graph to run inside. The plugin registers the MCP server plus a routing skill; its protocol layer needs no MCP SDK. This page walks through the whole arc: install the plugin, confirm the MCP server is live, run a flow through a tool call, and understand the permission and long-running-flow model. @@ -22,7 +24,7 @@ That single `plugin install` does three things at once: - **Registers the MCP server.** The plugin declares a `taskflow` server launched via `npx -y -p claude-taskflow claude-taskflow-mcp`, so Claude Code can reach the runtime over stdio. The `npx` pin binds the exact code that runs — nothing to install globally. + **Registers the MCP server.** The plugin declares a `taskflow` server launched via `npx -y -p claude-taskflow@1.0.0 claude-taskflow-mcp`, so Claude Code can reach the runtime over stdio. The `npx` pin binds the exact code that runs — nothing to install globally. **Installs the routing skill.** A bundled skill teaches Claude Code when to reach for the taskflow tools, so you don't have to remember their names. diff --git a/website/content/docs/en/guides/codex.mdx b/website/content/docs/en/guides/codex.mdx index d86b7d87..f130eb78 100644 --- a/website/content/docs/en/guides/codex.mdx +++ b/website/content/docs/en/guides/codex.mdx @@ -3,6 +3,8 @@ title: Using taskflow on Codex description: Install taskflow as a Codex plugin and orchestrate multi-phase workflows via MCP. --- +> **1.0.0 release target.** The exact npm pins below become available after publication. This checkout is still a release candidate. + [Codex](https://github.com/openai/codex) is a natural home for taskflow: it already thinks in terms of subagents, and taskflow gives those subagents a declarative graph to run inside. On Codex, taskflow ships as a plugin that registers an MCP server plus a routing skill, so the model can call it the moment it sees a multi-step task. This page walks through the whole arc: install the plugin, confirm the MCP server is live, run a flow through a tool call, and configure things for long-running work. @@ -22,7 +24,7 @@ That single `plugin add` does three things at once: - **Registers the MCP server.** The plugin's `.mcp.json` declares a `taskflow` server launched via `npx codex-taskflow-mcp`, so Codex can reach the runtime over stdio. + **Registers the MCP server.** The plugin's `.mcp.json` declares a `taskflow` server launched via `npx -y -p codex-taskflow@1.0.0 codex-taskflow-mcp`, so Codex can reach the runtime over stdio. **Installs the routing skill.** A bundled skill teaches Codex when to reach for the taskflow tools, so you don't have to remember their names. diff --git a/website/content/docs/en/guides/grok-build.mdx b/website/content/docs/en/guides/grok-build.mdx index 40c36692..b697ae4a 100644 --- a/website/content/docs/en/guides/grok-build.mdx +++ b/website/content/docs/en/guides/grok-build.mdx @@ -3,6 +3,8 @@ title: Using taskflow on Grok Build description: Install taskflow as a Grok Build plugin and orchestrate multi-phase workflows via MCP. --- +> **1.0.0 release target.** The exact npm pins below become available after publication. This checkout is still a release candidate. + [Grok Build](https://docs.x.ai/build/overview) is xAI's terminal coding agent. Taskflow's published path is its MCP server; a plugin scaffold is also available in the repository checkout. This page walks through install, verify, first run, permissions, and long-running flows. The full reference lives in [`docs/grok-mcp.md`](https://github.com/heggria/taskflow/blob/main/docs/grok-mcp.md) in the repo. @@ -12,7 +14,7 @@ This page walks through install, verify, first run, permissions, and long-runnin ### Published MCP package (recommended) ```bash title="Register taskflow MCP" -grok mcp add taskflow -- npx -y -p grok-taskflow@beta grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@1.0.0 grok-taskflow-mcp ``` Requires **Node.js ≥ 22.19.0**. The MCP protocol code has no MCP SDK dependency. A public Grok plugin marketplace/source is not published yet; do not substitute an imaginary source string. @@ -170,7 +172,7 @@ MCP-driven runs are non-interactive, so an `approval` phase **auto-rejects** (fa ```bash title="Manual MCP registration" pnpm add -g grok-taskflow grok mcp add taskflow -- grok-taskflow-mcp -# or: grok mcp add taskflow -- npx -y -p grok-taskflow@beta grok-taskflow-mcp +# or: grok mcp add taskflow -- npx -y -p grok-taskflow@1.0.0 grok-taskflow-mcp ``` ## Remove diff --git a/website/content/docs/en/guides/hermes.mdx b/website/content/docs/en/guides/hermes.mdx index a9489079..a3df0d4e 100644 --- a/website/content/docs/en/guides/hermes.mdx +++ b/website/content/docs/en/guides/hermes.mdx @@ -3,6 +3,8 @@ title: Using taskflow on Hermes Agent description: Install taskflow as an MCP server on Hermes Agent and orchestrate multi-phase workflows with isolated Hermes subagents. --- +> **1.0.0 release target.** The exact npm pins below become available after publication. This checkout is still a release candidate. + [Hermes Agent](https://hermes-agent.nousresearch.com/) is Nous Research's open-source coding agent (CLI, TUI, desktop, messaging gateways). Taskflow's published path is its MCP server: Hermes discovers `taskflow_*` tools and each agent phase spawns an isolated `hermes chat -q -Q` child. The full reference lives in [`docs/hermes-mcp.md`](https://github.com/heggria/taskflow/blob/main/docs/hermes-mcp.md) in the repo. @@ -12,7 +14,7 @@ The full reference lives in [`docs/hermes-mcp.md`](https://github.com/heggria/ta ### Published MCP package (after npm publish) ```bash title="Register taskflow MCP" -hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@beta hermes-taskflow-mcp +hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@1.0.0 hermes-taskflow-mcp ``` Or paste into `~/.hermes/config.yaml` / `$HERMES_HOME/config.yaml`: @@ -21,7 +23,7 @@ Or paste into `~/.hermes/config.yaml` / `$HERMES_HOME/config.yaml`: mcp_servers: taskflow: command: "npx" - args: ["-y", "-p", "hermes-taskflow@beta", "hermes-taskflow-mcp"] + args: ["-y", "-p", "hermes-taskflow@1.0.0", "hermes-taskflow-mcp"] env: # Required for mutating agent phases (terminal / file write / coding). # PI_TASKFLOW_HERMES_UNSAFE_YOLO: "1" # required for mutating agent phases diff --git a/website/content/docs/en/guides/opencode.mdx b/website/content/docs/en/guides/opencode.mdx index 9b901aab..494c3bab 100644 --- a/website/content/docs/en/guides/opencode.mdx +++ b/website/content/docs/en/guides/opencode.mdx @@ -3,6 +3,8 @@ title: Using taskflow on OpenCode description: Register taskflow as an MCP server on OpenCode and orchestrate multi-phase workflows. --- +> **1.0.0 release target.** The exact npm pins below become available after publication. This checkout is still a release candidate. + [OpenCode](https://opencode.ai) exposes taskflow through an MCP server whose protocol layer has no MCP SDK dependency; each phase runs as an isolated `opencode run` process. This page walks through the whole arc: register the MCP server, confirm it's live, run a flow through a tool call, and understand the permission and model-resolution model. @@ -16,7 +18,7 @@ OpenCode has no git-based plugin marketplace, so you register the MCP server dir ### Option A: the CLI ```bash title="Register the MCP server via the CLI" -opencode mcp add taskflow -- npx -y -p opencode-taskflow@beta opencode-taskflow-mcp +opencode mcp add taskflow -- npx -y -p opencode-taskflow@1.0.0 opencode-taskflow-mcp ``` ### Option B: edit opencode.json @@ -27,7 +29,7 @@ opencode mcp add taskflow -- npx -y -p opencode-taskflow@beta opencode-taskflow- "mcp": { "taskflow": { "type": "local", - "command": ["npx", "-y", "-p", "opencode-taskflow@beta", "opencode-taskflow-mcp"], + "command": ["npx", "-y", "-p", "opencode-taskflow@1.0.0", "opencode-taskflow-mcp"], "enabled": true } }, diff --git a/website/content/docs/en/guides/pi.mdx b/website/content/docs/en/guides/pi.mdx index 08a791a0..32adcb65 100644 --- a/website/content/docs/en/guides/pi.mdx +++ b/website/content/docs/en/guides/pi.mdx @@ -3,6 +3,8 @@ title: Using taskflow on Pi description: Install, run, save, and resume taskflows on the Pi coding agent. --- +> **1.0.0 release target.** The exact npm pins below become available after publication. This checkout is still a release candidate. + If you already use [Pi](https://pi.dev) to delegate work to subagents, taskflow turns those one-off delegations into named, resumable, multi-step workflows. You describe the work as a graph — fan-out, gates, loops, whatever you need — and only the final result comes back into your context. This page walks through the whole arc: install, run your first flow, save it, run it by name, and wire up model roles. By the end you'll have a reusable command that does a real job in one line. @@ -14,7 +16,7 @@ Requires **Node.js ≥ 22.19.0**. taskflow is a Pi extension. Install it once: ```bash title="Install pi-taskflow" -pi install npm:pi-taskflow@beta +pi install npm:pi-taskflow@1.0.0 ``` That's it. The extension registers a `taskflow` tool that the model can call automatically, plus a `/tf` command for you. No model-side configuration is required to start. diff --git a/website/content/docs/en/index.mdx b/website/content/docs/en/index.mdx index 619aa7b6..43e61ecb 100644 --- a/website/content/docs/en/index.mdx +++ b/website/content/docs/en/index.mdx @@ -1,11 +1,11 @@ --- -title: taskflow 0.3 Documentation +title: taskflow 1.0 Documentation description: "Trusted Effects for coding-agent workflows: declare effects, verify typed paths, and commit admitted filesystem changes through one resource authority." --- -> **0.3.0-beta.2 — beta channel candidate, not GA.** Trusted Effects MVP plus a Control Plane slice (S2 Unix UDS + S3-min store). Wire contracts remain PROPOSED. `#137` / `#95` are not in this slice. +> **1.0.0 release candidate — not yet published.** The stable scope is the mature DAG runtime, DSL, six host adapters and admitted declared-filesystem transactions. Control Plane wire contracts remain proposed and workspace-only; parked approvals, receipts, global coordination and WebUI are excluded. -taskflow is a declarative runtime for coding-agent workflows. It turns a graph into a verifiable execution contract, runs phases in isolation, and keeps intermediate transcripts out of the host conversation. The 0.3 candidate adds **Trusted Effects**: a typed declaration and resource-controlled commit path for admitted filesystem effects. +taskflow is a declarative runtime for coding-agent workflows. It turns a graph into a verifiable execution contract, runs phases in isolation, and keeps intermediate transcripts out of the host conversation. The 1.0 contract includes **Trusted Effects**: a typed declaration and resource-controlled commit path for admitted filesystem effects. ## Start with the right path @@ -28,7 +28,7 @@ taskflow is a declarative runtime for coding-agent workflows. It turns a graph i Trusted Effects is not an OS sandbox. The 0.3 MVP protects admitted declared filesystem targets through the resources path. Under resolve-only execution, writes to undeclared paths remain host-policy dependent; SecretRef and ServiceRef have no live vault or network backend in this cut. -## The 0.3 contract +## The declared-effect contract @@ -47,7 +47,7 @@ taskflow is a declarative runtime for coding-agent workflows. It turns a graph i ## Install on your host -The 0.3 beta is available from npm's `beta` channel, or from this repository for source-level work. Stable host guides continue to use the published 0.2.x line unless you explicitly select the beta channel. +Install `@1.0.0` after the release workflow completes. Until then, use the reviewed source checkout or a previously published exact version. These examples do not prove npm availability. Every taskflow package requires **Node.js 22.19.0 or newer**. The candidate's source and fixture checks use pnpm from the repository checkout. @@ -93,20 +93,20 @@ The 0.3 beta is available from npm's `beta` channel, or from this repository for ## Release boundary -The current 0.3 line has two related tracks: +The stable contract and workspace experiments have separate release boundaries: - **Trusted Effects MVP:** the release-bound product definition. It covers EffectIR, typed refs, labels, resource-controlled filesystem transactions, overlap admission, host-baseline honesty, and ledger-backed `why-*` explainers. -- **0.3-C Control Plane:** a follow-on track whose current code is a ControlHost/proposed-contract scaffold. Project stores, coordination, approvals, receipts, and evidence UI are later stages; none is the 0.3 MVP GA definition, and the WebUI is not shipped in this candidate. +- **Experimental Control Plane:** private workspace Unix UDS/store code with proposed wire contracts; admission, parked approvals, receipts, global coordination and WebUI remain incomplete and excluded from the stable API. -Read the [MVP freeze in the repository](https://github.com/heggria/taskflow/blob/rc/0.3.0-trusted-effects/docs/internal/0.3.0-trusted-effects-mvp.md) and the [0.3-C plan](https://github.com/heggria/taskflow/blob/rc/0.3.0-trusted-effects/docs/internal/0.3-c-control-plane-plan.md) for the normative scope. +Read the [MVP freeze in the repository](https://github.com/heggria/taskflow/blob/main/docs/internal/0.3.0-trusted-effects-mvp.md) and the [0.3-C plan](https://github.com/heggria/taskflow/blob/main/docs/internal/0.3-c-control-plane-plan.md) for the normative scope. ## More resources - + Runnable flow definitions, including the Trusted Effects write fixture. - + Candidate notes, release boundaries, and the 0.2 history. diff --git a/website/content/docs/en/trusted-effects.mdx b/website/content/docs/en/trusted-effects.mdx index 8658c14f..bf0e5661 100644 --- a/website/content/docs/en/trusted-effects.mdx +++ b/website/content/docs/en/trusted-effects.mdx @@ -2,13 +2,13 @@ title: Trusted Effects --- -> **0.3.0-beta.2 — beta channel candidate, not GA.** This page describes the Trusted Effects MVP prepared for the beta release; npm publication is still a release gate. +> **1.0.0 release candidate — not yet published.** The stable scope is the mature DAG runtime, DSL, six host adapters and admitted declared-filesystem transactions. Control Plane wire contracts remain proposed and workspace-only; parked approvals, receipts, global coordination and WebUI are excluded. Trusted Effects makes a phase's side effects explicit. The model may propose content, but for an admitted declared filesystem target, the **resources transaction is the only finalizer**. ## The vertical slice -The checked-in [`examples/trusted-effects-write.json`](https://github.com/heggria/taskflow/blob/rc/0.3.0-trusted-effects/examples/trusted-effects-write.json) declares one `fs.write` effect for `out/report.md`. The no-LLM fixture exercises the same resource-controlled path: +The checked-in [`examples/trusted-effects-write.json`](https://github.com/heggria/taskflow/blob/main/examples/trusted-effects-write.json) declares one `fs.write` effect for `out/report.md`. The no-LLM fixture exercises the same resource-controlled path: ```bash pnpm exec node --conditions=development --experimental-strip-types --test \ @@ -32,11 +32,11 @@ pnpm exec node --conditions=development --experimental-strip-types --test \ - Resolve-only hosts cannot prevent every write to an undeclared path. - SecretRef has no vault backend in this cut. - ServiceRef has no live network adapter in this cut. -- ControlHost is currently a scaffold; stores, approvals, receipts, and WebUI belong to later 0.3-C stages. None is proof that 0.3 is released or GA. +- ControlHost has experimental Unix UDS and files-only store code. Admission, parked approvals/receipts, coordination, Windows pipes and WebUI remain incomplete and are outside the 1.0 stable API. ## Read next - [Core concepts](/en/docs/concepts) - [Compiler & runtime](/en/docs/compiler-runtime) -- [0.3 MVP freeze in the repository](https://github.com/heggria/taskflow/blob/rc/0.3.0-trusted-effects/docs/internal/0.3.0-trusted-effects-mvp.md) -- [Host support baseline](https://github.com/heggria/taskflow/blob/rc/0.3.0-trusted-effects/conformance/workspace/host-support-baseline.json) +- [0.3 MVP freeze in the repository](https://github.com/heggria/taskflow/blob/main/docs/internal/0.3.0-trusted-effects-mvp.md) +- [Host support baseline](https://github.com/heggria/taskflow/blob/main/conformance/workspace/host-support-baseline.json) diff --git a/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx b/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx index 892c687b..aaef8acc 100644 --- a/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx +++ b/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx @@ -8,7 +8,7 @@ description: 编译期 .tf.ts 写法 —— rune erase 成 Taskflow JSON,再 S4 增加**编译期** TypeScript 前端:用 rune(`agent`、`map`、`race`…)写 `*.tf.ts`,CLI erase 成普通 Taskflow JSON。宿主仍通过 `taskflow_run` / `/tf run` 跑 **JSON**——**没有**解释执行路径,也**没有**宿主对 `.tf.ts` 的自动 build。 - **Package status.** `taskflow-dsl` lives in the monorepo (`packages/taskflow-dsl`). It is **not** required for JSON authors. Package manifests target `0.3.0-beta.2` on npm's `beta` channel; after publication, install with `npm install taskflow-dsl@beta`, or use a workspace / local path from this monorepo. + **Package status.** `taskflow-dsl` lives in the monorepo (`packages/taskflow-dsl`). It is **not** required for JSON authors. Package manifests target stable `1.0.0`; after publication, install with `npm install taskflow-dsl@1.0.0`, or use a workspace / local path from this monorepo. ## 工作流 diff --git a/website/content/docs/zh-cn/getting-started.mdx b/website/content/docs/zh-cn/getting-started.mdx index 19e66919..6ee6b16a 100644 --- a/website/content/docs/zh-cn/getting-started.mdx +++ b/website/content/docs/zh-cn/getting-started.mdx @@ -6,7 +6,7 @@ description: 五分钟内运行你的第一个 taskflow。 taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不需要写一个一个调用子代理的脚本,只需声明节点和边——运行时会替你处理 fan-out、重试、缓存和续跑。 - 本指南介绍稳定的 0.2.x 宿主安装路径。对于 **0.3.0-beta.2**,请从 [Trusted Effects 总览](/zh-cn/docs/trusted-effects) 开始,并显式选择 npm 的 `beta` channel;beta 尚未 GA。 + 本指南面向 **1.0.0**。精确安装 pin 在发布流程成功后可用;当前 checkout 仍是发布候选。声明文件事务边界见 [Trusted Effects 总览](/zh-cn/docs/trusted-effects)。 要最快地感受它,先跑一个看看。 @@ -81,7 +81,7 @@ taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不 ```bash title="安装 pi-taskflow" - pi install npm:pi-taskflow@beta + pi install npm:pi-taskflow@1.0.0 ``` @@ -98,7 +98,7 @@ taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不 ```bash title="注册 OpenCode MCP" - opencode mcp add taskflow -- npx -y -p opencode-taskflow@beta opencode-taskflow-mcp + opencode mcp add taskflow -- npx -y -p opencode-taskflow@1.0.0 opencode-taskflow-mcp ``` @@ -112,7 +112,7 @@ taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不 ```bash title="注册 Grok Build MCP" export PI_TASKFLOW_GROK_READONLY_SANDBOX_PROFILE=taskflow-readonly export PI_TASKFLOW_GROK_MUTATING_SANDBOX_PROFILE=taskflow-workspace - grok mcp add taskflow -- npx -y -p grok-taskflow@beta grok-taskflow-mcp + grok mcp add taskflow -- npx -y -p grok-taskflow@1.0.0 grok-taskflow-mcp # 公共 plugin source 尚未发布。仅 checkout 可用: # grok plugin install ./packages/grok-taskflow/plugin --trust ``` @@ -120,7 +120,7 @@ taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不 ```bash title="注册 Hermes Agent MCP" - hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@beta hermes-taskflow-mcp + hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@1.0.0 hermes-taskflow-mcp ``` 只读隔离与显式可写 opt-in 说明见 [Hermes Agent 指南](/zh-cn/docs/guides/hermes)。 diff --git a/website/content/docs/zh-cn/guides/claude-code.mdx b/website/content/docs/zh-cn/guides/claude-code.mdx index d73a3c51..abb47e82 100644 --- a/website/content/docs/zh-cn/guides/claude-code.mdx +++ b/website/content/docs/zh-cn/guides/claude-code.mdx @@ -3,6 +3,8 @@ title: 在 Claude Code 上使用 taskflow description: 将 taskflow 作为 Claude Code 插件安装,通过 MCP 编排多阶段工作流。 --- +> **1.0.0 发布目标。** 下列精确 npm pin 在发布完成后可用;当前 checkout 仍是发布候选。 + [Claude Code](https://claude.com/product/claude-code) 是 taskflow 的天然宿主:它本身就以 subagent 的方式思考,而 taskflow 给这些 subagent 提供了一个声明式图来运行。插件注册 MCP server 与路由 skill;协议层不依赖 MCP SDK。 本页带你走完整个流程:安装插件、确认 MCP 服务器已就绪、通过工具调用运行一个 flow,并理解权限与长时运行 flow 的模型。 @@ -22,7 +24,7 @@ claude plugin install claude-taskflow@taskflow - **注册 MCP 服务器。** 插件声明了一个通过 `npx -y -p claude-taskflow claude-taskflow-mcp` 启动的 `taskflow` 服务器,让 Claude Code 能通过 stdio 访问运行时。`npx` 的版本锁定绑定了确切运行的代码——无需全局安装任何东西。 + **注册 MCP 服务器。** 插件声明了一个通过 `npx -y -p claude-taskflow@1.0.0 claude-taskflow-mcp` 启动的 `taskflow` 服务器,让 Claude Code 能通过 stdio 访问运行时。`npx` 的版本锁定绑定了确切运行的代码——无需全局安装任何东西。 **安装路由 skill。** 一个内置 skill 教会 Claude Code 何时该调用 taskflow 工具,这样你不必记住它们的名字。 diff --git a/website/content/docs/zh-cn/guides/codex.mdx b/website/content/docs/zh-cn/guides/codex.mdx index b6968940..ea978e91 100644 --- a/website/content/docs/zh-cn/guides/codex.mdx +++ b/website/content/docs/zh-cn/guides/codex.mdx @@ -3,6 +3,8 @@ title: 在 Codex 上使用 taskflow description: 把 taskflow 作为 Codex 插件安装,通过 MCP 编排多阶段工作流。 --- +> **1.0.0 发布目标。** 下列精确 npm pin 在发布完成后可用;当前 checkout 仍是发布候选。 + [Codex](https://github.com/openai/codex) 是 taskflow 的天然归宿:它已经在用子代理的方式思考,而 taskflow 给那些子代理一个声明式图来在里面运行。在 Codex 上,taskflow 以插件形式发布,注册一个 MCP 服务器加一个路由 skill,于是模型一看到多步骤任务就能调用它。 本页走完整个弧线:安装插件、确认 MCP 服务器活了、通过一次工具调用运行一个 flow,以及为长时间运行的工作做配置。 @@ -22,7 +24,7 @@ codex plugin add taskflow@taskflow - **注册 MCP 服务器。** 插件的 `.mcp.json` 声明一个 `taskflow` 服务器,通过 `npx codex-taskflow-mcp` 启动,于是 Codex 能通过 stdio 访问运行时。 + **注册 MCP 服务器。** 插件的 `.mcp.json` 声明一个 `taskflow` 服务器,通过 `npx -y -p codex-taskflow@1.0.0 codex-taskflow-mcp` 启动,于是 Codex 能通过 stdio 访问运行时。 **安装路由 skill。** 一个打包的 skill 教 Codex 何时去拿 taskflow 工具,于是你不用记它们的名字。 diff --git a/website/content/docs/zh-cn/guides/grok-build.mdx b/website/content/docs/zh-cn/guides/grok-build.mdx index 399fdad5..00441897 100644 --- a/website/content/docs/zh-cn/guides/grok-build.mdx +++ b/website/content/docs/zh-cn/guides/grok-build.mdx @@ -3,6 +3,8 @@ title: 在 Grok Build 上使用 taskflow description: 将 taskflow 作为 Grok Build 插件安装,通过 MCP 编排多阶段工作流。 --- +> **1.0.0 发布目标。** 下列精确 npm pin 在发布完成后可用;当前 checkout 仍是发布候选。 + [Grok Build](https://docs.x.ai/build/overview) 是 xAI 的终端编码 agent。Taskflow 的正式发布路径是 MCP server;仓库 checkout 另带 plugin scaffold。 本页覆盖安装、验证、首次运行、权限与长时 flow。完整说明见仓库 [`docs/grok-mcp.md`](https://github.com/heggria/taskflow/blob/main/docs/grok-mcp.md)。 @@ -12,7 +14,7 @@ description: 将 taskflow 作为 Grok Build 插件安装,通过 MCP 编排多 ### 已发布 MCP 包(推荐) ```bash title="注册 taskflow MCP" -grok mcp add taskflow -- npx -y -p grok-taskflow@beta grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@1.0.0 grok-taskflow-mcp ``` 要求 **Node.js ≥ 22.19.0**。MCP 协议代码不依赖 MCP SDK。公共 Grok plugin marketplace/source 尚未发布;不要代入一个不存在的 source。 diff --git a/website/content/docs/zh-cn/guides/hermes.mdx b/website/content/docs/zh-cn/guides/hermes.mdx index 3325742e..836f8e73 100644 --- a/website/content/docs/zh-cn/guides/hermes.mdx +++ b/website/content/docs/zh-cn/guides/hermes.mdx @@ -3,6 +3,8 @@ title: 在 Hermes Agent 上使用 taskflow description: 在 Hermes Agent 上通过 MCP 安装 taskflow,用隔离的 Hermes 子代理编排多阶段工作流。 --- +> **1.0.0 发布目标。** 下列精确 npm pin 在发布完成后可用;当前 checkout 仍是发布候选。 + [Hermes Agent](https://hermes-agent.nousresearch.com/) 是 Nous Research 的开源 coding agent(CLI / TUI / 桌面 / 消息网关)。taskflow 的发布路径是 MCP server:Hermes 发现 `taskflow_*` 工具,每个 agent phase 会拉起隔离的 `hermes chat -q -Q` 子进程。 完整说明见仓库 [`docs/hermes-mcp.md`](https://github.com/heggria/taskflow/blob/main/docs/hermes-mcp.md)。 @@ -12,7 +14,7 @@ description: 在 Hermes Agent 上通过 MCP 安装 taskflow,用隔离的 Herme ### 已发布 npm 包(发布后) ```bash title="注册 taskflow MCP" -hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@beta hermes-taskflow-mcp +hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@1.0.0 hermes-taskflow-mcp ``` 或写入 `~/.hermes/config.yaml` / `$HERMES_HOME/config.yaml`: @@ -21,7 +23,7 @@ hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@beta hermes-t mcp_servers: taskflow: command: "npx" - args: ["-y", "-p", "hermes-taskflow@beta", "hermes-taskflow-mcp"] + args: ["-y", "-p", "hermes-taskflow@1.0.0", "hermes-taskflow-mcp"] env: # 需要写文件/终端的 mutating phase 必须打开 # PI_TASKFLOW_HERMES_UNSAFE_YOLO: "1" # required for mutating agent phases diff --git a/website/content/docs/zh-cn/guides/opencode.mdx b/website/content/docs/zh-cn/guides/opencode.mdx index 511a50dd..a35c8c1c 100644 --- a/website/content/docs/zh-cn/guides/opencode.mdx +++ b/website/content/docs/zh-cn/guides/opencode.mdx @@ -3,6 +3,8 @@ title: 在 OpenCode 上使用 taskflow description: 在 OpenCode 上将 taskflow 注册为 MCP 服务器,编排多阶段工作流。 --- +> **1.0.0 发布目标。** 下列精确 npm pin 在发布完成后可用;当前 checkout 仍是发布候选。 + [OpenCode](https://opencode.ai) 通过协议层不依赖 MCP SDK 的 MCP server 暴露 taskflow;每个阶段作为隔离的 `opencode run` 进程运行。 本页带你走完整个流程:注册 MCP 服务器、确认它已就绪、通过工具调用运行一个 flow,并理解权限与模型解析模型。 @@ -16,7 +18,7 @@ OpenCode 没有基于 git 的插件市场,所以你直接注册 MCP 服务器 ### 方式 A:CLI ```bash title="通过 CLI 注册 MCP 服务器" -opencode mcp add taskflow -- npx -y -p opencode-taskflow@beta opencode-taskflow-mcp +opencode mcp add taskflow -- npx -y -p opencode-taskflow@1.0.0 opencode-taskflow-mcp ``` ### 方式 B:编辑 opencode.json @@ -27,7 +29,7 @@ opencode mcp add taskflow -- npx -y -p opencode-taskflow@beta opencode-taskflow- "mcp": { "taskflow": { "type": "local", - "command": ["npx", "-y", "-p", "opencode-taskflow@beta", "opencode-taskflow-mcp"], + "command": ["npx", "-y", "-p", "opencode-taskflow@1.0.0", "opencode-taskflow-mcp"], "enabled": true } }, diff --git a/website/content/docs/zh-cn/guides/pi.mdx b/website/content/docs/zh-cn/guides/pi.mdx index 2c2c94b7..a80f4a08 100644 --- a/website/content/docs/zh-cn/guides/pi.mdx +++ b/website/content/docs/zh-cn/guides/pi.mdx @@ -3,6 +3,8 @@ title: 在 Pi 上使用 taskflow description: 在 Pi 编程智能体上安装、运行、保存和续跑 taskflow。 --- +> **1.0.0 发布目标。** 下列精确 npm pin 在发布完成后可用;当前 checkout 仍是发布候选。 + 如果你已经在用 [Pi](https://pi.dev) 把工作委托给子代理,taskflow 把这些一次性的委托变成有名字、可续跑、多步骤的工作流。你把工作描述成一个图——fan-out、gate、loop,你需要什么就写什么——只有最终结果回到你的上下文里。 本页走完整个弧线:安装、运行你的第一个 flow、保存它、按名字运行它,以及接好模型角色。读完时你会有一个一行就能干一件实事的可复用命令。 @@ -14,7 +16,7 @@ description: 在 Pi 编程智能体上安装、运行、保存和续跑 taskflow taskflow 是一个 Pi 扩展。装一次就行: ```bash title="安装 pi-taskflow" -pi install npm:pi-taskflow@beta +pi install npm:pi-taskflow@1.0.0 ``` 就这样。这个扩展注册了一个模型可以自动调用的 `taskflow` 工具,外加一个给你的 `/tf` 命令。启动不需要任何模型侧的配置。 diff --git a/website/content/docs/zh-cn/index.mdx b/website/content/docs/zh-cn/index.mdx index a9992912..914f6c44 100644 --- a/website/content/docs/zh-cn/index.mdx +++ b/website/content/docs/zh-cn/index.mdx @@ -1,11 +1,11 @@ --- -title: taskflow 0.3 文档 +title: taskflow 1.0 文档 description: "面向 coding-agent 工作流的 Trusted Effects:声明 effect,验证类型化路径,让已准入的文件修改经过唯一 resource authority。" --- -> **0.3.0-beta.2——beta channel candidate,尚未 GA。** Trusted Effects MVP 加上 Control Plane 切片(S2 Unix UDS + S3 最小 store)。Wire 合同仍为 PROPOSED。本切片不含 `#137` / `#95`。 +> **1.0.0 发布候选——尚未发布。** 稳定范围是成熟 DAG runtime、DSL、六宿主适配器和已准入的声明文件事务。Control Plane wire 合同仍为 proposed,只在 workspace 实验;parked 审批、receipt、全局协调与 WebUI 不属于稳定 API。 -taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图变成可验证的执行合同,让阶段隔离运行,并把中间 transcript 留在宿主对话之外。0.3 candidate 增加了 **Trusted Effects**:为已准入的文件 effect 提供类型化声明与受 resources 控制的提交路径。 +taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图变成可验证的执行合同,让阶段隔离运行,并把中间 transcript 留在宿主对话之外。1.0 合同包含 **Trusted Effects**:为已准入的文件 effect 提供类型化声明与受 resources 控制的提交路径。 ## 先选择正确路径 @@ -28,7 +28,7 @@ taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图 Trusted Effects 不是 OS sandbox。0.3 MVP 通过 resources 路径保护已准入的已声明文件目标。在 resolve-only 执行下,未声明路径的写入仍取决于宿主策略;这一版没有 SecretRef 的 live vault 后端或 ServiceRef 的网络后端。 -## 0.3 合同 +## 声明文件副作用合同 @@ -47,7 +47,7 @@ taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图 ## 在你的宿主上使用 -0.3 beta 可从 npm 的 `beta` channel 安装,也可从仓库源码运行。若未显式选择 beta,稳定宿主指南仍使用已发布的 0.2.x package pin。 +发布流程完成后使用 `@1.0.0`。发布前可使用已审查的源码 checkout 或已有的精确版本;以下示例不代表 npm 已发布。 所有 taskflow package 都要求 **Node.js 22.19.0 或更高版本**。Candidate 的源码与 fixture 检查使用仓库内的 pnpm。 @@ -93,20 +93,20 @@ taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图 ## 发布边界 -当前 0.3 有两条相关轨道: +稳定合同与 workspace 实验有独立发布边界: - **Trusted Effects MVP:** 面向发布的产品定义。覆盖 EffectIR、类型化 ref、labels、受 resources 控制的文件 transaction、重叠准入、诚实的宿主基线与 ledger-backed `why-*` explainers。 -- **0.3-C Control Plane:** 后续实现轨道;当前代码是 ControlHost / 拟议合同的脚手架。项目 store、协调、审批、receipts 与 evidence UI 属于后续阶段,不是 0.3 MVP 的 GA 定义,WebUI 也尚未在当前 candidate 交付。 +- **实验性 Control Plane:** private workspace Unix UDS/store 代码与 proposed wire 合同;admission、parked 审批、receipt、全局协调与 WebUI 尚未完成,不属于稳定 API。 -规范范围请读仓库中的 [MVP 冻结定义](https://github.com/heggria/taskflow/blob/rc/0.3.0-trusted-effects/docs/internal/0.3.0-trusted-effects-mvp.md) 与 [0.3-C 计划](https://github.com/heggria/taskflow/blob/rc/0.3.0-trusted-effects/docs/internal/0.3-c-control-plane-plan.md)。 +规范范围请读仓库中的 [MVP 冻结定义](https://github.com/heggria/taskflow/blob/main/docs/internal/0.3.0-trusted-effects-mvp.md) 与 [0.3-C 计划](https://github.com/heggria/taskflow/blob/main/docs/internal/0.3-c-control-plane-plan.md)。 ## 更多资源 - + 可运行的 flow 定义,包括 Trusted Effects 文件写入 fixture。 - + Candidate 说明、发布边界与 0.2 历史。 diff --git a/website/content/docs/zh-cn/trusted-effects.mdx b/website/content/docs/zh-cn/trusted-effects.mdx index 81a822d3..56933aca 100644 --- a/website/content/docs/zh-cn/trusted-effects.mdx +++ b/website/content/docs/zh-cn/trusted-effects.mdx @@ -2,13 +2,13 @@ title: Trusted Effects --- -> **0.3.0-beta.2——beta channel candidate,尚未 GA。** 本页描述为 beta 发布准备的 Trusted Effects MVP;npm 发布仍是发版闸门。 +> **1.0.0 发布候选——尚未发布。** 稳定范围是成熟 DAG runtime、DSL、六宿主适配器和已准入的声明文件事务。Control Plane wire 合同仍为 proposed,只在 workspace 实验;parked 审批、receipt、全局协调与 WebUI 不属于稳定 API。 Trusted Effects 把阶段的副作用写进合同。模型可以提出内容,但对于已准入的已声明文件目标,**resources transaction 是唯一最终提交者**。 ## Vertical slice -仓库内的 [`examples/trusted-effects-write.json`](https://github.com/heggria/taskflow/blob/rc/0.3.0-trusted-effects/examples/trusted-effects-write.json) 声明了一个对 `out/report.md` 的 `fs.write` effect。无 LLM fixture 会执行同一条受 resources 控制的路径: +仓库内的 [`examples/trusted-effects-write.json`](https://github.com/heggria/taskflow/blob/main/examples/trusted-effects-write.json) 声明了一个对 `out/report.md` 的 `fs.write` effect。无 LLM fixture 会执行同一条受 resources 控制的路径: ```bash pnpm exec node --conditions=development --experimental-strip-types --test \ @@ -32,11 +32,11 @@ pnpm exec node --conditions=development --experimental-strip-types --test \ - Resolve-only 宿主无法阻止所有对未声明路径的写入。 - 这一版没有 SecretRef vault 后端。 - 这一版没有 ServiceRef live network adapter。 -- ControlHost 当前是脚手架;store、审批、receipts 与 WebUI 属于后续的 0.3-C 阶段。它们不证明 0.3 已发布或 GA。 +- ControlHost 有实验性 Unix UDS 和 files-only store 代码。admission、parked 审批/receipt、协调、Windows pipe 与 WebUI 尚未完成,不属于 1.0 稳定 API。 ## 接下来阅读 - [核心概念](/zh-cn/docs/concepts) - [编译器与运行时](/zh-cn/docs/compiler-runtime) -- [仓库中的 0.3 MVP 冻结定义](https://github.com/heggria/taskflow/blob/rc/0.3.0-trusted-effects/docs/internal/0.3.0-trusted-effects-mvp.md) -- [宿主支持基线](https://github.com/heggria/taskflow/blob/rc/0.3.0-trusted-effects/conformance/workspace/host-support-baseline.json) +- [仓库中的 0.3 MVP 冻结定义](https://github.com/heggria/taskflow/blob/main/docs/internal/0.3.0-trusted-effects-mvp.md) +- [宿主支持基线](https://github.com/heggria/taskflow/blob/main/conformance/workspace/host-support-baseline.json) diff --git a/website/next-env.d.ts b/website/next-env.d.ts index 9edff1c7..ce4e94a6 100644 --- a/website/next-env.d.ts +++ b/website/next-env.d.ts @@ -1,6 +1,7 @@ /// /// import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/website/package.json b/website/package.json index db8a8226..196ca46a 100644 --- a/website/package.json +++ b/website/package.json @@ -20,7 +20,7 @@ "fumadocs-mdx": "15.3.1", "fumadocs-ui": "16.15.1", "lucide-react": "1.33.0", - "next": "16.3.3", + "next": "16.3.6", "react": "19.2.8", "react-dom": "19.2.8" }, From 008f403595ed4d3c9c18eaba5d22df96d7a9e3c0 Mon Sep 17 00:00:00 2001 From: heggria Date: Fri, 2 Oct 2026 16:36:09 +0800 Subject: [PATCH 11/30] fix: close release security gates and date authorized 1.0 release --- CHANGELOG.md | 8 ++-- docs/codex-mcp.md | 10 ++-- docs/internal/1.0.0-ga-scoreboard.md | 6 +-- docs/internal/1.0.0-release-plan.md | 10 ++-- .../plugin/skills/taskflow/configuration.md | 2 +- .../pi-taskflow/test/fixtures/pi1-provider.ts | 2 +- pnpm-lock.yaml | 46 +++++++++---------- pnpm-workspace.yaml | 7 +-- skills-src/taskflow/configuration.md | 2 +- 9 files changed, 48 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index beafe05f..1d348cf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,9 @@ All notable changes to taskflow are documented here. This project follows [Keep - HOL plugin-scanner highs: dummy test credentials use scanner placeholders / short literals; no `eval (` in comments; smoke/runner no longer place a `${…}` template within 30 chars of `spawn`. Local scan 81/100, 0 high. **Not GA.** -## [1.0.0] — Unreleased +## [1.0.0] — 2026-10-02 -> **Release candidate; not tagged or published.** The ten existing public Taskflow packages target npm `latest` after release gates pass. This heading must be dated only when the release is authorized. The mature DAG/DSL/host/declared-filesystem-transaction contract is the stable scope; the unfinished control-plane RFC is not a 1.0 completion claim. +> The ten existing public Taskflow packages target npm `latest` through the authorized tag workflow after release gates pass. The mature DAG/DSL/host/declared-filesystem-transaction contract is the stable scope; the unfinished control-plane RFC is not a 1.0 completion claim. A dated changelog alone is not evidence of completed publication. ### Changed @@ -27,13 +27,13 @@ All notable changes to taskflow are documented here. This project follows [Keep - Reject cross-project attachment to the experimental single-store control host, preserve corrupt or identity-less ledgers, and isolate malformed UDS handshakes to the offending connection. - Make release reruns accept valid non-draft GitHub releases, validate real changelog dates, and require every package's release dist-tag before creating a stable release. - Correct macOS canonical-path test fixtures and wait for detached child completion before removing their workspaces. Add real Pi CLI and clean-installed package regressions alongside live Codex/Claude checks. -- Update the documentation site's Next.js, sharp and baseline-browser-mapping dependencies to patched versions. +- Update Next.js, sharp, baseline-browser-mapping, Undici and brace-expansion to patched versions; the complete workspace dependency audit, including development dependencies, reports zero vulnerabilities. ### Support boundaries - Pi interactive approval is supported; MCP/headless approval auto-rejects. Grok flows with budgets are rejected when usage cannot be observed. - Declared filesystem effects use resource transactions. Resolve-only is not an OS sandbox; undeclared writes remain host-policy dependent. Secret/service references have no live backend. Event-kernel fallback and journal capacity limits remain explicit. -- Preparing this PR does not authorize merge, tag, npm publication or website deployment. Current evidence is tracked separately in `docs/internal/1.0.0-ga-scoreboard.md`. +- Registry artifacts, tag and GitHub Release are verified separately from source preparation. Current evidence is tracked in `docs/internal/1.0.0-ga-scoreboard.md`. ## [0.3.0-beta.2] — 2026-08-18 diff --git a/docs/codex-mcp.md b/docs/codex-mcp.md index 72833e69..d018ac8f 100644 --- a/docs/codex-mcp.md +++ b/docs/codex-mcp.md @@ -42,7 +42,7 @@ fan-out work), so you usually don't have to name them explicitly. Codex has sandbox profiles, not a strict per-tool-name whitelist. A phase whose effective `tools` are read-only maps to `codex exec -s read-only`; a mutating tool or an omitted list maps to `-s workspace-write`. -`danger-full-access` is never selected automatically. +Unrestricted filesystem access is never selected automatically. Thinking resolves phase → agent → global and is passed as `model_reasoning_effort`: `off`/`none`/`minimal` → `none`, @@ -187,10 +187,10 @@ Inside a codex session, just ask — codex will call the tools: > Run the "release-train" taskflow. ``` -> **Note on approvals.** In non-interactive `codex exec`, MCP tool calls require -> approval; pass `--dangerously-bypass-approvals-and-sandbox` for unattended -> automation (only in an already-sandboxed environment). Interactive `codex` -> prompts for approval normally. +> **Note on approvals.** Review the host's MCP approval policy interactively +> before unattended execution. Keep the filesystem sandbox enabled and limit +> MCP access to the tools needed by the workflow. If the configured policy +> requires a human decision, complete it interactively before continuing. ## Remove diff --git a/docs/internal/1.0.0-ga-scoreboard.md b/docs/internal/1.0.0-ga-scoreboard.md index e1eed70b..43d32640 100644 --- a/docs/internal/1.0.0-ga-scoreboard.md +++ b/docs/internal/1.0.0-ga-scoreboard.md @@ -1,6 +1,6 @@ # Taskflow 1.0.0 acceptance scoreboard -Status: **candidate / not published**. Updated 2026-10-02 after local verification. +Status: **release authorized / not yet published**. Updated 2026-10-02 after local verification. This ledger replaces the current-release interpretation of the historical 0.3 scoreboard. Local checks use macOS, Node 24.21.0, pnpm 11.17.0 and Pi 1.0.0. The final PR head's GitHub checks are the authoritative Node 22/Linux/Windows @@ -21,10 +21,10 @@ and remote security evidence; pending rows must not be inferred from local passe | Current live host executors | PASS for available Codex and Claude | Codex `gpt-6.1-sol`/high: 3-phase fruit→uppercase→TrustedEffects persistence, 39.2s. Claude `haiku`: 2-phase output transfer, 21.3s. Both use real authenticated CLIs | | OpenCode/Grok/Hermes live execution | NOT RUN | binaries unavailable locally; MCP fixture passes do not establish live model acceptance | | Process SIGKILL/recovery/restore/cleanup | PASS locally; three-OS remote gate | full suite includes crash recovery/writer races/resource restoration/detached cleanup; added control corruption and malformed UDS cases. Control remains Unix-only experimental | -| Website export/audit | PASS locally | production static export with `/taskflow` base path; patched Next.js 16.3.6, sharp 0.35.4, baseline-browser-mapping 2.11.0; `pnpm audit --prod` reports zero vulnerabilities | +| Website export/audit | PASS locally | production static export with `/taskflow` base path; patched Next.js 16.3.6, sharp 0.35.4, baseline-browser-mapping 2.11.0, Undici 8.10.2 and brace-expansion 5.0.12; full `pnpm audit` (including development dependencies) reports zero vulnerabilities | | Remote security | REMOTE GATE | exact-head CodeQL and Plugin Security Scan, retaining Cisco coverage and severity/score gates | | Final exact-SHA remote CI | PENDING | do not reuse PR173/174 or historical beta green as integrated-head evidence | -| Published artifacts and stable GitHub Release | NOT DONE | no tag/npm/release action authorized or performed | +| Published artifacts and stable GitHub Release | AUTHORIZED / NOT DONE | owner authorized the 1.0 release on 2026-10-02; tag/npm/release evidence still required | The supported stable scope is the mature DAG/DSL/six-host/declared-target transaction contract. Private control-plane wire contracts remain proposed; diff --git a/docs/internal/1.0.0-release-plan.md b/docs/internal/1.0.0-release-plan.md index 550ea377..cdc0fce9 100644 --- a/docs/internal/1.0.0-release-plan.md +++ b/docs/internal/1.0.0-release-plan.md @@ -1,10 +1,12 @@ # Taskflow 1.0.0 release preparation -Status: **IMPLEMENTING / NOT RELEASED**. This plan supersedes the beta.2 +Status: **RELEASE AUTHORIZED / VERIFICATION IN PROGRESS**. This plan supersedes the beta.2 release target for PR #142; older plans and evidence remain historical. The authorized scope is a complete release preparation PR, comprehensive verification, Pi 1.0 retry/approval compatibility and E2E regression fixes. -It does not authorize merge, tag, npm publication or website deployment. +The release owner subsequently authorized completing the existing repository's +1.0 publication on 2026-10-02. Merge/tag/registry publication must still follow +the checked-in workflow and final-commit gates, with one release executor. ## Contract @@ -31,8 +33,8 @@ secret/service backends and unbenchmarked journal capacity remain documented. - Local internal dependencies use `workspace:*`; deterministic packed manifests pin the exact release version. Fresh npm consumers resolve only the local tarballs for Taskflow packages and exercise all exports/declarations/bins. -- The candidate changelog stays **Unreleased**. Publication requires a separately - authorized dated heading, annotated tag on a verified main commit, npm +- The release changelog is dated after owner authorization. Publication requires + an annotated tag on a verified main commit, npm provenance, owner/repository/workflow/commit/integrity verification, all ten latest dist-tags, then a stable GitHub Release. Partial publication is failure. - Exact release-commit CI evidence replaces historical beta evidence. Preserve diff --git a/packages/codex-taskflow/plugin/skills/taskflow/configuration.md b/packages/codex-taskflow/plugin/skills/taskflow/configuration.md index 402d7546..358ce0b6 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/configuration.md @@ -300,7 +300,7 @@ Notes: `{{placeholder}}` is dropped so codex falls back to its configured default. Codex does **not** offer a strict per-tool name whitelist: read-only tool sets map to `-s read-only`; any mutating tool or no list maps to - `-s workspace-write` (never `danger-full-access`). Effective thinking maps + `-s workspace-write` (unrestricted filesystem access is never selected). Effective thinking maps to `model_reasoning_effort`: `off`/`none`/`minimal` → `none`, `low`/`medium`/`high`/`xhigh` pass through, and `max`/`ultra` → `xhigh`. Any other value fails closed before Codex is spawned. Codex usage accounting diff --git a/packages/pi-taskflow/test/fixtures/pi1-provider.ts b/packages/pi-taskflow/test/fixtures/pi1-provider.ts index 8bdbc6a4..eebf35ca 100644 --- a/packages/pi-taskflow/test/fixtures/pi1-provider.ts +++ b/packages/pi-taskflow/test/fixtures/pi1-provider.ts @@ -62,7 +62,7 @@ export default function (pi: ExtensionAPI) { return fauxAssistantMessage("CHILD_OK"); })); pi.registerProvider("taskflow-e2e", { - api: "taskflow-e2e-api", baseUrl: "http://127.0.0.1", apiKey: "synthetic-local-fixture", + api: "taskflow-e2e-api", baseUrl: "http://127.0.0.1", apiKey: handshake, models: [{ id: "fixture", name: "Local fixture", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 4096 }], streamSimple: core.streamSimple, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c49ac378..4904e980 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,11 +6,11 @@ settings: overrides: baseline-browser-mapping: 2.11.0 - brace-expansion: 5.0.9 + brace-expansion: 5.0.12 nanoid: 3.3.18 postcss: 8.5.23 sharp: 0.35.4 - undici: 8.9.0 + undici: 8.10.2 importers: @@ -18,10 +18,10 @@ importers: devDependencies: '@earendil-works/pi-agent-core': specifier: 1.0.0 - version: 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4) + version: 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3)(zod@4.6.4) '@earendil-works/pi-ai': specifier: 1.0.0 - version: 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4) + version: 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3)(zod@4.6.4) '@earendil-works/pi-coding-agent': specifier: 1.0.0 version: 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.4) @@ -128,10 +128,10 @@ importers: devDependencies: '@earendil-works/pi-agent-core': specifier: 1.0.0 - version: 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4) + version: 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3)(zod@4.6.4) '@earendil-works/pi-ai': specifier: 1.0.0 - version: 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4) + version: 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3)(zod@4.6.4) '@earendil-works/pi-coding-agent': specifier: 1.0.0 version: 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.4) @@ -1693,8 +1693,8 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - brace-expansion@5.0.9: - resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} buffer-equal-constant-time@1.0.1: @@ -2462,7 +2462,7 @@ packages: '@aws-sdk/credential-provider-node': '>=3.972.0 <4' '@smithy/hash-node': '>=4.3.0 <5' '@smithy/signature-v4': '>=5.4.0 <6' - undici: 8.9.0 + undici: 8.10.2 ws: ^8.21.0 zod: ^3.25 || ^4.0 peerDependenciesMeta: @@ -2753,8 +2753,8 @@ packages: undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} - undici@8.9.0: - resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==} + undici@8.10.2: + resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} engines: {node: '>=22.19.0'} unified@11.0.5: @@ -3089,9 +3089,9 @@ snapshots: dependencies: esbuild: 0.28.2 - '@earendil-works/pi-agent-core@1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4)': + '@earendil-works/pi-agent-core@1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3)(zod@4.6.4)': dependencies: - '@earendil-works/pi-ai': 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4) + '@earendil-works/pi-ai': 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3)(zod@4.6.4) typebox: 1.3.27 transitivePeerDependencies: - '@aws-sdk/credential-provider-node' @@ -3106,7 +3106,7 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4)': + '@earendil-works/pi-ai@1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3)(zod@4.6.4)': dependencies: '@anthropic-ai/sdk': 0.124.0(zod@4.6.4) '@aws-sdk/client-bedrock-runtime': 3.1127.0 @@ -3115,7 +3115,7 @@ snapshots: '@smithy/node-http-handler': 4.12.1 http-proxy-agent: 9.1.0 https-proxy-agent: 9.1.0 - openai: 7.19.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4) + openai: 7.19.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3)(zod@4.6.4) partial-json: 0.1.7 typebox: 1.3.27 transitivePeerDependencies: @@ -3138,8 +3138,8 @@ snapshots: '@earendil-works/pi-coding-agent@1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.4)': dependencies: '@earendil-works/chord': 1.0.0 - '@earendil-works/pi-agent-core': 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4) - '@earendil-works/pi-ai': 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4) + '@earendil-works/pi-agent-core': 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3)(zod@4.6.4) + '@earendil-works/pi-ai': 1.0.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3)(zod@4.6.4) '@earendil-works/pi-codemode': 1.0.0 '@earendil-works/pi-mcp': 1.0.0 '@earendil-works/pi-tui': 1.0.0 @@ -3157,7 +3157,7 @@ snapshots: quickjs-wasi: 3.6.2 semver: 7.8.5 typebox: 1.3.27 - undici: 8.9.0 + undici: 8.10.2 yaml: 2.9.0 transitivePeerDependencies: - '@aws-sdk/credential-provider-node' @@ -4184,7 +4184,7 @@ snapshots: bowser@2.14.1: {} - brace-expansion@5.0.9: + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 @@ -5199,7 +5199,7 @@ snapshots: minimatch@10.2.6: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 5.0.12 motion-dom@13.0.0: dependencies: @@ -5268,11 +5268,11 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 - openai@7.19.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.9.0)(ws@8.21.3)(zod@4.6.4): + openai@7.19.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3)(zod@4.6.4): optionalDependencies: '@aws-sdk/credential-provider-node': 3.972.83 '@smithy/signature-v4': 5.7.3 - undici: 8.9.0 + undici: 8.10.2 ws: 8.21.3 zod: 4.6.4 @@ -5624,7 +5624,7 @@ snapshots: undici-types@8.9.0: {} - undici@8.9.0: {} + undici@8.10.2: {} unified@11.0.5: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 722922ab..937a2f47 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,13 +17,14 @@ packages: - "website" overrides: baseline-browser-mapping: 2.11.0 - brace-expansion: 5.0.9 + # GHSA-q2hr-2g5m-vwhr and recursion DoS advisories: fixed in 5.0.12. + brace-expansion: 5.0.12 # Dependabot alert #33 / GHSA-2v37-7h3g-55p8: nanoid < 3.3.18 nanoid: 3.3.18 postcss: 8.5.23 sharp: 0.35.4 - # Dependabot high/medium open alerts: undici < 8.9.0 (CRLF, cache, cookie, desync) - undici: 8.9.0 + # September 2026 undici advisories (TLS, WebSocket, retry, cache): fixed in 8.10.2. + undici: 8.10.2 allowBuilds: "@google/genai": true esbuild: true diff --git a/skills-src/taskflow/configuration.md b/skills-src/taskflow/configuration.md index 9eb49146..9715e89b 100644 --- a/skills-src/taskflow/configuration.md +++ b/skills-src/taskflow/configuration.md @@ -314,7 +314,7 @@ Notes: `{{placeholder}}` is dropped so codex falls back to its configured default. Codex does **not** offer a strict per-tool name whitelist: read-only tool sets map to `-s read-only`; any mutating tool or no list maps to - `-s workspace-write` (never `danger-full-access`). Effective thinking maps + `-s workspace-write` (unrestricted filesystem access is never selected). Effective thinking maps to `model_reasoning_effort`: `off`/`none`/`minimal` → `none`, `low`/`medium`/`high`/`xhigh` pass through, and `max`/`ultra` → `xhigh`. Any other value fails closed before Codex is spawned. Codex usage accounting From 170dc8dfb58cad99994e13495bcae045a94495de Mon Sep 17 00:00:00 2001 From: heggria Date: Fri, 2 Oct 2026 16:41:49 +0800 Subject: [PATCH 12/30] docs: finalize Taskflow 1.0 installation and support contract --- README.md | 25 ++++++++++--------- README.zh-CN.md | 22 ++++++++-------- RELEASE.md | 9 ++++--- website/app/[lang]/layout.tsx | 8 +++--- website/app/[lang]/page.tsx | 18 ++++++------- website/app/page.tsx | 4 +-- .../en/compiler-runtime/typescript-dsl.mdx | 2 +- website/content/docs/en/getting-started.mdx | 2 +- .../content/docs/en/guides/claude-code.mdx | 2 +- website/content/docs/en/guides/codex.mdx | 2 +- website/content/docs/en/guides/grok-build.mdx | 2 +- website/content/docs/en/guides/hermes.mdx | 2 +- website/content/docs/en/guides/opencode.mdx | 2 +- website/content/docs/en/guides/pi.mdx | 2 +- website/content/docs/en/index.mdx | 8 +++--- website/content/docs/en/trusted-effects.mdx | 2 +- .../zh-cn/compiler-runtime/typescript-dsl.mdx | 2 +- .../content/docs/zh-cn/getting-started.mdx | 2 +- .../content/docs/zh-cn/guides/claude-code.mdx | 2 +- website/content/docs/zh-cn/guides/codex.mdx | 2 +- .../content/docs/zh-cn/guides/grok-build.mdx | 2 +- website/content/docs/zh-cn/guides/hermes.mdx | 2 +- .../content/docs/zh-cn/guides/opencode.mdx | 2 +- website/content/docs/zh-cn/guides/pi.mdx | 2 +- website/content/docs/zh-cn/index.mdx | 8 +++--- .../content/docs/zh-cn/trusted-effects.mdx | 2 +- 26 files changed, 70 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 8bec36ab..7660c92a 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ **English** · [简体中文](./README.zh-CN.md) -[1.0 overview](#taskflow-03-trusted-effects) · [Quickstart](#quickstart) · [Docs](https://heggria.github.io/taskflow/en/docs) · [Examples](./examples) · [Changelog](./CHANGELOG.md) +[1.0 overview](#taskflow-10-declarative-coding-agent-workflows) · [Quickstart](#quickstart) · [Docs](https://heggria.github.io/taskflow/en/docs) · [Examples](./examples) · [Changelog](./CHANGELOG.md)
@@ -19,9 +19,9 @@ # taskflow 1.0: declarative coding-agent workflows -**taskflow is a declarative runtime for coding-agent workflows.** It turns a graph into a verifiable execution contract, runs phases in isolation, and keeps intermediate work out of the host conversation. In the 1.0 release candidate, the contract also describes the effects a phase is allowed to propose. +**taskflow is a declarative runtime for coding-agent workflows.** It turns a graph into a verifiable execution contract, runs phases in isolation, and keeps intermediate work out of the host conversation. In Taskflow 1.0, the contract also describes the effects a phase is allowed to propose. -> **Status: 1.0.0 release candidate — not yet tagged or published.** The ten public packages stabilize the existing DAG runtime, DSL, six host adapters and resource transactions for admitted declared filesystem targets. Pi interactive approvals are supported; MCP/headless approval auto-rejects. `taskflow-control` is private workspace experimental with proposed wire contracts; CharterArc retains a separate experimental release. The [1.0 release plan](./docs/internal/1.0.0-release-plan.md) defines the required verification and remaining gates. +> **Taskflow 1.0 public contract.** The ten public packages stabilize the existing DAG runtime, DSL, six host adapters and resource transactions for admitted declared filesystem targets. Pi interactive approvals are supported; MCP/headless approval auto-rejects. `taskflow-control` is private workspace experimental with proposed wire contracts; CharterArc retains a separate experimental release. The [1.0 release plan](./docs/internal/1.0.0-release-plan.md) defines the verification gates. Publication status is recorded in [GitHub Releases](https://github.com/heggria/taskflow/releases) and the [npm registry](https://www.npmjs.com/package/pi-taskflow). ## The declared-effect contract @@ -49,9 +49,9 @@ This is **not** an OS sandbox. Resolve-only hosts cannot prevent every write to ## The 1.0 public contract -| Layer | What it does | Candidate status | +| Layer | What it does | Support boundary | |---|---|---| -| **Taskflow runtime** | Declarative DAGs, 12 phase types, budgets, retries, approvals, isolation, resume, replay, trace, and recompute | Stable contract; current acceptance required | +| **Taskflow runtime** | Declarative DAGs, 12 phase types, budgets, retries, approvals, isolation, resume, replay, trace, and recompute | Stable runtime contract | | **Trusted Effects** | Closed `EffectIR`, `PathRef` / `SecretRef` / `ServiceRef`, confidentiality/integrity labels, effect validation, overlap checks, and ledger-backed `why-*` explainers | Declared-target contract | | **Resource transaction** | Snapshot → lease → durable intent/permit → stage → commit, or restore and reject | Declared-target contract | | **Host adapters** | Pi, Codex, Claude Code, OpenCode, Grok Build, and Hermes Agent use the same flow contract | Existing host surface; support remains host-specific | @@ -62,24 +62,25 @@ The [1.0 release plan](./docs/internal/1.0.0-release-plan.md) defines the stable ## Quickstart -The 1.0 candidate can be exercised from this source checkout; exact npm pins become usable after publication. Use Node.js **≥ 22.19.0**: +Use Node.js **≥ 22.19.0**. To run from the 1.0 source checkout: ```bash git clone https://github.com/heggria/taskflow.git cd taskflow -git checkout release/taskflow-1.0 +git checkout v1.0.0 pnpm install pnpm run typecheck pnpm test ``` -The 1.0 commands below become usable after the tag workflow completes; until then they are release-target examples, not proof of registry availability. +Install the host adapter with an exact 1.0 version: + ```bash npm install --global pi-taskflow@1.0.0 npm install --global codex-taskflow@1.0.0 ``` -The host-specific plugin and MCP commands remain in the [host guides](https://heggria.github.io/taskflow/en/docs/guides/). Until publication, use a previously published exact version or the source checkout. +The host-specific plugin and MCP commands remain in the [host guides](https://heggria.github.io/taskflow/en/docs/guides/). Run the no-LLM Trusted Effects vertical-slice fixture: @@ -210,13 +211,13 @@ The monorepo contains the host-neutral `taskflow-core`, Trusted Effects and reso | Start here | Use it for | |---|---| -| [1.0 overview](https://heggria.github.io/taskflow/en/docs) | Candidate scope, status, and the honest security boundary | +| [1.0 overview](https://heggria.github.io/taskflow/en/docs) | 1.0 scope, support, and the security boundary | | [Getting Started](https://heggria.github.io/taskflow/en/docs/getting-started) | First flow and host setup | | [Core Concepts](https://heggria.github.io/taskflow/en/docs/concepts/) | DAGs, isolation, verification, resume, and evidence | | [Compiler & Runtime](https://heggria.github.io/taskflow/en/docs/compiler-runtime/) | JSON, TypeScript DSL, FlowIR, replay, and recompute | | [Host Guides](https://heggria.github.io/taskflow/en/docs/guides/) | Pi, Codex, Claude Code, OpenCode, Grok, and Hermes | | [Examples](./examples) | Runnable flow definitions, including Trusted Effects | -| [Changelog](./CHANGELOG.md) | Release history and candidate notes | +| [Changelog](./CHANGELOG.md) | Release history and version notes | ## License @@ -226,6 +227,6 @@ The monorepo contains the host-neutral `taskflow-core`, Trusted Effects and reso **Declare the effect. Verify the path. Commit through one authority.** -[Read the docs](https://heggria.github.io/taskflow/en/docs) · [Try the candidate](#quickstart) · [View releases](https://github.com/heggria/taskflow/releases) +[Read the docs](https://heggria.github.io/taskflow/en/docs) · [Install Taskflow 1.0](#quickstart) · [View releases](https://github.com/heggria/taskflow/releases) diff --git a/README.zh-CN.md b/README.zh-CN.md index 9a12ec01..4b075838 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -19,9 +19,9 @@ # taskflow 1.0:声明式 coding-agent 工作流 -**taskflow 是面向 coding-agent 工作流的声明式运行时。** 它把任务图变成可验证的执行合同,让阶段隔离运行,并把中间过程留在宿主对话之外。在 1.0 发布候选中,这份合同还可以描述每个阶段被允许提出的副作用。 +**taskflow 是面向 coding-agent 工作流的声明式运行时。** 它把任务图变成可验证的执行合同,让阶段隔离运行,并把中间过程留在宿主对话之外。在 Taskflow 1.0 中,这份合同还可以描述每个阶段被允许提出的副作用。 -> **状态:1.0.0 发布候选——尚未打 tag 或发布。** 十个公开包的稳定范围是已有 DAG runtime、DSL、六个宿主适配器和已准入声明文件目标的 resource transaction。Pi 支持交互审批;MCP/headless 审批自动拒绝。`taskflow-control` 是 private workspace 实验,wire 合同仍为 proposed;CharterArc 保留独立 experimental 发布线。[1.0 发布计划](./docs/internal/1.0.0-release-plan.md)列出全面验证与剩余闸门。 +> **Taskflow 1.0 公开合同。** 十个公开包的稳定范围是已有 DAG runtime、DSL、六个宿主适配器和已准入声明文件目标的 resource transaction。Pi 支持交互审批;MCP/headless 审批自动拒绝。`taskflow-control` 是 private workspace 实验,wire 合同仍为 proposed;CharterArc 保留独立 experimental 发布线。[1.0 发布计划](./docs/internal/1.0.0-release-plan.md)列出验证闸门。实际发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases) 与 [npm registry](https://www.npmjs.com/package/pi-taskflow)。 ## 声明文件副作用合同 @@ -49,9 +49,9 @@ flow / .tf.ts ## 1.0 公开合同 -| 层 | 作用 | candidate 状态 | +| 层 | 作用 | 支持边界 | |---|---|---| -| **Taskflow runtime** | 声明式 DAG、12 种阶段、预算、重试、审批、隔离、续跑、replay、trace 与 recompute | 稳定合同;仍需当前候选验收 | +| **Taskflow runtime** | 声明式 DAG、12 种阶段、预算、重试、审批、隔离、续跑、replay、trace 与 recompute | 稳定 runtime 合同 | | **Trusted Effects** | 封闭的 `EffectIR`、`PathRef` / `SecretRef` / `ServiceRef`、机密性/完整性标签、effect 校验、重叠检查与 ledger-backed `why-*` | 声明目标合同 | | **Resource transaction** | snapshot → lease → durable intent/permit → stage → commit,或 restore and reject | 声明目标合同 | | **宿主适配器** | Pi、Codex、Claude Code、OpenCode、Grok Build、Hermes Agent 共用同一 flow 合同 | 已有宿主表面;能力仍按宿主区分 | @@ -62,25 +62,25 @@ flow / .tf.ts ## 快速开始 -1.0 候选可从源码 checkout 运行;精确 npm pin 在发布完成后可用。准备 Node.js **≥ 22.19.0**: +准备 Node.js **≥ 22.19.0**。从 1.0 源码 checkout 运行: ```bash git clone https://github.com/heggria/taskflow.git cd taskflow -git checkout release/taskflow-1.0 +git checkout v1.0.0 pnpm install pnpm run typecheck pnpm test ``` -以下 1.0 命令在 tag workflow 完成后可用;在此之前它们只是发版目标示例,不代表 registry 已可获取。 +使用精确 1.0 版本安装宿主适配器: ```bash npm install --global pi-taskflow@1.0.0 npm install --global codex-taskflow@1.0.0 ``` -各宿主的 plugin 与 MCP 命令见[宿主指南](https://heggria.github.io/taskflow/zh-cn/docs/guides/)。发布之前可使用已有的精确版本,或源码 checkout。 +各宿主的 plugin 与 MCP 命令见[宿主指南](https://heggria.github.io/taskflow/zh-cn/docs/guides/)。也可从源码 checkout 运行。 运行不需要 LLM 的 Trusted Effects vertical-slice fixture: @@ -211,13 +211,13 @@ pnpm run test:pack | 从这里开始 | 适用场景 | |---|---| -| [1.0 总览](https://heggria.github.io/taskflow/zh-cn/docs) | candidate 范围、状态与诚实的安全边界 | +| [1.0 总览](https://heggria.github.io/taskflow/zh-cn/docs) | 1.0 范围、支持与安全边界 | | [快速开始](https://heggria.github.io/taskflow/zh-cn/docs/getting-started) | 第一个 flow 与宿主配置 | | [核心概念](https://heggria.github.io/taskflow/zh-cn/docs/concepts/) | DAG、隔离、验证、续跑与 evidence | | [编译器与运行时](https://heggria.github.io/taskflow/zh-cn/docs/compiler-runtime/) | JSON、TypeScript DSL、FlowIR、replay 与 recompute | | [宿主指南](https://heggria.github.io/taskflow/zh-cn/docs/guides/) | Pi、Codex、Claude Code、OpenCode、Grok 与 Hermes | | [示例](./examples) | 可运行的 flow 定义,包括 Trusted Effects | -| [变更记录](./CHANGELOG.md) | 发布历史与 candidate 说明 | +| [变更记录](./CHANGELOG.md) | 发布历史与版本说明 | ## 许可证 @@ -227,6 +227,6 @@ pnpm run test:pack **声明 effect。验证路径。让一个 authority 负责提交。** -[阅读文档](https://heggria.github.io/taskflow/zh-cn/docs) · [试用 candidate](#快速开始) · [查看 releases](https://github.com/heggria/taskflow/releases) +[阅读文档](https://heggria.github.io/taskflow/zh-cn/docs) · [安装 Taskflow 1.0](#快速开始) · [查看 releases](https://github.com/heggria/taskflow/releases) diff --git a/RELEASE.md b/RELEASE.md index 712b0bb4..45e35de4 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,8 +1,9 @@ # Taskflow 1.0 release guide -Taskflow `1.0.0` is a release candidate in this checkout. Preparing or merging -this PR does not publish it. Publish only after the gates below pass on the -final release commit and the release owner authorizes the tag. +This guide coordinates the ten-package Taskflow `1.0.0` release. Preparing +or merging a release change does not publish it. Publish only after the gates +below pass on the final release commit and the release owner authorizes the +tag. GitHub Releases and the npm registry record the completed publication. ## Public contract and package set @@ -127,7 +128,7 @@ missing adapter. The legacy beta dist-tag promotion workflow is not needed. ## Install and verify after publication -These are release-target examples until the tag workflow completes: +Use exact 1.0 pins for host installation, then verify the installed version: ```sh pi install npm:pi-taskflow@1.0.0 diff --git a/website/app/[lang]/layout.tsx b/website/app/[lang]/layout.tsx index 1a10cc55..151beaad 100644 --- a/website/app/[lang]/layout.tsx +++ b/website/app/[lang]/layout.tsx @@ -10,16 +10,16 @@ export function generateStaticParams() { const site = { en: { - title: "taskflow 1.0.0 — Taskflow release candidate", + title: "taskflow 1.0.0 — Declarative Coding-Agent Workflows", brand: "taskflow", description: - "Declare coding-agent effects, verify typed paths, and commit admitted filesystem changes through one resource authority. 1.0.0; release candidate; not yet published.", + "Declare coding-agent effects, verify typed paths, and commit admitted filesystem changes through one resource authority. Taskflow 1.0 public contract.", }, "zh-cn": { - title: "taskflow 1.0.0 — Taskflow release candidate", + title: "taskflow 1.0.0 — Declarative Coding-Agent Workflows", brand: "taskflow", description: - "声明 coding-agent effect,验证类型化路径,让已准入的文件修改经过唯一 resource authority。1.0.0,发布候选,尚未发布。", + "声明 coding-agent effect,验证类型化路径,让已准入的文件修改经过唯一 resource authority。Taskflow 1.0 公开合同。", }, } as const; diff --git a/website/app/[lang]/page.tsx b/website/app/[lang]/page.tsx index c7612a39..b94d9058 100644 --- a/website/app/[lang]/page.tsx +++ b/website/app/[lang]/page.tsx @@ -25,14 +25,14 @@ const copy = { localeZh: "中文", }, hero: { - eyebrow: "taskflow 1.0.0 · Taskflow release candidate", + eyebrow: "taskflow 1.0.0 · Declarative workflows", title: [ "Declare the effect.", "Verify the path.", "Commit through one authority.", ], sub: "taskflow turns coding-agent work into a verifiable runtime: explicit graphs, typed effect declarations, isolated execution, resource-controlled filesystem commits, and ledger-backed explanations across six hosts.", - noteKicker: "1.0.0 · release candidate · not yet published", + noteKicker: "1.0.0 · Public contract and support boundaries", noteBody: "An agent can propose content. For admitted declared targets, the resources transaction is the only finalizer. ControlHost has experimental Unix UDS/store code; admission, parked approvals, receipts, global coordination and WebUI remain outside the stable API.", micro: @@ -78,14 +78,14 @@ const copy = { ], }, install: { - label: "1.0.0 host installs — available after publication.", + label: "1.0.0 host installation commands.", copy: "Copy", copied: "Copied", guide: "Guide", }, capabilities: { title: "A mutation boundary, not a prompt promise.", - sub: "The candidate makes side effects explicit without claiming a sandbox it does not have.", + sub: "Taskflow 1.0 makes side effects explicit without claiming a sandbox it does not have.", items: [ { title: "Declare", @@ -187,10 +187,10 @@ const copy = { localeZh: "中文", }, hero: { - eyebrow: "taskflow 1.0.0 · Taskflow release candidate", + eyebrow: "taskflow 1.0.0 · Declarative workflows", title: ["声明 effect。", "验证路径。", "让一个 authority 负责提交。"], sub: "taskflow 把 coding-agent 工作变成可验证的运行时:显式任务图、类型化 effect 声明、隔离执行、受 resources 控制的文件提交,以及覆盖六个宿主的 ledger-backed 解释。", - noteKicker: "1.0.0 · 发布候选 · 尚未发布", + noteKicker: "1.0.0 · 公开合同与支持边界", noteBody: "智能体可以提出内容。对于已准入的已声明目标,resources transaction 是唯一最终提交者。ControlHost 有 private workspace Unix UDS/store 代码;admission、parked 审批、receipt、全局协调与 WebUI 不属于稳定 API。", micro: "Resolve-only 不是 OS sandbox。未声明写入仍取决于宿主策略。", @@ -235,14 +235,14 @@ const copy = { ], }, install: { - label: "1.0.0 宿主安装;发布完成后可用。", + label: "1.0.0 宿主安装指引。", copy: "复制", copied: "已复制", guide: "指南", }, capabilities: { title: "这是修改边界,不是 prompt 承诺。", - sub: "candidate 把副作用显式化,但不声称不存在的 sandbox。", + sub: "Taskflow 1.0 把副作用显式化,但不声称不存在的 sandbox。", items: [ { title: "声明", @@ -497,7 +497,7 @@ export default async function HomePage({
-

taskflow 1.0 · release candidate

+

taskflow 1.0 · declarative workflows

{t.cta.title}

{t.cta.body}

diff --git a/website/app/page.tsx b/website/app/page.tsx index 1ee32bca..5ca9d886 100644 --- a/website/app/page.tsx +++ b/website/app/page.tsx @@ -1,8 +1,8 @@ import type { Metadata } from "next"; -const title = "taskflow 0.3 — Trusted Effects for Coding Agents"; +const title = "taskflow 1.0 — Trusted Effects for Coding Agents"; const description = - "Declare coding-agent effects, verify typed paths, and commit admitted filesystem changes through one resource authority. 1.0.0; release candidate; not yet published."; + "Declare coding-agent effects, verify typed paths, and commit admitted filesystem changes through one resource authority. Taskflow 1.0 public contract."; const canonical = "https://heggria.github.io/taskflow/en/"; export const metadata: Metadata = { diff --git a/website/content/docs/en/compiler-runtime/typescript-dsl.mdx b/website/content/docs/en/compiler-runtime/typescript-dsl.mdx index 7a62287d..484a54ee 100644 --- a/website/content/docs/en/compiler-runtime/typescript-dsl.mdx +++ b/website/content/docs/en/compiler-runtime/typescript-dsl.mdx @@ -8,7 +8,7 @@ description: Compile-time .tf.ts authoring — erase runes to Taskflow JSON, the S4 adds a **compile-time** TypeScript frontend. You author `*.tf.ts` with **runes** (`agent`, `map`, `race`, …). A CLI erases them to ordinary Taskflow JSON. Hosts still run **JSON** via `taskflow_run` / `/tf run` — there is **no** interpret path and **no** host auto-build of `.tf.ts`. - **Package status.** `taskflow-dsl` lives in the monorepo (`packages/taskflow-dsl`). It is **not** required for JSON authors. Package manifests target stable `1.0.0`; after publication, install with `npm install taskflow-dsl@1.0.0`, or use a workspace / local path from this monorepo. + **Package status.** `taskflow-dsl` lives in the monorepo (`packages/taskflow-dsl`). It is **not** required for JSON authors. For Taskflow 1.0, install with `npm install taskflow-dsl@1.0.0`, or use a workspace / local path from this monorepo. ## Workflow diff --git a/website/content/docs/en/getting-started.mdx b/website/content/docs/en/getting-started.mdx index f8b0e3ad..74a7ec3c 100644 --- a/website/content/docs/en/getting-started.mdx +++ b/website/content/docs/en/getting-started.mdx @@ -6,7 +6,7 @@ description: Run your first taskflow in under five minutes. taskflow lets you describe multi-step agent work as a declarative graph. Instead of writing a script that calls subagents one by one, you declare the nodes and edges — and the runtime handles fan-out, retries, caching, and resume. - This guide targets **1.0.0**. Exact installation pins become available after the release workflow succeeds; this checkout is still a release candidate. See the [Trusted Effects overview](/en/docs/trusted-effects) for filesystem-transaction boundaries. + This guide uses exact **1.0.0** installation pins. See [GitHub Releases](https://github.com/heggria/taskflow/releases) for publication status. See the [Trusted Effects overview](/en/docs/trusted-effects) for filesystem-transaction boundaries. The fastest way to see it is to run something. diff --git a/website/content/docs/en/guides/claude-code.mdx b/website/content/docs/en/guides/claude-code.mdx index db647fd3..eb15a2ac 100644 --- a/website/content/docs/en/guides/claude-code.mdx +++ b/website/content/docs/en/guides/claude-code.mdx @@ -3,7 +3,7 @@ title: Using taskflow on Claude Code description: Install taskflow as a Claude Code plugin and orchestrate multi-phase workflows via MCP. --- -> **1.0.0 release target.** The exact npm pins below become available after publication. This checkout is still a release candidate. +> **Taskflow 1.0 installation.** Use the exact npm pins below. Publication status is recorded in [GitHub Releases](https://github.com/heggria/taskflow/releases). [Claude Code](https://claude.com/product/claude-code) is a natural home for taskflow: it already thinks in terms of subagents, and taskflow gives those subagents a declarative graph to run inside. The plugin registers the MCP server plus a routing skill; its protocol layer needs no MCP SDK. diff --git a/website/content/docs/en/guides/codex.mdx b/website/content/docs/en/guides/codex.mdx index f130eb78..4dccf80e 100644 --- a/website/content/docs/en/guides/codex.mdx +++ b/website/content/docs/en/guides/codex.mdx @@ -3,7 +3,7 @@ title: Using taskflow on Codex description: Install taskflow as a Codex plugin and orchestrate multi-phase workflows via MCP. --- -> **1.0.0 release target.** The exact npm pins below become available after publication. This checkout is still a release candidate. +> **Taskflow 1.0 installation.** Use the exact npm pins below. Publication status is recorded in [GitHub Releases](https://github.com/heggria/taskflow/releases). [Codex](https://github.com/openai/codex) is a natural home for taskflow: it already thinks in terms of subagents, and taskflow gives those subagents a declarative graph to run inside. On Codex, taskflow ships as a plugin that registers an MCP server plus a routing skill, so the model can call it the moment it sees a multi-step task. diff --git a/website/content/docs/en/guides/grok-build.mdx b/website/content/docs/en/guides/grok-build.mdx index b697ae4a..b5b9e3d4 100644 --- a/website/content/docs/en/guides/grok-build.mdx +++ b/website/content/docs/en/guides/grok-build.mdx @@ -3,7 +3,7 @@ title: Using taskflow on Grok Build description: Install taskflow as a Grok Build plugin and orchestrate multi-phase workflows via MCP. --- -> **1.0.0 release target.** The exact npm pins below become available after publication. This checkout is still a release candidate. +> **Taskflow 1.0 installation.** Use the exact npm pins below. Publication status is recorded in [GitHub Releases](https://github.com/heggria/taskflow/releases). [Grok Build](https://docs.x.ai/build/overview) is xAI's terminal coding agent. Taskflow's published path is its MCP server; a plugin scaffold is also available in the repository checkout. diff --git a/website/content/docs/en/guides/hermes.mdx b/website/content/docs/en/guides/hermes.mdx index a3df0d4e..8406f331 100644 --- a/website/content/docs/en/guides/hermes.mdx +++ b/website/content/docs/en/guides/hermes.mdx @@ -3,7 +3,7 @@ title: Using taskflow on Hermes Agent description: Install taskflow as an MCP server on Hermes Agent and orchestrate multi-phase workflows with isolated Hermes subagents. --- -> **1.0.0 release target.** The exact npm pins below become available after publication. This checkout is still a release candidate. +> **Taskflow 1.0 installation.** Use the exact npm pins below. Publication status is recorded in [GitHub Releases](https://github.com/heggria/taskflow/releases). [Hermes Agent](https://hermes-agent.nousresearch.com/) is Nous Research's open-source coding agent (CLI, TUI, desktop, messaging gateways). Taskflow's published path is its MCP server: Hermes discovers `taskflow_*` tools and each agent phase spawns an isolated `hermes chat -q -Q` child. diff --git a/website/content/docs/en/guides/opencode.mdx b/website/content/docs/en/guides/opencode.mdx index 494c3bab..e9b4d714 100644 --- a/website/content/docs/en/guides/opencode.mdx +++ b/website/content/docs/en/guides/opencode.mdx @@ -3,7 +3,7 @@ title: Using taskflow on OpenCode description: Register taskflow as an MCP server on OpenCode and orchestrate multi-phase workflows. --- -> **1.0.0 release target.** The exact npm pins below become available after publication. This checkout is still a release candidate. +> **Taskflow 1.0 installation.** Use the exact npm pins below. Publication status is recorded in [GitHub Releases](https://github.com/heggria/taskflow/releases). [OpenCode](https://opencode.ai) exposes taskflow through an MCP server whose protocol layer has no MCP SDK dependency; each phase runs as an isolated `opencode run` process. diff --git a/website/content/docs/en/guides/pi.mdx b/website/content/docs/en/guides/pi.mdx index 32adcb65..cc6d391e 100644 --- a/website/content/docs/en/guides/pi.mdx +++ b/website/content/docs/en/guides/pi.mdx @@ -3,7 +3,7 @@ title: Using taskflow on Pi description: Install, run, save, and resume taskflows on the Pi coding agent. --- -> **1.0.0 release target.** The exact npm pins below become available after publication. This checkout is still a release candidate. +> **Taskflow 1.0 installation.** Use the exact npm pins below. Publication status is recorded in [GitHub Releases](https://github.com/heggria/taskflow/releases). If you already use [Pi](https://pi.dev) to delegate work to subagents, taskflow turns those one-off delegations into named, resumable, multi-step workflows. You describe the work as a graph — fan-out, gates, loops, whatever you need — and only the final result comes back into your context. diff --git a/website/content/docs/en/index.mdx b/website/content/docs/en/index.mdx index 43e61ecb..4fd4bf42 100644 --- a/website/content/docs/en/index.mdx +++ b/website/content/docs/en/index.mdx @@ -3,7 +3,7 @@ title: taskflow 1.0 Documentation description: "Trusted Effects for coding-agent workflows: declare effects, verify typed paths, and commit admitted filesystem changes through one resource authority." --- -> **1.0.0 release candidate — not yet published.** The stable scope is the mature DAG runtime, DSL, six host adapters and admitted declared-filesystem transactions. Control Plane wire contracts remain proposed and workspace-only; parked approvals, receipts, global coordination and WebUI are excluded. +> **Taskflow 1.0 public contract.** The stable scope is the mature DAG runtime, DSL, six host adapters and admitted declared-filesystem transactions. Control Plane wire contracts remain proposed and workspace-only; parked approvals, receipts, global coordination and WebUI are excluded. taskflow is a declarative runtime for coding-agent workflows. It turns a graph into a verifiable execution contract, runs phases in isolation, and keeps intermediate transcripts out of the host conversation. The 1.0 contract includes **Trusted Effects**: a typed declaration and resource-controlled commit path for admitted filesystem effects. @@ -47,10 +47,10 @@ taskflow is a declarative runtime for coding-agent workflows. It turns a graph i ## Install on your host -Install `@1.0.0` after the release workflow completes. Until then, use the reviewed source checkout or a previously published exact version. These examples do not prove npm availability. +Install an exact `@1.0.0` version. Publication status is recorded in [GitHub Releases](https://github.com/heggria/taskflow/releases) and the [npm registry](https://www.npmjs.com/package/pi-taskflow). - Every taskflow package requires **Node.js 22.19.0 or newer**. The candidate's source and fixture checks use pnpm from the repository checkout. + Every taskflow package requires **Node.js 22.19.0 or newer**. Source and fixture checks use pnpm from the repository checkout. @@ -107,7 +107,7 @@ Read the [MVP freeze in the repository](https://github.com/heggria/taskflow/blob Runnable flow definitions, including the Trusted Effects write fixture. - Candidate notes, release boundaries, and the 0.2 history. + Version notes, release boundaries, and the 0.2 history. Source, issues, CI, and contribution workflow. diff --git a/website/content/docs/en/trusted-effects.mdx b/website/content/docs/en/trusted-effects.mdx index bf0e5661..60025dbc 100644 --- a/website/content/docs/en/trusted-effects.mdx +++ b/website/content/docs/en/trusted-effects.mdx @@ -2,7 +2,7 @@ title: Trusted Effects --- -> **1.0.0 release candidate — not yet published.** The stable scope is the mature DAG runtime, DSL, six host adapters and admitted declared-filesystem transactions. Control Plane wire contracts remain proposed and workspace-only; parked approvals, receipts, global coordination and WebUI are excluded. +> **Taskflow 1.0 public contract.** The stable scope is the mature DAG runtime, DSL, six host adapters and admitted declared-filesystem transactions. Control Plane wire contracts remain proposed and workspace-only; parked approvals, receipts, global coordination and WebUI are excluded. Trusted Effects makes a phase's side effects explicit. The model may propose content, but for an admitted declared filesystem target, the **resources transaction is the only finalizer**. diff --git a/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx b/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx index aaef8acc..9fb5a20b 100644 --- a/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx +++ b/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx @@ -8,7 +8,7 @@ description: 编译期 .tf.ts 写法 —— rune erase 成 Taskflow JSON,再 S4 增加**编译期** TypeScript 前端:用 rune(`agent`、`map`、`race`…)写 `*.tf.ts`,CLI erase 成普通 Taskflow JSON。宿主仍通过 `taskflow_run` / `/tf run` 跑 **JSON**——**没有**解释执行路径,也**没有**宿主对 `.tf.ts` 的自动 build。 - **Package status.** `taskflow-dsl` lives in the monorepo (`packages/taskflow-dsl`). It is **not** required for JSON authors. Package manifests target stable `1.0.0`; after publication, install with `npm install taskflow-dsl@1.0.0`, or use a workspace / local path from this monorepo. + **Package status.** `taskflow-dsl` lives in the monorepo (`packages/taskflow-dsl`). It is **not** required for JSON authors. For Taskflow 1.0, install with `npm install taskflow-dsl@1.0.0`, or use a workspace / local path from this monorepo. ## 工作流 diff --git a/website/content/docs/zh-cn/getting-started.mdx b/website/content/docs/zh-cn/getting-started.mdx index 6ee6b16a..692b5f9c 100644 --- a/website/content/docs/zh-cn/getting-started.mdx +++ b/website/content/docs/zh-cn/getting-started.mdx @@ -6,7 +6,7 @@ description: 五分钟内运行你的第一个 taskflow。 taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不需要写一个一个调用子代理的脚本,只需声明节点和边——运行时会替你处理 fan-out、重试、缓存和续跑。 - 本指南面向 **1.0.0**。精确安装 pin 在发布流程成功后可用;当前 checkout 仍是发布候选。声明文件事务边界见 [Trusted Effects 总览](/zh-cn/docs/trusted-effects)。 + 本指南使用精确 **1.0.0** 安装 pin。实际发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases)。声明文件事务边界见 [Trusted Effects 总览](/zh-cn/docs/trusted-effects)。 要最快地感受它,先跑一个看看。 diff --git a/website/content/docs/zh-cn/guides/claude-code.mdx b/website/content/docs/zh-cn/guides/claude-code.mdx index abb47e82..dd570885 100644 --- a/website/content/docs/zh-cn/guides/claude-code.mdx +++ b/website/content/docs/zh-cn/guides/claude-code.mdx @@ -3,7 +3,7 @@ title: 在 Claude Code 上使用 taskflow description: 将 taskflow 作为 Claude Code 插件安装,通过 MCP 编排多阶段工作流。 --- -> **1.0.0 发布目标。** 下列精确 npm pin 在发布完成后可用;当前 checkout 仍是发布候选。 +> **Taskflow 1.0 安装。** 使用下列精确 npm pin;实际发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases)。 [Claude Code](https://claude.com/product/claude-code) 是 taskflow 的天然宿主:它本身就以 subagent 的方式思考,而 taskflow 给这些 subagent 提供了一个声明式图来运行。插件注册 MCP server 与路由 skill;协议层不依赖 MCP SDK。 diff --git a/website/content/docs/zh-cn/guides/codex.mdx b/website/content/docs/zh-cn/guides/codex.mdx index ea978e91..55d932ef 100644 --- a/website/content/docs/zh-cn/guides/codex.mdx +++ b/website/content/docs/zh-cn/guides/codex.mdx @@ -3,7 +3,7 @@ title: 在 Codex 上使用 taskflow description: 把 taskflow 作为 Codex 插件安装,通过 MCP 编排多阶段工作流。 --- -> **1.0.0 发布目标。** 下列精确 npm pin 在发布完成后可用;当前 checkout 仍是发布候选。 +> **Taskflow 1.0 安装。** 使用下列精确 npm pin;实际发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases)。 [Codex](https://github.com/openai/codex) 是 taskflow 的天然归宿:它已经在用子代理的方式思考,而 taskflow 给那些子代理一个声明式图来在里面运行。在 Codex 上,taskflow 以插件形式发布,注册一个 MCP 服务器加一个路由 skill,于是模型一看到多步骤任务就能调用它。 diff --git a/website/content/docs/zh-cn/guides/grok-build.mdx b/website/content/docs/zh-cn/guides/grok-build.mdx index 00441897..e4e99f71 100644 --- a/website/content/docs/zh-cn/guides/grok-build.mdx +++ b/website/content/docs/zh-cn/guides/grok-build.mdx @@ -3,7 +3,7 @@ title: 在 Grok Build 上使用 taskflow description: 将 taskflow 作为 Grok Build 插件安装,通过 MCP 编排多阶段工作流。 --- -> **1.0.0 发布目标。** 下列精确 npm pin 在发布完成后可用;当前 checkout 仍是发布候选。 +> **Taskflow 1.0 安装。** 使用下列精确 npm pin;实际发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases)。 [Grok Build](https://docs.x.ai/build/overview) 是 xAI 的终端编码 agent。Taskflow 的正式发布路径是 MCP server;仓库 checkout 另带 plugin scaffold。 diff --git a/website/content/docs/zh-cn/guides/hermes.mdx b/website/content/docs/zh-cn/guides/hermes.mdx index 836f8e73..fba47b3b 100644 --- a/website/content/docs/zh-cn/guides/hermes.mdx +++ b/website/content/docs/zh-cn/guides/hermes.mdx @@ -3,7 +3,7 @@ title: 在 Hermes Agent 上使用 taskflow description: 在 Hermes Agent 上通过 MCP 安装 taskflow,用隔离的 Hermes 子代理编排多阶段工作流。 --- -> **1.0.0 发布目标。** 下列精确 npm pin 在发布完成后可用;当前 checkout 仍是发布候选。 +> **Taskflow 1.0 安装。** 使用下列精确 npm pin;实际发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases)。 [Hermes Agent](https://hermes-agent.nousresearch.com/) 是 Nous Research 的开源 coding agent(CLI / TUI / 桌面 / 消息网关)。taskflow 的发布路径是 MCP server:Hermes 发现 `taskflow_*` 工具,每个 agent phase 会拉起隔离的 `hermes chat -q -Q` 子进程。 diff --git a/website/content/docs/zh-cn/guides/opencode.mdx b/website/content/docs/zh-cn/guides/opencode.mdx index a35c8c1c..9d61c671 100644 --- a/website/content/docs/zh-cn/guides/opencode.mdx +++ b/website/content/docs/zh-cn/guides/opencode.mdx @@ -3,7 +3,7 @@ title: 在 OpenCode 上使用 taskflow description: 在 OpenCode 上将 taskflow 注册为 MCP 服务器,编排多阶段工作流。 --- -> **1.0.0 发布目标。** 下列精确 npm pin 在发布完成后可用;当前 checkout 仍是发布候选。 +> **Taskflow 1.0 安装。** 使用下列精确 npm pin;实际发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases)。 [OpenCode](https://opencode.ai) 通过协议层不依赖 MCP SDK 的 MCP server 暴露 taskflow;每个阶段作为隔离的 `opencode run` 进程运行。 diff --git a/website/content/docs/zh-cn/guides/pi.mdx b/website/content/docs/zh-cn/guides/pi.mdx index a80f4a08..c8e0bb9c 100644 --- a/website/content/docs/zh-cn/guides/pi.mdx +++ b/website/content/docs/zh-cn/guides/pi.mdx @@ -3,7 +3,7 @@ title: 在 Pi 上使用 taskflow description: 在 Pi 编程智能体上安装、运行、保存和续跑 taskflow。 --- -> **1.0.0 发布目标。** 下列精确 npm pin 在发布完成后可用;当前 checkout 仍是发布候选。 +> **Taskflow 1.0 安装。** 使用下列精确 npm pin;实际发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases)。 如果你已经在用 [Pi](https://pi.dev) 把工作委托给子代理,taskflow 把这些一次性的委托变成有名字、可续跑、多步骤的工作流。你把工作描述成一个图——fan-out、gate、loop,你需要什么就写什么——只有最终结果回到你的上下文里。 diff --git a/website/content/docs/zh-cn/index.mdx b/website/content/docs/zh-cn/index.mdx index 914f6c44..86409522 100644 --- a/website/content/docs/zh-cn/index.mdx +++ b/website/content/docs/zh-cn/index.mdx @@ -3,7 +3,7 @@ title: taskflow 1.0 文档 description: "面向 coding-agent 工作流的 Trusted Effects:声明 effect,验证类型化路径,让已准入的文件修改经过唯一 resource authority。" --- -> **1.0.0 发布候选——尚未发布。** 稳定范围是成熟 DAG runtime、DSL、六宿主适配器和已准入的声明文件事务。Control Plane wire 合同仍为 proposed,只在 workspace 实验;parked 审批、receipt、全局协调与 WebUI 不属于稳定 API。 +> **Taskflow 1.0 公开合同。** 稳定范围是成熟 DAG runtime、DSL、六宿主适配器和已准入的声明文件事务。Control Plane wire 合同仍为 proposed,只在 workspace 实验;parked 审批、receipt、全局协调与 WebUI 不属于稳定 API。 taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图变成可验证的执行合同,让阶段隔离运行,并把中间 transcript 留在宿主对话之外。1.0 合同包含 **Trusted Effects**:为已准入的文件 effect 提供类型化声明与受 resources 控制的提交路径。 @@ -47,10 +47,10 @@ taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图 ## 在你的宿主上使用 -发布流程完成后使用 `@1.0.0`。发布前可使用已审查的源码 checkout 或已有的精确版本;以下示例不代表 npm 已发布。 +使用精确 `@1.0.0` 版本安装。实际发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases) 与 [npm registry](https://www.npmjs.com/package/pi-taskflow)。 - 所有 taskflow package 都要求 **Node.js 22.19.0 或更高版本**。Candidate 的源码与 fixture 检查使用仓库内的 pnpm。 + 所有 taskflow package 都要求 **Node.js 22.19.0 或更高版本**。源码与 fixture 检查使用仓库内的 pnpm。 @@ -107,7 +107,7 @@ taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图 可运行的 flow 定义,包括 Trusted Effects 文件写入 fixture。 - Candidate 说明、发布边界与 0.2 历史。 + 版本说明、发布边界与 0.2 历史。 源码、issue、CI 与贡献流程。 diff --git a/website/content/docs/zh-cn/trusted-effects.mdx b/website/content/docs/zh-cn/trusted-effects.mdx index 56933aca..f3e1c684 100644 --- a/website/content/docs/zh-cn/trusted-effects.mdx +++ b/website/content/docs/zh-cn/trusted-effects.mdx @@ -2,7 +2,7 @@ title: Trusted Effects --- -> **1.0.0 发布候选——尚未发布。** 稳定范围是成熟 DAG runtime、DSL、六宿主适配器和已准入的声明文件事务。Control Plane wire 合同仍为 proposed,只在 workspace 实验;parked 审批、receipt、全局协调与 WebUI 不属于稳定 API。 +> **Taskflow 1.0 公开合同。** 稳定范围是成熟 DAG runtime、DSL、六宿主适配器和已准入的声明文件事务。Control Plane wire 合同仍为 proposed,只在 workspace 实验;parked 审批、receipt、全局协调与 WebUI 不属于稳定 API。 Trusted Effects 把阶段的副作用写进合同。模型可以提出内容,但对于已准入的已声明文件目标,**resources transaction 是唯一最终提交者**。 From 5dec238ae3fcdbf3c9a7d468b513778da1fbb187 Mon Sep 17 00:00:00 2001 From: heggria Date: Fri, 2 Oct 2026 23:00:30 +0800 Subject: [PATCH 13/30] fix(control): harden transport and preserve uncertain execution state --- README.md | 2 +- README.zh-CN.md | 2 +- RELEASE.md | 7 +- docs/internal/0.3-c-control-plane-plan.md | 2 + docs/internal/1.0.0-ga-scoreboard.md | 27 +- docs/internal/1.0.0-release-plan.md | 12 +- packages/taskflow-control/src/store/store.ts | 29 +- packages/taskflow-control/src/te-provider.ts | 25 +- packages/taskflow-control/src/uds.ts | 241 ++++++++------ .../test/control-host.test.ts | 79 +++++ .../test/fixtures/uds-malformed-client.ts | 22 ++ .../test/fixtures/uds-malformed-server.ts | 6 +- .../test/fixtures/uds-wire-client.ts | 61 ++++ packages/taskflow-control/test/store.test.ts | 104 +++++- .../taskflow-control/test/te-provider.test.ts | 39 +++ .../taskflow-control/test/uds-client.test.ts | 45 ++- .../test/uds-malformed.test.ts | 156 ++++++++- .../test/uds-wire-hardening.test.ts | 297 ++++++++++++++++++ 18 files changed, 1021 insertions(+), 135 deletions(-) create mode 100644 packages/taskflow-control/test/fixtures/uds-malformed-client.ts create mode 100644 packages/taskflow-control/test/fixtures/uds-wire-client.ts create mode 100644 packages/taskflow-control/test/uds-wire-hardening.test.ts diff --git a/README.md b/README.md index 7660c92a..b5848f5c 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ Host support is not a blanket security guarantee. Read the [host support baselin - Writes to undeclared paths remain host-policy dependent under resolve-only execution. - `SecretRef` and `ServiceRef` are typed handles only; no vault or live service adapter ships in this cut. - There is no FileBroker or full OS sandbox claim in 0.3 MVP. -- Control Plane admission, parked approvals, receipts, global coordination and WebUI are incomplete workspace experiments, outside the 1.0 stable API. +- Control Plane admission, authorized command-result replay, parked approvals, receipts, global coordination and WebUI remain incomplete. Publication of 1.0 is blocked pending closure of the accepted specifications. Duplicate control commands fail closed until verified caller identity and live disclosure authorization exist; request audit fields do not grant access. ## Development diff --git a/README.zh-CN.md b/README.zh-CN.md index 4b075838..9531c781 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -192,7 +192,7 @@ validate → Taskflow JSON → FlowIR + content hash - 在 resolve-only 执行下,未声明路径的写入仍取决于宿主策略。 - `SecretRef` 与 `ServiceRef` 只是类型化句柄;这一版没有 vault 或 live service adapter。 - 0.3 MVP 不声称提供 FileBroker 或完整 OS sandbox。 -- Control Plane admission、parked 审批、receipt、全局协调与 WebUI 尚未完成,不属于 1.0 稳定 API。 +- Control Plane admission、经授权的命令结果重放、parked 审批、receipt、全局协调与 WebUI 尚未完成;1.0 发布等待已接受 spec 的实现与验收闭环。可信调用者身份与实时披露授权就绪前,重复控制命令会被拒绝;请求中的审计字段不能授予访问权限。 ## 开发 diff --git a/RELEASE.md b/RELEASE.md index 45e35de4..c0c028f6 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -37,11 +37,12 @@ from the repository, separately from their npm delivery packages. `taskflow-control` is private, workspace-only and experimental. Its Unix UDS and store implementation does not constitute a complete control plane: -BoundPlan admission, global concurrency, parked approvals/CAS/receipts, +BoundPlan admission, authorized command-result replay, global concurrency, parked approvals/CAS/receipts, registry coordination, Windows named pipes and WebUI are incomplete. CharterArc retains its separate experimental version/tag/workflow and is -excluded from the ten-package transaction. Neither experiment is a Taskflow -1.0 stable API. Resolve-only path checks are not an OS sandbox; undeclared +excluded from the ten-package transaction. These packaging boundaries do not +waive accepted specifications: 1.0 publication remains blocked on their +implementation and acceptance. Resolve-only path checks are not an OS sandbox; undeclared writes remain host-policy dependent. SecretRef/ServiceRef have no live vault/service backend. High-scale journals and full kernel parity are not claimed; the optional event kernel still falls back for unsupported features. diff --git a/docs/internal/0.3-c-control-plane-plan.md b/docs/internal/0.3-c-control-plane-plan.md index cb68475b..05416e46 100644 --- a/docs/internal/0.3-c-control-plane-plan.md +++ b/docs/internal/0.3-c-control-plane-plan.md @@ -5,6 +5,8 @@ > **Base:** `rc/0.3.0-trusted-effects` @ sync 0.2.10 + TE 闭环 > **Design source:** Control Plane RFC v7.6(archive `backup/0.3-archive/mac-feat-0.3.0-control-plane`),**概念重写,不搬代码** +> **当前实现边界:** `taskflow-control` 是 private workspace 实验;上述计划不是完成状态。[P12 ADR](./p-adrs/P12-command-batch-reauth.md) 已 Accepted,规定 command batch 与 re-auth disclosure 的合同;合同接受不等于功能已实现。`commands.submit` 目前只原子追加 CommandRecord 与 ControlEvents,不执行 TE。Authorized command-result replay 尚未实现:`callerPrincipal` 与 `authorizationContextHash` 来自请求正文,只是 audit 字段,不构成可信身份或实时授权。所有重复 commandId(包括相同 hash/principal)在写入前 fail closed;重启后也不返回历史结果。RFC §9.4 要求的结果披露必须等待 verified principal 与 live project/command-class authorization 边界,不能用 audit 字段相等代替。 + ## 战略 - 不 cherry-pick 旧 Control Plane 代码(628KB/29 文件基于 0.2.4,冲突爆炸) diff --git a/docs/internal/1.0.0-ga-scoreboard.md b/docs/internal/1.0.0-ga-scoreboard.md index 43d32640..546d945e 100644 --- a/docs/internal/1.0.0-ga-scoreboard.md +++ b/docs/internal/1.0.0-ga-scoreboard.md @@ -1,6 +1,10 @@ # Taskflow 1.0.0 acceptance scoreboard -Status: **release authorized / not yet published**. Updated 2026-10-02 after local verification. +Status: **publication blocked on historical spec closure**. Updated 2026-10-02. +The owner's latest instruction requires implementation and acceptance of the +previously committed specs before publication. Earlier test passes remain +valid only for their exercised scope; experimental labels do not waive missing +accepted requirements. No merge, tag or npm publication has occurred. This ledger replaces the current-release interpretation of the historical 0.3 scoreboard. Local checks use macOS, Node 24.21.0, pnpm 11.17.0 and Pi 1.0.0. The final PR head's GitHub checks are the authoritative Node 22/Linux/Windows @@ -16,22 +20,25 @@ and remote security evidence; pending rows must not be inferred from local passe | Deterministic ten-package clean consumer | PASS on Node 24; Node 22 remote gate | 27 explicit imports, 84 wildcard exports, six bins, repeated deterministic packs and clean npm installation | | Five MCP server E2E | PASS locally | all five stdio suites plus 16 comprehensive Codex MCP checks; fixture executors. TE/background/cancellation/resume also have full-suite regression coverage | | Pi 1.0 packed real process | PASS locally | 17 cases use actual Pi CLI, packed extension and spawned children; deterministic local provider, no live model. Covers shorthand, map/reduce, context handshake, codemode, retry/retry exhaustion, usage, terminal reap, cancellation and RPC approval | -| Pi identity/resume and legacy lifecycle | PASS at stated scope | regression suite covers owning-run propagation and resume; recorded real 0.80.3 CLI threshold/overflow compaction stream exits successfully through current runner. This is not a separate live-model resume E2E | +| Pi identity/resume and legacy lifecycle | BLOCKED: real foreground crash recovery | ordinary resume and real 0.80.3 compaction replay pass. Independent real Pi 1.0 SIGKILL acceptance found dropped rapid checkpoints and fresh-host rejection of interrupted `running` runs; fixes and dead-owner/quiescence acceptance are in progress | | Pi TUI interactive and RPC/headless approval | PASS at stated scope | actual PTY: fullscreen approve/reject, normal approve/Escape. Real RPC: approve/reject/cancel/edit/edit-cancel/abort; print rejects. Long preview/confirmation/abort/error paths also covered by component/requester tests; not every terminal/keyboard combination exercised | | Current live host executors | PASS for available Codex and Claude | Codex `gpt-6.1-sol`/high: 3-phase fruit→uppercase→TrustedEffects persistence, 39.2s. Claude `haiku`: 2-phase output transfer, 21.3s. Both use real authenticated CLIs | | OpenCode/Grok/Hermes live execution | NOT RUN | binaries unavailable locally; MCP fixture passes do not establish live model acceptance | -| Process SIGKILL/recovery/restore/cleanup | PASS locally; three-OS remote gate | full suite includes crash recovery/writer races/resource restoration/detached cleanup; added control corruption and malformed UDS cases. Control remains Unix-only experimental | +| Process SIGKILL/recovery/restore/cleanup | PARTIAL: foreground gap under repair | existing crash recovery/writer races/resource restoration/detached cleanup tests pass; they did not cover the newly reproduced Pi foreground SIGKILL chain. UDS response/size/deadline hardening passes 54 independent process tests | | Website export/audit | PASS locally | production static export with `/taskflow` base path; patched Next.js 16.3.6, sharp 0.35.4, baseline-browser-mapping 2.11.0, Undici 8.10.2 and brace-expansion 5.0.12; full `pnpm audit` (including development dependencies) reports zero vulnerabilities | | Remote security | REMOTE GATE | exact-head CodeQL and Plugin Security Scan, retaining Cisco coverage and severity/score gates | | Final exact-SHA remote CI | PENDING | do not reuse PR173/174 or historical beta green as integrated-head evidence | -| Published artifacts and stable GitHub Release | AUTHORIZED / NOT DONE | owner authorized the 1.0 release on 2026-10-02; tag/npm/release evidence still required | +| Historical accepted specification closure | BLOCKED | approved Control Plane plan S3–S7 and accepted P-ADRs contain unimplemented runtime contracts; earlier/later scope conflicts and proposed wire details require explicit reconciliation. A package being private is not an acceptance waiver | +| Published artifacts and stable GitHub Release | BLOCKED / NOT DONE | release authorization is conditional on historical spec closure and Pi 1.0 compatibility; no merge, tag, npm publication or GitHub Release has occurred | -The supported stable scope is the mature DAG/DSL/six-host/declared-target -transaction contract. Private control-plane wire contracts remain proposed; -parked approval/Receipt/WebUI/global coordination/Windows pipes are not -accepted features. CharterArc stays independently experimental. Pi interactive -approval and fail-closed MCP/headless rejection are existing runtime features, -separate from the unfinished control-plane approval protocol. +The implemented mature surface is DAG/DSL/six-host/declared-target transactions. +Control Plane P1–P7, P9–P13 and P16 are Accepted decisions; P8/P14/P15 retain +proposed wire details. Authorized command-result replay, durable parked approval, +Receipt, global coordination and the later approved plan's WebUI track lack +complete implementation/acceptance. They cannot be waived by private or +experimental labels. Windows pipes and CharterArc retain their separate scope. +Existing Pi interactive approval and MCP/headless rejection do not establish +acceptance of the unfinished control-plane approval protocol. Local full logs are retained in the parent task workspace at `work/taskflow-release-logs/`; they are not shipped in npm packages. Earlier diff --git a/docs/internal/1.0.0-release-plan.md b/docs/internal/1.0.0-release-plan.md index cdc0fce9..6b7abc36 100644 --- a/docs/internal/1.0.0-release-plan.md +++ b/docs/internal/1.0.0-release-plan.md @@ -1,6 +1,6 @@ # Taskflow 1.0.0 release preparation -Status: **RELEASE AUTHORIZED / VERIFICATION IN PROGRESS**. This plan supersedes the beta.2 +Status: **SPEC CLOSURE REQUIRED / PUBLICATION BLOCKED**. This plan supersedes the beta.2 release target for PR #142; older plans and evidence remain historical. The authorized scope is a complete release preparation PR, comprehensive verification, Pi 1.0 retry/approval compatibility and E2E regression fixes. @@ -8,6 +8,14 @@ The release owner subsequently authorized completing the existing repository's 1.0 publication on 2026-10-02. Merge/tag/registry publication must still follow the checked-in workflow and final-commit gates, with one release executor. +The owner's subsequent 2026-10-02 instruction makes closure of previously +committed documents/specifications a hard pre-publication gate, alongside real +Pi 1.0 compatibility. No merge, tag or npm publication has occurred. The earlier +stable/experimental packaging boundary below does not waive an accepted spec. +Approved Control Plane S3-S7 and accepted ADR requirements must be traced to +implementation and meaningful acceptance evidence before the release resumes. +Missing implementation remains blocked; relabeling it experimental is not closure. + ## Contract The ten existing public packages stabilize the mature Taskflow DAG contract: @@ -20,7 +28,7 @@ compatibility must be verified; a source build does not prove installed use. `taskflow-control` is private workspace experimental. UDS/store scaffolding is retained and tested without claiming completed admission, global coordination, -parked approvals/CAS/receipts, registry, Windows pipes or WebUI. CharterArc +authorized command-result replay, parked approvals/CAS/receipts, registry, Windows pipes or WebUI. CharterArc continues its separate experimental package/version/workflow. The optional kernel's advanced-feature fallback, resolve-only path limitations, missing secret/service backends and unbenchmarked journal capacity remain documented. diff --git a/packages/taskflow-control/src/store/store.ts b/packages/taskflow-control/src/store/store.ts index 7d410e9b..82e144b5 100644 --- a/packages/taskflow-control/src/store/store.ts +++ b/packages/taskflow-control/src/store/store.ts @@ -7,6 +7,11 @@ * lock file. Atomic writes use UUID temp + wx + fsync + rename, and fail * closed if the destination is a symlink. * + * Authorized command-result replay is not implemented. Caller principal and + * authorizationContextHash are request-supplied audit fields, not a live + * authorization boundary. Duplicate commands fail closed before persistence + * until the host can authorize disclosure of a prior durable result. + * * P14 ADR Status stays Proposed. This is the S3 engine implementation gate. */ import * as crypto from "node:crypto"; @@ -84,13 +89,13 @@ export class ControlStore { constructor(storePath: string, header: ControlStoreHeader, commitSeq: number, commands: readonly CommandRecord[], releaseWriter: () => void) { this.#releaseWriter = releaseWriter; this.storePath = storePath; - this.#header = header; + this.#header = structuredClone(header); this.#commitSeq = commitSeq; - for (const command of commands) this.#commandIndex.set(command.commandId, command); + for (const command of commands) this.#commandIndex.set(command.commandId, structuredClone(command)); } get header(): ControlStoreHeader { - return this.#header; + return structuredClone(this.#header); } get commitSeq(): number { @@ -103,12 +108,13 @@ export class ControlStore { readCommand(commandId: string): CommandRecord | undefined { this.#assertOpen(); - return this.#commandIndex.get(commandId); + const command = this.#commandIndex.get(commandId); + return command === undefined ? undefined : structuredClone(command); } snapshot(): ControlStoreSnapshot { this.#assertOpen(); - return { header: this.#header, commitSeq: this.#commitSeq, status: this.#status }; + return { header: this.header, commitSeq: this.#commitSeq, status: this.#status }; } appendBatch(input: CommitBatchInput): { commitSeq: number; command: CommandRecord } { @@ -136,7 +142,7 @@ export class ControlStore { } const nextSeq = this.#commitSeq + 1; - const command: CommandRecord = { + let command: CommandRecord = { ...input.command, projectId: this.#header.projectId, controlDomainId: this.#header.controlDomainId, @@ -146,15 +152,20 @@ export class ControlStore { if (!Value.Check(CommandRecordSchema, command)) { throw durabilityFailed("CommandRecord failed schema check"); } + // Keep authoritative records independent of both caller-owned nested + // input and the copies returned by append/read/snapshot boundaries. + command = structuredClone(command); if (this.#commandIndex.has(command.commandId)) { + // Matching audit fields cannot authorize disclosure (RFC §9.4). + // This S3 path only appends a journal batch; it never submits TE work. throw new ControlError( "TF_IDEMPOTENCY_CONFLICT", - `command ${command.commandId} is already committed`, + `command ${command.commandId} is already committed; authorized command-result replay is not implemented without a live authorization boundary`, { recoveryAction: "none", sideEffects: "none" }, ); } const events: ControlEvent[] = input.events.map((event, index) => ({ - ...event, + ...structuredClone(event), projectId: this.#header.projectId, controlDomainId: this.#header.controlDomainId, commitSeq: nextSeq, @@ -185,7 +196,7 @@ export class ControlStore { this.#status = "fail-closed"; throw durabilityFailed(`control store persistence failed; close and reopen to recover: ${error instanceof Error ? error.message : String(error)}`); } - return { commitSeq: nextSeq, command }; + return { commitSeq: nextSeq, command: structuredClone(command) }; } close(): void { diff --git a/packages/taskflow-control/src/te-provider.ts b/packages/taskflow-control/src/te-provider.ts index e65c229e..24bdcaab 100644 --- a/packages/taskflow-control/src/te-provider.ts +++ b/packages/taskflow-control/src/te-provider.ts @@ -154,7 +154,8 @@ export function createTeExecutionProvider(te: TeExecutionAuthority): ExecutionPr controlDomainId: req.controlDomainId, }); if ("providerJobHandle" in result && typeof result.providerJobHandle === "string") { - if (typeof (result as TeSubmitHandle).poll === "function") { + if (["poll", "cancel", "collect", "reconcile"].every((method) => + typeof (result as unknown as Record)[method] === "function")) { handles.set(result.providerJobHandle, result as TeSubmitHandle); } return { outcome: "accepted", providerJobHandle: result.providerJobHandle }; @@ -167,25 +168,25 @@ export function createTeExecutionProvider(te: TeExecutionAuthority): ExecutionPr const poll = async (req: { providerJobHandle: string }): Promise => { const handle = handles.get(req.providerJobHandle); if (handle) return handle.poll(); - return statelessFallback(req.providerJobHandle).poll(); + return unavailableHandleOutcome(req.providerJobHandle); }; const cancel = async (req: { providerJobHandle: string }): Promise => { const handle = handles.get(req.providerJobHandle); if (handle) return handle.cancel(); - return statelessFallback(req.providerJobHandle).cancel(); + return unavailableHandleOutcome(req.providerJobHandle); }; const collect = async (req: { providerJobHandle: string }): Promise => { const handle = handles.get(req.providerJobHandle); if (handle) return handle.collect(); - return statelessFallback(req.providerJobHandle).collect(); + return unavailableHandleOutcome(req.providerJobHandle); }; const reconcile = async (req: { providerJobHandle: string }): Promise => { const handle = handles.get(req.providerJobHandle); if (handle) return handle.reconcile(); - return statelessFallback(req.providerJobHandle).reconcile(); + return unavailableHandleOutcome(req.providerJobHandle); }; return { @@ -202,16 +203,14 @@ export function createTeExecutionProvider(te: TeExecutionAuthority): ExecutionPr } /** - * Stateless fallback: a job id alone can still be observed via provider - * RPCs; S4 wires the real TE handle registry. Used only when submit returned - * a plain accepted union instead of a live handle. + * A job id alone proves neither provider state nor cancellation. Until a + * durable handle registry can recover live authority, keep the result + * ambiguous so callers cannot infer quiescence or release committed capacity. */ -function statelessFallback(providerJobHandle: string): Pick { +function unavailableHandleOutcome(providerJobHandle: string): { outcome: "ambiguous"; message: string } { return { - poll: async (): Promise => ({ outcome: "accepted", status: "running" }), - cancel: async (): Promise => ({ outcome: "accepted", cancelled: true }), - collect: async (): Promise => ({ outcome: "accepted", providerJobHandle }), - reconcile: async (): Promise => ({ outcome: "accepted", providerState: "running" }), + outcome: "ambiguous", + message: `live TE handle unavailable for ${JSON.stringify(providerJobHandle)}; provider outcome and side effects cannot be established`, }; } diff --git a/packages/taskflow-control/src/uds.ts b/packages/taskflow-control/src/uds.ts index 5aea14a8..8a365588 100644 --- a/packages/taskflow-control/src/uds.ts +++ b/packages/taskflow-control/src/uds.ts @@ -22,9 +22,10 @@ import * as fs from "node:fs"; import * as net from "node:net"; +import { Value } from "typebox/value"; import { bootstrapFailed, ControlError, errorFromEnvelope, errorToEnvelope, protocolError } from "./errors.ts"; import { createHelloGate } from "./hello.ts"; -import type { ErrorEnvelope, NegotiationHandshake } from "./schema/transport.ts"; +import { ErrorEnvelopeSchema, NegotiationHandshakeSchema, type ErrorEnvelope, type NegotiationHandshake } from "./schema/transport.ts"; // --------------------------------------------------------------------------- // Wire frames @@ -38,8 +39,48 @@ export type UdsFrame = | { type: "rpc-result"; id: number; ok: true; result: unknown } | { type: "rpc-result"; id: number; ok: false; error: ErrorEnvelope }; -function encodeFrame(frame: UdsFrame): string { - return JSON.stringify(frame) + "\n"; +/** Per-frame UTF-8 limit; large plans/evidence can use up to 16 MiB by default. */ +export const UDS_MAX_FRAME_BYTES = 16 * 1024 * 1024; + +function frameLimit(value = UDS_MAX_FRAME_BYTES): number { + if (!Number.isSafeInteger(value) || value < 1) throw new RangeError("maxFrameBytes must be a positive safe integer"); + return value; +} + +function isWireCounter(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function encodeFrame(frame: UdsFrame, maxBytes = UDS_MAX_FRAME_BYTES): string { + const encoded = JSON.stringify(frame); + if (Buffer.byteLength(encoded, "utf8") > maxBytes) throw new Error(`control wire frame exceeds ${maxBytes} bytes`); + return encoded + "\n"; +} + +/** Bound each frame before appending; coalesced frames have independent budgets. */ +function frameReader(maxBytes: number) { + let buffer = ""; + let bytes = 0; + const clear = (): void => { buffer = ""; bytes = 0; }; + return { + clear, + push(chunk: string, onLine: (line: string) => boolean): void { + let start = 0; + while (start < chunk.length) { + const end = chunk.indexOf("\n", start); + const part = chunk.slice(start, end < 0 ? undefined : end); + const addedBytes = Buffer.byteLength(part, "utf8"); + if (bytes + addedBytes > maxBytes) throw new Error(`control wire frame exceeds ${maxBytes} bytes`); + buffer += part; + bytes += addedBytes; + if (end < 0) return; + const line = buffer.trim(); + clear(); + if (line && !onLine(line)) return; + start = end + 1; + } + }, + }; } function parseFrame(line: string): Record { @@ -68,6 +109,10 @@ export interface UdsServerOptions { requiredFeatures?: readonly string[]; /** The winner's current fencing epoch, sent in the hello-ack (P16). */ getFencingEpoch: () => number; + /** Absolute deadline for the first valid hello (default 5s). */ + helloTimeoutMs?: number; + /** Maximum UTF-8 bytes per frame, excluding newline (default 16 MiB). */ + maxFrameBytes?: number; /** Dispatch one RPC after a successful per-connection hello. */ handleRpc: (method: string, params: unknown, fencingEpoch: number) => Promise; } @@ -84,6 +129,7 @@ export interface UdsServer { * owns the singleton lock and must release it. */ export function startUdsServer(options: UdsServerOptions): Promise { + frameLimit(options.maxFrameBytes); const sockets = new Set(); const server = net.createServer((socket) => { sockets.add(socket); @@ -126,19 +172,25 @@ function handleServerConnection(socket: net.Socket, options: UdsServerOptions): // Per-connection hello gate (P4): the first frame must be a hello. const gate = createHelloGate(options.serverHello, { requiredFeatures: options.requiredFeatures }); let greeted = false; - let buffer = ""; + const maxFrameBytes = frameLimit(options.maxFrameBytes); + const reader = frameReader(maxFrameBytes); let destroyed = false; + const helloTimer = setTimeout(() => { + destroyed = true; + reader.clear(); + socket.destroy(); + }, options.helloTimeoutMs ?? 5_000); const send = (frame: UdsFrame): void => { if (destroyed) return; - socket.write(encodeFrame(frame)); + socket.write(encodeFrame(frame, maxFrameBytes)); }; const failClosed = (frame: UdsFrame): void => { try { send(frame); } finally { destroyed = true; - buffer = ""; + reader.clear(); socket.destroy(); } }; @@ -146,39 +198,34 @@ function handleServerConnection(socket: net.Socket, options: UdsServerOptions): socket.setEncoding("utf8"); socket.on("data", (chunk: string) => { if (destroyed) return; - buffer += chunk; - let index: number; - while (!destroyed && (index = buffer.indexOf("\n")) >= 0) { - const line = buffer.slice(0, index).trim(); - buffer = buffer.slice(index + 1); - if (!line) continue; - let raw: Record; + try { + reader.push(chunk, (line) => { + const raw = parseFrame(line); + // Synchronous negotiation completes before a pipelined frame is read. + void handleFrame(raw).catch(() => { + destroyed = true; + reader.clear(); + socket.destroy(); + }); + return !destroyed; + }); + } catch (error) { try { - raw = parseFrame(line); - } catch (error) { - failClosed({ - type: greeted ? "rpc-result" : "hello-ack", - ...(greeted ? { id: 0 } : {}), - ok: false, - error: errorToEnvelope(error), - } as UdsFrame); - return; - } - // A final rejection boundary also covers failures while encoding the - // error response; no connection may produce an unhandled rejection. - void handleFrame(raw).catch(() => { - destroyed = true; - buffer = ""; + failClosed({ type: greeted ? "rpc-result" : "hello-ack", ...(greeted ? { id: 0 } : {}), ok: false, error: errorToEnvelope(error) } as UdsFrame); + } catch { + // Even encoding a rejection must remain local to the offending socket. socket.destroy(); - }); + } } }); socket.on("close", () => { + clearTimeout(helloTimer); destroyed = true; - buffer = ""; + reader.clear(); }); socket.on("error", () => { destroyed = true; + reader.clear(); socket.destroy(); }); @@ -197,6 +244,7 @@ function handleServerConnection(socket: net.Socket, options: UdsServerOptions): } const fencingEpoch = options.getFencingEpoch(); greeted = true; + clearTimeout(helloTimer); send({ type: "hello-ack", ok: true, @@ -216,8 +264,8 @@ function handleServerConnection(socket: net.Socket, options: UdsServerOptions): return; } const id = raw.id; - if (typeof id !== "number") { - failClosed({ type: "rpc-result", id: 0, ok: false, error: errorToEnvelope(protocolError("rpc frame requires a numeric id")) }); + if (!isWireCounter(id)) { + failClosed({ type: "rpc-result", id: 0, ok: false, error: errorToEnvelope(protocolError("rpc frame requires a non-negative safe integer id")) }); return; } const method = typeof raw.method === "string" ? raw.method : ""; @@ -225,7 +273,12 @@ function handleServerConnection(socket: net.Socket, options: UdsServerOptions): failClosed({ type: "rpc-result", id, ok: false, error: errorToEnvelope(protocolError("rpc frame requires a method name")) }); return; } - const fencingEpoch = typeof raw.fencingEpoch === "number" ? raw.fencingEpoch : 0; + // Preserve the legacy omitted-epoch default, not malformed values. + const fencingEpoch = raw.fencingEpoch === undefined ? 0 : raw.fencingEpoch; + if (!isWireCounter(fencingEpoch)) { + failClosed({ type: "rpc-result", id, ok: false, error: errorToEnvelope(protocolError("rpc frame requires a non-negative safe integer fencingEpoch")) }); + return; + } try { const result = await options.handleRpc(method, raw.params, fencingEpoch); send({ type: "rpc-result", id, ok: true, result }); @@ -264,6 +317,8 @@ export interface UdsClientOptions { connectTimeoutMs?: number; /** Budget for a single RPC round-trip (default 10s). */ rpcTimeoutMs?: number; + /** Maximum UTF-8 bytes per frame, excluding newline (default 16 MiB). */ + maxFrameBytes?: number; } export interface UdsClient { @@ -286,10 +341,11 @@ export interface UdsClient { export function connectUdsClient(options: UdsClientOptions): Promise { const connectTimeoutMs = options.connectTimeoutMs ?? 5_000; const rpcTimeoutMs = options.rpcTimeoutMs ?? 10_000; + const maxFrameBytes = frameLimit(options.maxFrameBytes); return new Promise((resolve, reject) => { const socket = net.connect(options.endpointPath); - let buffer = ""; + const reader = frameReader(maxFrameBytes); let settled = false; let closed = false; let nextId = 1; @@ -302,6 +358,15 @@ export function connectUdsClient(options: UdsClientOptions): Promise pending.clear(); }; + const failClosed = (error: ControlError): void => { + closed = true; + reader.clear(); + clearTimeout(timer); + if (!settled) { settled = true; reject(error); } + rejectPending(error); + socket.destroy(); + }; + const timer = setTimeout(() => { if (settled) return; settled = true; @@ -311,7 +376,11 @@ export function connectUdsClient(options: UdsClientOptions): Promise }, connectTimeoutMs); socket.on("connect", () => { - socket.write(encodeFrame({ type: "hello", hello: options.clientHello })); + try { + socket.write(encodeFrame({ type: "hello", hello: options.clientHello }, maxFrameBytes)); + } catch (error) { + failClosed(controlUnavailable(`control hello could not be sent: ${error instanceof Error ? error.message : String(error)}`)); + } }); socket.on("error", (error) => { @@ -326,69 +395,62 @@ export function connectUdsClient(options: UdsClientOptions): Promise socket.setEncoding("utf8"); socket.on("data", (chunk: string) => { - buffer += chunk; - let index: number; - while ((index = buffer.indexOf("\n")) >= 0) { - const line = buffer.slice(0, index).trim(); - buffer = buffer.slice(index + 1); - if (!line) continue; - let raw: Record; - try { - raw = parseFrame(line); - } catch (error) { - rejectPending(controlUnavailable(`control endpoint ${options.endpointPath} sent a malformed frame: ${error instanceof Error ? error.message : String(error)}`)); - continue; - } - if (raw.type === "hello-ack") { - if (settled) continue; - settled = true; - clearTimeout(timer); - if (raw.ok === true) { - if (typeof raw.fencingEpoch !== "number" || raw.serverHello === undefined) { - reject(controlUnavailable(`control endpoint ${options.endpointPath} sent a malformed hello-ack`)); - socket.destroy(); - return; + if (closed) return; + try { + reader.push(chunk, (line) => { + const raw = parseFrame(line); + if (raw.type !== "hello-ack" && raw.type !== "rpc-result") throw new Error("unexpected frame type"); + if (typeof raw.ok !== "boolean" || (raw.ok === false && !Value.Check(ErrorEnvelopeSchema, raw.error))) { + throw new Error("failure reply must match ErrorEnvelope and have a boolean ok"); + } + // Reject opposite-branch members without forbidding future metadata. + // Void successful RPCs may omit result, and null is a valid result. + if ((raw.ok && Object.hasOwn(raw, "error")) || (!raw.ok && ( + raw.type === "hello-ack" + ? Object.hasOwn(raw, "serverHello") || Object.hasOwn(raw, "fencingEpoch") + : Object.hasOwn(raw, "result") + ))) throw new Error("reply contains contradictory success and failure members"); + if (raw.type === "hello-ack") { + if (settled) throw new Error("duplicate hello-ack"); + if (raw.ok === false) { + failClosed(errorFromEnvelope(raw.error as ErrorEnvelope)); + return false; + } + if (!isWireCounter(raw.fencingEpoch) || !Value.Check(NegotiationHandshakeSchema, raw.serverHello)) { + throw new Error("malformed hello-ack"); } - wireEpoch = raw.fencingEpoch; + settled = true; + clearTimeout(timer); + wireEpoch = raw.fencingEpoch as number; wireServerHello = raw.serverHello as NegotiationHandshake; - const client: UdsClient = { + resolve({ endpointPath: options.endpointPath, - get fencingEpoch() { - return wireEpoch; - }, - get serverHello() { - return wireServerHello as NegotiationHandshake; - }, + get fencingEpoch() { return wireEpoch; }, + get serverHello() { return wireServerHello as NegotiationHandshake; }, rpc: (method: string, params?: unknown, fencingEpoch?: number) => - rpc(socket, pending, () => closed, () => wireEpoch, () => nextId++, rpcTimeoutMs, method, params, fencingEpoch), - close: () => { - closed = true; - rejectPending(controlUnavailable(`control client for ${options.endpointPath} is closed`)); - socket.destroy(); - }, - }; - resolve(client); - } else { - socket.destroy(); - reject(errorFromEnvelope(raw.error as ErrorEnvelope)); - return; + rpc(socket, pending, () => closed, () => wireEpoch, () => nextId++, rpcTimeoutMs, maxFrameBytes, method, params, fencingEpoch), + close: () => failClosed(controlUnavailable(`control client for ${options.endpointPath} is closed`)), + }); + return true; } - continue; - } - if (raw.type === "rpc-result") { - const id = raw.id; - if (typeof id !== "number") continue; + if (!settled || !isWireCounter(raw.id)) throw new Error("malformed rpc-result"); + const id = raw.id as number; const entry = pending.get(id); - if (!entry) continue; - pending.delete(id); - if (raw.ok === true) entry.resolve(raw.result); - else entry.reject(errorFromEnvelope(raw.error as ErrorEnvelope)); - continue; - } + if (entry) { + pending.delete(id); + if (raw.ok === true) entry.resolve(raw.result); + else entry.reject(errorFromEnvelope(raw.error as ErrorEnvelope)); + } + return true; + }); + } catch (error) { + failClosed(controlUnavailable(`control endpoint ${options.endpointPath} sent a malformed frame: ${error instanceof Error ? error.message : String(error)}`)); } }); socket.on("close", () => { + closed = true; + reader.clear(); if (!settled) { settled = true; clearTimeout(timer); @@ -407,6 +469,7 @@ function rpc( getEpoch: () => number, nextId: () => number, rpcTimeoutMs: number, + maxFrameBytes: number, method: string, params: unknown, fencingEpoch?: number, @@ -438,7 +501,7 @@ function rpc( fencingEpoch: fencingEpoch ?? getEpoch(), }; try { - socket.write(encodeFrame(frame)); + socket.write(encodeFrame(frame, maxFrameBytes)); } catch (error) { pending.delete(id); clearTimeout(rpcTimer); diff --git a/packages/taskflow-control/test/control-host.test.ts b/packages/taskflow-control/test/control-host.test.ts index c777e51a..e9a2151e 100644 --- a/packages/taskflow-control/test/control-host.test.ts +++ b/packages/taskflow-control/test/control-host.test.ts @@ -241,6 +241,85 @@ test("control-host: standalone opens projectStorePath and a later host sees the second.stop(); }); +for (const mode of ["standalone", "auto"] as const) { + test(`commands.submit: ${mode} duplicate and concurrent retries fail closed before writes or TE execution`, { + skip: mode === "auto" && process.platform === "win32", + }, async (t) => { + const paths = makePaths(); + const projectStorePath = path.join(paths.controlHome, "project-store"); + const provider = fakeProvider(); + const prepare = t.mock.method(provider, "prepare", async () => assert.fail("journal submission must not prepare TE work")); + const submit = t.mock.method(provider, "submit", async () => assert.fail("journal submission must not execute TE work")); + const options = hostOptions(paths, { mode, provider, holderId: "command-owner", projectStorePath }); + let owner = new ControlHost(options); + let caller = owner; + t.after(() => { if (caller !== owner) caller.stop(); owner.stop(); }); + const start = async () => { + await owner.start(); + owner.hello(clientHello); + if (mode === "auto") { + // Exercise the real commands.submit wire path, not just appendBatch. + caller = new ControlHost(hostOptions(paths, { mode: "coordinated", provider, projectStorePath })); + await caller.start(); + } else caller = owner; + }; + await start(); + const header = await caller.dispatch<{ projectId: string; controlDomainId: string }>("control.store.header", undefined, { fencingEpoch: 1 }); + const identity = { projectId: header.projectId, controlDomainId: header.controlDomainId }; + const commandId = "00000000-0000-0000-0000-0000000000dd"; + const body = { + command: { + commandId, kind: "run.submit", requestHash: "a".repeat(64), callerPrincipal: "cli", + authorizationContextHash: "a".repeat(64), ...identity, status: "accepted", + firstCommitSeq: 1, lastCommitSeq: 1, recordedAt: 1, + }, + events: [{ + eventId: "00000000-0000-0000-0000-0000000000ee", schemaVersion: 1, + ...identity, streamId: `command:${commandId}`, streamSeq: 1, commitSeq: 1, + commandId, commandEventIndex: 0, causationId: commandId, correlationId: commandId, + recordedAt: 1, payload: { kind: "command.recorded", commandId }, + }], + }; + // Two requests can be pending on the same connection, but only one batch + // may commit. The synchronous single-writer append never re-executes it. + const concurrent = await Promise.allSettled([0, 1].map(() => caller.dispatch("commands.submit", body, { fencingEpoch: 1 }))); + assert.equal(concurrent.filter((result) => result.status === "fulfilled").length, 1); + const rejected = concurrent.find((result) => result.status === "rejected"); + assert.ok(rejected && rejected.status === "rejected"); + assert.ok(rejected.reason instanceof ControlError); + assert.equal(rejected.reason.code, "TF_IDEMPOTENCY_CONFLICT"); + const journalPath = path.join(projectStorePath, "journal", "000001.jsonl"); + const journal = fs.readFileSync(journalPath); + const sequence = fs.readFileSync(path.join(projectStorePath, "commit-seq.json")); + const projection = fs.readFileSync(path.join(projectStorePath, "projections", "commands.json")); + assert.equal(journal.toString("utf8").trim().split("\n").length, 1); + for (const restarted of [false, true]) { + if (restarted) { + if (caller !== owner) caller.stop(); + owner.stop(); + owner = new ControlHost(options); + await start(); + } + for (const overrides of [{}, { requestHash: "b".repeat(64) }, { callerPrincipal: "other-principal" }, { authorizationContextHash: "b".repeat(64) }]) { + await assert.rejects(caller.dispatch("commands.submit", { ...body, command: { ...body.command, ...overrides } }, { fencingEpoch: 1 }), (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal(error.code, "TF_IDEMPOTENCY_CONFLICT"); + assert.equal(error.sideEffects, "none"); + assert.match(error.message, /authorized command-result replay is not implemented/); + return true; + }); + const status = await caller.dispatch<{ commitSeq: number }>("control.store.status", undefined, { fencingEpoch: 1 }); + assert.equal(status.commitSeq, 1); + assert.deepEqual(fs.readFileSync(journalPath), journal); + assert.deepEqual(fs.readFileSync(path.join(projectStorePath, "commit-seq.json")), sequence); + assert.deepEqual(fs.readFileSync(path.join(projectStorePath, "projections", "commands.json")), projection); + } + } + assert.equal(prepare.mock.calls.length, 0); + assert.equal(submit.mock.calls.length, 0); + }); +} + for (const mode of ["standalone", "auto"] as const) { test(`control-host: ${mode} store-open failure rolls back resources before retry`, { skip: mode === "auto" && process.platform === "win32", diff --git a/packages/taskflow-control/test/fixtures/uds-malformed-client.ts b/packages/taskflow-control/test/fixtures/uds-malformed-client.ts new file mode 100644 index 00000000..1a325733 --- /dev/null +++ b/packages/taskflow-control/test/fixtures/uds-malformed-client.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import { connectUdsClient } from "../../src/uds.ts"; +import { PROTOCOL_MAJOR, type NegotiationHandshake } from "../../src/schema/transport.ts"; + +const clientHello: NegotiationHandshake = { + protocolMajor: PROTOCOL_MAJOR, + supportedReadSchemas: ["taskflow.wire.v1"], supportedWriteSchemas: ["taskflow.wire.v1"], + requiredFeatures: [], offeredFeatures: [], + buildInfo: { packageVersion: "test", gitCommit: "test", schemaVersion: 1 }, +}; +try { + const client = await connectUdsClient({ endpointPath: process.env.TF_TEST_BAD_ENDPOINT!, clientHello, maxFrameBytes: 4096 }); + try { await client.rpc("probe"); } + finally { client.close(); } + throw new Error("malformed peer was accepted"); +} catch (error) { + assert.equal((error as { code?: string }).code, "TF_JOURNAL_UNAVAILABLE"); +} +const healthy = await connectUdsClient({ endpointPath: process.env.TF_TEST_GOOD_ENDPOINT!, clientHello }); +try { assert.deepEqual(await healthy.rpc("probe"), { mutations: 0 }); } +finally { healthy.close(); } +console.log("SURVIVED"); diff --git a/packages/taskflow-control/test/fixtures/uds-malformed-server.ts b/packages/taskflow-control/test/fixtures/uds-malformed-server.ts index 0a608fd7..29cb05a7 100644 --- a/packages/taskflow-control/test/fixtures/uds-malformed-server.ts +++ b/packages/taskflow-control/test/fixtures/uds-malformed-server.ts @@ -8,6 +8,8 @@ let mutations = 0; let failEpoch = process.env.TF_TEST_FAIL_EPOCH === "1"; const server = await startUdsServer({ endpointPath, + maxFrameBytes: Number(process.env.TF_TEST_MAX_FRAME_BYTES ?? 4096), + helloTimeoutMs: 300, serverHello: { protocolMajor: PROTOCOL_MAJOR, supportedReadSchemas: ["taskflow.wire.v1"], @@ -23,7 +25,9 @@ const server = await startUdsServer({ } return 1; }, - handleRpc: async (method) => { + handleRpc: async (method, params, fencingEpoch) => { + if (method === "echo") return params; + if (method === "inspect-epoch") return { fencingEpoch }; if (method === "mutate") mutations++; return { mutations }; }, diff --git a/packages/taskflow-control/test/fixtures/uds-wire-client.ts b/packages/taskflow-control/test/fixtures/uds-wire-client.ts new file mode 100644 index 00000000..5dd88c66 --- /dev/null +++ b/packages/taskflow-control/test/fixtures/uds-wire-client.ts @@ -0,0 +1,61 @@ +/** Runs vulnerable client callbacks in a disposable strict-rejection process. */ +import assert from "node:assert/strict"; +import { connectUdsClient } from "../../src/uds.ts"; +import type { NegotiationHandshake } from "../../src/schema/transport.ts"; + +const endpointPath = process.env.TF_TEST_ENDPOINT!; +const scenario = process.env.TF_TEST_SCENARIO!; +const hello: NegotiationHandshake = { + protocolMajor: 1, supportedReadSchemas: ["taskflow.wire.v1"], supportedWriteSchemas: ["taskflow.wire.v1"], + requiredFeatures: [], offeredFeatures: [], buildInfo: { packageVersion: "test", gitCommit: "test", schemaVersion: 1 }, +}; +const options = { endpointPath, clientHello: hello, maxFrameBytes: 512, connectTimeoutMs: 3_000, rpcTimeoutMs: 3_000 }; +async function bounded(promise: Promise): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([promise, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("protocol rejection/cleanup exceeded 700ms")), 700); + })]); + } finally { clearTimeout(timer); } +} +function transportError(error: unknown): boolean { + assert.ok(error instanceof Error); + assert.ok("code" in error && ["TF_JOURNAL_UNAVAILABLE", "TF_PROTOCOL_INCOMPATIBLE"].includes(String(error.code)), String(error)); + return true; +} +if (scenario === "hello-rejection") { + await assert.rejects(bounded(connectUdsClient(options)), transportError); +} else { + const client = await connectUdsClient(options); + try { + if (scenario === "rpc-rejection") { + const outcomes = await bounded(Promise.allSettled([client.rpc("one"), client.rpc("two")])); + for (const outcome of outcomes) { + assert.equal(outcome.status, "rejected"); + if (outcome.status === "rejected") transportError(outcome.reason); + } + await assert.rejects(bounded(client.rpc("after-failure")), transportError); + } else if (scenario === "coalesced") { + const values = await bounded(Promise.all(Array.from({ length: 20 }, () => client.rpc("one")))); + assert.deepEqual(values, Array(20).fill("中🙂")); + } else if (scenario === "compatible-results") { + const values = await bounded(Promise.all([client.rpc("void"), client.rpc("nullable")])); + assert.deepEqual(values, [undefined, null]); + } else if (scenario === "valid-failure") { + await assert.rejects(bounded(client.rpc("fails")), (error: unknown) => { + assert.ok(error instanceof Error && "code" in error); + assert.equal(error.code, "TF_COMMAND_FAILED"); + return true; + }); + assert.equal(await bounded(client.rpc("still-usable")), "healthy"); + } else { + const value = await bounded(client.rpc("one")); + assert.ok(value.startsWith("中🙂")); + } + } finally { client.close(); } +} +// The rejecting peer must not poison later connections in this process. +const healthy = await connectUdsClient(options); +try { assert.equal(await healthy.rpc("healthy"), "healthy"); } +finally { healthy.close(); } +console.log("SURVIVED"); diff --git a/packages/taskflow-control/test/store.test.ts b/packages/taskflow-control/test/store.test.ts index 87aa4eed..ada0ffd5 100644 --- a/packages/taskflow-control/test/store.test.ts +++ b/packages/taskflow-control/test/store.test.ts @@ -18,7 +18,7 @@ import { type CommandRecord, type ControlEvent, } from "../src/schema/index.ts"; -import { openControlStore } from "../src/store/index.ts"; +import { ControlStore, openControlStore } from "../src/store/index.ts"; const tempRoots: string[] = []; @@ -139,6 +139,108 @@ test("store: run.submit + events commit atomically and survive reopen (A3)", () } }); +test("store: duplicates fail closed without result disclosure or ledger writes, including after reopen", () => { + const storePath = makeStorePath(); + let store = openControlStore(storePath, { projectId: UUID, controlDomainId: UUID }); + const original = submitBatch(); + const committed = store.appendBatch(original); + const journalPath = path.join(storePath, "journal", "000001.jsonl"); + const journal = fs.readFileSync(journalPath); + const sequence = fs.readFileSync(path.join(storePath, "commit-seq.json")); + const projection = fs.readFileSync(path.join(storePath, "projections", "commands.json")); + try { + for (const restarted of [false, true]) { + if (restarted) { + store.close(); + store = openControlStore(storePath); + } + for (const overrides of [{}, { requestHash: "b".repeat(64) }, { callerPrincipal: "other-principal" }, { authorizationContextHash: "b".repeat(64) }]) { + assert.throws(() => store.appendBatch(submitBatch(overrides)), (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal(error.code, "TF_IDEMPOTENCY_CONFLICT"); + assert.equal(error.sideEffects, "none"); + assert.match(error.message, /authorized command-result replay is not implemented/); + return true; + }); + assert.equal(store.commitSeq, committed.commitSeq); + assert.equal(store.status, "healthy"); + assert.deepEqual(store.readCommand(original.command.commandId), committed.command); + assert.deepEqual(fs.readFileSync(journalPath), journal, "duplicate must not append events"); + assert.deepEqual(fs.readFileSync(path.join(storePath, "commit-seq.json")), sequence); + assert.deepEqual(fs.readFileSync(path.join(storePath, "projections", "commands.json")), projection); + } + } + } finally { + store.close(); + } +}); + +test("store: authority records are isolated from input and returned object mutations", () => { + const storePath = makeStorePath(); + let store = openControlStore(storePath, { projectId: UUID, controlDomainId: UUID }); + try { + const batch = submitBatch({ responseArtifactRef: { + digest: "sha256:" + SHA256, size: 1, mediaType: "text/plain", storageClass: "project", redactionClass: "none", + } }); + const expectedCommand = structuredClone(batch.command); + const expectedHeader = structuredClone(store.header); + const committed = store.appendBatch(batch); + const journalPath = path.join(storePath, "journal", "000001.jsonl"); + const originalJournal = fs.readFileSync(journalPath); + batch.command.responseArtifactRef!.digest = "mutated-input"; + batch.events[0]!.payload = { kind: "command.recorded", commandId: UUID }; + committed.command.callerPrincipal = "mutated-return"; + committed.command.responseArtifactRef!.size = 99; + const read = store.readCommand(UUID2)!; + read.commandId = UUID; + read.responseArtifactRef!.mediaType = "mutated-read"; + const header = store.header; + header.projectId = UUID2; + header.directoryBinding.canonicalPath = "mutated-header"; + const snapshot = store.snapshot(); + snapshot.header.controlDomainId = UUID2; + snapshot.header.directoryBinding.canonicalPath = "mutated-snapshot"; + assert.deepEqual(store.header, expectedHeader); + assert.deepEqual(store.readCommand(UUID2), expectedCommand); + assert.deepEqual(fs.readFileSync(journalPath), originalJournal); + // A later append serializes the full command index. An aliased record + // must not poison that projection or the identity of subsequent events. + const next = submitBatch({ commandId: UUID }); + store.appendBatch(next); + const projection = JSON.parse(fs.readFileSync(path.join(storePath, "projections", "commands.json"), "utf8")); + assert.deepEqual(projection[UUID2], expectedCommand); + store.close(); + store = openControlStore(storePath); + assert.deepEqual(store.header, expectedHeader); + assert.deepEqual(store.readCommand(UUID2), expectedCommand); + assert.equal(store.commitSeq, 2); + } finally { + store.close(); + } +}); + +test("store: constructor isolates recovered authority records from caller-owned inputs", () => { + const storePath = makeStorePath(); + const opened = openControlStore(storePath); + const header = opened.header; + opened.close(); + const command = submitBatch({ responseArtifactRef: { + digest: "sha256:" + SHA256, size: 1, mediaType: "text/plain", storageClass: "project", redactionClass: "none", + } }).command; + const expectedHeader = structuredClone(header); + const expectedCommand = structuredClone(command); + const recovered = new ControlStore(storePath, header, 1, [command], () => {}); + try { + header.directoryBinding.canonicalPath = "mutated-constructor-input"; + command.callerPrincipal = "mutated-constructor-input"; + command.responseArtifactRef!.digest = "mutated-constructor-input"; + assert.deepEqual(recovered.header, expectedHeader); + assert.deepEqual(recovered.readCommand(command.commandId), expectedCommand); + } finally { + recovered.close(); + } +}); + test("store: a second live opener is rejected (A5 single writer)", () => { const storePath = makeStorePath(); const first = openControlStore(storePath); diff --git a/packages/taskflow-control/test/te-provider.test.ts b/packages/taskflow-control/test/te-provider.test.ts index 78bfd0c9..c85d1d6e 100644 --- a/packages/taskflow-control/test/te-provider.test.ts +++ b/packages/taskflow-control/test/te-provider.test.ts @@ -8,6 +8,8 @@ import { type TeExecutionAuthority, } from "../src/te-provider.ts"; import { ControlError } from "../src/errors.ts"; +import { Value } from "typebox/value"; +import { CancelResultSchema, CollectResultSchema, PollResultSchema, ReconcileResultSchema } from "../src/schema/transport.ts"; const CAPABILITIES = { resolution: "contained" as const, @@ -175,6 +177,43 @@ test("te-provider: only TE-shaped authorities can become a provider (fail closed ); }); +for (const scenario of ["unknown", "plain-accepted", "reopened", "incomplete-handle"] as const) { + test(`te-provider: ${scenario} handle cannot establish outcome or cancellation`, async () => { + const calls: string[] = []; + const te = fakeTeAuthority(calls); + if (scenario === "plain-accepted") { + te.submit = async () => { + calls.push("submit"); + return { outcome: "accepted", providerJobHandle: "te-job-1" }; + }; + } else if (scenario === "incomplete-handle") { + te.submit = async () => { + calls.push("submit"); + return { + outcome: "accepted", providerJobHandle: "te-job-1", + poll: async () => { calls.push("poll"); return { outcome: "accepted", status: "running" }; }, + } as never; + }; + } + let provider = createTeExecutionProvider(te); + if (scenario !== "unknown") { + assert.equal((await provider.submit({ preparationId: "prep-1", owner: owner(), controlDomainId: "d" })).outcome, "accepted"); + } + if (scenario === "reopened") provider = createTeExecutionProvider(te); + const request = { providerJobHandle: "te-job-1" }; + const results = [await provider.poll(request), await provider.cancel(request), await provider.collect(request), await provider.reconcile(request)]; + const schemas = [PollResultSchema, CancelResultSchema, CollectResultSchema, ReconcileResultSchema]; + for (const [index, result] of results.entries()) { + assert.equal(result.outcome, "ambiguous", "missing live evidence must not authorize releasing a committed slot"); + assert.ok(Value.Check(schemas[index]!, result)); + assert.equal("cancelled" in result, false); + assert.equal("status" in result, false); + assert.equal("providerState" in result, false); + } + assert.deepEqual(calls, scenario === "unknown" ? [] : ["submit"], "observation must never re-submit work or call an incomplete handle"); + }); +} + test("te-provider: a provider is always marked te-resources", () => { const provider = createTeExecutionProvider(fakeTeAuthority()); const typed: ExecutionProvider = provider; diff --git a/packages/taskflow-control/test/uds-client.test.ts b/packages/taskflow-control/test/uds-client.test.ts index 1a2a7e60..e51a87de 100644 --- a/packages/taskflow-control/test/uds-client.test.ts +++ b/packages/taskflow-control/test/uds-client.test.ts @@ -29,7 +29,7 @@ class TestSocket extends EventEmitter { destroy(): void { this.emit("close"); } } -async function connect(t: TestContext): Promise<{ client: UdsClient; socket: TestSocket }> { +async function connect(t: TestContext, maxFrameBytes?: number): Promise<{ client: UdsClient; socket: TestSocket }> { const socket = new TestSocket(); t.mock.method(net, "connect", () => { queueMicrotask(() => socket.emit("connect")); @@ -37,7 +37,7 @@ async function connect(t: TestContext): Promise<{ client: UdsClient; socket: Tes }); syncBuiltinESMExports(); t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); }); - const client = await connectUdsClient({ endpointPath: "/test-only", clientHello: hello, rpcTimeoutMs: 1_000 }); + const client = await connectUdsClient({ endpointPath: "/test-only", clientHello: hello, rpcTimeoutMs: 1_000, maxFrameBytes }); t.after(() => client.close()); return { client, socket }; } @@ -53,6 +53,47 @@ test("uds client: all coalesced replies settle without another data event", asyn assert.deepEqual(await results, ["one", "two", "three"]); }); +test("uds client: byte budget applies per frame, including split multibyte input", async (t) => { + const { client, socket } = await connect(t, 4096); + const payload = "界".repeat(1000); + const results = Promise.all([client.rpc("one"), client.rpc("two")]); + const first = success(1, payload); + socket.emit("data", first.slice(0, 500)); + // This event exceeds the limit in total, but each individual frame fits. + socket.emit("data", first.slice(500) + success(2, payload)); + assert.deepEqual(await results, [payload, payload]); +}); + +test("uds client: malformed failure rejects every pending RPC and stops pipelined replies", async (t) => { + const { client, socket } = await connect(t); + const results = Promise.allSettled([client.rpc("one"), client.rpc("two")]); + socket.emit("data", JSON.stringify({ type: "rpc-result", id: 1, ok: false }) + "\n" + success(2, "must not resolve")); + for (const result of await results) { + assert.equal(result.status, "rejected"); + if (result.status === "rejected") assert.equal((result.reason as ControlError).code, "TF_JOURNAL_UNAVAILABLE"); + } + await assert.rejects(client.rpc("after-close"), { code: "TF_JOURNAL_UNAVAILABLE" }); +}); + +test("uds client: a negative reply id fails closed and rejects all pending RPCs", async (t) => { + const { client, socket } = await connect(t); + const results = Promise.allSettled([client.rpc("one"), client.rpc("two")]); + socket.emit("data", success(-1, "invalid") + success(1, "must not resolve")); + for (const result of await results) { + assert.equal(result.status, "rejected"); + if (result.status === "rejected") assert.ok(result.reason instanceof ControlError); + } + await assert.rejects(client.rpc("after failure"), /closed/); +}); + +test("uds client: unterminated multibyte input closes at its byte limit", async (t) => { + const { client, socket } = await connect(t, 4096); + const result = client.rpc("one"); + socket.emit("data", "界".repeat(1000)); + socket.emit("data", "界".repeat(400)); + await assert.rejects(result, { code: "TF_JOURNAL_UNAVAILABLE" }); +}); + test("uds client: split frame followed by coalesced replies drains completely", async (t) => { const { client, socket } = await connect(t); const results = Promise.all([client.rpc("one"), client.rpc("two")]); diff --git a/packages/taskflow-control/test/uds-malformed.test.ts b/packages/taskflow-control/test/uds-malformed.test.ts index d2ff0074..4a5e733d 100644 --- a/packages/taskflow-control/test/uds-malformed.test.ts +++ b/packages/taskflow-control/test/uds-malformed.test.ts @@ -21,14 +21,14 @@ const HELLO: NegotiationHandshake = { const helloFrame = { type: "hello", hello: HELLO }; const mutateFrame = { type: "rpc", id: 1, method: "mutate", fencingEpoch: 1 }; -async function isolatedServer(t: TestContext, failEpoch = false) { +async function isolatedServer(t: TestContext, failEpoch = false, maxFrameBytes = 4096) { const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-bad-uds-")); const endpointPath = path.join(root, "control.sock"); const child = spawn(process.execPath, [ "--conditions=development", "--experimental-strip-types", "--unhandled-rejections=strict", path.join(import.meta.dirname, "fixtures", "uds-malformed-server.ts"), ], { - env: { ...process.env, TF_TEST_ENDPOINT: endpointPath, TF_TEST_FAIL_EPOCH: failEpoch ? "1" : "0" }, + env: { ...process.env, TF_TEST_ENDPOINT: endpointPath, TF_TEST_FAIL_EPOCH: failEpoch ? "1" : "0", TF_TEST_MAX_FRAME_BYTES: String(maxFrameBytes) }, stdio: ["ignore", "pipe", "pipe"], }); let stderr = ""; @@ -61,6 +61,10 @@ async function isolatedServer(t: TestContext, failEpoch = false) { } function exchangeUntilClosed(endpointPath: string, frames: unknown[]): Promise { + return exchangeWireUntilClosed(endpointPath, frames.map((frame) => JSON.stringify(frame)).join("\n") + "\n"); +} + +function exchangeWireUntilClosed(endpointPath: string, wire: string): Promise { return new Promise((resolve, reject) => { const socket = net.connect(endpointPath); let response = ""; @@ -69,7 +73,7 @@ function exchangeUntilClosed(endpointPath: string, frames: unknown[]): Promise { response += chunk; }); socket.on("error", () => { /* reset is a valid fail-closed outcome */ }); socket.on("close", () => { clearTimeout(timer); resolve(response); }); - socket.on("connect", () => { socket.write(frames.map((frame) => JSON.stringify(frame)).join("\n") + "\n"); }); + socket.on("connect", () => { socket.write(wire); }); }); } @@ -86,6 +90,53 @@ test("uds: malformed hello shapes cannot terminate the shared server", UNIX_ONLY } }); +test("uds: invalid RPC counters fail closed before mutation and leave the shared server healthy", UNIX_ONLY, async (t) => { + const server = await isolatedServer(t); + // Raw JSON is necessary: JSON.stringify would replace Infinity with null. + const invalidTokens = ["1e999", "-1", "0.5", "9007199254740992", "null", '"1"', "true"]; + const frames = [ + ...invalidTokens.map((token) => `{"type":"rpc","id":${token},"method":"mutate","fencingEpoch":1}`), + ...invalidTokens.map((token) => `{"type":"rpc","id":1,"method":"mutate","fencingEpoch":${token}}`), + '{"type":"rpc","method":"mutate","fencingEpoch":1}', + ]; + for (const frame of frames) { + const response = await exchangeWireUntilClosed(server.endpointPath, + `${JSON.stringify(helloFrame)}\n${frame}\n${JSON.stringify(mutateFrame)}\n`); + assert.match(response, /TF_PROTOCOL_INCOMPATIBLE/, frame); + assert.deepEqual(await server.probe(), { mutations: 0 }, frame); + } +}); + +test("uds: zero RPC id and omitted legacy fencing epoch remain legal", UNIX_ONLY, async (t) => { + const server = await isolatedServer(t); + const reply = await new Promise<{ id: number; ok: boolean; result: { fencingEpoch: number } }>((resolve, reject) => { + const socket = net.connect(server.endpointPath); + let buffer = ""; + const timer = setTimeout(() => { socket.destroy(); reject(new Error("legacy RPC timed out")); }, 2_000); + socket.setEncoding("utf8"); + socket.on("error", (error) => { clearTimeout(timer); socket.destroy(); reject(error); }); + socket.on("connect", () => socket.write(`${JSON.stringify(helloFrame)}\n${JSON.stringify({ type: "rpc", id: 0, method: "inspect-epoch" })}\n`)); + socket.on("data", (chunk: string) => { + buffer += chunk; + let newline: number; + while ((newline = buffer.indexOf("\n")) >= 0) { + const frame = JSON.parse(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + if (frame.type === "rpc-result") { + clearTimeout(timer); + socket.destroy(); + resolve(frame); + return; + } + } + }); + }); + assert.equal(reply.id, 0); + assert.equal(reply.ok, true); + assert.deepEqual(reply.result, { fencingEpoch: 0 }); + assert.deepEqual(await server.probe(), { mutations: 0 }); +}); + test("uds: frames pipelined after a failed connection never dispatch RPCs", UNIX_ONLY, async (t) => { const server = await isolatedServer(t); for (const frames of [ @@ -104,3 +155,102 @@ test("uds: an unexpected hello handler failure is contained to its connection", await exchangeUntilClosed(server.endpointPath, [helloFrame, mutateFrame]); assert.deepEqual(await server.probe(), { mutations: 0 }); }); + +test("uds: oversized UTF-8 frames and prehello slow-drip timeout only close the offender", UNIX_ONLY, async (t) => { + const server = await isolatedServer(t); + for (const prefix of ["", JSON.stringify(helloFrame) + "\n"]) { + await new Promise((resolve, reject) => { + const socket = net.connect(server.endpointPath); + socket.resume(); + const timer = setTimeout(() => { socket.destroy(); reject(new Error("oversized frame was not closed")); }, 1_000); + socket.on("error", () => {}); + socket.on("close", () => { clearTimeout(timer); resolve(); }); + socket.on("connect", () => socket.write(prefix + "界".repeat(1500))); + }); + assert.deepEqual(await server.probe(), { mutations: 0 }); + } + await new Promise((resolve, reject) => { + const socket = net.connect(server.endpointPath); + const start = Date.now(); + const drip = setInterval(() => socket.write(" "), 30); + const timer = setTimeout(() => { socket.destroy(); reject(new Error("slow-drip renewed hello deadline")); }, 1_000); + socket.on("error", () => {}); + socket.on("close", () => { clearTimeout(timer); clearInterval(drip); assert.ok(Date.now() - start < 900); resolve(); }); + }); + assert.deepEqual(await server.probe(), { mutations: 0 }); +}); + +test("uds: bounded large multibyte payload and coalesced frames remain usable", UNIX_ONLY, async (t) => { + const server = await isolatedServer(t); + const client = await connectUdsClient({ endpointPath: server.endpointPath, clientHello: HELLO, maxFrameBytes: 4096 }); + t.after(() => client.close()); + const payload = "界".repeat(1000); + assert.deepEqual(await Promise.all([client.rpc("echo", payload), client.rpc("echo", payload)]), [payload, payload]); +}); + +test("uds: a legitimate MiB-sized payload round-trips under the default client budget", UNIX_ONLY, async (t) => { + const server = await isolatedServer(t, false, 2 * 1024 * 1024); + const client = await connectUdsClient({ endpointPath: server.endpointPath, clientHello: HELLO }); + t.after(() => client.close()); + const payload = "x".repeat(1024 * 1024); + assert.equal(await client.rpc("echo", payload), payload); + assert.deepEqual(await server.probe(), { mutations: 0 }); +}); + +test("uds: malformed failure replies cannot crash a real client process", UNIX_ONLY, async (t) => { + const healthy = await isolatedServer(t); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-bad-peer-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const envelope = { code: "TF_COMMAND_FAILED", message: "expected", recoveryAction: "none", sideEffects: "none" }; + const failures = [ + { ok: false }, { ok: false, error: null }, { ok: false, error: "bad" }, + { ok: "false", error: envelope }, { error: envelope }, + { ok: false, error: { ...envelope, code: "NOT_CLOSED" } }, + { ok: false, error: { ...envelope, recoveryAction: "bad" } }, + { ok: false, error: { ...envelope, sideEffects: "bad" } }, + { ok: false, error: { ...envelope, commitSeq: "1" } }, + { ok: false, error: { ...envelope, message: "" } }, + { ok: false, error: { ...envelope, projectId: "not-a-uuid" } }, + { ok: false, error: { ...envelope, extra: true } }, + ]; + for (const stage of ["hello-ack", "rpc-result", "oversized-hello", "oversized-rpc"]) { + for (const [i, failure] of (stage.startsWith("oversized") ? [{}] : failures).entries()) { + const endpointPath = path.join(root, `${stage}-${i}.sock`); + const sockets = new Set(); + const bad = net.createServer((socket) => { + sockets.add(socket); + socket.on("error", () => {}); + socket.on("close", () => sockets.delete(socket)); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString(); + let index: number; + while ((index = buffer.indexOf("\n")) >= 0) { + const frame = JSON.parse(buffer.slice(0, index)); + buffer = buffer.slice(index + 1); + if (stage === "oversized-hello" || (stage === "oversized-rpc" && frame.type === "rpc")) socket.write("界".repeat(1500)); + else if (stage === "hello-ack" || frame.type === "rpc") socket.write(JSON.stringify({ type: stage, id: frame.id, ...failure }) + "\n"); + else socket.write(JSON.stringify({ type: "hello-ack", ok: true, serverHello: HELLO, fencingEpoch: 1 }) + "\n"); + } + }); + }); + await new Promise((resolve) => bad.listen(endpointPath, resolve)); + try { + const child = spawn(process.execPath, ["--conditions=development", "--experimental-strip-types", "--unhandled-rejections=strict", path.join(import.meta.dirname, "fixtures", "uds-malformed-client.ts")], { + env: { ...process.env, TF_TEST_BAD_ENDPOINT: endpointPath, TF_TEST_GOOD_ENDPOINT: healthy.endpointPath }, stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (chunk) => { output += chunk; }); + child.stderr.on("data", (chunk) => { output += chunk; }); + const timer = setTimeout(() => child.kill("SIGKILL"), 2_000); + const [code] = await once(child, "exit"); + clearTimeout(timer); + assert.equal(code, 0, `${stage} ${JSON.stringify(failure)}: ${output}`); + assert.match(output, /SURVIVED/); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => bad.close(() => resolve())); + } + } + } +}); diff --git a/packages/taskflow-control/test/uds-wire-hardening.test.ts b/packages/taskflow-control/test/uds-wire-hardening.test.ts new file mode 100644 index 00000000..d5e70b92 --- /dev/null +++ b/packages/taskflow-control/test/uds-wire-hardening.test.ts @@ -0,0 +1,297 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { test, type TestContext } from "node:test"; +import { startUdsServer, connectUdsClient } from "../src/uds.ts"; +import type { NegotiationHandshake } from "../src/schema/transport.ts"; + +const UNIX_ONLY = { skip: process.platform === "win32", timeout: 15_000 }; +const LIMIT = 512; +const HELLO: NegotiationHandshake = { + protocolMajor: 1, supportedReadSchemas: ["taskflow.wire.v1"], supportedWriteSchemas: ["taskflow.wire.v1"], + requiredFeatures: [], offeredFeatures: [], buildInfo: { packageVersion: "test", gitCommit: "test", schemaVersion: 1 }, +}; +const ACK = { type: "hello-ack", ok: true, serverHello: HELLO, fencingEpoch: 1 }; +const ERROR = { code: "TF_COMMAND_FAILED", message: "rejected", recoveryAction: "none", sideEffects: "none" }; +const line = (value: unknown): Buffer => Buffer.from(JSON.stringify(value) + "\n"); +function endpoint(t: TestContext): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-wire-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return path.join(root, "control.sock"); +} +function exactFrame(make: (text: string) => unknown, bytes = LIMIT): Buffer { + const prefix = "中🙂"; + const text = prefix + "x".repeat(bytes - Buffer.byteLength(JSON.stringify(make(prefix)))); + const frame = line(make(text)); + assert.equal(frame.length - 1, bytes, "limit counts UTF-8 JSON bytes excluding LF"); + return frame; +} +async function server(t: TestContext, helloTimeoutMs = 150) { + const endpointPath = endpoint(t); + let calls = 0; + const options = { + endpointPath, serverHello: HELLO, maxFrameBytes: LIMIT, helloTimeoutMs, + getFencingEpoch: () => 1, + handleRpc: async () => ({ calls: ++calls }), + }; + const service = await startUdsServer(options); + t.after(() => service.close()); + return { + endpointPath, + get calls() { return calls; }, + async healthy() { + const client = await connectUdsClient({ endpointPath, clientHello: HELLO }); + try { assert.ok(await client.rpc("healthy")); } + finally { client.close(); } + }, + }; +} +async function rawClient(t: TestContext, endpointPath: string) { + const socket = net.connect(endpointPath); + t.after(() => socket.destroy()); + socket.on("error", () => { /* remote rejection may reset */ }); + let received = ""; + let closed = false; + socket.on("data", (chunk: Buffer) => { received += chunk.toString(); }); + socket.on("close", () => { closed = true; }); + await new Promise((resolve, reject) => { socket.once("connect", resolve); socket.once("error", reject); }); + return { + socket, + async closedWithin(ms = 700) { + const deadline = Date.now() + ms; + while (!closed && Date.now() < deadline) await delay(10); + assert.equal(closed, true, "connection must close before watchdog, even without LF"); + }, + async frames(count: number) { + const deadline = Date.now() + 700; + while (received.split("\n").length - 1 < count && !closed && Date.now() < deadline) await delay(10); + const frames = received.trim().split("\n").filter(Boolean).map((text) => JSON.parse(text) as Record); + assert.equal(frames.length, count, received); + return frames; + }, + }; +} + +for (const terminated of [false, true]) { + test(`uds wire: server rejects oversized ${terminated ? "terminated" : "unterminated"} UTF-8 frame`, UNIX_ONLY, async (t) => { + const service = await server(t, 5_000); + const client = await rawClient(t, service.endpointPath); + client.socket.write(line({ type: "hello", hello: HELLO })); + await client.frames(1); + const oversized = exactFrame((params) => ({ type: "rpc", id: 1, method: "probe", params, fencingEpoch: 1 }), LIMIT + 1); + client.socket.write(terminated ? oversized : oversized.subarray(0, -1)); + await client.closedWithin(); + assert.equal(service.calls, 0); + await service.healthy(); + }); +} + +test("uds wire: server accepts exact byte boundary, split UTF-8 and coalesced frames over aggregate limit", UNIX_ONLY, async (t) => { + const service = await server(t); + const client = await rawClient(t, service.endpointPath); + const frame = exactFrame((params) => ({ type: "rpc", id: 1, method: "probe", params, fencingEpoch: 1 })); + client.socket.write(line({ type: "hello", hello: HELLO })); + const split = frame.indexOf(Buffer.from("中")) + 1; + client.socket.write(frame.subarray(0, split)); + await delay(20); + client.socket.write(frame.subarray(split)); + const coalesced = Buffer.concat(Array.from({ length: 20 }, (_, index) => line({ type: "rpc", id: index + 2, method: "probe", fencingEpoch: 1 }))); + assert.ok(coalesced.length > LIMIT); + client.socket.write(coalesced); + for (const response of await client.frames(22)) assert.equal(response.ok, true); + assert.equal(service.calls, 21); +}); + +for (const dripping of [false, true]) { + test(`uds wire: absolute hello deadline closes ${dripping ? "slow-drip" : "idle"} peers`, UNIX_ONLY, async (t) => { + const service = await server(t, 150); + const client = await rawClient(t, service.endpointPath); + let interval: NodeJS.Timeout | undefined; + if (dripping) interval = setInterval(() => client.socket.write(" "), 25); + try { await client.closedWithin(700); } + finally { clearInterval(interval); } + await service.healthy(); + }); +} + +test("uds wire: successful hello cancels the server deadline", UNIX_ONLY, async (t) => { + const service = await server(t, 100); + const client = await connectUdsClient({ endpointPath: service.endpointPath, clientHello: HELLO }); + t.after(() => client.close()); + await delay(200); + assert.ok(await client.rpc("after-deadline")); +}); + +async function clientProcess(t: TestContext, scenario: string, attack: (socket: net.Socket, frames: Record[]) => void) { + const endpointPath = endpoint(t); + const sockets = new Set(); + let connections = 0; + const peer = net.createServer((socket) => { + sockets.add(socket); + socket.on("error", () => {}); + socket.on("close", () => sockets.delete(socket)); + const healthy = ++connections > 1; + let buffer = ""; + const frames: Record[] = []; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => { + buffer += chunk; + let index: number; + while ((index = buffer.indexOf("\n")) >= 0) { + const frame = JSON.parse(buffer.slice(0, index)) as Record; + buffer = buffer.slice(index + 1); + frames.push(frame); + if (healthy) socket.write(line(frame.type === "hello" ? ACK : { type: "rpc-result", id: frame.id, ok: true, result: "healthy" })); + else attack(socket, frames); + } + }); + }); + t.after(async () => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => peer.close(() => resolve())); + }); + await new Promise((resolve, reject) => { peer.once("error", reject); peer.listen(endpointPath, resolve); }); + const child = spawn(process.execPath, ["--conditions=development", "--experimental-strip-types", "--unhandled-rejections=strict", path.join(import.meta.dirname, "fixtures", "uds-wire-client.ts")], { + env: { ...process.env, TF_TEST_ENDPOINT: endpointPath, TF_TEST_SCENARIO: scenario }, stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (chunk: Buffer) => { output += chunk.toString(); }); + child.stderr.on("data", (chunk: Buffer) => { output += chunk.toString(); }); + const code = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { child.kill("SIGKILL"); reject(new Error(`client fixture hung: ${output}`)); }, 5_000); + child.once("exit", (exitCode) => { clearTimeout(timer); resolve(exitCode); }); + child.once("error", reject); + }); + assert.equal(code, 0, output); + assert.match(output, /SURVIVED/); +} + +const badPayloads = [ + { name: "missing error", patch: {} }, + { name: "null error", patch: { error: null } }, + { name: "scalar error", patch: { error: "no" } }, + { name: "incomplete envelope", patch: { error: { code: ERROR.code, message: ERROR.message } } }, + { name: "invalid envelope fields", patch: { error: { ...ERROR, recoveryAction: "bogus", sideEffects: 7 } } }, + { name: "nonboolean ok", patch: { ok: "false", error: ERROR } }, + { name: "missing ok", patch: { ok: undefined, error: ERROR } }, +]; +for (const phase of ["hello", "rpc"] as const) { + for (const payload of badPayloads) { + test(`uds wire: client survives ${phase} ${payload.name} and settles pending work`, UNIX_ONLY, async (t) => { + await clientProcess(t, `${phase}-rejection`, (socket, frames) => { + if (phase === "hello") socket.write(line({ type: "hello-ack", ok: false, ...payload.patch })); + else if (frames.length === 1) socket.write(line(ACK)); + else if (frames.length === 3) socket.write(line({ type: "rpc-result", id: 1, ok: false, ...payload.patch })); + }); + }); + } +} +for (const terminated of [false, true]) { + test(`uds wire: client rejects oversized ${terminated ? "terminated" : "unterminated"} hello response promptly`, UNIX_ONLY, async (t) => { + await clientProcess(t, "hello-rejection", (socket) => { + const frame = exactFrame((message) => ({ type: "hello-ack", ok: false, error: { ...ERROR, message } }), LIMIT + 1); + socket.write(terminated ? frame : frame.subarray(0, -1)); + }); + }); + test(`uds wire: client rejects oversized ${terminated ? "terminated" : "unterminated"} response and cleans pending`, UNIX_ONLY, async (t) => { + await clientProcess(t, "rpc-rejection", (socket, frames) => { + if (frames.length === 1) socket.write(line(ACK)); + else if (frames.length === 3) { + const frame = exactFrame((result) => ({ type: "rpc-result", id: 1, ok: true, result }), LIMIT + 1); + socket.write(terminated ? frame : frame.subarray(0, -1)); + } + }); + }); +} + +test("uds wire: client accepts exact boundary with split multibyte UTF-8", UNIX_ONLY, async (t) => { + await clientProcess(t, "boundary", (socket, frames) => { + if (frames.length === 1) socket.write(line(ACK)); + else { + const frame = exactFrame((result) => ({ type: "rpc-result", id: 1, ok: true, result })); + const split = frame.indexOf(Buffer.from("中")) + 1; + socket.write(frame.subarray(0, split)); + setTimeout(() => socket.write(frame.subarray(split)), 20); + } + }); +}); + +test("uds wire: client accepts coalesced valid responses above aggregate limit", UNIX_ONLY, async (t) => { + await clientProcess(t, "coalesced", (socket, frames) => { + if (frames.length === 1) socket.write(line(ACK)); + else if (frames.length === 21) { + const responses = Buffer.concat(frames.slice(1).map((frame) => line({ type: "rpc-result", id: frame.id, ok: true, result: "中🙂" }))); + assert.ok(responses.length > LIMIT); + socket.write(responses); + } + }); +}); + +// Validate each discriminated reply before resolving or removing pending work. +const malformedHelloSuccess = [ + { name: "missing serverHello", patch: { serverHello: undefined } }, + { name: "null serverHello", patch: { serverHello: null } }, + { name: "scalar serverHello", patch: { serverHello: "hello" } }, + { name: "malformed serverHello", patch: { serverHello: { ...HELLO, requiredFeatures: {} } } }, + { name: "missing epoch", patch: { fencingEpoch: undefined } }, + { name: "null epoch", patch: { fencingEpoch: null } }, + { name: "string epoch", patch: { fencingEpoch: "1" } }, + { name: "fractional epoch", patch: { fencingEpoch: 1.5 } }, + { name: "negative epoch", patch: { fencingEpoch: -1 } }, + { name: "unsafe epoch", patch: { fencingEpoch: Number.MAX_SAFE_INTEGER + 1 } }, +]; +for (const payload of malformedHelloSuccess) { + test(`uds wire: rejects successful hello with ${payload.name}`, UNIX_ONLY, async (t) => { + await clientProcess(t, "hello-rejection", (socket) => socket.write(line({ ...ACK, ...payload.patch }))); + }); +} +for (const ok of [true, false]) { + for (const [name, id] of [ + ["missing", undefined], ["null", null], ["string", "1"], + ["fractional", 1.5], ["negative", -1], ["unsafe", Number.MAX_SAFE_INTEGER + 1], + ] as const) { + test(`uds wire: rejects ${ok ? "success" : "failure"} RPC response with ${name} id`, UNIX_ONLY, async (t) => { + await clientProcess(t, "rpc-rejection", (socket, frames) => { + if (frames.length === 1) socket.write(line(ACK)); + else if (frames.length === 3) socket.write(line({ type: "rpc-result", id, ok, ...(ok ? { result: "invalid" } : { error: ERROR }) })); + }); + }); + } +} +for (const [phase, name, frame] of [ + ["hello", "success carrying error", { ...ACK, error: ERROR }], + ["hello", "failure carrying success fields", { ...ACK, ok: false, error: ERROR }], + ["rpc", "success carrying error", { type: "rpc-result", id: 1, ok: true, result: "contradictory", error: ERROR }], + ["rpc", "failure carrying result", { type: "rpc-result", id: 1, ok: false, result: "contradictory", error: ERROR }], +] as const) { + test(`uds wire: rejects contradictory ${phase} ${name}`, UNIX_ONLY, async (t) => { + await clientProcess(t, `${phase}-rejection`, (socket, frames) => { + if (phase === "hello") socket.write(line(frame)); + else if (frames.length === 1) socket.write(line(ACK)); + else if (frames.length === 3) socket.write(line(frame)); + }); + }); +} + +test("uds wire: preserves void and null results and ignores valid unmatched response IDs", UNIX_ONLY, async (t) => { + await clientProcess(t, "compatible-results", (socket, frames) => { + if (frames.length === 1) socket.write(line(ACK)); + else if (frames.length === 3) socket.write(Buffer.concat([ + line({ type: "rpc-result", id: 999, ok: true, result: "late unmatched response" }), + line({ type: "rpc-result", id: 1, ok: true }), + line({ type: "rpc-result", id: 2, ok: true, result: null }), + ])); + }); +}); + +test("uds wire: valid RPC failure preserves command error and usable connection", UNIX_ONLY, async (t) => { + await clientProcess(t, "valid-failure", (socket, frames) => { + if (frames.length === 1) socket.write(line(ACK)); + else if (frames.length === 2) socket.write(line({ type: "rpc-result", id: 1, ok: false, error: ERROR })); + else socket.write(line({ type: "rpc-result", id: 2, ok: true, result: "healthy" })); + }); +}); From fb733098f041ef4ea52f4c029d660d6a14885d49 Mon Sep 17 00:00:00 2001 From: heggria Date: Fri, 2 Oct 2026 23:20:05 +0800 Subject: [PATCH 14/30] fix(runtime): persist checkpoints and safely recover interrupted approvals --- .github/workflows/ci.yml | 3 +- CHANGELOG.md | 7 +- conformance/workspace/README.md | 18 +- .../workspace/host-guarantee-matrix.json | 87 ++++++++ .../workspace/host-support-baseline.json | 193 ++++++++++++------ docs/internal/1.0.0-ga-scoreboard.md | 10 +- docs/internal/1.0.0-release-plan.md | 3 +- docs/internal/1.0.0-spec-closure.md | 75 +++++++ package.json | 3 +- packages/pi-taskflow/src/index.ts | 58 +++++- .../pi-taskflow/test/e2e-pi-crash-resume.mts | 103 ++++++++++ .../pi-taskflow/test/fixtures/pi1-provider.ts | 8 + .../fixtures/recovery-quiescence-child.mts | 65 ++++++ .../test/recovery-quiescence.test.ts | 98 +++++++++ .../pi-taskflow/test/run-durability.test.ts | 173 ++++++++++++++++ packages/taskflow-control/src/store/store.ts | 9 + packages/taskflow-control/test/store.test.ts | 33 +++ packages/taskflow-core/src/resume.ts | 71 ++++++- packages/taskflow-core/src/store.ts | 6 + .../test/effects-deliverables.test.ts | 10 +- .../test/workspace-baseline.test.ts | 70 +++++++ packages/taskflow-mcp-core/src/mcp/server.ts | 38 ++-- .../test/checkpoint-durability.test.ts | 101 +++++++++ scripts/smoke-packed-packages.mjs | 18 +- scripts/workspace-probes/run.mjs | 1 + 25 files changed, 1151 insertions(+), 110 deletions(-) create mode 100644 conformance/workspace/host-guarantee-matrix.json create mode 100644 docs/internal/1.0.0-spec-closure.md create mode 100644 packages/pi-taskflow/test/e2e-pi-crash-resume.mts create mode 100644 packages/pi-taskflow/test/fixtures/recovery-quiescence-child.mts create mode 100644 packages/pi-taskflow/test/recovery-quiescence.test.ts create mode 100644 packages/pi-taskflow/test/run-durability.test.ts create mode 100644 packages/taskflow-mcp-core/test/checkpoint-durability.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d331a6de..523dac76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,12 +102,13 @@ jobs: run: pnpm run test:pi:peers env: PI_TEST_PEER_VERSION: ${{ matrix.pi }} - - name: Build and exercise real Pi 1.0 CLI, children, and RPC approvals + - name: Build and exercise real Pi 1.0 CLI, approvals, and crash recovery if: matrix.pi == '1.0.0' run: | pnpm --filter taskflow-core build pnpm --filter pi-taskflow build pnpm run test:e2e-pi + pnpm run test:e2e-pi-crash-resume e2e: # Required status context: keep this literal stable; the job now covers all MCP hosts. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d348cf1..19914654 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,13 +10,13 @@ All notable changes to taskflow are documented here. This project follows [Keep ## [1.0.0] — 2026-10-02 -> The ten existing public Taskflow packages target npm `latest` through the authorized tag workflow after release gates pass. The mature DAG/DSL/host/declared-filesystem-transaction contract is the stable scope; the unfinished control-plane RFC is not a 1.0 completion claim. A dated changelog alone is not evidence of completed publication. +> Publication is blocked on historical accepted-spec closure and real Pi 1.0 compatibility. The ten existing public packages target npm `latest` only after these gates and the authorized tag workflow pass. A dated changelog is not evidence of completed publication; experimental packaging does not waive accepted requirements. ### Changed - Align the root, ten public packages, plugin manifests and all MCP installation templates to `1.0.0`; retain exact internal dependency rewriting, deterministic packed consumers, npm provenance and all security gates. - Validate the complete release contract before packing or publishing, including package membership, version/pin agreement, experimental-package isolation and dated release notes. -- Keep `taskflow-control` private and workspace-only, with proposed experimental wire contracts. Admission, parked approvals/CAS/receipts, global coordination, Windows pipes and WebUI remain outside the stable contract. CharterArc keeps its independent experimental release path. +- Keep `taskflow-control` private and workspace-only while its accepted decisions and proposed wire details await full implementation/acceptance. Admission, parked approvals/CAS/receipts, global coordination and WebUI remain incomplete and cannot be waived by this packaging boundary. CharterArc keeps its independent experimental release path. - Prepare Pi 1.0 compatibility and owning-run identity propagation; release acceptance requires current packed-extension, real-process, retry, approval, TUI/RPC and resume evidence. - Integrate dependency/Actions maintenance with retained Cisco skill-scan coverage. Historical scans using different analyzer versions do not prove historical findings were fixed. @@ -24,7 +24,10 @@ All notable changes to taskflow are documented here. This project follows [Keep - Let Pi finish transient retries and overflow compaction before deciding that an assistant error is terminal; preserve successful post-run compaction exits on Pi 0.80.3 and count tool-reported model usage once. - Route Pi RPC approval through supported dialogs; reject cancellation, unavailable dialogs and aborted edits. Validate shorthand calls without an explicit action through the actual Pi tool schema. +- Persist every Pi/MCP runtime checkpoint instead of dropping rapid updates. Recover a Pi foreground approval checkpoint only after the owner is proven dead, the physical project identity matches, and the DAG proves other work cannot still be dispatched. Fork a new run and preserve the original bytes; living/unknown owners and potentially active children remain rejected. - Reject cross-project attachment to the experimental single-store control host, preserve corrupt or identity-less ledgers, and isolate malformed UDS handshakes to the offending connection. +- Bound UDS frames and initial handshake time; validate response discriminants, IDs and epochs before use. Keep unknown TE handles ambiguous, isolate store records from caller mutation, and reject copied/moved/replaced store identities before journal recovery. +- Restore the default host baseline's strict evidence format. Separate six-host implementation notes from the normative sandbox allowlist; no target receives a new sandbox approval. - Make release reruns accept valid non-draft GitHub releases, validate real changelog dates, and require every package's release dist-tag before creating a stable release. - Correct macOS canonical-path test fixtures and wait for detached child completion before removing their workspaces. Add real Pi CLI and clean-installed package regressions alongside live Codex/Claude checks. - Update Next.js, sharp, baseline-browser-mapping, Undici and brace-expansion to patched versions; the complete workspace dependency audit, including development dependencies, reports zero vulnerabilities. diff --git a/conformance/workspace/README.md b/conformance/workspace/README.md index 1dd34385..a228e930 100644 --- a/conformance/workspace/README.md +++ b/conformance/workspace/README.md @@ -1,10 +1,20 @@ # Workspace host conformance `host-support-baseline.json` is the finite, normative allowlist for sandboxed -workspace execution. It starts empty. A host is not sandbox-conformant until an +workspace execution. Its four historical exact-target cells are resolve-only +observations with rejected sandbox decisions; no sandbox target is approved. +A host is not sandbox-conformant until an exact host binary digest, OS build, architecture, sandbox mechanism version, backend version, redacted evidence bundle, and owner approval are checked in. +[`host-guarantee-matrix.json`](./host-guarantee-matrix.json) is a separate +informational implementation matrix, including six host adapters and the +declared-write resource transaction. It is never authorization input to the +trusted loader. Hermes is marked structural-only: the adapter exists, but no +real Hermes CLI probe is checked in. Claude likewise has no checked-in exact +sandbox target in this baseline. Source support and an MCP binding test do not +authorize a filesystem sandbox. + Collect conservative local evidence with: ```bash @@ -26,5 +36,7 @@ construction additionally requires the exact process-local approval token minted by the matcher. Evidence must use the exact `tfws-probe:v1` schema. The factory rechecks the independently observed Host/OS/binary tuple and binds the complete canonical backend-capabilities digest, backend ID/version, baseline -policy ID, classification, and support-baseline ID. The current allowlist is -empty, so no host may advertise a native workspace sandbox on the 0.2.x release line. +policy ID, classification, and support-baseline ID. All historical cells remain +rejected, so no host may advertise a native workspace +sandbox. Historical binary/version/OS tuples do not authorize newer binaries; +the exact target and complete evidence must be reverified before any approval. diff --git a/conformance/workspace/host-guarantee-matrix.json b/conformance/workspace/host-guarantee-matrix.json new file mode 100644 index 00000000..2075077b --- /dev/null +++ b/conformance/workspace/host-guarantee-matrix.json @@ -0,0 +1,87 @@ +{ + "schemaVersion": 1, + "baselineId": "taskflow-workspace-trusted-effects-v0.3.0-mvp", + "notes": "Informational implementation matrix only; never input to the trusted sandbox baseline loader. Resolve-only does not enforce a FileBroker sandbox. Hermes has structural adapter support, with no checked-in live CLI probe.", + "cells": [ + { + "host": "pi", + "resourceDomain": "filesystem", + "capability": "pathref-resolve", + "status": "supported", + "guarantee": "resolve-only", + "evidence": "packages/taskflow-core/src/resources/execution.ts" + }, + { + "host": "codex", + "resourceDomain": "filesystem", + "capability": "pathref-resolve", + "status": "supported", + "guarantee": "resolve-only", + "evidence": "packages/taskflow-core/src/resources/execution.ts" + }, + { + "host": "claude", + "resourceDomain": "filesystem", + "capability": "pathref-resolve", + "status": "supported", + "guarantee": "resolve-only", + "evidence": "packages/taskflow-core/src/resources/execution.ts" + }, + { + "host": "opencode", + "resourceDomain": "filesystem", + "capability": "pathref-resolve", + "status": "supported", + "guarantee": "resolve-only", + "evidence": "packages/taskflow-core/src/resources/execution.ts" + }, + { + "host": "grok", + "resourceDomain": "filesystem", + "capability": "pathref-resolve", + "status": "supported", + "guarantee": "resolve-only", + "evidence": "packages/taskflow-core/src/resources/execution.ts" + }, + { + "host": "hermes", + "resourceDomain": "filesystem", + "capability": "pathref-resolve", + "status": "structural-only", + "guarantee": "resolve-only", + "evidence": "packages/taskflow-hosts/src/hermes-runner.ts + packages/hermes-taskflow/src/mcp/server.ts; no checked-in live Hermes CLI probe" + }, + { + "host": "*", + "resourceDomain": "filesystem", + "capability": "file-broker", + "status": "unsupported", + "guarantee": "none", + "evidence": "No host has passed FileBroker adversarial baseline; do not claim sandbox." + }, + { + "host": "node-local", + "resourceDomain": "filesystem", + "capability": "trusted-effects-resource-transaction", + "status": "supported", + "guarantee": "pathref-lease-journal-permit-commit-or-restore", + "evidence": "packages/taskflow-core/src/resources/file-transaction.ts + test/resource-file-transaction.test.ts" + }, + { + "host": "*", + "resourceDomain": "secret", + "capability": "secretref-backend", + "status": "unsupported", + "guarantee": "type-only-fail-closed", + "evidence": "SecretRef validated; no vault adapter in 0.3 MVP" + }, + { + "host": "*", + "resourceDomain": "service", + "capability": "serviceref-adapter", + "status": "unsupported", + "guarantee": "type-only-fail-closed", + "evidence": "ServiceRef validated; no live adapter in 0.3 MVP" + } + ] +} diff --git a/conformance/workspace/host-support-baseline.json b/conformance/workspace/host-support-baseline.json index 904d2643..9d03874e 100644 --- a/conformance/workspace/host-support-baseline.json +++ b/conformance/workspace/host-support-baseline.json @@ -1,79 +1,142 @@ { "schemaVersion": 1, "baselineId": "taskflow-workspace-trusted-effects-v0.3.0-mvp", - "notes": "Honest cells for 0.3 Trusted Effects MVP. resolve-only is not a FileBroker sandbox. Empty claims removed.", "cells": [ { - "host": "pi", - "resourceDomain": "filesystem", - "capability": "pathref-resolve", - "status": "supported", - "guarantee": "resolve-only", - "evidence": "packages/taskflow-core/src/resources/execution.ts" + "target": { + "host": "pi", + "hostVersion": "0.80.6", + "hostBinarySha256": "af302f231437eaf6f37691bce4b34234fcb626bcb5eb3910d4fc3f6519bf78ca", + "os": "macos", + "osVersion": "26.3", + "osBuild": "25D125", + "arch": "arm64", + "sandboxMechanismVersion": "sandbox-exec:25D125", + "backendId": "taskflow-resolve-only", + "backendCapabilityVersion": "1", + "backendCapabilitiesSha256": "51d4209b8df2e42a80e64a6e4c66c16c14bfe40854e0c149b73742549a8583c1", + "baselinePolicyId": "taskflow-resolve-only" + }, + "classification": "resolve-only", + "agent": { + "path": "conformance/workspace/results/macos-26.3-arm64-25D125.json", + "sha256": "1047183e9e64d2b347f0a9dd0066cccacab40d761125e5aba152aefe37e484c4" + }, + "script": { + "path": "conformance/workspace/results/macos-26.3-arm64-25D125.json", + "sha256": "1047183e9e64d2b347f0a9dd0066cccacab40d761125e5aba152aefe37e484c4" + }, + "fileBroker": { + "path": "conformance/workspace/results/macos-26.3-arm64-25D125.json", + "sha256": "1047183e9e64d2b347f0a9dd0066cccacab40d761125e5aba152aefe37e484c4" + }, + "decision": { + "owner": "taskflow-conformance", + "status": "rejected", + "reason": "Historical exact-target resolve-only observation; AgentExecutor and FileBroker enforcement suites have not passed. Not approved for sandboxed execution." + } }, { - "host": "codex", - "resourceDomain": "filesystem", - "capability": "pathref-resolve", - "status": "supported", - "guarantee": "resolve-only", - "evidence": "packages/taskflow-core/src/resources/execution.ts" + "target": { + "host": "codex", + "hostVersion": "codex-cli 0.144.0-alpha.4", + "hostBinarySha256": "fba7b05624324ce44777b174fe6da1bcf08ef8cba634d85ecfaacbd8fa49aa8d", + "os": "macos", + "osVersion": "26.3", + "osBuild": "25D125", + "arch": "arm64", + "sandboxMechanismVersion": "sandbox-exec:25D125", + "backendId": "taskflow-resolve-only", + "backendCapabilityVersion": "1", + "backendCapabilitiesSha256": "51d4209b8df2e42a80e64a6e4c66c16c14bfe40854e0c149b73742549a8583c1", + "baselinePolicyId": "taskflow-resolve-only" + }, + "classification": "resolve-only", + "agent": { + "path": "conformance/workspace/results/macos-26.3-arm64-25D125.json", + "sha256": "1047183e9e64d2b347f0a9dd0066cccacab40d761125e5aba152aefe37e484c4" + }, + "script": { + "path": "conformance/workspace/results/macos-26.3-arm64-25D125.json", + "sha256": "1047183e9e64d2b347f0a9dd0066cccacab40d761125e5aba152aefe37e484c4" + }, + "fileBroker": { + "path": "conformance/workspace/results/macos-26.3-arm64-25D125.json", + "sha256": "1047183e9e64d2b347f0a9dd0066cccacab40d761125e5aba152aefe37e484c4" + }, + "decision": { + "owner": "taskflow-conformance", + "status": "rejected", + "reason": "Historical exact-target resolve-only observation; AgentExecutor and FileBroker enforcement suites have not passed. Not approved for sandboxed execution." + } }, { - "host": "claude", - "resourceDomain": "filesystem", - "capability": "pathref-resolve", - "status": "supported", - "guarantee": "resolve-only", - "evidence": "packages/taskflow-core/src/resources/execution.ts" + "target": { + "host": "opencode", + "hostVersion": "1.17.13", + "hostBinarySha256": "5124dfe29f6b4552a71a00d1b60427229c58576cb3e2bff5c884cd7c12d66bdb", + "os": "macos", + "osVersion": "26.3", + "osBuild": "25D125", + "arch": "arm64", + "sandboxMechanismVersion": "sandbox-exec:25D125", + "backendId": "taskflow-resolve-only", + "backendCapabilityVersion": "1", + "backendCapabilitiesSha256": "51d4209b8df2e42a80e64a6e4c66c16c14bfe40854e0c149b73742549a8583c1", + "baselinePolicyId": "taskflow-resolve-only" + }, + "classification": "resolve-only", + "agent": { + "path": "conformance/workspace/results/macos-26.3-arm64-25D125.json", + "sha256": "1047183e9e64d2b347f0a9dd0066cccacab40d761125e5aba152aefe37e484c4" + }, + "script": { + "path": "conformance/workspace/results/macos-26.3-arm64-25D125.json", + "sha256": "1047183e9e64d2b347f0a9dd0066cccacab40d761125e5aba152aefe37e484c4" + }, + "fileBroker": { + "path": "conformance/workspace/results/macos-26.3-arm64-25D125.json", + "sha256": "1047183e9e64d2b347f0a9dd0066cccacab40d761125e5aba152aefe37e484c4" + }, + "decision": { + "owner": "taskflow-conformance", + "status": "rejected", + "reason": "Historical exact-target resolve-only observation; AgentExecutor and FileBroker enforcement suites have not passed. Not approved for sandboxed execution." + } }, { - "host": "opencode", - "resourceDomain": "filesystem", - "capability": "pathref-resolve", - "status": "supported", - "guarantee": "resolve-only", - "evidence": "packages/taskflow-core/src/resources/execution.ts" - }, - { - "host": "grok", - "resourceDomain": "filesystem", - "capability": "pathref-resolve", - "status": "supported", - "guarantee": "resolve-only", - "evidence": "packages/taskflow-core/src/resources/execution.ts" - }, - { - "host": "*", - "resourceDomain": "filesystem", - "capability": "file-broker", - "status": "unsupported", - "guarantee": "none", - "evidence": "No host has passed FileBroker adversarial baseline; do not claim sandbox." - }, - { - "host": "node-local", - "resourceDomain": "filesystem", - "capability": "trusted-effects-resource-transaction", - "status": "supported", - "guarantee": "pathref-lease-journal-permit-commit-or-restore", - "evidence": "packages/taskflow-core/src/resources/file-transaction.ts + test/resource-file-transaction.test.ts" - }, - { - "host": "*", - "resourceDomain": "secret", - "capability": "secretref-backend", - "status": "unsupported", - "guarantee": "type-only-fail-closed", - "evidence": "SecretRef validated; no vault adapter in 0.3 MVP" - }, - { - "host": "*", - "resourceDomain": "service", - "capability": "serviceref-adapter", - "status": "unsupported", - "guarantee": "type-only-fail-closed", - "evidence": "ServiceRef validated; no live adapter in 0.3 MVP" + "target": { + "host": "grok", + "hostVersion": "grok 0.2.99 (b1b49ccb71a7)", + "hostBinarySha256": "01bcacec12e7c2a164d23975f1595400ff7f3cbd74f50b1858f6a5a14eb9ff81", + "os": "macos", + "osVersion": "26.3", + "osBuild": "25D125", + "arch": "arm64", + "sandboxMechanismVersion": "sandbox-exec:25D125", + "backendId": "taskflow-resolve-only", + "backendCapabilityVersion": "1", + "backendCapabilitiesSha256": "51d4209b8df2e42a80e64a6e4c66c16c14bfe40854e0c149b73742549a8583c1", + "baselinePolicyId": "taskflow-resolve-only" + }, + "classification": "resolve-only", + "agent": { + "path": "conformance/workspace/results/macos-26.3-arm64-25D125.json", + "sha256": "1047183e9e64d2b347f0a9dd0066cccacab40d761125e5aba152aefe37e484c4" + }, + "script": { + "path": "conformance/workspace/results/macos-26.3-arm64-25D125.json", + "sha256": "1047183e9e64d2b347f0a9dd0066cccacab40d761125e5aba152aefe37e484c4" + }, + "fileBroker": { + "path": "conformance/workspace/results/macos-26.3-arm64-25D125.json", + "sha256": "1047183e9e64d2b347f0a9dd0066cccacab40d761125e5aba152aefe37e484c4" + }, + "decision": { + "owner": "taskflow-conformance", + "status": "rejected", + "reason": "Historical exact-target resolve-only observation; AgentExecutor and FileBroker enforcement suites have not passed. Not approved for sandboxed execution." + } } ] } diff --git a/docs/internal/1.0.0-ga-scoreboard.md b/docs/internal/1.0.0-ga-scoreboard.md index 546d945e..25040d58 100644 --- a/docs/internal/1.0.0-ga-scoreboard.md +++ b/docs/internal/1.0.0-ga-scoreboard.md @@ -14,21 +14,21 @@ and remote security evidence; pending rows must not be inferred from local passe |---|---|---| | Public scope/version pins | PASS (working tree) | `node scripts/verify-release-contract.mjs --candidate`: ten packages at 1.0.0; exact plugin/MCP pins; private experimental control | | Release guard regressions | PASS locally | 23 focused contract/date/dist-tag/provenance tests; includes execution of the workflow's existing-release branch and false booleans | -| Node 24 full suite/typecheck | PASS locally | `pnpm test`: 2554 passed, zero failed/skipped; added existing-project isolation case subsequently passed separately. `pnpm run typecheck` passes | +| Node 24 full suite/typecheck | PASS locally | `pnpm test`: 2655 passed before final review; the timed-approval fault case was added and fixed. A later 2657-case run found a test cleanup acknowledgement race; that race is fixed and final whole-suite verification is pending. `pnpm run typecheck` passes | | Node 22 full suite | REMOTE GATE | final PR head, not historical CI | | All-package build/DSL dist E2E | PASS locally | `pnpm run build`; `pnpm --filter taskflow-dsl run test:e2e` builds, packs and clean-installs the CLI, including rejection cases | | Deterministic ten-package clean consumer | PASS on Node 24; Node 22 remote gate | 27 explicit imports, 84 wildcard exports, six bins, repeated deterministic packs and clean npm installation | | Five MCP server E2E | PASS locally | all five stdio suites plus 16 comprehensive Codex MCP checks; fixture executors. TE/background/cancellation/resume also have full-suite regression coverage | -| Pi 1.0 packed real process | PASS locally | 17 cases use actual Pi CLI, packed extension and spawned children; deterministic local provider, no live model. Covers shorthand, map/reduce, context handshake, codemode, retry/retry exhaustion, usage, terminal reap, cancellation and RPC approval | -| Pi identity/resume and legacy lifecycle | BLOCKED: real foreground crash recovery | ordinary resume and real 0.80.3 compaction replay pass. Independent real Pi 1.0 SIGKILL acceptance found dropped rapid checkpoints and fresh-host rejection of interrupted `running` runs; fixes and dead-owner/quiescence acceptance are in progress | +| Pi 1.0 packed real process | PASS locally | 17 standard cases plus rapid/slow SIGKILL recovery use actual Pi CLI, packed extension and spawned children; deterministic local provider, no live model. Covers shorthand, map/reduce, context handshake, codemode, retry/retry exhaustion, usage, terminal reap, cancellation and RPC approval | +| Pi identity/resume and legacy lifecycle | PASS for untimed, quiescent approval recovery; final fault review pending | real Pi rapid/slow SIGKILL → fresh Pi resume preserves completed effects once and parent bytes, rejects living owners and propagates new child identity. Timed approval/in-flight-child cases are excluded from automatic recovery; Pi 0.80.3 compaction replay remains covered | | Pi TUI interactive and RPC/headless approval | PASS at stated scope | actual PTY: fullscreen approve/reject, normal approve/Escape. Real RPC: approve/reject/cancel/edit/edit-cancel/abort; print rejects. Long preview/confirmation/abort/error paths also covered by component/requester tests; not every terminal/keyboard combination exercised | | Current live host executors | PASS for available Codex and Claude | Codex `gpt-6.1-sol`/high: 3-phase fruit→uppercase→TrustedEffects persistence, 39.2s. Claude `haiku`: 2-phase output transfer, 21.3s. Both use real authenticated CLIs | | OpenCode/Grok/Hermes live execution | NOT RUN | binaries unavailable locally; MCP fixture passes do not establish live model acceptance | -| Process SIGKILL/recovery/restore/cleanup | PARTIAL: foreground gap under repair | existing crash recovery/writer races/resource restoration/detached cleanup tests pass; they did not cover the newly reproduced Pi foreground SIGKILL chain. UDS response/size/deadline hardening passes 54 independent process tests | +| Process SIGKILL/recovery/restore/cleanup | PASS at tested scope; final fault review pending | actual Pi foreground crash recovery and parallel-child EACCES rejection now supplement existing detached/resource tests. UDS has 54 independent process tests; full control suite185/185 includes copy/move/replaced-directory rejection. Arbitrary in-flight recovery is not claimed | | Website export/audit | PASS locally | production static export with `/taskflow` base path; patched Next.js 16.3.6, sharp 0.35.4, baseline-browser-mapping 2.11.0, Undici 8.10.2 and brace-expansion 5.0.12; full `pnpm audit` (including development dependencies) reports zero vulnerabilities | | Remote security | REMOTE GATE | exact-head CodeQL and Plugin Security Scan, retaining Cisco coverage and severity/score gates | | Final exact-SHA remote CI | PENDING | do not reuse PR173/174 or historical beta green as integrated-head evidence | -| Historical accepted specification closure | BLOCKED | approved Control Plane plan S3–S7 and accepted P-ADRs contain unimplemented runtime contracts; earlier/later scope conflicts and proposed wire details require explicit reconciliation. A package being private is not an acceptance waiver | +| Historical accepted specification closure | BLOCKED | approved Control Plane plan S3–S7 and accepted P-ADRs contain unimplemented runtime contracts; the owner explicitly selected full Control Plane scope A; proposed wire details must be aligned with accepted behavior. A package being private is not an acceptance waiver | | Published artifacts and stable GitHub Release | BLOCKED / NOT DONE | release authorization is conditional on historical spec closure and Pi 1.0 compatibility; no merge, tag, npm publication or GitHub Release has occurred | The implemented mature surface is DAG/DSL/six-host/declared-target transactions. diff --git a/docs/internal/1.0.0-release-plan.md b/docs/internal/1.0.0-release-plan.md index 6b7abc36..b2f280f3 100644 --- a/docs/internal/1.0.0-release-plan.md +++ b/docs/internal/1.0.0-release-plan.md @@ -14,7 +14,8 @@ Pi 1.0 compatibility. No merge, tag or npm publication has occurred. The earlier stable/experimental packaging boundary below does not waive an accepted spec. Approved Control Plane S3-S7 and accepted ADR requirements must be traced to implementation and meaningful acceptance evidence before the release resumes. -Missing implementation remains blocked; relabeling it experimental is not closure. +The owner explicitly selected scope A: the complete approved Control Plane is +required for 1.0. Missing implementation remains blocked; relabeling it experimental is not closure. ## Contract diff --git a/docs/internal/1.0.0-spec-closure.md b/docs/internal/1.0.0-spec-closure.md new file mode 100644 index 00000000..4dd5421e --- /dev/null +++ b/docs/internal/1.0.0-spec-closure.md @@ -0,0 +1,75 @@ +# 1.0 specification closure ledger + +Status: **PUBLICATION BLOCKED**. Updated 2026-10-02. No merge, tag, npm +publication or GitHub Release has occurred. Passing the implemented surface's +tests does not complete the historical roadmap. + +The owner requires historical specifications to be implemented and accepted +before release. The earlier TE MVP excluded the old Control Plane, but the later +[approved Control Plane plan](./0.3-c-control-plane-plan.md) commits S3–S7 before +GA. The owner explicitly selected scope A: all of that Control Plane plan is +required for 1.0, with no publication before implementation and acceptance. +The current private package label cannot waive those commitments. Deferred +roadmap items below are visible gaps, not silently completed features. + +## Current implementation and actual regression evidence + +- Pi 1.0 retry/compaction, tool usage, supported RPC approvals and headless + rejection are implemented. Pi 0.80.3 compaction remains compatible. +- Pi and MCP save every runtime checkpoint. Real Pi quick/slow phase chains + survive SIGKILL at approval, reject a living owner, resume in a fresh Pi, + reuse completed effects once, preserve parent bytes and use the new run ID. +- Recovery requires ESRCH, unchanged canonical path/device/inode, a recorded + untimed approval wait and a DAG barrier against other runnable work. Unknown owners, + missing metadata, changed roots and potentially active children fail closed. + A real EACCES injection with a surviving parallel script tests the unsafe + stale-marker window. This does not claim arbitrary in-flight child recovery. +- UDS bounded frames, absolute hello deadline, full response-branch validation + and safe numeric counters have 54 independent process cases. Unknown TE + handles remain ambiguous. Store values cannot mutate authoritative records. + Copied/moved/replaced store directories are rejected before recovery writes; + the complete explicit clone/rebind/registry workflow is still missing. +- The checked-in host baseline loads through the strict evidence validator. + All four historical exact targets remain resolve-only and sandbox rejected. + Six-host implementation notes are separate; no Hermes live probe is claimed. + +## Control Plane coverage + +Source: [P-ADR index](./p-adrs/README.md), its P1–P16 documents, the approved +Control Plane plan and RFC v7.6 §23. Schema presence is not runtime acceptance. + +| Contract | Decision status | Remaining implementation/acceptance | +|---|---|---| +| P1 / P2 policy overlay and empty policy | Accepted | Trusted principal, live four-layer attenuation, revocation and compatibility corpus through controlled admission | +| P3 identity / registry | Accepted | Rebuildable registry, multi-mount, explicit move/rebind/adopt and new clone identity workflow | +| P4 negotiation / errors | Accepted | Directional write/read schema compatibility and cursor/status integration; UDS fault isolation is tested | +| P5 phase/state contract | Accepted | Actual controlled run projections, bounded reconcile, unknown retaining capacity and no fabricated terminal receipt | +| P6 / P7 hashes, artifacts, plans | Accepted | Unified canonical request hashing, durable scoped artifact storage, live authorized reads, immutable linker/fragments and cache predicates | +| P8 enforcement | Proposed TE wire; approved product boundary | Real TE provider/admission integration and finalized claims/observations wire; resolve-only must not imply sandbox | +| P9 / P10 legacy and rollback | Accepted | Foreign-writer detection/freeze and explicit read-only forensic export/rollback tiers | +| P11 compaction / cursors | Accepted | Verified hash-linked checkpoint, retention/cursor floor and receipts that remain valid after compaction | +| P12 commands / reauthorization | Accepted | Verified caller plus live authorization, durable result artifact before journal, authorized same-command result replay without re-execution. Current duplicate rejection is safe but incomplete; response artifact references are not yet verified by storage | +| P13 bootstrap | Accepted | Actual controlled first run, fresh bundle registry discovery and multi-project integration; singleton/UDS foundation is tested | +| P14 store | Proposed wire; S3 storage gate approved | Complete artifact/secret/coordinator store contracts and migration/recovery acceptance beyond the current files-only journal | +| P15 approval | Proposed TE wire; approved approval behavior | Durable park/expiry/CAS, edit/cancel payload agreement, restart readmission and linked receipt | +| P16 coordinator | Accepted | Persistent global reservation/commit/release, fencing, unknown capacity retention and explicit operator intervention | +| Plan S6 / S7 | Approved later plan | Thin MCP/CLI integration and usable runs/approvals/receipt WebUI | + +`commands.submit` currently appends supplied journal data; it does not execute +TE. `runs.status` remains a placeholder. Caller-supplied audit fields are not +authentication. Do not resolve authorized replay by default-allow policy. + +## Core, DSL and host roadmap + +| Contract | Verified scope | Remaining scope | +|---|---|---| +| S0–S4 frozen architecture / DSL | FlowIR/hash, event-fold oracle, eligible kernel kinds, offline replay, frozen synchronous DSL and installed CLI | No new missing frozen MVP gate found; the fold oracle is not a mid-run process-kill acceptance test | +| Accepted S5 / full event architecture | Differential tests exist and pass | Kernel default-on, compatible resume/input hashes, expect support, durable event-log recovery and flagship recompute measurement | +| Full DSL horizon | Frozen S4 frontend is implemented | Multi-body loop, route, saga/compensate, watch and experimental exports were explicitly beyond original MVP; kept separate from the explicitly included Control Plane work | +| Native workspace roadmap | Declared-target resource transactions, resolve-only checks and honest baseline | Native multi-root, OS sandbox/FileBroker and live secret/service backends lack acceptance | +| Six hosts | Shared runners/MCP fixtures; real authenticated Codex and Claude checks; real Pi CLI/RPC/PTY | OpenCode/Grok/Hermes live binaries were unavailable locally; fixtures do not establish live provider acceptance | + +Required before publication: implement the included missing contracts, resolve +required wire details against the accepted decisions, pass meaningful +fault/real-host tests and exact-head CI, then verify actual registry artifacts +and upgrade behavior. See the [scoreboard](./1.0.0-ga-scoreboard.md). diff --git a/package.json b/package.json index 1b4b4c88..c9555c84 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "test:hermes": "PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --conditions=development --experimental-strip-types --test 'packages/hermes-taskflow/test/*.test.ts'", "test:e2e-hermes-mcp": "node --conditions=development --experimental-strip-types packages/hermes-taskflow/test/e2e-hermes-mcp.mts", "test:pi:peers": "node scripts/verify-pi-peers.mjs && pnpm run test:pi", - "test:e2e-pi": "node --experimental-strip-types packages/pi-taskflow/test/e2e-pi1.mts" + "test:e2e-pi": "node --experimental-strip-types packages/pi-taskflow/test/e2e-pi1.mts", + "test:e2e-pi-crash-resume": "node --experimental-strip-types packages/pi-taskflow/test/e2e-pi-crash-resume.mts" }, "devDependencies": { "@earendil-works/pi-agent-core": "1.0.0", diff --git a/packages/pi-taskflow/src/index.ts b/packages/pi-taskflow/src/index.ts index ade6cebd..9b76499e 100644 --- a/packages/pi-taskflow/src/index.ts +++ b/packages/pi-taskflow/src/index.ts @@ -12,6 +12,7 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { resolve as resolvePath } from "node:path"; +import { randomUUID } from "node:crypto"; import { StringEnum } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; @@ -98,6 +99,8 @@ import { getBuildInfo, forkRunForResume, validateResumeRequest, + resumeSourceAfterOwnerExit, + isQuiescentApprovalCheckpoint, type ResumeOverrides, cwdBridgeModeFromEnv, directoryIdentity, @@ -479,6 +482,7 @@ async function runFlow( flowSourceDirIdentity?: DirectoryIdentity, ): Promise { const state = existing ?? makeRunState(def, args, ctx.cwd, flowSourceFile, flowSourceDirIdentity); + state.foregroundOwner = { version: 1, pid: process.pid, instanceId: randomUUID(), startedAt: Date.now() }; const emit = (s: RunState, finalOutput?: string) => { onUpdate?.({ @@ -487,15 +491,22 @@ async function runFlow( }); }; - // Throttled persistence: avoid disk writes on every sub-item event. - let lastPersist = 0; + // Runtime persist is checkpoint-only; streaming/UI updates use onProgress. + // Every checkpoint must survive a process kill before the next UI frame. const cleanupConfig = { maxKeep: DEFAULT_KEPT_RUNS, maxAgeDays: DEFAULT_RUN_AGE_DAYS }; - const persistThrottled = (s: RunState) => { - const now = Date.now(); - if (now - lastPersist >= 1000) { - lastPersist = now; - saveRun(s, cleanupConfig); + const waitingApprovals = new Set(); + const lifecycleKey = (s: RunState) => JSON.stringify([s.status, Object.values(s.phases).map((phase) => [phase.id, phase.status]).sort()]); + let lastLifecycleKey = ""; + const persistCheckpoint = (s: RunState) => { + const running = Object.values(s.phases).filter((phase) => phase.status === "running").map((phase) => phase.id); + if (s.foregroundOwner) { + delete s.foregroundOwner.approvalWait; + if (running.length && running.every((id) => waitingApprovals.has(id)) && isQuiescentApprovalCheckpoint(s, running)) { + s.foregroundOwner.approvalWait = running.sort(); + } } + saveRun(s, cleanupConfig); + lastLifecycleKey = lifecycleKey(s); }; // ~8fps heartbeat drives all rendering: it naturally caps the frame rate @@ -509,9 +520,22 @@ async function runFlow( (heartbeat as { unref?: () => void }).unref?.(); } - const requestApproval = createApprovalRequester(ctx, def.name, signal); + const approver = createApprovalRequester(ctx, def.name, signal); + const requestApproval: RuntimeDeps["requestApproval"] = approver ? async (request) => { + // Save completed upstream phases and the waiting phase before requesting + // user input. SIGKILL cannot run a trailing timer or finally block. + waitingApprovals.add(request.phaseId); + try { + persistCheckpoint(state); + return await approver(request); + } finally { + waitingApprovals.delete(request.phaseId); + persistCheckpoint(state); + } + } : undefined; try { + persistCheckpoint(state); // Discover settings/agents inside try so a YAML/IO crash in // discoverAgents or readSubagentSettings (F-001) is caught and // the heartbeat timer is cleared by the finally block below. @@ -548,7 +572,11 @@ async function runFlow( agents, globalThinking: settings.globalThinking, signal, - persist: persistThrottled, + persist: persistCheckpoint, + // Phase starts arrive via onProgress before child execution. Persist + // their status changes to invalidate an earlier approval-only marker; + // streaming text/token updates retain the same key and never write. + onProgress: (s) => { if (lifecycleKey(s) !== lastLifecycleKey) persistCheckpoint(s); }, // Deterministic-replay trace (best-effort, fail-open). Records each // subagent call + runtime decisions to an append-only JSONL so a future // `replay` can re-evaluate the run offline. Absent in tests = no-op. @@ -1102,7 +1130,9 @@ export default function (pi: ExtensionAPI) { return errorResult(action, "action=resume requires 'runId'"); const prevR = loadRunDiagnosed(ctx.cwd, params.runId); if (!prevR.ok) return errorResult(action, describeLoadFailure(prevR, `Run "${params.runId}"`)); - const prev = prevR.value; + const source = resumeSourceAfterOwnerExit(prevR.value, { cwd: ctx.cwd }); + if (!source.ok) return errorResult(action, source.errors.join("; ")); + const prev = source.value; // Build overrides if any override field is supplied (requires phaseId). const hasOverrideField = params.resumeTask !== undefined || @@ -1126,6 +1156,14 @@ export default function (pi: ExtensionAPI) { const prefix = overrides ? "Invalid resume overrides:\n- " : ""; return errorResult(action, `${prefix}${resumable.errors.join(overrides ? "\n- " : "; ")}`); } + if (prevR.value.status === "running") { + const current = loadRunDiagnosed(ctx.cwd, params.runId); + if (!current.ok || JSON.stringify(current.value) !== JSON.stringify(prevR.value)) { + return errorResult(action, "Interrupted checkpoint changed during recovery admission; retry from its current state"); + } + const rechecked = resumeSourceAfterOwnerExit(current.value, { cwd: ctx.cwd }); + if (!rechecked.ok) return errorResult(action, rechecked.errors.join("; ")); + } const child = forkRunForResume(prev, { overrides, cwd: ctx.cwd, host: "pi" }); const result = await runFlow(child.def, child.args, ctx, signal, onUpdate as any, child); const header = `Resumed taskflow '${result.state.flowName}' as run ${result.state.runId}` + diff --git a/packages/pi-taskflow/test/e2e-pi-crash-resume.mts b/packages/pi-taskflow/test/e2e-pi-crash-resume.mts new file mode 100644 index 00000000..e8b87812 --- /dev/null +++ b/packages/pi-taskflow/test/e2e-pi-crash-resume.mts @@ -0,0 +1,103 @@ +/** Built adapter + real Pi CLI: hard kill at approval, then immutable recovery. */ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { defaultResolveInstalledPiCli } from "../dist/runner.js"; + +type Event = { type: string; method?: string; id?: string; options?: string[]; toolName?: string; result?: { details?: { state?: { runId: string; status: string; parentRunId?: string; phases: Record } }; content?: unknown }; isError?: boolean }; +const cli = defaultResolveInstalledPiCli(); +assert.ok(cli); +const provider = fileURLToPath(new URL("fixtures/pi1-provider.ts", import.meta.url)); +const extension = fileURLToPath(new URL("../dist/index.js", import.meta.url)); +for (const slow of [false, true]) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-crash-resume-")); + const cwd = path.join(root, "project"), agentDir = path.join(root, "agent"); + fs.mkdirSync(cwd); fs.mkdirSync(agentDir); + const env = { ...process.env, PI_OFFLINE: "1", PI_CODING_AGENT_DIR: agentDir, TASKFLOW_AGENT_DIR: agentDir, TASKFLOW_E2E_TOKEN: randomUUID(), TASKFLOW_E2E_CRASH_SLOW: slow ? "1" : "0" }; + delete env.PI_TASKFLOW_PI_BIN; delete env.PI_TASKFLOW_PI_ENTRY; + fs.writeFileSync(path.join(agentDir, "settings.json"), JSON.stringify({ + defaultProvider: "taskflow-e2e", defaultModel: "fixture", modelRoles: { executor: "taskflow-e2e/fixture" }, + taskflow: { piChild: { resourceProfile: "allowlist", extensions: [provider], terminalGraceMs: 200 } }, + })); + const children: ReturnType[] = []; + const closed = new Set>(); + function start(prompt: string, hold = false) { + const child = spawn(process.execPath, [cli!, "--offline", "--no-session", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-extensions", "-e", provider, "-e", extension, "--model", "taskflow-e2e/fixture", "--mode", "rpc"], { cwd, env, stdio: ["pipe", "pipe", "pipe"] }); + children.push(child); + let onApproval!: () => void; + const approval = new Promise((resolve) => { onApproval = resolve; }); + const events: Event[] = []; + let buffer = "", stderr = ""; + const done = new Promise<{ events: Event[]; code: number | null; signal: string | null }>((resolve, reject) => { + const timer = setTimeout(() => { clearTimeout(timer); child.kill("SIGKILL"); reject(new Error(`Pi timeout: ${prompt}\n${stderr}`)); }, 25_000); + child.stderr!.on("data", (data) => { stderr += String(data); }); + child.stdout!.on("data", (data) => { + buffer += String(data); + let index: number; + while ((index = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, index); buffer = buffer.slice(index + 1); + if (!line.trim()) continue; + let event: Event; + try { event = JSON.parse(line); } catch { clearTimeout(timer); child.kill("SIGKILL"); reject(new Error(`non-JSON Pi frame ${line}`)); return; } + events.push(event); + if (event.type === "extension_ui_request" && event.method === "select") { + onApproval(); + if (!hold) child.stdin!.write(JSON.stringify({ type: "extension_ui_response", id: event.id, value: event.options?.find((option) => option === "Approve") }) + "\n"); + } + if (event.type === "agent_settled") { child.stdin!.end(); child.kill("SIGTERM"); } + } + }); + child.once("error", (error) => { clearTimeout(timer); reject(error); }); + child.stdin!.on("error", (error) => { clearTimeout(timer); child.kill("SIGKILL"); reject(error); }); + child.once("close", (code, signal) => { closed.add(child); clearTimeout(timer); resolve({ events, code, signal }); }); + }); + // Observe failures immediately even while the caller waits for approval; + // the original promise still rejects when awaited by the acceptance flow. + void done.catch(() => {}); + child.stdin!.write(JSON.stringify({ type: "prompt", message: prompt }) + "\n"); + return { child, done, approval }; + } + function result(events: Event[]) { + const event = events.find((frame) => frame.type === "tool_execution_end" && frame.toolName === "taskflow"); + assert.ok(event, JSON.stringify(events)); + return event; + } + try { + const seed = start("HOST:crash-seed", true); + await Promise.race([seed.approval, seed.done.then(() => { throw new Error("seed exited before approval"); })]); + const runDir = path.join(cwd, ".pi", "taskflows", "runs", "real-crash"); + const parentPath = path.join(runDir, fs.readdirSync(runDir).find((name) => name.endsWith(".json"))!); + const parentBytes = fs.readFileSync(parentPath, "utf8"); + const parent = JSON.parse(parentBytes); + assert.equal(parent.phases.once.status, "done"); + assert.equal(parent.phases.second.status, "done"); + assert.equal(parent.foregroundOwner.pid, seed.child.pid); + assert.deepEqual(parent.foregroundOwner.approvalWait, ["review"]); + const living = result((await start(`HOST:crash-resume:${parent.runId}`).done).events); + assert.equal(living.isError, true, "a second Pi must not recover the live owner"); + assert.match(JSON.stringify(living.result), /alive or unobservable/); + seed.child.kill("SIGKILL"); + assert.equal((await seed.done).signal, "SIGKILL"); + const resumed = result((await start(`HOST:crash-resume:${parent.runId}`).done).events); + assert.equal(resumed.isError, false, JSON.stringify(resumed)); + const child = resumed.result!.details!.state!; + assert.equal(child.status, "completed"); + assert.equal(child.parentRunId, parent.runId); + assert.notEqual(child.runId, parent.runId); + assert.equal(child.phases.recover.output, child.runId); + assert.equal(fs.readFileSync(path.join(cwd, "crash-once.txt"), "utf8"), "once"); + assert.equal(fs.readFileSync(path.join(cwd, "crash-second.txt"), "utf8"), "twice"); + assert.equal(fs.readFileSync(parentPath, "utf8"), parentBytes); + console.log(`PASS ${slow ? "slow" : "rapid"} checkpoint -> live-owner rejection -> SIGKILL -> fresh Pi recovery; completed effects once, child identity current, parent bytes unchanged`); + } finally { + await Promise.all(children.filter((child) => !closed.has(child)).map((child) => new Promise((resolve) => { + child.once("close", () => resolve()); + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + }))); + fs.rmSync(root, { recursive: true, force: true }); + } +} diff --git a/packages/pi-taskflow/test/fixtures/pi1-provider.ts b/packages/pi-taskflow/test/fixtures/pi1-provider.ts index eebf35ca..dcd39d52 100644 --- a/packages/pi-taskflow/test/fixtures/pi1-provider.ts +++ b/packages/pi-taskflow/test/fixtures/pi1-provider.ts @@ -34,6 +34,13 @@ export default function (pi: ExtensionAPI) { } if (text.startsWith("HOST:")) { const which = text.slice(5); + if (which === "crash-seed") return fauxAssistantMessage(fauxToolCall("taskflow", { action: "run", define: { name: "real-crash", phases: [ + { id: "once", type: "script", run: (process.env.TASKFLOW_E2E_CRASH_SLOW === "1" ? "sleep 1.1; " : "") + "printf once >> crash-once.txt; printf FIRST" }, + { id: "second", type: "script", run: "printf twice >> crash-second.txt; printf SECOND", dependsOn: ["once"] }, + { id: "review", type: "approval", task: "Hold for deliberate process crash", dependsOn: ["second"] }, + { id: "recover", type: "agent", agent: "executor", task: "CHILD_IDENTITY", dependsOn: ["review"], final: true }, + ] } })); + if (which.startsWith("crash-resume:")) return fauxAssistantMessage(fauxToolCall("taskflow", { action: "resume", runId: which.slice(13) })); if (which === "version") return fauxAssistantMessage(fauxToolCall("taskflow", { action: "version" })); if (which === "codemode") return fauxAssistantMessage(fauxToolCall("codemode", { code: 'text(await tools.taskflow({action:"version"}));' })); if (which === "shorthand") return fauxAssistantMessage(fauxToolCall("taskflow", { task: "CHILD_OK", agent: "executor" })); @@ -52,6 +59,7 @@ export default function (pi: ExtensionAPI) { ] } })); throw new Error(`Unknown fixture host request: ${which}`); } + if (text.includes("CHILD_IDENTITY")) return fauxAssistantMessage(process.env.PI_SUBAGENT_RUN_ID ?? "MISSING_ID"); if (text.includes("CHILD_USAGE")) return fauxAssistantMessage(fauxToolCall("fixture_meter", {})); if (text.includes("WRITE_TOKEN")) return fauxAssistantMessage(fauxToolCall("ctx_write", { key: "handshake", value: handshake })); if (text.includes("READ_TOKEN")) return fauxAssistantMessage(fauxToolCall("ctx_read", { key: "handshake" })); diff --git a/packages/pi-taskflow/test/fixtures/recovery-quiescence-child.mts b/packages/pi-taskflow/test/fixtures/recovery-quiescence-child.mts new file mode 100644 index 00000000..d34b47e0 --- /dev/null +++ b/packages/pi-taskflow/test/fixtures/recovery-quiescence-child.mts @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import path from "node:path"; +import { syncBuiltinESMExports } from "node:module"; +import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent"; +import { validateToolArguments } from "@earendil-works/pi-ai"; +import { runsDir } from "../../../taskflow-core/src/store.ts"; +import registerTaskflow from "../../src/index.ts"; + +const [cwd, scenario, nonce] = process.argv.slice(2); +if (!cwd || !nonce || !["parallel", "linear", "timeout"].includes(scenario!)) throw new Error("invalid owned fixture arguments"); +let tool: ToolDefinition | undefined; +registerTaskflow({ on() {}, registerCommand() {}, registerTool(value: ToolDefinition) { if (value.name === "taskflow") tool = value; } } as unknown as ExtensionAPI); +if (!tool) throw new Error("taskflow tool not registered"); +let failures = 0; +const originalRename = fs.renameSync; +fs.renameSync = ((from: fs.PathLike, to: fs.PathLike) => { + if (scenario !== "linear" && String(to).startsWith(runsDir(cwd)) && String(to).endsWith(".json")) { + const state = JSON.parse(fs.readFileSync(from, "utf8")); + if (state.phases?.effectC?.status === "running" || (scenario === "timeout" && state.phases?.approvalA?.status === "done")) { + failures++; + throw Object.assign(new Error("injected checkpoint rename failure"), { code: "EACCES" }); + } + } + return originalRename(from, to); +}) as typeof fs.renameSync; +syncBuiltinESMExports(); + +const flowName = "quiescence-fixture"; +const effect = `const fs = require('node:fs'); +const nonce = ${JSON.stringify(nonce)}; +fs.writeFileSync('child-owner.json', JSON.stringify({pid:process.pid,nonce})); +fs.writeFileSync('child-active', 'active'); +setInterval(() => { + try { + const stop = JSON.parse(fs.readFileSync('child-stop.json', 'utf8')); + if (stop.nonce === nonce && stop.pid === process.pid) { + fs.writeFileSync('child-stopped', nonce); + process.exit(0); + } + } catch {} +}, 15); +setTimeout(() => process.exit(0), 15000);`; +const phases = scenario === "parallel" ? [ + { id: "approvalA", type: "approval", task: "Hold approval" }, + { id: "quickB", type: "script", run: [process.execPath, "-e", "setTimeout(() => process.stdout.write('ready'), 400)"] }, + { id: "effectC", type: "script", run: [process.execPath, "-e", effect], final: true }, +] : [ + { id: "upstream", type: "script", run: [process.execPath, "-e", "require('node:fs').writeFileSync('upstream-done', 'once'); process.stdout.write('reusable')"] }, + { id: "approvalA", type: "approval", task: "Hold approval", dependsOn: ["upstream"], ...(scenario === "timeout" ? { timeoutMs: 1000, onExpire: "approve" } : {}) }, + { id: "effectC", type: "script", run: [process.execPath, "-e", scenario === "timeout" ? effect : "require('node:fs').writeFileSync('downstream-ran', 'unsafe')"], dependsOn: ["approvalA"], final: true }, +]; +const args = validateToolArguments(tool, { type: "toolCall", id: "fixture", name: "taskflow", arguments: { define: { name: flowName, concurrency: 2, phases } } }); +const timer = setInterval(() => { + const flowDir = path.join(runsDir(cwd), flowName); + if (!fs.existsSync(flowDir)) return; + const file = fs.readdirSync(flowDir).find((name) => name.endsWith(".json")); + if (!file) return; + const saved = JSON.parse(fs.readFileSync(path.join(flowDir, file), "utf8")); + if (scenario !== "linear" ? !fs.existsSync(path.join(cwd, "child-active")) : !saved.foregroundOwner?.approvalWait) return; + clearInterval(timer); + console.log(JSON.stringify({ ready: true, runId: saved.runId, failures, nonce, filePath: path.join(flowDir, file) })); +}, 15); +await tool.execute("fixture", args, undefined, undefined, { + mode: "rpc", hasUI: true, cwd, ui: { select: async () => new Promise(() => {}) }, +} as never); diff --git a/packages/pi-taskflow/test/recovery-quiescence.test.ts b/packages/pi-taskflow/test/recovery-quiescence.test.ts new file mode 100644 index 00000000..ce486392 --- /dev/null +++ b/packages/pi-taskflow/test/recovery-quiescence.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { forkRunForResume, resumeSourceAfterOwnerExit } from "../../taskflow-core/src/resume.ts"; +import { loadRunDiagnosed, probeProcess } from "../../taskflow-core/src/store.ts"; + +const fixture = fileURLToPath(new URL("./fixtures/recovery-quiescence-child.mts", import.meta.url)); +async function until(check: () => boolean, message: () => string, timeout = 7000) { + const started = Date.now(); + while (!check()) { + if (Date.now() - started > timeout) assert.fail(message()); + await delay(20); + } +} + +for (const scenario of ["parallel", "linear", "timeout"] as const) { + test(`recovery quiescence: real Pi adapter ${scenario} checkpoint after owner SIGKILL`, { timeout: 15000 }, async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-quiescence-")); + fs.mkdirSync(path.join(cwd, ".pi")); + const nonce = randomUUID(); + const child = spawn(process.execPath, ["--conditions=development", "--experimental-strip-types", fixture, cwd, scenario, nonce], { + env: { ...process.env, PI_TASKFLOW_BUILTIN_AGENTS_DIR: "" }, stdio: ["ignore", "pipe", "pipe"], + }); + // Attach before any kill/exit, and wait for stream closure before removal. + const closed = once(child, "close"); + let output = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { output += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + try { + await until(() => output.includes('"ready":true'), () => `owned fixture did not reach checkpoint: ${output} ${stderr}`); + const ready = JSON.parse(output.split("\n").find((line) => line.includes('"ready":true'))!); + assert.equal(ready.nonce, nonce); + assert.ok(path.relative(fs.realpathSync(cwd), fs.realpathSync(ready.filePath)).startsWith(".pi")); + const parentBytes = fs.readFileSync(ready.filePath); + child.kill("SIGKILL"); + await closed; + const loaded = loadRunDiagnosed(cwd, ready.runId); + assert.ok(loaded.ok, JSON.stringify(loaded)); + assert.equal(loaded.value.status, "running"); + assert.equal(probeProcess(loaded.value.foregroundOwner!.pid), "dead"); + const recovered = resumeSourceAfterOwnerExit(loaded.value, { cwd }); + if (scenario !== "linear") { + assert.ok(ready.failures > 0, "EACCES must actually hit a continuation checkpoint"); + const owner = JSON.parse(fs.readFileSync(path.join(cwd, "child-owner.json"), "utf8")); + assert.equal(owner.nonce, nonce); + assert.equal(probeProcess(owner.pid), "alive", "mutating sibling survives the foreground owner"); + assert.deepEqual(Object.values(loaded.value.phases).filter((phase) => phase.status === "running").map((phase) => phase.id), ["approvalA"]); + assert.equal(loaded.value.foregroundOwner!.approvalWait, undefined, "parallel dispatch or auto-expiry cannot leave a recovery-authorized marker"); + assert.equal(recovered.ok, false, "never fork while an unrecorded sibling remains alive"); + if (!recovered.ok) assert.match(recovered.errors.join(" "), /quiescent approval checkpoint/); + } else { + assert.equal(loaded.value.phases.upstream!.status, "done"); + assert.equal(loaded.value.phases.upstream!.output, "reusable"); + assert.deepEqual(loaded.value.foregroundOwner!.approvalWait, ["approvalA"]); + assert.equal(fs.existsSync(path.join(cwd, "downstream-ran")), false); + assert.ok(recovered.ok, JSON.stringify(recovered)); + const fork = forkRunForResume(recovered.value, { cwd, host: "pi" }); + assert.notEqual(fork.runId, loaded.value.runId); + assert.equal(fork.parentRunId, loaded.value.runId); + assert.deepEqual(fork.phases.upstream, loaded.value.phases.upstream); + assert.equal(fork.foregroundOwner, undefined, "new host must supply its own owner and cannot inherit approvalWait"); + assert.equal(fork.phases.approvalA, undefined); + assert.equal(fork.foregroundInterruption?.kind, "approval-owner-exit"); + } + assert.deepEqual(fs.readFileSync(ready.filePath), parentBytes, "recovery admission never changes parent bytes"); + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + await closed; + const ownerFile = path.join(cwd, "child-owner.json"); + if (fs.existsSync(ownerFile)) { + const owner = JSON.parse(fs.readFileSync(ownerFile, "utf8")); + // Request self-exit through a private nonce-bound stop file and + // wait for its acknowledgement and OS exit. Never signal a stored + // PID, which could have been reused after the owner was killed. + assert.equal(owner.nonce, nonce); + assert.ok(Number.isSafeInteger(owner.pid) && owner.pid > 0 && owner.pid !== process.pid && owner.pid !== child.pid); + if (probeProcess(owner.pid) === "alive") { + fs.writeFileSync(path.join(cwd, "child-stop.json"), JSON.stringify({ nonce, pid: owner.pid })); + await until(() => { + try { return fs.readFileSync(path.join(cwd, "child-stopped"), "utf8") === nonce; } + catch { return false; } + }, () => "owned worker did not acknowledge its stop request", 5000); + assert.equal(fs.readFileSync(path.join(cwd, "child-stopped"), "utf8"), nonce); + } + await until(() => probeProcess(owner.pid) === "dead", () => "owned script worker did not settle after cleanup", 5000); + } + fs.rmSync(cwd, { recursive: true, force: true }); + } + }); +} diff --git a/packages/pi-taskflow/test/run-durability.test.ts b/packages/pi-taskflow/test/run-durability.test.ts new file mode 100644 index 00000000..8073d9d2 --- /dev/null +++ b/packages/pi-taskflow/test/run-durability.test.ts @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, renameSync, rmSync, symlinkSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { test } from "node:test"; +import type { ExtensionAPI, ExtensionToolContext, ToolDefinition } from "@earendil-works/pi-coding-agent"; +import { listRuns, directoryIdentity, resumeSourceAfterOwnerExit, isQuiescentApprovalCheckpoint, forkRunForResume, probeProcess, type RunState } from "taskflow-core"; +import registerTaskflow from "../src/index.ts"; + +test("Pi checkpoints: rapid script completions are on disk before opening approval", async () => { + const cwd = mkdtempSync(join(tmpdir(), "pi-checkpoint-")); + try { + let registered: ToolDefinition | undefined; + registerTaskflow({ on() {}, registerCommand() {}, registerTool(t: ToolDefinition) { if (t.name === "taskflow") registered = t; } } as unknown as ExtensionAPI); + assert.ok(registered); + let checkpoint: RunState | undefined; + const result = await registered.execute("checkpoint", { action: "run", define: { name: "rapid-checkpoints", phases: [ + { id: "one", type: "script", run: "printf FIRST" }, + { id: "two", type: "script", run: "printf SECOND", dependsOn: ["one"] }, + { id: "review", type: "approval", task: "Hold", dependsOn: ["two"] }, + ] } }, undefined, undefined, { cwd, mode: "rpc", hasUI: true, ui: { + notify() {}, async select() { checkpoint = listRuns(cwd)[0]; return "Approve"; }, + } } as unknown as ExtensionToolContext); + assert.equal(result.isError, false, JSON.stringify(result)); + assert.ok(checkpoint); + assert.equal(checkpoint.phases.one?.status, "done"); + assert.equal(checkpoint.phases.one.output, "FIRST"); + assert.equal(checkpoint.phases.two?.status, "done"); + assert.equal(checkpoint.phases.two.output, "SECOND"); + assert.deepEqual(checkpoint.foregroundOwner?.approvalWait, ["review"]); + } finally { rmSync(cwd, { recursive: true, force: true }); } +}); + +function checkpoint(cwd: string): RunState { + return { + runId: "interrupted", flowName: "owner", cwd, args: {}, createdAt: 1, updatedAt: 1, status: "running", + def: { name: "owner", phases: [{ id: "review", type: "approval", task: "Hold" }] }, + phases: { review: { id: "review", status: "running" } }, + invocationRootSnapshot: directoryIdentity(cwd), + foregroundOwner: { version: 1, pid: 2147483647, instanceId: "opaque-instance", startedAt: 1, approvalWait: ["review"] }, + }; +} + +test("orphan resume: only a dead-owner approval checkpoint forks without mutating parent", () => { + const cwd = mkdtempSync(join(tmpdir(), "pi-owner-")); + try { + const parent = checkpoint(cwd); + const before = JSON.stringify(parent); + const source = resumeSourceAfterOwnerExit(parent, { cwd, inspectProcess: () => "dead" }); + assert.ok(source.ok); + const child = forkRunForResume(source.value, { cwd, host: "pi" }); + assert.equal(child.parentRunId, parent.runId); + assert.notEqual(child.runId, parent.runId); + assert.equal(child.foregroundOwner, undefined); + assert.equal(child.foregroundInterruption?.kind, "approval-owner-exit"); + assert.equal(JSON.stringify(parent), before); + } finally { rmSync(cwd, { recursive: true, force: true }); } +}); + +test("orphan resume: alive, EPERM, other errors and same PID remain closed", () => { + const cwd = mkdtempSync(join(tmpdir(), "pi-owner-")); + try { + for (const code of [undefined, "EPERM", "EACCES", "EIO", "EINVAL"]) { + const parent = checkpoint(cwd); + const before = JSON.stringify(parent); + const source = resumeSourceAfterOwnerExit(parent, { cwd, inspectProcess: (pid) => probeProcess(pid, () => { + if (code) throw Object.assign(new Error("unobservable"), { code }); + }) }); + assert.equal(source.ok, false, String(code)); + assert.equal(JSON.stringify(parent), before); + } + const parent = checkpoint(cwd); + parent.foregroundOwner!.pid = process.pid; + parent.foregroundOwner!.instanceId = "different-opaque-token"; + assert.equal(resumeSourceAfterOwnerExit(parent, { cwd, inspectProcess: () => assert.fail("same PID is not death proof") }).ok, false); + assert.equal(resumeSourceAfterOwnerExit(checkpoint(cwd), { cwd, inspectProcess: () => { throw new Error("unexpected"); } }).ok, false); + } finally { rmSync(cwd, { recursive: true, force: true }); } +}); + +test("orphan resume: malformed owner and missing checkpoint/root evidence are rejected before probing", () => { + const cwd = mkdtempSync(join(tmpdir(), "pi-owner-")); + try { + for (const patch of [ + { foregroundOwner: undefined }, + ...[0, -1, 1.5, 2147483648, NaN].map((pid) => ({ foregroundOwner: { ...checkpoint(cwd).foregroundOwner!, pid } })), + { invocationRootSnapshot: undefined }, + { foregroundOwner: { ...checkpoint(cwd).foregroundOwner!, approvalWait: undefined } }, + { foregroundOwner: { ...checkpoint(cwd).foregroundOwner!, instanceId: "" } }, + { foregroundOwner: { ...checkpoint(cwd).foregroundOwner!, approvalWait: ["missing"] } }, + ]) { + assert.equal(resumeSourceAfterOwnerExit({ ...checkpoint(cwd), ...patch }, { cwd, inspectProcess: () => assert.fail("invalid evidence must not probe") }).ok, false); + } + const inflight = checkpoint(cwd); + inflight.def.phases.push({ id: "writer", type: "script", run: "sleep 10" }); + inflight.phases.writer = { id: "writer", status: "running" }; + assert.equal(resumeSourceAfterOwnerExit(inflight, { cwd, inspectProcess: () => "dead" }).ok, false); + inflight.foregroundOwner!.approvalWait = ["review", "writer"]; + assert.equal(resumeSourceAfterOwnerExit(inflight, { cwd, inspectProcess: () => "dead" }).ok, false); + } finally { rmSync(cwd, { recursive: true, force: true }); } +}); + +test("orphan resume: physical root replacement/different project rejects, canonical alias accepts", { skip: process.platform === "win32" }, () => { + const cwd = mkdtempSync(join(tmpdir(), "pi-owner-")); + const other = mkdtempSync(join(tmpdir(), "pi-other-")); + const alias = `${cwd}-alias`; + try { + const parent = checkpoint(cwd); + assert.equal(resumeSourceAfterOwnerExit(parent, { cwd: other, inspectProcess: () => "dead" }).ok, false); + symlinkSync(cwd, alias); + assert.equal(resumeSourceAfterOwnerExit(parent, { cwd: alias, inspectProcess: () => "dead" }).ok, true); + renameSync(cwd, `${cwd}-old`); + mkdirSync(cwd); + assert.equal(resumeSourceAfterOwnerExit(parent, { cwd, inspectProcess: () => "dead" }).ok, false, "same lexical path with replacement directory must reject"); + rmSync(cwd, { recursive: true }); + renameSync(`${cwd}-old`, cwd); + parent.invocationRootSnapshot!.inode = "different-inode"; + assert.equal(resumeSourceAfterOwnerExit(parent, { cwd, inspectProcess: () => "dead" }).ok, false); + assert.equal(resumeSourceAfterOwnerExit(checkpoint(cwd), { cwd: join(cwd, "gone"), inspectProcess: () => "dead" }).ok, false); + } finally { rmSync(alias, { force: true }); rmSync(cwd, { recursive: true, force: true }); rmSync(other, { recursive: true, force: true }); } +}); + +test("orphan resume: old failed/paused override repair stays compatible, terminal stays rejected", () => { + for (const status of ["failed", "paused", "completed", "blocked"] as const) { + const parent = checkpoint("/unused"); + parent.status = status; + delete parent.foregroundOwner; + const source = resumeSourceAfterOwnerExit(parent, { cwd: "/unused", inspectProcess: () => assert.fail("normal history requires no owner probe") }); + assert.ok(source.ok); + if (status === "failed" || status === "paused") assert.ok(forkRunForResume(source.value, { host: "pi" })); + else assert.throws(() => forkRunForResume(source.value), /not resumable/); + } +}); + +test("orphan resume: graph must block future siblings before a durable approval marker is valid", () => { + const parent = checkpoint("/unused"); + parent.def.phases.push({ id: "writer", type: "script", run: "sleep 10" }); + assert.equal(isQuiescentApprovalCheckpoint(parent, ["review"]), false, "not-yet-started runnable child invalidates quiescence"); + parent.def.phases[1].dependsOn = ["review"]; + assert.equal(isQuiescentApprovalCheckpoint(parent, ["review"]), true); + parent.def.phases[1].join = "any"; + assert.equal(isQuiescentApprovalCheckpoint(parent, ["review"]), false, "any-join cannot prove mandatory approval blocking"); +}); + +test("orphan resume: timed approval cannot prove quiescence even with a saved waiting marker", () => { + const cwd = mkdtempSync(join(tmpdir(), "pi-timed-owner-")); + try { + for (const onExpire of ["approve", "reject", "fail"] as const) { + const parent = checkpoint(cwd); + parent.def.phases[0].timeoutMs = 1000; + parent.def.phases[0].onExpire = onExpire; + assert.equal(isQuiescentApprovalCheckpoint(parent, ["review"]), false, onExpire); + assert.equal(resumeSourceAfterOwnerExit(parent, { cwd, inspectProcess: () => assert.fail("timed marker must reject before probing") }).ok, false); + } + } finally { rmSync(cwd, { recursive: true, force: true }); } +}); + +test("Pi checkpoint: same-layer runnable child prevents approval recovery even before its start", async () => { + const cwd = mkdtempSync(join(tmpdir(), "pi-parallel-checkpoint-")); + try { + let registered: ToolDefinition | undefined; + registerTaskflow({ on() {}, registerCommand() {}, registerTool(t: ToolDefinition) { if (t.name === "taskflow") registered = t; } } as unknown as ExtensionAPI); + assert.ok(registered); + let checkpoint: RunState | undefined; + await registered.execute("checkpoint", { action: "run", define: { name: "parallel-checkpoint", concurrency: 2, phases: [ + { id: "review", type: "approval", task: "Hold" }, + { id: "writer", type: "script", run: "sleep 0.1; printf OTHER" }, + ] } }, undefined, undefined, { cwd, mode: "rpc", hasUI: true, ui: { + notify() {}, async select() { checkpoint = listRuns(cwd)[0]; return "Approve"; }, + } } as unknown as ExtensionToolContext); + assert.ok(checkpoint); + assert.equal(checkpoint.foregroundOwner?.approvalWait, undefined); + } finally { rmSync(cwd, { recursive: true, force: true }); } +}); diff --git a/packages/taskflow-control/src/store/store.ts b/packages/taskflow-control/src/store/store.ts index 82e144b5..a58b9587 100644 --- a/packages/taskflow-control/src/store/store.ts +++ b/packages/taskflow-control/src/store/store.ts @@ -238,6 +238,15 @@ export function openControlStore(storePath: string, options: OpenControlStoreOpt return new ControlStore(resolved, header, 0, [], releaseWriter); } + // Recovery may rewrite projections, so verify the authoritative binding + // first. A copied/moved/replaced store is not proof of the same project. + // Never reset UUIDs or rewrite historical evidence to make it attach. + const binding = existingHeader.directoryBinding; + const stat = fs.statSync(resolved); + if (binding.canonicalPath !== fs.realpathSync(resolved) + || binding.device !== String(stat.dev) || binding.inode !== String(stat.ino)) { + throw durabilityFailed("control store directory identity changed; explicit verified rebind or a new project store is required"); + } const recovered = recoverFromJournal(resolved, existingHeader); return new ControlStore(resolved, existingHeader, recovered.commitSeq, recovered.commands, releaseWriter); } catch (error) { diff --git a/packages/taskflow-control/test/store.test.ts b/packages/taskflow-control/test/store.test.ts index ada0ffd5..770c679b 100644 --- a/packages/taskflow-control/test/store.test.ts +++ b/packages/taskflow-control/test/store.test.ts @@ -38,6 +38,39 @@ const UUID = "00000000-0000-0000-0000-000000000001"; const UUID2 = "00000000-0000-0000-0000-000000000002"; const SHA256 = "a".repeat(64); +function storeBytes(root: string): Record { + const files: Record = {}; + for (const name of fs.readdirSync(root, { recursive: true }) as string[]) { + const file = path.join(root, name); + if (fs.statSync(file).isFile()) files[name] = fs.readFileSync(file).toString("base64"); + } + return files; +} + +for (const replacement of ["copied", "moved", "replaced-in-place"] as const) { + test(`store: ${replacement} identity must not be silently reused or rewritten`, () => { + const originalPath = makeStorePath(); + const store = openControlStore(originalPath); + store.appendBatch(submitBatch()); + store.close(); + let candidatePath = `${originalPath}-candidate`; + if (replacement === "moved") fs.renameSync(originalPath, candidatePath); + else { + fs.cpSync(originalPath, candidatePath, { recursive: true }); + if (replacement === "replaced-in-place") { + fs.renameSync(originalPath, `${originalPath}-old`); + fs.renameSync(candidatePath, originalPath); + candidatePath = originalPath; + } + } + const before = storeBytes(candidatePath); + assert.throws(() => openControlStore(candidatePath), (error: unknown) => + error instanceof ControlError && error.code === "TF_DURABILITY_FAILED" + && /directory identity.*explicit.*rebind/i.test(error.message)); + assert.deepEqual(storeBytes(candidatePath), before, "identity and ledger evidence must remain byte-identical"); + }); +} + function submitBatch(overrides: Partial = {}): { command: CommandRecord; events: ControlEvent[]; diff --git a/packages/taskflow-core/src/resume.ts b/packages/taskflow-core/src/resume.ts index 289a0d3c..55d4b4be 100644 --- a/packages/taskflow-core/src/resume.ts +++ b/packages/taskflow-core/src/resume.ts @@ -19,8 +19,9 @@ */ import { validateTaskflow, dependenciesOf, type Phase, type Taskflow } from "./schema.ts"; -import { newRunId, type PhaseState, type RunState } from "./store.ts"; +import { newRunId, probeProcess, type ProcessLiveness, type PhaseState, type RunState } from "./store.ts"; import { getBuildInfo } from "./build-info.ts"; +import { directoryIdentity } from "./cwd-bridge.ts"; /** Overrides for re-running exactly one phase on resume. `phaseId` is required; * at least one of the other fields must be supplied. All values are applied to @@ -33,6 +34,73 @@ export interface ResumeOverrides { idleTimeout?: number; } +/** A durable approval marker is safe only when the graph itself prevents all + * other unfinished phases from dispatching before one of these waits settles. + * Best-effort progress persistence cannot prove that a concurrent child stopped. + * Timed approvals may settle independently of the UI callback, so a stale marker + * cannot prove that their downstream children remain blocked. */ +export function isQuiescentApprovalCheckpoint(prev: RunState, waitingIds: readonly string[]): boolean { + if (!waitingIds.length || new Set(waitingIds).size !== waitingIds.length) return false; + const waiting = new Set(waitingIds); + const phases = new Map(prev.def.phases.map((phase) => [phase.id, phase])); + const isStopped = (id: string) => ["done", "failed", "skipped"].includes(prev.phases[id]?.status ?? ""); + for (const id of waiting) { + const phase = phases.get(id); + if (phase?.type !== "approval" || phase.timeoutMs !== undefined || prev.phases[id]?.status !== "running") return false; + } + if (Object.values(prev.phases).some((phase) => phase.status === "running" && !waiting.has(phase.id))) return false; + const blockedByApproval = (id: string, visiting = new Set()): boolean => { + if (waiting.has(id)) return true; + if (isStopped(id) || visiting.has(id)) return false; + const phase = phases.get(id); + if (!phase || phase.join === "any") return false; + const next = new Set(visiting).add(id); + return dependenciesOf(phase).some((dependency) => blockedByApproval(dependency, next)); + }; + return prev.def.phases.every((phase) => isStopped(phase.id) || blockedByApproval(phase.id)); +} + +/** Prepare an orphaned foreground checkpoint for an immutable resume fork. + * Only a valid recorded PID whose OS probe proves ESRCH may be recovered. + * PID reuse, EPERM, unavailable birth tokens and missing metadata stay closed. + * The parent object/file is never changed and no signal other than 0 is sent. */ +export function resumeSourceAfterOwnerExit( + prev: RunState, + options: { cwd: string; inspectProcess?: (pid: number) => ProcessLiveness }, +): { ok: true; value: RunState } | { ok: false; errors: string[] } { + if (prev.status !== "running") return { ok: true, value: prev }; + const owner = prev.foregroundOwner; + if (!owner || owner.version !== 1 || !Number.isSafeInteger(owner.pid) || owner.pid <= 0 || owner.pid > 0x7fffffff + || typeof owner.instanceId !== "string" || !owner.instanceId.trim() + || !Number.isFinite(owner.startedAt) || owner.startedAt < 0) { + return { ok: false, errors: [`Run '${prev.runId}' is running without a verifiable foreground owner; refusing orphan recovery`] }; + } + const savedRoot = prev.invocationRootSnapshot; + const currentRoot = directoryIdentity(options.cwd); + if (!savedRoot || !currentRoot || savedRoot.canonicalPath !== currentRoot.canonicalPath + || savedRoot.device !== currentRoot.device || savedRoot.inode !== currentRoot.inode) { + return { ok: false, errors: [`Run '${prev.runId}' interrupted checkpoint belongs to a missing, replaced or different invocation root`] }; + } + const running = Object.values(prev.phases).filter((phase) => phase.status === "running").map((phase) => phase.id).sort(); + const waiting = owner.approvalWait; + if (!Array.isArray(waiting) || !waiting.every((id) => typeof id === "string") || !running.length || waiting.length !== running.length + || running.some((id, index) => [...waiting].sort()[index] !== id) || !isQuiescentApprovalCheckpoint(prev, waiting)) { + return { ok: false, errors: [`Run '${prev.runId}' has no proven quiescent approval checkpoint; in-flight children may still be executing`] }; + } + let liveness: ProcessLiveness = "unknown"; + if (owner.pid !== process.pid) { + try { liveness = (options.inspectProcess ?? probeProcess)(owner.pid); } catch { /* unknown is not dead */ } + } + if (liveness !== "dead") { + return { ok: false, errors: [`Run '${prev.runId}' foreground owner ${owner.pid} is alive or unobservable; refusing concurrent resume`] }; + } + // This stopped-state view exists only in memory for the existing validation + // and fork helpers. Historical 'running' and its owner remain on disk. + return { ok: true, value: { ...prev, status: "failed", foregroundInterruption: { + kind: "approval-owner-exit", ownerPid: owner.pid, ownerInstanceId: owner.instanceId, detectedAt: Date.now(), + } } }; +} + function resumeStatusErrors(prev: RunState): string[] { return prev.status === "failed" || prev.status === "paused" ? [] @@ -189,6 +257,7 @@ export function forkRunForResume( ...(prev.flowSourceFile !== undefined ? { flowSourceFile: prev.flowSourceFile } : {}), ...(prev.flowSourceDirIdentity !== undefined ? { flowSourceDirIdentity: structuredClone(prev.flowSourceDirIdentity) } : {}), parentRunId: prev.runId, + ...(prev.foregroundInterruption ? { foregroundInterruption: structuredClone(prev.foregroundInterruption) } : {}), // Preserve workspace provenance/authority so a resume cannot silently // escape or downgrade the parent's cwd boundary. ...(prev.invocationRootSnapshot !== undefined ? { invocationRootSnapshot: structuredClone(prev.invocationRootSnapshot) } : {}), diff --git a/packages/taskflow-core/src/store.ts b/packages/taskflow-core/src/store.ts index e8ec18ab..8b4f0612 100644 --- a/packages/taskflow-core/src/store.ts +++ b/packages/taskflow-core/src/store.ts @@ -203,6 +203,12 @@ export interface RunState { cwdRootBinding?: DirectoryIdentity; /** OS PID of a detached runner process (set only for background runs). */ pid?: number; + /** Foreground host instance that owns a running checkpoint. startedAt is + * audit metadata, not an OS birth token. Missing/unobservable owner identity + * must never be interpreted as proof that execution stopped. */ + foregroundOwner?: { version: 1; pid: number; instanceId: string; startedAt: number; approvalWait?: string[] }; + /** Audit of the narrow approval checkpoint recovered into this fork. */ + foregroundInterruption?: { kind: "approval-owner-exit"; ownerPid: number; ownerInstanceId: string; detectedAt: number }; /** True for runs spawned via `detach: true` (background execution). */ detached?: boolean; /** Wall-clock launch time for detached lifecycle/orphan diagnostics. */ diff --git a/packages/taskflow-core/test/effects-deliverables.test.ts b/packages/taskflow-core/test/effects-deliverables.test.ts index ce583f9b..c11b8cb4 100644 --- a/packages/taskflow-core/test/effects-deliverables.test.ts +++ b/packages/taskflow-core/test/effects-deliverables.test.ts @@ -157,10 +157,16 @@ test("deliverable:Overlap mutating paths denied", () => { // 7 host matrix honesty test("deliverable:HostMatrix FileBroker unsupported", () => { - const p = path.join(repoRoot, "conformance/workspace/host-support-baseline.json"); + const p = path.join(repoRoot, "conformance/workspace/host-guarantee-matrix.json"); const j = JSON.parse(fs.readFileSync(p, "utf8")) as { - cells: Array<{ capability: string; status: string; guarantee: string }>; + cells: Array<{ host: string; capability: string; status: string; guarantee: string }>; }; + assert.deepEqual(j.cells.filter((c) => c.capability === "pathref-resolve").map((c) => c.host).sort(), + ["claude", "codex", "grok", "hermes", "opencode", "pi"]); + const hermes = j.cells.find((c) => c.host === "hermes"); + assert.ok(hermes); + assert.equal(hermes.status, "structural-only"); + assert.equal(hermes.guarantee, "resolve-only"); const fb = j.cells.find((c) => c.capability === "file-broker"); assert.ok(fb, "file-broker cell required"); assert.equal(fb.status, "unsupported"); diff --git a/packages/taskflow-core/test/workspace-baseline.test.ts b/packages/taskflow-core/test/workspace-baseline.test.ts index 2fd0f598..51f30f93 100644 --- a/packages/taskflow-core/test/workspace-baseline.test.ts +++ b/packages/taskflow-core/test/workspace-baseline.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; @@ -7,6 +8,7 @@ import { fileURLToPath } from "node:url"; import { test } from "node:test"; import { FILE_BROKER_EVIDENCE_CHECKS, + HOST_PROBE_HOSTS, HOST_PROBE_VERSION, hostProbeTargetsEqual, isExactHostProbeTarget, @@ -20,6 +22,19 @@ import { type HostSupportBaseline, } from "../src/resources/baseline.ts"; +test("checked-in default host baseline verifies its actual evidence without granting a sandbox", () => { + const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); + const verified = loadVerifiedHostSupportBaseline({ repositoryRoot }); + assert.ok(verified.cells.length > 0, "the baseline must preserve the existing exact-target probe evidence"); + assert.deepEqual(validateHostSupportBaseline(verified), []); + assert.deepEqual(verified.cells.map((cell) => cell.target.host).sort(), ["codex", "grok", "opencode", "pi"]); + for (const cell of verified.cells) { + assert.equal(cell.classification, "resolve-only"); + assert.equal(cell.decision.status, "rejected"); + assert.equal(matchSandboxedHostTarget(verified, cell.target).ok, false); + } +}); + const target: HostProbeTarget = { host: "codex", hostVersion: "1.2.3", @@ -99,6 +114,61 @@ function fixture(classification: "sandboxed-single-root" | "resolve-only" = "san return { repositoryRoot, evidencePath, baselinePath, baseline: value }; } +for (const host of HOST_PROBE_HOSTS) { + test(`trusted baseline schema: ${host} fixture loads without granting sandbox authority`, () => { + const f = fixture("resolve-only"); + try { + const cell = f.baseline.cells[0]; + cell.target = { ...cell.target, host }; + cell.decision = { owner: "test", status: "rejected", reason: "synthetic host-schema fixture only" }; + const evidenceBody = JSON.stringify(evidenceBundle(result("resolve-only", cell.target))); + fs.writeFileSync(f.evidencePath, evidenceBody); + for (const kind of ["agent", "script", "fileBroker"] as const) cell[kind].sha256 = sha256(evidenceBody); + fs.writeFileSync(f.baselinePath, JSON.stringify(f.baseline)); + const verified = loadVerifiedHostSupportBaseline({ repositoryRoot: f.repositoryRoot }); + assert.equal(verified.cells[0].target.host, host); + assert.equal(matchSandboxedHostTarget(verified, cell.target).ok, false); + } finally { + fs.rmSync(f.repositoryRoot, { recursive: true, force: true }); + } + }); +} + +test("host baseline schema accepts exactly the six host identifiers and rejects aliases", () => { + assert.deepEqual([...HOST_PROBE_HOSTS].sort(), ["claude", "codex", "grok", "hermes", "opencode", "pi"]); + for (const host of ["unknown", "Pi", "*", ""]) { + const value = baseline("a".repeat(64), "resolve-only"); + value.cells[0].target = { ...value.cells[0].target, host: host as HostProbeTarget["host"] }; + assert.ok(validateHostSupportBaseline(value).some((error) => error.includes("target.host is invalid")), host); + } +}); + +test("probe collector honors the Hermes binary override without claiming sandbox support", { skip: process.platform === "win32" }, () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tfws-hermes-probe-fixture-")); + try { + const binary = path.join(root, "hermes-fixture"); + const body = '#!/bin/sh\nprintf "hermes-probe-fixture 1.0\\n"\n'; + fs.writeFileSync(binary, body, { mode: 0o755 }); + const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); + const proc = spawnSync(process.execPath, [path.join(repositoryRoot, "scripts/workspace-probes/run.mjs")], { + env: { ...process.env, PATH: "/usr/bin:/bin", PI_TASKFLOW_HERMES_BIN: binary }, + encoding: "utf8", + timeout: 15_000, + }); + assert.equal(proc.status, 0, proc.stderr); + const bundle = JSON.parse(proc.stdout) as HostProbeEvidenceBundle; + const observed = bundle.results.find((probe) => probe.target.host === "hermes"); + assert.ok(observed, "Hermes override must identify a target even when hermes is absent from PATH"); + assert.equal(observed.target.hostVersion, "hermes-probe-fixture 1.0"); + assert.equal(observed.target.hostBinarySha256, sha256(body)); + assert.equal(observed.classification, "resolve-only"); + assert.equal(observed.agent.pass, false); + assert.equal(observed.fileBroker.pass, false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + test("host baseline authorizes only a trusted-loader verified exact sandbox cell", () => { const sandboxed = fixture(); const resolveOnly = fixture("resolve-only"); diff --git a/packages/taskflow-mcp-core/src/mcp/server.ts b/packages/taskflow-mcp-core/src/mcp/server.ts index efdd9f94..af8c198f 100644 --- a/packages/taskflow-mcp-core/src/mcp/server.ts +++ b/packages/taskflow-mcp-core/src/mcp/server.ts @@ -45,6 +45,7 @@ import { lstatSync, readFileSync, realpathSync } from "node:fs"; import { basename, dirname, join, resolve, relative, isAbsolute } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; +import { randomUUID } from "node:crypto"; import { discoverAgents, executeTaskflow, @@ -91,7 +92,7 @@ import { import type { SubagentRunner, AgentConfig } from "taskflow-core"; import { getBuildInfo, type BuildInfo } from "taskflow-core"; import { builtinVerifiers, discoverVerifiers } from "taskflow-core"; -import { forkRunForResume, validateResumeRequest, type ResumeOverrides } from "taskflow-core"; +import { forkRunForResume, resumeSourceAfterOwnerExit, validateResumeRequest, type ResumeOverrides } from "taskflow-core"; import { runsDir, traceFilePath, @@ -1015,20 +1016,15 @@ export function makeToolHandlers( deps.trace = new FileTraceSink(traceFilePath(runsDir(cwd), state.flowName, state.runId)); if (args.incremental === true) (deps as RuntimeDeps & { cacheScopeDefault?: string }).cacheScopeDefault = "cross-run"; - // Persist run state (throttled + final) so taskflow_peek / resume can read - // intermediate phase outputs after the run — same contract as the pi adapter. + // Runtime persist is checkpoint-only, not a streaming update. Every + // completed phase must reach disk before subsequent work starts. const cleanupConfig = { maxKeep: settings.taskflow.maxKeptRuns, maxAgeDays: settings.taskflow.maxRunAgeDays, }; - let lastPersist = 0; - deps.persist = (s) => { - const now = Date.now(); - if (now - lastPersist >= 1000) { - lastPersist = now; - saveRun(s, cleanupConfig); - } - }; + deps.persist = (s) => saveRun(s, cleanupConfig); + state.foregroundOwner = { version: 1, pid: process.pid, instanceId: randomUUID(), startedAt: Date.now() }; + saveRun(state, cleanupConfig); let terminalPersistError: string | undefined; let res: Awaited>; @@ -1130,7 +1126,9 @@ export function makeToolHandlers( if (!runId) return textContent("taskflow_resume requires `runId`.", true); const prevR = loadRunDiagnosed(cwd, runId); if (!prevR.ok) return textContent(describeLoadFailure(prevR, `Run "${runId}"`), true); - const prev = prevR.value; + const source = resumeSourceAfterOwnerExit(prevR.value, { cwd }); + if (!source.ok) return textContent(source.errors.join("; "), true); + const prev = source.value; const hasOverrideField = args.task !== undefined || args.model !== undefined || args.timeout !== undefined || args.idleTimeout !== undefined; @@ -1151,6 +1149,14 @@ export function makeToolHandlers( const prefix = overrides ? "Invalid resume overrides:\n- " : ""; return textContent(`${prefix}${resumable.errors.join(overrides ? "\n- " : "; ")}`, true); } + if (prevR.value.status === "running") { + const current = loadRunDiagnosed(cwd, runId); + if (!current.ok || JSON.stringify(current.value) !== JSON.stringify(prevR.value)) { + return textContent("Interrupted run changed before resume admission; reload and retry.", true); + } + const admitted = resumeSourceAfterOwnerExit(current.value, { cwd }); + if (!admitted.ok) return textContent(admitted.errors.join("; "), true); + } const child = forkRunForResume(prev, { overrides, cwd, host }); const settings = readSubagentSettings(); const agentScope = child.def.agentScope ?? "both"; @@ -1173,8 +1179,12 @@ export function makeToolHandlers( maxKeep: settings.taskflow.maxKeptRuns, maxAgeDays: settings.taskflow.maxRunAgeDays, }; - let lastPersist = 0; - deps.persist = (s) => { if (Date.now() - lastPersist >= 1000) { lastPersist = Date.now(); saveRun(s, cleanupConfig); } }; + deps.persist = (s) => saveRun(s, cleanupConfig); + // MCP has no interactive approval requester, so it never records an + // approvalWait marker. A dead owner alone cannot authorize recovery of + // a potentially active script/agent child. + child.foregroundOwner = { version: 1, pid: process.pid, instanceId: randomUUID(), startedAt: Date.now() }; + saveRun(child, cleanupConfig); let terminalPersistError: string | undefined; const res = await executeTaskflow(child, deps).finally(() => { terminalPersistError = persistTerminalRun(child, cleanupConfig); diff --git a/packages/taskflow-mcp-core/test/checkpoint-durability.test.ts b/packages/taskflow-mcp-core/test/checkpoint-durability.test.ts new file mode 100644 index 00000000..6f6a09ee --- /dev/null +++ b/packages/taskflow-mcp-core/test/checkpoint-durability.test.ts @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { directoryIdentity, emptyUsage, listRuns, probeProcess, runsDir, saveRun, type RunState, type SubagentRunner, type AgentConfig, type Taskflow } from "taskflow-core"; +import { makeToolHandlers } from "../src/mcp/server.ts"; + +for (const mode of ["run", "resume"] as const) { + test(`MCP ${mode} persists consecutive completed phases before the next runner starts`, async (t) => { + const cwd = await mkdtemp(join(tmpdir(), "tf-mcp-checkpoint-")); + t.after(() => rm(cwd, { recursive: true, force: true })); + const agentsDir = join(cwd, ".pi", "agents"); + await mkdir(agentsDir, { recursive: true }); + await writeFile(join(agentsDir, "checkpoint-agent.md"), "---\nname: checkpoint-agent\ndescription: checkpoint test\n---\nTest agent.\n"); + // Deterministically exercise multiple checkpoints inside the former 1s + // throttle window; the runtime result is observed separately from disk. + const now = Date.now(); + t.mock.method(Date, "now", () => now); + const def: Taskflow = { + name: "durable-burst", + phases: [ + { id: "first", type: "agent", agent: "checkpoint-agent", task: "first" }, + { id: "second", type: "agent", agent: "checkpoint-agent", task: "second", dependsOn: ["first"] }, + { id: "inspect", type: "agent", agent: "checkpoint-agent", task: "inspect", dependsOn: ["second"], final: true }, + ], + }; + const parent: RunState = { + runId: "checkpoint-parent", flowName: def.name, def, args: {}, status: "failed", + phases: {}, createdAt: now, updatedAt: now, cwd, + }; + if (mode === "resume") saveRun(parent); + let checkpoint: RunState | undefined; + const calls: string[] = []; + const runner: SubagentRunner = { + runTask: async (_cwd, _agents, agent, task) => { + calls.push(task); + if (task === "inspect") checkpoint = listRuns(cwd).find((run) => run.runId !== parent.runId); + return { agent, task, output: `${task}-output`, stderr: "", exitCode: 0, usage: emptyUsage() }; + }, + }; + const tools = makeToolHandlers(cwd, runner); + const result = await (mode === "run" + ? tools.taskflow_run!({ define: def }) + : tools.taskflow_resume!({ runId: parent.runId })) as { isError?: boolean; content: Array<{ text: string }> }; + assert.equal(result.isError, false, result.content[0]?.text); + assert.deepEqual(calls, ["first", "second", "inspect"]); + assert.equal(checkpoint?.status, "running"); + for (const id of ["first", "second"]) { + assert.equal(checkpoint?.phases[id]?.status, "done", `${id} must already be durable before further execution`); + assert.equal(checkpoint?.phases[id]?.output, `${id}-output`); + } + }); +} + +for (const owner of ["alive", "dead-approval", "dead-inflight"] as const) { + test(`MCP resume applies shared foreground owner admission: ${owner}`, async (t) => { + const cwd = await mkdtemp(join(tmpdir(), "tf-mcp-owner-")); + t.after(() => rm(cwd, { recursive: true, force: true })); + let pid = process.pid; + if (owner !== "alive") { + const child = spawnSync(process.execPath, ["-e", ""], { timeout: 5_000 }); + assert.equal(child.status, 0); + pid = child.pid; + assert.equal(probeProcess(pid), "dead", "test child must be confirmed exited"); + } + const now = Date.now(); + const parent: RunState = { + runId: "owner-parent", flowName: "owner-flow", + def: { name: "owner-flow", phases: [{ id: "review", type: "approval", task: "Review?", final: true }] }, + args: {}, status: "running", phases: { review: { id: "review", status: "running" } }, + createdAt: now, updatedAt: now, cwd, invocationRootSnapshot: directoryIdentity(cwd), + foregroundOwner: { version: 1, pid, instanceId: "original-pi-host", startedAt: now, + ...(owner !== "dead-inflight" ? { approvalWait: ["review"] } : {}) }, + }; + saveRun(parent); + const parentPath = join(runsDir(cwd), parent.flowName, `${parent.runId}.json`); + const original = await readFile(parentPath, "utf8"); + const runner: SubagentRunner = { runTask: async () => { throw new Error("approval recovery must not spawn an agent"); } }; + const result = await makeToolHandlers(cwd, runner).taskflow_resume!({ runId: parent.runId }) as { + isError?: boolean; content: Array<{ text: string }>; + }; + assert.equal(result.isError, true, "headless approval must still reject"); + const runs = listRuns(cwd); + const fork = runs.find((run) => run.parentRunId === parent.runId); + if (owner === "dead-approval") { + assert.ok(fork, result.content[0]?.text); + assert.equal(fork.status, "blocked"); + assert.equal(fork.foregroundInterruption?.ownerInstanceId, "original-pi-host"); + assert.equal(fork.foregroundOwner?.pid, process.pid); + assert.notEqual(fork.foregroundOwner?.instanceId, "original-pi-host"); + assert.equal(fork.foregroundOwner?.approvalWait, undefined); + } else { + assert.equal(fork, undefined); + assert.equal(runs.length, 1); + assert.match(result.content[0]?.text ?? "", owner === "alive" ? /alive or unobservable/ : /quiescent approval/); + } + assert.equal(await readFile(parentPath, "utf8"), original, "historical parent must remain byte-identical"); + }); +} diff --git a/scripts/smoke-packed-packages.mjs b/scripts/smoke-packed-packages.mjs index 22a374cf..12ae992e 100644 --- a/scripts/smoke-packed-packages.mjs +++ b/scripts/smoke-packed-packages.mjs @@ -224,14 +224,22 @@ try { const installedPiTests = join(consumerDir, "pi-e2e", "test"); mkdirSync(join(installedPiTests, "fixtures"), { recursive: true }); symlinkSync(join(consumerDir, "node_modules", "pi-taskflow", "dist"), join(consumerDir, "pi-e2e", "dist"), "junction"); - for (const relative of ["e2e-pi1.mts", "fixtures/pi1-provider.ts"]) { + const piProcessSuites = [ + { file: "e2e-pi1.mts", timeoutMs: 420_000 }, + { file: "e2e-pi-crash-resume.mts", timeoutMs: 180_000 }, + ]; + for (const relative of [...piProcessSuites.map(({ file }) => file), "fixtures/pi1-provider.ts"]) { copyFileSync(join(repo, "packages", "pi-taskflow", "test", relative), join(installedPiTests, relative)); } process.stdout.write("packed Pi 1.0 real-process fixture regressions:\n"); - run(process.execPath, ["--experimental-strip-types", join(installedPiTests, "e2e-pi1.mts")], { - cwd: consumerDir, - stdio: "inherit", - }); + for (const { file, timeoutMs } of piProcessSuites) { + process.stdout.write(`packed Pi suite: ${file}\n`); + run(process.execPath, ["--experimental-strip-types", join(installedPiTests, file)], { + cwd: consumerDir, + stdio: "inherit", + timeout: timeoutMs, + }); + } const publicImports = [ "taskflow-core", diff --git a/scripts/workspace-probes/run.mjs b/scripts/workspace-probes/run.mjs index da67741c..a7f59409 100644 --- a/scripts/workspace-probes/run.mjs +++ b/scripts/workspace-probes/run.mjs @@ -83,6 +83,7 @@ const BIN_ENV = { claude: "PI_TASKFLOW_CLAUDE_BIN", opencode: "PI_TASKFLOW_OPENCODE_BIN", grok: "PI_TASKFLOW_GROK_BIN", + hermes: "PI_TASKFLOW_HERMES_BIN", }; function commandPath(command) { From 7467f6484ed76b42d2396e0f881d3685f2a74c37 Mon Sep 17 00:00:00 2001 From: Heggria Date: Sat, 3 Oct 2026 22:36:35 +0800 Subject: [PATCH 15/30] test(control): budget for sequential malformed UDS clients The 26 isolated clients each have a 2s watchdog, but shared a 15s aggregate deadline. Node 22 CI can exhaust that deadline solely from cumulative process startup under load. Allow 60s for the matrix while retaining each client's watchdog and all protocol assertions. Verified a 700ms per-client startup delay cancels the old test at 15s and passes the updated matrix in 28.6s. A deliberately stalled client still fails at the 2s watchdog. Node 22 full suite and Node 24 UDS regressions pass; no transport implementation changes. --- packages/taskflow-control/test/uds-malformed.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/taskflow-control/test/uds-malformed.test.ts b/packages/taskflow-control/test/uds-malformed.test.ts index 4a5e733d..acee23bc 100644 --- a/packages/taskflow-control/test/uds-malformed.test.ts +++ b/packages/taskflow-control/test/uds-malformed.test.ts @@ -197,7 +197,10 @@ test("uds: a legitimate MiB-sized payload round-trips under the default client b assert.deepEqual(await server.probe(), { mutations: 0 }); }); -test("uds: malformed failure replies cannot crash a real client process", UNIX_ONLY, async (t) => { +// This matrix starts 26 clients sequentially, each with a 2s watchdog below. +// Allow their cumulative startup cost plus server readiness/cleanup on busy CI +// runners; the per-client watchdog still catches a hung or leaked process. +test("uds: malformed failure replies cannot crash a real client process", { ...UNIX_ONLY, timeout: 60_000 }, async (t) => { const healthy = await isolatedServer(t); const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-bad-peer-")); t.after(() => fs.rmSync(root, { recursive: true, force: true })); From c9cde1d802c1ba645712504c3d1064e7eacac98d Mon Sep 17 00:00:00 2001 From: Heggria Date: Wed, 7 Oct 2026 21:28:45 +0800 Subject: [PATCH 16/30] docs(release): reconcile PR 142 with the unreleased 1.0 track --- CHANGELOG.md | 6 ++-- README.md | 12 +++---- README.zh-CN.md | 12 +++---- RELEASE.md | 11 +++++++ docs/internal/0.3.0-beta.2-release-plan.md | 2 +- docs/internal/0.3.0-ga-scoreboard.md | 2 +- docs/internal/0.3.0-release-plan.md | 2 +- docs/internal/1.0.0-ga-scoreboard.md | 6 ++++ docs/internal/1.0.0-release-plan.md | 32 +++++++++++++++++-- docs/internal/1.0.0-spec-closure.md | 6 ++++ .../en/compiler-runtime/typescript-dsl.mdx | 2 ++ website/content/docs/en/getting-started.mdx | 2 ++ .../content/docs/en/guides/claude-code.mdx | 2 +- website/content/docs/en/guides/codex.mdx | 2 +- website/content/docs/en/guides/grok-build.mdx | 2 +- website/content/docs/en/guides/hermes.mdx | 2 +- website/content/docs/en/guides/opencode.mdx | 2 +- website/content/docs/en/guides/pi.mdx | 2 +- website/content/docs/en/index.mdx | 12 ++++--- website/content/docs/en/trusted-effects.mdx | 4 +-- .../zh-cn/compiler-runtime/typescript-dsl.mdx | 2 ++ .../content/docs/zh-cn/getting-started.mdx | 2 ++ .../content/docs/zh-cn/guides/claude-code.mdx | 2 +- website/content/docs/zh-cn/guides/codex.mdx | 2 +- .../content/docs/zh-cn/guides/grok-build.mdx | 2 +- website/content/docs/zh-cn/guides/hermes.mdx | 2 +- .../content/docs/zh-cn/guides/opencode.mdx | 2 +- website/content/docs/zh-cn/guides/pi.mdx | 2 +- website/content/docs/zh-cn/index.mdx | 12 ++++--- .../content/docs/zh-cn/trusted-effects.mdx | 4 +-- 30 files changed, 109 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19914654..729d7b1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,9 @@ All notable changes to taskflow are documented here. This project follows [Keep - HOL plugin-scanner highs: dummy test credentials use scanner placeholders / short literals; no `eval (` in comments; smoke/runner no longer place a `${…}` template within 30 chars of `spawn`. Local scan 81/100, 0 high. **Not GA.** -## [1.0.0] — 2026-10-02 +## [1.0.0] — Unreleased -> Publication is blocked on historical accepted-spec closure and real Pi 1.0 compatibility. The ten existing public packages target npm `latest` only after these gates and the authorized tag workflow pass. A dated changelog is not evidence of completed publication; experimental packaging does not waive accepted requirements. +> **Draft PR #142 targets 1.0.0; `rc/0.3.0-beta.2` is its historical branch name.** Latest published prerelease remains `v0.3.0-beta.1.2`. Publication is blocked on historical accepted-spec closure and real Pi 1.0 compatibility. The ten existing public packages target npm `latest` only after these gates and the authorized tag workflow pass. A dated changelog is not evidence of completed publication; experimental packaging does not waive accepted requirements. ### Changed @@ -40,7 +40,7 @@ All notable changes to taskflow are documented here. This project follows [Keep ## [0.3.0-beta.2] — 2026-08-18 -> npm `beta` dist-tag. **Not GA.** Control Plane slice: S2 Unix UDS + S3-min files-only store. Does **not** implement `#137` (`/tf web`) or `#95` (adaptive-authority isolation). TypeBox contracts and `wire-freeze.md` remain **PROPOSED**. `taskflow-control` is **not** in the ten-package publish set (`publishConfig.tag` stays `next`). +> **Historical unpublished candidate; superseded by the 1.0.0 target above.** The intended channel was npm `beta`; no `v0.3.0-beta.2` tag or publication occurred. **Not GA.** Control Plane slice: S2 Unix UDS + S3-min files-only store. Does **not** implement `#137` (`/tf web`) or `#95` (adaptive-authority isolation). TypeBox contracts and `wire-freeze.md` remain **PROPOSED**. `taskflow-control` is **not** in the ten-package publish set (`publishConfig.tag` stays `next`). ### Added diff --git a/README.md b/README.md index b5848f5c..d90ad375 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ **taskflow is a declarative runtime for coding-agent workflows.** It turns a graph into a verifiable execution contract, runs phases in isolation, and keeps intermediate work out of the host conversation. In Taskflow 1.0, the contract also describes the effects a phase is allowed to propose. -> **Taskflow 1.0 public contract.** The ten public packages stabilize the existing DAG runtime, DSL, six host adapters and resource transactions for admitted declared filesystem targets. Pi interactive approvals are supported; MCP/headless approval auto-rejects. `taskflow-control` is private workspace experimental with proposed wire contracts; CharterArc retains a separate experimental release. The [1.0 release plan](./docs/internal/1.0.0-release-plan.md) defines the verification gates. Publication status is recorded in [GitHub Releases](https://github.com/heggria/taskflow/releases) and the [npm registry](https://www.npmjs.com/package/pi-taskflow). +> **Status: 1.0.0 development candidate — unreleased.** Latest published prerelease: `v0.3.0-beta.1.2`. Draft PR [#142](https://github.com/heggria/taskflow/pull/142) targets `main`; its head `rc/0.3.0-beta.2` retains the historical branch name, while 1.0.0 supersedes the beta.2 release target. The complete approved Control Plane, including WebUI, remains required before 1.0 publication; its current private workspace status is not a waiver. See the [release plan](./docs/internal/1.0.0-release-plan.md) and [acceptance scoreboard](./docs/internal/1.0.0-ga-scoreboard.md). ## The declared-effect contract @@ -47,7 +47,7 @@ flow / .tf.ts This is **not** an OS sandbox. Resolve-only hosts cannot prevent every write to an undeclared path. Secret and service references are typed and fail closed in this cut; they do not imply a vault or network backend. -## The 1.0 public contract +## The 1.0 target and current implementation | Layer | What it does | Support boundary | |---|---|---| @@ -55,8 +55,8 @@ This is **not** an OS sandbox. Resolve-only hosts cannot prevent every write to | **Trusted Effects** | Closed `EffectIR`, `PathRef` / `SecretRef` / `ServiceRef`, confidentiality/integrity labels, effect validation, overlap checks, and ledger-backed `why-*` explainers | Declared-target contract | | **Resource transaction** | Snapshot → lease → durable intent/permit → stage → commit, or restore and reject | Declared-target contract | | **Host adapters** | Pi, Codex, Claude Code, OpenCode, Grok Build, and Hermes Agent use the same flow contract | Existing host surface; support remains host-specific | -| **Control Plane** | Unix UDS singleton/fencing and files-only store; proposed wire contracts and incomplete admission/parked approvals/receipts/coordination | Private workspace experiment; excluded from stable API | -| **WebUI** | Runs, approvals, receipts, and evidence browsing | Planned; excluded from stable API | +| **Control Plane** | Unix UDS singleton/fencing and files-only store; proposed wire contracts and incomplete admission/parked approvals/receipts/coordination | Currently private workspace; complete Control Plane required for 1.0 acceptance | +| **WebUI** | Runs, approvals, receipts, and evidence browsing | Incomplete; required for 1.0 acceptance | The [1.0 release plan](./docs/internal/1.0.0-release-plan.md) defines the stable scope and acceptance gates. The filesystem-transaction details are in [`docs/internal/0.3.0-trusted-effects-mvp.md`](./docs/internal/0.3.0-trusted-effects-mvp.md). The 0.3-C Control Plane plan is [`docs/internal/0.3-c-control-plane-plan.md`](./docs/internal/0.3-c-control-plane-plan.md). @@ -67,13 +67,13 @@ Use Node.js **≥ 22.19.0**. To run from the 1.0 source checkout: ```bash git clone https://github.com/heggria/taskflow.git cd taskflow -git checkout v1.0.0 +git checkout --track origin/rc/0.3.0-beta.2 pnpm install pnpm run typecheck pnpm test ``` -Install the host adapter with an exact 1.0 version: +**The following installation commands apply only after 1.0.0 is published; use the candidate source checkout above today.** ```bash npm install --global pi-taskflow@1.0.0 diff --git a/README.zh-CN.md b/README.zh-CN.md index 9531c781..db472b7f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -21,7 +21,7 @@ **taskflow 是面向 coding-agent 工作流的声明式运行时。** 它把任务图变成可验证的执行合同,让阶段隔离运行,并把中间过程留在宿主对话之外。在 Taskflow 1.0 中,这份合同还可以描述每个阶段被允许提出的副作用。 -> **Taskflow 1.0 公开合同。** 十个公开包的稳定范围是已有 DAG runtime、DSL、六个宿主适配器和已准入声明文件目标的 resource transaction。Pi 支持交互审批;MCP/headless 审批自动拒绝。`taskflow-control` 是 private workspace 实验,wire 合同仍为 proposed;CharterArc 保留独立 experimental 发布线。[1.0 发布计划](./docs/internal/1.0.0-release-plan.md)列出验证闸门。实际发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases) 与 [npm registry](https://www.npmjs.com/package/pi-taskflow)。 +> **状态:1.0.0 开发候选,尚未发布。** 最新已发布 prerelease 是 `v0.3.0-beta.1.2`。Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 目标为 `main`;head `rc/0.3.0-beta.2` 沿用历史分支名,当前发布目标已由 beta.2 改为 1.0.0。完整已批准 Control Plane(包括 WebUI)仍是 1.0 发布前必须完成并验收的范围;当前 private workspace 状态不能豁免这些要求。[发布计划](./docs/internal/1.0.0-release-plan.md)和[验收账本](./docs/internal/1.0.0-ga-scoreboard.md)记录范围与阻塞项。 ## 声明文件副作用合同 @@ -47,7 +47,7 @@ flow / .tf.ts 这**不是 OS sandbox**。在 resolve-only 宿主上,taskflow 不能阻止所有对未声明路径的写入。Secret 和 service reference 在这一版只是类型化、失败关闭的句柄,并不代表已经有 vault 或网络后端。 -## 1.0 公开合同 +## 1.0 目标与当前实现 | 层 | 作用 | 支持边界 | |---|---|---| @@ -55,8 +55,8 @@ flow / .tf.ts | **Trusted Effects** | 封闭的 `EffectIR`、`PathRef` / `SecretRef` / `ServiceRef`、机密性/完整性标签、effect 校验、重叠检查与 ledger-backed `why-*` | 声明目标合同 | | **Resource transaction** | snapshot → lease → durable intent/permit → stage → commit,或 restore and reject | 声明目标合同 | | **宿主适配器** | Pi、Codex、Claude Code、OpenCode、Grok Build、Hermes Agent 共用同一 flow 合同 | 已有宿主表面;能力仍按宿主区分 | -| **Control Plane** | Unix UDS singleton/fencing、files-only store 与拟议 wire contract;admission、parked 审批、receipt 和全局协调尚未完成 | private workspace 实验,不属于稳定 API | -| **WebUI** | runs、审批、receipts 与 evidence 浏览 | 计划中,不属于稳定 API | +| **Control Plane** | Unix UDS singleton/fencing、files-only store 与拟议 wire contract;admission、parked 审批、receipt 和全局协调尚未完成 | 当前 private workspace;完整 Control Plane 是 1.0 必须完成的验收范围 | +| **WebUI** | runs、审批、receipts 与 evidence 浏览 | 尚未完成;1.0 必须完成的验收范围 | [1.0 发布计划](./docs/internal/1.0.0-release-plan.md)定义稳定范围与验收闸门。文件事务细节见 [`docs/internal/0.3.0-trusted-effects-mvp.md`](./docs/internal/0.3.0-trusted-effects-mvp.md)。0.3-C Control Plane 计划见 [`docs/internal/0.3-c-control-plane-plan.md`](./docs/internal/0.3-c-control-plane-plan.md)。 @@ -67,13 +67,13 @@ flow / .tf.ts ```bash git clone https://github.com/heggria/taskflow.git cd taskflow -git checkout v1.0.0 +git checkout --track origin/rc/0.3.0-beta.2 pnpm install pnpm run typecheck pnpm test ``` -使用精确 1.0 版本安装宿主适配器: +**以下命令仅在 1.0.0 正式发布后适用;当前请使用上面的候选源码 checkout。** ```bash npm install --global pi-taskflow@1.0.0 diff --git a/RELEASE.md b/RELEASE.md index c0c028f6..53c970bb 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -5,6 +5,17 @@ or merging a release change does not publish it. Publish only after the gates below pass on the final release commit and the release owner authorizes the tag. GitHub Releases and the npm registry record the completed publication. +## Current preparation status + +**Unreleased 1.0.0; Draft PR #142 targets `main` from the retained historical +branch `rc/0.3.0-beta.2`.** The branch name is not a beta.2 release target. +Latest published prerelease remains `v0.3.0-beta.1.2`. See the +[track reconciliation](./docs/internal/1.0.0-release-plan.md#release-track-reconciliation-2026-10-07). +The complete approved Control Plane, including its WebUI, must be implemented +and accepted before 1.0 can ship; the current private package is not a waiver. +The 2026-10-07 metadata task keeps the PR Draft and performs no merge, tag or +publication. Commands below describe later gated release work. + ## Public contract and package set The stable contract is the existing declarative DAG runtime: twelve phase diff --git a/docs/internal/0.3.0-beta.2-release-plan.md b/docs/internal/0.3.0-beta.2-release-plan.md index 80e3d182..3be901ec 100644 --- a/docs/internal/0.3.0-beta.2-release-plan.md +++ b/docs/internal/0.3.0-beta.2-release-plan.md @@ -1,6 +1,6 @@ # 0.3.0-beta.2 — Control Plane 最小可跑增量 -> Historical plan/evidence. The current PR release target is superseded by [`1.0.0-release-plan.md`](./1.0.0-release-plan.md) and [`1.0.0-ga-scoreboard.md`](./1.0.0-ga-scoreboard.md). Historical status below is preserved; it is not current 1.0 acceptance evidence. +> Historical plan/evidence. The current PR release target is superseded by [`1.0.0-release-plan.md`](./1.0.0-release-plan.md) and [`1.0.0-ga-scoreboard.md`](./1.0.0-ga-scoreboard.md). Historical status below is preserved; it is not current 1.0 acceptance evidence. As reconciled on 2026-10-07, the latest published prerelease is `v0.3.0-beta.1.2`; #142 targets unreleased `1.0.0` from the retained branch `rc/0.3.0-beta.2`. Dated “current”/“not published” statements below apply only to their historical snapshots. > **Status:** APPROVED — 2026-08-13 用户指令「开始,记得用 kanban」。三路审查 APPROVE-WITH-FIXES 已吸收。开工令:从 `origin/main` 拉 `rc/0.3.0-beta.2`,Kanban 链式施工;停在 Draft PR + exact-SHA CI,未经新指令不得 Ready / merge / tag / npm。 > **Date:** 2026-08-13 diff --git a/docs/internal/0.3.0-ga-scoreboard.md b/docs/internal/0.3.0-ga-scoreboard.md index 09abebaa..74ee3706 100644 --- a/docs/internal/0.3.0-ga-scoreboard.md +++ b/docs/internal/0.3.0-ga-scoreboard.md @@ -1,6 +1,6 @@ # 0.3 Trusted Effects — GA Scoreboard -> Historical plan/evidence. The current PR release target is superseded by [`1.0.0-release-plan.md`](./1.0.0-release-plan.md) and [`1.0.0-ga-scoreboard.md`](./1.0.0-ga-scoreboard.md). Historical status below is preserved; it is not current 1.0 acceptance evidence. +> Historical plan/evidence. The current PR release target is superseded by [`1.0.0-release-plan.md`](./1.0.0-release-plan.md) and [`1.0.0-ga-scoreboard.md`](./1.0.0-ga-scoreboard.md). Historical status below is preserved; it is not current 1.0 acceptance evidence. As reconciled on 2026-10-07, the latest published prerelease is `v0.3.0-beta.1.2`; #142 targets unreleased `1.0.0` from the retained branch `rc/0.3.0-beta.2`. Dated “current”/“not published” statements below apply only to their historical snapshots. > Living ledger. **Do not claim GA** unless L6 is PASS with human tag evidence. > diff --git a/docs/internal/0.3.0-release-plan.md b/docs/internal/0.3.0-release-plan.md index 2da6c382..3d36f9e7 100644 --- a/docs/internal/0.3.0-release-plan.md +++ b/docs/internal/0.3.0-release-plan.md @@ -1,6 +1,6 @@ # 0.3.0 Trusted Effects — beta.1 release plan -> Historical plan/evidence. The current PR release target is superseded by [`1.0.0-release-plan.md`](./1.0.0-release-plan.md) and [`1.0.0-ga-scoreboard.md`](./1.0.0-ga-scoreboard.md). Historical status below is preserved; it is not current 1.0 acceptance evidence. +> Historical plan/evidence. The current PR release target is superseded by [`1.0.0-release-plan.md`](./1.0.0-release-plan.md) and [`1.0.0-ga-scoreboard.md`](./1.0.0-ga-scoreboard.md). Historical status below is preserved; it is not current 1.0 acceptance evidence. As reconciled on 2026-10-07, the latest published prerelease is `v0.3.0-beta.1.2`; #142 targets unreleased `1.0.0` from the retained branch `rc/0.3.0-beta.2`. Dated “current”/“not published” statements below apply only to their historical snapshots. ## Current state diff --git a/docs/internal/1.0.0-ga-scoreboard.md b/docs/internal/1.0.0-ga-scoreboard.md index 25040d58..1d498227 100644 --- a/docs/internal/1.0.0-ga-scoreboard.md +++ b/docs/internal/1.0.0-ga-scoreboard.md @@ -1,5 +1,11 @@ # Taskflow 1.0.0 acceptance scoreboard +> **Active track: unreleased 1.0.0 / Draft PR #142 → main.** +> `rc/0.3.0-beta.2` is the retained historical branch name, not the release version. +> Latest published prerelease: `v0.3.0-beta.1.2`. See the +> [2026-10-07 track reconciliation](./1.0.0-release-plan.md#release-track-reconciliation-2026-10-07). +> This documentation reconciliation does not add runtime acceptance evidence or unblock publication. + Status: **publication blocked on historical spec closure**. Updated 2026-10-02. The owner's latest instruction requires implementation and acceptance of the previously committed specs before publication. Earlier test passes remain diff --git a/docs/internal/1.0.0-release-plan.md b/docs/internal/1.0.0-release-plan.md index b2f280f3..34007197 100644 --- a/docs/internal/1.0.0-release-plan.md +++ b/docs/internal/1.0.0-release-plan.md @@ -17,6 +17,32 @@ implementation and meaningful acceptance evidence before the release resumes. The owner explicitly selected scope A: the complete approved Control Plane is required for 1.0. Missing implementation remains blocked; relabeling it experimental is not closure. +## Release track reconciliation (2026-10-07) + +| Surface | Meaning | +|---|---| +| Latest published prerelease | `v0.3.0-beta.1.2` (Trusted Effects beta); this remains the public baseline while #142 is Draft | +| `main` inspected for this reconciliation | `48a5ea371450398843f3b3f03b116afac814b10e`; its 0.3 release docs/scoreboard describe the earlier track, not #142's current target | +| Active PR | [#142](https://github.com/heggria/taskflow/pull/142), Draft, targeting `main` | +| Target release | **`1.0.0`, unreleased and publication blocked**, including the complete approved Control Plane | +| Retained head branch | `rc/0.3.0-beta.2`; the historical branch name preserves the existing PR and S2/S3 development lineage, not a beta.2 release commitment | +| Head inspected before this documentation change | `7467f6484ed76b42d2396e0f881d3685f2a74c37` | +| Version declarations | Root, ten public packages, three plugin manifests and five MCP template pins are `1.0.0` candidate metadata, not proof of publication | +| Historical beta.2 | The old candidate and its S2/S3 commits are retained as implementation history; its release target is superseded, and `v0.3.0-beta.2` has not been published | + +The beta.1.2 release note reserved beta.2 at the time; it does not override +this later 1.0 plan or promise another beta.2 publication. The old 0.3 plans +and scoreboard are historical snapshots, including their dated status claims. +The current scope and acceptance authority are this plan, +[`1.0.0-spec-closure.md`](./1.0.0-spec-closure.md) and +[`1.0.0-ga-scoreboard.md`](./1.0.0-ga-scoreboard.md). + +The 2026-10-07 task is metadata/documentation alignment only: keep #142 Draft; +no Ready transition, merge, tag, npm publish or GitHub Release. Earlier +conditional publication instructions do not authorize those actions in this task. +Changes here land through #142; `main` and published release notes remain the +published/history view until a separately authorized merge. + ## Contract The ten existing public packages stabilize the mature Taskflow DAG contract: @@ -27,7 +53,9 @@ trace/replay/recompute and admitted declared-filesystem resource transactions. Host-specific capabilities remain explicit. Pi SDK 1.0 and retained 0.80.3 compatibility must be verified; a source build does not prove installed use. -`taskflow-control` is private workspace experimental. UDS/store scaffolding is +The complete approved Control Plane, including the S6/S7 adapter/CLI and usable +WebUI, is required for 1.0 acceptance. Its current packaging is not a scope waiver. +`taskflow-control` is currently private workspace experimental. UDS/store scaffolding is retained and tested without claiming completed admission, global coordination, authorized command-result replay, parked approvals/CAS/receipts, registry, Windows pipes or WebUI. CharterArc continues its separate experimental package/version/workflow. The optional @@ -42,7 +70,7 @@ secret/service backends and unbenchmarked journal capacity remain documented. - Local internal dependencies use `workspace:*`; deterministic packed manifests pin the exact release version. Fresh npm consumers resolve only the local tarballs for Taskflow packages and exercise all exports/declarations/bins. -- The release changelog is dated after owner authorization. Publication requires +- The candidate changelog stays `Unreleased`; date it only for an authorized release after the gates pass. Publication requires an annotated tag on a verified main commit, npm provenance, owner/repository/workflow/commit/integrity verification, all ten latest dist-tags, then a stable GitHub Release. Partial publication is failure. diff --git a/docs/internal/1.0.0-spec-closure.md b/docs/internal/1.0.0-spec-closure.md index 4dd5421e..d0cb0d5a 100644 --- a/docs/internal/1.0.0-spec-closure.md +++ b/docs/internal/1.0.0-spec-closure.md @@ -1,5 +1,11 @@ # 1.0 specification closure ledger +> **Active track: unreleased 1.0.0 / Draft PR #142 → main.** +> `rc/0.3.0-beta.2` is the retained historical branch name, not the release version. +> Latest published prerelease: `v0.3.0-beta.1.2`. See the +> [2026-10-07 track reconciliation](./1.0.0-release-plan.md#release-track-reconciliation-2026-10-07). +> This documentation reconciliation does not add runtime acceptance evidence or unblock publication. + Status: **PUBLICATION BLOCKED**. Updated 2026-10-02. No merge, tag, npm publication or GitHub Release has occurred. Passing the implemented surface's tests does not complete the historical roadmap. diff --git a/website/content/docs/en/compiler-runtime/typescript-dsl.mdx b/website/content/docs/en/compiler-runtime/typescript-dsl.mdx index 484a54ee..7b6b6f17 100644 --- a/website/content/docs/en/compiler-runtime/typescript-dsl.mdx +++ b/website/content/docs/en/compiler-runtime/typescript-dsl.mdx @@ -3,6 +3,8 @@ title: TypeScript DSL (`taskflow-dsl`) description: Compile-time .tf.ts authoring — erase runes to Taskflow JSON, then FlowIR. --- +> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete approved Control Plane, including WebUI, remains required for 1.0 acceptance; current private workspace packaging is not a waiver. + # TypeScript DSL (`taskflow-dsl`) S4 adds a **compile-time** TypeScript frontend. You author `*.tf.ts` with **runes** (`agent`, `map`, `race`, …). A CLI erases them to ordinary Taskflow JSON. Hosts still run **JSON** via `taskflow_run` / `/tf run` — there is **no** interpret path and **no** host auto-build of `.tf.ts`. diff --git a/website/content/docs/en/getting-started.mdx b/website/content/docs/en/getting-started.mdx index 74a7ec3c..e506f460 100644 --- a/website/content/docs/en/getting-started.mdx +++ b/website/content/docs/en/getting-started.mdx @@ -3,6 +3,8 @@ title: Getting Started description: Run your first taskflow in under five minutes. --- +> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete approved Control Plane, including WebUI, remains required for 1.0 acceptance; current private workspace packaging is not a waiver. + taskflow lets you describe multi-step agent work as a declarative graph. Instead of writing a script that calls subagents one by one, you declare the nodes and edges — and the runtime handles fan-out, retries, caching, and resume. diff --git a/website/content/docs/en/guides/claude-code.mdx b/website/content/docs/en/guides/claude-code.mdx index eb15a2ac..2bf2627e 100644 --- a/website/content/docs/en/guides/claude-code.mdx +++ b/website/content/docs/en/guides/claude-code.mdx @@ -3,7 +3,7 @@ title: Using taskflow on Claude Code description: Install taskflow as a Claude Code plugin and orchestrate multi-phase workflows via MCP. --- -> **Taskflow 1.0 installation.** Use the exact npm pins below. Publication status is recorded in [GitHub Releases](https://github.com/heggria/taskflow/releases). +> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete approved Control Plane, including WebUI, remains required for 1.0 acceptance; current private workspace packaging is not a waiver. [Claude Code](https://claude.com/product/claude-code) is a natural home for taskflow: it already thinks in terms of subagents, and taskflow gives those subagents a declarative graph to run inside. The plugin registers the MCP server plus a routing skill; its protocol layer needs no MCP SDK. diff --git a/website/content/docs/en/guides/codex.mdx b/website/content/docs/en/guides/codex.mdx index 4dccf80e..f039332c 100644 --- a/website/content/docs/en/guides/codex.mdx +++ b/website/content/docs/en/guides/codex.mdx @@ -3,7 +3,7 @@ title: Using taskflow on Codex description: Install taskflow as a Codex plugin and orchestrate multi-phase workflows via MCP. --- -> **Taskflow 1.0 installation.** Use the exact npm pins below. Publication status is recorded in [GitHub Releases](https://github.com/heggria/taskflow/releases). +> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete approved Control Plane, including WebUI, remains required for 1.0 acceptance; current private workspace packaging is not a waiver. [Codex](https://github.com/openai/codex) is a natural home for taskflow: it already thinks in terms of subagents, and taskflow gives those subagents a declarative graph to run inside. On Codex, taskflow ships as a plugin that registers an MCP server plus a routing skill, so the model can call it the moment it sees a multi-step task. diff --git a/website/content/docs/en/guides/grok-build.mdx b/website/content/docs/en/guides/grok-build.mdx index b5b9e3d4..036393df 100644 --- a/website/content/docs/en/guides/grok-build.mdx +++ b/website/content/docs/en/guides/grok-build.mdx @@ -3,7 +3,7 @@ title: Using taskflow on Grok Build description: Install taskflow as a Grok Build plugin and orchestrate multi-phase workflows via MCP. --- -> **Taskflow 1.0 installation.** Use the exact npm pins below. Publication status is recorded in [GitHub Releases](https://github.com/heggria/taskflow/releases). +> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete approved Control Plane, including WebUI, remains required for 1.0 acceptance; current private workspace packaging is not a waiver. [Grok Build](https://docs.x.ai/build/overview) is xAI's terminal coding agent. Taskflow's published path is its MCP server; a plugin scaffold is also available in the repository checkout. diff --git a/website/content/docs/en/guides/hermes.mdx b/website/content/docs/en/guides/hermes.mdx index 8406f331..36b3e52f 100644 --- a/website/content/docs/en/guides/hermes.mdx +++ b/website/content/docs/en/guides/hermes.mdx @@ -3,7 +3,7 @@ title: Using taskflow on Hermes Agent description: Install taskflow as an MCP server on Hermes Agent and orchestrate multi-phase workflows with isolated Hermes subagents. --- -> **Taskflow 1.0 installation.** Use the exact npm pins below. Publication status is recorded in [GitHub Releases](https://github.com/heggria/taskflow/releases). +> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete approved Control Plane, including WebUI, remains required for 1.0 acceptance; current private workspace packaging is not a waiver. [Hermes Agent](https://hermes-agent.nousresearch.com/) is Nous Research's open-source coding agent (CLI, TUI, desktop, messaging gateways). Taskflow's published path is its MCP server: Hermes discovers `taskflow_*` tools and each agent phase spawns an isolated `hermes chat -q -Q` child. diff --git a/website/content/docs/en/guides/opencode.mdx b/website/content/docs/en/guides/opencode.mdx index e9b4d714..497cbbeb 100644 --- a/website/content/docs/en/guides/opencode.mdx +++ b/website/content/docs/en/guides/opencode.mdx @@ -3,7 +3,7 @@ title: Using taskflow on OpenCode description: Register taskflow as an MCP server on OpenCode and orchestrate multi-phase workflows. --- -> **Taskflow 1.0 installation.** Use the exact npm pins below. Publication status is recorded in [GitHub Releases](https://github.com/heggria/taskflow/releases). +> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete approved Control Plane, including WebUI, remains required for 1.0 acceptance; current private workspace packaging is not a waiver. [OpenCode](https://opencode.ai) exposes taskflow through an MCP server whose protocol layer has no MCP SDK dependency; each phase runs as an isolated `opencode run` process. diff --git a/website/content/docs/en/guides/pi.mdx b/website/content/docs/en/guides/pi.mdx index cc6d391e..f4228bc9 100644 --- a/website/content/docs/en/guides/pi.mdx +++ b/website/content/docs/en/guides/pi.mdx @@ -3,7 +3,7 @@ title: Using taskflow on Pi description: Install, run, save, and resume taskflows on the Pi coding agent. --- -> **Taskflow 1.0 installation.** Use the exact npm pins below. Publication status is recorded in [GitHub Releases](https://github.com/heggria/taskflow/releases). +> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete approved Control Plane, including WebUI, remains required for 1.0 acceptance; current private workspace packaging is not a waiver. If you already use [Pi](https://pi.dev) to delegate work to subagents, taskflow turns those one-off delegations into named, resumable, multi-step workflows. You describe the work as a graph — fan-out, gates, loops, whatever you need — and only the final result comes back into your context. diff --git a/website/content/docs/en/index.mdx b/website/content/docs/en/index.mdx index 4fd4bf42..f0134e68 100644 --- a/website/content/docs/en/index.mdx +++ b/website/content/docs/en/index.mdx @@ -3,7 +3,7 @@ title: taskflow 1.0 Documentation description: "Trusted Effects for coding-agent workflows: declare effects, verify typed paths, and commit admitted filesystem changes through one resource authority." --- -> **Taskflow 1.0 public contract.** The stable scope is the mature DAG runtime, DSL, six host adapters and admitted declared-filesystem transactions. Control Plane wire contracts remain proposed and workspace-only; parked approvals, receipts, global coordination and WebUI are excluded. +> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete approved Control Plane, including WebUI, remains required for 1.0 acceptance; current private workspace packaging is not a waiver. taskflow is a declarative runtime for coding-agent workflows. It turns a graph into a verifiable execution contract, runs phases in isolation, and keeps intermediate transcripts out of the host conversation. The 1.0 contract includes **Trusted Effects**: a typed declaration and resource-controlled commit path for admitted filesystem effects. @@ -47,7 +47,7 @@ taskflow is a declarative runtime for coding-agent workflows. It turns a graph i ## Install on your host -Install an exact `@1.0.0` version. Publication status is recorded in [GitHub Releases](https://github.com/heggria/taskflow/releases) and the [npm registry](https://www.npmjs.com/package/pi-taskflow). +After 1.0.0 publication, install an exact `@1.0.0` version. Publication status is recorded in [GitHub Releases](https://github.com/heggria/taskflow/releases) and the [npm registry](https://www.npmjs.com/package/pi-taskflow). Every taskflow package requires **Node.js 22.19.0 or newer**. Source and fixture checks use pnpm from the repository checkout. @@ -93,10 +93,10 @@ Install an exact `@1.0.0` version. Publication status is recorded in [GitHub Rel ## Release boundary -The stable contract and workspace experiments have separate release boundaries: +The implemented baseline and outstanding 1.0 requirements have distinct acceptance states: -- **Trusted Effects MVP:** the release-bound product definition. It covers EffectIR, typed refs, labels, resource-controlled filesystem transactions, overlap admission, host-baseline honesty, and ledger-backed `why-*` explainers. -- **Experimental Control Plane:** private workspace Unix UDS/store code with proposed wire contracts; admission, parked approvals, receipts, global coordination and WebUI remain incomplete and excluded from the stable API. +- **Trusted Effects MVP:** the implemented baseline; it does not alone close the 1.0 scope. It covers EffectIR, typed refs, labels, resource-controlled filesystem transactions, overlap admission, host-baseline honesty, and ledger-backed `why-*` explainers. +- **Experimental Control Plane:** private workspace Unix UDS/store code with proposed wire contracts; admission, parked approvals, receipts, global coordination and WebUI remain incomplete and are required for 1.0 acceptance. Read the [MVP freeze in the repository](https://github.com/heggria/taskflow/blob/main/docs/internal/0.3.0-trusted-effects-mvp.md) and the [0.3-C plan](https://github.com/heggria/taskflow/blob/main/docs/internal/0.3-c-control-plane-plan.md) for the normative scope. @@ -113,3 +113,5 @@ Read the [MVP freeze in the repository](https://github.com/heggria/taskflow/blob Source, issues, CI, and contribution workflow. + +For the active 1.0 scope and blockers, see the [PR #142 release plan](https://github.com/heggria/taskflow/blob/rc/0.3.0-beta.2/docs/internal/1.0.0-release-plan.md). diff --git a/website/content/docs/en/trusted-effects.mdx b/website/content/docs/en/trusted-effects.mdx index 60025dbc..1a8fddd8 100644 --- a/website/content/docs/en/trusted-effects.mdx +++ b/website/content/docs/en/trusted-effects.mdx @@ -2,7 +2,7 @@ title: Trusted Effects --- -> **Taskflow 1.0 public contract.** The stable scope is the mature DAG runtime, DSL, six host adapters and admitted declared-filesystem transactions. Control Plane wire contracts remain proposed and workspace-only; parked approvals, receipts, global coordination and WebUI are excluded. +> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete approved Control Plane, including WebUI, remains required for 1.0 acceptance; current private workspace packaging is not a waiver. Trusted Effects makes a phase's side effects explicit. The model may propose content, but for an admitted declared filesystem target, the **resources transaction is the only finalizer**. @@ -32,7 +32,7 @@ pnpm exec node --conditions=development --experimental-strip-types --test \ - Resolve-only hosts cannot prevent every write to an undeclared path. - SecretRef has no vault backend in this cut. - ServiceRef has no live network adapter in this cut. -- ControlHost has experimental Unix UDS and files-only store code. Admission, parked approvals/receipts, coordination, Windows pipes and WebUI remain incomplete and are outside the 1.0 stable API. +- ControlHost has experimental Unix UDS and files-only store code. Admission, parked approvals/receipts, coordination and WebUI remain incomplete and are required for 1.0 acceptance. Windows pipes remain a separate unverified scope. ## Read next diff --git a/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx b/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx index 9fb5a20b..9351f300 100644 --- a/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx +++ b/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx @@ -3,6 +3,8 @@ title: TypeScript DSL(`taskflow-dsl`) description: 编译期 .tf.ts 写法 —— rune erase 成 Taskflow JSON,再进 FlowIR。 --- +> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整已批准 Control Plane(包括 WebUI)仍是 1.0 必须完成的验收范围;当前 private workspace 状态不能豁免。 + # TypeScript DSL(`taskflow-dsl`) S4 增加**编译期** TypeScript 前端:用 rune(`agent`、`map`、`race`…)写 `*.tf.ts`,CLI erase 成普通 Taskflow JSON。宿主仍通过 `taskflow_run` / `/tf run` 跑 **JSON**——**没有**解释执行路径,也**没有**宿主对 `.tf.ts` 的自动 build。 diff --git a/website/content/docs/zh-cn/getting-started.mdx b/website/content/docs/zh-cn/getting-started.mdx index 692b5f9c..c7af7ff2 100644 --- a/website/content/docs/zh-cn/getting-started.mdx +++ b/website/content/docs/zh-cn/getting-started.mdx @@ -3,6 +3,8 @@ title: 开始使用 description: 五分钟内运行你的第一个 taskflow。 --- +> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整已批准 Control Plane(包括 WebUI)仍是 1.0 必须完成的验收范围;当前 private workspace 状态不能豁免。 + taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不需要写一个一个调用子代理的脚本,只需声明节点和边——运行时会替你处理 fan-out、重试、缓存和续跑。 diff --git a/website/content/docs/zh-cn/guides/claude-code.mdx b/website/content/docs/zh-cn/guides/claude-code.mdx index dd570885..307a7f82 100644 --- a/website/content/docs/zh-cn/guides/claude-code.mdx +++ b/website/content/docs/zh-cn/guides/claude-code.mdx @@ -3,7 +3,7 @@ title: 在 Claude Code 上使用 taskflow description: 将 taskflow 作为 Claude Code 插件安装,通过 MCP 编排多阶段工作流。 --- -> **Taskflow 1.0 安装。** 使用下列精确 npm pin;实际发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases)。 +> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整已批准 Control Plane(包括 WebUI)仍是 1.0 必须完成的验收范围;当前 private workspace 状态不能豁免。 [Claude Code](https://claude.com/product/claude-code) 是 taskflow 的天然宿主:它本身就以 subagent 的方式思考,而 taskflow 给这些 subagent 提供了一个声明式图来运行。插件注册 MCP server 与路由 skill;协议层不依赖 MCP SDK。 diff --git a/website/content/docs/zh-cn/guides/codex.mdx b/website/content/docs/zh-cn/guides/codex.mdx index 55d932ef..a7baa601 100644 --- a/website/content/docs/zh-cn/guides/codex.mdx +++ b/website/content/docs/zh-cn/guides/codex.mdx @@ -3,7 +3,7 @@ title: 在 Codex 上使用 taskflow description: 把 taskflow 作为 Codex 插件安装,通过 MCP 编排多阶段工作流。 --- -> **Taskflow 1.0 安装。** 使用下列精确 npm pin;实际发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases)。 +> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整已批准 Control Plane(包括 WebUI)仍是 1.0 必须完成的验收范围;当前 private workspace 状态不能豁免。 [Codex](https://github.com/openai/codex) 是 taskflow 的天然归宿:它已经在用子代理的方式思考,而 taskflow 给那些子代理一个声明式图来在里面运行。在 Codex 上,taskflow 以插件形式发布,注册一个 MCP 服务器加一个路由 skill,于是模型一看到多步骤任务就能调用它。 diff --git a/website/content/docs/zh-cn/guides/grok-build.mdx b/website/content/docs/zh-cn/guides/grok-build.mdx index e4e99f71..17ce1fb7 100644 --- a/website/content/docs/zh-cn/guides/grok-build.mdx +++ b/website/content/docs/zh-cn/guides/grok-build.mdx @@ -3,7 +3,7 @@ title: 在 Grok Build 上使用 taskflow description: 将 taskflow 作为 Grok Build 插件安装,通过 MCP 编排多阶段工作流。 --- -> **Taskflow 1.0 安装。** 使用下列精确 npm pin;实际发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases)。 +> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整已批准 Control Plane(包括 WebUI)仍是 1.0 必须完成的验收范围;当前 private workspace 状态不能豁免。 [Grok Build](https://docs.x.ai/build/overview) 是 xAI 的终端编码 agent。Taskflow 的正式发布路径是 MCP server;仓库 checkout 另带 plugin scaffold。 diff --git a/website/content/docs/zh-cn/guides/hermes.mdx b/website/content/docs/zh-cn/guides/hermes.mdx index fba47b3b..0c615666 100644 --- a/website/content/docs/zh-cn/guides/hermes.mdx +++ b/website/content/docs/zh-cn/guides/hermes.mdx @@ -3,7 +3,7 @@ title: 在 Hermes Agent 上使用 taskflow description: 在 Hermes Agent 上通过 MCP 安装 taskflow,用隔离的 Hermes 子代理编排多阶段工作流。 --- -> **Taskflow 1.0 安装。** 使用下列精确 npm pin;实际发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases)。 +> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整已批准 Control Plane(包括 WebUI)仍是 1.0 必须完成的验收范围;当前 private workspace 状态不能豁免。 [Hermes Agent](https://hermes-agent.nousresearch.com/) 是 Nous Research 的开源 coding agent(CLI / TUI / 桌面 / 消息网关)。taskflow 的发布路径是 MCP server:Hermes 发现 `taskflow_*` 工具,每个 agent phase 会拉起隔离的 `hermes chat -q -Q` 子进程。 diff --git a/website/content/docs/zh-cn/guides/opencode.mdx b/website/content/docs/zh-cn/guides/opencode.mdx index 9d61c671..118789d3 100644 --- a/website/content/docs/zh-cn/guides/opencode.mdx +++ b/website/content/docs/zh-cn/guides/opencode.mdx @@ -3,7 +3,7 @@ title: 在 OpenCode 上使用 taskflow description: 在 OpenCode 上将 taskflow 注册为 MCP 服务器,编排多阶段工作流。 --- -> **Taskflow 1.0 安装。** 使用下列精确 npm pin;实际发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases)。 +> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整已批准 Control Plane(包括 WebUI)仍是 1.0 必须完成的验收范围;当前 private workspace 状态不能豁免。 [OpenCode](https://opencode.ai) 通过协议层不依赖 MCP SDK 的 MCP server 暴露 taskflow;每个阶段作为隔离的 `opencode run` 进程运行。 diff --git a/website/content/docs/zh-cn/guides/pi.mdx b/website/content/docs/zh-cn/guides/pi.mdx index c8e0bb9c..0b18cb84 100644 --- a/website/content/docs/zh-cn/guides/pi.mdx +++ b/website/content/docs/zh-cn/guides/pi.mdx @@ -3,7 +3,7 @@ title: 在 Pi 上使用 taskflow description: 在 Pi 编程智能体上安装、运行、保存和续跑 taskflow。 --- -> **Taskflow 1.0 安装。** 使用下列精确 npm pin;实际发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases)。 +> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整已批准 Control Plane(包括 WebUI)仍是 1.0 必须完成的验收范围;当前 private workspace 状态不能豁免。 如果你已经在用 [Pi](https://pi.dev) 把工作委托给子代理,taskflow 把这些一次性的委托变成有名字、可续跑、多步骤的工作流。你把工作描述成一个图——fan-out、gate、loop,你需要什么就写什么——只有最终结果回到你的上下文里。 diff --git a/website/content/docs/zh-cn/index.mdx b/website/content/docs/zh-cn/index.mdx index 86409522..bed601aa 100644 --- a/website/content/docs/zh-cn/index.mdx +++ b/website/content/docs/zh-cn/index.mdx @@ -3,7 +3,7 @@ title: taskflow 1.0 文档 description: "面向 coding-agent 工作流的 Trusted Effects:声明 effect,验证类型化路径,让已准入的文件修改经过唯一 resource authority。" --- -> **Taskflow 1.0 公开合同。** 稳定范围是成熟 DAG runtime、DSL、六宿主适配器和已准入的声明文件事务。Control Plane wire 合同仍为 proposed,只在 workspace 实验;parked 审批、receipt、全局协调与 WebUI 不属于稳定 API。 +> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整已批准 Control Plane(包括 WebUI)仍是 1.0 必须完成的验收范围;当前 private workspace 状态不能豁免。 taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图变成可验证的执行合同,让阶段隔离运行,并把中间 transcript 留在宿主对话之外。1.0 合同包含 **Trusted Effects**:为已准入的文件 effect 提供类型化声明与受 resources 控制的提交路径。 @@ -47,7 +47,7 @@ taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图 ## 在你的宿主上使用 -使用精确 `@1.0.0` 版本安装。实际发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases) 与 [npm registry](https://www.npmjs.com/package/pi-taskflow)。 +1.0.0 正式发布后,使用精确 `@1.0.0` 版本安装。实际发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases) 与 [npm registry](https://www.npmjs.com/package/pi-taskflow)。 所有 taskflow package 都要求 **Node.js 22.19.0 或更高版本**。源码与 fixture 检查使用仓库内的 pnpm。 @@ -93,10 +93,10 @@ taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图 ## 发布边界 -稳定合同与 workspace 实验有独立发布边界: +已实现基线与 1.0 待完成范围分别验收: -- **Trusted Effects MVP:** 面向发布的产品定义。覆盖 EffectIR、类型化 ref、labels、受 resources 控制的文件 transaction、重叠准入、诚实的宿主基线与 ledger-backed `why-*` explainers。 -- **实验性 Control Plane:** private workspace Unix UDS/store 代码与 proposed wire 合同;admission、parked 审批、receipt、全局协调与 WebUI 尚未完成,不属于稳定 API。 +- **Trusted Effects MVP:** 已实现的基础能力,单独完成它不足以满足 1.0 范围。覆盖 EffectIR、类型化 ref、labels、受 resources 控制的文件 transaction、重叠准入、诚实的宿主基线与 ledger-backed `why-*` explainers。 +- **实验性 Control Plane:** private workspace Unix UDS/store 代码与 proposed wire 合同;admission、parked 审批、receipt、全局协调与 WebUI 尚未完成,是 1.0 必须完成的验收范围。 规范范围请读仓库中的 [MVP 冻结定义](https://github.com/heggria/taskflow/blob/main/docs/internal/0.3.0-trusted-effects-mvp.md) 与 [0.3-C 计划](https://github.com/heggria/taskflow/blob/main/docs/internal/0.3-c-control-plane-plan.md)。 @@ -113,3 +113,5 @@ taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图 源码、issue、CI 与贡献流程。 + +当前 1.0 范围与阻塞项见[PR #142 release plan](https://github.com/heggria/taskflow/blob/rc/0.3.0-beta.2/docs/internal/1.0.0-release-plan.md). diff --git a/website/content/docs/zh-cn/trusted-effects.mdx b/website/content/docs/zh-cn/trusted-effects.mdx index f3e1c684..e67b05fd 100644 --- a/website/content/docs/zh-cn/trusted-effects.mdx +++ b/website/content/docs/zh-cn/trusted-effects.mdx @@ -2,7 +2,7 @@ title: Trusted Effects --- -> **Taskflow 1.0 公开合同。** 稳定范围是成熟 DAG runtime、DSL、六宿主适配器和已准入的声明文件事务。Control Plane wire 合同仍为 proposed,只在 workspace 实验;parked 审批、receipt、全局协调与 WebUI 不属于稳定 API。 +> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整已批准 Control Plane(包括 WebUI)仍是 1.0 必须完成的验收范围;当前 private workspace 状态不能豁免。 Trusted Effects 把阶段的副作用写进合同。模型可以提出内容,但对于已准入的已声明文件目标,**resources transaction 是唯一最终提交者**。 @@ -32,7 +32,7 @@ pnpm exec node --conditions=development --experimental-strip-types --test \ - Resolve-only 宿主无法阻止所有对未声明路径的写入。 - 这一版没有 SecretRef vault 后端。 - 这一版没有 ServiceRef live network adapter。 -- ControlHost 有实验性 Unix UDS 和 files-only store 代码。admission、parked 审批/receipt、协调、Windows pipe 与 WebUI 尚未完成,不属于 1.0 稳定 API。 +- ControlHost 有实验性 Unix UDS 和 files-only store 代码。admission、parked 审批/receipt、协调与 WebUI 尚未完成,是 1.0 必须完成的验收范围。Windows pipe 仍单独列为未验证范围。 ## 接下来阅读 From 1c8886e0c25950684ef3f71687735763ec8001ab Mon Sep 17 00:00:00 2001 From: heggria Date: Wed, 7 Oct 2026 22:14:17 +0800 Subject: [PATCH 17/30] chore(control): preserve owner work for isolated full-A recovery --- .../taskflow-control/src/approval-service.ts | 333 ++++++++++++++ .../taskflow-control/src/authorization.ts | 282 ++++++++++++ packages/taskflow-control/src/control-host.ts | 1 + .../taskflow-control/src/schema/approval.ts | 10 + .../taskflow-control/src/schema/commands.ts | 92 ++-- .../taskflow-control/src/schema/common.ts | 4 +- .../taskflow-control/src/schema/header.ts | 4 +- packages/taskflow-control/src/schema/run.ts | 19 +- .../src/store/coordinator-store.ts | 423 ++++++++++++++++++ .../taskflow-control/src/store/journal.ts | 51 +++ .../test/approval-service.test.ts | 217 +++++++++ .../test/authorization.test.ts | 287 ++++++++++++ .../test/coordinator-store.test.ts | 414 +++++++++++++++++ .../test/fixtures/approval-worker.ts | 157 +++++++ .../test/fixtures/coordinator-worker.ts | 45 ++ .../taskflow-core/src/persistent-mutex.ts | 37 ++ .../test/persistent-mutex.test.ts | 19 + 17 files changed, 2328 insertions(+), 67 deletions(-) create mode 100644 packages/taskflow-control/src/approval-service.ts create mode 100644 packages/taskflow-control/src/authorization.ts create mode 100644 packages/taskflow-control/src/store/coordinator-store.ts create mode 100644 packages/taskflow-control/src/store/journal.ts create mode 100644 packages/taskflow-control/test/approval-service.test.ts create mode 100644 packages/taskflow-control/test/authorization.test.ts create mode 100644 packages/taskflow-control/test/coordinator-store.test.ts create mode 100644 packages/taskflow-control/test/fixtures/approval-worker.ts create mode 100644 packages/taskflow-control/test/fixtures/coordinator-worker.ts create mode 100644 packages/taskflow-core/src/persistent-mutex.ts create mode 100644 packages/taskflow-core/test/persistent-mutex.test.ts diff --git a/packages/taskflow-control/src/approval-service.ts b/packages/taskflow-control/src/approval-service.ts new file mode 100644 index 00000000..f8539c95 --- /dev/null +++ b/packages/taskflow-control/src/approval-service.ts @@ -0,0 +1,333 @@ +/** P15 durable approvals. The project journal, not this service, owns all state. */ +import { randomUUID } from "node:crypto"; +import { Value } from "typebox/value"; +import { ControlError } from "./errors.ts"; +import { commandRequestHash, type CommandRecord, type ControlEventPayload } from "./schema/commands.ts"; +import { ApprovalRequestSchema, type ApprovalRequest, type ApprovalMode, type ApprovalDecision } from "./schema/approval.ts"; +import { ArtifactRefSchema, type ArtifactRef } from "./schema/evidence.ts"; +import { BoundFragmentSchema, type BoundFragment } from "./schema/plan.ts"; +import type { RunSnapshot } from "./schema/run.ts"; +import type { ExecutionOwner } from "./schema/te-mirrors.ts"; +import type { ConcurrencyReservation } from "./schema/coordinator.ts"; +import { UuidSchema } from "./schema/common.ts"; + +// These structural extensions are shared with the authoritative store adapter; +// no additional persistence file or in-memory authority is maintained here. +export type ApprovalRun = RunSnapshot & { + runVersion: number; boundPlanHash: string; boundFragmentHash?: string; + policyHash: string; authorityEpoch: number; reservationId?: string; owner?: ExecutionOwner; +}; +export type DurableApprovalRequest = ApprovalRequest; +export interface ApprovalQuiescence { + proofId: string; projectId: string; projectControlDomainId: string; runId: string; + projectAdmitCommitSeq: number; providerNoLiveProcessTree: boolean; noAmbiguousJobs: boolean; + noPendingResourceIntents: boolean; reconcileTimeoutOnly: boolean; +} +export type ApprovalReleaseIntent = { + kind: "approval.release"; intentId: string; approvalRequestId: string; runId: string; + reservationId: string; evidence: ApprovalQuiescence; complete: boolean; +}; +export type ApprovalReadmissionIntent = { + kind: "approval.readmission"; intentId: string; approvalRequestId: string; runId: string; + reservation: ConcurrencyReservation; owner: ExecutionOwner; fragment: BoundFragment; + policyHash: string; authorizationContextHash: string; projectAdmitCommitSeq: number; complete: boolean; +}; +export type ApprovalOutbox = ApprovalReleaseIntent | ApprovalReadmissionIntent; +export type ApprovalJournalEvent = { eventId: string; recordedAt: number; payload: ControlEventPayload }; +export interface ApprovalJournalState { run: ApprovalRun; approvals: DurableApprovalRequest[]; outbox: ApprovalOutbox[] } +export interface ApprovalMutation extends ApprovalJournalState { command?: CommandRecord; events: ApprovalJournalEvent[] } +export interface ApprovalCommit extends ApprovalJournalState { commitSeq: number } +export interface ApprovalStorage { + readRun(runId: string): ApprovalRun | undefined; + readApproval(approvalRequestId: string): DurableApprovalRequest | undefined; + listApprovals(): DurableApprovalRequest[]; + readOutbox(runId: string): ApprovalOutbox[]; + /** Synchronous, writer-locked CAS; journal fsync precedes observable state. + * The adapter checks runVersion and commits the complete batch atomically. */ + mutateRun(runId: string, expectedRunVersion: number, + build: (state: ApprovalJournalState, meta: { commitSeq: number }) => ApprovalMutation): ApprovalCommit; +} +export type ApprovalOperation = "request" | "read" | "decide" | "cancel" | "expire" | "park" | "readmit"; +export type ApprovalAuthorization = { callerPrincipal: string; authorizationContextHash: string }; +export interface ApprovalAuthority { + /** Context is issued by the host's trusted identity authority; wire identity + * labels are never an input to authorization. Must evaluate current policy. */ + authorize(context: C, scope: { operation: ApprovalOperation; run: ApprovalRun; request?: DurableApprovalRequest }): Promise; + validateRequest(context: C, run: ApprovalRun, nodeInstanceId: string): Promise; + /** Evaluate actual host offers and this authenticated caller's handshake. */ + negotiateDurability(context: C, run: ApprovalRun, mode: ApprovalMode): Promise; + quiescence(context: C, run: ApprovalRun): Promise; + validateEdit(context: C, run: ApprovalRun, request: DurableApprovalRequest, edit: { kind: "output" | "plan"; artifact: ArtifactRef }): Promise; + /** Output edits are contract-checked; plan edits must re-Link. Both obtain + * current policy/capabilities and a new attenuated BoundFragment. */ + prepareReadmission(context: C, run: ApprovalRun, request: DurableApprovalRequest): Promise<{ + fragment: BoundFragment; owner: ExecutionOwner; policyHash: string; authorizationContextHash: string; + }>; + validateReadmission(context: C, run: ApprovalRun, intent: ApprovalReadmissionIntent): Promise; + reserve(context: C, run: ApprovalRun, owner: ExecutionOwner): Promise; + commitReservation(context: C, intent: ApprovalReadmissionIntent): Promise; + normalRelease(context: C, intent: ApprovalReleaseIntent): Promise; +} +export interface ApprovalRequestInput { + runId: string; expectedRunVersion: number; nodeInstanceId: string; mode: ApprovalMode; + allowedDecisions: ApprovalDecision[]; owner: string; audience?: string[]; requiredPrincipals?: string[]; + deadline: number; timeoutPolicy: ApprovalRequest["timeoutPolicy"]; +} +export interface ApprovalDecisionInput { + commandId: string; runId: string; approvalRequestId: string; expectedRunVersion: number; + decision: ApprovalDecision; editArtifactRef?: ArtifactRef; editKind?: "output" | "plan"; +} + +function stale(message: string): never { throw new ControlError("TF_STALE_VERSION", message); } +function invalid(message: string): never { throw new ControlError("TF_COMMAND_FAILED", message); } +function revoked(message: string): never { throw new ControlError("TF_AUTHORITY_REVOKED", message); } +function event(payload: ControlEventPayload): ApprovalJournalEvent { return { eventId: randomUUID(), recordedAt: Date.now(), payload }; } +function advance(state: ApprovalJournalState, changes: Partial, events: ApprovalJournalEvent[], command?: CommandRecord): ApprovalMutation { + const run = { ...state.run, ...changes.run, runVersion: state.run.runVersion + 1 }; + const approvals = (changes.approvals ?? state.approvals).map((request) => request.status === "pending" ? { ...request, expectedRunVersion: run.runVersion } : request); + const outbox = changes.outbox ?? state.outbox; + const lifecycle: ApprovalJournalEvent[] = []; + for (const intent of outbox) { + const previous = state.outbox.find((item) => item.intentId === intent.intentId); + if (!previous || !previous.complete && intent.complete) lifecycle.push(event({ + kind: intent.kind === "approval.release" ? intent.complete ? "approval.release.completed" : "approval.release.queued" : intent.complete ? "approval.readmission.completed" : "approval.readmission.queued", + approvalRequestId: intent.approvalRequestId, intentId: intent.intentId, + reservationId: intent.kind === "approval.release" ? intent.reservationId : intent.reservation.reservationId, + })); + } + return { run, approvals, outbox, events: [...events, ...lifecycle, event({ kind: "run.snapshot", run })], ...(command ? { command } : {}) }; +} + +export class ApprovalService { + readonly storage: ApprovalStorage; + readonly authority: ApprovalAuthority; + constructor(storage: ApprovalStorage, authority: ApprovalAuthority) { + this.storage = storage; this.authority = authority; + for (const name of ["authorize", "validateRequest", "negotiateDurability", "quiescence", "validateEdit", "prepareReadmission", "validateReadmission", "reserve", "commitReservation", "normalRelease"] as const) { + if (typeof authority[name] !== "function") revoked(`approval authority requires ${name}`); + } + } + #run(id: string): ApprovalRun { return this.storage.readRun(id) ?? invalid("approval requires an existing durable run"); } + #request(id: string): DurableApprovalRequest { return this.storage.readApproval(id) ?? invalid("approval request does not exist"); } + #checkQuiescence(run: ApprovalRun, proof: ApprovalQuiescence): boolean { + return Boolean(proof.proofId && proof.runId === run.runId && proof.projectId === run.projectId + && proof.projectControlDomainId === run.controlDomainId && proof.projectAdmitCommitSeq === run.projectAdmitCommitSeq + && proof.providerNoLiveProcessTree === true && proof.noAmbiguousJobs === true + && proof.noPendingResourceIntents === true && proof.reconcileTimeoutOnly === false); + } + #release(state: ApprovalJournalState, requestId: string, proof?: ApprovalQuiescence): ApprovalOutbox[] { + if (!proof || !state.run.reservationId || !this.#checkQuiescence(state.run, proof) + || state.outbox.some((item) => item.kind === "approval.release" && item.reservationId === state.run.reservationId && !item.complete)) return state.outbox; + return [...state.outbox, { kind: "approval.release", intentId: randomUUID(), approvalRequestId: requestId, runId: state.run.runId, reservationId: state.run.reservationId, evidence: proof, complete: false }]; + } + async read(context: C, approvalRequestId: string): Promise { + const request = this.#request(approvalRequestId); + await this.authority.authorize(context, { operation: "read", run: this.#run(request.runId), request }); + return structuredClone(request); + } + async request(context: C, input: ApprovalRequestInput): Promise { + const run = this.#run(input.runId); + if (!["compat-auto-reject", "durable-optional", "durable-required"].includes(input.mode)) invalid("unknown approval mode"); + await this.authority.authorize(context, { operation: "request", run }); + await this.authority.validateRequest(context, run, input.nodeInstanceId); + const durable = input.mode !== "compat-auto-reject" && await this.authority.negotiateDurability(context, run, input.mode); + if (input.mode === "durable-required" && !durable) throw new ControlError("TF_FEATURE_REQUIRED", "durable approval must be negotiated at Link/Admit"); + const request: DurableApprovalRequest = { + approvalRequestId: randomUUID(), runId: run.runId, nodeInstanceId: input.nodeInstanceId, + boundPlanHash: run.boundPlanHash, ...(run.boundFragmentHash ? { boundFragmentHash: run.boundFragmentHash } : {}), + expectedRunVersion: input.expectedRunVersion + 1, allowedDecisions: input.allowedDecisions, + owner: input.owner, ...(input.audience ? { audience: input.audience } : {}), + ...(input.requiredPrincipals ? { requiredPrincipals: input.requiredPrincipals } : {}), + deadline: input.deadline, timeoutPolicy: input.timeoutPolicy, status: durable ? "pending" : "rejected", createdAt: Date.now(), + }; + if (!Value.Check(ApprovalRequestSchema, request) || new Set(request.allowedDecisions).size !== request.allowedDecisions.length) invalid("invalid approval request"); + const proof = await this.authority.quiescence(context, run); + await this.authority.authorize(context, { operation: "request", run, request }); + this.storage.mutateRun(run.runId, input.expectedRunVersion, (state) => { + if (["completed", "failed", "blocked", "cancelled", "unknown"].includes(state.run.status) + || !["admitted", "executing", "parked", "reconciling"].includes(state.run.stage) + || !Number.isSafeInteger(state.run.projectAdmitCommitSeq) || state.run.projectAdmitCommitSeq! < 1) stale("run cannot request approval before durable admission or after terminal state"); + if (state.approvals.some((item) => item.nodeInstanceId === request.nodeInstanceId && item.status === "pending")) stale("node already has pending approval"); + const expired = durable && request.deadline <= Date.now(); + const settled = !durable || expired; + const parked = !settled && proof && this.#checkQuiescence(state.run, proof); + const stored = { ...request, ...(expired ? { status: "expired" as const, decidedAt: Date.now() } : !durable ? { decidedAt: Date.now() } : {}) }; + const nextRun = { ...state.run, requiresReadmission: !settled, status: settled ? "blocked" as const : "paused" as const, stage: settled ? "terminal" as const : parked ? "parked" as const : "reconciling" as const }; + return advance(state, { run: nextRun, approvals: [...state.approvals, stored], outbox: this.#release(state, request.approvalRequestId, proof) }, [event(settled + ? { kind: "approval.settled", approvalRequestId: stored.approvalRequestId, decision: expired ? "expired" : "rejected" } + : { kind: "approval.pending", approvalRequestId: stored.approvalRequestId, request: stored })]); + }); + await this.drainReleases(context, run.runId); + return this.read(context, request.approvalRequestId); + } + async decide(context: C, input: ApprovalDecisionInput): Promise { + input = structuredClone(input); + if (!Value.Check(UuidSchema, input.commandId) || !Number.isSafeInteger(input.expectedRunVersion) || input.expectedRunVersion < 0) invalid("invalid decision command identity/version"); + const request = this.#request(input.approvalRequestId), run = this.#run(input.runId); + if (request.runId !== run.runId) invalid("approval does not belong to this run"); + if (!["approve", "reject", "edit"].includes(input.decision) || !request.allowedDecisions.includes(input.decision)) invalid("decision not allowed"); + await this.authority.authorize(context, { operation: "decide", run, request }); + if (input.decision === "edit") { + if (!input.editArtifactRef || !Value.Check(ArtifactRefSchema, input.editArtifactRef) || !["output", "plan"].includes(input.editKind ?? "")) invalid("edit requires typed guidance artifact"); + await this.authority.validateEdit(context, run, request, { kind: input.editKind!, artifact: input.editArtifactRef! }); + } else if (input.editKind !== undefined || input.editArtifactRef !== undefined) invalid("edit payload requires edit decision"); + const proof = await this.authority.quiescence(context, run); + const auth = await this.authority.authorize(context, { operation: "decide", run: this.#run(run.runId), request: this.#request(request.approvalRequestId) }); + let expired = false; + this.storage.mutateRun(run.runId, input.expectedRunVersion, (state, meta) => { + const current = state.approvals.find((item) => item.approvalRequestId === request.approvalRequestId) ?? invalid("missing approval"); + if (current.status !== "pending" || state.run.status !== "paused" || current.expectedRunVersion !== input.expectedRunVersion) stale("approval is already settled or run changed"); + if (current.deadline <= Date.now()) { + expired = true; + return this.#expireMutation(state, current, proof); + } + const decisionEvent = event({ kind: "approval.settled", approvalRequestId: current.approvalRequestId, decision: input.decision === "approve" ? "approved" : input.decision === "reject" ? "rejected" : "edited" }); + const decided: DurableApprovalRequest = { ...current, status: input.decision === "approve" ? "approved" : input.decision === "reject" ? "rejected" : "edited", decidedAt: Date.now(), decisionCommandId: input.commandId, decisionEventId: decisionEvent.eventId, decisionCommitSeq: meta.commitSeq, + ...(input.decision === "edit" ? { editKind: input.editKind, editArtifactRef: input.editArtifactRef } : {}) }; + const approvals = state.approvals.map((item) => item.approvalRequestId === current.approvalRequestId ? decided : input.decision === "reject" && item.status === "pending" ? { ...item, status: "cancelled" as const, decidedAt: Date.now() } : item); + const stillPending = approvals.some((item) => item.status === "pending"); + const nextRun = { ...state.run, requiresReadmission: input.decision !== "reject", status: input.decision === "reject" ? "blocked" as const : stillPending ? "paused" as const : "running" as const, stage: input.decision === "reject" ? "terminal" as const : stillPending ? state.run.stage : "queued" as const }; + const command: CommandRecord = { commandId: input.commandId, kind: "approval.decide", requestHash: commandRequestHash(input), callerPrincipal: auth.callerPrincipal, authorizationContextHash: auth.authorizationContextHash, + projectId: run.projectId, controlDomainId: run.controlDomainId, status: "completed", firstCommitSeq: meta.commitSeq, lastCommitSeq: meta.commitSeq, recordedAt: Date.now() }; + return advance(state, { run: nextRun, approvals, ...(input.decision === "reject" ? { outbox: this.#release(state, current.approvalRequestId, proof) } : {}) }, [decisionEvent], command); + }); + if (expired) stale("approval expired; late decisions cannot be committed"); + return this.read(context, request.approvalRequestId); + } + #expireMutation(state: ApprovalJournalState, request: DurableApprovalRequest, proof?: ApprovalQuiescence): ApprovalMutation { + return advance(state, { run: { ...state.run, status: "blocked", stage: "terminal", requiresReadmission: false }, outbox: this.#release(state, request.approvalRequestId, proof), approvals: state.approvals.map((item) => item.status !== "pending" ? item : { ...item, status: item.approvalRequestId === request.approvalRequestId ? "expired" : "cancelled", decidedAt: Date.now() }) }, state.approvals.filter((item) => item.status === "pending").map((item) => event({ kind: "approval.settled", approvalRequestId: item.approvalRequestId, decision: item.approvalRequestId === request.approvalRequestId ? "expired" : "cancelled" }))); + } + async expireDue(context: C): Promise { + let count = 0; + for (const request of this.storage.listApprovals()) { + if (request.status !== "pending" || request.deadline > Date.now()) continue; + const run = this.#run(request.runId); + await this.authority.authorize(context, { operation: "expire", run, request }); + const proof = await this.authority.quiescence(context, run); + await this.authority.authorize(context, { operation: "expire", run: this.#run(run.runId), request: this.#request(request.approvalRequestId) }); + try { this.storage.mutateRun(run.runId, run.runVersion, (state) => { + const current = state.approvals.find((item) => item.approvalRequestId === request.approvalRequestId); + if (!current || current.status !== "pending" || current.deadline > Date.now()) stale("expiry lost CAS"); + return this.#expireMutation(state, current, proof); + }); count++; } catch (error) { if (!(error instanceof ControlError) || error.code !== "TF_STALE_VERSION") throw error; } + } + return count; + } + async cancel(context: C, input: { commandId: string; runId: string; expectedRunVersion: number }): Promise { + if (!Value.Check(UuidSchema, input.commandId)) invalid("cancel requires a command ID"); + const run = this.#run(input.runId); + await this.authority.authorize(context, { operation: "cancel", run }); + const proof = await this.authority.quiescence(context, run); + const authorization = await this.authority.authorize(context, { operation: "cancel", run: this.#run(run.runId) }); + return this.storage.mutateRun(run.runId, input.expectedRunVersion, (state, meta) => { + if (["completed", "failed", "blocked", "cancelled"].includes(state.run.status)) stale("terminal run history cannot be cancelled again"); + const command: CommandRecord = { commandId: input.commandId, kind: "run.cancel", requestHash: commandRequestHash(input), callerPrincipal: authorization.callerPrincipal, + authorizationContextHash: authorization.authorizationContextHash, projectId: run.projectId, controlDomainId: run.controlDomainId, status: "completed", firstCommitSeq: meta.commitSeq, lastCommitSeq: meta.commitSeq, recordedAt: Date.now() }; + return advance(state, { + run: { ...state.run, requiresReadmission: false, status: this.#checkQuiescence(state.run, proof) ? "cancelled" : "unknown", stage: this.#checkQuiescence(state.run, proof) ? "terminal" : "reconciling", needsOperator: !this.#checkQuiescence(state.run, proof) }, + approvals: state.approvals.map((item) => item.status === "pending" ? { ...item, status: "cancelled" as const, decidedAt: Date.now() } : item), + outbox: this.#release(state, state.approvals.at(-1)?.approvalRequestId ?? invalid("run has no approval"), proof), + }, state.approvals.filter((item) => item.status === "pending").map((item) => event({ kind: "approval.settled", approvalRequestId: item.approvalRequestId, decision: "cancelled" })), command); + }).run; + } + /** Re-prove quiescence after a non-quiescent pause; never infer it from status. */ + async park(context: C, input: { runId: string; expectedRunVersion: number }): Promise { + const run = this.#run(input.runId); + await this.authority.authorize(context, { operation: "park", run }); + const proof = await this.authority.quiescence(context, run); + await this.authority.authorize(context, { operation: "park", run: this.#run(run.runId) }); + this.storage.mutateRun(run.runId, input.expectedRunVersion, (state) => { + if (!(state.run.status === "paused" || state.run.status === "running" && state.run.stage === "queued") || state.run.requiresReadmission !== true) stale("terminal or executing runs cannot be revived by park"); + if (!this.#checkQuiescence(state.run, proof)) throw new ControlError("TF_RECONCILE_REQUIRED", "park requires fresh TE quiescence", { recoveryAction: "operator", sideEffects: "unknown" }); + const approval = state.approvals.find((item) => item.status === "pending" || item.status === "approved" || item.status === "edited") ?? invalid("run has no durable approval"); + const intent: ApprovalReleaseIntent[] = state.run.reservationId ? [{ kind: "approval.release", intentId: randomUUID(), approvalRequestId: approval.approvalRequestId, runId: run.runId, reservationId: state.run.reservationId, evidence: proof, complete: false }] : []; + return advance(state, { run: { ...state.run, status: "paused", stage: "parked", requiresReadmission: true }, outbox: [...state.outbox.filter((item) => item.kind !== "approval.release" || item.complete), ...intent] }, []); + }); + await this.drainReleases(context, run.runId); + return this.#run(run.runId); + } + /** Terminal status may precede process/TE settlement. Once settlement is + * proven, discharge capacity without reopening or changing terminal history. */ + async releaseTerminal(context: C, input: { runId: string; expectedRunVersion: number }): Promise { + const run = this.#run(input.runId); + await this.authority.authorize(context, { operation: "park", run }); + const proof = await this.authority.quiescence(context, run); + await this.authority.authorize(context, { operation: "park", run: this.#run(run.runId) }); + this.storage.mutateRun(run.runId, input.expectedRunVersion, (state) => { + if (!["completed", "failed", "blocked", "cancelled"].includes(state.run.status)) stale("terminal release requires terminal run"); + if (!this.#checkQuiescence(state.run, proof)) throw new ControlError("TF_RECONCILE_REQUIRED", "terminal release requires fresh TE quiescence", { recoveryAction: "operator", sideEffects: "unknown" }); + const approval = state.approvals.at(-1) ?? invalid("run has no approval history"); + return advance(state, { run: { ...state.run, requiresReadmission: false }, outbox: this.#release(state, approval.approvalRequestId, proof) }, []); + }); + await this.drainReleases(context, run.runId); + return this.#run(run.runId); + } + async drainReleases(context: C, runId?: string): Promise { + const ids = runId ? [runId] : [...new Set(this.storage.listApprovals().map((request) => request.runId))]; + for (const id of ids) for (const item of this.storage.readOutbox(id)) { + if (item.kind !== "approval.release" || item.complete) continue; + const run = this.#run(id); + await this.authority.authorize(context, { operation: "park", run }); + await this.authority.normalRelease(context, item); + const current = this.#run(id); + await this.authority.authorize(context, { operation: "park", run: current }); + this.storage.mutateRun(id, current.runVersion, (state) => { + const found = state.outbox.find((intent) => intent.intentId === item.intentId); + if (!found || found.complete || state.run.reservationId !== item.reservationId) stale("release intent changed"); + const queued = !state.approvals.some((request) => request.status === "pending") && state.approvals.some((request) => request.status === "approved" || request.status === "edited"); + return advance(state, { run: { ...state.run, slot: "released", ...(queued && state.run.status === "paused" ? { status: "running" as const, stage: "queued" as const } : {}) }, outbox: state.outbox.map((intent) => intent.intentId === item.intentId ? { ...intent, complete: true } : intent) }, []); + }); + } + } + async readmit(context: C, input: { approvalRequestId: string; expectedRunVersion: number }): Promise { + const request = this.#request(input.approvalRequestId), run = this.#run(request.runId); + if (run.runVersion !== input.expectedRunVersion) stale("readmission run version changed"); + await this.authority.authorize(context, { operation: "readmit", run, request }); + const pending = this.storage.readOutbox(run.runId).find((item): item is ApprovalReadmissionIntent => item.kind === "approval.readmission" && !item.complete); + if (pending) return this.#finishReadmission(context, pending); + if (!["approved", "edited"].includes(request.status) || run.stage !== "queued" || !["released", "none"].includes(run.slot)) stale("approved run must queue and release its old reservation before readmission"); + const prepared = await this.authority.prepareReadmission(context, run, request); + const auth = await this.authority.authorize(context, { operation: "readmit", run: this.#run(run.runId), request: this.#request(request.approvalRequestId) }); + const { fragment, owner } = prepared; + if (!Value.Check(BoundFragmentSchema, fragment) || fragment.projectId !== run.projectId || fragment.controlDomainId !== run.controlDomainId + || fragment.parentBoundPlanHash !== run.boundPlanHash || fragment.parentBoundFragmentHash !== run.boundFragmentHash + || fragment.sourceEventId !== request.decisionEventId || fragment.sourceCommitSeq !== request.decisionCommitSeq + || fragment.boundFragmentHash === run.boundFragmentHash || fragment.authorityEpoch < run.authorityEpoch + || !fragment.fragmentPolicyHash.endsWith(`:${prepared.policyHash}`) || prepared.authorizationContextHash !== auth.authorizationContextHash + || owner.runId !== run.runId || !owner.attemptId || !owner.unitId || owner.attemptId === run.owner?.attemptId || owner.unitId === run.owner?.unitId) revoked("readmission must bind fresh policy, fragment and execution owner"); + const reservation = await this.authority.reserve(context, run, owner); + if (reservation.reservationId === run.reservationId || reservation.runId !== run.runId || reservation.projectId !== run.projectId + || reservation.projectControlDomainId !== run.controlDomainId || reservation.state !== "reserved") revoked("readmission requires a new bound reservation"); + await this.authority.authorize(context, { operation: "readmit", run: this.#run(run.runId), request: this.#request(request.approvalRequestId) }); + const committed = this.storage.mutateRun(run.runId, input.expectedRunVersion, (state, meta) => { + if (state.run.stage !== "queued" || state.approvals.some((item) => item.status === "pending")) stale("readmission state changed"); + const intent: ApprovalReadmissionIntent = { kind: "approval.readmission", intentId: randomUUID(), approvalRequestId: request.approvalRequestId, runId: run.runId, reservation, owner, fragment, + policyHash: prepared.policyHash, authorizationContextHash: auth.authorizationContextHash, projectAdmitCommitSeq: meta.commitSeq, complete: false }; + return advance(state, { run: { ...state.run, status: "running", stage: "admitted", slot: "reserved", reservationId: reservation.reservationId, owner, policyHash: prepared.policyHash, authorityEpoch: fragment.authorityEpoch, boundFragmentHash: fragment.boundFragmentHash, projectAdmitCommitSeq: meta.commitSeq }, outbox: [...state.outbox, intent] }, []); + }); + const intent = committed.outbox.find((item): item is ApprovalReadmissionIntent => item.kind === "approval.readmission" && !item.complete)!; + return this.#finishReadmission(context, intent); + } + async #finishReadmission(context: C, intent: ApprovalReadmissionIntent): Promise { + const run = this.#run(intent.runId), request = this.#request(intent.approvalRequestId); + const assertAdmitted = (current: ApprovalRun) => { + if (current.status !== "running" || current.stage !== "admitted" || current.requiresReadmission !== true + || current.reservationId !== intent.reservation.reservationId || current.owner?.attemptId !== intent.owner.attemptId + || !["approved", "edited"].includes(this.#request(intent.approvalRequestId).status)) stale("readmission was cancelled or changed"); + }; + assertAdmitted(run); + await this.authority.authorize(context, { operation: "readmit", run, request }); + await this.authority.validateReadmission(context, run, intent); + await this.authority.authorize(context, { operation: "readmit", run: this.#run(run.runId), request: this.#request(request.approvalRequestId) }); + assertAdmitted(this.#run(run.runId)); + await this.authority.commitReservation(context, intent); + const current = this.#run(run.runId); + await this.authority.validateReadmission(context, current, intent); + await this.authority.authorize(context, { operation: "readmit", run: this.#run(run.runId), request: this.#request(request.approvalRequestId) }); + return this.storage.mutateRun(run.runId, current.runVersion, (state) => { + const pending = state.outbox.find((item) => item.intentId === intent.intentId); + if (!pending || pending.complete || state.run.stage !== "admitted" || state.run.reservationId !== intent.reservation.reservationId || state.run.owner?.attemptId !== intent.owner.attemptId) stale("readmission intent changed"); + return advance(state, { run: { ...state.run, slot: "committed", requiresReadmission: false }, outbox: state.outbox.map((item) => item.intentId === intent.intentId ? { ...item, complete: true } : item) }, []); + }).run; + } +} diff --git a/packages/taskflow-control/src/authorization.ts b/packages/taskflow-control/src/authorization.ts new file mode 100644 index 00000000..485fdd14 --- /dev/null +++ b/packages/taskflow-control/src/authorization.ts @@ -0,0 +1,282 @@ +/** + * Trusted local identity and P1/P2/P12 authorization. + * + * A protected bootstrap token proves possession of the local OS user's token, + * not kernel-verified per-connection peer credentials. The bootstrap owner must + * protect its directory/token (0700/0600); processes able to read that token act + * as that user. This module neither grants network/domain transfer authority nor + * replaces TE PathRef/permit checks. Policy callbacks must read current policy. + */ +import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; +import { lstatSync, realpathSync, statSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { canonicalJson } from "taskflow-core/flowir/hash"; +import { ControlError } from "./errors.ts"; +import type { CommandKind } from "./schema/commands.ts"; + +export const AUTHORIZATION_CAPABILITIES = [ + "project.read", "run.submit", "run.cancel", "approval.decide", "coordinator.setMaxActiveRuns", + "coordinator.forceRelease", "artifact.read", +] as const; +export type AuthorizationCapabilityKind = typeof AUTHORIZATION_CAPABILITIES[number]; +export type AuthorizationCapability = { kind: AuthorizationCapabilityKind; scopeRoot: string }; +export type AuthorizationPolicyLayer = { + capabilities?: AuthorizationCapability[]; + deny?: AuthorizationCapabilityKind[]; + attenuate?: AuthorizationCapability[]; + substitute?: { capability: AuthorizationCapabilityKind; with: AuthorizationCapability }[]; +}; +export type LiveAuthorizationPolicy = { + host: AuthorizationPolicyLayer; + user?: AuthorizationPolicyLayer; + project?: AuthorizationPolicyLayer; + invocation?: AuthorizationPolicyLayer; + revokedPrincipals?: string[]; +}; +export type AuthorizationBinding = { projectId: string; controlDomainId: string; projectRoot: string }; +declare const verifiedContextBrand: unique symbol; +/** No serializable identity fields. Only the issuing authority recognizes it. */ +export type VerifiedContext = { readonly [verifiedContextBrand]: true }; +export type AuthorizationChallenge = Readonly<{ + purpose: "taskflow.control.authentication"; version: 1; + id: string; nonce: string; expiresAt: number; principal: string; binding: Readonly; +}>; +export type AuthorizeOperation = "read" | "submit" | "replay" | "artifact"; +export type AuthorizationCommandKind = CommandKind | "run.cancel"; +export type AuthorizationRequest = AuthorizationBinding & { + operation: AuthorizeOperation; + commandKind?: AuthorizationCommandKind; + resourcePath?: string; + artifactDigest?: string; +}; +export type AuthorizationDecision = Readonly<{ + principal: string; + callerPrincipal: string; + authorizationContextHash: string; + capability: Readonly; + effectivePolicy: Readonly<{ hostBaseline: readonly AuthorizationCapabilityKind[]; layers: LiveAuthorizationPolicy; capability: Readonly }>; +}>; +export interface AuthorizationAuthorityOptions { + ownerUid: number; + /** Trusted bootstrap issuer configuration, never an RPC body/client label. */ + credentialIssuerId?: string; + bootstrapSecret: Uint8Array; + /** Explicit, trusted host baseline; [] grants no capabilities. */ + hostBaseline: readonly AuthorizationCapabilityKind[]; + loadLivePolicy: (principal: string, binding: Readonly) => unknown | Promise; + verifyArtifactReachability?: (principal: string, binding: Readonly, digest: string, commandKind?: AuthorizationCommandKind) => boolean | Promise; + now?: () => number; + challengeTtlMs?: number; +} +export interface AuthorizationAuthority { + issueStandalone(binding: AuthorizationBinding): VerifiedContext; + createChallenge(binding: AuthorizationBinding): AuthorizationChallenge; + authenticate(challengeId: string, proof: string): VerifiedContext; + identity(context: VerifiedContext): Readonly<{ principal: string; binding: Readonly }>; + revokeContext(context: VerifiedContext): void; + authorize(context: VerifiedContext, request: AuthorizationRequest): Promise; +} + +function denied(message: string): never { throw new ControlError("TF_POLICY_DENIED", message); } +function revoked(): never { throw new ControlError("TF_AUTHORITY_REVOKED", "verified context is absent, foreign, or revoked"); } +function record(value: unknown, keys: readonly string[]): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) denied("security object is required"); + const object = value as Record; + if (![Object.prototype, null].includes(Object.getPrototypeOf(object))) denied("security object prototype is unsupported"); + if (Object.keys(object).some((key) => !keys.includes(key))) denied("unknown security field"); + return object; +} +function text(value: unknown): value is string { return typeof value === "string" && value.length > 0; } +function kind(value: unknown): value is AuthorizationCapabilityKind { + return (AUTHORIZATION_CAPABILITIES as readonly unknown[]).includes(value); +} +function contained(root: string, target: string): boolean { + const suffix = relative(root, target); + return suffix === "" || (!isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`)); +} +/** Resolve existing ancestors too, so a symlink cannot enlarge lexical scope. */ +function canonicalPath(value: unknown): string { + if (!text(value) || !isAbsolute(value) || value.includes("\0")) denied("absolute resource scope is required"); + let existing = resolve(value); + const suffix: string[] = []; + for (;;) { + try { lstatSync(existing); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") denied("resource scope cannot be verified"); + const parent = dirname(existing); + if (parent === existing) denied("resource scope cannot be verified"); + suffix.unshift(relative(parent, existing)); existing = parent; + continue; + } + // An existing entry whose target cannot be resolved is not a missing + // path: notably a dangling symlink must never inherit its parent's scope. + try { return resolve(realpathSync(existing), ...suffix); } + catch { denied("existing resource scope or symbolic link cannot be verified"); } + } +} +function trustedBinding(value: unknown): Readonly { + const body = record(value, ["projectId", "controlDomainId", "projectRoot"]); + if (!text(body.projectId) || !text(body.controlDomainId)) denied("project/domain identity is required"); + // A mount is an existing canonical root, not an unverified client path. + const root = canonicalPath(body.projectRoot); + try { if (realpathSync(root) !== root || !statSync(root).isDirectory()) denied("project root is not a canonical directory"); } + catch { denied("project root must exist"); } + return Object.freeze({ projectId: body.projectId, controlDomainId: body.controlDomainId, projectRoot: root }); +} +function capability(value: unknown, root: string): AuthorizationCapability { + const body = record(value, ["kind", "scopeRoot"]); + if (!kind(body.kind)) denied("unknown capability"); + const scopeRoot = canonicalPath(body.scopeRoot); + if (!contained(root, scopeRoot)) denied("policy cannot expand project scope"); + return { kind: body.kind, scopeRoot }; +} +function array(value: unknown): unknown[] { if (!Array.isArray(value)) denied("security list is required"); return value; } +function freezePolicy(policy: LiveAuthorizationPolicy): LiveAuthorizationPolicy { + function freeze(value: unknown): void { + if (value && typeof value === "object") { + for (const child of Object.values(value)) freeze(child); + Object.freeze(value); + } + } + freeze(policy); return policy; +} +function layer(value: unknown, root: string): AuthorizationPolicyLayer { + const body = record(value, ["capabilities", "deny", "attenuate", "substitute"]); + const result: AuthorizationPolicyLayer = {}; + for (const field of ["capabilities", "attenuate"] as const) { + if (body[field] !== undefined) result[field] = array(body[field]).map((item) => capability(item, root)); + } + if (body.deny !== undefined) result.deny = array(body.deny).map((item) => { if (!kind(item)) denied("unknown denied capability"); return item; }); + if (body.substitute !== undefined) result.substitute = array(body.substitute).map((item) => { + const substitution = record(item, ["capability", "with"]); + if (!kind(substitution.capability)) denied("unknown substitution capability"); + const replacement = capability(substitution.with, root); + if (substitution.capability !== replacement.kind) denied("substitution cannot change command authority"); + return { capability: substitution.capability, with: replacement }; + }); + return result; +} +function livePolicy(value: unknown, root: string): LiveAuthorizationPolicy { + const body = record(value, ["host", "user", "project", "invocation", "revokedPrincipals"]); + const result: LiveAuthorizationPolicy = { host: layer(body.host, root) }; + for (const field of ["user", "project", "invocation"] as const) if (body[field] !== undefined) result[field] = layer(body[field], root); + if (body.revokedPrincipals !== undefined) result.revokedPrincipals = array(body.revokedPrincipals).map((item) => { if (!text(item)) denied("invalid revoked principal"); return item; }); + return result; +} +function requestedCapability(request: AuthorizationRequest): AuthorizationCapabilityKind { + if (request.commandKind !== undefined && !["run.submit", "run.cancel", "approval.decide", "coordinator.setMaxActiveRuns", "coordinator.forceRelease"].includes(request.commandKind)) denied("unknown command class"); + if (request.operation === "read") return "project.read"; + if (request.operation === "artifact") return "artifact.read"; + if ((request.operation === "submit" || request.operation === "replay") && request.commandKind !== undefined) return request.commandKind; + return denied("unknown operation or command class"); +} +/** Shared core canonical serialization, full SHA256 for wire audit fields. */ +export function authorizationAuditHash(value: unknown): string { + return createHash("sha256").update(canonicalJson(value), "utf8").digest("hex"); +} +/** Client helper: caller obtains secret from protected bootstrap storage. */ +export function answerAuthorizationChallenge(secret: Uint8Array, challenge: AuthorizationChallenge): string { + return createHmac("sha256", secret).update(canonicalJson(challenge), "utf8").digest("hex"); +} + +export function createAuthorizationAuthority(options: AuthorizationAuthorityOptions): AuthorizationAuthority { + if (!Number.isSafeInteger(options.ownerUid) || options.ownerUid < 0 || !process.getuid || options.ownerUid !== process.getuid()) { + throw new ControlError("TF_BOOTSTRAP_FAILED", "local authorization requires the actual process OS uid"); + } + if (!(options.bootstrapSecret instanceof Uint8Array) || options.bootstrapSecret.byteLength < 32) denied("protected bootstrap secret must have at least 256 bits"); + const secret = Buffer.from(options.bootstrapSecret); + const baseline = new Set(array([...options.hostBaseline]).map((value) => { if (!kind(value)) denied("unknown host baseline capability"); return value; })); + if (options.credentialIssuerId !== undefined && !/^[a-zA-Z0-9._-]{1,64}$/.test(options.credentialIssuerId)) denied("invalid trusted credential issuer"); + const principal = `os-user:${options.ownerUid}${options.credentialIssuerId === undefined ? "" : `/credential:${options.credentialIssuerId}`}`; + const now = options.now ?? Date.now; + const ttl = options.challengeTtlMs ?? 5_000; + if (!Number.isSafeInteger(ttl) || ttl < 1 || ttl > 60_000) denied("challenge lifetime must be bounded"); + const contexts = new WeakMap }>>(); + const challenges = new Map(); + function issue(binding: Readonly): VerifiedContext { + const context = Object.freeze(Object.create(null)) as VerifiedContext; + contexts.set(context, Object.freeze({ principal, binding })); return context; + } + function identity(context: VerifiedContext) { + if (context === null || typeof context !== "object") return revoked(); + const verified = contexts.get(context); if (!verified) return revoked(); return verified; + } + return { + issueStandalone: (binding) => issue(trustedBinding(binding)), + createChallenge(binding) { + const time = now(); + for (const [id, pending] of challenges) if (pending.expiresAt <= time) challenges.delete(id); + if (challenges.size >= 256) denied("too many pending authentication challenges"); + const challenge: AuthorizationChallenge = Object.freeze({ purpose: "taskflow.control.authentication", version: 1, id: randomUUID(), nonce: randomBytes(32).toString("hex"), expiresAt: time + ttl, principal, binding: trustedBinding(binding) }); + challenges.set(challenge.id, challenge); return challenge; + }, + authenticate(id, proof) { + const challenge = challenges.get(id); challenges.delete(id); + if (!challenge || challenge.expiresAt <= now() || typeof proof !== "string" || !/^[a-f0-9]{64}$/.test(proof)) denied("authentication challenge is absent, expired, or invalid"); + const expected = Buffer.from(answerAuthorizationChallenge(secret, challenge), "hex"); + if (!timingSafeEqual(expected, Buffer.from(proof, "hex"))) denied("bootstrap token proof failed"); + return issue(challenge.binding); + }, + identity, + revokeContext(context) { contexts.delete(context); }, + async authorize(context, request) { + const verified = identity(context); + const body = { ...record(request, ["operation", "commandKind", "resourcePath", "artifactDigest", "projectId", "controlDomainId", "projectRoot"]) }; + if (body.projectId !== verified.binding.projectId || body.controlDomainId !== verified.binding.controlDomainId + || canonicalPath(body.projectRoot) !== verified.binding.projectRoot) denied("verified context does not authorize this project/domain"); + const operation = body.operation as AuthorizeOperation; + const commandKind = body.commandKind as AuthorizationCommandKind | undefined; + const requested = requestedCapability({ ...verified.binding, operation, commandKind }); + if (operation === "artifact" && commandKind === undefined) denied("artifact authorization requires its ledger command class"); + const requiredCapabilities: AuthorizationCapabilityKind[] = operation === "artifact" ? [requested, commandKind!] : [requested]; + for (const required of requiredCapabilities) if (!baseline.has(required)) denied("capability is outside the explicit host baseline"); + let path = canonicalPath(body.resourcePath ?? verified.binding.projectRoot); + if (!contained(verified.binding.projectRoot, path)) denied("resource is outside the verified project"); + if (operation === "artifact") { + if (!text(body.artifactDigest) || !options.verifyArtifactReachability) denied("artifact digest is not bearer authorization"); + try { if (!await options.verifyArtifactReachability(verified.principal, verified.binding, body.artifactDigest, commandKind)) denied("artifact is not reachable from an authorized ledger entry"); } + catch { denied("artifact reachability is unavailable or denied"); } + } + // The policy read comes after any asynchronous reachability check, so a + // revocation during that check cannot expose historical artifact bytes. + let policy: LiveAuthorizationPolicy; + try { policy = livePolicy(await options.loadLivePolicy(verified.principal, verified.binding), verified.binding.projectRoot); } + catch (error) { if (error instanceof ControlError) throw error; return denied("current policy is unavailable"); } + identity(context); // Revocation while awaiting a policy read must also deny. + path = canonicalPath(body.resourcePath ?? verified.binding.projectRoot); + if (canonicalPath(verified.binding.projectRoot) !== verified.binding.projectRoot || !contained(verified.binding.projectRoot, path)) denied("project/resource scope changed during authorization"); + if (policy.revokedPrincipals?.includes(verified.principal)) return revoked(); + let scopeRoot = verified.binding.projectRoot; + for (const required of requiredCapabilities) { + let substitution: string | undefined; + for (const current of [policy.host, policy.user, policy.project, policy.invocation]) { + if (!current) continue; + if (current.deny?.includes(required)) denied("capability is denied by current policy"); + const attenuation = current.attenuate?.filter((entry) => entry.kind === required); + for (const grant of [current.capabilities, attenuation?.length ? attenuation : undefined]) { + if (grant === undefined) continue; + // No attenuation entry for this kind means inheritance; an explicit + // capabilities: [] always means deny everything. + const candidates = grant.filter((entry) => entry.kind === required && contained(entry.scopeRoot, path)); + if (candidates.length === 0) denied("capability scope is absent or attenuated"); + const widest = candidates.sort((a, b) => a.scopeRoot.length - b.scopeRoot.length)[0]!; + if (contained(scopeRoot, widest.scopeRoot)) scopeRoot = widest.scopeRoot; + } + for (const replacement of current.substitute ?? []) { + if (replacement.capability !== required) continue; + if (substitution !== undefined && substitution !== replacement.with.scopeRoot) denied("substitution conflict"); + substitution = replacement.with.scopeRoot; + if (!contained(scopeRoot, substitution) || !contained(substitution, path)) denied("substitution cannot expand effective scope"); + scopeRoot = substitution; + } + } + } + const effectiveCapability = Object.freeze({ kind: requested, scopeRoot }); + freezePolicy(policy); + return Object.freeze({ principal: verified.principal, callerPrincipal: verified.principal, + authorizationContextHash: authorizationAuditHash({ principal: verified.principal, binding: verified.binding, hostBaseline: [...baseline].sort(), policy, operation, commandKind, scopeRoot }), + capability: effectiveCapability, + effectivePolicy: Object.freeze({ hostBaseline: Object.freeze([...baseline].sort()), layers: policy, capability: effectiveCapability }) }); + }, + }; +} diff --git a/packages/taskflow-control/src/control-host.ts b/packages/taskflow-control/src/control-host.ts index eb834005..2ff1f1c8 100644 --- a/packages/taskflow-control/src/control-host.ts +++ b/packages/taskflow-control/src/control-host.ts @@ -303,6 +303,7 @@ export class ControlHost { stage: "received", slot: "none", needsOperator: false, + runVersion: 0, }; return snapshot as unknown as T; } diff --git a/packages/taskflow-control/src/schema/approval.ts b/packages/taskflow-control/src/schema/approval.ts index bb38bcb0..6b70884f 100644 --- a/packages/taskflow-control/src/schema/approval.ts +++ b/packages/taskflow-control/src/schema/approval.ts @@ -46,7 +46,10 @@ export const ApprovalRequestSchema = Type.Object( createdAt: Type.Integer({ minimum: 0 }), decidedAt: Type.Optional(Type.Integer({ minimum: 0 })), decisionCommandId: Type.Optional(UuidSchema), + decisionEventId: Type.Optional(UuidSchema), + decisionCommitSeq: Type.Optional(Type.Integer({ minimum: 1 })), editArtifactRef: Type.Optional(ArtifactRefSchema), + editKind: Type.Optional(StringEnum(["output", "plan"])), }, { additionalProperties: false }, ); @@ -67,7 +70,10 @@ export type ApprovalRequest = { createdAt: number; decidedAt?: number; decisionCommandId?: string; + decisionEventId?: string; + decisionCommitSeq?: number; editArtifactRef?: ArtifactRef; + editKind?: "output" | "plan"; }; /** @@ -87,6 +93,8 @@ export const ApprovalDecisionCommandSchema = Type.Object( runId: UuidSchema, expectedRunVersion: Type.Integer({ minimum: 0 }), decision: ApprovalDecisionSchema, + editArtifactRef: Type.Optional(ArtifactRefSchema), + editKind: Type.Optional(StringEnum(["output", "plan"])), recordedAt: Type.Integer({ minimum: 0 }), }, { additionalProperties: false }, @@ -102,5 +110,7 @@ export type ApprovalDecisionCommand = { runId: string; expectedRunVersion: number; decision: ApprovalDecision; + editArtifactRef?: ArtifactRef; + editKind?: "output" | "plan"; recordedAt: number; }; diff --git a/packages/taskflow-control/src/schema/commands.ts b/packages/taskflow-control/src/schema/commands.ts index 087ff4ba..55069395 100644 --- a/packages/taskflow-control/src/schema/commands.ts +++ b/packages/taskflow-control/src/schema/commands.ts @@ -8,14 +8,17 @@ */ import { createHash } from "node:crypto"; -import { Type } from "typebox"; +import { Type, type Static } from "typebox"; +import { canonicalJson } from "taskflow-core/flowir/hash"; import { StringEnum } from "taskflow-core/typebox-helpers"; import { CONTROL_WIRE_SCHEMA_VERSION, Sha256HexSchema, UuidSchema, } from "./common.ts"; -import type { ArtifactRef } from "./evidence.ts"; +import { ArtifactRefSchema, ReceiptSchema, type ArtifactRef } from "./evidence.ts"; +import { ApprovalRequestSchema } from "./approval.ts"; +import { RunSnapshotSchema } from "./run.ts"; // --------------------------------------------------------------------------- // Command kinds (closed union; P15/P16 kinds, plus the base run command) @@ -23,11 +26,12 @@ import type { ArtifactRef } from "./evidence.ts"; export const CommandKindSchema = StringEnum([ "run.submit", + "run.cancel", "approval.decide", "coordinator.setMaxActiveRuns", "coordinator.forceRelease", ]); -export type CommandKind = "run.submit" | "approval.decide" | "coordinator.setMaxActiveRuns" | "coordinator.forceRelease"; +export type CommandKind = "run.submit" | "run.cancel" | "approval.decide" | "coordinator.setMaxActiveRuns" | "coordinator.forceRelease"; export const CommandStatusSchema = StringEnum(["accepted", "completed", "failed"]); export type CommandStatus = "accepted" | "completed" | "failed"; @@ -48,18 +52,7 @@ export const CommandRecordSchema = Type.Object( status: CommandStatusSchema, firstCommitSeq: Type.Integer({ minimum: 1 }), lastCommitSeq: Type.Integer({ minimum: 1 }), - responseArtifactRef: Type.Optional( - Type.Object( - { - digest: Type.String({ minLength: 1 }), - size: Type.Integer({ minimum: 0 }), - mediaType: Type.String({ minLength: 1 }), - storageClass: Type.String({ minLength: 1 }), - redactionClass: Type.String({ minLength: 1 }), - }, - { additionalProperties: false }, - ), - ), + responseArtifactRef: Type.Optional(ArtifactRefSchema), recordedAt: Type.Integer({ minimum: 0 }), }, { additionalProperties: false }, @@ -85,7 +78,7 @@ export type CommandRecord = { // §17 approval.*; run terminal). Additive extension bumps schemaVersion. // --------------------------------------------------------------------------- -export const ControlEventPayloadSchema = Type.Union([ +export const LegacyControlEventPayloadSchema = Type.Union([ Type.Object( { kind: Type.Literal("command.recorded"), commandId: UuidSchema }, { additionalProperties: false }, @@ -149,43 +142,26 @@ export const ControlEventPayloadSchema = Type.Union([ { additionalProperties: false }, ), ]); -export type ControlEventPayload = { - kind: "command.recorded"; - commandId: string; -} | { - kind: "reconcile.started"; - attempt: number; -} | { - kind: "reconcile.settled"; - outcome: "running" | "terminal" | "exhausted"; - terminalStatus?: "completed" | "failed" | "blocked" | "cancelled"; -} | { - kind: "dispatch.acknowledged"; - providerJobHandle?: string; -} | { - kind: "dispatch.rejected"; - reason: string; -} | { - kind: "dispatch.ambiguous"; -} | { - kind: "run.terminal"; - status: "completed" | "failed" | "blocked" | "cancelled"; -} | { - kind: "approval.pending"; - approvalRequestId: string; -} | { - kind: "approval.settled"; - decision: "approved" | "rejected" | "expired" | "cancelled"; -} | { - kind: "compaction.checkpoint"; - throughCommitSeq: number; -}; +/** V2 carries complete approval and lifecycle facts. V1 remains read-only. */ +export const ControlEventPayloadSchema = Type.Union([ + ...LegacyControlEventPayloadSchema.anyOf.filter((schema) => !["approval.pending", "approval.settled"].includes(schema.properties.kind.const as string)), + Type.Object({ kind: Type.Literal("approval.pending"), approvalRequestId: UuidSchema, request: ApprovalRequestSchema }, { additionalProperties: false }), + Type.Object({ kind: Type.Literal("approval.settled"), approvalRequestId: UuidSchema, decision: StringEnum(["approved", "rejected", "edited", "expired", "cancelled"]) }, { additionalProperties: false }), + Type.Object({ kind: Type.Literal("run.snapshot"), run: RunSnapshotSchema }, { additionalProperties: false }), + Type.Object({ kind: Type.Literal("approval.release.queued"), approvalRequestId: UuidSchema, intentId: UuidSchema, reservationId: UuidSchema }, { additionalProperties: false }), + Type.Object({ kind: Type.Literal("approval.release.completed"), approvalRequestId: UuidSchema, intentId: UuidSchema, reservationId: UuidSchema }, { additionalProperties: false }), + Type.Object({ kind: Type.Literal("approval.readmission.queued"), approvalRequestId: UuidSchema, intentId: UuidSchema, reservationId: UuidSchema }, { additionalProperties: false }), + Type.Object({ kind: Type.Literal("approval.readmission.completed"), approvalRequestId: UuidSchema, intentId: UuidSchema, reservationId: UuidSchema }, { additionalProperties: false }), + Type.Object({ kind: Type.Literal("receipt.issued"), receipt: ReceiptSchema }, { additionalProperties: false }), + Type.Object({ kind: Type.Literal("artifact.recorded"), runId: UuidSchema, commandKind: CommandKindSchema, artifact: ArtifactRefSchema }, { additionalProperties: false }), +]); +export type ControlEventPayload = Static | Static; // --------------------------------------------------------------------------- // ControlEvent (P12 / RFC §10) — ledger envelope // --------------------------------------------------------------------------- -export const ControlEventSchema = Type.Object( +const CurrentControlEventSchema = Type.Object( { eventId: UuidSchema, schemaVersion: Type.Literal(CONTROL_WIRE_SCHEMA_VERSION), @@ -203,9 +179,13 @@ export const ControlEventSchema = Type.Object( }, { additionalProperties: false }, ); +export const ControlEventSchema = Type.Union([ + CurrentControlEventSchema, + Type.Object({ ...CurrentControlEventSchema.properties, schemaVersion: Type.Literal(1), payload: LegacyControlEventPayloadSchema }, { additionalProperties: false }), +]); export type ControlEvent = { eventId: string; - schemaVersion: typeof CONTROL_WIRE_SCHEMA_VERSION; + schemaVersion: 1 | typeof CONTROL_WIRE_SCHEMA_VERSION; controlDomainId: string; streamId: string; streamSeq: number; @@ -260,17 +240,3 @@ export function commandRequestHash(body: unknown): string { return createHash("sha256").update(canonicalJson(body), "utf8").digest("hex"); } -function canonicalJson(value: unknown): string { - if (value === null) return "null"; - if (typeof value === "boolean" || typeof value === "number") return JSON.stringify(value); - if (typeof value === "string") return JSON.stringify(value); - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; - if (typeof value === "object") { - const record = value as Record; - const keys = Object.keys(record) - .filter((key) => record[key] !== undefined) - .sort(); - return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`; - } - return "null"; -} diff --git a/packages/taskflow-control/src/schema/common.ts b/packages/taskflow-control/src/schema/common.ts index 7e2a7641..bf069661 100644 --- a/packages/taskflow-control/src/schema/common.ts +++ b/packages/taskflow-control/src/schema/common.ts @@ -12,7 +12,9 @@ import { StringEnum } from "taskflow-core/typebox-helpers"; * document that carries `schemaVersion` uses this constant; additive evolution * bumps it (wire-freeze rule 4: freeze allows only additive changes + bump). */ -export const CONTROL_WIRE_SCHEMA_VERSION = 1; +export const CONTROL_WIRE_SCHEMA_VERSION = 2; +/** Historical journal envelopes remain readable without rewriting their bytes. */ +export const LEGACY_CONTROL_WIRE_SCHEMA_VERSION = 1; /** UUID v4 scalar convention (ControlDomainId, projectId, eventId, ...). */ export const UuidSchema = Type.String({ diff --git a/packages/taskflow-control/src/schema/header.ts b/packages/taskflow-control/src/schema/header.ts index 834503e2..ec45366e 100644 --- a/packages/taskflow-control/src/schema/header.ts +++ b/packages/taskflow-control/src/schema/header.ts @@ -44,7 +44,7 @@ export const ControlStoreHeaderSchema = Type.Object( { projectId: ProjectIdSchema, controlDomainId: ControlDomainIdSchema, - schemaVersion: Type.Literal(CONTROL_WIRE_SCHEMA_VERSION), + schemaVersion: Type.Union([Type.Literal(1), Type.Literal(CONTROL_WIRE_SCHEMA_VERSION)]), directoryBinding: DirectoryBindingSchema, }, { additionalProperties: false }, @@ -52,7 +52,7 @@ export const ControlStoreHeaderSchema = Type.Object( export type ControlStoreHeader = { projectId: ProjectId; controlDomainId: ControlDomainId; - schemaVersion: typeof CONTROL_WIRE_SCHEMA_VERSION; + schemaVersion: 1 | typeof CONTROL_WIRE_SCHEMA_VERSION; directoryBinding: DirectoryBinding; }; diff --git a/packages/taskflow-control/src/schema/run.ts b/packages/taskflow-control/src/schema/run.ts index 645154e5..36540bfc 100644 --- a/packages/taskflow-control/src/schema/run.ts +++ b/packages/taskflow-control/src/schema/run.ts @@ -8,7 +8,8 @@ import { Type } from "typebox"; import { StringEnum } from "taskflow-core/typebox-helpers"; -import { UuidSchema } from "./common.ts"; +import { UuidSchema, Sha256HexSchema } from "./common.ts"; +import { ExecutionOwnerSchema, type ExecutionOwner } from "./te-mirrors.ts"; export const RunStatusSchema = StringEnum([ "running", @@ -62,6 +63,14 @@ export const RunSnapshotSchema = Type.Object( stage: RunStageSchema, slot: RunSlotStateSchema, needsOperator: Type.Boolean(), + runVersion: Type.Integer({ minimum: 0 }), + boundPlanHash: Type.Optional(Type.String({ minLength: 1 })), + boundFragmentHash: Type.Optional(Type.String({ minLength: 1 })), + reservationId: Type.Optional(UuidSchema), + owner: Type.Optional(ExecutionOwnerSchema), + policyHash: Type.Optional(Sha256HexSchema), + authorityEpoch: Type.Optional(Type.Integer({ minimum: 0 })), + requiresReadmission: Type.Optional(Type.Boolean()), projectAdmitCommitSeq: Type.Optional(Type.Integer({ minimum: 1 })), }, { additionalProperties: false }, @@ -74,5 +83,13 @@ export type RunSnapshot = { stage: RunStage; slot: RunSlotState; needsOperator: boolean; + runVersion: number; + boundPlanHash?: string; + boundFragmentHash?: string; + reservationId?: string; + owner?: ExecutionOwner; + policyHash?: string; + authorityEpoch?: number; + requiresReadmission?: boolean; projectAdmitCommitSeq?: number; }; diff --git a/packages/taskflow-control/src/store/coordinator-store.ts b/packages/taskflow-control/src/store/coordinator-store.ts new file mode 100644 index 00000000..f45d39d0 --- /dev/null +++ b/packages/taskflow-control/src/store/coordinator-store.ts @@ -0,0 +1,423 @@ +/** P16 user-private, files-only capacity authority. Project Runs/Receipts stay + * in project ledgers. Every operation re-reads one atomically published state + * under the shared persistent mutex; no process-local capacity cache exists. */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { randomUUID } from "node:crypto"; +import { Type } from "typebox"; +import { Value } from "typebox/value"; +import { PersistentFileMutex } from "taskflow-core/persistent-mutex"; +import { ControlError, type ControlErrorCode } from "../errors.ts"; +import { commandRequestHash } from "../schema/commands.ts"; +import { UuidSchema, Sha256HexSchema } from "../schema/common.ts"; +import { CoordinatorLeaseSchema, CoordinatorCommandRecordSchema, ConcurrencyReservationSchema, assertReservationInvariants, + type CoordinatorLease, type CoordinatorCommandRecord, type ConcurrencyReservation, type CapacitySnapshot } from "../schema/coordinator.ts"; +import { writeJsonAtomicHardened } from "./store.ts"; + +export interface CoordinatorIdentity { principal: string; ownerId: string; operator: boolean } +export interface CoordinatorAdmissionProof { + reservationId: string; projectId: string; projectControlDomainId: string; runId: string; projectAdmitCommitSeq: number; runVersion: number; +} +/** Explicit absence must come from the trusted project admission gate. A null + * observation or failed read never authorizes TTL capacity reclamation. */ +export interface CoordinatorAdmissionAbsence { + status: "not-admitted"; reservationId: string; projectId: string; projectControlDomainId: string; runId: string; runVersion: number; +} +export type CoordinatorAdmissionObservation = CoordinatorAdmissionProof | CoordinatorAdmissionAbsence | null; +export interface CoordinatorReleaseProof extends CoordinatorAdmissionProof { + proofId: string; status: string; stage: string; requiresReadmission: boolean; + providerNoLiveProcessTree: boolean; noAmbiguousJobs: boolean; reconcileTimeoutOnly: boolean; +} +export type CoordinatorOperation = "reserve" | "commit" | "renew" | "markOrphanSuspect" | "normalRelease" | "forceRelease" | "setMaxActiveRuns" | "readReservation" | "readCommand" | "snapshot"; +/** Configured only by the trusted host service. Context is opaque service + * authority, never a wire-body principal or caller-supplied quiescence bool. */ +export interface CoordinatorAuthority { + readLease(): CoordinatorLease | null | Promise; + authorize(context: Context, operation: CoordinatorOperation, reservation?: ConcurrencyReservation): CoordinatorIdentity | Promise; + readAdmission(reservation: ConcurrencyReservation): CoordinatorAdmissionObservation | Promise; + readRelease(reservation: ConcurrencyReservation): CoordinatorReleaseProof | Promise; +} +export interface CoordinatorStoreOptions { + initialMaxActiveRuns: number; + epoch: number; + holderId: string; + authority: CoordinatorAuthority; +} +export interface StoredReservation { + reservation: ConcurrencyReservation; + ownerId: string; + principal: string; + updatedAt: number; + admitRunVersion?: number; + releaseKind?: "normal" | "force"; + releaseProofId?: string; + concurrencyGuarantee: "enforced" | "operator-overridden"; +} +export interface StoredCoordinatorCommand { + record: CoordinatorCommandRecord; + result: StoredReservation | number; +} +interface AuditRecord { + commitSeq: number; epoch: number; recordedAt: number; action: string; principal: string; + previousHash: string; stateDigest: string; hash: string; +} +interface CoordinatorState { + version: 1; fencingEpoch: number; commitSeq: number; lastWallTime: number; maxActiveRuns: number; + directoryIdentity: { canonicalPath: string; dev: number; ino: number }; + instanceId: string; + reservations: Record; + commands: Record; + audit: AuditRecord[]; + stateHash: string; +} +export interface CoordinatorSnapshot { + commitSeq: number; fencingEpoch: number; capacity: CapacitySnapshot; + concurrencyGuarantee: "enforced" | "operator-overridden"; + reservations: StoredReservation[]; commands: StoredCoordinatorCommand[]; +} +const integer = Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }); +const text = Type.String({ minLength: 1 }); +const rowSchema = Type.Object({ reservation: ConcurrencyReservationSchema, ownerId: text, principal: text, updatedAt: integer, + admitRunVersion: Type.Optional(integer), releaseKind: Type.Optional(Type.Union([Type.Literal("normal"), Type.Literal("force")])), + releaseProofId: Type.Optional(text), concurrencyGuarantee: Type.Union([Type.Literal("enforced"), Type.Literal("operator-overridden")]) }, { additionalProperties: false }); +const commandSchema = Type.Object({ record: CoordinatorCommandRecordSchema, result: Type.Union([rowSchema, Type.Integer({ minimum: 1 })]) }, { additionalProperties: false }); +const auditSchema = Type.Object({ commitSeq: integer, epoch: integer, recordedAt: integer, action: text, principal: text, + previousHash: Sha256HexSchema, stateDigest: Sha256HexSchema, hash: Sha256HexSchema }, { additionalProperties: false }); +const stateSchema = Type.Object({ version: Type.Literal(1), fencingEpoch: integer, commitSeq: integer, lastWallTime: integer, + directoryIdentity: Type.Object({ canonicalPath: text, dev: integer, ino: integer }, { additionalProperties: false }), + instanceId: UuidSchema, + maxActiveRuns: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), reservations: Type.Record(Type.String(), rowSchema), + commands: Type.Record(Type.String(), commandSchema), audit: Type.Array(auditSchema), stateHash: Sha256HexSchema }, { additionalProperties: false }); +const ZERO_HASH = "0".repeat(64); +export const COORDINATOR_CRASH_ENV = "TASKFLOW_COORDINATOR_CRASH_AT"; +export type CoordinatorCrashPoint = "before-state-write" | "after-state-write"; +const active = (row: StoredReservation) => ["reserved", "committed", "orphan-suspect"].includes(row.reservation.state); +const fail = (code: ControlErrorCode, message: string): never => { throw new ControlError(code, message, { recoveryAction: "operator", sideEffects: "none" }); }; +const validId = (id: string) => { if (!Value.Check(UuidSchema, id)) fail("TF_COMMAND_FAILED", "coordinator identifier must be a UUID"); }; +const positive = (value: number, name: string) => { if (!Number.isSafeInteger(value) || value < 1) fail("TF_COMMAND_FAILED", `${name} must be a positive safe integer`); }; +function capacity(state: CoordinatorState): CapacitySnapshot { + const rows = Object.values(state.reservations); + const reserved = rows.filter((r) => r.reservation.state === "reserved").length; + const committed = rows.filter((r) => r.reservation.state === "committed").length; + const orphanSuspect = rows.filter((r) => r.reservation.state === "orphan-suspect").length; + return { maxActiveRuns: state.maxActiveRuns, active: reserved + committed + orphanSuspect, reserved, committed, orphanSuspect }; +} +function businessDigest(state: CoordinatorState): string { + return commandRequestHash({ fencingEpoch: state.fencingEpoch, maxActiveRuns: state.maxActiveRuns, reservations: state.reservations, commands: state.commands }); +} +function hashState(state: CoordinatorState): string { const { stateHash: _, ...body } = state; return commandRequestHash(body); } +function assertNoSymlink(file: string): void { + try { if (fs.lstatSync(file).isSymbolicLink()) fail("TF_DURABILITY_FAILED", `coordinator refuses symlink: ${file}`); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } +} +function prepareDirectory(directory: string): string { + const missing: string[] = []; + let ancestor = path.resolve(directory); + while (!fs.existsSync(ancestor)) { missing.unshift(path.basename(ancestor)); ancestor = path.dirname(ancestor); } + assertNoSymlink(ancestor); + let canonical = fs.realpathSync(ancestor); + for (const segment of missing) { + canonical = path.join(canonical, segment); + try { fs.mkdirSync(canonical, { mode: 0o700 }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } + assertNoSymlink(canonical); + if (!fs.lstatSync(canonical).isDirectory()) fail("TF_DURABILITY_FAILED", "coordinator path component is not a directory"); + } + const stat = fs.lstatSync(canonical); + if (!stat.isDirectory() || (process.getuid && stat.uid !== process.getuid())) fail("TF_DURABILITY_FAILED", "coordinator directory must be owned by the host user"); + if (process.platform !== "win32") fs.chmodSync(canonical, 0o700); + return canonical; +} +function validateState(raw: unknown): CoordinatorState { + if (!Value.Check(stateSchema, raw)) fail("TF_DURABILITY_FAILED", "coordinator state has an invalid closed shape"); + const state = raw as CoordinatorState; + if (hashState(state) !== state.stateHash || state.audit.length !== state.commitSeq) fail("TF_DURABILITY_FAILED", "coordinator state/audit integrity mismatch"); + let previous = ZERO_HASH; + let priorTime = 0; + for (const [index, entry] of state.audit.entries()) { + const { hash, ...body } = entry; + if (entry.commitSeq !== index + 1 || entry.previousHash !== previous || entry.recordedAt < priorTime || entry.epoch > state.fencingEpoch || commandRequestHash(body) !== hash) fail("TF_DURABILITY_FAILED", "coordinator audit chain is inconsistent"); + previous = hash; priorTime = entry.recordedAt; + } + if (priorTime > state.lastWallTime || (state.audit.length && state.audit.at(-1)!.stateDigest !== businessDigest(state))) fail("TF_DURABILITY_FAILED", "coordinator projection is not bound to its latest audit"); + const bindings = new Set(); + for (const [id, row] of Object.entries(state.reservations)) { + validId(id); + if (id !== row.reservation.reservationId || row.updatedAt > state.lastWallTime || row.reservation.coordinatorEpoch > state.fencingEpoch) fail("TF_DURABILITY_FAILED", "coordinator reservation identity/time mismatch"); + for (const number of [row.reservation.coordinatorEpoch, row.reservation.projectAdmitCommitSeq, row.reservation.reservedExpiresAt, row.reservation.renewedAt]) { + if (number !== undefined && (!Number.isSafeInteger(number) || number < 0)) fail("TF_DURABILITY_FAILED", "coordinator reservation contains an unsafe numeric authority"); + } + try { assertReservationInvariants(row.reservation); } catch { fail("TF_ADMISSION_BINDING_CONFLICT", "committed/orphan reservation must have admission evidence and no TTL"); } + if (row.reservation.state === "reserved" && (row.reservation.reservedExpiresAt === undefined || row.reservation.projectAdmitCommitSeq !== undefined)) fail("TF_ADMISSION_BINDING_CONFLICT", "pre-admit reservation requires TTL and no admitted sequence"); + if (["committed", "orphan-suspect"].includes(row.reservation.state)) { + const binding = bindingKey(row.reservation); + if (bindings.has(binding)) fail("TF_ADMISSION_BINDING_CONFLICT", "duplicate admitted project/domain/run binding"); + bindings.add(binding); + } + } + for (const [id, command] of Object.entries(state.commands)) { + validId(id); + if (id !== command.record.commandId || command.record.status !== "completed" || command.record.firstCommitSeq !== command.record.lastCommitSeq || command.record.lastCommitSeq > state.commitSeq) fail("TF_DURABILITY_FAILED", "coordinator command authority mismatch"); + } + if (capacity(state).active > state.maxActiveRuns) fail("TF_CAPACITY_EXCEEDED", "durable coordinator exceeds its capacity ceiling"); + return state; +} +function bindingKey(row: Pick): string { return `${row.projectId}/${row.projectControlDomainId}/${row.runId}`; } +function matchesProof(row: StoredReservation, proof: CoordinatorAdmissionProof): boolean { + return !!proof && proof.reservationId === row.reservation.reservationId && bindingKey(row.reservation) === bindingKey(proof) && proof.projectAdmitCommitSeq === row.reservation.projectAdmitCommitSeq + && Number.isSafeInteger(proof.runVersion) && proof.runVersion >= (row.admitRunVersion ?? 0); +} +function isAdmissionProof(proof: CoordinatorAdmissionObservation): proof is CoordinatorAdmissionProof { + return !!proof && !("status" in proof) && Number.isSafeInteger(proof.projectAdmitCommitSeq) && proof.projectAdmitCommitSeq > 0 && Number.isSafeInteger(proof.runVersion) && proof.runVersion >= 0; +} + +export class UserCoordinatorStore { + readonly storePath: string; + readonly #options: CoordinatorStoreOptions; + readonly #mutex: PersistentFileMutex; + readonly #directoryIdentity: { dev: number; ino: number }; + #closed = false; + #clockHighWater = 0; + constructor(directory: string, options: CoordinatorStoreOptions) { + positive(options.initialMaxActiveRuns, "initialMaxActiveRuns"); + if (!Number.isSafeInteger(options.epoch) || options.epoch < 1 || !options.holderId?.trim()) fail("TF_AUTHORITY_REVOKED", "coordinator requires a verified holder and positive epoch"); + if (!options.authority || ["readLease", "authorize", "readAdmission", "readRelease"].some((key) => typeof (options.authority as unknown as Record)[key] !== "function")) fail("TF_AUTHORITY_REVOKED", "coordinator requires trusted live authority callbacks"); + this.#options = { ...options }; + this.storePath = prepareDirectory(directory); + const { dev, ino } = fs.statSync(this.storePath); this.#directoryIdentity = { dev, ino }; + this.#mutex = new PersistentFileMutex(path.join(this.storePath, "writer.lock")); + } + close(): void { this.#closed = true; } + async initialize(): Promise { await this.#transaction("initialize", undefined, undefined, async () => undefined); } + async snapshot(context: Context): Promise { + return this.#transaction("snapshot", context, undefined, async (state) => ({ commitSeq: state.commitSeq, fencingEpoch: state.fencingEpoch, capacity: capacity(state), + concurrencyGuarantee: Object.values(state.reservations).some((r) => r.concurrencyGuarantee === "operator-overridden") ? "operator-overridden" : "enforced", + reservations: Object.values(state.reservations), commands: Object.values(state.commands) })); + } + async readReservation(id: string, context: Context): Promise { + validId(id); + return this.#transaction("readReservation", context, id, async (state, identity) => { + const row = state.reservations[id]; if (row) this.#owner(row, identity!); return row; + }); + } + async readCommand(id: string, context: Context): Promise { + validId(id); + return this.#transaction("readCommand", context, undefined, async (state, identity) => { + const command = state.commands[id]; + if (command && command.record.callerPrincipal !== identity!.principal) fail("TF_CROSS_PRINCIPAL_COMMAND", "coordinator result belongs to another verified principal"); + return command; + }); + } + async reserve(input: { reservationId?: string; projectId: string; projectControlDomainId: string; runId: string; ttlMs: number }, context: Context): Promise { + input = structuredClone(input); + const id = input.reservationId ?? randomUUID(); + for (const value of [id, input.projectId, input.projectControlDomainId, input.runId]) validId(value); + this.#ttl(input.ttlMs); + return this.#transaction("reserve", context, id, async (state, identity, now) => { + const existing = state.reservations[id]; + if (existing) { + this.#owner(existing, identity!); + if (bindingKey(existing.reservation) !== bindingKey(input) || existing.reservation.state !== "reserved") fail("TF_ADMISSION_BINDING_CONFLICT", "reservation ID cannot be rebound or reused after admission/expiry/release"); + return existing; + } + if (capacity(state).active >= state.maxActiveRuns) fail("TF_CAPACITY_EXCEEDED", "global admitted-run capacity exhausted"); + return state.reservations[id] = { reservation: { reservationId: id, projectId: input.projectId, projectControlDomainId: input.projectControlDomainId, runId: input.runId, + state: "reserved", slots: 1, coordinatorEpoch: this.#options.epoch, reservedExpiresAt: now + input.ttlMs }, + ownerId: identity!.ownerId, principal: identity!.principal, updatedAt: now, concurrencyGuarantee: "enforced" }; + }, { reservationId: id, projectId: input.projectId, projectControlDomainId: input.projectControlDomainId, runId: input.runId, state: "reserved", slots: 1, coordinatorEpoch: this.#options.epoch }); + } + async commit(id: string, input: { projectAdmitCommitSeq: number; attemptId?: string; providerJobHandle?: string }, context: Context): Promise { + input = structuredClone(input); + positive(input.projectAdmitCommitSeq, "projectAdmitCommitSeq"); if (input.attemptId) validId(input.attemptId); + return this.#transaction("commit", context, id, async (state, identity, now) => { + const row = this.#row(state, id); this.#owner(row, identity!); + if (row.reservation.state === "committed") { + if (row.reservation.projectAdmitCommitSeq !== input.projectAdmitCommitSeq || row.reservation.attemptId !== input.attemptId || row.reservation.providerJobHandle !== input.providerJobHandle) fail("TF_ADMISSION_BINDING_CONFLICT", "admission retry changed durable binding"); + return row; + } + if (row.reservation.state !== "reserved") fail("TF_ADMISSION_BINDING_CONFLICT", "only a live pre-admit reservation can commit"); + if (Object.values(state.reservations).some((other) => other !== row && ["committed", "orphan-suspect"].includes(other.reservation.state) && bindingKey(other.reservation) === bindingKey(row.reservation))) fail("TF_ADMISSION_BINDING_CONFLICT", "project/domain/run already has a committed reservation"); + const proposed = { ...row.reservation, ...input }; + const proof = await this.#options.authority.readAdmission(structuredClone(proposed)); + this.#now(now); + if (row.reservation.reservedExpiresAt! <= Date.now()) fail("TF_ADMISSION_BINDING_CONFLICT", "reservation expired before durable admission commit"); + if (!isAdmissionProof(proof) || !matchesProof({ ...row, reservation: proposed }, proof)) fail("TF_ADMISSION_BINDING_CONFLICT", "project ledger does not prove this admission"); + row.reservation = { ...proposed, state: "committed", coordinatorEpoch: this.#options.epoch }; + delete row.reservation.reservedExpiresAt; + row.admitRunVersion = proof.runVersion; row.updatedAt = now; return row; + }); + } + async renew(id: string, input: { ttlMs?: number }, context: Context): Promise { + input = structuredClone(input); + if (input.ttlMs !== undefined) this.#ttl(input.ttlMs); + return this.#transaction("renew", context, id, async (state, identity, now) => { + const row = this.#row(state, id); this.#owner(row, identity!); + if (!active(row)) fail("TF_ADMISSION_BINDING_CONFLICT", "inactive reservation cannot renew"); + if (row.reservation.state === "reserved") { + if (input.ttlMs === undefined) fail("TF_COMMAND_FAILED", "reserved renewal requires a TTL"); + row.reservation.reservedExpiresAt = now + input.ttlMs!; + } else if (input.ttlMs !== undefined) fail("TF_ADMISSION_BINDING_CONFLICT", "committed/orphan reservations must never receive a TTL"); + row.reservation.renewedAt = now; row.reservation.coordinatorEpoch = this.#options.epoch; row.updatedAt = now; return row; + }); + } + async markOrphanSuspect(id: string, context: Context): Promise { + return this.#transaction("markOrphanSuspect", context, id, async (state, identity, now) => { + const row = this.#row(state, id); this.#owner(row, identity!); + if (!["committed", "orphan-suspect"].includes(row.reservation.state)) fail("TF_ADMISSION_BINDING_CONFLICT", "only admitted reservations can become orphan-suspect"); + if (row.reservation.state === "committed") { row.reservation.state = "orphan-suspect"; row.updatedAt = now; row.reservation.coordinatorEpoch = this.#options.epoch; } + return row; + }); + } + async normalRelease(id: string, context: Context): Promise { + return this.#transaction("normalRelease", context, id, async (state, identity, now) => { + const row = this.#row(state, id); this.#owner(row, identity!); + if (row.reservation.state === "released" && row.releaseKind === "normal") return row; + if (!["committed", "orphan-suspect"].includes(row.reservation.state)) fail("TF_ADMISSION_BINDING_CONFLICT", "normalRelease requires an admitted reservation"); + const proof = await this.#options.authority.readRelease(structuredClone(row.reservation)); + this.#now(now); + const terminal = ["completed", "failed", "blocked", "cancelled"].includes(proof?.status); + const parked = proof?.status === "paused" && proof.stage === "parked" && proof.requiresReadmission === true; + if (!matchesProof(row, proof) || !proof.proofId?.trim() || proof.providerNoLiveProcessTree !== true || proof.noAmbiguousJobs !== true || proof.reconcileTimeoutOnly !== false || (!terminal && !parked)) fail("TF_RECONCILE_REQUIRED", "D37 requires current ledger-bound terminal/parked and provider quiescence evidence"); + row.reservation.state = "released"; row.reservation.coordinatorEpoch = this.#options.epoch; + delete row.reservation.reservedExpiresAt; row.releaseKind = "normal"; row.releaseProofId = proof.proofId; row.updatedAt = now; return row; + }); + } + async forceRelease(id: string, input: { commandId: string; riskAcknowledgement: true; reason: string }, context: Context): Promise { + input = structuredClone(input); + validId(input.commandId); + if (input.riskAcknowledgement !== true || !input.reason?.trim()) fail("TF_POLICY_DENIED", "forceRelease requires explicit risk acknowledgement and reason"); + return this.#transaction("forceRelease", context, id, async (state, identity, now) => { + this.#operator(identity!); + return this.#command(state, identity!, input.commandId, { kind: "forceRelease", reservationId: id, riskAcknowledgement: true, reason: input.reason }, () => { + const row = this.#row(state, id); + if (row.reservation.state === "expired") fail("TF_ADMISSION_BINDING_CONFLICT", "expired reservation cannot be force released"); + row.reservation.state = "released"; row.reservation.coordinatorEpoch = this.#options.epoch; + delete row.reservation.reservedExpiresAt; row.releaseKind = "force"; row.concurrencyGuarantee = "operator-overridden"; row.updatedAt = now; return row; + }) as StoredReservation; + }); + } + async setMaxActiveRuns(input: { commandId: string; maxActiveRuns: number }, context: Context): Promise { + input = structuredClone(input); + validId(input.commandId); positive(input.maxActiveRuns, "maxActiveRuns"); + return this.#transaction("setMaxActiveRuns", context, undefined, async (state, identity) => { + this.#operator(identity!); + return this.#command(state, identity!, input.commandId, { kind: "setMaxActiveRuns", maxActiveRuns: input.maxActiveRuns }, () => { + if (input.maxActiveRuns < capacity(state).active) fail("TF_CAPACITY_EXCEEDED", "cannot lower capacity below reserved/committed/orphan usage"); + return state.maxActiveRuns = input.maxActiveRuns; + }) as number; + }); + } + #command(state: CoordinatorState, identity: CoordinatorIdentity, id: string, body: { kind: "forceRelease" | "setMaxActiveRuns"; [key: string]: unknown }, execute: () => StoredReservation | number): StoredReservation | number { + const hash = commandRequestHash(body); const prior = state.commands[id]; + if (prior) { + if (prior.record.callerPrincipal !== identity.principal) fail("TF_CROSS_PRINCIPAL_COMMAND", "coordinator result belongs to another verified principal"); + if (prior.record.requestHash !== hash) fail("TF_IDEMPOTENCY_CONFLICT", "coordinator command ID was reused with a different request"); + return prior.result; + } + const result = execute(); + state.commands[id] = { record: { commandId: id, kind: body.kind, requestHash: hash, callerPrincipal: identity.principal, firstCommitSeq: state.commitSeq + 1, lastCommitSeq: state.commitSeq + 1, status: "completed" }, result: structuredClone(result) }; + return result; + } + #ttl(ttlMs: number): void { positive(ttlMs, "ttlMs"); if (ttlMs > 86_400_000) fail("TF_COMMAND_FAILED", "reservation TTL must not exceed one day"); } + #row(state: CoordinatorState, id: string): StoredReservation { validId(id); const row = state.reservations[id]; if (!row) fail("TF_ADMISSION_BINDING_CONFLICT", "reservation not found"); return row; } + #owner(row: StoredReservation, identity: CoordinatorIdentity): void { if (row.ownerId !== identity.ownerId || row.principal !== identity.principal) fail("TF_AUTHORITY_REVOKED", "reservation owner/project authority does not match"); } + #operator(identity: CoordinatorIdentity): void { if (identity.operator !== true) fail("TF_POLICY_DENIED", "an authorized coordinator operator is required"); } + #binding(): void { + assertNoSymlink(this.storePath); const st = fs.statSync(this.storePath); + if (st.dev !== this.#directoryIdentity.dev || st.ino !== this.#directoryIdentity.ino) fail("TF_DURABILITY_FAILED", "coordinator directory identity changed"); + assertNoSymlink(path.join(this.storePath, "writer.lock.queue")); assertNoSymlink(path.join(this.storePath, "coordinator.json")); assertNoSymlink(path.join(this.storePath, "coordinator.identity.json")); + } + #now(floor = 0): number { + const now = Date.now(); + if (!Number.isSafeInteger(now) || now < Math.max(floor, this.#clockHighWater)) fail("TF_AUTHORITY_REVOKED", "wall clock moved backwards; no TTL reclaim or mutation is safe"); + this.#clockHighWater = now; return now; + } + async #fence(now: number): Promise { + const lease = await this.#options.authority.readLease(); + const observedNow = this.#now(now); + if (!Value.Check(CoordinatorLeaseSchema, lease) || !Number.isSafeInteger(lease!.expiresAt) || !Number.isSafeInteger(lease!.fencingEpoch) || lease!.fencingEpoch !== this.#options.epoch || lease!.holderId !== this.#options.holderId || lease!.expiresAt <= observedNow) fail("TF_AUTHORITY_REVOKED", "coordinator holder/epoch/lease is no longer authoritative"); + return lease!.expiresAt; + } + async #expireReservations(state: CoordinatorState, now: number): Promise { + for (const row of Object.values(state.reservations)) { + if (row.reservation.state !== "reserved" || row.reservation.reservedExpiresAt! > now) continue; + let proof: CoordinatorAdmissionObservation; + try { proof = await this.#options.authority.readAdmission(structuredClone(row.reservation)); } + catch { this.#now(now); continue; } // An unavailable project ledger retains its slot. + const observedNow = this.#now(now); await this.#fence(observedNow); + if (!proof || proof.reservationId !== row.reservation.reservationId || bindingKey(proof) !== bindingKey(row.reservation)) continue; + if (isAdmissionProof(proof)) { + // Run Admitted may have reached its project ledger before the host + // crashed between that fsync and coordinator commit. Never TTL-free it. + row.reservation.state = "committed"; row.reservation.projectAdmitCommitSeq = proof.projectAdmitCommitSeq; + row.reservation.coordinatorEpoch = this.#options.epoch; row.admitRunVersion = proof.runVersion; + delete row.reservation.reservedExpiresAt; row.updatedAt = observedNow; + } else if ("status" in proof && proof.status === "not-admitted" && Number.isSafeInteger(proof.runVersion) && proof.runVersion >= 0) { + row.reservation.state = "expired"; delete row.reservation.reservedExpiresAt; row.updatedAt = observedNow; + } + } + } + async #transaction(action: string, context: Context | undefined, id: string | undefined, apply: (state: CoordinatorState, identity: CoordinatorIdentity | undefined, now: number) => Promise, authorizationReservation?: ConcurrencyReservation): Promise { + if (this.#closed) fail("TF_DURABILITY_FAILED", "coordinator store is closed"); + this.#binding(); + const release = await this.#mutex.acquire({ timeoutMs: 30_000, signal: AbortSignal.timeout(30_000) }); + try { + if (this.#closed) fail("TF_DURABILITY_FAILED", "coordinator store closed during lock admission"); + this.#binding(); const file = path.join(this.storePath, "coordinator.json"), identityFile = path.join(this.storePath, "coordinator.identity.json"); + let storeIdentity: { version: 1; canonicalPath: string; dev: number; ino: number; instanceId: string }; + if (fs.existsSync(identityFile)) { + storeIdentity = JSON.parse(fs.readFileSync(identityFile, "utf8")); + if (!Value.Check(Type.Object({ version: Type.Literal(1), canonicalPath: text, dev: integer, ino: integer, instanceId: UuidSchema }, { additionalProperties: false }), storeIdentity) || storeIdentity.canonicalPath !== this.storePath || storeIdentity.dev !== this.#directoryIdentity.dev || storeIdentity.ino !== this.#directoryIdentity.ino) fail("TF_DURABILITY_FAILED", "coordinator genesis directory identity is invalid or changed"); + if (!fs.existsSync(file)) fail("TF_DURABILITY_FAILED", "coordinator authoritative ledger is missing; bootstrap would erase admitted capacity"); + } else { + if (fs.existsSync(file) || action !== "initialize") fail("TF_DURABILITY_FAILED", "coordinator genesis authority is missing"); + storeIdentity = { version: 1, canonicalPath: this.storePath, ...this.#directoryIdentity, instanceId: randomUUID() }; + await this.#fence(this.#now()); this.#binding(); writeJsonAtomicHardened(identityFile, storeIdentity); + } + let state: CoordinatorState; + if (fs.existsSync(file)) { state = validateState(JSON.parse(fs.readFileSync(file, "utf8"))); } + else { state = { version: 1, fencingEpoch: this.#options.epoch, commitSeq: 0, lastWallTime: 0, maxActiveRuns: this.#options.initialMaxActiveRuns, + directoryIdentity: { canonicalPath: this.storePath, ...this.#directoryIdentity }, instanceId: storeIdentity.instanceId, reservations: {}, commands: {}, audit: [], stateHash: "" }; } + if (state.instanceId !== storeIdentity.instanceId || state.directoryIdentity.canonicalPath !== this.storePath || state.directoryIdentity.dev !== this.#directoryIdentity.dev || state.directoryIdentity.ino !== this.#directoryIdentity.ino) fail("TF_DURABILITY_FAILED", "coordinator directory identity changed; explicit authority rebind is required"); + const before = commandRequestHash(state); + const now = this.#now(state.lastWallTime); await this.#fence(now); + if (state.fencingEpoch > this.#options.epoch) fail("TF_AUTHORITY_REVOKED", "persisted coordinator epoch fences this host"); + state.fencingEpoch = this.#options.epoch; + let identity: CoordinatorIdentity | undefined; + if (action !== "initialize") { + identity = structuredClone(await this.#options.authority.authorize(context as Context, action as CoordinatorOperation, structuredClone(id && state.reservations[id] ? state.reservations[id]!.reservation : authorizationReservation))); + this.#now(now); + if (!identity?.principal?.trim() || !identity.ownerId?.trim() || typeof identity.operator !== "boolean") fail("TF_AUTHORITY_REVOKED", "trusted authority did not verify a principal and owner"); + } + await this.#expireReservations(state, this.#now(now)); + const value = await apply(state, identity, now); + this.#now(now); + if (action !== "initialize") { + const currentIdentity = await this.#options.authority.authorize(context as Context, action as CoordinatorOperation, structuredClone(id && state.reservations[id] ? state.reservations[id]!.reservation : authorizationReservation)); + this.#now(now); + if (!currentIdentity || currentIdentity.principal !== identity!.principal || currentIdentity.ownerId !== identity!.ownerId || currentIdentity.operator !== identity!.operator) fail("TF_AUTHORITY_REVOKED", "live authority changed during coordinator transaction"); + } + if (this.#closed) fail("TF_DURABILITY_FAILED", "coordinator store closed during transaction"); + this.#binding(); const leaseExpiresAt = await this.#fence(this.#now(now)); + const currentTime = this.#now(now); + if (commandRequestHash(state) !== before || !fs.existsSync(file)) { + state.lastWallTime = currentTime; + state.commitSeq++; + const body = { commitSeq: state.commitSeq, epoch: state.fencingEpoch, recordedAt: currentTime, action, principal: identity?.principal ?? "coordinator-bootstrap", previousHash: state.audit.at(-1)?.hash ?? ZERO_HASH, stateDigest: businessDigest(state) }; + state.audit.push({ ...body, hash: commandRequestHash(body) }); state.stateHash = hashState(state); validateState(state); + if (this.#now(currentTime) >= leaseExpiresAt) fail("TF_AUTHORITY_REVOKED", "coordinator lease expired before atomic publication"); + if (process.env[COORDINATOR_CRASH_ENV] === "before-state-write") process.kill(process.pid, "SIGKILL"); + writeJsonAtomicHardened(file, state); + if (process.env[COORDINATOR_CRASH_ENV] === "after-state-write") process.kill(process.pid, "SIGKILL"); + } + if (action === "snapshot") Object.assign(value as object, { commitSeq: state.commitSeq, fencingEpoch: state.fencingEpoch }); + return structuredClone(value); + } catch (error) { + if (error instanceof ControlError) throw error; + throw new ControlError("TF_DURABILITY_FAILED", `coordinator transaction failed: ${error instanceof Error ? error.message : String(error)}`, { recoveryAction: "operator", sideEffects: "unknown" }); + } finally { release(); } + } +} +export async function openCoordinatorStore(directory: string, options: CoordinatorStoreOptions): Promise> { + const store = new UserCoordinatorStore(directory, options); + try { await store.initialize(); return store; } catch (error) { store.close(); throw error; } +} diff --git a/packages/taskflow-control/src/store/journal.ts b/packages/taskflow-control/src/store/journal.ts new file mode 100644 index 00000000..5b2a2653 --- /dev/null +++ b/packages/taskflow-control/src/store/journal.ts @@ -0,0 +1,51 @@ +/** Versioned project journal. V1 bytes remain immutable; V2 chains every batch. */ +import { createHash } from "node:crypto"; +import { canonicalJson } from "taskflow-core/flowir/hash"; +import { Type } from "typebox"; +import { Value } from "typebox/value"; +import { ApprovalRequestSchema, CommandRecordSchema, ControlEventSchema, RunSnapshotSchema, ReceiptSchema, UuidSchema, Sha256HexSchema, ConcurrencyReservationSchema, BoundFragmentSchema, ExecutionOwnerSchema } from "../schema/index.ts"; +import type { CommandRecord, ControlEvent, Receipt } from "../schema/index.ts"; +import type { ApprovalJournalState } from "../approval-service.ts"; + +const options = { additionalProperties: false }; +const QuiescenceSchema = Type.Object({ + proofId: Type.String({ minLength: 1 }), projectId: UuidSchema, projectControlDomainId: UuidSchema, runId: UuidSchema, + projectAdmitCommitSeq: Type.Integer({ minimum: 1 }), providerNoLiveProcessTree: Type.Boolean(), + noAmbiguousJobs: Type.Boolean(), noPendingResourceIntents: Type.Boolean(), reconcileTimeoutOnly: Type.Boolean(), +}, options); +export const ApprovalOutboxSchema = Type.Union([ + Type.Object({ kind: Type.Literal("approval.release"), intentId: UuidSchema, approvalRequestId: UuidSchema, runId: UuidSchema, + reservationId: UuidSchema, evidence: QuiescenceSchema, complete: Type.Boolean() }, options), + Type.Object({ kind: Type.Literal("approval.readmission"), intentId: UuidSchema, approvalRequestId: UuidSchema, runId: UuidSchema, + reservation: ConcurrencyReservationSchema, owner: ExecutionOwnerSchema, fragment: BoundFragmentSchema, + policyHash: Sha256HexSchema, authorizationContextHash: Sha256HexSchema, projectAdmitCommitSeq: Type.Integer({ minimum: 1 }), complete: Type.Boolean() }, options), +]); +export const ProjectStateSchema = Type.Object({ + run: Type.Object({ ...RunSnapshotSchema.properties, boundPlanHash: Type.String({ minLength: 1 }), policyHash: Sha256HexSchema, authorityEpoch: Type.Integer({ minimum: 0 }) }, options), + approvals: Type.Array(ApprovalRequestSchema), outbox: Type.Array(ApprovalOutboxSchema), +}, options); +const LegacyBatchSchema = Type.Object({ + recordKind: Type.Literal("commit-batch"), commitSeq: Type.Integer({ minimum: 1 }), command: CommandRecordSchema, + events: Type.Array(ControlEventSchema, { minItems: 1 }), +}, options); +const BatchSchema = Type.Object({ + recordKind: Type.Literal("lifecycle-batch"), schemaVersion: Type.Literal(2), commitSeq: Type.Integer({ minimum: 1 }), + previousHash: Sha256HexSchema, contentHash: Sha256HexSchema, command: Type.Optional(CommandRecordSchema), + events: Type.Array(ControlEventSchema, { minItems: 1 }), projection: Type.Optional(ProjectStateSchema), receipt: Type.Optional(ReceiptSchema), +}, options); +export type LegacyJournalBatch = { recordKind: "commit-batch"; commitSeq: number; command: CommandRecord; events: ControlEvent[] }; +export type LifecycleBatch = { + recordKind: "lifecycle-batch"; schemaVersion: 2; commitSeq: number; previousHash: string; contentHash: string; + command?: CommandRecord; events: ControlEvent[]; projection?: ApprovalJournalState; receipt?: Receipt; +}; +export type JournalBatch = LegacyJournalBatch | LifecycleBatch; +export const JOURNAL_GENESIS = "0".repeat(64); +export function journalHash(record: Omit | LegacyJournalBatch): string { + return createHash("sha256").update(canonicalJson(record)).digest("hex"); +} +export function validJournalBatch(value: unknown): value is JournalBatch { + if (Value.Check(LegacyBatchSchema, value)) return value.events.every((event) => event.schemaVersion === 1); + if (!Value.Check(BatchSchema, value) || value.events.some((event) => event.schemaVersion !== 2)) return false; + const { contentHash, ...body } = value; + return contentHash === journalHash(body as Omit); +} diff --git a/packages/taskflow-control/test/approval-service.test.ts b/packages/taskflow-control/test/approval-service.test.ts new file mode 100644 index 00000000..acfb7119 --- /dev/null +++ b/packages/taskflow-control/test/approval-service.test.ts @@ -0,0 +1,217 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawn } from "node:child_process"; +import { test } from "node:test"; +import { ControlError } from "../src/errors.ts"; +import type { VerifiedContext } from "../src/authorization.ts"; +import type { ApprovalDecisionInput } from "../src/approval-service.ts"; +import { approvalFixture } from "./fixtures/approval-worker.ts"; + +type Fixture = Awaited>; +async function fixtureCase(fn: (f: Fixture, directory: string) => Promise) { + const directory = mkdtempSync(join(tmpdir(), "tf-approval-")); + let fixture: Fixture | undefined; + try { fixture = await approvalFixture(directory); await fn(fixture, directory); } + finally { fixture?.close(); rmSync(directory, { recursive: true, force: true }); } +} +const errorCode = (code: string) => (error: unknown) => error instanceof ControlError && error.code === code; +function decision(f: Fixture, requestId: string, kind: ApprovalDecisionInput["decision"] = "approve"): ApprovalDecisionInput { + return { commandId: randomUUID(), runId: f.runId, approvalRequestId: requestId, expectedRunVersion: f.store.readRun(f.runId)!.runVersion, decision: kind }; +} + +test("durable request journals park before actual P16 release; pending CAS version follows release ack", async () => fixtureCase(async (f) => { + const request = await f.service.request(f.context, f.request()); + const run = f.store.readRun(f.runId)!; + assert.equal(request.status, "pending"); + assert.equal(run.status, "paused"); assert.equal(run.stage, "parked"); assert.equal(run.slot, "released"); + assert.equal(request.expectedRunVersion, run.runVersion); + assert.equal((await f.coordinator.snapshot(f.context)).capacity.active, 0); + assert.equal(f.store.readOutbox(f.runId).find((item) => item.kind === "approval.release")?.complete, true); +})); + +test("three modes preserve headless rejection, negotiated optional durability and required feature failure", async () => { + for (const mode of ["compat-auto-reject", "durable-optional", "durable-required"] as const) { + await fixtureCase(async (f) => { + f.set({ durable: false }); const seq = f.store.commitSeq; + if (mode === "durable-required") { + await assert.rejects(f.service.request(f.context, f.request({ mode })), errorCode("TF_FEATURE_REQUIRED")); + assert.equal(f.store.commitSeq, seq); + } else { + const request = await f.service.request(f.context, f.request({ mode })); + assert.equal(request.status, "rejected"); assert.equal(f.store.readRun(f.runId)!.status, "blocked"); + assert.equal((await f.coordinator.snapshot(f.context)).capacity.active, 0); + } + }); + } + await fixtureCase(async (f) => assert.equal((await f.service.request(f.context, f.request({ mode: "durable-optional" }))).status, "pending")); +}); + +test("forged service context and unreached phase fail without journal mutation", async () => fixtureCase(async (f) => { + const seq = f.store.commitSeq; + await assert.rejects(f.service.request({ callerPrincipal: `os-user:${process.getuid?.() ?? 1000}` } as unknown as VerifiedContext, f.request()), errorCode("TF_AUTHORITY_REVOKED")); + await assert.rejects(f.service.request(f.context, f.request({ nodeInstanceId: "invented" })), errorCode("TF_COMMAND_FAILED")); + assert.equal(f.store.commitSeq, seq); +})); + +test("two authenticated clients race one runVersion: exactly one decision command commits", async () => fixtureCase(async (f) => { + const request = await f.service.request(f.context, f.request()); + const first = decision(f, request.approvalRequestId), second = { ...first, commandId: randomUUID(), decision: "reject" as const }; + const results = await Promise.allSettled([f.service.decide(f.context, first), f.service.decide(f.secondContext, second)]); + assert.equal(results.filter((item) => item.status === "fulfilled").length, 1); + assert.equal(results.filter((item) => item.status === "rejected" && errorCode("TF_STALE_VERSION")(item.reason)).length, 1); + assert.equal([f.store.readCommand(first.commandId), f.store.readCommand(second.commandId)].filter(Boolean).length, 1); + assert.notEqual(f.store.readApproval(request.approvalRequestId)!.status, "pending"); +})); + +test("deadline expires during trusted edit validation: no late approve/edit command survives", async () => fixtureCase(async (f) => { + const request = await f.service.request(f.context, f.request({ deadline: Date.now() + 1000 })); + f.set({ editDelay: 1100 }); + const edit = { ...decision(f, request.approvalRequestId, "edit"), editKind: "output" as const, editArtifactRef: f.artifact() }; + await assert.rejects(f.service.decide(f.context, edit), errorCode("TF_STALE_VERSION")); + assert.equal(f.store.readApproval(request.approvalRequestId)!.status, "expired"); + assert.equal(f.store.readRun(f.runId)!.status, "blocked"); assert.equal(f.store.readCommand(edit.commandId), undefined); + await assert.rejects(f.service.decide(f.context, decision(f, request.approvalRequestId)), errorCode("TF_STALE_VERSION")); +})); + +test("cancel wins before decide, and approve then cancel cannot be resurrected by park/drain", async () => { + for (const approveFirst of [false, true]) await fixtureCase(async (f) => { + const request = await f.service.request(f.context, f.request()), old = decision(f, request.approvalRequestId); + if (approveFirst) await f.service.decide(f.context, old); + await f.service.cancel(f.context, { commandId: randomUUID(), runId: f.runId, expectedRunVersion: f.store.readRun(f.runId)!.runVersion }); + const terminal = f.store.readRun(f.runId)!; + assert.equal(terminal.status, "cancelled"); + await assert.rejects(f.service.park(f.context, { runId: f.runId, expectedRunVersion: terminal.runVersion }), errorCode("TF_STALE_VERSION")); + await f.service.drainReleases(f.context); + assert.equal(f.store.readRun(f.runId)!.status, "cancelled"); assert.equal(f.store.readRun(f.runId)!.stage, "terminal"); + if (!approveFirst) { + assert.equal(f.store.readApproval(request.approvalRequestId)!.status, "cancelled"); + await assert.rejects(f.service.decide(f.context, old), errorCode("TF_STALE_VERSION")); + } + }); +}); + +test("non-quiescent rejection holds capacity until fresh terminal settlement; history never reopens", async () => fixtureCase(async (f) => { + f.set({ quiescent: false }); + const request = await f.service.request(f.context, f.request()); + assert.equal(f.store.readRun(f.runId)!.stage, "reconciling"); + await f.service.decide(f.context, decision(f, request.approvalRequestId, "reject")); + assert.equal(f.store.readRun(f.runId)!.status, "blocked"); assert.equal((await f.coordinator.snapshot(f.context)).capacity.active, 1); + await assert.rejects(f.service.releaseTerminal(f.context, { runId: f.runId, expectedRunVersion: f.store.readRun(f.runId)!.runVersion }), errorCode("TF_RECONCILE_REQUIRED")); + f.set({ quiescent: true }); + await f.service.releaseTerminal(f.context, { runId: f.runId, expectedRunVersion: f.store.readRun(f.runId)!.runVersion }); + assert.equal(f.store.readRun(f.runId)!.status, "blocked"); assert.equal(f.store.readRun(f.runId)!.slot, "released"); + assert.equal((await f.coordinator.snapshot(f.context)).capacity.active, 0); +})); + +test("edited guidance is contract checked and persisted; only plan edits require re-Link", async () => { + for (const editKind of ["output", "plan"] as const) await fixtureCase(async (f) => { + const request = await f.service.request(f.context, f.request()), artifact = f.artifact(); + const edit = { ...decision(f, request.approvalRequestId, "edit"), editKind, editArtifactRef: artifact }; + f.set({ invalidEdit: true }); + await assert.rejects(f.service.decide(f.context, edit), errorCode("TF_COMMAND_FAILED")); + assert.equal(f.store.readApproval(request.approvalRequestId)!.status, "pending"); + f.set({ invalidEdit: false }); await f.service.decide(f.context, edit); + assert.deepEqual(f.store.readApproval(request.approvalRequestId)!.editArtifactRef, artifact); + assert.equal(f.store.readApproval(request.approvalRequestId)!.status, "edited"); + await f.service.readmit(f.context, { approvalRequestId: request.approvalRequestId, expectedRunVersion: f.store.readRun(f.runId)!.runVersion }); + assert.equal(f.relinks(), editKind === "plan" ? 1 : 0); + }); +}); + +test("restart preserves pending approval and readmits under fresh policy/reservation/owner/fragment", async () => fixtureCase(async (original, directory) => { + const old = original.store.readRun(original.runId)!; + const request = await original.service.request(original.context, original.request()); + original.close(); + const fresh = await approvalFixture(directory); + try { + assert.equal((await fresh.service.read(fresh.context, request.approvalRequestId)).status, "pending"); + await fresh.service.decide(fresh.context, decision(fresh, request.approvalRequestId)); + fresh.set({ policyHash: "b".repeat(64) }); + const journal = join(directory, "project-store", "journal", "000001.jsonl"), prefix = readFileSync(journal); + const run = await fresh.service.readmit(fresh.context, { approvalRequestId: request.approvalRequestId, expectedRunVersion: fresh.store.readRun(fresh.runId)!.runVersion }); + assert.equal(run.stage, "admitted"); assert.equal(run.slot, "committed"); assert.equal(run.policyHash, "b".repeat(64)); + assert.notEqual(run.reservationId, old.reservationId); assert.notEqual(run.owner!.attemptId, old.owner!.attemptId); + assert.ok(run.boundFragmentHash); assert.equal(run.requiresReadmission, false); + assert.equal((await fresh.coordinator.snapshot(fresh.context)).capacity.active, 1); + assert.deepEqual(readFileSync(journal).subarray(0, prefix.length), prefix, "old journal bytes are never rewritten"); + } finally { fresh.close(); } +})); + +test("durable release and admission outboxes resume after both cross-ledger interruption windows", async () => { + for (const window of ["release", "admission"] as const) await fixtureCase(async (original, directory) => { + let requestId: string; + if (window === "release") { + original.set({ releaseFailure: true }); + await assert.rejects(original.service.request(original.context, original.request()), /interruption after coordinator release/); + requestId = original.store.listApprovals()[0].approvalRequestId; + } else { + requestId = (await original.service.request(original.context, original.request())).approvalRequestId; + await original.service.decide(original.context, decision(original, requestId)); original.set({ commitFailure: true }); + await assert.rejects(original.service.readmit(original.context, { approvalRequestId: requestId, expectedRunVersion: original.store.readRun(original.runId)!.runVersion }), /interruption before coordinator commit/); + } + original.close(); const fresh = await approvalFixture(directory); + try { + if (window === "release") { await fresh.service.drainReleases(fresh.context); assert.equal(fresh.store.readRun(fresh.runId)!.slot, "released"); } + else { + await fresh.service.readmit(fresh.context, { approvalRequestId: requestId, expectedRunVersion: fresh.store.readRun(fresh.runId)!.runVersion }); + assert.equal(fresh.store.readRun(fresh.runId)!.slot, "committed"); + } + assert.ok(fresh.store.readOutbox(fresh.runId).every((item) => item.complete)); + } finally { fresh.close(); } + }); +}); + +test("revocation and stale owner block admission; fake principal cannot disclose pending history", async () => fixtureCase(async (f) => { + const request = await f.service.request(f.context, f.request()); + await assert.rejects(f.service.read({ callerPrincipal: request.owner } as unknown as VerifiedContext, request.approvalRequestId), errorCode("TF_AUTHORITY_REVOKED")); + await f.service.decide(f.context, decision(f, request.approvalRequestId)); + f.set({ reusedOwner: true }); + await assert.rejects(f.service.readmit(f.context, { approvalRequestId: request.approvalRequestId, expectedRunVersion: f.store.readRun(f.runId)!.runVersion }), errorCode("TF_AUTHORITY_REVOKED")); + f.set({ reusedOwner: false, revoked: true }); const seq = f.store.commitSeq; + await assert.rejects(f.service.readmit(f.context, { approvalRequestId: request.approvalRequestId, expectedRunVersion: f.store.readRun(f.runId)!.runVersion }), errorCode("TF_AUTHORITY_REVOKED")); + assert.equal(f.store.commitSeq, seq); assert.equal(f.store.readRun(f.runId)!.stage, "queued"); +})); + +test("real worker process persists pending decision across SIGKILL and fresh-host admission", { skip: process.platform === "win32" }, async () => { + const directory = mkdtempSync(join(tmpdir(), "tf-approval-process-")); + const children: ReturnType[] = [], exited = new Set>(); + function start() { + const child = spawn(process.execPath, ["--conditions=development", "--experimental-strip-types", join(import.meta.dirname, "fixtures", "approval-worker.ts")], + { env: { ...process.env, TF_APPROVAL_WORKER_DIR: directory }, stdio: ["pipe", "pipe", "pipe"] }); + children.push(child); let buffer = "", stderr = ""; + const messages: any[] = [], listeners: (() => void)[] = []; + child.stdout!.on("data", (data) => { buffer += data.toString(); let index: number; while ((index = buffer.indexOf("\n")) >= 0) { messages.push(JSON.parse(buffer.slice(0, index))); buffer = buffer.slice(index + 1); } listeners.splice(0).forEach((fn) => fn()); }); + child.stderr!.on("data", (data) => { stderr += data.toString(); }); + const closed = new Promise((resolve) => child.once("close", () => { exited.add(child); listeners.splice(0).forEach((fn) => fn()); resolve(); })); + async function receive(predicate: (value: any) => boolean) { + const deadline = Date.now() + 15_000; + while (!messages.some(predicate)) { + if (exited.has(child)) throw new Error(`worker exited: ${stderr}`); + if (Date.now() >= deadline) throw new Error(`worker deadline: ${stderr}`); + await new Promise((resolve) => { const timer = setTimeout(() => { const at = listeners.indexOf(wake); if (at >= 0) listeners.splice(at, 1); resolve(); }, 1000); const wake = () => { clearTimeout(timer); resolve(); }; listeners.push(wake); }); + } + const at = messages.findIndex(predicate); return messages.splice(at, 1)[0]; + } + return { child, closed, ready: () => receive((item) => item.ready === true), rpc: async (body: unknown) => { + const id = randomUUID(); child.stdin!.write(JSON.stringify({ ...(body as object), id }) + "\n"); + const result = await receive((item) => item.id === id); assert.equal(result.ok, true, JSON.stringify(result)); return result.result; + } }; + } + try { + const seed = start(); await seed.ready(); const request = await seed.rpc({ action: "request" }); + seed.child.kill("SIGKILL"); await seed.closed; + const fresh = start(), ready = await fresh.ready(); assert.equal(ready.runId, request.runId); + assert.equal((await fresh.rpc({ action: "read", approvalRequestId: request.approvalRequestId })).status, "pending"); + await fresh.rpc({ action: "decide", client: 2, body: { commandId: randomUUID(), runId: request.runId, approvalRequestId: request.approvalRequestId, expectedRunVersion: request.expectedRunVersion, decision: "approve" } }); + const run = await fresh.rpc({ action: "run" }); + const admitted = await fresh.rpc({ action: "readmit", body: { approvalRequestId: request.approvalRequestId, expectedRunVersion: run.runVersion } }); + assert.equal(admitted.stage, "admitted"); assert.equal(admitted.slot, "committed"); assert.ok(admitted.boundFragmentHash); + fresh.child.stdin!.end(); await fresh.closed; + } finally { + await Promise.all(children.filter((child) => !exited.has(child)).map((child) => new Promise((resolve) => { child.once("close", () => resolve()); child.kill("SIGKILL"); }))); + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/taskflow-control/test/authorization.test.ts b/packages/taskflow-control/test/authorization.test.ts new file mode 100644 index 00000000..74e88ab0 --- /dev/null +++ b/packages/taskflow-control/test/authorization.test.ts @@ -0,0 +1,287 @@ +import assert from "node:assert/strict"; +import { createHash, randomBytes } from "node:crypto"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, test } from "node:test"; +import { canonicalJson } from "taskflow-core/flowir/hash"; +import { ControlError } from "../src/errors.ts"; +import { + AUTHORIZATION_CAPABILITIES, answerAuthorizationChallenge, authorizationAuditHash, + createAuthorizationAuthority, type AuthorizationAuthorityOptions, type AuthorizationBinding, type AuthorizationChallenge, + type AuthorizationPolicyLayer, type AuthorizationRequest, type VerifiedContext, +} from "../src/authorization.ts"; + +const root = realpathSync(mkdtempSync(join(tmpdir(), "tf-authz-"))); +const inner = join(root, "allowed"), deeper = join(inner, "narrow"), other = join(root, "other"); +mkdirSync(deeper, { recursive: true }); mkdirSync(other); +const outside = realpathSync(mkdtempSync(join(tmpdir(), "tf-authz-outside-"))); +symlinkSync(outside, join(root, "escape")); +after(() => { rmSync(root, { recursive: true, force: true }); rmSync(outside, { recursive: true, force: true }); }); +const binding: AuthorizationBinding = { projectId: "project-a", controlDomainId: "domain-a", projectRoot: root }; +const secret = randomBytes(32); +function fixture(overrides: Partial = {}) { + let policy: unknown = { host: {} }; + let reads = 0; + const authority = createAuthorizationAuthority({ ownerUid: process.getuid!(), bootstrapSecret: secret, + hostBaseline: AUTHORIZATION_CAPABILITIES, + loadLivePolicy: () => { reads++; return policy; }, ...overrides }); + return { authority, context: authority.issueStandalone(binding), set: (value: unknown) => { policy = value; }, reads: () => reads }; +} +function request(operation: AuthorizationRequest["operation"] = "read", extra: Partial = {}): AuthorizationRequest { + return { ...binding, operation, ...(operation === "submit" || operation === "replay" || operation === "artifact" ? { commandKind: "run.submit" as const } : {}), ...extra }; +} +function code(expected: string) { return (error: unknown) => error instanceof ControlError && error.code === expected && error.sideEffects === "none"; } +const denied = code("TF_POLICY_DENIED"), revoked = code("TF_AUTHORITY_REVOKED"); + +test("trusted contexts cannot be forged through body fields, JSON, spread, or a second issuer", async () => { + const f = fixture(), foreign = fixture(); + for (const context of [null, { principal: f.authority.identity(f.context).principal }, { ...f.context }, JSON.parse(JSON.stringify(f.context)), foreign.context]) { + await assert.rejects(f.authority.authorize(context as VerifiedContext, request()), revoked); + } + const identity = f.authority.identity(f.context); + assert.equal(identity.principal, `os-user:${process.getuid!()}`); + assert.ok(Object.isFrozen(identity)); assert.ok(Object.isFrozen(identity.binding)); assert.ok(Object.isFrozen(f.context)); + await assert.rejects(f.authority.authorize(f.context, { ...request(), callerPrincipal: identity.principal } as AuthorizationRequest), denied); + assert.equal(f.reads(), 0, "identity spoofing must fail before reading policy"); +}); + +test("bootstrap token challenge binds principal, project/domain and nonce; wrong proof consumes the challenge", async () => { + const f = fixture(); + const challenge = f.authority.createChallenge(binding); + const proof = answerAuthorizationChallenge(secret, challenge); + const context = f.authority.authenticate(challenge.id, proof); + assert.equal((await f.authority.authorize(context, request())).callerPrincipal, `os-user:${process.getuid!()}`); + assert.throws(() => f.authority.authenticate(challenge.id, proof), denied); + const tamperers: ((input: AuthorizationChallenge) => AuthorizationChallenge)[] = [ + (input) => ({ ...input, principal: `os-user:${process.getuid!() + 1}` }), + (input) => ({ ...input, purpose: "other.purpose" as never }), + (input) => ({ ...input, version: 2 as never }), + (input) => ({ ...input, nonce: "00" }), + (input) => ({ ...input, expiresAt: input.expiresAt + 1 }), + (input) => ({ ...input, binding: { ...input.binding, projectId: "project-b" } }), + (input) => ({ ...input, binding: { ...input.binding, controlDomainId: "domain-b" } }), + (input) => ({ ...input, binding: { ...input.binding, projectRoot: other } }), + ]; + for (const tamper of tamperers) { + const pending = f.authority.createChallenge(binding); + assert.throws(() => f.authority.authenticate(pending.id, answerAuthorizationChallenge(secret, tamper(pending))), denied); + } + const wrong = f.authority.createChallenge(binding); + assert.throws(() => f.authority.authenticate(wrong.id, answerAuthorizationChallenge(randomBytes(32), wrong)), denied); + assert.throws(() => f.authority.authenticate(wrong.id, answerAuthorizationChallenge(secret, wrong)), denied); +}); + +test("trusted credential issuers bind distinct principals and tokens; wire labels cannot select one", async () => { + const issuerA = fixture({ credentialIssuerId: "adapter-a" }); + const issuerB = fixture({ credentialIssuerId: "adapter-b", bootstrapSecret: randomBytes(32) }); + assert.notEqual(issuerA.authority.identity(issuerA.context).principal, issuerB.authority.identity(issuerB.context).principal); + const challenge = issuerB.authority.createChallenge(binding); + assert.throws(() => issuerB.authority.authenticate(challenge.id, answerAuthorizationChallenge(secret, challenge)), denied); + assert.throws(() => fixture({ credentialIssuerId: "untrusted\nlabel" }), denied); + await assert.rejects(issuerA.authority.authorize(issuerA.context, { ...request(), credentialIssuerId: "adapter-b" } as AuthorizationRequest), denied); +}); + +test("expired and malformed authentication fails closed; challenge queue is bounded", () => { + let time = 10; + const f = fixture({ now: () => time, challengeTtlMs: 20 }); + const expired = f.authority.createChallenge(binding); time = 30; + assert.throws(() => f.authority.authenticate(expired.id, answerAuthorizationChallenge(secret, expired)), denied); + for (const proof of ["", "a".repeat(63), "G".repeat(64), null]) { + const pending = f.authority.createChallenge(binding); + assert.throws(() => f.authority.authenticate(pending.id, proof as string), denied); + } + for (let i = 0; i < 256; i++) f.authority.createChallenge(binding); + assert.throws(() => f.authority.createChallenge(binding), denied); + time += 20; assert.ok(f.authority.createChallenge(binding)); +}); + +test("issuance requires actual OS uid, an existing project directory and protected token strength", () => { + assert.throws(() => fixture({ ownerUid: process.getuid!() + 1 }), code("TF_BOOTSTRAP_FAILED")); + assert.throws(() => fixture({ bootstrapSecret: randomBytes(31) }), denied); + assert.throws(() => fixture({ challengeTtlMs: 60_001 }), denied); + assert.throws(() => fixture({ hostBaseline: ["network" as never] }), denied); + const f = fixture(); + assert.throws(() => f.authority.issueStandalone({ ...binding, projectRoot: join(root, "missing") }), denied); + writeFileSync(join(root, "file"), "file"); + assert.throws(() => f.authority.issueStandalone({ ...binding, projectRoot: join(root, "file") }), denied); +}); + +test("empty policy inherits only an explicit host baseline, never unrestricted authority", async () => { + const f = fixture({ hostBaseline: ["project.read", "run.submit"] }); + assert.equal((await f.authority.authorize(f.context, request())).capability.kind, "project.read"); + assert.equal((await f.authority.authorize(f.context, request("submit"))).capability.kind, "run.submit"); + await assert.rejects(f.authority.authorize(f.context, request("submit", { commandKind: "approval.decide" })), denied); + const none = fixture({ hostBaseline: [] }); + await assert.rejects(none.authority.authorize(none.context, request()), denied); + for (const policy of [{}, { user: {} }, null, { host: { capabilities: [] } }]) { + f.set(policy); await assert.rejects(f.authority.authorize(f.context, request()), denied); + } +}); + +test("run.cancel requires its own explicit capability and current authorization", async () => { + const f = fixture({ hostBaseline: ["run.submit", "run.cancel", "artifact.read"] }); + assert.equal((await f.authority.authorize(f.context, request("submit", { commandKind: "run.cancel" }))).capability.kind, "run.cancel"); + assert.equal((await f.authority.authorize(f.context, request("replay", { commandKind: "run.cancel" }))).capability.kind, "run.cancel"); + f.set({ host: {}, project: { deny: ["run.cancel"] } }); + await assert.rejects(f.authority.authorize(f.context, request("submit", { commandKind: "run.cancel" })), denied); + await assert.rejects(f.authority.authorize(f.context, request("replay", { commandKind: "run.cancel" })), denied); + const baseline = fixture({ hostBaseline: ["run.submit"] }); + await assert.rejects(baseline.authority.authorize(baseline.context, request("submit", { commandKind: "run.cancel" })), denied); +}); + +for (const field of ["host", "user", "project", "invocation"] as const) { + test(`four-layer policy: ${field} can deny or attenuate, and lower layers cannot enlarge it`, async () => { + const f = fixture(); + f.set({ host: {}, [field]: { deny: ["run.submit"] } }); + await assert.rejects(f.authority.authorize(f.context, request("submit")), denied); + assert.equal((await f.authority.authorize(f.context, request())).capability.kind, "project.read"); + f.set({ host: { capabilities: [{ kind: "run.submit", scopeRoot: inner }] }, [field]: { attenuate: [{ kind: "run.submit", scopeRoot: deeper }] } }); + await assert.rejects(f.authority.authorize(f.context, request("submit", { resourcePath: other })), denied); + const accepted = await f.authority.authorize(f.context, request("submit", { resourcePath: join(deeper, "new-file") })); + assert.equal(accepted.capability.scopeRoot, deeper); + f.set({ host: { capabilities: [{ kind: "run.submit", scopeRoot: inner }] }, user: { capabilities: [{ kind: "run.submit", scopeRoot: root }] } }); + await assert.rejects(f.authority.authorize(f.context, request("submit", { resourcePath: other })), denied); + }); +} + +test("substitution is scoped attenuation; conflicting substitutions deny instead of picking a winner", async () => { + const f = fixture(); + const narrow: AuthorizationPolicyLayer = { substitute: [{ capability: "run.submit", with: { kind: "run.submit", scopeRoot: inner } }] }; + f.set({ host: {}, user: narrow }); + assert.equal((await f.authority.authorize(f.context, request("submit", { resourcePath: deeper }))).capability.scopeRoot, inner); + await assert.rejects(f.authority.authorize(f.context, request("submit", { resourcePath: other })), denied); + f.set({ host: {}, user: narrow, project: { substitute: [{ capability: "run.submit", with: { kind: "run.submit", scopeRoot: deeper } }] } }); + await assert.rejects(f.authority.authorize(f.context, request("submit", { resourcePath: deeper })), denied); + f.set({ host: {}, invocation: { substitute: [{ capability: "run.submit", with: { kind: "approval.decide", scopeRoot: inner } }] } }); + await assert.rejects(f.authority.authorize(f.context, request("submit", { resourcePath: inner })), denied); +}); + +test("unknown security keys and capability values fail closed at every policy layer", async () => { + const f = fixture(); + for (const field of ["host", "user", "project", "invocation"] as const) { + for (const bad of [ + { allowNetwork: true }, { capabilities: [{ kind: "network", scopeRoot: root }] }, + { deny: ["DomainTransfer"] }, { attenuate: [{ kind: "run.submit", scopeRoot: root, label: "trusted" }] }, + { capabilities: [{ kind: "run.submit", scopeRoot: outside }] }, { capabilities: "all" }, + ]) { + f.set({ host: {}, [field]: bad }); await assert.rejects(f.authority.authorize(f.context, request()), denied); + } + } + f.set({ host: {}, policyHash: "a".repeat(64) }); await assert.rejects(f.authority.authorize(f.context, request()), denied); + f.set({ host: {} }); + await assert.rejects(f.authority.authorize(f.context, request("network" as never)), denied); + await assert.rejects(f.authority.authorize(f.context, request("read", { commandKind: "unknown" as never })), denied); +}); + +test("project/domain and symlink scope escapes fail before policy read or side effect", async () => { + const f = fixture(); + for (const extra of [{ projectId: "project-b" }, { controlDomainId: "domain-b" }, { projectRoot: outside }, { resourcePath: outside }, { resourcePath: join(root, "escape", "future-file") }]) { + await assert.rejects(f.authority.authorize(f.context, request("submit", extra)), denied); + } + assert.equal(f.reads(), 0); +}); + +test("dangling symlink ancestors cannot become ordinary missing resource paths", async () => { + const alias = join(root, "dangling-resource"); + symlinkSync(join(outside, "not-created"), alias); + const f = fixture(); + try { + for (const resourcePath of [alias, join(alias, "future-file"), join(alias, "missing", "future-file")]) { + await assert.rejects(f.authority.authorize(f.context, request("submit", { resourcePath })), denied); + } + assert.equal(f.reads(), 0); + } finally { unlinkSync(alias); } +}); + +test("dangling policy scope rejects even when the requested resource uses an independent valid grant", async () => { + const alias = join(root, "dangling-policy"); + symlinkSync(join(outside, "not-created"), alias); + const f = fixture(); + try { + for (const extra of [ + { capabilities: [{ kind: "run.submit", scopeRoot: root }, { kind: "project.read", scopeRoot: alias }] }, + { attenuate: [{ kind: "project.read", scopeRoot: alias }] }, + { substitute: [{ capability: "project.read", with: { kind: "project.read", scopeRoot: alias } }] }, + ]) { + f.set({ host: extra }); + await assert.rejects(f.authority.authorize(f.context, request("submit")), denied); + } + } finally { unlinkSync(alias); } +}); + +test("every read/submit/replay loads live policy, including revoked principals and changed audit context", async () => { + const f = fixture(); + for (const operation of ["read", "submit", "replay"] as const) await f.authority.authorize(f.context, request(operation)); + assert.equal(f.reads(), 3); + const before = await f.authority.authorize(f.context, request("replay")); + f.set({ host: {}, project: { attenuate: [{ kind: "project.read", scopeRoot: inner }] } }); + const after = await f.authority.authorize(f.context, request("replay")); + assert.notEqual(before.authorizationContextHash, after.authorizationContextHash); + assert.ok(Object.isFrozen(after.effectivePolicy.layers)); + assert.ok(Object.isFrozen(after.effectivePolicy.layers.project!.attenuate)); + f.set({ host: {}, project: { deny: ["run.submit"] } }); + await assert.rejects(f.authority.authorize(f.context, request("replay")), denied); + f.set({ host: {}, revokedPrincipals: [before.callerPrincipal] }); + await assert.rejects(f.authority.authorize(f.context, request()), revoked); + assert.equal(authorizationAuditHash({ z: [null, 1], a: { b: true } }), createHash("sha256").update(canonicalJson({ a: { b: true }, z: [null, 1] })).digest("hex")); +}); + +test("request identity/class cannot change during live policy reads", async () => { + let finish!: (value: unknown) => void; + const f = fixture({ loadLivePolicy: () => new Promise((resolve) => { finish = resolve; }) }); + const input = request("submit"); + const pending = f.authority.authorize(f.context, input); + input.commandKind = "coordinator.forceRelease"; + input.projectId = "project-b"; + finish({ host: {} }); + const result = await pending; + assert.equal(result.capability.kind, "run.submit"); + const fixed = fixture(); + assert.equal(result.authorizationContextHash, (await fixed.authority.authorize(fixed.context, request("submit"))).authorizationContextHash); +}); + +test("a resource symlink redirected while live policy is loading cannot escape at disclosure", async () => { + const alias = join(root, "changing-alias"); symlinkSync(inner, alias); + let finish!: (value: unknown) => void; + const f = fixture({ loadLivePolicy: () => new Promise((resolve) => { finish = resolve; }) }); + const pending = f.authority.authorize(f.context, request("read", { resourcePath: join(alias, "future") })); + unlinkSync(alias); symlinkSync(outside, alias); + finish({ host: {} }); + try { await assert.rejects(pending, denied); } finally { unlinkSync(alias); } +}); + +test("unavailable live policy and context revocation during an asynchronous policy read deny", async () => { + const unavailable = fixture({ loadLivePolicy: () => { throw new Error("storage down"); } }); + await assert.rejects(unavailable.authority.authorize(unavailable.context, request("replay")), denied); + let finish!: (value: unknown) => void; + const f = fixture({ loadLivePolicy: () => new Promise((resolve) => { finish = resolve; }) }); + const pending = f.authority.authorize(f.context, request()); + f.authority.revokeContext(f.context); finish({ host: {} }); + await assert.rejects(pending, revoked); + await assert.rejects(f.authority.authorize(f.context, request()), revoked); +}); + +test("artifact digest requires live ledger reachability and policy; revocation during lookup denies", async () => { + let reachable = true, lookups = 0; + const f = fixture({ verifyArtifactReachability: async (principal, checked, digest) => { + lookups++; assert.equal(principal, `os-user:${process.getuid!()}`); assert.deepEqual(checked, binding); assert.equal(digest, "digest"); return reachable; + } }); + await f.authority.authorize(f.context, request("artifact", { artifactDigest: "digest" })); + reachable = false; + await assert.rejects(f.authority.authorize(f.context, request("artifact", { artifactDigest: "digest" })), denied); + reachable = true; f.set({ host: {}, project: { deny: ["artifact.read"] } }); + await assert.rejects(f.authority.authorize(f.context, request("artifact", { artifactDigest: "digest" })), denied); + assert.equal(lookups, 3); + f.set({ host: {}, invocation: { deny: ["run.submit"] } }); + await assert.rejects(f.authority.authorize(f.context, request("artifact", { artifactDigest: "digest" })), denied); + await assert.rejects(f.authority.authorize(f.context, request("artifact", { artifactDigest: "digest", commandKind: undefined })), denied); + const noLedger = fixture(); + await assert.rejects(noLedger.authority.authorize(noLedger.context, request("artifact", { artifactDigest: "digest" })), denied); + await assert.rejects(f.authority.authorize(f.context, { ...request("artifact", { artifactDigest: "digest" }), artifactReachable: true } as AuthorizationRequest), denied); + let finish!: (value: boolean) => void; + const racing = fixture({ verifyArtifactReachability: () => new Promise((resolve) => { finish = resolve; }) }); + const pending = racing.authority.authorize(racing.context, request("artifact", { artifactDigest: "digest" })); + racing.set({ host: {}, invocation: { deny: ["run.submit"] } }); + finish(true); await assert.rejects(pending, denied); +}); diff --git a/packages/taskflow-control/test/coordinator-store.test.ts b/packages/taskflow-control/test/coordinator-store.test.ts new file mode 100644 index 00000000..0ea36ed5 --- /dev/null +++ b/packages/taskflow-control/test/coordinator-store.test.ts @@ -0,0 +1,414 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { after, test } from "node:test"; +import { ControlError } from "../src/errors.ts"; +import { commandRequestHash } from "../src/schema/commands.ts"; +import { openCoordinatorStore, type CoordinatorStoreOptions, type CoordinatorReleaseProof, type CoordinatorIdentity, type UserCoordinatorStore } from "../src/store/coordinator-store.ts"; +import type { ConcurrencyReservation } from "../src/schema/coordinator.ts"; + +const roots: string[] = []; +const makePath = () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-coordinator-")); roots.push(root); return path.join(root, "user-private"); }; +after(() => { for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); }); +const bytes = (directory: string) => fs.readFileSync(path.join(directory, "coordinator.json"), "utf8"); +const binding = (overrides = {}) => ({ reservationId: randomUUID(), projectId: randomUUID(), projectControlDomainId: randomUUID(), runId: randomUUID(), ttlMs: 60_000, ...overrides }); +const errorCode = (code: string) => (error: unknown) => error instanceof ControlError && error.code === code; +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +function deferred() { let resolve!: () => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; } +type Context = object; +function authority(max = 2, epoch = 1) { + const identities = new WeakMap(); + const context = {}; identities.set(context, { principal: "user:one", ownerId: "owner:one", operator: false }); + const operator = {}; identities.set(operator, { principal: "operator:one", ownerId: "owner:operator", operator: true }); + let leaseEpoch = epoch, leaseExpired = false, revoked = false; + let releaseOverride: Partial = {}; + let admissionOverride: Partial<{ projectAdmitCommitSeq: number; runVersion: number }> = {}; + let admissionUnavailable = false; + let admissionHook: (() => Promise) | undefined; + let releaseHook: (() => Promise) | undefined; + const seenBindings: ConcurrencyReservation[] = []; + const options: CoordinatorStoreOptions = { initialMaxActiveRuns: max, epoch, holderId: "fixture-host", authority: { + readLease: () => ({ holderId: "fixture-host", fencingEpoch: leaseEpoch, endpoint: "test://coordinator", expiresAt: Date.now() + (leaseExpired ? -1 : 60_000) }), + authorize: (ctx, _operation, row) => { + if (row) seenBindings.push(structuredClone(row)); + const identity = identities.get(ctx); if (!identity || revoked) throw new ControlError("TF_AUTHORITY_REVOKED", "opaque host authority was not verified"); + return structuredClone(identity); + }, + readAdmission: async (row) => { + await admissionHook?.(); if (admissionUnavailable) return null; + const proofFile = path.join(admissionDirectory ?? "", "test-project-admission.json"); + if (admissionDirectory && fs.existsSync(proofFile)) return JSON.parse(fs.readFileSync(proofFile, "utf8")); + return row.projectAdmitCommitSeq === undefined && admissionOverride.projectAdmitCommitSeq === undefined + ? { status: "not-admitted" as const, reservationId: row.reservationId, projectId: row.projectId, projectControlDomainId: row.projectControlDomainId, runId: row.runId, runVersion: 0 } + : { ...row, projectAdmitCommitSeq: row.projectAdmitCommitSeq!, runVersion: 1, ...admissionOverride }; + }, + readRelease: async (row) => { await releaseHook?.(); return { ...row, projectAdmitCommitSeq: row.projectAdmitCommitSeq!, runVersion: 2, proofId: "trusted-ledger-provider-proof", status: "completed", stage: "terminal", requiresReadmission: false, providerNoLiveProcessTree: true, noAmbiguousJobs: true, reconcileTimeoutOnly: false, ...releaseOverride }; }, + } }; + let admissionDirectory: string | undefined; + return { options, context, operator, seenBindings, + issue: (identity: CoordinatorIdentity) => { const ctx = {}; identities.set(ctx, identity); return ctx; }, + revoke: () => { revoked = true; }, lease: (newEpoch: number, expired = false) => { leaseEpoch = newEpoch; leaseExpired = expired; }, + release: (proof: Partial) => { releaseOverride = proof; }, admission: (proof: typeof admissionOverride) => { admissionOverride = proof; }, + unavailableAdmission: () => { admissionUnavailable = true; }, projectLedger: (directory: string) => { admissionDirectory = directory; }, + waitAdmission: (hook: () => Promise) => { admissionHook = hook; }, waitRelease: (hook: () => Promise) => { releaseHook = hook; }, + }; +} +async function admitted(store: UserCoordinatorStore, context: Context, input = binding()) { + await store.reserve(input, context); return store.commit(input.reservationId, { projectAdmitCommitSeq: 7 }, context); +} +const worker = fileURLToPath(new URL("./fixtures/coordinator-worker.ts", import.meta.url)); +async function runWorker(config: Record) { + const child = spawn(process.execPath, ["--conditions=development", "--experimental-strip-types", worker, JSON.stringify(config)], { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = "", stderr = ""; child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); + const timer = setTimeout(() => child.kill("SIGKILL"), 40_000); + try { + const close = await new Promise<{ code: number | null; signal: string | null }>((resolve, reject) => { child.once("error", reject); child.once("close", (code, signal) => resolve({ code, signal })); }); + if (close.signal) return { ...close, stderr, stdout, data: undefined }; + assert.ok(stdout.trim(), `worker produced no result: ${stderr}`); + return { ...close, stderr, stdout, data: JSON.parse(stdout.trim()) as { ok: boolean; code?: string; result?: unknown } }; + } finally { clearTimeout(timer); } +} + +test("P16 reserve is globally metered, scope-authorized, one slot, with detached inputs/outputs", async () => { + const directory = makePath(), auth = authority(1), store = await openCoordinatorStore(directory, auth.options); + try { + const input = binding(), result = await store.reserve(input, auth.context); + assert.equal(result.reservation.slots, 1); assert.equal(auth.seenBindings[0]?.projectId, input.projectId); + result.reservation.state = "released"; input.projectId = randomUUID(); + assert.equal((await store.readReservation(result.reservation.reservationId, auth.context))!.reservation.state, "reserved"); + const before = bytes(directory); + await assert.rejects(store.reserve(binding(), auth.context), errorCode("TF_CAPACITY_EXCEEDED")); + await assert.rejects(store.reserve(binding(), { principal: "user:one", ownerId: "owner:one", operator: true }), errorCode("TF_AUTHORITY_REVOKED")); + assert.equal(bytes(directory), before); + assert.equal((await store.snapshot(auth.context)).capacity.active, 1); + } finally { store.close(); } +}); + +test("P16 32 real processes race across projects: exactly N slots and N+1 fails closed", { timeout: 60_000 }, async () => { + const directory = makePath(), auth = authority(8), store = await openCoordinatorStore(directory, auth.options); + try { + const results = await Promise.all(Array.from({ length: 32 }, (_, index) => runWorker({ path: directory, mode: "reserve", max: 8, identity: { principal: `user:${index}`, ownerId: `owner:${index}`, operator: false }, input: binding() }))); + assert.equal(results.filter((r) => r.data?.ok).length, 8, results.map((r) => r.stderr + r.stdout).join("\n")); + assert.equal(results.filter((r) => r.data?.code === "TF_CAPACITY_EXCEEDED").length, 24); + const snapshot = await store.snapshot(auth.context); + assert.deepEqual(snapshot.capacity, { maxActiveRuns: 8, active: 8, reserved: 8, committed: 0, orphanSuspect: 0 }); + assert.equal(new Set(snapshot.reservations.map((r) => r.reservation.projectId)).size, 8); + } finally { store.close(); } +}); + +test("P16 racing first opens create exactly one durable authority before enforcing capacity", { timeout: 30_000 }, async () => { + const directory = makePath(); + const results = await Promise.all(Array.from({ length: 8 }, (_, index) => runWorker({ path: directory, mode: "reserve", max: 2, identity: { principal: `bootstrap:${index}`, ownerId: `owner:${index}`, operator: false }, input: binding() }))); + assert.equal(results.filter((r) => r.data?.ok).length, 2, results.map((r) => r.stderr + r.stdout).join("\n")); + assert.equal(results.filter((r) => r.data?.code === "TF_CAPACITY_EXCEEDED").length, 6); + const auth = authority(2), store = await openCoordinatorStore(directory, auth.options); + try { assert.equal((await store.snapshot(auth.context)).capacity.active, 2); assert.equal(JSON.parse(bytes(directory)).audit.filter((r: { action: string }) => r.action === "initialize").length, 1); } + finally { store.close(); } +}); + +test("P16 only pre-admit reserved expires; committed and orphan-suspect never use TTL", async () => { + const directory = makePath(), auth = authority(3), store = await openCoordinatorStore(directory, auth.options); + try { + const expiring = binding({ ttlMs: 80 }); await store.reserve(expiring, auth.context); + const committed = await admitted(store, auth.context), orphan = await admitted(store, auth.context); + await store.markOrphanSuspect(orphan.reservation.reservationId, auth.context); + await sleep(100); + const snapshot = await store.snapshot(auth.context); + assert.equal(snapshot.capacity.active, 2); assert.equal(snapshot.capacity.committed, 1); assert.equal(snapshot.capacity.orphanSuspect, 1); + assert.equal(snapshot.commitSeq, JSON.parse(bytes(directory)).commitSeq, "expiry-triggered snapshot reports the published seq"); + assert.equal((await store.readReservation(expiring.reservationId, auth.context))!.reservation.state, "expired"); + for (const row of [committed, orphan]) { + await assert.rejects(store.renew(row.reservation.reservationId, { ttlMs: 10 }, auth.context), errorCode("TF_ADMISSION_BINDING_CONFLICT")); + assert.equal((await store.renew(row.reservation.reservationId, {}, auth.context)).reservation.reservedExpiresAt, undefined); + } + } finally { store.close(); } +}); + +test("P16 project admission is proven, unique, and cannot commit after proof crosses TTL", async () => { + const directory = makePath(), auth = authority(3), store = await openCoordinatorStore(directory, auth.options); + try { + const first = binding(); await admitted(store, auth.context, first); + const second = { ...first, reservationId: randomUUID() }; await store.reserve(second, auth.context); + const before = bytes(directory); + await assert.rejects(store.commit(second.reservationId, { projectAdmitCommitSeq: 7 }, auth.context), errorCode("TF_ADMISSION_BINDING_CONFLICT")); + assert.equal(bytes(directory), before); + const short = binding({ ttlMs: 30 }); await store.reserve(short, auth.context); + auth.waitAdmission(() => sleep(40)); + await assert.rejects(store.commit(short.reservationId, { projectAdmitCommitSeq: 7 }, auth.context), errorCode("TF_ADMISSION_BINDING_CONFLICT")); + assert.equal((await store.readReservation(short.reservationId, auth.context))!.reservation.state, "expired"); + } finally { store.close(); } +}); + +test("P16 a wrong admission commit sequence never crosses the global capacity binding", async () => { + const directory = makePath(), auth = authority(1), store = await openCoordinatorStore(directory, auth.options); + try { + const input = binding(); await store.reserve(input, auth.context); auth.admission({ projectAdmitCommitSeq: 6 }); + const before = bytes(directory); + await assert.rejects(store.commit(input.reservationId, { projectAdmitCommitSeq: 7 }, auth.context), errorCode("TF_ADMISSION_BINDING_CONFLICT")); + assert.equal(bytes(directory), before); assert.equal((await store.snapshot(auth.context)).capacity.reserved, 1); + } finally { store.close(); } +}); + +for (const [name, proof] of Object.entries({ liveChild: { providerNoLiveProcessTree: false }, ambiguousJob: { noAmbiguousJobs: false }, reconcileTimeout: { reconcileTimeoutOnly: true }, staleSeq: { projectAdmitCommitSeq: 6 }, staleVersion: { runVersion: 0 }, wrongProject: { projectId: randomUUID() }, unknown: { status: "unknown" }, parkedNoReadmission: { status: "paused", stage: "parked", requiresReadmission: false } })) { + test(`P16 normalRelease rejects ${name} without changing authoritative bytes`, async () => { + const directory = makePath(), auth = authority(), store = await openCoordinatorStore(directory, auth.options); + try { + const row = await admitted(store, auth.context); auth.release(proof); + const before = bytes(directory); + await assert.rejects(store.normalRelease(row.reservation.reservationId, auth.context), errorCode("TF_RECONCILE_REQUIRED")); + assert.equal(bytes(directory), before); assert.equal((await store.snapshot(auth.context)).capacity.active, 1); + } finally { store.close(); } + }); +} + +test("P16 parked release, concurrent retries and restart preserve prior row and allow fresh readmission", async () => { + const directory = makePath(), auth = authority(1); let store = await openCoordinatorStore(directory, auth.options); + try { + const input = binding(), row = await admitted(store, auth.context, input); + auth.release({ status: "paused", stage: "parked", requiresReadmission: true }); + const releases = await Promise.all(Array.from({ length: 8 }, () => store.normalRelease(row.reservation.reservationId, auth.context))); + for (const result of releases) assert.deepEqual(result, releases[0]); + const before = bytes(directory); store.close(); store = await openCoordinatorStore(directory, auth.options); + assert.deepEqual(await store.normalRelease(input.reservationId, auth.context), releases[0]); assert.equal(bytes(directory), before); + const newContext = auth.issue({ principal: "user:one", ownerId: "owner:fresh", operator: false }); + const fresh = await admitted(store, newContext, { ...input, reservationId: randomUUID() }); + assert.equal(fresh.ownerId, "owner:fresh"); assert.notEqual(fresh.reservation.reservationId, input.reservationId); + await assert.rejects(store.normalRelease(input.reservationId, newContext), errorCode("TF_AUTHORITY_REVOKED")); + } finally { store.close(); } +}); + +test("P16 forceRelease requires operator+risk, durable command hash/principal and fresh replay authorization", async () => { + const directory = makePath(), auth = authority(1); let store = await openCoordinatorStore(directory, auth.options); + try { + const row = await admitted(store, auth.context), input = { commandId: randomUUID(), riskAcknowledgement: true as const, reason: "operator reviewed unknown provider" }; + const before = bytes(directory); + await assert.rejects(store.forceRelease(row.reservation.reservationId, input, auth.context), errorCode("TF_POLICY_DENIED")); + await assert.rejects(store.forceRelease(row.reservation.reservationId, { ...input, riskAcknowledgement: false as unknown as true }, auth.operator), errorCode("TF_POLICY_DENIED")); + assert.equal(bytes(directory), before); + const released = await store.forceRelease(row.reservation.reservationId, input, auth.operator); + assert.equal(released.concurrencyGuarantee, "operator-overridden"); assert.equal((await store.snapshot(auth.operator)).capacity.active, 0); + const published = bytes(directory); store.close(); store = await openCoordinatorStore(directory, auth.options); + assert.deepEqual(await store.forceRelease(row.reservation.reservationId, input, auth.operator), released); assert.equal(bytes(directory), published); + await assert.rejects(store.forceRelease(row.reservation.reservationId, { ...input, reason: "changed" }, auth.operator), errorCode("TF_IDEMPOTENCY_CONFLICT")); + const other = auth.issue({ principal: "operator:other", ownerId: "owner:other", operator: true }); + await assert.rejects(store.forceRelease(row.reservation.reservationId, input, other), errorCode("TF_CROSS_PRINCIPAL_COMMAND")); + auth.revoke(); await assert.rejects(store.forceRelease(row.reservation.reservationId, input, auth.operator), errorCode("TF_AUTHORITY_REVOKED")); + assert.equal(bytes(directory), published); + } finally { store.close(); } +}); + +test("P16 capacity command cannot overbook or replay a different request and command outputs are detached", async () => { + const directory = makePath(), auth = authority(2), store = await openCoordinatorStore(directory, auth.options); + try { + await admitted(store, auth.context); await admitted(store, auth.context); + await assert.rejects(store.setMaxActiveRuns({ commandId: randomUUID(), maxActiveRuns: 1 }, auth.operator), errorCode("TF_CAPACITY_EXCEEDED")); + const input = { commandId: randomUUID(), maxActiveRuns: 3 }; + assert.equal(await store.setMaxActiveRuns(input, auth.operator), 3); + const before = bytes(directory), command = await store.readCommand(input.commandId, auth.operator); + command!.record.callerPrincipal = "attacker"; + assert.equal(await store.setMaxActiveRuns(input, auth.operator), 3); assert.equal(bytes(directory), before); + await assert.rejects(store.setMaxActiveRuns({ ...input, maxActiveRuns: 4 }, auth.operator), errorCode("TF_IDEMPOTENCY_CONFLICT")); + } finally { store.close(); } +}); + +for (const change of ["revoke", "epoch", "expired", "close"] as const) { + test(`P16 ${change} during asynchronous provider proof aborts publication`, async () => { + const directory = makePath(), auth = authority(), store = await openCoordinatorStore(directory, auth.options); + try { + const row = await admitted(store, auth.context), entered = deferred(), proceed = deferred(); + auth.waitRelease(async () => { entered.resolve(); await proceed.promise; }); + const before = bytes(directory), pending = store.normalRelease(row.reservation.reservationId, auth.context); + await entered.promise; + if (change === "revoke") auth.revoke(); else if (change === "epoch") auth.lease(2); else if (change === "expired") auth.lease(1, true); else store.close(); + proceed.resolve(); await assert.rejects(pending, errorCode(change === "close" ? "TF_DURABILITY_FAILED" : "TF_AUTHORITY_REVOKED")); + assert.equal(bytes(directory), before); + } finally { store.close(); } + }); +} + +test("P16 newer persistent epoch fences an otherwise live old host", async () => { + const directory = makePath(), oldAuth = authority(), old = await openCoordinatorStore(directory, oldAuth.options), newAuth = authority(2, 2); + const newer = await openCoordinatorStore(directory, newAuth.options); + try { const before = bytes(directory); await assert.rejects(old.reserve(binding(), oldAuth.context), errorCode("TF_AUTHORITY_REVOKED")); assert.equal(bytes(directory), before); assert.equal((await newer.snapshot(newAuth.context)).fencingEpoch, 2); } + finally { old.close(); newer.close(); } +}); + +test("P16 authority lease which expires while being read does not permit publication", async () => { + const directory = makePath(), auth = authority(), store = await openCoordinatorStore(directory, auth.options); + try { + const before = bytes(directory); + auth.options.authority.readLease = async () => { const expiresAt = Date.now() + 10; await sleep(25); return { holderId: "fixture-host", fencingEpoch: 1, endpoint: "test://coordinator", expiresAt }; }; + await assert.rejects(store.reserve(binding(), auth.context), errorCode("TF_AUTHORITY_REVOKED")); + assert.equal(bytes(directory), before); + } finally { store.close(); } +}); + +for (const crash of ["before-state-write", "after-state-write"] as const) { + test(`P16 SIGKILL ${crash} recovers complete reserve and admission states`, { timeout: 30_000 }, async () => { + const directory = makePath(), auth = authority(1); let store = await openCoordinatorStore(directory, auth.options); + try { + const input = binding(), identity = { principal: "user:one", ownerId: "owner:one", operator: false }; + const child = await runWorker({ path: directory, max: 1, mode: "reserve", input, identity, crash }); + assert.equal(child.signal, "SIGKILL", child.stderr + child.stdout); + store.close(); store = await openCoordinatorStore(directory, auth.options); + assert.equal((await store.snapshot(auth.context)).capacity.active, crash === "before-state-write" ? 0 : 1); + await store.reserve(input, auth.context); + const commit = await runWorker({ path: directory, max: 1, mode: "commit", input, identity, crash }); + assert.equal(commit.signal, "SIGKILL", commit.stderr + commit.stdout); + store.close(); store = await openCoordinatorStore(directory, auth.options); + const row = await store.readReservation(input.reservationId, auth.context); + assert.equal(row!.reservation.state, crash === "before-state-write" ? "reserved" : "committed"); + assert.equal((await store.snapshot(auth.context)).capacity.active, 1); + } finally { store.close(); } + }); +} + +for (const mode of ["normalRelease", "forceRelease"] as const) { + for (const crash of ["before-state-write", "after-state-write"] as const) { + test(`P16 ${mode} SIGKILL ${crash} preserves complete capacity/result/audit`, { timeout: 30_000 }, async () => { + const directory = makePath(), auth = authority(1); let store = await openCoordinatorStore(directory, auth.options); + try { + const input = binding(); await admitted(store, auth.context, input); + const commandId = randomUUID(), identity = mode === "forceRelease" ? { principal: "operator:one", ownerId: "owner:operator", operator: true } : { principal: "user:one", ownerId: "owner:one", operator: false }; + const result = await runWorker({ path: directory, max: 1, mode, input, identity, crash, commandId }); + assert.equal(result.signal, "SIGKILL", result.stderr + result.stdout); + store.close(); store = await openCoordinatorStore(directory, auth.options); + assert.equal((await store.snapshot(auth.context)).capacity.active, crash === "before-state-write" ? 1 : 0); + const context = mode === "forceRelease" ? auth.operator : auth.context; + const retry = () => mode === "forceRelease" ? store.forceRelease(input.reservationId, { commandId, riskAcknowledgement: true, reason: "operator reviewed unknown provider" }, context) : store.normalRelease(input.reservationId, context); + const released = await retry(), published = bytes(directory); + assert.deepEqual(await retry(), released); assert.equal(bytes(directory), published); + if (mode === "forceRelease") assert.equal((await store.readCommand(commandId, auth.operator))!.record.status, "completed"); + } finally { store.close(); } + }); + } +} + +test("P16 eight process-level release retries preserve one durable released record", { timeout: 30_000 }, async () => { + const directory = makePath(), auth = authority(1), store = await openCoordinatorStore(directory, auth.options); + try { + const input = binding(); await admitted(store, auth.context, input); + const results = await Promise.all(Array.from({ length: 8 }, () => runWorker({ path: directory, mode: "normalRelease", max: 1, input, identity: { principal: "user:one", ownerId: "owner:one", operator: false } }))); + for (const result of results) { assert.equal(result.data?.ok, true, result.stderr + result.stdout); assert.deepEqual(result.data?.result, results[0]!.data?.result); } + const raw = JSON.parse(bytes(directory)); assert.equal(raw.audit.filter((r: { action: string }) => r.action === "normalRelease").length, 1); + assert.equal((await store.snapshot(auth.context)).capacity.active, 0); + } finally { store.close(); } +}); + +test("P16 reopened store rejects backward wall clock without reclaim or publication", async () => { + const directory = makePath(), auth = authority(), store = await openCoordinatorStore(directory, auth.options); + try { + await admitted(store, auth.context); const before = bytes(directory); + const result = await runWorker({ path: directory, mode: "snapshot", input: binding(), identity: { principal: "user:one", ownerId: "owner:one", operator: false }, clockOffset: -60_000 }); + assert.equal(result.data?.code, "TF_AUTHORITY_REVOKED"); assert.equal(bytes(directory), before); + } finally { store.close(); } +}); + +test("P16 project-admit fsync then host SIGKILL before coordinator commit must never TTL-free its run", { timeout: 20_000 }, async () => { + const directory = makePath(), auth = authority(1); auth.projectLedger(directory); + let store = await openCoordinatorStore(directory, auth.options); + try { + const input = binding({ ttlMs: 3000 }); await store.reserve(input, auth.context); + const child = await runWorker({ path: directory, max: 1, mode: "persist-admission", input, identity: { principal: "user:one", ownerId: "owner:one", operator: false } }); + assert.equal(child.signal, "SIGKILL", child.stderr + child.stdout); + const before = JSON.parse(bytes(directory)); assert.equal(before.reservations[input.reservationId].reservation.state, "reserved"); + await sleep(Math.max(0, before.reservations[input.reservationId].reservation.reservedExpiresAt - Date.now()) + 30); + store.close(); store = await openCoordinatorStore(directory, auth.options); + const recovered = await store.readReservation(input.reservationId, auth.context); + assert.equal(recovered!.reservation.state, "committed"); assert.equal(recovered!.reservation.projectAdmitCommitSeq, 7); assert.equal(recovered!.reservation.reservedExpiresAt, undefined); + assert.equal((await store.snapshot(auth.context)).capacity.active, 1); + await assert.rejects(store.reserve(binding(), auth.context), errorCode("TF_CAPACITY_EXCEEDED")); + } finally { store.close(); } +}); + +test("P16 unavailable admission evidence retains expired reserved capacity", async () => { + const directory = makePath(), auth = authority(1), store = await openCoordinatorStore(directory, auth.options); + try { + const input = binding({ ttlMs: 30 }); await store.reserve(input, auth.context); auth.unavailableAdmission(); await sleep(50); + const before = bytes(directory); + assert.equal((await store.snapshot(auth.context)).capacity.active, 1); + assert.equal((await store.readReservation(input.reservationId, auth.context))!.reservation.state, "reserved"); + await assert.rejects(store.reserve(binding(), auth.context), errorCode("TF_CAPACITY_EXCEEDED")); assert.equal(bytes(directory), before); + } finally { store.close(); } +}); + +test("P16 failed project ledger read and another reservation's admission proof both retain expired capacity", async () => { + const directory = makePath(), auth = authority(1), store = await openCoordinatorStore(directory, auth.options); + try { + const input = binding({ ttlMs: 30 }); await store.reserve(input, auth.context); await sleep(50); + const before = bytes(directory); + auth.options.authority.readAdmission = () => { throw new Error("project ledger unavailable"); }; + assert.equal((await store.snapshot(auth.context)).capacity.active, 1); assert.equal(bytes(directory), before); + auth.options.authority.readAdmission = () => ({ ...input, reservationId: randomUUID(), projectAdmitCommitSeq: 7, runVersion: 1 }); + assert.equal((await store.snapshot(auth.context)).capacity.active, 1); assert.equal(bytes(directory), before); + } finally { store.close(); } +}); + +test("P16 missing authoritative state cannot bootstrap over committed capacity", async () => { + const directory = makePath(), auth = authority(1), store = await openCoordinatorStore(directory, auth.options); + await admitted(store, auth.context); store.close(); + const file = path.join(directory, "coordinator.json"), displaced = path.join(directory, "displaced-coordinator.json"); + fs.renameSync(file, displaced); const original = fs.readFileSync(displaced, "utf8"), genesis = fs.readFileSync(path.join(directory, "coordinator.identity.json"), "utf8"); + await assert.rejects(openCoordinatorStore(directory, auth.options), errorCode("TF_DURABILITY_FAILED")); + assert.equal(fs.existsSync(file), false); assert.equal(fs.readFileSync(displaced, "utf8"), original); assert.equal(fs.readFileSync(path.join(directory, "coordinator.identity.json"), "utf8"), genesis); + fs.renameSync(displaced, file); const recovered = await openCoordinatorStore(directory, auth.options); + try { assert.equal((await recovered.snapshot(auth.context)).capacity.active, 1); await assert.rejects(recovered.reserve(binding(), auth.context), errorCode("TF_CAPACITY_EXCEEDED")); } finally { recovered.close(); } +}); + +// Build valid legacy state, including a recomputed audit digest, so this checks +// the semantic D3 invariant rather than merely tripping a checksum mismatch. +function editDurableState(directory: string, edit: (state: any) => void) { + const state = JSON.parse(bytes(directory)); edit(state); + const last = state.audit.at(-1); last.stateDigest = commandRequestHash({ fencingEpoch: state.fencingEpoch, maxActiveRuns: state.maxActiveRuns, reservations: state.reservations, commands: state.commands }); + const { hash: _auditHash, ...auditBody } = last; last.hash = commandRequestHash(auditBody); + const { stateHash: _stateHash, ...body } = state; state.stateHash = commandRequestHash(body); + fs.writeFileSync(path.join(directory, "coordinator.json"), JSON.stringify(state)); +} +for (const state of ["released", "expired", "committed", "orphan-suspect"] as const) { + test(`P16 legacy TTL residue on ${state} ${state === "released" || state === "expired" ? "reopens" : "fails closed"}`, async () => { + const directory = makePath(), auth = authority(), store = await openCoordinatorStore(directory, auth.options); + const row = await admitted(store, auth.context); store.close(); + editDurableState(directory, (data) => { const target = data.reservations[row.reservation.reservationId].reservation; target.state = state; target.reservedExpiresAt = Date.now() + 60_000; }); + const before = bytes(directory); + if (state === "released" || state === "expired") { const reopened = await openCoordinatorStore(directory, auth.options); try { assert.equal((await reopened.snapshot(auth.context)).capacity.active, 0); } finally { reopened.close(); } } + else await assert.rejects(openCoordinatorStore(directory, auth.options), errorCode("TF_ADMISSION_BINDING_CONFLICT")); + assert.equal(bytes(directory), before); + }); +} + +test("P16 reopening rejects a semantically duplicated admitted binding even with valid checksums", async () => { + const directory = makePath(), auth = authority(2), store = await openCoordinatorStore(directory, auth.options); + const row = await admitted(store, auth.context); store.close(); + editDurableState(directory, (data) => { + const id = randomUUID(), duplicate = structuredClone(data.reservations[row.reservation.reservationId]); + duplicate.reservation.reservationId = id; duplicate.reservation.state = "orphan-suspect"; data.reservations[id] = duplicate; + }); + const before = bytes(directory); + await assert.rejects(openCoordinatorStore(directory, auth.options), errorCode("TF_ADMISSION_BINDING_CONFLICT")); assert.equal(bytes(directory), before); +}); + +test("P16 host cannot open without all trusted authority callbacks", async () => { + const directory = makePath(), auth = authority(); + const invalid = { ...auth.options, authority: { ...auth.options.authority, readRelease: undefined } } as unknown as CoordinatorStoreOptions; + await assert.rejects(openCoordinatorStore(directory, invalid), errorCode("TF_AUTHORITY_REVOKED")); + assert.equal(fs.existsSync(path.join(directory, "coordinator.json")), false); +}); + +test("P16 copied/moved authority, tampered audit and symlink state fail closed", async () => { + const directory = makePath(), auth = authority(), store = await openCoordinatorStore(directory, auth.options); + await admitted(store, auth.context); store.close(); + const copy = directory + "-copy"; fs.cpSync(directory, copy, { recursive: true }); + const copiedBytes = bytes(copy); await assert.rejects(openCoordinatorStore(copy, auth.options), errorCode("TF_DURABILITY_FAILED")); assert.equal(bytes(copy), copiedBytes); + const moved = directory + "-moved"; fs.renameSync(directory, moved); + await assert.rejects(openCoordinatorStore(moved, auth.options), errorCode("TF_DURABILITY_FAILED")); + fs.renameSync(moved, directory); + const original = bytes(directory), raw = JSON.parse(original); raw.audit[0].action = "forged"; fs.writeFileSync(path.join(directory, "coordinator.json"), JSON.stringify(raw)); + await assert.rejects(openCoordinatorStore(directory, auth.options), errorCode("TF_DURABILITY_FAILED")); + fs.writeFileSync(path.join(directory, "coordinator.json"), original); fs.renameSync(path.join(directory, "coordinator.json"), path.join(directory, "outside.json")); + fs.symlinkSync(path.join(directory, "outside.json"), path.join(directory, "coordinator.json")); + await assert.rejects(openCoordinatorStore(directory, auth.options), errorCode("TF_DURABILITY_FAILED")); assert.equal(fs.readFileSync(path.join(directory, "outside.json"), "utf8"), original); +}); diff --git a/packages/taskflow-control/test/fixtures/approval-worker.ts b/packages/taskflow-control/test/fixtures/approval-worker.ts new file mode 100644 index 00000000..1210e3a9 --- /dev/null +++ b/packages/taskflow-control/test/fixtures/approval-worker.ts @@ -0,0 +1,157 @@ +/** Real project journal + P16 coordinator fixture; no process-local approval store. */ +import { randomBytes, randomUUID, createHash } from "node:crypto"; +import { readFileSync, existsSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import { openControlStore, type ControlStore } from "../../src/store/store.ts"; +import { openCoordinatorStore } from "../../src/store/coordinator-store.ts"; +import { createAuthorizationAuthority, AUTHORIZATION_CAPABILITIES, type VerifiedContext } from "../../src/authorization.ts"; +import { ApprovalService, type ApprovalStorage, type ApprovalRun, type ApprovalAuthority, type ApprovalRequestInput, type ApprovalDecisionInput } from "../../src/approval-service.ts"; +import { ControlError, errorToEnvelope } from "../../src/errors.ts"; +import { CONTROL_WIRE_SCHEMA_VERSION } from "../../src/schema/common.ts"; + +type SeedStore = ControlStore & ApprovalStorage & { createRun(run: ApprovalRun, events: readonly never[]): ApprovalRun }; +export async function approvalFixture(directory: string) { + const store = openControlStore(join(directory, "project-store")) as SeedStore; + const binding = { projectId: store.header.projectId, controlDomainId: store.header.controlDomainId, projectRoot: directory }; + const uid = process.getuid?.() ?? 1000; + let revoked = false, durable = true, quiescent = true, releaseFailure = false, commitFailure = false, editDelay = 0; + let invalidEdit = false, reusedOwner = false, policyHash = "a".repeat(64), relinks = 0; + const auth = createAuthorizationAuthority({ ownerUid: uid, bootstrapSecret: randomBytes(32), hostBaseline: AUTHORIZATION_CAPABILITIES, + loadLivePolicy: () => ({ host: {}, ...(revoked ? { revokedPrincipals: [`os-user:${uid}`] } : {}) }) }); + const context = auth.issueStandalone(binding), secondContext = auth.issueStandalone(binding); + const principal = auth.identity(context).principal; + async function authorize(ctx: VerifiedContext, operation: string) { + return auth.authorize(ctx, { ...binding, operation: operation === "read" ? "read" : "submit", + ...(operation === "read" ? {} : { commandKind: operation === "decide" ? "approval.decide" as const : "run.submit" as const }) }); + } + const coordinator = await openCoordinatorStore(join(directory, "coordinator"), { initialMaxActiveRuns: 2, epoch: 1, holderId: "fixture", authority: { + readLease: () => ({ holderId: "fixture", fencingEpoch: 1, endpoint: "fixture", expiresAt: Date.now() + 60_000 }), + authorize: async (ctx: VerifiedContext) => ({ principal: (await authorize(ctx, "request")).callerPrincipal, ownerId: `fixture:${binding.projectId}`, operator: false }), + readAdmission: (reservation) => { + const run = store.readRun(reservation.runId); + if (!run || run.reservationId !== reservation.reservationId) return { status: "not-admitted" as const, reservationId: reservation.reservationId, projectId: reservation.projectId, projectControlDomainId: reservation.projectControlDomainId, runId: reservation.runId, runVersion: run?.runVersion ?? 0 }; + if (reservation.state === "reserved" && (run.status !== "running" || !["admitted", "executing"].includes(run.stage))) return null; + return { reservationId: reservation.reservationId, projectId: run.projectId, projectControlDomainId: run.controlDomainId, runId: run.runId, projectAdmitCommitSeq: run.projectAdmitCommitSeq!, runVersion: run.runVersion }; + }, + readRelease: (reservation) => { + const run = store.readRun(reservation.runId)!; + const intent = store.readOutbox(run.runId).find((item) => item.kind === "approval.release" && item.reservationId === reservation.reservationId); + if (!intent || intent.kind !== "approval.release") throw new ControlError("TF_AUTHORITY_REVOKED", "no durable release evidence"); + return { ...intent.evidence, reservationId: reservation.reservationId, runVersion: run.runVersion, status: run.status, stage: run.stage, requiresReadmission: run.requiresReadmission === true, + providerNoLiveProcessTree: quiescent && intent.evidence.providerNoLiveProcessTree && intent.evidence.noPendingResourceIntents, + noAmbiguousJobs: quiescent && intent.evidence.noAmbiguousJobs }; + }, + } }); + let service!: ApprovalService; + const authority: ApprovalAuthority = { + authorize: async (ctx, scope) => { + const decision = await authorize(ctx, scope.operation); + if (scope.request && scope.operation !== "request") { + const request = scope.request; + if (decision.callerPrincipal !== request.owner && !request.audience?.includes(decision.callerPrincipal)) throw new ControlError("TF_POLICY_DENIED", "approval audience denied"); + if (request.requiredPrincipals?.length && !request.requiredPrincipals.includes(decision.callerPrincipal)) throw new ControlError("TF_POLICY_DENIED", "required principal denied"); + } + return decision; + }, + validateRequest: async (_ctx, _run, node) => { if (node !== "review" && node !== "review2") throw new ControlError("TF_COMMAND_FAILED", "not a reached approval node"); }, + negotiateDurability: async () => durable, + quiescence: async (_ctx, run) => ({ proofId: randomUUID(), ...binding, projectControlDomainId: run.controlDomainId, + runId: run.runId, projectAdmitCommitSeq: run.projectAdmitCommitSeq!, providerNoLiveProcessTree: quiescent, + noAmbiguousJobs: quiescent, noPendingResourceIntents: quiescent, reconcileTimeoutOnly: false }), + validateEdit: async (_ctx, _run, _request, edit) => { + if (editDelay) await new Promise((resolve) => setTimeout(resolve, editDelay)); + const bytes = readFileSync(join(directory, `artifact-${edit.artifact.digest}`)); + if (invalidEdit || bytes.length !== edit.artifact.size || createHash("sha256").update(bytes).digest("hex") !== edit.artifact.digest + || typeof JSON.parse(bytes.toString()).guidance !== "string") throw new ControlError("TF_COMMAND_FAILED", "edited output violates contract"); + }, + prepareReadmission: async (ctx, run, request) => { + if (request.editKind === "plan") relinks++; + const authorization = await authorize(ctx, "readmit"); + const fresh = randomUUID(), digest = createHash("sha256").update(fresh).digest("hex"); + return { policyHash, authorizationContextHash: authorization.authorizationContextHash, + owner: reusedOwner ? run.owner! : { runId: run.runId, phaseId: "next", attemptId: randomUUID(), unitId: randomUUID(), ancestry: [run.runId] }, + fragment: { schemaVersion: CONTROL_WIRE_SCHEMA_VERSION, projectId: run.projectId, controlDomainId: run.controlDomainId, fragmentId: fresh, + parentBoundPlanHash: run.boundPlanHash, ...(run.boundFragmentHash ? { parentBoundFragmentHash: run.boundFragmentHash } : {}), + sourceEventId: request.decisionEventId!, sourceCommitSeq: request.decisionCommitSeq!, fragmentIRHash: `ir:${digest}`, + fragmentPolicyHash: `policy:${policyHash}`, capabilitySetHash: `cap:${digest}`, authorityEpoch: run.authorityEpoch + 1, + boundFragmentHash: `fragment:${digest}`, executionSemanticHash: `semantic:${digest}` } }; + }, + validateReadmission: async (ctx, _run, intent) => { + const current = await authorize(ctx, "readmit"); + if (current.authorizationContextHash !== intent.authorizationContextHash || policyHash !== intent.policyHash) throw new ControlError("TF_AUTHORITY_REVOKED", "current policy no longer admits fragment"); + }, + reserve: async (ctx, run) => (await coordinator.reserve({ projectId: run.projectId, projectControlDomainId: run.controlDomainId, runId: run.runId, ttlMs: 60_000 }, ctx)).reservation, + commitReservation: async (ctx, intent) => { + if (commitFailure) throw new Error("fixture interruption before coordinator commit"); + await coordinator.commit(intent.reservation.reservationId, { projectAdmitCommitSeq: intent.projectAdmitCommitSeq, attemptId: intent.owner.attemptId }, ctx); + }, + normalRelease: async (ctx, intent) => { + await coordinator.normalRelease(intent.reservationId, ctx); + if (releaseFailure) throw new Error("fixture interruption after coordinator release"); + }, + }; + service = new ApprovalService(store, authority); + const pointer = join(directory, "fixture-run-id"); + let runId: string; + if (existsSync(pointer)) runId = readFileSync(pointer, "utf8"); + else { + runId = randomUUID(); + const reservation = (await coordinator.reserve({ ...binding, projectControlDomainId: binding.controlDomainId, runId, ttlMs: 60_000 }, context)).reservation; + const run: ApprovalRun = { ...binding, runId, runVersion: 0, status: "running", stage: "executing", slot: "reserved", needsOperator: false, + boundPlanHash: `plan:${"1".repeat(64)}`, policyHash, authorityEpoch: 1, reservationId: reservation.reservationId, + owner: { runId, phaseId: "review", attemptId: randomUUID(), unitId: randomUUID(), ancestry: [] }, projectAdmitCommitSeq: store.commitSeq + 1 }; + // projectRoot is a context binding, not a RunSnapshot wire field. + delete (run as Partial).projectRoot; + store.createRun(run, []); + await coordinator.commit(reservation.reservationId, { projectAdmitCommitSeq: run.projectAdmitCommitSeq!, attemptId: run.owner!.attemptId }, context); + const current = store.readRun(runId)!; + store.mutateRun(runId, current.runVersion, (state) => ({ ...state, run: { ...state.run, slot: "committed", runVersion: state.run.runVersion + 1 }, events: [] })); + writeFileSync(pointer, runId); + } + const request = (overrides: Partial = {}): ApprovalRequestInput => ({ runId, expectedRunVersion: store.readRun(runId)!.runVersion, + nodeInstanceId: "review", mode: "durable-required", allowedDecisions: ["approve", "reject", "edit"], owner: principal, + deadline: Date.now() + 60_000, timeoutPolicy: "durable-expire", ...overrides }); + const artifact = (guidance = "edited guidance") => { + const bytes = Buffer.from(JSON.stringify({ guidance })), digest = createHash("sha256").update(bytes).digest("hex"); + writeFileSync(join(directory, `artifact-${digest}`), bytes); + return { digest, size: bytes.length, mediaType: "application/json", storageClass: "project", redactionClass: "internal" }; + }; + return { store, coordinator, service, context, secondContext, auth, runId, request, artifact, relinks: () => relinks, + set: (patch: Record) => { + if (typeof patch.revoked === "boolean") revoked = patch.revoked; + if (typeof patch.durable === "boolean") durable = patch.durable; + if (typeof patch.quiescent === "boolean") quiescent = patch.quiescent; + if (typeof patch.releaseFailure === "boolean") releaseFailure = patch.releaseFailure; + if (typeof patch.commitFailure === "boolean") commitFailure = patch.commitFailure; + if (typeof patch.editDelay === "number") editDelay = patch.editDelay; + if (typeof patch.invalidEdit === "boolean") invalidEdit = patch.invalidEdit; + if (typeof patch.reusedOwner === "boolean") reusedOwner = patch.reusedOwner; + if (typeof patch.policyHash === "string") policyHash = patch.policyHash; + }, + close: () => { coordinator.close(); store.close(); } }; +} + +if (process.env.TF_APPROVAL_WORKER_DIR) { + const fixture = await approvalFixture(process.env.TF_APPROVAL_WORKER_DIR); + console.log(JSON.stringify({ ready: true, runId: fixture.runId })); + const lines = createInterface({ input: process.stdin }); + for await (const line of lines) { + let id: unknown; + try { + const input = JSON.parse(line); + id = input.id; + let result: unknown; + if (input.action === "set") { fixture.set(input.patch); result = true; } + else if (input.action === "request") result = await fixture.service.request(fixture.context, fixture.request(input.patch)); + else if (input.action === "decide") result = await fixture.service.decide(input.client === 2 ? fixture.secondContext : fixture.context, input.body as ApprovalDecisionInput); + else if (input.action === "readmit") result = await fixture.service.readmit(fixture.context, input.body); + else if (input.action === "drain") { await fixture.service.drainReleases(fixture.context); result = true; } + else if (input.action === "read") result = await fixture.service.read(fixture.context, input.approvalRequestId); + else if (input.action === "run") result = fixture.store.readRun(fixture.runId); + else throw new Error("unknown fixture action"); + console.log(JSON.stringify({ id: input.id, ok: true, result })); + } catch (error) { console.log(JSON.stringify({ id, ok: false, error: errorToEnvelope(error) })); } + } + fixture.close(); +} diff --git a/packages/taskflow-control/test/fixtures/coordinator-worker.ts b/packages/taskflow-control/test/fixtures/coordinator-worker.ts new file mode 100644 index 00000000..eb627d40 --- /dev/null +++ b/packages/taskflow-control/test/fixtures/coordinator-worker.ts @@ -0,0 +1,45 @@ +/** Process boundary fixture. Authority is issued by this test host, never by + * coordinator wire input. No provider or credential discovery occurs. */ +import { openCoordinatorStore, COORDINATOR_CRASH_ENV, type CoordinatorIdentity } from "../../src/store/coordinator-store.ts"; +import { ControlError } from "../../src/errors.ts"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +const config = JSON.parse(process.argv[2]!) as { + path: string; mode: "reserve" | "commit" | "persist-admission" | "normalRelease" | "forceRelease" | "snapshot"; + max?: number; epoch?: number; identity: CoordinatorIdentity; + input: { reservationId: string; projectId: string; projectControlDomainId: string; runId: string; ttlMs: number; projectAdmitCommitSeq?: number }; + crash?: string; clockOffset?: number; commandId?: string; +}; +if (config.clockOffset) { const realNow = Date.now; Date.now = () => realNow() + config.clockOffset!; } +const context = Object.freeze({ token: Symbol("test-host-issued-context") }); +const epoch = config.epoch ?? 1; +try { + const store = await openCoordinatorStore(config.path, { initialMaxActiveRuns: config.max ?? 8, epoch, holderId: "fixture-host", authority: { + readLease: () => ({ holderId: "fixture-host", fencingEpoch: epoch, endpoint: "test://coordinator", expiresAt: Date.now() + 60_000 }), + authorize: (ctx) => { if (ctx !== context) throw new ControlError("TF_AUTHORITY_REVOKED", "unknown context"); return config.identity; }, + readAdmission: (row) => { + const proofFile = path.join(config.path, "test-project-admission.json"); + if (fs.existsSync(proofFile)) return JSON.parse(fs.readFileSync(proofFile, "utf8")); + return row.projectAdmitCommitSeq === undefined ? { status: "not-admitted" as const, reservationId: row.reservationId, projectId: row.projectId, projectControlDomainId: row.projectControlDomainId, runId: row.runId, runVersion: 0 } + : { ...row, projectAdmitCommitSeq: config.input.projectAdmitCommitSeq ?? 7, runVersion: 1 }; + }, + readRelease: (row) => ({ ...row, projectAdmitCommitSeq: row.projectAdmitCommitSeq!, runVersion: 2, proofId: "fixture-provider-proof", status: "completed", stage: "terminal", requiresReadmission: false, providerNoLiveProcessTree: true, noAmbiguousJobs: true, reconcileTimeoutOnly: false }), + } }); + if (config.mode === "persist-admission") { + const fd = fs.openSync(path.join(config.path, "test-project-admission.json"), "wx", 0o600); + try { fs.writeFileSync(fd, JSON.stringify({ reservationId: config.input.reservationId, projectId: config.input.projectId, projectControlDomainId: config.input.projectControlDomainId, runId: config.input.runId, projectAdmitCommitSeq: 7, runVersion: 1 })); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } + process.kill(process.pid, "SIGKILL"); + } + if (config.crash) process.env[COORDINATOR_CRASH_ENV] = config.crash; + const result = config.mode === "reserve" ? await store.reserve(config.input, context) + : config.mode === "commit" ? await store.commit(config.input.reservationId, { projectAdmitCommitSeq: config.input.projectAdmitCommitSeq ?? 7 }, context) + : config.mode === "normalRelease" ? await store.normalRelease(config.input.reservationId, context) + : config.mode === "forceRelease" ? await store.forceRelease(config.input.reservationId, { commandId: config.commandId!, riskAcknowledgement: true, reason: "operator reviewed unknown provider" }, context) + : await store.snapshot(context); + store.close(); + process.stdout.write(JSON.stringify({ ok: true, result }) + "\n"); +} catch (error) { + process.stdout.write(JSON.stringify({ ok: false, code: error instanceof ControlError ? error.code : "unexpected", message: error instanceof Error ? error.message : String(error) }) + "\n"); + process.exitCode = 2; +} diff --git a/packages/taskflow-core/src/persistent-mutex.ts b/packages/taskflow-core/src/persistent-mutex.ts new file mode 100644 index 00000000..221f5d1d --- /dev/null +++ b/packages/taskflow-core/src/persistent-mutex.ts @@ -0,0 +1,37 @@ +/** Narrow durable-mutex entry point for cooperating local control services. + * Unknown process observations never authorize reclamation of a live lock. */ +import { + PersistentFileMutex as BasePersistentFileMutex, + defaultProcessIdentity, + readProcessBirthToken, + type ObservedProcess, + type PersistentCoordinatorOptions, +} from "./resources/persistence.ts"; + +export { + defaultProcessIdentity, + readProcessBirthToken, + type ObservedProcess, + type PersistentCoordinatorOptions, + type ProcessIdentity, + type ProcessInspector, +} from "./resources/persistence.ts"; + +export function inspectPersistentOwner(pid: number): ObservedProcess { + if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0x7fffffff) return { alive: true }; + if (pid === process.pid) return { alive: true, ...defaultProcessIdentity() }; + try { process.kill(pid, 0); } + catch (error) { + // EPERM and unexpected OS errors mean unknown, not proof of death. + if ((error as NodeJS.ErrnoException).code === "ESRCH") return { alive: false }; + return { alive: true }; + } + const birthToken = readProcessBirthToken(pid); + return birthToken === undefined ? { alive: true } : { alive: true, birthToken, birthTokenKind: "native" }; +} + +export class PersistentFileMutex extends BasePersistentFileMutex { + constructor(lockPath: string, options: Omit = {}) { + super(lockPath, { ...options, inspectProcess: options.inspectProcess ?? inspectPersistentOwner }); + } +} diff --git a/packages/taskflow-core/test/persistent-mutex.test.ts b/packages/taskflow-core/test/persistent-mutex.test.ts new file mode 100644 index 00000000..a955b94d --- /dev/null +++ b/packages/taskflow-core/test/persistent-mutex.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { inspectPersistentOwner } from "../src/persistent-mutex.ts"; + +for (const code of ["ESRCH", "EPERM", "EIO", "EINVAL"] as const) { + test(`durable mutex owner probe only reclaims ESRCH: ${code}`, (t) => { + t.mock.method(process, "kill", (_pid: number, signal: string | number) => { + assert.equal(signal, 0); + throw Object.assign(new Error(code), { code }); + }); + assert.equal(inspectPersistentOwner(0x7fffffff).alive, code !== "ESRCH"); + }); +} + +test("durable mutex owner probe rejects malformed identity without signalling", (t) => { + t.mock.method(process, "kill", () => { throw new Error("must not signal malformed PID"); }); + for (const pid of [-1, 0, 1.5, NaN, Infinity, 0x80000000]) assert.equal(inspectPersistentOwner(pid).alive, true); + assert.equal(inspectPersistentOwner(process.pid).alive, true); +}); From 04e96ad402e110dc326dfdbea2e534b7cbe104e3 Mon Sep 17 00:00:00 2001 From: heggria Date: Fri, 2 Oct 2026 23:46:44 +0800 Subject: [PATCH 18/30] feat(control): add durable evidence and fenced receipt storage --- .../src/store/evidence-store.ts | 405 ++++++++++++++ .../test/evidence-store.test.ts | 510 ++++++++++++++++++ 2 files changed, 915 insertions(+) create mode 100644 packages/taskflow-control/src/store/evidence-store.ts create mode 100644 packages/taskflow-control/test/evidence-store.test.ts diff --git a/packages/taskflow-control/src/store/evidence-store.ts b/packages/taskflow-control/src/store/evidence-store.ts new file mode 100644 index 00000000..f2281779 --- /dev/null +++ b/packages/taskflow-control/src/store/evidence-store.ts @@ -0,0 +1,405 @@ +/** + * Subordinate immutable evidence storage. The project journal remains authority. + * These internal ports MUST be implemented by the authenticated ControlHost and + * its fenced store, never from request body fields. No standalone receipt + * admission, authentication, journal migration, or physical retention is implied. + */ +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { Value } from "typebox/value"; +import { canonicalJson } from "taskflow-core/flowir/hash"; +import { ControlError, reconcileRequired } from "../errors.ts"; +import { + ArtifactRefSchema, ControlEventSchema, ReceiptSchema, CONTROL_WIRE_SCHEMA_VERSION, + type ArtifactRef, type ControlEvent, type Receipt, type ReceiptAssurance, + type BuildInfoWire, +} from "../schema/index.ts"; + +export interface EvidenceChainEntry { + event: ControlEvent; + previousHash: string; + hash: string; +} + +/** Committed projection; the adapter must derive it from its validated ledger. */ +export interface EvidenceRun { + runId: string; + eventIds: readonly string[]; + boundPlanHash?: string; + boundFragmentHash?: string; + artifactRefs: readonly ArtifactRef[]; + assurance: ReceiptAssurance; + buildInfo: BuildInfoWire; + /** Durable TE evidence reference, resolved by the required trusted reader. */ + terminalEvidenceId?: string; +} + +export interface IssuedEvidenceReceipt { + receiptRef: ArtifactRef; + manifestProofRef: ArtifactRef; +} + +/** + * All projections (runs/commands/receipts) must be derived by the owner's + * validated journal reader. Hash checking below detects changed chain bytes; + * it does not turn an untrusted caller-supplied anchor into a trust root. + */ +export interface EvidenceLedgerView { + projectId: string; + controlDomainId: string; + directoryBinding: { canonicalPath: string; device: string; inode: string }; + anchorHash: string; + tipHash: string; + tipCommitSeq: number; + events: readonly EvidenceChainEntry[]; + runs: Readonly>; + commands: Readonly>; + receipts: Readonly>; +} + +export interface EvidenceStoreOptions { + storePath: string; + ledger: { + read(): EvidenceLedgerView | Promise; + /** Hold the SAME writer fence until the returned operation Promise settles. */ + withWriter(operation: (view: EvidenceLedgerView) => Promise): Promise; + /** Atomic journal marker + tip CAS, called INSIDE the existing writer fence. */ + commitReceiptOnce(runId: string, prepared: PreparedEvidenceReceipt): Promise; + }; + /** Resolve an authenticated transport actor and recheck CURRENT policy. */ + authorize(actor: unknown, scope: { + projectId: string; controlDomainId: string; + target: { kind: "run" | "command"; id: string }; + artifactRef?: ArtifactRef; + }): { principal: string; authorizationContextHash: string } | Promise<{ principal: string; authorizationContextHash: string }>; + terminalEvidence: { + /** Must reject live processes/jobs, ambiguous jobs and pending/dirty intents. */ + verify(project: { projectId: string; controlDomainId: string }, runId: string, evidenceId: string): TerminalEvidenceObservation | Promise; + }; + /** Optional fault observer; never supplies authority. */ + onDurabilityPoint?: (point: "file-fsynced" | "file-published") => void; +} + +export interface TerminalEvidenceObservation { + terminalStatus: "completed" | "failed" | "blocked" | "cancelled"; + terminalEventId: string; + evidenceCommit: string; + providerOutcome: "completed" | "failed" | "operator-intervened"; +} + +interface ManifestProof { + version: 1; + projectId: string; + controlDomainId: string; + runId: string; + entries: { eventId: string; commitSeq: number; eventDigest: string }[]; +} + +export interface PreparedEvidenceReceipt extends IssuedEvidenceReceipt { + receipt: Receipt; + expectedTip: { commitSeq: number; hash: string }; +} + +const HEX = /^[0-9a-f]{64}$/; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function fail(message: string): never { + throw new ControlError("TF_DURABILITY_FAILED", message, { recoveryAction: "operator", sideEffects: "unknown" }); +} + +function digest(bytes: Uint8Array | string): string { return crypto.createHash("sha256").update(bytes).digest("hex"); } + +export function evidenceEventHash(previousHash: string, event: ControlEvent): string { + if (!HEX.test(previousHash)) fail("invalid evidence chain predecessor"); + return digest(canonicalJson({ format: "taskflow:control-event:v1", previousHash, event })); +} + +function proofRoot(proof: ManifestProof): string { + return digest(canonicalJson({ format: "taskflow:receipt-manifest:v1", proof })); +} + +function validRef(ref: ArtifactRef): void { + if (!Value.Check(ArtifactRefSchema, ref) || !HEX.test(ref.digest) || !Number.isSafeInteger(ref.size)) fail("invalid artifact reference"); +} + +function sameRef(a: ArtifactRef, b: ArtifactRef): boolean { return canonicalJson(a) === canonicalJson(b); } + +/** Logical compaction only. No method in this class deletes journal or blobs. */ +export class EvidenceStore { + readonly #options: EvidenceStoreOptions; + readonly #root: string; + readonly #identity: { projectId: string; controlDomainId: string; device: string; inode: string }; + #failed = false; + + private constructor(options: EvidenceStoreOptions, initial: EvidenceLedgerView) { + if (!options.ledger || typeof options.ledger.read !== "function" || typeof options.ledger.withWriter !== "function" || typeof options.ledger.commitReceiptOnce !== "function" + || typeof options.authorize !== "function" || typeof options.terminalEvidence?.verify !== "function") fail("evidence store requires trusted ledger, writer, terminal evidence and live authorization ports"); + this.#options = options; + const supplied = path.resolve(options.storePath); + if (fs.lstatSync(supplied).isSymbolicLink()) fail("evidence root is a symlink"); + this.#root = fs.realpathSync(supplied); + this.#identity = { projectId: initial.projectId, controlDomainId: initial.controlDomainId, + device: initial.directoryBinding.device, inode: initial.directoryBinding.inode }; + this.#validateView(initial); + } + + static async open(options: EvidenceStoreOptions): Promise { + if (!options.ledger || typeof options.ledger.read !== "function") fail("evidence store requires trusted ledger"); + return new EvidenceStore(options, await options.ledger.read()); + } + + #validateView(view: EvidenceLedgerView): void { + if (this.#failed) fail("evidence store is fail-closed; reopen after recovery"); + const st = fs.lstatSync(this.#root); + if (!st.isDirectory() || st.isSymbolicLink() || fs.realpathSync(this.#root) !== this.#root + || view.projectId !== this.#identity.projectId || view.controlDomainId !== this.#identity.controlDomainId + || String(st.dev) !== this.#identity.device || String(st.ino) !== this.#identity.inode + || view.directoryBinding.canonicalPath !== this.#root + || view.directoryBinding.device !== String(st.dev) || view.directoryBinding.inode !== String(st.ino)) fail("evidence store directory identity changed"); + if (!UUID.test(view.projectId) || !UUID.test(view.controlDomainId) || !HEX.test(view.anchorHash) + || !HEX.test(view.tipHash) || !Number.isSafeInteger(view.tipCommitSeq) || view.tipCommitSeq < 0) fail("invalid evidence ledger identity or tip"); + let previous = view.anchorHash; + let sequence = 0; + const ids = new Set(); + for (const entry of view.events) { + const event = entry.event; + if (!Value.Check(ControlEventSchema, event) || !Number.isSafeInteger(event.commitSeq) + || event.projectId !== view.projectId || event.controlDomainId !== view.controlDomainId + || event.commitSeq < sequence || event.commitSeq > sequence + 1 || event.commitSeq < 1 + || ids.has(event.eventId) || entry.previousHash !== previous + || entry.hash !== evidenceEventHash(previous, event)) fail("invalid evidence ledger chain"); + ids.add(event.eventId); + previous = entry.hash; + sequence = event.commitSeq; + } + if (previous !== view.tipHash || sequence !== view.tipCommitSeq) fail("evidence ledger tip does not match complete retained chain"); + this.#floor(view); + } + + async #write(operation: (view: EvidenceLedgerView) => T | Promise): Promise { + return this.#options.ledger.withWriter(async (view) => { + this.#validateView(view); + try { return await operation(view); } catch (error) { + if (!(error instanceof ControlError) || error.code === "TF_DURABILITY_FAILED") this.#failed = true; + throw error; + } + }); + } + + #path(ref: ArtifactRef, create: boolean): string { + validRef(ref); + let current = this.#root; + for (const part of ["artifacts", "sha256", ref.digest.slice(0, 2)]) { + current = path.join(current, part); + if (create && !fs.existsSync(current)) { + fs.mkdirSync(current, { mode: 0o700 }); + const parentFd = fs.openSync(path.dirname(current), "r"); + try { fs.fsyncSync(parentFd); } finally { fs.closeSync(parentFd); } + } + const st = fs.lstatSync(current); + if (!st.isDirectory() || st.isSymbolicLink()) fail("artifact directory is not a confined regular directory"); + } + const file = path.join(current, ref.digest); + try { if (!fs.lstatSync(file).isFile()) fail("artifact destination is not a regular file"); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + return file; + } + + #read(ref: ArtifactRef): Buffer { + const file = this.#path(ref, false); + const bytes = fs.readFileSync(file); + if (bytes.length !== ref.size || digest(bytes) !== ref.digest) fail("artifact bytes do not match committed reference"); + return bytes; + } + + #stage(bytes: Uint8Array, metadata: Omit): ArtifactRef { + if (metadata.redactionClass === "secret") throw new ControlError("TF_POLICY_DENIED", "secret material must use SecretRef, not the generic artifact store"); + const ref: ArtifactRef = { ...metadata, size: bytes.length, digest: digest(bytes) }; + const file = this.#path(ref, true); + if (fs.existsSync(file)) { this.#read(ref); return ref; } + const temp = path.join(path.dirname(file), `.${crypto.randomUUID()}`); + const fd = fs.openSync(temp, "wx", 0o600); + try { + try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } + this.#options.onDurabilityPoint?.("file-fsynced"); + try { fs.linkSync(temp, file); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + this.#read(ref); + } + const directoryFd = fs.openSync(path.dirname(file), "r"); + try { fs.fsyncSync(directoryFd); } finally { fs.closeSync(directoryFd); } + this.#options.onDurabilityPoint?.("file-published"); + } finally { fs.unlinkSync(temp); } + return ref; + } + + async stageArtifact(bytes: Uint8Array, metadata: Omit): Promise { + return this.#write(() => this.#stage(bytes, metadata)); + } + + /** Owner must call under its commit fence before acknowledging acceptance. */ + async assertDurableReferences(refs: readonly ArtifactRef[]): Promise { + await this.#write(() => { for (const ref of refs) this.#read(ref); }); + } + + #json(value: unknown, mediaType: string): ArtifactRef { + return this.#stage(Buffer.from(canonicalJson(value)), { mediaType, storageClass: "local", redactionClass: "internal" }); + } + + #run(view: EvidenceLedgerView, runId: string): EvidenceRun { + if (!UUID.test(runId) || !Object.hasOwn(view.runs, runId)) fail("run has no committed evidence"); + const run = view.runs[runId]; + if (run.runId !== runId || (!run.boundPlanHash && !run.boundFragmentHash)) fail("run evidence identity or bound plan missing"); + return run; + } + + #proof(view: EvidenceLedgerView, run: EvidenceRun): ManifestProof { + const selected = new Set(run.eventIds); + const events = view.events.filter(({ event }) => selected.has(event.eventId)); + if (selected.size !== run.eventIds.length || events.length !== selected.size || events.length === 0 + || events.some(({ event }, index) => event.eventId !== run.eventIds[index])) fail("run event manifest is incomplete, duplicated or out of order"); + return { version: 1, projectId: view.projectId, controlDomainId: view.controlDomainId, runId: run.runId, + entries: events.map(({ event }) => ({ eventId: event.eventId, commitSeq: event.commitSeq, + eventDigest: digest(canonicalJson(event)) })) }; + } + + /** Internal preparation only; its blobs remain unreachable until committed. */ + async prepareFinalReceipt(runId: string): Promise { + return this.#write((view) => this.#prepare(view, runId)); + } + + /** Trusted control service entry. Holds the owner fence through commit-once. */ + async issueFinalReceipt(runId: string): Promise { + return this.#write(async (view) => { + const prepared = await this.#prepare(view, runId); + if (!Object.hasOwn(view.receipts, runId)) { + await this.#options.ledger.commitReceiptOnce(runId, prepared); + } + const committed = await this.#options.ledger.read(); + this.#validateView(committed); + if (!Object.hasOwn(committed.receipts, runId)) fail("receipt journal commit did not establish reachability"); + const issued = committed.receipts[runId]; + if (!sameRef(issued.receiptRef, prepared.receiptRef) || !sameRef(issued.manifestProofRef, prepared.manifestProofRef)) fail("receipt commit returned a different immutable outcome"); + return this.#verifyReceipt(committed, runId, issued); + }); + } + + async #prepare(view: EvidenceLedgerView, runId: string): Promise { + const expectedTip = { commitSeq: view.tipCommitSeq, hash: view.tipHash }; + if (Object.hasOwn(view.receipts, runId)) { + const existing = view.receipts[runId]; + return { ...existing, receipt: this.#verifyReceipt(view, runId, existing), expectedTip }; + } + const run = this.#run(view, runId); + const proof = this.#proof(view, run); + const last = view.events.find(({ event }) => event.eventId === run.eventIds.at(-1))!.event; + if (last.payload.kind !== "run.terminal" || !run.terminalEvidenceId) throw reconcileRequired("final Receipt requires committed terminal and complete provider quiescence evidence"); + const termination = await this.#options.terminalEvidence.verify(view, runId, run.terminalEvidenceId); + this.#validateView(view); + if (termination.terminalStatus !== last.payload.status || termination.terminalEventId !== last.eventId || !termination.evidenceCommit + || !["completed", "failed", "operator-intervened"].includes(termination.providerOutcome)) throw reconcileRequired("terminal evidence is incomplete or ambiguous"); + for (const ref of run.artifactRefs) this.#read(ref); + const receipt: Receipt = { + schemaVersion: CONTROL_WIRE_SCHEMA_VERSION, controlDomainId: view.controlDomainId, runId, + ...(run.boundPlanHash ? { boundPlanHash: run.boundPlanHash } : {}), + ...(run.boundFragmentHash ? { boundFragmentHash: run.boundFragmentHash } : {}), + eventManifest: [...run.eventIds], manifestRoot: proofRoot(proof), + startCommitSeq: proof.entries[0].commitSeq, endCommitSeq: proof.entries.at(-1)!.commitSeq, + artifactRefs: [...run.artifactRefs], assurance: { ...run.assurance, providerOutcome: termination.providerOutcome, journalContinuity: true, artifactIntegrity: "verified" }, + buildInfo: run.buildInfo, + }; + if (!Value.Check(ReceiptSchema, receipt)) fail("derived Receipt failed schema validation"); + const manifestProofRef = this.#json(proof, "application/vnd.taskflow.manifest+json"); + const receiptRef = this.#json(receipt, "application/vnd.taskflow.receipt+json"); + return { receipt, receiptRef, manifestProofRef, expectedTip }; + } + + async #authorize(actor: unknown, target: { kind: "run" | "command"; id: string }, artifactRef?: ArtifactRef): Promise { + const view = await this.#options.ledger.read(); + this.#validateView(view); + const decision = await this.#options.authorize(actor, { projectId: view.projectId, controlDomainId: view.controlDomainId, target, ...(artifactRef ? { artifactRef } : {}) }); + if (!decision || typeof decision.principal !== "string" || decision.principal.length === 0 + || !HEX.test(decision.authorizationContextHash)) throw new ControlError("TF_POLICY_DENIED", "evidence authorization decision unavailable"); + // Reachability is read afresh AFTER asynchronous policy evaluation, never + // cached from a previous request or inferred from the policy audit hash. + const current = await this.#options.ledger.read(); + this.#validateView(current); + return current; + } + + async readArtifact(actor: unknown, target: { kind: "run" | "command"; id: string }, ref: ArtifactRef): Promise { + const view = await this.#authorize(actor, target, ref); + validRef(ref); + const refs = target.kind === "run" + ? (Object.hasOwn(view.runs, target.id) ? view.runs[target.id].artifactRefs : []) + : (Object.hasOwn(view.commands, target.id) && view.commands[target.id].responseArtifactRef ? [view.commands[target.id].responseArtifactRef!] : []); + if (!refs.some((candidate) => sameRef(candidate, ref))) throw new ControlError("TF_POLICY_DENIED", "artifact has no authorized ledger reference"); + return this.#read(ref); + } + + #verifyReceipt(view: EvidenceLedgerView, runId: string, issued: IssuedEvidenceReceipt): Receipt { + let receipt: Receipt; + let proof: ManifestProof; + try { + receipt = JSON.parse(this.#read(issued.receiptRef).toString("utf8")); + proof = JSON.parse(this.#read(issued.manifestProofRef).toString("utf8")); + } catch { return fail("committed Receipt or manifest proof is missing or damaged"); } + const run = this.#run(view, runId); + const expected = this.#proof(view, run); + if (!Value.Check(ReceiptSchema, receipt) || receipt.runId !== runId || receipt.controlDomainId !== view.controlDomainId + || receipt.boundPlanHash !== run.boundPlanHash || receipt.boundFragmentHash !== run.boundFragmentHash + || canonicalJson(receipt.artifactRefs) !== canonicalJson(run.artifactRefs) + || canonicalJson(receipt.buildInfo) !== canonicalJson(run.buildInfo) + || canonicalJson(proof) !== canonicalJson(expected) || receipt.manifestRoot !== proofRoot(expected) + || canonicalJson(receipt.eventManifest) !== canonicalJson(expected.entries.map((entry) => entry.eventId)) + || receipt.startCommitSeq !== expected.entries[0].commitSeq || receipt.endCommitSeq !== expected.entries.at(-1)!.commitSeq) fail("Receipt manifest does not match committed run evidence"); + return receipt; + } + + async readReceipt(actor: unknown, runId: string): Promise<{ receipt: Receipt; verification: { artifactIntegrity: "verified" | "unknown" } }> { + const view = await this.#authorize(actor, { kind: "run", id: runId }); + if (!Object.hasOwn(view.receipts, runId)) throw new ControlError("TF_POLICY_DENIED", "run has no issued Receipt"); + const receipt = this.#verifyReceipt(view, runId, view.receipts[runId]); + let artifactIntegrity: "verified" | "unknown" = "verified"; + for (const ref of receipt.artifactRefs) { + try { this.#read(ref); } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") artifactIntegrity = "unknown"; + else throw error; + } + } + return { receipt, verification: { artifactIntegrity } }; + } + + #floor(view: EvidenceLedgerView): number { + let through = 0; + for (const { event } of view.events) if (event.payload.kind === "compaction.checkpoint") { + if (!Number.isSafeInteger(event.payload.throughCommitSeq) || event.payload.throughCommitSeq < through + || event.payload.throughCommitSeq >= event.commitSeq) fail("invalid committed compaction checkpoint"); + through = event.payload.throughCommitSeq; + } + return through + 1; + } + + async prepareCheckpoint(throughCommitSeq: number): Promise<{ eventPayload: { kind: "compaction.checkpoint"; throughCommitSeq: number }; expectedTip: { commitSeq: number; hash: string } }> { + return this.#write((view) => { + if (!Number.isSafeInteger(throughCommitSeq) || throughCommitSeq < this.#floor(view) || throughCommitSeq > view.tipCommitSeq) fail("invalid logical compaction boundary"); + // Full chain remains on disk. Validate issued evidence before advancing + // visibility; this method never commits a checkpoint or deletes a byte. + for (const [runId, issued] of Object.entries(view.receipts)) this.#verifyReceipt(view, runId, issued); + return { eventPayload: { kind: "compaction.checkpoint", throughCommitSeq }, expectedTip: { commitSeq: view.tipCommitSeq, hash: view.tipHash } }; + }); + } + + async checkCursor(cursor: { nextCommitSeq: number; leaseExpiresAt: number }, now: number): Promise<{ minAvailableCommitSeq: number }> { + const view = await this.#options.ledger.read(); + this.#validateView(view); + const minAvailableCommitSeq = this.#floor(view); + if (!Number.isSafeInteger(now) || !Number.isSafeInteger(cursor.nextCommitSeq) || !Number.isSafeInteger(cursor.leaseExpiresAt) + || cursor.nextCommitSeq < minAvailableCommitSeq || cursor.leaseExpiresAt <= now) throw new ControlError("TF_CURSOR_EXPIRED", "cursor requires checkpoint resync", { recoveryAction: "refresh" }); + return { minAvailableCommitSeq }; + } +} + +export async function createEvidenceStore(options: EvidenceStoreOptions): Promise { return EvidenceStore.open(options); } diff --git a/packages/taskflow-control/test/evidence-store.test.ts b/packages/taskflow-control/test/evidence-store.test.ts new file mode 100644 index 00000000..f2e9609a --- /dev/null +++ b/packages/taskflow-control/test/evidence-store.test.ts @@ -0,0 +1,510 @@ +/** Real disk tests of the subordinate evidence mechanism, not host/auth integration. */ +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { spawnSync } from "node:child_process"; +import { canonicalJson } from "taskflow-core/flowir/hash"; +import { createHash } from "node:crypto"; +import { test, type TestContext } from "node:test"; +import { ControlError, reconcileRequired } from "../src/errors.ts"; +import { CONTROL_WIRE_SCHEMA_VERSION, type ArtifactRef, type ControlEvent } from "../src/schema/index.ts"; +import { createEvidenceStore, evidenceEventHash, type EvidenceLedgerView, + type EvidenceStoreOptions, type PreparedEvidenceReceipt } from "../src/store/evidence-store.ts"; + +const PROJECT = "10000000-0000-0000-0000-000000000001"; +const DOMAIN = "10000000-0000-0000-0000-000000000002"; +const RUN = "10000000-0000-0000-0000-000000000003"; +const COMMAND = "10000000-0000-0000-0000-000000000004"; +const META = { mediaType: "text/plain", storageClass: "local", redactionClass: "internal" }; +const actor = Object.freeze({ authenticatedTestActor: Symbol("identity") }); + +function isCode(code: string): (error: unknown) => boolean { + return (error) => error instanceof ControlError && error.code === code; +} + +function fixture(t: TestContext) { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "tf-evidence-"))); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const disk = path.join(root, "test-authoritative-ledger.json"); + const terminalFile = path.join(root, "test-terminal-evidence.json"); + const st = fs.statSync(root); + let view: EvidenceLedgerView = { projectId: PROJECT, controlDomainId: DOMAIN, + directoryBinding: { canonicalPath: root, device: String(st.dev), inode: String(st.ino) }, + anchorHash: "0".repeat(64), tipHash: "0".repeat(64), tipCommitSeq: 0, + events: [], runs: {}, commands: {}, receipts: {} }; + const save = () => fs.writeFileSync(disk, JSON.stringify(view)); + const read = (): EvidenceLedgerView => JSON.parse(fs.readFileSync(disk, "utf8")); + save(); + let revoked = false; + let writesAllowed = true; + const options: EvidenceStoreOptions = { + storePath: root, + ledger: { read, commitReceiptOnce: async (runId, prepared) => { assert.equal(runId, RUN); issue(prepared); }, async withWriter(operation) { + if (!writesAllowed) throw new ControlError("TF_STALE_VERSION", "test writer fenced"); + return operation(read()); + } }, + authorize(candidate, scope) { + if (candidate !== actor || scope.projectId !== PROJECT || scope.controlDomainId !== DOMAIN) throw new ControlError("TF_POLICY_DENIED", "denied"); + if (revoked) throw new ControlError("TF_AUTHORITY_REVOKED", "revoked"); + return { principal: "test-verified-principal", authorizationContextHash: "a".repeat(64) }; + }, + terminalEvidence: { verify(project, runId, evidenceId) { + const evidence = JSON.parse(fs.readFileSync(terminalFile, "utf8")) as { + mode: string; terminalEventId: string; evidenceCommit: string; + }; + if (project.projectId !== PROJECT || project.controlDomainId !== DOMAIN || runId !== RUN + || evidenceId !== "test-durable-terminal" || evidence.mode !== "quiescent") throw reconcileRequired("test provider is still live/ambiguous/dirty"); + return { terminalStatus: "completed", terminalEventId: evidence.terminalEventId, evidenceCommit: evidence.evidenceCommit, providerOutcome: "completed" }; + } }, + }; + const append = (payload: ControlEvent["payload"], streamId = `run:${RUN}`): ControlEvent => { + const seq = view.tipCommitSeq + 1; + const event: ControlEvent = { + eventId: `20000000-0000-0000-0000-${String(seq).padStart(12, "0")}`, + schemaVersion: CONTROL_WIRE_SCHEMA_VERSION, projectId: PROJECT, controlDomainId: DOMAIN, + streamId, streamSeq: seq, commitSeq: seq, causationId: COMMAND, correlationId: RUN, + recordedAt: seq, payload, + }; + const entry = { event, previousHash: view.tipHash, hash: evidenceEventHash(view.tipHash, event) }; + view = { ...view, tipHash: entry.hash, tipCommitSeq: seq, events: [...view.events, entry] }; + save(); + return event; + }; + const terminal = (refs: ArtifactRef[] = []) => { + const event = append({ kind: "run.terminal", status: "completed" }); + view = { ...view, runs: { [RUN]: { + runId: RUN, eventIds: [event.eventId], boundPlanHash: "ir:approved-plan", artifactRefs: refs, + terminalEvidenceId: "test-durable-terminal", + assurance: { journalContinuity: true, providerOutcome: "completed", artifactIntegrity: "verified", + provenance: { confidentiality: "internal", integrity: "project" }, + enforcement: { capabilities: { resolution: "contained", mutationMediation: "brokered", + processIsolation: "none", revocation: "admission-only", baselinePolicyId: "test", hostProbeSha256: "a".repeat(64) } } }, + buildInfo: { packageVersion: "test", gitCommit: "5dec238", schemaVersion: 1 }, + } } }; + fs.writeFileSync(terminalFile, JSON.stringify({ mode: "quiescent", terminalEventId: event.eventId, evidenceCommit: view.tipHash })); + save(); + }; + const issue = (prepared: PreparedEvidenceReceipt) => { + assert.deepEqual(prepared.expectedTip, { commitSeq: view.tipCommitSeq, hash: view.tipHash }); + // Stand-in for owner's atomic issuance marker; test file is not production protocol. + view = { ...view, receipts: { ...view.receipts, [RUN]: { + receiptRef: prepared.receiptRef, manifestProofRef: prepared.manifestProofRef, + } } }; + save(); + }; + return { root, disk, terminalFile, options, append, terminal, issue, read, + open: () => createEvidenceStore(options), + edit: (change: (view: EvidenceLedgerView) => void) => { view = read(); change(view); save(); }, + revoke: () => { revoked = true; }, fence: () => { writesAllowed = false; } }; +} + +function blob(root: string, ref: ArtifactRef): string { return path.join(root, "artifacts/sha256", ref.digest.slice(0, 2), ref.digest); } +function snapshot(root: string): Record { + const result: Record = {}; + for (const item of fs.readdirSync(root, { recursive: true, withFileTypes: true })) { + const file = path.join(item.parentPath, item.name); + if (item.isFile()) result[path.relative(root, file)] = fs.readFileSync(file).toString("base64"); + else if (item.isSymbolicLink()) result[path.relative(root, file)] = `link:${fs.readlinkSync(file)}`; + } + return result; +} + +test("evidence artifacts: authorized run/command reads survive reopen; digest or staged orphan alone does not authorize", async (t) => { + const f = fixture(t); + const store = (await f.open()); + const ref = (await store.stageArtifact(Buffer.from("actual output"), META)); + await assert.rejects(async () => (await store.readArtifact(actor, { kind: "run", id: RUN }, ref)), isCode("TF_POLICY_DENIED")); + f.terminal([ref]); + f.edit((view) => { view.commands = { [COMMAND]: { responseArtifactRef: ref } }; }); + assert.equal((await (await f.open()).readArtifact(actor, { kind: "run", id: RUN }, ref)).toString(), "actual output"); + assert.equal((await (await f.open()).readArtifact(actor, { kind: "command", id: COMMAND }, ref)).toString(), "actual output"); + await assert.rejects(async () => (await store.readArtifact(actor, { kind: "run", id: RUN }, { ...ref, redactionClass: "public" })), isCode("TF_POLICY_DENIED")); +}); + +test("evidence authorization: forged actor, revoked permission, missing policy and another project deny disclosure", async (t) => { + const f = fixture(t); + const ref = (await (await f.open()).stageArtifact(Buffer.from("secret response"), META)); + f.terminal([ref]); + await assert.rejects(async () => (await (await f.open()).readArtifact({ callerPrincipal: "cli", authorizationContextHash: "forged" }, { kind: "run", id: RUN }, ref)), isCode("TF_POLICY_DENIED")); + f.revoke(); + await assert.rejects(async () => (await (await f.open()).readArtifact(actor, { kind: "run", id: RUN }, ref)), isCode("TF_AUTHORITY_REVOKED")); + await assert.rejects(async () => await createEvidenceStore({ ...f.options, authorize: undefined } as unknown as EvidenceStoreOptions), isCode("TF_DURABILITY_FAILED")); + const other = fixture(t); + other.edit((view) => { view.projectId = "30000000-0000-0000-0000-000000000001"; }); + await assert.rejects(async () => (await (await other.open()).readArtifact(actor, { kind: "run", id: RUN }, ref)), isCode("TF_POLICY_DENIED")); +}); + +test("evidence writes: corrupt existing digest is never overwritten and stale writer cannot write", async (t) => { + const f = fixture(t); + const store = (await f.open()); + const ref = (await store.stageArtifact(Buffer.from("correct"), META)); + fs.writeFileSync(blob(f.root, ref), "corrupt"); + const before = snapshot(f.root); + await assert.rejects(async () => (await store.stageArtifact(Buffer.from("correct"), META)), isCode("TF_DURABILITY_FAILED")); + assert.deepEqual(snapshot(f.root), before); + await assert.rejects(async () => (await store.stageArtifact(Buffer.from("other"), META)), /fail-closed/); + f.fence(); + await assert.rejects(async () => (await (await f.open()).stageArtifact(Buffer.from("unfenced"), META)), isCode("TF_STALE_VERSION")); +}); + +test("evidence paths: secret labels, invalid refs, symlink destinations and directories are rejected", async (t) => { + const f = fixture(t); + await assert.rejects(async () => (await (await f.open()).stageArtifact(Buffer.from("secret"), { ...META, redactionClass: "secret" })), isCode("TF_POLICY_DENIED")); + const ref = (await (await f.open()).stageArtifact(Buffer.from("untouched"), META)); + const target = path.join(f.root, "decoy"); + fs.writeFileSync(target, "untouched"); + fs.unlinkSync(blob(f.root, ref)); + fs.symlinkSync(target, blob(f.root, ref)); + await assert.rejects(async () => (await (await f.open()).stageArtifact(Buffer.from("untouched"), META)), isCode("TF_DURABILITY_FAILED")); + assert.equal(fs.readFileSync(target, "utf8"), "untouched"); + await assert.rejects(async () => (await (await f.open()).assertDurableReferences([{ ...ref, digest: "../../decoy" }])), isCode("TF_DURABILITY_FAILED")); + const other = fixture(t); + fs.symlinkSync(f.root, path.join(other.root, "artifacts")); + await assert.rejects(async () => (await (await other.open()).stageArtifact(Buffer.from("escaped"), META)), isCode("TF_DURABILITY_FAILED")); +}); + +test("evidence references: missing and wrong-size artifacts cannot be accepted", async (t) => { + const f = fixture(t); + const ref = (await (await f.open()).stageArtifact(Buffer.from("output"), META)); + await assert.rejects(async () => (await (await f.open()).assertDurableReferences([{ ...ref, size: ref.size + 1 }])), isCode("TF_DURABILITY_FAILED")); + fs.unlinkSync(blob(f.root, ref)); + await assert.rejects(async () => (await (await f.open()).assertDurableReferences([ref]))); +}); + +test("evidence Receipt: prepared is unreachable until issuance; exact immutable bytes and output survive restart/retry", async (t) => { + const f = fixture(t); + const store = (await f.open()); + const ref = (await store.stageArtifact(Buffer.from("final output"), META)); + f.terminal([ref]); + const prepared = (await store.prepareFinalReceipt(RUN)); + await assert.rejects(async () => (await store.readReceipt(actor, RUN)), isCode("TF_POLICY_DENIED")); + f.issue(prepared); + const before = snapshot(f.root); + const restarted = (await f.open()); + assert.deepEqual((await restarted.readReceipt(actor, RUN)), { receipt: prepared.receipt, verification: { artifactIntegrity: "verified" } }); + assert.deepEqual((await restarted.prepareFinalReceipt(RUN)).receipt, prepared.receipt); + assert.deepEqual(snapshot(f.root), before); + f.revoke(); + await assert.rejects(async () => (await restarted.readReceipt(actor, RUN)), isCode("TF_AUTHORITY_REVOKED")); +}); + +for (const mode of ["live", "ambiguous", "dirty-intent", "reconcile-exhausted", "force-released"]) { + test(`evidence Receipt: ${mode} trusted terminal observation cannot mint a final Receipt`, async (t) => { + const f = fixture(t); + f.terminal(); + const evidence = JSON.parse(fs.readFileSync(f.terminalFile, "utf8")); + fs.writeFileSync(f.terminalFile, JSON.stringify({ ...evidence, mode })); + const before = snapshot(f.root); + await assert.rejects(async () => (await (await f.open()).prepareFinalReceipt(RUN)), isCode("TF_RECONCILE_REQUIRED")); + assert.deepEqual(snapshot(f.root), before); + }); +} + +test("evidence Receipt: unknown/paused run or absent durable terminal reference cannot mint", async (t) => { + const f = fixture(t); + f.terminal(); + f.edit((view) => { delete view.runs[RUN].terminalEvidenceId; }); + await assert.rejects(async () => (await (await f.open()).prepareFinalReceipt(RUN)), isCode("TF_RECONCILE_REQUIRED")); + f.terminal(); + const last = f.append({ kind: "reconcile.settled", outcome: "exhausted" }); + f.edit((view) => { view.runs[RUN].eventIds = [...view.runs[RUN].eventIds, last.eventId]; }); + await assert.rejects(async () => (await (await f.open()).prepareFinalReceipt(RUN)), isCode("TF_RECONCILE_REQUIRED")); +}); + +test("evidence Receipt: mismatched durable terminal status cannot mint", async (t) => { + const f = fixture(t); + f.terminal(); + const store = await createEvidenceStore({ ...f.options, terminalEvidence: { + verify: async (...args) => ({ ...await f.options.terminalEvidence.verify(...args), terminalStatus: "failed" }), + } }); + const before = snapshot(f.root); + await assert.rejects(async () => (await store.prepareFinalReceipt(RUN)), isCode("TF_RECONCILE_REQUIRED")); + assert.deepEqual(snapshot(f.root), before); +}); + +test("evidence authorization: unavailable live policy cannot reveal artifact bytes", async (t) => { + const f = fixture(t); + const ref = (await (await f.open()).stageArtifact(Buffer.from("withheld output"), META)); + f.terminal([ref]); + const store = await createEvidenceStore({ ...f.options, authorize: () => { throw new ControlError("TF_POLICY_DENIED", "policy unavailable"); } }); + await assert.rejects(async () => (await store.readArtifact(actor, { kind: "run", id: RUN }, ref)), isCode("TF_POLICY_DENIED")); +}); + +test("evidence Receipt: tampered output is a failure rather than retention-unknown", async (t) => { + const f = fixture(t); + const ref = (await (await f.open()).stageArtifact(Buffer.from("checked output"), META)); + f.terminal([ref]); + f.issue((await (await f.open()).prepareFinalReceipt(RUN))); + fs.writeFileSync(blob(f.root, ref), "corrupt output"); + const before = snapshot(f.root); + await assert.rejects(async () => (await (await f.open()).readReceipt(actor, RUN)), isCode("TF_DURABILITY_FAILED")); + assert.deepEqual(snapshot(f.root), before); +}); + +test("evidence Receipt: missing retained artifact reports current unknown while issued bytes stay unchanged", async (t) => { + const f = fixture(t); + const store = (await f.open()); + const ref = (await store.stageArtifact(Buffer.from("retained later"), META)); + f.terminal([ref]); + const prepared = (await store.prepareFinalReceipt(RUN)); + f.issue(prepared); + const issuedBytes = fs.readFileSync(blob(f.root, prepared.receiptRef)); + fs.unlinkSync(blob(f.root, ref)); + const result = (await (await f.open()).readReceipt(actor, RUN)); + assert.equal(result.verification.artifactIntegrity, "unknown"); + assert.equal(result.receipt.assurance.artifactIntegrity, "verified"); + assert.deepEqual(fs.readFileSync(blob(f.root, prepared.receiptRef)), issuedBytes); +}); + +for (const which of ["receiptRef", "manifestProofRef"] as const) { + test(`evidence corruption: damaged ${which} is rejected on restart and never overwritten`, async (t) => { + const f = fixture(t); + f.terminal(); + const prepared = (await (await f.open()).prepareFinalReceipt(RUN)); + f.issue(prepared); + fs.writeFileSync(blob(f.root, prepared[which]), '{"torn":'); + const before = snapshot(f.root); + await assert.rejects(async () => (await (await f.open()).readReceipt(actor, RUN)), isCode("TF_DURABILITY_FAILED")); + await assert.rejects(async () => (await (await f.open()).prepareFinalReceipt(RUN)), isCode("TF_DURABILITY_FAILED")); + assert.deepEqual(snapshot(f.root), before); + }); +} + +test("evidence manifest: full event payload, domain, run membership and order are bound", async (t) => { + const f = fixture(t); + f.terminal(); + const prepared = (await (await f.open()).prepareFinalReceipt(RUN)); + f.issue(prepared); + f.edit((view) => { view.events[0].event.payload = { kind: "run.terminal", status: "failed" }; }); + const before = snapshot(f.root); + await assert.rejects(async () => (await f.open()), isCode("TF_DURABILITY_FAILED")); + assert.deepEqual(snapshot(f.root), before); + // Even if the trusted fixture re-hashes the changed ledger, issue-time root + // cannot change with it. Whole-root rollback itself remains out of scope. + f.edit((view) => { + view.events[0].hash = evidenceEventHash(view.anchorHash, view.events[0].event); + view.tipHash = view.events[0].hash; + }); + await assert.rejects(async () => (await (await f.open()).readReceipt(actor, RUN)), isCode("TF_DURABILITY_FAILED")); +}); + +test("evidence manifest: reordered or duplicate event roster is rejected", async (t) => { + const f = fixture(t); + const first = f.append({ kind: "dispatch.acknowledged", providerJobHandle: "test-job" }); + f.terminal(); + f.edit((view) => { view.runs[RUN].eventIds = [...view.runs[RUN].eventIds, first.eventId]; }); + await assert.rejects(async () => (await (await f.open()).prepareFinalReceipt(RUN)), isCode("TF_DURABILITY_FAILED")); + f.edit((view) => { view.runs[RUN].eventIds = [first.eventId, first.eventId]; }); + await assert.rejects(async () => (await (await f.open()).prepareFinalReceipt(RUN)), isCode("TF_DURABILITY_FAILED")); +}); + +test("evidence logical compaction: checkpoint floor survives restart and preserves Receipt/journal/artifact bytes", async (t) => { + const f = fixture(t); + const store = (await f.open()); + const ref = (await store.stageArtifact(Buffer.from("reachable after checkpoint"), META)); + f.terminal([ref]); + const prepared = (await store.prepareFinalReceipt(RUN)); + f.issue(prepared); + const receiptBytes = fs.readFileSync(blob(f.root, prepared.receiptRef)); + const chainBefore = f.read().events; + const checkpoint = (await store.prepareCheckpoint(1)); + assert.deepEqual((await store.checkCursor({ nextCommitSeq: 1, leaseExpiresAt: 100 }, 1)), { minAvailableCommitSeq: 1 }); + f.append(checkpoint.eventPayload, "project:compaction"); + const reopened = (await f.open()); + await assert.rejects(async () => (await reopened.checkCursor({ nextCommitSeq: 1, leaseExpiresAt: 100 }, 1)), isCode("TF_CURSOR_EXPIRED")); + assert.deepEqual((await reopened.checkCursor({ nextCommitSeq: 2, leaseExpiresAt: 100 }, 1)), { minAvailableCommitSeq: 2 }); + await assert.rejects(async () => (await reopened.checkCursor({ nextCommitSeq: 2, leaseExpiresAt: 1 }, 1)), isCode("TF_CURSOR_EXPIRED")); + assert.deepEqual(f.read().events.slice(0, chainBefore.length), chainBefore); + assert.deepEqual(fs.readFileSync(blob(f.root, prepared.receiptRef)), receiptBytes); + assert.deepEqual((await reopened.readReceipt(actor, RUN)).receipt, prepared.receipt); + assert.equal((await reopened.readArtifact(actor, { kind: "run", id: RUN }, ref)).toString(), "reachable after checkpoint"); + fs.writeFileSync(path.join(f.root, "compaction.json"), '{"throughCommitSeq":9000}'); + assert.equal((await (await f.open()).checkCursor({ nextCommitSeq: 2, leaseExpiresAt: 100 }, 1)).minAvailableCommitSeq, 2); +}); + +test("evidence checkpoints: future/backward/unverified chain does not move floor", async (t) => { + const f = fixture(t); + f.terminal(); + await assert.rejects(async () => (await (await f.open()).prepareCheckpoint(2)), isCode("TF_DURABILITY_FAILED")); + f.append({ kind: "compaction.checkpoint", throughCommitSeq: 1 }); + await assert.rejects(async () => (await (await f.open()).prepareCheckpoint(1)), isCode("TF_DURABILITY_FAILED")); + f.append({ kind: "compaction.checkpoint", throughCommitSeq: 100 }); + await assert.rejects(async () => (await (await f.open()).checkCursor({ nextCommitSeq: 101, leaseExpiresAt: 100 }, 1)), isCode("TF_DURABILITY_FAILED")); +}); + +test("evidence root: replaced project identity is rejected by an existing instance", async (t) => { + const f = fixture(t); + const store = (await f.open()); + f.edit((view) => { view.projectId = "30000000-0000-0000-0000-000000000001"; }); + await assert.rejects(async () => (await store.stageArtifact(Buffer.from("wrong project"), META)), isCode("TF_DURABILITY_FAILED")); +}); + +test("evidence canonical bytes match shared request hash serializer for JSON values", async () => { + const value = { z: [null, false, "中文", 1.25], a: { omitted: undefined, c: true, b: "\\n" } }; + assert.equal(createHash("sha256").update(canonicalJson(value)).digest("hex"), createHash("sha256").update(canonicalJson(JSON.parse(canonicalJson(value)))).digest("hex")); + assert.equal(canonicalJson(value), '{"a":{"b":"\\\\n","c":true},"z":[null,false,"中文",1.25]}'); + const edgeCases = { a: undefined, z: [undefined, NaN, Infinity, -Infinity] }; + assert.equal(canonicalJson(edgeCases), '{"z":[null,null,null,null]}'); + assert.equal(evidenceEventHash("0".repeat(64), edgeCases as unknown as ControlEvent), + "52af6d5306197b5fae06e0056028533902376a65ba93d73e5d6a5d01e11caefc"); +}); + +function barrier() { + let release!: () => void; + const waiting = new Promise((resolve) => { release = resolve; }); + return { waiting, release }; +} + +test("evidence asynchronous live policy: revoke during evaluation denies this request", async (t) => { + const f = fixture(t); + const ref = await (await f.open()).stageArtifact(Buffer.from("not disclosed"), META); + f.terminal([ref]); + const entered = barrier(); + const resume = barrier(); + const store = await createEvidenceStore({ ...f.options, authorize: async (...args) => { + entered.release(); + await resume.waiting; + return await f.options.authorize(...args); + } }); + const reading = store.readArtifact(actor, { kind: "run", id: RUN }, ref); + await entered.waiting; + f.revoke(); + resume.release(); + await assert.rejects(reading, isCode("TF_AUTHORITY_REVOKED")); +}); + +test("evidence asynchronous live policy: reachability is reloaded after policy await", async (t) => { + const f = fixture(t); + const ref = await (await f.open()).stageArtifact(Buffer.from("now unreachable"), META); + f.terminal([ref]); + const entered = barrier(); + const resume = barrier(); + const store = await createEvidenceStore({ ...f.options, authorize: async (...args) => { + entered.release(); + await resume.waiting; + return await f.options.authorize(...args); + } }); + const reading = store.readArtifact(actor, { kind: "run", id: RUN }, ref); + await entered.waiting; + f.edit((view) => { view.runs[RUN].artifactRefs = []; }); + resume.release(); + await assert.rejects(reading, isCode("TF_POLICY_DENIED")); +}); + +test("evidence asynchronous terminal verification: owner fence spans await and durable preparation", async (t) => { + const f = fixture(t); + f.terminal(); + const entered = barrier(); + const resume = barrier(); + let held = false; + const store = await createEvidenceStore({ ...f.options, + ledger: { read: async () => f.read(), commitReceiptOnce: f.options.ledger.commitReceiptOnce, withWriter: async (operation) => { + assert.equal(held, false); + held = true; + try { return await operation(f.read()); } finally { held = false; } + } }, + terminalEvidence: { verify: async (...args) => { + assert.equal(held, true); + entered.release(); + await resume.waiting; + assert.equal(held, true); + return await f.options.terminalEvidence.verify(...args); + } }, + onDurabilityPoint: () => { assert.equal(held, true); }, + }); + const preparing = store.prepareFinalReceipt(RUN); + await entered.waiting; + assert.equal(held, true); + assert.equal(fs.existsSync(path.join(f.root, "artifacts")), false); + resume.release(); + const prepared = await preparing; + assert.equal(held, false); + f.issue(prepared); + assert.deepEqual((await store.readReceipt(actor, RUN)).receipt, prepared.receipt); +}); + +test("evidence issue-once: owner fence spans async journal commit and retry returns the exact Receipt", async (t) => { + const f = fixture(t); + f.terminal(); + const entered = barrier(); + const resume = barrier(); + let held = false; + let commits = 0; + const store = await createEvidenceStore({ ...f.options, ledger: { + read: async () => f.read(), + withWriter: async (operation) => { + assert.equal(held, false); + held = true; + try { return await operation(f.read()); } finally { held = false; } + }, + commitReceiptOnce: async (runId, prepared) => { + assert.equal(held, true); + commits++; + entered.release(); + await resume.waiting; + assert.equal(held, true); + await f.options.ledger.commitReceiptOnce(runId, prepared); + }, + } }); + const issuing = store.issueFinalReceipt(RUN); + await entered.waiting; + assert.equal(held, true); + assert.deepEqual(f.read().receipts, {}); + resume.release(); + const receipt = await issuing; + assert.equal(held, false); + assert.deepEqual(await store.issueFinalReceipt(RUN), receipt); + assert.deepEqual(await (await f.open()).issueFinalReceipt(RUN), receipt); + assert.equal(commits, 1); +}); + +test("evidence issue-once: lost commit acknowledgement recovers exact issued Receipt without reissuing", async (t) => { + const f = fixture(t); + f.terminal(); + const store = await createEvidenceStore({ ...f.options, ledger: { ...f.options.ledger, + commitReceiptOnce: async (runId, prepared) => { + await f.options.ledger.commitReceiptOnce(runId, prepared); + throw new Error("injected lost reply after committed marker"); + }, + } }); + await assert.rejects(store.issueFinalReceipt(RUN), /injected lost reply/); + const before = snapshot(f.root); + const reopened = await f.open(); + assert.deepEqual(await reopened.issueFinalReceipt(RUN), (await reopened.readReceipt(actor, RUN)).receipt); + assert.deepEqual(snapshot(f.root), before); +}); + +test("evidence issue-once: a commit hook that establishes no journal reachability cannot claim issuance", async (t) => { + const f = fixture(t); + f.terminal(); + const store = await createEvidenceStore({ ...f.options, ledger: { ...f.options.ledger, commitReceiptOnce: async () => {} } }); + await assert.rejects(store.issueFinalReceipt(RUN), isCode("TF_DURABILITY_FAILED")); + await assert.rejects((await f.open()).readReceipt(actor, RUN), isCode("TF_POLICY_DENIED")); +}); + +for (const point of ["file-fsynced", "file-published"]) { + test(`evidence SIGKILL: ${point} leaves only unreachable staging/orphan; reopen preserves bytes`, { skip: process.platform === "win32" }, async (t) => { + const f = fixture(t); + const moduleUrl = new URL("../src/store/evidence-store.ts", import.meta.url).href; + const script = ` + import fs from 'node:fs'; + import { createEvidenceStore } from ${JSON.stringify(moduleUrl)}; + const read = () => JSON.parse(fs.readFileSync(${JSON.stringify(f.disk)}, 'utf8')); + const store = await createEvidenceStore({ storePath: ${JSON.stringify(f.root)}, + ledger: { read, withWriter: async operation => await operation(read()), commitReceiptOnce: async () => { throw Error('unused'); } }, + authorize: () => { throw Error('denied'); }, terminalEvidence: { verify: () => { throw Error('unused'); } }, + onDurabilityPoint: point => { if (point === ${JSON.stringify(point)}) process.kill(process.pid, 'SIGKILL'); } + }); + (await store.stageArtifact(Buffer.from('crash output'), ${JSON.stringify(META)})); + `; + const result = spawnSync(process.execPath, ["--conditions=development", "--experimental-strip-types", "--input-type=module", "-e", script], { timeout: 10_000 }); + assert.equal(result.signal, "SIGKILL", result.stderr.toString()); + const before = snapshot(f.root); + const store = (await f.open()); + assert.deepEqual(snapshot(f.root), before, "reopen may not erase forensic staging bytes"); + const ref = (await store.stageArtifact(Buffer.from("crash output"), META)); + await assert.rejects(async () => (await store.readArtifact(actor, { kind: "run", id: RUN }, ref)), isCode("TF_POLICY_DENIED")); + assert.deepEqual(f.read().receipts, {}); + }); +} From 6c87cec95c929df00105f10b090037b0e92e51ff Mon Sep 17 00:00:00 2001 From: heggria Date: Fri, 2 Oct 2026 23:46:44 +0800 Subject: [PATCH 19/30] feat(control): add authenticated loopback console surface --- packages/taskflow-control/src/web-console.ts | 243 ++++++++++++++++++ .../taskflow-control/src/web-console/view.ts | 109 ++++++++ .../taskflow-control/test/web-console.test.ts | 221 ++++++++++++++++ 3 files changed, 573 insertions(+) create mode 100644 packages/taskflow-control/src/web-console.ts create mode 100644 packages/taskflow-control/src/web-console/view.ts create mode 100644 packages/taskflow-control/test/web-console.test.ts diff --git a/packages/taskflow-control/src/web-console.ts b/packages/taskflow-control/src/web-console.ts new file mode 100644 index 00000000..67582a34 --- /dev/null +++ b/packages/taskflow-control/src/web-console.ts @@ -0,0 +1,243 @@ +/** Explicitly launched, loopback-only operator console. No store or auth authority. + * The trusted launcher owns OS identity; authorize MUST verify current policy on + * each operation and return a client bound to that verified principal. Browser + * sessions prove possession of launcher authority, not OS identity themselves. + */ +import { randomBytes, timingSafeEqual } from "node:crypto"; +import * as http from "node:http"; +import type { AddressInfo, Socket } from "node:net"; +import { Value } from "typebox/value"; +import { ArtifactRefSchema, type ArtifactRef } from "./schema/evidence.ts"; +import { UuidSchema } from "./schema/common.ts"; +import { ControlError } from "./errors.ts"; +import { CONSOLE_HTML, CONSOLE_JS, CONSOLE_CSS } from "./web-console/view.ts"; + +export type WebConsoleOperation = "control.status" | "projects.list" | "runs.list" | "runs.get" | "approvals.list" | "approval.decide" | "receipts.get" | "evidence.why"; +export interface WebConsoleProject { + projectId: string; + controlDomainId: string; + name?: string; + path?: string; + mountState?: string; +} +/** Internal seam for the owner to map to its authenticated ControlHost client. + * projects.list returns WebConsoleProject[]; runs.list returns RunSnapshot[]; + * approvals.list returns ApprovalRequest[] (scoped by the requested project); + * receipts.get returns Receipt|null; runs.get/control.status/evidence.why return + * their actual owner DTO. No zero-ID snapshots or synthetic success fallback. + */ +export interface WebConsoleControlClient { + call(operation: WebConsoleOperation, params: Readonly>): Promise; +} +export interface WebConsoleAuthorization { + operation: WebConsoleOperation; + /** Random browser-session handle, NEVER a claimed principal. */ + sessionId: string; + projectId?: string; + controlDomainId?: string; +} +export interface WebConsoleOptions { + /** Required live check. Resolve verified identity from trusted launcher state, + * never from HTTP input/sessionId alone. Re-check project/operation policy. */ + authorize: (request: WebConsoleAuthorization) => Promise; + port?: number; + /** Absolute browser session lifetime, default 30 minutes. */ + sessionTtlMs?: number; + /** Absolute one-use bootstrap lifetime, default 5 minutes. */ + bootstrapTtlMs?: number; + /** Owner enables only after its output-edit ArtifactRef contract is wired. */ + approvalEditWithArtifactRef?: boolean; +} +export interface WebConsoleServer { + readonly url: string; + /** One-use secret for trusted launcher/browser handoff. Never log, put in a + * URL, persist in localStorage or send to a remote service. */ + readonly bootstrapToken: string; + close(): Promise; +} +type Session = { csrf: string; expiresAt: number }; +class HttpFailure extends Error { + readonly status: number; + constructor(status: number, message: string) { super(message); this.status = status; } +} +const token = (): string => randomBytes(32).toString("base64url"); +const sameSecret = (actual: unknown, expected: string): boolean => { + if (typeof actual !== "string") return false; + const a = Buffer.from(actual); + const b = Buffer.from(expected); + return a.length === b.length && timingSafeEqual(a, b); +}; +function positive(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${name} must be a positive safe integer`); + return value; +} +function uuid(value: unknown, name: string): string { + if (!Value.Check(UuidSchema, value)) throw new HttpFailure(400, `${name} must be a UUID`); + return value as string; +} +function boundedText(value: unknown, name: string): string { + if (typeof value !== "string" || !value || value.length > 512) throw new HttpFailure(400, `${name} is required (maximum 512 characters)`); + return value; +} +function onlyKeys(body: Record, keys: readonly string[]): void { + if (Object.keys(body).some((key) => !keys.includes(key))) throw new HttpFailure(400, "Unexpected request field"); +} +async function jsonBody(request: http.IncomingMessage): Promise> { + if (request.headers["content-type"]?.split(";")[0]?.trim() !== "application/json") throw new HttpFailure(415, "Content-Type must be application/json"); + if (Number(request.headers["content-length"]) > 65_536) throw new HttpFailure(413, "Request body exceeds 64 KiB"); + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.length; + if (bytes > 65_536) throw new HttpFailure(413, "Request body exceeds 64 KiB"); + chunks.push(buffer); + } + let raw: unknown; + try { raw = JSON.parse(Buffer.concat(chunks).toString("utf8")); } + catch { throw new HttpFailure(400, "Malformed JSON request"); } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new HttpFailure(400, "Request must be a JSON object"); + return raw as Record; +} + +export async function startWebConsole(options: WebConsoleOptions): Promise { + if (typeof options.authorize !== "function") throw new Error("A live authorize callback is required"); + const port = options.port ?? 0; + if (!Number.isInteger(port) || port < 0 || port > 65_535) throw new Error("port must be 0..65535"); + const sessionTtlMs = positive(options.sessionTtlMs ?? 30 * 60_000, "sessionTtlMs"); + const bootstrapExpiresAt = Date.now() + positive(options.bootstrapTtlMs ?? 5 * 60_000, "bootstrapTtlMs"); + const bootstrapToken = token(); + let bootstrapUsed = false; + let origin = ""; + let authority = ""; + const sessions = new Map(); + const sockets = new Set(); + const features = { approvalEditWithArtifactRef: options.approvalEditWithArtifactRef === true }; + + const server = http.createServer((request, response) => { void handle(request, response); }); + server.requestTimeout = 10_000; + server.headersTimeout = 10_000; + server.on("connection", (socket) => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); }); + server.on("clientError", (_error, socket) => socket.destroy()); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => { server.off("error", reject); resolve(); }); + }); + authority = `127.0.0.1:${(server.address() as AddressInfo).port}`; + origin = `http://${authority}`; + server.on("error", () => { /* no process-wide uncaught server errors */ }); + + async function handle(request: http.IncomingMessage, response: http.ServerResponse): Promise { + response.setHeader("Cache-Control", "no-store"); + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("Referrer-Policy", "no-referrer"); + response.setHeader("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'"); + const send = (status: number, value: unknown): void => { + response.statusCode = status; + response.setHeader("Content-Type", "application/json; charset=utf-8"); + response.end(JSON.stringify(value)); + }; + try { + if (request.headers.host !== authority) throw new HttpFailure(403, "Invalid console Host"); + if (request.headers.origin !== undefined && request.headers.origin !== origin) throw new HttpFailure(403, "Invalid console Origin"); + const site = request.headers["sec-fetch-site"]; + if (site !== undefined && site !== "same-origin" && site !== "none") throw new HttpFailure(403, "Cross-site console request denied"); + const url = new URL(request.url ?? "/", origin); + if (url.origin !== origin) throw new HttpFailure(403, "Invalid request origin"); + const method = request.method ?? "GET"; + if (method === "GET" && ["/", "/app.js", "/style.css"].includes(url.pathname)) { + response.setHeader("Content-Type", url.pathname === "/" ? "text/html; charset=utf-8" : url.pathname === "/app.js" ? "text/javascript; charset=utf-8" : "text/css; charset=utf-8"); + response.end(url.pathname === "/" ? CONSOLE_HTML : url.pathname === "/app.js" ? CONSOLE_JS : CONSOLE_CSS); + return; + } + if (!url.pathname.startsWith("/api/")) throw new HttpFailure(404, "Unknown console route"); + const mutating = method !== "GET"; + if (!["GET", "POST", "DELETE"].includes(method)) throw new HttpFailure(405, "Method not allowed"); + if (mutating && request.headers.origin !== origin) throw new HttpFailure(403, "Console mutations require same Origin"); + if (url.pathname === "/api/session" && method === "POST") { + const body = await jsonBody(request); + onlyKeys(body, ["token"]); + if (bootstrapUsed || Date.now() >= bootstrapExpiresAt || !sameSecret(body.token, bootstrapToken)) throw new HttpFailure(401, "Invalid or expired launcher token"); + // Consume synchronously before any await: concurrent exchanges cannot reuse it. + bootstrapUsed = true; + const sessionId = token(); + const session = { csrf: token(), expiresAt: Date.now() + sessionTtlMs }; + sessions.set(sessionId, session); + response.setHeader("Set-Cookie", `taskflow_console=${sessionId}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${Math.ceil(sessionTtlMs / 1000)}`); + send(200, { csrf: session.csrf, expiresAt: session.expiresAt, features }); + return; + } + const cookies = (request.headers.cookie ?? "").split(";").map((part) => part.trim()).filter((part) => part.startsWith("taskflow_console=")); + const sessionId = cookies.length === 1 ? cookies[0]!.slice("taskflow_console=".length) : ""; + const session = sessions.get(sessionId); + if (!session || Date.now() >= session.expiresAt) { + sessions.delete(sessionId); + throw new HttpFailure(401, "Console session expired; launch a new session"); + } + if (mutating && !sameSecret(request.headers["x-taskflow-csrf"], session.csrf)) throw new HttpFailure(403, "Invalid CSRF token"); + if (url.pathname === "/api/session") { + if (method === "DELETE") { + sessions.delete(sessionId); + response.setHeader("Set-Cookie", "taskflow_console=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"); + send(200, { closed: true }); + } else if (method === "GET") send(200, { csrf: session.csrf, expiresAt: session.expiresAt, features }); + else throw new HttpFailure(405, "Method not allowed"); + return; + } + let operation: WebConsoleOperation; + let params: Record = {}; + if (url.pathname === "/api/status" && method === "GET") operation = "control.status"; + else if (url.pathname === "/api/projects" && method === "GET") operation = "projects.list"; + else { + const segments = url.pathname.split("/").slice(1); + if (segments[0] !== "api" || segments[1] !== "projects") throw new HttpFailure(404, "Unknown console route"); + params = { projectId: uuid(segments[2], "projectId"), controlDomainId: uuid(url.searchParams.get("controlDomainId"), "controlDomainId") }; + if (segments.length === 4 && method === "GET" && segments[3] === "runs") operation = "runs.list"; + else if (segments.length === 4 && method === "GET" && segments[3] === "approvals") operation = "approvals.list"; + else if (segments[3] === "runs" && method === "GET" && [5, 6].includes(segments.length)) { + params.runId = uuid(segments[4], "runId"); + if (segments.length === 5) operation = "runs.get"; + else if (segments[5] === "receipt") operation = "receipts.get"; + else if (segments[5] === "why") { + operation = "evidence.why"; + params.kind = url.searchParams.get("kind"); + if (params.kind !== "stale" && params.kind !== "effect") throw new HttpFailure(400, "why kind must be stale or effect"); + if (params.kind === "effect") params.effectId = boundedText(url.searchParams.get("effectId"), "effectId"); + if (url.searchParams.has("phaseId")) params.phaseId = boundedText(url.searchParams.get("phaseId"), "phaseId"); + } else throw new HttpFailure(404, "Unknown console route"); + } else if (segments[3] === "approvals" && segments[5] === "decisions" && segments.length === 6 && method === "POST") { + operation = "approval.decide"; + params.approvalRequestId = uuid(segments[4], "approvalRequestId"); + const body = await jsonBody(request); + onlyKeys(body, ["commandId", "runId", "expectedRunVersion", "decision", "editArtifactRef"]); + params.commandId = uuid(body.commandId, "commandId"); + params.runId = uuid(body.runId, "runId"); + if (!Number.isSafeInteger(body.expectedRunVersion) || Number(body.expectedRunVersion) < 0) throw new HttpFailure(400, "expectedRunVersion must be a non-negative safe integer"); + params.expectedRunVersion = body.expectedRunVersion; + if (typeof body.decision !== "string" || !["approve", "reject", "edit"].includes(body.decision)) throw new HttpFailure(400, "Invalid approval decision"); + params.decision = body.decision; + if (body.decision === "edit") { + if (!features.approvalEditWithArtifactRef) throw new HttpFailure(409, "Approval editing is unavailable on this control service"); + if (!Value.Check(ArtifactRefSchema, body.editArtifactRef)) throw new HttpFailure(400, "edit requires a valid editArtifactRef"); + params.editArtifactRef = body.editArtifactRef as ArtifactRef; + } else if (body.editArtifactRef !== undefined) throw new HttpFailure(400, "editArtifactRef requires edit decision"); + } else throw new HttpFailure(404, "Unknown console route"); + } + const client = await options.authorize({ operation, sessionId, ...(typeof params.projectId === "string" ? { projectId: params.projectId } : {}), ...(typeof params.controlDomainId === "string" ? { controlDomainId: params.controlDomainId } : {}) }); + const result = await client.call(operation, params); + send(200, { result }); + } catch (error) { + if (response.headersSent) { response.destroy(); return; } + if (error instanceof HttpFailure) send(error.status, { error: { message: error.message } }); + else if (error instanceof ControlError) { + const status = ["TF_POLICY_DENIED", "TF_AUTHORITY_REVOKED", "TF_CROSS_PRINCIPAL_COMMAND"].includes(error.code) ? 403 : ["TF_STALE_VERSION", "TF_IDEMPOTENCY_CONFLICT"].includes(error.code) ? 409 : error.code === "TF_FEATURE_REQUIRED" ? 503 : 500; + send(status, { error: error.toEnvelope() }); + } else send(500, { error: { message: "Control service request failed" } }); + } + } + return { url: origin, bootstrapToken, close: async () => { + sessions.clear(); + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + } }; +} diff --git a/packages/taskflow-control/src/web-console/view.ts b/packages/taskflow-control/src/web-console/view.ts new file mode 100644 index 00000000..69dc6946 --- /dev/null +++ b/packages/taskflow-control/src/web-console/view.ts @@ -0,0 +1,109 @@ +/** Static shell only. All owner-provided content is inserted with textContent. */ +export const CONSOLE_HTML = ` +Taskflow · Operator console +
TaskflowOperator console
Disconnected
+ + +
Local operator session · Live authorization remains with ControlHost
`; + +export const CONSOLE_CSS = `:root{color-scheme:light;--ink:#192a30;--muted:#67777c;--line:#dce4e6;--accent:#146a60;--canvas:#f6f8f8}*{box-sizing:border-box}body{margin:0;background:var(--canvas);color:var(--ink);font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}header{height:68px;padding:0 30px;background:white;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between}header strong{font-size:20px;letter-spacing:-.6px}.subtitle{margin-left:18px;color:var(--muted)}#connection{font-size:12px;border:1px solid var(--line);padding:4px 10px;border-radius:20px}.login{max-width:610px;margin:70px auto;padding:32px;background:#fff;border:1px solid var(--line);border-radius:12px}h1{font-size:23px;letter-spacing:-.5px;margin:0 0 8px}h2{font-size:16px;margin:0}p{margin:8px 0 18px}.muted{color:var(--muted)}.mono,pre{font-family:ui-monospace,SFMono-Regular,monospace}label{display:block;margin:14px 0 6px;font-weight:600}input,select,textarea{font:inherit;padding:8px 10px;background:white;border:1px solid #b8c8cc;border-radius:6px;color:var(--ink)}input:focus,select:focus,textarea:focus{outline:2px solid #8ac5bb;outline-offset:2px}#token{width:100%;margin-bottom:14px}button{font:inherit;font-weight:600;cursor:pointer;background:var(--accent);color:white;border:1px solid var(--accent);border-radius:6px;padding:7px 13px}button:hover{filter:brightness(.95)}button:disabled{cursor:not-allowed;opacity:.48}.quiet{background:white;color:var(--ink);border-color:var(--line)}#console{display:grid;grid-template-columns:246px minmax(0,1fr);min-height:calc(100vh - 110px)}#console[hidden]{display:none}aside{padding:25px 18px;border-right:1px solid var(--line);background:#eef3f2}main{padding:30px 36px;max-width:1400px;width:100%}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:20px}.section-heading p{margin-bottom:0;font-size:11px;overflow-wrap:anywhere}.project{display:block;width:100%;text-align:left;margin:8px 0;padding:12px;background:transparent;border:1px solid transparent;color:var(--ink);overflow-wrap:anywhere}.project.selected{background:white;border-color:#c5d7d2;box-shadow:0 1px 3px #163c3010}.project small{display:block;color:var(--muted);font-weight:400;margin-top:5px}#logout{margin-top:28px;width:100%}nav{display:flex;gap:6px;border-bottom:1px solid var(--line);margin:20px 0 25px;padding-bottom:10px}nav button{background:transparent;color:var(--muted);border-color:transparent}nav button.selected{background:#e4efeb;color:#155f53}#notice{min-height:22px;margin:0;color:var(--muted)}#notice.error,#login-error{color:#9c3131}.card{background:white;border:1px solid var(--line);border-radius:9px;padding:17px;margin:12px 0}.card h3{margin:0 0 6px;font-size:14px;overflow-wrap:anywhere}.row{display:flex;align-items:center;justify-content:space-between;gap:16px}.tag{display:inline-block;background:#edf1f2;color:#385158;font-size:12px;border-radius:4px;padding:2px 7px;margin-right:6px}.actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:15px}.actions input{width:180px}.empty{padding:28px;border:1px dashed #c6d3d5;border-radius:8px;color:var(--muted);background:#fcfdfd}pre{white-space:pre-wrap;overflow-wrap:anywhere;padding:18px;border:1px solid var(--line);border-radius:8px;background:#fff;font-size:12px}textarea{display:block;width:100%;min-height:70px;margin:12px 0}details{margin-top:30px;color:var(--muted)}summary{cursor:pointer}footer{padding:10px 30px;font-size:11px;color:var(--muted);border-top:1px solid var(--line)}[hidden]{display:none!important}@media(max-width:800px){#console{grid-template-columns:1fr}aside{border-right:0;border-bottom:1px solid var(--line)}main{padding:24px 18px}.subtitle{display:none}.row{align-items:flex-start}.section-heading{align-items:flex-start}.login{margin:30px 15px}header{padding:0 18px}}`; + +export const CONSOLE_JS = String.raw`'use strict'; +const $ = (id) => document.getElementById(id); +const state = { csrf: '', features: {}, project: null, projects: [], runs: [], generation: 0, commands: new Map() }; +function node(tag, text, className) { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (className) e.className = className; return e; } +function clear(e) { e.replaceChildren(); } +function notice(text, error = false) { $('notice').textContent = text; $('notice').className = error ? 'error' : ''; } +function message(error) { return (error.code ? error.code + ': ' : '') + error.message; } +async function api(path, body, method) { + const headers = {}; if (body !== undefined) headers['Content-Type'] = 'application/json'; if (state.csrf) headers['X-Taskflow-CSRF'] = state.csrf; + const response = await fetch(path, { method: method || (body === undefined ? 'GET' : 'POST'), headers, credentials: 'same-origin', ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); + const value = await response.json(); if (!response.ok) { const e = new Error(value.error?.message || 'Control request failed'); e.code = value.error?.code; if (response.status === 401) $('connection').textContent = 'Session expired'; throw e; } return value; +} +function projectUrl(project, suffix) { return '/api/projects/' + encodeURIComponent(project.projectId) + suffix + '?controlDomainId=' + encodeURIComponent(project.controlDomainId); } +function empty(parent, text) { parent.append(node('p', text, 'empty')); } +function tab(name) { for (const n of ['runs', 'approvals', 'evidence']) $(n + '-panel').hidden = name !== n; for (const b of document.querySelectorAll('[data-tab]')) b.classList.toggle('selected', b.dataset.tab === name); } +for (const b of document.querySelectorAll('[data-tab]')) b.addEventListener('click', () => tab(b.dataset.tab)); +function renderProjects() { + clear($('projects')); if (!state.projects.length) empty($('projects'), 'No authorized projects are mounted.'); + for (const p of state.projects) { const b = node('button', undefined, 'project'); b.classList.toggle('selected', state.project?.projectId === p.projectId); b.append(node('span', p.name || p.path || p.projectId), node('small', p.mountState || 'Mounted project')); b.addEventListener('click', () => { state.project = p; renderProjects(); void refreshProject(); }); $('projects').append(b); } +} +function renderRuns(runs, project) { + clear($('runs')); clear($('run-choice')); state.runs = runs; + if (!runs.length) { empty($('runs'), 'No runs in this project.'); $('run-choice').append(node('option', 'No runs available')); } + for (const r of runs) { + const option = node('option', r.runId); option.value = r.runId; $('run-choice').append(option); + const card = node('div', undefined, 'card'); const row = node('div', undefined, 'row'); const info = node('div'); info.append(node('h3', r.runId)); + const labels = node('div'); for (const value of [r.status, r.stage, 'slot: ' + r.slot]) labels.append(node('span', value, 'tag')); if (r.needsOperator) labels.append(node('span', 'Needs operator', 'tag')); info.append(labels); + const details = node('button', 'Inspect', 'quiet'); details.addEventListener('click', async () => { const g = state.generation; notice('Loading run…'); try { const data = await api(projectUrl(project, '/runs/' + encodeURIComponent(r.runId))); if (g !== state.generation) return; $('run-detail').hidden = false; $('run-detail').textContent = JSON.stringify(data.result, null, 2); $('run-choice').value = r.runId; notice('Run loaded from control.'); } catch (e) { if (g === state.generation) notice(message(e), true); } }); + row.append(info, details); card.append(row); $('runs').append(card); + } + for (const id of ['receipt', 'why-stale', 'why-effect']) $(id).disabled = runs.length === 0; +} +function renderApprovals(approvals, project) { + clear($('approvals')); $('edit-capability').textContent = state.features.approvalEditWithArtifactRef ? 'Editing requires an existing, authorized output ArtifactRef.' : 'Editing is not supported by the connected control service.'; + if (!approvals.length) empty($('approvals'), 'No approval requests in this project.'); + for (const a of approvals) { + const card = node('div', undefined, 'card'); card.append(node('h3', a.message || a.approvalRequestId), node('p', 'Run ' + a.runId + ' · version ' + a.expectedRunVersion + ' · ' + a.status, 'mono muted'), node('p', 'Deadline: ' + new Date(a.deadline).toLocaleString(), 'muted')); + const editRef = node('textarea'); editRef.placeholder = 'Output ArtifactRef JSON'; editRef.setAttribute('aria-label', 'Output ArtifactRef JSON'); editRef.hidden = !state.features.approvalEditWithArtifactRef || !a.allowedDecisions.includes('edit'); card.append(editRef); + const buttons = node('div', undefined, 'actions'); + for (const decision of a.allowedDecisions) { + const b = node('button', decision[0].toUpperCase() + decision.slice(1), decision === 'approve' ? '' : 'quiet'); b.disabled = a.status !== 'pending' || (decision === 'edit' && !state.features.approvalEditWithArtifactRef); b.addEventListener('click', async () => { + const g = state.generation; const peers = Array.from(buttons.querySelectorAll('button')); peers.forEach(x => x.disabled = true); notice('Submitting ' + decision + '…'); + try { + const editArtifactRef = decision === 'edit' ? JSON.parse(editRef.value) : undefined; + const key = [project.projectId, a.approvalRequestId, a.expectedRunVersion, decision, JSON.stringify(editArtifactRef)].join(':'); + if (!state.commands.has(key)) state.commands.set(key, crypto.randomUUID()); + const body = { commandId: state.commands.get(key), runId: a.runId, expectedRunVersion: a.expectedRunVersion, decision, ...(editArtifactRef === undefined ? {} : { editArtifactRef }) }; + await api(projectUrl(project, '/approvals/' + encodeURIComponent(a.approvalRequestId) + '/decisions'), body); + if (g !== state.generation) return; await refreshProject(); notice('Decision committed by control.'); + } catch (e) { + if (g !== state.generation) return; + if (e.code === 'TF_STALE_VERSION') await refreshProject(); + notice(message(e) + (e.code === 'TF_STALE_VERSION' ? ' The current request has been refreshed; review before deciding again.' : ''), true); + peers.forEach(x => x.disabled = a.status !== 'pending' || (x.textContent === 'Edit' && !state.features.approvalEditWithArtifactRef)); + } + }); buttons.append(b); + } + card.append(buttons); $('approvals').append(card); + } +} +async function refreshProject() { + const p = state.project; const g = ++state.generation; $('run-detail').hidden = true; clear($('run-detail')); clear($('runs')); clear($('approvals')); clear($('run-choice')); $('evidence-output').textContent = 'Choose a run to inspect its committed evidence.'; + for (const id of ['receipt', 'why-stale', 'why-effect']) $(id).disabled = true; + if (!p) { $('project-title').textContent = 'Select a project'; $('project-context').textContent = ''; return; } + $('project-title').textContent = p.name || p.path || p.projectId; $('project-context').textContent = p.projectId + ' · domain ' + p.controlDomainId; notice('Loading project…'); + const results = await Promise.allSettled([api(projectUrl(p, '/runs')), api(projectUrl(p, '/approvals'))]); if (g !== state.generation) return; + const errors = []; + for (let i = 0; i < results.length; i++) { const r = results[i]; const target = $(i === 0 ? 'runs' : 'approvals'); if (r.status === 'fulfilled' && Array.isArray(r.value.result)) { if (i === 0) renderRuns(r.value.result, p); else renderApprovals(r.value.result, p); } else { const text = r.status === 'rejected' ? message(r.reason) : 'Control returned an invalid list'; empty(target, text); errors.push(text); } } + notice(errors.length ? errors.join(' · ') : 'Updated from the project ledger.', errors.length > 0); +} +async function projects() { + notice('Loading projects…'); + try { const data = await api('/api/projects'); if (!Array.isArray(data.result)) throw new Error('Control returned an invalid project list'); state.projects = data.result; state.project = state.projects.find(p => p.projectId === state.project?.projectId) || state.projects[0] || null; renderProjects(); await refreshProject(); } + catch (e) { state.projects = []; state.project = null; renderProjects(); await refreshProject(); notice(message(e), true); } +} +async function connected(session) { + state.csrf = session.csrf; state.features = session.features; $('token').value = ''; $('login').hidden = true; $('console').hidden = false; $('connection').textContent = 'Local session'; + try { const status = await api('/api/status'); $('control-status').textContent = JSON.stringify(status.result, null, 2); } catch (e) { $('control-status').textContent = message(e); } + await projects(); +} +$('connect').addEventListener('click', async () => { $('connect').disabled = true; $('login-error').textContent = ''; try { const secret = $('token').value; $('token').value = ''; await connected(await api('/api/session', { token: secret })); } catch (e) { $('login-error').textContent = message(e); } finally { $('connect').disabled = false; } }); +$('token').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('connect').click(); }); +$('refresh-projects').addEventListener('click', () => { void projects(); }); $('refresh').addEventListener('click', () => { void refreshProject(); }); +$('logout').addEventListener('click', async () => { try { await api('/api/session', undefined, 'DELETE'); location.reload(); } catch (e) { notice(message(e), true); } }); +async function evidence(kind) { + const p = state.project; const runId = $('run-choice').value; if (!p || !runId || !state.runs.length) return; + const g = state.generation; $('evidence-output').textContent = 'Loading evidence…'; + try { let url = projectUrl(p, '/runs/' + encodeURIComponent(runId) + (kind === 'receipt' ? '/receipt' : '/why')); if (kind !== 'receipt') url += '&kind=' + kind; if (kind === 'effect') url += '&effectId=' + encodeURIComponent($('effect-id').value); if ($('phase-id').value) url += '&phaseId=' + encodeURIComponent($('phase-id').value); const data = await api(url); if (g !== state.generation) return; $('evidence-output').textContent = data.result === null && kind === 'receipt' ? 'No final Receipt has been issued for this run.' : JSON.stringify(data.result, null, 2); } + catch (e) { if (g === state.generation) $('evidence-output').textContent = message(e); } +} +$('receipt').addEventListener('click', () => { void evidence('receipt'); }); $('why-stale').addEventListener('click', () => { void evidence('stale'); }); $('why-effect').addEventListener('click', () => { void evidence('effect'); }); +void api('/api/session').then(connected).catch(() => {}); +`; diff --git a/packages/taskflow-control/test/web-console.test.ts b/packages/taskflow-control/test/web-console.test.ts new file mode 100644 index 00000000..cd0c13a8 --- /dev/null +++ b/packages/taskflow-control/test/web-console.test.ts @@ -0,0 +1,221 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs"; +import * as http from "node:http"; +import * as os from "node:os"; +import * as path from "node:path"; +import { test, type TestContext } from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; +import { startWebConsole, type WebConsoleOptions, type WebConsoleServer, type WebConsoleAuthorization } from "../src/web-console.ts"; +import { ControlError } from "../src/errors.ts"; +import { ControlHost, defaultServerHello } from "../src/control-host.ts"; +import { createTeExecutionProvider } from "../src/te-provider.ts"; + +const projectId = "11111111-1111-4111-8111-111111111111"; +const domainId = "22222222-2222-4222-8222-222222222222"; +const runId = "33333333-3333-4333-8333-333333333333"; +const approvalId = "44444444-4444-4444-8444-444444444444"; +const scoped = (suffix: string) => `/api/projects/${projectId}${suffix}?controlDomainId=${domainId}`; +const decision = () => ({ commandId: randomUUID(), runId, expectedRunVersion: 3, decision: "approve" }); +async function launch(t: TestContext, options: WebConsoleOptions) { + const console = await startWebConsole(options); + t.after(() => console.close()); + return console; +} +async function session(console: WebConsoleServer) { + const response = await fetch(`${console.url}/api/session`, { method: "POST", headers: { Origin: console.url, "Content-Type": "application/json" }, body: JSON.stringify({ token: console.bootstrapToken }) }); + assert.equal(response.status, 200); + const data = await response.json() as { csrf: string }; + const setCookie = response.headers.get("set-cookie")!; + assert.match(setCookie, /HttpOnly/); + assert.match(setCookie, /SameSite=Strict/); + return { Cookie: setCookie.split(";")[0]!, Origin: console.url, "X-Taskflow-CSRF": data.csrf, "Content-Type": "application/json" }; +} +const unusedClient = { call: async () => ({}) }; +function rawStatus(url: string, headers: Record): Promise { + return new Promise((resolve, reject) => { + const request = http.get(url, { headers }, (response) => { response.resume(); resolve(response.statusCode!); }); + request.on("error", reject); + }); +} + +test("web console: requires live authorization injection; token absent from public HTML/URL/scripts", async (t) => { + await assert.rejects(startWebConsole({} as WebConsoleOptions), /authorize/); + const console = await launch(t, { authorize: async () => unusedClient }); + assert.equal(console.url.includes(console.bootstrapToken), false); + for (const route of ["/", "/app.js", "/style.css"]) { + const response = await fetch(console.url + route); + assert.equal(response.status, 200); + assert.equal((await response.text()).includes(console.bootstrapToken), false); + assert.match(response.headers.get("content-security-policy")!, /frame-ancestors 'none'/); + assert.equal(response.headers.get("access-control-allow-origin"), null); + } +}); + +test("web console: loopback alone gives no access and one-use bootstrap rejects replay", async (t) => { + const console = await launch(t, { authorize: async () => unusedClient }); + assert.equal((await fetch(console.url + "/api/projects")).status, 401); + await session(console); + const replay = await fetch(console.url + "/api/session", { method: "POST", headers: { Origin: console.url, "Content-Type": "application/json" }, body: JSON.stringify({ token: console.bootstrapToken }) }); + assert.equal(replay.status, 401); + assert.equal((await replay.text()).includes(console.bootstrapToken), false); +}); + +test("web console: rejects wrong Host, cross-origin, missing Origin and CSRF before owner calls", async (t) => { + let calls = 0; + const console = await launch(t, { authorize: async () => { calls++; return unusedClient; } }); + const headers = await session(console); + assert.equal(await rawStatus(console.url + "/api/projects", { ...headers, Host: "attacker.invalid" }), 403); + assert.equal((await fetch(console.url + "/api/projects", { headers: { ...headers, Origin: "https://attacker.invalid" } })).status, 403); + assert.equal((await fetch(console.url + "/api/projects", { headers: { ...headers, "Sec-Fetch-Site": "cross-site" } })).status, 403); + for (const bad of [{ ...headers, Origin: "" }, { ...headers, "X-Taskflow-CSRF": "wrong" }]) { + assert.equal((await fetch(console.url + scoped(`/approvals/${approvalId}/decisions`), { method: "POST", headers: bad, body: JSON.stringify(decision()) })).status, 403); + } + assert.equal(calls, 0); +}); + +test("web console: live authorization runs for every request and revocation denies subsequent disclosure", async (t) => { + const auth: WebConsoleAuthorization[] = []; + let revoked = false; + let calls = 0; + const console = await launch(t, { authorize: async (request) => { + auth.push(request); + if (revoked) throw new ControlError("TF_AUTHORITY_REVOKED", "Project access revoked"); + return { call: async () => { calls++; return []; } }; + } }); + const headers = await session(console); + for (let i = 0; i < 2; i++) assert.equal((await fetch(console.url + scoped("/runs"), { headers })).status, 200); + revoked = true; + const denied = await fetch(console.url + scoped("/runs"), { headers }); + assert.equal(denied.status, 403); + assert.match(await denied.text(), /TF_AUTHORITY_REVOKED/); + assert.equal(auth.length, 3); + assert.equal(calls, 2); + assert.equal(auth[0]!.projectId, projectId); + assert.equal(auth[0]!.controlDomainId, domainId); + assert.equal("principalId" in auth[0]!, false); +}); + +test("web console: project routing comes from path and domain, trusted fields cannot enter decision intent", async (t) => { + const intents: Record[] = []; + const console = await launch(t, { authorize: async () => ({ call: async (_operation, params) => { intents.push({ ...params }); return { accepted: true }; } }) }); + const headers = await session(console); + const url = console.url + scoped(`/approvals/${approvalId}/decisions`); + for (const field of ["callerPrincipal", "authorizationContextHash", "events", "projectId", "fencingEpoch"]) { + assert.equal((await fetch(url, { method: "POST", headers, body: JSON.stringify({ ...decision(), [field]: "forged" }) })).status, 400); + } + const body = decision(); + assert.equal((await fetch(url, { method: "POST", headers, body: JSON.stringify(body) })).status, 200); + assert.deepEqual(intents, [{ projectId, controlDomainId: domainId, approvalRequestId: approvalId, ...body }]); +}); + +test("web console: decision version and error envelope preserved; competing requests reach authoritative CAS", async (t) => { + let version = 3; + let commits = 0; + const console = await launch(t, { authorize: async () => ({ call: async (_operation, params) => { + if (params.expectedRunVersion !== version) throw new ControlError("TF_STALE_VERSION", "Approval version changed", { recoveryAction: "refresh", sideEffects: "none" }); + version++; commits++; return { committed: true }; + } }) }); + const headers = await session(console); + const replies = await Promise.all([decision(), decision()].map((body) => fetch(console.url + scoped(`/approvals/${approvalId}/decisions`), { method: "POST", headers, body: JSON.stringify(body) }))); + assert.deepEqual(replies.map((r) => r.status).sort(), [200, 409]); + const stale = await replies.find((r) => r.status === 409)!.json() as { error: { code: string; recoveryAction: string; sideEffects: string } }; + assert.deepEqual(stale.error, { code: "TF_STALE_VERSION", message: "Approval version changed", recoveryAction: "refresh", sideEffects: "none" }); + assert.equal(commits, 1); +}); + +test("web console: edit unavailable by default; enabled edit requires ArtifactRef", async (t) => { + let calls = 0; + const authorize = async () => ({ call: async () => { calls++; return { committed: true }; } }); + const disabled = await launch(t, { authorize }); + const disabledHeaders = await session(disabled); + assert.equal((await fetch(disabled.url + scoped(`/approvals/${approvalId}/decisions`), { method: "POST", headers: disabledHeaders, body: JSON.stringify({ ...decision(), decision: "edit" }) })).status, 409); + assert.equal(calls, 0); + const enabled = await launch(t, { authorize, approvalEditWithArtifactRef: true }); + const headers = await session(enabled); + assert.equal((await fetch(enabled.url + scoped(`/approvals/${approvalId}/decisions`), { method: "POST", headers, body: JSON.stringify({ ...decision(), decision: "edit" }) })).status, 400); + const artifact = { digest: "a".repeat(64), size: 4, mediaType: "text/plain", storageClass: "project", redactionClass: "none" }; + assert.equal((await fetch(enabled.url + scoped(`/approvals/${approvalId}/decisions`), { method: "POST", headers, body: JSON.stringify({ ...decision(), decision: "edit", editArtifactRef: artifact }) })).status, 200); + assert.equal(calls, 1); +}); + +test("web console: missing receipt stays absent, why scope preserved and backend errors do not expose secrets", async (t) => { + const requests: Record[] = []; + const console = await launch(t, { authorize: async () => ({ call: async (op, params) => { + requests.push({ ...params }); + if (op === "receipts.get") return null; + if (op === "evidence.why") return { evidence: "" }; + throw new Error("SECRET INTERNAL AUTH MATERIAL"); + } }) }); + const headers = await session(console); + assert.deepEqual(await (await fetch(console.url + scoped(`/runs/${runId}/receipt`), { headers })).json(), { result: null }); + assert.equal((await fetch(console.url + scoped(`/runs/${runId}/why`) + "&kind=effect&effectId=write&phaseId=save", { headers })).status, 200); + assert.deepEqual(requests[1], { projectId, controlDomainId: domainId, runId, kind: "effect", effectId: "write", phaseId: "save" }); + const failure = await fetch(console.url + "/api/status", { headers }); + assert.equal(failure.status, 500); + assert.doesNotMatch(await failure.text(), /SECRET/); + const js = await (await fetch(console.url + "/app.js")).text(); + assert.doesNotMatch(js, /innerHTML|outerHTML|insertAdjacentHTML|document\.write/); +}); + +test("web console: session expiry and logout stop API access", async (t) => { + const console = await launch(t, { authorize: async () => unusedClient, sessionTtlMs: 50 }); + const headers = await session(console); + await delay(70); + assert.equal((await fetch(console.url + "/api/status", { headers })).status, 401); + const active = await launch(t, { authorize: async () => unusedClient }); + const activeHeaders = await session(active); + assert.equal((await fetch(active.url + "/api/session", { method: "DELETE", headers: activeHeaders })).status, 200); + assert.equal((await fetch(active.url + "/api/status", { headers: activeHeaders })).status, 401); +}); + +test("web console: actual ControlHost dispatch serves live status/header through injected bridge", async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-console-host-")); + fs.mkdirSync(path.join(root, "home")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const unavailable = async (): Promise => { throw new Error("Provider execution is outside this status-only integration test"); }; + const host = new ControlHost({ mode: "standalone", controlHome: path.join(root, "home"), projectStorePath: path.join(root, "project"), provider: createTeExecutionProvider({ assurance: "resolve-only-no-sandbox", probe: unavailable, prepare: unavailable, submit: unavailable, watch: async function* () { throw new Error("No test execution provider"); } }) }); + t.after(() => host.stop()); + await host.start(); + assert.equal(host.hello(defaultServerHello()).ok, true); + let liveChecks = 0; + const console = await launch(t, { authorize: async () => { + liveChecks++; + return { call: async (operation) => { + if (operation === "control.status") return host.dispatch(operation, {}, { fencingEpoch: host.status.fencingEpoch }); + if (operation === "projects.list") { + const header = await host.dispatch<{ projectId: string; controlDomainId: string }>("control.store.header", {}, { fencingEpoch: host.status.fencingEpoch }); + return [{ projectId: header.projectId, controlDomainId: header.controlDomainId, path: root }]; + } + throw new ControlError("TF_FEATURE_REQUIRED", "Owner lifecycle integration pending"); + } }; + } }); + const headers = await session(console); + const status = await (await fetch(console.url + "/api/status", { headers })).json() as { result: { state: string; singleton: string } }; + assert.equal(status.result.state, "started"); + assert.equal(status.result.singleton, "standalone"); + const projects = await (await fetch(console.url + "/api/projects", { headers })).json() as { result: { projectId: string }[] }; + assert.equal(projects.result.length, 1); + assert.notEqual(projects.result[0]!.projectId, "00000000-0000-0000-0000-000000000000"); + assert.equal(liveChecks, 2); +}); + +test("web console: invalid intents and oversized bodies cannot reach owner", async (t) => { + let calls = 0; + const console = await launch(t, { authorize: async () => { calls++; return unusedClient; } }); + const headers = await session(console); + const url = console.url + scoped(`/approvals/${approvalId}/decisions`); + for (const patch of [{ expectedRunVersion: -1 }, { expectedRunVersion: 1.5 }, { expectedRunVersion: "3" }, { decision: ["approve"] }, { commandId: "bad" }]) { + assert.equal((await fetch(url, { method: "POST", headers, body: JSON.stringify({ ...decision(), ...patch }) })).status, 400); + } + assert.equal((await fetch(url, { method: "POST", headers, body: JSON.stringify({ ...decision(), padding: "x".repeat(70_000) }) })).status, 413); + assert.equal((await fetch(console.url + `/api/projects/${projectId}/runs`, { headers })).status, 400); + assert.equal(calls, 0); +}); + +test("web console: expired bootstrap cannot create session", async (t) => { + const console = await launch(t, { authorize: async () => unusedClient, bootstrapTtlMs: 20 }); + await delay(30); + const response = await fetch(console.url + "/api/session", { method: "POST", headers: { Origin: console.url, "Content-Type": "application/json" }, body: JSON.stringify({ token: console.bootstrapToken }) }); + assert.equal(response.status, 401); +}); From 57c93caa1c952c44944c893d53a24420b28dedff Mon Sep 17 00:00:00 2001 From: heggria Date: Sat, 3 Oct 2026 20:07:00 +0800 Subject: [PATCH 20/30] fix(control): fence evidence disclosure and detect path replacement --- .../src/store/evidence-store.ts | 132 +++++++++++++----- .../test/evidence-store.test.ts | 104 ++++++++++++++ 2 files changed, 200 insertions(+), 36 deletions(-) diff --git a/packages/taskflow-control/src/store/evidence-store.ts b/packages/taskflow-control/src/store/evidence-store.ts index f2281779..999665d4 100644 --- a/packages/taskflow-control/src/store/evidence-store.ts +++ b/packages/taskflow-control/src/store/evidence-store.ts @@ -62,7 +62,12 @@ export interface EvidenceStoreOptions { storePath: string; ledger: { read(): EvidenceLedgerView | Promise; - /** Hold the SAME writer fence until the returned operation Promise settles. */ + /** + * Hold the SAME writer fence until the returned operation Promise settles, + * including disclosures. ALL journal/projection mutations must use this + * fence; reads within it must not recursively acquire it. Authorization + * must evaluate current policy at completion, not return a cached grant. + */ withWriter(operation: (view: EvidenceLedgerView) => Promise): Promise; /** Atomic journal marker + tip CAS, called INSIDE the existing writer fence. */ commitReceiptOnce(runId: string, prepared: PreparedEvidenceReceipt): Promise; @@ -101,6 +106,9 @@ export interface PreparedEvidenceReceipt extends IssuedEvidenceReceipt { expectedTip: { commitSeq: number; hash: string }; } +interface DirectoryIdentity { path: string; device: number; inode: number } +interface ArtifactPath { file: string; directories: DirectoryIdentity[] } + const HEX = /^[0-9a-f]{64}$/; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -187,9 +195,13 @@ export class EvidenceStore { }); } - #path(ref: ArtifactRef, create: boolean): string { + #path(ref: ArtifactRef, create: boolean): ArtifactPath { validRef(ref); let current = this.#root; + const root = fs.lstatSync(current); + if (!root.isDirectory() || root.isSymbolicLink() || fs.realpathSync(current) !== current + || String(root.dev) !== this.#identity.device || String(root.ino) !== this.#identity.inode) fail("evidence root identity changed"); + const directories: DirectoryIdentity[] = [{ path: current, device: root.dev, inode: root.ino }]; for (const part of ["artifacts", "sha256", ref.digest.slice(0, 2)]) { current = path.join(current, part); if (create && !fs.existsSync(current)) { @@ -198,17 +210,36 @@ export class EvidenceStore { try { fs.fsyncSync(parentFd); } finally { fs.closeSync(parentFd); } } const st = fs.lstatSync(current); - if (!st.isDirectory() || st.isSymbolicLink()) fail("artifact directory is not a confined regular directory"); + if (!st.isDirectory() || st.isSymbolicLink() || fs.realpathSync(current) !== current) fail("artifact directory is not a confined regular directory"); + directories.push({ path: current, device: st.dev, inode: st.ino }); } const file = path.join(current, ref.digest); try { if (!fs.lstatSync(file).isFile()) fail("artifact destination is not a regular file"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } - return file; + return { file, directories }; + } + + // Revalidate every ancestor at durability boundaries, including before any + // cleanup. Portable Node path APIs cannot guarantee confinement against a + // hostile same-UID process replacing paths between individual syscalls. + #assertDirectories(directories: readonly DirectoryIdentity[]): void { + for (const directory of directories) { + const st = fs.lstatSync(directory.path); + if (!st.isDirectory() || st.isSymbolicLink() || st.dev !== directory.device || st.ino !== directory.inode + || fs.realpathSync(directory.path) !== directory.path) fail("artifact directory identity changed"); + } } #read(ref: ArtifactRef): Buffer { - const file = this.#path(ref, false); - const bytes = fs.readFileSync(file); + const { file, directories } = this.#path(ref, false); + this.#assertDirectories(directories); + const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + let bytes: Buffer; + try { + if (!fs.fstatSync(fd).isFile()) fail("artifact is not a regular file"); + bytes = fs.readFileSync(fd); + } finally { fs.closeSync(fd); } + this.#assertDirectories(directories); if (bytes.length !== ref.size || digest(bytes) !== ref.digest) fail("artifact bytes do not match committed reference"); return bytes; } @@ -216,22 +247,40 @@ export class EvidenceStore { #stage(bytes: Uint8Array, metadata: Omit): ArtifactRef { if (metadata.redactionClass === "secret") throw new ControlError("TF_POLICY_DENIED", "secret material must use SecretRef, not the generic artifact store"); const ref: ArtifactRef = { ...metadata, size: bytes.length, digest: digest(bytes) }; - const file = this.#path(ref, true); + const { file, directories } = this.#path(ref, true); if (fs.existsSync(file)) { this.#read(ref); return ref; } const temp = path.join(path.dirname(file), `.${crypto.randomUUID()}`); + this.#assertDirectories(directories); const fd = fs.openSync(temp, "wx", 0o600); + const tempIdentity = fs.fstatSync(fd); try { try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } this.#options.onDurabilityPoint?.("file-fsynced"); + this.#assertDirectories(directories); + const staged = fs.lstatSync(temp); + if (!staged.isFile() || staged.dev !== tempIdentity.dev || staged.ino !== tempIdentity.ino) fail("staged artifact identity changed"); try { fs.linkSync(temp, file); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; this.#read(ref); } + this.#assertDirectories(directories); const directoryFd = fs.openSync(path.dirname(file), "r"); try { fs.fsyncSync(directoryFd); } finally { fs.closeSync(directoryFd); } this.#options.onDurabilityPoint?.("file-published"); - } finally { fs.unlinkSync(temp); } + this.#assertDirectories(directories); + this.#read(ref); + } finally { + // Preserve forensic staging bytes if the original directory or file was + // replaced. In particular, never follow a replacement symlink to clean up. + let unchanged = false; + try { + this.#assertDirectories(directories); + const staged = fs.lstatSync(temp); + unchanged = staged.isFile() && staged.dev === tempIdentity.dev && staged.ino === tempIdentity.ino; + } catch { /* Untrusted cleanup path: leave it alone. */ } + if (unchanged) fs.unlinkSync(temp); + } return ref; } @@ -316,27 +365,37 @@ export class EvidenceStore { return { receipt, receiptRef, manifestProofRef, expectedTip }; } - async #authorize(actor: unknown, target: { kind: "run" | "command"; id: string }, artifactRef?: ArtifactRef): Promise { - const view = await this.#options.ledger.read(); - this.#validateView(view); - const decision = await this.#options.authorize(actor, { projectId: view.projectId, controlDomainId: view.controlDomainId, target, ...(artifactRef ? { artifactRef } : {}) }); - if (!decision || typeof decision.principal !== "string" || decision.principal.length === 0 - || !HEX.test(decision.authorizationContextHash)) throw new ControlError("TF_POLICY_DENIED", "evidence authorization decision unavailable"); - // Reachability is read afresh AFTER asynchronous policy evaluation, never - // cached from a previous request or inferred from the policy audit hash. - const current = await this.#options.ledger.read(); - this.#validateView(current); - return current; + async #disclose(actor: unknown, target: { kind: "run" | "command"; id: string }, artifactRef: ArtifactRef | undefined, + read: (view: EvidenceLedgerView) => T): Promise { + return this.#write(async (view) => { + const authorize = () => this.#options.authorize(actor, { projectId: view.projectId, controlDomainId: view.controlDomainId, + target, ...(artifactRef ? { artifactRef } : {}) }); + const validateDecision = (decision: Awaited>) => { + if (!decision || typeof decision.principal !== "string" || decision.principal.length === 0 + || !HEX.test(decision.authorizationContextHash)) throw new ControlError("TF_POLICY_DENIED", "evidence authorization decision unavailable"); + }; + validateDecision(await authorize()); + // The owner fence protects this fresh projection through disclosure. A + // final live policy check must follow the last asynchronous ledger read: + // revocation during that read may not reuse the earlier decision. + const current = await this.#options.ledger.read(); + this.#validateView(current); + validateDecision(await authorize()); + this.#validateView(current); + // No await between final authorization, reachability and filesystem read. + return read(current); + }); } async readArtifact(actor: unknown, target: { kind: "run" | "command"; id: string }, ref: ArtifactRef): Promise { - const view = await this.#authorize(actor, target, ref); - validRef(ref); - const refs = target.kind === "run" - ? (Object.hasOwn(view.runs, target.id) ? view.runs[target.id].artifactRefs : []) - : (Object.hasOwn(view.commands, target.id) && view.commands[target.id].responseArtifactRef ? [view.commands[target.id].responseArtifactRef!] : []); - if (!refs.some((candidate) => sameRef(candidate, ref))) throw new ControlError("TF_POLICY_DENIED", "artifact has no authorized ledger reference"); - return this.#read(ref); + return this.#disclose(actor, target, ref, (view) => { + validRef(ref); + const refs = target.kind === "run" + ? (Object.hasOwn(view.runs, target.id) ? view.runs[target.id].artifactRefs : []) + : (Object.hasOwn(view.commands, target.id) && view.commands[target.id].responseArtifactRef ? [view.commands[target.id].responseArtifactRef!] : []); + if (!refs.some((candidate) => sameRef(candidate, ref))) throw new ControlError("TF_POLICY_DENIED", "artifact has no authorized ledger reference"); + return this.#read(ref); + }); } #verifyReceipt(view: EvidenceLedgerView, runId: string, issued: IssuedEvidenceReceipt): Receipt { @@ -359,17 +418,18 @@ export class EvidenceStore { } async readReceipt(actor: unknown, runId: string): Promise<{ receipt: Receipt; verification: { artifactIntegrity: "verified" | "unknown" } }> { - const view = await this.#authorize(actor, { kind: "run", id: runId }); - if (!Object.hasOwn(view.receipts, runId)) throw new ControlError("TF_POLICY_DENIED", "run has no issued Receipt"); - const receipt = this.#verifyReceipt(view, runId, view.receipts[runId]); - let artifactIntegrity: "verified" | "unknown" = "verified"; - for (const ref of receipt.artifactRefs) { - try { this.#read(ref); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") artifactIntegrity = "unknown"; - else throw error; + return this.#disclose(actor, { kind: "run", id: runId }, undefined, (view) => { + if (!Object.hasOwn(view.receipts, runId)) throw new ControlError("TF_POLICY_DENIED", "run has no issued Receipt"); + const receipt = this.#verifyReceipt(view, runId, view.receipts[runId]); + let artifactIntegrity: "verified" | "unknown" = "verified"; + for (const ref of receipt.artifactRefs) { + try { this.#read(ref); } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") artifactIntegrity = "unknown"; + else throw error; + } } - } - return { receipt, verification: { artifactIntegrity } }; + return { receipt, verification: { artifactIntegrity } }; + }); } #floor(view: EvidenceLedgerView): number { diff --git a/packages/taskflow-control/test/evidence-store.test.ts b/packages/taskflow-control/test/evidence-store.test.ts index f2e9609a..5686c3ee 100644 --- a/packages/taskflow-control/test/evidence-store.test.ts +++ b/packages/taskflow-control/test/evidence-store.test.ts @@ -508,3 +508,107 @@ for (const point of ["file-fsynced", "file-published"]) { assert.deepEqual(f.read().receipts, {}); }); } + +for (const level of ["root", "artifacts", "sha256", "bucket"] as const) { + for (const replacement of ["symlink", "directory"] as const) { + test(`evidence publication: ${level} ${replacement} replacement after fsync fails closed without redirected cleanup`, async (t) => { + const f = fixture(t); + const foreign = fixture(t); + const bytes = Buffer.from("confined publication"); + const hash = createHash("sha256").update(bytes).digest("hex"); + const parts = ["artifacts", "sha256", hash.slice(0, 2)]; + const depth = { root: 0, artifacts: 1, sha256: 2, bucket: 3 }[level]; + const replaced = path.join(f.root, ...parts.slice(0, depth)); + const moved = path.join(foreign.root, "moved"); + let staged = ""; + const store = await createEvidenceStore({ ...f.options, onDurabilityPoint: (point) => { + if (point !== "file-fsynced") return; + staged = fs.readdirSync(path.join(f.root, ...parts)).find((file) => file.startsWith("."))!; + fs.renameSync(replaced, moved); + if (replacement === "symlink") fs.symlinkSync(moved, replaced, "dir"); + else { + fs.mkdirSync(path.join(replaced, ...parts.slice(depth)), { recursive: true }); + fs.writeFileSync(path.join(replaced, ...parts.slice(depth), staged), "replacement forensic data"); + } + } }); + await assert.rejects(store.stageArtifact(bytes, META), isCode("TF_DURABILITY_FAILED")); + const movedBucket = path.join(moved, ...parts.slice(depth)); + assert.equal(fs.existsSync(path.join(movedBucket, hash)), false, "no published digest outside original directory"); + assert.deepEqual(fs.readFileSync(path.join(movedBucket, staged)), bytes, "original staging bytes preserved"); + if (replacement === "directory") assert.equal(fs.readFileSync(path.join(replaced, ...parts.slice(depth), staged), "utf8"), "replacement forensic data"); + }); + } +} + +test("evidence publication: replacement after publish cannot acknowledge and cannot delete moved staging", async (t) => { + const f = fixture(t); + const foreign = fixture(t); + const bytes = Buffer.from("published then replaced"); + const hash = createHash("sha256").update(bytes).digest("hex"); + const bucket = path.join(f.root, "artifacts", "sha256", hash.slice(0, 2)); + const moved = path.join(foreign.root, "moved"); + const store = await createEvidenceStore({ ...f.options, onDurabilityPoint: (point) => { + if (point === "file-published") { fs.renameSync(bucket, moved); fs.symlinkSync(moved, bucket, "dir"); } + } }); + await assert.rejects(store.stageArtifact(bytes, META), isCode("TF_DURABILITY_FAILED")); + assert.equal(fs.readdirSync(moved).length, 2, "both digest and forensic staging bytes retained"); +}); + +for (const kind of ["artifact", "receipt"] as const) { + test(`evidence ${kind}: revoke during final async ledger read denies disclosure`, async (t) => { + const f = fixture(t); + const ref = await (await f.open()).stageArtifact(Buffer.from("revoked before disclosure"), META); + f.terminal([ref]); + await (await f.open()).issueFinalReceipt(RUN); + const entered = barrier(); + const resume = barrier(); + let policyChecks = 0; + const store = await createEvidenceStore({ ...f.options, + authorize: async (...args) => { policyChecks++; return f.options.authorize(...args); }, + ledger: { ...f.options.ledger, read: async () => { + if (policyChecks === 1) { entered.release(); await resume.waiting; } + return f.read(); + } }, + }); + const reading = kind === "artifact" ? store.readArtifact(actor, { kind: "run", id: RUN }, ref) : store.readReceipt(actor, RUN); + await entered.waiting; + f.revoke(); + resume.release(); + await assert.rejects(reading, isCode("TF_AUTHORITY_REVOKED")); + assert.equal(policyChecks, 2); + }); +} + +test("evidence disclosure: serialized owner fence orders removal before reads and blocks mutations through authorization", async (t) => { + const f = fixture(t); + const ref = await (await f.open()).stageArtifact(Buffer.from("fenced output"), META); + f.terminal([ref]); + let tail = Promise.resolve(); + let held = false; + const withWriter: EvidenceStoreOptions["ledger"]["withWriter"] = async (operation) => { + const predecessor = tail; + const released = barrier(); + tail = released.waiting; + await predecessor; + assert.equal(held, false); + held = true; + try { return await operation(f.read()); } finally { held = false; released.release(); } + }; + const entered = barrier(); + const resume = barrier(); + const store = await createEvidenceStore({ ...f.options, + ledger: { ...f.options.ledger, withWriter }, + authorize: async (...args) => { assert.equal(held, true); entered.release(); await resume.waiting; return f.options.authorize(...args); }, + }); + const reading = store.readArtifact(actor, { kind: "run", id: RUN }, ref); + await entered.waiting; + let removed = false; + const removal = withWriter(async () => { f.edit((view) => { view.runs[RUN].artifactRefs = []; }); removed = true; }); + await Promise.resolve(); + assert.equal(removed, false, "journal mutator cannot run during the disclosure fence"); + resume.release(); + assert.equal((await reading).toString(), "fenced output"); + await removal; + assert.equal(removed, true); + await assert.rejects(store.readArtifact(actor, { kind: "run", id: RUN }, ref), isCode("TF_POLICY_DENIED")); +}); From a5a07fc465ecfaa7fa4b4ac96d002d3f4c9b6756 Mon Sep 17 00:00:00 2001 From: heggria Date: Sat, 3 Oct 2026 20:07:00 +0800 Subject: [PATCH 21/30] fix(control): revalidate console sessions across asynchronous work --- packages/taskflow-control/src/web-console.ts | 49 +++++++-- .../taskflow-control/src/web-console/view.ts | 11 ++- .../taskflow-control/test/web-console.test.ts | 99 ++++++++++++++++++- 3 files changed, 147 insertions(+), 12 deletions(-) diff --git a/packages/taskflow-control/src/web-console.ts b/packages/taskflow-control/src/web-console.ts index 67582a34..a31ae71e 100644 --- a/packages/taskflow-control/src/web-console.ts +++ b/packages/taskflow-control/src/web-console.ts @@ -31,6 +31,8 @@ export interface WebConsoleControlClient { } export interface WebConsoleAuthorization { operation: WebConsoleOperation; + /** Re-check before dispatch and before disclosing its result; no second call. */ + purpose: "execute" | "disclose"; /** Random browser-session handle, NEVER a claimed principal. */ sessionId: string; projectId?: string; @@ -38,7 +40,8 @@ export interface WebConsoleAuthorization { } export interface WebConsoleOptions { /** Required live check. Resolve verified identity from trusted launcher state, - * never from HTTP input/sessionId alone. Re-check project/operation policy. */ + * never from HTTP input/sessionId alone. Re-check project/operation policy for + * both purposes. The owner must also enforce live policy at mutation commit. */ authorize: (request: WebConsoleAuthorization) => Promise; port?: number; /** Absolute browser session lifetime, default 30 minutes. */ @@ -108,6 +111,8 @@ export async function startWebConsole(options: WebConsoleOptions): Promise | undefined; let origin = ""; let authority = ""; const sessions = new Map(); @@ -132,12 +137,18 @@ export async function startWebConsole(options: WebConsoleOptions): Promise { + if (closed || request.aborted || request.socket.destroyed || response.destroyed || response.writableEnded) throw new HttpFailure(503, "Console request closed"); + }; + let assertSession: (() => void) | undefined; const send = (status: number, value: unknown): void => { + assertConnected(); response.statusCode = status; response.setHeader("Content-Type", "application/json; charset=utf-8"); response.end(JSON.stringify(value)); }; try { + assertConnected(); if (request.headers.host !== authority) throw new HttpFailure(403, "Invalid console Host"); if (request.headers.origin !== undefined && request.headers.origin !== origin) throw new HttpFailure(403, "Invalid console Origin"); const site = request.headers["sec-fetch-site"]; @@ -156,6 +167,7 @@ export async function startWebConsole(options: WebConsoleOptions): Promise= bootstrapExpiresAt || !sameSecret(body.token, bootstrapToken)) throw new HttpFailure(401, "Invalid or expired launcher token"); // Consume synchronously before any await: concurrent exchanges cannot reuse it. @@ -174,6 +186,13 @@ export async function startWebConsole(options: WebConsoleOptions): Promise { + assertConnected(); + if (sessions.get(sessionId) !== session || Date.now() >= session.expiresAt) { + if (sessions.get(sessionId) === session) sessions.delete(sessionId); + throw new HttpFailure(401, "Console session expired; launch a new session"); + } + }; if (mutating && !sameSecret(request.headers["x-taskflow-csrf"], session.csrf)) throw new HttpFailure(403, "Invalid CSRF token"); if (url.pathname === "/api/session") { if (method === "DELETE") { @@ -209,6 +228,7 @@ export async function startWebConsole(options: WebConsoleOptions): Promise { + return { url: origin, bootstrapToken, close: () => { + if (closing) return closing; + closed = true; sessions.clear(); for (const socket of sockets) socket.destroy(); - await new Promise((resolve) => server.close(() => resolve())); + closing = new Promise((resolve) => server.close(() => resolve())); + return closing; } }; } diff --git a/packages/taskflow-control/src/web-console/view.ts b/packages/taskflow-control/src/web-console/view.ts index 69dc6946..c09ffef6 100644 --- a/packages/taskflow-control/src/web-console/view.ts +++ b/packages/taskflow-control/src/web-console/view.ts @@ -16,7 +16,7 @@ export const CONSOLE_CSS = `:root{color-scheme:light;--ink:#192a30;--muted:#6777 export const CONSOLE_JS = String.raw`'use strict'; const $ = (id) => document.getElementById(id); -const state = { csrf: '', features: {}, project: null, projects: [], runs: [], generation: 0, commands: new Map() }; +const state = { csrf: '', features: {}, project: null, projects: [], runs: [], generation: 0, evidenceGeneration: 0, commands: new Map() }; function node(tag, text, className) { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (className) e.className = className; return e; } function clear(e) { e.replaceChildren(); } function notice(text, error = false) { $('notice').textContent = text; $('notice').className = error ? 'error' : ''; } @@ -100,10 +100,13 @@ $('refresh-projects').addEventListener('click', () => { void projects(); }); $(' $('logout').addEventListener('click', async () => { try { await api('/api/session', undefined, 'DELETE'); location.reload(); } catch (e) { notice(message(e), true); } }); async function evidence(kind) { const p = state.project; const runId = $('run-choice').value; if (!p || !runId || !state.runs.length) return; - const g = state.generation; $('evidence-output').textContent = 'Loading evidence…'; - try { let url = projectUrl(p, '/runs/' + encodeURIComponent(runId) + (kind === 'receipt' ? '/receipt' : '/why')); if (kind !== 'receipt') url += '&kind=' + kind; if (kind === 'effect') url += '&effectId=' + encodeURIComponent($('effect-id').value); if ($('phase-id').value) url += '&phaseId=' + encodeURIComponent($('phase-id').value); const data = await api(url); if (g !== state.generation) return; $('evidence-output').textContent = data.result === null && kind === 'receipt' ? 'No final Receipt has been issued for this run.' : JSON.stringify(data.result, null, 2); } - catch (e) { if (g === state.generation) $('evidence-output').textContent = message(e); } + const g = state.generation; const e = ++state.evidenceGeneration; const effectId = $('effect-id').value; const phaseId = $('phase-id').value; + const current = () => g === state.generation && e === state.evidenceGeneration && runId === $('run-choice').value && effectId === $('effect-id').value && phaseId === $('phase-id').value; + $('evidence-output').textContent = 'Loading evidence…'; + try { let url = projectUrl(p, '/runs/' + encodeURIComponent(runId) + (kind === 'receipt' ? '/receipt' : '/why')); if (kind !== 'receipt') url += '&kind=' + kind; if (kind === 'effect') url += '&effectId=' + encodeURIComponent(effectId); if (phaseId) url += '&phaseId=' + encodeURIComponent(phaseId); const data = await api(url); if (!current()) return; $('evidence-output').textContent = data.result === null && kind === 'receipt' ? 'No final Receipt has been issued for this run.' : JSON.stringify(data.result, null, 2); } + catch (error) { if (current()) $('evidence-output').textContent = message(error); } } +for (const id of ['run-choice', 'effect-id', 'phase-id']) $(id).addEventListener('input', () => { state.evidenceGeneration++; $('evidence-output').textContent = 'Choose an evidence view for the current selection.'; }); $('receipt').addEventListener('click', () => { void evidence('receipt'); }); $('why-stale').addEventListener('click', () => { void evidence('stale'); }); $('why-effect').addEventListener('click', () => { void evidence('effect'); }); void api('/api/session').then(connected).catch(() => {}); `; diff --git a/packages/taskflow-control/test/web-console.test.ts b/packages/taskflow-control/test/web-console.test.ts index cd0c13a8..2bee767e 100644 --- a/packages/taskflow-control/test/web-console.test.ts +++ b/packages/taskflow-control/test/web-console.test.ts @@ -4,6 +4,8 @@ import * as fs from "node:fs"; import * as http from "node:http"; import * as os from "node:os"; import * as path from "node:path"; +import * as vm from "node:vm"; +import { CONSOLE_JS } from "../src/web-console/view.ts"; import { test, type TestContext } from "node:test"; import { setTimeout as delay } from "node:timers/promises"; import { startWebConsole, type WebConsoleOptions, type WebConsoleServer, type WebConsoleAuthorization } from "../src/web-console.ts"; @@ -89,7 +91,8 @@ test("web console: live authorization runs for every request and revocation deni const denied = await fetch(console.url + scoped("/runs"), { headers }); assert.equal(denied.status, 403); assert.match(await denied.text(), /TF_AUTHORITY_REVOKED/); - assert.equal(auth.length, 3); + assert.equal(auth.length, 5); + assert.deepEqual(auth.map((a) => a.purpose), ["execute", "disclose", "execute", "disclose", "execute"]); assert.equal(calls, 2); assert.equal(auth[0]!.projectId, projectId); assert.equal(auth[0]!.controlDomainId, domainId); @@ -197,7 +200,7 @@ test("web console: actual ControlHost dispatch serves live status/header through const projects = await (await fetch(console.url + "/api/projects", { headers })).json() as { result: { projectId: string }[] }; assert.equal(projects.result.length, 1); assert.notEqual(projects.result[0]!.projectId, "00000000-0000-0000-0000-000000000000"); - assert.equal(liveChecks, 2); + assert.equal(liveChecks, 4); }); test("web console: invalid intents and oversized bodies cannot reach owner", async (t) => { @@ -219,3 +222,95 @@ test("web console: expired bootstrap cannot create session", async (t) => { const response = await fetch(console.url + "/api/session", { method: "POST", headers: { Origin: console.url, "Content-Type": "application/json" }, body: JSON.stringify({ token: console.bootstrapToken }) }); assert.equal(response.status, 401); }); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +for (const boundary of ["execute", "call", "disclose"] as const) { + for (const invalidation of ["logout", "expiry", "close", "disconnect"] as const) { + test(`web console: ${invalidation} during ${boundary} prevents later dispatch/disclosure`, { timeout: 5000 }, async (t) => { + const entered = deferred(); + const resume = deferred(); + const finished = deferred(); + let calls = 0; + const console = await launch(t, { + sessionTtlMs: invalidation === "expiry" ? 200 : 60_000, + authorize: async ({ purpose }) => { + if (purpose === boundary) { entered.resolve(); await resume.promise; finished.resolve(); } + return { call: async () => { + calls++; + if (boundary === "call") { entered.resolve(); await resume.promise; finished.resolve(); } + return { secret: "private-result" }; + } }; + }, + }); + const headers = await session(console); + const controller = new AbortController(); + const pending = fetch(console.url + scoped(`/approvals/${approvalId}/decisions`), { method: "POST", headers, body: JSON.stringify(decision()), signal: controller.signal }).then(async (r) => ({ status: r.status, text: await r.text() }), () => null); + await entered.promise; + if (invalidation === "logout") assert.equal((await fetch(console.url + "/api/session", { method: "DELETE", headers })).status, 200); + if (invalidation === "expiry") await delay(220); + if (invalidation === "close") await console.close(); + if (invalidation === "disconnect") { controller.abort(); await pending; await delay(20); } + resume.resolve(); + await finished.promise; + const result = await pending; + await delay(10); + assert.equal(calls, boundary === "execute" ? 0 : 1); + if (invalidation === "logout" || invalidation === "expiry") { + assert.equal(result?.status, 401); + assert.doesNotMatch(result!.text, /private-result/); + } else assert.equal(result, null); + }); + } +} + +for (const failure of [false, true]) { + test(`web console: revocation during owner call suppresses delayed ${failure ? "error" : "result"} without replaying mutation`, async (t) => { + const entered = deferred(); const resume = deferred(); + let revoked = false; let calls = 0; + const purposes: string[] = []; + const console = await launch(t, { authorize: async ({ purpose }) => { + purposes.push(purpose); + if (revoked) throw new ControlError("TF_AUTHORITY_REVOKED", "revoked"); + return { call: async () => { + calls++; entered.resolve(); await resume.promise; + if (failure) throw new ControlError("TF_STALE_VERSION", "private-result"); + return { secret: "private-result" }; + } }; + } }); + const headers = await session(console); + const pending = fetch(console.url + scoped(`/approvals/${approvalId}/decisions`), { method: "POST", headers, body: JSON.stringify(decision()) }); + await entered.promise; revoked = true; resume.resolve(); + const response = await pending; + assert.equal(response.status, 403); + assert.doesNotMatch(await response.text(), /private-result/); + assert.equal(calls, 1); + assert.deepEqual(purposes, ["execute", "disclose"]); + }); +} + +test("web console UI: older evidence response cannot overwrite a newer request in the same project", async () => { + const elements = new Map void }>(); + const getElementById = (id: string) => { + if (!elements.has(id)) elements.set(id, { value: "", textContent: "", addEventListener: () => {} }); + return elements.get(id)!; + }; + const reads = [deferred<{ ok: boolean; json: () => Promise }>(), deferred<{ ok: boolean; json: () => Promise }>()]; + let index = 0; + const context = vm.createContext({ document: { getElementById, querySelectorAll: () => [] }, fetch: (url: string) => url === "/api/session" ? new Promise(() => {}) : reads[index++]!.promise }); + vm.runInContext(CONSOLE_JS, context); + vm.runInContext(`state.project = { projectId: '${projectId}', controlDomainId: '${domainId}' }; state.runs = [{}];`, context); + getElementById("run-choice").value = runId; + const old = vm.runInContext("evidence('receipt')", context) as Promise; + const current = vm.runInContext("evidence('stale')", context) as Promise; + reads[1]!.resolve({ ok: true, json: async () => ({ result: { current: true } }) }); + await current; + reads[0]!.resolve({ ok: true, json: async () => ({ result: { obsolete: true } }) }); + await old; + assert.match(getElementById("evidence-output").textContent, /current/); + assert.doesNotMatch(getElementById("evidence-output").textContent, /obsolete/); +}); From 0c0df53ed3f42b00fbdb5b42f27cea6966744daf Mon Sep 17 00:00:00 2001 From: heggria Date: Sat, 3 Oct 2026 20:14:27 +0800 Subject: [PATCH 22/30] fix(core): bind persistent mutex queues and ticket cleanup --- .../src/resources/persistence.ts | 255 ++++++++++++------ .../test/resource-persistence.test.ts | 115 +++++++- 2 files changed, 293 insertions(+), 77 deletions(-) diff --git a/packages/taskflow-core/src/resources/persistence.ts b/packages/taskflow-core/src/resources/persistence.ts index 9aa0cf6a..fc2e4c45 100644 --- a/packages/taskflow-core/src/resources/persistence.ts +++ b/packages/taskflow-core/src/resources/persistence.ts @@ -250,8 +250,24 @@ function ownerIsStale(record: ProcessIdentity, identity: ProcessIdentity, inspec observed.birthToken !== undefined && observed.birthToken !== record.birthToken; } +interface FileIdentity { dev: number; ino: number; } +interface PendingRelease { + queueDirectory: string; + queueIdentity: FileIdentity; + record: MutexChoosingRecord | MutexTicketRecord; + fileIdentity: FileIdentity; +} interface TaskflowPersistenceGlobal { - __taskflowPendingMutexTicketReleasesV1?: Map; + __taskflowPendingMutexTicketReleasesV2?: Map; +} +function sameIdentity(a: FileIdentity, b: FileIdentity): boolean { + return a.dev === b.dev && a.ino === b.ino; +} +function sameOwner(a: MutexChoosingRecord | MutexTicketRecord, b: MutexChoosingRecord | MutexTicketRecord): boolean { + return a.kind === b.kind && a.token === b.token && a.pid === b.pid && + a.birthToken === b.birthToken && a.birthTokenKind === b.birthTokenKind && + a.createdAt === b.createdAt && + (a.kind !== "ticket" || (b.kind === "ticket" && a.ticket === b.ticket)); } // A mutex instance can disappear after returning a completed critical-section @@ -260,7 +276,7 @@ interface TaskflowPersistenceGlobal { // on the ticket itself carries the same fact across worker isolates/processes. const persistenceGlobal = globalThis as typeof globalThis & TaskflowPersistenceGlobal; const PROCESS_PENDING_TICKET_RELEASES = - persistenceGlobal.__taskflowPendingMutexTicketReleasesV1 ??= new Map(); + persistenceGlobal.__taskflowPendingMutexTicketReleasesV2 ??= new Map(); function delay(ms: number, signal?: AbortSignal): Promise { if (signal?.aborted) return Promise.reject(new CoordinatorAbortError()); @@ -305,71 +321,140 @@ export class PersistentFileMutex { return `${this.lockPath}.queue`; } - #removeTicketBestEffort(ticketPath: string, queueDirectory: string): boolean { + #assertQueue(queueDirectory: string, expected: FileIdentity): void { + const current = fs.lstatSync(queueDirectory); + if (!current.isDirectory() || !sameIdentity(current, expected)) { + throw new Error("TFWS_MUTEX_IDENTITY: mutex queue was replaced"); + } + } + + #bindQueue(queueDirectory: string): FileIdentity { + // A sibling durable anchor lets a fresh process detect replacement even + // when the old queue and its live tickets have been renamed out of view. + const anchor = `${queueDirectory}.identity`; + ensureDirectory(path.dirname(queueDirectory)); + let created = false; + try { + fs.lstatSync(queueDirectory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + // Never recreate a missing queue from an established namespace. + try { + fs.lstatSync(anchor); + throw new Error("TFWS_MUTEX_IDENTITY: anchored mutex queue is missing"); + } catch (anchorError) { + if ((anchorError as NodeJS.ErrnoException).code !== "ENOENT") throw anchorError; + } + try { fs.mkdirSync(queueDirectory, { mode: 0o700 }); created = true; } + catch (mkdirError) { if ((mkdirError as NodeJS.ErrnoException).code !== "EEXIST") throw mkdirError; } + } + const stat = fs.lstatSync(queueDirectory); + if (!stat.isDirectory()) throw new Error("TFWS_MUTEX_IDENTITY: queue must be a real directory"); + const identity = { dev: stat.dev, ino: stat.ino }; + const temp = `${anchor}.tmp.${crypto.randomUUID()}`; + if (created) try { + const fd = fs.openSync(temp, "wx", 0o600); + try { fs.writeFileSync(fd, JSON.stringify(identity)); fs.fsyncSync(fd); } + finally { fs.closeSync(fd); } + try { fs.linkSync(temp, anchor); fsyncDirectory(path.dirname(anchor)); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } + } finally { try { fs.unlinkSync(temp); } catch { /* unpublished temporary file */ } } + const fd = fs.openSync(anchor, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + const anchorStat = fs.fstatSync(fd); + if (!anchorStat.isFile() || !sameIdentity(anchorStat, fs.lstatSync(anchor))) { + throw new Error("TFWS_MUTEX_IDENTITY: invalid queue anchor"); + } + const saved = JSON.parse(fs.readFileSync(fd, "utf8")) as FileIdentity; + if (!sameIdentity(saved, identity)) throw new Error("TFWS_MUTEX_IDENTITY: mutex queue was replaced"); + } finally { fs.closeSync(fd); } + this.#assertQueue(queueDirectory, identity); + return identity; + } + + #removeTicketBestEffort(ticketPath: string, pending: PendingRelease, terminalOnly = true): boolean { for (let attempt = 0; attempt < 3; attempt++) { try { + this.#assertQueue(pending.queueDirectory, pending.queueIdentity); + const stat = fs.lstatSync(ticketPath); + if (!stat.isFile() || !sameIdentity(stat, pending.fileIdentity)) return false; + const current = JSON.parse(fs.readFileSync(ticketPath, "utf8")) as MutexTicketRecord; + if (!sameOwner(current, pending.record) || current.state !== (terminalOnly ? "done" : pending.record.state)) return false; + if (!sameIdentity(fs.lstatSync(ticketPath), stat)) return false; fs.unlinkSync(ticketPath); - fsyncDirectory(queueDirectory); + fsyncDirectory(pending.queueDirectory); return true; } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; + // A missing ticket is already released, but a missing queue is not. + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + try { this.#assertQueue(pending.queueDirectory, pending.queueIdentity); } + catch { return false; } + return true; + } } } return false; } - #markQueueRecordDone( - recordPath: string, - expected: MutexChoosingRecord | MutexTicketRecord, - ): boolean { - const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0; + #markQueueRecordDone(recordPath: string, pending: PendingRelease): boolean { for (let attempt = 0; attempt < 3; attempt++) { let fd: number | undefined; try { - fd = fs.openSync(recordPath, fs.constants.O_RDWR | noFollow); + this.#assertQueue(pending.queueDirectory, pending.queueIdentity); + fd = fs.openSync(recordPath, fs.constants.O_RDWR | fs.constants.O_NOFOLLOW); + const stat = fs.fstatSync(fd); + if (!stat.isFile() || !sameIdentity(stat, pending.fileIdentity) || + !sameIdentity(stat, fs.lstatSync(recordPath))) return false; const raw = fs.readFileSync(fd, "utf8"); const current = JSON.parse(raw) as MutexChoosingRecord | MutexTicketRecord; - if (current.kind !== expected.kind || current.token !== expected.token || - (current.kind === "ticket" && expected.kind === "ticket" && current.ticket !== expected.ticket) || - current.pid !== expected.pid || - current.birthToken !== expected.birthToken || current.birthTokenKind !== expected.birthTokenKind) { - return false; - } + if (!sameOwner(current, pending.record)) return false; if (current.state === "done") return true; if (current.state !== "held") return false; const terminal = Buffer.from(JSON.stringify({ ...current, state: "done" })); if (terminal.byteLength !== Buffer.byteLength(raw)) return false; let offset = 0; - while (offset < terminal.byteLength) { - offset += fs.writeSync(fd, terminal, offset, terminal.byteLength - offset, offset); - } + while (offset < terminal.byteLength) offset += fs.writeSync(fd, terminal, offset, terminal.byteLength - offset, offset); fs.fsyncSync(fd); return true; } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; - } finally { - if (fd !== undefined) fs.closeSync(fd); - } + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + try { this.#assertQueue(pending.queueDirectory, pending.queueIdentity); } + catch { return false; } + return true; + } + } finally { if (fd !== undefined) fs.closeSync(fd); } } return false; } #drainPendingTicketReleases(): void { const ownQueueDirectory = this.#queueDirectory(); - for (const [ticketPath, queueDirectory] of PROCESS_PENDING_TICKET_RELEASES) { - if (queueDirectory !== ownQueueDirectory) continue; - if (this.#removeTicketBestEffort(ticketPath, queueDirectory)) { + for (const [ticketPath, pending] of PROCESS_PENDING_TICKET_RELEASES) { + if (pending.queueDirectory !== ownQueueDirectory) continue; + if (this.#markQueueRecordDone(ticketPath, pending) && this.#removeTicketBestEffort(ticketPath, pending)) { PROCESS_PENDING_TICKET_RELEASES.delete(ticketPath); } } - if ([...PROCESS_PENDING_TICKET_RELEASES.values()].some((queueDirectory) => queueDirectory === ownQueueDirectory)) { + if ([...PROCESS_PENDING_TICKET_RELEASES.values()].some((pending) => pending.queueDirectory === ownQueueDirectory)) { throw new Error("TFWS_CONTROL_PLANE_CLEANUP: a prior mutex ticket could not be released"); } } - #readQueueRecord(filePath: string): MutexChoosingRecord | MutexTicketRecord | undefined { + #readQueueRecord(filePath: string, queueIdentity: FileIdentity, allowCorruptCleanup = true): { + record: MutexChoosingRecord | MutexTicketRecord; + fileIdentity: FileIdentity; + } | undefined { + let fd: number | undefined; + let stat: fs.Stats | undefined; + let raw: string | undefined; + const queueDirectory = path.dirname(filePath); try { - const record = readJsonFile(filePath, undefined); + this.#assertQueue(queueDirectory, queueIdentity); + fd = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + stat = fs.fstatSync(fd); + if (!stat.isFile()) throw new Error("invalid mutex queue file"); + raw = fs.readFileSync(fd, "utf8"); + const record = JSON.parse(raw) as MutexChoosingRecord | MutexTicketRecord; if (!record || (record.kind !== "choosing" && record.kind !== "ticket") || typeof record.token !== "string" || !Number.isSafeInteger(record.pid) || typeof record.birthToken !== "string" || record.birthToken.length === 0 || @@ -377,30 +462,29 @@ export class PersistentFileMutex { (record.state !== "held" && record.state !== "done")) { throw new Error("invalid mutex queue record"); } - if (record.kind === "ticket" && - (!Number.isSafeInteger(record.ticket) || record.ticket < 1)) { + if (record.kind === "ticket" && (!Number.isSafeInteger(record.ticket) || record.ticket < 1)) { throw new Error("invalid mutex ticket"); } - return record; + if (!sameIdentity(stat, fs.lstatSync(filePath))) throw new Error("mutex queue record was replaced"); + return { record, fileIdentity: stat }; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; - try { - const stat = fs.statSync(filePath); - if (this.now() - stat.mtimeMs >= this.corruptLockStaleMs) { - // Queue filenames contain a never-reused UUID. Removing this exact - // corrupt path cannot delete a newer owner's ticket (unlike renaming - // one shared lockPath after a stale read). + // A corrupt record is reclaimable only if its exact inode and bytes + // still match our observation. Never follow or remove symlink records. + if (allowCorruptCleanup && stat?.isFile() && raw !== undefined && this.now() - stat.mtimeMs >= this.corruptLockStaleMs) { + this.#assertQueue(queueDirectory, queueIdentity); + const current = fs.lstatSync(filePath); + if (current.isFile() && sameIdentity(current, stat) && fs.readFileSync(filePath, "utf8") === raw && + sameIdentity(fs.lstatSync(filePath), stat)) { fs.unlinkSync(filePath); return undefined; } - } catch (statError) { - if ((statError as NodeJS.ErrnoException).code === "ENOENT") return undefined; } throw error; - } + } finally { if (fd !== undefined) fs.closeSync(fd); } } - #queueSnapshot(queueDirectory: string): { + #queueSnapshot(queueDirectory: string, queueIdentity: FileIdentity, own?: PendingRelease): { choosing: Array<{ path: string; record: MutexChoosingRecord }>; tickets: Array<{ path: string; record: MutexTicketRecord }>; } { @@ -410,7 +494,7 @@ export class PersistentFileMutex { try { names = fs.readdirSync(queueDirectory); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return { choosing, tickets }; + // Missing queue evidence cannot be interpreted as an empty mutex. throw error; } for (const name of names) { @@ -418,25 +502,21 @@ export class PersistentFileMutex { const ticketName = /^ticket-([0-9a-f-]+)\.json$/.exec(name); if (!choosingName && !ticketName) continue; const filePath = path.join(queueDirectory, name); - const record = this.#readQueueRecord(filePath); - if (!record) continue; + const isOwn = own !== undefined && (choosingName?.[1] ?? ticketName?.[1]) === own.record.token; + const observed = this.#readQueueRecord(filePath, queueIdentity, !isOwn); + if (!observed) continue; + const { record, fileIdentity } = observed; + const pending = { queueDirectory, queueIdentity, record, fileIdentity }; + if (isOwn && (!sameOwner(record, own.record) || record.state !== "held" || + !sameIdentity(fileIdentity, own.fileIdentity))) { + throw new Error("TFWS_MUTEX_IDENTITY: own waiting record was replaced"); + } if (record.token !== (choosingName?.[1] ?? ticketName?.[1])) { throw new Error("mutex queue token does not match its immutable filename"); } - if (record.state === "done") { - // The owner durably ended its critical section before cleanup failed. - // Any process/worker may remove this exact immutable terminal ticket; - // no birth-token inference is involved. - try { fs.unlinkSync(filePath); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - continue; - } - if (ownerIsStale(record, this.identity, this.inspectProcess)) { - // Every contender owns a unique immutable file, so stale recovery is - // exact-path deletion with no shared-name ABA window. - try { fs.unlinkSync(filePath); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + if (record.state === "done" || ownerIsStale(record, this.identity, this.inspectProcess)) { + if (!this.#removeTicketBestEffort(filePath, pending, false)) { + throw new Error("TFWS_MUTEX_IDENTITY: observed queue record changed during cleanup"); } continue; } @@ -454,7 +534,18 @@ export class PersistentFileMutex { const timeoutMs = options.timeoutMs ?? 10_000; const deadline = this.now() + timeoutMs; const queueDirectory = this.#queueDirectory(); - ensureDirectory(queueDirectory); + let queueIdentity: FileIdentity; + while (true) { + try { queueIdentity = this.#bindQueue(queueDirectory); break; } + catch (error) { + // A concurrent first creator may still be publishing its anchor. + // Existing queues never authorize us to synthesize that anchor. + if ((error as NodeJS.ErrnoException).code !== "ENOENT" || this.now() >= deadline) { + throw new Error("TFWS_MUTEX_IDENTITY: queue anchor is missing or invalid", { cause: error }); + } + await delay(Math.min(this.pollMs, Math.max(1, deadline - this.now())), options.signal); + } + } const token = crypto.randomUUID(); const choosingPath = path.join(queueDirectory, `choosing-${token}.json`); const ticketPath = path.join(queueDirectory, `ticket-${token}.json`); @@ -465,12 +556,14 @@ export class PersistentFileMutex { createdAt: this.now(), state: "held", }; - let publishedTicket = false; - let own: MutexTicketRecord | undefined; + let choosingPending: PendingRelease | undefined; + let ticketPending: PendingRelease | undefined; try { + this.#assertQueue(queueDirectory, queueIdentity); writeJsonAtomicDurable(choosingPath, choosing); + choosingPending = { queueDirectory, queueIdentity, record: choosing, fileIdentity: fs.lstatSync(choosingPath) }; if (options.signal?.aborted) throw new CoordinatorAbortError(); - const initial = this.#queueSnapshot(queueDirectory); + const initial = this.#queueSnapshot(queueDirectory, queueIdentity, choosingPending); const maxTicket = initial.tickets.reduce((max, item) => Math.max(max, item.record.ticket), 0); if (maxTicket >= Number.MAX_SAFE_INTEGER) throw new Error("mutex ticket space exhausted"); const ticketRecord: MutexTicketRecord = { @@ -481,31 +574,42 @@ export class PersistentFileMutex { createdAt: this.now(), state: "held", }; - own = ticketRecord; + this.#assertQueue(queueDirectory, queueIdentity); writeJsonAtomicDurable(ticketPath, ticketRecord); - publishedTicket = true; - fs.unlinkSync(choosingPath); + ticketPending = { queueDirectory, queueIdentity, record: ticketRecord, fileIdentity: fs.lstatSync(ticketPath) }; + if (!this.#markQueueRecordDone(choosingPath, choosingPending) || + !this.#removeTicketBestEffort(choosingPath, choosingPending)) { + throw new Error("TFWS_MUTEX_IDENTITY: choosing record could not be retired"); + } fsyncDirectory(queueDirectory); while (true) { if (options.signal?.aborted) throw new CoordinatorAbortError(); - const snapshot = this.#queueSnapshot(queueDirectory); + this.#assertQueue(queueDirectory, queueIdentity); + const snapshot = this.#queueSnapshot(queueDirectory, queueIdentity, ticketPending); + const ownTicket = snapshot.tickets.find((item) => item.path === ticketPath)?.record; + if (!ownTicket || !sameOwner(ownTicket, ticketRecord) || + !sameIdentity(fs.lstatSync(ticketPath), ticketPending.fileIdentity)) { + throw new Error("TFWS_MUTEX_IDENTITY: own waiting ticket was lost or replaced"); + } const otherChoosing = snapshot.choosing.some((item) => item.record.token !== token); const earlier = snapshot.tickets.some((item) => item.record.token !== token && (item.record.ticket < ticketRecord.ticket || (item.record.ticket === ticketRecord.ticket && item.record.token < token))); if (!otherChoosing && !earlier) { + this.#assertQueue(queueDirectory, queueIdentity); + const pending = ticketPending; let released = false; return () => { if (released) return; - const terminal = this.#markQueueRecordDone(ticketPath, ticketRecord); - if (this.#removeTicketBestEffort(ticketPath, queueDirectory)) { + const terminal = this.#markQueueRecordDone(ticketPath, pending); + if (terminal && this.#removeTicketBestEffort(ticketPath, pending)) { released = true; PROCESS_PENDING_TICKET_RELEASES.delete(ticketPath); return; } - PROCESS_PENDING_TICKET_RELEASES.set(ticketPath, queueDirectory); + PROCESS_PENDING_TICKET_RELEASES.set(ticketPath, pending); console.warn( `[taskflow] mutex ticket cleanup deferred for ${path.basename(ticketPath)}` + (terminal ? " (critical section durably released)" : " (release marker unavailable; fail-closed)"), @@ -518,16 +622,15 @@ export class PersistentFileMutex { await delay(Math.min(this.pollMs, Math.max(1, deadline - this.now())), options.signal); } } catch (error) { - const candidates: Array<[string, MutexChoosingRecord | MutexTicketRecord]> = [ - [choosingPath, choosing], - ...(publishedTicket && own ? [[ticketPath, own] as [string, MutexTicketRecord]] : []), + const candidates: Array<[string, PendingRelease]> = [ + ...(choosingPending ? [[choosingPath, choosingPending] as [string, PendingRelease]] : []), + ...(ticketPending ? [[ticketPath, ticketPending] as [string, PendingRelease]] : []), ]; for (const [candidate, record] of candidates) { // A cancelled/timed-out contender is no longer live. Persist that fact in // the immutable queue record before unlink so permission failures cannot // leave a same-process ticket that blocks the queue forever. - this.#markQueueRecordDone(candidate, record); - this.#removeTicketBestEffort(candidate, queueDirectory); + if (this.#markQueueRecordDone(candidate, record)) this.#removeTicketBestEffort(candidate, record); } fsyncDirectory(queueDirectory); throw error; diff --git a/packages/taskflow-core/test/resource-persistence.test.ts b/packages/taskflow-core/test/resource-persistence.test.ts index a7a4a571..34e3a520 100644 --- a/packages/taskflow-core/test/resource-persistence.test.ts +++ b/packages/taskflow-core/test/resource-persistence.test.ts @@ -37,7 +37,7 @@ test("persistent mutex removes only the exact stale immutable queue ticket", asy const directory = fs.mkdtempSync(path.join(os.tmpdir(), "tfws-mutex-stale-")); const lockPath = path.join(directory, "state.lock"); const queueDirectory = `${lockPath}.queue`; - fs.mkdirSync(queueDirectory, { recursive: true }); + (await new PersistentFileMutex(lockPath).acquire())(); const token = crypto.randomUUID(); const stalePath = path.join(queueDirectory, `ticket-${token}.json`); fs.writeFileSync(stalePath, JSON.stringify({ @@ -281,3 +281,116 @@ test("persistent mutex: durable terminal ticket survives worker-local cleanup lo fs.rmSync(directory, { recursive: true, force: true }); } }); + +for (const fault of ["replacement", "symlink", "missing", "unanchored", "corrupt-anchor"] as const) { + test(`persistent mutex rejects ${fault} queue without destroying evidence`, async (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "tfws-mutex-identity-")); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const lock = path.join(directory, "lock"); + const queue = `${lock}.queue`; + const anchor = `${queue}.identity`; + const target = path.join(directory, "preserved"); + const release = await new PersistentFileMutex(lock).acquire(); + if (fault === "unanchored") fs.unlinkSync(anchor); + else if (fault === "corrupt-anchor") fs.writeFileSync(anchor, "broken anchor"); + else { + fs.renameSync(queue, target); + if (fault === "replacement") fs.mkdirSync(queue); + if (fault === "symlink") fs.symlinkSync(target, queue, "dir"); + } + const before = fs.existsSync(anchor) ? fs.readFileSync(anchor) : undefined; + await assert.rejects(new PersistentFileMutex(lock, { pollMs: 1 }).acquire({ timeoutMs: 25 }), /TFWS_MUTEX_IDENTITY/); + assert.deepEqual(fs.existsSync(anchor) ? fs.readFileSync(anchor) : undefined, before); + if (fault === "missing") assert.equal(fs.existsSync(queue), false); + // Restore the isolated fixture so the original holder can release normally. + if (fault === "unanchored" || fault === "corrupt-anchor") { + const stat = fs.statSync(queue); + fs.writeFileSync(anchor, JSON.stringify({ dev: stat.dev, ino: stat.ino })); + } else { + if (fault === "replacement") fs.rmdirSync(queue); + if (fault === "symlink") fs.unlinkSync(queue); + fs.renameSync(target, queue); + } + release(); + }); +} + +test("persistent mutex never grants a lost waiting ticket", async (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "tfws-mutex-lost-")); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const lock = path.join(directory, "lock"); + const queue = `${lock}.queue`; + const release = await new PersistentFileMutex(lock).acquire(); + const original = fs.readdirSync(queue); + const pending = new PersistentFileMutex(lock, { pollMs: 1 }).acquire({ timeoutMs: 500 }); + const rejected = assert.rejects(pending, /own waiting ticket was lost/); + const ticket = fs.readdirSync(queue).find((name) => name.startsWith("ticket-") && !original.includes(name)); + assert.ok(ticket); + fs.unlinkSync(path.join(queue, ticket)); + release(); + await rejected; + (await new PersistentFileMutex(lock).acquire())(); +}); + +for (const replacement of ["owner", "inode"] as const) { + test(`persistent mutex release and deferred cleanup preserve changed ${replacement}`, async (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "tfws-mutex-foreign-")); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + t.mock.method(console, "warn", () => undefined); + const lock = path.join(directory, "lock"); + const queue = `${lock}.queue`; + const release = await new PersistentFileMutex(lock).acquire(); + const ticket = path.join(queue, fs.readdirSync(queue).find((name) => name.startsWith("ticket-"))!); + const bytes = fs.readFileSync(ticket); + if (replacement === "inode") { + fs.renameSync(ticket, `${ticket}.preserved`); + fs.writeFileSync(ticket, bytes); + } else { + const record = JSON.parse(bytes.toString()); + fs.writeFileSync(ticket, JSON.stringify({ ...record, birthToken: "replacement-owner" })); + } + const preserved = fs.readFileSync(ticket); + release(); + assert.deepEqual(fs.readFileSync(ticket), preserved); + await assert.rejects(new PersistentFileMutex(lock).acquire(), /CLEANUP/); + assert.deepEqual(fs.readFileSync(ticket), preserved); + }); +} + +test("persistent mutex abort preserves a changed waiting owner", async (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "tfws-mutex-abort-identity-")); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const lock = path.join(directory, "lock"); + const queue = `${lock}.queue`; + const release = await new PersistentFileMutex(lock).acquire(); + const original = fs.readdirSync(queue); + const controller = new AbortController(); + const pending = new PersistentFileMutex(lock).acquire({ signal: controller.signal }); + const rejected = assert.rejects(pending, /aborted/); + const name = fs.readdirSync(queue).find((entry) => entry.startsWith("ticket-") && !original.includes(entry)); + assert.ok(name); + const ticket = path.join(queue, name); + const record = JSON.parse(fs.readFileSync(ticket, "utf8")); + const replacement = JSON.stringify({ ...record, birthToken: "different-owner" }); + fs.writeFileSync(ticket, replacement); + controller.abort(); + await rejected; + assert.equal(fs.readFileSync(ticket, "utf8"), replacement); + release(); +}); + +test("persistent mutex stale cleanup preserves a record replaced during process inspection", async (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "tfws-mutex-stale-identity-")); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const lock = path.join(directory, "lock"); + (await new PersistentFileMutex(lock).acquire())(); + const token = crypto.randomUUID(); + const ticket = path.join(`${lock}.queue`, `ticket-${token}.json`); + const old = { kind: "ticket", pid: 424242, birthToken: "old", birthTokenKind: "native", token, ticket: 1, createdAt: 1, state: "held" }; + fs.writeFileSync(ticket, JSON.stringify(old)); + const replacement = JSON.stringify({ ...old, birthToken: "replacement" }); + await assert.rejects(new PersistentFileMutex(lock, { + inspectProcess: () => { fs.writeFileSync(ticket, replacement); return { alive: false }; }, + }).acquire(), /record changed during cleanup/); + assert.equal(fs.readFileSync(ticket, "utf8"), replacement); +}); From 4687b5437bce678548464f12ec9ecdcf261d3b0e Mon Sep 17 00:00:00 2001 From: heggria Date: Sat, 3 Oct 2026 20:31:01 +0800 Subject: [PATCH 23/30] fix(core): recover interrupted mutex initialization safely --- .../src/resources/mutex-bootstrap.ts | 148 ++++++++++++++++++ .../src/resources/persistence.ts | 46 +----- .../test/fixtures/mutex-bootstrap-worker.mts | 38 +++++ .../test/mutex-bootstrap.test.ts | 135 ++++++++++++++++ 4 files changed, 325 insertions(+), 42 deletions(-) create mode 100644 packages/taskflow-core/src/resources/mutex-bootstrap.ts create mode 100644 packages/taskflow-core/test/fixtures/mutex-bootstrap-worker.mts create mode 100644 packages/taskflow-core/test/mutex-bootstrap.test.ts diff --git a/packages/taskflow-core/src/resources/mutex-bootstrap.ts b/packages/taskflow-core/src/resources/mutex-bootstrap.ts new file mode 100644 index 00000000..9a212e9d --- /dev/null +++ b/packages/taskflow-core/src/resources/mutex-bootstrap.ts @@ -0,0 +1,148 @@ +/** Crash-recoverable creation of a new mutex namespace. Existing unanchored + * queues are never migrated here: that requires a host-wide old-writer fence. */ +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +export interface DirectoryIdentity { dev: number; ino: number; } +interface BootstrapOwner { pid: number; birthToken: string; birthTokenKind: "native" | "opaque"; } +interface BootstrapIntent { + version: 1; + token: string; + owner: BootstrapOwner; + parent: DirectoryIdentity; + directory: DirectoryIdentity; + stage: string; +} +interface ObservedJson { value: unknown; identity: DirectoryIdentity; } + +const same = (a: DirectoryIdentity, b: DirectoryIdentity) => a.dev === b.dev && a.ino === b.ino; +const fault = (message: string) => new Error(`TFWS_MUTEX_IDENTITY: ${message}`); +const waiting = () => Object.assign(fault("mutex initialization is pending"), { code: "ENOENT" }); +function exists(file: string): boolean { + try { fs.lstatSync(file); return true; } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; } +} +function directoryIdentity(directory: string): DirectoryIdentity { + const stat = fs.lstatSync(directory); + if (!stat.isDirectory()) throw fault("bootstrap path must be a real directory"); + return { dev: stat.dev, ino: stat.ino }; +} +function read(file: string): ObservedJson { + const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + const stat = fs.fstatSync(fd); + if (!stat.isFile() || !same(stat, fs.lstatSync(file))) throw fault("bootstrap record was replaced"); + return { value: JSON.parse(fs.readFileSync(fd, "utf8")), identity: { dev: stat.dev, ino: stat.ino } }; + } finally { fs.closeSync(fd); } +} +function identity(value: unknown): value is DirectoryIdentity { + if (!value || typeof value !== "object") return false; + const v = value as DirectoryIdentity; + return Number.isSafeInteger(v.dev) && Number.isSafeInteger(v.ino) && v.dev >= 0 && v.ino > 0; +} +function intent(value: unknown, queueName: string): BootstrapIntent { + if (!value || typeof value !== "object") throw fault("invalid bootstrap intent"); + const v = value as BootstrapIntent; + if (v.version !== 1 || typeof v.token !== "string" || !/^[0-9a-f-]{36}$/.test(v.token) || + v.stage !== `${queueName}.staging-${v.token}` || !identity(v.parent) || !identity(v.directory) || + !v.owner || !Number.isSafeInteger(v.owner.pid) || v.owner.pid <= 0 || v.owner.pid > 0x7fffffff || + typeof v.owner.birthToken !== "string" || v.owner.birthToken.length === 0 || + (v.owner.birthTokenKind !== "native" && v.owner.birthTokenKind !== "opaque")) throw fault("invalid bootstrap intent"); + return v; +} +function publish(file: string, value: unknown, syncDirectory: (directory: string) => void): void { + const temp = `${file}.tmp.${crypto.randomUUID()}`; + const fd = fs.openSync(temp, "wx", 0o600); + try { fs.writeFileSync(fd, JSON.stringify(value)); fs.fsyncSync(fd); } + finally { fs.closeSync(fd); } + try { + try { fs.linkSync(temp, file); syncDirectory(path.dirname(file)); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } + } finally { fs.unlinkSync(temp); } +} + +export function bindMutexQueue( + queue: string, + owner: BootstrapOwner, + canRecover: (owner: BootstrapOwner) => boolean, + syncDirectory: (directory: string) => void, +): DirectoryIdentity { + const parentPath = path.dirname(queue); + fs.mkdirSync(parentPath, { recursive: true }); + const parent = directoryIdentity(parentPath); + const anchor = `${queue}.identity`; + const journal = `${queue}.initializing`; + const completed = `${queue}.initialized`; + let ownToken: string | undefined; + + // Compatibility for already anchored namespaces from the preceding mutex + // version. A completion record without its genesis is corruption, not legacy. + if (!exists(journal) && exists(anchor)) { + if (exists(completed)) throw fault("bootstrap genesis is missing"); + const saved = read(anchor).value; + const current = directoryIdentity(queue); + if (!identity(saved) || !same(saved, current)) throw fault("mutex queue was replaced"); + return current; + } + if (!exists(journal)) { + if (exists(completed)) throw fault("bootstrap genesis is missing"); + if (exists(queue)) throw waiting(); // legacy or interrupted old protocol + const token = crypto.randomUUID(); + const stage = `${path.basename(queue)}.staging-${token}`; + const stagePath = path.join(parentPath, stage); + fs.mkdirSync(stagePath, { mode: 0o700 }); + const stagedIdentity = directoryIdentity(stagePath); + syncDirectory(stagePath); + syncDirectory(parentPath); + const proposed: BootstrapIntent = { version: 1, token, owner, parent, directory: stagedIdentity, stage }; + publish(journal, proposed, syncDirectory); + const winner = intent(read(journal).value, path.basename(queue)); + if (winner.token === token) ownToken = token; + else { + if (same(directoryIdentity(stagePath), stagedIdentity)) fs.rmdirSync(stagePath); + } + } + const selected = intent(read(journal).value, path.basename(queue)); + const assertParent = () => { + if (!same(directoryIdentity(parentPath), selected.parent)) throw fault("bootstrap parent was replaced"); + }; + const assertPublished = () => { + assertParent(); + const saved = read(anchor).value; + if (!identity(saved) || !same(saved, selected.directory) || !same(directoryIdentity(queue), selected.directory)) { + throw fault("bootstrap anchor does not match queue"); + } + }; + assertParent(); + if (exists(completed)) { + if (JSON.stringify(read(completed).value) !== JSON.stringify(selected)) throw fault("bootstrap completion does not match genesis"); + assertPublished(); + return selected.directory; + } + if (selected.token !== ownToken && !canRecover(selected.owner)) throw waiting(); + const stagePath = path.join(parentPath, selected.stage); + if (!exists(queue)) { + if (exists(anchor)) throw fault("anchored mutex queue is missing"); + if (!same(directoryIdentity(stagePath), selected.directory)) throw fault("bootstrap staging directory was replaced"); + try { fs.renameSync(stagePath, queue); syncDirectory(parentPath); } + catch (error) { + // Multiple recoverers operate on one permanent selected inode. Only + // one rename can succeed; another may observe its completed result. + if ((error as NodeJS.ErrnoException).code !== "ENOENT" || !exists(queue)) throw error; + } + } + if (!same(directoryIdentity(queue), selected.directory)) throw fault("bootstrap queue was replaced"); + publish(anchor, selected.directory, syncDirectory); + assertPublished(); + // The selected genesis is permanent, closing delayed-creator ABA without a + // mutable recovery claim. Completion is also immutable and is published only + // after the anchor. No choosing/ticket record can precede this commit point. + // A later missing anchor is therefore corruption, never initialization work. + const latest = intent(read(journal).value, path.basename(queue)); + if (JSON.stringify(latest) !== JSON.stringify(selected)) throw fault("bootstrap genesis was replaced"); + publish(completed, selected, syncDirectory); + if (JSON.stringify(read(completed).value) !== JSON.stringify(selected)) throw fault("bootstrap completion does not match genesis"); + assertPublished(); + return selected.directory; +} diff --git a/packages/taskflow-core/src/resources/persistence.ts b/packages/taskflow-core/src/resources/persistence.ts index fc2e4c45..9f343e5c 100644 --- a/packages/taskflow-core/src/resources/persistence.ts +++ b/packages/taskflow-core/src/resources/persistence.ts @@ -2,6 +2,7 @@ import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; import { execFileSync } from "node:child_process"; +import { bindMutexQueue } from "./mutex-bootstrap.ts"; /** Exact, platform-native process-birth identity when the platform exposes one. * Never return an uptime estimate or a lower-precision timestamp: incomparable @@ -123,7 +124,7 @@ export function defaultProcessInspector(pid: number): ObservedProcess { : { alive: true, birthToken, birthTokenKind: "native" }; } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if (code !== "EPERM") return { alive: false }; + if (code === "ESRCH") return { alive: false }; const birthToken = readProcessBirthToken(pid); return birthToken === undefined ? { alive: true } @@ -329,47 +330,8 @@ export class PersistentFileMutex { } #bindQueue(queueDirectory: string): FileIdentity { - // A sibling durable anchor lets a fresh process detect replacement even - // when the old queue and its live tickets have been renamed out of view. - const anchor = `${queueDirectory}.identity`; - ensureDirectory(path.dirname(queueDirectory)); - let created = false; - try { - fs.lstatSync(queueDirectory); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - // Never recreate a missing queue from an established namespace. - try { - fs.lstatSync(anchor); - throw new Error("TFWS_MUTEX_IDENTITY: anchored mutex queue is missing"); - } catch (anchorError) { - if ((anchorError as NodeJS.ErrnoException).code !== "ENOENT") throw anchorError; - } - try { fs.mkdirSync(queueDirectory, { mode: 0o700 }); created = true; } - catch (mkdirError) { if ((mkdirError as NodeJS.ErrnoException).code !== "EEXIST") throw mkdirError; } - } - const stat = fs.lstatSync(queueDirectory); - if (!stat.isDirectory()) throw new Error("TFWS_MUTEX_IDENTITY: queue must be a real directory"); - const identity = { dev: stat.dev, ino: stat.ino }; - const temp = `${anchor}.tmp.${crypto.randomUUID()}`; - if (created) try { - const fd = fs.openSync(temp, "wx", 0o600); - try { fs.writeFileSync(fd, JSON.stringify(identity)); fs.fsyncSync(fd); } - finally { fs.closeSync(fd); } - try { fs.linkSync(temp, anchor); fsyncDirectory(path.dirname(anchor)); } - catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } - } finally { try { fs.unlinkSync(temp); } catch { /* unpublished temporary file */ } } - const fd = fs.openSync(anchor, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); - try { - const anchorStat = fs.fstatSync(fd); - if (!anchorStat.isFile() || !sameIdentity(anchorStat, fs.lstatSync(anchor))) { - throw new Error("TFWS_MUTEX_IDENTITY: invalid queue anchor"); - } - const saved = JSON.parse(fs.readFileSync(fd, "utf8")) as FileIdentity; - if (!sameIdentity(saved, identity)) throw new Error("TFWS_MUTEX_IDENTITY: mutex queue was replaced"); - } finally { fs.closeSync(fd); } - this.#assertQueue(queueDirectory, identity); - return identity; + return bindMutexQueue(queueDirectory, this.identity, + (owner) => ownerIsStale(owner, this.identity, this.inspectProcess), fsyncDirectory); } #removeTicketBestEffort(ticketPath: string, pending: PendingRelease, terminalOnly = true): boolean { diff --git a/packages/taskflow-core/test/fixtures/mutex-bootstrap-worker.mts b/packages/taskflow-core/test/fixtures/mutex-bootstrap-worker.mts new file mode 100644 index 00000000..11e8e4ad --- /dev/null +++ b/packages/taskflow-core/test/fixtures/mutex-bootstrap-worker.mts @@ -0,0 +1,38 @@ +import fs from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; +import { PersistentFileMutex } from '../../src/resources/persistence.ts'; +const [mode, lock, marker] = process.argv.slice(2); +function pause(point: string) { + fs.writeFileSync(marker, point); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); +} +const realLink = fs.linkSync; +const realRename = fs.renameSync; +if (mode === 'intent' || mode === 'anchor' || mode === 'complete' || mode === 'late') { + fs.linkSync = function(source, destination) { + if (mode === 'late' && String(destination) === lock+'.queue.initializing') { + fs.writeFileSync(marker, mode); + while (!fs.existsSync(marker+'.continue')) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,5); + } + try { realLink(source,destination); } + finally { + if (mode==='late' && String(destination)===lock+'.queue.initializing') fs.writeFileSync(marker+'.published','attempted'); + } + const suffix = mode==='intent' ? 'initializing' : mode==='complete' ? 'initialized' : 'identity'; + if (mode!=='late' && String(destination) === lock+'.queue.'+suffix) pause(mode); + }; +} else if (mode === 'renamed') { + fs.renameSync = function(source,destination) { + realRename(source,destination); + if (String(destination)===lock+'.queue') pause(mode); + }; +} +syncBuiltinESMExports(); +const mutex = new PersistentFileMutex(lock, {pollMs: 2}); +if (mode === 'recover-counter') { + for(let i=0;i<5;i++) await mutex.runExclusive(async()=>{ + const previous=Number(fs.readFileSync(marker,'utf8')); + await new Promise(resolve=>setTimeout(resolve,2)); + fs.writeFileSync(marker,String(previous+1)); + },{timeoutMs:5000}); +} else (await mutex.acquire({timeoutMs:5000}))(); diff --git a/packages/taskflow-core/test/mutex-bootstrap.test.ts b/packages/taskflow-core/test/mutex-bootstrap.test.ts new file mode 100644 index 00000000..cc8ef190 --- /dev/null +++ b/packages/taskflow-core/test/mutex-bootstrap.test.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fork, type ChildProcess } from "node:child_process"; +import { once } from "node:events"; +import { test, type TestContext } from "node:test"; +import { PersistentFileMutex } from "../src/resources/persistence.ts"; +const worker = new URL("./fixtures/mutex-bootstrap-worker.mts", import.meta.url); +function fixture(t: TestContext) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tfws-bootstrap-")); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return { dir, lock: path.join(dir, "mutex"), marker: path.join(dir, "marker") }; +} +function child(t: TestContext, args: string[]): ChildProcess { + const process = fork(worker, args, { stdio: ["ignore", "pipe", "pipe", "ipc"] }); + t.after(() => { if (process.exitCode === null && process.signalCode === null) process.kill("SIGKILL"); }); + return process; +} +async function paused(process: ChildProcess, marker: string): Promise { + const until = Date.now() + 5000; + while (!fs.existsSync(marker)) { + assert.equal(process.exitCode, null, "fixture must remain alive until fault boundary"); + assert.ok(Date.now() < until, "fixture should reach fault boundary"); + await new Promise(resolve => setTimeout(resolve, 5)); + } +} +async function killOwned(process: ChildProcess) { + const exit = once(process, "exit"); + process.kill("SIGKILL"); + await exit; +} +for (const phase of ["intent", "renamed", "anchor"] as const) { + test(`mutex bootstrap recovers SIGKILL after ${phase}, with four simultaneous recoverers`, async t => { + const { lock, marker, dir } = fixture(t); + const initializer = child(t, [phase, lock, marker]); + await paused(initializer, marker); + const intent = fs.readFileSync(`${lock}.queue.initializing`); + await assert.rejects(new PersistentFileMutex(lock, { pollMs: 1 }).acquire({ timeoutMs: 30 }), /TFWS_MUTEX_IDENTITY/); + assert.deepEqual(fs.readFileSync(`${lock}.queue.initializing`), intent, "live initializer evidence is preserved"); + await killOwned(initializer); + const counter = path.join(dir, "counter"); + fs.writeFileSync(counter, "0"); + const contenders = Array.from({ length: 4 }, () => child(t, ["recover-counter", lock, counter])); + await Promise.all(contenders.map(async process => { + let errors = ""; + process.stderr!.on("data", chunk => { errors += String(chunk); }); + const [code] = await once(process, "exit"); + assert.equal(code, 0, errors); + })); + assert.equal(fs.readFileSync(counter, "utf8"), "20"); + assert.equal(fs.existsSync(`${lock}.queue.initializing`), true); + assert.equal(fs.existsSync(`${lock}.queue.initialized`), true); + assert.deepEqual(fs.readdirSync(`${lock}.queue`), []); + }); +} + +test("mutex bootstrap preserves dead intent with replaced canonical inode", async t => { + const { lock, marker } = fixture(t); + const initializer = child(t, ["renamed", lock, marker]); + await paused(initializer, marker); + await killOwned(initializer); + fs.renameSync(`${lock}.queue`, `${lock}.preserved`); + fs.mkdirSync(`${lock}.queue`); + const before = fs.readFileSync(`${lock}.queue.initializing`); + await assert.rejects(new PersistentFileMutex(lock).acquire({ timeoutMs: 30 }), /TFWS_MUTEX_IDENTITY/); + assert.deepEqual(fs.readFileSync(`${lock}.queue.initializing`), before); + assert.equal(fs.existsSync(`${lock}.queue.identity`), false); +}); + +test("mutex bootstrap cannot infer legacy quiescence from an empty queue", async t => { + const { lock } = fixture(t); + fs.mkdirSync(`${lock}.queue`); + await assert.rejects(new PersistentFileMutex(lock, { pollMs: 1 }).acquire({ timeoutMs: 25 }), /TFWS_MUTEX_IDENTITY/); + assert.deepEqual(fs.readdirSync(`${lock}.queue`), []); + assert.equal(fs.existsSync(`${lock}.queue.identity`), false); + assert.equal(fs.existsSync(`${lock}.queue.initializing`), false); +}); + +test("mutex bootstrap completion is usable after crash before initializer returns", async t => { + const { lock, marker } = fixture(t); + const initializer = child(t, ["complete", lock, marker]); + await paused(initializer, marker); + const committed = fs.readFileSync(`${lock}.queue.initialized`); + // A complete namespace no longer depends on its initializer's liveness. + (await new PersistentFileMutex(lock).acquire({ timeoutMs: 100 }))(); + await killOwned(initializer); + (await new PersistentFileMutex(lock).acquire({ timeoutMs: 100 }))(); + assert.deepEqual(fs.readFileSync(`${lock}.queue.initialized`), committed); +}); + +test("mutex bootstrap late creator cannot publish a second generation after first completes", async t => { + const { lock, marker } = fixture(t); + const late = child(t, ["late", lock, marker]); + await paused(late, marker); + const release = await new PersistentFileMutex(lock).acquire(); + const genesis = fs.readFileSync(`${lock}.queue.initializing`); + const completion = fs.readFileSync(`${lock}.queue.initialized`); + const exited = once(late, "exit"); + fs.writeFileSync(`${marker}.continue`, "continue"); + await paused(late, `${marker}.published`); + assert.equal(late.exitCode, null, "late contender cannot acquire while first holder is live"); + assert.deepEqual(fs.readFileSync(`${lock}.queue.initializing`), genesis); + release(); + const [code] = await exited; + assert.equal(code, 0); + assert.deepEqual(fs.readFileSync(`${lock}.queue.initializing`), genesis); + assert.deepEqual(fs.readFileSync(`${lock}.queue.initialized`), completion); +}); + +for (const missing of ["initializing", "identity"] as const) { + test(`completed mutex namespace rejects missing ${missing} without rewriting evidence`, async t => { + const { lock } = fixture(t); + (await new PersistentFileMutex(lock).acquire())(); + const completion = fs.readFileSync(`${lock}.queue.initialized`); + fs.unlinkSync(`${lock}.queue.${missing}`); + await assert.rejects(new PersistentFileMutex(lock, { pollMs: 1 }).acquire({ timeoutMs: 25 }), /TFWS_MUTEX_IDENTITY/); + assert.equal(fs.existsSync(`${lock}.queue.${missing}`), false); + assert.deepEqual(fs.readFileSync(`${lock}.queue.initialized`), completion); + }); +} + +test("mutex bootstrap ignores an unpublished staging directory after initializer SIGKILL", async t => { + const { dir, lock, marker } = fixture(t); + const initializer = child(t, ["late", lock, marker]); + await paused(initializer, marker); + const staged = fs.readdirSync(dir).find(name => name.startsWith("mutex.queue.staging-")); + assert.ok(staged); + const original = fs.statSync(path.join(dir, staged)); + await killOwned(initializer); + assert.equal(fs.existsSync(`${lock}.queue.initializing`), false); + (await new PersistentFileMutex(lock).acquire())(); + assert.equal(fs.statSync(path.join(dir, staged)).ino, original.ino, "unpublished evidence is preserved"); + assert.notEqual(fs.statSync(`${lock}.queue`).ino, original.ino, "fresh namespace does not adopt orphan staging"); +}); From ef96e37113509522fe076f57d35c921af0f03116 Mon Sep 17 00:00:00 2001 From: heggria Date: Wed, 7 Oct 2026 22:14:17 +0800 Subject: [PATCH 24/30] test(control): preserve public malformed-client watchdog limits --- packages/taskflow-control/test/uds-malformed.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/taskflow-control/test/uds-malformed.test.ts b/packages/taskflow-control/test/uds-malformed.test.ts index 4a5e733d..acee23bc 100644 --- a/packages/taskflow-control/test/uds-malformed.test.ts +++ b/packages/taskflow-control/test/uds-malformed.test.ts @@ -197,7 +197,10 @@ test("uds: a legitimate MiB-sized payload round-trips under the default client b assert.deepEqual(await server.probe(), { mutations: 0 }); }); -test("uds: malformed failure replies cannot crash a real client process", UNIX_ONLY, async (t) => { +// This matrix starts 26 clients sequentially, each with a 2s watchdog below. +// Allow their cumulative startup cost plus server readiness/cleanup on busy CI +// runners; the per-client watchdog still catches a hung or leaked process. +test("uds: malformed failure replies cannot crash a real client process", { ...UNIX_ONLY, timeout: 60_000 }, async (t) => { const healthy = await isolatedServer(t); const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-bad-peer-")); t.after(() => fs.rmSync(root, { recursive: true, force: true })); From a98e386aa6f1e5a5f5cdbf345de8655a60ff5627 Mon Sep 17 00:00:00 2001 From: heggria Date: Wed, 7 Oct 2026 23:11:41 +0800 Subject: [PATCH 25/30] feat(control): complete durable multi-project execution and recovery --- docs/internal/1.0.0-spec-closure.md | 21 +- package.json | 2 +- packages/taskflow-control/GUIDE.md | 65 +++ packages/taskflow-control/package.json | 14 +- .../taskflow-control/src/admission-service.ts | 69 +++ .../taskflow-control/src/approval-edits.ts | 51 +++ .../taskflow-control/src/approval-service.ts | 65 ++- packages/taskflow-control/src/control-cli.ts | 193 +++++++++ .../taskflow-control/src/control-console.ts | 73 ++++ packages/taskflow-control/src/control-host.ts | 220 +++++----- packages/taskflow-control/src/control-mcp.ts | 73 ++++ packages/taskflow-control/src/host-runtime.ts | 396 ++++++++++++++++++ packages/taskflow-control/src/index.ts | 10 + .../taskflow-control/src/legacy-conflict.ts | 112 +++++ .../taskflow-control/src/local-bootstrap.ts | 73 ++++ .../taskflow-control/src/project-admin-cli.ts | 88 ++++ .../taskflow-control/src/project-registry.ts | 129 ++++++ .../src/runtime-checkpoint.ts | 45 ++ .../taskflow-control/src/runtime-provider.ts | 211 ++++++++++ .../taskflow-control/src/schema/commands.ts | 11 +- packages/taskflow-control/src/singleton.ts | 7 +- .../src/store/coordinator-store.ts | 63 ++- .../src/store/evidence-adapter.ts | 116 +++++ .../src/store/evidence-explanation.ts | 68 +++ .../src/store/evidence-store.ts | 18 +- .../taskflow-control/src/store/journal.ts | 17 +- .../src/store/project-lifecycle.ts | 182 ++++++++ packages/taskflow-control/src/store/store.ts | 360 +++++++++++----- packages/taskflow-control/src/uds.ts | 18 +- packages/taskflow-control/src/web-console.ts | 20 +- .../taskflow-control/src/web-console/view.ts | 21 +- .../test/admission-service.test.ts | 122 ++++++ .../test/approval-edits-host.test.ts | 88 ++++ .../test/approval-replay.test.ts | 106 +++++ .../taskflow-control/test/control-cli.test.ts | 130 ++++++ .../test/control-console-integration.test.ts | 103 +++++ .../test/control-host.test.ts | 300 ++++++++----- .../taskflow-control/test/control-mcp.test.ts | 181 ++++++++ .../test/coordinator-store.test.ts | 110 +++++ .../test/evidence-adapter.test.ts | 224 ++++++++++ .../test/evidence-explanation.test.ts | 52 +++ .../test/evidence-host-integration.test.ts | 64 +++ .../test/fixtures/admission-worker.ts | 67 +++ .../test/fixtures/approval-replay-worker.ts | 9 + .../test/fixtures/coordinator-worker.ts | 3 + .../test/fixtures/evidence-adapter-crash.mts | 16 + .../test/fixtures/legacy-writer.ts | 7 + .../test/fixtures/runtime-restart-worker.ts | 84 ++++ .../test/fixtures/store-mutate.ts | 2 +- .../test/host-runtime-integration.test.ts | 76 ++++ .../test/legacy-conflict.test.ts | 87 ++++ .../test/project-admin-cli.test.ts | 62 +++ .../test/project-isolation.test.ts | 280 +++++++------ .../test/project-lifecycle.test.ts | 136 ++++++ .../test/project-registry.test.ts | 105 +++++ .../test/runtime-quiescence.test.ts | 38 ++ .../test/runtime-restart.test.ts | 82 ++++ packages/taskflow-control/test/schema.test.ts | 9 +- .../taskflow-control/test/singleton.test.ts | 2 + .../test/store-process.test.ts | 6 +- packages/taskflow-control/test/store.test.ts | 10 +- .../test/uds-inflight.test.ts | 24 ++ .../taskflow-control/test/web-console.test.ts | 74 +++- .../taskflow-core/src/control-execution.ts | 5 + .../src/legacy-writer-lifecycle.ts | 170 ++++++++ packages/taskflow-core/src/runner-core.ts | 21 + packages/taskflow-core/src/runtime.ts | 29 ++ .../test/approval-continuation.test.ts | 28 ++ .../test/legacy-writer-lifecycle.test.ts | 166 ++++++++ packages/taskflow-mcp-core/src/mcp/jsonrpc.ts | 15 +- .../test/jsonrpc-malformed.test.ts | 18 + pnpm-lock.yaml | 3 + 72 files changed, 5389 insertions(+), 536 deletions(-) create mode 100644 packages/taskflow-control/GUIDE.md create mode 100644 packages/taskflow-control/src/admission-service.ts create mode 100644 packages/taskflow-control/src/approval-edits.ts create mode 100644 packages/taskflow-control/src/control-cli.ts create mode 100644 packages/taskflow-control/src/control-console.ts create mode 100644 packages/taskflow-control/src/control-mcp.ts create mode 100644 packages/taskflow-control/src/host-runtime.ts create mode 100644 packages/taskflow-control/src/legacy-conflict.ts create mode 100644 packages/taskflow-control/src/local-bootstrap.ts create mode 100644 packages/taskflow-control/src/project-admin-cli.ts create mode 100644 packages/taskflow-control/src/project-registry.ts create mode 100644 packages/taskflow-control/src/runtime-checkpoint.ts create mode 100644 packages/taskflow-control/src/runtime-provider.ts create mode 100644 packages/taskflow-control/src/store/evidence-adapter.ts create mode 100644 packages/taskflow-control/src/store/evidence-explanation.ts create mode 100644 packages/taskflow-control/src/store/project-lifecycle.ts create mode 100644 packages/taskflow-control/test/admission-service.test.ts create mode 100644 packages/taskflow-control/test/approval-edits-host.test.ts create mode 100644 packages/taskflow-control/test/approval-replay.test.ts create mode 100644 packages/taskflow-control/test/control-cli.test.ts create mode 100644 packages/taskflow-control/test/control-console-integration.test.ts create mode 100644 packages/taskflow-control/test/control-mcp.test.ts create mode 100644 packages/taskflow-control/test/evidence-adapter.test.ts create mode 100644 packages/taskflow-control/test/evidence-explanation.test.ts create mode 100644 packages/taskflow-control/test/evidence-host-integration.test.ts create mode 100644 packages/taskflow-control/test/fixtures/admission-worker.ts create mode 100644 packages/taskflow-control/test/fixtures/approval-replay-worker.ts create mode 100644 packages/taskflow-control/test/fixtures/evidence-adapter-crash.mts create mode 100644 packages/taskflow-control/test/fixtures/legacy-writer.ts create mode 100644 packages/taskflow-control/test/fixtures/runtime-restart-worker.ts create mode 100644 packages/taskflow-control/test/host-runtime-integration.test.ts create mode 100644 packages/taskflow-control/test/legacy-conflict.test.ts create mode 100644 packages/taskflow-control/test/project-admin-cli.test.ts create mode 100644 packages/taskflow-control/test/project-lifecycle.test.ts create mode 100644 packages/taskflow-control/test/project-registry.test.ts create mode 100644 packages/taskflow-control/test/runtime-quiescence.test.ts create mode 100644 packages/taskflow-control/test/runtime-restart.test.ts create mode 100644 packages/taskflow-control/test/uds-inflight.test.ts create mode 100644 packages/taskflow-core/src/control-execution.ts create mode 100644 packages/taskflow-core/src/legacy-writer-lifecycle.ts create mode 100644 packages/taskflow-core/test/approval-continuation.test.ts create mode 100644 packages/taskflow-core/test/legacy-writer-lifecycle.test.ts create mode 100644 packages/taskflow-mcp-core/test/jsonrpc-malformed.test.ts diff --git a/docs/internal/1.0.0-spec-closure.md b/docs/internal/1.0.0-spec-closure.md index 4dd5421e..28f05307 100644 --- a/docs/internal/1.0.0-spec-closure.md +++ b/docs/internal/1.0.0-spec-closure.md @@ -1,9 +1,24 @@ # 1.0 specification closure ledger -Status: **PUBLICATION BLOCKED**. Updated 2026-10-02. No merge, tag, npm +Status: **PUBLICATION BLOCKED**. Candidate note updated 2026-10-07. No merge, tag, npm publication or GitHub Release has occurred. Passing the implemented surface's tests does not complete the historical roadmap. +The isolated Task9 candidate now implements authenticated multi-project control, +durable command replay, actual coordinated TE execution, parked approval recovery, +validated output/plan edits, Receipt/compaction, CLI/MCP/console adapters, observable +legacy-writer conflict handling, and explicit offline identity/export commands. +See the [candidate package guide](../../packages/taskflow-control/GUIDE.md) for +supported operations and recovery boundaries. Actual process-kill, browser, and +cross-project tests cover these paths. Final commit/artifact acceptance is recorded +outside this repository in the Task9 handoff, and is not public CI or GA acceptance. + +The tables and detailed evidence below retain the **2026-10-02 release-owner +baseline**. Their remaining-work cells are historical baseline gaps, not a claim +that the isolated candidate still lacks each implementation. Release-owner +integration and acceptance must reconcile that baseline against the candidate; +this local branch does not change the ADR decision status or authorize publication. + The owner requires historical specifications to be implemented and accepted before release. The earlier TE MVP excluded the old Control Plane, but the later [approved Control Plane plan](./0.3-c-control-plane-plan.md) commits S3–S7 before @@ -55,8 +70,8 @@ Control Plane plan and RFC v7.6 §23. Schema presence is not runtime acceptance. | P16 coordinator | Accepted | Persistent global reservation/commit/release, fencing, unknown capacity retention and explicit operator intervention | | Plan S6 / S7 | Approved later plan | Thin MCP/CLI integration and usable runs/approvals/receipt WebUI | -`commands.submit` currently appends supplied journal data; it does not execute -TE. `runs.status` remains a placeholder. Caller-supplied audit fields are not +At that baseline, `commands.submit` appended supplied journal data; it did not execute +TE, and `runs.status` was a placeholder. Caller-supplied audit fields are not authentication. Do not resolve authorized replay by default-allow policy. ## Core, DSL and host roadmap diff --git a/package.json b/package.json index c9555c84..4ac7d2bc 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "packageManager": "pnpm@11.17.0", "scripts": { - "build": "pnpm run build:skills && pnpm --filter taskflow-core build && pnpm --filter charterarc build && pnpm --filter taskflow-control build && pnpm --filter taskflow-mcp-core build && pnpm --filter taskflow-hosts build && pnpm --filter taskflow-dsl build && pnpm --filter pi-taskflow build && pnpm --filter codex-taskflow build && pnpm --filter claude-taskflow build && pnpm --filter opencode-taskflow build && pnpm --filter grok-taskflow build && pnpm --filter hermes-taskflow build", + "build": "pnpm run build:skills && pnpm --filter taskflow-core build && pnpm --filter charterarc build && pnpm --filter taskflow-mcp-core build && pnpm --filter taskflow-control build && pnpm --filter taskflow-hosts build && pnpm --filter taskflow-dsl build && pnpm --filter pi-taskflow build && pnpm --filter codex-taskflow build && pnpm --filter claude-taskflow build && pnpm --filter opencode-taskflow build && pnpm --filter grok-taskflow build && pnpm --filter hermes-taskflow build", "build:website": "cd website && npm run build", "build:skills": "node scripts/build-skills.mjs", "test:pack": "pnpm run build && node scripts/smoke-packed-packages.mjs", diff --git a/packages/taskflow-control/GUIDE.md b/packages/taskflow-control/GUIDE.md new file mode 100644 index 00000000..d3161f5e --- /dev/null +++ b/packages/taskflow-control/GUIDE.md @@ -0,0 +1,65 @@ +# Taskflow control plane + +This is a private implementation candidate. Release-owner integration, exact-head CI, +and publication acceptance remain separate from local source and packed-package tests. + +The control service mounts explicitly configured projects and routes requests by +their durable project identity. It authenticates local clients, rereads policy, +persists command results, coordinates capacity, and executes through the existing TE +runtime. Durable approval waits can survive an owner crash without repeating completed +phases. Receipts and explanations come from the project journal and runtime evidence. + +## Local use + +Use Node 22.19 or later. Keep the control home outside every execution project. + +```sh +taskflow-control run --root /path/project --flow /path/flow.json --control-home /path/control-home +taskflow-control serve --root /path/project --root /path/another --control-home /path/control-home --console +taskflow-control status --root /path/project --control-home /path/control-home +taskflow-control mcp --root /path/project --control-home /path/control-home +``` + +`auto` starts an owner or attaches to the existing owner. `coordinated` requires an +existing owner; it does not silently start a standalone service. The console prints +a private handoff-file path. Its one-use browser token is not placed in a URL or +printed to stdout. Local policy files under the control home are checked on each +operation. The CLI runs scripts; embedded callers must configure an agent runner +for agent phases. MCP exposes the same authenticated Host operations. + +Approval output edits use validated text artifacts. Plan edits are restricted to +unstarted downstream phase bodies in the same dependency graph; completed phases, +top-level settings, and resource declarations cannot be rewritten. Plan edits are +compiled again before readmission. The existing runtime executes the continuation. + +## Offline project administration + +```sh +taskflow-project-admin move-rebind --store /moved/project/.taskflow/control +taskflow-project-admin clone --store /source/.taskflow/control --destination /new/project/.taskflow/control +taskflow-project-admin export --store /project/.taskflow/control --output /private/export.json --kind readonly +``` + +Stop project writers before an offline filesystem move. `move-rebind` verifies the +same directory device/inode, absence of the old path, writer exclusion, and settled +history before preserving identity. `clone` creates a fresh, empty project/domain; +it does not copy authoritative run history. Export preserves source bytes, including +damaged journal evidence, and always states `executePromise: false`. + +## Recovery boundaries + +Missing or damaged headers, incomplete journals, ambiguous effects, unknown owners, +and changed directory identities fail closed. An interrupted script can leave a +dirty TE mutation even after its process exits: status remains `unknown`, capacity +is retained, and no terminal Receipt is fabricated. SIGINT first records a versioned +cancellation; a bounded process shutdown fallback still reaps owned children. + +Legacy conflict detection covers observable core run records and locks. It stops +new attempts and never kills external writers. The trusted local +`LegacyConflictGuard.acknowledgeStopped()` API requires captured writers to be dead. +Invisible unmanaged writers require external service-manager exclusion. The managed +legacy mutex migration helper applies only to its directly supervised child writers. + +The provider reports resolve-only containment and admission-time revocation. This +does not claim an OS sandbox, arbitrary in-flight recovery, cross-domain transfer, +or general DAG editing. Logical compaction retains the physical evidence journal. diff --git a/packages/taskflow-control/package.json b/packages/taskflow-control/package.json index 3b2cf425..cc534ef8 100644 --- a/packages/taskflow-control/package.json +++ b/packages/taskflow-control/package.json @@ -1,7 +1,7 @@ { "name": "taskflow-control", "version": "0.3.0-beta.2", - "description": "Experimental workspace-only control plane: Unix UDS singleton attach and files-only project store. Proposed wire contracts; admission, parked approvals, receipts, registry coordination and WebUI are incomplete and excluded from the Taskflow 1.0 public contract.", + "description": "Private Taskflow control-plane candidate with authenticated project routing, durable approvals, coordinated TE execution, receipts, CLI, MCP and a local console. Publication requires release-owner integration and acceptance.", "keywords": [ "taskflow", "control-plane", @@ -52,17 +52,23 @@ }, "files": [ "dist", - "README.md" + "README.md", + "GUIDE.md" ], "scripts": { "build": "rm -rf dist && tsc -p tsconfig.build.json", "prepublishOnly": "npm run build" }, "dependencies": { - "taskflow-core": "workspace:*" + "taskflow-core": "workspace:*", + "taskflow-mcp-core": "workspace:*" }, "peerDependencies": { "typebox": "*" }, - "private": true + "private": true, + "bin": { + "taskflow-control": "./dist/control-cli.js", + "taskflow-project-admin": "./dist/project-admin-cli.js" + } } diff --git a/packages/taskflow-control/src/admission-service.ts b/packages/taskflow-control/src/admission-service.ts new file mode 100644 index 00000000..afae94f3 --- /dev/null +++ b/packages/taskflow-control/src/admission-service.ts @@ -0,0 +1,69 @@ +/** Project-side admission arbitration. ADMIT and ABANDON are decisions in the + * actual ControlStore journal, serialized by its writer fence. No side files or + * observer callbacks carry admission authority. */ +import { ControlError } from "./errors.ts"; +import type { ConcurrencyReservation } from "./schema/coordinator.ts"; +import type { ControlStore, AdmissionBinding, AdmissionDecision } from "./store/store.ts"; +import type { CoordinatorAdmissionDecision, CoordinatorAdmissionObservation, CoordinatorAdmissionProof } from "./store/coordinator-store.ts"; + +export class ProjectAdmissionService { + readonly #store: ControlStore; + constructor(store: ControlStore) { this.#store = store; } + + readAdmission(reservation: ConcurrencyReservation): CoordinatorAdmissionObservation { + this.#binding(reservation); + const decision = this.#store.readAdmission(binding(reservation)); + return decision ? proof(decision) : null; + } + + abandonAdmissionIfAbsent(reservation: ConcurrencyReservation): CoordinatorAdmissionDecision { + this.#binding(reservation); + return proof(this.#store.abandonAdmissionIfAbsent(binding(reservation))); + } + + admit(reservation: ConcurrencyReservation, expectedRunVersion: number): CoordinatorAdmissionProof { + this.#binding(reservation); + if (reservation.state !== "reserved") conflict("admission requires a reserved coordinator binding"); + const decision = this.#store.admitRun(binding(reservation), expectedRunVersion); + if (decision.status !== "admitted") conflict("reservation was durably abandoned; a fresh reservation is required"); + return proof(decision) as CoordinatorAdmissionProof; + } + + /** Called by the host at its dispatch boundary after live authorization and + * a current coordinator read. This is a binding check, not a replacement for + * the host's epoch/provider dispatch fence. No await occurs in this check. */ + assertDispatch(reservation: ConcurrencyReservation): CoordinatorAdmissionProof { + this.#binding(reservation); + if (reservation.state !== "committed") conflict("dispatch requires a committed coordinator reservation"); + const decision = this.#store.readAdmission(binding(reservation)); + if (!decision || decision.status !== "admitted" || decision.decisionCommitSeq !== reservation.projectAdmitCommitSeq) { + conflict("dispatch requires the exact durable project admission"); + } + const run = this.#store.readRun(reservation.runId); + if (!run || run.status !== "running" || !["admitted", "executing"].includes(run.stage) + || run.requiresReadmission || run.reservationId !== reservation.reservationId + || run.projectAdmitCommitSeq !== decision.decisionCommitSeq) conflict("run no longer authorizes this dispatch"); + return proof(decision) as CoordinatorAdmissionProof; + } + + #binding(reservation: ConcurrencyReservation): void { + const header = this.#store.header; + if (reservation.projectId !== header.projectId || reservation.projectControlDomainId !== header.controlDomainId) { + conflict("admission reservation belongs to another project or control domain"); + } + } +} +function proof(decision: AdmissionDecision): CoordinatorAdmissionDecision { + const binding = { reservationId: decision.reservationId, projectId: decision.projectId, + projectControlDomainId: decision.projectControlDomainId, runId: decision.runId, runVersion: decision.runVersion }; + return decision.status === "admitted" ? { ...binding, projectAdmitCommitSeq: decision.decisionCommitSeq } + : { ...binding, status: "abandoned", abandonmentCommitSeq: decision.decisionCommitSeq }; +} +function conflict(message: string): never { + throw new ControlError("TF_ADMISSION_BINDING_CONFLICT", message, { recoveryAction: "operator", sideEffects: "none" }); +} + +function binding(reservation: ConcurrencyReservation): AdmissionBinding { + return { reservationId: reservation.reservationId, projectId: reservation.projectId, + projectControlDomainId: reservation.projectControlDomainId, runId: reservation.runId }; +} diff --git a/packages/taskflow-control/src/approval-edits.ts b/packages/taskflow-control/src/approval-edits.ts new file mode 100644 index 00000000..f6b72886 --- /dev/null +++ b/packages/taskflow-control/src/approval-edits.ts @@ -0,0 +1,51 @@ +/** Bounded approval edits: typed UTF-8 output, or replacement of unstarted + * downstream phase bodies in the already admitted graph. */ +import { compileTaskflowToIR, isQuiescentApprovalCheckpoint, transitiveDownstream, validateTaskflow, type RunState, type Taskflow } from "taskflow-core"; +import { contractViolations } from "taskflow-core/contract"; +import { ControlError } from "./errors.ts"; +import { commandRequestHash } from "./schema/commands.ts"; +import type { ArtifactRef } from "./schema/evidence.ts"; + +export const MAX_APPROVAL_EDIT_BYTES = 1024 * 1024; +function invalid(message: string): never { throw new ControlError("TF_COMMAND_FAILED", message); } + +export function decodeApprovalEdit(bytes: Uint8Array, artifact: ArtifactRef, kind: "output" | "plan"): string { + if (bytes.byteLength > MAX_APPROVAL_EDIT_BYTES || artifact.storageClass !== "project" || artifact.redactionClass !== "internal") invalid("approval edit requires a project/internal artifact of at most 1 MiB"); + if (kind === "output" ? !/^text\/plain(?:;\s*charset=utf-8)?$/i.test(artifact.mediaType) : !/^application\/json(?:;\s*charset=utf-8)?$/i.test(artifact.mediaType)) invalid("approval edit artifact media type does not match its edit kind"); + let text: string; + try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { return invalid("approval edit must be valid UTF-8"); } + if (text.includes("\0")) invalid("approval edit must not contain NUL"); + return text; +} + +export function validateApprovalOutputEdit(state: RunState, phaseId: string, text: string): string { + const phase = state.def.phases.find(item => item.id === phaseId); + if (phase?.type !== "approval") invalid("output edit target is not an approval phase"); + const output = text.trim(); + if (!output) invalid("approval output edit must not be empty"); + // Core approval outputs are text (edit.note), so validate that exact value + // against its OutputContract before admitting any downstream work. + const violations = contractViolations(output, phase.expect ?? { type: "string" }); + if (violations.length) invalid(`approval output contract failed: ${violations.join("; ")}`); + return output; +} + +export async function validateApprovalPlanEdit(state: RunState, phaseId: string, candidate: unknown): Promise<{ flow: Taskflow; irHash: string }> { + const check = validateTaskflow(candidate); + if (!check.ok) invalid(`approval plan is invalid: ${check.errors.join("; ")}`); + if (!state.foregroundOwner?.approvalWait?.includes(phaseId) || !isQuiescentApprovalCheckpoint(state, state.foregroundOwner.approvalWait)) throw new ControlError("TF_RECONCILE_REQUIRED", "plan edit requires its quiescent approval checkpoint"); + const flow = structuredClone(candidate) as Taskflow; + const { phases: originalPhases, ...originalTop } = state.def, { phases: nextPhases, ...nextTop } = flow; + if (commandRequestHash(originalTop) !== commandRequestHash(nextTop)) invalid("plan edit cannot change top-level settings or resource declarations"); + if (commandRequestHash(originalPhases.map(phase => phase.id)) !== commandRequestHash(nextPhases.map(phase => phase.id))) invalid("plan edit cannot add, remove, or reorder phases"); + const downstream = new Set(transitiveDownstream(originalPhases, phaseId)); + for (let i = 0; i < originalPhases.length; i++) { + const previous = originalPhases[i]!, next = nextPhases[i]!; + if (commandRequestHash(previous) === commandRequestHash(next)) continue; + if (!downstream.has(previous.id) || state.phases[previous.id] !== undefined) invalid("plan edit cannot rewrite completed, active, or non-downstream phases"); + if (commandRequestHash([previous.dependsOn, previous.from, previous.join]) !== commandRequestHash([next.dependsOn, next.from, next.join])) invalid("plan edit cannot change phase dependencies or joins"); + } + const compiled = await compileTaskflowToIR(flow); + if (!compiled.hash || compiled.usedFallbackHash || compiled.errors.length) invalid("edited plan cannot be linked to a content-addressed IR"); + return { flow, irHash: compiled.hash }; +} diff --git a/packages/taskflow-control/src/approval-service.ts b/packages/taskflow-control/src/approval-service.ts index f8539c95..73f334e8 100644 --- a/packages/taskflow-control/src/approval-service.ts +++ b/packages/taskflow-control/src/approval-service.ts @@ -35,17 +35,19 @@ export type ApprovalReadmissionIntent = { export type ApprovalOutbox = ApprovalReleaseIntent | ApprovalReadmissionIntent; export type ApprovalJournalEvent = { eventId: string; recordedAt: number; payload: ControlEventPayload }; export interface ApprovalJournalState { run: ApprovalRun; approvals: DurableApprovalRequest[]; outbox: ApprovalOutbox[] } -export interface ApprovalMutation extends ApprovalJournalState { command?: CommandRecord; events: ApprovalJournalEvent[] } -export interface ApprovalCommit extends ApprovalJournalState { commitSeq: number } +export interface ApprovalMutation extends ApprovalJournalState { command?: CommandRecord; responseJson?: string; events: ApprovalJournalEvent[] } +export interface ApprovalCommit extends ApprovalJournalState { commitSeq: number; responseJson?: string } +export type CommandBinding = Pick; export interface ApprovalStorage { - readRun(runId: string): ApprovalRun | undefined; + readCommittedCommand?(binding: CommandBinding): {record: CommandRecord; responseJson: string} | undefined; + readRun(runId: string): ApprovalRun | undefined; readApproval(approvalRequestId: string): DurableApprovalRequest | undefined; listApprovals(): DurableApprovalRequest[]; readOutbox(runId: string): ApprovalOutbox[]; /** Synchronous, writer-locked CAS; journal fsync precedes observable state. * The adapter checks runVersion and commits the complete batch atomically. */ mutateRun(runId: string, expectedRunVersion: number, - build: (state: ApprovalJournalState, meta: { commitSeq: number }) => ApprovalMutation): ApprovalCommit; + build: (state: ApprovalJournalState, meta: { commitSeq: number }) => ApprovalMutation, binding?: CommandBinding): ApprovalCommit; } export type ApprovalOperation = "request" | "read" | "decide" | "cancel" | "expire" | "park" | "readmit"; export type ApprovalAuthorization = { callerPrincipal: string; authorizationContextHash: string }; @@ -107,6 +109,19 @@ export class ApprovalService { if (typeof authority[name] !== "function") revoked(`approval authority requires ${name}`); } } + #binding(run: ApprovalRun, input: {commandId:string}, kind: CommandRecord["kind"], auth: ApprovalAuthorization): CommandBinding { + return {commandId:input.commandId, kind, requestHash:commandRequestHash(input), callerPrincipal:auth.callerPrincipal, projectId:run.projectId,controlDomainId:run.controlDomainId}; + } + async #replay(context:C, binding:CommandBinding, operation:ApprovalOperation, runId:string, requestId?:string):Promise { + const authorize = () => this.authority.authorize(context,{operation,run:this.#run(runId),...(requestId ? {request:this.#request(requestId)}:{})}); + const auth = await authorize(); + if (auth.callerPrincipal !== binding.callerPrincipal) revoked("verified principal changed during replay"); + const saved = this.storage.readCommittedCommand?.(binding); if (!saved) return; + const final = await authorize(); + if (final.callerPrincipal !== binding.callerPrincipal) revoked("verified principal changed before disclosure"); + return this.storage.readCommittedCommand!(binding)!.responseJson; + } + #run(id: string): ApprovalRun { return this.storage.readRun(id) ?? invalid("approval requires an existing durable run"); } #request(id: string): DurableApprovalRequest { return this.storage.readApproval(id) ?? invalid("approval request does not exist"); } #checkQuiescence(run: ApprovalRun, proof: ApprovalQuiescence): boolean { @@ -115,10 +130,12 @@ export class ApprovalService { && proof.providerNoLiveProcessTree === true && proof.noAmbiguousJobs === true && proof.noPendingResourceIntents === true && proof.reconcileTimeoutOnly === false); } + #proof(p:ApprovalQuiescence):ApprovalQuiescence { return {proofId:p.proofId,projectId:p.projectId,projectControlDomainId:p.projectControlDomainId,runId:p.runId,projectAdmitCommitSeq:p.projectAdmitCommitSeq,providerNoLiveProcessTree:p.providerNoLiveProcessTree,noAmbiguousJobs:p.noAmbiguousJobs,noPendingResourceIntents:p.noPendingResourceIntents,reconcileTimeoutOnly:p.reconcileTimeoutOnly}; } + #release(state: ApprovalJournalState, requestId: string, proof?: ApprovalQuiescence): ApprovalOutbox[] { if (!proof || !state.run.reservationId || !this.#checkQuiescence(state.run, proof) || state.outbox.some((item) => item.kind === "approval.release" && item.reservationId === state.run.reservationId && !item.complete)) return state.outbox; - return [...state.outbox, { kind: "approval.release", intentId: randomUUID(), approvalRequestId: requestId, runId: state.run.runId, reservationId: state.run.reservationId, evidence: proof, complete: false }]; + return [...state.outbox, { kind: "approval.release", intentId: randomUUID(), approvalRequestId: requestId, runId: state.run.runId, reservationId: state.run.reservationId, evidence: this.#proof(proof), complete: false }]; } async read(context: C, approvalRequestId: string): Promise { const request = this.#request(approvalRequestId); @@ -166,7 +183,10 @@ export class ApprovalService { const request = this.#request(input.approvalRequestId), run = this.#run(input.runId); if (request.runId !== run.runId) invalid("approval does not belong to this run"); if (!["approve", "reject", "edit"].includes(input.decision) || !request.allowedDecisions.includes(input.decision)) invalid("decision not allowed"); - await this.authority.authorize(context, { operation: "decide", run, request }); + const initialAuth = await this.authority.authorize(context, { operation: "decide", run, request }); + const binding = this.#binding(run,input,"approval.decide",initialAuth); + const replay = await this.#replay(context,binding,"decide",run.runId,request.approvalRequestId); + if (replay !== undefined) return JSON.parse(replay) as DurableApprovalRequest; if (input.decision === "edit") { if (!input.editArtifactRef || !Value.Check(ArtifactRefSchema, input.editArtifactRef) || !["output", "plan"].includes(input.editKind ?? "")) invalid("edit requires typed guidance artifact"); await this.authority.validateEdit(context, run, request, { kind: input.editKind!, artifact: input.editArtifactRef! }); @@ -174,7 +194,7 @@ export class ApprovalService { const proof = await this.authority.quiescence(context, run); const auth = await this.authority.authorize(context, { operation: "decide", run: this.#run(run.runId), request: this.#request(request.approvalRequestId) }); let expired = false; - this.storage.mutateRun(run.runId, input.expectedRunVersion, (state, meta) => { + const committed = this.storage.mutateRun(run.runId, input.expectedRunVersion, (state, meta) => { const current = state.approvals.find((item) => item.approvalRequestId === request.approvalRequestId) ?? invalid("missing approval"); if (current.status !== "pending" || state.run.status !== "paused" || current.expectedRunVersion !== input.expectedRunVersion) stale("approval is already settled or run changed"); if (current.deadline <= Date.now()) { @@ -189,10 +209,14 @@ export class ApprovalService { const nextRun = { ...state.run, requiresReadmission: input.decision !== "reject", status: input.decision === "reject" ? "blocked" as const : stillPending ? "paused" as const : "running" as const, stage: input.decision === "reject" ? "terminal" as const : stillPending ? state.run.stage : "queued" as const }; const command: CommandRecord = { commandId: input.commandId, kind: "approval.decide", requestHash: commandRequestHash(input), callerPrincipal: auth.callerPrincipal, authorizationContextHash: auth.authorizationContextHash, projectId: run.projectId, controlDomainId: run.controlDomainId, status: "completed", firstCommitSeq: meta.commitSeq, lastCommitSeq: meta.commitSeq, recordedAt: Date.now() }; - return advance(state, { run: nextRun, approvals, ...(input.decision === "reject" ? { outbox: this.#release(state, current.approvalRequestId, proof) } : {}) }, [decisionEvent], command); - }); + return { ...advance(state, { run: nextRun, approvals, ...(input.decision === "reject" ? { outbox: this.#release(state, current.approvalRequestId, proof) } : {}) }, [decisionEvent], command), responseJson: JSON.stringify(decided) }; + }, binding); if (expired) stale("approval expired; late decisions cannot be committed"); - return this.read(context, request.approvalRequestId); + const response = await this.#replay(context,binding,"decide",run.runId,request.approvalRequestId); + if (response !== undefined) return JSON.parse(response) as DurableApprovalRequest; + // Compatibility adapters still return their own committed response, never latest state. + await this.authority.authorize(context,{operation:"decide",run:this.#run(run.runId),request:this.#request(request.approvalRequestId)}); + return JSON.parse(committed.responseJson!) as DurableApprovalRequest; } #expireMutation(state: ApprovalJournalState, request: DurableApprovalRequest, proof?: ApprovalQuiescence): ApprovalMutation { return advance(state, { run: { ...state.run, status: "blocked", stage: "terminal", requiresReadmission: false }, outbox: this.#release(state, request.approvalRequestId, proof), approvals: state.approvals.map((item) => item.status !== "pending" ? item : { ...item, status: item.approvalRequestId === request.approvalRequestId ? "expired" : "cancelled", decidedAt: Date.now() }) }, state.approvals.filter((item) => item.status === "pending").map((item) => event({ kind: "approval.settled", approvalRequestId: item.approvalRequestId, decision: item.approvalRequestId === request.approvalRequestId ? "expired" : "cancelled" }))); @@ -214,21 +238,30 @@ export class ApprovalService { return count; } async cancel(context: C, input: { commandId: string; runId: string; expectedRunVersion: number }): Promise { - if (!Value.Check(UuidSchema, input.commandId)) invalid("cancel requires a command ID"); + input = structuredClone(input); + if (!Value.Check(UuidSchema, input.commandId) || !Number.isSafeInteger(input.expectedRunVersion) || input.expectedRunVersion < 0) invalid("cancel requires a command ID and version"); const run = this.#run(input.runId); - await this.authority.authorize(context, { operation: "cancel", run }); + const initialAuth = await this.authority.authorize(context, { operation: "cancel", run }); + const binding = this.#binding(run,input,"run.cancel",initialAuth); + const replay = await this.#replay(context,binding,"cancel",run.runId); + if (replay !== undefined) return JSON.parse(replay) as ApprovalRun; const proof = await this.authority.quiescence(context, run); const authorization = await this.authority.authorize(context, { operation: "cancel", run: this.#run(run.runId) }); - return this.storage.mutateRun(run.runId, input.expectedRunVersion, (state, meta) => { + const committed = this.storage.mutateRun(run.runId, input.expectedRunVersion, (state, meta) => { if (["completed", "failed", "blocked", "cancelled"].includes(state.run.status)) stale("terminal run history cannot be cancelled again"); const command: CommandRecord = { commandId: input.commandId, kind: "run.cancel", requestHash: commandRequestHash(input), callerPrincipal: authorization.callerPrincipal, authorizationContextHash: authorization.authorizationContextHash, projectId: run.projectId, controlDomainId: run.controlDomainId, status: "completed", firstCommitSeq: meta.commitSeq, lastCommitSeq: meta.commitSeq, recordedAt: Date.now() }; - return advance(state, { + const mutation = advance(state, { run: { ...state.run, requiresReadmission: false, status: this.#checkQuiescence(state.run, proof) ? "cancelled" : "unknown", stage: this.#checkQuiescence(state.run, proof) ? "terminal" : "reconciling", needsOperator: !this.#checkQuiescence(state.run, proof) }, approvals: state.approvals.map((item) => item.status === "pending" ? { ...item, status: "cancelled" as const, decidedAt: Date.now() } : item), outbox: this.#release(state, state.approvals.at(-1)?.approvalRequestId ?? invalid("run has no approval"), proof), }, state.approvals.filter((item) => item.status === "pending").map((item) => event({ kind: "approval.settled", approvalRequestId: item.approvalRequestId, decision: "cancelled" })), command); - }).run; + return {...mutation,responseJson:JSON.stringify(mutation.run)}; + }, binding); + const response = await this.#replay(context,binding,"cancel",run.runId); + if (response !== undefined) return JSON.parse(response) as ApprovalRun; + await this.authority.authorize(context,{operation:"cancel",run:this.#run(run.runId)}); + return JSON.parse(committed.responseJson!) as ApprovalRun; } /** Re-prove quiescence after a non-quiescent pause; never infer it from status. */ async park(context: C, input: { runId: string; expectedRunVersion: number }): Promise { @@ -240,7 +273,7 @@ export class ApprovalService { if (!(state.run.status === "paused" || state.run.status === "running" && state.run.stage === "queued") || state.run.requiresReadmission !== true) stale("terminal or executing runs cannot be revived by park"); if (!this.#checkQuiescence(state.run, proof)) throw new ControlError("TF_RECONCILE_REQUIRED", "park requires fresh TE quiescence", { recoveryAction: "operator", sideEffects: "unknown" }); const approval = state.approvals.find((item) => item.status === "pending" || item.status === "approved" || item.status === "edited") ?? invalid("run has no durable approval"); - const intent: ApprovalReleaseIntent[] = state.run.reservationId ? [{ kind: "approval.release", intentId: randomUUID(), approvalRequestId: approval.approvalRequestId, runId: run.runId, reservationId: state.run.reservationId, evidence: proof, complete: false }] : []; + const intent: ApprovalReleaseIntent[] = state.run.reservationId ? [{ kind: "approval.release", intentId: randomUUID(), approvalRequestId: approval.approvalRequestId, runId: run.runId, reservationId: state.run.reservationId, evidence: this.#proof(proof), complete: false }] : []; return advance(state, { run: { ...state.run, status: "paused", stage: "parked", requiresReadmission: true }, outbox: [...state.outbox.filter((item) => item.kind !== "approval.release" || item.complete), ...intent] }, []); }); await this.drainReleases(context, run.runId); diff --git a/packages/taskflow-control/src/control-cli.ts b/packages/taskflow-control/src/control-cli.ts new file mode 100644 index 00000000..11c4eb34 --- /dev/null +++ b/packages/taskflow-control/src/control-cli.ts @@ -0,0 +1,193 @@ +#!/usr/bin/env node +/** Explicit local entrypoint; auto never falls back to standalone. */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { pathToFileURL } from "node:url"; +import { randomUUID } from "node:crypto"; +import { ControlHost, defaultServerHello } from "./control-host.ts"; +import { ProjectRegistry } from "./project-registry.ts"; +import { RuntimeTeExecutionProvider } from "./runtime-provider.ts"; +import { answerAuthorizationChallenge, type AuthorizationChallenge, type VerifiedContext, type AuthorizationCommandKind } from "./authorization.ts"; +import { bootstrapLocalAuthority, createPrivateFileOnce, ensurePrivateDirectory } from "./local-bootstrap.ts"; +import { createControlEvidenceStore, readControlEvidence } from "./store/evidence-adapter.ts"; +import { launchControlConsole } from "./control-console.ts"; +import { serveControlMcp } from "./control-mcp.ts"; +import { singletonPaths } from "./singleton.ts"; +import { ControlError } from "./errors.ts"; +import type { ControlMode } from "./modes.ts"; +import type { RunSnapshot } from "./schema/run.ts"; +import { Value } from "typebox/value"; +import { registerGracefulSignalOwner } from "taskflow-core/control-execution"; +import { ControlStoreHeaderSchema } from "./schema/header.ts"; + +const HELP = `taskflow-control run --root DIR --flow FILE [--args JSON] [--mode auto|coordinated|standalone] +taskflow-control status --root DIR [--run UUID] +taskflow-control serve --root DIR [--root DIR ...] [--console] [--port NUMBER] +taskflow-control mcp --root DIR [--mode auto|coordinated|standalone] +Common: --control-home DIR (defaults to TASKFLOW_HOME/control or ~/.taskflow/control). +Local owner grants are persisted under CONTROL_HOME/policies and reread per operation. +The CLI executes script flows directly; agent execution requires an embedded configured runner. +Console's one-use browser token is saved to a 0600 local file, never printed or put in URLs. +`; +function fail(message: string): never { throw new ControlError("TF_BOOTSTRAP_FAILED", message); } +function parse(argv: string[]) { + const command = argv[0]; if (!["run", "status", "serve", "mcp"].includes(command)) fail(HELP); + const flags = new Map(); + for (let i = 1; i < argv.length; i++) { + const flag = argv[i]; if (!["--root", "--flow", "--args", "--mode", "--control-home", "--run", "--console", "--port"].includes(flag)) fail(`unknown option ${flag}`); + const value = flag === "--console" ? "true" : argv[++i]; if (!value || value.startsWith("--")) fail(`value required for ${flag}`); + if (flag !== "--root" && flags.has(flag)) fail(`duplicate option ${flag}`); + flags.set(flag, [...(flags.get(flag) ?? []), value]); + } + const roots = (flags.get("--root") ?? [process.cwd()]).map(root => fs.realpathSync(root)); + if (roots.some(root => !fs.statSync(root).isDirectory())) fail("project root must be a directory"); + if (command !== "serve" && roots.length !== 1) fail("multiple roots require serve"); + const get = (key: string) => flags.get(key)?.[0]; + const mode = get("--mode") ?? (command === "status" ? "coordinated" : "auto"); + if (!["auto", "coordinated", "standalone"].includes(mode)) fail("invalid control mode"); + const port = get("--port") === undefined ? undefined : Number(get("--port")); + if (port !== undefined && (!Number.isInteger(port) || port < 0 || port > 65535)) fail("invalid console port"); + if (command !== "serve" && (get("--console") || port !== undefined)) fail("console options require serve"); + return { command, roots, mode: mode as ControlMode, controlHome: path.resolve(singletonPaths(get("--control-home")).controlHome), flow: get("--flow"), args: get("--args"), runId: get("--run"), console: !!get("--console"), port }; +} +function readHeader(root: string) { + const file = path.join(root, ".taskflow", "control", "header"); + if (fs.lstatSync(file).isSymbolicLink()) fail("project header cannot be a symbolic link"); + const header: unknown = JSON.parse(fs.readFileSync(file, "utf8")); + if (!Value.Check(ControlStoreHeaderSchema, header)) fail("project header is invalid"); + const directory = fs.realpathSync(path.dirname(file)), stat = fs.statSync(directory); + if (header.directoryBinding.canonicalPath !== directory || header.directoryBinding.device !== String(stat.dev) || header.directoryBinding.inode !== String(stat.ino)) fail("project store identity mismatch"); + return header; +} +export async function runControlCli(argv = process.argv.slice(2)): Promise { + if (argv.length === 0 || argv.includes("--help")) { process.stdout.write(HELP); return 0; } + const config = parse(argv); + for (const root of config.roots) { + const suffix = path.relative(root, config.controlHome); + if (suffix === "" || (!path.isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${path.sep}`))) fail("control home must be outside every project root; TE authority storage cannot overlap the execution workspace"); + } + // Reject unreadable input before opening an owner. Runtime validates its schema. + const flow: unknown = config.command === "run" ? JSON.parse(fs.readFileSync(config.flow ?? fail("run requires --flow FILE"), "utf8")) : undefined; + const args: unknown = JSON.parse(config.args ?? "{}"); + ensurePrivateDirectory(config.controlHome); + const registry = new ProjectRegistry(path.join(config.controlHome, "registry.json")); + const { authorization, secret } = bootstrapLocalAuthority(config.controlHome, config.roots, { + verifyArtifactReachability: (_principal, binding, digest, commandKind) => { + const mount = registry.resolve(binding.projectId); + if (mount.projectRoot !== binding.projectRoot || mount.store.header.controlDomainId !== binding.controlDomainId) return false; + const ledger = readControlEvidence(mount.store); + if (commandKind === "run.submit") { + for (const run of Object.values(ledger.runs)) if (run.artifactRefs.some(ref => ref.digest === digest)) return true; + for (const refs of Object.values(ledger.receipts)) if (refs.receiptRef.digest === digest || refs.manifestProofRef.digest === digest) return true; + } + if (ledger.events.some(({ event }) => event.payload.kind === "artifact.recorded" && event.payload.commandKind === commandKind && event.payload.artifact.digest === digest)) return true; + return mount.store.readJournal().some(batch => batch.command?.kind === commandKind && batch.command?.responseArtifactRef?.digest === digest); + }, + }); + const host = new ControlHost({ mode: config.mode, controlHome: config.controlHome, registry, authorization, trustedInProcessFeatures: ["durable-approval"], + projectRoot: config.roots[0], projectStorePath: path.join(config.roots[0], ".taskflow", "control"), + projectMounts: config.roots.slice(1).map(projectRoot => ({ projectRoot, storePath: path.join(projectRoot, ".taskflow", "control") })), + evidenceFactory: (mount, verify) => createControlEvidenceStore(mount.store, { + terminalEvidence: { verify }, + authorize: async (actor, scope) => { + const context = actor as VerifiedContext; + authorization.identity(context); + const header = mount.store.header; + if (scope.projectId !== header.projectId || scope.controlDomainId !== header.controlDomainId) fail("evidence request does not match the mounted project"); + let commandKind: AuthorizationCommandKind = "run.submit"; + if (scope.target.kind === "command") { + const command = mount.store.readJournal().find(batch => batch.command?.commandId === scope.target.id)?.command; + if (!command) fail("evidence command is absent from the mounted ledger"); + commandKind = command.kind; + } else { + if (!mount.store.readRun(scope.target.id)) fail("evidence run is absent from the mounted ledger"); + if (scope.artifactRef) { + const recorded = readControlEvidence(mount.store).events.find(({ event }) => event.payload.kind === "artifact.recorded" + && event.payload.runId === scope.target.id && event.payload.artifact.digest === scope.artifactRef!.digest)?.event; + if (recorded?.payload.kind === "artifact.recorded") commandKind = recorded.payload.commandKind as AuthorizationCommandKind; + } + } + return authorization.authorize(context, { projectId: header.projectId, controlDomainId: header.controlDomainId, projectRoot: mount.projectRoot, + operation: scope.artifactRef ? "artifact" : "replay", commandKind, ...(scope.artifactRef ? { artifactDigest: scope.artifactRef.digest } : {}) }); + }, + }), + provider: new RuntimeTeExecutionProvider(path.join(config.controlHome, "provider"), { agents: [], usageAccounting: "unavailable" }), + }); + let consoleServer: Awaited> | undefined; + let handoffPath: string | undefined; + let interrupted = false; + const interrupt = () => { interrupted = true; }; + const releaseSignalOwner = registerGracefulSignalOwner(interrupt); + process.on("SIGINT", interrupt); process.on("SIGTERM", interrupt); + try { + await host.start(); host.hello(defaultServerHello()); + const header = readHeader(config.roots[0]); + let context: VerifiedContext | undefined; + if (host.status.singleton === "attached") { + const challenge = await host.dispatch("auth.challenge", { projectId: header.projectId }, { fencingEpoch: host.status.fencingEpoch }); + if (challenge.binding.projectRoot !== config.roots[0] || challenge.binding.projectId !== header.projectId || challenge.binding.controlDomainId !== header.controlDomainId) fail("host challenge does not match the configured project"); + await host.dispatch("auth.authenticate", { challengeId: challenge.id, proof: answerAuthorizationChallenge(secret, challenge) }, { fencingEpoch: host.status.fencingEpoch }); + } else context = authorization.issueStandalone({ projectId: header.projectId, controlDomainId: header.controlDomainId, projectRoot: config.roots[0] }); + const call = (method: string, params: unknown) => context ? host.dispatchAuthenticated(context, method, params) : host.dispatch(method, params, { fencingEpoch: host.status.fencingEpoch }); + if (config.command === "mcp") { + const stopInput = () => process.stdin.destroy(); + process.on("SIGINT", stopInput); process.on("SIGTERM", stopInput); + try { await serveControlMcp({ call, projectId: header.projectId }); } + finally { process.off("SIGINT", stopInput); process.off("SIGTERM", stopInput); } + return 0; + } + if (config.command === "run") { + const accepted = await call<{ runId: string }>("commands.submit", { projectId: header.projectId, commandId: randomUUID(), kind: "run.submit", flow, args }); + // Keep individual RPCs bounded; an approval can remain parked until another + // authenticated client resolves it. SIGINT retains durable reconciliation facts. + let run: RunSnapshot; + for (;;) { + run = await call("runs.status", { projectId: header.projectId, runId: accepted.runId }); + if (interrupted && !["completed", "failed", "blocked", "cancelled"].includes(run.status)) { + try { + await call("runs.cancel", { projectId: header.projectId, runId: accepted.runId, commandId: randomUUID(), expectedRunVersion: run.runVersion }); + interrupted = false; + } catch (error) { + if (!(error instanceof ControlError) || error.code !== "TF_STALE_VERSION") throw error; + continue; + } + run = await call("runs.status", { projectId: header.projectId, runId: accepted.runId }); + } + if (["completed", "failed", "blocked", "cancelled", "unknown"].includes(run.status) || run.needsOperator) break; + await new Promise(resolve => setTimeout(resolve, 100)); + } + const result = run.status === "unknown" ? undefined : await call("runs.result", { projectId: header.projectId, runId: accepted.runId }); + process.stdout.write(JSON.stringify({ ...accepted, run, ...(result ? { result } : {}) }) + "\n"); return run.status === "completed" ? 0 : 1; + } + if (config.command === "status") { + const result = await call(config.runId ? "runs.status" : "runs.list", { projectId: header.projectId, ...(config.runId ? { runId: config.runId } : {}) }); + process.stdout.write(JSON.stringify({ control: host.status, result }) + "\n"); return 0; + } + if (!context) fail("serve requires ownership; a host already owns this control home (use status/run to attach)"); + if (config.console) { + const contexts = new Map(); + for (const root of config.roots) { const h = readHeader(root); contexts.set(h.projectId, authorization.issueStandalone({ projectId: h.projectId, controlDomainId: h.controlDomainId, projectRoot: root })); } + consoleServer = await launchControlConsole({ host, authorization, registry, contexts, port: config.port }); + handoffPath = path.join(config.controlHome, `console-handoff-${randomUUID()}.json`); + createPrivateFileOnce(handoffPath, Buffer.from(JSON.stringify({ url: consoleServer.url, token: consoleServer.bootstrapToken }) + "\n")); + } + process.stdout.write(JSON.stringify({ control: host.status, projects: registry.list(), ...(consoleServer ? { consoleUrl: consoleServer.url, browserHandoffFile: handoffPath } : {}) }) + "\n"); + if (!interrupted) await new Promise(resolve => { + const stop = () => { process.off("SIGINT", stop); process.off("SIGTERM", stop); resolve(); }; + process.on("SIGINT", stop); process.on("SIGTERM", stop); + }); + return 0; + } finally { + releaseSignalOwner(); + process.off("SIGINT", interrupt); process.off("SIGTERM", interrupt); + await consoleServer?.close(); + if (handoffPath) { try { fs.unlinkSync(handoffPath); } catch { /* preserve primary shutdown error */ } } + host.stop(); registry.close(); secret.fill(0); + } +} +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + runControlCli().then(code => { process.exitCode = code; }, error => { + process.stderr.write(JSON.stringify({ error: error instanceof ControlError ? error.code : "TF_BOOTSTRAP_FAILED", message: error instanceof Error ? error.message : "local control failed" }) + "\n"); + process.exitCode = 1; + }); +} diff --git a/packages/taskflow-control/src/control-console.ts b/packages/taskflow-control/src/control-console.ts new file mode 100644 index 00000000..8aec5bb4 --- /dev/null +++ b/packages/taskflow-control/src/control-console.ts @@ -0,0 +1,73 @@ +/** Trusted local launcher bridge. All run/approval/evidence semantics stay in Host. */ +import { ControlError } from "./errors.ts"; +import type { AuthorizationAuthority, VerifiedContext } from "./authorization.ts"; +import type { ProjectRegistry } from "./project-registry.ts"; +import { startWebConsole, type WebConsoleAuthorization, type WebConsoleServer } from "./web-console.ts"; + +export interface AuthenticatedControlDispatcher { + dispatchAuthenticated(context: VerifiedContext, method: string, params: unknown): Promise; +} +export interface ControlConsoleOptions { + host: AuthenticatedControlDispatcher; + authorization: AuthorizationAuthority; + registry: ProjectRegistry; + /** Issued by the trusted launcher before HTTP starts, never from a browser body. */ + contexts: ReadonlyMap; + port?: number; + sessionTtlMs?: number; +} + +export async function launchControlConsole(options: ControlConsoleOptions): Promise { + const contexts = new Map(options.contexts); + if (contexts.size === 0) throw new ControlError("TF_POLICY_DENIED", "console requires verified mounted-project contexts"); + const validate = async (projectId: string, request: WebConsoleAuthorization): Promise => { + const context = contexts.get(projectId); + if (!context) throw new ControlError("TF_POLICY_DENIED", "project is not available to this console launcher"); + const { store, projectRoot } = options.registry.resolve(projectId); + const header = store.header; + if (request.controlDomainId !== undefined && request.controlDomainId !== header.controlDomainId) throw new ControlError("TF_POLICY_DENIED", "project domain does not match this mount"); + await options.authorization.authorize(context, { + projectId, controlDomainId: header.controlDomainId, projectRoot, + operation: ["approval.decide", "approvals.stageEdit"].includes(request.operation) ? "submit" : "read", + ...(["approval.decide", "approvals.stageEdit"].includes(request.operation) ? { commandKind: "approval.decide" as const } : {}), + }); + return context; + }; + return startWebConsole({ + port: options.port, sessionTtlMs: options.sessionTtlMs, + approvalEditWithArtifactRef: true, approvalOutputEdit: true, + async authorize(request) { + if (request.projectId !== undefined) { + const context = await validate(request.projectId, request); + return { call: (operation, params) => options.host.dispatchAuthenticated(context, operation, params) }; + } + // A context never expands to other mounts. Global navigation aggregates + // project-scoped Host calls and filters denied projects at live policy. + const allowed: { projectId: string; context: VerifiedContext }[] = []; + for (const projectId of contexts.keys()) { + try { allowed.push({ projectId, context: await validate(projectId, request) }); } + catch (error) { + if (!(error instanceof ControlError) || !["TF_POLICY_DENIED", "TF_AUTHORITY_REVOKED"].includes(error.code)) throw error; + } + } + if (allowed.length === 0) throw new ControlError("TF_POLICY_DENIED", "no mounted project is currently authorized"); + return { async call(operation, params) { + if (operation === "projects.list") { + const rows: unknown[] = []; + for (const entry of allowed) { + const result = await options.host.dispatchAuthenticated(entry.context, operation, { ...params, projectId: entry.projectId }); + await validate(entry.projectId, request); + if (!Array.isArray(result)) throw new ControlError("TF_COMMAND_FAILED", "host project list is invalid"); + rows.push(...result); + } + // Recheck every disclosed scope after the last Host await. Revocation + // refuses the whole response; it cannot return a cached earlier grant. + await Promise.all(allowed.map(entry => validate(entry.projectId, request))); + return rows; + } + if (operation !== "control.status") throw new ControlError("TF_POLICY_DENIED", "project selection is required"); + return options.host.dispatchAuthenticated(allowed[0].context, operation, params); + } }; + }, + }); +} diff --git a/packages/taskflow-control/src/control-host.ts b/packages/taskflow-control/src/control-host.ts index 2ff1f1c8..b52409c5 100644 --- a/packages/taskflow-control/src/control-host.ts +++ b/packages/taskflow-control/src/control-host.ts @@ -15,13 +15,18 @@ */ import * as fs from "node:fs"; +import * as path from "node:path"; +import { ProjectRegistry } from "./project-registry.ts"; +import { HostRuntime, type HostRuntimeOptions } from "./host-runtime.ts"; +import { RuntimeTeExecutionProvider } from "./runtime-provider.ts"; +import type { AuthorizationAuthority, VerifiedContext } from "./authorization.ts"; +import type { UdsConnection } from "./uds.ts"; import { getBuildInfo } from "taskflow-core"; import { ControlError, bootstrapFailed } from "./errors.ts"; import { createHelloGate, helloRequiredError, type HelloGate, type HelloVerdict } from "./hello.ts"; import { resolveControlMode, type ControlMode } from "./modes.ts"; import { acquireUserSingleton, - defaultProcessIdentity, recoverStaleEndpoint, renewCoordinatorLease, singletonPaths, @@ -33,12 +38,19 @@ import { import { connectUdsClient, startUdsServer, type UdsClient, type UdsServer } from "./uds.ts"; import { openControlStore, type ControlStore } from "./store/index.ts"; import { CONTROL_WIRE_SCHEMA_VERSION, PROTOCOL_MAJOR, type NegotiationHandshake } from "./schema/index.ts"; -import type { CommandRecord, ControlEvent } from "./schema/index.ts"; + import type { ExecutionProvider } from "./te-provider.ts"; -import type { ControlStoreHeader } from "./schema/index.ts"; -import type { RunSnapshot } from "./schema/run.ts"; + + export interface ControlHostOptions { + registry?: ProjectRegistry; + authorization?: AuthorizationAuthority; + projectRoot?: string; + projectMounts?: {storePath: string; projectRoot: string}[]; + trustedInProcessFeatures?: readonly string[]; + maxActiveRuns?: number; + evidenceFactory?: HostRuntimeOptions["evidenceFactory"]; /** Default auto (fresh install, P13). */ mode?: ControlMode; /** User control home (default `~/.taskflow/control`, TASKFLOW_HOME override). */ @@ -81,7 +93,7 @@ function defaultServerHello(): NegotiationHandshake { supportedReadSchemas: ["taskflow.wire.v1"], supportedWriteSchemas: ["taskflow.wire.v1"], requiredFeatures: [], - offeredFeatures: [], + offeredFeatures: ["durable-approval"], buildInfo: { packageVersion: info.packageVersion, gitCommit: info.gitCommit, @@ -107,9 +119,18 @@ export class ControlHost { #udsClient?: UdsClient; #leaseTimer?: NodeJS.Timeout; #store?: ControlStore; - #standaloneLeasePath?: string; + #standalonePaths?: SingletonPaths; + #runtime?: HostRuntime; + #registry?: ProjectRegistry; + #ownsRegistry = false; + #ready: Promise; + #signalReady!: () => void; + #starting?: Promise; + #contextFeatures = new WeakMap(); + #connections = new WeakMap(); constructor(options: ControlHostOptions) { + this.#ready = new Promise(resolve => { this.#signalReady = resolve; }); if (!options.provider || options.provider.kind !== "te-resources") { throw new ControlError( "TF_AUTHORITY_REVOKED", @@ -153,6 +174,13 @@ export class ControlHost { async start(): Promise { if (this.#state === "started") return this.status; + if (this.#starting) return this.#starting; + const starting=this.#startOnce();this.#starting=starting; + try { return await starting; } finally { this.#starting=undefined; } + } + + async #startOnce(): Promise { + this.#ready=new Promise(resolve=>{this.#signalReady=resolve;}); try { switch (this.mode) { case "standalone": { @@ -161,6 +189,7 @@ export class ControlHost { this.#acquireStandaloneLease(); this.#state = "started"; this.#singleton = "standalone"; + this.#startLeaseTimer(); break; } case "auto": @@ -191,7 +220,8 @@ export class ControlHost { serverHello: this.#helloGate.serverHello, requiredFeatures: this.#options.requiredFeatures, getFencingEpoch: () => this.#fencingEpoch, - handleRpc: (method, params, fencingEpoch) => this.#dispatchCore(method, params, fencingEpoch), + handleRpc: async (method, params, fencingEpoch, connection) => { await this.#ready; return this.#dispatchWire(method, params, fencingEpoch, connection); }, + onDisconnect: connection => { const ctx = this.#connections.get(connection.token)?.context; if (ctx) this.#options.authorization?.revokeContext(ctx); this.#connections.delete(connection.token); }, }); } catch (error) { try { result.release(); } catch { /* best effort */ } @@ -207,10 +237,7 @@ export class ControlHost { // never trusted from the lock file alone (A2b). let client: UdsClient; try { - client = await connectUdsClient({ - endpointPath: result.endpoint, - clientHello: this.#clientHello(), - }); + client = await this.#connectWinner(result.endpoint); } catch (error) { throw bootstrapFailed( `attached to the winner but could not reach its control endpoint ${result.endpoint}: ${error instanceof Error ? error.message : String(error)}`, @@ -227,12 +254,18 @@ export class ControlHost { } } this.#openProjectStore(); + if (this.#registry && this.#options.authorization && this.provider instanceof RuntimeTeExecutionProvider && this.#singleton !== "attached") { + this.#runtime = new HostRuntime({ registry: this.#registry, authorization: this.#options.authorization, provider: this.provider, controlHome: this.paths.controlHome, maxActiveRuns: this.#options.maxActiveRuns, epoch: () => this.#fencingEpoch, holderId: () => this.#holderId!, liveLease: () => this.#liveLease(), evidenceFactory: this.#options.evidenceFactory, durableApprovalAvailable: context => (this.#contextFeatures.get(context) ?? this.#options.trustedInProcessFeatures ?? []).includes("durable-approval") }); + await this.#runtime.initialize(); + } } catch (error) { this.stop(); this.#state = "failed-closed"; + this.#signalReady(); if (error instanceof ControlError) throw error; throw bootstrapFailed(`ControlHost could not start in ${this.mode} mode: ${error instanceof Error ? error.message : String(error)}`); } + this.#signalReady(); return this.status; } @@ -261,7 +294,7 @@ export class ControlHost { /** RPC core without the in-process hello gate (the UDS layer enforces its own). */ async #dispatchCore(method: string, params: unknown, fencingEpoch: number): Promise { - if (fencingEpoch < this.#fencingEpoch) { + if (fencingEpoch !== this.#fencingEpoch) { throw new ControlError( "TF_AUTHORITY_REVOKED", `fencing epoch ${fencingEpoch} is stale; host epoch is ${this.#fencingEpoch}`, @@ -274,39 +307,11 @@ export class ControlHost { case "control.probe": { return await this.provider.probe() as unknown as T; } - case "control.store.header": { - return this.#requireStore().header as unknown as T; - } - case "control.store.status": { - return this.#requireStore().snapshot() as unknown as T; - } - case "commands.submit": { - const body = params as { command?: CommandRecord; events?: ControlEvent[] }; - if (!body || typeof body !== "object" || body.command === undefined || !Array.isArray(body.events)) { - throw new ControlError( - "TF_COMMAND_FAILED", - "commands.submit requires { command, events }", - { recoveryAction: "retry-new-command", sideEffects: "none" }, - ); - } - return this.#requireStore().appendBatch({ command: body.command, events: body.events }) as unknown as T; - } - case "runs.status": { - const runId = typeof params === "object" && params !== null && "runId" in params - ? String((params as { runId: unknown }).runId) - : undefined; - const snapshot: RunSnapshot = { - runId: runId ?? "00000000-0000-0000-0000-000000000000", - projectId: "00000000-0000-0000-0000-000000000000", - controlDomainId: "00000000-0000-0000-0000-000000000000", - status: "unknown", - stage: "received", - slot: "none", - needsOperator: false, - runVersion: 0, - }; - return snapshot as unknown as T; - } + case "control.store.header": + case "control.store.status": + case "commands.submit": + case "runs.status": + throw new ControlError("TF_AUTHORITY_REVOKED", "project operations require a verified transport context"); default: throw new ControlError( "TF_COMMAND_FAILED", @@ -318,24 +323,25 @@ export class ControlHost { /** Renew the coordinator lease while held (best-effort; S3 wires the timer). */ renewLease(): void { - if (this.#singleton === "won" && this.#holderId !== undefined) { - renewCoordinatorLease(this.paths, this.#holderId, this.#fencingEpoch, this.#options.leaseTtlMs ?? 30_000, this.now); + if ((this.#singleton === "won" || this.#singleton === "standalone") && this.#holderId !== undefined) { + renewCoordinatorLease(this.#standalonePaths ?? this.paths, this.#holderId, this.#fencingEpoch, this.#options.leaseTtlMs ?? 30_000, this.now); } } stop(): void { + this.#signalReady(); if (this.#leaseTimer) { clearInterval(this.#leaseTimer); this.#leaseTimer = undefined; } + this.#runtime?.close(); this.#runtime = undefined; + if (this.#ownsRegistry) this.#registry?.close(); + this.#registry = undefined; if (this.#store) { try { this.#store.close(); } catch { /* best effort */ } this.#store = undefined; } - if (this.#standaloneLeasePath) { - try { fs.unlinkSync(this.#standaloneLeasePath); } catch { /* best effort */ } - this.#standaloneLeasePath = undefined; - } + this.#standalonePaths = undefined; const server = this.#udsServer; this.#udsServer = undefined; const client = this.#udsClient; @@ -375,6 +381,12 @@ export class ControlHost { }; } + async #connectWinner(endpointPath: string): Promise { + const deadline = Date.now() + 2000; + for (;;) { try { return await connectUdsClient({endpointPath,clientHello:this.#clientHello()}); } + catch(error) { if(Date.now()>=deadline)throw error; await new Promise(resolve=>setTimeout(resolve,25)); } } + } + /** Keep the coordinator lease valid while the winner holds the singleton. */ #startLeaseTimer(): void { const ttl = this.#options.leaseTtlMs ?? 30_000; @@ -392,77 +404,65 @@ export class ControlHost { } #acquireStandaloneLease(): void { - const leasePath = `${this.paths.controlHome}/standalone-lease.json`; - const holderId = this.#options.holderId ?? `standalone-${defaultProcessIdentity().pid}`; - const identity = this.#options.processIdentity ?? defaultProcessIdentity(); - try { - const fd = fs.openSync(leasePath, "wx", 0o600); - this.#standaloneLeasePath = leasePath; - try { - fs.writeFileSync(fd, JSON.stringify({ - version: 1, - holderId, - pid: identity.pid, - birthToken: identity.birthToken, - acquiredAt: this.now(), - endpoint: this.paths.endpointPath, - })); - } finally { - fs.closeSync(fd); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "EEXIST") { - throw bootstrapFailed(`standalone lease already exists at ${leasePath}; a standalone control is already running for this project`); - } - throw error; - } - this.#holderId = holderId; - this.#fencingEpoch = 1; - this.#standaloneLeasePath = leasePath; + const standalonePaths={...this.paths,lockPath:path.join(this.paths.controlHome,"standalone-lease.json"),leasePath:path.join(this.paths.controlHome,"standalone-coordinator-lease.json"),endpointPath:path.join(this.paths.controlHome,"standalone.sock")}; + const result=acquireUserSingleton({paths:standalonePaths,holderId:this.#options.holderId,processIdentity:this.#options.processIdentity,inspectProcess:this.#options.inspectProcess,now:this.#options.now,leaseTtlMs:this.#options.leaseTtlMs}); + if(result.status!=="won")throw bootstrapFailed("standalone lease belongs to a live or unverified owner"); + this.#standalonePaths=standalonePaths;this.#holderId=result.holderId;this.#fencingEpoch=result.fencingEpoch;this.#releaseSingleton=result.release; } /** Until multi-mount routing exists, never silently use another project's ledger. */ - async #verifyAttachedProjectStore(client: UdsClient): Promise { - const storePath = this.#options.projectStorePath; - if (!storePath) return; - try { - const header = await client.rpc("control.store.header"); - const canonicalPath = fs.realpathSync(storePath); - const stat = fs.statSync(canonicalPath); - if (header.directoryBinding.canonicalPath !== canonicalPath - || header.directoryBinding.device !== String(stat.dev) - || header.directoryBinding.inode !== String(stat.ino)) { - throw new Error("winner is bound to a different project store; multi-mount routing is unavailable"); - } - } catch (error) { - throw bootstrapFailed(`cannot attach project store ${storePath}: ${error instanceof Error ? error.message : String(error)}`); - } + async #verifyAttachedProjectStore(_client: UdsClient): Promise { + // Mount identity is checked after authenticated challenge. An unauthenticated + // peer cannot read another project's header or select its writer path. } #openProjectStore(): void { - const storePath = this.#options.projectStorePath; - if (!storePath) return; - // Attached clients read store state over UDS; only the writer opens it. if (this.#singleton === "attached") return; - try { - this.#store = openControlStore(storePath); - } catch (error) { - throw bootstrapFailed( - `could not open project ControlStore at ${storePath}: ${error instanceof Error ? error.message : String(error)}`, - ); + if (this.#options.registry) { this.#registry = this.#options.registry; if (this.#options.projectStorePath) this.#registry.mount(this.#options.projectStorePath, this.#options.projectRoot); for (const mount of this.#options.projectMounts ?? []) this.#registry.mount(mount.storePath, mount.projectRoot); return; } + const storePath = this.#options.projectStorePath; + if (!storePath && !this.#options.projectMounts?.length) return; + if (this.#options.authorization) { + this.#registry = new ProjectRegistry(path.join(this.paths.controlHome, "registry.json")); this.#ownsRegistry = true; + if (storePath) this.#registry.mount(storePath, this.#options.projectRoot); + for (const mount of this.#options.projectMounts ?? []) this.#registry.mount(mount.storePath, mount.projectRoot); return; } + if (storePath) this.#store = openControlStore(storePath); } - #requireStore(): ControlStore { - if (!this.#store) { - throw new ControlError( - "TF_JOURNAL_UNAVAILABLE", - "ControlHost has no project store open (pass projectStorePath and start as winner/standalone)", - { recoveryAction: "none", sideEffects: "none" }, - ); + #liveLease() { + if (this.#state !== "started") return null; + try { const lease = JSON.parse(fs.readFileSync((this.#standalonePaths ?? this.paths).leasePath, "utf8")); return lease.holderId === this.#holderId && lease.fencingEpoch === this.#fencingEpoch ? lease : null; } catch { return null; } + } + + async dispatchAuthenticated(context: VerifiedContext, method: string, params: unknown): Promise { + if (this.#state !== "started" || !this.#runtime || !this.#options.authorization) throw new ControlError("TF_AUTHORITY_REVOKED", "authenticated runtime is unavailable"); + this.#options.authorization.identity(context); + if (method === "control.status") return this.status as T; + if (method === "control.probe") return await this.provider.probe() as T; + return await this.#runtime.dispatch(context, method, params) as T; + } + + async #dispatchWire(method: string, params: unknown, epoch: number, connection: UdsConnection): Promise { + if (epoch !== this.#fencingEpoch || this.#state !== "started" || connection.closed) throw new ControlError("TF_AUTHORITY_REVOKED", "connection or epoch is no longer live"); + if (method === "control.status" || method === "control.probe") return this.#dispatchCore(method, params, epoch); + const auth = this.#options.authorization; if (!auth || !this.#registry) throw new ControlError("TF_AUTHORITY_REVOKED", "transport authentication is not configured"); + let session = this.#connections.get(connection.token); if (!session) { session = {}; this.#connections.set(connection.token, session); } + const body = params && typeof params === "object" ? params as Record : {}; + if (method === "auth.challenge") { + const mount = this.#registry.resolve(typeof body.projectId === "string" ? body.projectId : undefined); + const challenge = auth.createChallenge({ projectId: mount.store.header.projectId, controlDomainId: mount.store.header.controlDomainId, projectRoot: mount.projectRoot }); + session.challengeId = challenge.id; return challenge; } - return this.#store; + if (method === "auth.authenticate") { + if (typeof body.challengeId !== "string" || body.challengeId !== session.challengeId || typeof body.proof !== "string") throw new ControlError("TF_POLICY_DENIED", "challenge belongs to another connection"); + delete session.challengeId; + if (session.context) auth.revokeContext(session.context); + session.context = auth.authenticate(body.challengeId, body.proof); this.#contextFeatures.set(session.context, (connection.hello?.offeredFeatures ?? []).filter(feature=>this.#helloGate.serverHello.offeredFeatures.includes(feature))); return { authenticated: true }; + } + if (!session.context) throw new ControlError("TF_AUTHORITY_REVOKED", "authenticate before project RPCs"); + return this.dispatchAuthenticated(session.context, method, params); } + } // Re-export the mode/env surface used by hosts. diff --git a/packages/taskflow-control/src/control-mcp.ts b/packages/taskflow-control/src/control-mcp.ts new file mode 100644 index 00000000..d3dab26a --- /dev/null +++ b/packages/taskflow-control/src/control-mcp.ts @@ -0,0 +1,73 @@ +/** MCP is a protocol adapter over the launcher's already authenticated Host. */ +import { Type } from "typebox"; +import { Value } from "typebox/value"; +import { TaskflowSchema } from "taskflow-core"; +import { RPC, RpcError, serveStdio, type RpcHandler } from "taskflow-mcp-core/jsonrpc"; +import { UuidSchema, StringEnum } from "./schema/common.ts"; +import { ApprovalModeSchema } from "./schema/approval.ts"; +import { ArtifactRefSchema } from "./schema/evidence.ts"; +import { ControlError } from "./errors.ts"; + +const scope = { projectId: Type.Optional(UuidSchema), controlDomainId: Type.Optional(UuidSchema) }; +const run = { ...scope, runId: UuidSchema }; +const closed = { additionalProperties: false }; +const definitions = [ + { name: "taskflow_control_projects", description: "List projects authorized for this local session.", method: "projects.list", inputSchema: Type.Object(scope, closed) }, + { name: "taskflow_control_runs", description: "List durable run status in the authorized project.", method: "runs.list", inputSchema: Type.Object(scope, closed) }, + { name: "taskflow_control_submit", description: "Submit a flow to the authenticated Host. Reuse commandId when retrying the same request.", method: "commands.submit", inputSchema: Type.Object({ ...scope, commandId: UuidSchema, flow: TaskflowSchema, args: Type.Optional(Type.Record(Type.String(), Type.Unknown())), approvalMode: Type.Optional(ApprovalModeSchema) }, closed) }, + { name: "taskflow_control_status", description: "Read durable status without phase transcripts.", method: "runs.status", inputSchema: Type.Object(run, closed) }, + { name: "taskflow_control_result", description: "Read the authorized run result, including final output only.", method: "runs.result", inputSchema: Type.Object(run, closed) }, + { name: "taskflow_control_cancel", description: "Cancel a run through the Host's durable control path with its expected run version.", method: "runs.cancel", inputSchema: Type.Object({ ...run, commandId: UuidSchema, expectedRunVersion: Type.Integer({ minimum: 0 }) }, closed) }, + { name: "taskflow_control_approvals", description: "List durable approval requests in the authorized project.", method: "approvals.list", inputSchema: Type.Object(scope, closed) }, + { name: "taskflow_control_approval_stage_edit", description: "Stage bounded output or plan text as a ledger-reachable approval edit artifact. Refresh status before deciding.", method: "approvals.stageEdit", inputSchema: Type.Object({ ...run, editKind: StringEnum(["output", "plan"]), content: Type.String({ maxLength: 1024 * 1024 }) }, closed) }, + { name: "taskflow_control_approval_decide", description: "Approve, reject, or edit a pending approval with its expected run version. Edit requires a staged artifact and editKind.", method: "approvals.decide", inputSchema: Type.Object({ ...run, commandId: UuidSchema, approvalRequestId: UuidSchema, expectedRunVersion: Type.Integer({ minimum: 0 }), decision: StringEnum(["approve", "reject", "edit"]), editKind: Type.Optional(StringEnum(["output", "plan"])), editArtifactRef: Type.Optional(ArtifactRefSchema) }, closed) }, + { name: "taskflow_control_resume", description: "Resume a recoverable run through Host admission and authority checks.", method: "runs.resume", inputSchema: Type.Object(run, closed) }, + { name: "taskflow_control_receipt", description: "Read the authorized evidence receipt for a terminal run.", method: "receipts.get", inputSchema: Type.Object(run, closed) }, + { name: "taskflow_control_why", description: "Explain authorized durable run evidence and effect causes.", method: "evidence.why", inputSchema: Type.Object({ ...run, effectId: Type.Optional(Type.String({ minLength: 1 })), phaseId: Type.Optional(Type.String({ minLength: 1 })), seeds: Type.Optional(Type.Array(Type.String({ minLength: 1 }))) }, closed) }, +] as const; +const callSchema = Type.Object({ name: Type.String(), arguments: Type.Optional(Type.Record(Type.String(), Type.Unknown())) }, closed); +const initializeSchema = Type.Object({ protocolVersion: Type.String(), capabilities: Type.Record(Type.String(), Type.Unknown()), clientInfo: Type.Object({ name: Type.String(), version: Type.String(), title: Type.Optional(Type.String()) }, { additionalProperties: true }) }, { additionalProperties: true }); + +export interface ControlMcpOptions { + /** Bound by CLI bootstrap/HMAC attach; no caller identity can be supplied over MCP. */ + call(method: string, params: unknown): Promise; + projectId: string; + input?: NodeJS.ReadableStream; + output?: NodeJS.WritableStream; +} + +export function serveControlMcp(options: ControlMcpOptions): Promise { + let initialized = false; + const requireInitialized = () => { if (!initialized) throw new RpcError(RPC.INVALID_REQUEST, "initialize is required before using control tools"); }; + const handlers: Record = Object.create(null); + handlers.initialize = params => { + if (!Value.Check(initializeSchema, params)) throw new RpcError(RPC.INVALID_PARAMS, "invalid initialize parameters"); + initialized = true; + return { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "taskflow-control", version: "0.3.0-beta.2" } }; + }; + handlers["notifications/initialized"] = () => undefined; + handlers.ping = () => ({}); + handlers["tools/list"] = params => { + requireInitialized(); + if (params !== undefined && !Value.Check(Type.Object({}, closed), params)) throw new RpcError(RPC.INVALID_PARAMS, "tools/list accepts no parameters"); + return { tools: definitions.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) }; + }; + handlers["tools/call"] = async params => { + requireInitialized(); + if (!Value.Check(callSchema, params)) throw new RpcError(RPC.INVALID_PARAMS, "invalid tool call"); + const tool = definitions.find(definition => definition.name === params.name); + if (!tool) throw new RpcError(RPC.INVALID_PARAMS, "unknown control tool"); + const args = params.arguments ?? {}; + if (tool.method === "approvals.decide" && (args.decision === "edit" ? args.editKind === undefined || args.editArtifactRef === undefined : args.editKind !== undefined || args.editArtifactRef !== undefined)) throw new RpcError(RPC.INVALID_PARAMS, "edit decision requires exactly editKind and editArtifactRef"); + if (!Value.Check(tool.inputSchema, args)) throw new RpcError(RPC.INVALID_PARAMS, "invalid tool arguments"); + try { + const result = await options.call(tool.method, { projectId: options.projectId, ...args, ...(tool.method === "commands.submit" ? { kind: "run.submit" } : {}) }); + return { content: [{ type: "text", text: JSON.stringify(result) }] }; + } catch (error) { + // Never dump request bodies, transcripts, credentials, or unexpected exception text. + const code = error instanceof ControlError ? error.code : "TF_COMMAND_FAILED"; + return { isError: true, content: [{ type: "text", text: JSON.stringify({ error: code }) }] }; + } + }; + return serveStdio(handlers, { input: options.input, output: options.output }); +} diff --git a/packages/taskflow-control/src/host-runtime.ts b/packages/taskflow-control/src/host-runtime.ts new file mode 100644 index 00000000..f991db8d --- /dev/null +++ b/packages/taskflow-control/src/host-runtime.ts @@ -0,0 +1,396 @@ +/** Authenticated mounted-project command service. All lifecycle facts are written + * through ControlStore; coordinator authority reads those same durable facts. */ +import { randomUUID } from "node:crypto"; +import * as path from "node:path"; +import { getBuildInfo, compileTaskflowToIR, validateTaskflow, type Taskflow } from "taskflow-core"; +import { Value } from "typebox/value"; +import { ControlError } from "./errors.ts"; +import { LegacyConflictGuard } from "./legacy-conflict.ts"; +import { ProjectRegistry, type ProjectMount } from "./project-registry.ts"; +import { type AuthorizationAuthority, type VerifiedContext } from "./authorization.ts"; +import { UserCoordinatorStore } from "./store/coordinator-store.ts"; +import { ProjectAdmissionService } from "./admission-service.ts"; +import { RuntimeTeExecutionProvider } from "./runtime-provider.ts"; +import { commandRequestHash, type CommandRecord, type ControlEventPayload } from "./schema/commands.ts"; +import { CONTROL_WIRE_SCHEMA_VERSION, UuidSchema } from "./schema/common.ts"; +import type { BoundPlan } from "./schema/plan.ts"; +import type { RunSnapshot } from "./schema/run.ts"; +import { ApprovalService, type ApprovalRun, type ApprovalDecisionInput, type ApprovalAuthority } from "./approval-service.ts"; +import type { ApprovalMode, ApprovalRequest as DurableApprovalRequest } from "./schema/approval.ts"; +import type { ArtifactRef } from "./schema/evidence.ts"; +import { decodeApprovalEdit, validateApprovalOutputEdit, validateApprovalPlanEdit, MAX_APPROVAL_EDIT_BYTES } from "./approval-edits.ts"; +import type { ApprovalRequest, ApprovalDecision } from "taskflow-core"; +import type { CoordinatorLease, ConcurrencyReservation } from "./schema/coordinator.ts"; +import type { EvidenceStore, TerminalEvidenceObservation } from "./store/evidence-store.ts"; +import { explainControlEvidence } from "./store/evidence-explanation.ts"; +import type { ControlStore } from "./store/store.ts"; + +export interface HostRuntimeOptions { + registry: ProjectRegistry; authorization: AuthorizationAuthority; provider: RuntimeTeExecutionProvider; + controlHome: string; epoch: () => number; holderId: () => string; liveLease: () => CoordinatorLease | null; + maxActiveRuns?: number; + durableApprovalAvailable?: (context:VerifiedContext)=>boolean; + evidenceFactory?: (mount: ProjectMount, verify: HostRuntime["verifyTerminal"]) => EvidenceStore | Promise; +} +function fail(message: string): never { throw new ControlError("TF_COMMAND_FAILED", message); } +function object(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) fail("RPC requires an object"); return value as Record; } +function id(value: unknown): string { if (!Value.Check(UuidSchema, value)) fail("UUID required"); return value as string; } +const fact = (payload: ControlEventPayload) => ({ eventId: randomUUID(), recordedAt: Date.now(), payload }); + +export class HostRuntime { + readonly options: HostRuntimeOptions; + readonly coordinator: UserCoordinatorStore; + readonly #evidence = new Map>(); + readonly #approvals = new Map>(); + readonly #linked = new Map(); + #closed = false; + readonly #submitting = new Set(); + readonly #refreshes = new Map>(); + constructor(options: HostRuntimeOptions) { + this.options = options; + this.coordinator = new UserCoordinatorStore(path.join(options.controlHome, "coordinator"), { initialMaxActiveRuns: options.maxActiveRuns ?? 1, + epoch: options.epoch(), holderId: options.holderId(), authority: { + readLease: options.liveLease, + authorize: async (context, operation, reservation) => { + const identity = options.authorization.identity(context); + const mount = options.registry.resolve(reservation?.projectId ?? identity.binding.projectId); + const kind = operation === "forceRelease" ? "coordinator.forceRelease" : operation === "setMaxActiveRuns" ? "coordinator.setMaxActiveRuns" : "run.submit"; + const decision = await this.#authorize(context, mount, operation === "snapshot" || operation === "readReservation" ? "read" : "submit", kind); + return { principal: decision.principal, ownerId: decision.principal, operator: operation === "forceRelease" || operation === "setMaxActiveRuns" }; + }, + readAdmission: reservation => this.#admission(reservation).readAdmission(reservation), + abandonAdmissionIfAbsent: reservation => this.#admission(reservation).abandonAdmissionIfAbsent(reservation), + readRelease: async reservation => { + const store = options.registry.resolve(reservation.projectId).store; + const run = store.readRun(reservation.runId); if (!run) fail("release run is absent"); + const observed = await options.provider.observe(run.runId); + const quiet = await options.provider.quiescence(run.runId); + return { reservationId: reservation.reservationId, projectId: run.projectId, projectControlDomainId: run.controlDomainId, runId: run.runId, + projectAdmitCommitSeq: run.projectAdmitCommitSeq!, runVersion: run.runVersion, proofId: observed.evidenceCommit, + status: run.status, stage: run.stage, requiresReadmission: !!run.requiresReadmission, + providerNoLiveProcessTree: quiet.quiescent, noAmbiguousJobs: observed.status !== "ambiguous" && quiet.noPendingResourceIntents, + reconcileTimeoutOnly: false }; + }, + } }); + } + async initialize() { await this.coordinator.initialize(); } + close() { this.#closed = true; this.coordinator.close(); } + #admission(r: ConcurrencyReservation) { return new ProjectAdmissionService(this.options.registry.resolve(r.projectId).store); } + async #authorize(context: VerifiedContext, mount: ProjectMount, operation: "read" | "submit" | "replay", commandKind?: "run.submit" | "run.cancel" | "approval.decide" | "coordinator.setMaxActiveRuns" | "coordinator.forceRelease") { + const h = mount.store.header; + return this.options.authorization.authorize(context, { projectId: h.projectId, controlDomainId: h.controlDomainId, projectRoot: mount.projectRoot, operation, ...(commandKind ? { commandKind } : {}) }); + } + #mutate(store: ControlStore, run: ApprovalRun, changes: Partial, payloads: ControlEventPayload[] = []) { + return store.mutateRun(run.runId, run.runVersion, state => { const next = { ...state.run, ...changes, runVersion: state.run.runVersion + 1 }; + return { ...state, run: next, events: [...payloads.map(fact), fact({ kind: "run.snapshot", run: next })] }; }).run; + } + async dispatch(context: VerifiedContext, method: string, raw: unknown): Promise { + const body = raw === undefined ? {} : object(structuredClone(raw)); + const identity = this.options.authorization.identity(context); + const projectId = body.projectId === undefined ? identity.binding.projectId : id(body.projectId); + const mount = this.options.registry.resolve(projectId); + if (body.controlDomainId !== undefined && body.controlDomainId !== mount.store.header.controlDomainId) throw new ControlError("TF_POLICY_DENIED", "request control domain mismatch"); + await this.#authorize(context, mount, "read"); + switch (method) { + case "projects.list": return [{ projectId, controlDomainId: mount.store.header.controlDomainId, name: path.basename(mount.projectRoot) }]; + case "control.store.header": return mount.store.header; + case "control.store.status": return mount.store.snapshot(); + case "coordinator.status": return this.coordinator.snapshot(context); + case "coordinator.setMaxActiveRuns": return this.coordinator.setMaxActiveRuns({commandId:id(body.commandId),maxActiveRuns:body.maxActiveRuns as number},context); + case "coordinator.forceRelease": return this.coordinator.forceRelease(id(body.reservationId),{commandId:id(body.commandId),riskAcknowledgement:body.riskAcknowledgement as true,reason:body.reason as string},context); + case "runs.list": return mount.store.listRuns(); + case "commands.submit": return this.#submit(context, mount, body); + case "runs.get": case "runs.status": case "runs.wait": { + const runId = id(body.runId); const run = mount.store.readRun(runId); if (!run) fail("run does not exist in this project"); + if (method === "runs.wait" && !["completed", "failed", "blocked", "cancelled"].includes(run.status)) await this.options.provider.wait(runId); + await this.#refresh(context, mount, runId); + await this.#authorize(context, mount, "read"); + return mount.store.readRun(runId); + } + case "runs.cancel": return this.#cancel(context,mount,body); + case "runs.result": { await this.#authorize(context,mount,"replay","run.submit"); const runId=id(body.runId); if (!mount.store.readRun(runId)) fail("run is absent"); const observed=await this.options.provider.observe(runId); await this.#authorize(context,mount,"replay","run.submit"); return observed; } + case "approvals.stageEdit": { + const runId=id(body.runId),kind=body.editKind; + if(!["output","plan"].includes(String(kind)) || typeof body.content!=="string" || Buffer.byteLength(body.content)>MAX_APPROVAL_EDIT_BYTES)fail("stageEdit requires bounded output or plan text"); + await this.#authorize(context,mount,"submit","approval.decide"); + const run=mount.store.readRun(runId);if(!run || run.status!=="paused")fail("edit artifact requires a paused approval run"); + const evidence=await this.#evidenceStore(mount); + const artifact=await evidence.stageArtifact(Buffer.from(body.content,"utf8"),{mediaType:kind==="output"?"text/plain; charset=utf-8":"application/json",storageClass:"project",redactionClass:"internal"}); + await evidence.assertDurableReferences([artifact]);await this.#authorize(context,mount,"submit","approval.decide"); + mount.store.mutateRun(runId,run.runVersion,state=>{ + if(state.run.status!=="paused")fail("approval changed before artifact staging"); + const next={...state.run,runVersion:state.run.runVersion+1}; + return {...state,run:next,approvals:state.approvals.map(item=>item.status==="pending"?{...item,expectedRunVersion:next.runVersion}:item),events:[fact({kind:"artifact.recorded",runId,commandKind:"approval.decide",artifact}),fact({kind:"run.snapshot",run:next})]}; + }); + return artifact; + } + case "approval.decide": case "approvals.decide": { + const input = {commandId:id(body.commandId),runId:id(body.runId),approvalRequestId:id(body.approvalRequestId),expectedRunVersion:body.expectedRunVersion as number,decision:body.decision as ApprovalDecisionInput["decision"],...(body.editArtifactRef!==undefined?{editArtifactRef:body.editArtifactRef as ApprovalDecisionInput["editArtifactRef"]}:{}),...(body.editKind!==undefined?{editKind:body.editKind as "output"|"plan"}:{})}; + const needsRecovery=!this.options.provider.hasLiveExecution(input.runId) && mount.store.readRun(input.runId)?.status==="paused"; + if(needsRecovery){ + const recovered=this.options.provider.recoverableExecution(input.runId);this.#linked.set(input.runId,{plan:recovered.plan,flow:recovered.flow}); + } + const decision=await this.#approvalService(mount).decide(context,input); + if(needsRecovery) await this.#resume(context,mount,input.runId); + return decision; + } + case "runs.resume": return this.#resume(context,mount,id(body.runId)); + case "approvals.expire": return this.#approvalService(mount).expireDue(context); + case "approvals.readmit": return this.#approvalService(mount).readmit(context,{approvalRequestId:id(body.approvalRequestId),expectedRunVersion:body.expectedRunVersion as number}); + case "approvals.list": return mount.store.listApprovals(); + case "evidence.why": return explainControlEvidence(mount.store,context,id(body.runId),{ + authorize:async()=>this.#authorize(context,mount,"replay","run.submit"),readDiagnostic:runId=>this.options.provider.readDiagnostic(runId) + },{effectId:body.effectId as string|undefined,phaseId:body.phaseId as string|undefined,seeds:body.seeds as string[]|undefined}); + case "receipts.get": { + const evidence = await this.#evidenceStore(mount); const receipt=await evidence.readReceipt(context, id(body.runId)); return receipt.receipt; + } + default: fail(`unsupported authenticated RPC ${method}`); + } + } + async #submit(context: VerifiedContext, mount: ProjectMount, body: Record) { + if (Object.keys(body).some(key => !["projectId", "controlDomainId", "commandId", "kind", "flow", "args", "approvalMode"].includes(key))) fail("submit contains untrusted or unsupported fields"); + if (body.kind !== "run.submit") fail("commands.submit requires kind run.submit"); + const commandId = id(body.commandId); const auth = await this.#authorize(context, mount, "submit", "run.submit"); + const h = mount.store.header; + const binding = { commandId, kind: "run.submit" as const, callerPrincipal: auth.principal, requestHash: commandRequestHash(body), projectId: h.projectId, controlDomainId: h.controlDomainId }; + const previous = mount.store.readCommittedCommand(binding); if (previous) { await this.#authorize(context, mount, "replay", "run.submit"); return JSON.parse(previous.responseJson); } + new LegacyConflictGuard(mount.projectRoot,mount.store.storePath).assertMayAttempt(); + const validation = validateTaskflow(body.flow); if (!validation.ok) fail(validation.errors.join("; ")); + let mode = (body.approvalMode ?? "compat-auto-reject") as ApprovalMode; if (!["compat-auto-reject","durable-optional","durable-required"].includes(mode)) fail("invalid approval mode"); + if(mode!=="compat-auto-reject" && !this.options.durableApprovalAvailable?.(context)){if(mode==="durable-required")throw new ControlError("TF_FEATURE_REQUIRED","durable-approval was not negotiated by this authenticated client");mode="compat-auto-reject";} + const flow = structuredClone(body.flow) as Taskflow; const args = body.args === undefined ? {} : object(body.args); + const compiled = await compileTaskflowToIR(flow); const probe = await this.options.provider.probe(); + if (!compiled.hash || compiled.usedFallbackHash) throw new ControlError("TF_COMMAND_FAILED", "flow cannot be content-addressed at Link"); + const runId = randomUUID(); const owner = { runId, phaseId: "control", attemptId: randomUUID(), unitId: "run", ancestry: [] }; + const planBody = { schemaVersion: CONTROL_WIRE_SCHEMA_VERSION as typeof CONTROL_WIRE_SCHEMA_VERSION, projectId: h.projectId, controlDomainId: h.controlDomainId, planId: randomUUID(), + bindings: [{ name: "invocation", path: { workspace: "invocation", intent: "existing-directory" as const, access: "read-write" as const } }], + spawnTemplate: { allowedAgentClasses: ["configured"], allowedProviderClasses: ["te-resources"], maxToolCallsPerStep: 0, maxEffectsPerNode: 1000, maxChildren: 1000, maxDepth: 32, budgetShare: 1 }, + savedFlowPins: [], grantRefs: [], claims: [{ name: "irHash", value: compiled.hash }], enforcementCapabilities: probe.capabilities, + dynamicPolicy: { hostCeiling: { maxActiveRuns: this.options.maxActiveRuns ?? 1 }, authorizationContextHash: auth.authorizationContextHash } }; + const plan: BoundPlan = { ...planBody, boundPlanHash: `plan:${commandRequestHash(planBody)}` }; + this.#linked.set(runId,{flow,plan}); + const run: ApprovalRun = { runId, projectId: h.projectId, controlDomainId: h.controlDomainId, status: "running", stage: "linked", slot: "none", needsOperator: false, runVersion: 0, + boundPlanHash: plan.boundPlanHash, policyHash: commandRequestHash(auth.effectivePolicy), authorityEpoch: this.options.epoch(), owner }; + const response = { runId, projectId: h.projectId, controlDomainId: h.controlDomainId, status: "accepted" }; + let reservation: ConcurrencyReservation | undefined; + const preparation = await this.options.provider.prepareExecution({ plan, flow, args, projectRoot: mount.projectRoot, principal: auth.principal, owner, controlDomainId: h.controlDomainId, + requestApproval: request => this.#requestApproval(context,mount,runId,request,mode), + dispatchFence: activate => {if(!reservation)fail("dispatch reservation missing");return this.#dispatchFence(context,mount,reservation.reservationId,activate);}, + beforeDispatch: async () => { + if (!reservation) fail("dispatch has no admission binding"); + const row = await this.coordinator.readReservation(reservation.reservationId, context); if (!row) fail("dispatch reservation missing"); + await this.#authorize(context, mount, "submit", "run.submit"); + const lease = this.options.liveLease(); + if (!lease || lease.fencingEpoch !== run.authorityEpoch || lease.expiresAt <= Date.now()) throw new ControlError("TF_AUTHORITY_REVOKED", "dispatch fencing epoch is no longer live"); + new ProjectAdmissionService(mount.store).assertDispatch(row.reservation); + } }); + if (preparation.outcome !== "accepted") throw new ControlError("TF_PROVIDER_AMBIGUOUS", "TE preparation did not accept"); + await this.#authorize(context, mount, "submit", "run.submit"); + const raced = mount.store.readCommittedCommand(binding); if (raced) return JSON.parse(raced.responseJson); + const command: CommandRecord = { ...binding, authorizationContextHash: auth.authorizationContextHash, status: "accepted", firstCommitSeq: 1, lastCommitSeq: 1, recordedAt: Date.now() }; + this.#submitting.add(runId); + mount.store.createRun(run, [fact({ kind: "run.snapshot", run })], { command, responseJson: JSON.stringify(response) }); + + try { + reservation = (await this.coordinator.reserve({ projectId: h.projectId, projectControlDomainId: h.controlDomainId, runId, ttlMs: 30000 }, context)).reservation; + const proof = new ProjectAdmissionService(mount.store).admit(reservation, mount.store.readRun(runId)!.runVersion); + reservation = (await this.coordinator.commit(reservation.reservationId, { projectAdmitCommitSeq: proof.projectAdmitCommitSeq, attemptId: owner.attemptId }, context)).reservation; + this.#mutate(mount.store, mount.store.readRun(runId)!, { slot: "committed", stage: "admitted" }); + const result = await this.options.provider.submit({ preparationId: preparation.fulfillment.preparationId, owner, controlDomainId: h.controlDomainId }); + if (result.outcome === "accepted") this.#mutate(mount.store, mount.store.readRun(runId)!, { stage: "executing" }, [{ kind: "dispatch.acknowledged", providerJobHandle: result.providerJobHandle }]); + else this.#mutate(mount.store, mount.store.readRun(runId)!, { status: "unknown", stage: "reconciling", needsOperator: true }, [{ kind: "dispatch.ambiguous" }]); + } catch { + // The command is already durable: report its original acceptance, preserve + // admission/capacity evidence, and expose uncertainty through run state. + this.#mutate(mount.store, mount.store.readRun(runId)!, { status: "unknown", stage: "reconciling", needsOperator: true }, [{ kind: "dispatch.ambiguous" }]); + } + this.#submitting.delete(runId); + await this.#authorize(context, mount, "replay", "run.submit"); return response; + } + async #dispatchFence(context:VerifiedContext,mount:ProjectMount,reservationId:string,activate:()=>T):Promise{ + return this.coordinator.withDispatch(reservationId,context,async row=>{ + await this.#authorize(context,mount,"submit","run.submit"); + return ()=>{ + const current=this.options.registry.resolve(row.reservation.projectId); + const run=current.store.readRun(row.reservation.runId),lease=this.options.liveLease(); + if(!run||!lease||lease.fencingEpoch!==this.options.epoch()||run.authorityEpoch!==lease.fencingEpoch||lease.expiresAt<=Date.now())throw new ControlError("TF_AUTHORITY_REVOKED","dispatch authority is no longer live"); + this.options.authorization.identity(context); + new ProjectAdmissionService(current.store).assertDispatch(row.reservation); + new LegacyConflictGuard(current.projectRoot,current.store.storePath).assertMayAttempt(); + return activate(); + }; + }); + } + async #resume(context:VerifiedContext,mount:ProjectMount,runId:string){ + await this.#authorize(context,mount,"submit","run.submit"); + if(this.options.provider.hasLiveExecution(runId))return mount.store.readRun(runId); + new LegacyConflictGuard(mount.projectRoot,mount.store.storePath).assertMayAttempt(); + let saved=this.options.provider.recoverableExecution(runId);this.#linked.set(runId,{plan:saved.plan,flow:saved.flow}); + let current=mount.store.readRun(runId);if(!current)fail("resume run missing"); + const decision=mount.store.listApprovals().filter(a=>a.runId===runId && ["approved","edited","rejected","expired","cancelled"].includes(a.status)).at(-1); + if(!decision)throw new ControlError("TF_RECONCILE_REQUIRED","pending approval must be decided before continuation"); + // The prior host may have committed readmission before dying at the still + // waiting checkpoint. That reservation/owner cannot authorize this process. + // Recovery above proves the old writer dead; only fresh TE quiescence permits + // parking and normally releasing its capacity before another readmission. + if(["approved","edited"].includes(decision.status) && !current.requiresReadmission){ + if(current.status!=="running"||current.stage!=="admitted"||current.slot!=="committed")throw new ControlError("TF_RECONCILE_REQUIRED","only an interrupted committed readmission may be re-parked"); + const quiet=await this.options.provider.quiescence(runId); + if(!quiet.quiescent||!quiet.noPendingResourceIntents)throw new ControlError("TF_RECONCILE_REQUIRED","prior readmission is not quiescent"); + await this.#authorize(context,mount,"submit","run.submit"); + current=this.#mutate(mount.store,current,{status:"paused",stage:"parked",requiresReadmission:true,needsOperator:false}); + await this.#approvalService(mount).park(context,{runId,expectedRunVersion:current.runVersion}); + current=mount.store.readRun(runId)!; + } + if(["approved","edited"].includes(decision.status) && current.requiresReadmission)await this.#approvalService(mount).readmit(context,{approvalRequestId:decision.approvalRequestId,expectedRunVersion:current.runVersion}); + if(decision.status==="edited" && decision.editKind==="plan"){ + const edit=await this.#readApprovalEdit(context,mount,current,decision,{kind:"plan",artifact:decision.editArtifactRef!}); + if(edit.kind!=="plan")fail("plan edit missing");await this.options.provider.applyApprovalPlan(runId,decision.nodeInstanceId,edit.flow); + saved=this.options.provider.recoverableExecution(runId);this.#linked.set(runId,{plan:saved.plan,flow:saved.flow}); + } + const run=mount.store.readRun(runId)!; + const beforeDispatch=async()=>{ + await this.#authorize(context,mount,"submit","run.submit"); + if(!["approved","edited"].includes(decision.status))return; // rejection resumes only the waiting approval; core blocks its dependents + const row=await this.coordinator.readReservation(run.reservationId!,context);await this.#authorize(context,mount,"submit","run.submit"); + if(!row)fail("resumed reservation missing");new ProjectAdmissionService(mount.store).assertDispatch(row.reservation); + }; + await this.options.provider.resumeExecution(runId,{...saved,owner:run.owner!,beforeDispatch,dispatchFence:["approved","edited"].includes(decision.status)?activate=>this.#dispatchFence(context,mount,run.reservationId!,activate):undefined,requestApproval:request=>this.#requestApproval(context,mount,runId,request,"durable-required")}); + return mount.store.readRun(runId); + } + async #cancel(context: VerifiedContext, mount: ProjectMount, body: Record) { + const input={commandId:id(body.commandId),runId:id(body.runId),expectedRunVersion:body.expectedRunVersion as number}; + const auth=await this.#authorize(context,mount,"submit","run.cancel"), h=mount.store.header; + const binding={commandId:input.commandId,kind:"run.cancel" as const,requestHash:commandRequestHash(input),callerPrincipal:auth.principal,projectId:h.projectId,controlDomainId:h.controlDomainId}; + const prior=mount.store.readCommittedCommand(binding); if(prior) return JSON.parse(prior.responseJson); + const response={runId:input.runId,status:"cancellation-requested"}; + mount.store.mutateRun(input.runId,input.expectedRunVersion,(state,meta)=>{ + if (["completed","failed","blocked","cancelled"].includes(state.run.status)) throw new ControlError("TF_STALE_VERSION","terminal run cannot be cancelled"); + const run={...state.run,status:"unknown" as const,stage:"reconciling" as const,needsOperator:true,requiresReadmission:false,runVersion:state.run.runVersion+1}; + return {...state,run,approvals:state.approvals.map(a=>a.status==="pending"?{...a,status:"cancelled" as const,decidedAt:Date.now()}:a),events:[fact({kind:"run.snapshot",run})],command:{...binding,authorizationContextHash:auth.authorizationContextHash,status:"accepted",firstCommitSeq:meta.commitSeq,lastCommitSeq:meta.commitSeq,recordedAt:Date.now()},responseJson:JSON.stringify(response)}; + },binding); + await this.options.provider.cancel({providerJobHandle:input.runId}); + await this.#refresh(context,mount,input.runId); + await this.#authorize(context,mount,"replay","run.cancel"); return response; + } + async #readApprovalEdit(context:VerifiedContext,mount:ProjectMount,run:ApprovalRun,request:DurableApprovalRequest,edit:{kind:"output"|"plan";artifact:ArtifactRef}){ + if(!Number.isSafeInteger(edit.artifact.size)||edit.artifact.size<0||edit.artifact.size>MAX_APPROVAL_EDIT_BYTES)fail("approval edit artifact exceeds the supported size"); + const evidence=await this.#evidenceStore(mount); + const bytes=await evidence.readArtifact(context,{kind:"run",id:run.runId},edit.artifact); + const text=decodeApprovalEdit(bytes,edit.artifact,edit.kind); + const diagnostic=this.options.provider.readDiagnostic(run.runId); + if(!diagnostic || diagnostic.projectId!==run.projectId || diagnostic.controlDomainId!==run.controlDomainId)throw new ControlError("TF_RECONCILE_REQUIRED","edit checkpoint is unavailable"); + if(edit.kind==="output")return {kind:"output" as const,output:validateApprovalOutputEdit(diagnostic.state,request.nodeInstanceId,text)}; + let candidate:unknown;try{candidate=JSON.parse(text);}catch{fail("approval plan artifact is not JSON");} + return {kind:"plan" as const,...await validateApprovalPlanEdit(diagnostic.state,request.nodeInstanceId,candidate)}; + } + #approvalService(mount:ProjectMount):ApprovalService { + let service=this.#approvals.get(mount.store.header.projectId); if(service)return service; + const authority:ApprovalAuthority={ + authorize:async(context,scope)=>this.#authorize(context,mount,scope.operation==="read"?"read":"submit",scope.operation==="decide"?"approval.decide":scope.operation==="cancel"?"run.cancel":"run.submit"), + validateRequest:async(_context,run,node)=>{ if(!this.#linked.get(run.runId)?.flow.phases.some(p=>p.id===node && p.type==="approval")) throw new ControlError("TF_AUTHORITY_REVOKED","approval node is not in linked flow"); }, + negotiateDurability:async(context)=>this.options.durableApprovalAvailable?.(context)===true, + quiescence:async(_context,run)=>{const q=await this.options.provider.quiescence(run.runId);return {proofId:q.proofId,projectId:run.projectId,projectControlDomainId:run.controlDomainId,runId:run.runId,projectAdmitCommitSeq:run.projectAdmitCommitSeq!,providerNoLiveProcessTree:q.quiescent,noAmbiguousJobs:q.quiescent,noPendingResourceIntents:q.noPendingResourceIntents,reconcileTimeoutOnly:false};}, + validateEdit:async(context,run,request,edit)=>{await this.#readApprovalEdit(context,mount,run,request,edit);}, + prepareReadmission:async(context,run,request)=>{ + new LegacyConflictGuard(mount.projectRoot,mount.store.storePath).assertMayAttempt(); + const linked=this.#linked.get(run.runId);if(!linked)throw new ControlError("TF_RECONCILE_REQUIRED","lost live continuation requires operator reconciliation"); + if(request.status==="edited" && request.editKind==="plan"){ + const edit=await this.#readApprovalEdit(context,mount,run,request,{kind:"plan",artifact:request.editArtifactRef!});if(edit.kind!=="plan")fail("plan edit missing");linked.flow=edit.flow; + } + const auth=await this.#authorize(context,mount,"submit","run.submit"); + const ir=request.editKind==="output"?{hash:this.options.provider.readDiagnostic(run.runId)?.state.flowDefHash}:await compileTaskflowToIR(linked.flow);if(!ir.hash)fail("readmission Link failed"); + const policyHash=commandRequestHash(auth.effectivePolicy); const fragmentBody={schemaVersion:CONTROL_WIRE_SCHEMA_VERSION as typeof CONTROL_WIRE_SCHEMA_VERSION,projectId:run.projectId,controlDomainId:run.controlDomainId,fragmentId:randomUUID(),parentBoundPlanHash:run.boundPlanHash,...(run.boundFragmentHash?{parentBoundFragmentHash:run.boundFragmentHash}:{}),sourceEventId:request.decisionEventId!,sourceCommitSeq:request.decisionCommitSeq!,fragmentIRHash:ir.hash,fragmentPolicyHash:`policy:${policyHash}`,capabilitySetHash:`caps:${commandRequestHash(linked.plan.enforcementCapabilities)}`,authorityEpoch:this.options.epoch()}; + return {fragment:{...fragmentBody,boundFragmentHash:`fragment:${commandRequestHash(fragmentBody)}`,executionSemanticHash:ir.hash},owner:{runId:run.runId,phaseId:request.nodeInstanceId,attemptId:randomUUID(),unitId:randomUUID(),ancestry:[]},policyHash,authorizationContextHash:auth.authorizationContextHash}; + }, + validateReadmission:async(context,run,intent)=>{await this.#authorize(context,mount,"submit","run.submit");if(run.authorityEpoch!==this.options.epoch()||run.policyHash!==intent.policyHash)throw new ControlError("TF_AUTHORITY_REVOKED","readmission authority changed");}, + reserve:async(context,run)=>(await this.coordinator.reserve({projectId:run.projectId,projectControlDomainId:run.controlDomainId,runId:run.runId,ttlMs:30000},context)).reservation, + commitReservation:async(context,intent)=>{await this.coordinator.commit(intent.reservation.reservationId,{projectAdmitCommitSeq:intent.projectAdmitCommitSeq,attemptId:intent.owner.attemptId},context);}, + normalRelease:async(context,intent)=>{await this.coordinator.normalRelease(intent.reservationId,context);}, + }; + service=new ApprovalService(mount.store,authority);this.#approvals.set(mount.store.header.projectId,service);return service; + } + async #requestApproval(context:VerifiedContext,mount:ProjectMount,runId:string,request:ApprovalRequest,mode:ApprovalMode):Promise{ + const service=this.#approvalService(mount),run=mount.store.readRun(runId)!; + const previous=mount.store.listApprovals().filter(item=>item.runId===runId && item.nodeInstanceId===request.phaseId).at(-1); + const pending=previous ?? await service.request(context,{runId,expectedRunVersion:run.runVersion,nodeInstanceId:request.phaseId,mode,allowedDecisions:["approve","reject","edit"],owner:this.options.authorization.identity(context).principal,deadline:Date.now()+86400000,timeoutPolicy:"durable-expire"}); + for(;;){ + if(this.#closed)throw new ControlError("TF_RECONCILE_REQUIRED","control owner stopped during approval"); + const current=mount.store.readApproval(pending.approvalRequestId)!; + if(current.status!=="pending"){ + if(!["approved","edited"].includes(current.status))return {decision:"reject",note:current.status}; + const latest=mount.store.readRun(runId)!; + if(latest.requiresReadmission && !["released","none"].includes(latest.slot)){ + const q=await this.options.provider.quiescence(runId); + if(!q.quiescent){await new Promise(resolve=>setTimeout(resolve,20));continue;} + await service.park(context,{runId,expectedRunVersion:latest.runVersion}); + } + if(latest.requiresReadmission)await service.readmit(context,{approvalRequestId:current.approvalRequestId,expectedRunVersion:mount.store.readRun(runId)!.runVersion}); + const admitted=mount.store.readRun(runId)!;const row=await this.coordinator.readReservation(admitted.reservationId!,context);await this.#authorize(context,mount,"submit","run.submit"); + if(!row)fail("readmission reservation missing");new ProjectAdmissionService(mount.store).assertDispatch(row.reservation); + let decision:ApprovalDecision={decision:"approve"}; + if(current.status==="edited"){ + const edit=await this.#readApprovalEdit(context,mount,admitted,current,{kind:current.editKind!,artifact:current.editArtifactRef!}); + if(edit.kind==="output")decision={decision:"edit",note:edit.output}; + else{await this.options.provider.applyApprovalPlan(runId,current.nodeInstanceId,edit.flow);this.#linked.get(runId)!.flow=edit.flow;decision={decision:"edit",note:"Plan updated"};} + } + return this.#dispatchFence(context,mount,admitted.reservationId!,()=>decision); + } + const nowRun=mount.store.readRun(runId)!; + if(nowRun.status==="paused" && nowRun.stage==="reconciling"){ + const q=await this.options.provider.quiescence(runId); + if(q.quiescent)try{await service.park(context,{runId,expectedRunVersion:nowRun.runVersion});}catch(error){if(!(error instanceof ControlError)||error.code!=="TF_STALE_VERSION")throw error;} + } + if(current.deadline<=Date.now())await service.expireDue(context); + await new Promise(resolve=>setTimeout(resolve,20)); + } + } + async #refresh(context: VerifiedContext, mount: ProjectMount, runId: string) { + let pending=this.#refreshes.get(runId);if(!pending){pending=this.#refreshOnce(context,mount,runId);this.#refreshes.set(runId,pending);void pending.finally(()=>this.#refreshes.delete(runId)).catch(()=>{});} + await pending;await this.#authorize(context,mount,"read");return mount.store.readRun(runId)!; + } + async #refreshOnce(context: VerifiedContext, mount: ProjectMount, runId: string) { + let run = mount.store.readRun(runId)!; + const terminalEvent = mount.store.readJournal().flatMap(batch=>batch.events).find(event=>event.streamId===runId && event.payload.kind==="run.terminal"); + let observed; try { observed = await this.options.provider.observe(runId); } catch(error) { + if(this.#submitting.has(runId)||["completed","failed","blocked","cancelled"].includes(run.status))return run; + if(error instanceof ControlError && error.code==="TF_DURABILITY_FAILED")throw error; + return run.status==="unknown"?run:this.#mutate(mount.store,run,{status:"unknown",stage:"reconciling",needsOperator:true},[{kind:"reconcile.settled",outcome:"exhausted"}]); + } + if (observed.projectId !== run.projectId || observed.controlDomainId !== run.controlDomainId) throw new ControlError("TF_AUTHORITY_REVOKED", "provider evidence belongs to another project"); + await this.#authorize(context, mount, "read"); + run = mount.store.readRun(runId)!; + if (observed.status === "running") return run; + if (observed.status === "ambiguous" || !observed.noPendingResourceIntents) { + if (run.status !== "unknown") run = this.#mutate(mount.store, run, { status: "unknown", stage: "reconciling", needsOperator: true }, [{ kind: "reconcile.settled", outcome: "exhausted" }]); + return run; + } + if (!terminalEvent) { + const probe = await this.options.provider.probe(); + if(this.options.evidenceFactory && observed.finalOutput !== undefined){ + const evidence=await this.#evidenceStore(mount); + const artifact=await evidence.stageArtifact(Buffer.from(observed.finalOutput),{mediaType:"text/plain; charset=utf-8",storageClass:"project",redactionClass:"internal"}); + await evidence.assertDurableReferences([artifact]); + this.#mutate(mount.store,mount.store.readRun(runId)!,{},[{kind:"artifact.recorded",runId,commandKind:"run.submit",artifact}]); + } + mount.store.recordRunEvidence(runId, { buildInfo: getBuildInfo(), assurance: { journalContinuity: true, providerOutcome: observed.status === "completed" ? "completed" : "failed", artifactIntegrity: "unknown", provenance: { confidentiality: "internal", integrity: "project" }, enforcement: { capabilities: probe.capabilities } } }); + run = mount.store.readRun(runId)!; + run = this.#mutate(mount.store, run, { status: observed.status, stage: "terminal", needsOperator: false }, [{ kind: "run.terminal", status: observed.status }]); + } + if (run.reservationId && run.slot !== "released") { await this.coordinator.normalRelease(run.reservationId, context); run = this.#mutate(mount.store, mount.store.readRun(runId)!, { slot: "released" }); } + if (this.options.evidenceFactory) await (await this.#evidenceStore(mount)).issueFinalReceipt(runId); + return run; + } + async #evidenceStore(mount: ProjectMount): Promise { let e = this.#evidence.get(mount.store.header.projectId); if (!e) { if (!this.options.evidenceFactory) throw new ControlError("TF_FEATURE_REQUIRED", "evidence adapter is not configured"); e = Promise.resolve(this.options.evidenceFactory(mount, this.verifyTerminal.bind(this))); this.#evidence.set(mount.store.header.projectId, e); } return e; } + async verifyTerminal(project: {projectId: string; controlDomainId: string}, runId: string, eventId: string):Promise { + const store = this.options.registry.resolve(project.projectId).store; + if (store.header.controlDomainId !== project.controlDomainId) throw new ControlError("TF_POLICY_DENIED", "evidence domain mismatch"); + const events = store.readJournal().flatMap(batch => batch.events); + const event = events.find(event => event.eventId === eventId && event.streamId === runId && event.payload.kind === "run.terminal"); + const observed = await this.options.provider.observe(runId); + const quiet = await this.options.provider.quiescence(runId); + if (!quiet.quiescent || !quiet.noPendingResourceIntents) throw new ControlError("TF_RECONCILE_REQUIRED","current TE quiescence is not proven"); + if (!event || event.payload.kind !== "run.terminal" || !["completed","failed","blocked","cancelled"].includes(observed.status) || observed.runId !== runId || observed.projectId !== project.projectId || observed.controlDomainId !== project.controlDomainId || observed.status !== event.payload.status || !observed.noPendingResourceIntents) throw new ControlError("TF_RECONCILE_REQUIRED", "terminal event has no matching TE observation"); + return { terminalStatus: event.payload.status as TerminalEvidenceObservation["terminalStatus"], terminalEventId: eventId, evidenceCommit: observed.evidenceCommit, providerOutcome: observed.status === "completed" ? "completed" as const : "failed" as const }; + } +} diff --git a/packages/taskflow-control/src/index.ts b/packages/taskflow-control/src/index.ts index b9cae8a4..e1fcfb0c 100644 --- a/packages/taskflow-control/src/index.ts +++ b/packages/taskflow-control/src/index.ts @@ -19,6 +19,16 @@ export * from "./uds.ts"; export * from "./te-provider.ts"; export * from "./control-host.ts"; export * from "./store/index.ts"; +export { ProjectRegistry, type ProjectMount } from "./project-registry.ts"; +export { createAuthorizationAuthority, answerAuthorizationChallenge, AUTHORIZATION_CAPABILITIES } from "./authorization.ts"; +export type { AuthorizationAuthority, AuthorizationAuthorityOptions, VerifiedContext, AuthorizationBinding } from "./authorization.ts"; +export { ApprovalService } from "./approval-service.ts"; +export { ProjectAdmissionService } from "./admission-service.ts"; +export { RuntimeTeExecutionProvider } from "./runtime-provider.ts"; +export { createControlEvidenceStore, readControlEvidence, compactControlEvidence } from "./store/evidence-adapter.ts"; +export { launchControlConsole } from "./control-console.ts"; +export * from "./store/project-lifecycle.ts"; +export * from "./legacy-conflict.ts"; // Convenience: the wire protocol major used by the negotiation gate. export { PROTOCOL_MAJOR } from "./schema/transport.ts"; diff --git a/packages/taskflow-control/src/legacy-conflict.ts b/packages/taskflow-control/src/legacy-conflict.ts new file mode 100644 index 00000000..f97196ea --- /dev/null +++ b/packages/taskflow-control/src/legacy-conflict.ts @@ -0,0 +1,112 @@ +/** P9: read-only inspection of observable legacy store writers. This cannot + * detect arbitrary old processes that leave no run record or lock. */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { runsDir, probeProcess } from "taskflow-core"; +import { ControlError } from "./errors.ts"; +import { writeJsonAtomicHardened } from "./store/store.ts"; + +const MAX_ENTRIES = 4096, MAX_FILE_BYTES = 1024 * 1024, MAX_TOTAL_BYTES = 16 * 1024 * 1024; +interface Observation { path: string; pid?: number } +interface Marker { version: 1; projectRoot: string; state: "blocked" | "acknowledged"; observedAt: number; observations: Observation[]; acknowledgedAt?: number } +function conflict(): never { throw new ControlError("TF_LEGACY_CONFLICT", "legacy store writer conflict requires trusted operator acknowledgement after proving the writer stopped", { recoveryAction: "operator" }); } +function object(value: unknown): Record | undefined { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; } +function owner(value: unknown): number | undefined { + const record = object(value), foreground = object(record?.foregroundOwner); + const pid = record?.pid ?? foreground?.pid; + return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0 && pid <= 0x7fffffff ? pid : undefined; +} +function noLinks(target: string): void { + for (let current = path.resolve(target);;) { + try { if (fs.lstatSync(current).isSymbolicLink()) throw new Error("symbolic link"); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const parent = path.dirname(current); if (parent === current) return; current = parent; + } +} +function readObject(file: string, budget: { bytes: number }): Record { + noLinks(file); + const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); + try { + const before = fs.fstatSync(fd, { bigint: true }); + if (!before.isFile() || before.size > BigInt(MAX_FILE_BYTES) || (budget.bytes += Number(before.size)) > MAX_TOTAL_BYTES) throw new Error("inspection limit"); + const bytes = Buffer.alloc(Number(before.size) + 1), count = fs.readSync(fd, bytes, 0, bytes.length, 0); + const after = fs.fstatSync(fd, { bigint: true }), named = fs.lstatSync(file, { bigint: true }); + noLinks(file); + if (count !== Number(before.size) || before.dev !== named.dev || before.ino !== named.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs) throw new Error("writer changed during inspection"); + const result = object(JSON.parse(bytes.subarray(0, count).toString("utf8"))); if (!result) throw new Error("invalid record"); return result; + } finally { fs.closeSync(fd); } +} + +export class LegacyConflictGuard { + readonly projectRoot: string; + readonly markerPath: string; + constructor(projectRoot: string, storePath: string) { + this.projectRoot = fs.realpathSync(projectRoot); + this.markerPath = path.join(storePath, "legacy-conflict.json"); + } + /** Checks known core layouts without listRuns(), index rebuilds or legacy writes. */ + inspect(): Observation[] { + const found: Observation[] = [], budget = { bytes: 0 }; let entries = 0; + const root = runsDir(this.projectRoot), flowRoot = path.dirname(root); + const inspectFile = (file: string, lock: boolean) => { + try { + const record = readObject(file, budget), pid = owner(record), foregroundPid = owner(object(record.foregroundOwner)); + const pids = [...new Set([pid, foregroundPid].filter((value): value is number => value !== undefined))]; + for (const candidate of pids) if (probeProcess(candidate) !== "dead") found.push({ path: file, pid: candidate }); + if (pids.length === 0 && (lock || !["completed", "failed", "blocked"].includes(String(record.status)))) found.push({ path: file }); + } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") found.push({ path: file }); } + }; + const walk = (directory: string, depth: number, locksOnly = false): void => { + let handle: fs.Dir | undefined; + try { + noLinks(directory); handle = fs.opendirSync(directory); + for (;;) { + const entry = handle.readSync(); if (!entry) break; + if (++entries > MAX_ENTRIES) { found.push({ path: directory }); return; } + const file = path.join(directory, entry.name); + if (entry.name.endsWith(".lock") || entry.name.includes(".lock.steal.")) inspectFile(file, true); + else if (!locksOnly && entry.isDirectory() && depth > 0) walk(file, depth - 1); + else if (!locksOnly && entry.name.endsWith(".json") && entry.name !== "index.json") inspectFile(file, false); + else if (!locksOnly && entry.isSymbolicLink()) found.push({ path: file }); + if (entries > MAX_ENTRIES) return; + } + } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") found.push({ path: directory }); } + finally { handle?.closeSync(); } + }; + walk(flowRoot, 0, true); walk(root, 1); + return found; + } + #marker(): Marker | undefined { + try { + const value = readObject(this.markerPath, { bytes: 0 }); + if (value.version !== 1 || value.projectRoot !== this.projectRoot || !["blocked", "acknowledged"].includes(String(value.state)) || !Array.isArray(value.observations) + || value.observations.some(item => !object(item) || typeof item.path !== "string" || (item.pid !== undefined && owner(item) === undefined))) conflict(); + return value as unknown as Marker; + } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; conflict(); } + } + #record(observations: Observation[], previous?: Marker): void { + noLinks(this.markerPath); + const combined = [...(previous?.state === "blocked" ? previous.observations : []), ...observations]; + const unique = [...new Map(combined.map(item => [`${item.path}\0${item.pid ?? "unknown"}`, item])).values()]; + writeJsonAtomicHardened(this.markerPath, { version: 1, projectRoot: this.projectRoot, state: "blocked", observedAt: Date.now(), observations: unique } satisfies Marker); + } + assertMayAttempt(): void { + const previous = this.#marker(), observations = this.inspect(); + if (observations.length) { this.#record(observations, previous); conflict(); } + if (previous?.state === "blocked") conflict(); + } + /** Trusted local operator API only. No wire request/boolean proves quiescence. + * Captured PIDs must be dead even if their record has disappeared. Unknown + * owners must first be resolved to an observable dead PID at the same path. */ + acknowledgeStopped(): void { + const previous = this.#marker(); if (!previous || previous.state !== "blocked") return; + const current = this.inspect(); if (current.length) { this.#record(current, previous); conflict(); } + for (const observation of previous.observations) { + let pid = observation.pid; + if (pid === undefined) { try { pid = owner(readObject(observation.path, { bytes: 0 })); } catch { conflict(); } } + if (pid === undefined || probeProcess(pid) !== "dead") conflict(); + } + noLinks(this.markerPath); + writeJsonAtomicHardened(this.markerPath, { ...previous, state: "acknowledged", acknowledgedAt: Date.now() } satisfies Marker); + } +} diff --git a/packages/taskflow-control/src/local-bootstrap.ts b/packages/taskflow-control/src/local-bootstrap.ts new file mode 100644 index 00000000..522f4cb2 --- /dev/null +++ b/packages/taskflow-control/src/local-bootstrap.ts @@ -0,0 +1,73 @@ +/** Protected local-user bootstrap. Possession authenticates; live policy grants. */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { ControlError } from "./errors.ts"; +import { createAuthorizationAuthority, type AuthorizationCapabilityKind, type AuthorizationAuthorityOptions } from "./authorization.ts"; + +export const LOCAL_OWNER_CAPABILITIES: readonly AuthorizationCapabilityKind[] = [ + "project.read", "run.submit", "run.cancel", "approval.decide", "artifact.read", +]; +function fail(message: string): never { throw new ControlError("TF_BOOTSTRAP_FAILED", message); } +function noLinks(target: string): void { + for (let current = path.resolve(target);;) { + try { if (fs.lstatSync(current).isSymbolicLink()) fail("bootstrap refuses symbolic-link paths"); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const parent = path.dirname(current); if (parent === current) return; current = parent; + } +} +export function ensurePrivateDirectory(directory: string): string { + const resolved = path.resolve(directory); noLinks(resolved); + fs.mkdirSync(resolved, { recursive: true, mode: 0o700 }); + const stat = fs.lstatSync(resolved); + if (!process.getuid || !stat.isDirectory() || stat.uid !== process.getuid() || (stat.mode & 0o077) !== 0) fail("bootstrap directory must be owned by this OS user with mode 0700"); + return resolved; +} +export function readPrivateFile(file: string): Buffer { + noLinks(file); ensurePrivateDirectory(path.dirname(file)); + const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + const stat = fs.fstatSync(fd), named = fs.lstatSync(file); + if (!stat.isFile() || !process.getuid || stat.uid !== process.getuid() || (stat.mode & 0o077) !== 0 + || stat.dev !== named.dev || stat.ino !== named.ino || stat.size > 1024 * 1024) fail("bootstrap file owner, mode or identity is invalid"); + const bytes = fs.readFileSync(fd); noLinks(file); + const after = fs.lstatSync(file); + if (after.dev !== stat.dev || after.ino !== stat.ino) fail("bootstrap file changed while reading"); + return bytes; + } finally { fs.closeSync(fd); } +} +/** Publish complete bytes without replacing another process's winning secret. */ +export function createPrivateFileOnce(file: string, bytes: Uint8Array): void { + ensurePrivateDirectory(path.dirname(file)); noLinks(file); + const temp = path.join(path.dirname(file), `.bootstrap-${randomUUID()}`); + const fd = fs.openSync(temp, "wx", 0o600); + try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } + try { + try { fs.linkSync(temp, file); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } + } finally { fs.unlinkSync(temp); } + const directory = fs.openSync(path.dirname(file), "r"); try { fs.fsyncSync(directory); } finally { fs.closeSync(directory); } +} +export function policyPath(controlHome: string, projectRoot: string): string { + return path.join(controlHome, "policies", `${createHash("sha256").update(projectRoot).digest("hex")}.json`); +} +export function bootstrapLocalAuthority(controlHome: string, projectRoots: readonly string[], options: Pick = {}) { + const home = ensurePrivateDirectory(controlHome); + const keyPath = path.join(home, "bootstrap.key"); createPrivateFileOnce(keyPath, randomBytes(32)); + const secret = readPrivateFile(keyPath); if (secret.length !== 32) fail("bootstrap secret must contain exactly 32 bytes"); + for (const projectRoot of projectRoots) { + createPrivateFileOnce(policyPath(home, projectRoot), Buffer.from(JSON.stringify({ version: 1, projectRoot, capabilities: LOCAL_OWNER_CAPABILITIES, revokedPrincipals: [] }) + "\n")); + } + const authorization = createAuthorizationAuthority({ ownerUid: process.getuid!(), bootstrapSecret: secret, + hostBaseline: LOCAL_OWNER_CAPABILITIES, verifyArtifactReachability: options.verifyArtifactReachability, + loadLivePolicy: (_principal, binding) => { + const value: unknown = JSON.parse(readPrivateFile(policyPath(home, binding.projectRoot)).toString("utf8")); + if (!value || typeof value !== "object" || Array.isArray(value)) fail("local policy must be an object"); + const policy = value as Record; + if (Object.keys(policy).some(key => !["version", "projectRoot", "capabilities", "revokedPrincipals"].includes(key)) || policy.version !== 1 || policy.projectRoot !== binding.projectRoot + || !Array.isArray(policy.capabilities) || policy.capabilities.some(kind => !LOCAL_OWNER_CAPABILITIES.includes(kind)) + || !Array.isArray(policy.revokedPrincipals) || policy.revokedPrincipals.some(p => typeof p !== "string")) fail("local policy is invalid"); + return { host: { capabilities: policy.capabilities.map(kind => ({ kind, scopeRoot: binding.projectRoot })) }, revokedPrincipals: policy.revokedPrincipals }; + }, + }); + return { authorization, secret, keyPath }; +} diff --git a/packages/taskflow-control/src/project-admin-cli.ts b/packages/taskflow-control/src/project-admin-cli.ts new file mode 100644 index 00000000..e7261bed --- /dev/null +++ b/packages/taskflow-control/src/project-admin-cli.ts @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** Offline, explicit local-operator P3/P10 workflows. No RPC request can create + * this authority; it is bound to the invoking OS user and configured paths. */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { randomUUID } from "node:crypto"; +import { pathToFileURL } from "node:url"; +import { Value } from "typebox/value"; +import { ControlError } from "./errors.ts"; +import { ControlStoreHeaderSchema } from "./schema/header.ts"; +import { readPrivateFile } from "./local-bootstrap.ts"; +import { createProjectClone, exportProjectEvidence, rebindMovedProject, + type ProjectLifecycleAuthority, type ProjectLifecycleOperation } from "./store/project-lifecycle.ts"; + +const HELP = `Offline project identity and evidence maintenance (local OS owner only). + + taskflow-project-admin move-rebind --store MOVED_STORE + taskflow-project-admin clone --store SOURCE_STORE --destination NEW_STORE + taskflow-project-admin export --store STORE --output NEW_JSON [--kind readonly|lossy] + +Stop writers before moving the directory. move-rebind verifies the moved store's +original device/inode and refuses copies, active writers, or ambiguous runs. +Clone creates an empty ledger with fresh project/domain IDs; source history stays +untouched. Export writes a new private JSON file, preserves damaged journal bytes +in readonly mode, and always declares executePromise:false. No destination is +overwritten. Source store and destination parent must be owned private directories. +`; +function fail(message: string): never { throw new ControlError("TF_POLICY_DENIED", message, {recoveryAction:"operator",sideEffects:"none"}); } +function privateDirectory(input:string):{path:string;dev:number;ino:number} { + const absolute=path.resolve(input); + for(let current=absolute;;){const stat=fs.lstatSync(current);if(stat.isSymbolicLink())fail("operator paths cannot contain symbolic links");const parent=path.dirname(current);if(parent===current)break;current=parent;} + const stat=fs.lstatSync(absolute); + if(!process.getuid || !stat.isDirectory() || stat.uid!==process.getuid() || (stat.mode&0o077)!==0)fail("operator directory must be owned by this OS user with mode 0700"); + return {path:fs.realpathSync(absolute),dev:stat.dev,ino:stat.ino}; +} +function missing(file:string) { try {fs.lstatSync(file);fail("destination already exists; choose a new path");}catch(error){if((error as NodeJS.ErrnoException).code!=="ENOENT")throw error;} } +function publishExclusive(file:string,bytes:Buffer,check:()=>void){ + check();missing(file); + const temporary=path.join(path.dirname(file),`.project-export-${randomUUID()}`); + const fd=fs.openSync(temporary,"wx",0o600); + try{fs.writeFileSync(fd,bytes);fs.fsyncSync(fd);}finally{fs.closeSync(fd);} + try{check();fs.linkSync(temporary,file);const dir=fs.openSync(path.dirname(file),"r");try{fs.fsyncSync(dir);}finally{fs.closeSync(dir);}} + finally{fs.unlinkSync(temporary);} +} +export async function runProjectAdminCli(argv=process.argv.slice(2)):Promise{ + if(argv.length===0||argv.includes("--help")){process.stdout.write(HELP);return 0;} + const operation=argv[0];if(!["move-rebind","clone","export"].includes(operation))fail("unknown project administration operation"); + const flags=new Map(); + for(let index=1;index!allowed.includes(key)))fail("argument does not apply to this operation"); + const source=privateDirectory(flags.get("--store")!); + const raw:unknown=JSON.parse(readPrivateFile(path.join(source.path,"header")).toString("utf8")); + if(!Value.Check(ControlStoreHeaderSchema,raw))fail("invalid authoritative store header"); + const header=raw, uid=process.getuid!(); + const destinationInput=operation==="clone"?flags.get("--destination"):operation==="export"?flags.get("--output"):undefined; + if(operation!=="move-rebind"&&!destinationInput)fail(operation==="clone"?"--destination is required":"--output is required"); + const destination=destinationInput?path.resolve(destinationInput):undefined; + const parent=destination?privateDirectory(path.dirname(destination)):undefined; + if(destination){if(destination===source.path||destination.startsWith(source.path+path.sep))fail("destination must be outside the source ledger");missing(destination);} + const token=Object.freeze(Object.create(null)) as object; + const check=()=>{ + if(process.getuid!()!==uid)fail("operator OS identity changed"); + const current=privateDirectory(source.path);if(current.dev!==source.dev||current.ino!==source.ino)fail("source directory identity changed"); + if(parent){const latest=privateDirectory(parent.path);if(latest.dev!==parent.dev||latest.ino!==parent.ino)fail("destination parent identity changed");} + }; + const authority:ProjectLifecycleAuthority={authorize(context,scope){ + check();if(context!==token||scope.operation!==operation as ProjectLifecycleOperation||scope.header.projectId!==header.projectId||scope.header.controlDomainId!==header.controlDomainId)fail("operator context does not authorize this identity operation"); + if(scope.destination!==undefined && scope.destination!==(operation==="move-rebind"?source.path:destination))fail("operator destination changed"); + return {principal:`os-user:${uid}`,operator:true}; + }}; + if(operation==="move-rebind"){ + const result=await rebindMovedProject(source.path,token,authority); + process.stdout.write(JSON.stringify({operation,header:result})+"\n");return 0; + } + if(operation==="clone"){ + const result=await createProjectClone(source.path,destination!,token,authority); + process.stdout.write(JSON.stringify({operation,header:result,historyCopied:false})+"\n");return 0; + } + const kind=flags.get("--kind")??"readonly";if(kind!=="readonly"&&kind!=="lossy")fail("export kind must be readonly or lossy"); + const result=await exportProjectEvidence(source.path,kind,token,authority); + publishExclusive(destination!,Buffer.from(JSON.stringify(result,null,2)+"\n"),check); + process.stdout.write(JSON.stringify({operation,exportKind:kind,executePromise:false,integrity:result.integrity,fileCount:result.files.length,output:destination})+"\n");return 0; +} +if(process.argv[1]&&import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href){ + runProjectAdminCli().then(code=>{process.exitCode=code;},error=>{process.stderr.write(JSON.stringify({error:error instanceof ControlError?error.code:"TF_BOOTSTRAP_FAILED",message:error instanceof Error?error.message:"project administration failed"})+"\n");process.exitCode=1;}); +} diff --git a/packages/taskflow-control/src/project-registry.ts b/packages/taskflow-control/src/project-registry.ts new file mode 100644 index 00000000..0eeba29c --- /dev/null +++ b/packages/taskflow-control/src/project-registry.ts @@ -0,0 +1,129 @@ +/** P3 discovery projection. Header identity and the mounted store remain authority. */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { Type } from "typebox"; +import { Value } from "typebox/value"; +import { ControlError } from "./errors.ts"; +import { ControlRegistryEntrySchema, ControlStoreHeaderSchema, type ControlRegistryEntry } from "./schema/header.ts"; +import { openControlStore, writeJsonAtomicHardened, type ControlStore } from "./store/store.ts"; + +export interface ProjectMount { + readonly store: ControlStore; + readonly projectRoot: string; +} +interface Mounted extends ProjectMount { rootDevice: number; rootInode: number } +const RegistrySchema = Type.Object({ version: Type.Literal(1), entries: Type.Array(ControlRegistryEntrySchema) }, { additionalProperties: false }); +function fail(message: string): never { throw new ControlError("TF_DURABILITY_FAILED", message, { recoveryAction: "operator" }); } +function noLinks(target: string): void { + let current = path.resolve(target); + for (;;) { + try { if (fs.lstatSync(current).isSymbolicLink()) fail("registry refuses symbolic-link paths"); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const parent = path.dirname(current); if (parent === current) return; current = parent; + } +} + +/** Constructed by the singleton owner only. Persisted paths are discovery hints, + * never automatic permission to open a project or restore a run projection. */ +export class ProjectRegistry { + readonly registryPath: string; + readonly #directory: string; + readonly #directoryIdentity: { dev: number; ino: number }; + readonly #mounts = new Map(); + readonly #entries = new Map(); + #closed = false; + constructor(registryPath: string) { + this.registryPath = path.resolve(registryPath); + noLinks(this.registryPath); + this.#directory = path.dirname(this.registryPath); + fs.mkdirSync(this.#directory, { recursive: true, mode: 0o700 }); + const stat = fs.lstatSync(this.#directory); + if (!stat.isDirectory() || process.getuid && stat.uid !== process.getuid()) fail("registry directory must belong to the host user"); + this.#directoryIdentity = { dev: stat.dev, ino: stat.ino }; + if (process.platform !== "win32") fs.chmodSync(this.#directory, 0o700); + try { + const raw: unknown = JSON.parse(fs.readFileSync(this.registryPath, "utf8")); + if (!Value.Check(RegistrySchema, raw)) fail("malformed registry projection; preserve it for operator repair"); + for (const entry of raw.entries) { + if (this.#entries.has(entry.projectId) || [...this.#entries.values()].some((item) => item.controlDomainId === entry.controlDomainId)) fail("registry contains conflicting identities"); + this.#entries.set(entry.projectId, { ...entry, mountState: "unmounted" }); + } + } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + } + #checkDirectory(): void { + if (this.#closed) fail("project registry is closed"); + noLinks(this.registryPath); + const stat = fs.lstatSync(this.#directory); + if (!stat.isDirectory() || stat.dev !== this.#directoryIdentity.dev || stat.ino !== this.#directoryIdentity.ino) fail("registry directory identity changed"); + } + #checkMount(mount: Mounted): void { + noLinks(mount.store.storePath); noLinks(mount.projectRoot); + const root = fs.lstatSync(mount.projectRoot), dir = fs.lstatSync(mount.store.storePath); + const header = mount.store.header; + if (root.dev !== mount.rootDevice || root.ino !== mount.rootInode || !root.isDirectory() + || String(dir.dev) !== header.directoryBinding.device || String(dir.ino) !== header.directoryBinding.inode + || fs.realpathSync(mount.store.storePath) !== header.directoryBinding.canonicalPath) fail("mounted project directory identity changed"); + let durable: unknown; + try { const file = path.join(mount.store.storePath, "header"); noLinks(file); durable = JSON.parse(fs.readFileSync(file, "utf8")); } + catch { fail("mounted project header is absent or unreadable"); } + if (!Value.Check(ControlStoreHeaderSchema, durable) || JSON.stringify(durable) !== JSON.stringify(header)) fail("mounted project header changed"); + if (mount.store.status !== "healthy") fail("mounted project store is not healthy"); + } + #save(): void { + this.#checkDirectory(); + writeJsonAtomicHardened(this.registryPath, { version: 1, entries: [...this.#entries.values()] }); + } + /** Paths are trusted launcher configuration; never call with unchecked RPC paths. */ + mount(storePath: string, projectRoot?: string): ProjectMount { + this.#checkDirectory(); noLinks(storePath); + const resolved = path.resolve(storePath); + const rootPath = projectRoot ?? (path.basename(resolved) === "control" && path.basename(path.dirname(resolved)) === ".taskflow" + ? path.dirname(path.dirname(resolved)) : fail("nonstandard store path requires an explicit project root")); + noLinks(rootPath); + const root = fs.realpathSync(rootPath), rootStat = fs.lstatSync(root); + if (!rootStat.isDirectory()) fail("project root must be a directory"); + const relative = path.relative(root, resolved); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) fail("project store must be inside its configured root"); + for (const mounted of this.#mounts.values()) { + if (path.resolve(mounted.store.storePath) !== resolved) continue; + this.#checkMount(mounted); + if (mounted.projectRoot !== root) fail("mounted store cannot be rebound to a different project root"); + return mounted; + } + const store = openControlStore(resolved); + try { + const header = store.header; + if (this.#mounts.has(header.projectId) || [...this.#mounts.values()].some((m) => m.store.header.controlDomainId === header.controlDomainId)) fail("a project/domain identity is already mounted elsewhere"); + const mounted: Mounted = { store, projectRoot: root, rootDevice: rootStat.dev, rootInode: rootStat.ino }; + this.#checkMount(mounted); + const entry: ControlRegistryEntry = { projectId: header.projectId, controlDomainId: header.controlDomainId, storePath: resolved, directoryBinding: header.directoryBinding, mountState: "mounted" }; + const previous = this.#entries.get(header.projectId); + this.#entries.set(header.projectId, entry); + try { this.#save(); } catch (error) { if (previous) this.#entries.set(header.projectId, previous); else this.#entries.delete(header.projectId); throw error; } + this.#mounts.set(header.projectId, mounted); return mounted; + } catch (error) { store.close(); throw error; } + } + resolve(projectId?: string): ProjectMount { + this.#checkDirectory(); + if (projectId === undefined && this.#mounts.size !== 1) throw new ControlError("TF_COMMAND_FAILED", "explicit projectId required for multi-project routing"); + const mount = projectId === undefined ? this.#mounts.values().next().value : this.#mounts.get(projectId); + if (!mount) throw new ControlError("TF_POLICY_DENIED", "project is not mounted"); + this.#checkMount(mount); return mount; + } + list(): ControlRegistryEntry[] { + this.#checkDirectory(); + for (const mount of this.#mounts.values()) this.#checkMount(mount); + return structuredClone([...this.#entries.values()]); + } + unmount(projectId: string): void { + const mount = this.resolve(projectId); + mount.store.close(); this.#mounts.delete(projectId); + const entry = this.#entries.get(projectId)!; this.#entries.set(projectId, { ...entry, mountState: "unmounted" }); this.#save(); + } + close(): void { + if (this.#closed) return; + this.#closed = true; + for (const mount of this.#mounts.values()) mount.store.close(); + this.#mounts.clear(); + } +} diff --git a/packages/taskflow-control/src/runtime-checkpoint.ts b/packages/taskflow-control/src/runtime-checkpoint.ts new file mode 100644 index 00000000..2b9f1745 --- /dev/null +++ b/packages/taskflow-control/src/runtime-checkpoint.ts @@ -0,0 +1,45 @@ +/** Exact engine checkpoints; recovery never infers process death from timeouts. */ +import { randomUUID } from "node:crypto"; +import { isQuiescentApprovalCheckpoint, resumeSourceAfterOwnerExit, type RunState } from "taskflow-core"; +import { defaultProcessIdentity, type ProcessIdentityLike } from "./singleton.ts"; +import { ControlError } from "./errors.ts"; + +export interface RuntimeCheckpoint { + state: RunState; + writer: ProcessIdentityLike; +} + +export function isWaitingRuntimeCheckpoint(checkpoint: RuntimeCheckpoint | undefined): boolean { + return !!checkpoint && checkpoint.state.status === "running" && + isQuiescentApprovalCheckpoint(checkpoint.state, checkpoint.state.foregroundOwner?.approvalWait ?? []); +} + +export function ownRuntimeState(state: RunState): ProcessIdentityLike { + const writer = defaultProcessIdentity(); + state.foregroundOwner = { version: 1, pid: writer.pid, instanceId: randomUUID(), startedAt: Date.now(), + ...(state.foregroundOwner?.approvalWait ? { approvalWait: [...state.foregroundOwner.approvalWait] } : {}) }; + return writer; +} + +export function captureRuntimeCheckpoint(state: RunState, writer: ProcessIdentityLike, waiting: ReadonlySet): RuntimeCheckpoint { + const snapshot = structuredClone(state); + const running = Object.values(snapshot.phases).filter(phase => phase.status === "running").map(phase => phase.id).sort(); + if (snapshot.foregroundOwner) { + delete snapshot.foregroundOwner.approvalWait; + if (running.length && running.every(id => waiting.has(id)) && isQuiescentApprovalCheckpoint(snapshot, running)) snapshot.foregroundOwner.approvalWait = running; + } + return { state: snapshot, writer: { ...writer } }; +} + +export function recoverRuntimeCheckpoint(checkpoint: RuntimeCheckpoint | undefined, cwd: string): RunState { + const state = checkpoint?.state, writer = checkpoint?.writer; + if (!state || !writer || !writer.birthToken || !["native", "opaque"].includes(writer.birthTokenKind) + || writer.pid !== state.foregroundOwner?.pid || state.status !== "running") { + throw new ControlError("TF_RECONCILE_REQUIRED", "runtime has no verifiable approval checkpoint writer"); + } + // Core requires an actual ESRCH process probe. A live reused PID, EPERM, + // unavailable OS identity, and every other unknown remain closed. No kill is sent. + const source = resumeSourceAfterOwnerExit(state, { cwd }); + if (!source.ok) throw new ControlError("TF_RECONCILE_REQUIRED", source.errors.join("; ")); + return structuredClone(state); +} diff --git a/packages/taskflow-control/src/runtime-provider.ts b/packages/taskflow-control/src/runtime-provider.ts new file mode 100644 index 00000000..67bbb5b5 --- /dev/null +++ b/packages/taskflow-control/src/runtime-provider.ts @@ -0,0 +1,211 @@ +/** Concrete TE provider. A durable intent precedes execution; only an awaited + * runtime plus settled TE journal can prove terminal state. Restart can continue + * an exact approval checkpoint after the old writer is proven dead; every other + * lost live handle remains ambiguous and is never re-submitted. */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { randomUUID } from "node:crypto"; +import { executeTaskflow, directoryIdentity, validateTaskflow, validateInvocationArgs, compileTaskflowToIR, type Taskflow, type RunState, type RuntimeDeps, type ApprovalRequest, type ApprovalDecision } from "taskflow-core"; +import { createResolveOnlyWorkspaceSession, WriteIntentJournal } from "taskflow-core/control-execution"; +import { commandRequestHash } from "./schema/commands.ts"; +import type { BoundPlan } from "./schema/plan.ts"; +import type { ExecutionOwner } from "./schema/te-mirrors.ts"; +import type { PrepareResult, ProviderEvent, PollResult, CancelResult, CollectResult, ReconcileResult, SubmitResult } from "./schema/transport.ts"; +import type { ExecutionProvider } from "./te-provider.ts"; +import { writeJsonAtomicHardened } from "./store/store.ts"; +import type { EvidenceDiagnostic } from "./store/evidence-explanation.ts"; +import { ControlError } from "./errors.ts"; +import { captureRuntimeCheckpoint, isWaitingRuntimeCheckpoint, ownRuntimeState, recoverRuntimeCheckpoint, type RuntimeCheckpoint } from "./runtime-checkpoint.ts"; +import { validateApprovalPlanEdit } from "./approval-edits.ts"; + +export interface RuntimeExecutionInput { + plan: BoundPlan; flow: Taskflow; args: Record; projectRoot: string; principal: string; + owner: ExecutionOwner; controlDomainId: string; + beforeDispatch: () => Promise; + dispatchFence?: (activate: () => T) => Promise; + requestApproval?: (request: ApprovalRequest) => Promise; +} +export interface RuntimeObservation { + runId: string; projectId: string; controlDomainId: string; providerJobHandle: string; + status: "running" | "completed" | "failed" | "blocked" | "cancelled" | "ambiguous"; + finalOutput?: string; evidenceCommit: string; noPendingResourceIntents: boolean; +} +interface DurableJob { + version: 1; runId: string; projectId: string; controlDomainId: string; owner: ExecutionOwner; + projectRoot: string; planHash: string; providerJobHandle: string; + status: RuntimeObservation["status"]; finalOutput?: string; noPendingResourceIntents: boolean; + execution?: Pick; + checkpoint?: RuntimeCheckpoint; + digest: string; +} +interface LiveJob { promise: Promise; abort: AbortController; state: RunState; job: Omit; persist: () => void; } +const unavailable = (message: string) => ({ outcome: "ambiguous" as const, message }); + +export class RuntimeTeExecutionProvider implements ExecutionProvider { + readonly kind = "te-resources" as const; + readonly directory: string; + readonly #identity: { dev: number; ino: number }; + readonly #preparations = new Map(); + readonly #live = new Map(); + readonly #waiting = new Set(); + readonly #deps: Pick; + constructor(directory: string, deps: Pick = { agents: [] }) { + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + if (fs.lstatSync(directory).isSymbolicLink()) throw new ControlError("TF_DURABILITY_FAILED", "provider directory cannot be a symbolic link"); + this.directory = fs.realpathSync(directory); this.#identity = fs.statSync(this.directory); this.#deps = deps; + } + async probe() { + return { outcome: "accepted" as const, capabilities: { processIsolation: "none" as const, resolution: "contained" as const, + mutationMediation: "brokered" as const, revocation: "admission-only" as const, baselinePolicyId: "taskflow-resolve-only", + hostProbeSha256: commandRequestHash({ backend: "taskflow-resolve-only", platform: process.platform, arch: process.arch, node: process.version, processIsolation: "none" }) } }; + } + async prepare(): Promise { throw new ControlError("TF_COMMAND_FAILED", "concrete TE preparation requires linked execution input"); } + async prepareExecution(input: RuntimeExecutionInput): Promise { + const check = validateTaskflow(input.flow); + const errors = [...check.errors, ...validateInvocationArgs(input.flow, input.args)]; + if (errors.length) throw new ControlError("TF_COMMAND_FAILED", errors.join("; ")); + await compileTaskflowToIR(input.flow); // real compilation, not a caller-supplied plan claim + const preparationId = randomUUID(); + this.#preparations.set(preparationId, { ...input, plan: structuredClone(input.plan), flow: structuredClone(input.flow), args: structuredClone(input.args) }); + return { outcome: "accepted", fulfillment: { preparationId, enforcementCapabilities: input.plan.enforcementCapabilities } }; + } + #check() { const st = fs.lstatSync(this.directory); if (!st.isDirectory() || st.dev !== this.#identity.dev || st.ino !== this.#identity.ino) throw new ControlError("TF_DURABILITY_FAILED", "provider directory identity changed"); } + #file(handle: string) { if (!/^[a-f0-9-]{36}$/.test(handle)) throw new ControlError("TF_COMMAND_FAILED", "invalid provider handle"); this.#check(); return path.join(this.directory, `${handle}.json`); } + #read(handle: string): DurableJob | undefined { + const file = this.#file(handle); + let raw: DurableJob; try { if (fs.lstatSync(file).isSymbolicLink()) throw new Error("symbolic link"); raw = JSON.parse(fs.readFileSync(file, "utf8")); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw new ControlError("TF_DURABILITY_FAILED", "provider record unavailable or corrupt"); } + const { digest, ...body } = raw; + if (raw.version !== 1 || raw.providerJobHandle !== handle || digest !== commandRequestHash(body)) throw new ControlError("TF_DURABILITY_FAILED", "provider record checksum mismatch"); + this.#check(); return raw; + } + #write(job: Omit) { this.#check(); writeJsonAtomicHardened(this.#file(job.providerJobHandle), { ...job, digest: commandRequestHash(job) }); this.#check(); } + async submit(req: { preparationId: string; owner: ExecutionOwner; controlDomainId: string }): Promise { + const input = this.#preparations.get(req.preparationId); + if (!input || commandRequestHash(req.owner) !== commandRequestHash(input.owner) || req.controlDomainId !== input.controlDomainId) throw new ControlError("TF_AUTHORITY_REVOKED", "preparation identity does not match"); + this.#preparations.delete(req.preparationId); // one-shot even if dispatch fails + const handle = input.owner.runId; + if (this.#read(handle)) return unavailable("existing durable job must be reconciled, never resubmitted"); + return this.#start(handle, input); + } + hasLiveExecution(handle: string): boolean { return this.#live.has(handle); } + async applyApprovalPlan(handle: string, phaseId: string, flow: Taskflow): Promise { + const saved = this.#read(handle); + if (!saved?.execution || !saved.checkpoint || !isWaitingRuntimeCheckpoint(saved.checkpoint)) throw new ControlError("TF_RECONCILE_REQUIRED", "plan edit requires a durable approval checkpoint"); + const live = this.#live.get(handle); + if (!live) recoverRuntimeCheckpoint(saved.checkpoint, saved.projectRoot); + const edit = await validateApprovalPlanEdit(saved.checkpoint.state, phaseId, flow); + if (this.#read(handle)?.digest !== saved.digest) throw new ControlError("TF_STALE_VERSION", "checkpoint changed during plan edit validation"); + const state = live?.state ?? saved.checkpoint.state; + // Fixed DAG layers retain these phase objects. Update only validated, + // unstarted phase bodies so their next dispatch sees the newly linked plan. + for (let i = 0; i < state.def.phases.length; i++) { + const phase = state.def.phases[i]!, next = edit.flow.phases[i]!; + if (commandRequestHash(phase) === commandRequestHash(next)) continue; + for (const key of Object.keys(phase)) delete (phase as Record)[key]; + Object.assign(phase, structuredClone(next)); + } + state.flowDefHash = edit.irHash; state.phaseFingerprints = undefined; state.updatedAt = Date.now(); + if (live) { live.job.execution!.flow = state.def; live.persist(); } + else { const { digest: _digest, ...job } = saved; job.execution!.flow = state.def; this.#write(job); } + } + /** Read actual persisted execution evidence, including terminal checkpoints. + * Availability is not a claim that this checkpoint is safe to resume. */ + readDiagnostic(handle: string): EvidenceDiagnostic | undefined { + const job = this.#read(handle); + if (!job?.checkpoint) return undefined; + const state = job.checkpoint.state; + if (!state || job.runId !== handle || state.runId !== handle || !/^[a-f0-9-]{36}$/.test(job.projectId) + || job.execution && (job.execution.plan.projectId !== job.projectId || job.execution.plan.controlDomainId !== job.controlDomainId + || job.execution.controlDomainId !== job.controlDomainId || job.execution.plan.boundPlanHash !== job.planHash + || job.execution.projectRoot !== job.projectRoot || commandRequestHash(job.execution.flow) !== commandRequestHash(state.def))) { + throw new ControlError("TF_DURABILITY_FAILED", "diagnostic checkpoint execution identity mismatch"); + } + return { runId: job.runId, projectId: job.projectId, controlDomainId: job.controlDomainId, state: structuredClone(state), + controlDirectory: path.join(this.directory, "resources", job.projectId), projectRoot: job.projectRoot }; + } + recoverableExecution(handle: string): NonNullable { + const job = this.#read(handle); + if (!job || job.status !== "running" || !job.execution || this.#live.has(handle)) throw new ControlError("TF_RECONCILE_REQUIRED", "runtime is not a recoverable orphan"); + recoverRuntimeCheckpoint(job.checkpoint, job.projectRoot); + if (job.execution.plan.boundPlanHash !== job.planHash || job.execution.plan.projectId !== job.projectId + || job.execution.controlDomainId !== job.controlDomainId || job.checkpoint?.state.runId !== handle + || commandRequestHash(job.execution.flow) !== commandRequestHash(job.checkpoint.state.def)) throw new ControlError("TF_DURABILITY_FAILED", "checkpoint execution identity mismatch"); + return structuredClone(job.execution); + } + async resumeExecution(handle: string, input: RuntimeExecutionInput): Promise { + const execution = this.recoverableExecution(handle), job = this.#read(handle)!; + if (input.owner.runId !== handle || commandRequestHash(execution.plan) !== commandRequestHash(input.plan) + || commandRequestHash(execution.flow) !== commandRequestHash(input.flow) || commandRequestHash(execution.args) !== commandRequestHash(input.args) + || execution.projectRoot !== input.projectRoot || execution.controlDomainId !== input.controlDomainId) throw new ControlError("TF_AUTHORITY_REVOKED", "resume cannot alter the linked execution"); + const quiet = await this.quiescence(handle); + if (!quiet.quiescent) throw new ControlError("TF_RECONCILE_REQUIRED", "checkpoint has unresolved TE effects"); + return this.#start(handle, input, job); + } + async #start(handle: string, input: RuntimeExecutionInput, previous?: DurableJob): Promise { + const abort = new AbortController(); + const controlDirectory = path.join(this.directory, "resources", input.plan.projectId); + const session = await createResolveOnlyWorkspaceSession({ invocationRoot: input.projectRoot, controlDirectory, principalId: input.principal, signal: abort.signal }); + const binding = await session.bindPhase({ invocationRoot: input.projectRoot, runId: input.owner.runId, phaseId: input.owner.phaseId, argDefinitions: input.flow.args ?? {}, argValues: input.args }); + await input.beforeDispatch(); + const activate = (): SubmitResult => { + const current = this.#read(handle); + if (this.#live.has(handle) || (previous ? current?.digest !== previous.digest : !!current)) throw new ControlError("TF_RECONCILE_REQUIRED", "runtime writer changed before dispatch"); + const state: RunState = previous ? recoverRuntimeCheckpoint(previous.checkpoint, input.projectRoot) + : { runId: input.owner.runId, flowName: input.flow.name, def: input.flow, args: input.args, status: "running", phases: {}, createdAt: Date.now(), updatedAt: Date.now(), cwd: input.projectRoot, invocationRootSnapshot: directoryIdentity(input.projectRoot) }; + const writer = ownRuntimeState(state), waiting = new Set(); + const job: Omit = { version: 1, runId: input.owner.runId, projectId: input.plan.projectId, controlDomainId: input.controlDomainId, + owner: input.owner, projectRoot: input.projectRoot, planHash: input.plan.boundPlanHash, providerJobHandle: handle, status: "running", noPendingResourceIntents: false, + execution: { plan: input.plan, flow: input.flow, args: input.args, projectRoot: input.projectRoot, principal: input.principal, owner: input.owner, controlDomainId: input.controlDomainId } }; + const persist = () => { job.checkpoint = captureRuntimeCheckpoint(state, writer, waiting); this.#write(job); }; + // Claim the checkpoint before execution. Any crash after this intent and before + // the next verified wait is ambiguous, so a retry cannot duplicate dispatch. + persist(); + const live: LiveJob = { abort, promise: Promise.resolve(), state, job, persist }; this.#live.set(handle, live); + live.promise = (async () => { + try { + const result = await executeTaskflow(state, { ...this.#deps, cwd: input.projectRoot, signal: abort.signal, + workspaceSession: session, workspaceControlDirectory: controlDirectory, cwdBridgeMode: "resolve-only", + _workspaceBinding: binding, _cwdBoundary: input.projectRoot, _disableCache: true, + ...(previous ? { _approvalCheckpointContinuation: { runId: state.runId } } : {}), + persist: () => { try { persist(); } catch (error) { abort.abort(); throw error; } }, + requestApproval: input.requestApproval ? async request => { + waiting.add(request.phaseId); this.#waiting.add(handle); + try { persist(); return await input.requestApproval!(request); } + finally { waiting.delete(request.phaseId); if (!waiting.size) this.#waiting.delete(handle); persist(); } + } : undefined }); + const intents = await new WriteIntentJournal({ directory: controlDirectory, journalEpoch: 1 }).listIntents(); + const clean = !intents.some(intent => intent.owner.runId === input.owner.runId && ["pending", "dirty-unknown"].includes(intent.status)); + const status = !clean ? "ambiguous" : abort.signal.aborted ? "cancelled" : result.state.status === "paused" ? "ambiguous" : result.state.status; + this.#write({ ...job, status, finalOutput: result.finalOutput, noPendingResourceIntents: clean }); + } catch { this.#write({ ...job, status: "ambiguous", noPendingResourceIntents: false }); } + finally { this.#live.delete(handle); } + })(); + return { outcome: "accepted", providerJobHandle: handle }; + }; + return input.dispatchFence ? input.dispatchFence(activate) : activate(); + } + #recoverable(job: DurableJob): boolean { + if (job.status !== "running" || !job.execution) return false; + try { recoverRuntimeCheckpoint(job.checkpoint, job.projectRoot); return true; } catch { return false; } + } + async observe(handle: string): Promise { + const job = this.#read(handle); + if (!job) throw new ControlError("TF_RECONCILE_REQUIRED", "provider has no durable execution evidence"); + const status = job.status === "running" && !this.#live.has(handle) && !this.#recoverable(job) ? "ambiguous" : job.status; + return { runId: job.runId, projectId: job.projectId, controlDomainId: job.controlDomainId, providerJobHandle: handle, status, + finalOutput: job.finalOutput, evidenceCommit: job.digest, noPendingResourceIntents: job.noPendingResourceIntents }; + } + async quiescence(handle: string): Promise<{ proofId: string; quiescent: boolean; noPendingResourceIntents: boolean }> { + const job = this.#read(handle); if (!job) return { proofId: "unobserved", quiescent: false, noPendingResourceIntents: false }; + const intents = await new WriteIntentJournal({ directory: path.join(this.directory, "resources", job.projectId), journalEpoch: 1 }).listIntents(); + const clean = !intents.some(intent => intent.owner.runId === job.runId && ["pending", "dirty-unknown"].includes(intent.status)); + return { proofId: commandRequestHash({ digest: job.digest, intents }), quiescent: clean && (this.#waiting.has(handle) && isWaitingRuntimeCheckpoint(job.checkpoint) || !this.#live.has(handle) && (this.#recoverable(job) || !["running", "ambiguous"].includes(job.status))), noPendingResourceIntents: clean }; + } + async poll(req: { providerJobHandle: string }): Promise { const s = await this.observe(req.providerJobHandle); return s.status === "ambiguous" ? unavailable("runtime outcome is not proven") : { outcome: "accepted", status: s.status === "running" ? "running" : s.status === "completed" ? "completed" : "failed" }; } + async cancel(req: { providerJobHandle: string }): Promise { const live = this.#live.get(req.providerJobHandle); if (live) { live.abort.abort(); await live.promise; } const s = await this.observe(req.providerJobHandle); return s.status === "ambiguous" || s.status === "running" ? unavailable("quiescent cancellation is not proven") : { outcome: "accepted", cancelled: s.status === "cancelled" }; } + async collect(req: { providerJobHandle: string }): Promise { const s = await this.observe(req.providerJobHandle); return ["running", "ambiguous"].includes(s.status) ? unavailable("runtime output is not terminal") : { outcome: "accepted", providerJobHandle: req.providerJobHandle }; } + async reconcile(req: { providerJobHandle: string }): Promise { const s = await this.observe(req.providerJobHandle); return s.status === "ambiguous" ? { outcome: "accepted", providerState: "exhausted" } : { outcome: "accepted", providerState: s.status === "running" ? "running" : "terminal" }; } + async *watch(req: { providerJobHandle: string }): AsyncIterable { const live = this.#live.get(req.providerJobHandle); if (live) await live.promise; const s = await this.observe(req.providerJobHandle); yield { kind: "terminal", outcome: s.status === "completed" ? "completed" : s.status === "ambiguous" || s.status === "running" ? "ambiguous" : "failed" }; } + async wait(handle: string): Promise { await this.#live.get(handle)?.promise; return this.observe(handle); } +} diff --git a/packages/taskflow-control/src/schema/commands.ts b/packages/taskflow-control/src/schema/commands.ts index 55069395..f12bc838 100644 --- a/packages/taskflow-control/src/schema/commands.ts +++ b/packages/taskflow-control/src/schema/commands.ts @@ -143,8 +143,7 @@ export const LegacyControlEventPayloadSchema = Type.Union([ ), ]); /** V2 carries complete approval and lifecycle facts. V1 remains read-only. */ -export const ControlEventPayloadSchema = Type.Union([ - ...LegacyControlEventPayloadSchema.anyOf.filter((schema) => !["approval.pending", "approval.settled"].includes(schema.properties.kind.const as string)), +const LifecycleEventPayloadSchema = Type.Union([ Type.Object({ kind: Type.Literal("approval.pending"), approvalRequestId: UuidSchema, request: ApprovalRequestSchema }, { additionalProperties: false }), Type.Object({ kind: Type.Literal("approval.settled"), approvalRequestId: UuidSchema, decision: StringEnum(["approved", "rejected", "edited", "expired", "cancelled"]) }, { additionalProperties: false }), Type.Object({ kind: Type.Literal("run.snapshot"), run: RunSnapshotSchema }, { additionalProperties: false }), @@ -155,7 +154,13 @@ export const ControlEventPayloadSchema = Type.Union([ Type.Object({ kind: Type.Literal("receipt.issued"), receipt: ReceiptSchema }, { additionalProperties: false }), Type.Object({ kind: Type.Literal("artifact.recorded"), runId: UuidSchema, commandKind: CommandKindSchema, artifact: ArtifactRefSchema }, { additionalProperties: false }), ]); -export type ControlEventPayload = Static | Static; +// Preserve the literal tuple for TypeBox static inference: a variadic filtered +// array before new cases can otherwise erase the lifecycle discriminants. +export const ControlEventPayloadSchema = Type.Union([ + ...LegacyControlEventPayloadSchema.anyOf.filter((schema) => !["approval.pending", "approval.settled"].includes(schema.properties.kind.const as string)), + ...LifecycleEventPayloadSchema.anyOf, +]); +export type ControlEventPayload = Static | Static; // --------------------------------------------------------------------------- // ControlEvent (P12 / RFC §10) — ledger envelope diff --git a/packages/taskflow-control/src/singleton.ts b/packages/taskflow-control/src/singleton.ts index 25d113b8..bda50329 100644 --- a/packages/taskflow-control/src/singleton.ts +++ b/packages/taskflow-control/src/singleton.ts @@ -454,7 +454,7 @@ export function acquireUserSingleton(options: SingletonOptions): SingletonAcquir } // 5) Identity-bound reclaim of a dead owner (fail-closed). - const reclaimed = reclaimStaleLock(paths, existing, identity, inspect, now, endpoint, leaseTtlMs); + const reclaimed = reclaimStaleLock(paths, existing, identity, inspect, now, endpoint, leaseTtlMs, holderId); if (reclaimed) { const stat = fs.statSync(paths.lockPath, { bigint: true }); published = { dev: stat.dev, ino: stat.ino }; @@ -484,6 +484,7 @@ function reclaimStaleLock( now: () => number, endpoint: string, leaseTtlMs: number, + holderId: string, ): boolean { const claimFilePath = claimPath(paths); const claim: ClaimRecord = { @@ -539,7 +540,7 @@ function reclaimStaleLock( fsyncDirectory(paths.controlHome); publishLockByHardLink(paths.lockPath, { version: SINGLETON_LOCK_VERSION, - holderId: claim.claimantId, + holderId, pid: identity.pid, birthToken: identity.birthToken, birthTokenKind: identity.birthTokenKind, @@ -548,7 +549,7 @@ function reclaimStaleLock( acquiredAt: now(), }); writeLease(paths, { - holderId: claim.claimantId, + holderId, fencingEpoch: existing.fencingEpoch + 1, endpoint, expiresAt: now() + leaseTtlMs, diff --git a/packages/taskflow-control/src/store/coordinator-store.ts b/packages/taskflow-control/src/store/coordinator-store.ts index f45d39d0..84aae0b7 100644 --- a/packages/taskflow-control/src/store/coordinator-store.ts +++ b/packages/taskflow-control/src/store/coordinator-store.ts @@ -18,23 +18,31 @@ export interface CoordinatorIdentity { principal: string; ownerId: string; opera export interface CoordinatorAdmissionProof { reservationId: string; projectId: string; projectControlDomainId: string; runId: string; projectAdmitCommitSeq: number; runVersion: number; } -/** Explicit absence must come from the trusted project admission gate. A null - * observation or failed read never authorizes TTL capacity reclamation. */ +/** Read-only absence is informational and never authorizes capacity release. */ export interface CoordinatorAdmissionAbsence { status: "not-admitted"; reservationId: string; projectId: string; projectControlDomainId: string; runId: string; runVersion: number; } -export type CoordinatorAdmissionObservation = CoordinatorAdmissionProof | CoordinatorAdmissionAbsence | null; +export interface CoordinatorAbandonmentProof { + status: "abandoned"; reservationId: string; projectId: string; projectControlDomainId: string; runId: string; + runVersion: number; abandonmentCommitSeq: number; +} +export type CoordinatorAdmissionDecision = CoordinatorAdmissionProof | CoordinatorAbandonmentProof; +export type CoordinatorAdmissionObservation = CoordinatorAdmissionDecision | CoordinatorAdmissionAbsence | null; export interface CoordinatorReleaseProof extends CoordinatorAdmissionProof { proofId: string; status: string; stage: string; requiresReadmission: boolean; providerNoLiveProcessTree: boolean; noAmbiguousJobs: boolean; reconcileTimeoutOnly: boolean; } -export type CoordinatorOperation = "reserve" | "commit" | "renew" | "markOrphanSuspect" | "normalRelease" | "forceRelease" | "setMaxActiveRuns" | "readReservation" | "readCommand" | "snapshot"; +export type CoordinatorOperation = "dispatch" | "reserve" | "commit" | "renew" | "markOrphanSuspect" | "normalRelease" | "forceRelease" | "setMaxActiveRuns" | "readReservation" | "readCommand" | "snapshot"; /** Configured only by the trusted host service. Context is opaque service * authority, never a wire-body principal or caller-supplied quiescence bool. */ export interface CoordinatorAuthority { readLease(): CoordinatorLease | null | Promise; authorize(context: Context, operation: CoordinatorOperation, reservation?: ConcurrencyReservation): CoordinatorIdentity | Promise; readAdmission(reservation: ConcurrencyReservation): CoordinatorAdmissionObservation | Promise; + /** Explicit project journal CAS against the SAME writer fence as admission. + * Existing admission wins; otherwise durably tombstone this reservation. + * Omission is safe: expired reservations retain capacity until reconciled. */ + abandonAdmissionIfAbsent?(reservation: ConcurrencyReservation): CoordinatorAdmissionDecision | Promise; readRelease(reservation: ConcurrencyReservation): CoordinatorReleaseProof | Promise; } export interface CoordinatorStoreOptions { @@ -199,6 +207,22 @@ export class UserCoordinatorStore { const row = state.reservations[id]; if (row) this.#owner(row, identity!); return row; }); } + /** Hold the durable capacity fence through asynchronous preparation and the + * synchronous provider launch. Preparation must not launch work or re-enter + * this coordinator. The returned activation must not await: its project + * binding/auth checks and actual runtime activation occur in one JS turn. */ + async withDispatch(id: string, context: Context, prepare: (row: StoredReservation) => Promise<() => T>): Promise { + validId(id); + let activate: (() => T) | undefined; + let result!: T; + await this.#transaction("dispatch", context, id, async (state, identity) => { + const row = this.#row(state, id); this.#owner(row, identity!); + if (row.reservation.state !== "committed") fail("TF_ADMISSION_BINDING_CONFLICT", "dispatch requires a committed reservation"); + activate = await prepare(structuredClone(row)); + if (typeof activate !== "function") fail("TF_COMMAND_FAILED", "dispatch preparation must return a synchronous activation"); + }, undefined, () => { result = activate!(); }); + return result; + } async readCommand(id: string, context: Context): Promise { validId(id); return this.#transaction("readCommand", context, undefined, async (state, identity) => { @@ -243,7 +267,7 @@ export class UserCoordinatorStore { if (!isAdmissionProof(proof) || !matchesProof({ ...row, reservation: proposed }, proof)) fail("TF_ADMISSION_BINDING_CONFLICT", "project ledger does not prove this admission"); row.reservation = { ...proposed, state: "committed", coordinatorEpoch: this.#options.epoch }; delete row.reservation.reservedExpiresAt; - row.admitRunVersion = proof.runVersion; row.updatedAt = now; return row; + row.admitRunVersion = proof!.runVersion; row.updatedAt = now; return row; }); } async renew(id: string, input: { ttlMs?: number }, context: Context): Promise { @@ -341,7 +365,15 @@ export class UserCoordinatorStore { for (const row of Object.values(state.reservations)) { if (row.reservation.state !== "reserved" || row.reservation.reservedExpiresAt! > now) continue; let proof: CoordinatorAdmissionObservation; - try { proof = await this.#options.authority.readAdmission(structuredClone(row.reservation)); } + try { + proof = await this.#options.authority.readAdmission(structuredClone(row.reservation)); + // An ordinary observer cannot close the late-admit race. Only the + // explicit durable ADMIT/ABANDON arbitration may free a slot. + if (!isAdmissionProof(proof) && this.#options.authority.abandonAdmissionIfAbsent) { + await this.#fence(this.#now(now)); + proof = await this.#options.authority.abandonAdmissionIfAbsent(structuredClone(row.reservation)); + } + } catch { this.#now(now); continue; } // An unavailable project ledger retains its slot. const observedNow = this.#now(now); await this.#fence(observedNow); if (!proof || proof.reservationId !== row.reservation.reservationId || bindingKey(proof) !== bindingKey(row.reservation)) continue; @@ -351,15 +383,20 @@ export class UserCoordinatorStore { row.reservation.state = "committed"; row.reservation.projectAdmitCommitSeq = proof.projectAdmitCommitSeq; row.reservation.coordinatorEpoch = this.#options.epoch; row.admitRunVersion = proof.runVersion; delete row.reservation.reservedExpiresAt; row.updatedAt = observedNow; - } else if ("status" in proof && proof.status === "not-admitted" && Number.isSafeInteger(proof.runVersion) && proof.runVersion >= 0) { + } else if ("status" in proof && proof.status === "abandoned" + && Number.isSafeInteger(proof.abandonmentCommitSeq) && proof.abandonmentCommitSeq > 0 + && Number.isSafeInteger(proof.runVersion) && proof.runVersion >= 0) { row.reservation.state = "expired"; delete row.reservation.reservedExpiresAt; row.updatedAt = observedNow; } } } - async #transaction(action: string, context: Context | undefined, id: string | undefined, apply: (state: CoordinatorState, identity: CoordinatorIdentity | undefined, now: number) => Promise, authorizationReservation?: ConcurrencyReservation): Promise { + async #transaction(action: string, context: Context | undefined, id: string | undefined, apply: (state: CoordinatorState, identity: CoordinatorIdentity | undefined, now: number) => Promise, authorizationReservation?: ConcurrencyReservation, afterCommit?: () => void): Promise { if (this.#closed) fail("TF_DURABILITY_FAILED", "coordinator store is closed"); this.#binding(); - const release = await this.#mutex.acquire({ timeoutMs: 30_000, signal: AbortSignal.timeout(30_000) }); + const release = await this.#mutex.acquire({ timeoutMs: 30_000, signal: AbortSignal.timeout(30_000) }).catch((error: unknown) => { + if (error instanceof ControlError) throw error; + throw new ControlError("TF_DURABILITY_FAILED", `coordinator writer fence failed: ${error instanceof Error ? error.message : String(error)}`, { recoveryAction: "operator", sideEffects: "none" }); + }); try { if (this.#closed) fail("TF_DURABILITY_FAILED", "coordinator store closed during lock admission"); this.#binding(); const file = path.join(this.storePath, "coordinator.json"), identityFile = path.join(this.storePath, "coordinator.identity.json"); @@ -410,6 +447,14 @@ export class UserCoordinatorStore { if (process.env[COORDINATOR_CRASH_ENV] === "after-state-write") process.kill(process.pid, "SIGKILL"); } if (action === "snapshot") Object.assign(value as object, { commitSeq: state.commitSeq, fencingEpoch: state.fencingEpoch }); + // No await may be inserted between the last live fence/publication and + // provider activation. The mutex is released only in finally below. + if (afterCommit) { + if (this.#closed) fail("TF_DURABILITY_FAILED", "coordinator store closed before dispatch activation"); + this.#binding(); + if (this.#now(currentTime) >= leaseExpiresAt) fail("TF_AUTHORITY_REVOKED", "coordinator lease expired before dispatch activation"); + afterCommit(); + } return structuredClone(value); } catch (error) { if (error instanceof ControlError) throw error; diff --git a/packages/taskflow-control/src/store/evidence-adapter.ts b/packages/taskflow-control/src/store/evidence-adapter.ts new file mode 100644 index 00000000..a98fbab1 --- /dev/null +++ b/packages/taskflow-control/src/store/evidence-adapter.ts @@ -0,0 +1,116 @@ +/** Evidence derived exclusively from the validated ControlStore journal. */ +import { canonicalJson } from "taskflow-core/flowir/hash"; +import { ControlError } from "../errors.ts"; +import type { ArtifactRef } from "../schema/index.ts"; +import { ControlStore } from "./store.ts"; +import { JOURNAL_GENESIS, journalHash, type JournalBatch } from "./journal.ts"; +import { + createEvidenceStore, evidenceEventHash, type EvidenceLedgerView, + type EvidenceStore, type EvidenceStoreOptions, type PreparedEvidenceReceipt, +} from "./evidence-store.ts"; + +export type ControlEvidenceOptions = Pick; + +function fail(message: string): never { + throw new ControlError("TF_DURABILITY_FAILED", message, { recoveryAction: "operator", sideEffects: "unknown" }); +} + +function batchTip(batches: readonly JournalBatch[]) { + const last = batches.at(-1); + return { commitSeq: last?.commitSeq ?? 0, + hash: last ? last.recordKind === "lifecycle-batch" ? last.contentHash : journalHash(last) : JOURNAL_GENESIS }; +} + +/** No cached projection or caller-selected anchor becomes an evidence authority. */ +export function readControlEvidence(store: ControlStore): EvidenceLedgerView { + const batches = store.readJournal(); + const header = store.header; + const view: EvidenceLedgerView = { + projectId: header.projectId, controlDomainId: header.controlDomainId, + directoryBinding: header.directoryBinding, + anchorHash: JOURNAL_GENESIS, tipHash: JOURNAL_GENESIS, tipCommitSeq: 0, + events: [], runs: {}, commands: {}, receipts: {}, + }; + const events: EvidenceLedgerView["events"][number][] = []; + const runs: Record = {}; + const commands: Record = {}; + const receipts: Record = {}; + for (const batch of batches) { + for (const event of batch.events) { + const hash = evidenceEventHash(view.tipHash, event); + events.push({ event, previousHash: view.tipHash, hash }); + view.tipHash = hash; + } + view.tipCommitSeq = batch.commitSeq; + if (batch.command) commands[batch.command.commandId] = { + ...(batch.command.responseArtifactRef ? { responseArtifactRef: batch.command.responseArtifactRef } : {}), + }; + if (batch.recordKind !== "lifecycle-batch") continue; + if (batch.evidenceMetadata) { + const metadata = batch.evidenceMetadata; + if (Object.hasOwn(runs, metadata.runId)) fail("run evidence metadata was committed more than once"); + runs[metadata.runId] = { runId: metadata.runId, eventIds: [], artifactRefs: [], + buildInfo: metadata.buildInfo, assurance: metadata.assurance }; + } + if (batch.receipt) { + if (!batch.receiptRef || !batch.manifestProofRef || Object.hasOwn(receipts, batch.receipt.runId)) fail("receipt issuance lacks unique atomic references"); + receipts[batch.receipt.runId] = { receiptRef: batch.receiptRef, manifestProofRef: batch.manifestProofRef }; + } + } + for (const run of Object.values(runs)) { + const state = store.readRun(run.runId); + if (!state) fail("evidence metadata has no committed run"); + run.boundPlanHash = state.boundPlanHash; + run.boundFragmentHash = state.boundFragmentHash; + const scoped = events.filter(({ event }) => event.correlationId === run.runId); + const terminal = scoped.findIndex(({ event }) => event.payload.kind === "run.terminal"); + // Receipt issuance and later release snapshots must never change its manifest. + const manifest = terminal < 0 ? scoped : scoped.slice(0, terminal + 1); + run.eventIds = manifest.map(({ event }) => event.eventId); + const refs: ArtifactRef[] = []; + for (const { event } of manifest) { + if (event.payload.kind !== "artifact.recorded" || event.payload.runId !== run.runId) continue; + const ref = event.payload.artifact; + if (!refs.some(candidate => canonicalJson(candidate) === canonicalJson(ref))) refs.push(ref); + } + run.artifactRefs = refs; + if (terminal >= 0) { + const event = scoped[terminal].event; + if (event.payload.kind !== "run.terminal" || event.payload.status !== state.status || state.stage !== "terminal") fail("terminal event differs from authoritative run state"); + run.terminalEvidenceId = event.eventId; + } + } + return { ...view, events, runs, commands, receipts }; +} + +/** Use the project's existing writer exclusion for staging, issuance and reads. */ +export async function createControlEvidenceStore(store: ControlStore, options: ControlEvidenceOptions): Promise { + return createEvidenceStore({ + ...options, storePath: store.storePath, + ledger: { + read: () => readControlEvidence(store), + withWriter: operation => store.withWriter(() => operation(readControlEvidence(store))), + async commitReceiptOnce(runId: string, prepared: PreparedEvidenceReceipt) { + const current = readControlEvidence(store); + if (prepared.expectedTip.commitSeq !== current.tipCommitSeq || prepared.expectedTip.hash !== current.tipHash) { + throw new ControlError("TF_STALE_VERSION", "evidence journal advanced before receipt issuance"); + } + const tip = batchTip(store.readJournal()); + store.appendReceipt(runId, { receipt: prepared.receipt, receiptRef: prepared.receiptRef, + manifestProofRef: prepared.manifestProofRef }, tip); + }, + }, + }); +} + +/** Advance only the logical visibility floor; retained journal and blobs survive. */ +export async function compactControlEvidence(store: ControlStore, evidence: EvidenceStore, throughCommitSeq: number): Promise { + await store.withWriter(async () => { + const prepared = await evidence.prepareCheckpoint(throughCommitSeq); + const current = readControlEvidence(store); + if (prepared.expectedTip.commitSeq !== current.tipCommitSeq || prepared.expectedTip.hash !== current.tipHash) { + throw new ControlError("TF_STALE_VERSION", "journal advanced before compaction checkpoint"); + } + store.appendCheckpoint(throughCommitSeq, batchTip(store.readJournal())); + }); +} diff --git a/packages/taskflow-control/src/store/evidence-explanation.ts b/packages/taskflow-control/src/store/evidence-explanation.ts new file mode 100644 index 00000000..e35cb0fc --- /dev/null +++ b/packages/taskflow-control/src/store/evidence-explanation.ts @@ -0,0 +1,68 @@ +/** Authorized diagnostics from the real project journal and TE checkpoint. */ +import { formatWhyStale, readMapOf, declaredReadMapOfDef } from "taskflow-core/stale"; +import { whyEffectFromDurableJournal } from "taskflow-core/effects/why"; +import type { RunState } from "taskflow-core"; +import { ControlError } from "../errors.ts"; +import type { ControlStore } from "./store.ts"; +import type { EvidenceStoreOptions } from "./evidence-store.ts"; + +export interface EvidenceDiagnostic { + runId: string; + projectId: string; + controlDomainId: string; + state: RunState; + controlDirectory: string; + projectRoot: string; +} +export interface EvidenceExplanationQuery { effectId?: string; phaseId?: string; seeds?: string[] } +export interface EvidenceExplanationOptions { + authorize: EvidenceStoreOptions["authorize"]; + /** Trusted provider reader; callers cannot select a checkpoint or journal path. */ + readDiagnostic(runId: string): EvidenceDiagnostic | undefined | Promise; +} + +export async function explainControlEvidence(store: ControlStore, actor: unknown, runId: string, + options: EvidenceExplanationOptions, query: EvidenceExplanationQuery = {}) { + if (query.effectId !== undefined && (typeof query.effectId !== "string" || !query.effectId.trim()) + || query.phaseId !== undefined && (typeof query.phaseId !== "string" || !query.phaseId.trim()) + || query.seeds !== undefined && (!Array.isArray(query.seeds) || query.seeds.some(seed => typeof seed !== "string" || !seed.trim()))) { + throw new ControlError("TF_COMMAND_FAILED", "invalid evidence diagnostic selector"); + } + return store.withWriter(async () => { + const header = store.header; + const authorize = async () => { + const auth = await options.authorize(actor, { projectId: header.projectId, controlDomainId: header.controlDomainId, + target: { kind: "run", id: runId } }); + if (!auth?.principal || !/^[a-f0-9]{64}$/.test(auth.authorizationContextHash)) throw new ControlError("TF_POLICY_DENIED", "diagnostic authority unavailable"); + }; + await authorize(); + const batches = store.readJournal(); + const run = store.readRun(runId); + if (!run) throw new ControlError("TF_POLICY_DENIED", "run is absent from this project"); + const diagnostic = await options.readDiagnostic(runId); + if (diagnostic && (diagnostic.runId !== runId || diagnostic.state.runId !== runId + || diagnostic.projectId !== header.projectId || diagnostic.controlDomainId !== header.controlDomainId)) { + throw new ControlError("TF_DURABILITY_FAILED", "runtime checkpoint identity differs from the project journal"); + } + const stale = diagnostic ? formatWhyStale(runId, diagnostic.state.flowName, readMapOf(diagnostic.state.phases), + query.seeds ?? [], declaredReadMapOfDef(diagnostic.state.def)) : undefined; + const effect = query.effectId && diagnostic ? await whyEffectFromDurableJournal({ flow: diagnostic.state.def, + runId, effectId: query.effectId, phaseId: query.phaseId, workspaceRoot: diagnostic.projectRoot, + controlDirectory: diagnostic.controlDirectory }) : undefined; + await authorize(); + // Validate authoritative bytes again after the asynchronous TE journal read. + store.readJournal(); + const events = batches.flatMap(batch => batch.events).filter(event => event.correlationId === runId); + return { + runId, projectId: header.projectId, controlDomainId: header.controlDomainId, + status: run.status, stage: run.stage, slot: run.slot, needsOperator: run.needsOperator, + journalCommitSeq: store.commitSeq, + lifecycle: events.map(event => ({ eventId: event.eventId, commitSeq: event.commitSeq, recordedAt: event.recordedAt, payload: event.payload })), + pendingApprovals: store.listApprovals().filter(approval => approval.runId === runId && approval.status === "pending") + .map(approval => ({ approvalRequestId: approval.approvalRequestId, nodeInstanceId: approval.nodeInstanceId, deadline: approval.deadline })), + receiptIssued: batches.some(batch => batch.recordKind === "lifecycle-batch" && batch.receipt?.runId === runId), + checkpoint: diagnostic ? { available: true as const, updatedAt: diagnostic.state.updatedAt, runtimeStatus: diagnostic.state.status, stale, + ...(effect ? { effect } : {}) } : { available: false as const, reason: "provider has no durable checkpoint for this run" }, + }; + }); +} diff --git a/packages/taskflow-control/src/store/evidence-store.ts b/packages/taskflow-control/src/store/evidence-store.ts index 999665d4..04cd8b54 100644 --- a/packages/taskflow-control/src/store/evidence-store.ts +++ b/packages/taskflow-control/src/store/evidence-store.ts @@ -133,6 +133,22 @@ function validRef(ref: ArtifactRef): void { function sameRef(a: ArtifactRef, b: ArtifactRef): boolean { return canonicalJson(a) === canonicalJson(b); } +/** Resolve committed run artifacts without inventing Receipt metadata for live runs. + * Callers outside EvidenceStore must supply the validated owner ledger view. */ +export function evidenceRunArtifactRefs(view: EvidenceLedgerView, runId: string): readonly ArtifactRef[] { + if (Object.hasOwn(view.runs, runId)) return view.runs[runId].artifactRefs; + const scoped = view.events.filter(({ event }) => event.correlationId === runId); + if (!scoped.some(({ event }) => event.payload.kind === "run.snapshot" && event.payload.run.runId === runId + && event.payload.run.projectId === view.projectId && event.payload.run.controlDomainId === view.controlDomainId)) return []; + const refs: ArtifactRef[] = []; + for (const { event } of scoped) { + if (event.payload.kind !== "artifact.recorded" || event.payload.runId !== runId) continue; + const ref = event.payload.artifact; + if (!refs.some(candidate => sameRef(candidate, ref))) refs.push(ref); + } + return refs; +} + /** Logical compaction only. No method in this class deletes journal or blobs. */ export class EvidenceStore { readonly #options: EvidenceStoreOptions; @@ -391,7 +407,7 @@ export class EvidenceStore { return this.#disclose(actor, target, ref, (view) => { validRef(ref); const refs = target.kind === "run" - ? (Object.hasOwn(view.runs, target.id) ? view.runs[target.id].artifactRefs : []) + ? evidenceRunArtifactRefs(view, target.id) : (Object.hasOwn(view.commands, target.id) && view.commands[target.id].responseArtifactRef ? [view.commands[target.id].responseArtifactRef!] : []); if (!refs.some((candidate) => sameRef(candidate, ref))) throw new ControlError("TF_POLICY_DENIED", "artifact has no authorized ledger reference"); return this.#read(ref); diff --git a/packages/taskflow-control/src/store/journal.ts b/packages/taskflow-control/src/store/journal.ts index 5b2a2653..be847b22 100644 --- a/packages/taskflow-control/src/store/journal.ts +++ b/packages/taskflow-control/src/store/journal.ts @@ -3,8 +3,8 @@ import { createHash } from "node:crypto"; import { canonicalJson } from "taskflow-core/flowir/hash"; import { Type } from "typebox"; import { Value } from "typebox/value"; -import { ApprovalRequestSchema, CommandRecordSchema, ControlEventSchema, RunSnapshotSchema, ReceiptSchema, UuidSchema, Sha256HexSchema, ConcurrencyReservationSchema, BoundFragmentSchema, ExecutionOwnerSchema } from "../schema/index.ts"; -import type { CommandRecord, ControlEvent, Receipt } from "../schema/index.ts"; +import { ApprovalRequestSchema, CommandRecordSchema, ControlEventSchema, RunSnapshotSchema, ReceiptSchema, UuidSchema, Sha256HexSchema, ConcurrencyReservationSchema, BoundFragmentSchema, ExecutionOwnerSchema, ArtifactRefSchema, BuildInfoWireSchema, ReceiptAssuranceSchema } from "../schema/index.ts"; +import type { CommandRecord, ControlEvent, Receipt, ArtifactRef, BuildInfoWire, ReceiptAssurance } from "../schema/index.ts"; import type { ApprovalJournalState } from "../approval-service.ts"; const options = { additionalProperties: false }; @@ -28,15 +28,22 @@ const LegacyBatchSchema = Type.Object({ recordKind: Type.Literal("commit-batch"), commitSeq: Type.Integer({ minimum: 1 }), command: CommandRecordSchema, events: Type.Array(ControlEventSchema, { minItems: 1 }), }, options); +export const AdmissionDecisionSchema = Type.Object({ + reservationId: UuidSchema, projectId: UuidSchema, projectControlDomainId: UuidSchema, runId: UuidSchema, + status: Type.Union([Type.Literal("admitted"), Type.Literal("abandoned")]), decisionCommitSeq: Type.Integer({minimum:1}), runVersion: Type.Integer({minimum:0}), +}, options); +export const EvidenceMetadataSchema = Type.Object({runId:UuidSchema,buildInfo:BuildInfoWireSchema,assurance:ReceiptAssuranceSchema},options); const BatchSchema = Type.Object({ recordKind: Type.Literal("lifecycle-batch"), schemaVersion: Type.Literal(2), commitSeq: Type.Integer({ minimum: 1 }), previousHash: Sha256HexSchema, contentHash: Sha256HexSchema, command: Type.Optional(CommandRecordSchema), - events: Type.Array(ControlEventSchema, { minItems: 1 }), projection: Type.Optional(ProjectStateSchema), receipt: Type.Optional(ReceiptSchema), + events: Type.Array(ControlEventSchema, { minItems: 1 }), projection: Type.Optional(ProjectStateSchema), receipt: Type.Optional(ReceiptSchema), responseJson: Type.Optional(Type.String()), admission: Type.Optional(AdmissionDecisionSchema), receiptRef: Type.Optional(ArtifactRefSchema), manifestProofRef: Type.Optional(ArtifactRefSchema), evidenceMetadata: Type.Optional(EvidenceMetadataSchema), }, options); +export interface AdmissionBinding { reservationId: string; projectId: string; projectControlDomainId: string; runId: string } +export interface AdmissionDecision extends AdmissionBinding { status: "admitted" | "abandoned"; decisionCommitSeq: number; runVersion: number } export type LegacyJournalBatch = { recordKind: "commit-batch"; commitSeq: number; command: CommandRecord; events: ControlEvent[] }; export type LifecycleBatch = { recordKind: "lifecycle-batch"; schemaVersion: 2; commitSeq: number; previousHash: string; contentHash: string; - command?: CommandRecord; events: ControlEvent[]; projection?: ApprovalJournalState; receipt?: Receipt; + command?: CommandRecord; events: ControlEvent[]; projection?: ApprovalJournalState; receipt?: Receipt; responseJson?: string; admission?: AdmissionDecision; receiptRef?: ArtifactRef; manifestProofRef?: ArtifactRef; evidenceMetadata?: {runId:string;buildInfo:BuildInfoWire;assurance:ReceiptAssurance}; }; export type JournalBatch = LegacyJournalBatch | LifecycleBatch; export const JOURNAL_GENESIS = "0".repeat(64); @@ -44,7 +51,7 @@ export function journalHash(record: Omit | Legacy return createHash("sha256").update(canonicalJson(record)).digest("hex"); } export function validJournalBatch(value: unknown): value is JournalBatch { - if (Value.Check(LegacyBatchSchema, value)) return value.events.every((event) => event.schemaVersion === 1); + if (Value.Check(LegacyBatchSchema, value)) return true; if (!Value.Check(BatchSchema, value) || value.events.some((event) => event.schemaVersion !== 2)) return false; const { contentHash, ...body } = value; return contentHash === journalHash(body as Omit); diff --git a/packages/taskflow-control/src/store/project-lifecycle.ts b/packages/taskflow-control/src/store/project-lifecycle.ts new file mode 100644 index 00000000..8c3fabc0 --- /dev/null +++ b/packages/taskflow-control/src/store/project-lifecycle.ts @@ -0,0 +1,182 @@ +/** Explicit P3 identity maintenance and P10 forensic export. Registry hints do + * not participate in identity decisions. All callbacks are trusted host ports. */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { createHash } from "node:crypto"; +import { Value } from "typebox/value"; +import { ControlError } from "../errors.ts"; +import { ControlStoreHeaderSchema, type ControlStoreHeader } from "../schema/header.ts"; +import { type Receipt } from "../schema/evidence.ts"; +import { isTerminalRunStatus } from "../schema/run.ts"; +import { JOURNAL_GENESIS, validJournalBatch, journalHash, type JournalBatch } from "./journal.ts"; +import { acquireWriterLock, openControlStore, writeJsonAtomicHardened } from "./store.ts"; + +export type ProjectLifecycleOperation = "move-rebind" | "clone" | "adopt" | "export"; +export interface ProjectLifecycleAuthority { + /** Implement using a verified operator context and CURRENT project policy. + * Path arguments are trusted launcher/operator configuration, not wire body. */ + authorize(context: C, scope: { operation: ProjectLifecycleOperation; header: ControlStoreHeader; destination?: string }): Promise<{ principal: string; operator: boolean }> | { principal: string; operator: boolean }; +} +export interface ForensicFile { path: string; sha256: string; size: number; bytesBase64?: string } +export interface ProjectForensicExport { + exportKind: "readonly" | "lossy"; executePromise: false; + header: ControlStoreHeader; sourcePath: string; integrity: "verified" | "damaged"; + verificationScope: "captured-bytes-and-journal-structure"; diagnostics: string[]; files: ForensicFile[]; journal: JournalBatch[]; receipts: Receipt[]; +} +const digest = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex"); +function fail(message: string): never { throw new ControlError("TF_DURABILITY_FAILED", message, { recoveryAction: "operator", sideEffects: "none" }); } +function directory(input: string): string { + const stat = fs.lstatSync(input); + if (!stat.isDirectory() || stat.isSymbolicLink()) fail("project store must be a real directory"); + const canonical = fs.realpathSync(input); + if (process.getuid && stat.uid !== process.getuid()) fail("project store must belong to the current host user"); + return canonical; +} +function read(file: string): Buffer { + const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + const stat = fs.fstatSync(fd), linked = fs.lstatSync(file); + if (!stat.isFile() || linked.isSymbolicLink() || stat.dev !== linked.dev || stat.ino !== linked.ino) fail("forensic evidence is not an unchanged regular file"); + const bytes = fs.readFileSync(fd), after = fs.lstatSync(file); + if (after.dev !== stat.dev || after.ino !== stat.ino || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs) fail("evidence changed during read"); + return bytes; + } finally { fs.closeSync(fd); } +} +function headerAt(root: string): ControlStoreHeader { + let value: unknown; + try { value = JSON.parse(read(path.join(root, "header")).toString("utf8")); } + catch { fail("authoritative header is missing or unreadable; identity cannot be reconstructed"); } + if (!Value.Check(ControlStoreHeaderSchema, value)) fail("authoritative header is invalid"); + return value; +} +function sameHeader(a: ControlStoreHeader, b: ControlStoreHeader): boolean { return JSON.stringify(a) === JSON.stringify(b); } +async function authorize(authority: ProjectLifecycleAuthority, context: C, operation: ProjectLifecycleOperation, header: ControlStoreHeader, destination?: string) { + const result = await authority.authorize(context, { operation, header: structuredClone(header), ...(destination ? { destination } : {}) }); + if (!result || !result.principal?.trim() || result.operator !== true) throw new ControlError("TF_POLICY_DENIED", "verified current operator authority is required"); + return result.principal; +} +function collect(root: string): Map { + const result = new Map(); + const walk = (relative: string) => { + const file = path.join(root, relative); let stat: fs.Stats; + try { stat = fs.lstatSync(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; } + if (stat.isSymbolicLink()) fail("forensic export refuses symlink evidence"); + if (stat.isDirectory()) { for (const child of fs.readdirSync(file).sort()) walk(path.join(relative, child)); } + else if (stat.isFile()) result.set(relative.split(path.sep).join("/"), read(file)); + else fail("forensic export refuses special evidence files"); + }; + for (const name of ["header", "commit-seq.json", "journal", "receipts", "artifacts"]) walk(name); + return result; +} +function fingerprint(files: Map): string { return JSON.stringify([...files].map(([name, bytes]) => [name, bytes.length, digest(bytes)])); } +function parseEvidence(root: string, header: ControlStoreHeader, files: Map, kind: "readonly" | "lossy"): ProjectForensicExport { + const diagnostics: string[] = [], journal: JournalBatch[] = [], receipts: Receipt[] = []; + let tip = JOURNAL_GENESIS, sequence = 0; + const commands = new Set(), admissions = new Set(), runVersions = new Map(); + for (const [name, bytes] of files) { + if (!name.startsWith("journal/")) continue; + if (name !== "journal/000001.jsonl") { diagnostics.push(`unsupported journal segment: ${name}`); continue; } + const lines = bytes.toString("utf8").split("\n"); + if (bytes.length && bytes.at(-1) !== 10) diagnostics.push("incomplete journal tail preserved"); + for (const [index, line] of lines.entries()) { + if (!line) continue; + let value: unknown; try { value = JSON.parse(line); } catch { diagnostics.push(`unreadable journal line ${index + 1}`); continue; } + if (!validJournalBatch(value)) { diagnostics.push(`invalid journal line ${index + 1}`); continue; } + const batch = value; + if (batch.commitSeq !== sequence + 1 || batch.events.some(e => e.projectId !== header.projectId || e.controlDomainId !== header.controlDomainId || e.commitSeq !== batch.commitSeq) + || batch.command && (batch.command.projectId !== header.projectId || batch.command.controlDomainId !== header.controlDomainId) + || batch.recordKind === "lifecycle-batch" && batch.previousHash !== tip) diagnostics.push(`journal binding or continuity mismatch at ${batch.commitSeq}`); + if (batch.command) { + if (commands.has(batch.command.commandId) || batch.command.firstCommitSeq !== batch.commitSeq || batch.command.lastCommitSeq !== batch.commitSeq) diagnostics.push("duplicate or invalid command sequence"); + commands.add(batch.command.commandId); + } + if (batch.events.some((e, index) => batch.command ? e.commandId !== batch.command.commandId || e.commandEventIndex !== index : e.commandId !== undefined)) diagnostics.push("event command binding mismatch"); + if (batch.recordKind === "lifecycle-batch") { + if (batch.command && (batch.responseJson === undefined || !batch.projection)) diagnostics.push("missing immutable command response"); + if (batch.responseJson !== undefined) { try { JSON.parse(batch.responseJson); if (!batch.command) diagnostics.push("orphan command response"); } catch { diagnostics.push("invalid immutable response"); } } + if (batch.projection) { + const p = batch.projection, run = p.run, prior = runVersions.get(run.runId); + if (run.projectId !== header.projectId || run.controlDomainId !== header.controlDomainId || p.approvals.some(a => a.runId !== run.runId) || p.outbox.some(o => o.runId !== run.runId) || prior !== undefined && run.runVersion !== prior + 1) diagnostics.push("projection binding/version mismatch"); + runVersions.set(run.runId, run.runVersion); + } + if (batch.admission) { const d = batch.admission; + if (d.projectId !== header.projectId || d.projectControlDomainId !== header.controlDomainId || d.decisionCommitSeq !== batch.commitSeq || admissions.has(d.reservationId)) diagnostics.push("admission binding mismatch"); + admissions.add(d.reservationId); + } + if (batch.receipt) { + if (batch.receipt.controlDomainId !== header.controlDomainId) diagnostics.push("receipt domain mismatch"); + receipts.push(batch.receipt); + for (const ref of [batch.receiptRef, batch.manifestProofRef]) { + if (!ref) { diagnostics.push("missing receipt evidence reference"); continue; } + const blob = files.get(`artifacts/sha256/${ref.digest.slice(0, 2)}/${ref.digest}`); + if (!blob || blob.length !== ref.size || digest(blob) !== ref.digest) diagnostics.push("receipt blob missing or corrupt"); + } + } + } + journal.push(batch); sequence = batch.commitSeq; tip = batch.recordKind === "lifecycle-batch" ? batch.contentHash : journalHash(batch); + } + } + for (const [name, bytes] of files) { + if (name.startsWith("artifacts/sha256/") && path.basename(name) !== digest(bytes)) diagnostics.push(`artifact digest mismatch: ${name}`); + } + const onDiskSeq = files.get("commit-seq.json"); + if (onDiskSeq) { try { const n = JSON.parse(onDiskSeq.toString("utf8")).commitSeq; if (!Number.isSafeInteger(n) || n > sequence) diagnostics.push("commit sequence projection exceeds valid journal"); } catch { diagnostics.push("invalid commit sequence projection"); } } + return { exportKind: kind, executePromise: false, header, sourcePath: root, integrity: diagnostics.length ? "damaged" : "verified", verificationScope: "captured-bytes-and-journal-structure", diagnostics, + files: [...files].map(([name, bytes]) => ({ path: name, sha256: digest(bytes), size: bytes.length, ...(kind === "readonly" ? { bytesBase64: bytes.toString("base64") } : {}) })), journal, receipts }; +} +/** Performs no source writes and does not call recovery/openControlStore. Damaged + * bytes remain available in readonly export; lossy export explicitly omits raw + * bytes. A concurrent change fails rather than claiming a coherent snapshot. */ +export async function exportProjectEvidence(storePath: string, kind: "readonly" | "lossy", context: C, authority: ProjectLifecycleAuthority): Promise { + if (kind !== "readonly" && kind !== "lossy") fail("unknown forensic export kind"); + const root = directory(storePath), identity = fs.statSync(root), header = headerAt(root); + const principal = await authorize(authority, context, "export", header); + const first = collect(root), result = parseEvidence(root, header, first, kind); + if (await authorize(authority, context, "export", header) !== principal) throw new ControlError("TF_AUTHORITY_REVOKED", "export authority changed"); + const second = collect(root), current = fs.statSync(root); + if (identity.dev !== current.dev || identity.ino !== current.ino || !sameHeader(header, headerAt(root)) || fingerprint(first) !== fingerprint(second)) fail("project evidence changed during forensic snapshot"); + return result; +} + +/** Same inode/device is necessary; a copied directory cannot adopt an identity + * using a caller's claim that the original is gone. Explicit adoption is only + * accepted for the same proven moved store until a stronger global fence exists. */ +export async function rebindMovedProject(storePath: string, context: C, authority: ProjectLifecycleAuthority, operation: "move-rebind" | "adopt" = "move-rebind"): Promise { + const root = directory(storePath), header = headerAt(root); + const principal = await authorize(authority, context, operation, header, root); + const release = acquireWriterLock(root); + try { + const original = read(path.join(root, "header")); + if (!sameHeader(header, headerAt(root))) fail("header changed during identity maintenance"); + const stat = fs.statSync(root), binding = header.directoryBinding; + if (binding.device !== String(stat.dev) || binding.inode !== String(stat.ino)) fail("copy/adopt exclusivity is unproven; create a fresh project and domain"); + if (binding.canonicalPath === root) return header; + try { fs.lstatSync(binding.canonicalPath); fail("old project binding still exists; identity cannot be rebound"); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const beforeFiles = collect(root), evidence = parseEvidence(root, header, beforeFiles, "readonly"); + if (evidence.integrity !== "verified") fail("damaged project evidence cannot authorize identity maintenance"); + const runs = new Map(evidence.journal.flatMap(batch => batch.recordKind === "lifecycle-batch" && batch.projection ? [[batch.projection.run.runId, batch.projection.run] as const] : [])); + for (const run of runs.values()) if (!isTerminalRunStatus(run.status) && !(run.status === "paused" && run.stage === "parked" && run.slot === "released" && run.requiresReadmission === true)) fail("active or ambiguous run prevents exclusive project rebind"); + if (await authorize(authority, context, operation, header, root) !== principal) throw new ControlError("TF_AUTHORITY_REVOKED", "identity maintenance authority changed"); + const current = fs.statSync(root); + if (current.dev !== stat.dev || current.ino !== stat.ino || !read(path.join(root, "header")).equals(original) || fingerprint(beforeFiles) !== fingerprint(collect(root))) fail("project binding or evidence changed before header publication"); + try { fs.lstatSync(binding.canonicalPath); fail("old project binding reappeared"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const updated = { ...header, directoryBinding: { ...binding, canonicalPath: root } }; + writeJsonAtomicHardened(path.join(root, "header"), updated); return updated; + } finally { release(); } +} + +/** Copies no authoritative history. A copied ledger stays untouched as evidence; + * destination must be a separate empty path and receives two fresh UUIDs. */ +export async function createProjectClone(sourcePath: string, destinationPath: string, context: C, authority: ProjectLifecycleAuthority): Promise { + const source = directory(sourcePath), header = headerAt(source); + const parent = directory(path.dirname(path.resolve(destinationPath))), destination = path.join(parent, path.basename(destinationPath)); + if (destination === source || destination.startsWith(source + path.sep)) fail("clone destination must be outside the source ledger"); + const principal = await authorize(authority, context, "clone", header, destination); + if (await authorize(authority, context, "clone", header, destination) !== principal) throw new ControlError("TF_AUTHORITY_REVOKED", "clone authority changed"); + if (!sameHeader(header, headerAt(source))) fail("source identity changed during clone authorization"); + // Exclusive mkdir prevents an existing copied ledger from being overwritten. + try { fs.mkdirSync(destination, { mode: 0o700 }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") fail("clone destination already exists; preserve it and choose a fresh directory"); throw error; } + const store = openControlStore(destination); + try { return store.header; } finally { store.close(); } +} diff --git a/packages/taskflow-control/src/store/store.ts b/packages/taskflow-control/src/store/store.ts index a58b9587..60802cde 100644 --- a/packages/taskflow-control/src/store/store.ts +++ b/packages/taskflow-control/src/store/store.ts @@ -1,19 +1,14 @@ /** - * Files-only ControlStore (beta.2 S3-minimum). - * - * Layout: header + commit-seq.json + journal/ + projections/ + empty - * commands/ + receipts/. Journal is the authority; projections are - * store-self only (submitted-command index). Single writer via exclusive - * lock file. Atomic writes use UUID temp + wx + fsync + rename, and fail - * closed if the destination is a symlink. - * - * Authorized command-result replay is not implemented. Caller principal and - * authorizationContextHash are request-supplied audit fields, not a live - * authorization boundary. Duplicate commands fail closed before persistence - * until the host can authorize disclosure of a prior durable result. - * - * P14 ADR Status stays Proposed. This is the S3 engine implementation gate. + * Authoritative per-project append journal. A lifetime OS writer lock excludes + * competing hosts; an async fence serializes evidence work with lifecycle CAS. + * V1 submit records stay readable. Chained V2 batches atomically bind run state, + * approvals/outboxes, commands and immutable responses, admission decisions, + * receipts and evidence metadata. Corrupt or incomplete bytes are preserved. + * Storage ports are trusted in-process APIs: live authorization belongs to the + * service/host before lookup and again before result disclosure. */ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { ArtifactRef, Receipt, BuildInfoWire, ReceiptAssurance } from "../schema/evidence.ts"; import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; @@ -30,12 +25,16 @@ import { type ControlStoreStatus, } from "../schema/index.ts"; +import { JOURNAL_GENESIS, journalHash, validJournalBatch, ProjectStateSchema, type JournalBatch, type LifecycleBatch, type AdmissionBinding, type AdmissionDecision } from "./journal.ts"; +import type { ApprovalRun, ApprovalJournalState, ApprovalMutation, ApprovalCommit, ApprovalJournalEvent, CommandBinding } from "../approval-service.ts"; +export type { AdmissionBinding, AdmissionDecision } from "./journal.ts"; export const CONTROL_CRASH_ENV = "TASKFLOW_CONTROL_CRASH_AT"; export type ControlCrashPoint = | "header-fsynced" | "journal-append" - | "projection-rebuild"; + | "projection-rebuild" + | "lifecycle-committed"; const LOCK_NAME = "writer.lock"; const HEADER_NAME = "header"; @@ -64,13 +63,6 @@ export interface ControlStoreSnapshot { status: ControlStoreStatus; } -interface JournalBatch { - recordKind: "commit-batch"; - commitSeq: number; - command: CommandRecord; - events: ControlEvent[]; -} - interface WriterLock { ownerId: string; pid: number; @@ -85,13 +77,26 @@ export class ControlStore { #closed = false; readonly #releaseWriter: () => void; #commandIndex = new Map(); - - constructor(storePath: string, header: ControlStoreHeader, commitSeq: number, commands: readonly CommandRecord[], releaseWriter: () => void) { + #states = new Map(); + #responses = new Map(); + #admissions = new Map(); + #tip = JOURNAL_GENESIS; + #writerProof: string; + #headerProof: string; + #writing = false; + #fence = new AsyncLocalStorage(); + #fenceOwner?: symbol; + #fenceQueue: Promise = Promise.resolve(); + + constructor(storePath: string, header: ControlStoreHeader, commitSeq: number, commands: readonly CommandRecord[], releaseWriter: () => void, batches: JournalBatch[] = []) { this.#releaseWriter = releaseWriter; this.storePath = storePath; + this.#writerProof = fs.readFileSync(path.join(storePath, LOCK_NAME), "utf8"); + this.#headerProof = fs.readFileSync(path.join(storePath, HEADER_NAME), "utf8"); this.#header = structuredClone(header); this.#commitSeq = commitSeq; for (const command of commands) this.#commandIndex.set(command.commandId, structuredClone(command)); + for (const batch of batches) this.#apply(batch); } get header(): ControlStoreHeader { @@ -118,11 +123,12 @@ export class ControlStore { } appendBatch(input: CommitBatchInput): { commitSeq: number; command: CommandRecord } { - this.#assertOpen(); + this.#assertOpen(); this.#assertFence(); if (this.#status === "fail-closed") { throw durabilityFailed("control store is fail-closed; refusing mutation"); } - if (input.command.kind !== "run.submit") { + try { this.readJournal(); } catch (error) { this.#status = "fail-closed"; throw durabilityFailed(String(error)); } + if (input.command.kind !== "run.submit") { throw durabilityFailed(`beta.2 store only accepts run.submit; got ${input.command.kind}`); } if (input.events.length === 0) { @@ -192,6 +198,7 @@ export class ControlStore { writeJsonAtomicHardened(commandIndexPath(this.storePath), Object.fromEntries(nextIndex)); this.#commandIndex = nextIndex; this.#commitSeq = nextSeq; + this.#apply(record); } catch (error) { this.#status = "fail-closed"; throw durabilityFailed(`control store persistence failed; close and reopen to recover: ${error instanceof Error ? error.message : String(error)}`); @@ -199,15 +206,172 @@ export class ControlStore { return { commitSeq: nextSeq, command: structuredClone(command) }; } + /** Trusted in-process storage ports; callers must authorize disclosure. */ + readRun(runId: string): ApprovalRun | undefined { this.readJournal(); return structuredClone(this.#states.get(runId)?.run); } + listRuns(): ApprovalRun[] { this.readJournal(); return [...this.#states.values()].map(s => structuredClone(s.run)); } + readRunState(runId: string): ApprovalJournalState | undefined { this.readJournal(); return structuredClone(this.#states.get(runId)); } + readApproval(id: string) { this.readJournal(); for (const s of this.#states.values()) { const request = s.approvals.find(a => a.approvalRequestId === id); if (request) return structuredClone(request); } } + listApprovals() { this.readJournal(); return [...this.#states.values()].flatMap(s => structuredClone(s.approvals)); } + readOutbox(runId: string) { this.readJournal(); return structuredClone(this.#states.get(runId)?.outbox ?? []); } + readCommittedCommand(binding: CommandBinding): { record: CommandRecord; responseJson: string } | undefined { + this.readJournal(); this.#assertIdentity(binding.projectId, binding.controlDomainId); + const record = this.#commandIndex.get(binding.commandId); if (!record) return; + if (record.callerPrincipal !== binding.callerPrincipal) throw new ControlError("TF_CROSS_PRINCIPAL_COMMAND", "command belongs to another principal"); + if (record.kind !== binding.kind || record.requestHash !== binding.requestHash) throw new ControlError("TF_IDEMPOTENCY_CONFLICT", "command identity has different request content"); + const saved = this.#responses.get(binding.commandId); + if (!saved) throw durabilityFailed("committed command has no immutable response"); + return structuredClone({ record: saved.record, responseJson: saved.responseJson }); + } + createRun(run: ApprovalRun, events: readonly ApprovalJournalEvent[] = [], result?: {command: CommandRecord; responseJson: string}): ApprovalRun { + this.#assertOpen(); if (this.#states.has(run.runId)) throw new ControlError("TF_IDEMPOTENCY_CONFLICT", "run already exists"); + const state = { run: structuredClone(run), approvals: [], outbox: [] }; + this.#commitLifecycle(state, events, result?.command, result?.responseJson); return structuredClone(run); + } + mutateRun(runId: string, expectedRunVersion: number, build: (state: ApprovalJournalState, meta: {commitSeq: number}) => ApprovalMutation, binding?: CommandBinding): ApprovalCommit { + this.#assertOpen(); + if (binding) { + const previous = this.readCommittedCommand(binding); + if (previous) { const saved = this.#responses.get(binding.commandId)!; return {...structuredClone(saved.state), commitSeq: saved.record.lastCommitSeq, responseJson: saved.responseJson}; } + } + if (this.#writing) throw durabilityFailed("nested project mutations are forbidden"); + const state = this.#states.get(runId); + if (!state || state.run.runVersion !== expectedRunVersion) throw new ControlError("TF_STALE_VERSION", "run version changed"); + this.#writing = true; + try { + const next = build(structuredClone(state), {commitSeq: this.#commitSeq + 1}); + if (next.run.runId !== runId || next.run.runVersion !== expectedRunVersion + 1) throw durabilityFailed("mutation must advance the same run exactly once"); + if (binding && next.command && (next.command.commandId !== binding.commandId || next.command.requestHash !== binding.requestHash || next.command.callerPrincipal !== binding.callerPrincipal || next.command.kind !== binding.kind)) throw durabilityFailed("mutation command differs from checked identity"); + this.#commitLifecycle({run:next.run, approvals:next.approvals, outbox:next.outbox}, next.events, next.command, next.responseJson); + return {...structuredClone({run:next.run, approvals:next.approvals, outbox:next.outbox}), commitSeq:this.#commitSeq, ...(next.responseJson !== undefined ? {responseJson:next.responseJson}: {})}; + } finally { this.#writing = false; } + } + readAdmission(binding: AdmissionBinding): AdmissionDecision | undefined { + this.readJournal(); this.#assertIdentity(binding.projectId, binding.projectControlDomainId); + const prior = this.#admissions.get(binding.reservationId); + if (prior && prior.runId !== binding.runId) throw durabilityFailed("reservation is bound to another run"); + if(prior) return structuredClone(prior); + const run=this.#states.get(binding.runId)?.run; + if(run?.reservationId===binding.reservationId&&run.projectAdmitCommitSeq) return {...binding,status:"admitted",decisionCommitSeq:run.projectAdmitCommitSeq,runVersion:run.runVersion}; + return undefined; + } + admitRun(binding: AdmissionBinding, expectedRunVersion: number): AdmissionDecision { return this.decideAdmission(binding, "admitted", expectedRunVersion); } + abandonAdmissionIfAbsent(binding: AdmissionBinding): AdmissionDecision { return this.decideAdmission(binding, "abandoned"); } + decideAdmission(binding: AdmissionBinding, status: "admitted" | "abandoned", expectedRunVersion?: number): AdmissionDecision { + const prior = this.readAdmission(binding); if (prior) return prior; + const old = this.#states.get(binding.runId); + // Upgrade pre-existing admission facts before allowing an abandonment decision. + if (old?.run.reservationId === binding.reservationId && old.run.projectAdmitCommitSeq) return { ...binding, status:"admitted", decisionCommitSeq:old.run.projectAdmitCommitSeq, runVersion:old.run.runVersion }; + if (status === "admitted" && (!old || old.run.status !== "running" || !["linked","queued"].includes(old.run.stage) || old.run.requiresReadmission || old.run.runVersion !== expectedRunVersion)) throw new ControlError("TF_STALE_VERSION", "admission run version changed"); + const decision: AdmissionDecision = {...binding, status, decisionCommitSeq:this.#commitSeq+1, runVersion: status === "admitted" ? old!.run.runVersion+1 : old?.run.runVersion ?? 0}; + const projection = status === "admitted" ? {...structuredClone(old!), run:{...old!.run, status:"running" as const, stage:"admitted" as const, slot:"reserved" as const, reservationId:binding.reservationId, projectAdmitCommitSeq:decision.decisionCommitSeq, runVersion:decision.runVersion}} : undefined; + this.#commitLifecycle(projection, [], undefined, undefined, decision); return structuredClone(decision); + } + get journalTip(): {commitSeq:number; hash:string} { this.#assertOpen(); return {commitSeq:this.#commitSeq,hash:this.#tip}; } + readJournal(): JournalBatch[] { + this.#assertOpen(); + const batches=readJournalBatches(journalPath(this.storePath)); let tip=JOURNAL_GENESIS,seq=0; + for (const b of batches) { + if (b.commitSeq !== ++seq || b.events.some(e=>e.projectId!==this.#header.projectId||e.controlDomainId!==this.#header.controlDomainId) || b.recordKind==="lifecycle-batch" && b.previousHash!==tip) throw durabilityFailed("journal changed or lost identity"); + tip=b.recordKind==="lifecycle-batch"?b.contentHash:journalHash(b); + } + if(seq!==this.#commitSeq||tip!==this.#tip) throw durabilityFailed("journal differs from held writer state"); + const file=journalPath(this.storePath); if(fs.existsSync(file)) {const bytes=fs.readFileSync(file); if(bytes.length&&bytes.at(-1)!==10) throw durabilityFailed("journal has torn trailing evidence");} + return structuredClone(batches); + } + withWriter(operation:()=>Promise):Promise { + if(this.#fenceOwner && this.#fence.getStore()===this.#fenceOwner) return operation(); + const task=this.#fenceQueue.then(async()=>{ + this.#assertOpen(); const owner=Symbol("project-writer"); this.#fenceOwner=owner; + try { return await this.#fence.run(owner,operation); } finally { this.#fenceOwner=undefined; } + }); this.#fenceQueue=task.catch(()=>{}); return task; + } + recordRunEvidence(runId:string, metadata:{buildInfo:BuildInfoWire;assurance:ReceiptAssurance}):void { + const records=this.readJournal(); + const prior=records.find(b=>b.recordKind==="lifecycle-batch"&&b.evidenceMetadata?.runId===runId) as LifecycleBatch|undefined; + if(prior) {if(JSON.stringify(prior.evidenceMetadata)!==JSON.stringify({runId,...metadata})) throw durabilityFailed("run evidence metadata is immutable"); return;} + const old=this.#states.get(runId); if(!old) throw durabilityFailed("run does not exist"); + const next={...structuredClone(old),run:{...old.run,runVersion:old.run.runVersion+1}}; + this.#commitLifecycle(next,[],undefined,undefined,undefined,{evidenceMetadata:{runId,...metadata}}); + } + appendReceipt(runId:string, evidence:{receipt:Receipt;receiptRef:ArtifactRef;manifestProofRef:ArtifactRef}, expectedBatchTip:{commitSeq:number;hash:string}):void { + this.#checkTip(expectedBatchTip); + if(this.readJournal().some(b=>b.recordKind==="lifecycle-batch"&&b.receipt?.runId===runId)) throw new ControlError("TF_IDEMPOTENCY_CONFLICT","receipt already issued"); + const old=this.#states.get(runId); if(!old||evidence.receipt.runId!==runId||!["completed","failed","blocked","cancelled"].includes(old.run.status)) throw durabilityFailed("receipt requires same terminal run"); + const next={...structuredClone(old),run:{...old.run,runVersion:old.run.runVersion+1}}; + this.#commitLifecycle(next,[{eventId:crypto.randomUUID(),recordedAt:Date.now(),payload:{kind:"receipt.issued",receipt:evidence.receipt}}],undefined,undefined,undefined,evidence); + } + appendCheckpoint(throughCommitSeq:number, expectedBatchTip:{commitSeq:number;hash:string}):void { + this.#checkTip(expectedBatchTip); + const run=[...this.#states.values()][0]; if(!run||throughCommitSeq<1||throughCommitSeq>this.#commitSeq) throw durabilityFailed("invalid compaction checkpoint"); + const next={...structuredClone(run),run:{...run.run,runVersion:run.run.runVersion+1}}; + this.#commitLifecycle(next,[{eventId:crypto.randomUUID(),recordedAt:Date.now(),payload:{kind:"compaction.checkpoint",throughCommitSeq}}]); + } + #checkTip(tip:{commitSeq:number;hash:string}) { this.readJournal(); if(tip.commitSeq!==this.#commitSeq||tip.hash!==this.#tip) throw new ControlError("TF_STALE_VERSION","journal tip changed"); } + + #assertIdentity(projectId: string, controlDomainId: string) { + if (projectId !== this.#header.projectId || controlDomainId !== this.#header.controlDomainId) throw durabilityFailed("project/domain differs from authoritative store header"); + } + #apply(batch: JournalBatch) { + this.#tip = batch.recordKind === "lifecycle-batch" ? batch.contentHash : journalHash(batch); + if (batch.command) this.#commandIndex.set(batch.command.commandId, structuredClone(batch.command)); + if (batch.recordKind !== "lifecycle-batch") return; + if (batch.projection) this.#states.set(batch.projection.run.runId, structuredClone(batch.projection)); + if (batch.admission) this.#admissions.set(batch.admission.reservationId, structuredClone(batch.admission)); + if (batch.command && batch.responseJson !== undefined && batch.projection) this.#responses.set(batch.command.commandId, {record:structuredClone(batch.command), responseJson:batch.responseJson, state:structuredClone(batch.projection)}); + } + #commitLifecycle(projection: ApprovalJournalState | undefined, facts: readonly ApprovalJournalEvent[], command?: CommandRecord, responseJson?: string, admission?: AdmissionDecision, evidence?: Pick) { + this.#assertOpen(); this.#assertFence(); this.readJournal(); if (this.#status === "fail-closed") throw durabilityFailed("store is fail-closed"); + if (projection) { + this.#assertIdentity(projection.run.projectId, projection.run.controlDomainId); + if (!Value.Check(ProjectStateSchema, projection) || projection.approvals.some(a => a.runId !== projection.run.runId) || projection.outbox.some(o => o.runId !== projection.run.runId)) throw durabilityFailed("invalid project projection"); + } + const seq = this.#commitSeq + 1; + if(projection) { + const previous=this.#states.get(projection.run.runId); + for(const intent of projection.outbox) { + if(intent.kind!=="approval.readmission" || previous?.outbox.some(old=>old.intentId===intent.intentId)) continue; + const r=intent.reservation, run=projection.run, winner=this.#admissions.get(r.reservationId); + if(winner || admission || r.projectId!==run.projectId || r.projectControlDomainId!==run.controlDomainId || r.runId!==run.runId || run.reservationId!==r.reservationId || intent.projectAdmitCommitSeq!==seq || run.projectAdmitCommitSeq!==seq || run.stage!=="admitted") throw new ControlError("TF_STALE_VERSION","readmission lost admission arbitration or binding changed"); + admission={reservationId:r.reservationId,projectId:r.projectId,projectControlDomainId:r.projectControlDomainId,runId:r.runId,status:"admitted",decisionCommitSeq:seq,runVersion:run.runVersion}; + } + } + if (command) { + this.#assertIdentity(command.projectId, command.controlDomainId); + if (this.#commandIndex.has(command.commandId)) throw new ControlError("TF_IDEMPOTENCY_CONFLICT", "command already exists"); + command = {...structuredClone(command), firstCommitSeq:seq, lastCommitSeq:seq}; + if (responseJson === undefined || !projection) throw durabilityFailed("lifecycle command requires immutable response and projection"); + try { JSON.parse(responseJson); } catch { throw durabilityFailed("invalid command response JSON"); } + } else if (responseJson !== undefined) throw durabilityFailed("response requires a command"); + const id = crypto.randomUUID(), runId = projection?.run.runId ?? admission!.runId; + const entries: ApprovalJournalEvent[] = facts.length ? structuredClone([...facts]) : [{eventId:id, recordedAt:Date.now(), payload: projection ? {kind:"run.snapshot", run:projection.run} : {kind:"reconcile.started", attempt:1}}]; + const events: ControlEvent[] = entries.map((fact,index) => ({...fact, schemaVersion:2, projectId:this.#header.projectId, controlDomainId:this.#header.controlDomainId, streamId:runId, streamSeq:seq, commitSeq:seq, causationId:command?.commandId ?? id, correlationId:runId, ...(command ? {commandId:command.commandId,commandEventIndex:index}: {})})); + const body: Omit = {recordKind:"lifecycle-batch", schemaVersion:2, commitSeq:seq, previousHash:this.#tip, events, ...(projection ? {projection:structuredClone(projection)}:{}), ...(command ? {command}:{}), ...(responseJson !== undefined ? {responseJson}:{}), ...(admission ? {admission}: {}), ...evidence}; + const batch: LifecycleBatch = {...body, contentHash:journalHash(body)}; + if (!validJournalBatch(batch)) throw durabilityFailed("lifecycle batch schema invalid"); + try { + crashDuringJournalAppend(journalPath(this.storePath)); appendJsonLineDurable(journalPath(this.storePath), batch); + maybeCrash("lifecycle-committed"); + writeJsonAtomicHardened(commitSeqPath(this.storePath), {commitSeq:seq}); + this.#apply(batch); this.#commitSeq = seq; + } catch (e) { this.#status = "fail-closed"; throw durabilityFailed(`lifecycle persistence failed: ${String(e)}`); } + } + close(): void { if (this.#closed) return; this.#closed = true; this.#releaseWriter(); } - #assertOpen(): void { - if (this.#closed) throw durabilityFailed("control store is closed"); - } + #assertFence():void { if(this.#fenceOwner && this.#fence.getStore()!==this.#fenceOwner) throw new ControlError("TF_STALE_VERSION","project writer is held by another operation"); } + #assertOpen(): void { + if (this.#closed) throw durabilityFailed("control store is closed"); + const st=fs.lstatSync(this.storePath), binding=this.#header.directoryBinding; + for(const child of [LOCK_NAME,HEADER_NAME,JOURNAL_DIR,PROJECTIONS_DIR,COMMANDS_DIR,RECEIPTS_DIR]) assertNotSymlink(path.join(this.storePath,child)); + if(fs.readFileSync(path.join(this.storePath,HEADER_NAME),"utf8")!==this.#headerProof) throw durabilityFailed("durable project header changed"); + if(fs.readFileSync(path.join(this.storePath,LOCK_NAME),"utf8")!==this.#writerProof) throw durabilityFailed("project writer ownership changed"); + if(st.isSymbolicLink()||String(st.dev)!==binding.device||String(st.ino)!==binding.inode||fs.realpathSync(this.storePath)!==binding.canonicalPath) throw durabilityFailed("store directory identity changed"); + } + } export function openControlStore(storePath: string, options: OpenControlStoreOptions = {}): ControlStore { @@ -241,14 +405,16 @@ export function openControlStore(storePath: string, options: OpenControlStoreOpt // Recovery may rewrite projections, so verify the authoritative binding // first. A copied/moved/replaced store is not proof of the same project. // Never reset UUIDs or rewrite historical evidence to make it attach. - const binding = existingHeader.directoryBinding; + if (options.projectId && options.projectId !== existingHeader.projectId || options.controlDomainId && options.controlDomainId !== existingHeader.controlDomainId) throw durabilityFailed("requested identity differs from existing store"); + const binding = existingHeader.directoryBinding; const stat = fs.statSync(resolved); if (binding.canonicalPath !== fs.realpathSync(resolved) || binding.device !== String(stat.dev) || binding.inode !== String(stat.ino)) { throw durabilityFailed("control store directory identity changed; explicit verified rebind or a new project store is required"); } - const recovered = recoverFromJournal(resolved, existingHeader); - return new ControlStore(resolved, existingHeader, recovered.commitSeq, recovered.commands, releaseWriter); + for (const child of [JOURNAL_DIR,PROJECTIONS_DIR,COMMANDS_DIR,RECEIPTS_DIR]) assertNotSymlink(path.join(resolved,child)); + const recovered = recoverFromJournal(resolved, existingHeader); + return new ControlStore(resolved, existingHeader, recovered.commitSeq, recovered.commands, releaseWriter, recovered.batches); } catch (error) { releaseWriter(); throw error; @@ -288,40 +454,39 @@ function readHeader(storePath: string): ControlStoreHeader | undefined { function recoverFromJournal( storePath: string, header: ControlStoreHeader, -): { commitSeq: number; commands: CommandRecord[] } { - const batches = readJournalBatches(journalPath(storePath)); - const commands: CommandRecord[] = []; - const commandIds = new Set(); - let commitSeq = 0; - for (const batch of batches) { - if (batch.recordKind !== "commit-batch") { - throw durabilityFailed("journal contains an unknown record kind"); - } - if (batch.commitSeq !== commitSeq + 1) { - throw durabilityFailed(`journal commitSeq gap: expected ${commitSeq + 1}, got ${batch.commitSeq}`); - } - if ( - batch.command.projectId !== header.projectId - || batch.command.controlDomainId !== header.controlDomainId - ) { - throw durabilityFailed("journal command identity does not match store header"); - } - if (commandIds.has(batch.command.commandId) - || batch.command.firstCommitSeq !== batch.commitSeq - || batch.command.lastCommitSeq !== batch.commitSeq) { - throw durabilityFailed("journal command has duplicate identity or inconsistent commit sequence"); - } - for (const [index, event] of batch.events.entries()) { - if (event.projectId !== header.projectId || event.controlDomainId !== header.controlDomainId - || event.commitSeq !== batch.commitSeq || event.commandId !== batch.command.commandId - || event.commandEventIndex !== index) { - throw durabilityFailed("journal event identity or sequence does not match its batch"); - } - } - commandIds.add(batch.command.commandId); - commands.push(batch.command); - commitSeq = batch.commitSeq; - } +): { commitSeq: number; commands: CommandRecord[]; batches: JournalBatch[] } { + const batches = readJournalBatches(journalPath(storePath)); + const commands: CommandRecord[] = [], commandIds = new Set(); + const runs = new Map(), admissions = new Set(); + let commitSeq = 0, tip = JOURNAL_GENESIS; + for (const batch of batches) { + if (batch.commitSeq !== commitSeq + 1) throw durabilityFailed("journal sequence gap"); + if (batch.command) { + const c = batch.command; + if (c.projectId !== header.projectId || c.controlDomainId !== header.controlDomainId || commandIds.has(c.commandId) || c.firstCommitSeq !== batch.commitSeq || c.lastCommitSeq !== batch.commitSeq) throw durabilityFailed("journal command binding/sequence invalid"); + commandIds.add(c.commandId); commands.push(c); + } + for (const [index,event] of batch.events.entries()) { + if (event.projectId !== header.projectId || event.controlDomainId !== header.controlDomainId || event.commitSeq !== batch.commitSeq || (batch.command && (event.commandId !== batch.command.commandId || event.commandEventIndex !== index)) || (!batch.command && event.commandId !== undefined)) throw durabilityFailed("journal event binding/sequence invalid"); + } + if (batch.recordKind === "lifecycle-batch") { + if (batch.previousHash !== tip) throw durabilityFailed("journal hash chain mismatch"); + if (batch.command && (batch.responseJson === undefined || !batch.projection)) throw durabilityFailed("missing immutable command response"); + if (batch.responseJson !== undefined) { if (!batch.command) throw durabilityFailed("orphan command response"); try { JSON.parse(batch.responseJson); } catch { throw durabilityFailed("invalid immutable response"); } } + if (batch.projection) { + const state = batch.projection, run = state.run, old = runs.get(run.runId); + if (run.projectId !== header.projectId || run.controlDomainId !== header.controlDomainId || state.approvals.some(a => a.runId !== run.runId) || state.outbox.some(o => o.runId !== run.runId) || (old && run.runVersion !== old.run.runVersion + 1)) throw durabilityFailed("journal projection binding/version invalid"); + runs.set(run.runId,state); + } + if (batch.admission) { + const d = batch.admission; + if (d.projectId !== header.projectId || d.projectControlDomainId !== header.controlDomainId || d.decisionCommitSeq !== batch.commitSeq || admissions.has(d.reservationId)) throw durabilityFailed("admission binding invalid"); + admissions.add(d.reservationId); + } + tip = batch.contentHash; + } else tip = journalHash(batch); + commitSeq = batch.commitSeq; + } const onDiskSeq = readCommitSeqFile(storePath); if (onDiskSeq !== undefined && onDiskSeq > commitSeq) { @@ -331,7 +496,7 @@ function recoverFromJournal( } // Validate the complete authoritative state before any recovery write. A // torn tail is recoverable only when the preceding ledger is consistent. - truncateTornJournal(journalPath(storePath)); + // Damaged/torn evidence is preserved; recovery never rewrites journal bytes. ensureDirectory(path.join(storePath, JOURNAL_DIR)); ensureDirectory(path.join(storePath, PROJECTIONS_DIR)); ensureDirectory(path.join(storePath, COMMANDS_DIR)); @@ -341,7 +506,7 @@ function recoverFromJournal( commandIndexPath(storePath), Object.fromEntries(commands.map((command) => [command.commandId, command])), ); - return { commitSeq, commands }; + return { commitSeq, commands, batches }; } function readCommitSeqFile(storePath: string): number | undefined { @@ -366,9 +531,10 @@ function readJournalBatches(filePath: string): JournalBatch[] { if (!fs.existsSync(filePath)) return []; const raw = fs.readFileSync(filePath); const text = raw.toString("utf8"); - const lines = text.split("\n"); + if (raw.length && raw[raw.length-1] !== 10) throw durabilityFailed("incomplete journal tail; preserve evidence for explicit recovery"); + const lines = text.split("\n"); const complete = text.endsWith("\n") ? lines.slice(0, -1) : lines.slice(0, -1); - // Torn tail (no terminating newline on last line) is discarded — old-complete. + // Every authoritative line must be complete; damaged bytes remain untouched. const batches: JournalBatch[] = []; for (const line of complete) { if (!line) continue; @@ -378,36 +544,14 @@ function readJournalBatches(filePath: string): JournalBatch[] { } catch { throw durabilityFailed("journal contains a complete but unreadable line"); } - if (!parsed || typeof parsed !== "object" - || !("recordKind" in parsed) || parsed.recordKind !== "commit-batch" - || !("commitSeq" in parsed) || !Number.isSafeInteger(parsed.commitSeq) - || !("command" in parsed) || !Value.Check(CommandRecordSchema, parsed.command) - || !("events" in parsed) || !Array.isArray(parsed.events) || parsed.events.length === 0 - || !parsed.events.every((event: unknown) => Value.Check(ControlEventSchema, event))) { - throw durabilityFailed("journal contains a malformed commit batch"); - } + if (!validJournalBatch(parsed)) throw durabilityFailed("journal contains a malformed commit batch"); batches.push(parsed as JournalBatch); } return batches; } -function truncateTornJournal(filePath: string): void { - if (!fs.existsSync(filePath)) return; - assertNotSymlink(filePath); - const raw = fs.readFileSync(filePath); - if (raw.length === 0 || raw[raw.length - 1] === 0x0a) return; - const lastNewline = raw.lastIndexOf(0x0a); - const fd = fs.openSync(filePath, "r+"); - try { - fs.ftruncateSync(fd, lastNewline === -1 ? 0 : lastNewline + 1); - fs.fsyncSync(fd); - } finally { - fs.closeSync(fd); - } -} - /** Publish a fully written owner atomically; a visible lock is never half-written. */ -function acquireWriterLock(storePath: string): () => void { +export function acquireWriterLock(storePath: string): () => void { const lockPath = path.join(storePath, LOCK_NAME); const owner: WriterLock = { ownerId: crypto.randomUUID(), pid: process.pid, acquiredAt: Date.now() }; const contents = JSON.stringify(owner); @@ -560,19 +704,7 @@ function appendJsonLineDurable(filePath: string, record: unknown): void { assertNotSymlink(filePath); ensureDirectory(path.dirname(filePath)); const existed = fs.existsSync(filePath); - if (existed) { - const raw = fs.readFileSync(filePath); - if (raw.length > 0 && raw[raw.length - 1] !== 0x0a) { - const lastNewline = raw.lastIndexOf(0x0a); - const repairFd = fs.openSync(filePath, "r+"); - try { - fs.ftruncateSync(repairFd, lastNewline === -1 ? 0 : lastNewline + 1); - fs.fsyncSync(repairFd); - } finally { - fs.closeSync(repairFd); - } - } - } + if (existed) { const raw=fs.readFileSync(filePath); if(raw.length&&raw.at(-1)!==10) throw durabilityFailed("refusing append after incomplete journal tail"); } const fd = fs.openSync(filePath, "a", 0o600); try { const data = Buffer.from(`${JSON.stringify(record)}\n`); @@ -598,16 +730,22 @@ function assertNotSymlink(filePath: string): void { } function ensureDirectory(directory: string): void { - fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + assertNotSymlink(directory); + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + assertNotSymlink(directory); } function fsyncDirectory(directory: string): void { try { const fd = fs.openSync(directory, "r"); try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } - } catch { - /* some filesystems refuse directory fsync */ - } + } catch (error) { + const code=(error as NodeJS.ErrnoException).code; + // Only explicit platform lack of directory-fsync support is tolerated. + // I/O, capacity and permission failures must never become a successful commit. + if(code === "EINVAL" || code === "ENOTSUP" || process.platform === "win32" && (code === "EISDIR" || code === "EPERM")) return; + throw error; + } } function durabilityFailed(message: string): ControlError { diff --git a/packages/taskflow-control/src/uds.ts b/packages/taskflow-control/src/uds.ts index 8a365588..d85bb74f 100644 --- a/packages/taskflow-control/src/uds.ts +++ b/packages/taskflow-control/src/uds.ts @@ -114,9 +114,12 @@ export interface UdsServerOptions { /** Maximum UTF-8 bytes per frame, excluding newline (default 16 MiB). */ maxFrameBytes?: number; /** Dispatch one RPC after a successful per-connection hello. */ - handleRpc: (method: string, params: unknown, fencingEpoch: number) => Promise; + handleRpc: (method: string, params: unknown, fencingEpoch: number, connection: UdsConnection) => Promise; + onDisconnect?: (connection: UdsConnection) => void; } +export interface UdsConnection { readonly token: object; readonly closed: boolean; readonly hello?: NegotiationHandshake; } + export interface UdsServer { readonly endpointPath: string; close(): Promise; @@ -172,9 +175,12 @@ function handleServerConnection(socket: net.Socket, options: UdsServerOptions): // Per-connection hello gate (P4): the first frame must be a hello. const gate = createHelloGate(options.serverHello, { requiredFeatures: options.requiredFeatures }); let greeted = false; + let inFlight = 0; const maxFrameBytes = frameLimit(options.maxFrameBytes); const reader = frameReader(maxFrameBytes); let destroyed = false; + let clientHello: NegotiationHandshake | undefined; + const connection: UdsConnection = Object.freeze({ token: Object.freeze({}), get closed() { return destroyed; }, get hello() { return clientHello; } }); const helloTimer = setTimeout(() => { destroyed = true; reader.clear(); @@ -221,6 +227,7 @@ function handleServerConnection(socket: net.Socket, options: UdsServerOptions): socket.on("close", () => { clearTimeout(helloTimer); destroyed = true; + try { options.onDisconnect?.(connection); } catch { /* isolate trusted cleanup failure to this connection */ } reader.clear(); }); socket.on("error", () => { @@ -244,6 +251,7 @@ function handleServerConnection(socket: net.Socket, options: UdsServerOptions): } const fencingEpoch = options.getFencingEpoch(); greeted = true; + clientHello = raw.hello as NegotiationHandshake; clearTimeout(helloTimer); send({ type: "hello-ack", @@ -279,12 +287,16 @@ function handleServerConnection(socket: net.Socket, options: UdsServerOptions): failClosed({ type: "rpc-result", id, ok: false, error: errorToEnvelope(protocolError("rpc frame requires a non-negative safe integer fencingEpoch")) }); return; } + if (inFlight >= 64) { + failClosed({type:"rpc-result",id,ok:false,error:errorToEnvelope(protocolError("too many concurrent RPCs on one control channel"))}); return; + } + inFlight++; try { - const result = await options.handleRpc(method, raw.params, fencingEpoch); + const result = await options.handleRpc(method, raw.params, fencingEpoch, connection); send({ type: "rpc-result", id, ok: true, result }); } catch (error) { send({ type: "rpc-result", id, ok: false, error: errorToEnvelope(error) }); - } + } finally { inFlight--; } return; } failClosed({ diff --git a/packages/taskflow-control/src/web-console.ts b/packages/taskflow-control/src/web-console.ts index a31ae71e..d0c39d7d 100644 --- a/packages/taskflow-control/src/web-console.ts +++ b/packages/taskflow-control/src/web-console.ts @@ -12,7 +12,7 @@ import { UuidSchema } from "./schema/common.ts"; import { ControlError } from "./errors.ts"; import { CONSOLE_HTML, CONSOLE_JS, CONSOLE_CSS } from "./web-console/view.ts"; -export type WebConsoleOperation = "control.status" | "projects.list" | "runs.list" | "runs.get" | "approvals.list" | "approval.decide" | "receipts.get" | "evidence.why"; +export type WebConsoleOperation = "control.status" | "projects.list" | "runs.list" | "runs.get" | "approvals.list" | "approval.decide" | "approvals.stageEdit" | "receipts.get" | "evidence.why"; export interface WebConsoleProject { projectId: string; controlDomainId: string; @@ -50,6 +50,8 @@ export interface WebConsoleOptions { bootstrapTtlMs?: number; /** Owner enables only after its output-edit ArtifactRef contract is wired. */ approvalEditWithArtifactRef?: boolean; + /** Owner supports staging output text as a same-run artifact. */ + approvalOutputEdit?: boolean; } export interface WebConsoleServer { readonly url: string; @@ -117,7 +119,7 @@ export async function startWebConsole(options: WebConsoleOptions): Promise(); const sockets = new Set(); - const features = { approvalEditWithArtifactRef: options.approvalEditWithArtifactRef === true }; + const features = { approvalEditWithArtifactRef: options.approvalEditWithArtifactRef === true, approvalOutputEdit: options.approvalOutputEdit === true }; const server = http.createServer((request, response) => { void handle(request, response); }); server.requestTimeout = 10_000; @@ -224,12 +226,20 @@ export async function startWebConsole(options: WebConsoleOptions): Promise { const g = state.generation; const peers = Array.from(buttons.querySelectorAll('button')); peers.forEach(x => x.disabled = true); notice('Submitting ' + decision + '…'); try { - const editArtifactRef = decision === 'edit' ? JSON.parse(editRef.value) : undefined; - const key = [project.projectId, a.approvalRequestId, a.expectedRunVersion, decision, JSON.stringify(editArtifactRef)].join(':'); + let editArtifactRef; let expectedRunVersion = a.expectedRunVersion; + if (decision === 'edit' && state.features.approvalOutputEdit) { + if (!editRef.value.trim()) throw new Error('Enter revised output text before choosing Edit.'); + if (!preparedEdit || preparedEdit.content !== editRef.value) { + const staged = await api(projectUrl(project, '/runs/' + encodeURIComponent(a.runId) + '/approval-output'), { content: editRef.value }); + const fresh = await api(projectUrl(project, '/approvals')); + const current = fresh.result.find(item => item.approvalRequestId === a.approvalRequestId); + if (!current || current.status !== 'pending' || current.expectedRunVersion !== a.expectedRunVersion + 1) { const error = new Error('Approval changed while staging the edit.'); error.code = 'TF_STALE_VERSION'; throw error; } + preparedEdit = { content: editRef.value, artifact: staged.result, version: current.expectedRunVersion }; + } + editArtifactRef = preparedEdit.artifact; expectedRunVersion = preparedEdit.version; + } else if (decision === 'edit') editArtifactRef = JSON.parse(editRef.value); + const key = [project.projectId, a.approvalRequestId, expectedRunVersion, decision, JSON.stringify(editArtifactRef)].join(':'); if (!state.commands.has(key)) state.commands.set(key, crypto.randomUUID()); - const body = { commandId: state.commands.get(key), runId: a.runId, expectedRunVersion: a.expectedRunVersion, decision, ...(editArtifactRef === undefined ? {} : { editArtifactRef }) }; + const body = { commandId: state.commands.get(key), runId: a.runId, expectedRunVersion, decision, ...(editArtifactRef === undefined ? {} : { editArtifactRef, editKind: 'output' }) }; await api(projectUrl(project, '/approvals/' + encodeURIComponent(a.approvalRequestId) + '/decisions'), body); if (g !== state.generation) return; await refreshProject(); notice('Decision committed by control.'); } catch (e) { diff --git a/packages/taskflow-control/test/admission-service.test.ts b/packages/taskflow-control/test/admission-service.test.ts new file mode 100644 index 00000000..a67f7c7b --- /dev/null +++ b/packages/taskflow-control/test/admission-service.test.ts @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { after, test } from "node:test"; +import { openControlStore } from "../src/store/store.ts"; +import { openCoordinatorStore } from "../src/store/coordinator-store.ts"; +import { coordinatorOptions, withProject, type AdmissionWorkerConfig } from "./fixtures/admission-worker.ts"; + +const roots: string[] = []; +after(() => { for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); }); +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +async function setup(ttlMs = 250): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-real-admission-")); roots.push(root); + const projectPath = path.join(root, "project"), coordinatorPath = path.join(root, "coordinator"); + const store = openControlStore(projectPath), header = store.header, runId = randomUUID(); + store.createRun({ projectId: header.projectId, controlDomainId: header.controlDomainId, runId, runVersion: 0, status: "running", stage: "queued", slot: "none", needsOperator: false, + boundPlanHash: `plan:${"1".repeat(64)}`, policyHash: "2".repeat(64), authorityEpoch: 1 }); + store.close(); + const config: AdmissionWorkerConfig = { projectPath, coordinatorPath, mode: "recover", reservation: { + reservationId: randomUUID(), projectId: header.projectId, projectControlDomainId: header.controlDomainId, + runId, state: "reserved", slots: 1, coordinatorEpoch: 1, reservedExpiresAt: 0, + } }; + const coordinator = await openCoordinatorStore(coordinatorPath, coordinatorOptions(config)); + try { config.reservation = (await coordinator.reserve({ ...config.reservation, ttlMs }, {})).reservation; } + finally { coordinator.close(); } + return config; +} +const worker = fileURLToPath(new URL("./fixtures/admission-worker.ts", import.meta.url)); +function start(config: AdmissionWorkerConfig) { + const child = spawn(process.execPath, ["--conditions=development", "--experimental-strip-types", worker, JSON.stringify(config)], { stdio: ["ignore", "pipe", "pipe", "ipc"] }); + let stdout = "", stderr = ""; + child.stdout!.on("data", (chunk) => { stdout += chunk; }); child.stderr!.on("data", (chunk) => { stderr += chunk; }); + const timer = setTimeout(() => child.kill("SIGKILL"), 15_000); + const done = new Promise<{ signal: string | null; code: number | null; data?: { ok: boolean; code?: string; result?: unknown }; stderr: string }>((resolve, reject) => { + child.once("error", reject); child.once("close", (code, signal) => { clearTimeout(timer); resolve({ code, signal, data: stdout.trim() ? JSON.parse(stdout.trim()) : undefined, stderr }); }); + }); + const ready = config.waitForStart ? new Promise((resolve, reject) => { child.once("message", () => resolve()); child.once("error", reject); child.once("exit", () => reject(new Error(`worker exited before barrier: ${stderr}`))); }) : Promise.resolve(); + return { child, done, ready }; +} +async function run(config: AdmissionWorkerConfig) { return start(config).done; } +async function expiry(config: AdmissionWorkerConfig) { await sleep(Math.max(0, config.reservation.reservedExpiresAt! - Date.now()) + 20); } +async function snapshot(config: AdmissionWorkerConfig) { + const coordinator = await openCoordinatorStore(config.coordinatorPath, coordinatorOptions(config)); + try { return await coordinator.snapshot({}); } finally { coordinator.close(); } +} + +test("real project journal: abandonment is immutable and late admission/dispatch remain rejected after restart", async () => { + const config = await setup(); await expiry(config); + const recovered = await snapshot(config); assert.equal(recovered.capacity.active, 0); + assert.equal(recovered.reservations[0]!.reservation.state, "expired"); + const decision = await withProject(config.projectPath, (_service, store) => store.readAdmission(config.reservation)); + assert.equal(decision?.status, "abandoned"); assert.ok(decision!.decisionCommitSeq > 0); + for (const mode of ["admit", "dispatch"] as const) { + const late = await run({ ...config, mode }); assert.equal(late.data?.code, "TF_ADMISSION_BINDING_CONFLICT", JSON.stringify(late)); + } + assert.equal((await snapshot(config)).capacity.active, 0); +}); + +test("real project admit fsync then SIGKILL recovers committed capacity before any dispatch", async () => { + const config = await setup(1500); + const killed = await run({ ...config, mode: "admit", killAfterDecision: true }); assert.equal(killed.signal, "SIGKILL", killed.stderr); + // The project decision alone never authorizes dispatch on a reserved row. + await withProject(config.projectPath, (service) => assert.throws(() => service.assertDispatch(config.reservation), { code: "TF_ADMISSION_BINDING_CONFLICT" })); + await expiry(config); const recovered = await snapshot(config); + assert.equal(recovered.capacity.active, 1); assert.equal(recovered.capacity.committed, 1); + assert.equal((await run({ ...config, mode: "dispatch" })).data?.ok, true); + const coordinator = await openCoordinatorStore(config.coordinatorPath, coordinatorOptions(config)); + try { await assert.rejects(coordinator.reserve({ ...config.reservation, reservationId: randomUUID(), runId: randomUUID(), ttlMs: 1000 }, {}), { code: "TF_CAPACITY_EXCEEDED" }); } + finally { coordinator.close(); } +}); + +test("durable project abandonment survives coordinator SIGKILL before capacity publication", async () => { + const config = await setup(); await expiry(config); + const killed = await run({ ...config, mode: "recover", killAfterDecision: true }); assert.equal(killed.signal, "SIGKILL", killed.stderr); + const raw = JSON.parse(fs.readFileSync(path.join(config.coordinatorPath, "coordinator.json"), "utf8")); + assert.equal(raw.reservations[config.reservation.reservationId].reservation.state, "reserved"); + assert.equal((await run({ ...config, mode: "admit" })).data?.code, "TF_ADMISSION_BINDING_CONFLICT"); + assert.equal((await snapshot(config)).capacity.active, 0); +}); + +test("two real processes race actual project journal ADMIT versus ABANDON; only durable winner can dispatch", { timeout: 30_000 }, async () => { + for (let index = 0; index < 6; index++) { + const config = await setup(600); + const a = start({ ...config, mode: "admit", waitForStart: true }), b = start({ ...config, mode: "abandon", waitForStart: true }); + await Promise.all([a.ready, b.ready]); a.child.send("go"); b.child.send("go"); + const results = await Promise.all([a.done, b.done]); + assert.ok(results[1]!.data?.ok, JSON.stringify(results)); + const decision = await withProject(config.projectPath, (_service, store) => store.readAdmission(config.reservation)); + assert.ok(decision); await expiry(config); const state = await snapshot(config); + assert.equal(state.capacity.active, decision.status === "admitted" ? 1 : 0); + const dispatch = await run({ ...config, mode: "dispatch" }); + assert.equal(dispatch.data?.ok, decision.status === "admitted", JSON.stringify({ results, decision, dispatch })); + const journal = fs.readFileSync(path.join(config.projectPath, "journal", "000001.jsonl"), "utf8").trim().split("\n").map((line) => JSON.parse(line)); + assert.equal(journal.filter((batch) => batch.admission?.reservationId === config.reservation.reservationId).length, 1); + } +}); + +test("read-only absence never releases capacity without an explicit abandonment authority", async () => { + const config = await setup(); await expiry(config); + const options = coordinatorOptions(config); delete options.authority.abandonAdmissionIfAbsent; + const coordinator = await openCoordinatorStore(config.coordinatorPath, options); + try { assert.equal((await coordinator.snapshot({})).capacity.active, 1); } + finally { coordinator.close(); } + await withProject(config.projectPath, (_service, store) => assert.equal(store.readAdmission(config.reservation), undefined)); +}); + +test("cross-project admission, wrong coordinator sequence, and corrupt ledger fail closed", async () => { + const config = await setup(2000); + await withProject(config.projectPath, (service) => { + assert.throws(() => service.admit({ ...config.reservation, projectId: randomUUID() }, 0), { code: "TF_ADMISSION_BINDING_CONFLICT" }); + const admitted = service.admit(config.reservation, 0); + assert.throws(() => service.assertDispatch({ ...config.reservation, state: "committed", projectAdmitCommitSeq: admitted.projectAdmitCommitSeq + 1 }), { code: "TF_ADMISSION_BINDING_CONFLICT" }); + }); + const journalPath = path.join(config.projectPath, "journal", "000001.jsonl"); + fs.appendFileSync(journalPath, '{"corrupt":true}\n'); const before = fs.readFileSync(journalPath); + assert.throws(() => openControlStore(config.projectPath), { code: "TF_DURABILITY_FAILED" }); + assert.deepEqual(fs.readFileSync(journalPath), before); +}); diff --git a/packages/taskflow-control/test/approval-edits-host.test.ts b/packages/taskflow-control/test/approval-edits-host.test.ts new file mode 100644 index 00000000..e7dad10f --- /dev/null +++ b/packages/taskflow-control/test/approval-edits-host.test.ts @@ -0,0 +1,88 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { randomUUID, randomBytes } from "node:crypto"; +import type { Taskflow } from "taskflow-core"; +import { ControlHost } from "../src/control-host.ts"; +import { RuntimeTeExecutionProvider } from "../src/runtime-provider.ts"; +import { ProjectRegistry } from "../src/project-registry.ts"; +import { AUTHORIZATION_CAPABILITIES, createAuthorizationAuthority, type VerifiedContext } from "../src/authorization.ts"; +import { createControlEvidenceStore } from "../src/store/evidence-adapter.ts"; +import type { ArtifactRef } from "../src/schema/evidence.ts"; + +async function fixture() { + const base=fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()),"approval-edit-host-")),root=path.join(base,"project");fs.mkdirSync(root); + const registry=new ProjectRegistry(path.join(base,"registry.json")),mount=registry.mount(path.join(root,".taskflow/control"),root); + const authority=createAuthorizationAuthority({ownerUid:process.getuid!(),bootstrapSecret:randomBytes(32),hostBaseline:AUTHORIZATION_CAPABILITIES, + loadLivePolicy:(_principal,binding)=>({host:{capabilities:AUTHORIZATION_CAPABILITIES.map(kind=>({kind,scopeRoot:binding.projectRoot}))}})}); + const context=authority.issueStandalone({projectId:mount.store.header.projectId,controlDomainId:mount.store.header.controlDomainId,projectRoot:root}); + const provider=new RuntimeTeExecutionProvider(path.join(base,"provider")); + const host=new ControlHost({mode:"standalone",trustedInProcessFeatures:["durable-approval"],controlHome:path.join(base,"home"),registry,authorization:authority,provider, + evidenceFactory:(project,verify)=>createControlEvidenceStore(project.store,{terminalEvidence:{verify},authorize:(actor,scope)=>authority.authorize(actor as VerifiedContext,{projectId:scope.projectId,controlDomainId:scope.controlDomainId,projectRoot:project.projectRoot,operation:"replay",commandKind:"approval.decide"})})}); + await host.start(); + async function submit(flow:Taskflow,ctx=context,project=mount){ + const {runId}=await host.dispatchAuthenticated<{runId:string}>(ctx,"commands.submit",{commandId:randomUUID(),kind:"run.submit",approvalMode:"durable-required",flow}); + const until=Date.now()+10000; + while(Date.now()setTimeout(r,10));} + throw new Error("run did not park"); + } + async function stage(runId:string,editKind:"output"|"plan",content:string,ctx=context){return host.dispatchAuthenticated(ctx,"approvals.stageEdit",{runId,editKind,content});} + async function decide(runId:string,editKind:"output"|"plan",editArtifactRef:ArtifactRef){ + const approval=mount.store.listApprovals().find(a=>a.runId===runId&&a.status==="pending")!; + return host.dispatchAuthenticated(context,"approval.decide",{commandId:randomUUID(),runId,approvalRequestId:approval.approvalRequestId,expectedRunVersion:mount.store.readRun(runId)!.runVersion,decision:"edit",editKind,editArtifactRef}); + } + return {base,root,mount,registry,authority,context,provider,host,submit,stage,decide,async close(){host.stop();await Promise.all(mount.store.listRuns().map(run=>provider.wait(run.runId)));registry.close();fs.rmSync(base,{recursive:true,force:true});}}; +} +const flow:Taskflow={name:"edited-approval",phases:[{id:"before",type:"script",run:"printf x >> before-count",idempotent:false},{id:"approval",type:"approval",dependsOn:["before"],task:"Edit?"},{id:"after",type:"script",dependsOn:["approval"],run:["printf","%s","{steps.approval.output}"]}]}; + +test("real Host consumes contract-validated output edit without changing semantic plan",{timeout:20000},async()=>{ + const f=await fixture();try{ + const runId=await f.submit(flow),before=f.provider.readDiagnostic(runId)!.state.flowDefHash; + const artifact=await f.stage(runId,"output","reviewed output");await f.decide(runId,"output",artifact); + await f.host.dispatchAuthenticated(f.context,"runs.wait",{runId}); + assert.equal((await f.provider.observe(runId)).finalOutput,"reviewed output"); + assert.equal(f.provider.readDiagnostic(runId)!.state.flowDefHash,before); + assert.equal(fs.readFileSync(path.join(f.root,"before-count"),"utf8"),"x"); + }finally{await f.close();} +}); + +test("real Host relinks an edited downstream script and preserves completed effects",{timeout:20000},async()=>{ + const f=await fixture();try{ + const runId=await f.submit(flow),before=f.provider.readDiagnostic(runId)!.state.flowDefHash; + const edited=structuredClone(flow);edited.phases[2]!.run="printf x >> edited-count; printf edited-plan"; + const artifact=await f.stage(runId,"plan",JSON.stringify(edited));await f.decide(runId,"plan",artifact); + await f.host.dispatchAuthenticated(f.context,"runs.wait",{runId}); + assert.equal((await f.provider.observe(runId)).finalOutput,"edited-plan"); + assert.notEqual(f.provider.readDiagnostic(runId)!.state.flowDefHash,before); + assert.equal(fs.readFileSync(path.join(f.root,"before-count"),"utf8"),"x");assert.equal(fs.readFileSync(path.join(f.root,"edited-count"),"utf8"),"x"); + }finally{await f.close();} +}); + +test("bad output, completed phase edits, graph edits and cross-project edit artifacts fail closed",{timeout:20000},async()=>{ + const f=await fixture();try{ + const runId=await f.submit(flow); + await assert.rejects(f.decide(runId,"output",await f.stage(runId,"output"," ")),/must not be empty/); + const changed=structuredClone(flow);changed.phases[0]!.run="printf replay"; + await assert.rejects(f.decide(runId,"plan",await f.stage(runId,"plan",JSON.stringify(changed))),/completed, active/); + const changedGraph=structuredClone(flow);changedGraph.phases[2]!.dependsOn=["before"];changedGraph.phases[2]!.run="printf unsafe"; + await assert.rejects(f.decide(runId,"plan",await f.stage(runId,"plan",JSON.stringify(changedGraph))),/dependencies/); + await assert.rejects(f.decide(runId,"plan",await f.stage(runId,"plan",JSON.stringify({...flow,concurrency:1}))),/top-level/); + const otherRoot=path.join(f.base,"other");fs.mkdirSync(otherRoot);const other=f.registry.mount(path.join(otherRoot,".taskflow/control"),otherRoot); + const otherContext=f.authority.issueStandalone({projectId:other.store.header.projectId,controlDomainId:other.store.header.controlDomainId,projectRoot:otherRoot}); + const otherId=await f.submit(flow,otherContext,other),foreign=await f.stage(otherId,"output","foreign",otherContext); + await assert.rejects(f.decide(runId,"output",foreign),/authorized ledger reference/); + assert.equal(f.mount.store.listApprovals().find(a=>a.runId===runId)!.status,"pending"); + f.host.stop();await f.provider.wait(otherId); + }finally{await f.close();} +}); + +test("approval edit cannot consume bytes that differ from the committed artifact digest",{timeout:20000},async()=>{ + const f=await fixture();try{ + const runId=await f.submit(flow),artifact=await f.stage(runId,"output","original output"); + fs.writeFileSync(path.join(f.mount.store.storePath,"artifacts/sha256",artifact.digest.slice(0,2),artifact.digest),"tampered output"); + await assert.rejects(f.decide(runId,"output",artifact),/bytes do not match/); + assert.equal(f.mount.store.listApprovals().find(a=>a.runId===runId)!.status,"pending"); + }finally{await f.close();} +}); diff --git a/packages/taskflow-control/test/approval-replay.test.ts b/packages/taskflow-control/test/approval-replay.test.ts new file mode 100644 index 00000000..d6be03ad --- /dev/null +++ b/packages/taskflow-control/test/approval-replay.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import {randomUUID,randomBytes} from "node:crypto"; +import {mkdtempSync,readFileSync,writeFileSync,rmSync,unlinkSync,renameSync,mkdirSync,symlinkSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import {spawnSync} from "node:child_process"; +import {test} from "node:test"; +import {approvalFixture} from "./fixtures/approval-worker.ts"; +import {ControlError} from "../src/errors.ts"; +import {openControlStore} from "../src/store/store.ts"; +import {commandRequestHash} from "../src/schema/commands.ts"; +import {ApprovalService, type ApprovalDecisionInput} from "../src/approval-service.ts"; +import {createAuthorizationAuthority,AUTHORIZATION_CAPABILITIES} from "../src/authorization.ts"; +const code=(c:string)=>(e:unknown)=>e instanceof ControlError&&e.code===c; +async function setup(){const directory=mkdtempSync(join(tmpdir(),"tf-real-replay-"));const f=await approvalFixture(directory);const request=await f.service.request(f.context,f.request());const input:ApprovalDecisionInput={commandId:randomUUID(),runId:f.runId,approvalRequestId:request.approvalRequestId,expectedRunVersion:f.store.readRun(f.runId)!.runVersion,decision:"approve"};return {directory,f,input,journal:join(directory,"project-store/journal/000001.jsonl")};} + +test("real journal identical competing clients converge before stale CAS with one exact response",async()=>{const {directory,f,input,journal}=await setup();try{ + const before=f.store.commitSeq;const [a,b]=await Promise.all([f.service.decide(f.context,input),f.service.decide(f.secondContext,input)]); + assert.deepEqual(a,b);assert.equal(f.store.commitSeq,before+1);const bytes=readFileSync(journal); + await f.service.cancel(f.context,{commandId:randomUUID(),runId:f.runId,expectedRunVersion:f.store.readRun(f.runId)!.runVersion}); + const changed=readFileSync(journal);assert.deepEqual(await f.service.decide(f.context,input),a);assert.deepEqual(readFileSync(journal),changed);assert.ok(changed.length>bytes.length); +}finally{f.close();rmSync(directory,{recursive:true,force:true});}}); + +test("same command changed body conflicts; authorized other principal cannot disclose; revoked caller denied",async()=>{const {directory,f,input,journal}=await setup();try{ + await f.service.decide(f.context,input);const bytes=readFileSync(journal); + await assert.rejects(f.service.decide(f.context,{...input,decision:"reject"}),code("TF_IDEMPOTENCY_CONFLICT")); + const record=f.store.readCommand(input.commandId)!; + assert.throws(()=>f.store.readCommittedCommand({...record,callerPrincipal:"other-authorized-principal"}),code("TF_CROSS_PRINCIPAL_COMMAND")); + f.set({revoked:true});await assert.rejects(f.service.decide(f.context,input),code("TF_AUTHORITY_REVOKED"));assert.deepEqual(readFileSync(journal),bytes); +}finally{f.close();rmSync(directory,{recursive:true,force:true});}}); + +test("real fsynced decision killed before transport ack replays exact response in fresh store", {skip:process.platform==="win32"},async()=>{ + const {directory,f,input,journal}=await setup();const file=join(directory,"input.json");writeFileSync(file,JSON.stringify(input));f.close(); + try{ + const child=spawnSync(process.execPath,["--conditions=development","--experimental-strip-types",join(import.meta.dirname,"fixtures/approval-replay-worker.ts")],{env:{...process.env,TF_REPLAY_DIRECTORY:directory,TF_REPLAY_INPUT:file},encoding:"utf8",timeout:15000}); + assert.equal(child.signal,"SIGKILL",child.stderr);assert.equal(child.stdout,""); + const bytes=readFileSync(journal), batches=bytes.toString().trim().split("\n").map(line=>JSON.parse(line)); + const commits=batches.filter(b=>b.command?.commandId===input.commandId);assert.equal(commits.length,1); + const expected=commits[0].responseJson; + const fresh=await approvalFixture(directory);try{assert.equal(JSON.stringify(await fresh.service.decide(fresh.context,input)),expected);assert.deepEqual(readFileSync(journal),bytes);}finally{fresh.close();} + }finally{rmSync(directory,{recursive:true,force:true});} +}); + +test("cancel response is immutable across restart and later lifecycle changes",async()=>{const {directory,f,journal}=await setup();try{ + const input={commandId:randomUUID(),runId:f.runId,expectedRunVersion:f.store.readRun(f.runId)!.runVersion};const response=await f.service.cancel(f.context,input);const bytes=readFileSync(journal);f.close(); + const fresh=await approvalFixture(directory);try{assert.deepEqual(await fresh.service.cancel(fresh.context,input),response);assert.deepEqual(readFileSync(journal),bytes);}finally{fresh.close();} +}finally{f.close();rmSync(directory,{recursive:true,force:true});}}); + +for(const damage of ["truncate","response","delete"] as const) test(`committed response ${damage} fails closed while live and after restart without rewriting bytes`,async()=>{ + const {directory,f,input,journal}=await setup();try{ + await f.service.decide(f.context,input);const original=readFileSync(journal); + if(damage==="truncate") writeFileSync(journal,original.subarray(0,original.length-5)); + else if(damage==="response") writeFileSync(journal,original.toString().replace('"responseJson":','"lostResponseJson":')); + else unlinkSync(journal); + const damaged=damage==="delete"?undefined:readFileSync(journal); + await assert.rejects(f.service.decide(f.context,input),code("TF_DURABILITY_FAILED"));f.close(); + assert.throws(()=>openControlStore(join(directory,"project-store")),code("TF_DURABILITY_FAILED")); + if(damaged) assert.deepEqual(readFileSync(journal),damaged); + }finally{f.close();rmSync(directory,{recursive:true,force:true});} +}); + +test("same UUID has independent authority in two real project/domain journals",async()=>{ + const one=await setup(),two=await setup();try{ + two.input.commandId=one.input.commandId; + const a=await one.f.service.decide(one.f.context,one.input), b=await two.f.service.decide(two.f.context,two.input); + assert.notEqual(a.runId,b.runId); + const record=one.f.store.readCommand(one.input.commandId)!; + assert.throws(()=>two.f.store.readCommittedCommand({...record,requestHash:commandRequestHash(one.input)}),code("TF_DURABILITY_FAILED")); + assert.equal(one.f.store.readCommittedCommand(record)?.responseJson,JSON.stringify(a)); + }finally{one.f.close();two.f.close();rmSync(one.directory,{recursive:true,force:true});rmSync(two.directory,{recursive:true,force:true});} +}); + +for(const damage of ["header","projection-symlink"] as const) test(`project ${damage} replacement cannot disclose or overwrite another directory`,async()=>{ + const {directory,f,input}=await setup();try{ + await f.service.decide(f.context,input); + if(damage==="header") { + const header=join(directory,"project-store/header"); writeFileSync(header,"broken identity"); + await assert.rejects(f.service.decide(f.context,input),code("TF_DURABILITY_FAILED")); + assert.equal(readFileSync(header,"utf8"),"broken identity"); + } else { + f.close(); const projections=join(directory,"project-store/projections"),outside=join(directory,"outside"); + renameSync(projections,projections+".saved");mkdirSync(outside);writeFileSync(join(outside,"commands.json"),"external-owner-data");symlinkSync(outside,projections); + assert.throws(()=>openControlStore(join(directory,"project-store")),code("TF_DURABILITY_FAILED")); + assert.equal(readFileSync(join(outside,"commands.json"),"utf8"),"external-owner-data"); + } + }finally{f.close();rmSync(directory,{recursive:true,force:true});} +}); + +test("independently verified authorized audience principal cannot replay another principal command",async()=>{ + const directory=mkdtempSync(join(tmpdir(),"tf-real-principal-"));const f=await approvalFixture(directory); + try { + const binding={projectId:f.store.header.projectId,controlDomainId:f.store.header.controlDomainId,projectRoot:directory}; + const other=createAuthorizationAuthority({ownerUid:process.getuid!(),bootstrapSecret:randomBytes(32),credentialIssuerId:"other",hostBaseline:AUTHORIZATION_CAPABILITIES,loadLivePolicy:()=>({host:{}})}); + const context=other.issueStandalone(binding),principal=other.identity(context).principal; + const request=await f.service.request(f.context,f.request({audience:[principal]})); + const input:ApprovalDecisionInput={commandId:randomUUID(),runId:f.runId,approvalRequestId:request.approvalRequestId,expectedRunVersion:f.store.readRun(f.runId)!.runVersion,decision:"approve"}; + await f.service.decide(f.context,input); + const service=new ApprovalService(f.store,{...f.service.authority,authorize:async(ctx,scope)=>{ + const decision=await other.authorize(ctx,{...binding,operation:scope.operation==="read"?"read":"submit",...(scope.operation==="read"?{}:{commandKind:"approval.decide" as const})}); + assert.ok(scope.request?.audience?.includes(decision.callerPrincipal));return decision; + }}); + const bytes=readFileSync(join(directory,"project-store/journal/000001.jsonl")); + await assert.rejects(service.decide(context,input),code("TF_CROSS_PRINCIPAL_COMMAND")); + assert.deepEqual(readFileSync(join(directory,"project-store/journal/000001.jsonl")),bytes); + }finally{f.close();rmSync(directory,{recursive:true,force:true});} +}); diff --git a/packages/taskflow-control/test/control-cli.test.ts b/packages/taskflow-control/test/control-cli.test.ts new file mode 100644 index 00000000..f300a664 --- /dev/null +++ b/packages/taskflow-control/test/control-cli.test.ts @@ -0,0 +1,130 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { connectUdsClient } from "../src/uds.ts"; +import type { Receipt } from "../src/schema/evidence.ts"; +import { defaultServerHello } from "../src/control-host.ts"; +import { bootstrapLocalAuthority, policyPath, readPrivateFile } from "../src/local-bootstrap.ts"; + +const cli = fileURLToPath(new URL("../src/control-cli.ts", import.meta.url)); +function temp() { return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "tf-cli-"))); } +function start(args: string[]) { + const child = spawn(process.execPath, ["--conditions=development", "--experimental-strip-types", cli, ...args], { stdio: "pipe", env: { ...process.env, PI_TASKFLOW_BUILTIN_AGENTS_DIR: "" } }); + let stdout = "", stderr = ""; + child.stdout.on("data", data => { stdout += String(data); }); child.stderr.on("data", data => { stderr += String(data); }); + const done = new Promise<{ code: number | null; stdout: string; stderr: string }>(resolve => child.on("close", code => resolve({ code, stdout, stderr }))); + return { child, done, output: () => ({ stdout, stderr }) }; +} +async function stop(child: ChildProcessWithoutNullStreams) { + if (child.exitCode !== null || child.signalCode !== null) return; + const done = new Promise(resolve => child.once("close", () => resolve())); child.kill("SIGTERM"); + const timeout = setTimeout(() => child.kill("SIGKILL"), 3000); await done; clearTimeout(timeout); +} +async function ready(process: ReturnType) { + const deadline = Date.now() + 10000; + while (!process.output().stdout.includes("\n")) { + if (process.child.exitCode !== null || Date.now() > deadline) throw new Error(`CLI not ready: ${JSON.stringify(process.output())}`); + await new Promise(resolve => setTimeout(resolve, 25)); + } + return JSON.parse(process.output().stdout.split("\n")[0]); +} +function flow(root: string) { + const file = path.join(root, "flow.json"); + fs.writeFileSync(file, JSON.stringify({ name: "cli-script", phases: [{ id: "write-proof", type: "script", run: [process.execPath, "-e", "require('node:fs').writeFileSync('executed.txt','real-script'); process.stdout.write('real-output')"], final: true }] })); + return file; +} +test("CLI fresh default auto executes real script and persists a terminal run", { timeout: 20000 }, async () => { + const base = temp(); const root = path.join(base, "project"); fs.mkdirSync(root); const home = path.join(base, "home"); const file = flow(root); + const running = start(["run", "--root", root, "--control-home", home, "--flow", file]); + try { + const result = await running.done; assert.equal(result.code, 0, result.stderr + result.stdout); + const body = JSON.parse(result.stdout); assert.equal(body.run.status, "completed"); assert.equal(body.run.slot, "released"); assert.equal(body.result.finalOutput, "real-output"); + assert.equal(fs.readFileSync(path.join(root, "executed.txt"), "utf8"), "real-script"); + assert.equal(readPrivateFile(path.join(home, "bootstrap.key")).length, 32); + assert.ok(fs.existsSync(path.join(home, "registry.json"))); + } finally { await stop(running.child); fs.rmSync(base, { recursive: true, force: true }); } +}); +test("CLI interrupt commits cancellation and retains capacity for an ambiguous script mutation", { timeout: 20000 }, async () => { + const base = temp(), root = path.join(base, "project"), home = path.join(base, "home"); fs.mkdirSync(root); + const file = path.join(root, "flow.json"); + fs.writeFileSync(file, JSON.stringify({ name: "interrupt", phases: [{ id: "wait", type: "script", run: [process.execPath, "-e", "require('node:fs').writeFileSync('started.txt','started'); setTimeout(()=>require('node:fs').writeFileSync('late.txt','unexpected'),10000)"], final: true }] })); + const running = start(["run", "--root", root, "--control-home", home, "--flow", file]); + try { + const deadline = Date.now() + 10000; + while (!fs.existsSync(path.join(root, "started.txt"))) { + assert.equal(running.child.exitCode, null, running.output().stderr); + assert.ok(Date.now() < deadline, "script never started"); await new Promise(resolve => setTimeout(resolve, 20)); + } + running.child.kill("SIGINT"); + const result = await running.done; assert.equal(result.code, 1, result.stderr + result.stdout); + const body = JSON.parse(result.stdout); assert.equal(body.run.status, "unknown"); assert.equal(body.run.slot, "committed"); assert.equal(body.run.needsOperator, true); + assert.equal(fs.existsSync(path.join(root, "late.txt")), false); + const journal = fs.readFileSync(path.join(root, ".taskflow", "control", "journal", "000001.jsonl"), "utf8"); + assert.match(journal, /"kind":"run.cancel"/); + assert.doesNotMatch(journal, /"kind":"run.terminal"/); + } finally { await stop(running.child); fs.rmSync(base, { recursive: true, force: true }); } +}); +test("CLI serve accepts authenticated process attach, status, and console handoff without printing secrets", { timeout: 25000 }, async () => { + const base = temp(); const root = path.join(base, "project"); fs.mkdirSync(root); const home = path.join(base, "home"); const file = flow(root); + const server = start(["serve", "--root", root, "--control-home", home, "--console"]); + try { + const info = await ready(server); const handoff = JSON.parse(readPrivateFile(info.browserHandoffFile).toString()); + assert.equal(handoff.url, info.consoleUrl); assert.ok(handoff.token.length > 30); + assert.ok(!server.output().stdout.includes(handoff.token)); assert.ok(!info.consoleUrl.includes("token")); + const unauthenticated = await connectUdsClient({ endpointPath: info.control.endpoint, clientHello: defaultServerHello() }); + try { + await assert.rejects(unauthenticated.rpc("runs.list", {}), /authenticate/); + const challenge = await unauthenticated.rpc<{ id: string }>("auth.challenge", {}); + await assert.rejects(unauthenticated.rpc("auth.authenticate", { challengeId: challenge.id, proof: "0".repeat(64) }), /proof failed/); + await assert.rejects(unauthenticated.rpc("runs.list", {}), /authenticate/); + } finally { unauthenticated.close(); } + const client = start(["run", "--root", root, "--control-home", home, "--flow", file]); + const run = await client.done; assert.equal(run.code, 0, run.stderr + run.stdout); const result = JSON.parse(run.stdout); + assert.equal(result.run.status, "completed"); + const session = await fetch(`${handoff.url}/api/session`, { method: "POST", headers: { "content-type": "application/json", origin: handoff.url }, body: JSON.stringify({ token: handoff.token }) }); + assert.equal(session.status, 200, await session.text()); + const cookie = session.headers.get("set-cookie")!.split(";")[0]; + const receiptResponse = await fetch(`${handoff.url}/api/projects/${result.projectId}/runs/${result.runId}/receipt?controlDomainId=${result.controlDomainId}`, { headers: { cookie } }); + assert.equal(receiptResponse.status, 200, await receiptResponse.clone().text()); + const { result: receipt } = await receiptResponse.json() as { result: Receipt }; assert.equal(receipt.runId, result.runId); assert.equal(receipt.assurance.providerOutcome, "completed"); + const status = await start(["status", "--root", root, "--control-home", home, "--run", result.runId]).done; + assert.equal(status.code, 0, status.stderr); assert.equal(JSON.parse(status.stdout).control.singleton, "attached"); + const policyFile = policyPath(home, root); const policy = JSON.parse(readPrivateFile(policyFile).toString()); policy.capabilities = []; + fs.writeFileSync(policyFile, JSON.stringify(policy), { mode: 0o600 }); + const denied = await start(["status", "--root", root, "--control-home", home]).done; + assert.equal(denied.code, 1); assert.match(denied.stderr, /TF_POLICY_DENIED/); + } finally { await stop(server.child); fs.rmSync(base, { recursive: true, force: true }); } +}); +test("bootstrap denies permissive or symbolic-link credential paths and preserves existing policy", () => { + const root = temp(); const home = path.join(root, "home"); + try { + const first = bootstrapLocalAuthority(home, [root]); const key = path.join(home, "bootstrap.key"); + fs.chmodSync(key, 0o644); assert.throws(() => bootstrapLocalAuthority(home, [root]), /owner, mode or identity/); fs.chmodSync(key, 0o600); + const saved = path.join(home, "saved"); fs.renameSync(key, saved); fs.symlinkSync(saved, key); + assert.throws(() => bootstrapLocalAuthority(home, [root]), /symbolic-link/); fs.unlinkSync(key); fs.renameSync(saved, key); + const file = policyPath(home, root); fs.writeFileSync(file, JSON.stringify({ version: 1, projectRoot: root, capabilities: [], revokedPrincipals: [] })); + const again = bootstrapLocalAuthority(home, [root]); assert.deepEqual(again.secret, first.secret); + assert.deepEqual(JSON.parse(readPrivateFile(file).toString()).capabilities, []); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test("CLI multi-project server routes distinct roots and coordinated absence fails closed", { timeout: 25000 }, async () => { + const base = temp(); const roots = [path.join(base, "one"), path.join(base, "two")]; for (const root of roots) fs.mkdirSync(root); + const home = path.join(base, "home"), file = flow(roots[1]); + const server = start(["serve", "--root", roots[0], "--root", roots[1], "--control-home", home]); + try { + const info = await ready(server); assert.equal(info.projects.length, 2); + const result = await start(["run", "--root", roots[1], "--control-home", home, "--flow", file]).done; + assert.equal(result.code, 0, result.stderr + result.stdout); assert.equal(fs.readFileSync(path.join(roots[1], "executed.txt"), "utf8"), "real-script"); + assert.equal(fs.existsSync(path.join(roots[0], "executed.txt")), false); + const first = await start(["status", "--root", roots[0], "--control-home", home]).done; + assert.equal(first.code, 0, first.stderr); assert.deepEqual(JSON.parse(first.stdout).result, []); + await stop(server.child); + const unavailable = await start(["status", "--root", roots[0], "--control-home", home]).done; + assert.equal(unavailable.code, 1); assert.match(unavailable.stderr, /TF_JOURNAL_UNAVAILABLE/); + } finally { await stop(server.child); fs.rmSync(base, { recursive: true, force: true }); } +}); diff --git a/packages/taskflow-control/test/control-console-integration.test.ts b/packages/taskflow-control/test/control-console-integration.test.ts new file mode 100644 index 00000000..9df8168a --- /dev/null +++ b/packages/taskflow-control/test/control-console-integration.test.ts @@ -0,0 +1,103 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { randomBytes, randomUUID } from "node:crypto"; +import { ControlHost } from "../src/control-host.ts"; +import { ProjectRegistry } from "../src/project-registry.ts"; +import { RuntimeTeExecutionProvider } from "../src/runtime-provider.ts"; +import { AUTHORIZATION_CAPABILITIES, createAuthorizationAuthority, type VerifiedContext } from "../src/authorization.ts"; +import { createControlEvidenceStore } from "../src/store/evidence-adapter.ts"; +import { launchControlConsole } from "../src/control-console.ts"; + +test("console reads two real Host projects, actual TE run, and live revocation without cross-project data", async () => { + const directory = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "tf-console-real-")); + const registry = new ProjectRegistry(path.join(directory, "registry.json")); + const roots = ["one", "two"].map(name => { const root = path.join(directory, name); fs.mkdirSync(root); return root; }); + const mounts = roots.map(root => registry.mount(path.join(root, ".taskflow/control"), root)); + const denied = new Set(); + const authority = createAuthorizationAuthority({ ownerUid: process.getuid!(), bootstrapSecret: randomBytes(32), hostBaseline: AUTHORIZATION_CAPABILITIES, + loadLivePolicy: (_principal, binding) => ({ host: { capabilities: denied.has(binding.projectId) ? [] : AUTHORIZATION_CAPABILITIES.map(kind => ({ kind, scopeRoot: binding.projectRoot })) } }) }); + const contexts = new Map(mounts.map(mount => [mount.store.header.projectId, authority.issueStandalone({ projectId: mount.store.header.projectId, controlDomainId: mount.store.header.controlDomainId, projectRoot: mount.projectRoot })])); + const provider = new RuntimeTeExecutionProvider(path.join(directory, "provider")); + const host = new ControlHost({ mode: "standalone", registry, authorization: authority, provider, controlHome: path.join(directory, "home") }); + let consoleServer: Awaited> | undefined; + try { + await host.start(); + const first = mounts[0].store.header, second = mounts[1].store.header; + const accepted = await host.dispatchAuthenticated<{ runId: string }>(contexts.get(first.projectId)!, "commands.submit", { + commandId: randomUUID(), kind: "run.submit", flow: { name: "console-real", phases: [{ id: "work", type: "script", run: "printf console-visible" }] }, + }); + await host.dispatchAuthenticated(contexts.get(first.projectId)!, "runs.wait", { runId: accepted.runId }); + consoleServer = await launchControlConsole({ host, registry, authorization: authority, contexts }); + const origin = consoleServer.url; + const bootstrap = await fetch(`${origin}/api/session`, { method: "POST", headers: { Origin: origin, "Content-Type": "application/json" }, body: JSON.stringify({ token: consoleServer.bootstrapToken }) }); + assert.equal(bootstrap.status, 200); + const cookie = bootstrap.headers.get("set-cookie")!.split(";")[0]; + const get = (route: string) => fetch(`${origin}${route}`, { headers: { Cookie: cookie } }); + const projects = await get("/api/projects"); assert.equal(projects.status, 200); + const projectBody = await projects.json() as { result: { projectId: string }[] }; + assert.deepEqual(new Set(projectBody.result.map(row => row.projectId)), new Set([first.projectId, second.projectId])); + const list = await get(`/api/projects/${first.projectId}/runs?controlDomainId=${first.controlDomainId}`); + const listed = await list.json() as { result: { runId: string; status: string }[] }; + assert.equal(list.status, 200); assert.equal(listed.result[0].runId, accepted.runId); assert.equal(listed.result[0].status, "completed"); + const other = await get(`/api/projects/${second.projectId}/runs?controlDomainId=${second.controlDomainId}`); + assert.deepEqual((await other.json() as { result: unknown }).result, []); + const mixed = await get(`/api/projects/${first.projectId}/runs?controlDomainId=${second.controlDomainId}`); assert.equal(mixed.status, 403); + denied.add(first.projectId); + const revoked = await get(`/api/projects/${first.projectId}/runs?controlDomainId=${first.controlDomainId}`); assert.equal(revoked.status, 403); + const filtered = await get("/api/projects"); + assert.deepEqual((await filtered.json() as { result: { projectId: string }[] }).result.map(row => row.projectId), [second.projectId]); + denied.add(second.projectId); assert.equal((await get("/api/projects")).status, 403); + assert.equal((await provider.observe(accepted.runId)).finalOutput, "console-visible"); + } finally { await consoleServer?.close(); host.stop(); registry.close(); fs.rmSync(directory, { recursive: true, force: true }); } +}); + + +test("console stages output text through actual Host and edited approval drives actual downstream script", { timeout: 20000 }, async () => { + const directory = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "tf-console-edit-")); + const root = path.join(directory, "project"); fs.mkdirSync(root); + const registry = new ProjectRegistry(path.join(directory, "registry.json")); + const mount = registry.mount(path.join(root, ".taskflow/control"), root); + let revoked = false; + const authority = createAuthorizationAuthority({ ownerUid: process.getuid!(), bootstrapSecret: randomBytes(32), hostBaseline: AUTHORIZATION_CAPABILITIES, + loadLivePolicy: () => ({ host: { capabilities: revoked ? [] : AUTHORIZATION_CAPABILITIES.map(kind => ({ kind, scopeRoot: root })) } }) }); + const { projectId, controlDomainId } = mount.store.header; + const context = authority.issueStandalone({ projectId, controlDomainId, projectRoot: root }); + const provider = new RuntimeTeExecutionProvider(path.join(directory, "provider")); + const host = new ControlHost({ mode: "standalone", registry, authorization: authority, provider, trustedInProcessFeatures: ["durable-approval"], controlHome: path.join(directory, "home"), + evidenceFactory: (project, verify) => createControlEvidenceStore(project.store, { terminalEvidence: { verify }, authorize: (actor, scope) => authority.authorize(actor as VerifiedContext, { projectId: scope.projectId, controlDomainId: scope.controlDomainId, projectRoot: root, operation: "replay", commandKind: "approval.decide" }) }) }); + let server: Awaited> | undefined; + try { + await host.start(); + const accepted = await host.dispatchAuthenticated<{runId:string}>(context, "commands.submit", { commandId: randomUUID(), kind: "run.submit", approvalMode: "durable-required", flow: { name: "console-edit", phases: [{ id: "review", type: "approval", task: "Review output" }, { id: "consume", type: "script", dependsOn: ["review"], run: ["printf", "%s", "{steps.review.output}"] }] } }); + const deadline = Date.now() + 10000; + while (mount.store.readRun(accepted.runId)?.stage !== "parked") { if (Date.now() > deadline) throw new Error("approval did not park"); await new Promise(resolve => setTimeout(resolve, 10)); } + server = await launchControlConsole({ host, authorization: authority, registry, contexts: new Map([[projectId, context]]) }); + const login = await fetch(server.url + "/api/session", { method: "POST", headers: { Origin: server.url, "Content-Type": "application/json" }, body: JSON.stringify({ token: server.bootstrapToken }) }); + const session = await login.json() as { csrf: string; features: { approvalOutputEdit: boolean } }; + assert.equal(session.features.approvalOutputEdit, true); + const headers = { Cookie: login.headers.get("set-cookie")!.split(";")[0], Origin: server.url, "Content-Type": "application/json", "X-Taskflow-CSRF": session.csrf }; + const scoped = (suffix: string) => server!.url + `/api/projects/${projectId}${suffix}?controlDomainId=${controlDomainId}`; + const stageUrl = scoped(`/runs/${accepted.runId}/approval-output`); + const before = mount.store.journalTip; + assert.equal((await fetch(stageUrl, { method: "POST", headers: { ...headers, "X-Taskflow-CSRF": "bad" }, body: JSON.stringify({content:"no"}) })).status, 403); + assert.equal((await fetch(stageUrl, { method: "POST", headers, body: JSON.stringify({content:"no",projectRoot:root}) })).status, 400); + assert.deepEqual(mount.store.journalTip, before); + const staged = await fetch(stageUrl, { method: "POST", headers, body: JSON.stringify({content:"Browser reviewed output"}) }); + assert.equal(staged.status, 200, await staged.clone().text()); + const artifact = (await staged.json() as {result:unknown}).result; + const approvals = await fetch(scoped("/approvals"), { headers }); + const approval = (await approvals.json() as {result:{approvalRequestId:string;expectedRunVersion:number}[]}).result[0]!; + const decided = await fetch(scoped(`/approvals/${approval.approvalRequestId}/decisions`), { method: "POST", headers, body: JSON.stringify({ commandId: randomUUID(), runId: accepted.runId, expectedRunVersion: approval.expectedRunVersion, decision: "edit", editKind: "output", editArtifactRef: artifact }) }); + assert.equal(decided.status, 200, await decided.clone().text()); + await host.dispatchAuthenticated(context, "runs.wait", {runId:accepted.runId}); + assert.equal((await provider.observe(accepted.runId)).finalOutput, "Browser reviewed output"); + assert.equal(mount.store.readRun(accepted.runId)?.status, "completed"); + revoked = true; + const committed = mount.store.journalTip; + assert.equal((await fetch(stageUrl, {method:"POST",headers,body:JSON.stringify({content:"denied"})})).status,403); + assert.deepEqual(mount.store.journalTip, committed); + } finally { await server?.close(); host.stop(); await Promise.all(mount.store.listRuns().map(run => provider.wait(run.runId))); registry.close(); fs.rmSync(directory,{recursive:true,force:true}); } +}); diff --git a/packages/taskflow-control/test/control-host.test.ts b/packages/taskflow-control/test/control-host.test.ts index e9a2151e..b2c15672 100644 --- a/packages/taskflow-control/test/control-host.test.ts +++ b/packages/taskflow-control/test/control-host.test.ts @@ -3,6 +3,13 @@ import { after, test } from "node:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { randomBytes, randomUUID } from "node:crypto"; +import { AUTHORIZATION_CAPABILITIES, answerAuthorizationChallenge, createAuthorizationAuthority, type AuthorizationChallenge } from "../src/authorization.ts"; +import { ProjectRegistry } from "../src/project-registry.ts"; +import { RuntimeTeExecutionProvider } from "../src/runtime-provider.ts"; +import type { RunSnapshot } from "../src/schema/run.ts"; +import type { ApprovalRequest } from "../src/schema/approval.ts"; +import { connectUdsClient, type UdsClient } from "../src/uds.ts"; import { ControlHost, type ControlHostOptions } from "../src/control-host.ts"; import { ControlError } from "../src/errors.ts"; import { createTeExecutionProvider, type TeExecutionAuthority } from "../src/te-provider.ts"; @@ -12,7 +19,7 @@ import { PROTOCOL_MAJOR, type NegotiationHandshake } from "../src/schema/transpo const tempRoots: string[] = []; function makePaths(): SingletonPaths { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-control-host-")); + const root = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "tf-control-host-")); tempRoots.push(root); return singletonPaths(root); } @@ -191,132 +198,138 @@ test("control-host: stop releases the singleton so a fresh host can win", async second.stop(); }); -test("control-host: standalone opens projectStorePath and a later host sees the same ledger (A3)", async () => { +function authenticatedFixture(mode: "standalone" | "auto") { const paths = makePaths(); - const storePath = path.join(paths.controlHome, "project-store"); - const first = new ControlHost(hostOptions(paths, { mode: "standalone", holderId: "s1", projectStorePath: storePath })); - await first.start(); - first.hello(clientHello); - const header = await first.dispatch<{ projectId: string }>("control.store.header", undefined, { fencingEpoch: 1 }); - assert.match(header.projectId, /^[0-9a-f-]{36}$/i); - const submitted = await first.dispatch<{ commitSeq: number }>("commands.submit", { - command: { - commandId: "00000000-0000-0000-0000-0000000000bb", - kind: "run.submit", - requestHash: "a".repeat(64), - callerPrincipal: "cli", - authorizationContextHash: "a".repeat(64), - projectId: header.projectId, - controlDomainId: header.projectId, - status: "accepted", - firstCommitSeq: 1, - lastCommitSeq: 1, - recordedAt: 1, - }, - events: [{ - eventId: "00000000-0000-0000-0000-0000000000cc", - schemaVersion: 1, - controlDomainId: header.projectId, - streamId: "command:00000000-0000-0000-0000-0000000000bb", - streamSeq: 1, - commitSeq: 1, - commandId: "00000000-0000-0000-0000-0000000000bb", - commandEventIndex: 0, - causationId: "00000000-0000-0000-0000-0000000000bb", - correlationId: "00000000-0000-0000-0000-0000000000bb", - projectId: header.projectId, - recordedAt: 1, - payload: { kind: "command.recorded", commandId: "00000000-0000-0000-0000-0000000000bb" }, - }], - }, { fencingEpoch: 1 }); - assert.equal(submitted.commitSeq, 1); - first.stop(); + const projectRoot = path.join(paths.controlHome, "project"); + fs.mkdirSync(projectRoot); + const projectStorePath = path.join(projectRoot, ".taskflow", "control"); + const secret = randomBytes(32); + const authorization = createAuthorizationAuthority({ + ownerUid: process.getuid!(), bootstrapSecret: secret, hostBaseline: AUTHORIZATION_CAPABILITIES, + loadLivePolicy: () => ({ host: { capabilities: AUTHORIZATION_CAPABILITIES.map(kind => ({ kind, scopeRoot: projectRoot })) } }), + }); + const provider = new RuntimeTeExecutionProvider(path.join(paths.controlHome, "provider")); + let registry: ProjectRegistry; + let owner: ControlHost; + let caller: ControlHost; + let context: ReturnType; + const wireClients: UdsClient[] = []; + const dispatch = (method: string, params?: unknown): Promise => mode === "auto" + ? caller.dispatch(method, params, { fencingEpoch: caller.status.fencingEpoch }) + : owner.dispatchAuthenticated(context, method, params); + const stop = () => { for (const client of wireClients) client.close(); if (caller !== owner) caller?.stop(); owner?.stop(); registry?.close(); }; + const start = async () => { + registry = new ProjectRegistry(path.join(paths.controlHome, "registry.json")); + const mount = registry.mount(projectStorePath, projectRoot); + context = authorization.issueStandalone({ projectId: mount.store.header.projectId, controlDomainId: mount.store.header.controlDomainId, projectRoot }); + owner = new ControlHost(hostOptions(paths, { mode, provider, authorization, registry, holderId: "command-owner" })); + await owner.start(); + owner.hello(clientHello); + caller = owner; + if (mode === "auto") { + caller = new ControlHost(hostOptions(paths, { mode: "coordinated", provider, projectStorePath })); + await caller.start(); + const challenge = await caller.dispatch("auth.challenge", { projectId: mount.store.header.projectId }, { fencingEpoch: caller.status.fencingEpoch }); + assert.deepEqual(challenge.binding, { projectId: mount.store.header.projectId, controlDomainId: mount.store.header.controlDomainId, projectRoot }); + const authenticated = await caller.dispatch("auth.authenticate", { challengeId: challenge.id, proof: answerAuthorizationChallenge(secret, challenge) }, { fencingEpoch: caller.status.fencingEpoch }); + assert.deepEqual(authenticated, { authenticated: true }); + } + }; + const connectAuthenticated = async (offeredFeatures: string[]) => { + const client = await connectUdsClient({ endpointPath: paths.endpointPath, clientHello: { ...clientHello, offeredFeatures } }); + wireClients.push(client); + const challenge = await client.rpc("auth.challenge", { projectId: registry.resolve().store.header.projectId }); + assert.deepEqual(await client.rpc("auth.authenticate", { challengeId: challenge.id, proof: answerAuthorizationChallenge(secret, challenge) }), { authenticated: true }); + return client; + }; + return { projectRoot, projectStorePath, provider, start, stop, dispatch, connectAuthenticated, + unauthenticated: (method: string, params?: unknown) => owner.dispatch(method, params, { fencingEpoch: owner.status.fencingEpoch }) }; +} - const second = new ControlHost(hostOptions(paths, { mode: "standalone", holderId: "s2", projectStorePath: storePath })); - await second.start(); - second.hello(clientHello); - const status = await second.dispatch<{ header: { projectId: string }; commitSeq: number }>("control.store.status", undefined, { fencingEpoch: 1 }); - assert.equal(status.header.projectId, header.projectId); - assert.equal(status.commitSeq, 1); - second.stop(); +// Recovery may rebuild projections; only header, sequence and journal are authority. +function ledgerBytes(storePath: string, includeProjections = false) { + const files: Record = {}; + for (const entry of fs.readdirSync(storePath, { recursive: true, withFileTypes: true })) { + if (entry.isFile() && entry.name !== "writer.lock") { + const file = path.join(entry.parentPath, entry.name); + const relative = path.relative(storePath, file); + if (includeProjections || !relative.startsWith(`projections${path.sep}`)) files[relative] = fs.readFileSync(file); + } + } + return files; +} + +test("control-host: authenticated standalone reopens the same durable project identity and ledger (A3)", async (t) => { + const f = authenticatedFixture("standalone"); + t.after(f.stop); + await f.start(); + const header = await f.dispatch<{ projectId: string; controlDomainId: string }>("control.store.header"); + assert.match(header.projectId, /^[0-9a-f-]{36}$/i); + const accepted = await f.dispatch<{ runId: string }>("commands.submit", { + commandId: randomUUID(), kind: "run.submit", flow: { name: "persisted", phases: [{ id: "work", type: "script", run: "printf durable-runtime" }] }, + }); + const run = await f.dispatch("runs.wait", { runId: accepted.runId }); + assert.equal(run.status, "completed"); + assert.equal(run.slot, "released"); + const before = await f.dispatch<{ commitSeq: number }>("control.store.status"); + assert.ok(before.commitSeq > 0); + const bytes = ledgerBytes(f.projectStorePath); + f.stop(); + await f.start(); + assert.deepEqual(await f.dispatch("control.store.header"), header); + assert.equal((await f.dispatch<{ commitSeq: number }>("control.store.status")).commitSeq, before.commitSeq); + assert.deepEqual(await f.dispatch("runs.get", { runId: accepted.runId }), run); + assert.deepEqual(ledgerBytes(f.projectStorePath), bytes); }); for (const mode of ["standalone", "auto"] as const) { - test(`commands.submit: ${mode} duplicate and concurrent retries fail closed before writes or TE execution`, { + test(`commands.submit: ${mode} concurrent and restarted retries replay exactly and execute once`, { skip: mode === "auto" && process.platform === "win32", }, async (t) => { - const paths = makePaths(); - const projectStorePath = path.join(paths.controlHome, "project-store"); - const provider = fakeProvider(); - const prepare = t.mock.method(provider, "prepare", async () => assert.fail("journal submission must not prepare TE work")); - const submit = t.mock.method(provider, "submit", async () => assert.fail("journal submission must not execute TE work")); - const options = hostOptions(paths, { mode, provider, holderId: "command-owner", projectStorePath }); - let owner = new ControlHost(options); - let caller = owner; - t.after(() => { if (caller !== owner) caller.stop(); owner.stop(); }); - const start = async () => { - await owner.start(); - owner.hello(clientHello); - if (mode === "auto") { - // Exercise the real commands.submit wire path, not just appendBatch. - caller = new ControlHost(hostOptions(paths, { mode: "coordinated", provider, projectStorePath })); - await caller.start(); - } else caller = owner; - }; - await start(); - const header = await caller.dispatch<{ projectId: string; controlDomainId: string }>("control.store.header", undefined, { fencingEpoch: 1 }); - const identity = { projectId: header.projectId, controlDomainId: header.controlDomainId }; - const commandId = "00000000-0000-0000-0000-0000000000dd"; - const body = { - command: { - commandId, kind: "run.submit", requestHash: "a".repeat(64), callerPrincipal: "cli", - authorizationContextHash: "a".repeat(64), ...identity, status: "accepted", - firstCommitSeq: 1, lastCommitSeq: 1, recordedAt: 1, - }, - events: [{ - eventId: "00000000-0000-0000-0000-0000000000ee", schemaVersion: 1, - ...identity, streamId: `command:${commandId}`, streamSeq: 1, commitSeq: 1, - commandId, commandEventIndex: 0, causationId: commandId, correlationId: commandId, - recordedAt: 1, payload: { kind: "command.recorded", commandId }, - }], - }; - // Two requests can be pending on the same connection, but only one batch - // may commit. The synchronous single-writer append never re-executes it. - const concurrent = await Promise.allSettled([0, 1].map(() => caller.dispatch("commands.submit", body, { fencingEpoch: 1 }))); - assert.equal(concurrent.filter((result) => result.status === "fulfilled").length, 1); - const rejected = concurrent.find((result) => result.status === "rejected"); - assert.ok(rejected && rejected.status === "rejected"); - assert.ok(rejected.reason instanceof ControlError); - assert.equal(rejected.reason.code, "TF_IDEMPOTENCY_CONFLICT"); - const journalPath = path.join(projectStorePath, "journal", "000001.jsonl"); - const journal = fs.readFileSync(journalPath); - const sequence = fs.readFileSync(path.join(projectStorePath, "commit-seq.json")); - const projection = fs.readFileSync(path.join(projectStorePath, "projections", "commands.json")); - assert.equal(journal.toString("utf8").trim().split("\n").length, 1); + const f = authenticatedFixture(mode); + t.after(f.stop); + const submit = t.mock.method(f.provider, "submit"); + await f.start(); + const body = { commandId: randomUUID(), kind: "run.submit", flow: { + name: "one-effect", phases: [{ id: "work", type: "script", run: "printf 'once\n' >> effects.txt; printf executed" }], + } }; + const concurrent = await Promise.all([0, 1].map(() => f.dispatch<{ runId: string }>("commands.submit", body))); + assert.equal(JSON.stringify(concurrent[1]), JSON.stringify(concurrent[0])); + const accepted = concurrent[0]!; + const completed = await f.dispatch("runs.wait", { runId: accepted.runId }); + assert.equal(completed.status, "completed"); + assert.equal(completed.slot, "released"); + assert.equal((await f.provider.observe(accepted.runId)).finalOutput, "executed"); + assert.equal(fs.readFileSync(path.join(f.projectRoot, "effects.txt"), "utf8"), "once\n"); + assert.equal((await f.dispatch("runs.list")).length, 1); + const bytes = ledgerBytes(f.projectStorePath); + const sequence = (await f.dispatch<{ commitSeq: number }>("control.store.status")).commitSeq; for (const restarted of [false, true]) { - if (restarted) { - if (caller !== owner) caller.stop(); - owner.stop(); - owner = new ControlHost(options); - await start(); + if (restarted) { f.stop(); await f.start(); } + const allBytes = ledgerBytes(f.projectStorePath, true); + assert.equal(JSON.stringify(await f.dispatch("commands.submit", body)), JSON.stringify(accepted)); + await assert.rejects(f.dispatch("commands.submit", { ...body, flow: { ...body.flow, name: "changed-request" } }), (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal(error.code, "TF_IDEMPOTENCY_CONFLICT"); + assert.equal(error.sideEffects, "none"); + return true; + }); + for (const untrusted of [{ callerPrincipal: "other-principal" }, { authorizationContextHash: "b".repeat(64) }, { requestHash: "b".repeat(64) }, { command: {}, events: [] }]) { + await assert.rejects(f.dispatch("commands.submit", { ...body, ...untrusted }), /untrusted/); } - for (const overrides of [{}, { requestHash: "b".repeat(64) }, { callerPrincipal: "other-principal" }, { authorizationContextHash: "b".repeat(64) }]) { - await assert.rejects(caller.dispatch("commands.submit", { ...body, command: { ...body.command, ...overrides } }, { fencingEpoch: 1 }), (error: unknown) => { + for (const method of ["control.store.header", "control.store.status", "commands.submit"]) { + await assert.rejects(f.unauthenticated(method, body), (error: unknown) => { assert.ok(error instanceof ControlError); - assert.equal(error.code, "TF_IDEMPOTENCY_CONFLICT"); - assert.equal(error.sideEffects, "none"); - assert.match(error.message, /authorized command-result replay is not implemented/); + assert.equal(error.code, "TF_AUTHORITY_REVOKED"); return true; }); - const status = await caller.dispatch<{ commitSeq: number }>("control.store.status", undefined, { fencingEpoch: 1 }); - assert.equal(status.commitSeq, 1); - assert.deepEqual(fs.readFileSync(journalPath), journal); - assert.deepEqual(fs.readFileSync(path.join(projectStorePath, "commit-seq.json")), sequence); - assert.deepEqual(fs.readFileSync(path.join(projectStorePath, "projections", "commands.json")), projection); } + assert.equal((await f.dispatch<{ commitSeq: number }>("control.store.status")).commitSeq, sequence); + assert.deepEqual(ledgerBytes(f.projectStorePath), bytes); + assert.deepEqual(ledgerBytes(f.projectStorePath, true), allBytes); + assert.equal(fs.readFileSync(path.join(f.projectRoot, "effects.txt"), "utf8"), "once\n"); } - assert.equal(prepare.mock.calls.length, 0); - assert.equal(submit.mock.calls.length, 0); + assert.equal(submit.mock.calls.length, 1); }); } @@ -330,7 +343,13 @@ for (const mode of ["standalone", "auto"] as const) { const clearTimer = t.mock.method(globalThis, "clearInterval"); const failed = new ControlHost(hostOptions(paths, { mode, projectStorePath, holderId: "failed" })); t.after(() => failed.stop()); - await assert.rejects(failed.start(), /could not open project ControlStore/); + await assert.rejects(failed.start(), (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal(error.code, "TF_BOOTSTRAP_FAILED"); + assert.match(error.message, /EEXIST/); + assert.ok(error.message.includes(projectStorePath)); + return true; + }); assert.equal(failed.state, "failed-closed"); assert.equal(failed.status.singleton, "none"); assert.equal(failed.status.holderId, undefined); @@ -347,3 +366,56 @@ for (const mode of ["standalone", "auto"] as const) { assert.equal((await failed.start()).state, "started"); }); } + + +test("control-host: authenticated UDS durable approval requires the client's negotiated feature before commit", { + skip: process.platform === "win32", timeout: 20_000, +}, async (t) => { + const f = authenticatedFixture("auto"); + t.after(f.stop); + await f.start(); + const legacy = await f.connectAuthenticated([]); + const body = { commandId: randomUUID(), kind: "run.submit", approvalMode: "durable-required", flow: { + name: "wire-feature-skew", phases: [ + { id: "review", type: "approval", task: "Approve execution?" }, + { id: "work", type: "script", dependsOn: ["review"], run: "printf forbidden > effects.txt" }, + ], + } }; + const before = ledgerBytes(f.projectStorePath, true); + await assert.rejects(legacy.rpc("commands.submit", body), (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal(error.code, "TF_FEATURE_REQUIRED"); + assert.equal(error.sideEffects, "none"); + assert.match(error.message, /not negotiated/); + return true; + }); + assert.deepEqual(ledgerBytes(f.projectStorePath, true), before); + assert.deepEqual(await legacy.rpc("runs.list"), []); + assert.deepEqual(await legacy.rpc("approvals.list"), []); + + const negotiated = await f.connectAuthenticated(["durable-approval"]); + const accepted = await negotiated.rpc<{ runId: string }>("commands.submit", body); + const deadline = Date.now() + 5_000; + let approvals: ApprovalRequest[] = []; + let parked: RunSnapshot; + for (;;) { + approvals = await negotiated.rpc("approvals.list"); + parked = await negotiated.rpc("runs.get", { runId: accepted.runId }); + if (approvals.length && parked.stage === "parked" && parked.slot === "released") break; + assert.ok(Date.now() < deadline, "negotiated durable approval must park and release capacity"); + await new Promise(resolve => setTimeout(resolve, 20)); + } + assert.equal(approvals.length, 1); + assert.equal(approvals[0]!.status, "pending"); + assert.equal(approvals[0]!.runId, accepted.runId); + assert.equal(fs.existsSync(path.join(f.projectRoot, "effects.txt")), false); + await negotiated.rpc("approval.decide", { + commandId: randomUUID(), runId: accepted.runId, approvalRequestId: approvals[0]!.approvalRequestId, + expectedRunVersion: parked.runVersion, decision: "reject", + }); + const terminal = await negotiated.rpc("runs.wait", { runId: accepted.runId }); + assert.equal(terminal.status, "blocked"); + assert.equal(terminal.slot, "released"); + assert.equal((await negotiated.rpc("approvals.list"))[0]!.status, "rejected"); + assert.equal(fs.existsSync(path.join(f.projectRoot, "effects.txt")), false); +}); diff --git a/packages/taskflow-control/test/control-mcp.test.ts b/packages/taskflow-control/test/control-mcp.test.ts new file mode 100644 index 00000000..e6077e31 --- /dev/null +++ b/packages/taskflow-control/test/control-mcp.test.ts @@ -0,0 +1,181 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { policyPath, readPrivateFile } from "../src/local-bootstrap.ts"; + +const cli = fileURLToPath(new URL("../src/control-cli.ts", import.meta.url)); +interface RpcResponse { id: number | null; result?: unknown; error?: { code: number; message: string } } +function launch(args: string[]) { + const child = spawn(process.execPath, ["--conditions=development", "--experimental-strip-types", cli, ...args], { stdio: "pipe" }); + let stdout = "", stderr = "", buffer = "", sequence = 0; + const messages: RpcResponse[] = []; + const waiting = new Map(); + child.stderr.on("data", data => { stderr += String(data); }); + child.stdout.on("data", data => { + stdout += String(data); buffer += String(data); + for (;;) { + const end = buffer.indexOf("\n"); if (end === -1) break; + const line = buffer.slice(0, end); buffer = buffer.slice(end + 1); + let message: RpcResponse; + try { message = JSON.parse(line); } catch { throw new Error(`non-JSON protocol output: ${line}`); } + messages.push(message); + if (message.id !== null) { waiting.get(message.id)?.resolve(message); waiting.delete(message.id); } + } + }); + const done = new Promise(resolve => child.on("close", code => { + for (const waiter of waiting.values()) waiter.reject(new Error(`MCP exited ${code}: ${stderr}`)); + resolve(code); + })); + return { + child, messages, done, output: () => ({ stdout, stderr }), + request(method: string, params?: unknown): Promise { + const id = ++sequence; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { waiting.delete(id); reject(new Error(`MCP ${method} timed out: ${stderr}`)); }, 10000); + waiting.set(id, { resolve(value) { clearTimeout(timer); resolve(value); }, reject(error) { clearTimeout(timer); reject(error); } }); + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, ...(params === undefined ? {} : { params }) }) + "\n"); + }); + }, + async stop() { + if (child.exitCode !== null || child.signalCode !== null) return; + child.stdin.end(); child.kill("SIGTERM"); + const timer = setTimeout(() => child.kill("SIGKILL"), 3000); await done; clearTimeout(timer); + }, + }; +} +type Session = ReturnType; +async function initialize(session: Session) { + const reply = await session.request("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "control-process-test", version: "1" } }); + assert.equal(reply.error, undefined); session.child.stdin.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n'); +} +async function tool(session: Session, name: string, args: Record = {}): Promise> { + const reply = await session.request("tools/call", { name: `taskflow_control_${name}`, arguments: args }); + assert.equal(reply.error, undefined, JSON.stringify(reply)); + const result = reply.result as { isError?: boolean; content: { type: string; text: string }[] }; + assert.equal(result.isError, undefined, JSON.stringify(result)); + return JSON.parse(result.content[0].text); +} +async function until(read: () => Promise, predicate: (value: T) => boolean): Promise { + const deadline = Date.now() + 10000; + for (;;) { const value = await read(); if (predicate(value)) return value; if (Date.now() > deadline) throw new Error(`condition timed out: ${JSON.stringify(value)}`); await new Promise(resolve => setTimeout(resolve, 30)); } +} +function setup() { + const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-"))); + const root = path.join(base, "project"), second = path.join(base, "other"), home = path.join(base, "home"); + fs.mkdirSync(root); fs.mkdirSync(second); + return { base, root, second, home }; +} +function scriptFlow() { + return { name: "mcp-script", phases: [ + { id: "intermediate", type: "script", run: [process.execPath, "-e", "process.stdout.write('PRIVATE_INTERMEDIATE_MARKER')"] }, + { id: "final", type: "script", dependsOn: ["intermediate"], run: [process.execPath, "-e", "require('node:fs').writeFileSync('executed.txt','real-mcp');process.stdout.write('mcp-final')"], final: true }, + ] }; +} + +test("MCP owner uses authenticated real execution, closed tools, durable results and receipts", { timeout: 30000 }, async () => { + const { base, root, home } = setup(); let session = launch(["mcp", "--root", root, "--control-home", home]); + try { + assert.equal((await session.request("tools/list")).error?.code, -32600); + session.child.stdin.write("null\n{bad-json\n"); + await initialize(session); + const listed = (await session.request("tools/list")).result as { tools: { name: string; inputSchema: { additionalProperties: boolean } }[] }; + assert.equal(listed.tools.length, 12); assert.ok(listed.tools.every(entry => entry.inputSchema.additionalProperties === false)); + for (const forged of [{ principal: "owner" }, { callerPrincipal: "owner" }, { projectRoot: root }, { permissions: ["*"] }, { authorizationContextHash: "fake" }]) { + assert.equal((await session.request("tools/call", { name: "taskflow_control_runs", arguments: forged })).error?.code, -32602); + } + assert.equal((await session.request("tools/call", { name: "taskflow_control_cancel", arguments: { runId: "bad", commandId: randomUUID() } })).error?.code, -32602); + const accepted = await tool(session, "submit", { commandId: randomUUID(), flow: scriptFlow() }); + const runId = accepted.runId as string; + const status = await until(() => tool(session, "status", { runId }), run => run.status === "completed"); + assert.equal(status.slot, "released"); + const result = await tool(session, "result", { runId }); assert.equal(result.finalOutput, "mcp-final"); + const receipt = await tool(session, "receipt", { runId }); assert.equal(receipt.runId, runId); + assert.ok(await tool(session, "why", { runId })); + assert.equal(fs.readFileSync(path.join(root, "executed.txt"), "utf8"), "real-mcp"); + assert.ok(!session.output().stdout.includes("PRIVATE_INTERMEDIATE_MARKER")); + assert.ok(session.messages.some(message => message.error?.code === -32700)); + assert.ok(session.messages.some(message => message.error?.code === -32600 && message.id === null)); + await session.stop(); + session = launch(["mcp", "--root", root, "--control-home", home]); await initialize(session); + assert.equal((await tool(session, "status", { runId })).status, "completed"); + assert.equal((await tool(session, "result", { runId })).finalOutput, "mcp-final"); + } finally { await session.stop(); fs.rmSync(base, { recursive: true, force: true }); } +}); + +test("MCP stages real approval edits and enforces changed command permission before applying them", { timeout: 30000 }, async () => { + const { base, root, home } = setup(); const session = launch(["mcp", "--root", root, "--control-home", home]); + const flow = { name: "mcp-edit", phases: [{ id: "approval", type: "approval", task: "Review output" }, { id: "final", type: "script", dependsOn: ["approval"], run: [process.execPath, "-e", "process.stdout.write(process.argv[1])", "{steps.approval.output}"], final: true }] }; + try { + await initialize(session); + const accepted = await tool(session, "submit", { commandId: randomUUID(), flow, approvalMode: "durable-required" }); + const runId = accepted.runId; + await until(() => tool(session, "status", { runId }), value => value.status === "paused"); + const artifact = await tool(session, "approval_stage_edit", { runId, editKind: "output", content: "edited-mcp-result" }); + assert.equal(artifact.mediaType, "text/plain; charset=utf-8"); + assert.ok(!session.output().stdout.includes("edited-mcp-result")); + const status = await tool(session, "status", { runId }); + const approvals = await tool(session, "approvals") as unknown as { approvalRequestId: string }[]; + const decision = { runId, commandId: randomUUID(), approvalRequestId: approvals[0].approvalRequestId, expectedRunVersion: status.runVersion, decision: "edit", editKind: "output", editArtifactRef: artifact }; + const file = policyPath(home, root), policy = JSON.parse(readPrivateFile(file).toString()); + fs.writeFileSync(file, JSON.stringify({ ...policy, capabilities: policy.capabilities.filter((kind: string) => kind !== "approval.decide") }), { mode: 0o600 }); + const denied = await session.request("tools/call", { name: "taskflow_control_approval_decide", arguments: decision }); + assert.equal((denied.result as { isError: boolean }).isError, true); + assert.equal((await tool(session, "status", { runId })).status, "paused"); + fs.writeFileSync(file, JSON.stringify(policy), { mode: 0o600 }); + await tool(session, "approval_decide", decision); + await until(() => tool(session, "status", { runId }), value => value.status === "completed"); + assert.equal((await tool(session, "result", { runId })).finalOutput, "edited-mcp-result"); + } finally { await session.stop(); fs.rmSync(base, { recursive: true, force: true }); } +}); + +test("MCP attach retains its project scope and live policy enforcement", { timeout: 30000 }, async () => { + const { base, root, second, home } = setup(); + const owner = launch(["serve", "--root", root, "--root", second, "--control-home", home]); + let session: Session | undefined; + try { + await until(async () => owner.output().stdout, value => value.includes("\n")); + const info = JSON.parse(owner.output().stdout.split("\n")[0]) as { projects: { projectId: string; projectRoot: string }[] }; + session = launch(["mcp", "--root", root, "--control-home", home]); await initialize(session); + const projects = await tool(session, "projects") as unknown as { projectId: string }[]; + assert.equal(projects.length, 1); + const otherId = info.projects.find(project => project.projectId !== projects[0].projectId)!.projectId; + const denied = await session.request("tools/call", { name: "taskflow_control_runs", arguments: { projectId: otherId } }); + assert.equal((denied.result as { isError: boolean }).isError, true); + const accepted = await tool(session, "submit", { commandId: randomUUID(), flow: scriptFlow() }); + await until(() => tool(session!, "status", { runId: accepted.runId }), value => value.status === "completed"); + assert.equal(fs.existsSync(path.join(second, "executed.txt")), false); + assert.equal((await tool(session, "result", { runId: accepted.runId })).finalOutput, "mcp-final"); + const policyFile = policyPath(home, root), policy = JSON.parse(readPrivateFile(policyFile).toString()); + policy.capabilities = []; fs.writeFileSync(policyFile, JSON.stringify(policy), { mode: 0o600 }); + const revoked = await session.request("tools/call", { name: "taskflow_control_runs", arguments: {} }); + assert.equal((revoked.result as { isError: boolean }).isError, true); + const secret = readPrivateFile(path.join(home, "bootstrap.key")); + assert.ok(!session.output().stdout.includes(secret.toString("hex"))); + assert.ok(!session.output().stdout.includes(secret.toString("base64"))); + } finally { await session?.stop(); await owner.stop(); fs.rmSync(base, { recursive: true, force: true }); } +}); + +test("MCP durable approval and cancellation are Host operations", { timeout: 30000 }, async () => { + const { base, root, home } = setup(); const session = launch(["mcp", "--root", root, "--control-home", home]); + const approvalFlow = { name: "mcp-approval", phases: [{ id: "approval", type: "approval", task: "Approve?" }, { id: "final", type: "script", dependsOn: ["approval"], run: [process.execPath, "-e", "process.stdout.write('approved-mcp')"], final: true }] }; + try { + await initialize(session); + const accepted = await tool(session, "submit", { commandId: randomUUID(), flow: approvalFlow, approvalMode: "durable-required" }); + const parked = await until(() => tool(session, "status", { runId: accepted.runId }), value => value.status === "paused"); + const approvals = await tool(session, "approvals") as unknown as { approvalRequestId: string; status: string }[]; + assert.equal(approvals.length, 1); assert.equal(approvals[0].status, "pending"); + await tool(session, "approval_decide", { commandId: randomUUID(), runId: accepted.runId, approvalRequestId: approvals[0].approvalRequestId, expectedRunVersion: parked.runVersion, decision: "approve" }); + await until(() => tool(session, "status", { runId: accepted.runId }), value => value.status === "completed"); + assert.equal((await tool(session, "result", { runId: accepted.runId })).finalOutput, "approved-mcp"); + const pending = await tool(session, "submit", { commandId: randomUUID(), flow: approvalFlow, approvalMode: "durable-required" }); + const paused = await until(() => tool(session, "status", { runId: pending.runId }), value => value.status === "paused"); + await tool(session, "cancel", { commandId: randomUUID(), runId: pending.runId, expectedRunVersion: paused.runVersion }); + const cancelled = await until(() => tool(session, "status", { runId: pending.runId }), value => value.status === "cancelled"); + assert.equal(cancelled.slot, "released"); + } finally { await session.stop(); fs.rmSync(base, { recursive: true, force: true }); } +}); diff --git a/packages/taskflow-control/test/coordinator-store.test.ts b/packages/taskflow-control/test/coordinator-store.test.ts index 0ea36ed5..b44432df 100644 --- a/packages/taskflow-control/test/coordinator-store.test.ts +++ b/packages/taskflow-control/test/coordinator-store.test.ts @@ -46,6 +46,13 @@ function authority(max = 2, epoch = 1) { ? { status: "not-admitted" as const, reservationId: row.reservationId, projectId: row.projectId, projectControlDomainId: row.projectControlDomainId, runId: row.runId, runVersion: 0 } : { ...row, projectAdmitCommitSeq: row.projectAdmitCommitSeq!, runVersion: 1, ...admissionOverride }; }, + abandonAdmissionIfAbsent: async (row) => { + if (admissionUnavailable) throw new Error("project unavailable"); + const observed = await options.authority.readAdmission(row); + if (observed && !("status" in observed)) return observed; + return { status: "abandoned" as const, reservationId: row.reservationId, projectId: row.projectId, + projectControlDomainId: row.projectControlDomainId, runId: row.runId, runVersion: 0, abandonmentCommitSeq: 1 }; + }, readRelease: async (row) => { await releaseHook?.(); return { ...row, projectAdmitCommitSeq: row.projectAdmitCommitSeq!, runVersion: 2, proofId: "trusted-ledger-provider-proof", status: "completed", stage: "terminal", requiresReadmission: false, providerNoLiveProcessTree: true, noAmbiguousJobs: true, reconcileTimeoutOnly: false, ...releaseOverride }; }, } }; let admissionDirectory: string | undefined; @@ -412,3 +419,106 @@ test("P16 copied/moved authority, tampered audit and symlink state fail closed", fs.symlinkSync(path.join(directory, "outside.json"), path.join(directory, "coordinator.json")); await assert.rejects(openCoordinatorStore(directory, auth.options), errorCode("TF_DURABILITY_FAILED")); assert.equal(fs.readFileSync(path.join(directory, "outside.json"), "utf8"), original); }); + +async function waitForQueueSize(directory: string, minimum: number): Promise { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const entries = fs.readdirSync(path.join(directory, "writer.lock.queue")); + if (entries.length >= minimum) return; + await sleep(5); + } + assert.fail(`expected ${minimum} actual persistent mutex contenders`); +} + +test("P16 dispatch holds persistent capacity fence through preparation and synchronous launch against another process", { timeout: 20_000 }, async () => { + const directory = makePath(), auth = authority(1), store = await openCoordinatorStore(directory, auth.options); + const entered = deferred(), proceed = deferred(); + let activations = 0, releaseFinished = false; + try { + const input = binding(), row = await admitted(store, auth.context, input); + const dispatch = store.withDispatch(input.reservationId, auth.context, async (observed) => { + assert.deepEqual(observed, row); entered.resolve(); await proceed.promise; + return () => { + assert.equal(releaseFinished, false); + assert.equal(JSON.parse(bytes(directory)).reservations[input.reservationId].reservation.state, "committed"); + activations++; return "activated"; + }; + }); + await entered.promise; + const releasing = runWorker({ path: directory, mode: "forceRelease", max: 1, + identity: { principal: "operator:one", ownerId: "owner:operator", operator: true }, input, + commandId: randomUUID() }).then((result) => { releaseFinished = true; return result; }); + await waitForQueueSize(directory, 2); + assert.equal(releaseFinished, false); assert.equal(activations, 0); + assert.equal(JSON.parse(bytes(directory)).reservations[input.reservationId].reservation.state, "committed"); + proceed.resolve(); assert.equal(await dispatch, "activated"); + assert.equal((await releasing).data?.ok, true); assert.equal(activations, 1); + assert.equal((await store.readReservation(input.reservationId, auth.context))?.reservation.state, "released"); + await assert.rejects(store.withDispatch(input.reservationId, auth.context, async () => () => { activations++; }), errorCode("TF_ADMISSION_BINDING_CONFLICT")); + assert.equal(activations, 1); + } finally { proceed.resolve(); store.close(); } +}); + +for (const changed of ["epoch", "expired", "revoked", "closed"] as const) { + test(`P16 dispatch denies ${changed} authority after asynchronous preparation before activation`, async () => { + const directory = makePath(), auth = authority(1), store = await openCoordinatorStore(directory, auth.options); + const entered = deferred(), proceed = deferred(); let activations = 0; + try { + const input = binding(); await admitted(store, auth.context, input); const before = bytes(directory); + const dispatch = store.withDispatch(input.reservationId, auth.context, async () => { + entered.resolve(); await proceed.promise; return () => { activations++; }; + }); + await entered.promise; + if (changed === "epoch") auth.lease(2); + else if (changed === "expired") auth.lease(1, true); + else if (changed === "revoked") auth.revoke(); + else store.close(); + proceed.resolve(); + await assert.rejects(dispatch, errorCode(changed === "closed" ? "TF_DURABILITY_FAILED" : "TF_AUTHORITY_REVOKED")); + assert.equal(activations, 0); assert.equal(bytes(directory), before); + } finally { proceed.resolve(); store.close(); } + }); +} + +test("P16 preparing dispatch cannot make a second process overbook committed capacity", { timeout: 20_000 }, async () => { + const directory = makePath(), auth = authority(1), store = await openCoordinatorStore(directory, auth.options); + const entered = deferred(), proceed = deferred(); let activated = false; + try { + const input = binding(); await admitted(store, auth.context, input); + const dispatch = store.withDispatch(input.reservationId, auth.context, async () => { + entered.resolve(); await proceed.promise; return () => { activated = true; }; + }); + await entered.promise; + const racing = runWorker({ path: directory, mode: "reserve", max: 1, + identity: { principal: "other", ownerId: "other", operator: false }, input: binding() }); + await waitForQueueSize(directory, 2); assert.equal(activated, false); + proceed.resolve(); await dispatch; + assert.equal((await racing).data?.code, "TF_CAPACITY_EXCEEDED"); + assert.equal((await store.snapshot(auth.context)).capacity.active, 1); + } finally { proceed.resolve(); store.close(); } +}); + +for (const changed of ["expired", "closed"] as const) { + test(`P16 dispatch denies ${changed} during final awaited lease read`, async () => { + const directory = makePath(), auth = authority(1), store = await openCoordinatorStore(directory, auth.options); + const entered = deferred(), proceed = deferred(); let preparing = false, activations = 0; + const readLease = auth.options.authority.readLease; + try { + const input = binding(); await admitted(store, auth.context, input); + auth.options.authority.readLease = async () => { + const lease = await readLease(); + if (preparing) { + entered.resolve(); await proceed.promise; + if (changed === "expired") lease!.expiresAt = Date.now() - 1; + } + return lease; + }; + const dispatch = store.withDispatch(input.reservationId, auth.context, async () => { + preparing = true; return () => { activations++; }; + }); + await entered.promise; if (changed === "closed") store.close(); proceed.resolve(); + await assert.rejects(dispatch, errorCode(changed === "closed" ? "TF_DURABILITY_FAILED" : "TF_AUTHORITY_REVOKED")); + assert.equal(activations, 0); + } finally { proceed.resolve(); store.close(); } + }); +} diff --git a/packages/taskflow-control/test/evidence-adapter.test.ts b/packages/taskflow-control/test/evidence-adapter.test.ts new file mode 100644 index 00000000..0d33f321 --- /dev/null +++ b/packages/taskflow-control/test/evidence-adapter.test.ts @@ -0,0 +1,224 @@ +/** Real ControlStore journal integration; provider observation is a trusted test port. */ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { test, type TestContext } from "node:test"; +import { openControlStore } from "../src/store/store.ts"; +import { createControlEvidenceStore, compactControlEvidence, readControlEvidence, type ControlEvidenceOptions } from "../src/store/evidence-adapter.ts"; +import { ControlError } from "../src/errors.ts"; +import type { ApprovalRun } from "../src/approval-service.ts"; +import type { ArtifactRef, ReceiptAssurance } from "../src/schema/index.ts"; + +const assurance: ReceiptAssurance = { journalContinuity: true, providerOutcome: "ambiguous", artifactIntegrity: "unknown", + provenance: { confidentiality: "internal", integrity: "project" }, enforcement: { capabilities: { + resolution: "contained", mutationMediation: "brokered", processIsolation: "none", revocation: "admission-only", + baselinePolicyId: "adapter-test-policy", hostProbeSha256: "a".repeat(64), + } } }; +const buildInfo = { packageVersion: "adapter-test", gitCommit: "durable-build-identity", schemaVersion: 2 }; +const code = (expected: string) => (error: unknown) => error instanceof ControlError && error.code === expected; + +function fixture(t: TestContext) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-real-evidence-")); + let store = openControlStore(path.join(root, "project")); + t.after(() => { store.close(); fs.rmSync(root, { force: true, recursive: true }); }); + const actor = Object.freeze({ identity: Symbol("verified") }); + let revoked = false; + let observedTerminal = true; + const run: ApprovalRun = { runId: randomUUID(), projectId: store.header.projectId, controlDomainId: store.header.controlDomainId, + status: "running", stage: "executing", slot: "committed", needsOperator: false, runVersion: 0, + boundPlanHash: "ir:actual-plan", policyHash: "a".repeat(64), authorityEpoch: 1 }; + store.createRun(run); + store.recordRunEvidence(run.runId, { buildInfo, assurance }); + const options: ControlEvidenceOptions = { + authorize(candidate, scope) { + if (candidate !== actor || scope.projectId !== run.projectId || scope.controlDomainId !== run.controlDomainId) throw new ControlError("TF_POLICY_DENIED", "unverified actor or wrong project"); + if (revoked) throw new ControlError("TF_AUTHORITY_REVOKED", "revoked by current policy"); + return { principal: "test:authenticated", authorizationContextHash: "b".repeat(64) }; + }, + terminalEvidence: { verify(project, runId, evidenceId) { + const terminal = store.readJournal().flatMap(b => b.events).find(e => e.eventId === evidenceId); + if (!observedTerminal || project.projectId !== run.projectId || runId !== run.runId || terminal?.payload.kind !== "run.terminal") throw new ControlError("TF_RECONCILE_REQUIRED", "no trusted provider observation"); + if (terminal.payload.status !== "completed") throw new Error("unexpected test terminal"); + return { terminalStatus: terminal.payload.status, terminalEventId: terminal.eventId, + evidenceCommit: "trusted-provider-observation", providerOutcome: "completed" }; + } }, + }; + return { root, run, actor, options, get store() { return store; }, + open: () => createControlEvidenceStore(store, options), + reopen() { store.close(); store = openControlStore(path.join(root, "project")); }, + revoke() { revoked = true; }, unobserve() { observedTerminal = false; }, + terminal(ref?: ArtifactRef) { + const current = store.readRun(run.runId)!; + store.mutateRun(run.runId, current.runVersion, state => ({ ...state, + run: { ...state.run, status: "completed", stage: "terminal", runVersion: state.run.runVersion + 1 }, + events: [...(ref ? [{ eventId: randomUUID(), recordedAt: Date.now(), payload: { kind: "artifact.recorded" as const, runId: run.runId, commandKind: "run.submit" as const, artifact: ref } }] : []), + { eventId: randomUUID(), recordedAt: Date.now(), payload: { kind: "run.terminal", status: "completed" } }], + })); + }, + }; +} +function blob(root: string, ref: ArtifactRef) { return path.join(root, "project/artifacts/sha256", ref.digest.slice(0, 2), ref.digest); } + +test("real ledger: atomic receipt refs survive restart and preserve manifest through issuance", async t => { + const f = fixture(t), evidence = await f.open(); + const ref = await evidence.stageArtifact(Buffer.from("durable output"), { mediaType: "text/plain", storageClass: "local", redactionClass: "internal" }); + f.terminal(ref); + const before = readControlEvidence(f.store); + const batches = f.store.readJournal(); + const last = batches.at(-1)!; + assert.notEqual(before.tipHash, last.recordKind === "lifecycle-batch" && last.contentHash); + const receipt = await evidence.issueFinalReceipt(f.run.runId); + assert.deepEqual(receipt.eventManifest, before.runs[f.run.runId].eventIds); + const issuance = f.store.readJournal().at(-1)!; + assert.equal(issuance.recordKind, "lifecycle-batch"); + if (issuance.recordKind !== "lifecycle-batch") assert.fail(); + assert.ok(issuance.receiptRef && issuance.manifestProofRef); + assert.deepEqual(issuance.receipt, receipt); + const seq = f.store.commitSeq; + f.reopen(); + assert.deepEqual((await (await f.open()).readReceipt(f.actor, f.run.runId)).receipt, receipt); + assert.deepEqual(await (await f.open()).issueFinalReceipt(f.run.runId), receipt); + assert.equal(f.store.commitSeq, seq); + assert.equal((await (await f.open()).readArtifact(f.actor, { kind: "run", id: f.run.runId }, ref)).toString(), "durable output"); +}); + +test("real ledger: staged receipt without journal issuance stays unreachable", async t => { + const f = fixture(t); f.terminal(); + const evidence = await f.open(); + await evidence.prepareFinalReceipt(f.run.runId); + f.reopen(); + await assert.rejects((await f.open()).readReceipt(f.actor, f.run.runId), code("TF_POLICY_DENIED")); + assert.equal(Object.keys(readControlEvidence(f.store).receipts).length, 0); +}); + +test("real ledger: damaged receipt is preserved and never regenerated", async t => { + const f = fixture(t); f.terminal(); + await (await f.open()).issueFinalReceipt(f.run.runId); + const ref = readControlEvidence(f.store).receipts[f.run.runId].receiptRef; + fs.writeFileSync(blob(f.root, ref), "damaged"); + f.reopen(); + await assert.rejects((await f.open()).issueFinalReceipt(f.run.runId), code("TF_DURABILITY_FAILED")); + assert.equal(fs.readFileSync(blob(f.root, ref), "utf8"), "damaged"); +}); + +test("real ledger: revoked and forged actors cannot read committed receipt", async t => { + const f = fixture(t); f.terminal(); const evidence = await f.open(); + await evidence.issueFinalReceipt(f.run.runId); + await assert.rejects(evidence.readReceipt({ principal: "test:authenticated" }, f.run.runId), code("TF_POLICY_DENIED")); + f.revoke(); + await assert.rejects(evidence.readReceipt(f.actor, f.run.runId), code("TF_AUTHORITY_REVOKED")); +}); + +test("real ledger: terminal journal status alone cannot issue a receipt", async t => { + const f = fixture(t); f.terminal(); f.unobserve(); + await assert.rejects((await f.open()).issueFinalReceipt(f.run.runId), code("TF_RECONCILE_REQUIRED")); + assert.equal(Object.keys(readControlEvidence(f.store).receipts).length, 0); +}); + +test("real ledger: writer fence blocks mutations while terminal verification awaits", async t => { + const f = fixture(t); f.terminal(); + let release!: () => void, entered!: () => void; + const waiting = new Promise(resolve => { entered = resolve; }); + const gate = new Promise(resolve => { release = resolve; }); + const verify = f.options.terminalEvidence.verify; + f.options.terminalEvidence = { async verify(...args) { entered(); await gate; return verify(...args); } }; + const evidence = await f.open(); const issuance = evidence.issueFinalReceipt(f.run.runId); + await waiting; + const run = f.store.readRun(f.run.runId)!; + assert.throws(() => f.store.mutateRun(run.runId, run.runVersion, state => ({ ...state, run: { ...state.run, runVersion: state.run.runVersion + 1 }, events: [] }))); + release(); await issuance; +}); + +test("real ledger: immutable build and assurance metadata reject replacement", async t => { + const f = fixture(t); + const seq = f.store.commitSeq; + f.store.recordRunEvidence(f.run.runId, { buildInfo, assurance }); + assert.equal(f.store.commitSeq, seq); + assert.throws(() => f.store.recordRunEvidence(f.run.runId, { buildInfo: { ...buildInfo, gitCommit: "other-build" }, assurance })); +}); + + +test("real ledger: compaction checkpoint expires cursors while old receipt and all journal bytes survive", async t => { + const f = fixture(t); f.terminal(); const evidence = await f.open(); + const receipt = await evidence.issueFinalReceipt(f.run.runId); + const through = f.store.commitSeq; + const journal = path.join(f.store.storePath, "journal/000001.jsonl"); + const before = fs.readFileSync(journal); + await compactControlEvidence(f.store, evidence, through); + assert.deepEqual(fs.readFileSync(journal).subarray(0, before.length), before); + await assert.rejects(evidence.checkCursor({ nextCommitSeq: through, leaseExpiresAt: 100 }, 1), code("TF_CURSOR_EXPIRED")); + assert.equal((await evidence.checkCursor({ nextCommitSeq: through + 1, leaseExpiresAt: 100 }, 1)).minAvailableCommitSeq, through + 1); + f.reopen(); + assert.deepEqual((await (await f.open()).readReceipt(f.actor, f.run.runId)).receipt, receipt); +}); + +for (const point of ["file-published", "lifecycle-committed"] as const) test(`real ledger: SIGKILL at ${point} recovers atomic receipt reachability`, async t => { + const f = fixture(t); f.terminal(); + const storePath = f.store.storePath; f.store.close(); + const child = spawnSync(process.execPath, ["--conditions=development", "--experimental-strip-types", + new URL("./fixtures/evidence-adapter-crash.mts", import.meta.url).pathname, storePath, f.run.runId, point], + { encoding: "utf8", timeout: 15000 }); + assert.equal(child.signal, "SIGKILL", child.stderr); + f.reopen(); + const view = readControlEvidence(f.store); + assert.equal(Object.hasOwn(view.receipts, f.run.runId), point === "lifecycle-committed"); + const evidence = await f.open(); + if (point === "file-published") await assert.rejects(evidence.readReceipt(f.actor, f.run.runId), code("TF_POLICY_DENIED")); + else assert.equal((await evidence.readReceipt(f.actor, f.run.runId)).receipt.runId, f.run.runId); + const receipt = await evidence.issueFinalReceipt(f.run.runId); + assert.equal(receipt.runId, f.run.runId); + assert.equal(f.store.readJournal().filter(batch => batch.recordKind === "lifecycle-batch" && batch.receipt).length, 1); +}); + +test("real ledgers: identical staged bytes in another project do not grant reachability", async t => { + const a = fixture(t), b = fixture(t), ea = await a.open(), eb = await b.open(); + const meta = { mediaType: "text/plain", storageClass: "local", redactionClass: "internal" }; + const ref = await ea.stageArtifact(Buffer.from("shared digest"), meta); + assert.deepEqual(await eb.stageArtifact(Buffer.from("shared digest"), meta), ref); + a.terminal(ref); b.terminal(); + await ea.issueFinalReceipt(a.run.runId); + await assert.rejects(ea.readReceipt(b.actor, a.run.runId), code("TF_POLICY_DENIED")); + await assert.rejects(eb.readArtifact(b.actor, { kind: "run", id: b.run.runId }, ref), code("TF_POLICY_DENIED")); + await assert.rejects(eb.readReceipt(b.actor, a.run.runId), code("TF_POLICY_DENIED")); +}); + +test("real ledger: policy revoked after first authorization denies final disclosure", async t => { + const f = fixture(t); f.terminal(); + await (await f.open()).issueFinalReceipt(f.run.runId); + const authorize = f.options.authorize; + f.options.authorize = async (...args) => { + const decision = await authorize(...args); + f.revoke(); + return decision; + }; + await assert.rejects((await f.open()).readReceipt(f.actor, f.run.runId), code("TF_AUTHORITY_REVOKED")); +}); + +test("real ledger: damaged committed journal is rejected without replacement", async t => { + const f = fixture(t); f.terminal(); const evidence = await f.open(); + await evidence.issueFinalReceipt(f.run.runId); + const file = path.join(f.store.storePath, "journal/000001.jsonl"); + const damaged = fs.readFileSync(file, "utf8").replace("durable-build-identity", "changed-build-identity"); + fs.writeFileSync(file, damaged); + await assert.rejects(evidence.readReceipt(f.actor, f.run.runId), code("TF_DURABILITY_FAILED")); + assert.equal(fs.readFileSync(file, "utf8"), damaged); +}); + + +test("real ledger: preterminal approval artifact reachability requires same committed run without Receipt metadata", async t => { + const f = fixture(t), evidence = await f.open(); + const pending = { ...f.run, runId: randomUUID(), status: "paused" as const, stage: "parked" as const, runVersion: 0 }; + f.store.createRun(pending); + const ref = await evidence.stageArtifact(Buffer.from("approved edit"), { mediaType: "text/plain", storageClass: "local", redactionClass: "internal" }); + await assert.rejects(evidence.readArtifact(f.actor, { kind: "run", id: pending.runId }, ref), code("TF_POLICY_DENIED")); + f.store.mutateRun(pending.runId, pending.runVersion, state => ({ ...state, run: { ...state.run, runVersion: 1 }, + events: [{ eventId: randomUUID(), recordedAt: Date.now(), payload: { kind: "artifact.recorded", runId: pending.runId, commandKind: "approval.decide", artifact: ref } }] })); + assert.equal(Object.hasOwn(readControlEvidence(f.store).runs, pending.runId), false); + assert.equal((await evidence.readArtifact(f.actor, { kind: "run", id: pending.runId }, ref)).toString(), "approved edit"); + await assert.rejects(evidence.readArtifact(f.actor, { kind: "run", id: f.run.runId }, ref), code("TF_POLICY_DENIED")); + f.reopen(); + assert.equal((await (await f.open()).readArtifact(f.actor, { kind: "run", id: pending.runId }, ref)).toString(), "approved edit"); +}); diff --git a/packages/taskflow-control/test/evidence-explanation.test.ts b/packages/taskflow-control/test/evidence-explanation.test.ts new file mode 100644 index 00000000..4ffc5296 --- /dev/null +++ b/packages/taskflow-control/test/evidence-explanation.test.ts @@ -0,0 +1,52 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { randomUUID } from "node:crypto"; +import { openControlStore } from "../src/store/store.ts"; +import { explainControlEvidence, type EvidenceExplanationOptions } from "../src/store/evidence-explanation.ts"; +import { ControlError } from "../src/errors.ts"; + +function setup() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-why-real-ledger-")); + const store = openControlStore(path.join(root, "store")); + const runId = randomUUID(); + store.createRun({ runId, projectId: store.header.projectId, controlDomainId: store.header.controlDomainId, + status: "running", stage: "queued", slot: "none", needsOperator: false, runVersion: 0, + boundPlanHash: "ir:actual-test", policyHash: "a".repeat(64), authorityEpoch: 1 }); + const actor = {}, options: EvidenceExplanationOptions = { + authorize(candidate) { if (candidate !== actor) throw new ControlError("TF_POLICY_DENIED", "denied"); + return { principal: "test-verified", authorizationContextHash: "a".repeat(64) }; }, + readDiagnostic() { return undefined; }, + }; + return { store, runId, root, actor, options, close() { store.close(); fs.rmSync(root, { force: true, recursive: true }); } }; +} + +test("why: pre-dispatch run explains actual lifecycle with explicit checkpoint absence", async () => { + const f = setup(); try { + const why = await explainControlEvidence(f.store, f.actor, f.runId, f.options); + assert.equal(why.stage, "queued"); assert.equal(why.receiptIssued, false); + assert.equal(why.checkpoint.available, false); + assert.equal(why.lifecycle.length, 1); + assert.equal(why.lifecycle[0].payload.kind, "run.snapshot"); + } finally { f.close(); } +}); + +test("why: live revocation during diagnostic await prevents disclosure", async () => { + const f = setup(); try { + let revoked = false; + f.options.authorize = () => { if (revoked) throw new ControlError("TF_AUTHORITY_REVOKED", "revoked"); return { principal: "verified", authorizationContextHash: "a".repeat(64) }; }; + f.options.readDiagnostic = async () => { await Promise.resolve(); revoked = true; return undefined; }; + await assert.rejects(explainControlEvidence(f.store, f.actor, f.runId, f.options), (error: unknown) => error instanceof ControlError && error.code === "TF_AUTHORITY_REVOKED"); + } finally { f.close(); } +}); + +test("why: provider checkpoint bound to another project is rejected", async () => { + const f = setup(); try { + f.options.readDiagnostic = () => ({ runId: f.runId, projectId: randomUUID(), controlDomainId: f.store.header.controlDomainId, + controlDirectory: path.join(f.root, "resources"), projectRoot: f.root, + state: { cwd: f.root, runId: f.runId, flowName: "wrong-project", def: { name: "wrong-project", phases: [] }, phases: {}, args: {}, status: "running", createdAt: 1, updatedAt: 1 } }); + await assert.rejects(explainControlEvidence(f.store, f.actor, f.runId, f.options), (error: unknown) => error instanceof ControlError && error.code === "TF_DURABILITY_FAILED"); + } finally { f.close(); } +}); diff --git a/packages/taskflow-control/test/evidence-host-integration.test.ts b/packages/taskflow-control/test/evidence-host-integration.test.ts new file mode 100644 index 00000000..7d2862ec --- /dev/null +++ b/packages/taskflow-control/test/evidence-host-integration.test.ts @@ -0,0 +1,64 @@ +/** Real Host -> ControlStore -> concrete TE runtime -> receipt and diagnostics. */ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { randomUUID, randomBytes } from "node:crypto"; +import { ControlHost } from "../src/control-host.ts"; +import { RuntimeTeExecutionProvider } from "../src/runtime-provider.ts"; +import { ProjectRegistry } from "../src/project-registry.ts"; +import { AUTHORIZATION_CAPABILITIES, createAuthorizationAuthority, type VerifiedContext } from "../src/authorization.ts"; +import { createControlEvidenceStore } from "../src/store/evidence-adapter.ts"; +import type { explainControlEvidence } from "../src/store/evidence-explanation.ts"; +import type { RunSnapshot, Receipt } from "../src/schema/index.ts"; + +test("real Host receipt contains actual TE output and why explains checkpoint provenance", { timeout: 30000 }, async () => { + const base = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "tf-host-evidence-")); + const root = path.join(base, "project"); fs.mkdirSync(root); + const registry = new ProjectRegistry(path.join(base, "registry.json")); + const mount = registry.mount(path.join(root, ".taskflow/control"), root); + let allowed = true; + const authority = createAuthorizationAuthority({ ownerUid: process.getuid!(), bootstrapSecret: randomBytes(32), hostBaseline: AUTHORIZATION_CAPABILITIES, + loadLivePolicy: () => ({ host: { capabilities: allowed ? AUTHORIZATION_CAPABILITIES.map(kind => ({ kind, scopeRoot: root })) : [] } }) }); + const context = authority.issueStandalone({ projectId: mount.store.header.projectId, controlDomainId: mount.store.header.controlDomainId, projectRoot: root }); + const provider = new RuntimeTeExecutionProvider(path.join(base, "provider")); + const host = new ControlHost({ mode: "standalone", controlHome: path.join(base, "home"), registry, authorization: authority, provider, + evidenceFactory: (project, verify) => createControlEvidenceStore(project.store, { + terminalEvidence: { verify }, + authorize: (actor, scope) => authority.authorize(actor as VerifiedContext, { projectId: scope.projectId, + controlDomainId: scope.controlDomainId, projectRoot: project.projectRoot, operation: "replay", commandKind: "run.submit" }), + }) }); + try { + await host.start(); + const accepted = await host.dispatchAuthenticated<{ runId: string }>(context, "commands.submit", { commandId: randomUUID(), kind: "run.submit", + flow: { name: "evidence-actual", phases: [{ id: "first", type: "script", run: "printf first-output", effects: [{ id: "declared-output", kind: "fs.write", target: { kind: "path", path: { workspace: "project", subpath: { literalPath: "declared.txt" }, access: "read-write", intent: "create-file", maxLifetime: { scope: "phase" } } } }] }, + { id: "second", type: "script", dependsOn: ["first"], run: "printf second-output" }] } }); + const run = await host.dispatchAuthenticated(context, "runs.wait", { runId: accepted.runId }); + assert.equal(run.status, "completed"); + const receipt = await host.dispatchAuthenticated(context, "receipts.get", { runId: run.runId }); + assert.equal(receipt.runId, run.runId); + assert.equal(receipt.assurance.providerOutcome, "completed"); + assert.equal(receipt.assurance.artifactIntegrity, "verified"); + assert.equal(receipt.artifactRefs.length, 1); + const output = receipt.artifactRefs[0]; + assert.equal(fs.readFileSync(path.join(mount.store.storePath, "artifacts/sha256", output.digest.slice(0, 2), output.digest), "utf8"), "second-output"); + const why = await host.dispatchAuthenticated>>(context, "evidence.why", { runId: run.runId, seeds: ["first"] }); + assert.equal(why.status, "completed"); assert.equal(why.receiptIssued, true); + assert.equal(why.checkpoint.available, true); + if (!why.checkpoint.available) assert.fail("checkpoint unavailable"); + assert.match(why.checkpoint.stale!, /why-stale/); + assert.match(why.checkpoint.stale!, /first/); + assert.ok(why.lifecycle.some(event => event.payload.kind === "run.terminal")); + const unknownEffect = await host.dispatchAuthenticated>>(context, "evidence.why", { runId: run.runId, effectId: "absent-effect" }); + if (!unknownEffect.checkpoint.available) assert.fail(); + assert.equal(unknownEffect.checkpoint.effect?.ok, false); + const declaredEffect = await host.dispatchAuthenticated>>(context, "evidence.why", { runId: run.runId, effectId: "declared-output", phaseId: "first" }); + if (!declaredEffect.checkpoint.available || !declaredEffect.checkpoint.effect?.ok) assert.fail("declared effect diagnostic missing"); + assert.equal(declaredEffect.checkpoint.effect.why.authorized.allowed, false); + assert.match(declaredEffect.checkpoint.effect.why.reasons.join(" "), /durable|journal|intent/); + allowed = false; + await assert.rejects(host.dispatchAuthenticated(context, "evidence.why", { runId: run.runId }), /capability|denied|revoked/); + await assert.rejects(host.dispatchAuthenticated(context, "receipts.get", { runId: run.runId }), /capability|denied|revoked/); + } finally { host.stop(); registry.close(); fs.rmSync(base, { force: true, recursive: true }); } +}); diff --git a/packages/taskflow-control/test/fixtures/admission-worker.ts b/packages/taskflow-control/test/fixtures/admission-worker.ts new file mode 100644 index 00000000..8e06a057 --- /dev/null +++ b/packages/taskflow-control/test/fixtures/admission-worker.ts @@ -0,0 +1,67 @@ +/** Process harness around production ControlStore/ProjectAdmissionService. */ +import { openControlStore, type ControlStore } from "../../src/store/store.ts"; +import { ProjectAdmissionService } from "../../src/admission-service.ts"; +import { openCoordinatorStore, type CoordinatorStoreOptions } from "../../src/store/coordinator-store.ts"; +import type { ConcurrencyReservation } from "../../src/schema/coordinator.ts"; +import { ControlError } from "../../src/errors.ts"; + +export interface AdmissionWorkerConfig { + projectPath: string; coordinatorPath: string; reservation: ConcurrencyReservation; + mode: "admit" | "abandon" | "recover" | "dispatch"; + killAfterDecision?: boolean; waitForStart?: boolean; +} +export async function withProject(directory: string, apply: (service: ProjectAdmissionService, store: ControlStore) => T): Promise { + const deadline = Date.now() + 10_000; + let store: ControlStore; + for (;;) { + try { store = openControlStore(directory); break; } + catch (error) { + if (!(error instanceof ControlError) || !error.message.includes("live or unverified writer") || Date.now() >= deadline) throw error; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + } + try { return apply(new ProjectAdmissionService(store), store); } + finally { store.close(); } +} +export function coordinatorOptions(config: AdmissionWorkerConfig): CoordinatorStoreOptions { + return { initialMaxActiveRuns: 1, epoch: 1, holderId: "admission-test-host", authority: { + readLease: () => ({ holderId: "admission-test-host", fencingEpoch: 1, endpoint: "test://admission", expiresAt: Date.now() + 60_000 }), + authorize: () => ({ principal: "test-user", ownerId: "test-owner", operator: false }), + readAdmission: (reservation) => withProject(config.projectPath, (service) => service.readAdmission(reservation)), + abandonAdmissionIfAbsent: async (reservation) => { + const result = await withProject(config.projectPath, (service) => service.abandonAdmissionIfAbsent(reservation)); + if (config.killAfterDecision) process.kill(process.pid, "SIGKILL"); + return result; + }, + readRelease: () => { throw new Error("release is outside admission arbitration"); }, + } }; +} +async function main(config: AdmissionWorkerConfig) { + if (config.waitForStart) { + process.send?.({ ready: true }); + await new Promise((resolve) => process.once("message", () => resolve())); + } + try { + let result: unknown; + if (config.mode === "admit" || config.mode === "abandon") { + result = await withProject(config.projectPath, (service) => config.mode === "admit" + ? service.admit(config.reservation, 0) : service.abandonAdmissionIfAbsent(config.reservation)); + if (config.killAfterDecision) process.kill(process.pid, "SIGKILL"); + } else { + const coordinator = await openCoordinatorStore(config.coordinatorPath, coordinatorOptions(config)); + try { + if (config.mode === "recover") result = await coordinator.snapshot({}); + else { + const row = await coordinator.readReservation(config.reservation.reservationId, {}); + if (!row) throw new Error("missing reservation"); + result = await withProject(config.projectPath, (service) => service.assertDispatch(row.reservation)); + } + } finally { coordinator.close(); } + } + process.stdout.write(JSON.stringify({ ok: true, result }) + "\n"); + } catch (error) { + process.stdout.write(JSON.stringify({ ok: false, code: error instanceof ControlError ? error.code : "unexpected", message: String(error) }) + "\n"); + process.exitCode = 2; + } finally { process.disconnect?.(); } +} +if (process.argv[2]) await main(JSON.parse(process.argv[2]) as AdmissionWorkerConfig); diff --git a/packages/taskflow-control/test/fixtures/approval-replay-worker.ts b/packages/taskflow-control/test/fixtures/approval-replay-worker.ts new file mode 100644 index 00000000..a83ba45f --- /dev/null +++ b/packages/taskflow-control/test/fixtures/approval-replay-worker.ts @@ -0,0 +1,9 @@ +/** Crash only this private test process after real journal fsync, before response. */ +import { readFileSync } from "node:fs"; +import { approvalFixture } from "./approval-worker.ts"; +const directory=process.env.TF_REPLAY_DIRECTORY!; +const fixture=await approvalFixture(directory); +const input=JSON.parse(readFileSync(process.env.TF_REPLAY_INPUT!,"utf8")); +process.env.TASKFLOW_CONTROL_CRASH_AT="lifecycle-committed"; +await fixture.service.decide(fixture.context,input); +throw new Error("injected crash did not happen"); diff --git a/packages/taskflow-control/test/fixtures/coordinator-worker.ts b/packages/taskflow-control/test/fixtures/coordinator-worker.ts index eb627d40..971972ce 100644 --- a/packages/taskflow-control/test/fixtures/coordinator-worker.ts +++ b/packages/taskflow-control/test/fixtures/coordinator-worker.ts @@ -24,6 +24,9 @@ try { return row.projectAdmitCommitSeq === undefined ? { status: "not-admitted" as const, reservationId: row.reservationId, projectId: row.projectId, projectControlDomainId: row.projectControlDomainId, runId: row.runId, runVersion: 0 } : { ...row, projectAdmitCommitSeq: config.input.projectAdmitCommitSeq ?? 7, runVersion: 1 }; }, + abandonAdmissionIfAbsent: (row) => ({ status: "abandoned" as const, reservationId: row.reservationId, + projectId: row.projectId, projectControlDomainId: row.projectControlDomainId, runId: row.runId, + runVersion: 0, abandonmentCommitSeq: 1 }), readRelease: (row) => ({ ...row, projectAdmitCommitSeq: row.projectAdmitCommitSeq!, runVersion: 2, proofId: "fixture-provider-proof", status: "completed", stage: "terminal", requiresReadmission: false, providerNoLiveProcessTree: true, noAmbiguousJobs: true, reconcileTimeoutOnly: false }), } }); if (config.mode === "persist-admission") { diff --git a/packages/taskflow-control/test/fixtures/evidence-adapter-crash.mts b/packages/taskflow-control/test/fixtures/evidence-adapter-crash.mts new file mode 100644 index 00000000..b2e2f22e --- /dev/null +++ b/packages/taskflow-control/test/fixtures/evidence-adapter-crash.mts @@ -0,0 +1,16 @@ +import { openControlStore } from "../../src/store/store.ts"; +import { createControlEvidenceStore } from "../../src/store/evidence-adapter.ts"; +const [storePath, runId, point] = process.argv.slice(2); +const store = openControlStore(storePath); +const evidence = await createControlEvidenceStore(store, { + authorize() { throw new Error("crash worker never discloses"); }, + terminalEvidence: { verify(_project, _runId, evidenceId) { + const terminal = store.readJournal().flatMap(batch => batch.events).find(event => event.eventId === evidenceId); + if (terminal?.payload.kind !== "run.terminal" || terminal.payload.status !== "completed") throw new Error("missing seeded test terminal"); + return { terminalStatus: "completed", terminalEventId: evidenceId, evidenceCommit: "trusted-test-provider", providerOutcome: "completed" }; + } }, + onDurabilityPoint(observed) { if (observed === point) process.kill(process.pid, "SIGKILL"); }, +}); +if (point === "lifecycle-committed") process.env.TASKFLOW_CONTROL_CRASH_AT = point; +await evidence.issueFinalReceipt(runId); +throw new Error("expected crash did not occur"); diff --git a/packages/taskflow-control/test/fixtures/legacy-writer.ts b/packages/taskflow-control/test/fixtures/legacy-writer.ts new file mode 100644 index 00000000..ad542c5f --- /dev/null +++ b/packages/taskflow-control/test/fixtures/legacy-writer.ts @@ -0,0 +1,7 @@ +/** Real legacy persistence API in a separate, externally owned process. */ +import { saveRun, newRunId } from "taskflow-core"; +const root = process.argv[2]; +const runId = newRunId("old-writer"); +saveRun({ runId, flowName: "old-writer", cwd: root, def: { name: "old-writer", phases: [] }, args: {}, phases: {}, status: "running", createdAt: Date.now(), updatedAt: Date.now(), pid: process.pid }, { maxKeep: 0, maxAgeDays: 0 }); +process.stdout.write(JSON.stringify({ pid: process.pid, runId }) + "\n"); +setInterval(() => {}, 1000); diff --git a/packages/taskflow-control/test/fixtures/runtime-restart-worker.ts b/packages/taskflow-control/test/fixtures/runtime-restart-worker.ts new file mode 100644 index 00000000..958cde7e --- /dev/null +++ b/packages/taskflow-control/test/fixtures/runtime-restart-worker.ts @@ -0,0 +1,84 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { randomBytes, randomUUID } from "node:crypto"; +import { ControlHost } from "../../src/control-host.ts"; +import { ProjectRegistry } from "../../src/project-registry.ts"; +import { RuntimeTeExecutionProvider, type RuntimeExecutionInput } from "../../src/runtime-provider.ts"; +import { AUTHORIZATION_CAPABILITIES, createAuthorizationAuthority, type VerifiedContext } from "../../src/authorization.ts"; +import { createControlEvidenceStore } from "../../src/store/evidence-adapter.ts"; +import type { ArtifactRef } from "../../src/schema/evidence.ts"; +import type { RunSnapshot } from "../../src/schema/run.ts"; + +const [base, action] = process.argv.slice(2) as [string, string]; +const root = path.join(base, "project"); +fs.mkdirSync(root, { recursive: true }); +const registry = new ProjectRegistry(path.join(base, "registry.json")); +const mount = registry.mount(path.join(root, ".taskflow", "control"), root); +const authority = createAuthorizationAuthority({ ownerUid: process.getuid!(), bootstrapSecret: randomBytes(32), hostBaseline: AUTHORIZATION_CAPABILITIES, + loadLivePolicy: () => ({ host: { capabilities: AUTHORIZATION_CAPABILITIES.map(kind => ({ kind, scopeRoot: root })) } }) }); +const context = authority.issueStandalone({ projectId: mount.store.header.projectId, controlDomainId: mount.store.header.controlDomainId, projectRoot: root }); +class CrashBoundaryProvider extends RuntimeTeExecutionProvider { + #recoveries=0; + override recoverableExecution(handle:string) { + const execution=super.recoverableExecution(handle); + if(action.endsWith("commit-pause") && ++this.#recoveries===2){ + // Fault injection after the real approval journal commit, before readmission. + process.send!({kind:"edited",runId:handle,approval:mount.store.listApprovals().find(item=>item.runId===handle)}); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0); + } + return execution; + } + override async resumeExecution(handle:string,input:RuntimeExecutionInput){ + if(action.endsWith("readmitted-pause")){ + process.send!({kind:"edited",runId:handle,approval:mount.store.listApprovals().find(item=>item.runId===handle),run:mount.store.readRun(handle)}); + await new Promise(()=>{}); + } + return super.resumeExecution(handle,input); + } +} +const provider = new CrashBoundaryProvider(path.join(base, "provider")); +const host = new ControlHost({ mode: "standalone", trustedInProcessFeatures: ["durable-approval"], controlHome: path.join(base, "home"), registry, authorization: authority, provider, + evidenceFactory:(project,verify)=>createControlEvidenceStore(project.store,{terminalEvidence:{verify},authorize:(actor,scope)=>authority.authorize(actor as VerifiedContext,{projectId:scope.projectId,controlDomainId:scope.controlDomainId,projectRoot:project.projectRoot,operation:"replay",commandKind:"approval.decide"})}) }); +await host.start(); +try { + if (action.startsWith("start")) { + const accepted = await host.dispatchAuthenticated<{ runId: string }>(context, "commands.submit", { + commandId: randomUUID(), kind: "run.submit", approvalMode: "durable-required", + flow: { name: "kill-approval-restart", phases: [ + { id: "before", type: "script", run: "printf x >> before-count; printf before-output", idempotent: false }, + { id: "approve", type: "approval", dependsOn: ["before"], task: "Proceed?" }, + { id: "after", type: "script", dependsOn: ["approve"], run: action==="start-output"?"printf x >> after-count; cat":"printf x >> after-count; printf after-output", ...(action==="start-output"?{input:"{steps.approve.output}"}:{}), idempotent: false }, + ] }, + }); + const deadline = Date.now() + 10000; + while (Date.now() < deadline) { + const run = mount.store.readRun(accepted.runId)!; + if (run.stage === "parked" && run.slot === "released") { + process.send!({ kind: "parked", runId: accepted.runId, pid: process.pid }); + await new Promise(() => {}); + } + await new Promise(resolve => setTimeout(resolve, 10)); + } + throw new Error("approval never parked"); + } else { + const approval = mount.store.listApprovals().find(item => item.status === "pending" || action==="resume-committed" && item.status==="edited")!; + if (!approval) throw new Error("missing durable pending approval"); + const run = mount.store.readRun(approval.runId)!; + if(action==="resume-committed")await host.dispatchAuthenticated(context,"runs.resume",{runId:run.runId}); + else if(action.endsWith("pause")){ + const editKind=action.startsWith("plan")?"plan":"output"; + const edited=structuredClone(provider.readDiagnostic(run.runId)!.state.def); + edited.phases.find(phase=>phase.id==="after")!.run="printf x >> after-count; printf restart-edited-plan"; + const artifact=await host.dispatchAuthenticated(context,"approvals.stageEdit",{runId:run.runId,editKind,content:editKind==="plan"?JSON.stringify(edited):"restart-edited-output"}); + await host.dispatchAuthenticated(context,"approval.decide",{commandId:randomUUID(),runId:run.runId,approvalRequestId:approval.approvalRequestId,expectedRunVersion:mount.store.readRun(run.runId)!.runVersion,decision:"edit",editKind,editArtifactRef:artifact}); + }else await host.dispatchAuthenticated(context, "approval.decide", { commandId: randomUUID(), runId: run.runId, approvalRequestId: approval.approvalRequestId, + expectedRunVersion: run.runVersion, decision: "approve" }); + const final = await host.dispatchAuthenticated(context, "runs.wait", { runId: run.runId }); + process.send!({ kind: "completed", run: final, observation: await provider.observe(run.runId) }); + } +} catch (error) { + process.send!({ kind: "error", error: error instanceof Error ? error.stack : String(error) }); + process.exitCode = 1; +} finally { + host.stop(); registry.close(); process.disconnect?.(); +} diff --git a/packages/taskflow-control/test/fixtures/store-mutate.ts b/packages/taskflow-control/test/fixtures/store-mutate.ts index 80997727..7c336931 100644 --- a/packages/taskflow-control/test/fixtures/store-mutate.ts +++ b/packages/taskflow-control/test/fixtures/store-mutate.ts @@ -23,7 +23,7 @@ const projectId = process.env.TF_TEST_PROJECT_ID ?? "00000000-0000-0000-0000-000 const domainId = process.env.TF_TEST_DOMAIN_ID ?? "00000000-0000-0000-0000-000000000001"; try { - const store = openControlStore(storePath, { projectId, controlDomainId: domainId }); + const store = openControlStore(storePath, action === "init" || process.env.TF_TEST_PROJECT_ID || process.env.TF_TEST_DOMAIN_ID ? { projectId, controlDomainId: domainId } : {}); if (action === "mutate") { store.appendBatch({ command: { diff --git a/packages/taskflow-control/test/host-runtime-integration.test.ts b/packages/taskflow-control/test/host-runtime-integration.test.ts new file mode 100644 index 00000000..99e8ec41 --- /dev/null +++ b/packages/taskflow-control/test/host-runtime-integration.test.ts @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { randomUUID, randomBytes } from "node:crypto"; +import { ControlHost } from "../src/control-host.ts"; +import { RuntimeTeExecutionProvider } from "../src/runtime-provider.ts"; +import { ProjectRegistry } from "../src/project-registry.ts"; +import { AUTHORIZATION_CAPABILITIES, createAuthorizationAuthority } from "../src/authorization.ts"; +import type { RunSnapshot } from "../src/schema/run.ts"; + +async function fixture(features:readonly string[] = ["durable-approval"]) { + const base = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "control-host-real-")); + const root = path.join(base,"project"); fs.mkdirSync(root); + const registry = new ProjectRegistry(path.join(base,"registry.json")); + const mount = registry.mount(path.join(root,".taskflow","control"),root); + let allowed = true; + const authority = createAuthorizationAuthority({ ownerUid: process.getuid!(), bootstrapSecret: randomBytes(32), hostBaseline: AUTHORIZATION_CAPABILITIES, + loadLivePolicy: () => ({host:{capabilities: allowed ? AUTHORIZATION_CAPABILITIES.map(kind=>({kind,scopeRoot:root})) : []}}) }); + const context = authority.issueStandalone({projectId:mount.store.header.projectId,controlDomainId:mount.store.header.controlDomainId,projectRoot:root}); + const provider = new RuntimeTeExecutionProvider(path.join(base,"provider")); + const host = new ControlHost({mode:"standalone",controlHome:path.join(base,"home"),registry,authorization:authority,provider,trustedInProcessFeatures:features}); + await host.start(); + return {base,root,registry,mount,authority,context,provider,host,revoke:()=>{allowed=false;},close:()=>{host.stop();registry.close();fs.rmSync(base,{recursive:true,force:true});}}; +} +test("real Host submits through project admission and TE runtime, persists exact response and terminal result", async()=>{ + const f = await fixture(); try { + const body={commandId:randomUUID(),kind:"run.submit",flow:{name:"actual-script",phases:[{id:"work",type:"script",run:"printf real-runtime"}]}}; + const accepted = await f.host.dispatchAuthenticated<{runId:string}>(f.context,"commands.submit",body); + const run = await f.host.dispatchAuthenticated(f.context,"runs.wait",{runId:accepted.runId}); + assert.equal(run.status,"completed"); assert.equal(run.slot,"released"); + assert.equal((await f.provider.observe(run.runId)).finalOutput,"real-runtime"); + assert.deepEqual(await f.host.dispatchAuthenticated(f.context,"commands.submit",body),accepted); + assert.equal(f.mount.store.listRuns().length,1); + const wal = fs.readFileSync(path.join(f.base,"provider","resources",run.projectId,"resource-journal.wal.jsonl"),"utf8"); + assert.match(wal,/write-intent/); assert.match(wal,/write-commit/); + f.revoke(); await assert.rejects(f.host.dispatchAuthenticated(f.context,"commands.submit",body), /capability/); + } finally {f.close();} +}); +test("forged body authority is rejected before any project write", async()=>{ + const f=await fixture();try { + const seq=f.mount.store.snapshot().commitSeq; + await assert.rejects(f.host.dispatchAuthenticated(f.context,"commands.submit",{command:{callerPrincipal:"operator"},events:[]}),/untrusted/); + assert.equal(f.mount.store.snapshot().commitSeq,seq); + }finally{f.close();} +}); +test("actual TE approval parks, durable decision replays exactly, readmission resumes downstream once",{timeout:20000},async()=>{ + const f=await fixture();try{ + const accepted=await f.host.dispatchAuthenticated<{runId:string}>(f.context,"commands.submit",{commandId:randomUUID(),kind:"run.submit",approvalMode:"durable-required",flow:{name:"human",phases:[{id:"approval",type:"approval",task:"Approve?"},{id:"work",type:"script",dependsOn:["approval"],run:"printf approved-runtime"}]}}); + let approvals=f.mount.store.listApprovals();const deadline=Date.now()+5000; + while(!approvals.length&&Date.now()setTimeout(r,20));approvals=f.mount.store.listApprovals();} + assert.equal(approvals.length,1); assert.equal(approvals[0]!.status,"pending"); + let parked=f.mount.store.readRun(accepted.runId)!; + while(parked.slot!=="released"&&Date.now()setTimeout(r,20));parked=f.mount.store.readRun(accepted.runId)!;} + assert.equal(parked.stage,"parked");assert.equal(parked.slot,"released"); + const body={commandId:randomUUID(),runId:accepted.runId,approvalRequestId:approvals[0]!.approvalRequestId,expectedRunVersion:parked.runVersion,decision:"approve"}; + const decided=await f.host.dispatchAuthenticated(f.context,"approval.decide",body); + const completed=await f.host.dispatchAuthenticated(f.context,"runs.wait",{runId:accepted.runId}); + assert.equal(completed.status,"completed");assert.equal(completed.slot,"released"); + assert.deepEqual(await f.host.dispatchAuthenticated(f.context,"approval.decide",body),decided); + assert.equal((await f.provider.observe(completed.runId)).finalOutput,"approved-runtime"); + }finally{f.close();} +}); +test("durable-required fails before command commit without negotiated feature; optional auto-rejects",async()=>{ + const f=await fixture([]);try{ + const flow={name:"feature-skew",phases:[{id:"review",type:"approval",task:"Approve?"}]}; + const seq=f.mount.store.snapshot().commitSeq; + await assert.rejects(f.host.dispatchAuthenticated(f.context,"commands.submit",{commandId:randomUUID(),kind:"run.submit",approvalMode:"durable-required",flow}),/not negotiated/); + assert.equal(f.mount.store.snapshot().commitSeq,seq); + const accepted=await f.host.dispatchAuthenticated<{runId:string}>(f.context,"commands.submit",{commandId:randomUUID(),kind:"run.submit",approvalMode:"durable-optional",flow}); + const run=await f.host.dispatchAuthenticated(f.context,"runs.wait",{runId:accepted.runId}); + assert.equal(run.status,"blocked");assert.equal(run.slot,"released"); + assert.equal(f.mount.store.listApprovals()[0]!.status,"rejected"); + }finally{f.close();} +}); diff --git a/packages/taskflow-control/test/legacy-conflict.test.ts b/packages/taskflow-control/test/legacy-conflict.test.ts new file mode 100644 index 00000000..05d0168f --- /dev/null +++ b/packages/taskflow-control/test/legacy-conflict.test.ts @@ -0,0 +1,87 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { randomBytes, randomUUID } from "node:crypto"; +import { runsDir, probeProcess } from "taskflow-core"; +import { LegacyConflictGuard } from "../src/legacy-conflict.ts"; +import { ControlError } from "../src/errors.ts"; +import { ProjectRegistry } from "../src/project-registry.ts"; +import { createAuthorizationAuthority, AUTHORIZATION_CAPABILITIES } from "../src/authorization.ts"; +import { RuntimeTeExecutionProvider } from "../src/runtime-provider.ts"; +import { ControlHost } from "../src/control-host.ts"; + +const worker = fileURLToPath(new URL("./fixtures/legacy-writer.ts", import.meta.url)); +const isConflict = (error: unknown) => error instanceof ControlError && error.code === "TF_LEGACY_CONFLICT" && error.recoveryAction === "operator"; +function setup() { + const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "tf-legacy-"))); + const root = path.join(base, "project"), store = path.join(root, ".taskflow", "control"); fs.mkdirSync(store, { recursive: true }); + return { base, root, store, guard: new LegacyConflictGuard(root, store) }; +} +async function writer(root: string) { + const child = spawn(process.execPath, ["--conditions=development", "--experimental-strip-types", worker, root], { stdio: ["ignore", "pipe", "pipe"] }); + const closed = new Promise(resolve => child.once("close", () => resolve())); + const record = await new Promise<{ pid: number; runId: string }>((resolve, reject) => { + let text = "", stderr = ""; const timer = setTimeout(() => { child.kill(); reject(new Error(`legacy writer timeout ${stderr}`)); }, 5000); + child.stderr.on("data", data => { stderr += String(data); }); + child.once("error", error => { clearTimeout(timer); reject(error); }); + child.stdout.on("data", data => { text += String(data); if (text.includes("\n")) { clearTimeout(timer); resolve(JSON.parse(text.split("\n")[0])); } }); + }); + return { ...record, async stop() { if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM"); await closed; } }; +} + +test("observed old writer freezes fresh Host admission until stopped and explicitly acknowledged", { timeout: 20000 }, async () => { + const f = setup(), registry = new ProjectRegistry(path.join(f.base, "registry.json")); + const mount = registry.mount(f.store, f.root); + const authority = createAuthorizationAuthority({ ownerUid: process.getuid!(), bootstrapSecret: randomBytes(32), hostBaseline: AUTHORIZATION_CAPABILITIES, loadLivePolicy: () => ({ host: { capabilities: AUTHORIZATION_CAPABILITIES.map(kind => ({ kind, scopeRoot: f.root })) } }) }); + const context = authority.issueStandalone({ projectId: mount.store.header.projectId, controlDomainId: mount.store.header.controlDomainId, projectRoot: f.root }); + const host = new ControlHost({ mode: "standalone", controlHome: path.join(f.base, "home"), registry, authorization: authority, provider: new RuntimeTeExecutionProvider(path.join(f.base, "provider")) }); + const legacy = await writer(f.root); + try { + await host.start(); + const index = path.join(runsDir(f.root), "index.json"), before = fs.readFileSync(index); + const seq = mount.store.snapshot().commitSeq; + const request = { commandId: randomUUID(), kind: "run.submit", flow: { name: "after-old", phases: [{ id: "work", type: "script", run: [process.execPath, "-e", "process.stdout.write('after-old-writer')"] }] } }; + await assert.rejects(host.dispatchAuthenticated(context, "commands.submit", request), isConflict); + assert.equal(mount.store.snapshot().commitSeq, seq); assert.deepEqual(fs.readFileSync(index), before); + assert.equal(probeProcess(legacy.pid), "alive"); assert.throws(() => f.guard.acknowledgeStopped(), isConflict); + await legacy.stop(); assert.equal(probeProcess(legacy.pid), "dead"); + assert.throws(() => new LegacyConflictGuard(f.root, f.store).assertMayAttempt(), isConflict); + f.guard.acknowledgeStopped(); f.guard.assertMayAttempt(); + const accepted = await host.dispatchAuthenticated<{ runId: string }>(context, "commands.submit", request); + const terminal = await host.dispatchAuthenticated<{ status: string }>(context, "runs.wait", { runId: accepted.runId }); assert.equal(terminal.status, "completed"); + assert.deepEqual(fs.readFileSync(index), before); + } finally { await legacy.stop(); host.stop(); registry.close(); fs.rmSync(f.base, { recursive: true, force: true }); } +}); + +test("captured PID remains blocking after its legacy record is hidden; unknown owner cannot be acknowledged by removal", { timeout: 10000 }, async () => { + const f = setup(), legacy = await writer(f.root); + try { + assert.throws(() => f.guard.assertMayAttempt(), isConflict); + const record = path.join(runsDir(f.root), "old-writer", `${legacy.runId}.json`); + fs.writeFileSync(record, JSON.stringify({ ...JSON.parse(fs.readFileSync(record, "utf8")), status: "completed" })); + assert.ok(f.guard.inspect().some(observation => observation.pid === legacy.pid), "a terminal label does not prove the recorded owner stopped"); + fs.unlinkSync(record); + assert.throws(() => f.guard.acknowledgeStopped(), isConflict); assert.equal(probeProcess(legacy.pid), "alive"); + await legacy.stop(); f.guard.acknowledgeStopped(); f.guard.assertMayAttempt(); + const lock = path.join(runsDir(f.root), "index.json.lock"); fs.writeFileSync(lock, "{}"); + assert.throws(() => f.guard.assertMayAttempt(), isConflict); fs.unlinkSync(lock); + assert.throws(() => f.guard.acknowledgeStopped(), isConflict); + fs.writeFileSync(lock, JSON.stringify({ pid: legacy.pid, ts: Date.now() })); + f.guard.acknowledgeStopped(); f.guard.assertMayAttempt(); + } finally { await legacy.stop(); fs.rmSync(f.base, { recursive: true, force: true }); } +}); + +test("legacy inspection does not rebuild index and refuses symlink or oversized evidence", () => { + const f = setup(); + try { + const root = runsDir(f.root); fs.mkdirSync(root, { recursive: true }); + f.guard.assertMayAttempt(); assert.equal(fs.existsSync(path.join(root, "index.json")), false); + fs.writeFileSync(path.join(root, "large.json"), " ".repeat(1024 * 1024 + 1)); assert.throws(() => f.guard.assertMayAttempt(), isConflict); + fs.unlinkSync(path.join(root, "large.json")); fs.symlinkSync(f.store, path.join(root, "outside")); + assert.ok(f.guard.inspect().some(observation => observation.path.endsWith("outside"))); + } finally { fs.rmSync(f.base, { recursive: true, force: true }); } +}); diff --git a/packages/taskflow-control/test/project-admin-cli.test.ts b/packages/taskflow-control/test/project-admin-cli.test.ts new file mode 100644 index 00000000..7bc5380d --- /dev/null +++ b/packages/taskflow-control/test/project-admin-cli.test.ts @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { randomUUID, createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { openControlStore } from "../src/store/store.ts"; +import type { ProjectForensicExport } from "../src/store/project-lifecycle.ts"; +const cli=fileURLToPath(new URL("../src/project-admin-cli.ts",import.meta.url)); +function invoke(args:string[]){const result=spawnSync(process.execPath,["--conditions=development","--experimental-strip-types",cli,...args],{encoding:"utf8",timeout:10000});assert.equal(result.error,undefined);return result;} +function snapshot(root:string){const files:Record={};function walk(dir:string){for(const name of fs.readdirSync(dir).sort()){const file=path.join(dir,name);if(fs.lstatSync(file).isDirectory())walk(file);else files[path.relative(root,file)]=createHash("sha256").update(fs.readFileSync(file)).digest("hex");}}walk(root);return files;} +function fixture(){ + const base=fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(),"tf-admin-"))),source=path.join(base,"ledger"); + const store=openControlStore(source),h=store.header; + const run={runId:randomUUID(),projectId:h.projectId,controlDomainId:h.controlDomainId,status:"completed" as const,stage:"terminal" as const,slot:"released" as const,needsOperator:false,runVersion:0,boundPlanHash:`plan:${"a".repeat(64)}`,policyHash:"b".repeat(64),authorityEpoch:1}; + store.createRun(run,[{eventId:randomUUID(),recordedAt:Date.now(),payload:{kind:"run.terminal",status:"completed"}}]);store.close(); + return {base,source,header:h,close:()=>fs.rmSync(base,{recursive:true,force:true})}; +} +test("offline move-rebind process preserves IDs and all journal bytes for the same moved inode",()=>{ + const f=fixture();try{ + const before=snapshot(f.source),identity=fs.statSync(f.source),moved=path.join(f.base,"moved");fs.renameSync(f.source,moved); + const result=invoke(["move-rebind","--store",moved]);assert.equal(result.status,0,result.stderr); + const header=JSON.parse(result.stdout).header;assert.equal(header.projectId,f.header.projectId);assert.equal(header.controlDomainId,f.header.controlDomainId); + assert.equal(header.directoryBinding.canonicalPath,moved);assert.equal(String(identity.ino),header.directoryBinding.inode); + const after=snapshot(moved);delete before.header;delete after.header;assert.deepEqual(after,before);assert.equal(fs.existsSync(f.source),false); + }finally{f.close();} +}); +test("clone process creates fresh identities with no copied history and refuses existing copied ledgers",()=>{ + const f=fixture();try{ + const before=snapshot(f.source),destination=path.join(f.base,"clone"); + const result=invoke(["clone","--store",f.source,"--destination",destination]);assert.equal(result.status,0,result.stderr); + const body=JSON.parse(result.stdout);assert.notEqual(body.header.projectId,f.header.projectId);assert.notEqual(body.header.controlDomainId,f.header.controlDomainId);assert.equal(body.historyCopied,false); + assert.equal(JSON.parse(fs.readFileSync(path.join(destination,"commit-seq.json"),"utf8")).commitSeq,0);assert.deepEqual(snapshot(f.source),before); + const copy=path.join(f.base,"copied");fs.cpSync(f.source,copy,{recursive:true});fs.chmodSync(copy,0o700);const copied=snapshot(copy); + const rejected=invoke(["clone","--store",f.source,"--destination",copy]);assert.equal(rejected.status,1);assert.deepEqual(snapshot(copy),copied);assert.deepEqual(snapshot(f.source),before); + const adopt=invoke(["move-rebind","--store",copy]);assert.equal(adopt.status,1);assert.match(adopt.stderr,/copy\/adopt exclusivity/);assert.deepEqual(snapshot(copy),copied); + }finally{f.close();} +}); +test("damaged forensic export process preserves raw bytes, declares no execution promise, never overwrites",()=>{ + const f=fixture();try{ + const journal=path.join(f.source,"journal/000001.jsonl");fs.appendFileSync(journal,'{"broken":');const damaged=fs.readFileSync(journal),before=snapshot(f.source),output=path.join(f.base,"evidence.json"); + const result=invoke(["export","--store",f.source,"--output",output]);assert.equal(result.status,0,result.stderr); + const exported=JSON.parse(fs.readFileSync(output,"utf8")) as ProjectForensicExport; + assert.equal(exported.integrity,"damaged");assert.equal(exported.executePromise,false);assert.equal(exported.exportKind,"readonly"); + assert.deepEqual(Buffer.from(exported.files.find(file=>file.path==="journal/000001.jsonl")!.bytesBase64!,"base64"),damaged); + assert.equal(fs.statSync(output).mode&0o777,0o600);assert.deepEqual(snapshot(f.source),before); + const saved=fs.readFileSync(output);const rejected=invoke(["export","--store",f.source,"--output",output]);assert.equal(rejected.status,1);assert.deepEqual(fs.readFileSync(output),saved);assert.deepEqual(snapshot(f.source),before); + const lossy=path.join(f.base,"lossy.json");assert.equal(invoke(["export","--store",f.source,"--output",lossy,"--kind","lossy"]).status,0); + assert.ok((JSON.parse(fs.readFileSync(lossy,"utf8")) as ProjectForensicExport).files.every(file=>file.bytesBase64===undefined)); + }finally{f.close();} +}); +test("offline CLI rejects public directories and source symlinks without mutation",()=>{ + const f=fixture();try{ + const before=snapshot(f.source);fs.chmodSync(f.source,0o755); + const denied=invoke(["export","--store",f.source,"--output",path.join(f.base,"no.json")]);assert.equal(denied.status,1);assert.match(denied.stderr,/0700/); + fs.chmodSync(f.source,0o700);const link=path.join(f.base,"link");fs.symlinkSync(f.source,link); + assert.equal(invoke(["export","--store",link,"--output",path.join(f.base,"no.json")]).status,1); + assert.deepEqual(snapshot(f.source),before);assert.equal(fs.existsSync(path.join(f.base,"no.json")),false); + }finally{f.close();} +}); diff --git a/packages/taskflow-control/test/project-isolation.test.ts b/packages/taskflow-control/test/project-isolation.test.ts index b2b5cce7..53211ada 100644 --- a/packages/taskflow-control/test/project-isolation.test.ts +++ b/packages/taskflow-control/test/project-isolation.test.ts @@ -3,139 +3,181 @@ import { after, test } from "node:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { ControlHost, type ControlHostOptions } from "../src/control-host.ts"; -import { createTeExecutionProvider, type TeExecutionAuthority } from "../src/te-provider.ts"; +import { randomBytes, randomUUID } from "node:crypto"; +import { ControlHost } from "../src/control-host.ts"; +import { ControlError } from "../src/errors.ts"; +import { RuntimeTeExecutionProvider } from "../src/runtime-provider.ts"; +import { ProjectRegistry } from "../src/project-registry.ts"; +import { AUTHORIZATION_CAPABILITIES, answerAuthorizationChallenge, createAuthorizationAuthority, type AuthorizationChallenge } from "../src/authorization.ts"; import { openControlStore } from "../src/store/index.ts"; -import { singletonPaths, type SingletonPaths } from "../src/singleton.ts"; -import { PROTOCOL_MAJOR, type NegotiationHandshake } from "../src/schema/transport.ts"; +import { singletonPaths } from "../src/singleton.ts"; +import type { RunSnapshot } from "../src/schema/run.ts"; const tempRoots: string[] = []; - -function makePaths(): SingletonPaths { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-control-host-")); - tempRoots.push(root); - return singletonPaths(root); -} - after(() => { - for (const root of tempRoots) { - try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ } - } + for (const root of tempRoots) fs.rmSync(root, { recursive: true, force: true }); }); -function fakeProvider() { - const te: TeExecutionAuthority = { - assurance: "resolve-only-no-sandbox", - probe: async () => ({ classification: "resolve-only" as const, baselinePolicyId: "taskflow-resolve-only", hostProbeSha256: "a".repeat(64) }), - prepare: async () => ({ outcome: "accepted" as const, fulfillment: { preparationId: "prep", enforcementCapabilities: { resolution: "contained", mutationMediation: "brokered", processIsolation: "none", revocation: "admission-only", baselinePolicyId: "b", hostProbeSha256: "a".repeat(64) } } }), - submit: async () => ({ outcome: "accepted" as const, providerJobHandle: "job" }), - watch: async function* () { yield { kind: "terminal" as const, outcome: "completed" as const }; }, +function fixture() { + const root = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "tf-isolation-")); + tempRoots.push(root); + const projectRoot = path.join(root, "project-a"); + fs.mkdirSync(projectRoot); + const storePath = path.join(projectRoot, ".taskflow", "control"); + const paths = singletonPaths(path.join(root, "home")); + const secret = randomBytes(32); + const authorization = createAuthorizationAuthority({ + ownerUid: process.getuid!(), bootstrapSecret: secret, hostBaseline: AUTHORIZATION_CAPABILITIES, + loadLivePolicy: (_principal, binding) => ({ host: { capabilities: AUTHORIZATION_CAPABILITIES.map(kind => ({ kind, scopeRoot: binding.projectRoot })) } }), + }); + const provider = new RuntimeTeExecutionProvider(path.join(root, "provider")); + let registry: ProjectRegistry; + let owner: ControlHost; + const clients: ControlHost[] = []; + const start = async () => { + registry = new ProjectRegistry(path.join(root, "registry.json")); + const mount = registry.mount(storePath, projectRoot); + owner = new ControlHost({ mode: "auto", provider, authorization, registry, singletonPaths: paths }); + await owner.start(); + return mount; }; - return createTeExecutionProvider(te); -} - -function hostOptions(paths: SingletonPaths, overrides: Partial = {}): ControlHostOptions { - return { - provider: fakeProvider(), - singletonPaths: paths, - controlHome: paths.controlHome, - ...overrides, + const attach = async (projectStorePath?: string) => { + const client = new ControlHost({ mode: "coordinated", provider, singletonPaths: paths, projectStorePath }); + clients.push(client); + await client.start(); + assert.equal(client.status.singleton, "attached"); + return client; }; + const authenticate = async (client: ControlHost, projectId: string) => { + const challenge = await rpc(client, "auth.challenge", { projectId }); + assert.equal(challenge.binding.projectId, projectId); + assert.deepEqual(await rpc(client, "auth.authenticate", { challengeId: challenge.id, proof: answerAuthorizationChallenge(secret, challenge) }), { authenticated: true }); + return challenge.binding; + }; + const stop = () => { for (const client of clients) client.stop(); owner?.stop(); registry?.close(); }; + return { root, projectRoot, storePath, provider, start, attach, authenticate, stop, + mount: (store: string, project: string) => registry.mount(store, project) }; } -const clientHello: NegotiationHandshake = { - protocolMajor: PROTOCOL_MAJOR, - supportedReadSchemas: ["taskflow.wire.v1"], - supportedWriteSchemas: ["taskflow.wire.v1"], - requiredFeatures: [], - offeredFeatures: [], - buildInfo: { packageVersion: "0.3.0", gitCommit: "abc", schemaVersion: 1 }, -}; +function rpc(client: ControlHost, method: string, params?: unknown): Promise { + return client.dispatch(method, params, { fencingEpoch: client.status.fencingEpoch }); +} +function denied(error: unknown) { + assert.ok(error instanceof ControlError); + assert.ok(["TF_POLICY_DENIED", "TF_AUTHORITY_REVOKED"].includes(error.code)); + assert.equal(error.sideEffects, "none"); + return true; +} +function durableBytes(directory: string): Record { + const files: Record = {}; + for (const entry of fs.readdirSync(directory, { recursive: true, withFileTypes: true })) { + if (entry.isFile()) { + const file = path.join(entry.parentPath, entry.name); + files[path.relative(directory, file)] = fs.readFileSync(file); + } + } + return files; +} for (const existing of [false, true]) { -test(`project isolation: a different ${existing ? "existing" : "new"} project fails closed before any ledger mutation`, async (t) => { - const paths = makePaths(); - const a = path.join(paths.controlHome, "a"); - const b = path.join(paths.controlHome, "b"); - const winner = new ControlHost(hostOptions(paths, { projectStorePath: a })); - if (existing) openControlStore(b).close(); - const bBefore = existing ? fs.readFileSync(path.join(b, "header")) : undefined; - const other = new ControlHost(hostOptions(paths, { projectStorePath: b })); - t.after(() => { other.stop(); winner.stop(); }); - await winner.start(); - winner.hello(clientHello); - const before = fs.readFileSync(path.join(a, "header")); - await assert.rejects(other.start(), /project store|projectStorePath/); - assert.equal(other.state, "failed-closed"); - assert.deepEqual(fs.readFileSync(path.join(a, "header")), before); - assert.equal(fs.existsSync(b), existing); - if (existing) assert.deepEqual(fs.readFileSync(path.join(b, "header")), bBefore); - assert.equal((await winner.dispatch<{ commitSeq: number }>("control.store.status", undefined, { fencingEpoch: 1 })).commitSeq, 0); -}); - + test(`project isolation: another ${existing ? "existing" : "new"} project attaches transport but cannot access or mutate a ledger`, async (t) => { + const f = fixture(); + t.after(f.stop); + const b = path.join(f.root, "project-b", ".taskflow", "control"); + let otherProjectId: string = randomUUID(); + if (existing) { + const store = openControlStore(b); + otherProjectId = store.header.projectId; + store.close(); + } + const bBefore = existing ? durableBytes(b) : undefined; + const mount = await f.start(); + const before = durableBytes(f.storePath); + const other = await f.attach(b); + for (const method of ["control.store.header", "control.store.status", "commands.submit", "runs.list"]) { + await assert.rejects(rpc(other, method, { projectId: otherProjectId }), denied); + } + await assert.rejects(rpc(other, "auth.challenge", { projectId: otherProjectId }), denied); + // A valid session for A still cannot use an explicit B routing identity. + await f.authenticate(other, mount.store.header.projectId); + for (const method of ["control.store.header", "control.store.status", "commands.submit", "runs.list"]) { + await assert.rejects(rpc(other, method, { projectId: otherProjectId }), denied); + await assert.rejects(rpc(other, method, { controlDomainId: randomUUID() }), denied); + } + assert.deepEqual(durableBytes(f.storePath), before); + assert.equal(mount.store.snapshot().commitSeq, 0); + assert.equal(fs.existsSync(b), existing); + if (existing) assert.deepEqual(durableBytes(b), bBefore); + }); } -test("project isolation: attached clients without a project cannot access the winner ledger", async (t) => { - const paths = makePaths(); - const winner = new ControlHost(hostOptions(paths, { projectStorePath: path.join(paths.controlHome, "a") })); - const other = new ControlHost(hostOptions(paths)); - t.after(() => { other.stop(); winner.stop(); }); - await winner.start(); - await other.start(); - for (const method of ["control.store.header", "control.store.status", "commands.submit"]) { - await assert.rejects(other.dispatch(method, {}, { fencingEpoch: other.status.fencingEpoch }), /project store|projectStorePath/); - } - assert.equal((await other.dispatch<{ state: string }>("control.status", undefined, { fencingEpoch: other.status.fencingEpoch })).state, "started"); +test("project isolation: attached clients without a project or authentication cannot read the winner ledger", async (t) => { + const f = fixture(); + t.after(f.stop); + await f.start(); + const before = durableBytes(f.storePath); + const other = await f.attach(); + for (const method of ["control.store.header", "control.store.status", "commands.submit"]) { + await assert.rejects(rpc(other, method, {}), /projectStorePath/); + } + for (const method of ["runs.list", "projects.list"]) await assert.rejects(rpc(other, method, {}), denied); + assert.equal((await rpc<{ state: string }>(other, "control.status")).state, "started"); + assert.deepEqual(durableBytes(f.storePath), before); }); -test("project isolation: same canonical project attaches and recovers after winner restart", async (t) => { - const paths = makePaths(); - const a = path.join(paths.controlHome, "a"); - const alias = path.join(paths.controlHome, "alias"); - const winner = new ControlHost(hostOptions(paths, { projectStorePath: a })); - const other = new ControlHost(hostOptions(paths, { projectStorePath: alias })); - const restarted = new ControlHost(hostOptions(paths, { projectStorePath: a })); - t.after(() => { other.stop(); winner.stop(); restarted.stop(); }); - await winner.start(); - fs.symlinkSync(a, alias); - await other.start(); - const header = await other.dispatch<{ projectId: string }>("control.store.header", undefined, { fencingEpoch: other.status.fencingEpoch }); - assert.equal(other.status.singleton, "attached"); - const submitted = await other.dispatch<{ commitSeq: number }>("commands.submit", { - command: { - commandId: "00000000-0000-0000-0000-0000000000bb", - kind: "run.submit", - requestHash: "a".repeat(64), - callerPrincipal: "cli", - authorizationContextHash: "a".repeat(64), - projectId: header.projectId, - controlDomainId: header.projectId, - status: "accepted", - firstCommitSeq: 1, - lastCommitSeq: 1, - recordedAt: 1, - }, - events: [{ - eventId: "00000000-0000-0000-0000-0000000000cc", - schemaVersion: 1, - controlDomainId: header.projectId, - streamId: "command:00000000-0000-0000-0000-0000000000bb", - streamSeq: 1, - commitSeq: 1, - commandId: "00000000-0000-0000-0000-0000000000bb", - commandEventIndex: 0, - causationId: "00000000-0000-0000-0000-0000000000bb", - correlationId: "00000000-0000-0000-0000-0000000000bb", - projectId: header.projectId, - recordedAt: 1, - payload: { kind: "command.recorded", commandId: "00000000-0000-0000-0000-0000000000bb" }, - }], - }, { fencingEpoch: other.status.fencingEpoch }); - assert.equal(submitted.commitSeq, 1); - other.stop(); - winner.stop(); - await restarted.start(); - restarted.hello(clientHello); - assert.equal((await restarted.dispatch<{ projectId: string }>("control.store.header", undefined, { fencingEpoch: restarted.status.fencingEpoch })).projectId, header.projectId); - assert.equal((await restarted.dispatch<{ commitSeq: number }>("control.store.status", undefined, { fencingEpoch: restarted.status.fencingEpoch })).commitSeq, 1); +test("project isolation: authenticated project A cannot read or mutate mounted project B", async (t) => { + const f = fixture(); + t.after(f.stop); + const a = await f.start(); + const rootB = path.join(f.root, "project-b"); + fs.mkdirSync(rootB); + const b = f.mount(path.join(rootB, ".taskflow", "control"), rootB); + const other = await f.attach(f.storePath); + await f.authenticate(other, a.store.header.projectId); + const beforeA = durableBytes(f.storePath), beforeB = durableBytes(b.store.storePath); + for (const method of ["control.store.header", "control.store.status", "commands.submit", "runs.list"]) { + await assert.rejects(rpc(other, method, { + projectId: b.store.header.projectId, controlDomainId: b.store.header.controlDomainId, + commandId: randomUUID(), kind: "run.submit", flow: { name: "forbidden", phases: [{ id: "work", type: "script", run: "printf forbidden > effects.txt" }] }, + }), denied); + } + assert.deepEqual(durableBytes(f.storePath), beforeA); + assert.deepEqual(durableBytes(b.store.storePath), beforeB); + assert.equal(fs.existsSync(path.join(rootB, "effects.txt")), false); +}); + +test("project isolation: same canonical project authenticates through an alias and recovers after winner restart", async (t) => { + const f = fixture(); + t.after(f.stop); + const a = await f.start(); + const alias = path.join(f.root, "alias"); + fs.symlinkSync(f.storePath, alias); + const other = await f.attach(alias); + const binding = await f.authenticate(other, a.store.header.projectId); + assert.equal(binding.projectRoot, f.projectRoot); + const header = await rpc(other, "control.store.header"); + const body = { commandId: randomUUID(), kind: "run.submit", flow: { name: "isolated", phases: [{ id: "work", type: "script", run: "printf 'once\n' >> effects.txt; printf isolated-runtime" }] } }; + const accepted = await rpc<{ runId: string }>(other, "commands.submit", body); + const completed = await rpc(other, "runs.wait", { runId: accepted.runId }); + assert.equal(completed.status, "completed"); + assert.equal(completed.slot, "released"); + const status = await rpc<{ commitSeq: number }>(other, "control.store.status"); + assert.ok(status.commitSeq > 0); + const before = durableBytes(f.storePath); + f.stop(); + await f.start(); + const reattached = await f.attach(alias); + assert.deepEqual(await f.authenticate(reattached, a.store.header.projectId), binding); + assert.deepEqual(await rpc(reattached, "control.store.header"), header); + assert.equal((await rpc<{ commitSeq: number }>(reattached, "control.store.status")).commitSeq, status.commitSeq); + const restartedBytes = durableBytes(f.storePath); + assert.equal(JSON.stringify(await rpc(reattached, "commands.submit", body)), JSON.stringify(accepted)); + assert.deepEqual(await rpc(reattached, "runs.get", { runId: accepted.runId }), completed); + assert.deepEqual(durableBytes(f.storePath), restartedBytes); + // Startup renews the writer lock and rebuilds disposable command projections. + // Authoritative header, sequence and journal bytes remain fixed across restart. + for (const file of ["header", "commit-seq.json", "journal/000001.jsonl"]) { + assert.deepEqual(restartedBytes[file], before[file]); + } + assert.equal(fs.readFileSync(path.join(f.projectRoot, "effects.txt"), "utf8"), "once\n"); }); diff --git a/packages/taskflow-control/test/project-lifecycle.test.ts b/packages/taskflow-control/test/project-lifecycle.test.ts new file mode 100644 index 00000000..c6edecd6 --- /dev/null +++ b/packages/taskflow-control/test/project-lifecycle.test.ts @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { randomUUID, createHash } from "node:crypto"; +import { after, test } from "node:test"; +import { openControlStore } from "../src/store/store.ts"; +import { createControlEvidenceStore } from "../src/store/evidence-adapter.ts"; +import { rebindMovedProject, createProjectClone, exportProjectEvidence, type ProjectLifecycleAuthority } from "../src/store/project-lifecycle.ts"; +import { ControlError } from "../src/errors.ts"; +import type { ApprovalRun } from "../src/approval-service.ts"; +import type { ReceiptAssurance } from "../src/schema/evidence.ts"; +const roots: string[] = []; +after(() => { for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); }); +const context = Object.freeze({ token: Symbol("operator") }); +const authority: ProjectLifecycleAuthority = { authorize(candidate) { + if (candidate !== context) throw new ControlError("TF_POLICY_DENIED", "unverified context"); + return { principal: "test:verified-operator", operator: true }; +} }; +function root() { const dir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "tf-lifecycle-")); roots.push(dir); return dir; } +function bytes(directory: string): Record { + const result: Record = {}; + for (const name of fs.readdirSync(directory, { recursive: true }).sort()) { + const file = path.join(directory, String(name)); if (fs.statSync(file).isFile()) result[String(name)] = fs.readFileSync(file).toString("base64"); + } + return result; +} +function run(store: ReturnType): ApprovalRun { + return { runId: randomUUID(), projectId: store.header.projectId, controlDomainId: store.header.controlDomainId, + status: "completed", stage: "terminal", slot: "released", needsOperator: false, runVersion: 0, + boundPlanHash: "ir:plan", policyHash: "a".repeat(64), authorityEpoch: 1 }; +} + +test("verified same-inode move preserves IDs and journal bytes then actual store reopens", async () => { + const base = root(), from = path.join(base, "old"), to = path.join(base, "moved"); + const store = openControlStore(from); store.createRun(run(store)); const header = store.header; store.close(); + const before = bytes(from); fs.renameSync(from, to); + assert.throws(() => openControlStore(to), { code: "TF_DURABILITY_FAILED" }); + const rebound = await rebindMovedProject(to, context, authority); + assert.equal(rebound.projectId, header.projectId); assert.equal(rebound.controlDomainId, header.controlDomainId); + assert.equal(rebound.directoryBinding.canonicalPath, to); + const after = bytes(to); delete before.header; delete after.header; assert.deepEqual(after, before); + const reopened = openControlStore(to); assert.equal(reopened.listRuns().length, 1); reopened.close(); +}); + +test("move refuses an actual live writer even after its directory is renamed", async () => { + const base = root(), from = path.join(base, "old"), to = path.join(base, "moved"), store = openControlStore(from); + fs.renameSync(from, to); const before = bytes(to); + try { await assert.rejects(rebindMovedProject(to, context, authority), { code: "TF_DURABILITY_FAILED" }); assert.deepEqual(bytes(to), before); } + finally { store.close(); } +}); + +test("copied identity cannot be adopted; clone creates new empty project/domain and preserves copied evidence", async () => { + const base = root(), source = path.join(base, "source"), copy = path.join(base, "copied"), fresh = path.join(base, "new"); + const store = openControlStore(source); store.createRun(run(store)); const original = store.header; store.close(); + fs.cpSync(source, copy, { recursive: true }); const before = bytes(copy); + await assert.rejects(rebindMovedProject(copy, context, authority, "adopt"), { code: "TF_DURABILITY_FAILED" }); + const created = await createProjectClone(copy, fresh, context, authority); + assert.notEqual(created.projectId, original.projectId); assert.notEqual(created.controlDomainId, original.controlDomainId); + assert.deepEqual(bytes(copy), before); assert.deepEqual(bytes(source), before); + const reopened = openControlStore(fresh); assert.equal(reopened.commitSeq, 0); assert.deepEqual(reopened.listRuns(), []); reopened.close(); + await assert.rejects(createProjectClone(source, copy, context, authority), { code: "TF_DURABILITY_FAILED" }); assert.deepEqual(bytes(copy), before); +}); + +test("rebind denies old binding reuse and active/ambiguous project runs", async () => { + for (const reason of ["old-path", "running", "unknown"] as const) { + const base = root(), from = path.join(base, "old"), to = path.join(base, "moved"), store = openControlStore(from); + if (reason !== "old-path") store.createRun({ ...run(store), status: reason, stage: "executing", slot: "committed" }); + store.close(); fs.renameSync(from, to); if (reason === "old-path") fs.mkdirSync(from); + const before = bytes(to); await assert.rejects(rebindMovedProject(to, context, authority), { code: "TF_DURABILITY_FAILED" }); assert.deepEqual(bytes(to), before); + } +}); + +test("operator verification and live authority revocation protect identity changes and export", async () => { + const base = root(), from = path.join(base, "old"), to = path.join(base, "moved"), store = openControlStore(from); store.close(); fs.renameSync(from, to); + const before = bytes(to); await assert.rejects(rebindMovedProject(to, {}, authority), { code: "TF_POLICY_DENIED" }); + let calls = 0; + const revoked: ProjectLifecycleAuthority = { authorize(candidate, scope) { if (++calls > 1) throw new ControlError("TF_AUTHORITY_REVOKED", "revoked"); return authority.authorize(candidate, scope); } }; + await assert.rejects(rebindMovedProject(to, context, revoked), { code: "TF_AUTHORITY_REVOKED" }); assert.deepEqual(bytes(to), before); + calls = 0; await assert.rejects(exportProjectEvidence(to, "readonly", context, revoked), { code: "TF_AUTHORITY_REVOKED" }); assert.deepEqual(bytes(to), before); +}); + +test("readonly/lossy exports include actual journal and issued Receipt evidence without source writes", async () => { + const base = root(), directory = path.join(base, "project"), store = openControlStore(directory), value = run(store); + store.createRun(value); + const assurance: ReceiptAssurance = { journalContinuity: true, providerOutcome: "completed", artifactIntegrity: "verified", + provenance: { confidentiality: "internal", integrity: "project" }, enforcement: { capabilities: { + resolution: "contained", mutationMediation: "brokered", processIsolation: "none", revocation: "admission-only", baselinePolicyId: "test", hostProbeSha256: "a".repeat(64) } } }; + store.recordRunEvidence(value.runId, { buildInfo: { packageVersion: "test", gitCommit: "commit", schemaVersion: 2 }, assurance }); + store.mutateRun(value.runId, store.readRun(value.runId)!.runVersion, state => ({ ...state, run: { ...state.run, runVersion: state.run.runVersion + 1 }, events: [{ eventId: randomUUID(), recordedAt: Date.now(), payload: { kind: "run.terminal", status: "completed" } }] })); + const evidence = await createControlEvidenceStore(store, { authorize: () => ({ principal: "test", authorizationContextHash: "a".repeat(64) }), + terminalEvidence: { verify: (_project, _run, eventId) => ({ terminalStatus: "completed", terminalEventId: eventId, evidenceCommit: "provider-proof", providerOutcome: "completed" }) } }); + const receipt = await evidence.issueFinalReceipt(value.runId); store.close(); + const before = bytes(directory); + for (const kind of ["readonly", "lossy"] as const) { + const output = await exportProjectEvidence(directory, kind, context, authority); + assert.equal(output.executePromise, false); assert.equal(output.integrity, "verified"); assert.deepEqual(output.receipts, [receipt]); + assert.ok(output.journal.length > 0); assert.ok(output.files.some(file => file.path.startsWith("artifacts/sha256/"))); + for (const file of output.files) { + if (kind === "readonly") assert.equal(createHash("sha256").update(Buffer.from(file.bytesBase64!, "base64")).digest("hex"), file.sha256); + else assert.equal(file.bytesBase64, undefined); + } + assert.deepEqual(bytes(directory), before); + } +}); + +test("damaged journal remains exportable as raw forensic evidence but cannot authorize move", async () => { + const base = root(), from = path.join(base, "old"), to = path.join(base, "moved"), store = openControlStore(from); store.createRun(run(store)); store.close(); + fs.appendFileSync(path.join(from, "journal/000001.jsonl"), '{"torn":'); const before = bytes(from); + const exported = await exportProjectEvidence(from, "readonly", context, authority); + assert.equal(exported.integrity, "damaged"); assert.equal(exported.executePromise, false); assert.ok(exported.diagnostics.some(s => s.includes("incomplete"))); + assert.equal(exported.files.find(f => f.path === "journal/000001.jsonl")!.bytesBase64, before["journal/000001.jsonl"]); assert.deepEqual(bytes(from), before); + fs.renameSync(from, to); await assert.rejects(rebindMovedProject(to, context, authority), { code: "TF_DURABILITY_FAILED" }); assert.deepEqual(bytes(to), before); +}); + +test("source changes during export are rejected; symlink evidence cannot escape source", async () => { + const base = root(), directory = path.join(base, "project"), store = openControlStore(directory); store.close(); + let calls = 0; const changing: ProjectLifecycleAuthority = { authorize(candidate, scope) { + if (++calls === 2) fs.writeFileSync(path.join(directory, "receipts", "changed"), "new evidence"); + return authority.authorize(candidate, scope); + } }; + await assert.rejects(exportProjectEvidence(directory, "readonly", context, changing), { code: "TF_DURABILITY_FAILED" }); + fs.symlinkSync(path.join(directory, "header"), path.join(directory, "receipts", "link")); + await assert.rejects(exportProjectEvidence(directory, "readonly", context, authority), { code: "TF_DURABILITY_FAILED" }); +}); + +test("explicit adopt works only for a proven same-inode move, not a copy whose original is absent", async () => { + const base = root(), original = path.join(base, "original"), moved = path.join(base, "moved"), copied = path.join(base, "copied"); + const store = openControlStore(original); const header = store.header; store.close(); + fs.cpSync(original, copied, { recursive: true }); fs.renameSync(original, moved); + const before = bytes(copied); + await assert.rejects(rebindMovedProject(copied, context, authority, "adopt"), { code: "TF_DURABILITY_FAILED" }); assert.deepEqual(bytes(copied), before); + const adopted = await rebindMovedProject(moved, context, authority, "adopt"); + assert.equal(adopted.projectId, header.projectId); assert.equal(adopted.controlDomainId, header.controlDomainId); + const reopened = openControlStore(moved); assert.deepEqual(reopened.header, adopted); reopened.close(); +}); diff --git a/packages/taskflow-control/test/project-registry.test.ts b/packages/taskflow-control/test/project-registry.test.ts new file mode 100644 index 00000000..3ff33b31 --- /dev/null +++ b/packages/taskflow-control/test/project-registry.test.ts @@ -0,0 +1,105 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { ProjectRegistry } from "../src/project-registry.ts"; +import { ControlError } from "../src/errors.ts"; + +function fixture() { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "tf-registry-"))); + const projects = ["a", "b"].map((name) => { const p = path.join(root, name); fs.mkdirSync(p); return p; }); + const file = path.join(root, "user", "registry.json"); + const registry = new ProjectRegistry(file); + return { root, projects, file, registry, cleanup() { registry.close(); fs.rmSync(root, { recursive: true, force: true }); } }; +} +const storePath = (root: string) => path.join(root, ".taskflow", "control"); +const durability = (error: unknown) => error instanceof ControlError && error.code === "TF_DURABILITY_FAILED"; + +test("registry mounts two real stores with explicit routing and stable distinct identities", () => { + const f = fixture(); try { + const a = f.registry.mount(storePath(f.projects[0])), b = f.registry.mount(storePath(f.projects[1])); + assert.notEqual(a.store.header.projectId, b.store.header.projectId); + assert.notEqual(a.store.header.controlDomainId, b.store.header.controlDomainId); + assert.equal(f.registry.resolve(a.store.header.projectId).store, a.store); + assert.equal(f.registry.resolve(b.store.header.projectId).store, b.store); + assert.throws(() => f.registry.resolve(), /explicit projectId/); + assert.equal(f.registry.mount(storePath(f.projects[0])), a); + assert.equal(f.registry.list().length, 2); + } finally { f.cleanup(); } +}); + +test("registry loss rebuilds from exact existing headers and never invents run state", () => { + const f = fixture(); let reopened: ProjectRegistry | undefined; try { + const a = f.registry.mount(storePath(f.projects[0])); + const identity = a.store.header, bytes = fs.readFileSync(path.join(a.store.storePath, "header")); + f.registry.close(); fs.unlinkSync(f.file); + reopened = new ProjectRegistry(f.file); + const again = reopened.mount(storePath(f.projects[0])); + assert.deepEqual(again.store.header, identity); + assert.deepEqual(fs.readFileSync(path.join(again.store.storePath, "header")), bytes); + assert.equal(again.store.commitSeq, 0); + } finally { reopened?.close(); f.cleanup(); } +}); + +test("persisted registry discovery does not automatically open or authorize project paths", () => { + const f = fixture(); let reopened: ProjectRegistry | undefined; try { + const id = f.registry.mount(storePath(f.projects[0])).store.header.projectId; + f.registry.close(); reopened = new ProjectRegistry(f.file); + assert.equal(reopened.list()[0].mountState, "unmounted"); + assert.throws(() => reopened!.resolve(id), /not mounted/); + assert.equal(reopened.mount(storePath(f.projects[0])).store.header.projectId, id); + } finally { reopened?.close(); f.cleanup(); } +}); + +test("missing ledger header is rejected without resetting its remaining bytes", () => { + const f = fixture(); let reopened: ProjectRegistry | undefined; try { + const a = f.registry.mount(storePath(f.projects[0])); f.registry.close(); + const seq = path.join(a.store.storePath, "commit-seq.json"), before = fs.readFileSync(seq); + fs.unlinkSync(path.join(a.store.storePath, "header")); fs.unlinkSync(f.file); + reopened = new ProjectRegistry(f.file); + assert.throws(() => reopened!.mount(storePath(f.projects[0])), durability); + assert.equal(fs.existsSync(path.join(a.store.storePath, "header")), false); + assert.deepEqual(fs.readFileSync(seq), before); + } finally { reopened?.close(); f.cleanup(); } +}); + +test("copied ledger cannot alias another project and preserves copied evidence", () => { + const f = fixture(); try { + const a = f.registry.mount(storePath(f.projects[0])); + const copied = storePath(f.projects[1]); fs.cpSync(a.store.storePath, copied, { recursive: true }); + fs.unlinkSync(path.join(copied, "writer.lock")); + const before = fs.readFileSync(path.join(copied, "header")); + assert.throws(() => f.registry.mount(copied), durability); + assert.deepEqual(fs.readFileSync(path.join(copied, "header")), before); + assert.equal(f.registry.resolve(a.store.header.projectId).store, a.store); + } finally { f.cleanup(); } +}); + +test("mounted header mutation prevents routing while leaving bytes untouched", () => { + const f = fixture(); try { + const a = f.registry.mount(storePath(f.projects[0])); + const file = path.join(a.store.storePath, "header"); fs.writeFileSync(file, "corrupt"); + assert.throws(() => f.registry.resolve(a.store.header.projectId), durability); + assert.equal(fs.readFileSync(file, "utf8"), "corrupt"); + } finally { f.cleanup(); } +}); + +test("symlink and replaced registry directory fail closed", () => { + const f = fixture(); try { + const alias = path.join(f.root, "alias"); fs.symlinkSync(f.projects[0], alias, "dir"); + assert.throws(() => f.registry.mount(storePath(alias)), durability); + const original = path.dirname(f.file); fs.renameSync(original, `${original}-old`); fs.mkdirSync(original); + assert.throws(() => f.registry.mount(storePath(f.projects[0])), durability); + } finally { f.cleanup(); } +}); + +test("corrupt discovery projection is preserved and does not reset ledger identity", () => { + const f = fixture(); try { + const a = f.registry.mount(storePath(f.projects[0])); f.registry.close(); + const before = fs.readFileSync(path.join(a.store.storePath, "header")); fs.writeFileSync(f.file, "broken"); + assert.throws(() => new ProjectRegistry(f.file)); + assert.equal(fs.readFileSync(f.file, "utf8"), "broken"); + assert.deepEqual(fs.readFileSync(path.join(a.store.storePath, "header")), before); + } finally { f.cleanup(); } +}); diff --git a/packages/taskflow-control/test/runtime-quiescence.test.ts b/packages/taskflow-control/test/runtime-quiescence.test.ts new file mode 100644 index 00000000..a1e153a4 --- /dev/null +++ b/packages/taskflow-control/test/runtime-quiescence.test.ts @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { randomUUID, randomBytes } from "node:crypto"; +import { ControlHost } from "../src/control-host.ts"; +import { RuntimeTeExecutionProvider } from "../src/runtime-provider.ts"; +import { ProjectRegistry } from "../src/project-registry.ts"; +import { AUTHORIZATION_CAPABILITIES, createAuthorizationAuthority } from "../src/authorization.ts"; + +test("approval concurrent with real script retains capacity until whole graph is quiescent", { timeout: 15000 }, async () => { + const base = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "control-quiescence-")), root = path.join(base, "project"); fs.mkdirSync(root); + const registry = new ProjectRegistry(path.join(base, "registry.json")); const mount = registry.mount(path.join(root, ".taskflow", "control"), root); + const authority = createAuthorizationAuthority({ ownerUid: process.getuid!(), bootstrapSecret: randomBytes(32), hostBaseline: AUTHORIZATION_CAPABILITIES, + loadLivePolicy: () => ({ host: { capabilities: AUTHORIZATION_CAPABILITIES.map(kind => ({ kind, scopeRoot: root })) } }) }); + const context = authority.issueStandalone({ projectId: mount.store.header.projectId, controlDomainId: mount.store.header.controlDomainId, projectRoot: root }); + const provider = new RuntimeTeExecutionProvider(path.join(base, "provider")); const host = new ControlHost({ mode: "standalone", trustedInProcessFeatures: ["durable-approval"], controlHome: path.join(base, "home"), registry, authorization: authority, provider }); + await host.start(); let runId: string | undefined; + try { + ({ runId } = await host.dispatchAuthenticated<{ runId: string }>(context, "commands.submit", { commandId: randomUUID(), kind: "run.submit", approvalMode: "durable-required", + flow: { name: "parallel-approval", phases: [ + { id: "approval", type: "approval", task: "Approve?" }, + { id: "sibling", type: "script", run: "printf live > sibling-live; while [ ! -f release-sibling ]; do sleep 0.02; done; printf done" }, + ] } })); + const deadline = Date.now() + 5000; + while ((!mount.store.listApprovals().length || !fs.existsSync(path.join(root, "sibling-live"))) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10)); + assert.equal(mount.store.listApprovals().length, 1); assert.ok(fs.existsSync(path.join(root, "sibling-live"))); + assert.equal((await provider.quiescence(runId)).quiescent, false); + assert.equal(mount.store.readRun(runId)!.slot, "committed"); + fs.writeFileSync(path.join(root, "release-sibling"), "release"); + while (!(await provider.quiescence(runId)).quiescent && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10)); + assert.equal((await provider.quiescence(runId)).quiescent, true); + } finally { + fs.writeFileSync(path.join(root, "release-sibling"), "release"); + host.stop(); if (runId) await provider.wait(runId); registry.close(); fs.rmSync(base, { recursive: true, force: true }); + } +}); diff --git a/packages/taskflow-control/test/runtime-restart.test.ts b/packages/taskflow-control/test/runtime-restart.test.ts new file mode 100644 index 00000000..7a57a384 --- /dev/null +++ b/packages/taskflow-control/test/runtime-restart.test.ts @@ -0,0 +1,82 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fork, type ChildProcess } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { RuntimeTeExecutionProvider } from "../src/runtime-provider.ts"; + +const fixture = fileURLToPath(new URL("./fixtures/runtime-restart-worker.ts", import.meta.url)); +function launch(base: string, action: string) { + const child = fork(fixture, [base, action], { execArgv: ["--conditions=development", "--experimental-strip-types"], stdio: ["ignore", "pipe", "pipe", "ipc"] }); + let output = ""; + child.stdout?.on("data", data => { output += data; }); child.stderr?.on("data", data => { output += data; }); + const message = new Promise>((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`worker ${action} timed out: ${output}`)), 20000); + child.once("message", value => { clearTimeout(timer); const result = value as Record; result.kind === "error" ? reject(new Error(String(result.error))) : resolve(result); }); + child.once("exit", code => { clearTimeout(timer); reject(new Error(`worker ${action} exited ${code}: ${output}`)); }); + child.once("error", reject); + }); + return { child, message }; +} +async function stop(child: ChildProcess) { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = new Promise(resolve => child.once("exit", () => resolve())); child.kill("SIGKILL"); await exited; +} + +test("SIGKILL at real runtime approval resumes in fresh host without replaying completed scripts", { timeout: 45000 }, async () => { + const base = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "control-runtime-restart-")); + const first = launch(base, "start"); let second: ReturnType | undefined; + try { + const parked = await first.message; assert.equal(parked.kind, "parked"); + const runId = String(parked.runId), root = path.join(base, "project"); + const durable = JSON.parse(fs.readFileSync(path.join(base, "provider", `${runId}.json`), "utf8")); + assert.equal(durable.checkpoint.writer.pid, first.child.pid); + assert.equal(typeof durable.checkpoint.writer.birthToken, "string"); + assert.ok(durable.checkpoint.writer.birthToken.length > 0); + assert.deepEqual(durable.checkpoint.state.foregroundOwner.approvalWait, ["approve"]); + assert.equal(durable.checkpoint.state.phases.before.status, "done"); + assert.equal(durable.checkpoint.state.phases.before.output, "before-output"); + assert.equal(fs.readFileSync(path.join(root, "before-count"), "utf8"), "x"); + assert.equal(fs.existsSync(path.join(root, "after-count")), false); + const observer = new RuntimeTeExecutionProvider(path.join(base, "provider")); + const diagnostic = observer.readDiagnostic(runId)!; + assert.equal(diagnostic.state.phases.before?.output, "before-output"); + assert.equal(diagnostic.projectRoot, root); + assert.equal(diagnostic.controlDirectory, path.join(base, "provider", "resources", diagnostic.projectId)); + diagnostic.state.phases.before!.output = "caller mutation"; + assert.equal(observer.readDiagnostic(runId)!.state.phases.before?.output, "before-output"); + assert.throws(() => observer.recoverableExecution(runId), /alive or unobservable/); + assert.equal((await observer.quiescence(runId)).quiescent, false); + await stop(first.child); + assert.equal(observer.recoverableExecution(runId).flow.name, "kill-approval-restart"); + assert.equal((await observer.quiescence(runId)).quiescent, true); + second = launch(base, "resume"); const result = await second.message; + assert.equal(result.kind, "completed"); + assert.equal((result.run as { status: string }).status, "completed"); + assert.equal((result.observation as { finalOutput: string }).finalOutput, "after-output"); + assert.equal(observer.readDiagnostic(runId)!.state.status, "completed"); + assert.equal(fs.readFileSync(path.join(root, "before-count"), "utf8"), "x"); + assert.equal(fs.readFileSync(path.join(root, "after-count"), "utf8"), "x"); + } finally { await stop(first.child); if (second) await stop(second.child); fs.rmSync(base, { recursive: true, force: true }); } +}); + +for (const [editKind,boundary] of [["output","commit"],["plan","commit"],["plan","readmitted"]] as const) test(`SIGKILL at ${boundary} ${editKind} edit restarts its real effects exactly once`,{timeout:45000},async()=>{ + const base=fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()),"control-edit-restart-")); + const children:ChildProcess[]=[]; + try{ + const first=launch(base,editKind==="output"?"start-output":"start");children.push(first.child);const parked=await first.message;await stop(first.child); + const second=launch(base,`${editKind}-${boundary}-pause`);children.push(second.child);const edited=await second.message; + assert.equal(edited.kind,"edited");assert.equal((edited.approval as {status:string}).status,"edited"); + if(boundary==="readmitted")assert.equal((edited.run as {slot:string}).slot,"committed"); + assert.equal(fs.readFileSync(path.join(base,"project","before-count"),"utf8"),"x");assert.equal(fs.existsSync(path.join(base,"project","after-count")),false); + await stop(second.child); + const third=launch(base,"resume-committed");children.push(third.child);const result=await third.message; + assert.equal(result.kind,"completed");assert.equal((result.run as {status:string}).status,"completed"); + assert.equal((result.observation as {finalOutput:string}).finalOutput,`restart-edited-${editKind}`); + assert.equal(fs.readFileSync(path.join(base,"project","before-count"),"utf8"),"x");assert.equal(fs.readFileSync(path.join(base,"project","after-count"),"utf8"),"x"); + assert.equal((result.run as {runId:string}).runId,parked.runId); + if(boundary==="readmitted")assert.notEqual((result.run as {owner:{attemptId:string}}).owner.attemptId,(edited.run as {owner:{attemptId:string}}).owner.attemptId); + }finally{for(const child of children)await stop(child);fs.rmSync(base,{recursive:true,force:true});} +}); diff --git a/packages/taskflow-control/test/schema.test.ts b/packages/taskflow-control/test/schema.test.ts index 757b0d4b..85355ff6 100644 --- a/packages/taskflow-control/test/schema.test.ts +++ b/packages/taskflow-control/test/schema.test.ts @@ -51,9 +51,11 @@ test("schema: closed contracts reject unknown fields (additionalProperties: fals test("schema: schemaVersion is pinned on envelope documents", () => { const header = ControlStoreHeaderSchema as { properties?: Record }; assert.ok(header.properties && "schemaVersion" in header.properties); - const event = ControlEventSchema as { properties?: Record }; - assert.ok(event.properties && "schemaVersion" in event.properties); - assert.equal(CONTROL_WIRE_SCHEMA_VERSION, 1); + assert.equal(CONTROL_WIRE_SCHEMA_VERSION, 2); + assert.equal(Value.Check(ControlEventSchema, { ...event(), schemaVersion: 1 }), true, "legacy envelopes remain readable"); + assert.equal(Value.Check(ControlEventSchema, event()), true, "current envelopes carry version 2"); + assert.equal(Value.Check(ControlEventSchema, { ...event(), schemaVersion: 3 }), false, "unknown future version is rejected"); + assert.equal(Value.Check(ControlEventSchema, { ...event(), schemaVersion: undefined }), false); }); test("schema: RunStatus / RunStage are the exact frozen enums (P5)", () => { @@ -169,6 +171,7 @@ test("schema: RunSnapshot carries status + stage + slot + needsOperator", () => slot: "orphan-suspect", needsOperator: true, projectAdmitCommitSeq: 3, + runVersion: 4, }; assert.equal(Value.Check(RunSnapshotSchema, snapshot), true); assert.equal(Value.Check(RunSnapshotSchema, { ...snapshot, needsOperator: undefined }), false); diff --git a/packages/taskflow-control/test/singleton.test.ts b/packages/taskflow-control/test/singleton.test.ts index 16216028..8591519d 100644 --- a/packages/taskflow-control/test/singleton.test.ts +++ b/packages/taskflow-control/test/singleton.test.ts @@ -236,6 +236,8 @@ test("singleton: reclaim bumps the fencing epoch so the old holder is fenced out const second = acquireUserSingleton({ paths, holderId: "holder-b", inspectProcess: deadOwnerInspector() }); assert.equal(second.status, "won"); assert.equal(second.fencingEpoch, 2); + assert.equal(JSON.parse(fs.readFileSync(paths.lockPath, "utf8")).holderId, second.holderId); + assert.equal(JSON.parse(fs.readFileSync(paths.leasePath, "utf8")).holderId, second.holderId); // Old holder's RPC with epoch 1 must now be rejected by the lease gate. assert.throws(() => assertFencing({ holderId: "holder-b", fencingEpoch: 2, endpoint: paths.endpointPath, expiresAt: 0 }, 1), ControlError); second.release(); diff --git a/packages/taskflow-control/test/store-process.test.ts b/packages/taskflow-control/test/store-process.test.ts index 4ea21c65..a98b6a6d 100644 --- a/packages/taskflow-control/test/store-process.test.ts +++ b/packages/taskflow-control/test/store-process.test.ts @@ -102,7 +102,7 @@ test("store crash: SIGKILL after header fsync / before journal is old-complete ( assertNotMixed(storePath, "old"); }); -test("store crash: SIGKILL mid journal append is old-complete (A4)", UNIX_ONLY, async () => { +test("store crash: SIGKILL mid journal append fails closed and preserves evidence (A4)", UNIX_ONLY, async () => { const storePath = makeStorePath(); const primed = openControlStore(storePath); primed.close(); @@ -115,7 +115,9 @@ test("store crash: SIGKILL mid journal append is old-complete (A4)", UNIX_ONLY, }); const result = await waitForExit(child); assert.equal(result.signal, "SIGKILL"); - assertNotMixed(storePath, "old"); + const journal=path.join(storePath,"journal/000001.jsonl"), damaged=fs.readFileSync(journal); + assert.throws(()=>openControlStore(storePath), /incomplete journal tail/); + assert.deepEqual(fs.readFileSync(journal),damaged); }); test("store crash: SIGKILL during projection rebuild is new-complete (A4)", UNIX_ONLY, async () => { diff --git a/packages/taskflow-control/test/store.test.ts b/packages/taskflow-control/test/store.test.ts index 770c679b..7f087132 100644 --- a/packages/taskflow-control/test/store.test.ts +++ b/packages/taskflow-control/test/store.test.ts @@ -213,7 +213,7 @@ test("store: authority records are isolated from input and returned object mutat let store = openControlStore(storePath, { projectId: UUID, controlDomainId: UUID }); try { const batch = submitBatch({ responseArtifactRef: { - digest: "sha256:" + SHA256, size: 1, mediaType: "text/plain", storageClass: "project", redactionClass: "none", + digest: SHA256, size: 1, mediaType: "text/plain", storageClass: "project", redactionClass: "none", } }); const expectedCommand = structuredClone(batch.command); const expectedHeader = structuredClone(store.header); @@ -256,9 +256,9 @@ test("store: constructor isolates recovered authority records from caller-owned const storePath = makeStorePath(); const opened = openControlStore(storePath); const header = opened.header; - opened.close(); + // The trusted recovery constructor requires the already-held writer proof. const command = submitBatch({ responseArtifactRef: { - digest: "sha256:" + SHA256, size: 1, mediaType: "text/plain", storageClass: "project", redactionClass: "none", + digest: SHA256, size: 1, mediaType: "text/plain", storageClass: "project", redactionClass: "none", } }).command; const expectedHeader = structuredClone(header); const expectedCommand = structuredClone(command); @@ -271,6 +271,7 @@ test("store: constructor isolates recovered authority records from caller-owned assert.deepEqual(recovered.readCommand(command.commandId), expectedCommand); } finally { recovered.close(); + opened.close(); } }); @@ -338,8 +339,11 @@ test("store: writeFile/appendFile/createWriteStream in control src stay on metad } const allowed = [ "singleton.ts", + "local-bootstrap.ts", // private host bootstrap credential metadata + "project-admin-cli.ts", // explicit offline operator export; exclusive private evidence file, never project execution data "control-host.ts", "store/store.ts", + "store/evidence-store.ts", // content-addressed immutable evidence only ]; for (const hit of hits) { const file = hit.split(":")[0]; diff --git a/packages/taskflow-control/test/uds-inflight.test.ts b/packages/taskflow-control/test/uds-inflight.test.ts new file mode 100644 index 00000000..a23222d0 --- /dev/null +++ b/packages/taskflow-control/test/uds-inflight.test.ts @@ -0,0 +1,24 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { startUdsServer, connectUdsClient } from "../src/uds.ts"; +import { defaultServerHello } from "../src/control-host.ts"; + +test("one flooding client and a throwing disconnect hook cannot exhaust or crash the shared UDS host", {timeout:10000}, async()=>{ + const dir=fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(),"tf-uds-limit-"))); + let release!:()=>void; const hold=new Promise(resolve=>{release=resolve;});let active=0;let max=0; + const endpointPath=path.join(dir,"sock"); + const server=await startUdsServer({endpointPath,serverHello:defaultServerHello(),getFencingEpoch:()=>1, + onDisconnect:()=>{throw new Error("isolated cleanup failure");}, + handleRpc:async(method)=>{if(method==="ping")return "healthy";active++;max=Math.max(max,active);await hold;active--;return "done";}}); + const client=await connectUdsClient({endpointPath,clientHello:defaultServerHello()}); + try{ + const requests=Array.from({length:65},()=>client.rpc("hold",{})); + const settled=await Promise.allSettled(requests); + assert.ok(settled.some(result=>result.status==="rejected"));assert.equal(max,64); + const healthy=await connectUdsClient({endpointPath,clientHello:defaultServerHello()}); + try{assert.equal(await healthy.rpc("ping",{}),"healthy");}finally{healthy.close();} + }finally{release();client.close();await server.close();fs.rmSync(dir,{recursive:true,force:true});} +}); diff --git a/packages/taskflow-control/test/web-console.test.ts b/packages/taskflow-control/test/web-console.test.ts index 2bee767e..41eeaa0c 100644 --- a/packages/taskflow-control/test/web-console.test.ts +++ b/packages/taskflow-control/test/web-console.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { randomUUID } from "node:crypto"; +import { randomUUID, randomBytes } from "node:crypto"; import * as fs from "node:fs"; import * as http from "node:http"; import * as os from "node:os"; @@ -11,7 +11,9 @@ import { setTimeout as delay } from "node:timers/promises"; import { startWebConsole, type WebConsoleOptions, type WebConsoleServer, type WebConsoleAuthorization } from "../src/web-console.ts"; import { ControlError } from "../src/errors.ts"; import { ControlHost, defaultServerHello } from "../src/control-host.ts"; -import { createTeExecutionProvider } from "../src/te-provider.ts"; +import { RuntimeTeExecutionProvider } from "../src/runtime-provider.ts"; +import { ProjectRegistry } from "../src/project-registry.ts"; +import { createAuthorizationAuthority } from "../src/authorization.ts"; const projectId = "11111111-1111-4111-8111-111111111111"; const domainId = "22222222-2222-4222-8222-222222222222"; @@ -173,25 +175,26 @@ test("web console: session expiry and logout stop API access", async (t) => { }); test("web console: actual ControlHost dispatch serves live status/header through injected bridge", async (t) => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-console-host-")); + const root = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "tf-console-host-")); fs.mkdirSync(path.join(root, "home")); t.after(() => fs.rmSync(root, { recursive: true, force: true })); - const unavailable = async (): Promise => { throw new Error("Provider execution is outside this status-only integration test"); }; - const host = new ControlHost({ mode: "standalone", controlHome: path.join(root, "home"), projectStorePath: path.join(root, "project"), provider: createTeExecutionProvider({ assurance: "resolve-only-no-sandbox", probe: unavailable, prepare: unavailable, submit: unavailable, watch: async function* () { throw new Error("No test execution provider"); } }) }); + const projectRoot = path.join(root, "project"); fs.mkdirSync(projectRoot); + const registry = new ProjectRegistry(path.join(root, "registry.json")); + const mount = registry.mount(path.join(projectRoot, ".taskflow/control"), projectRoot); + const binding = { projectId: mount.store.header.projectId, controlDomainId: mount.store.header.controlDomainId, projectRoot }; + const authorization = createAuthorizationAuthority({ ownerUid: process.getuid!(), bootstrapSecret: randomBytes(32), hostBaseline: ["project.read"], + loadLivePolicy: () => ({ host: { capabilities: [{ kind: "project.read", scopeRoot: projectRoot }] } }) }); + const context = authorization.issueStandalone(binding); + t.after(() => registry.close()); + const host = new ControlHost({ mode: "standalone", controlHome: path.join(root, "home"), registry, authorization, provider: new RuntimeTeExecutionProvider(path.join(root, "provider")) }); t.after(() => host.stop()); await host.start(); assert.equal(host.hello(defaultServerHello()).ok, true); let liveChecks = 0; const console = await launch(t, { authorize: async () => { liveChecks++; - return { call: async (operation) => { - if (operation === "control.status") return host.dispatch(operation, {}, { fencingEpoch: host.status.fencingEpoch }); - if (operation === "projects.list") { - const header = await host.dispatch<{ projectId: string; controlDomainId: string }>("control.store.header", {}, { fencingEpoch: host.status.fencingEpoch }); - return [{ projectId: header.projectId, controlDomainId: header.controlDomainId, path: root }]; - } - throw new ControlError("TF_FEATURE_REQUIRED", "Owner lifecycle integration pending"); - } }; + await authorization.authorize(context, { ...binding, operation: "read" }); + return { call: (operation, params) => host.dispatchAuthenticated(context, operation, params) }; } }); const headers = await session(console); const status = await (await fetch(console.url + "/api/status", { headers })).json() as { result: { state: string; singleton: string } }; @@ -314,3 +317,48 @@ test("web console UI: older evidence response cannot overwrite a newer request i assert.match(getElementById("evidence-output").textContent, /current/); assert.doesNotMatch(getElementById("evidence-output").textContent, /obsolete/); }); + + +test("web console UI stages output text, uses the refreshed version and replays one decision after a lost response", async () => { + class Element { + value = ""; textContent = ""; hidden = false; disabled = false; placeholder = ""; className = ""; + children: Element[] = []; attributes = new Map(); listeners = new MapPromise>(); + classList = { toggle() {} }; + readonly tag: string; + constructor(tag = "div") { this.tag = tag; } + append(...elements: Element[]) { this.children.push(...elements); } + replaceChildren() { this.children = []; } + setAttribute(name: string, value: string) { this.attributes.set(name,value); } + addEventListener(name: string, callback: ()=>Promise) { this.listeners.set(name,callback); } + querySelectorAll(tag: string): Element[] { return this.children.flatMap(child => [...(child.tag === tag ? [child] : []), ...child.querySelectorAll(tag)]); } + } + const elements = new Map(); + const getElementById = (id:string) => { if(!elements.has(id)) elements.set(id,new Element()); return elements.get(id)!; }; + const artifact = { digest: "a".repeat(64), size: 13, mediaType: "text/plain; charset=utf-8", storageClass: "project", redactionClass: "internal" }; + const requests: {url:string;body?:Record}[] = []; let decisions = 0; + const context = vm.createContext({ document: { getElementById, querySelectorAll:()=>[], createElement:(tag:string)=>new Element(tag) }, crypto:{randomUUID}, + fetch: async(url:string,options?:{body?:string}) => { + if(url === "/api/session") return new Promise(()=>{}); + const body = options?.body ? JSON.parse(options.body) as Record : undefined; + requests.push({url,body}); + let result:unknown; + if(url.includes("/approval-output?")) result = artifact; + else if(url.includes("/approvals?")) result = [{approvalRequestId:approvalId,status:"pending",expectedRunVersion:4}]; + else if(url.includes("/decisions?")) { decisions++; if(decisions===1) return {ok:false,status:503,json:async()=>({error:{message:"response lost"}})}; result={committed:true}; } + else throw new Error("Unexpected request " + url); + return {ok:true,status:200,json:async()=>({result})}; + } + }); + vm.runInContext(CONSOLE_JS,context); + vm.runInContext(`state.features={approvalEditWithArtifactRef:true,approvalOutputEdit:true}; refreshProject=async()=>{}; renderApprovals([{approvalRequestId:'${approvalId}',runId:'${runId}',expectedRunVersion:3,status:'pending',allowedDecisions:['approve','reject','edit'],deadline:Date.now()+10000}],{projectId:'${projectId}',controlDomainId:'${domainId}'});`,context); + const textarea = getElementById("approvals").querySelectorAll("textarea")[0]!; + assert.equal(textarea.attributes.get("aria-label"),"Edited output"); textarea.value="edited output"; + const button=getElementById("approvals").querySelectorAll("button").find(item=>item.textContent==="Edit")!; + await button.listeners.get("click")!(); await button.listeners.get("click")!(); + assert.equal(requests.filter(item=>item.url.includes("/approval-output?")).length,1); + assert.deepEqual(requests[0]!.body,{content:"edited output"}); + const sent=requests.filter(item=>item.url.includes("/decisions?")); assert.equal(sent.length,2); + assert.deepEqual(sent[0]!.body,sent[1]!.body); + assert.equal(sent[0]!.body!.expectedRunVersion,4); assert.equal(sent[0]!.body!.editKind,"output"); + assert.deepEqual(sent[0]!.body!.editArtifactRef,artifact); +}); diff --git a/packages/taskflow-core/src/control-execution.ts b/packages/taskflow-core/src/control-execution.ts new file mode 100644 index 00000000..cac149c7 --- /dev/null +++ b/packages/taskflow-core/src/control-execution.ts @@ -0,0 +1,5 @@ +/** Private control-plane bridge; does not expose the general resources API. */ +export { createResolveOnlyWorkspaceSession } from "./resources/execution.ts"; +export type { ResolveOnlyWorkspaceSession, ResolveOnlyPhaseBinding } from "./resources/execution.ts"; +export { WriteIntentJournal } from "./resources/journal.ts"; +export { registerGracefulSignalOwner } from "./runner-core.ts"; diff --git a/packages/taskflow-core/src/legacy-writer-lifecycle.ts b/packages/taskflow-core/src/legacy-writer-lifecycle.ts new file mode 100644 index 00000000..2cecae9a --- /dev/null +++ b/packages/taskflow-core/src/legacy-writer-lifecycle.ts @@ -0,0 +1,170 @@ +/** Explicit upgrade boundary for a closed set of directly supervised writers. + * + * This gate must own a NEW namespace before any old writer is admitted. It is + * not an attestation API for an existing installation. A restarted supervisor + * cannot recover proof of exclusion from PIDs or an empty queue; it must remain + * stopped for operator/service-manager reconciliation. Writers that fork other + * writers must be supervised at that outer service boundary instead. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { randomUUID } from "node:crypto"; +import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; +import { bindMutexQueue, type DirectoryIdentity } from "./resources/mutex-bootstrap.ts"; +import { defaultProcessIdentity } from "./resources/persistence.ts"; + +const fault = (detail: string) => new Error(`TFWS_LEGACY_EXCLUSION_REQUIRED: ${detail}`); +const same = (a: DirectoryIdentity, b: DirectoryIdentity) => a.dev === b.dev && a.ino === b.ino; +function directory(file: string): DirectoryIdentity { + const stat = fs.lstatSync(file); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw fault("namespace must be a real directory"); + return { dev: stat.dev, ino: stat.ino }; +} +function syncDirectory(file: string): void { + const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } +} +function durableExclusive(file: string, value: unknown): void { + const fd = fs.openSync(file, "wx", 0o600); + try { fs.writeFileSync(fd, JSON.stringify(value)); fs.fsyncSync(fd); } + finally { fs.closeSync(fd); } + syncDirectory(path.dirname(file)); +} + +export interface LegacyMigrationResult { + lockPath: string; + preservedQueue: string; + generation: string; +} + +export class LegacyWriterLifecycle { + readonly directory: string; + readonly lockPath: string; + #root: DirectoryIdentity; + #parent: DirectoryIdentity; + #genesis: DirectoryIdentity; + #genesisBytes: string; + #generation = randomUUID(); + #children = new Set(); + #closed = false; + #closedEvidence?: DirectoryIdentity; + #draining = false; + #result?: LegacyMigrationResult; + #completionProof = new Map(); + + private constructor(scope: string) { + this.directory = path.resolve(scope); + this.lockPath = path.join(this.directory, "writer.lock"); + this.#parent = directory(path.dirname(this.directory)); + // No recursive mkdir and no adoption, including after a supervisor crash. + try { fs.mkdirSync(this.directory, { mode: 0o700 }); } + catch (error) { throw fault(`new managed namespace required (${(error as NodeJS.ErrnoException).code ?? "unknown"})`); } + this.#root = directory(this.directory); + const record = path.join(this.directory, "lifecycle.json"); + this.#genesisBytes = JSON.stringify({ version: 1, generation: this.#generation, root: this.#root, owner: defaultProcessIdentity() }); + durableExclusive(record, JSON.parse(this.#genesisBytes)); + this.#genesis = fs.lstatSync(record); + syncDirectory(path.dirname(this.directory)); + } + + static create(scopeDirectory: string): LegacyWriterLifecycle { + return new LegacyWriterLifecycle(scopeDirectory); + } + + #assertIdentity(): void { + try { + if (!same(directory(path.dirname(this.directory)), this.#parent) || !same(directory(this.directory), this.#root)) throw fault("namespace replaced"); + const record = path.join(this.directory, "lifecycle.json"); + const fd = fs.openSync(record, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + const stat = fs.fstatSync(fd); + if (!stat.isFile() || !same(stat, this.#genesis) || !same(stat, fs.lstatSync(record)) || fs.readFileSync(fd, "utf8") !== this.#genesisBytes) throw fault("lifecycle evidence replaced"); + } finally { fs.closeSync(fd); } + if (this.#closed) { + const closed = path.join(this.directory, "admission.closed.json"); + const closedFd = fs.openSync(closed, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + const stat = fs.fstatSync(closedFd); + if (!this.#closedEvidence || !stat.isFile() || !same(stat, this.#closedEvidence) || !same(stat, fs.lstatSync(closed)) || + fs.readFileSync(closedFd, "utf8") !== JSON.stringify({ generation: this.#generation })) throw fault("admission closure evidence replaced"); + } finally { fs.closeSync(closedFd); } + } + } catch (error) { throw fault(`lifecycle evidence unavailable: ${(error as Error).message}`); } + } + + /** Only direct child writers are covered. Do not use for daemonizing programs. + * The caller may request shutdown, but this module never signals a process. */ + spawn(command: string, args: readonly string[] = [], options: SpawnOptions = {}): ChildProcess { + this.#assertIdentity(); + if (this.#closed) throw fault("writer admission is closed"); + if (options.detached || options.shell) throw fault("detached and shell writers require external service exclusion"); + // Persist reservation BEFORE spawn. Lost supervisor state can never be + // mistaken for a namespace with no historical admissions. + durableExclusive(path.join(this.directory, `admission-${randomUUID()}.json`), { generation: this.#generation }); + const child = spawn(command, [...args], options); + this.#children.add(child); + child.once("exit", () => { this.#children.delete(child); }); + child.once("error", () => { + // A failed spawn has no OS process; errors on an existing child are not + // evidence of exit and do not remove it from the admitted population. + if (child.pid === undefined) this.#children.delete(child); + }); + return child; + } + + /** Close admission now; finish only after actual exit observations. A timeout + * leaves the gate closed, the queue untouched, and may be retried. */ + async drainAndMigrate(options: { timeoutMs: number }): Promise { + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 0) throw new RangeError("timeoutMs must be finite and nonnegative"); + this.#assertIdentity(); + if (this.#result) { + for (const [file, proof] of this.#completionProof) { + try { + const stat = fs.lstatSync(file); + if (stat.isSymbolicLink() || !same(stat, proof.identity) || + (proof.bytes === undefined ? !stat.isDirectory() : !stat.isFile() || fs.readFileSync(file, "utf8") !== proof.bytes)) throw fault("completed migration evidence replaced"); + } catch (error) { throw fault(`completed migration evidence unavailable: ${(error as Error).message}`); } + } + return this.#result; + } + if (this.#draining) throw fault("migration is already running"); + this.#draining = true; + try { + if (!this.#closed) { + this.#closed = true; + durableExclusive(path.join(this.directory, "admission.closed.json"), { generation: this.#generation }); + this.#closedEvidence = fs.lstatSync(path.join(this.directory, "admission.closed.json")); + } + const deadline = performance.now() + options.timeoutMs; + while (this.#children.size > 0) { + this.#assertIdentity(); + if (performance.now() >= deadline) throw fault("owned writers have not exited"); + await new Promise(resolve => setTimeout(resolve, Math.min(10, Math.max(1, deadline - performance.now())))); + } + this.#assertIdentity(); + const queue = `${this.lockPath}.queue`; + // Missing queue is not permission to bootstrap after a failed upgrade. + const oldQueue = directory(queue); + for (const suffix of [".identity", ".initializing", ".initialized"]) { + try { fs.lstatSync(`${queue}${suffix}`); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; } + throw fault("namespace is already anchored or has partial initialization evidence"); + } + const preservedQueue = `${queue}.legacy-${this.#generation}`; + durableExclusive(path.join(this.directory, "migration.intent.json"), { generation: this.#generation, queue: oldQueue, preservedQueue }); + this.#assertIdentity(); + if (!same(directory(queue), oldQueue)) throw fault("legacy queue changed before preservation"); + fs.renameSync(queue, preservedQueue); + syncDirectory(this.directory); + bindMutexQueue(queue, defaultProcessIdentity(), () => false, syncDirectory); + this.#assertIdentity(); + durableExclusive(path.join(this.directory, "migration.complete.json"), { generation: this.#generation, preservedQueue }); + for (const file of [queue, preservedQueue, `${queue}.identity`, `${queue}.initializing`, `${queue}.initialized`, path.join(this.directory, "migration.complete.json")]) { + const stat = fs.lstatSync(file); + this.#completionProof.set(file, { identity: stat, bytes: stat.isFile() ? fs.readFileSync(file, "utf8") : undefined }); + } + this.#result = Object.freeze({ lockPath: this.lockPath, preservedQueue, generation: this.#generation }); + return this.#result; + } finally { this.#draining = false; } + } +} diff --git a/packages/taskflow-core/src/runner-core.ts b/packages/taskflow-core/src/runner-core.ts index d828e0a8..75e38130 100644 --- a/packages/taskflow-core/src/runner-core.ts +++ b/packages/taskflow-core/src/runner-core.ts @@ -399,8 +399,29 @@ const SIGNAL_EXIT_CODES: Readonly void) | undefined; +let gracefulSignalDeadline: ReturnType | undefined; +/** A process-owning launcher may persist cancellation before reaping its children. + * Only one trusted in-process owner can claim this bounded shutdown window. */ +export function registerGracefulSignalOwner(owner: NonNullable): () => void { + if (gracefulSignalOwner) throw new Error("a graceful process signal owner is already registered"); + gracefulSignalOwner = owner; + return () => { + if (gracefulSignalOwner !== owner) return; + gracefulSignalOwner = undefined; + if (gracefulSignalDeadline) clearTimeout(gracefulSignalDeadline); + gracefulSignalDeadline = undefined; + }; +} for (const signal of EXTERNAL_SIGNALS) { process.on(signal, () => { + if (gracefulSignalOwner) { + if (!gracefulSignalDeadline) gracefulSignalDeadline = setTimeout(() => { + killAllChildren(); + process.exit(SIGNAL_EXIT_CODES[signal]); + }, 10000); + try { gracefulSignalOwner(signal); return; } catch { /* fail into default process cleanup */ } + } if (handlingExternalSignal) return; handlingExternalSignal = true; killAllChildren(); diff --git a/packages/taskflow-core/src/runtime.ts b/packages/taskflow-core/src/runtime.ts index 2f816b88..b28486a2 100644 --- a/packages/taskflow-core/src/runtime.ts +++ b/packages/taskflow-core/src/runtime.ts @@ -49,6 +49,7 @@ import { runCodeCompilesScorer } from "./scorer-runtime.ts"; import { buildReflexionSummary, isContractViolation, REFLEXION_SENTINEL, type ReflexionInput } from "./reflexion.ts"; import { hashInput, newRunId, type PhaseState, type RunState, runsDir } from "./store.ts"; import { resolveFinalOutput } from "./final-output.ts"; +import { isQuiescentApprovalCheckpoint } from "./resume.ts"; import { CacheStore, resolveFingerprint } from "./cache.ts"; import { compileTaskflowToIR, phaseFingerprint } from "./flowir/index.ts"; import { computeStaleFrontier, declaredReadMapOfDef, readMapOf } from "./stale.ts"; @@ -154,6 +155,10 @@ export interface RuntimeDeps { /** Internal: a cwd-bridge flow may mutate workspace state, so output-only * cache/resume reuse is disabled for the entire nested execution tree. */ _disableCache?: boolean; + /** Internal host-only continuation of an exact durable approval checkpoint. + * The host must first exclude the old writer and re-admit live authority. + * Unlike cache reuse, already settled effects are never dispatched again. */ + _approvalCheckpointContinuation?: { runId: string }; /** Internal: execution originated from an LLM-authored flow{def}/ctx_spawn. * Resource-bearing fields remain denied through every nested frame. */ _dynamic?: boolean; @@ -4418,6 +4423,10 @@ export async function recomputeTaskflow( export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promise { deps = snapshotFlowLoader(deps); + if (deps._approvalCheckpointContinuation?.runId === state.runId && + !isQuiescentApprovalCheckpoint(state, state.foregroundOwner?.approvalWait ?? [])) { + throw new Error("Approval continuation requires an exact quiescent checkpoint"); + } const def: Taskflow = state.def; const failBeforeExecution = (finalOutput: string): RuntimeResult => { state.status = "failed"; @@ -4616,6 +4625,9 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi async function runTaskflowLayers(state: RunState, deps: RuntimeDeps): Promise { const def: Taskflow = state.def; + const settledCheckpointPhases = new Set(deps._approvalCheckpointContinuation?.runId === state.runId + ? Object.values(state.phases).filter(phase => ["done", "failed", "skipped"].includes(phase.status)).map(phase => phase.id) + : []); // Ownership migration must happen before ANY phase in the new definition is // scheduled. Definition evolution may remove/rename ordinary phases as well // as graft children; neither their terminal failure nor their usage may leak @@ -4715,6 +4727,20 @@ async function runTaskflowLayers(state: RunState, deps: RuntimeDeps): Promise [p.id, p])); + // A concurrently completed gate may already have blocked the old execution + // while another branch waited for approval. Preserve that control outcome as + // well as its output; skipping its completed work must not reopen the gate. + for (const phase of def.phases) { + if (!settledCheckpointPhases.has(phase.id) || !["gate", "approval"].includes(phase.type ?? "agent")) continue; + const previous = state.phases[phase.id]; + if (previous?.gate?.verdict === "block") { + gateBlocked = true; + gateReason = previous.gate.reason ?? ""; + gateOutput = previous.output ?? ""; + gatePhaseId = phase.id; + break; + } + } for (const layer of layers) { if (deps.signal?.aborted) { @@ -4728,6 +4754,9 @@ async function runTaskflowLayers(state: RunState, deps: RuntimeDeps): Promise { + // This is continuation of this exact execution, including non-idempotent + // effects. Cache policy and external fingerprint changes cannot replay it. + if (settledCheckpointPhases.has(phase.id)) return; // Snapshot prior state BEFORE marking running, so resume cache checks work. const prior = state.phases[phase.id]; diff --git a/packages/taskflow-core/test/approval-continuation.test.ts b/packages/taskflow-core/test/approval-continuation.test.ts new file mode 100644 index 00000000..c823fc6f --- /dev/null +++ b/packages/taskflow-core/test/approval-continuation.test.ts @@ -0,0 +1,28 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { executeTaskflow } from "../src/runtime.ts"; +import type { RunState } from "../src/store.ts"; + +test("exact approval continuation preserves a gate that blocked while another branch waited", async () => { + const state: RunState = { runId: "checkpoint", flowName: "blocked-checkpoint", def: { name: "blocked-checkpoint", phases: [ + { id: "gate", type: "approval", task: "First independent gate" }, + { id: "waiting", type: "approval", task: "Second independent gate" }, + { id: "effect", type: "script", dependsOn: ["waiting"], run: "exit 77" }, + ] }, args: {}, cwd: process.cwd(), status: "running", createdAt: 1, updatedAt: 1, + foregroundOwner: { version: 1, pid: process.pid, instanceId: "verified-host", startedAt: 1, approvalWait: ["waiting"] }, + phases: { gate: { id: "gate", status: "done", gate: { verdict: "block", reason: "Already rejected" }, output: "Blocked output" }, waiting: { id: "waiting", status: "running" } } }; + const result = await executeTaskflow(state, { cwd: process.cwd(), agents: [], _disableCache: true, + _approvalCheckpointContinuation: { runId: state.runId }, requestApproval: async () => { throw new Error("blocked approval must not run again"); } }); + assert.equal(result.state.status, "blocked"); + assert.equal(result.state.phases.effect?.status, "skipped"); + assert.match(result.finalOutput, /Already rejected[\s\S]*Blocked output/); +}); + +test("exact continuation rejects a marker that leaves independent work runnable", async () => { + const state: RunState = { runId: "unsafe", flowName: "unsafe", def: { name: "unsafe", phases: [ + { id: "waiting", type: "approval", task: "Wait" }, { id: "effect", type: "script", run: "exit 77" }, + ] }, args: {}, cwd: process.cwd(), status: "running", createdAt: 1, updatedAt: 1, + foregroundOwner: { version: 1, pid: process.pid, instanceId: "host", startedAt: 1, approvalWait: ["waiting"] }, + phases: { waiting: { id: "waiting", status: "running" } } }; + await assert.rejects(executeTaskflow(state, { cwd: process.cwd(), agents: [], _approvalCheckpointContinuation: { runId: state.runId } }), /quiescent checkpoint/); +}); diff --git a/packages/taskflow-core/test/legacy-writer-lifecycle.test.ts b/packages/taskflow-core/test/legacy-writer-lifecycle.test.ts new file mode 100644 index 00000000..c0269639 --- /dev/null +++ b/packages/taskflow-core/test/legacy-writer-lifecycle.test.ts @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { once } from "node:events"; +import { test, type TestContext } from "node:test"; +import { spawn, type ChildProcess } from "node:child_process"; +import { LegacyWriterLifecycle } from "../src/legacy-writer-lifecycle.ts"; +import { PersistentFileMutex } from "../src/persistent-mutex.ts"; + +function fixture(t: TestContext) { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-legacy-gate-")); + t.after(() => fs.rmSync(parent, { recursive: true, force: true })); + const scope = path.join(parent, "managed"); + const gate = LegacyWriterLifecycle.create(scope); + const queue = `${gate.lockPath}.queue`; + fs.mkdirSync(queue); + return { parent, scope, gate, queue }; +} +async function writer(t: TestContext, gate: LegacyWriterLifecycle, code: string): Promise { + const child = gate.spawn(process.execPath, ["--input-type=module", "-e", code], { stdio: ["ignore", "ignore", "pipe", "ipc"] }); + t.after(async () => { + if (child.exitCode === null && child.signalCode === null) { + const done = once(child, "exit"); + child.kill("SIGKILL"); + await done; + } + }); + await once(child, "message"); + return child; +} +async function stop(child: ChildProcess) { + const done = once(child, "exit"); + child.send("stop"); + await done; +} + +test("legacy migration refuses paused pre-ticket writer despite empty queue", async t => { + const { gate, queue } = fixture(t); + const child = await writer(t, gate, 'process.on("message", () => process.exit(0)); process.send("paused-before-ticket");'); + await assert.rejects(gate.drainAndMigrate({ timeoutMs: 20 }), /owned writers have not exited/); + assert.deepEqual(fs.readdirSync(queue), []); + assert.equal(fs.existsSync(`${queue}.identity`), false); + assert.throws(() => gate.spawn(process.execPath, ["-e", "process.exit(0)"]), /admission is closed/); + await stop(child); + const result = await gate.drainAndMigrate({ timeoutMs: 500 }); + assert.deepEqual(fs.readdirSync(result.preservedQueue), []); + (await new PersistentFileMutex(result.lockPath).acquire({ timeoutMs: 500 }))(); +}); + +test("legacy live holder must exit; preserved ticket bytes are never cleaned or overwritten", async t => { + const { gate, queue } = fixture(t); + const ticket = path.join(queue, "old-holder.json"); + const child = await writer(t, gate, `import fs from 'node:fs'; fs.writeFileSync(${JSON.stringify(ticket)}, 'historical-holder'); process.on('message', () => process.exit(0)); process.send('holding');`); + await assert.rejects(gate.drainAndMigrate({ timeoutMs: 10 }), /not exited/); + assert.equal(fs.readFileSync(ticket, "utf8"), "historical-holder"); + await stop(child); + const result = await gate.drainAndMigrate({ timeoutMs: 500 }); + assert.equal(fs.readFileSync(path.join(result.preservedQueue, "old-holder.json"), "utf8"), "historical-holder"); + assert.deepEqual(fs.readdirSync(queue), []); +}); + +test("closing admission is synchronous while existing writers drain", async t => { + const { gate } = fixture(t); + const child = await writer(t, gate, 'process.on("message", () => process.exit(0)); process.send("ready");'); + const draining = gate.drainAndMigrate({ timeoutMs: 1000 }); + assert.throws(() => gate.spawn(process.execPath), /admission is closed/); + await assert.rejects(gate.drainAndMigrate({ timeoutMs: 1 }), /already running/); + await stop(child); + await draining; +}); + +test("a restarted gate cannot reconstruct unknown liveness from disk or an empty queue", t => { + const { scope, queue } = fixture(t); + assert.throws(() => LegacyWriterLifecycle.create(scope), /new managed namespace required/); + assert.deepEqual(fs.readdirSync(queue), []); +}); + +test("preexisting unmanaged scope is never adopted", t => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-unmanaged-")); + t.after(() => fs.rmSync(parent, { recursive: true, force: true })); + const queue = path.join(parent, "writer.lock.queue"); + fs.mkdirSync(queue); + fs.writeFileSync(path.join(queue, "old-ticket"), "keep"); + assert.throws(() => LegacyWriterLifecycle.create(parent), /new managed namespace required/); + assert.equal(fs.readFileSync(path.join(queue, "old-ticket"), "utf8"), "keep"); +}); + +for (const changed of ["deleted", "replaced", "corrupted"] as const) { + test(`missing or ${changed} lifecycle evidence cannot authorize migration`, async t => { + const { gate, scope, queue } = fixture(t); + const marker = path.join(scope, "lifecycle.json"); + if (changed === "corrupted") fs.writeFileSync(marker, "{}"); + else { + const bytes = fs.readFileSync(marker); + fs.renameSync(marker, `${marker}.saved`); + if (changed === "replaced") fs.writeFileSync(marker, bytes); + } + await assert.rejects(gate.drainAndMigrate({ timeoutMs: 0 }), /lifecycle evidence/); + assert.equal(fs.existsSync(queue), true); + assert.equal(fs.existsSync(`${queue}.identity`), false); + }); +} + +test("root replacement rejects stale gate", async t => { + const { gate, scope } = fixture(t); + fs.renameSync(scope, `${scope}.saved`); + fs.mkdirSync(scope); + await assert.rejects(gate.drainAndMigrate({ timeoutMs: 0 }), /namespace replaced/); + assert.deepEqual(fs.readdirSync(scope), []); +}); + +test("partial new-protocol evidence must not be erased as a legacy migration", async t => { + const { gate, queue } = fixture(t); + fs.writeFileSync(`${queue}.initialized`, "corrupt"); + await assert.rejects(gate.drainAndMigrate({ timeoutMs: 0 }), /partial initialization/); + assert.equal(fs.readFileSync(`${queue}.initialized`, "utf8"), "corrupt"); + assert.equal(fs.existsSync(queue), true); +}); + +test("daemonizing options cannot enter direct-child lifecycle", t => { + const { gate } = fixture(t); + assert.throws(() => gate.spawn(process.execPath, [], { detached: true }), /external service exclusion/); + assert.throws(() => gate.spawn(process.execPath, [], { shell: true }), /external service exclusion/); +}); + +test("missing old queue never becomes implicit successful migration", async t => { + const { gate, queue } = fixture(t); + fs.rmdirSync(queue); + await assert.rejects(gate.drainAndMigrate({ timeoutMs: 0 }), /ENOENT/); + assert.equal(fs.existsSync(`${queue}.identity`), false); +}); + +test("deleted closed-admission evidence after timeout cannot authorize retry", async t => { + const { gate, scope, queue } = fixture(t); + const child = await writer(t, gate, 'process.on("message", () => process.exit(0)); process.send("ready");'); + await assert.rejects(gate.drainAndMigrate({ timeoutMs: 0 }), /not exited/); + await stop(child); + fs.unlinkSync(path.join(scope, "admission.closed.json")); + await assert.rejects(gate.drainAndMigrate({ timeoutMs: 0 }), /lifecycle evidence unavailable/); + assert.equal(fs.existsSync(queue), true); + assert.equal(fs.existsSync(`${queue}.identity`), false); +}); + + +test("fresh process cannot reopen lifecycle after supervisor exit", async t => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-legacy-restart-")); + t.after(() => fs.rmSync(parent, { recursive: true, force: true })); + const scope = path.join(parent, "managed"); + const source = new URL("../src/legacy-writer-lifecycle.ts", import.meta.url).href; + const creator = spawn(process.execPath, ["--experimental-strip-types", "--input-type=module", "-e", + `import fs from 'node:fs'; import { LegacyWriterLifecycle } from ${JSON.stringify(source)}; const gate = LegacyWriterLifecycle.create(${JSON.stringify(scope)}); fs.mkdirSync(gate.lockPath + '.queue');`], { stdio: "pipe" }); + const [code] = await once(creator, "exit"); + assert.equal(code, 0); + assert.throws(() => LegacyWriterLifecycle.create(scope), /new managed namespace required/); + assert.equal(fs.existsSync(path.join(scope, "writer.lock.queue.identity")), false); +}); + + +test("completed migration cannot replay success after initialization evidence deletion", async t => { + const { gate, queue } = fixture(t); + const result = await gate.drainAndMigrate({ timeoutMs: 0 }); + assert.deepEqual(await gate.drainAndMigrate({ timeoutMs: 0 }), result); + fs.unlinkSync(`${queue}.initialized`); + await assert.rejects(gate.drainAndMigrate({ timeoutMs: 0 }), /completed migration evidence unavailable/); +}); diff --git a/packages/taskflow-mcp-core/src/mcp/jsonrpc.ts b/packages/taskflow-mcp-core/src/mcp/jsonrpc.ts index c252912e..c79c7c67 100644 --- a/packages/taskflow-mcp-core/src/mcp/jsonrpc.ts +++ b/packages/taskflow-mcp-core/src/mcp/jsonrpc.ts @@ -107,14 +107,23 @@ export function serveStdio( const trimmed = line.trim(); if (!trimmed) return; - let msg: JsonRpcRequest; + let parsed: unknown; try { - msg = JSON.parse(trimmed); + parsed = JSON.parse(trimmed); } catch { // No recoverable id — per JSON-RPC, a parse error uses id:null. respondErr(null, { code: RPC.PARSE_ERROR, message: "Parse error" }); return; } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + respondErr(null, { code: RPC.INVALID_REQUEST, message: "Invalid Request" }); + return; + } + const msg = parsed as JsonRpcRequest; + if (msg.id !== undefined && msg.id !== null && typeof msg.id !== "string" && typeof msg.id !== "number") { + respondErr(null, { code: RPC.INVALID_REQUEST, message: "Invalid Request" }); + return; + } const isNotification = msg.id === undefined || msg.id === null; const id = (msg.id ?? null) as string | number | null; @@ -136,7 +145,7 @@ export function serveStdio( return; } - const handler = handlers[msg.method]; + const handler = Object.hasOwn(handlers, msg.method) ? handlers[msg.method] : undefined; if (!handler) { // Unknown notifications are silently ignored (e.g. notifications/*). if (!isNotification) respondErr(id, { code: RPC.METHOD_NOT_FOUND, message: `Method not found: ${msg.method}` }); diff --git a/packages/taskflow-mcp-core/test/jsonrpc-malformed.test.ts b/packages/taskflow-mcp-core/test/jsonrpc-malformed.test.ts new file mode 100644 index 00000000..6be4d902 --- /dev/null +++ b/packages/taskflow-mcp-core/test/jsonrpc-malformed.test.ts @@ -0,0 +1,18 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { PassThrough } from "node:stream"; +import { serveStdio, RPC } from "../src/mcp/jsonrpc.ts"; + +test("stdio rejects nonobjects and invalid ids, ignores inherited handlers, and remains usable", async () => { + const input = new PassThrough(), output = new PassThrough(); + let text = ""; output.on("data", chunk => { text += chunk.toString(); }); + const done = serveStdio({ ping: () => ({ alive: true }) }, { input, output }); + for (const line of ["null", "[]", "true", "42", '{"jsonrpc":"2.0","id":{},"method":"ping"}', '{"jsonrpc":"2.0","id":1,"method":"toString"}', '{"jsonrpc":"2.0","id":2,"method":"ping"}']) input.write(line + "\n"); + await new Promise(resolve => setImmediate(resolve)); + input.end(); await done; + const messages = text.trim().split("\n").map(line => JSON.parse(line)); + assert.equal(messages.length, 7); + assert.equal(messages.filter(message => message.error?.code === RPC.INVALID_REQUEST).length, 5); + assert.equal(messages.find(message => message.id === 1).error.code, RPC.METHOD_NOT_FOUND); + assert.deepEqual(messages.find(message => message.id === 2).result, { alive: true }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4904e980..fe780acb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,9 @@ importers: taskflow-core: specifier: workspace:* version: link:../taskflow-core + taskflow-mcp-core: + specifier: workspace:* + version: link:../taskflow-mcp-core typebox: specifier: '*' version: 1.3.10 From 7048c79c9817204753c65e4b25ccee184e9ed84e Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 8 Oct 2026 10:37:46 +0800 Subject: [PATCH 26/30] test: generate ephemeral security fixture values --- packages/taskflow-control/test/web-console.test.ts | 5 +++-- packages/taskflow-core/test/resource-persistence.test.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/taskflow-control/test/web-console.test.ts b/packages/taskflow-control/test/web-console.test.ts index 41eeaa0c..a34f180d 100644 --- a/packages/taskflow-control/test/web-console.test.ts +++ b/packages/taskflow-control/test/web-console.test.ts @@ -239,6 +239,7 @@ for (const boundary of ["execute", "call", "disclose"] as const) { const resume = deferred(); const finished = deferred(); let calls = 0; + const privateResult = randomUUID(); const console = await launch(t, { sessionTtlMs: invalidation === "expiry" ? 200 : 60_000, authorize: async ({ purpose }) => { @@ -246,7 +247,7 @@ for (const boundary of ["execute", "call", "disclose"] as const) { return { call: async () => { calls++; if (boundary === "call") { entered.resolve(); await resume.promise; finished.resolve(); } - return { secret: "private-result" }; + return { secret: privateResult }; } }; }, }); @@ -265,7 +266,7 @@ for (const boundary of ["execute", "call", "disclose"] as const) { assert.equal(calls, boundary === "execute" ? 0 : 1); if (invalidation === "logout" || invalidation === "expiry") { assert.equal(result?.status, 401); - assert.doesNotMatch(result!.text, /private-result/); + assert.equal(result!.text.includes(privateResult), false); } else assert.equal(result, null); }); } diff --git a/packages/taskflow-core/test/resource-persistence.test.ts b/packages/taskflow-core/test/resource-persistence.test.ts index 34e3a520..2b1979f0 100644 --- a/packages/taskflow-core/test/resource-persistence.test.ts +++ b/packages/taskflow-core/test/resource-persistence.test.ts @@ -347,7 +347,7 @@ for (const replacement of ["owner", "inode"] as const) { fs.writeFileSync(ticket, bytes); } else { const record = JSON.parse(bytes.toString()); - fs.writeFileSync(ticket, JSON.stringify({ ...record, birthToken: "replacement-owner" })); + fs.writeFileSync(ticket, JSON.stringify({ ...record, birthToken: crypto.randomUUID() })); } const preserved = fs.readFileSync(ticket); release(); From ca247acd806dd0a37268eea8bf7078cf28f643cd Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 8 Oct 2026 10:42:18 +0800 Subject: [PATCH 27/30] test: generate remaining synthetic owner and result fixtures --- packages/taskflow-control/test/web-console.test.ts | 7 ++++--- packages/taskflow-core/test/resource-persistence.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/taskflow-control/test/web-console.test.ts b/packages/taskflow-control/test/web-console.test.ts index a34f180d..4976f628 100644 --- a/packages/taskflow-control/test/web-console.test.ts +++ b/packages/taskflow-control/test/web-console.test.ts @@ -276,14 +276,15 @@ for (const failure of [false, true]) { test(`web console: revocation during owner call suppresses delayed ${failure ? "error" : "result"} without replaying mutation`, async (t) => { const entered = deferred(); const resume = deferred(); let revoked = false; let calls = 0; + const privateResult = randomUUID(); const purposes: string[] = []; const console = await launch(t, { authorize: async ({ purpose }) => { purposes.push(purpose); if (revoked) throw new ControlError("TF_AUTHORITY_REVOKED", "revoked"); return { call: async () => { calls++; entered.resolve(); await resume.promise; - if (failure) throw new ControlError("TF_STALE_VERSION", "private-result"); - return { secret: "private-result" }; + if (failure) throw new ControlError("TF_STALE_VERSION", privateResult); + return { secret: privateResult }; } }; } }); const headers = await session(console); @@ -291,7 +292,7 @@ for (const failure of [false, true]) { await entered.promise; revoked = true; resume.resolve(); const response = await pending; assert.equal(response.status, 403); - assert.doesNotMatch(await response.text(), /private-result/); + assert.equal((await response.text()).includes(privateResult), false); assert.equal(calls, 1); assert.deepEqual(purposes, ["execute", "disclose"]); }); diff --git a/packages/taskflow-core/test/resource-persistence.test.ts b/packages/taskflow-core/test/resource-persistence.test.ts index 2b1979f0..e2f03588 100644 --- a/packages/taskflow-core/test/resource-persistence.test.ts +++ b/packages/taskflow-core/test/resource-persistence.test.ts @@ -371,7 +371,7 @@ test("persistent mutex abort preserves a changed waiting owner", async (t) => { assert.ok(name); const ticket = path.join(queue, name); const record = JSON.parse(fs.readFileSync(ticket, "utf8")); - const replacement = JSON.stringify({ ...record, birthToken: "different-owner" }); + const replacement = JSON.stringify({ ...record, birthToken: crypto.randomUUID() }); fs.writeFileSync(ticket, replacement); controller.abort(); await rejected; @@ -388,7 +388,7 @@ test("persistent mutex stale cleanup preserves a record replaced during process const ticket = path.join(`${lock}.queue`, `ticket-${token}.json`); const old = { kind: "ticket", pid: 424242, birthToken: "old", birthTokenKind: "native", token, ticket: 1, createdAt: 1, state: "held" }; fs.writeFileSync(ticket, JSON.stringify(old)); - const replacement = JSON.stringify({ ...old, birthToken: "replacement" }); + const replacement = JSON.stringify({ ...old, birthToken: crypto.randomUUID() }); await assert.rejects(new PersistentFileMutex(lock, { inspectProcess: () => { fs.writeFileSync(ticket, replacement); return { alive: false }; }, }).acquire(), /record changed during cleanup/); From 472bb98ded6c47813c8610fefb74c13f0127cdbb Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 8 Oct 2026 11:36:06 +0800 Subject: [PATCH 28/30] fix(control): close operator delivery and Pi compatibility gaps Provision explicit operator authority and expose audited CLI/MCP mutations. Preserve singleton epochs through graceful restart and validate legacy coordinator evidence before migration. Bundle control in the existing public MCP package and verify fresh installed default-auto execution. Retain lifecycle recovery while restricting ambient Pi MCP/resource tools, and reconcile release scope documentation. --- .github/workflows/ci.yml | 27 ++- .gitignore | 1 + RELEASE.md | 49 +++- docs/internal/0.3-c-control-plane-plan.md | 2 +- docs/internal/1.0.0-ga-scoreboard.md | 114 ++++----- docs/internal/1.0.0-release-plan.md | 10 +- docs/internal/1.0.0-spec-closure.md | 163 ++++++------- packages/pi-taskflow/src/approval-view.ts | 2 +- packages/pi-taskflow/src/runner.ts | 16 +- packages/pi-taskflow/test/e2e-pi-compat.mts | 184 ++++++++++++++ .../pi-taskflow/test/fixtures/compat-mcp.mjs | 16 ++ .../test/fixtures/compat-provider.mjs | 49 ++++ packages/pi-taskflow/test/runner.test.ts | 15 ++ packages/taskflow-control/GUIDE.md | 52 +++- .../taskflow-control/src/authorization.ts | 3 + packages/taskflow-control/src/control-cli.ts | 48 +++- packages/taskflow-control/src/control-host.ts | 5 +- packages/taskflow-control/src/control-mcp.ts | 16 +- packages/taskflow-control/src/host-runtime.ts | 9 +- .../taskflow-control/src/local-bootstrap.ts | 89 ++++++- .../taskflow-control/src/project-admin-cli.ts | 2 +- packages/taskflow-control/src/singleton.ts | 57 ++++- .../src/store/coordinator-store.ts | 53 ++++- .../taskflow-control/test/control-mcp.test.ts | 2 +- .../test/operator-route.test.ts | 224 ++++++++++++++++++ .../taskflow-control/test/singleton.test.ts | 34 +++ .../test/release-contract.test.ts | 1 + packages/taskflow-mcp-core/package.json | 14 +- pnpm-lock.yaml | 3 + scripts/build-control-bundle.mjs | 21 ++ scripts/smoke-control-bundle.mjs | 51 ++++ scripts/smoke-packed-packages.mjs | 4 +- scripts/verify-release-contract.mjs | 3 + 33 files changed, 1141 insertions(+), 198 deletions(-) create mode 100644 packages/pi-taskflow/test/e2e-pi-compat.mts create mode 100644 packages/pi-taskflow/test/fixtures/compat-mcp.mjs create mode 100644 packages/pi-taskflow/test/fixtures/compat-provider.mjs create mode 100644 packages/taskflow-control/test/operator-route.test.ts create mode 100644 scripts/build-control-bundle.mjs create mode 100644 scripts/smoke-control-bundle.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 523dac76..b6065c06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,7 @@ jobs: strategy: fail-fast: false matrix: - pi: ["0.80.3", "1.0.0"] + pi: ["0.80.3", "0.85.1", "1.0.4"] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 @@ -86,27 +86,30 @@ jobs: cache: "pnpm" - name: Install locked dependencies run: pnpm install --frozen-lockfile --registry https://registry.npmjs.org/ - # The regular Node 22/24 suite uses the locked 1.0.0 adapter dev peers. - # Also exercise the former adapter resolution, rather than accidentally - # claiming the root SDK version is used by every workspace package. - - name: Install previous adapter peers - if: matrix.pi == '0.80.3' + # The regular Node 22/24 suite and packed smoke retain locked 1.0.0 peers. + # Change only this disposable job's adapter peers; verify their direct + # resolution before claiming the selected SDK was actually exercised. + - name: Install exact adapter peers run: >- pnpm --filter pi-taskflow add --save-dev --save-exact - @earendil-works/pi-agent-core@0.80.3 - @earendil-works/pi-ai@0.80.3 - @earendil-works/pi-coding-agent@0.80.3 - @earendil-works/pi-tui@0.80.3 + @earendil-works/pi-agent-core@${{ matrix.pi }} + @earendil-works/pi-ai@${{ matrix.pi }} + @earendil-works/pi-coding-agent@${{ matrix.pi }} + @earendil-works/pi-tui@${{ matrix.pi }} --registry https://registry.npmjs.org/ - name: Verify resolved SDK and run complete adapter suite run: pnpm run test:pi:peers env: PI_TEST_PEER_VERSION: ${{ matrix.pi }} - - name: Build and exercise real Pi 1.0 CLI, approvals, and crash recovery - if: matrix.pi == '1.0.0' + - name: Build core and Pi extension against selected SDK run: | pnpm --filter taskflow-core build pnpm --filter pi-taskflow build + - name: Real Pi compatibility and MCP allowlist controls + run: node --experimental-strip-types packages/pi-taskflow/test/e2e-pi-compat.mts + - name: Real Pi 1.0.4 lifecycle, approvals, usage, and crash recovery + if: matrix.pi == '1.0.4' + run: | pnpm run test:e2e-pi pnpm run test:e2e-pi-crash-resume diff --git a/.gitignore b/.gitignore index bca43df4..307761cf 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ dist/ # Generated at build time from the root README (single source of truth) packages/*/README.md !packages/charterarc/README.md +packages/taskflow-mcp-core/CONTROL_GUIDE.md # Runtime state (never commit) data/ diff --git a/RELEASE.md b/RELEASE.md index 53c970bb..2a97548f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -46,10 +46,21 @@ exports must use shipped JavaScript/declarations and never source-only `development` conditions. Codex/Claude/Grok plugin scaffolds are distributed from the repository, separately from their npm delivery packages. -`taskflow-control` is private, workspace-only and experimental. Its Unix UDS -and store implementation does not constitute a complete control plane: -BoundPlan admission, authorized command-result replay, global concurrency, parked approvals/CAS/receipts, -registry coordination, Windows named pipes and WebUI are incomplete. +The private `taskflow-control` workspace is the source implementation; it is not +an eleventh npm release package. The existing public `taskflow-mcp-core` tarball +bundles its compiled JavaScript under `dist/control/` and exposes +`taskflow-control` and `taskflow-project-admin` binaries. Its build compiles the +bundle directly from source; consumers need neither a checkout nor the private +package. `CONTROL_GUIDE.md` is included in that tarball. The shared release +contract verifies these two public bin declarations. + +The candidate implements authenticated multi-project routing, TE admission, +authorized durable replay, global concurrency, parked approvals/CAS, Receipt, +CLI/MCP and the local WebUI. The operator path uses a separately provisioned +credential and explicit risk acknowledgement for forced release. Current +implementation and acceptance boundaries are in the [closure ledger](docs/internal/1.0.0-spec-closure.md). +Unix UDS is the supported control transport; Windows named pipes remain non-GA +under accepted P13. These facts replace the former scaffold-only description. CharterArc retains its separate experimental version/tag/workflow and is excluded from the ten-package transaction. These packaging boundaries do not waive accepted specifications: 1.0 publication remains blocked on their @@ -58,6 +69,36 @@ writes remain host-policy dependent. SecretRef/ServiceRef have no live vault/service backend. High-scale journals and full kernel parity are not claimed; the optional event kernel still falls back for unsupported features. +## Bundled control: fresh installation and upgrade + +For a **local candidate**, install the retained `taskflow-core-1.0.0.tgz` and +`taskflow-mcp-core-1.0.0.tgz` plus the selected TypeBox peer into a new npm project +with lifecycle scripts disabled. No `taskflow-control` tarball is installed. +`scripts/smoke-control-bundle.mjs`, called by the ten-package consumer gate, +executes the installed `.bin` entries, a fresh default-auto script run, a second +run after restart and durable status lookup, with an isolated `TASKFLOW_HOME`. + +After a separately authorized publication, the equivalent public installation is: + +```sh +npm install --save-exact taskflow-mcp-core@1.0.0 typebox@1.3.30 +./node_modules/.bin/taskflow-control run --root /absolute/project --flow /absolute/flow.json +``` + +This command is a post-publication instruction, not a claim that the candidate +has been published. With no `--mode`, `auto` creates the registry and project +store and starts or attaches to the user singleton; no manual daemon setup is +required. Keep `TASKFLOW_HOME` outside execution projects. The CLI executes +script phases; agent phases require a configured embedded runner. The installed +binary is `node_modules/taskflow-mcp-core/dist/control/control-cli.js`. + +For upgrade, stop the owned control process gracefully, preserve the control +home and project journals, replace the public package, and restart via the same +command/home. Incompatible handshakes fail closed; do not delete authority state +to bypass version skew. An ambiguous recovery keeps capacity and requires an +explicit operator decision. Never run old and new private/public launchers as +separate authorities: they use the same singleton endpoint and stores. + ## Required preparation and verification Use the repository-pinned pnpm and Node **22.19.0 or newer**. Run the full unit diff --git a/docs/internal/0.3-c-control-plane-plan.md b/docs/internal/0.3-c-control-plane-plan.md index 05416e46..b7dd1e54 100644 --- a/docs/internal/0.3-c-control-plane-plan.md +++ b/docs/internal/0.3-c-control-plane-plan.md @@ -5,7 +5,7 @@ > **Base:** `rc/0.3.0-trusted-effects` @ sync 0.2.10 + TE 闭环 > **Design source:** Control Plane RFC v7.6(archive `backup/0.3-archive/mac-feat-0.3.0-control-plane`),**概念重写,不搬代码** -> **当前实现边界:** `taskflow-control` 是 private workspace 实验;上述计划不是完成状态。[P12 ADR](./p-adrs/P12-command-batch-reauth.md) 已 Accepted,规定 command batch 与 re-auth disclosure 的合同;合同接受不等于功能已实现。`commands.submit` 目前只原子追加 CommandRecord 与 ControlEvents,不执行 TE。Authorized command-result replay 尚未实现:`callerPrincipal` 与 `authorizationContextHash` 来自请求正文,只是 audit 字段,不构成可信身份或实时授权。所有重复 commandId(包括相同 hash/principal)在写入前 fail closed;重启后也不返回历史结果。RFC §9.4 要求的结果披露必须等待 verified principal 与 live project/command-class authorization 边界,不能用 audit 字段相等代替。 +> **历史计划与当前状态:** 本文保留 2026-08-12 批准的范围和阶段,不作为当前实现缺口清单。此前 scaffold-only 描述已被实现取代:当前候选已有可信身份、实时授权、TE admission、持久结果重放、协调并发、审批及 WebUI。具体代码、仍有边界和精确提交验收见 [1.0 closure ledger](./1.0.0-spec-closure.md) 与 [scoreboard](./1.0.0-ga-scoreboard.md)。计划获批、代码存在与发布验收是不同结论;本文不授权发布。 ## 战略 diff --git a/docs/internal/1.0.0-ga-scoreboard.md b/docs/internal/1.0.0-ga-scoreboard.md index 1d498227..480669b7 100644 --- a/docs/internal/1.0.0-ga-scoreboard.md +++ b/docs/internal/1.0.0-ga-scoreboard.md @@ -1,58 +1,60 @@ # Taskflow 1.0.0 acceptance scoreboard -> **Active track: unreleased 1.0.0 / Draft PR #142 → main.** -> `rc/0.3.0-beta.2` is the retained historical branch name, not the release version. -> Latest published prerelease: `v0.3.0-beta.1.2`. See the -> [2026-10-07 track reconciliation](./1.0.0-release-plan.md#release-track-reconciliation-2026-10-07). -> This documentation reconciliation does not add runtime acceptance evidence or unblock publication. - -Status: **publication blocked on historical spec closure**. Updated 2026-10-02. -The owner's latest instruction requires implementation and acceptance of the -previously committed specs before publication. Earlier test passes remain -valid only for their exercised scope; experimental labels do not waive missing -accepted requirements. No merge, tag or npm publication has occurred. -This ledger replaces the current-release interpretation of the historical -0.3 scoreboard. Local checks use macOS, Node 24.21.0, pnpm 11.17.0 and Pi 1.0.0. -The final PR head's GitHub checks are the authoritative Node 22/Linux/Windows -and remote security evidence; pending rows must not be inferred from local passes. - -| Gate | State | Evidence | -|---|---|---| -| Public scope/version pins | PASS (working tree) | `node scripts/verify-release-contract.mjs --candidate`: ten packages at 1.0.0; exact plugin/MCP pins; private experimental control | -| Release guard regressions | PASS locally | 23 focused contract/date/dist-tag/provenance tests; includes execution of the workflow's existing-release branch and false booleans | -| Node 24 full suite/typecheck | PASS locally | `pnpm test`: 2655 passed before final review; the timed-approval fault case was added and fixed. A later 2657-case run found a test cleanup acknowledgement race; that race is fixed and final whole-suite verification is pending. `pnpm run typecheck` passes | -| Node 22 full suite | REMOTE GATE | final PR head, not historical CI | -| All-package build/DSL dist E2E | PASS locally | `pnpm run build`; `pnpm --filter taskflow-dsl run test:e2e` builds, packs and clean-installs the CLI, including rejection cases | -| Deterministic ten-package clean consumer | PASS on Node 24; Node 22 remote gate | 27 explicit imports, 84 wildcard exports, six bins, repeated deterministic packs and clean npm installation | -| Five MCP server E2E | PASS locally | all five stdio suites plus 16 comprehensive Codex MCP checks; fixture executors. TE/background/cancellation/resume also have full-suite regression coverage | -| Pi 1.0 packed real process | PASS locally | 17 standard cases plus rapid/slow SIGKILL recovery use actual Pi CLI, packed extension and spawned children; deterministic local provider, no live model. Covers shorthand, map/reduce, context handshake, codemode, retry/retry exhaustion, usage, terminal reap, cancellation and RPC approval | -| Pi identity/resume and legacy lifecycle | PASS for untimed, quiescent approval recovery; final fault review pending | real Pi rapid/slow SIGKILL → fresh Pi resume preserves completed effects once and parent bytes, rejects living owners and propagates new child identity. Timed approval/in-flight-child cases are excluded from automatic recovery; Pi 0.80.3 compaction replay remains covered | -| Pi TUI interactive and RPC/headless approval | PASS at stated scope | actual PTY: fullscreen approve/reject, normal approve/Escape. Real RPC: approve/reject/cancel/edit/edit-cancel/abort; print rejects. Long preview/confirmation/abort/error paths also covered by component/requester tests; not every terminal/keyboard combination exercised | -| Current live host executors | PASS for available Codex and Claude | Codex `gpt-6.1-sol`/high: 3-phase fruit→uppercase→TrustedEffects persistence, 39.2s. Claude `haiku`: 2-phase output transfer, 21.3s. Both use real authenticated CLIs | -| OpenCode/Grok/Hermes live execution | NOT RUN | binaries unavailable locally; MCP fixture passes do not establish live model acceptance | -| Process SIGKILL/recovery/restore/cleanup | PASS at tested scope; final fault review pending | actual Pi foreground crash recovery and parallel-child EACCES rejection now supplement existing detached/resource tests. UDS has 54 independent process tests; full control suite185/185 includes copy/move/replaced-directory rejection. Arbitrary in-flight recovery is not claimed | -| Website export/audit | PASS locally | production static export with `/taskflow` base path; patched Next.js 16.3.6, sharp 0.35.4, baseline-browser-mapping 2.11.0, Undici 8.10.2 and brace-expansion 5.0.12; full `pnpm audit` (including development dependencies) reports zero vulnerabilities | -| Remote security | REMOTE GATE | exact-head CodeQL and Plugin Security Scan, retaining Cisco coverage and severity/score gates | -| Final exact-SHA remote CI | PENDING | do not reuse PR173/174 or historical beta green as integrated-head evidence | -| Historical accepted specification closure | BLOCKED | approved Control Plane plan S3–S7 and accepted P-ADRs contain unimplemented runtime contracts; the owner explicitly selected full Control Plane scope A; proposed wire details must be aligned with accepted behavior. A package being private is not an acceptance waiver | -| Published artifacts and stable GitHub Release | BLOCKED / NOT DONE | release authorization is conditional on historical spec closure and Pi 1.0 compatibility; no merge, tag, npm publication or GitHub Release has occurred | - -The implemented mature surface is DAG/DSL/six-host/declared-target transactions. -Control Plane P1–P7, P9–P13 and P16 are Accepted decisions; P8/P14/P15 retain -proposed wire details. Authorized command-result replay, durable parked approval, -Receipt, global coordination and the later approved plan's WebUI track lack -complete implementation/acceptance. They cannot be waived by private or -experimental labels. Windows pipes and CharterArc retain their separate scope. -Existing Pi interactive approval and MCP/headless rejection do not establish -acceptance of the unfinished control-plane approval protocol. - -Local full logs are retained in the parent task workspace at -`work/taskflow-release-logs/`; they are not shipped in npm packages. Earlier -red runs are retained: macOS `/var` canonical-path assertions and detached -cleanup race; hard-coded DSL version; a transient release-regexp edit during -parallel integration. The frozen source suite and affected E2Es pass after -the fixes. No red run is treated as an acceptance pass. - -Do not mark 1.0 released or GA from a candidate build, green test count, fixture -MCP, prior PR CI or historical live evidence. The publication row requires the -actual authorized main/tag/registry/GitHub evidence for all ten artifacts. +**Unreleased 1.0.0 / Draft #142. Updated 2026-10-08.** No main merge, tag, npm +publication or GitHub Release is authorized in this local follow-up. Earlier +conditional release instructions do not replace the current local-only boundary. + +## Last fixed remote baseline + +Commit `ca247acd806dd0a37268eea8bf7078cf28f643cd` is the last pushed candidate. +Its [CI](https://github.com/heggria/taskflow/actions/runs/37719287182) passed all +12 jobs, including Node 22/24 (2,941 source tests each), typecheck, build, +three-OS process supervision, Pi peers, packed consumers, MCP E2E, website and +CodeQL. Its [Plugin Security Scan](https://github.com/heggria/taskflow/actions/runs/37719287251) +passed at 87, no high/critical findings, with the Cisco deep-scan completion gate. +Residual findings remain in the complete report; this is not a claim of zero +security findings or a current dependency-advisory audit. + +Local installed acceptance for that same baseline passed Node 22/24 572 tests +each, exact archive/build/installed bytes, ten deterministic public packages, +27 imports, 87 wildcard exports and six bins. Actual Pi **1.0.0** passed 17 cases +plus rapid/slow SIGKILL recovery. Actual Chrome completed approval edit → real TE +→ Receipt → project isolation → revocation. The original checkout's 48 dirty +files were preserved. These facts supersede the old 2,657-case/pending-CI rows. + +## Current local scope-close candidate + +This candidate retains the full accepted Control Plane. Independent review found +specific public-delivery/operator gaps rather than absence of the already +implemented admission, replay, approvals, Receipt, registry or WebUI. The +[closure ledger](./1.0.0-spec-closure.md) maps those implementations to tests. + +| Gate | Current state and evidence | +|---|---| +| Ten-package release membership/version | Candidate contract retains exactly ten public packages at 1.0.0. `taskflow-control` stays a private source workspace; its bins are bundled in `taskflow-mcp-core`, not an eleventh package | +| P16/S6 operator route | Separate explicitly provisioned key/issuer and live project policy; ordinary credentials denied; CLI and optional MCP mutation tools; durable command audit/replay, risk acknowledgement, reason and operator-overridden assurance. `operator-route.test.ts` exercises real processes | +| P13 fresh public installation | `build-control-bundle.mjs` compiles the source into the public carrier. `smoke-control-bundle.mjs` runs installed npm-bin links, default-auto fresh run, restart and durable status without a private package or workspace source | +| P13 epoch persistence/upgrade | Real process testing found crash-recovery epoch followed by graceful restart could regress. Candidate persists monotonic fencing and validates legacy coordinator evidence before migration; malformed authority state remains fail-closed | +| Pi 1.0.4 integration | Draft #181 head `063282bc41ab233ca2d50f7dde8f0d88e86b31e8` compared independently. Existing #142 lifecycle/usage/checkpoint recovery retained; server/resource tool-filter delta and actual CLI controls verified per selected SDK, never relabeled from 1.0.0 | +| Source/typecheck/installed tests | Final local results are recorded in the scope-close handoff, bound to the frozen candidate SHA, commands, counts, actual SDK/runtime versions and byte attestations; the previous baseline counts above are not reused | +| Actual browser | PASS on the local implementation: actual Chrome approval edit → TE → Receipt → project isolation → live revocation; final handoff binds the test and source hashes | +| Final local tarballs | Final packaging gate: deterministic packs and clean consumers must use the frozen SHA and include the public control bundle; hashes and outcome belong to the external handoff | +| New-head remote CI/security | NOT RUN / NOT AUTHORIZED. Previous ca247ac or #181 greens cannot certify this combined local revision | +| Published registry artifacts/GitHub Release | NOT DONE / NOT AUTHORIZED. Requires exact final registry hashes, upgrade verification and the single original publisher handoff | + +## Explicit boundaries + +Unix UDS is supported; Windows named pipes remain non-GA under P13. The CLI +executes scripts; configured agent runners remain an embedded-provider choice. +Resolve-only containment and admission-time revocation do not claim an OS sandbox. +Unknown effects retain capacity unless an operator explicitly overrides it; +force release does not prove process termination or produce a terminal Receipt. +Plan edits preserve the DAG. Logical compaction retains the physical journal. +Live vault/service backends, invisible unmanaged legacy writers, arbitrary +in-flight recovery and every host/TUI combination are not implied by these gates. + +Historical live Codex/Claude and prior SDK results remain evidence at their +recorded versions. Local deterministic-provider Pi processes are actual CLI/MCP +protocol tests, not fresh live-provider authentication. No release decision may +be inferred from a test count, private label, old remaining-work table or green +CI for another SHA. diff --git a/docs/internal/1.0.0-release-plan.md b/docs/internal/1.0.0-release-plan.md index 34007197..71e3997e 100644 --- a/docs/internal/1.0.0-release-plan.md +++ b/docs/internal/1.0.0-release-plan.md @@ -55,9 +55,13 @@ compatibility must be verified; a source build does not prove installed use. The complete approved Control Plane, including the S6/S7 adapter/CLI and usable WebUI, is required for 1.0 acceptance. Its current packaging is not a scope waiver. -`taskflow-control` is currently private workspace experimental. UDS/store scaffolding is -retained and tested without claiming completed admission, global coordination, -authorized command-result replay, parked approvals/CAS/receipts, registry, Windows pipes or WebUI. CharterArc +`taskflow-control` remains a private source workspace, while its compiled CLI, +MCP adapter and local console are bundled inside the existing public +`taskflow-mcp-core` package. No eleventh public package is introduced. Authenticated +admission, global coordination, durable replay, parked approval/CAS/Receipt, +registry routing and WebUI are implemented; final acceptance is tracked in the +current closure ledger rather than inferred from this plan's old baseline. +Windows named pipes remain non-GA under P13. CharterArc continues its separate experimental package/version/workflow. The optional kernel's advanced-feature fallback, resolve-only path limitations, missing secret/service backends and unbenchmarked journal capacity remain documented. diff --git a/docs/internal/1.0.0-spec-closure.md b/docs/internal/1.0.0-spec-closure.md index e8ed5332..2e53476a 100644 --- a/docs/internal/1.0.0-spec-closure.md +++ b/docs/internal/1.0.0-spec-closure.md @@ -1,96 +1,97 @@ # 1.0 specification closure ledger -> **Active track: unreleased 1.0.0 / Draft PR #142 → main.** -> `rc/0.3.0-beta.2` is the retained historical branch name, not the release version. -> Latest published prerelease: `v0.3.0-beta.1.2`. See the -> [2026-10-07 track reconciliation](./1.0.0-release-plan.md#release-track-reconciliation-2026-10-07). -> The isolated integration candidate retains full scope A; documentation alone does not unblock publication. +**Track: unreleased 1.0.0, Draft PR #142. Updated 2026-10-08.** The retained +`rc/0.3.0-beta.2` branch name is historical. No merge, tag, npm publication or +GitHub Release is authorized by this ledger. -Status: **PUBLICATION BLOCKED**. Candidate note updated 2026-10-08. No main merge, tag, npm -publication or GitHub Release has occurred. Passing the implemented surface's -tests does not complete the historical roadmap. +This is the current implementation map, replacing the 2026-10-02 scaffold-only +remaining-work table. Accepted P-ADRs and the approved Control Plane S3–S7 plan +remain requirements; a private source workspace is not a scope waiver. -The isolated Task9 candidate now implements authenticated multi-project control, -durable command replay, actual coordinated TE execution, parked approval recovery, -validated output/plan edits, Receipt/compaction, CLI/MCP/console adapters, observable -legacy-writer conflict handling, and explicit offline identity/export commands. -See the [candidate package guide](../../packages/taskflow-control/GUIDE.md) for -supported operations and recovery boundaries. Actual process-kill, browser, and -cross-project tests cover these paths. Final commit/artifact acceptance is recorded -outside this repository in the Task9 handoff, and is not public CI or GA acceptance. +## Evidence boundaries -The tables and detailed evidence below retain the **2026-10-02 release-owner -baseline**. Their remaining-work cells are historical baseline gaps, not a claim -that the isolated candidate still lacks each implementation. Release-owner -integration and acceptance must reconcile that baseline against the candidate; -this local branch does not change the ADR decision status or authorize publication. +The last remotely accepted integration is +`ca247acd806dd0a37268eea8bf7078cf28f643cd`: [CI 12/12](https://github.com/heggria/taskflow/actions/runs/37719287182) +and [Plugin Security Scan](https://github.com/heggria/taskflow/actions/runs/37719287251) +passed. Node 22/24 each passed 2,941 source tests. Its local installed consumers +each passed 572 control/core tests, and its ten-public-package consumer passed +17 actual Pi **1.0.0** cases plus two SIGKILL recovery cases. Those results belong +to that exact commit and do not establish Pi 1.0.4 or the follow-up below. -The owner requires historical specifications to be implemented and accepted -before release. The earlier TE MVP excluded the old Control Plane, but the later -[approved Control Plane plan](./0.3-c-control-plane-plan.md) commits S3–S7 before -GA. The owner explicitly selected scope A: all of that Control Plane plan is -required for 1.0, with no publication before implementation and acceptance. -The current private package label cannot waive those commitments. Deferred -roadmap items below are visible gaps, not silently completed features. +The local follow-up closes the independently identified operator and public +bundle paths, integrates the distinct Pi 1.0.4 tool-filter delta from Draft +[#181](https://github.com/heggria/taskflow/pull/181), and corrects this ledger. +It is not yet pushed. Final local source/installed results and artifact hashes +are recorded together in the Task9 scope-close handoff; new-head remote CI +remains a gate requiring separate authorization. The original dirty checkout +and previously accepted artifacts are preserved. -## Current implementation and actual regression evidence +## Current Control Plane implementation -- Pi 1.0 retry/compaction, tool usage, supported RPC approvals and headless - rejection are implemented. Pi 0.80.3 compaction remains compatible. -- Pi and MCP save every runtime checkpoint. Real Pi quick/slow phase chains - survive SIGKILL at approval, reject a living owner, resume in a fresh Pi, - reuse completed effects once, preserve parent bytes and use the new run ID. -- Recovery requires ESRCH, unchanged canonical path/device/inode, a recorded - untimed approval wait and a DAG barrier against other runnable work. Unknown owners, - missing metadata, changed roots and potentially active children fail closed. - A real EACCES injection with a surviving parallel script tests the unsafe - stale-marker window. This does not claim arbitrary in-flight child recovery. -- UDS bounded frames, absolute hello deadline, full response-branch validation - and safe numeric counters have 54 independent process cases. Unknown TE - handles remain ambiguous. Store values cannot mutate authoritative records. - Copied/moved/replaced store directories are rejected before recovery writes; - the complete explicit clone/rebind/registry workflow is still missing. -- The checked-in host baseline loads through the strict evidence validator. - All four historical exact targets remain resolve-only and sandbox rejected. - Six-host implementation notes are separate; no Hermes live probe is claimed. +Paths below are relative to `packages/taskflow-control/`. Tests are evidence of +the stated exercised surface, not proof of every possible platform or threat. -## Control Plane coverage +| Contract | Current implementation | Concrete verification and remaining boundary | +|---|---|---| +| P1/P2 policy | Verified opaque contexts, trusted OS-user/bootstrap identity, live layered attenuation and revocation in `src/authorization.ts` | `test/authorization.test.ts`; operator credentials are separate from ordinary bootstrap. Resolve-only execution is not an OS security boundary | +| P3 identity/registry | Durable project/domain identity, registry discovery, multi-mount and explicit offline move/rebind, empty clone, forensic export | `project-registry`, `project-isolation`, `project-lifecycle` and `project-admin-cli` tests; no silent clone or identity reuse | +| P4 wire/negotiation | Schema negotiation, fencing and bounded UDS clients; malformed frames/handshakes isolate the connection | `hello`, `uds-*`, `singleton` tests; incompatible peers fail closed | +| P5 run state | Real TE-backed admission, status/result, cancellation and bounded reconciliation | `host-runtime-integration`, `runtime-quiescence`, `runtime-restart` tests; unknown effects retain capacity without a fabricated terminal Receipt | +| P6/P7 hashes/plans/artifacts | Canonical request/plan hashes, persisted scoped artifacts, admission linker and verified artifact reachability | `admission-service`, `evidence-store`, `evidence-host-integration`, `approval-edits-host` tests; edits retain the same DAG and only change unstarted downstream bodies | +| P8 execution boundary | `RuntimeTeExecutionProvider` executes the existing TE runtime and reports actual assurance | `te-provider` and admission/runtime process tests; admission-time revocation and resolve-only containment are explicit, no FileBroker/OS sandbox claim | +| P9/P10 legacy/export | Observable legacy writers freeze new attempts; explicit offline read-only/lossy exports preserve source bytes | `legacy-conflict`, `project-lifecycle`, `project-admin-cli` tests; invisible unmanaged writers still require service-manager exclusion | +| P11 compaction/cursors | Hash-linked logical checkpoint, cursor floor and Receipt verification against retained journal evidence | `evidence-store`, `evidence-adapter`, `evidence-explanation` tests; physical journal truncation/high-scale throughput is not claimed | +| P12 commands/replay | Durable result artifact before commit; verified principal and live command-class authorization on original call and replay | `control-host`, `approval-replay`, `authorization` tests; same command does not re-execute. Client audit fields never grant authority | +| P13 bootstrap/delivery | Auto/coordinated/explicit standalone and singleton are implemented. Public `taskflow-mcp-core` now bundles control/administration bins; private `taskflow-control` is not a published dependency | `control-cli`, `singleton`, `uds-process`; `scripts/smoke-control-bundle.mjs` executes installed bins and fresh default-auto runs. Unix UDS required; Windows pipes remain non-GA | +| P14 persistence | Validated files-only project/coordinator stores, immutable publication, journal/artifact verification and conservative recovery | `store-corruption`, `store-process`, `store-lock-race`, `coordinator-store`, evidence process tests; live vault/service backends remain outside this implemented provider | +| P15 approval | Durable park/expiry/CAS, output/plan artifact edits, restart readmission and linked terminal evidence | `approval-service`, `approval-replay`, `approval-edits-host`, runtime restart and browser tests; arbitrary in-flight work is not automatically recovered | +| P16 coordinator | Durable reservations, capacity/fencing and release predicates. Separate explicitly provisioned operator route exposes capacity change and acknowledged force release | `coordinator-store`, `operator-route` real CLI/MCP tests; default credentials denied, cross-project records filtered, live operator revocation, durable audit/replay and operator-overridden assurance | +| S6/S7 surfaces | CLI and MCP use authenticated Host dispatch; local console exposes runs, approvals, edits, Receipt and explanations | `control-cli`, `control-mcp`, `control-console-integration`, `web-console`; actual Chrome edit → TE → Receipt → isolation → revocation passed at the prior fixed candidate and is rebound in the final local handoff | -Source: [P-ADR index](./p-adrs/README.md), its P1–P16 documents, the approved -Control Plane plan and RFC v7.6 §23. Schema presence is not runtime acceptance. +The P16 follow-up found a real restart defect: after crash recovery raised the +singleton epoch, a later graceful release/start could reset it to one and be +fenced by the persistent coordinator. The local fix retains a durable monotonic +epoch; the operator process regression includes both crash and graceful restart. +It does not lower the persisted coordinator fence or release ambiguous capacity. -| Contract | Decision status | Remaining implementation/acceptance | -|---|---|---| -| P1 / P2 policy overlay and empty policy | Accepted | Trusted principal, live four-layer attenuation, revocation and compatibility corpus through controlled admission | -| P3 identity / registry | Accepted | Rebuildable registry, multi-mount, explicit move/rebind/adopt and new clone identity workflow | -| P4 negotiation / errors | Accepted | Directional write/read schema compatibility and cursor/status integration; UDS fault isolation is tested | -| P5 phase/state contract | Accepted | Actual controlled run projections, bounded reconcile, unknown retaining capacity and no fabricated terminal receipt | -| P6 / P7 hashes, artifacts, plans | Accepted | Unified canonical request hashing, durable scoped artifact storage, live authorized reads, immutable linker/fragments and cache predicates | -| P8 enforcement | Proposed TE wire; approved product boundary | Real TE provider/admission integration and finalized claims/observations wire; resolve-only must not imply sandbox | -| P9 / P10 legacy and rollback | Accepted | Foreign-writer detection/freeze and explicit read-only forensic export/rollback tiers | -| P11 compaction / cursors | Accepted | Verified hash-linked checkpoint, retention/cursor floor and receipts that remain valid after compaction | -| P12 commands / reauthorization | Accepted | Verified caller plus live authorization, durable result artifact before journal, authorized same-command result replay without re-execution. Current duplicate rejection is safe but incomplete; response artifact references are not yet verified by storage | -| P13 bootstrap | Accepted | Actual controlled first run, fresh bundle registry discovery and multi-project integration; singleton/UDS foundation is tested | -| P14 store | Proposed wire; S3 storage gate approved | Complete artifact/secret/coordinator store contracts and migration/recovery acceptance beyond the current files-only journal | -| P15 approval | Proposed TE wire; approved approval behavior | Durable park/expiry/CAS, edit/cancel payload agreement, restart readmission and linked receipt | -| P16 coordinator | Accepted | Persistent global reservation/commit/release, fencing, unknown capacity retention and explicit operator intervention | -| Plan S6 / S7 | Approved later plan | Thin MCP/CLI integration and usable runs/approvals/receipt WebUI | +## Public delivery and Pi compatibility -At that baseline, `commands.submit` appended supplied journal data; it did not execute -TE, and `runs.status` was a placeholder. Caller-supplied audit fields are not -authentication. Do not resolve authorized replay by default-allow policy. +The release transaction still contains exactly ten public packages. The existing +`taskflow-mcp-core` package compiles and includes the private implementation under +`dist/control/`, with `taskflow-control`, `taskflow-project-admin` and an installed +`CONTROL_GUIDE.md`. No private package or workspace checkout is needed at runtime. +The [release guide](../../RELEASE.md) documents the bundled binary, local-tarball +acceptance, first default-auto run and version-skew/upgrade behavior. -## Core, DSL and host roadmap +Pi 1.0.0 acceptance above stays labeled 1.0.0. Draft #181's source head +`063282bc41ab233ca2d50f7dde8f0d88e86b31e8` independently verified 0.80.3/0.85.1/1.0.4. +This candidate preserves #142's checkpoint/usage/recovery changes while importing +its compatible explicit-tool filter and real CLI fixtures. The local source matrix passed 277 adapter tests per selected SDK: actual Pi +1.0.4 passed 28 CLI controls on Node 22/24; 0.80.3 and 0.85.1 each passed +12 CLI controls on Node 24. Pi 1.0.0 and 1.0.4 each separately passed +17 lifecycle and two crash/restart cases. Final installed acceptance is bound +to the frozen commit in the external handoff; neither #181's CI nor older +1.0.0 results replace it. -| Contract | Verified scope | Remaining scope | -|---|---|---| -| S0–S4 frozen architecture / DSL | FlowIR/hash, event-fold oracle, eligible kernel kinds, offline replay, frozen synchronous DSL and installed CLI | No new missing frozen MVP gate found; the fold oracle is not a mid-run process-kill acceptance test | -| Accepted S5 / full event architecture | Differential tests exist and pass | Kernel default-on, compatible resume/input hashes, expect support, durable event-log recovery and flagship recompute measurement | -| Full DSL horizon | Frozen S4 frontend is implemented | Multi-body loop, route, saga/compensate, watch and experimental exports were explicitly beyond original MVP; kept separate from the explicitly included Control Plane work | -| Native workspace roadmap | Declared-target resource transactions, resolve-only checks and honest baseline | Native multi-root, OS sandbox/FileBroker and live secret/service backends lack acceptance | -| Six hosts | Shared runners/MCP fixtures; real authenticated Codex and Claude checks; real Pi CLI/RPC/PTY | OpenCode/Grok/Hermes live binaries were unavailable locally; fixtures do not establish live provider acceptance | +The filter also closes a reproduced Pi 1.0.4 `read,codemode` resource path: +excluding only `mcp__*` still permitted native MCP resource reads. Native +resource tools are now excluded unless explicitly selected by exact name or +star selector. A full `*` or explicit `mcp__` selector retains Pi's native +MCP opt-in semantics; this filter is not an OS sandbox. + +## Retained limits and release gates -Required before publication: implement the included missing contracts, resolve -required wire details against the accepted decisions, pass meaningful -fault/real-host tests and exact-head CI, then verify actual registry artifacts -and upgrade behavior. See the [scoreboard](./1.0.0-ga-scoreboard.md). +- Recovery requires provable dead ownership and safe checkpoint/quiescence; + ambiguous TE mutations remain unknown. Forced release is an explicit audited + operator override, not proof that side effects have ended. +- The synchronous S4 DSL and optional event kernel retain their documented + scope. Full kernel default-on, arbitrary advanced DSL horizon, native sandbox, + live vault/service backends and multi-user console are not newly claimed. +- Historical authenticated Codex/Claude checks are versioned evidence. Fixture + MCP and local Pi providers are not new live-provider authentication acceptance + for OpenCode/Grok/Hermes or every TUI combination. +- Final candidate source, public bundle, installed process tests and artifact + hashes must agree. Its remote CI is pending until explicitly authorized. +- Registry artifact verification, authorized main/tag/npm/GitHub Release and + the single original publisher handoff remain publication gates. See the + [current scoreboard](./1.0.0-ga-scoreboard.md). diff --git a/packages/pi-taskflow/src/approval-view.ts b/packages/pi-taskflow/src/approval-view.ts index bca55369..2b231ba4 100644 --- a/packages/pi-taskflow/src/approval-view.ts +++ b/packages/pi-taskflow/src/approval-view.ts @@ -284,7 +284,7 @@ export class ApprovalViewComponent { /** Dialogs are available over RPC, but custom terminal components are not. */ export function createApprovalRequester( - ctx: Pick & { mode?: ExtensionContext["mode"] }, + ctx: Pick & { mode?: "tui" | "rpc" | "json" | "print" }, flowName: string, signal?: AbortSignal, ): RuntimeDeps["requestApproval"] { diff --git a/packages/pi-taskflow/src/runner.ts b/packages/pi-taskflow/src/runner.ts index 1dff003c..6c458e0d 100644 --- a/packages/pi-taskflow/src/runner.ts +++ b/packages/pi-taskflow/src/runner.ts @@ -426,7 +426,21 @@ export async function runAgentTask( if (piChild.resourceProfile !== "inherit") args.push("--no-extensions"); if (model) args.push("--model", model); if (thinking) args.push("--thinking", thinking); - if (tools && tools.length > 0) args.push("--tools", tools.join(",")); + if (tools && tools.length > 0) { + args.push("--tools", tools.join(",")); + // Pi 1.0.4 retains MCP tools behind codemode even when --tools omits + // them. Keep an explicit Taskflow allowlist from gaining ambient MCP + // capabilities. Older supported Pi accepts this inert exclusion too. + if (!tools.some((name) => name === "*" || name.startsWith("mcp__"))) { + // Native resource tools have no mcp__ prefix but Pi also retains + // them for codemode. Preserve explicitly selected resource names + // and Pi's star-glob selectors while excluding ambient resources. + const resources = ["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"]; + const selected = tools.map((name) => new RegExp(`^${name.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`)); + const excluded = ["mcp__*", ...resources.filter((name) => !selected.some((pattern) => pattern.test(name)))]; + args.push("--exclude-tools", excluded.join(",")); + } + } let tmpPromptDir: string | null = null; let tmpPromptPath: string | null = null; diff --git a/packages/pi-taskflow/test/e2e-pi-compat.mts b/packages/pi-taskflow/test/e2e-pi-compat.mts new file mode 100644 index 00000000..9f4d4869 --- /dev/null +++ b/packages/pi-taskflow/test/e2e-pi-compat.mts @@ -0,0 +1,184 @@ +/** Real installed Pi CLI + built extension + spawned children. Only model responses are local fixtures. */ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawn, execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { runAgentTask, defaultResolveInstalledPiCli } from "../dist/runner.js"; + +const root = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-pi-compat-")); +const cwd = path.join(root, "project"), agentDir = path.join(root, "agent"); +fs.mkdirSync(cwd); fs.mkdirSync(agentDir); +const provider = fileURLToPath(new URL("fixtures/compat-provider.mjs", import.meta.url)); +const extension = fileURLToPath(new URL("../dist/index.js", import.meta.url)); +const sdkVersion = JSON.parse(fs.readFileSync(new URL("../node_modules/@earendil-works/pi-coding-agent/package.json", import.meta.url), "utf8")).version as string; +const modern = Number(sdkVersion.split(".")[0]) >= 1; +console.log(`Actual Pi SDK/CLI: ${sdkVersion}`); +const cli = defaultResolveInstalledPiCli(); +assert.ok(cli, "resolve the adapter-local Pi CLI"); +assert.equal(execFileSync(process.execPath, [cli, "--version"], { encoding: "utf8" }).trim(), sdkVersion); +const env = { ...process.env, PI_OFFLINE: "1", PI_CODING_AGENT_DIR: agentDir, TASKFLOW_AGENT_DIR: agentDir, TASKFLOW_COMPAT_FIXTURE: randomUUID() }; +delete env.PI_TASKFLOW_PI_BIN; delete env.PI_TASKFLOW_PI_ENTRY; delete env.PI_TASKFLOW_BUILTIN_AGENTS_DIR; +fs.writeFileSync(path.join(agentDir, "settings.json"), JSON.stringify({ + defaultProvider: "taskflow-compat", defaultModel: "fixture", modelRoles: { executor: "taskflow-compat/fixture" }, + retry: { enabled: true, maxRetries: 2, baseDelayMs: 20 }, + taskflow: { piChild: { resourceProfile: "allowlist", extensions: [provider], terminalGraceMs: 200 } }, +})); +// Dynamic protocol assertions intentionally inspect arbitrary JSON event payloads. +type Event = { type: string; [key: string]: any }; +const baseArgs = ["--offline", "--no-session", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-extensions", "-e", provider, "-e", extension, "--model", "taskflow-compat/fixture"]; +let passed = 0; +function pass(name: string) { passed++; console.log(`PASS ${name}`); } + +async function cliRun(prompt: string, rpcDecision?: string) { + return await new Promise<{ events: Event[]; code: number | null; stderr: string }>((resolve, reject) => { + const rpc = rpcDecision !== undefined; + const args = [...baseArgs, "--mode", rpc ? "rpc" : "json", ...(rpc ? [] : ["-p", prompt])]; + const child = spawn(process.execPath, [cli!, ...args], { cwd, env, stdio: [rpc ? "pipe" : "ignore", "pipe", "pipe"] }); + const events: Event[] = []; let pending = "", stderr = "", settled = false; + let failure: Error | undefined, forcedKill: ReturnType | undefined; + const stop = (error?: Error) => { + failure ??= error; + if (forcedKill) return; + // Allow the Pi supervisor to terminate its detached Taskflow children. + child.kill("SIGTERM"); + forcedKill = setTimeout(() => child.kill("SIGKILL"), 5000); + }; + const timer = setTimeout(() => stop(new Error(`Pi timed out: ${prompt} ${rpcDecision}\n${stderr}`)), 30000); + child.stderr!.on("data", (data) => { stderr += String(data); }); + child.stdout!.on("data", (data) => { + pending += String(data); const lines = pending.split("\n"); pending = lines.pop()!; + for (const line of lines) { + if (!line.trim()) continue; + let event: Event; try { event = JSON.parse(line); } catch { stop(new Error(`Non-JSON Pi output: ${line}`)); return; } + events.push(event); + if (rpc && event.type === "extension_ui_request" && ["select", "input", "confirm"].includes(event.method)) { + if (rpcDecision === "abort") child.stdin!.write(JSON.stringify({ id: "abort", type: "abort" }) + "\n"); + else { + const cancelled = rpcDecision === "cancel" || (rpcDecision === "edit-cancel" && event.method === "input"); + const wanted = rpcDecision!.startsWith("edit") ? "edit" : rpcDecision!; + const value = event.method === "input" ? "Reviewed guidance" : (event.options as string[] | undefined)?.find((s) => s.toLowerCase().startsWith(wanted)) ?? wanted; + child.stdin!.write(JSON.stringify({ type: "extension_ui_response", id: event.id, ...(cancelled ? { cancelled: true } : event.method === "confirm" ? { confirmed: wanted === "approve" } : { value }) }) + "\n"); + } + } + if (rpc && (event.type === "agent_settled" || (!modern && event.type === "agent_end" && event.willRetry === false)) && !settled) { settled = true; child.stdin!.end(); stop(); } + } + }); + child.on("error", (error) => { failure = error; }); + child.on("close", (code) => { clearTimeout(timer); clearTimeout(forcedKill); if (failure) reject(failure); else resolve({ events, code, stderr }); }); + if (rpc) child.stdin!.write(JSON.stringify({ id: "prompt", type: "prompt", message: prompt }) + "\n"); + }); +} +function taskflowResult(events: Event[]) { + const event = events.find((e) => e.type === "tool_execution_end" && e.toolName === "taskflow"); + assert.ok(event, "Taskflow tool must actually execute"); return event; +} + +const savedEnv = new Map(); +try { + for (const key of ["PI_OFFLINE", "PI_CODING_AGENT_DIR", "TASKFLOW_AGENT_DIR", "TASKFLOW_COMPAT_FIXTURE", "PI_TASKFLOW_PI_BIN", "PI_TASKFLOW_PI_ENTRY", "PI_TASKFLOW_BUILTIN_AGENTS_DIR"]) { + savedEnv.set(key, process.env[key]); if (env[key] === undefined) delete process.env[key]; else process.env[key] = env[key]; + } + const version = await cliRun("HOST:version"); + assert.equal(version.code, 0, version.stderr); + assert.equal(taskflowResult(version.events).isError, false); + pass("built extension loads and executes taskflow"); + const identity = await cliRun("HOST:identity"); + const identityResult = taskflowResult(identity.events).result; + assert.equal(identityResult.details.state.status, "completed", JSON.stringify(identityResult)); + assert.equal(identityResult.details.state.phases.child.output, identityResult.details.state.runId); + pass("persisted Taskflow run ID reaches actual Pi JSON child"); + const agents = [{ name: "fixture", description: "local fixture", systemPrompt: "", source: "user" as const, filePath: "", model: "taskflow-compat/fixture" }]; + const profile = { resourceProfile: "allowlist" as const, extensions: [provider], terminalGraceMs: 200 }; + if (modern) { + fs.writeFileSync(path.join(agentDir, "mcp.json"), JSON.stringify({ mcpServers: { + compat: { command: process.execPath, args: [fileURLToPath(new URL("fixtures/compat-mcp.mjs", import.meta.url))], exposure: "codemode", env: { TASKFLOW_COMPAT_AUDIT: path.join(root, "mcp-audit.jsonl") } }, + } })); + const mcpProfile = { ...profile, extensions: [...profile.extensions, + fileURLToPath(new URL("../node_modules/@earendil-works/pi-coding-agent/dist/extensions/mcp/index.js", import.meta.url)), + fileURLToPath(new URL("../node_modules/@earendil-works/pi-coding-agent/dist/extensions/codemode/index.js", import.meta.url)), + ] }; + const auditFile = path.join(root, "mcp-audit.jsonl"); + async function boundary(task: string, tools: string[] | undefined, allowed: boolean, marker: string, method: string) { + fs.writeFileSync(auditFile, ""); + const result = await runAgentTask(cwd, agents, "fixture", task, { tools, signal: AbortSignal.timeout(20000) }, undefined, mcpProfile); + assert.equal(result.exitCode, 0, JSON.stringify(result)); + const calls = fs.readFileSync(auditFile, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line)); + assert.equal(result.output.includes(marker), allowed, JSON.stringify({ tools, result })); + assert.equal(calls.some((call) => call.method === method), allowed, "Actual MCP server must receive only authorized calls"); + pass(`${task} ${JSON.stringify(tools ?? "unrestricted")} ${allowed ? "allowed" : "denied"}`); + } + await boundary("CHILD_MCP", undefined, true, "MCP_OK", "tools/call"); + await boundary("CHILD_MCP", ["read", "codemode"], false, "MCP_OK", "tools/call"); + await boundary("CHILD_MCP", ["codemode", "mcp__compat__*"], true, "MCP_OK", "tools/call"); + await boundary("CHILD_MCP", ["codemode", "mcp__other__*"], false, "MCP_OK", "tools/call"); + await boundary("CHILD_MCP", ["*"], true, "MCP_OK", "tools/call"); + await boundary("CHILD_RESOURCE", undefined, true, "RESOURCE_OK", "resources/read"); + await boundary("CHILD_RESOURCE", ["read"], false, "RESOURCE_OK", "resources/read"); + await boundary("CHILD_RESOURCE", ["read", "codemode"], false, "RESOURCE_OK", "resources/read"); + await boundary("CHILD_RESOURCE", ["codemode", "read_mcp_resource"], true, "RESOURCE_OK", "resources/read"); + await boundary("CHILD_RESOURCE", ["codemode", "read_*"], true, "RESOURCE_OK", "resources/read"); + await boundary("CHILD_RESOURCE", ["codemode", "mcp__compat__*"], false, "RESOURCE_OK", "resources/read"); + await boundary("CHILD_RESOURCE", ["*"], true, "RESOURCE_OK", "resources/read"); + for (const [tools, expected] of [ + [["read", "codemode"], []], + [["codemode", "list_mcp_*"], ["list_mcp_resources", "list_mcp_resource_templates"]], + [["codemode", "read_mcp_resource"], ["read_mcp_resource"]], + ] as const) { + const catalog = await runAgentTask(cwd, agents, "fixture", "RESOURCE_NAMES", { tools: [...tools], signal: AbortSignal.timeout(20000) }, undefined, mcpProfile); + assert.equal(catalog.exitCode, 0, JSON.stringify(catalog)); + assert.doesNotMatch(catalog.output, /Script failed|Script error/); + for (const name of ["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"]) { + assert.equal(catalog.output.includes(name), (expected as readonly string[]).includes(name), JSON.stringify({ tools, catalog })); + } + pass(`native resource catalog ${JSON.stringify(tools)}`); + } + const filtered = await runAgentTask(cwd, agents, "fixture", "TOOL_NAMES", { tools: ["read"], signal: AbortSignal.timeout(20000) }, undefined, mcpProfile); + assert.equal(filtered.exitCode, 0, JSON.stringify(filtered)); + assert.deepEqual(JSON.parse(filtered.output), ["read"], "Taskflow's explicit whitelist must not retain ambient MCP tools"); + pass("read whitelist excludes ambient MCP tools"); + } + for (const decision of ["approve", "reject", "cancel", "edit", "edit-cancel", "abort"]) { + const result = await cliRun("HOST:approval", decision); + assert.ok(result.events.some((e) => e.type === "extension_ui_request" && ["select", "confirm"].includes(e.method)), "RPC must ask the human"); + const tool = taskflowResult(result.events); + const state = tool.result.details.state; + assert.ok(state, JSON.stringify(tool.result)); + const mayProceed = decision === "approve" || decision === "edit"; + assert.equal(state.phases.after?.status === "done", mayProceed, JSON.stringify(tool.result)); + if (mayProceed) { + assert.equal(state.status, "completed"); + assert.equal(state.phases.review.approval.decision, decision); + assert.ok(JSON.stringify(tool.result).includes("AFTER_APPROVAL")); + if (decision === "edit") assert.equal(state.phases.review.output, "Reviewed guidance"); + } else { + assert.ok(["blocked", "paused"].includes(state.status), JSON.stringify(state)); + assert.equal(state.phases.review.approval?.decision, "reject", JSON.stringify(state)); + } + pass(`RPC approval ${decision}`); + } + const shared = await runAgentTask(cwd, agents, "fixture", "TOOL_NAMES", { + tools: ["read"], ctxDir: root, nodeId: "compat", signal: AbortSignal.timeout(20000), + }, undefined, profile); + assert.equal(shared.exitCode, 0, JSON.stringify(shared)); + assert.deepEqual(JSON.parse(shared.output), ["ctx_read", "ctx_report", "ctx_spawn", "ctx_write", "read"]); + pass("shared context extension loads and preserves ctx tool whitelist"); + for (const task of ["CHILD_RETRY", "CHILD_IDENTITY"]) { + const result = await runAgentTask(cwd, agents, "fixture", task, { signal: AbortSignal.timeout(20000), runId: "compat-owned-run" }, undefined, profile); + assert.equal(result.exitCode, 0, JSON.stringify(result)); + assert.equal(result.errorMessage, undefined, JSON.stringify(result)); + assert.equal(result.output, task === "CHILD_IDENTITY" ? "compat-owned-run" : "CHILD_OK"); + pass(task); + } + const printApproval = await cliRun("HOST:approval"); + const printResult = taskflowResult(printApproval.events); + assert.ok(printResult.result.details.state); + assert.equal(printResult.result.details.state.phases.review.approval.decision, "reject"); + assert.notEqual(printResult.result.details.state.phases.after?.status, "done"); pass("print approval does not execute downstream"); + console.log(`Pi compatibility real-process E2E: ${passed} passed; no live provider calls.`); +} finally { + for (const [key, value] of savedEnv) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } + fs.rmSync(root, { recursive: true, force: true }); +} diff --git a/packages/pi-taskflow/test/fixtures/compat-mcp.mjs b/packages/pi-taskflow/test/fixtures/compat-mcp.mjs new file mode 100644 index 00000000..8162fe0a --- /dev/null +++ b/packages/pi-taskflow/test/fixtures/compat-mcp.mjs @@ -0,0 +1,16 @@ +// Harmless stdio MCP server used only by the real CLI compatibility suite. +import { appendFileSync } from "node:fs"; +import { createInterface } from "node:readline"; +for await (const line of createInterface({ input: process.stdin })) { + const request = JSON.parse(line); + if (request.id === undefined) continue; + let result = {}; + if (process.env.TASKFLOW_COMPAT_AUDIT) appendFileSync(process.env.TASKFLOW_COMPAT_AUDIT, JSON.stringify({ method: request.method, params: request.params }) + "\n"); + if (request.method === "initialize") result = { protocolVersion: "2024-11-05", capabilities: { tools: {}, resources: {} }, serverInfo: { name: "compat", version: "1" } }; + if (request.method === "tools/list") result = { tools: [{ name: "probe", description: "Harmless compatibility probe", inputSchema: { type: "object", properties: {} } }] }; + if (request.method === "tools/call") result = { content: [{ type: "text", text: "MCP_OK" }] }; + if (request.method === "resources/list") result = { resources: [{ uri: "compat://fixture", name: "Harmless fixture", mimeType: "text/plain" }] }; + if (request.method === "resources/templates/list") result = { resourceTemplates: [{ uriTemplate: "compat://{name}", name: "Harmless template" }] }; + if (request.method === "resources/read") result = { contents: [{ uri: request.params.uri, mimeType: "text/plain", text: "RESOURCE_OK" }] }; + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\n"); +} diff --git a/packages/pi-taskflow/test/fixtures/compat-provider.mjs b/packages/pi-taskflow/test/fixtures/compat-provider.mjs new file mode 100644 index 00000000..44e21ffd --- /dev/null +++ b/packages/pi-taskflow/test/fixtures/compat-provider.mjs @@ -0,0 +1,49 @@ +// Deterministic model at the provider boundary; the installed Pi CLI, extension, +// tool dispatch, child process and JSON/RPC protocols remain real. +export default function (pi) { + const handshake = process.env.TASKFLOW_COMPAT_FIXTURE; + if (!handshake) throw new Error("Test fixture requires explicit opt-in"); + let attempts = 0; + pi.registerProvider("taskflow-compat", { + api: "taskflow-compat-api", baseUrl: "http://127.0.0.1", apiKey: handshake, + models: [{ id: "fixture", name: "Local fixture", reasoning: false, input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 4096 }], + streamSimple(model, context) { + const user = context.messages.filter(m => m.role === "user").at(-1); + const prompt = typeof user?.content === "string" ? user.content : (user?.content ?? []).filter(c => c.type === "text").map(c => c.text).join("\n"); + const results = context.messages.filter(m => m.role === "toolResult"); + const tool = (name, args) => [{ type: "toolCall", id: "compat-call", name, arguments: args }]; + let content = [{ type: "text", text: "CHILD_OK" }], stopReason = "stop", errorMessage; + if (results.length) content = [{ type: "text", text: JSON.stringify(results.at(-1).content) }]; + else if (prompt.includes("CHILD_RETRY") && attempts++ === 0) { + content = []; stopReason = "error"; errorMessage = "429 rate limit exceeded (local fixture)"; + } else if (prompt.includes("CHILD_IDENTITY")) content = [{ type: "text", text: process.env.PI_SUBAGENT_RUN_ID ?? "MISSING_ID" }]; + else if (prompt.includes("TOOL_NAMES")) { + // Pi 0.x provides context.tools; Pi 1.x normalizes tools into system messages. + const tools = context.tools ?? context.messages.filter(m => m.role === "system").flatMap(m => m.toolsAdded ?? []); + content = [{ type: "text", text: JSON.stringify(tools.map(t => t.name).sort()) }]; + } else if (prompt.includes("CHILD_MCP")) content = tool("codemode", { code: "text(await tools.mcp__compat__probe({}));" }); + else if (prompt.includes("RESOURCE_NAMES")) content = tool("codemode", { code: "text(ALL_TOOLS.filter(t => ['list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource'].includes(t.name)).map(t => t.name).sort().join(','));" }); + else if (prompt.includes("CHILD_RESOURCE")) content = tool("codemode", { code: "void ALL_TOOLS; text(await tools.read_mcp_resource({server: 'compat', uri: 'compat://fixture'}));" }); + else if (prompt === "HOST:version") content = tool("taskflow", { action: "version" }); + else if (prompt === "HOST:approval") content = tool("taskflow", { action: "run", define: { name: "compat-approval", phases: [ + { id: "review", type: "approval", task: "Approve fixture?" }, + { id: "after", type: "script", run: "printf AFTER_APPROVAL", dependsOn: ["review"], final: true }, + ] } }); + else if (prompt === "HOST:identity") content = tool("taskflow", { action: "run", define: { name: "compat-identity", phases: [ + { id: "child", type: "agent", agent: "executor", task: "CHILD_IDENTITY", final: true }, + ] } }); + if (content.some(c => c.type === "toolCall")) stopReason = "toolUse"; + const message = { role: "assistant", content, api: model.api, provider: model.provider, model: model.id, + usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason, ...(errorMessage ? { errorMessage } : {}), timestamp: Date.now() }; + return { + async *[Symbol.asyncIterator]() { + yield { type: "start", partial: message }; + yield stopReason === "error" ? { type: "error", reason: "error", error: message } : { type: "done", reason: stopReason, message }; + }, + async result() { return message; }, + }; + }, + }); +} diff --git a/packages/pi-taskflow/test/runner.test.ts b/packages/pi-taskflow/test/runner.test.ts index 2b8f3d0e..c6b9baba 100644 --- a/packages/pi-taskflow/test/runner.test.ts +++ b/packages/pi-taskflow/test/runner.test.ts @@ -588,6 +588,7 @@ test("runAgentTask: ctxDir/nodeId opt-in injects env, --extension, and the guida `fs.writeFileSync(${JSON.stringify(capture)}, JSON.stringify({\n` + ` hasExtension: argv.includes("--extension"),\n` + ` tools: (() => { const j = argv.indexOf("--tools"); return j >= 0 ? argv[j + 1] : null; })(),\n` + + ` excluded: (() => { const j = argv.indexOf("--exclude-tools"); return j >= 0 ? argv[j + 1] : null; })(),\n` + ` ctxDir: process.env.PI_TASKFLOW_CTX_DIR ?? null,\n` + ` nodeId: process.env.PI_TASKFLOW_NODE_ID ?? null,\n` + ` cwdBridgeMode: process.env.TASKFLOW_CWD_BRIDGE_MODE ?? null,\n` + @@ -638,6 +639,20 @@ test("runAgentTask: ctxDir/nodeId opt-in injects env, --extension, and the guida assert.match(wl.tools, new RegExp(`\\b${t}\\b`), `whitelist includes ${t}`); } + assert.equal(wl.excluded, "mcp__*,list_mcp_resources,list_mcp_resource_templates,read_mcp_resource"); + for (const [tools, expected] of [ + [["codemode", "read_mcp_resource"], "mcp__*,list_mcp_resources,list_mcp_resource_templates"], + [["codemode", "list_mcp_*"], "mcp__*,read_mcp_resource"], + [["codemode", "read_*"], "mcp__*,list_mcp_resources,list_mcp_resource_templates"], + [["codemode", "read_mcp_resource?"], "mcp__*,list_mcp_resources,list_mcp_resource_templates,read_mcp_resource"], + [["codemode", "mcp__compat__*"], null], + [["*"], null], + [[], null], + ] as const) { + await runAgentTask(dir, agents, "t", "check tool boundary", { tools: [...tools] }); + assert.equal(JSON.parse(fs.readFileSync(capture, "utf8")).excluded, expected, JSON.stringify(tools)); + } + // (2) Opted OUT. fs.rmSync(capture, { force: true }); await runAgentTask(dir, agents, "t", "do work", {}); diff --git a/packages/taskflow-control/GUIDE.md b/packages/taskflow-control/GUIDE.md index d3161f5e..a60c3b09 100644 --- a/packages/taskflow-control/GUIDE.md +++ b/packages/taskflow-control/GUIDE.md @@ -1,7 +1,16 @@ # Taskflow control plane -This is a private implementation candidate. Release-owner integration, exact-head CI, -and publication acceptance remain separate from local source and packed-package tests. +The implementation lives in a private workspace, but its compiled control and +project-administration binaries ship inside the existing `taskflow-mcp-core` +public package. No `taskflow-control` registry package is needed. The current +1.0.0 candidate is unreleased; publication and new-head CI remain separate gates. + +A local candidate can be installed from its `taskflow-core` and +`taskflow-mcp-core` tarballs plus TypeBox, with npm lifecycle scripts disabled. +The control entry is `node_modules/taskflow-mcp-core/dist/control/control-cli.js`, +also linked as `node_modules/.bin/taskflow-control`. This guide is shipped as +`taskflow-mcp-core/CONTROL_GUIDE.md`. After authorized publication, install with +`npm install --save-exact taskflow-mcp-core@1.0.0 typebox@1.3.30`. The control service mounts explicitly configured projects and routes requests by their durable project identity. It authenticates local clients, rereads policy, @@ -32,6 +41,45 @@ unstarted downstream phase bodies in the same dependency graph; completed phases top-level settings, and resource declarations cannot be rewritten. Plan edits are compiled again before readmission. The existing runtime executes the continuation. +## Explicit coordinator operators + +Normal bootstrap credentials cannot change capacity or force-release reservations. +An OS owner must explicitly provision the separate operator credential and a +project-scoped operator policy. The key is never printed; the output identifies +its principal and policy file. Provisioning does not overwrite an existing policy. + +```sh +taskflow-control operator-provision --root /path/project --control-home /path/control-home +taskflow-control coordinator-status --root /path/project --control-home /path/control-home --mode auto --operator +taskflow-control set-max-active-runs --root /path/project --control-home /path/control-home --mode auto --operator --command-id UUID --max-active-runs 4 +taskflow-control force-release --root /path/project --control-home /path/control-home --mode auto --operator --command-id UUID --reservation UUID --acknowledge-risk --reason "operator reviewed retained effects" +taskflow-control mcp --root /path/project --control-home /path/control-home --operator +``` + +Replace each `UUID` with an actual identifier. Reuse the same command ID and +identical payload for a retry; a changed payload conflicts, and revocation is +checked again before replay. Mutations default to `coordinated`; the examples +explicitly permit `auto` to start the shared owner if absent. Capacity is a +user-global admitted-run limit, while reservation identifiers and force release +remain bound to the authenticated project. Reducing capacity below active use is +rejected. Status shows global aggregate counts and only authorized project rows. + +Operator credentials grant project read plus the two coordinator mutations; +they do not inherit run submission or approval authority. Only an explicit +`mcp --operator` exposes the two operator mutation tools. A caller-supplied role +or credential label cannot replace the separate HMAC proof. The protected +`operator-policies` file is reread on each operation; removing a grant or revoking +the principal also rejects existing sessions and retries. Deleting/replacing +`operator.key` invalidates sessions; restart clients after deliberate key rotation. +Possession of that OS-user-protected key is the trust boundary, not isolation +from arbitrary processes running as the same OS user. + +Force release frees accounting capacity without proving side effects have stopped. +It requires both explicit acknowledgement and a nonempty reason, persists the +request and `CoordinatorCommandRecord`, and records `operator-overridden`. +It does not fabricate a terminal Run or Receipt. Investigate unknown effects +before making this explicit decision. + ## Offline project administration ```sh diff --git a/packages/taskflow-control/src/authorization.ts b/packages/taskflow-control/src/authorization.ts index 485fdd14..9a4f01d0 100644 --- a/packages/taskflow-control/src/authorization.ts +++ b/packages/taskflow-control/src/authorization.ts @@ -69,6 +69,9 @@ export interface AuthorizationAuthorityOptions { challengeTtlMs?: number; } export interface AuthorizationAuthority { + /** Optional, explicitly provisioned issuer; never granted by a caller role label. */ + issueOperatorStandalone?(binding: AuthorizationBinding): VerifiedContext; + createOperatorChallenge?(binding: AuthorizationBinding): AuthorizationChallenge; issueStandalone(binding: AuthorizationBinding): VerifiedContext; createChallenge(binding: AuthorizationBinding): AuthorizationChallenge; authenticate(challengeId: string, proof: string): VerifiedContext; diff --git a/packages/taskflow-control/src/control-cli.ts b/packages/taskflow-control/src/control-cli.ts index 11c4eb34..0496a3f7 100644 --- a/packages/taskflow-control/src/control-cli.ts +++ b/packages/taskflow-control/src/control-cli.ts @@ -8,7 +8,7 @@ import { ControlHost, defaultServerHello } from "./control-host.ts"; import { ProjectRegistry } from "./project-registry.ts"; import { RuntimeTeExecutionProvider } from "./runtime-provider.ts"; import { answerAuthorizationChallenge, type AuthorizationChallenge, type VerifiedContext, type AuthorizationCommandKind } from "./authorization.ts"; -import { bootstrapLocalAuthority, createPrivateFileOnce, ensurePrivateDirectory } from "./local-bootstrap.ts"; +import { bootstrapLocalAuthority, createPrivateFileOnce, ensurePrivateDirectory, provisionLocalOperator, readPrivateFile } from "./local-bootstrap.ts"; import { createControlEvidenceStore, readControlEvidence } from "./store/evidence-adapter.ts"; import { launchControlConsole } from "./control-console.ts"; import { serveControlMcp } from "./control-mcp.ts"; @@ -23,19 +23,25 @@ import { ControlStoreHeaderSchema } from "./schema/header.ts"; const HELP = `taskflow-control run --root DIR --flow FILE [--args JSON] [--mode auto|coordinated|standalone] taskflow-control status --root DIR [--run UUID] taskflow-control serve --root DIR [--root DIR ...] [--console] [--port NUMBER] -taskflow-control mcp --root DIR [--mode auto|coordinated|standalone] +taskflow-control mcp --root DIR [--mode auto|coordinated|standalone] [--operator] +taskflow-control operator-provision --root DIR --control-home DIR +taskflow-control coordinator-status --root DIR [--operator] +taskflow-control set-max-active-runs --root DIR --operator --command-id UUID --max-active-runs NUMBER +taskflow-control force-release --root DIR --operator --command-id UUID --reservation UUID --acknowledge-risk --reason TEXT Common: --control-home DIR (defaults to TASKFLOW_HOME/control or ~/.taskflow/control). +Operator provisioning is an explicit OS-owner action; a separate credential and live project policy grant operations. +maxActiveRuns is user-global. Force release does not stop execution and marks the concurrency guarantee operator-overridden. Local owner grants are persisted under CONTROL_HOME/policies and reread per operation. The CLI executes script flows directly; agent execution requires an embedded configured runner. Console's one-use browser token is saved to a 0600 local file, never printed or put in URLs. `; function fail(message: string): never { throw new ControlError("TF_BOOTSTRAP_FAILED", message); } function parse(argv: string[]) { - const command = argv[0]; if (!["run", "status", "serve", "mcp"].includes(command)) fail(HELP); + const command = argv[0]; if (!["run", "status", "serve", "mcp", "operator-provision", "coordinator-status", "set-max-active-runs", "force-release"].includes(command)) fail(HELP); const flags = new Map(); for (let i = 1; i < argv.length; i++) { - const flag = argv[i]; if (!["--root", "--flow", "--args", "--mode", "--control-home", "--run", "--console", "--port"].includes(flag)) fail(`unknown option ${flag}`); - const value = flag === "--console" ? "true" : argv[++i]; if (!value || value.startsWith("--")) fail(`value required for ${flag}`); + const flag = argv[i]; if (!["--root", "--flow", "--args", "--mode", "--control-home", "--run", "--console", "--port", "--operator", "--command-id", "--max-active-runs", "--reservation", "--acknowledge-risk", "--reason"].includes(flag)) fail(`unknown option ${flag}`); + const value = ["--console", "--operator", "--acknowledge-risk"].includes(flag) ? "true" : argv[++i]; if (!value || value.startsWith("--")) fail(`value required for ${flag}`); if (flag !== "--root" && flags.has(flag)) fail(`duplicate option ${flag}`); flags.set(flag, [...(flags.get(flag) ?? []), value]); } @@ -43,12 +49,16 @@ function parse(argv: string[]) { if (roots.some(root => !fs.statSync(root).isDirectory())) fail("project root must be a directory"); if (command !== "serve" && roots.length !== 1) fail("multiple roots require serve"); const get = (key: string) => flags.get(key)?.[0]; - const mode = get("--mode") ?? (command === "status" ? "coordinated" : "auto"); + const mode = get("--mode") ?? (["status", "coordinator-status", "set-max-active-runs", "force-release"].includes(command) ? "coordinated" : "auto"); if (!["auto", "coordinated", "standalone"].includes(mode)) fail("invalid control mode"); const port = get("--port") === undefined ? undefined : Number(get("--port")); if (port !== undefined && (!Number.isInteger(port) || port < 0 || port > 65535)) fail("invalid console port"); if (command !== "serve" && (get("--console") || port !== undefined)) fail("console options require serve"); - return { command, roots, mode: mode as ControlMode, controlHome: path.resolve(singletonPaths(get("--control-home")).controlHome), flow: get("--flow"), args: get("--args"), runId: get("--run"), console: !!get("--console"), port }; + if (get("--operator") && !["mcp", "status", "coordinator-status", "set-max-active-runs", "force-release"].includes(command)) fail("--operator requires an explicit operator-capable client command"); + if (["set-max-active-runs", "force-release"].includes(command) && !get("--command-id")) fail("operator mutations require --command-id UUID for durable retries"); + if (command === "set-max-active-runs" && (!Number.isSafeInteger(Number(get("--max-active-runs"))) || Number(get("--max-active-runs")) < 1)) fail("--max-active-runs must be a positive safe integer"); + if (command === "force-release" && (!get("--reservation") || !get("--acknowledge-risk") || !get("--reason")?.trim())) fail("force-release requires --reservation, --acknowledge-risk and a nonempty --reason"); + return { operator: !!get("--operator"), commandId: get("--command-id"), maxActiveRuns: Number(get("--max-active-runs")), reservationId: get("--reservation"), reason: get("--reason"), command, roots, mode: mode as ControlMode, controlHome: path.resolve(singletonPaths(get("--control-home")).controlHome), flow: get("--flow"), args: get("--args"), runId: get("--run"), console: !!get("--console"), port }; } function readHeader(root: string) { const file = path.join(root, ".taskflow", "control", "header"); @@ -70,6 +80,9 @@ export async function runControlCli(argv = process.argv.slice(2)): Promise { @@ -84,6 +97,7 @@ export async function runControlCli(argv = process.argv.slice(2)): Promise batch.command?.kind === commandKind && batch.command?.responseArtifactRef?.digest === digest); }, }); + const credentialSecret = config.operator ? readPrivateFile(path.join(config.controlHome, "operator.key")) : secret; const host = new ControlHost({ mode: config.mode, controlHome: config.controlHome, registry, authorization, trustedInProcessFeatures: ["durable-approval"], projectRoot: config.roots[0], projectStorePath: path.join(config.roots[0], ".taskflow", "control"), projectMounts: config.roots.slice(1).map(projectRoot => ({ projectRoot, storePath: path.join(projectRoot, ".taskflow", "control") })), @@ -124,18 +138,26 @@ export async function runControlCli(argv = process.argv.slice(2)): Promise("auth.challenge", { projectId: header.projectId }, { fencingEpoch: host.status.fencingEpoch }); + const challenge = await host.dispatch("auth.challenge", { projectId: header.projectId, ...(config.operator ? { credential: "operator" } : {}) }, { fencingEpoch: host.status.fencingEpoch }); if (challenge.binding.projectRoot !== config.roots[0] || challenge.binding.projectId !== header.projectId || challenge.binding.controlDomainId !== header.controlDomainId) fail("host challenge does not match the configured project"); - await host.dispatch("auth.authenticate", { challengeId: challenge.id, proof: answerAuthorizationChallenge(secret, challenge) }, { fencingEpoch: host.status.fencingEpoch }); - } else context = authorization.issueStandalone({ projectId: header.projectId, controlDomainId: header.controlDomainId, projectRoot: config.roots[0] }); + await host.dispatch("auth.authenticate", { challengeId: challenge.id, proof: answerAuthorizationChallenge(credentialSecret, challenge) }, { fencingEpoch: host.status.fencingEpoch }); + } else { + const binding = { projectId: header.projectId, controlDomainId: header.controlDomainId, projectRoot: config.roots[0] }; + context = config.operator ? authorization.issueOperatorStandalone!(binding) : authorization.issueStandalone(binding); + } const call = (method: string, params: unknown) => context ? host.dispatchAuthenticated(context, method, params) : host.dispatch(method, params, { fencingEpoch: host.status.fencingEpoch }); if (config.command === "mcp") { const stopInput = () => process.stdin.destroy(); process.on("SIGINT", stopInput); process.on("SIGTERM", stopInput); - try { await serveControlMcp({ call, projectId: header.projectId }); } + try { await serveControlMcp({ call, projectId: header.projectId, operator: config.operator }); } finally { process.off("SIGINT", stopInput); process.off("SIGTERM", stopInput); } return 0; } + if (["coordinator-status", "set-max-active-runs", "force-release"].includes(config.command)) { + const method = config.command === "coordinator-status" ? "coordinator.status" : config.command === "set-max-active-runs" ? "coordinator.setMaxActiveRuns" : "coordinator.forceRelease"; + const params = { projectId: header.projectId, ...(config.command === "set-max-active-runs" ? { commandId: config.commandId, maxActiveRuns: config.maxActiveRuns } : config.command === "force-release" ? { commandId: config.commandId, reservationId: config.reservationId, riskAcknowledgement: true, reason: config.reason } : {}) }; + process.stdout.write(JSON.stringify(await call(method, params)) + "\n"); return 0; + } if (config.command === "run") { const accepted = await call<{ runId: string }>("commands.submit", { projectId: header.projectId, commandId: randomUUID(), kind: "run.submit", flow, args }); // Keep individual RPCs bounded; an approval can remain parked until another @@ -182,10 +204,10 @@ export async function runControlCli(argv = process.argv.slice(2)): Promise { process.exitCode = code; }, error => { process.stderr.write(JSON.stringify({ error: error instanceof ControlError ? error.code : "TF_BOOTSTRAP_FAILED", message: error instanceof Error ? error.message : "local control failed" }) + "\n"); process.exitCode = 1; diff --git a/packages/taskflow-control/src/control-host.ts b/packages/taskflow-control/src/control-host.ts index b52409c5..08f45426 100644 --- a/packages/taskflow-control/src/control-host.ts +++ b/packages/taskflow-control/src/control-host.ts @@ -450,7 +450,10 @@ export class ControlHost { const body = params && typeof params === "object" ? params as Record : {}; if (method === "auth.challenge") { const mount = this.#registry.resolve(typeof body.projectId === "string" ? body.projectId : undefined); - const challenge = auth.createChallenge({ projectId: mount.store.header.projectId, controlDomainId: mount.store.header.controlDomainId, projectRoot: mount.projectRoot }); + if (body.credential !== undefined && body.credential !== "operator") throw new ControlError("TF_POLICY_DENIED", "unknown credential issuer"); + const binding = { projectId: mount.store.header.projectId, controlDomainId: mount.store.header.controlDomainId, projectRoot: mount.projectRoot }; + if (body.credential === "operator" && !auth.createOperatorChallenge) throw new ControlError("TF_POLICY_DENIED", "operator credential is not provisioned"); + const challenge = body.credential === "operator" ? auth.createOperatorChallenge!(binding) : auth.createChallenge(binding); session.challengeId = challenge.id; return challenge; } if (method === "auth.authenticate") { diff --git a/packages/taskflow-control/src/control-mcp.ts b/packages/taskflow-control/src/control-mcp.ts index d3dab26a..6dc3c65c 100644 --- a/packages/taskflow-control/src/control-mcp.ts +++ b/packages/taskflow-control/src/control-mcp.ts @@ -1,7 +1,7 @@ /** MCP is a protocol adapter over the launcher's already authenticated Host. */ import { Type } from "typebox"; import { Value } from "typebox/value"; -import { TaskflowSchema } from "taskflow-core"; +import { TaskflowSchema, getBuildInfo } from "taskflow-core"; import { RPC, RpcError, serveStdio, type RpcHandler } from "taskflow-mcp-core/jsonrpc"; import { UuidSchema, StringEnum } from "./schema/common.ts"; import { ApprovalModeSchema } from "./schema/approval.ts"; @@ -12,6 +12,7 @@ const scope = { projectId: Type.Optional(UuidSchema), controlDomainId: Type.Opti const run = { ...scope, runId: UuidSchema }; const closed = { additionalProperties: false }; const definitions = [ + { name: "taskflow_control_coordinator", description: "Read user-global capacity and only this project's reservation identifiers and authorized command audit.", method: "coordinator.status", inputSchema: Type.Object(scope, closed) }, { name: "taskflow_control_projects", description: "List projects authorized for this local session.", method: "projects.list", inputSchema: Type.Object(scope, closed) }, { name: "taskflow_control_runs", description: "List durable run status in the authorized project.", method: "runs.list", inputSchema: Type.Object(scope, closed) }, { name: "taskflow_control_submit", description: "Submit a flow to the authenticated Host. Reuse commandId when retrying the same request.", method: "commands.submit", inputSchema: Type.Object({ ...scope, commandId: UuidSchema, flow: TaskflowSchema, args: Type.Optional(Type.Record(Type.String(), Type.Unknown())), approvalMode: Type.Optional(ApprovalModeSchema) }, closed) }, @@ -25,6 +26,10 @@ const definitions = [ { name: "taskflow_control_receipt", description: "Read the authorized evidence receipt for a terminal run.", method: "receipts.get", inputSchema: Type.Object(run, closed) }, { name: "taskflow_control_why", description: "Explain authorized durable run evidence and effect causes.", method: "evidence.why", inputSchema: Type.Object({ ...run, effectId: Type.Optional(Type.String({ minLength: 1 })), phaseId: Type.Optional(Type.String({ minLength: 1 })), seeds: Type.Optional(Type.Array(Type.String({ minLength: 1 }))) }, closed) }, ] as const; +const operatorDefinitions = [ + { name: "taskflow_control_set_max_active_runs", description: "Explicit operator only: set the user-global admitted-run limit. Reuse commandId for exact retries.", method: "coordinator.setMaxActiveRuns", inputSchema: Type.Object({ ...scope, commandId: UuidSchema, maxActiveRuns: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }) }, closed) }, + { name: "taskflow_control_force_release", description: "Explicit operator only: release capacity without proving execution stopped; permanently marks operator-overridden. Requires acknowledgement and an audit reason.", method: "coordinator.forceRelease", inputSchema: Type.Object({ ...scope, commandId: UuidSchema, reservationId: UuidSchema, riskAcknowledgement: Type.Literal(true), reason: Type.String({ minLength: 1, maxLength: 4096 }) }, closed) }, +] as const; const callSchema = Type.Object({ name: Type.String(), arguments: Type.Optional(Type.Record(Type.String(), Type.Unknown())) }, closed); const initializeSchema = Type.Object({ protocolVersion: Type.String(), capabilities: Type.Record(Type.String(), Type.Unknown()), clientInfo: Type.Object({ name: Type.String(), version: Type.String(), title: Type.Optional(Type.String()) }, { additionalProperties: true }) }, { additionalProperties: true }); @@ -32,30 +37,33 @@ export interface ControlMcpOptions { /** Bound by CLI bootstrap/HMAC attach; no caller identity can be supplied over MCP. */ call(method: string, params: unknown): Promise; projectId: string; + /** Trusted launcher choice; this never grants Host authority by itself. */ + operator?: boolean; input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream; } export function serveControlMcp(options: ControlMcpOptions): Promise { + const tools = [...definitions, ...(options.operator ? operatorDefinitions : [])]; let initialized = false; const requireInitialized = () => { if (!initialized) throw new RpcError(RPC.INVALID_REQUEST, "initialize is required before using control tools"); }; const handlers: Record = Object.create(null); handlers.initialize = params => { if (!Value.Check(initializeSchema, params)) throw new RpcError(RPC.INVALID_PARAMS, "invalid initialize parameters"); initialized = true; - return { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "taskflow-control", version: "0.3.0-beta.2" } }; + return { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "taskflow-control", version: getBuildInfo().packageVersion } }; }; handlers["notifications/initialized"] = () => undefined; handlers.ping = () => ({}); handlers["tools/list"] = params => { requireInitialized(); if (params !== undefined && !Value.Check(Type.Object({}, closed), params)) throw new RpcError(RPC.INVALID_PARAMS, "tools/list accepts no parameters"); - return { tools: definitions.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) }; + return { tools: tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) }; }; handlers["tools/call"] = async params => { requireInitialized(); if (!Value.Check(callSchema, params)) throw new RpcError(RPC.INVALID_PARAMS, "invalid tool call"); - const tool = definitions.find(definition => definition.name === params.name); + const tool = tools.find(definition => definition.name === params.name); if (!tool) throw new RpcError(RPC.INVALID_PARAMS, "unknown control tool"); const args = params.arguments ?? {}; if (tool.method === "approvals.decide" && (args.decision === "edit" ? args.editKind === undefined || args.editArtifactRef === undefined : args.editKind !== undefined || args.editArtifactRef !== undefined)) throw new RpcError(RPC.INVALID_PARAMS, "edit decision requires exactly editKind and editArtifactRef"); diff --git a/packages/taskflow-control/src/host-runtime.ts b/packages/taskflow-control/src/host-runtime.ts index f991db8d..c8204008 100644 --- a/packages/taskflow-control/src/host-runtime.ts +++ b/packages/taskflow-control/src/host-runtime.ts @@ -95,7 +95,14 @@ export class HostRuntime { case "projects.list": return [{ projectId, controlDomainId: mount.store.header.controlDomainId, name: path.basename(mount.projectRoot) }]; case "control.store.header": return mount.store.header; case "control.store.status": return mount.store.snapshot(); - case "coordinator.status": return this.coordinator.snapshot(context); + case "coordinator.status": { + const snapshot = await this.coordinator.snapshot(context); + await this.#authorize(context, mount, "read"); + return { ...snapshot, + reservations: snapshot.reservations.filter(row => row.reservation.projectId === projectId && row.reservation.projectControlDomainId === mount.store.header.controlDomainId), + commands: snapshot.commands.filter(command => command.record.callerPrincipal === identity.principal && (typeof command.result === "number" || command.result.reservation.projectId === projectId && command.result.reservation.projectControlDomainId === mount.store.header.controlDomainId)), + }; + } case "coordinator.setMaxActiveRuns": return this.coordinator.setMaxActiveRuns({commandId:id(body.commandId),maxActiveRuns:body.maxActiveRuns as number},context); case "coordinator.forceRelease": return this.coordinator.forceRelease(id(body.reservationId),{commandId:id(body.commandId),riskAcknowledgement:body.riskAcknowledgement as true,reason:body.reason as string},context); case "runs.list": return mount.store.listRuns(); diff --git a/packages/taskflow-control/src/local-bootstrap.ts b/packages/taskflow-control/src/local-bootstrap.ts index 522f4cb2..a0acc8ff 100644 --- a/packages/taskflow-control/src/local-bootstrap.ts +++ b/packages/taskflow-control/src/local-bootstrap.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { createHash, randomBytes, randomUUID } from "node:crypto"; import { ControlError } from "./errors.ts"; -import { createAuthorizationAuthority, type AuthorizationCapabilityKind, type AuthorizationAuthorityOptions } from "./authorization.ts"; +import { createAuthorizationAuthority, type AuthorizationCapabilityKind, type AuthorizationAuthorityOptions, type AuthorizationAuthority, type AuthorizationBinding, type VerifiedContext } from "./authorization.ts"; export const LOCAL_OWNER_CAPABILITIES: readonly AuthorizationCapabilityKind[] = [ "project.read", "run.submit", "run.cancel", "approval.decide", "artifact.read", @@ -57,7 +57,7 @@ export function bootstrapLocalAuthority(controlHome: string, projectRoots: reado for (const projectRoot of projectRoots) { createPrivateFileOnce(policyPath(home, projectRoot), Buffer.from(JSON.stringify({ version: 1, projectRoot, capabilities: LOCAL_OWNER_CAPABILITIES, revokedPrincipals: [] }) + "\n")); } - const authorization = createAuthorizationAuthority({ ownerUid: process.getuid!(), bootstrapSecret: secret, + const ordinary = createAuthorizationAuthority({ ownerUid: process.getuid!(), bootstrapSecret: secret, hostBaseline: LOCAL_OWNER_CAPABILITIES, verifyArtifactReachability: options.verifyArtifactReachability, loadLivePolicy: (_principal, binding) => { const value: unknown = JSON.parse(readPrivateFile(policyPath(home, binding.projectRoot)).toString("utf8")); @@ -69,5 +69,90 @@ export function bootstrapLocalAuthority(controlHome: string, projectRoots: reado return { host: { capabilities: policy.capabilities.map(kind => ({ kind, scopeRoot: binding.projectRoot })) }, revokedPrincipals: policy.revokedPrincipals }; }, }); + const authorization = withOperatorIssuer(home, ordinary); return { authorization, secret, keyPath }; } + + +export const LOCAL_OPERATOR_CAPABILITIES: readonly AuthorizationCapabilityKind[] = [ + "project.read", "coordinator.setMaxActiveRuns", "coordinator.forceRelease", +]; +export function operatorPolicyPath(controlHome: string, projectRoot: string): string { + return path.join(controlHome, "operator-policies", `${createHash("sha256").update(projectRoot).digest("hex")}.json`); +} +/** OS-owner local configuration action. Ordinary bootstrap never calls this. */ +export function provisionLocalOperator(controlHome: string, projectRoot: string) { + const home = ensurePrivateDirectory(controlHome), root = fs.realpathSync(projectRoot); + if (!fs.statSync(root).isDirectory()) fail("operator project root must be a directory"); + const keyPath = path.join(home, "operator.key"); + createPrivateFileOnce(keyPath, randomBytes(32)); + if (readPrivateFile(keyPath).length !== 32) fail("operator secret must contain exactly 32 bytes"); + const file = operatorPolicyPath(home, root); + createPrivateFileOnce(file, Buffer.from(JSON.stringify({ version: 1, projectRoot: root, capabilities: LOCAL_OPERATOR_CAPABILITIES, revokedPrincipals: [] }) + "\n")); + return { credential: "operator", principal: `os-user:${process.getuid!()}/credential:operator`, policyPath: file }; +} + +function withOperatorIssuer(home: string, ordinary: AuthorizationAuthority): AuthorizationAuthority { + const contexts = new WeakMap(); + const challenges = new Map(); + let operator: AuthorizationAuthority | undefined; + let operatorSecret: Buffer | undefined; + const denied = (): never => { throw new ControlError("TF_POLICY_DENIED", "operator credential is absent, invalid, or revoked"); }; + function checkOperatorKey() { + let current: Buffer; + try { current = readPrivateFile(path.join(home, "operator.key")); } catch { return denied(); } + if (current.length !== 32 || operatorSecret && !current.equals(operatorSecret)) return denied(); + return current; + } + function issuer() { + const bytes = checkOperatorKey(); + if (!operator) { + operatorSecret = bytes; + operator = createAuthorizationAuthority({ ownerUid: process.getuid!(), credentialIssuerId: "operator", bootstrapSecret: bytes, + hostBaseline: LOCAL_OPERATOR_CAPABILITIES, + loadLivePolicy: (_principal, binding) => { + checkOperatorKey(); + let policy: Record; + try { policy = JSON.parse(readPrivateFile(operatorPolicyPath(home, binding.projectRoot)).toString("utf8")); } catch { return denied(); } + if (!policy || typeof policy !== "object" || Array.isArray(policy) || Object.keys(policy).some(key => !["version", "projectRoot", "capabilities", "revokedPrincipals"].includes(key)) + || policy.version !== 1 || policy.projectRoot !== binding.projectRoot || !Array.isArray(policy.capabilities) + || policy.capabilities.some(kind => !LOCAL_OPERATOR_CAPABILITIES.includes(kind)) || !Array.isArray(policy.revokedPrincipals) + || policy.revokedPrincipals.some(principal => typeof principal !== "string")) return denied(); + return { host: { capabilities: policy.capabilities.map(kind => ({ kind, scopeRoot: binding.projectRoot })) }, revokedPrincipals: policy.revokedPrincipals }; + }, + }); + } + return operator; + } + function remember(context: VerifiedContext, authority: AuthorizationAuthority) { contexts.set(context, authority); return context; } + function contextIssuer(context: VerifiedContext) { + const authority = context && typeof context === "object" ? contexts.get(context) : undefined; + if (!authority) throw new ControlError("TF_AUTHORITY_REVOKED", "verified context is absent, foreign, or revoked"); + if (authority === operator) checkOperatorKey(); + return authority; + } + function challenge(binding: AuthorizationBinding, authority: AuthorizationAuthority) { + for (const [id, pending] of challenges) if (pending.expiresAt <= Date.now()) challenges.delete(id); + if (challenges.size >= 256) throw new ControlError("TF_POLICY_DENIED", "too many pending authentication challenges"); + const result = authority.createChallenge(binding); challenges.set(result.id, { issuer: authority, expiresAt: result.expiresAt }); return result; + } + return { + issueStandalone: binding => remember(ordinary.issueStandalone(binding), ordinary), + issueOperatorStandalone: binding => { const authority = issuer(); return remember(authority.issueStandalone(binding), authority); }, + createChallenge: binding => challenge(binding, ordinary), + createOperatorChallenge: binding => challenge(binding, issuer()), + authenticate(id, proof) { + const pending = challenges.get(id); challenges.delete(id); + if (!pending) throw new ControlError("TF_POLICY_DENIED", "authentication challenge is absent or expired"); + if (pending.issuer === operator) checkOperatorKey(); + return remember(pending.issuer.authenticate(id, proof), pending.issuer); + }, + identity: context => contextIssuer(context).identity(context), + revokeContext(context) { const authority = contexts.get(context); authority?.revokeContext(context); contexts.delete(context); }, + async authorize(context, request) { + const decision = await contextIssuer(context).authorize(context, request); + contextIssuer(context).identity(context); + return decision; + }, + }; +} diff --git a/packages/taskflow-control/src/project-admin-cli.ts b/packages/taskflow-control/src/project-admin-cli.ts index e7261bed..adc0e6f1 100644 --- a/packages/taskflow-control/src/project-admin-cli.ts +++ b/packages/taskflow-control/src/project-admin-cli.ts @@ -83,6 +83,6 @@ export async function runProjectAdminCli(argv=process.argv.slice(2)):Promise{process.exitCode=code;},error=>{process.stderr.write(JSON.stringify({error:error instanceof ControlError?error.code:"TF_BOOTSTRAP_FAILED",message:error instanceof Error?error.message:"project administration failed"})+"\n");process.exitCode=1;}); } diff --git a/packages/taskflow-control/src/singleton.ts b/packages/taskflow-control/src/singleton.ts index bda50329..65795c09 100644 --- a/packages/taskflow-control/src/singleton.ts +++ b/packages/taskflow-control/src/singleton.ts @@ -26,6 +26,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { Value } from "typebox/value"; import { bootstrapFailed, ControlError } from "./errors.ts"; +import { readDurableCoordinatorEpoch } from "./store/coordinator-store.ts"; import { CoordinatorLeaseSchema, type CoordinatorLease } from "./schema/coordinator.ts"; // --------------------------------------------------------------------------- @@ -353,6 +354,35 @@ function writeLease(paths: SingletonPaths, lease: CoordinatorLease): void { writeJsonAtomicDurable(paths.leasePath, lease); } +/** Monotonic generation survives normal lock/lease cleanup. Only a lock owner + * publishes this floor, before making its lease usable or deleting its lock. */ +function readEpochFloor(paths: SingletonPaths): number { + const file = path.join(paths.controlHome, "coordinator-epoch.json"); + try { + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink() || process.getuid && stat.uid !== process.getuid() || (stat.mode & 0o077) !== 0) throw bootstrapFailed("coordinator epoch file owner, mode or type is invalid"); + const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + const opened = fs.fstatSync(fd); + if (opened.dev !== stat.dev || opened.ino !== stat.ino || opened.size > 1024) throw bootstrapFailed("coordinator epoch file identity changed"); + const value = JSON.parse(fs.readFileSync(fd, "utf8")) as { version?: number; fencingEpoch?: number }; + const named = fs.lstatSync(file); + if (named.dev !== opened.dev || named.ino !== opened.ino) throw bootstrapFailed("coordinator epoch file identity changed"); + if (!value || value.version !== 1 || !Number.isSafeInteger(value.fencingEpoch) || value.fencingEpoch! < 1 || Object.keys(value).some(key => !["version", "fencingEpoch"].includes(key))) throw bootstrapFailed("malformed coordinator epoch floor"); + return value.fencingEpoch!; + } finally { fs.closeSync(fd); } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return readDurableCoordinatorEpoch(path.join(paths.controlHome, "coordinator")); + if (error instanceof ControlError) throw error; + throw bootstrapFailed("unreadable coordinator epoch floor"); + } +} +function persistEpochFloor(paths: SingletonPaths, epoch: number): void { + const floor = readEpochFloor(paths); + if (!Number.isSafeInteger(epoch) || epoch < 1 || epoch < floor) throw bootstrapFailed("coordinator epoch cannot move backwards"); + if (epoch !== floor) writeJsonAtomicDurable(path.join(paths.controlHome, "coordinator-epoch.json"), { version: 1, fencingEpoch: epoch }); +} + function claimPath(paths: SingletonPaths): string { return `${paths.lockPath}.claim`; } @@ -403,24 +433,32 @@ export function acquireUserSingleton(options: SingletonOptions): SingletonAcquir let published: { dev: bigint; ino: bigint } | undefined; for (let attempt = 0; attempt < maxAttempts; attempt++) { - // 1) Try to publish the lock by hard-link create. + // 1) Try to publish the lock by hard-link create. The durable floor + // survives graceful shutdown; a fresh install alone starts at one. + const nextEpoch = readEpochFloor(paths) + 1; + if (!Number.isSafeInteger(nextEpoch)) throw bootstrapFailed("coordinator epoch is exhausted"); const record: SingletonLockRecord = { version: SINGLETON_LOCK_VERSION, holderId, pid: identity.pid, birthToken: identity.birthToken, birthTokenKind: identity.birthTokenKind, - fencingEpoch: 1, + fencingEpoch: nextEpoch, endpoint, acquiredAt: now(), }; try { publishLockByHardLink(paths.lockPath, record); - // 2) Fresh-install win: lease starts at epoch 1 (P13: fencing epoch 0 - // only in the BootstrapManifest before the first acquisition). + // A previous holder may have completed between our floor read and + // publication. Retry before exposing a reused generation. + if (readEpochFloor(paths) >= nextEpoch) { + fs.unlinkSync(paths.lockPath); fsyncDirectory(paths.controlHome); continue; + } + persistEpochFloor(paths, nextEpoch); + // 2) Publish the usable lease only after its generation is durable. writeLease(paths, { holderId, - fencingEpoch: 1, + fencingEpoch: nextEpoch, endpoint, expiresAt: now() + leaseTtlMs, }); @@ -429,7 +467,7 @@ export function acquireUserSingleton(options: SingletonOptions): SingletonAcquir return { status: "won", holderId, - fencingEpoch: 1, + fencingEpoch: nextEpoch, endpoint, release: () => releaseUserSingleton(paths, { holderId, dev: published!.dev, ino: published!.ino }), }; @@ -532,6 +570,9 @@ function reclaimStaleLock( if (current === null || current.holderId !== existing.holderId || current.acquiredAt !== existing.acquiredAt) { return false; // another claimant already took over — retry loop } + // Preserve the previous generation before opening the publish gap. + persistEpochFloor(paths, existing.fencingEpoch); + if (!Number.isSafeInteger(existing.fencingEpoch + 1)) throw bootstrapFailed("coordinator epoch is exhausted"); // rename-to-discard: remove the dead owner's lock without a shared-name // ABA window, then publish our own (hard-link create again). const discard = discardPath(paths, crypto.randomBytes(8).toString("hex")); @@ -548,6 +589,7 @@ function reclaimStaleLock( endpoint, acquiredAt: now(), }); + persistEpochFloor(paths, existing.fencingEpoch + 1); writeLease(paths, { holderId, fencingEpoch: existing.fencingEpoch + 1, @@ -572,6 +614,9 @@ export interface SingletonReleaseIdentity { */ export function releaseUserSingleton(paths: SingletonPaths, acquired: SingletonReleaseIdentity): void { if (!lockInodeMatches(paths, { dev: acquired.dev, ino: acquired.ino })) return; // not our lock — never delete someone else's + const current = readLockRecord(paths); + if (!current || current.holderId !== acquired.holderId) return; + persistEpochFloor(paths, current.fencingEpoch); const discard = discardPath(paths, crypto.randomBytes(8).toString("hex")); try { fs.renameSync(paths.lockPath, discard); diff --git a/packages/taskflow-control/src/store/coordinator-store.ts b/packages/taskflow-control/src/store/coordinator-store.ts index 84aae0b7..57e60d91 100644 --- a/packages/taskflow-control/src/store/coordinator-store.ts +++ b/packages/taskflow-control/src/store/coordinator-store.ts @@ -61,7 +61,11 @@ export interface StoredReservation { releaseProofId?: string; concurrencyGuarantee: "enforced" | "operator-overridden"; } +export type CoordinatorOperatorRequest = { kind: "setMaxActiveRuns"; maxActiveRuns: number } + | { kind: "forceRelease"; reservationId: string; riskAcknowledgement: true; reason: string }; export interface StoredCoordinatorCommand { + /** New commands retain inspectable audit intent; legacy records remain readable. */ + request?: CoordinatorOperatorRequest; record: CoordinatorCommandRecord; result: StoredReservation | number; } @@ -88,7 +92,11 @@ const text = Type.String({ minLength: 1 }); const rowSchema = Type.Object({ reservation: ConcurrencyReservationSchema, ownerId: text, principal: text, updatedAt: integer, admitRunVersion: Type.Optional(integer), releaseKind: Type.Optional(Type.Union([Type.Literal("normal"), Type.Literal("force")])), releaseProofId: Type.Optional(text), concurrencyGuarantee: Type.Union([Type.Literal("enforced"), Type.Literal("operator-overridden")]) }, { additionalProperties: false }); -const commandSchema = Type.Object({ record: CoordinatorCommandRecordSchema, result: Type.Union([rowSchema, Type.Integer({ minimum: 1 })]) }, { additionalProperties: false }); +const operatorRequestSchema = Type.Union([ + Type.Object({ kind: Type.Literal("setMaxActiveRuns"), maxActiveRuns: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }) }, { additionalProperties: false }), + Type.Object({ kind: Type.Literal("forceRelease"), reservationId: UuidSchema, riskAcknowledgement: Type.Literal(true), reason: text }, { additionalProperties: false }), +]); +const commandSchema = Type.Object({ request: Type.Optional(operatorRequestSchema), record: CoordinatorCommandRecordSchema, result: Type.Union([rowSchema, Type.Integer({ minimum: 1 })]) }, { additionalProperties: false }); const auditSchema = Type.Object({ commitSeq: integer, epoch: integer, recordedAt: integer, action: text, principal: text, previousHash: Sha256HexSchema, stateDigest: Sha256HexSchema, hash: Sha256HexSchema }, { additionalProperties: false }); const stateSchema = Type.Object({ version: Type.Literal(1), fencingEpoch: integer, commitSeq: integer, lastWallTime: integer, @@ -164,11 +172,48 @@ function validateState(raw: unknown): CoordinatorState { } for (const [id, command] of Object.entries(state.commands)) { validId(id); + if (command.request && (command.request.kind !== command.record.kind || commandRequestHash(command.request) !== command.record.requestHash)) fail("TF_DURABILITY_FAILED", "coordinator audit request does not match its command"); if (id !== command.record.commandId || command.record.status !== "completed" || command.record.firstCommitSeq !== command.record.lastCommitSeq || command.record.lastCommitSeq > state.commitSeq) fail("TF_DURABILITY_FAILED", "coordinator command authority mismatch"); } if (capacity(state).active > state.maxActiveRuns) fail("TF_CAPACITY_EXCEEDED", "durable coordinator exceeds its capacity ceiling"); return state; } +/** Read-only upgrade fence for homes predating coordinator-epoch.json. Never + * trust a bare epoch field: verify the complete ledger and its genesis binding. */ +export function readDurableCoordinatorEpoch(directory: string): number { + const resolved = path.resolve(directory); + let stat: fs.Stats; + try { stat = fs.lstatSync(resolved); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return 0; throw error; } + if (!stat.isDirectory() || stat.isSymbolicLink() || process.getuid && stat.uid !== process.getuid() || (stat.mode & 0o077) !== 0) fail("TF_DURABILITY_FAILED", "legacy coordinator directory owner, mode or type is invalid"); + function read(name: string): unknown | undefined { + const file = path.join(resolved, name); + let named: fs.Stats; + try { named = fs.lstatSync(file); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } + if (!named.isFile() || named.isSymbolicLink() || process.getuid && named.uid !== process.getuid() || (named.mode & 0o077) !== 0) fail("TF_DURABILITY_FAILED", "legacy coordinator file owner, mode or type is invalid"); + const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + const opened = fs.fstatSync(fd); + if (opened.dev !== named.dev || opened.ino !== named.ino) fail("TF_DURABILITY_FAILED", "legacy coordinator file identity changed"); + const value: unknown = JSON.parse(fs.readFileSync(fd, "utf8")); + const after = fs.lstatSync(file); + if (opened.dev !== after.dev || opened.ino !== after.ino) fail("TF_DURABILITY_FAILED", "legacy coordinator file identity changed"); + return value; + } finally { fs.closeSync(fd); } + } + const raw = read("coordinator.json"), identity = read("coordinator.identity.json"); + if (raw === undefined && identity === undefined) return 0; + if (raw === undefined || identity === undefined) fail("TF_DURABILITY_FAILED", "legacy coordinator ledger or genesis identity is missing"); + const state = validateState(raw); + const identitySchema = Type.Object({ version: Type.Literal(1), canonicalPath: text, dev: integer, ino: integer, instanceId: UuidSchema }, { additionalProperties: false }); + if (!Value.Check(identitySchema, identity) || identity.canonicalPath !== resolved || identity.dev !== stat.dev || identity.ino !== stat.ino + || identity.instanceId !== state.instanceId || state.directoryIdentity.canonicalPath !== resolved || state.directoryIdentity.dev !== stat.dev || state.directoryIdentity.ino !== stat.ino + || fs.realpathSync(resolved) !== resolved) fail("TF_DURABILITY_FAILED", "legacy coordinator directory/genesis binding is invalid"); + const after = fs.lstatSync(resolved); + if (after.dev !== stat.dev || after.ino !== stat.ino) fail("TF_DURABILITY_FAILED", "legacy coordinator directory identity changed"); + return state.fencingEpoch; +} function bindingKey(row: Pick): string { return `${row.projectId}/${row.projectControlDomainId}/${row.runId}`; } function matchesProof(row: StoredReservation, proof: CoordinatorAdmissionProof): boolean { return !!proof && proof.reservationId === row.reservation.reservationId && bindingKey(row.reservation) === bindingKey(proof) && proof.projectAdmitCommitSeq === row.reservation.projectAdmitCommitSeq @@ -308,7 +353,7 @@ export class UserCoordinatorStore { async forceRelease(id: string, input: { commandId: string; riskAcknowledgement: true; reason: string }, context: Context): Promise { input = structuredClone(input); validId(input.commandId); - if (input.riskAcknowledgement !== true || !input.reason?.trim()) fail("TF_POLICY_DENIED", "forceRelease requires explicit risk acknowledgement and reason"); + if (input.riskAcknowledgement !== true || typeof input.reason !== "string" || !input.reason.trim()) fail("TF_POLICY_DENIED", "forceRelease requires explicit risk acknowledgement and reason"); return this.#transaction("forceRelease", context, id, async (state, identity, now) => { this.#operator(identity!); return this.#command(state, identity!, input.commandId, { kind: "forceRelease", reservationId: id, riskAcknowledgement: true, reason: input.reason }, () => { @@ -330,7 +375,7 @@ export class UserCoordinatorStore { }) as number; }); } - #command(state: CoordinatorState, identity: CoordinatorIdentity, id: string, body: { kind: "forceRelease" | "setMaxActiveRuns"; [key: string]: unknown }, execute: () => StoredReservation | number): StoredReservation | number { + #command(state: CoordinatorState, identity: CoordinatorIdentity, id: string, body: CoordinatorOperatorRequest, execute: () => StoredReservation | number): StoredReservation | number { const hash = commandRequestHash(body); const prior = state.commands[id]; if (prior) { if (prior.record.callerPrincipal !== identity.principal) fail("TF_CROSS_PRINCIPAL_COMMAND", "coordinator result belongs to another verified principal"); @@ -338,7 +383,7 @@ export class UserCoordinatorStore { return prior.result; } const result = execute(); - state.commands[id] = { record: { commandId: id, kind: body.kind, requestHash: hash, callerPrincipal: identity.principal, firstCommitSeq: state.commitSeq + 1, lastCommitSeq: state.commitSeq + 1, status: "completed" }, result: structuredClone(result) }; + state.commands[id] = { request: structuredClone(body), record: { commandId: id, kind: body.kind, requestHash: hash, callerPrincipal: identity.principal, firstCommitSeq: state.commitSeq + 1, lastCommitSeq: state.commitSeq + 1, status: "completed" }, result: structuredClone(result) }; return result; } #ttl(ttlMs: number): void { positive(ttlMs, "ttlMs"); if (ttlMs > 86_400_000) fail("TF_COMMAND_FAILED", "reservation TTL must not exceed one day"); } diff --git a/packages/taskflow-control/test/control-mcp.test.ts b/packages/taskflow-control/test/control-mcp.test.ts index e6077e31..c73b861a 100644 --- a/packages/taskflow-control/test/control-mcp.test.ts +++ b/packages/taskflow-control/test/control-mcp.test.ts @@ -84,7 +84,7 @@ test("MCP owner uses authenticated real execution, closed tools, durable results session.child.stdin.write("null\n{bad-json\n"); await initialize(session); const listed = (await session.request("tools/list")).result as { tools: { name: string; inputSchema: { additionalProperties: boolean } }[] }; - assert.equal(listed.tools.length, 12); assert.ok(listed.tools.every(entry => entry.inputSchema.additionalProperties === false)); + assert.equal(listed.tools.length, 13); assert.ok(listed.tools.every(entry => entry.inputSchema.additionalProperties === false)); for (const forged of [{ principal: "owner" }, { callerPrincipal: "owner" }, { projectRoot: root }, { permissions: ["*"] }, { authorizationContextHash: "fake" }]) { assert.equal((await session.request("tools/call", { name: "taskflow_control_runs", arguments: forged })).error?.code, -32602); } diff --git a/packages/taskflow-control/test/operator-route.test.ts b/packages/taskflow-control/test/operator-route.test.ts new file mode 100644 index 00000000..dade24da --- /dev/null +++ b/packages/taskflow-control/test/operator-route.test.ts @@ -0,0 +1,224 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { bootstrapLocalAuthority, provisionLocalOperator, operatorPolicyPath, readPrivateFile } from "../src/local-bootstrap.ts"; +import { answerAuthorizationChallenge } from "../src/authorization.ts"; +import type { CoordinatorSnapshot, StoredReservation } from "../src/store/coordinator-store.ts"; +import type { RunSnapshot } from "../src/schema/run.ts"; + +const cli = fileURLToPath(new URL("../src/control-cli.ts", import.meta.url)); +function setup() { + const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "tf-operator-"))); + const home = path.join(base, "home"), root = path.join(base, "project"), other = path.join(base, "other"); + fs.mkdirSync(root); fs.mkdirSync(other); + return { base, home, root, other }; +} +interface Reply { id?: number; error?: { code: number }; result?: { isError?: boolean; content: { text: string }[]; tools?: { name: string }[] } } +function launch(args: string[]) { + const child = spawn(process.execPath, ["--conditions=development", "--experimental-strip-types", cli, ...args], { stdio: "pipe" }); + let stdout = "", stderr = "", buffer = "", sequence = 0; + const pending = new Map(); + child.stdout.on("data", data => { + stdout += String(data); buffer += String(data); + for (;;) { + const end = buffer.indexOf("\n"); if (end < 0) break; + const line = buffer.slice(0, end); buffer = buffer.slice(end + 1); + const reply = JSON.parse(line) as Reply; + if (reply.id !== undefined) { pending.get(reply.id)?.resolve(reply); pending.delete(reply.id); } + } + }); + child.stderr.on("data", data => { stderr += String(data); }); + const done = new Promise<{ code: number | null; stdout: string; stderr: string }>(resolve => child.once("close", code => { + for (const waiter of pending.values()) waiter.reject(new Error(`child exited: ${stderr}`)); + pending.clear(); resolve({ code, stdout, stderr }); + })); + return { + child, done, output: () => ({ stdout, stderr }), + request(method: string, params: unknown = {}): Promise { + const id = ++sequence; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { pending.delete(id); reject(new Error(`RPC timeout: ${method} ${stderr}`)); }, 15_000); + pending.set(id, { resolve(reply) { clearTimeout(timer); resolve(reply); }, reject(error) { clearTimeout(timer); reject(error); } }); + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); + }); + }, + async stop(signal: NodeJS.Signals = "SIGTERM") { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill(signal); const timer = setTimeout(() => child.kill("SIGKILL"), 3_000); await done; clearTimeout(timer); + }, + }; +} +async function until(read: () => T | Promise, accept: (value: T) => boolean, label: string): Promise { + const deadline = Date.now() + 15_000; + for (;;) { + const value = await read(); if (accept(value)) return value; + assert.ok(Date.now() < deadline, `${label}: ${JSON.stringify(value)}`); + await new Promise(resolve => setTimeout(resolve, 30)); + } +} +async function initialize(session: ReturnType) { + const reply = await session.request("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "operator-process-test", version: "1" } }); + assert.equal(reply.error, undefined); +} +async function tool(session: ReturnType, name: string, args: Record = {}): Promise { + const reply = await session.request("tools/call", { name: `taskflow_control_${name}`, arguments: args }); + assert.equal(reply.error, undefined, JSON.stringify(reply)); + assert.equal(reply.result?.isError, undefined, JSON.stringify(reply)); + return JSON.parse(reply.result!.content[0]!.text) as T; +} +async function toolDenied(session: ReturnType, name: string, args: Record) { + const reply = await session.request("tools/call", { name: `taskflow_control_${name}`, arguments: args }); + assert.equal(reply.result?.isError, true, JSON.stringify(reply)); + assert.match(reply.result!.content[0]!.text, /TF_POLICY_DENIED|TF_AUTHORITY_REVOKED/); +} + +test("operator bootstrap is separate, explicit, scope-bound and live-revocable", async () => { + const f = setup(); + try { + const local = bootstrapLocalAuthority(f.home, [f.root, f.other]); + assert.equal(fs.existsSync(path.join(f.home, "operator.key")), false); + const binding = { projectId: randomUUID(), controlDomainId: randomUUID(), projectRoot: f.root }; + const request = { ...binding, operation: "submit" as const, commandKind: "coordinator.setMaxActiveRuns" as const }; + const ordinary = local.authorization.issueStandalone(binding); + await assert.rejects(local.authorization.authorize(ordinary, request), /baseline/); + assert.throws(() => local.authorization.createOperatorChallenge!(binding), /absent/); + provisionLocalOperator(f.home, f.root); + const challenge = local.authorization.createOperatorChallenge!(binding); + assert.match(challenge.principal, /\/credential:operator$/); + assert.throws(() => local.authorization.authenticate(challenge.id, answerAuthorizationChallenge(local.secret, challenge)), /proof failed/); + const second = local.authorization.createOperatorChallenge!(binding); + const context = local.authorization.authenticate(second.id, answerAuthorizationChallenge(readPrivateFile(path.join(f.home, "operator.key")), second)); + assert.equal((await local.authorization.authorize(context, request)).capability.kind, "coordinator.setMaxActiveRuns"); + await assert.rejects(local.authorization.authorize(ordinary, request), /baseline/); + await assert.rejects(local.authorization.authorize(context, { ...request, projectRoot: f.other, projectId: randomUUID() }), /project\/domain/); + const file = operatorPolicyPath(f.home, f.root), policy = JSON.parse(readPrivateFile(file).toString()); + policy.capabilities = []; fs.writeFileSync(file, JSON.stringify(policy)); + await assert.rejects(local.authorization.authorize(context, request), /capability/); + // Provisioning again must not overwrite deliberate revocation. + provisionLocalOperator(f.home, f.root); + assert.deepEqual(JSON.parse(readPrivateFile(file).toString()).capabilities, []); + fs.unlinkSync(path.join(f.home, "operator.key")); + assert.throws(() => local.authorization.identity(context), /absent/); + assert.equal(local.authorization.identity(ordinary).binding.projectId, binding.projectId); + } finally { fs.rmSync(f.base, { recursive: true, force: true }); } +}); + +test("CLI and MCP operator routes deny defaults, preserve project isolation and replay audited unknown release after restart", { timeout: 120_000 }, async () => { + const f = setup(); + const sessions: ReturnType[] = []; + const start = (args: string[]) => { const process = launch(args); sessions.push(process); return process; }; + const common = ["--root", f.root, "--control-home", f.home]; + const command = async (args: string[], success = true) => { + const result = await start([...args, ...common]).done; + assert.equal(result.code, success ? 0 : 1, result.stderr + result.stdout); return result; + }; + const serve = async () => { + const server = start(["serve", ...common, "--root", f.other]); + const output = await until(server.output, output => output.stdout.includes("\n"), "server ready"); + return { server, info: JSON.parse(output.stdout.split("\n")[0]!) as { projects: { projectId: string }[] } }; + }; + try { + let { server, info } = await serve(); + const initial = fs.readFileSync(path.join(f.home, "coordinator", "coordinator.json")); + assert.equal(fs.existsSync(path.join(f.home, "operator.key")), false); + const capacityCommand = randomUUID(); + assert.match((await command(["set-max-active-runs", "--command-id", capacityCommand, "--max-active-runs", "2"], false)).stderr, /TF_POLICY_DENIED/); + assert.deepEqual(fs.readFileSync(path.join(f.home, "coordinator", "coordinator.json")), initial); + await command(["operator-provision"]); + const ordinary = start(["mcp", ...common]); await initialize(ordinary); + const ordinaryTools = (await ordinary.request("tools/list")).result!.tools!; + assert.equal(ordinaryTools.length, 13); + assert.equal(ordinaryTools.some(t => /force_release|set_max_active_runs/.test(t.name)), false); + assert.equal((await ordinary.request("tools/call", { name: "taskflow_control_set_max_active_runs", arguments: { commandId: capacityCommand, maxActiveRuns: 2 } })).error?.code, -32602); + assert.equal(JSON.parse((await command(["set-max-active-runs", "--operator", "--command-id", capacityCommand, "--max-active-runs", "2"])).stdout), 2); + const operator = start(["mcp", ...common, "--operator"]); await initialize(operator); + assert.equal((await operator.request("tools/list")).result!.tools!.length, 15); + assert.equal(await tool(operator, "set_max_active_runs", { commandId: capacityCommand, maxActiveRuns: 2 }), 2); + const otherHeader = JSON.parse(fs.readFileSync(path.join(f.other, ".taskflow", "control", "header"), "utf8")); + assert.ok(info.projects.some(p => p.projectId === otherHeader.projectId)); + await toolDenied(operator, "set_max_active_runs", { projectId: otherHeader.projectId, commandId: randomUUID(), maxActiveRuns: 3 }); + + const otherOrdinary = start(["mcp", "--root", f.other, "--control-home", f.home]); await initialize(otherOrdinary); + const otherRun = await tool<{ runId: string }>(otherOrdinary, "submit", { commandId: randomUUID(), flow: { name: "other-project", phases: [{ id: "done", type: "script", run: "printf other" }] } }); + await until(() => tool(otherOrdinary, "status", { runId: otherRun.runId }), run => run.status === "completed", "other run complete"); + const b = await tool(otherOrdinary, "coordinator"); + const bReservation = b.reservations.find(row => row.reservation.runId === otherRun.runId)!; + assert.ok(bReservation); + const scoped = await tool(operator, "coordinator"); + assert.equal(JSON.stringify(scoped).includes(bReservation.reservation.reservationId), false); + const beforeCrossProject = fs.readFileSync(path.join(f.home, "coordinator", "coordinator.json")); + await toolDenied(operator, "force_release", { commandId: randomUUID(), reservationId: bReservation.reservation.reservationId, riskAcknowledgement: true, reason: "must not release another project" }); + assert.deepEqual(fs.readFileSync(path.join(f.home, "coordinator", "coordinator.json")), beforeCrossProject); + + const accepted = await tool<{ runId: string }>(ordinary, "submit", { commandId: randomUUID(), flow: { name: "unknown-owner", phases: [{ id: "work", type: "script", run: [process.execPath, "-e", "require('node:fs').writeFileSync('started','yes');setTimeout(()=>process.stdout.write('done'),1500)"] }] } }); + await until(() => fs.existsSync(path.join(f.root, "started")), Boolean, "real script started"); + await server.stop("SIGKILL"); await ordinary.stop(); await operator.stop(); await otherOrdinary.stop(); + // The actual child can finish, but the dead owner cannot publish terminal proof. + await new Promise(resolve => setTimeout(resolve, 1700)); + ({ server } = await serve()); + const restartedOrdinary = start(["mcp", ...common]); await initialize(restartedOrdinary); + const unknown = await tool(restartedOrdinary, "status", { runId: accepted.runId }); + assert.equal(unknown.status, "unknown"); assert.equal(unknown.stage, "reconciling"); + const snapshot = JSON.parse((await command(["coordinator-status", "--operator"])).stdout) as CoordinatorSnapshot; + const held = snapshot.reservations.find(row => row.reservation.runId === accepted.runId)!; + assert.ok(["committed", "orphan-suspect"].includes(held.reservation.state)); + const releaseCommand = randomUUID(), reason = "Owner died; explicitly accept uncertain process outcome"; + const release = ["force-release", "--operator", "--command-id", releaseCommand, "--reservation", held.reservation.reservationId, "--reason", reason]; + assert.match((await command(release, false)).stderr, /acknowledge-risk/); + const released = JSON.parse((await command([...release, "--acknowledge-risk"])).stdout) as StoredReservation; + assert.equal(released.releaseKind, "force"); assert.equal(released.concurrencyGuarantee, "operator-overridden"); + assert.equal(released.reservation.state, "released"); + await restartedOrdinary.stop(); await server.stop(); + const legacyFile = path.join(f.home, "coordinator", "coordinator.json"); + const legacyBytes = fs.readFileSync(legacyFile); + const legacyEpoch = JSON.parse(legacyBytes.toString()).fencingEpoch as number; + assert.ok(legacyEpoch >= 2); + // Emulate an old installation: its recovered ledger survives graceful + // shutdown, but old code never wrote the new persistent generation file. + fs.unlinkSync(path.join(f.home, "coordinator-epoch.json")); + const corrupt = JSON.parse(legacyBytes.toString()); corrupt.fencingEpoch += 100; + fs.writeFileSync(legacyFile, JSON.stringify(corrupt)); + const corruptBytes = fs.readFileSync(legacyFile); + assert.match((await command(["coordinator-status", "--mode", "auto"], false)).stderr, /TF_DURABILITY_FAILED/); + assert.deepEqual(fs.readFileSync(legacyFile), corruptBytes); + assert.equal(fs.existsSync(path.join(f.home, "coordinator-epoch.json")), false); + assert.equal(fs.existsSync(path.join(f.home, "singleton.lock.json")), false); + fs.writeFileSync(legacyFile, legacyBytes); + ({ server } = await serve()); + assert.ok(JSON.parse(fs.readFileSync(path.join(f.home, "coordinator-epoch.json"), "utf8")).fencingEpoch > legacyEpoch); + await server.stop(); + ({ server } = await serve()); + const beforeReplay = fs.readFileSync(path.join(f.home, "coordinator", "coordinator.json")); + const replay = await command([...release, "--acknowledge-risk"]); + assert.equal(replay.stdout, JSON.stringify(released) + "\n"); + assert.equal(JSON.parse((await command(["set-max-active-runs", "--operator", "--command-id", capacityCommand, "--max-active-runs", "2"])).stdout), 2); + assert.deepEqual(fs.readFileSync(path.join(f.home, "coordinator", "coordinator.json")), beforeReplay); + const final = JSON.parse((await command(["coordinator-status", "--operator"])).stdout) as CoordinatorSnapshot; + assert.equal(final.concurrencyGuarantee, "operator-overridden"); + const audit = final.commands.find(c => c.record.commandId === releaseCommand)!; + assert.deepEqual(audit.request, { kind: "forceRelease", reservationId: held.reservation.reservationId, riskAcknowledgement: true, reason }); + assert.match(audit.record.callerPrincipal, /\/credential:operator$/); + assert.equal(final.commands.filter(c => c.record.commandId === releaseCommand).length, 1); + const liveOperator = start(["mcp", ...common, "--operator"]); await initialize(liveOperator); + assert.deepEqual(await tool(liveOperator, "force_release", { commandId: releaseCommand, reservationId: held.reservation.reservationId, riskAcknowledgement: true, reason }), released); + const missingAcknowledgement = await liveOperator.request("tools/call", { name: "taskflow_control_force_release", arguments: { commandId: randomUUID(), reservationId: held.reservation.reservationId, reason } }); + assert.equal(missingAcknowledgement.error?.code, -32602); + const alteredRetry = await liveOperator.request("tools/call", { name: "taskflow_control_force_release", arguments: { commandId: releaseCommand, reservationId: held.reservation.reservationId, riskAcknowledgement: true, reason: "different request" } }); + assert.match(alteredRetry.result!.content[0]!.text, /TF_IDEMPOTENCY_CONFLICT/); + assert.deepEqual(fs.readFileSync(path.join(f.home, "coordinator", "coordinator.json")), beforeReplay); + const policyFile = operatorPolicyPath(f.home, f.root), policy = JSON.parse(readPrivateFile(policyFile).toString()); + policy.capabilities = []; fs.writeFileSync(policyFile, JSON.stringify(policy)); + const beforeRevoked = fs.readFileSync(path.join(f.home, "coordinator", "coordinator.json")); + await toolDenied(liveOperator, "force_release", { commandId: releaseCommand, reservationId: held.reservation.reservationId, riskAcknowledgement: true, reason }); + assert.match((await command([...release, "--acknowledge-risk"], false)).stderr, /TF_POLICY_DENIED/); + assert.deepEqual(fs.readFileSync(path.join(f.home, "coordinator", "coordinator.json")), beforeRevoked); + } finally { + for (const session of sessions.reverse()) await session.stop(); + fs.rmSync(f.base, { recursive: true, force: true }); + } +}); diff --git a/packages/taskflow-control/test/singleton.test.ts b/packages/taskflow-control/test/singleton.test.ts index 8591519d..eaddaa0d 100644 --- a/packages/taskflow-control/test/singleton.test.ts +++ b/packages/taskflow-control/test/singleton.test.ts @@ -242,3 +242,37 @@ test("singleton: reclaim bumps the fencing epoch so the old holder is fenced out assert.throws(() => assertFencing({ holderId: "holder-b", fencingEpoch: 2, endpoint: paths.endpointPath, expiresAt: 0 }, 1), ControlError); second.release(); }); + + +test("singleton: crash reclaim followed by graceful restarts never resets the fencing generation", () => { + const paths = makePaths(); + const first = acquireUserSingleton({ paths, holderId: "dead", processIdentity: { pid: DEAD_PID, birthToken: "dead-generation", birthTokenKind: "native" } }); + assert.equal(first.status, "won"); + const reclaimed = acquireUserSingleton({ paths, holderId: "recovered", inspectProcess: deadOwnerInspector() }); + assert.equal(reclaimed.status, "won"); + assert.equal(reclaimed.fencingEpoch, 2); + reclaimed.release(); + assert.equal(fs.existsSync(paths.lockPath), false); + assert.equal(fs.existsSync(paths.leasePath), false); + const fresh = acquireUserSingleton({ paths, holderId: "fresh" }); + assert.equal(fresh.status, "won"); assert.equal(fresh.fencingEpoch, 3); + assert.throws(() => assertFencing(readCoordinatorLease(paths), 2), /stale/); + fresh.release(); + const next = acquireUserSingleton({ paths, holderId: "next" }); + assert.equal(next.status, "won"); assert.equal(next.fencingEpoch, 4); next.release(); +}); + +test("singleton: malformed or redirected persisted epochs fail closed", () => { + const paths = makePaths(), file = path.join(paths.controlHome, "coordinator-epoch.json"); + fs.writeFileSync(file, '{"version":1,"fencingEpoch":0}', { mode: 0o600 }); + assert.throws(() => acquireUserSingleton({ paths }), /epoch floor/); + fs.writeFileSync(file, JSON.stringify({ version: 1, fencingEpoch: Number.MAX_SAFE_INTEGER + 1 })); + assert.throws(() => acquireUserSingleton({ paths }), /epoch floor/); + fs.writeFileSync(file, '{"version":1,"fencingEpoch":9}'); fs.chmodSync(file, 0o644); + assert.throws(() => acquireUserSingleton({ paths }), /epoch file/); + assert.equal(fs.existsSync(paths.lockPath), false); + fs.unlinkSync(file); + const outside = path.join(paths.controlHome, "saved-epoch"); fs.writeFileSync(outside, '{"version":1,"fencingEpoch":99}', { mode: 0o600 }); fs.symlinkSync(outside, file); + assert.throws(() => acquireUserSingleton({ paths }), /epoch file/); + assert.equal(fs.existsSync(paths.lockPath), false); +}); diff --git a/packages/taskflow-hosts/test/release-contract.test.ts b/packages/taskflow-hosts/test/release-contract.test.ts index 6e398969..d361b6eb 100644 --- a/packages/taskflow-hosts/test/release-contract.test.ts +++ b/packages/taskflow-hosts/test/release-contract.test.ts @@ -55,6 +55,7 @@ test("release contract rejects drift, unpinned templates and experimental depend ["experimental dependency", (root) => change(root, "packages/taskflow-hosts/package.json", (m) => { m.dependencies = { "taskflow-control": "workspace:*" }; }), /experimental dependency/], ["mixed dependency", (root) => change(root, "packages/taskflow-hosts/package.json", (m) => { m.dependencies = { "taskflow-core": "^1.0.0" }; }), /must use workspace/], ["experimental latest", (root) => change(root, "packages/charterarc/package.json", (m) => { m.publishConfig = { tag: "latest" }; }), /experimental channel/], + ["missing public control delivery", (root) => change(root, "packages/taskflow-mcp-core/package.json", (m) => { delete m.bin; }), /public control binary/], ]; for (const [name, mutate, expected] of cases) { const root = fixture(); try { mutate(root); assert.throws(() => verifyReleaseContract(root), expected, name); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/packages/taskflow-mcp-core/package.json b/packages/taskflow-mcp-core/package.json index 0d5c3c15..06bef599 100644 --- a/packages/taskflow-mcp-core/package.json +++ b/packages/taskflow-mcp-core/package.json @@ -1,7 +1,7 @@ { "name": "taskflow-mcp-core", "version": "1.0.0", - "description": "Host-neutral MCP server for taskflow: a dependency-free stdio JSON-RPC server exposing the taskflow_* tools, plus the DAG SVG/outline renderer. Shared by the codex/claude/opencode/grok/hermes adapters \u2014 depends only on taskflow-core.", + "description": "Host-neutral Taskflow MCP server and DAG renderer, with bundled local control and project administration binaries. Shared by the Codex, Claude, OpenCode, Grok and Hermes adapters.", "keywords": [ "taskflow", "mcp", @@ -50,10 +50,15 @@ } }, "files": [ - "dist" + "dist", + "CONTROL_GUIDE.md" ], + "bin": { + "taskflow-control": "./dist/control/control-cli.js", + "taskflow-project-admin": "./dist/control/project-admin-cli.js" + }, "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.build.json && node ../../scripts/copy-readme.mjs taskflow-mcp-core", + "build": "rm -rf dist && tsc -p tsconfig.build.json && node ../../scripts/build-control-bundle.mjs && node ../../scripts/copy-readme.mjs taskflow-mcp-core", "prepublishOnly": "npm run build" }, "publishConfig": { @@ -79,5 +84,8 @@ }, "dependencies": { "taskflow-core": "workspace:*" + }, + "peerDependencies": { + "typebox": "*" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe780acb..dd1496f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,6 +184,9 @@ importers: taskflow-core: specifier: workspace:* version: link:../taskflow-core + typebox: + specifier: '*' + version: 1.3.3 website: dependencies: diff --git a/scripts/build-control-bundle.mjs b/scripts/build-control-bundle.mjs new file mode 100644 index 00000000..5f54967e --- /dev/null +++ b/scripts/build-control-bundle.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +/** Ship the private control implementation inside the existing public MCP package. + * Compile directly from source on every MCP build: no stale workspace dist copy, + * runtime workspace dependency, or additional public package is required. */ +import { execFileSync } from "node:child_process"; +import { copyFileSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repo = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const control = join(repo, "packages", "taskflow-control"); +const delivery = join(repo, "packages", "taskflow-mcp-core"); +const output = join(delivery, "dist", "control"); +const require = createRequire(join(control, "package.json")); +const compilerManifest = require.resolve("typescript/package.json"); +const compiler = JSON.parse(readFileSync(compilerManifest, "utf8")); +rmSync(output, { recursive: true, force: true }); +mkdirSync(output, { recursive: true }); +execFileSync(process.execPath, [resolve(dirname(compilerManifest), compiler.bin.tsc), "-p", join(control, "tsconfig.build.json"), "--outDir", output], { cwd: control, stdio: "inherit" }); +copyFileSync(join(control, "GUIDE.md"), join(delivery, "CONTROL_GUIDE.md")); diff --git a/scripts/smoke-control-bundle.mjs b/scripts/smoke-control-bundle.mjs new file mode 100644 index 00000000..4fc67707 --- /dev/null +++ b/scripts/smoke-control-bundle.mjs @@ -0,0 +1,51 @@ +/** P13 acceptance against an installed public package, never workspace imports. */ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export function smokeControlBundle(consumerDir) { + assert.equal(existsSync(join(consumerDir, "node_modules", "taskflow-control")), false, + "public control must not depend on the private workspace package"); + const bundle = join(consumerDir, "node_modules", "taskflow-mcp-core"); + const manifest = JSON.parse(readFileSync(join(bundle, "package.json"), "utf8")); + assert.equal(manifest.bin["taskflow-control"], "./dist/control/control-cli.js"); + assert.ok(readFileSync(join(bundle, "CONTROL_GUIDE.md"), "utf8").includes("taskflow-control run")); + // Unix control is the supported P13 transport. Keep the socket path below + // macOS sockaddr_un limits even when the checkout or npm consumer is deep. + const temporaryRoot = realpathSync(mkdtempSync("/tmp/tfpc-")); + try { + const project = join(temporaryRoot, "project"); + const home = join(temporaryRoot, "home"); + mkdirSync(project, { mode: 0o700 }); + const env = { ...process.env, TASKFLOW_HOME: home, PI_TASKFLOW_BUILTIN_AGENTS_DIR: "" }; + const call = (bin, args) => { + const executable = join(consumerDir, "node_modules", ".bin", bin); + assert.ok(realpathSync(executable).startsWith(realpathSync(bundle) + "/"), "binary must resolve inside the installed public package"); + const result = spawnSync(process.execPath, [executable, ...args], { cwd: project, env, encoding: "utf8", timeout: 30_000 }); + assert.equal(result.error, undefined, result.error?.message); + assert.equal(result.status, 0, `${bin}: ${result.stderr}\n${result.stdout}`); + return result.stdout; + }; + assert.match(call("taskflow-control", ["--help"]), /taskflow-control run/); + assert.match(call("taskflow-project-admin", ["--help"]), /move-rebind/); + const flow = join(project, "fresh.json"); + writeFileSync(flow, JSON.stringify({ name: "fresh-public-control", phases: [{ id: "proof", type: "script", run: [process.execPath, "-e", "require('node:fs').writeFileSync('proof.txt','installed');process.stdout.write('PUBLIC_CONTROL_OK')"], final: true }] })); + assert.equal(existsSync(join(home, "control", "registry.json")), false); + // No --mode, --control-home, daemon configuration or source-only condition. + const first = JSON.parse(call("taskflow-control", ["run", "--root", project, "--flow", flow])); + assert.equal(first.run.status, "completed"); + assert.equal(first.run.slot, "released"); + assert.equal(first.result.finalOutput, "PUBLIC_CONTROL_OK"); + assert.equal(readFileSync(join(project, "proof.txt"), "utf8"), "installed"); + assert.ok(existsSync(join(home, "control", "registry.json"))); + const second = JSON.parse(call("taskflow-control", ["run", "--root", project, "--flow", flow])); + assert.equal(second.projectId, first.projectId, "restart must retain the project identity"); + assert.equal(second.controlDomainId, first.controlDomainId); + assert.notEqual(second.runId, first.runId); + assert.equal(second.run.status, "completed"); + const status = JSON.parse(call("taskflow-control", ["status", "--root", project, "--mode", "auto", "--run", first.runId])); + assert.equal(status.result.status, "completed"); + process.stdout.write("public control bundle passed: 2 installed bins, fresh default auto run, durable restart and status; no private package\n"); + } finally { rmSync(temporaryRoot, { recursive: true, force: true }); } +} diff --git a/scripts/smoke-packed-packages.mjs b/scripts/smoke-packed-packages.mjs index 12ae992e..6d5dd1b0 100644 --- a/scripts/smoke-packed-packages.mjs +++ b/scripts/smoke-packed-packages.mjs @@ -16,6 +16,7 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { RELEASE_PACKAGE_NAMES, packReleasePackages } from "./pack-release-packages.mjs"; +import { smokeControlBundle } from "./smoke-control-bundle.mjs"; const repo = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const packageNames = RELEASE_PACKAGE_NAMES; @@ -313,9 +314,10 @@ try { ]) { smokeMcpBin(binName, serverName); } + smokeControlBundle(consumerDir); process.stdout.write( - `packed consumer smoke passed: ${packageNames.length} packages, ${publicImports.length} explicit imports, ${wildcardExports} wildcard exports, 6 bins\n`, + `packed consumer smoke passed: ${packageNames.length} packages, ${publicImports.length} explicit imports, ${wildcardExports} wildcard exports, 8 bins\n`, ); } finally { rmSync(temporaryRoot, { recursive: true, force: true }); diff --git a/scripts/verify-release-contract.mjs b/scripts/verify-release-contract.mjs index f0135122..5e6d1dfa 100644 --- a/scripts/verify-release-contract.mjs +++ b/scripts/verify-release-contract.mjs @@ -48,6 +48,9 @@ export function verifyReleaseContract(repo, { published = false } = {}) { const hermesPins = hermes.match(/hermes-taskflow@[^"\s,\]]+/g) ?? []; assert.deepEqual(hermesPins, [`hermes-taskflow@${version}`], "hermes: MCP pin mismatch"); assert.equal(readJson("packages/taskflow-control/package.json").private, true, "experimental taskflow-control must remain private"); + const controlDelivery = readJson("packages/taskflow-mcp-core/package.json"); + assert.equal(controlDelivery.bin?.["taskflow-control"], "./dist/control/control-cli.js", "public control binary must ship in taskflow-mcp-core"); + assert.equal(controlDelivery.bin?.["taskflow-project-admin"], "./dist/control/project-admin-cli.js", "public project administration binary must ship in taskflow-mcp-core"); const charterarc = readJson("packages/charterarc/package.json"); assert.match(charterarc.version, /-experimental\./, "CharterArc must retain a separate experimental version"); assert.equal(charterarc.publishConfig?.tag, "experimental", "CharterArc must retain its experimental channel"); From 0c008bea6564af29ae9986045ce7168ee8865605 Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 8 Oct 2026 11:50:52 +0800 Subject: [PATCH 29/30] docs(release): reconcile final 1.0 implementation and authorization --- CHANGELOG.md | 36 +++++++++---------- README.md | 6 ++-- README.zh-CN.md | 6 ++-- RELEASE.md | 5 +-- docs/internal/1.0.0-ga-scoreboard.md | 16 +++++---- docs/internal/1.0.0-release-plan.md | 13 +++---- docs/internal/1.0.0-spec-closure.md | 10 +++--- .../en/compiler-runtime/typescript-dsl.mdx | 2 +- website/content/docs/en/getting-started.mdx | 2 +- .../content/docs/en/guides/claude-code.mdx | 2 +- website/content/docs/en/guides/codex.mdx | 2 +- website/content/docs/en/guides/grok-build.mdx | 2 +- website/content/docs/en/guides/hermes.mdx | 2 +- website/content/docs/en/guides/opencode.mdx | 2 +- website/content/docs/en/guides/pi.mdx | 2 +- website/content/docs/en/index.mdx | 4 +-- website/content/docs/en/trusted-effects.mdx | 4 +-- .../zh-cn/compiler-runtime/typescript-dsl.mdx | 2 +- .../content/docs/zh-cn/getting-started.mdx | 2 +- .../content/docs/zh-cn/guides/claude-code.mdx | 2 +- website/content/docs/zh-cn/guides/codex.mdx | 2 +- .../content/docs/zh-cn/guides/grok-build.mdx | 2 +- website/content/docs/zh-cn/guides/hermes.mdx | 2 +- .../content/docs/zh-cn/guides/opencode.mdx | 2 +- website/content/docs/zh-cn/guides/pi.mdx | 2 +- website/content/docs/zh-cn/index.mdx | 4 +-- .../content/docs/zh-cn/trusted-effects.mdx | 2 +- 27 files changed, 69 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 729d7b1e..a3c8941d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,33 +10,29 @@ All notable changes to taskflow are documented here. This project follows [Keep ## [1.0.0] — Unreleased -> **Draft PR #142 targets 1.0.0; `rc/0.3.0-beta.2` is its historical branch name.** Latest published prerelease remains `v0.3.0-beta.1.2`. Publication is blocked on historical accepted-spec closure and real Pi 1.0 compatibility. The ten existing public packages target npm `latest` only after these gates and the authorized tag workflow pass. A dated changelog is not evidence of completed publication; experimental packaging does not waive accepted requirements. - -### Changed +### Added -- Align the root, ten public packages, plugin manifests and all MCP installation templates to `1.0.0`; retain exact internal dependency rewriting, deterministic packed consumers, npm provenance and all security gates. -- Validate the complete release contract before packing or publishing, including package membership, version/pin agreement, experimental-package isolation and dated release notes. -- Keep `taskflow-control` private and workspace-only while its accepted decisions and proposed wire details await full implementation/acceptance. Admission, parked approvals/CAS/receipts, global coordination and WebUI remain incomplete and cannot be waived by this packaging boundary. CharterArc keeps its independent experimental release path. -- Prepare Pi 1.0 compatibility and owning-run identity propagation; release acceptance requires current packed-extension, real-process, retry, approval, TUI/RPC and resume evidence. -- Integrate dependency/Actions maintenance with retained Cisco skill-scan coverage. Historical scans using different analyzer versions do not prove historical findings were fixed. +- Authenticated multi-project Control Plane: durable project/domain registry, real runtime admission, global concurrency reservations, command-result replay, scoped artifacts, terminal Receipts and evidence explanations. +- Durable approval park/CAS, output and plan edits, restart readmission, and local WebUI for runs, approvals and evidence. Plan edits preserve the DAG. +- Public `taskflow-control` and `taskflow-project-admin` binaries bundled in `taskflow-mcp-core`, including fresh default-auto bootstrap and an installed guide. The release remains ten public packages; the private control source workspace is not a runtime dependency. +- Explicit operator provisioning with a separate credential and live project policy. CLI/MCP capacity changes and acknowledged force release retain durable audit/replay; forced release marks accounting operator-overridden without claiming execution stopped or creating a terminal Receipt. ### Fixed -- Let Pi finish transient retries and overflow compaction before deciding that an assistant error is terminal; preserve successful post-run compaction exits on Pi 0.80.3 and count tool-reported model usage once. -- Route Pi RPC approval through supported dialogs; reject cancellation, unavailable dialogs and aborted edits. Validate shorthand calls without an explicit action through the actual Pi tool schema. -- Persist every Pi/MCP runtime checkpoint instead of dropping rapid updates. Recover a Pi foreground approval checkpoint only after the owner is proven dead, the physical project identity matches, and the DAG proves other work cannot still be dispatched. Fork a new run and preserve the original bytes; living/unknown owners and potentially active children remain rejected. -- Reject cross-project attachment to the experimental single-store control host, preserve corrupt or identity-less ledgers, and isolate malformed UDS handshakes to the offending connection. -- Bound UDS frames and initial handshake time; validate response discriminants, IDs and epochs before use. Keep unknown TE handles ambiguous, isolate store records from caller mutation, and reject copied/moved/replaced store identities before journal recovery. -- Restore the default host baseline's strict evidence format. Separate six-host implementation notes from the normative sandbox allowlist; no target receives a new sandbox approval. -- Make release reruns accept valid non-draft GitHub releases, validate real changelog dates, and require every package's release dist-tag before creating a stable release. -- Correct macOS canonical-path test fixtures and wait for detached child completion before removing their workspaces. Add real Pi CLI and clean-installed package regressions alongside live Codex/Claude checks. -- Update Next.js, sharp, baseline-browser-mapping, Undici and brace-expansion to patched versions; the complete workspace dependency audit, including development dependencies, reports zero vulnerabilities. +- Preserve project isolation, reject corrupt or identity-less ledgers without overwriting them, and isolate malformed/bounded UDS clients. Validate copied/moved/replaced store identities before recovery. +- Retain monotonic singleton fencing across crash recovery and graceful restart; validate complete legacy coordinator evidence before migrating homes without an epoch floor. +- Let Pi finish transient retries and compaction before terminal failure, count tool-reported usage once, and use supported RPC approval dialogs with cancellation and unavailable-dialog rejection. +- Persist every Pi/MCP checkpoint. Recover approval checkpoints only after proven dead ownership, matching physical project identity and a safe DAG barrier; fork a new run and preserve parent bytes. +- Restrict ambient Pi 1.0.4 MCP server and native resource tools for explicit tool allowlists while preserving explicit selectors. Actual 0.80.3/0.85.1/1.0.0/1.0.4 compatibility evidence is versioned separately. +- Preserve deterministic ten-package packing, exact internal dependency pins, npm owner/provenance/source/integrity verification and release dist-tag gates. Release reruns verify existing artifacts rather than replacing them. ### Support boundaries -- Pi interactive approval is supported; MCP/headless approval auto-rejects. Grok flows with budgets are rejected when usage cannot be observed. -- Declared filesystem effects use resource transactions. Resolve-only is not an OS sandbox; undeclared writes remain host-policy dependent. Secret/service references have no live backend. Event-kernel fallback and journal capacity limits remain explicit. -- Registry artifacts, tag and GitHub Release are verified separately from source preparation. Current evidence is tracked in `docs/internal/1.0.0-ga-scoreboard.md`. +- The existing twelve-phase DAG runtime, TypeScript DSL, planning, retry/budget contracts, context isolation, background execution, resume/recompute/replay and declared filesystem resource transactions remain the stable contract. +- Pi interactive approval and the separate authenticated Control MCP durable-approval route are supported. Ordinary host MCP/headless approval phases reject automatically. Grok rejects budgets when usage is unavailable. +- Control uses Unix UDS; Windows named pipes remain non-GA. Resolve-only containment is not an OS sandbox. Unknown effects retain capacity unless explicitly overridden; arbitrary in-flight recovery is not promised. +- Secret/service references have no live backend. Event-kernel fallback and physical journal capacity limits remain explicit. Unavailable live hosts are recorded as unverified rather than inferred from fixtures. +- Final source/installed verification and release records are tracked in `docs/internal/1.0.0-ga-scoreboard.md`. A dated changelog alone does not establish completed npm or GitHub publication. ## [0.3.0-beta.2] — 2026-08-18 diff --git a/README.md b/README.md index d90ad375..e6c07ddd 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,8 @@ This is **not** an OS sandbox. Resolve-only hosts cannot prevent every write to | **Trusted Effects** | Closed `EffectIR`, `PathRef` / `SecretRef` / `ServiceRef`, confidentiality/integrity labels, effect validation, overlap checks, and ledger-backed `why-*` explainers | Declared-target contract | | **Resource transaction** | Snapshot → lease → durable intent/permit → stage → commit, or restore and reject | Declared-target contract | | **Host adapters** | Pi, Codex, Claude Code, OpenCode, Grok Build, and Hermes Agent use the same flow contract | Existing host surface; support remains host-specific | -| **Control Plane** | Unix UDS singleton/fencing and files-only store; proposed wire contracts and incomplete admission/parked approvals/receipts/coordination | Currently private workspace; complete Control Plane required for 1.0 acceptance | -| **WebUI** | Runs, approvals, receipts, and evidence browsing | Incomplete; required for 1.0 acceptance | +| **Control Plane** | Authenticated multi-project admission, global concurrency, durable replay, approvals/CAS, Receipts and operator CLI/MCP | Bundled in public `taskflow-mcp-core`; Unix UDS, Windows pipes non-GA | +| **WebUI** | Runs, approval edits, Receipts and evidence browsing | Local token login, live authorization and project isolation | The [1.0 release plan](./docs/internal/1.0.0-release-plan.md) defines the stable scope and acceptance gates. The filesystem-transaction details are in [`docs/internal/0.3.0-trusted-effects-mvp.md`](./docs/internal/0.3.0-trusted-effects-mvp.md). The 0.3-C Control Plane plan is [`docs/internal/0.3-c-control-plane-plan.md`](./docs/internal/0.3-c-control-plane-plan.md). @@ -192,7 +192,7 @@ Host support is not a blanket security guarantee. Read the [host support baselin - Writes to undeclared paths remain host-policy dependent under resolve-only execution. - `SecretRef` and `ServiceRef` are typed handles only; no vault or live service adapter ships in this cut. - There is no FileBroker or full OS sandbox claim in 0.3 MVP. -- Control Plane admission, authorized command-result replay, parked approvals, receipts, global coordination and WebUI remain incomplete. Publication of 1.0 is blocked pending closure of the accepted specifications. Duplicate control commands fail closed until verified caller identity and live disclosure authorization exist; request audit fields do not grant access. +- Control Plane admission, authorized replay, durable approvals, Receipts, global coordination and WebUI are implemented. Public `taskflow-mcp-core` provides the control binaries; operator actions require an explicitly provisioned separate credential. Request audit fields never grant authority. ## Development diff --git a/README.zh-CN.md b/README.zh-CN.md index db472b7f..e6b6d8d5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -55,8 +55,8 @@ flow / .tf.ts | **Trusted Effects** | 封闭的 `EffectIR`、`PathRef` / `SecretRef` / `ServiceRef`、机密性/完整性标签、effect 校验、重叠检查与 ledger-backed `why-*` | 声明目标合同 | | **Resource transaction** | snapshot → lease → durable intent/permit → stage → commit,或 restore and reject | 声明目标合同 | | **宿主适配器** | Pi、Codex、Claude Code、OpenCode、Grok Build、Hermes Agent 共用同一 flow 合同 | 已有宿主表面;能力仍按宿主区分 | -| **Control Plane** | Unix UDS singleton/fencing、files-only store 与拟议 wire contract;admission、parked 审批、receipt 和全局协调尚未完成 | 当前 private workspace;完整 Control Plane 是 1.0 必须完成的验收范围 | -| **WebUI** | runs、审批、receipts 与 evidence 浏览 | 尚未完成;1.0 必须完成的验收范围 | +| **Control Plane** | 经认证的多项目准入、全局并发、持久命令重放、审批/CAS、Receipt 与 operator CLI/MCP | 公共 `taskflow-mcp-core` 内交付;Unix UDS,Windows pipes 非 GA | +| **WebUI** | runs、审批及编辑、receipts 与 evidence 浏览 | 本地令牌登录;实时权限与项目隔离 | [1.0 发布计划](./docs/internal/1.0.0-release-plan.md)定义稳定范围与验收闸门。文件事务细节见 [`docs/internal/0.3.0-trusted-effects-mvp.md`](./docs/internal/0.3.0-trusted-effects-mvp.md)。0.3-C Control Plane 计划见 [`docs/internal/0.3-c-control-plane-plan.md`](./docs/internal/0.3-c-control-plane-plan.md)。 @@ -192,7 +192,7 @@ validate → Taskflow JSON → FlowIR + content hash - 在 resolve-only 执行下,未声明路径的写入仍取决于宿主策略。 - `SecretRef` 与 `ServiceRef` 只是类型化句柄;这一版没有 vault 或 live service adapter。 - 0.3 MVP 不声称提供 FileBroker 或完整 OS sandbox。 -- Control Plane admission、经授权的命令结果重放、parked 审批、receipt、全局协调与 WebUI 尚未完成;1.0 发布等待已接受 spec 的实现与验收闭环。可信调用者身份与实时披露授权就绪前,重复控制命令会被拒绝;请求中的审计字段不能授予访问权限。 +- Control Plane admission、经授权的命令重放、持久审批、Receipt、全局协调与 WebUI 已实现。公共 `taskflow-mcp-core` 提供 Control 命令;operator 操作需显式配置独立凭据。请求中的审计字段不能授予访问权限。 ## 开发 diff --git a/RELEASE.md b/RELEASE.md index 2a97548f..48fbc1bc 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -13,8 +13,9 @@ Latest published prerelease remains `v0.3.0-beta.1.2`. See the [track reconciliation](./docs/internal/1.0.0-release-plan.md#release-track-reconciliation-2026-10-07). The complete approved Control Plane, including its WebUI, must be implemented and accepted before 1.0 can ship; the current private package is not a waiver. -The 2026-10-07 metadata task keeps the PR Draft and performs no merge, tag or -publication. Commands below describe later gated release work. +The owner authorized complete closure through publication on 2026-10-08. +The earlier metadata-only/local-only restrictions are historical; all final +release gates below remain required. ## Public contract and package set diff --git a/docs/internal/1.0.0-ga-scoreboard.md b/docs/internal/1.0.0-ga-scoreboard.md index 480669b7..165b91fc 100644 --- a/docs/internal/1.0.0-ga-scoreboard.md +++ b/docs/internal/1.0.0-ga-scoreboard.md @@ -1,8 +1,10 @@ # Taskflow 1.0.0 acceptance scoreboard -**Unreleased 1.0.0 / Draft #142. Updated 2026-10-08.** No main merge, tag, npm -publication or GitHub Release is authorized in this local follow-up. Earlier -conditional release instructions do not replace the current local-only boundary. +**Unreleased 1.0.0 / PR #142. Updated 2026-10-08.** The owner now explicitly +authorizes complete closure through publication. This supersedes the earlier +local-only instruction; final-SHA CI/security, verified main/tag, provenance, +registry bytes and installed acceptance remain mandatory. One release executor +owns all remote writes; the original dirty implementation checkout is preserved. ## Last fixed remote baseline @@ -36,11 +38,11 @@ implemented admission, replay, approvals, Receipt, registry or WebUI. The | P13 fresh public installation | `build-control-bundle.mjs` compiles the source into the public carrier. `smoke-control-bundle.mjs` runs installed npm-bin links, default-auto fresh run, restart and durable status without a private package or workspace source | | P13 epoch persistence/upgrade | Real process testing found crash-recovery epoch followed by graceful restart could regress. Candidate persists monotonic fencing and validates legacy coordinator evidence before migration; malformed authority state remains fail-closed | | Pi 1.0.4 integration | Draft #181 head `063282bc41ab233ca2d50f7dde8f0d88e86b31e8` compared independently. Existing #142 lifecycle/usage/checkpoint recovery retained; server/resource tool-filter delta and actual CLI controls verified per selected SDK, never relabeled from 1.0.0 | -| Source/typecheck/installed tests | Final local results are recorded in the scope-close handoff, bound to the frozen candidate SHA, commands, counts, actual SDK/runtime versions and byte attestations; the previous baseline counts above are not reused | +| Source/typecheck/installed tests | PASS at `472bb98ded6c47813c8610fefb74c13f0127cdbb`: Node 22.23.3/24.21.0 each 2,945 source and 576 public installed control/core cases; typecheck passes. The intact source-scanner-containing store.test.ts (25 cases) is excluded only from installed adaptation and covered by both full source suites | | Actual browser | PASS on the local implementation: actual Chrome approval edit → TE → Receipt → project isolation → live revocation; final handoff binds the test and source hashes | -| Final local tarballs | Final packaging gate: deterministic packs and clean consumers must use the frozen SHA and include the public control bundle; hashes and outcome belong to the external handoff | -| New-head remote CI/security | NOT RUN / NOT AUTHORIZED. Previous ca247ac or #181 greens cannot certify this combined local revision | -| Published registry artifacts/GitHub Release | NOT DONE / NOT AUTHORIZED. Requires exact final registry hashes, upgrade verification and the single original publisher handoff | +| Final local tarballs | PASS at `472bb98`: ten repeat-identical packages, 27 imports, 87 wildcard exports, eight bins; fresh default-auto control and restart/status, no private package. Both Node versions: actual installed Pi 1.0.0 = 17 lifecycle + 2 crash; Pi 1.0.4 = 28 compatibility + 17 lifecycle + 2 crash. Archive/build/installed bytes agree | +| New-head remote CI/security | AUTHORIZED / PENDING. Previous ca247ac or #181 greens cannot certify the new final revision | +| Published registry artifacts/GitHub Release | AUTHORIZED / PENDING. Requires verified final main/tag, exact registry hashes, dist-tags and installed verification by the single release executor | ## Explicit boundaries diff --git a/docs/internal/1.0.0-release-plan.md b/docs/internal/1.0.0-release-plan.md index 71e3997e..063dbff6 100644 --- a/docs/internal/1.0.0-release-plan.md +++ b/docs/internal/1.0.0-release-plan.md @@ -1,6 +1,6 @@ # Taskflow 1.0.0 release preparation -Status: **SPEC CLOSURE REQUIRED / PUBLICATION BLOCKED**. This plan supersedes the beta.2 +Status: **IMPLEMENTATION LOCALLY ACCEPTED / FINAL RELEASE GATES PENDING**. This plan supersedes the beta.2 release target for PR #142; older plans and evidence remain historical. The authorized scope is a complete release preparation PR, comprehensive verification, Pi 1.0 retry/approval compatibility and E2E regression fixes. @@ -37,11 +37,12 @@ The current scope and acceptance authority are this plan, [`1.0.0-spec-closure.md`](./1.0.0-spec-closure.md) and [`1.0.0-ga-scoreboard.md`](./1.0.0-ga-scoreboard.md). -The 2026-10-07 task is metadata/documentation alignment only: keep #142 Draft; -no Ready transition, merge, tag, npm publish or GitHub Release. Earlier -conditional publication instructions do not authorize those actions in this task. -Changes here land through #142; `main` and published release notes remain the -published/history view until a separately authorized merge. +The 2026-10-07 metadata-only and subsequent local-only limits are historical. +On 2026-10-08 the owner explicitly requested complete closure through +publication. The single release executor may update #142, complete final remote +checks, merge, date the changelog, verify main and push the release tag. The +workflow alone publishes npm packages and creates the verified GitHub Release. +This authorization does not waive failed tests, provenance or artifact checks. ## Contract diff --git a/docs/internal/1.0.0-spec-closure.md b/docs/internal/1.0.0-spec-closure.md index 2e53476a..8d0c1f10 100644 --- a/docs/internal/1.0.0-spec-closure.md +++ b/docs/internal/1.0.0-spec-closure.md @@ -1,8 +1,8 @@ # 1.0 specification closure ledger **Track: unreleased 1.0.0, Draft PR #142. Updated 2026-10-08.** The retained -`rc/0.3.0-beta.2` branch name is historical. No merge, tag, npm publication or -GitHub Release is authorized by this ledger. +`rc/0.3.0-beta.2` branch name is historical. The owner authorized full closure through publication on 2026-10-08; final +release checks and registry verification remain mandatory. This is the current implementation map, replacing the 2026-10-02 scaffold-only remaining-work table. Accepted P-ADRs and the approved Control Plane S3–S7 plan @@ -21,9 +21,9 @@ to that exact commit and do not establish Pi 1.0.4 or the follow-up below. The local follow-up closes the independently identified operator and public bundle paths, integrates the distinct Pi 1.0.4 tool-filter delta from Draft [#181](https://github.com/heggria/taskflow/pull/181), and corrects this ledger. -It is not yet pushed. Final local source/installed results and artifact hashes +The implementation passed local acceptance at `472bb98`. Final local source/installed results and artifact hashes are recorded together in the Task9 scope-close handoff; new-head remote CI -remains a gate requiring separate authorization. The original dirty checkout +remains a required gate under the current publication authorization. The original dirty checkout and previously accepted artifacts are preserved. ## Current Control Plane implementation @@ -91,7 +91,7 @@ MCP opt-in semantics; this filter is not an OS sandbox. MCP and local Pi providers are not new live-provider authentication acceptance for OpenCode/Grok/Hermes or every TUI combination. - Final candidate source, public bundle, installed process tests and artifact - hashes must agree. Its remote CI is pending until explicitly authorized. + hashes must agree. Its remote CI must pass at the final release revision. - Registry artifact verification, authorized main/tag/npm/GitHub Release and the single original publisher handoff remain publication gates. See the [current scoreboard](./1.0.0-ga-scoreboard.md). diff --git a/website/content/docs/en/compiler-runtime/typescript-dsl.mdx b/website/content/docs/en/compiler-runtime/typescript-dsl.mdx index 7b6b6f17..03162dab 100644 --- a/website/content/docs/en/compiler-runtime/typescript-dsl.mdx +++ b/website/content/docs/en/compiler-runtime/typescript-dsl.mdx @@ -3,7 +3,7 @@ title: TypeScript DSL (`taskflow-dsl`) description: Compile-time .tf.ts authoring — erase runes to Taskflow JSON, then FlowIR. --- -> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete approved Control Plane, including WebUI, remains required for 1.0 acceptance; current private workspace packaging is not a waiver. +> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete Control Plane, including WebUI, is implemented and locally verified; the final commit must still pass remote release gates. # TypeScript DSL (`taskflow-dsl`) diff --git a/website/content/docs/en/getting-started.mdx b/website/content/docs/en/getting-started.mdx index e506f460..feba199d 100644 --- a/website/content/docs/en/getting-started.mdx +++ b/website/content/docs/en/getting-started.mdx @@ -3,7 +3,7 @@ title: Getting Started description: Run your first taskflow in under five minutes. --- -> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete approved Control Plane, including WebUI, remains required for 1.0 acceptance; current private workspace packaging is not a waiver. +> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete Control Plane, including WebUI, is implemented and locally verified; the final commit must still pass remote release gates. taskflow lets you describe multi-step agent work as a declarative graph. Instead of writing a script that calls subagents one by one, you declare the nodes and edges — and the runtime handles fan-out, retries, caching, and resume. diff --git a/website/content/docs/en/guides/claude-code.mdx b/website/content/docs/en/guides/claude-code.mdx index 2bf2627e..222560ff 100644 --- a/website/content/docs/en/guides/claude-code.mdx +++ b/website/content/docs/en/guides/claude-code.mdx @@ -3,7 +3,7 @@ title: Using taskflow on Claude Code description: Install taskflow as a Claude Code plugin and orchestrate multi-phase workflows via MCP. --- -> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete approved Control Plane, including WebUI, remains required for 1.0 acceptance; current private workspace packaging is not a waiver. +> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete Control Plane, including WebUI, is implemented and locally verified; the final commit must still pass remote release gates. [Claude Code](https://claude.com/product/claude-code) is a natural home for taskflow: it already thinks in terms of subagents, and taskflow gives those subagents a declarative graph to run inside. The plugin registers the MCP server plus a routing skill; its protocol layer needs no MCP SDK. diff --git a/website/content/docs/en/guides/codex.mdx b/website/content/docs/en/guides/codex.mdx index f039332c..1e8e81b8 100644 --- a/website/content/docs/en/guides/codex.mdx +++ b/website/content/docs/en/guides/codex.mdx @@ -3,7 +3,7 @@ title: Using taskflow on Codex description: Install taskflow as a Codex plugin and orchestrate multi-phase workflows via MCP. --- -> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete approved Control Plane, including WebUI, remains required for 1.0 acceptance; current private workspace packaging is not a waiver. +> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete Control Plane, including WebUI, is implemented and locally verified; the final commit must still pass remote release gates. [Codex](https://github.com/openai/codex) is a natural home for taskflow: it already thinks in terms of subagents, and taskflow gives those subagents a declarative graph to run inside. On Codex, taskflow ships as a plugin that registers an MCP server plus a routing skill, so the model can call it the moment it sees a multi-step task. diff --git a/website/content/docs/en/guides/grok-build.mdx b/website/content/docs/en/guides/grok-build.mdx index 036393df..a5fc8720 100644 --- a/website/content/docs/en/guides/grok-build.mdx +++ b/website/content/docs/en/guides/grok-build.mdx @@ -3,7 +3,7 @@ title: Using taskflow on Grok Build description: Install taskflow as a Grok Build plugin and orchestrate multi-phase workflows via MCP. --- -> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete approved Control Plane, including WebUI, remains required for 1.0 acceptance; current private workspace packaging is not a waiver. +> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete Control Plane, including WebUI, is implemented and locally verified; the final commit must still pass remote release gates. [Grok Build](https://docs.x.ai/build/overview) is xAI's terminal coding agent. Taskflow's published path is its MCP server; a plugin scaffold is also available in the repository checkout. diff --git a/website/content/docs/en/guides/hermes.mdx b/website/content/docs/en/guides/hermes.mdx index 36b3e52f..27936960 100644 --- a/website/content/docs/en/guides/hermes.mdx +++ b/website/content/docs/en/guides/hermes.mdx @@ -3,7 +3,7 @@ title: Using taskflow on Hermes Agent description: Install taskflow as an MCP server on Hermes Agent and orchestrate multi-phase workflows with isolated Hermes subagents. --- -> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete approved Control Plane, including WebUI, remains required for 1.0 acceptance; current private workspace packaging is not a waiver. +> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete Control Plane, including WebUI, is implemented and locally verified; the final commit must still pass remote release gates. [Hermes Agent](https://hermes-agent.nousresearch.com/) is Nous Research's open-source coding agent (CLI, TUI, desktop, messaging gateways). Taskflow's published path is its MCP server: Hermes discovers `taskflow_*` tools and each agent phase spawns an isolated `hermes chat -q -Q` child. diff --git a/website/content/docs/en/guides/opencode.mdx b/website/content/docs/en/guides/opencode.mdx index 497cbbeb..a9c7a38e 100644 --- a/website/content/docs/en/guides/opencode.mdx +++ b/website/content/docs/en/guides/opencode.mdx @@ -3,7 +3,7 @@ title: Using taskflow on OpenCode description: Register taskflow as an MCP server on OpenCode and orchestrate multi-phase workflows. --- -> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete approved Control Plane, including WebUI, remains required for 1.0 acceptance; current private workspace packaging is not a waiver. +> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete Control Plane, including WebUI, is implemented and locally verified; the final commit must still pass remote release gates. [OpenCode](https://opencode.ai) exposes taskflow through an MCP server whose protocol layer has no MCP SDK dependency; each phase runs as an isolated `opencode run` process. diff --git a/website/content/docs/en/guides/pi.mdx b/website/content/docs/en/guides/pi.mdx index f4228bc9..27b197bf 100644 --- a/website/content/docs/en/guides/pi.mdx +++ b/website/content/docs/en/guides/pi.mdx @@ -3,7 +3,7 @@ title: Using taskflow on Pi description: Install, run, save, and resume taskflows on the Pi coding agent. --- -> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete approved Control Plane, including WebUI, remains required for 1.0 acceptance; current private workspace packaging is not a waiver. +> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete Control Plane, including WebUI, is implemented and locally verified; the final commit must still pass remote release gates. If you already use [Pi](https://pi.dev) to delegate work to subagents, taskflow turns those one-off delegations into named, resumable, multi-step workflows. You describe the work as a graph — fan-out, gates, loops, whatever you need — and only the final result comes back into your context. diff --git a/website/content/docs/en/index.mdx b/website/content/docs/en/index.mdx index f0134e68..3dae37d8 100644 --- a/website/content/docs/en/index.mdx +++ b/website/content/docs/en/index.mdx @@ -3,7 +3,7 @@ title: taskflow 1.0 Documentation description: "Trusted Effects for coding-agent workflows: declare effects, verify typed paths, and commit admitted filesystem changes through one resource authority." --- -> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete approved Control Plane, including WebUI, remains required for 1.0 acceptance; current private workspace packaging is not a waiver. +> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete Control Plane, including WebUI, is implemented and locally verified; the final commit must still pass remote release gates. taskflow is a declarative runtime for coding-agent workflows. It turns a graph into a verifiable execution contract, runs phases in isolation, and keeps intermediate transcripts out of the host conversation. The 1.0 contract includes **Trusted Effects**: a typed declaration and resource-controlled commit path for admitted filesystem effects. @@ -96,7 +96,7 @@ After 1.0.0 publication, install an exact `@1.0.0` version. Publication status i The implemented baseline and outstanding 1.0 requirements have distinct acceptance states: - **Trusted Effects MVP:** the implemented baseline; it does not alone close the 1.0 scope. It covers EffectIR, typed refs, labels, resource-controlled filesystem transactions, overlap admission, host-baseline honesty, and ledger-backed `why-*` explainers. -- **Experimental Control Plane:** private workspace Unix UDS/store code with proposed wire contracts; admission, parked approvals, receipts, global coordination and WebUI remain incomplete and are required for 1.0 acceptance. +- **Control Plane:** authenticated multi-project admission, durable approvals/replay/Receipts, global coordination and WebUI are implemented. Control binaries ship in public `taskflow-mcp-core`; Unix UDS is supported and Windows pipes remain non-GA. Read the [MVP freeze in the repository](https://github.com/heggria/taskflow/blob/main/docs/internal/0.3.0-trusted-effects-mvp.md) and the [0.3-C plan](https://github.com/heggria/taskflow/blob/main/docs/internal/0.3-c-control-plane-plan.md) for the normative scope. diff --git a/website/content/docs/en/trusted-effects.mdx b/website/content/docs/en/trusted-effects.mdx index 1a8fddd8..86a33d96 100644 --- a/website/content/docs/en/trusted-effects.mdx +++ b/website/content/docs/en/trusted-effects.mdx @@ -2,7 +2,7 @@ title: Trusted Effects --- -> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete approved Control Plane, including WebUI, remains required for 1.0 acceptance; current private workspace packaging is not a waiver. +> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete Control Plane, including WebUI, is implemented and locally verified; the final commit must still pass remote release gates. Trusted Effects makes a phase's side effects explicit. The model may propose content, but for an admitted declared filesystem target, the **resources transaction is the only finalizer**. @@ -32,7 +32,7 @@ pnpm exec node --conditions=development --experimental-strip-types --test \ - Resolve-only hosts cannot prevent every write to an undeclared path. - SecretRef has no vault backend in this cut. - ServiceRef has no live network adapter in this cut. -- ControlHost has experimental Unix UDS and files-only store code. Admission, parked approvals/receipts, coordination and WebUI remain incomplete and are required for 1.0 acceptance. Windows pipes remain a separate unverified scope. +- ControlHost implements authenticated admission, durable approvals/Receipts, coordination and WebUI over Unix UDS and files-only stores. The existing public MCP package bundles the control binaries. Windows pipes remain non-GA. ## Read next diff --git a/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx b/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx index 9351f300..704bcca7 100644 --- a/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx +++ b/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx @@ -3,7 +3,7 @@ title: TypeScript DSL(`taskflow-dsl`) description: 编译期 .tf.ts 写法 —— rune erase 成 Taskflow JSON,再进 FlowIR。 --- -> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整已批准 Control Plane(包括 WebUI)仍是 1.0 必须完成的验收范围;当前 private workspace 状态不能豁免。 +> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整 Control Plane(包括 WebUI)已实现并通过本地验收;最终提交仍须通过远程发布门槛。 # TypeScript DSL(`taskflow-dsl`) diff --git a/website/content/docs/zh-cn/getting-started.mdx b/website/content/docs/zh-cn/getting-started.mdx index c7af7ff2..45e268f9 100644 --- a/website/content/docs/zh-cn/getting-started.mdx +++ b/website/content/docs/zh-cn/getting-started.mdx @@ -3,7 +3,7 @@ title: 开始使用 description: 五分钟内运行你的第一个 taskflow。 --- -> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整已批准 Control Plane(包括 WebUI)仍是 1.0 必须完成的验收范围;当前 private workspace 状态不能豁免。 +> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整 Control Plane(包括 WebUI)已实现并通过本地验收;最终提交仍须通过远程发布门槛。 taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不需要写一个一个调用子代理的脚本,只需声明节点和边——运行时会替你处理 fan-out、重试、缓存和续跑。 diff --git a/website/content/docs/zh-cn/guides/claude-code.mdx b/website/content/docs/zh-cn/guides/claude-code.mdx index 307a7f82..9403cdf1 100644 --- a/website/content/docs/zh-cn/guides/claude-code.mdx +++ b/website/content/docs/zh-cn/guides/claude-code.mdx @@ -3,7 +3,7 @@ title: 在 Claude Code 上使用 taskflow description: 将 taskflow 作为 Claude Code 插件安装,通过 MCP 编排多阶段工作流。 --- -> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整已批准 Control Plane(包括 WebUI)仍是 1.0 必须完成的验收范围;当前 private workspace 状态不能豁免。 +> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整 Control Plane(包括 WebUI)已实现并通过本地验收;最终提交仍须通过远程发布门槛。 [Claude Code](https://claude.com/product/claude-code) 是 taskflow 的天然宿主:它本身就以 subagent 的方式思考,而 taskflow 给这些 subagent 提供了一个声明式图来运行。插件注册 MCP server 与路由 skill;协议层不依赖 MCP SDK。 diff --git a/website/content/docs/zh-cn/guides/codex.mdx b/website/content/docs/zh-cn/guides/codex.mdx index a7baa601..df118d0b 100644 --- a/website/content/docs/zh-cn/guides/codex.mdx +++ b/website/content/docs/zh-cn/guides/codex.mdx @@ -3,7 +3,7 @@ title: 在 Codex 上使用 taskflow description: 把 taskflow 作为 Codex 插件安装,通过 MCP 编排多阶段工作流。 --- -> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整已批准 Control Plane(包括 WebUI)仍是 1.0 必须完成的验收范围;当前 private workspace 状态不能豁免。 +> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整 Control Plane(包括 WebUI)已实现并通过本地验收;最终提交仍须通过远程发布门槛。 [Codex](https://github.com/openai/codex) 是 taskflow 的天然归宿:它已经在用子代理的方式思考,而 taskflow 给那些子代理一个声明式图来在里面运行。在 Codex 上,taskflow 以插件形式发布,注册一个 MCP 服务器加一个路由 skill,于是模型一看到多步骤任务就能调用它。 diff --git a/website/content/docs/zh-cn/guides/grok-build.mdx b/website/content/docs/zh-cn/guides/grok-build.mdx index 17ce1fb7..3f0f65de 100644 --- a/website/content/docs/zh-cn/guides/grok-build.mdx +++ b/website/content/docs/zh-cn/guides/grok-build.mdx @@ -3,7 +3,7 @@ title: 在 Grok Build 上使用 taskflow description: 将 taskflow 作为 Grok Build 插件安装,通过 MCP 编排多阶段工作流。 --- -> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整已批准 Control Plane(包括 WebUI)仍是 1.0 必须完成的验收范围;当前 private workspace 状态不能豁免。 +> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整 Control Plane(包括 WebUI)已实现并通过本地验收;最终提交仍须通过远程发布门槛。 [Grok Build](https://docs.x.ai/build/overview) 是 xAI 的终端编码 agent。Taskflow 的正式发布路径是 MCP server;仓库 checkout 另带 plugin scaffold。 diff --git a/website/content/docs/zh-cn/guides/hermes.mdx b/website/content/docs/zh-cn/guides/hermes.mdx index 0c615666..398d0f5f 100644 --- a/website/content/docs/zh-cn/guides/hermes.mdx +++ b/website/content/docs/zh-cn/guides/hermes.mdx @@ -3,7 +3,7 @@ title: 在 Hermes Agent 上使用 taskflow description: 在 Hermes Agent 上通过 MCP 安装 taskflow,用隔离的 Hermes 子代理编排多阶段工作流。 --- -> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整已批准 Control Plane(包括 WebUI)仍是 1.0 必须完成的验收范围;当前 private workspace 状态不能豁免。 +> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整 Control Plane(包括 WebUI)已实现并通过本地验收;最终提交仍须通过远程发布门槛。 [Hermes Agent](https://hermes-agent.nousresearch.com/) 是 Nous Research 的开源 coding agent(CLI / TUI / 桌面 / 消息网关)。taskflow 的发布路径是 MCP server:Hermes 发现 `taskflow_*` 工具,每个 agent phase 会拉起隔离的 `hermes chat -q -Q` 子进程。 diff --git a/website/content/docs/zh-cn/guides/opencode.mdx b/website/content/docs/zh-cn/guides/opencode.mdx index 118789d3..765afc97 100644 --- a/website/content/docs/zh-cn/guides/opencode.mdx +++ b/website/content/docs/zh-cn/guides/opencode.mdx @@ -3,7 +3,7 @@ title: 在 OpenCode 上使用 taskflow description: 在 OpenCode 上将 taskflow 注册为 MCP 服务器,编排多阶段工作流。 --- -> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整已批准 Control Plane(包括 WebUI)仍是 1.0 必须完成的验收范围;当前 private workspace 状态不能豁免。 +> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整 Control Plane(包括 WebUI)已实现并通过本地验收;最终提交仍须通过远程发布门槛。 [OpenCode](https://opencode.ai) 通过协议层不依赖 MCP SDK 的 MCP server 暴露 taskflow;每个阶段作为隔离的 `opencode run` 进程运行。 diff --git a/website/content/docs/zh-cn/guides/pi.mdx b/website/content/docs/zh-cn/guides/pi.mdx index 0b18cb84..78862b50 100644 --- a/website/content/docs/zh-cn/guides/pi.mdx +++ b/website/content/docs/zh-cn/guides/pi.mdx @@ -3,7 +3,7 @@ title: 在 Pi 上使用 taskflow description: 在 Pi 编程智能体上安装、运行、保存和续跑 taskflow。 --- -> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整已批准 Control Plane(包括 WebUI)仍是 1.0 必须完成的验收范围;当前 private workspace 状态不能豁免。 +> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整 Control Plane(包括 WebUI)已实现并通过本地验收;最终提交仍须通过远程发布门槛。 如果你已经在用 [Pi](https://pi.dev) 把工作委托给子代理,taskflow 把这些一次性的委托变成有名字、可续跑、多步骤的工作流。你把工作描述成一个图——fan-out、gate、loop,你需要什么就写什么——只有最终结果回到你的上下文里。 diff --git a/website/content/docs/zh-cn/index.mdx b/website/content/docs/zh-cn/index.mdx index bed601aa..e902c462 100644 --- a/website/content/docs/zh-cn/index.mdx +++ b/website/content/docs/zh-cn/index.mdx @@ -3,7 +3,7 @@ title: taskflow 1.0 文档 description: "面向 coding-agent 工作流的 Trusted Effects:声明 effect,验证类型化路径,让已准入的文件修改经过唯一 resource authority。" --- -> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整已批准 Control Plane(包括 WebUI)仍是 1.0 必须完成的验收范围;当前 private workspace 状态不能豁免。 +> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整 Control Plane(包括 WebUI)已实现并通过本地验收;最终提交仍须通过远程发布门槛。 taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图变成可验证的执行合同,让阶段隔离运行,并把中间 transcript 留在宿主对话之外。1.0 合同包含 **Trusted Effects**:为已准入的文件 effect 提供类型化声明与受 resources 控制的提交路径。 @@ -96,7 +96,7 @@ taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图 已实现基线与 1.0 待完成范围分别验收: - **Trusted Effects MVP:** 已实现的基础能力,单独完成它不足以满足 1.0 范围。覆盖 EffectIR、类型化 ref、labels、受 resources 控制的文件 transaction、重叠准入、诚实的宿主基线与 ledger-backed `why-*` explainers。 -- **实验性 Control Plane:** private workspace Unix UDS/store 代码与 proposed wire 合同;admission、parked 审批、receipt、全局协调与 WebUI 尚未完成,是 1.0 必须完成的验收范围。 +- **Control Plane:** 已实现经认证的多项目准入、持久审批/重放/Receipt、全局协调与 WebUI;Control 命令随公共 `taskflow-mcp-core` 交付。支持 Unix UDS,Windows pipes 仍非 GA。 规范范围请读仓库中的 [MVP 冻结定义](https://github.com/heggria/taskflow/blob/main/docs/internal/0.3.0-trusted-effects-mvp.md) 与 [0.3-C 计划](https://github.com/heggria/taskflow/blob/main/docs/internal/0.3-c-control-plane-plan.md)。 diff --git a/website/content/docs/zh-cn/trusted-effects.mdx b/website/content/docs/zh-cn/trusted-effects.mdx index e67b05fd..1106b7d9 100644 --- a/website/content/docs/zh-cn/trusted-effects.mdx +++ b/website/content/docs/zh-cn/trusted-effects.mdx @@ -2,7 +2,7 @@ title: Trusted Effects --- -> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整已批准 Control Plane(包括 WebUI)仍是 1.0 必须完成的验收范围;当前 private workspace 状态不能豁免。 +> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整 Control Plane(包括 WebUI)已实现并通过本地验收;最终提交仍须通过远程发布门槛。 Trusted Effects 把阶段的副作用写进合同。模型可以提出内容,但对于已准入的已声明文件目标,**resources transaction 是唯一最终提交者**。 From 086185ba9e684b2652a00eca833ca943336334df Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 8 Oct 2026 11:59:30 +0800 Subject: [PATCH 30/30] fix(release): close audit findings and prepare dated 1.0 notes --- CHANGELOG.md | 2 +- README.md | 6 +- README.zh-CN.md | 6 +- RELEASE.md | 30 +- docs/internal/1.0.0-ga-scoreboard.md | 36 +- .../taskflow-control/test/singleton.test.ts | 3 +- pnpm-lock.yaml | 355 +++++++++--------- pnpm-workspace.yaml | 3 +- .../en/compiler-runtime/typescript-dsl.mdx | 2 +- website/content/docs/en/getting-started.mdx | 2 +- .../content/docs/en/guides/claude-code.mdx | 2 +- website/content/docs/en/guides/codex.mdx | 2 +- website/content/docs/en/guides/grok-build.mdx | 2 +- website/content/docs/en/guides/hermes.mdx | 2 +- website/content/docs/en/guides/opencode.mdx | 2 +- website/content/docs/en/guides/pi.mdx | 2 +- website/content/docs/en/index.mdx | 4 +- website/content/docs/en/trusted-effects.mdx | 2 +- .../zh-cn/compiler-runtime/typescript-dsl.mdx | 2 +- .../content/docs/zh-cn/getting-started.mdx | 2 +- .../content/docs/zh-cn/guides/claude-code.mdx | 2 +- website/content/docs/zh-cn/guides/codex.mdx | 2 +- .../content/docs/zh-cn/guides/grok-build.mdx | 2 +- website/content/docs/zh-cn/guides/hermes.mdx | 2 +- .../content/docs/zh-cn/guides/opencode.mdx | 2 +- website/content/docs/zh-cn/guides/pi.mdx | 2 +- website/content/docs/zh-cn/index.mdx | 4 +- .../content/docs/zh-cn/trusted-effects.mdx | 4 +- website/package.json | 2 +- 29 files changed, 261 insertions(+), 228 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3c8941d..f622fcf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to taskflow are documented here. This project follows [Keep - HOL plugin-scanner highs: dummy test credentials use scanner placeholders / short literals; no `eval (` in comments; smoke/runner no longer place a `${…}` template within 30 chars of `spawn`. Local scan 81/100, 0 high. **Not GA.** -## [1.0.0] — Unreleased +## [1.0.0] — 2026-10-08 ### Added diff --git a/README.md b/README.md index e6c07ddd..22356bcb 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ **taskflow is a declarative runtime for coding-agent workflows.** It turns a graph into a verifiable execution contract, runs phases in isolation, and keeps intermediate work out of the host conversation. In Taskflow 1.0, the contract also describes the effects a phase is allowed to propose. -> **Status: 1.0.0 development candidate — unreleased.** Latest published prerelease: `v0.3.0-beta.1.2`. Draft PR [#142](https://github.com/heggria/taskflow/pull/142) targets `main`; its head `rc/0.3.0-beta.2` retains the historical branch name, while 1.0.0 supersedes the beta.2 release target. The complete approved Control Plane, including WebUI, remains required before 1.0 publication; its current private workspace status is not a waiver. See the [release plan](./docs/internal/1.0.0-release-plan.md) and [acceptance scoreboard](./docs/internal/1.0.0-ga-scoreboard.md). +> **Taskflow 1.0.0** includes the DAG runtime, trusted filesystem effects and complete Control Plane (CLI/MCP/WebUI). [GitHub Releases](https://github.com/heggria/taskflow/releases) and npm establish publication; see the [acceptance scoreboard](./docs/internal/1.0.0-ga-scoreboard.md) for verified scope and limits. ## The declared-effect contract @@ -67,13 +67,13 @@ Use Node.js **≥ 22.19.0**. To run from the 1.0 source checkout: ```bash git clone https://github.com/heggria/taskflow.git cd taskflow -git checkout --track origin/rc/0.3.0-beta.2 +git checkout v1.0.0 pnpm install pnpm run typecheck pnpm test ``` -**The following installation commands apply only after 1.0.0 is published; use the candidate source checkout above today.** +Install the matching 1.0.0 package set after confirming the release is available: ```bash npm install --global pi-taskflow@1.0.0 diff --git a/README.zh-CN.md b/README.zh-CN.md index e6b6d8d5..2a1db508 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -21,7 +21,7 @@ **taskflow 是面向 coding-agent 工作流的声明式运行时。** 它把任务图变成可验证的执行合同,让阶段隔离运行,并把中间过程留在宿主对话之外。在 Taskflow 1.0 中,这份合同还可以描述每个阶段被允许提出的副作用。 -> **状态:1.0.0 开发候选,尚未发布。** 最新已发布 prerelease 是 `v0.3.0-beta.1.2`。Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 目标为 `main`;head `rc/0.3.0-beta.2` 沿用历史分支名,当前发布目标已由 beta.2 改为 1.0.0。完整已批准 Control Plane(包括 WebUI)仍是 1.0 发布前必须完成并验收的范围;当前 private workspace 状态不能豁免这些要求。[发布计划](./docs/internal/1.0.0-release-plan.md)和[验收账本](./docs/internal/1.0.0-ga-scoreboard.md)记录范围与阻塞项。 +> **Taskflow 1.0.0**:DAG 运行时、可信文件效果及完整 Control Plane(CLI/MCP/WebUI)。公开版本与发布时间以 [GitHub Releases](https://github.com/heggria/taskflow/releases) 和 npm 为准;支持范围与验证记录见[验收账本](./docs/internal/1.0.0-ga-scoreboard.md)。 ## 声明文件副作用合同 @@ -67,13 +67,13 @@ flow / .tf.ts ```bash git clone https://github.com/heggria/taskflow.git cd taskflow -git checkout --track origin/rc/0.3.0-beta.2 +git checkout v1.0.0 pnpm install pnpm run typecheck pnpm test ``` -**以下命令仅在 1.0.0 正式发布后适用;当前请使用上面的候选源码 checkout。** +确认正式版本已在 registry 可用后,安装一致的 1.0.0 包版本: ```bash npm install --global pi-taskflow@1.0.0 diff --git a/RELEASE.md b/RELEASE.md index 48fbc1bc..5b9ef875 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -7,15 +7,12 @@ tag. GitHub Releases and the npm registry record the completed publication. ## Current preparation status -**Unreleased 1.0.0; Draft PR #142 targets `main` from the retained historical -branch `rc/0.3.0-beta.2`.** The branch name is not a beta.2 release target. -Latest published prerelease remains `v0.3.0-beta.1.2`. See the -[track reconciliation](./docs/internal/1.0.0-release-plan.md#release-track-reconciliation-2026-10-07). -The complete approved Control Plane, including its WebUI, must be implemented -and accepted before 1.0 can ship; the current private package is not a waiver. -The owner authorized complete closure through publication on 2026-10-08. -The earlier metadata-only/local-only restrictions are historical; all final -release gates below remain required. +**1.0.0 release transaction.** PR #142 carries the implementation; its retained +`rc/0.3.0-beta.2` branch name is historical. The owner authorized complete +closure through publication on 2026-10-08. A prepared version or dated changelog +does not establish publication: consult the [tag workflow](https://github.com/heggria/taskflow/actions/workflows/publish.yml), +[GitHub Releases](https://github.com/heggria/taskflow/releases) and npm metadata. +All exact-commit and artifact verification gates below remain required. ## Public contract and package set @@ -23,7 +20,8 @@ The stable contract is the existing declarative DAG runtime: twelve phase kinds, validation/planning, retries and budgets where the host can enforce them, Pi interactive approvals, resume/recompute/replay, background runs, trace, the TypeScript DSL, and resource transactions for admitted declared -filesystem targets. MCP/headless approval phases reject automatically. Host +filesystem targets. Ordinary host MCP/headless approval phases reject automatically; the separate +authenticated Control MCP route supports durable approvals. Host permissions and usage reporting remain host-specific; Grok rejects budgets because its stream does not supply reliable usage. @@ -164,11 +162,13 @@ coverage, plus CodeQL and all required exact-SHA CI jobs. ## Authorized tag and publish -After review, all exact-SHA checks and the required live/TUI evidence pass, -merge to `main`, rerun required checks there, and replace the `1.0.0` changelog -heading's `Unreleased` with the actual release date. Commit that change and -verify `node scripts/verify-release-contract.mjs --published`. The release -owner may then push annotated `v1.0.0` from that verified main commit. +After review and the required live/TUI evidence, replace the `1.0.0` changelog +heading's `Unreleased` with the actual release date in a pull request. Validate +`node scripts/verify-release-contract.mjs --published` and complete every +required check on that final PR head before squash-merging to `main`. This +respects the active PR-only, linear-history ruleset; do not push a direct dating +commit to protected main. Rerun the required checks on the resulting main SHA, +verify its release contract, then push annotated `v1.0.0` at that exact commit. `.github/workflows/publish.yml` alone publishes. It verifies tag/main ancestry, the dated changelog, versions/pins, immutable reproducible packed consumers, diff --git a/docs/internal/1.0.0-ga-scoreboard.md b/docs/internal/1.0.0-ga-scoreboard.md index 165b91fc..1014d6c4 100644 --- a/docs/internal/1.0.0-ga-scoreboard.md +++ b/docs/internal/1.0.0-ga-scoreboard.md @@ -1,14 +1,14 @@ # Taskflow 1.0.0 acceptance scoreboard -**Unreleased 1.0.0 / PR #142. Updated 2026-10-08.** The owner now explicitly +**1.0.0 release transaction / PR #142. Updated 2026-10-08.** The owner now explicitly authorizes complete closure through publication. This supersedes the earlier local-only instruction; final-SHA CI/security, verified main/tag, provenance, registry bytes and installed acceptance remain mandatory. One release executor owns all remote writes; the original dirty implementation checkout is preserved. -## Last fixed remote baseline +## Prior fixed remote baseline -Commit `ca247acd806dd0a37268eea8bf7078cf28f643cd` is the last pushed candidate. +Commit `ca247acd806dd0a37268eea8bf7078cf28f643cd` is the prior accepted remote candidate. Its [CI](https://github.com/heggria/taskflow/actions/runs/37719287182) passed all 12 jobs, including Node 22/24 (2,941 source tests each), typecheck, build, three-OS process supervision, Pi peers, packed consumers, MCP E2E, website and @@ -44,6 +44,36 @@ implemented admission, replay, approvals, Receipt, registry or WebUI. The | New-head remote CI/security | AUTHORIZED / PENDING. Previous ca247ac or #181 greens cannot certify the new final revision | | Published registry artifacts/GitHub Release | AUTHORIZED / PENDING. Requires verified final main/tag, exact registry hashes, dist-tags and installed verification by the single release executor | +## Final release follow-up + +The 2026-10-08 production audit found eight website dependency advisories. +Next 16.3.8, Sharp 0.35.5 and source-map-js 1.2.2 close them: isolated frozen +install, production export and production audit (zero advisories) pass. All +thirteen non-website lock importers, release package versions and Pi pins are +unchanged; supply-chain protections and release-age policy remain enabled. + +The first follow-up Plugin Security Scan identified a synthetic process birth +token literal in `singleton.test.ts`, not a live credential. It now uses a +runtime UUID, preserving assertions; all twelve singleton cases pass on each +Node runtime. Scanner pins, thresholds and Cisco coverage remain unchanged. +The failed scan is retained as historical evidence; only final-head results +can satisfy the release gate. + +Current installed runtime evidence (Node 24.21.0, implementation `472bb98`): +Pi SDK 1.0.4 passed nine actual PTY/TUI cases (approve/reject/edit, regular mode, +Escape/Ctrl-C, timeout, confirmation and a 300-line proposal). Codex CLI 0.160.0 +passed live three-phase dataflow and declared filesystem-effect execution; +Claude Code 2.1.293 passed live two-phase execution. These use native existing +authentication and isolated project/session state, with no credential copies. + +Native Pi live-provider access is **BLOCKED**: the configured channel returned +HTTP 403 restricting requests to official Claude Code. No bypass was attempted. +OpenCode, Grok and Hermes live binaries were unavailable. Their protocol/fixture +checks remain separate; no current live-provider pass is claimed for these +four hosts. Pi CLI/TUI fixture evidence is actual process/protocol coverage, +not native model authentication. These environment coverage gaps must remain +visible in the release record. + ## Explicit boundaries Unix UDS is supported; Windows named pipes remain non-GA under P13. The CLI diff --git a/packages/taskflow-control/test/singleton.test.ts b/packages/taskflow-control/test/singleton.test.ts index eaddaa0d..3b396d5f 100644 --- a/packages/taskflow-control/test/singleton.test.ts +++ b/packages/taskflow-control/test/singleton.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; import { after, test } from "node:test"; import * as fs from "node:fs"; import * as os from "node:os"; @@ -246,7 +247,7 @@ test("singleton: reclaim bumps the fencing epoch so the old holder is fenced out test("singleton: crash reclaim followed by graceful restarts never resets the fencing generation", () => { const paths = makePaths(); - const first = acquireUserSingleton({ paths, holderId: "dead", processIdentity: { pid: DEAD_PID, birthToken: "dead-generation", birthTokenKind: "native" } }); + const first = acquireUserSingleton({ paths, holderId: "dead", processIdentity: { pid: DEAD_PID, birthToken: randomUUID(), birthTokenKind: "native" } }); assert.equal(first.status, "won"); const reclaimed = acquireUserSingleton({ paths, holderId: "recovered", inspectProcess: deadOwnerInspector() }); assert.equal(reclaimed.status, "won"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd1496f4..e4f08f85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,7 +9,8 @@ overrides: brace-expansion: 5.0.12 nanoid: 3.3.18 postcss: 8.5.23 - sharp: 0.35.4 + sharp: 0.35.5 + source-map-js: 1.2.2 undici: 8.10.2 importers: @@ -192,19 +193,19 @@ importers: dependencies: fumadocs-core: specifier: 16.15.1 - version: 16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4) + version: 16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.8(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4) fumadocs-mdx: specifier: 15.3.1 - version: 15.3.1(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + version: 15.3.1(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.8(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4))(next@16.3.8(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) fumadocs-ui: specifier: 16.15.1 - version: 16.15.1(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) + version: 16.15.1(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.8(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4))(next@16.3.8(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) lucide-react: specifier: 1.33.0 version: 1.33.0(react@19.2.8) next: - specifier: 16.3.6 - version: 16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: 16.3.8 + version: 16.3.8(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: specifier: 19.2.8 version: 19.2.8 @@ -638,160 +639,160 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.4': - resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.4': - resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.4': - resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.3': - resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.3': - resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.3': - resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.3': - resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.3': - resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.3': - resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.3': - resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.3': - resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': - resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.3': - resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.4': - resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.4': - resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.4': - resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.4': - resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.4': - resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.4': - resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.4': - resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.4': - resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.4': - resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.4': - resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.4': - resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.4': - resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.4': - resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -818,57 +819,57 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} - '@next/env@16.3.6': - resolution: {integrity: sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==} + '@next/env@16.3.8': + resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==} - '@next/swc-darwin-arm64@16.3.6': - resolution: {integrity: sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw==} + '@next/swc-darwin-arm64@16.3.8': + resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.6': - resolution: {integrity: sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew==} + '@next/swc-darwin-x64@16.3.8': + resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.6': - resolution: {integrity: sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q==} + '@next/swc-linux-arm64-gnu@16.3.8': + resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.6': - resolution: {integrity: sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA==} + '@next/swc-linux-arm64-musl@16.3.8': + resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.3.6': - resolution: {integrity: sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw==} + '@next/swc-linux-x64-gnu@16.3.8': + resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.3.6': - resolution: {integrity: sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ==} + '@next/swc-linux-x64-musl@16.3.8': + resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.6': - resolution: {integrity: sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw==} + '@next/swc-win32-arm64-msvc@16.3.8': + resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.6': - resolution: {integrity: sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg==} + '@next/swc-win32-x64-msvc@16.3.8': + resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2421,8 +2422,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.3.6: - resolution: {integrity: sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw==} + next@16.3.8: + resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -2647,8 +2648,8 @@ packages: engines: {node: '>=10'} hasBin: true - sharp@0.35.4: - resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -2671,8 +2672,8 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} source-map@0.7.6: @@ -3315,108 +3316,108 @@ snapshots: '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.35.4': + '@img/sharp-darwin-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-arm64': 1.3.4 optional: true - '@img/sharp-darwin-x64@0.35.4': + '@img/sharp-darwin-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.4 optional: true - '@img/sharp-freebsd-wasm32@0.35.4': + '@img/sharp-freebsd-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.3': + '@img/sharp-libvips-darwin-arm64@1.3.4': optional: true - '@img/sharp-libvips-darwin-x64@1.3.3': + '@img/sharp-libvips-darwin-x64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm64@1.3.3': + '@img/sharp-libvips-linux-arm64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm@1.3.3': + '@img/sharp-libvips-linux-arm@1.3.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.3': + '@img/sharp-libvips-linux-ppc64@1.3.4': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.3': + '@img/sharp-libvips-linux-riscv64@1.3.4': optional: true - '@img/sharp-libvips-linux-s390x@1.3.3': + '@img/sharp-libvips-linux-s390x@1.3.4': optional: true - '@img/sharp-libvips-linux-x64@1.3.3': + '@img/sharp-libvips-linux-x64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.3': + '@img/sharp-libvips-linuxmusl-x64@1.3.4': optional: true - '@img/sharp-linux-arm64@0.35.4': + '@img/sharp-linux-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.4 optional: true - '@img/sharp-linux-arm@0.35.4': + '@img/sharp-linux-arm@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.4 optional: true - '@img/sharp-linux-ppc64@0.35.4': + '@img/sharp-linux-ppc64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.4 optional: true - '@img/sharp-linux-riscv64@0.35.4': + '@img/sharp-linux-riscv64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.4 optional: true - '@img/sharp-linux-s390x@0.35.4': + '@img/sharp-linux-s390x@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.4 optional: true - '@img/sharp-linux-x64@0.35.4': + '@img/sharp-linux-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.4 optional: true - '@img/sharp-linuxmusl-arm64@0.35.4': + '@img/sharp-linuxmusl-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 optional: true - '@img/sharp-linuxmusl-x64@0.35.4': + '@img/sharp-linuxmusl-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 optional: true - '@img/sharp-wasm32@0.35.4': + '@img/sharp-wasm32@0.35.5': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.4': + '@img/sharp-webcontainers-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-win32-arm64@0.35.4': + '@img/sharp-win32-arm64@0.35.5': optional: true - '@img/sharp-win32-ia32@0.35.4': + '@img/sharp-win32-ia32@0.35.5': optional: true - '@img/sharp-win32-x64@0.35.4': + '@img/sharp-win32-x64@0.35.5': optional: true '@jridgewell/gen-mapping@0.3.13': @@ -3470,30 +3471,30 @@ snapshots: transitivePeerDependencies: - supports-color - '@next/env@16.3.6': {} + '@next/env@16.3.8': {} - '@next/swc-darwin-arm64@16.3.6': + '@next/swc-darwin-arm64@16.3.8': optional: true - '@next/swc-darwin-x64@16.3.6': + '@next/swc-darwin-x64@16.3.8': optional: true - '@next/swc-linux-arm64-gnu@16.3.6': + '@next/swc-linux-arm64-gnu@16.3.8': optional: true - '@next/swc-linux-arm64-musl@16.3.6': + '@next/swc-linux-arm64-musl@16.3.8': optional: true - '@next/swc-linux-x64-gnu@16.3.6': + '@next/swc-linux-x64-gnu@16.3.8': optional: true - '@next/swc-linux-x64-musl@16.3.6': + '@next/swc-linux-x64-musl@16.3.8': optional: true - '@next/swc-win32-arm64-msvc@16.3.6': + '@next/swc-win32-arm64-msvc@16.3.8': optional: true - '@next/swc-win32-x64-msvc@16.3.6': + '@next/swc-win32-x64-msvc@16.3.8': optional: true '@opentelemetry/api@1.9.0': @@ -3960,7 +3961,7 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 - source-map-js: 1.2.1 + source-map-js: 1.2.2 tailwindcss: 4.3.3 '@tailwindcss/oxide-android-arm64@4.3.3': @@ -4379,7 +4380,7 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4): + fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.8(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4): dependencies: '@fumari/image-size': 0.1.1 estree-util-value-to-estree: 3.5.0 @@ -4407,21 +4408,21 @@ snapshots: '@types/mdast': 4.0.4 '@types/react': 19.2.18 lucide-react: 1.33.0(react@19.2.8) - next: 16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.8(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) zod: 4.6.4 transitivePeerDependencies: - supports-color - fumadocs-mdx@15.3.1(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): + fumadocs-mdx@15.3.1(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.8(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4))(next@16.3.8(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.2 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4) + fumadocs-core: 16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.8(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4) github-slugger: 2.0.0 magic-string: 1.4.2 mdast-util-mdx: 3.0.0 @@ -4440,12 +4441,12 @@ snapshots: '@types/mdast': 4.0.4 '@types/mdx': 2.0.14 '@types/react': 19.2.18 - next: 16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.8(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 transitivePeerDependencies: - supports-color - fumadocs-ui@16.15.1(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): + fumadocs-ui@16.15.1(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.8(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4))(next@16.3.8(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): dependencies: '@fuma-translate/react': 1.0.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fumadocs/tailwind': 0.1.1(tailwindcss@4.3.3) @@ -4461,7 +4462,7 @@ snapshots: '@radix-ui/react-tabs': 1.1.21(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) class-variance-authority: 0.7.1 cnfast: 0.1.0 - fumadocs-core: 16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4) + fumadocs-core: 16.15.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.33.0(react@19.2.8))(next@16.3.8(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.4) lucide-react: 1.33.0(react@19.2.8) motion: 13.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-themes: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -4475,7 +4476,7 @@ snapshots: optionalDependencies: '@types/mdx': 2.0.14 '@types/react': 19.2.18 - next: 16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.8(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) transitivePeerDependencies: - '@types/react-dom' - tailwindcss @@ -5230,9 +5231,9 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - next@16.3.6(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.3.8(@opentelemetry/api@1.9.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@next/env': 16.3.6 + '@next/env': 16.3.8 '@swc/helpers': 0.5.23 baseline-browser-mapping: 2.11.0 caniuse-lite: 1.0.30001802 @@ -5241,16 +5242,16 @@ snapshots: react-dom: 19.2.8(react@19.2.8) styled-jsx: 5.1.6(react@19.2.8) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.6 - '@next/swc-darwin-x64': 16.3.6 - '@next/swc-linux-arm64-gnu': 16.3.6 - '@next/swc-linux-arm64-musl': 16.3.6 - '@next/swc-linux-x64-gnu': 16.3.6 - '@next/swc-linux-x64-musl': 16.3.6 - '@next/swc-win32-arm64-msvc': 16.3.6 - '@next/swc-win32-x64-msvc': 16.3.6 + '@next/swc-darwin-arm64': 16.3.8 + '@next/swc-darwin-x64': 16.3.8 + '@next/swc-linux-arm64-gnu': 16.3.8 + '@next/swc-linux-arm64-musl': 16.3.8 + '@next/swc-linux-x64-gnu': 16.3.8 + '@next/swc-linux-x64-musl': 16.3.8 + '@next/swc-win32-arm64-msvc': 16.3.8 + '@next/swc-win32-x64-msvc': 16.3.8 '@opentelemetry/api': 1.9.0 - sharp: 0.35.4(@types/node@26.5.1) + sharp: 0.35.5(@types/node@26.5.1) transitivePeerDependencies: - '@babel/core' - '@types/node' @@ -5313,7 +5314,7 @@ snapshots: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 proper-lockfile@4.1.2: dependencies: @@ -5494,37 +5495,37 @@ snapshots: semver@7.8.5: {} - sharp@0.35.4(@types/node@26.5.1): + sharp@0.35.5(@types/node@26.5.1): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.4 - '@img/sharp-darwin-x64': 0.35.4 - '@img/sharp-freebsd-wasm32': 0.35.4 - '@img/sharp-libvips-darwin-arm64': 1.3.3 - '@img/sharp-libvips-darwin-x64': 1.3.3 - '@img/sharp-libvips-linux-arm': 1.3.3 - '@img/sharp-libvips-linux-arm64': 1.3.3 - '@img/sharp-libvips-linux-ppc64': 1.3.3 - '@img/sharp-libvips-linux-riscv64': 1.3.3 - '@img/sharp-libvips-linux-s390x': 1.3.3 - '@img/sharp-libvips-linux-x64': 1.3.3 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 - '@img/sharp-linux-arm': 0.35.4 - '@img/sharp-linux-arm64': 0.35.4 - '@img/sharp-linux-ppc64': 0.35.4 - '@img/sharp-linux-riscv64': 0.35.4 - '@img/sharp-linux-s390x': 0.35.4 - '@img/sharp-linux-x64': 0.35.4 - '@img/sharp-linuxmusl-arm64': 0.35.4 - '@img/sharp-linuxmusl-x64': 0.35.4 - '@img/sharp-webcontainers-wasm32': 0.35.4 - '@img/sharp-win32-arm64': 0.35.4 - '@img/sharp-win32-ia32': 0.35.4 - '@img/sharp-win32-x64': 0.35.4 + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 '@types/node': 26.5.1 optional: true @@ -5547,7 +5548,7 @@ snapshots: signal-exit@3.0.7: {} - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} source-map@0.7.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 937a2f47..edf57832 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,7 +22,8 @@ overrides: # Dependabot alert #33 / GHSA-2v37-7h3g-55p8: nanoid < 3.3.18 nanoid: 3.3.18 postcss: 8.5.23 - sharp: 0.35.4 + sharp: 0.35.5 + source-map-js: 1.2.2 # September 2026 undici advisories (TLS, WebSocket, retry, cache): fixed in 8.10.2. undici: 8.10.2 allowBuilds: diff --git a/website/content/docs/en/compiler-runtime/typescript-dsl.mdx b/website/content/docs/en/compiler-runtime/typescript-dsl.mdx index 03162dab..7c7cfac8 100644 --- a/website/content/docs/en/compiler-runtime/typescript-dsl.mdx +++ b/website/content/docs/en/compiler-runtime/typescript-dsl.mdx @@ -3,7 +3,7 @@ title: TypeScript DSL (`taskflow-dsl`) description: Compile-time .tf.ts authoring — erase runes to Taskflow JSON, then FlowIR. --- -> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete Control Plane, including WebUI, is implemented and locally verified; the final commit must still pass remote release gates. +> **Taskflow 1.0.0 documentation.** These instructions use exact 1.0.0 package pins. Check [GitHub Releases](https://github.com/heggria/taskflow/releases) for publication and [the acceptance scoreboard](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-ga-scoreboard.md) for tested scope and limitations. The Control Plane and WebUI are implemented; control binaries are bundled in `taskflow-mcp-core`. # TypeScript DSL (`taskflow-dsl`) diff --git a/website/content/docs/en/getting-started.mdx b/website/content/docs/en/getting-started.mdx index feba199d..14386c9c 100644 --- a/website/content/docs/en/getting-started.mdx +++ b/website/content/docs/en/getting-started.mdx @@ -3,7 +3,7 @@ title: Getting Started description: Run your first taskflow in under five minutes. --- -> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete Control Plane, including WebUI, is implemented and locally verified; the final commit must still pass remote release gates. +> **Taskflow 1.0.0 documentation.** These instructions use exact 1.0.0 package pins. Check [GitHub Releases](https://github.com/heggria/taskflow/releases) for publication and [the acceptance scoreboard](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-ga-scoreboard.md) for tested scope and limitations. The Control Plane and WebUI are implemented; control binaries are bundled in `taskflow-mcp-core`. taskflow lets you describe multi-step agent work as a declarative graph. Instead of writing a script that calls subagents one by one, you declare the nodes and edges — and the runtime handles fan-out, retries, caching, and resume. diff --git a/website/content/docs/en/guides/claude-code.mdx b/website/content/docs/en/guides/claude-code.mdx index 222560ff..c1c3c836 100644 --- a/website/content/docs/en/guides/claude-code.mdx +++ b/website/content/docs/en/guides/claude-code.mdx @@ -3,7 +3,7 @@ title: Using taskflow on Claude Code description: Install taskflow as a Claude Code plugin and orchestrate multi-phase workflows via MCP. --- -> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete Control Plane, including WebUI, is implemented and locally verified; the final commit must still pass remote release gates. +> **Taskflow 1.0.0 documentation.** These instructions use exact 1.0.0 package pins. Check [GitHub Releases](https://github.com/heggria/taskflow/releases) for publication and [the acceptance scoreboard](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-ga-scoreboard.md) for tested scope and limitations. The Control Plane and WebUI are implemented; control binaries are bundled in `taskflow-mcp-core`. [Claude Code](https://claude.com/product/claude-code) is a natural home for taskflow: it already thinks in terms of subagents, and taskflow gives those subagents a declarative graph to run inside. The plugin registers the MCP server plus a routing skill; its protocol layer needs no MCP SDK. diff --git a/website/content/docs/en/guides/codex.mdx b/website/content/docs/en/guides/codex.mdx index 1e8e81b8..38233cae 100644 --- a/website/content/docs/en/guides/codex.mdx +++ b/website/content/docs/en/guides/codex.mdx @@ -3,7 +3,7 @@ title: Using taskflow on Codex description: Install taskflow as a Codex plugin and orchestrate multi-phase workflows via MCP. --- -> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete Control Plane, including WebUI, is implemented and locally verified; the final commit must still pass remote release gates. +> **Taskflow 1.0.0 documentation.** These instructions use exact 1.0.0 package pins. Check [GitHub Releases](https://github.com/heggria/taskflow/releases) for publication and [the acceptance scoreboard](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-ga-scoreboard.md) for tested scope and limitations. The Control Plane and WebUI are implemented; control binaries are bundled in `taskflow-mcp-core`. [Codex](https://github.com/openai/codex) is a natural home for taskflow: it already thinks in terms of subagents, and taskflow gives those subagents a declarative graph to run inside. On Codex, taskflow ships as a plugin that registers an MCP server plus a routing skill, so the model can call it the moment it sees a multi-step task. diff --git a/website/content/docs/en/guides/grok-build.mdx b/website/content/docs/en/guides/grok-build.mdx index a5fc8720..4c4923f9 100644 --- a/website/content/docs/en/guides/grok-build.mdx +++ b/website/content/docs/en/guides/grok-build.mdx @@ -3,7 +3,7 @@ title: Using taskflow on Grok Build description: Install taskflow as a Grok Build plugin and orchestrate multi-phase workflows via MCP. --- -> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete Control Plane, including WebUI, is implemented and locally verified; the final commit must still pass remote release gates. +> **Taskflow 1.0.0 documentation.** These instructions use exact 1.0.0 package pins. Check [GitHub Releases](https://github.com/heggria/taskflow/releases) for publication and [the acceptance scoreboard](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-ga-scoreboard.md) for tested scope and limitations. The Control Plane and WebUI are implemented; control binaries are bundled in `taskflow-mcp-core`. [Grok Build](https://docs.x.ai/build/overview) is xAI's terminal coding agent. Taskflow's published path is its MCP server; a plugin scaffold is also available in the repository checkout. diff --git a/website/content/docs/en/guides/hermes.mdx b/website/content/docs/en/guides/hermes.mdx index 27936960..0d3ff85f 100644 --- a/website/content/docs/en/guides/hermes.mdx +++ b/website/content/docs/en/guides/hermes.mdx @@ -3,7 +3,7 @@ title: Using taskflow on Hermes Agent description: Install taskflow as an MCP server on Hermes Agent and orchestrate multi-phase workflows with isolated Hermes subagents. --- -> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete Control Plane, including WebUI, is implemented and locally verified; the final commit must still pass remote release gates. +> **Taskflow 1.0.0 documentation.** These instructions use exact 1.0.0 package pins. Check [GitHub Releases](https://github.com/heggria/taskflow/releases) for publication and [the acceptance scoreboard](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-ga-scoreboard.md) for tested scope and limitations. The Control Plane and WebUI are implemented; control binaries are bundled in `taskflow-mcp-core`. [Hermes Agent](https://hermes-agent.nousresearch.com/) is Nous Research's open-source coding agent (CLI, TUI, desktop, messaging gateways). Taskflow's published path is its MCP server: Hermes discovers `taskflow_*` tools and each agent phase spawns an isolated `hermes chat -q -Q` child. diff --git a/website/content/docs/en/guides/opencode.mdx b/website/content/docs/en/guides/opencode.mdx index a9c7a38e..ecba18b6 100644 --- a/website/content/docs/en/guides/opencode.mdx +++ b/website/content/docs/en/guides/opencode.mdx @@ -3,7 +3,7 @@ title: Using taskflow on OpenCode description: Register taskflow as an MCP server on OpenCode and orchestrate multi-phase workflows. --- -> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete Control Plane, including WebUI, is implemented and locally verified; the final commit must still pass remote release gates. +> **Taskflow 1.0.0 documentation.** These instructions use exact 1.0.0 package pins. Check [GitHub Releases](https://github.com/heggria/taskflow/releases) for publication and [the acceptance scoreboard](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-ga-scoreboard.md) for tested scope and limitations. The Control Plane and WebUI are implemented; control binaries are bundled in `taskflow-mcp-core`. [OpenCode](https://opencode.ai) exposes taskflow through an MCP server whose protocol layer has no MCP SDK dependency; each phase runs as an isolated `opencode run` process. diff --git a/website/content/docs/en/guides/pi.mdx b/website/content/docs/en/guides/pi.mdx index 27b197bf..28518472 100644 --- a/website/content/docs/en/guides/pi.mdx +++ b/website/content/docs/en/guides/pi.mdx @@ -3,7 +3,7 @@ title: Using taskflow on Pi description: Install, run, save, and resume taskflows on the Pi coding agent. --- -> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete Control Plane, including WebUI, is implemented and locally verified; the final commit must still pass remote release gates. +> **Taskflow 1.0.0 documentation.** These instructions use exact 1.0.0 package pins. Check [GitHub Releases](https://github.com/heggria/taskflow/releases) for publication and [the acceptance scoreboard](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-ga-scoreboard.md) for tested scope and limitations. The Control Plane and WebUI are implemented; control binaries are bundled in `taskflow-mcp-core`. If you already use [Pi](https://pi.dev) to delegate work to subagents, taskflow turns those one-off delegations into named, resumable, multi-step workflows. You describe the work as a graph — fan-out, gates, loops, whatever you need — and only the final result comes back into your context. diff --git a/website/content/docs/en/index.mdx b/website/content/docs/en/index.mdx index 3dae37d8..0ef6d8c9 100644 --- a/website/content/docs/en/index.mdx +++ b/website/content/docs/en/index.mdx @@ -3,7 +3,7 @@ title: taskflow 1.0 Documentation description: "Trusted Effects for coding-agent workflows: declare effects, verify typed paths, and commit admitted filesystem changes through one resource authority." --- -> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete Control Plane, including WebUI, is implemented and locally verified; the final commit must still pass remote release gates. +> **Taskflow 1.0.0 documentation.** These instructions use exact 1.0.0 package pins. Check [GitHub Releases](https://github.com/heggria/taskflow/releases) for publication and [the acceptance scoreboard](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-ga-scoreboard.md) for tested scope and limitations. The Control Plane and WebUI are implemented; control binaries are bundled in `taskflow-mcp-core`. taskflow is a declarative runtime for coding-agent workflows. It turns a graph into a verifiable execution contract, runs phases in isolation, and keeps intermediate transcripts out of the host conversation. The 1.0 contract includes **Trusted Effects**: a typed declaration and resource-controlled commit path for admitted filesystem effects. @@ -114,4 +114,4 @@ Read the [MVP freeze in the repository](https://github.com/heggria/taskflow/blob -For the active 1.0 scope and blockers, see the [PR #142 release plan](https://github.com/heggria/taskflow/blob/rc/0.3.0-beta.2/docs/internal/1.0.0-release-plan.md). +For the active 1.0 scope and blockers, see the [PR #142 release plan](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-release-plan.md). diff --git a/website/content/docs/en/trusted-effects.mdx b/website/content/docs/en/trusted-effects.mdx index 86a33d96..eb8d1377 100644 --- a/website/content/docs/en/trusted-effects.mdx +++ b/website/content/docs/en/trusted-effects.mdx @@ -2,7 +2,7 @@ title: Trusted Effects --- -> **1.0.0 is unreleased.** Installation pins for 1.0.0 on this page apply only after publication. Current candidate source is on Draft PR [#142](https://github.com/heggria/taskflow/pull/142), using the historical branch `rc/0.3.0-beta.2`. Latest published prerelease: `v0.3.0-beta.1.2`. The complete Control Plane, including WebUI, is implemented and locally verified; the final commit must still pass remote release gates. +> **Taskflow 1.0.0 documentation.** These instructions use exact 1.0.0 package pins. Check [GitHub Releases](https://github.com/heggria/taskflow/releases) for publication and [the acceptance scoreboard](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-ga-scoreboard.md) for tested scope and limitations. The Control Plane and WebUI are implemented; control binaries are bundled in `taskflow-mcp-core`. Trusted Effects makes a phase's side effects explicit. The model may propose content, but for an admitted declared filesystem target, the **resources transaction is the only finalizer**. diff --git a/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx b/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx index 704bcca7..1c0dc139 100644 --- a/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx +++ b/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx @@ -3,7 +3,7 @@ title: TypeScript DSL(`taskflow-dsl`) description: 编译期 .tf.ts 写法 —— rune erase 成 Taskflow JSON,再进 FlowIR。 --- -> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整 Control Plane(包括 WebUI)已实现并通过本地验收;最终提交仍须通过远程发布门槛。 +> **Taskflow 1.0.0 文档。** 以下命令固定使用 1.0.0 包版本。发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases),已验证范围与限制见[验收账本](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-ga-scoreboard.md)。Control Plane 与 WebUI 已实现,Control 命令随 `taskflow-mcp-core` 交付。 # TypeScript DSL(`taskflow-dsl`) diff --git a/website/content/docs/zh-cn/getting-started.mdx b/website/content/docs/zh-cn/getting-started.mdx index 45e268f9..697070b9 100644 --- a/website/content/docs/zh-cn/getting-started.mdx +++ b/website/content/docs/zh-cn/getting-started.mdx @@ -3,7 +3,7 @@ title: 开始使用 description: 五分钟内运行你的第一个 taskflow。 --- -> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整 Control Plane(包括 WebUI)已实现并通过本地验收;最终提交仍须通过远程发布门槛。 +> **Taskflow 1.0.0 文档。** 以下命令固定使用 1.0.0 包版本。发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases),已验证范围与限制见[验收账本](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-ga-scoreboard.md)。Control Plane 与 WebUI 已实现,Control 命令随 `taskflow-mcp-core` 交付。 taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不需要写一个一个调用子代理的脚本,只需声明节点和边——运行时会替你处理 fan-out、重试、缓存和续跑。 diff --git a/website/content/docs/zh-cn/guides/claude-code.mdx b/website/content/docs/zh-cn/guides/claude-code.mdx index 9403cdf1..c9628884 100644 --- a/website/content/docs/zh-cn/guides/claude-code.mdx +++ b/website/content/docs/zh-cn/guides/claude-code.mdx @@ -3,7 +3,7 @@ title: 在 Claude Code 上使用 taskflow description: 将 taskflow 作为 Claude Code 插件安装,通过 MCP 编排多阶段工作流。 --- -> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整 Control Plane(包括 WebUI)已实现并通过本地验收;最终提交仍须通过远程发布门槛。 +> **Taskflow 1.0.0 文档。** 以下命令固定使用 1.0.0 包版本。发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases),已验证范围与限制见[验收账本](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-ga-scoreboard.md)。Control Plane 与 WebUI 已实现,Control 命令随 `taskflow-mcp-core` 交付。 [Claude Code](https://claude.com/product/claude-code) 是 taskflow 的天然宿主:它本身就以 subagent 的方式思考,而 taskflow 给这些 subagent 提供了一个声明式图来运行。插件注册 MCP server 与路由 skill;协议层不依赖 MCP SDK。 diff --git a/website/content/docs/zh-cn/guides/codex.mdx b/website/content/docs/zh-cn/guides/codex.mdx index df118d0b..4ff6386d 100644 --- a/website/content/docs/zh-cn/guides/codex.mdx +++ b/website/content/docs/zh-cn/guides/codex.mdx @@ -3,7 +3,7 @@ title: 在 Codex 上使用 taskflow description: 把 taskflow 作为 Codex 插件安装,通过 MCP 编排多阶段工作流。 --- -> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整 Control Plane(包括 WebUI)已实现并通过本地验收;最终提交仍须通过远程发布门槛。 +> **Taskflow 1.0.0 文档。** 以下命令固定使用 1.0.0 包版本。发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases),已验证范围与限制见[验收账本](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-ga-scoreboard.md)。Control Plane 与 WebUI 已实现,Control 命令随 `taskflow-mcp-core` 交付。 [Codex](https://github.com/openai/codex) 是 taskflow 的天然归宿:它已经在用子代理的方式思考,而 taskflow 给那些子代理一个声明式图来在里面运行。在 Codex 上,taskflow 以插件形式发布,注册一个 MCP 服务器加一个路由 skill,于是模型一看到多步骤任务就能调用它。 diff --git a/website/content/docs/zh-cn/guides/grok-build.mdx b/website/content/docs/zh-cn/guides/grok-build.mdx index 3f0f65de..dc341c61 100644 --- a/website/content/docs/zh-cn/guides/grok-build.mdx +++ b/website/content/docs/zh-cn/guides/grok-build.mdx @@ -3,7 +3,7 @@ title: 在 Grok Build 上使用 taskflow description: 将 taskflow 作为 Grok Build 插件安装,通过 MCP 编排多阶段工作流。 --- -> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整 Control Plane(包括 WebUI)已实现并通过本地验收;最终提交仍须通过远程发布门槛。 +> **Taskflow 1.0.0 文档。** 以下命令固定使用 1.0.0 包版本。发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases),已验证范围与限制见[验收账本](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-ga-scoreboard.md)。Control Plane 与 WebUI 已实现,Control 命令随 `taskflow-mcp-core` 交付。 [Grok Build](https://docs.x.ai/build/overview) 是 xAI 的终端编码 agent。Taskflow 的正式发布路径是 MCP server;仓库 checkout 另带 plugin scaffold。 diff --git a/website/content/docs/zh-cn/guides/hermes.mdx b/website/content/docs/zh-cn/guides/hermes.mdx index 398d0f5f..e4f65a7b 100644 --- a/website/content/docs/zh-cn/guides/hermes.mdx +++ b/website/content/docs/zh-cn/guides/hermes.mdx @@ -3,7 +3,7 @@ title: 在 Hermes Agent 上使用 taskflow description: 在 Hermes Agent 上通过 MCP 安装 taskflow,用隔离的 Hermes 子代理编排多阶段工作流。 --- -> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整 Control Plane(包括 WebUI)已实现并通过本地验收;最终提交仍须通过远程发布门槛。 +> **Taskflow 1.0.0 文档。** 以下命令固定使用 1.0.0 包版本。发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases),已验证范围与限制见[验收账本](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-ga-scoreboard.md)。Control Plane 与 WebUI 已实现,Control 命令随 `taskflow-mcp-core` 交付。 [Hermes Agent](https://hermes-agent.nousresearch.com/) 是 Nous Research 的开源 coding agent(CLI / TUI / 桌面 / 消息网关)。taskflow 的发布路径是 MCP server:Hermes 发现 `taskflow_*` 工具,每个 agent phase 会拉起隔离的 `hermes chat -q -Q` 子进程。 diff --git a/website/content/docs/zh-cn/guides/opencode.mdx b/website/content/docs/zh-cn/guides/opencode.mdx index 765afc97..96326466 100644 --- a/website/content/docs/zh-cn/guides/opencode.mdx +++ b/website/content/docs/zh-cn/guides/opencode.mdx @@ -3,7 +3,7 @@ title: 在 OpenCode 上使用 taskflow description: 在 OpenCode 上将 taskflow 注册为 MCP 服务器,编排多阶段工作流。 --- -> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整 Control Plane(包括 WebUI)已实现并通过本地验收;最终提交仍须通过远程发布门槛。 +> **Taskflow 1.0.0 文档。** 以下命令固定使用 1.0.0 包版本。发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases),已验证范围与限制见[验收账本](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-ga-scoreboard.md)。Control Plane 与 WebUI 已实现,Control 命令随 `taskflow-mcp-core` 交付。 [OpenCode](https://opencode.ai) 通过协议层不依赖 MCP SDK 的 MCP server 暴露 taskflow;每个阶段作为隔离的 `opencode run` 进程运行。 diff --git a/website/content/docs/zh-cn/guides/pi.mdx b/website/content/docs/zh-cn/guides/pi.mdx index 78862b50..cb3083ab 100644 --- a/website/content/docs/zh-cn/guides/pi.mdx +++ b/website/content/docs/zh-cn/guides/pi.mdx @@ -3,7 +3,7 @@ title: 在 Pi 上使用 taskflow description: 在 Pi 编程智能体上安装、运行、保存和续跑 taskflow。 --- -> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整 Control Plane(包括 WebUI)已实现并通过本地验收;最终提交仍须通过远程发布门槛。 +> **Taskflow 1.0.0 文档。** 以下命令固定使用 1.0.0 包版本。发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases),已验证范围与限制见[验收账本](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-ga-scoreboard.md)。Control Plane 与 WebUI 已实现,Control 命令随 `taskflow-mcp-core` 交付。 如果你已经在用 [Pi](https://pi.dev) 把工作委托给子代理,taskflow 把这些一次性的委托变成有名字、可续跑、多步骤的工作流。你把工作描述成一个图——fan-out、gate、loop,你需要什么就写什么——只有最终结果回到你的上下文里。 diff --git a/website/content/docs/zh-cn/index.mdx b/website/content/docs/zh-cn/index.mdx index e902c462..a5b4158a 100644 --- a/website/content/docs/zh-cn/index.mdx +++ b/website/content/docs/zh-cn/index.mdx @@ -3,7 +3,7 @@ title: taskflow 1.0 文档 description: "面向 coding-agent 工作流的 Trusted Effects:声明 effect,验证类型化路径,让已准入的文件修改经过唯一 resource authority。" --- -> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整 Control Plane(包括 WebUI)已实现并通过本地验收;最终提交仍须通过远程发布门槛。 +> **Taskflow 1.0.0 文档。** 以下命令固定使用 1.0.0 包版本。发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases),已验证范围与限制见[验收账本](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-ga-scoreboard.md)。Control Plane 与 WebUI 已实现,Control 命令随 `taskflow-mcp-core` 交付。 taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图变成可验证的执行合同,让阶段隔离运行,并把中间 transcript 留在宿主对话之外。1.0 合同包含 **Trusted Effects**:为已准入的文件 effect 提供类型化声明与受 resources 控制的提交路径。 @@ -114,4 +114,4 @@ taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图 -当前 1.0 范围与阻塞项见[PR #142 release plan](https://github.com/heggria/taskflow/blob/rc/0.3.0-beta.2/docs/internal/1.0.0-release-plan.md). +当前 1.0 范围与阻塞项见[PR #142 release plan](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-release-plan.md). diff --git a/website/content/docs/zh-cn/trusted-effects.mdx b/website/content/docs/zh-cn/trusted-effects.mdx index 1106b7d9..340e1d1a 100644 --- a/website/content/docs/zh-cn/trusted-effects.mdx +++ b/website/content/docs/zh-cn/trusted-effects.mdx @@ -2,7 +2,7 @@ title: Trusted Effects --- -> **1.0.0 尚未发布。** 本页的 1.0.0 安装 pin 仅供发布后使用;当前候选代码位于 Draft PR [#142](https://github.com/heggria/taskflow/pull/142) 的历史分支 `rc/0.3.0-beta.2`。最新已发布 prerelease 为 `v0.3.0-beta.1.2`。完整 Control Plane(包括 WebUI)已实现并通过本地验收;最终提交仍须通过远程发布门槛。 +> **Taskflow 1.0.0 文档。** 以下命令固定使用 1.0.0 包版本。发布状态见 [GitHub Releases](https://github.com/heggria/taskflow/releases),已验证范围与限制见[验收账本](https://github.com/heggria/taskflow/blob/main/docs/internal/1.0.0-ga-scoreboard.md)。Control Plane 与 WebUI 已实现,Control 命令随 `taskflow-mcp-core` 交付。 Trusted Effects 把阶段的副作用写进合同。模型可以提出内容,但对于已准入的已声明文件目标,**resources transaction 是唯一最终提交者**。 @@ -32,7 +32,7 @@ pnpm exec node --conditions=development --experimental-strip-types --test \ - Resolve-only 宿主无法阻止所有对未声明路径的写入。 - 这一版没有 SecretRef vault 后端。 - 这一版没有 ServiceRef live network adapter。 -- ControlHost 有实验性 Unix UDS 和 files-only store 代码。admission、parked 审批/receipt、协调与 WebUI 尚未完成,是 1.0 必须完成的验收范围。Windows pipe 仍单独列为未验证范围。 +- ControlHost 已通过 Unix UDS 和 files-only store 实现经认证的准入、持久审批/Receipt、协调与 WebUI。Control 命令随公共 MCP 包交付;Windows pipes 仍非 GA。 ## 接下来阅读 diff --git a/website/package.json b/website/package.json index 196ca46a..519c1c09 100644 --- a/website/package.json +++ b/website/package.json @@ -20,7 +20,7 @@ "fumadocs-mdx": "15.3.1", "fumadocs-ui": "16.15.1", "lucide-react": "1.33.0", - "next": "16.3.6", + "next": "16.3.8", "react": "19.2.8", "react-dom": "19.2.8" },