From e69e8ea1e239b999cf2e48fe923fe58e0946fce7 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Thu, 13 Aug 2026 14:13:57 +0800 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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. ```