@haiyangbg/buildbeat 2.0.0-beta.2 → 2.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -1
- package/bin/buildbeat-v2.js +13 -1
- package/docs/CLI.md +1 -1
- package/docs/RELEASING.md +1 -1
- package/docs/V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md +8 -0
- package/docs/v2/guide/01-quickstart.md +2 -0
- package/docs/v2/guide/02-workflow-guide.md +17 -0
- package/docs/v2/guide/06-evidence-guide.md +6 -0
- package/docs/v2/guide/07-approval-guide.md +17 -0
- package/docs/v2/guide/10-recovery.md +3 -1
- package/example/.buildbeat/manifest.json +1 -1
- package/package.json +1 -1
- package/src/v2/cli/run.js +144 -8
- package/src/v2/domain/event-registry.js +24 -0
- package/src/v2/engine/reducer.js +3 -0
- package/src/v2/observe/observe.js +9 -2
- package/src/v2/presets/software-delivery.yaml +5 -0
- package/src/v2/runtime/decisions.js +6 -4
- package/src/v2/runtime/env-contract.js +101 -0
- package/src/v2/runtime/findings.js +158 -0
- package/src/v2/runtime/orchestrator.js +108 -17
- package/src/v2/runtime/repo-ref.js +38 -0
- package/src/v2/runtime/run-record.js +18 -3
- package/src/v2/workspace/workspace-manager.js +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,10 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
> 本项目吃自己的狗粮(红线④:必更 CHANGELOG)。格式循 Keep a Changelog,倒序。
|
|
4
4
|
|
|
5
|
+
## v2.0.0-beta.3 — 2026-09-01
|
|
6
|
+
|
|
7
|
+
> 主题:三十轮部署战役(底座 WORK-C0-HEALTH-NONPROD-01,DEPLOY-01~30 + L4 之夜)的机制回灌。战役复盘:`底座/pm/2026-09-01-BuildBeat三十轮复盘.md`。
|
|
8
|
+
> **发布状态**:同 beta.1/beta.2 流程(OIDC Trusted Publishing,dist-tag `next`;`latest` 保持 v1.21.0)。
|
|
9
|
+
|
|
10
|
+
- **发现分诊门**(复盘改革条 4):run 配置 `reviewTriage: required` 后,review 的 P0/P1 finding 不再自动派 fixer——停 `WAITING_HUMAN`(kind `finding-triage`)待人逐指纹裁决,approve `enter-fix` 才放行。finding 是处方不是事实;自动路由处方在战役振荡期连烧四轮
|
|
11
|
+
- **锚定审查与裁决台账**(改革条 3):finding 全部落 Git 面 `delivery/work/<id>/review-findings.jsonl`(指纹=严重度+正文规范化 hash);`findings list` / `findings adjudicate --action accept|dismiss` 人裁决;`dismiss` 后同指纹不再阻断(重提记 `RE-RAISED` 可见)、严重度升级自动重开;Reviewer input 注入历史裁决锚(`anchor`)、fixer input 注入带裁决状态的工单(`findings`)。裁决记忆在 Git 面,删 runtime 不丢
|
|
12
|
+
- **环境契约 `requires:`**:run 配置声明信封依赖的二进制与最低版本,Run 启动前 fail-closed 全量核验、一次报清(真实事故:vendored-only `rg`、bash 3.2、新 shell 解析 Node 14 各烧整轮才见真因);`doctor` 同步报告
|
|
13
|
+
- **review 轮数预算原生化**(改革条 1 的机制化):官方预设 `budgets.maxAttempts.review: 2`——第三轮 review 启动前即停人批,无需 prompt 约束兜底
|
|
14
|
+
- **`preflight` 预检通道**:`preflight --config <cfg> --step <id>` 在主 checkout 干跑某步 worker 命令,分钟级打到首个失败边界再进 Run;无 worktree、无台账、零证据(横幅明示 dry signal;`BUILDBEAT_PREFLIGHT=1`),Run 必须复现才算数
|
|
15
|
+
- **fix(v2) 崩溃恢复不再误路由**:被中断的步现在重跑自身(丢失尝试仍计预算),不再按步骤失败走 failure 边——真实事故(deploy-18):宿主超时杀掉 verify worker,crash 被路由去 fix,fixer 面对零 verifier 证据白烧一轮。交互式 shell 里 `start` 现在提示脱离启动(nohup/setsid)
|
|
16
|
+
- 战役期间已并入的内核修复一并随本版发布:预算守卫(final attempt 失败不再派 fix,deploy-14)、porcelain 状态列保护、runtime 证据引用仓库相对化、Node <20 清晰报错(各见对应 commit)
|
|
17
|
+
- 指南更新:验证金字塔警示与"真缺陷类清零即升层"(06)、分诊门与锚定审查(07)、环境契约与 review 预算(02)、崩溃重跑语义与启动纪律(10)
|
|
18
|
+
|
|
5
19
|
## v2.0.0-beta.2 — 2026-08-28
|
|
6
20
|
|
|
7
21
|
> 主题:aiplatform-meta(底座)v2 迁移试点抓出的内核修复。
|
|
8
|
-
>
|
|
22
|
+
> **发布状态**:`@haiyangbg/buildbeat@2.0.0-beta.2` 已于 2026-08-28 经 OIDC Trusted Publishing 发布到 dist-tag `next`(run 33175013599,双 job success);`latest` 保持 v1.21.0。独立回读:dist-tag 路由、integrity、SLSA provenance、隔离安装全过,证据见 [`docs/V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md`](docs/V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md)。
|
|
9
23
|
|
|
10
24
|
- **fix(v2) 范围门中文路径误拦**:git `core.quotepath` 默认把非 ASCII 路径转义为带引号的八进制串,`listChangedPaths` 直接喂给 allowedPaths 前缀检查导致范围内中文文件被判越界(真实事故:底座 `RUN-META-V2-01` 被 `pm/登录二期看板.md` 阻断)。读回改用 `core.quotepath=off`,中文路径永久回归进 `tests/v2-invariants.test.js`
|
|
11
25
|
|
package/bin/buildbeat-v2.js
CHANGED
|
@@ -2,5 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
// v2 runtime CLI entry. The v1 `buildbeat` bin stays frozen on src/cli.js;
|
|
4
4
|
// v2 ships as a separate entry until it takes over `latest`.
|
|
5
|
+
//
|
|
6
|
+
// Guard before loading any module: the kernel uses Node>=20 syntax, and on a
|
|
7
|
+
// machine whose default node drifted older the raw SyntaxError stack hides
|
|
8
|
+
// the actual problem (real incident: default node v14 during the meta pilot).
|
|
9
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
10
|
+
if (major < 20) {
|
|
11
|
+
console.error(
|
|
12
|
+
`buildbeat-v2 needs Node >= 20; this shell resolved v${process.versions.node}.\n` +
|
|
13
|
+
"Check `which node` / nvm default, then rerun (e.g. `nvm use 23`).",
|
|
14
|
+
);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
5
17
|
|
|
6
|
-
import
|
|
18
|
+
import("../src/v2/cli/run.js");
|
package/docs/CLI.md
CHANGED
|
@@ -128,7 +128,7 @@ Schema 2 is the first write-capable shape targeted by Wave 1:
|
|
|
128
128
|
{
|
|
129
129
|
"schemaVersion": 2,
|
|
130
130
|
"scaffoldVersion": "v1.21",
|
|
131
|
-
"cliVersion": "2.0.0-beta.
|
|
131
|
+
"cliVersion": "2.0.0-beta.3",
|
|
132
132
|
"layout": "default",
|
|
133
133
|
"installedAt": "2026-08-24T00:00:00.000Z",
|
|
134
134
|
"files": {
|
package/docs/RELEASING.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
This runbook governs BuildBeat's public npm distribution. The canonical package is `@haiyangbg/buildbeat` in `HaiYangBG1/BuildBeat`; the canonical executable is `buildbeat`, while `solobaton` remains an executable alias. The old `solobaton` npm package is a frozen legacy distribution ID and must not receive the scoped write/upgrade command surface.
|
|
4
4
|
|
|
5
|
-
Release evidence at source package version `@haiyangbg/buildbeat@2.0.0-beta.
|
|
5
|
+
Release evidence at source package version `@haiyangbg/buildbeat@2.0.0-beta.3` (pending publication verification); latest independently verified BuildBeat npm distribution `@haiyangbg/buildbeat@2.0.0-beta.2` (dist-tag `next`; `latest` remains `1.21.0`), anchored by annotated tag `v2.0.0-beta.2` at commit `d7a9ab9`, workflow run [33175013599](https://github.com/HaiYangBG1/BuildBeat/actions/runs/33175013599), and archived in [`V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md`](V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md). The beta.1 chain stays archived in [`V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md`](V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md). The latest stable distribution stays `@haiyangbg/buildbeat@1.21.0`, anchored by annotated tag `v1.21.0` at commit `ce69a05`, workflow run [32864438692](https://github.com/HaiYangBG1/BuildBeat/actions/runs/32864438692), and the matching [GitHub Release](https://github.com/HaiYangBG1/BuildBeat/releases/tag/v1.21.0). Exact registry identity, provenance, signatures, isolated-install readback, Environment approval, and immutable-artifact boundary are archived in [`V1.21-RELEASE-EVIDENCE-2026-08-25.md`](V1.21-RELEASE-EVIDENCE-2026-08-25.md). First-scoped-release bootstrap behavior and legacy deprecation remain archived in [`WP4.3-RELEASE-EVIDENCE-2026-08-25.md`](WP4.3-RELEASE-EVIDENCE-2026-08-25.md). The legacy distribution remains `solobaton@1.16.3`; all three published legacy versions are retained and deprecated toward the scoped package.
|
|
6
6
|
|
|
7
7
|
## Release invariants
|
|
8
8
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# v2.0.0-beta.2 发布证据(2026-08-28)
|
|
2
|
+
|
|
3
|
+
> 授权:所有者会话内 "4. OK"(beta.2 发布 + 部署审批);流程与 [beta.1](V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md) 完全一致。
|
|
4
|
+
|
|
5
|
+
- 内容:单一内核修复——范围门中文路径误拦(git quotepath 转义;aiplatform-meta 迁移试点真实事故 `RUN-META-V2-01`),修复 + 中文路径永久回归(`3f39189`)。
|
|
6
|
+
- 候选:`d7a9ab9`(`v2` tip),tag `v2.0.0-beta.2`;本地 prepublishOnly 全链 `GATE_EXIT=0`(node 143/143);CI 两个 run(`3f39189` / `d7a9ab9`)conclusion=success。
|
|
7
|
+
- 发布:workflow run [33175013599](https://github.com/HaiYangBG1/BuildBeat/actions/runs/33175013599) 双 job success(publish + verify:exact integrity、dist-tag 路由、provenance、隔离安装、签名审计)。
|
|
8
|
+
- 本地独立回读:dist-tags `{bootstrap: 0.0.0, latest: 1.21.0, next: 2.0.0-beta.2}`(`latest` 未动);`dist.integrity = sha512-Lt90fCFnNCvnMJK/cs/lUrau4uESf5iDCmy9GFYD6sFdGSHbwlcOM4afBlziHTuoz3rVKpgw8b/+bP+bjarh6w==`;provenance `https://slsa.dev/provenance/v1`;所有者机器全局安装已切换为该官方工件(quotepath 修复在包内核验)。
|
|
@@ -80,6 +80,8 @@ buildbeat-v2 approve --repo . --run RUN-DEMO-1 --transition enter-wait-merge --b
|
|
|
80
80
|
|
|
81
81
|
批准即 merge-ready;合并/推送/发布永远是你的动作,Runner 不代劳。被 findings 阻断时会自动路由 fix→verify 重走,超预算或指纹重复则停下交还给你([Approval 指南](07-approval-guide.md)、[Recovery](10-recovery.md))。
|
|
82
82
|
|
|
83
|
+
想让 finding 先过你的手再派 fixer:run 配置加 `reviewTriage: required`,配套 `findings list` / `findings adjudicate` 逐指纹裁决(dismiss 后同指纹不再阻断);正式起 Run 前可用 `preflight --step <id>` 在主 checkout 分钟级干跑单步(不产证据);信封的环境依赖用 `requires:` 声明,启动前 fail-closed 核验。详见 [Approval 指南](07-approval-guide.md)、[Evidence 指南](06-evidence-guide.md)、[Workflow 指南](02-workflow-guide.md)。
|
|
84
|
+
|
|
83
85
|
## 5. observe:让系统盯生产(v0)
|
|
84
86
|
|
|
85
87
|
```bash
|
|
@@ -37,6 +37,23 @@ terminal:
|
|
|
37
37
|
|
|
38
38
|
run 配置里 `entry` 可覆盖 workflow 的 `entry`(例如从 `build` 起步、跳过 intent/plan 步——digest 仍会绑进批准对象);`stopAt` 指定停点。workflow 文件整体做 sha256 → `RUN_CREATED.workflowDigest`,事后可证明当时跑的是哪份流程。
|
|
39
39
|
|
|
40
|
+
run 配置还可声明(beta.3,皆来自三十轮部署战役的真实事故):
|
|
41
|
+
|
|
42
|
+
- **`requires:` 环境契约**——信封隐式依赖的二进制与最低版本,Run 启动前 fail-closed 全量核验,一次报清所有问题(真实事故:`rg` 只在某会话 vendored PATH、`/bin/bash` 3.2、新 shell 解析到 Node 14,各烧掉整轮 Run 才见真因):
|
|
43
|
+
|
|
44
|
+
```yaml
|
|
45
|
+
requires:
|
|
46
|
+
- command: bash
|
|
47
|
+
min: 4
|
|
48
|
+
- command: rg
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- **`reviewTriage: required` 发现分诊门**——review 的阻断性 finding 先停人分诊、再派 fixer(见 [Approval 指南](07-approval-guide.md))。
|
|
52
|
+
|
|
53
|
+
## review 轮数预算
|
|
54
|
+
|
|
55
|
+
官方预设自带 `budgets.maxAttempts.review: 2`(战役章程"每 Run 2 轮 review 封顶"的原生化):第三轮 review 在启动前即停 `WAITING_HUMAN`,理由写明预算耗尽。项目可用自己的 workflow 文件覆盖;机制就是每步 `maxAttempts`,无需新概念。
|
|
56
|
+
|
|
40
57
|
## 修改纪律
|
|
41
58
|
|
|
42
59
|
预设是产品的一部分:改 `software-delivery.yaml` 前先想清是不是项目差异——项目差异用自己的 workflow 文件(run 配置 `workflow:` 指过去),不改官方预设。schema additive-only,破坏性改法升 `version`。
|
|
@@ -20,6 +20,12 @@
|
|
|
20
20
|
|
|
21
21
|
`L0` 自述 → `L1` 静态检查 → `L2` 本地真实执行(命令回读默认档)→ `L3` 部署后验证 → `L4` 生产实测。门用 `minGrade` 提要求(如 merge 底线 L2;生产切换收口要 L4)。
|
|
22
22
|
|
|
23
|
+
**验证金字塔警示(三十轮战役最贵一课)**:低层验证追理论完备的边际价值远低于早一层真机。实战中 7400 行 L3 套件打磨到极限,而 4 个真正的上线阻断(systemd 解析行为、部署/服务身份分裂、探针预算按替身标定、TLS ref 格式)**全部是 L3 结构性测不到的**,一晚 L4 找齐。经验法则:**模拟层"真缺陷类清零"即升层,不追理论完备**。同理,verify 全量重跑在候选只动局部时是纯重复——按内容哈希缓存/裁剪属于信封层优化(战役实测 25→13 分钟),内核不代做:缓存正确性依赖环境稳定假设,由信封所有者自己承担。
|
|
24
|
+
|
|
25
|
+
## 预检 ≠ 证据
|
|
26
|
+
|
|
27
|
+
`buildbeat-v2 preflight --config <run-config> --step <id>`:在主 checkout 直接干跑某步的 worker 命令——无 worktree、无台账、不落任何证据(输出自带 `PREFLIGHT (dry signal, never evidence)` 横幅,环境变量 `BUILDBEAT_PREFLIGHT=1`)。用途是分钟级循环打到首个失败边界再进 Run(战役里 harness 缺陷每个要烧一整轮 Run,预检模式一晚拆完);**预检发现的任何东西必须由 Run 复现才算数**。
|
|
28
|
+
|
|
23
29
|
## 候选作用域
|
|
24
30
|
|
|
25
31
|
证据以 `subject` 绑定候选:merge 门只统计当前 candidate 的证据,旧候选/旧 review 轮次的记录不混入(真实事故回归,见 evals `fix-loop`)。
|
|
@@ -32,3 +32,20 @@ merge 批准只表示 **merge-ready**:真正的合并、push、发布是你在
|
|
|
32
32
|
## 人批点由 Risk Preset 决定
|
|
33
33
|
|
|
34
34
|
`fast` 仅 Merge;`standard` Plan+Merge;`controlled` Intent+Plan+Merge+Release;`legacy-four-gates` 为 v1 四 Gate 完整形态(迁移期用,见 [迁移指南](08-migration-v1.md))。待批项强制携带 findings 摘要与理由——防"秒批"退化;人批等待时长进 `metrics`。
|
|
35
|
+
|
|
36
|
+
## 发现分诊门与锚定审查(beta.3)
|
|
37
|
+
|
|
38
|
+
来自三十轮部署战役最大的结构性教训:**finding 是处方不是事实**,无记忆 fresh reviewer 会开出互斥处方并翻案早已接受的设计,自动路由 fixer 让振荡直接烧钱。两个机制配套:
|
|
39
|
+
|
|
40
|
+
1. **分诊门**:run 配置 `reviewTriage: required` 后,review 产出 P0/P1 finding 不再自动派 fixer,而是停 `WAITING_HUMAN`(kind `finding-triage`),待批理由逐条列出 finding 指纹。人先裁决、再 `approve --transition enter-fix` 放行(或 `reject` 终止 Run)。
|
|
41
|
+
2. **裁决台账**:finding 全部落 Git 面 `delivery/work/<id>/review-findings.jsonl`(指纹 = 严重度+正文规范化 hash):
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
buildbeat-v2 findings list --repo . --work WORK-X
|
|
45
|
+
buildbeat-v2 findings adjudicate --repo . --work WORK-X --fingerprint <fp> --action dismiss --by <名字> --note "<为什么>"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`dismiss` 后同指纹不再阻断(重提会以 `RE-RAISED` 记账可见,但不重启循环);**严重度升级=新指纹,自动重新阻断**——压噪不压真信号,与 observe 的 dismiss 回调同一原则。
|
|
49
|
+
3. **锚定注入**:Reviewer(readonly 步)的 `BUILDBEAT_INPUT` 带 `anchor`(历史 finding+裁决全表),信封 prompt 应告知 reviewer"已裁决的结论不得翻案";fixer 等写入步的 input 带 `findings`(上一轮 review 的 finding 及其裁决状态)——fixer 只修 accepted/open,不猜。
|
|
50
|
+
|
|
51
|
+
裁决记忆在 Git 面,删 runtime 不丢(不变量 23 同款测试覆盖)。
|
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
buildbeat-v2 resume --config <run-config.yaml>
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
在途步会以 `crashed`
|
|
21
|
+
在途步会以 `crashed` 关闭(事实落账),然后**重跑该步本身**(beta.3 改):进程死掉不说明候选有问题,丢失的那次尝试照常计入该步预算,预算耗尽即停人工。此前的语义是把 crash 当步骤失败走 failure 边——真实事故(deploy-18):宿主工具超时杀掉 verify worker,crash 被路由去 fix,fixer 面对零 verifier 证据白烧一轮。工作树脏了仍然先停人工。带批准恢复时会做 candidate/plan 新鲜度检查,变了即 `APPROVAL_STALE` 转人工。恢复不了就删 runtime 重跑——候选分支与 Git 面记录不丢。
|
|
22
|
+
|
|
23
|
+
**启动纪律**(同一事故的另一半):长于分钟级的 Run 必须以脱离宿主工具超时的方式启动(`nohup`/`setsid`),交互式 shell 里 `start` 会打印这条提醒。
|
|
22
24
|
|
|
23
25
|
### 锁卡住("another run is active")
|
|
24
26
|
|
package/package.json
CHANGED
package/src/v2/cli/run.js
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// Deliberately thin — all facts live in the event ledger; this file only
|
|
4
4
|
// parses input, wires adapters, and renders derived state.
|
|
5
5
|
|
|
6
|
-
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
-
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
10
10
|
|
|
11
11
|
import { createShellAdapter } from "../adapters/shell.js";
|
|
12
12
|
import { loadRiskPreset } from "../engine/risk-preset.js";
|
|
@@ -15,9 +15,17 @@ import { parseYamlSubset } from "../engine/yaml-subset.js";
|
|
|
15
15
|
import { parsePolicyDoc } from "../policy/policy.js";
|
|
16
16
|
import { observeStatus, runObserveCycle, triageIntent } from "../observe/observe.js";
|
|
17
17
|
import { acceptArtifact, approveRun, listInbox, rejectRun } from "../runtime/decisions.js";
|
|
18
|
+
import { checkRequires } from "../runtime/env-contract.js";
|
|
19
|
+
import {
|
|
20
|
+
adjudicateFinding,
|
|
21
|
+
findingsAccountRef,
|
|
22
|
+
latestAdjudications,
|
|
23
|
+
readFindingsAccount,
|
|
24
|
+
} from "../runtime/findings.js";
|
|
18
25
|
import { computeMetrics, renderMetrics } from "../runtime/metrics.js";
|
|
19
26
|
import { writeRunRecord } from "../runtime/run-record.js";
|
|
20
27
|
import { resumeRun, startRun } from "../runtime/orchestrator.js";
|
|
28
|
+
import { toRepoRef } from "../runtime/repo-ref.js";
|
|
21
29
|
import { EventLedger } from "../storage/event-ledger.js";
|
|
22
30
|
import { acquireLock, releaseLock } from "../workspace/workspace-manager.js";
|
|
23
31
|
|
|
@@ -41,6 +49,9 @@ Usage:
|
|
|
41
49
|
run.js observe run --config <observe.yaml>
|
|
42
50
|
run.js observe status --repo <path>
|
|
43
51
|
run.js observe triage --repo <path> --intent <ref> --action <fix_now|schedule|dismiss> [--by <name>] [--note <text>]
|
|
52
|
+
run.js preflight --config <run-config.yaml> --step <id>
|
|
53
|
+
run.js findings list --repo <path> --work <WORK-ID>
|
|
54
|
+
run.js findings adjudicate --repo <path> --work <WORK-ID> --fingerprint <fp> --action <accept|dismiss> [--by <name>] [--note <text>]
|
|
44
55
|
`;
|
|
45
56
|
|
|
46
57
|
function parseFlags(argv) {
|
|
@@ -82,7 +93,8 @@ function printState(state, ledger) {
|
|
|
82
93
|
console.log(`step ${step}: ${info.status} (attempts ${info.attempts})`);
|
|
83
94
|
}
|
|
84
95
|
for (const item of state.evidence) {
|
|
85
|
-
|
|
96
|
+
const ref = isAbsolute(item.ref) ? "<legacy-absolute-evidence-ref>" : item.ref;
|
|
97
|
+
console.log(`evidence [${item.status}/${item.grade}] ${item.kind} ${ref}`);
|
|
86
98
|
}
|
|
87
99
|
if (state.pendingHuman) {
|
|
88
100
|
console.log(`waiting on human: ${state.pendingHuman.transition}`);
|
|
@@ -141,6 +153,10 @@ function loadRunConfig(flags, command) {
|
|
|
141
153
|
policies.push(parsePolicyDoc(parseYamlSubset(readFileSync(resolve(configDir, policyPath), "utf8"))));
|
|
142
154
|
}
|
|
143
155
|
|
|
156
|
+
if (config.reviewTriage !== undefined && !["required", "off"].includes(config.reviewTriage)) {
|
|
157
|
+
throw new Error(`reviewTriage must be "required" or "off", got: ${config.reviewTriage}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
144
160
|
return {
|
|
145
161
|
repoRoot,
|
|
146
162
|
workflow,
|
|
@@ -157,6 +173,8 @@ function loadRunConfig(flags, command) {
|
|
|
157
173
|
maxAttemptsPerStep: config.maxAttemptsPerStep ?? 4,
|
|
158
174
|
stepTimeoutMs: config.stepTimeoutMs,
|
|
159
175
|
allowedPaths: config.allowedPaths,
|
|
176
|
+
requires: config.requires ?? [],
|
|
177
|
+
reviewTriage: config.reviewTriage === "required" ? "required" : null,
|
|
160
178
|
planDigest: digestOfWorkFile("plan.md"),
|
|
161
179
|
intentDigest: digestOfWorkFile("intent.md"),
|
|
162
180
|
};
|
|
@@ -164,8 +182,14 @@ function loadRunConfig(flags, command) {
|
|
|
164
182
|
|
|
165
183
|
function commandStart(flags) {
|
|
166
184
|
const options = loadRunConfig(flags, "start");
|
|
185
|
+
if (process.stdout.isTTY) {
|
|
186
|
+
// Run launch discipline (real incident: a host-tool timeout killed a
|
|
187
|
+
// verify worker mid-run): anything longer than minutes belongs in a
|
|
188
|
+
// detached process, not an interactive foreground shell.
|
|
189
|
+
console.log("tip: long runs should be started detached (nohup/setsid); interactive shells die with their host");
|
|
190
|
+
}
|
|
167
191
|
const result = startRun(options);
|
|
168
|
-
console.log(`ledger: ${result.ledgerPath}`);
|
|
192
|
+
console.log(`ledger: ${toRepoRef(options.repoRoot, result.ledgerPath)}`);
|
|
169
193
|
printState(result.state, { corruption: null });
|
|
170
194
|
}
|
|
171
195
|
|
|
@@ -175,7 +199,7 @@ function commandResume(flags) {
|
|
|
175
199
|
if (!result.resumed) {
|
|
176
200
|
console.log(`nothing to resume: ${result.reason}`);
|
|
177
201
|
}
|
|
178
|
-
console.log(`ledger: ${result.ledgerPath}`);
|
|
202
|
+
console.log(`ledger: ${toRepoRef(options.repoRoot, result.ledgerPath)}`);
|
|
179
203
|
printState(result.state, { corruption: null });
|
|
180
204
|
}
|
|
181
205
|
|
|
@@ -299,6 +323,116 @@ function commandDoctor(flags) {
|
|
|
299
323
|
console.log("push protection: repository has no remotes (nothing to protect)");
|
|
300
324
|
}
|
|
301
325
|
console.log("kernel capabilities: merge/deploy/publish have no call path in the runner (invariant 20)");
|
|
326
|
+
if (options.requires.length > 0) {
|
|
327
|
+
console.log("environment contract (requires):");
|
|
328
|
+
const check = checkRequires(options.requires);
|
|
329
|
+
for (const row of check.checked) {
|
|
330
|
+
console.log(` ${row.command}: OK${row.version ? ` (${row.version})` : ""}`);
|
|
331
|
+
}
|
|
332
|
+
for (const problem of check.problems) {
|
|
333
|
+
console.log(` PROBLEM ${problem}`);
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
console.log("environment contract: none declared (implicit PATH facts stay unchecked)");
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Preflight: run one step's configured worker command directly in the main
|
|
341
|
+
// checkout — no worktree, no ledger, no evidence. Minute-level dry loops
|
|
342
|
+
// against the first failure boundary before a full run is what turned the
|
|
343
|
+
// deploy campaign's idle phase around; the output is a dry signal only and a
|
|
344
|
+
// Run must reproduce anything it finds.
|
|
345
|
+
function commandPreflight(flags) {
|
|
346
|
+
const options = loadRunConfig(flags, "preflight");
|
|
347
|
+
if (!flags.step) {
|
|
348
|
+
throw new Error("preflight requires --step");
|
|
349
|
+
}
|
|
350
|
+
const stepDef = options.workflow.steps.find((candidate) => candidate.id === flags.step);
|
|
351
|
+
if (!stepDef) {
|
|
352
|
+
throw new Error(`step not in workflow: ${flags.step}`);
|
|
353
|
+
}
|
|
354
|
+
const spec = stepDef.worker ? options.adapterConfigs[stepDef.worker] : null;
|
|
355
|
+
if (!spec) {
|
|
356
|
+
throw new Error(`step ${flags.step} has no configured worker command to preflight`);
|
|
357
|
+
}
|
|
358
|
+
if (options.requires.length > 0) {
|
|
359
|
+
const check = checkRequires(options.requires);
|
|
360
|
+
for (const problem of check.problems) {
|
|
361
|
+
console.log(`requires PROBLEM: ${problem}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const fill = (text) =>
|
|
365
|
+
String(text)
|
|
366
|
+
.replaceAll("{workspace}", options.repoRoot)
|
|
367
|
+
.replaceAll("{step}", flags.step)
|
|
368
|
+
.replaceAll("{worker}", stepDef.worker);
|
|
369
|
+
const args = (spec.args ?? []).map(fill);
|
|
370
|
+
let env;
|
|
371
|
+
if (spec.inheritEnv === true) {
|
|
372
|
+
env = { ...process.env };
|
|
373
|
+
} else {
|
|
374
|
+
env = {};
|
|
375
|
+
for (const key of ["PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "TERM", "USER", "SHELL"]) {
|
|
376
|
+
if (process.env[key] !== undefined) {
|
|
377
|
+
env[key] = process.env[key];
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
Object.assign(env, spec.env ?? {});
|
|
382
|
+
env.BUILDBEAT_PREFLIGHT = "1";
|
|
383
|
+
console.log(`PREFLIGHT (dry signal, never evidence): step ${flags.step} -> ${spec.command} ${args.join(" ")}`);
|
|
384
|
+
console.log(`cwd: main checkout (no worktree, no ledger, no evidence written)`);
|
|
385
|
+
const result = spawnSync(spec.command, args, {
|
|
386
|
+
cwd: options.repoRoot,
|
|
387
|
+
stdio: "inherit",
|
|
388
|
+
env,
|
|
389
|
+
timeout: spec.timeoutMs,
|
|
390
|
+
});
|
|
391
|
+
if (result.error) {
|
|
392
|
+
throw new Error(`preflight could not run the command: ${result.error.message}`);
|
|
393
|
+
}
|
|
394
|
+
const exitCode = result.status ?? 1;
|
|
395
|
+
console.log(`preflight exit=${exitCode} — a Run must reproduce this before it counts`);
|
|
396
|
+
process.exitCode = exitCode;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function commandFindings(rest) {
|
|
400
|
+
const [sub, ...args] = rest;
|
|
401
|
+
const flags = parseFlags(args);
|
|
402
|
+
if (sub === "list") {
|
|
403
|
+
if (!flags.repo || !flags.work) {
|
|
404
|
+
throw new Error("findings list requires --repo and --work");
|
|
405
|
+
}
|
|
406
|
+
const rows = readFindingsAccount(resolve(flags.repo), flags.work);
|
|
407
|
+
const findings = rows.filter((row) => row.kind === "finding");
|
|
408
|
+
if (findings.length === 0) {
|
|
409
|
+
console.log(`no recorded findings (${findingsAccountRef(flags.work)})`);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const adjudicated = latestAdjudications(rows);
|
|
413
|
+
for (const row of findings) {
|
|
414
|
+
const verdict = adjudicated.get(row.fingerprint);
|
|
415
|
+
const status = verdict ? `${verdict.action} by ${verdict.by}` : "open";
|
|
416
|
+
const reRaised = row.reRaised ? " RE-RAISED" : "";
|
|
417
|
+
console.log(`[${row.severity} ${row.fingerprint}] (${status})${reRaised} ${row.summary}`);
|
|
418
|
+
}
|
|
419
|
+
} else if (sub === "adjudicate") {
|
|
420
|
+
if (!flags.repo || !flags.work || !flags.fingerprint || !flags.action) {
|
|
421
|
+
throw new Error("findings adjudicate requires --repo, --work, --fingerprint and --action");
|
|
422
|
+
}
|
|
423
|
+
const row = adjudicateFinding(resolve(flags.repo), flags.work, {
|
|
424
|
+
fingerprint: flags.fingerprint,
|
|
425
|
+
action: flags.action,
|
|
426
|
+
by: flags.by,
|
|
427
|
+
note: flags.note,
|
|
428
|
+
});
|
|
429
|
+
console.log(`adjudicated ${row.fingerprint} -> ${row.action} ([${row.severity}] ${row.summary})`);
|
|
430
|
+
if (row.action === "dismiss") {
|
|
431
|
+
console.log("dismissed: this fingerprint no longer blocks; an escalated severity reopens on its own");
|
|
432
|
+
}
|
|
433
|
+
} else {
|
|
434
|
+
throw new Error(`findings subcommand must be list|adjudicate, got: ${sub ?? "(none)"}`);
|
|
435
|
+
}
|
|
302
436
|
}
|
|
303
437
|
|
|
304
438
|
function commandEvents(flags) {
|
|
@@ -393,7 +527,7 @@ function commandObserve(rest) {
|
|
|
393
527
|
throw new Error("observe run requires --config <observe.yaml>");
|
|
394
528
|
}
|
|
395
529
|
const result = runObserveCycle({ configPath: flags.config });
|
|
396
|
-
console.log(`observe cycle ${result.cycle} finished (ledger: ${result.
|
|
530
|
+
console.log(`observe cycle ${result.cycle} finished (ledger: ${result.ledgerRef})`);
|
|
397
531
|
for (const row of result.results) {
|
|
398
532
|
const bands = row.bands.length > 0 ? ` bands=${row.bands.join(",")}` : "";
|
|
399
533
|
const intent = row.intent ? ` intent=${row.intent.outcome}:${row.intent.intentRef}` : "";
|
|
@@ -445,9 +579,9 @@ function commandObserve(rest) {
|
|
|
445
579
|
|
|
446
580
|
function main() {
|
|
447
581
|
const [command, ...rest] = process.argv.slice(2);
|
|
448
|
-
if (command === "observe") {
|
|
582
|
+
if (command === "observe" || command === "findings") {
|
|
449
583
|
try {
|
|
450
|
-
commandObserve(rest);
|
|
584
|
+
(command === "observe" ? commandObserve : commandFindings)(rest);
|
|
451
585
|
} catch (error) {
|
|
452
586
|
console.error(`error: ${error.message}`);
|
|
453
587
|
process.exitCode = 1;
|
|
@@ -480,6 +614,8 @@ function main() {
|
|
|
480
614
|
commandStatus(flags);
|
|
481
615
|
} else if (command === "stop") {
|
|
482
616
|
commandStop(flags);
|
|
617
|
+
} else if (command === "preflight") {
|
|
618
|
+
commandPreflight(flags);
|
|
483
619
|
} else {
|
|
484
620
|
process.stdout.write(USAGE);
|
|
485
621
|
process.exitCode = command ? 2 : 0;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Initial event type registry v1 per docs/v2/SPEC-0001-events-v1.md §4.
|
|
2
2
|
// Semantics are frozen; the registry and per-type data may only grow additively.
|
|
3
3
|
|
|
4
|
+
import { isAbsolute } from "node:path";
|
|
5
|
+
|
|
4
6
|
import {
|
|
5
7
|
ACTOR_KINDS,
|
|
6
8
|
BUDGET_KINDS,
|
|
@@ -58,6 +60,23 @@ const EVENT_ENUMS = {
|
|
|
58
60
|
TRIAGE_RECORDED: { action: TRIAGE_ACTIONS },
|
|
59
61
|
};
|
|
60
62
|
|
|
63
|
+
const REPO_REF_FIELDS = {
|
|
64
|
+
WORKSPACE_BOUND: ["repo", "worktreePath"],
|
|
65
|
+
EVIDENCE_RECORDED: ["evidenceRef"],
|
|
66
|
+
RUN_COMPACTED: ["runRecordRef"],
|
|
67
|
+
INTENT_DRAFTED: ["intentRef"],
|
|
68
|
+
TRIAGE_RECORDED: ["intentRef"],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function unsafeRepoRef(value) {
|
|
72
|
+
return (
|
|
73
|
+
typeof value !== "string" ||
|
|
74
|
+
value.length === 0 ||
|
|
75
|
+
isAbsolute(value) ||
|
|
76
|
+
/(^|[\\/])\.\.([\\/]|$)/.test(value)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
61
80
|
export class EventInputError extends Error {
|
|
62
81
|
constructor(message) {
|
|
63
82
|
super(message);
|
|
@@ -87,6 +106,11 @@ export function validateEventInput(type, actor, data) {
|
|
|
87
106
|
throw new EventInputError(`event ${type} missing required data field: ${field}`);
|
|
88
107
|
}
|
|
89
108
|
}
|
|
109
|
+
for (const field of REPO_REF_FIELDS[type] ?? []) {
|
|
110
|
+
if (unsafeRepoRef(data[field])) {
|
|
111
|
+
throw new EventInputError(`event ${type} field ${field} must be a repository-relative reference`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
90
114
|
const enums = EVENT_ENUMS[type];
|
|
91
115
|
if (enums) {
|
|
92
116
|
for (const [field, allowed] of Object.entries(enums)) {
|
package/src/v2/engine/reducer.js
CHANGED
|
@@ -140,6 +140,9 @@ export function applyEvent(state, event) {
|
|
|
140
140
|
status: data.status,
|
|
141
141
|
grade: data.grade,
|
|
142
142
|
...(data.findings ? { findings: data.findings } : {}),
|
|
143
|
+
...(data.suppressedFingerprints
|
|
144
|
+
? { suppressedFingerprints: data.suppressedFingerprints }
|
|
145
|
+
: {}),
|
|
143
146
|
});
|
|
144
147
|
break;
|
|
145
148
|
}
|
|
@@ -22,6 +22,7 @@ import { collectCommandEvidence } from "../evidence/collector.js";
|
|
|
22
22
|
import { EventLedger } from "../storage/event-ledger.js";
|
|
23
23
|
import { loadObserveConfig, severityRank } from "./observe-config.js";
|
|
24
24
|
import { OBSERVE_REDUCER } from "./observe-reducer.js";
|
|
25
|
+
import { toRepoRef } from "../runtime/repo-ref.js";
|
|
25
26
|
|
|
26
27
|
const OBSERVE_IDS = { run: "OBSERVE", work: "OBSERVE" };
|
|
27
28
|
const KERNEL_ACTOR = { kind: "kernel", id: "observe" };
|
|
@@ -142,7 +143,7 @@ function recordEvidence({ ledger, config, cycle, provider, execResult, kind, ste
|
|
|
142
143
|
type: "EVIDENCE_RECORDED",
|
|
143
144
|
actor: { kind: "provider", id: provider.id },
|
|
144
145
|
data: {
|
|
145
|
-
evidenceRef: record.location,
|
|
146
|
+
evidenceRef: toRepoRef(config.repoRoot, record.location),
|
|
146
147
|
kind: record.kind,
|
|
147
148
|
subject: record.subject,
|
|
148
149
|
digest: record.digest,
|
|
@@ -350,7 +351,13 @@ export function runObserveCycle({ configPath, now = new Date().toISOString() })
|
|
|
350
351
|
data: { cycle, providersRun: config.providers.length },
|
|
351
352
|
...OBSERVE_IDS,
|
|
352
353
|
});
|
|
353
|
-
return {
|
|
354
|
+
return {
|
|
355
|
+
cycle,
|
|
356
|
+
ledgerPath: ledger.path,
|
|
357
|
+
ledgerRef: toRepoRef(config.repoRoot, ledger.path),
|
|
358
|
+
results,
|
|
359
|
+
state: ledger.state,
|
|
360
|
+
};
|
|
354
361
|
}
|
|
355
362
|
|
|
356
363
|
export function triageIntent({ repoRoot, intentRef, action, by = "unknown", note, now = new Date().toISOString() }) {
|
|
@@ -13,6 +13,7 @@ import { evaluatePolicies, sha256Text } from "../policy/policy.js";
|
|
|
13
13
|
import { EventLedger } from "../storage/event-ledger.js";
|
|
14
14
|
import { acquireLock, readback, releaseLock } from "../workspace/workspace-manager.js";
|
|
15
15
|
import { writeRunRecord } from "./run-record.js";
|
|
16
|
+
import { resolveRepoRef } from "./repo-ref.js";
|
|
16
17
|
|
|
17
18
|
const KERNEL = { kind: "kernel", id: "orchestrator" };
|
|
18
19
|
|
|
@@ -68,10 +69,11 @@ export function approveRun(repoRoot, runId, { by = "human", transition, ts, poli
|
|
|
68
69
|
acquireLock(repoRoot, runId);
|
|
69
70
|
try {
|
|
70
71
|
const bound = ledger.state.workspaces[runId];
|
|
71
|
-
|
|
72
|
+
const worktreePath = bound ? resolveRepoRef(repoRoot, bound.worktreePath) : null;
|
|
73
|
+
if (!bound || !existsSync(worktreePath)) {
|
|
72
74
|
throw new DecisionError(`worktree missing for ${runId}; cannot verify the approval subject`);
|
|
73
75
|
}
|
|
74
|
-
const tree = readback(
|
|
76
|
+
const tree = readback(worktreePath);
|
|
75
77
|
const when = ts ?? new Date().toISOString();
|
|
76
78
|
if (tree.dirty || tree.head !== pending.subject.candidate) {
|
|
77
79
|
const lastEvidence = ledger.state.evidence[ledger.state.evidence.length - 1];
|
|
@@ -102,8 +104,8 @@ export function approveRun(repoRoot, runId, { by = "human", transition, ts, poli
|
|
|
102
104
|
state: ledger.state,
|
|
103
105
|
candidate: pending.subject.candidate,
|
|
104
106
|
workDir: join(repoRoot, "delivery", "work", ledger.state.run.work),
|
|
105
|
-
worktreePath
|
|
106
|
-
readWorktree: () => readback(
|
|
107
|
+
worktreePath,
|
|
108
|
+
readWorktree: () => readback(worktreePath),
|
|
107
109
|
},
|
|
108
110
|
);
|
|
109
111
|
for (const row of policyRows) {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Environment contract: a run config may declare the binaries (and minimum
|
|
2
|
+
// versions) its frozen envelope silently depends on, and the kernel checks
|
|
3
|
+
// them fail-closed before a run starts. Absorbed from real incidents: a
|
|
4
|
+
// frozen verifier needed `rg` that only a vendored PATH provided, candidate
|
|
5
|
+
// scripts assumed bash >= 4 on a /bin/bash 3.2 host, and a fresh shell
|
|
6
|
+
// resolved Node 14 — each burned runs before anyone saw the real cause.
|
|
7
|
+
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
export class EnvContractError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "EnvContractError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Version detection in --version output needs at least major.minor (a bare
|
|
18
|
+
// integer in arbitrary output is too easy to false-match); a declared min
|
|
19
|
+
// may be major-only ("20").
|
|
20
|
+
function parseVersion(text) {
|
|
21
|
+
const match = String(text).match(/(\d+)\.(\d+)(?:\.(\d+))?/);
|
|
22
|
+
if (!match) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseMin(value) {
|
|
29
|
+
const match = String(value).trim().match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
|
|
30
|
+
if (!match) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function compareVersions(left, right) {
|
|
37
|
+
for (let index = 0; index < 3; index += 1) {
|
|
38
|
+
if ((left[index] ?? 0) !== (right[index] ?? 0)) {
|
|
39
|
+
return (left[index] ?? 0) - (right[index] ?? 0);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Checks every entry and reports all problems at once (a run that dies on
|
|
46
|
+
// the first missing binary hides the second). Non-zero --version exits are
|
|
47
|
+
// tolerated; only a spawn failure means "not runnable".
|
|
48
|
+
export function checkRequires(requires) {
|
|
49
|
+
const problems = [];
|
|
50
|
+
const checked = [];
|
|
51
|
+
for (const entry of requires ?? []) {
|
|
52
|
+
if (!entry || typeof entry !== "object" || typeof entry.command !== "string" || entry.command.length === 0) {
|
|
53
|
+
problems.push(`requires entries need a command name, got: ${JSON.stringify(entry)}`);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// A generous timeout: a missing binary fails instantly (ENOENT), while a
|
|
57
|
+
// loaded host must not turn a present binary into a false "not runnable".
|
|
58
|
+
const probe = spawnSync(entry.command, [entry.versionFlag ?? "--version"], {
|
|
59
|
+
encoding: "utf8",
|
|
60
|
+
timeout: 30_000,
|
|
61
|
+
});
|
|
62
|
+
if (probe.error) {
|
|
63
|
+
problems.push(
|
|
64
|
+
`${entry.command}: not runnable in this environment (${probe.error.code ?? probe.error.message})`,
|
|
65
|
+
);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const version = parseVersion(`${probe.stdout ?? ""}\n${probe.stderr ?? ""}`);
|
|
69
|
+
if (entry.min !== undefined) {
|
|
70
|
+
const min = parseMin(entry.min);
|
|
71
|
+
if (!min) {
|
|
72
|
+
problems.push(`${entry.command}: min ${JSON.stringify(entry.min)} is not a version`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!version) {
|
|
76
|
+
problems.push(
|
|
77
|
+
`${entry.command}: version undetectable but min ${entry.min} is required (fail closed)`,
|
|
78
|
+
);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (compareVersions(version, min) < 0) {
|
|
82
|
+
problems.push(
|
|
83
|
+
`${entry.command}: resolves to ${version.join(".")}, below required ${entry.min}`,
|
|
84
|
+
);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
checked.push({ command: entry.command, version: version ? version.join(".") : null });
|
|
89
|
+
}
|
|
90
|
+
return { ok: problems.length === 0, problems, checked };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function assertRequires(requires) {
|
|
94
|
+
const result = checkRequires(requires);
|
|
95
|
+
if (!result.ok) {
|
|
96
|
+
throw new EnvContractError(
|
|
97
|
+
`environment contract not satisfied:\n - ${result.problems.join("\n - ")}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Review findings account per Work (Git plane, invariant 23: runtime stays
|
|
2
|
+
// deletable). Every reviewer finding lands as a row keyed by a fingerprint;
|
|
3
|
+
// human adjudications (accept/dismiss) append rows bound to that fingerprint.
|
|
4
|
+
// A dismissed fingerprint no longer blocks and later reviewers receive the
|
|
5
|
+
// adjudicated history as an anchor — re-litigating a settled verdict takes a
|
|
6
|
+
// human decision, not a louder fresh reviewer. (Absorbed from the 30-run
|
|
7
|
+
// deploy campaign: memoryless fresh reviewers oscillated between mutually
|
|
8
|
+
// exclusive prescriptions and re-litigated accepted designs.)
|
|
9
|
+
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
export const ADJUDICATION_ACTIONS = ["accept", "dismiss"];
|
|
15
|
+
|
|
16
|
+
export class FindingsError extends Error {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "FindingsError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function findingsAccountRef(workId) {
|
|
24
|
+
return `delivery/work/${workId}/review-findings.jsonl`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function accountPath(repoRoot, workId) {
|
|
28
|
+
return join(repoRoot, "delivery", "work", workId, "review-findings.jsonl");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Same-text findings from different fresh reviewers must collide; severity is
|
|
32
|
+
// part of the identity so an escalation (P2 -> P0) reopens on its own.
|
|
33
|
+
export function fingerprintFinding(finding) {
|
|
34
|
+
const normalized = `${finding.severity}|${finding.summary}`
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/\s+/g, " ")
|
|
37
|
+
.trim();
|
|
38
|
+
return createHash("sha256").update(normalized, "utf8").digest("hex").slice(0, 16);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function readFindingsAccount(repoRoot, workId) {
|
|
42
|
+
const filePath = accountPath(repoRoot, workId);
|
|
43
|
+
if (!existsSync(filePath)) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
const rows = [];
|
|
47
|
+
for (const line of readFileSync(filePath, "utf8").split("\n")) {
|
|
48
|
+
if (line.length === 0) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
rows.push(JSON.parse(line));
|
|
53
|
+
} catch {
|
|
54
|
+
throw new FindingsError(`findings account has an invalid line: ${filePath}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return rows;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function appendRow(repoRoot, workId, row) {
|
|
61
|
+
const dir = join(repoRoot, "delivery", "work", workId);
|
|
62
|
+
mkdirSync(dir, { recursive: true });
|
|
63
|
+
appendFileSync(join(dir, "review-findings.jsonl"), `${JSON.stringify(row)}\n`, "utf8");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Latest adjudication wins per fingerprint.
|
|
67
|
+
export function latestAdjudications(rows) {
|
|
68
|
+
const map = new Map();
|
|
69
|
+
for (const row of rows) {
|
|
70
|
+
if (row.kind === "adjudication") {
|
|
71
|
+
map.set(row.fingerprint, row);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return map;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Records one review attempt's findings into the account. Fingerprints that
|
|
78
|
+
// already have a finding row are not duplicated; a finding whose fingerprint
|
|
79
|
+
// a human dismissed is recorded as a re-raise attempt so the oscillation is
|
|
80
|
+
// visible in the account, not just absent from the routing.
|
|
81
|
+
export function recordReviewFindings(repoRoot, workId, { run, step, attempt, findings, ts }) {
|
|
82
|
+
const rows = readFindingsAccount(repoRoot, workId);
|
|
83
|
+
const adjudicated = latestAdjudications(rows);
|
|
84
|
+
const known = new Set(rows.filter((row) => row.kind === "finding").map((row) => row.fingerprint));
|
|
85
|
+
const recorded = [];
|
|
86
|
+
for (const [index, finding] of findings.entries()) {
|
|
87
|
+
const fingerprint = fingerprintFinding(finding);
|
|
88
|
+
const dismissed = adjudicated.get(fingerprint)?.action === "dismiss";
|
|
89
|
+
if (known.has(fingerprint) && !dismissed) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const row = {
|
|
93
|
+
ts,
|
|
94
|
+
kind: "finding",
|
|
95
|
+
run,
|
|
96
|
+
step,
|
|
97
|
+
attempt,
|
|
98
|
+
id: `F-${run}-${step}-${attempt}-${index + 1}`,
|
|
99
|
+
severity: finding.severity,
|
|
100
|
+
summary: finding.summary,
|
|
101
|
+
fingerprint,
|
|
102
|
+
...(dismissed ? { reRaised: true } : {}),
|
|
103
|
+
};
|
|
104
|
+
appendRow(repoRoot, workId, row);
|
|
105
|
+
known.add(fingerprint);
|
|
106
|
+
recorded.push(row);
|
|
107
|
+
}
|
|
108
|
+
return recorded;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function adjudicateFinding(repoRoot, workId, { fingerprint, action, by, note, ts }) {
|
|
112
|
+
if (!ADJUDICATION_ACTIONS.includes(action)) {
|
|
113
|
+
throw new FindingsError(`action must be one of ${ADJUDICATION_ACTIONS.join("|")}, got: ${action}`);
|
|
114
|
+
}
|
|
115
|
+
const rows = readFindingsAccount(repoRoot, workId);
|
|
116
|
+
const finding = rows.find((row) => row.kind === "finding" && row.fingerprint === fingerprint);
|
|
117
|
+
if (!finding) {
|
|
118
|
+
throw new FindingsError(
|
|
119
|
+
`no recorded finding with fingerprint ${fingerprint}; adjudications bind to recorded findings only`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const row = {
|
|
123
|
+
ts: ts ?? new Date().toISOString(),
|
|
124
|
+
kind: "adjudication",
|
|
125
|
+
fingerprint,
|
|
126
|
+
action,
|
|
127
|
+
by: by ?? "human",
|
|
128
|
+
...(note ? { note } : {}),
|
|
129
|
+
};
|
|
130
|
+
appendRow(repoRoot, workId, row);
|
|
131
|
+
return { ...row, summary: finding.summary, severity: finding.severity };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Compact anchor for worker input (BUILDBEAT_INPUT): the full adjudicated
|
|
135
|
+
// history plus open findings, capped so the env payload stays small.
|
|
136
|
+
const ANCHOR_CAP = 50;
|
|
137
|
+
const SUMMARY_CAP = 300;
|
|
138
|
+
|
|
139
|
+
export function buildAnchor(repoRoot, workId) {
|
|
140
|
+
const rows = readFindingsAccount(repoRoot, workId);
|
|
141
|
+
if (rows.length === 0) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
const adjudicated = latestAdjudications(rows);
|
|
145
|
+
const entries = rows
|
|
146
|
+
.filter((row) => row.kind === "finding")
|
|
147
|
+
.map((row) => ({
|
|
148
|
+
fingerprint: row.fingerprint,
|
|
149
|
+
severity: row.severity,
|
|
150
|
+
summary:
|
|
151
|
+
row.summary.length > SUMMARY_CAP ? `${row.summary.slice(0, SUMMARY_CAP)}…` : row.summary,
|
|
152
|
+
adjudication: adjudicated.get(row.fingerprint)?.action ?? "open",
|
|
153
|
+
}));
|
|
154
|
+
return {
|
|
155
|
+
account: findingsAccountRef(workId),
|
|
156
|
+
findings: entries.slice(-ANCHOR_CAP),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -26,6 +26,15 @@ import {
|
|
|
26
26
|
releaseLock,
|
|
27
27
|
} from "../workspace/workspace-manager.js";
|
|
28
28
|
import { writeRunRecord } from "./run-record.js";
|
|
29
|
+
import { assertRequires } from "./env-contract.js";
|
|
30
|
+
import {
|
|
31
|
+
buildAnchor,
|
|
32
|
+
fingerprintFinding,
|
|
33
|
+
latestAdjudications,
|
|
34
|
+
readFindingsAccount,
|
|
35
|
+
recordReviewFindings,
|
|
36
|
+
} from "./findings.js";
|
|
37
|
+
import { resolveRepoRef, toRepoRef } from "./repo-ref.js";
|
|
29
38
|
|
|
30
39
|
const KERNEL = { kind: "kernel", id: "orchestrator" };
|
|
31
40
|
|
|
@@ -135,6 +144,7 @@ function makeContext(options, ledger, workspace) {
|
|
|
135
144
|
workflow.budgets?.maxAttempts?.[step] ?? maxAttemptsPerStep;
|
|
136
145
|
context.policies = options.policies ?? [];
|
|
137
146
|
context.allowedPaths = options.allowedPaths ?? null;
|
|
147
|
+
context.reviewTriage = options.reviewTriage ?? null;
|
|
138
148
|
context.policyCtx = () => ({
|
|
139
149
|
state: ledger.state,
|
|
140
150
|
candidate: ledger.state.workspaces[workspace.workspaceId]?.candidate ?? null,
|
|
@@ -219,6 +229,14 @@ function settleOutcome(context, step, outcome, tree, exec) {
|
|
|
219
229
|
]);
|
|
220
230
|
return null;
|
|
221
231
|
}
|
|
232
|
+
// A step that failed its final attempt can never run again, so routing
|
|
233
|
+
// to fix would spend a worker on a candidate nothing can verify.
|
|
234
|
+
if ((ledger.state.steps[step]?.attempts ?? 0) >= context.maxAttemptsFor(step)) {
|
|
235
|
+
context.waitHuman(`resume-${step}`, [
|
|
236
|
+
`budget exhausted: ${step} failed its final attempt (maxAttempts=${context.maxAttemptsFor(step)}); not routing to fix`,
|
|
237
|
+
]);
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
222
240
|
}
|
|
223
241
|
const to = nextStep(workflow, step, outcome);
|
|
224
242
|
let result = "PASS";
|
|
@@ -337,11 +355,35 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
337
355
|
const outputsDir = join(context.runtimeDir, "runs", ledger.state.run.id, "outputs");
|
|
338
356
|
mkdirSync(outputsDir, { recursive: true });
|
|
339
357
|
const outputPath = join(outputsDir, `${step}-${attempt}.json`);
|
|
358
|
+
// Anchored review: readonly (reviewer) steps receive the adjudicated
|
|
359
|
+
// findings history so a fresh reviewer inherits settled verdicts instead
|
|
360
|
+
// of re-litigating them; writing steps get the latest review findings
|
|
361
|
+
// with their adjudication status (the fixer's worklist).
|
|
362
|
+
const input = { workId: ledger.state.run.work, runId: ledger.state.run.id, step, attempt };
|
|
363
|
+
const anchor = buildAnchor(context.repoRoot, ledger.state.run.work);
|
|
364
|
+
if (anchor && stepDef.readonly) {
|
|
365
|
+
input.anchor = anchor;
|
|
366
|
+
} else if (anchor) {
|
|
367
|
+
const lastReview = [...ledger.state.evidence]
|
|
368
|
+
.reverse()
|
|
369
|
+
.find((item) => item.kind === "review");
|
|
370
|
+
if (lastReview?.findings?.length) {
|
|
371
|
+
const adjudicated = latestAdjudications(
|
|
372
|
+
readFindingsAccount(context.repoRoot, ledger.state.run.work),
|
|
373
|
+
);
|
|
374
|
+
input.findings = lastReview.findings.map((finding) => ({
|
|
375
|
+
severity: finding.severity,
|
|
376
|
+
summary: finding.summary,
|
|
377
|
+
fingerprint: fingerprintFinding(finding),
|
|
378
|
+
adjudication: adjudicated.get(fingerprintFinding(finding))?.action ?? "open",
|
|
379
|
+
}));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
340
382
|
const exec = adapter.execute({
|
|
341
383
|
step,
|
|
342
384
|
worker: stepDef.worker,
|
|
343
385
|
workspacePath: workspace.worktreePath,
|
|
344
|
-
input
|
|
386
|
+
input,
|
|
345
387
|
timeoutMs: context.stepTimeoutMs,
|
|
346
388
|
outputPath,
|
|
347
389
|
});
|
|
@@ -359,7 +401,7 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
359
401
|
actor: KERNEL,
|
|
360
402
|
ts: now(),
|
|
361
403
|
data: {
|
|
362
|
-
evidenceRef: evidence.location,
|
|
404
|
+
evidenceRef: toRepoRef(context.repoRoot, evidence.location),
|
|
363
405
|
kind: evidence.kind,
|
|
364
406
|
subject: evidence.subject,
|
|
365
407
|
digest: evidence.digest,
|
|
@@ -432,21 +474,39 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
432
474
|
|
|
433
475
|
let blockingFindings = [];
|
|
434
476
|
if (envelope?.findings) {
|
|
435
|
-
|
|
436
|
-
|
|
477
|
+
recordReviewFindings(context.repoRoot, ledger.state.run.work, {
|
|
478
|
+
run: ledger.state.run.id,
|
|
479
|
+
step,
|
|
480
|
+
attempt,
|
|
481
|
+
findings: envelope.findings,
|
|
482
|
+
ts: now(),
|
|
483
|
+
});
|
|
484
|
+
// A fingerprint a human dismissed stays visible in the evidence but no
|
|
485
|
+
// longer blocks: settled verdicts do not reopen without a human.
|
|
486
|
+
const adjudicated = latestAdjudications(
|
|
487
|
+
readFindingsAccount(context.repoRoot, ledger.state.run.work),
|
|
437
488
|
);
|
|
489
|
+
const suppressed = [];
|
|
490
|
+
blockingFindings = envelope.findings.filter((finding) => {
|
|
491
|
+
if (adjudicated.get(fingerprintFinding(finding))?.action === "dismiss") {
|
|
492
|
+
suppressed.push(fingerprintFinding(finding));
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
return finding.severity === "P0" || finding.severity === "P1";
|
|
496
|
+
});
|
|
438
497
|
ledger.append({
|
|
439
498
|
type: "EVIDENCE_RECORDED",
|
|
440
499
|
actor: KERNEL,
|
|
441
500
|
ts: now(),
|
|
442
501
|
data: {
|
|
443
|
-
evidenceRef: outputPath,
|
|
502
|
+
evidenceRef: toRepoRef(context.repoRoot, outputPath),
|
|
444
503
|
kind: "review",
|
|
445
504
|
subject: tree.head,
|
|
446
505
|
digest: sha256(canonicalJson(envelope)),
|
|
447
506
|
status: blockingFindings.length > 0 ? "failed" : "passed",
|
|
448
507
|
grade: "L2",
|
|
449
508
|
findings: envelope.findings,
|
|
509
|
+
...(suppressed.length > 0 ? { suppressedFingerprints: suppressed } : {}),
|
|
450
510
|
},
|
|
451
511
|
});
|
|
452
512
|
}
|
|
@@ -531,7 +591,29 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
531
591
|
} else {
|
|
532
592
|
outcome = "succeeded";
|
|
533
593
|
}
|
|
534
|
-
|
|
594
|
+
const routed = settleOutcome(context, step, outcome, tree, exec);
|
|
595
|
+
// Finding triage gate (reviewTriage: required): blocking findings stop
|
|
596
|
+
// for a human verdict before any fixer runs. Findings are prescriptions,
|
|
597
|
+
// not facts — auto-routing them to a fixer burned four oscillation
|
|
598
|
+
// rounds in the deploy campaign before a human stopped the loop.
|
|
599
|
+
if (routed && outcome === "findings-blocking" && context.reviewTriage === "required") {
|
|
600
|
+
context.waitHuman(
|
|
601
|
+
`enter-${routed}`,
|
|
602
|
+
[
|
|
603
|
+
`review found ${blockingFindings.length} blocking finding(s); triage before ${routed} runs`,
|
|
604
|
+
...blockingFindings
|
|
605
|
+
.slice(0, 5)
|
|
606
|
+
.map(
|
|
607
|
+
(finding) =>
|
|
608
|
+
`[${finding.severity} ${fingerprintFinding(finding)}] ${finding.summary.slice(0, 200)}`,
|
|
609
|
+
),
|
|
610
|
+
`adjudicate fingerprints (findings adjudicate), then approve enter-${routed} or reject the run`,
|
|
611
|
+
],
|
|
612
|
+
"finding-triage",
|
|
613
|
+
);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
step = routed;
|
|
535
617
|
}
|
|
536
618
|
}
|
|
537
619
|
|
|
@@ -568,6 +650,11 @@ export function startRun(options) {
|
|
|
568
650
|
if (!workflowDigest) {
|
|
569
651
|
throw new OrchestratorError("workflowDigest is required (pin what you run)");
|
|
570
652
|
}
|
|
653
|
+
// Environment contract first: a missing or too-old binary fails the start
|
|
654
|
+
// with a readable cause instead of burning a run on an implicit PATH fact.
|
|
655
|
+
if (options.requires?.length) {
|
|
656
|
+
assertRequires(options.requires);
|
|
657
|
+
}
|
|
571
658
|
const { ledger, ledgerPath } = openLedgerFor(repoRoot, runId);
|
|
572
659
|
if (ledger.events.length > 0) {
|
|
573
660
|
throw new OrchestratorError(`run ${runId} already has a ledger; use resumeRun`);
|
|
@@ -600,9 +687,9 @@ export function startRun(options) {
|
|
|
600
687
|
ts: now(),
|
|
601
688
|
data: {
|
|
602
689
|
workspaceId: workspace.workspaceId,
|
|
603
|
-
repo: repoRoot,
|
|
690
|
+
repo: toRepoRef(repoRoot, repoRoot),
|
|
604
691
|
branch: workspace.branch,
|
|
605
|
-
worktreePath: workspace.worktreePath,
|
|
692
|
+
worktreePath: toRepoRef(repoRoot, workspace.worktreePath),
|
|
606
693
|
base: workspace.base,
|
|
607
694
|
},
|
|
608
695
|
});
|
|
@@ -626,6 +713,9 @@ export function resumeRun(options) {
|
|
|
626
713
|
if (!repoRoot || !runId) {
|
|
627
714
|
throw new OrchestratorError("repoRoot and runId are required");
|
|
628
715
|
}
|
|
716
|
+
if (options.requires?.length) {
|
|
717
|
+
assertRequires(options.requires);
|
|
718
|
+
}
|
|
629
719
|
const { ledger, ledgerPath } = openLedgerFor(repoRoot, runId);
|
|
630
720
|
const state = ledger.state;
|
|
631
721
|
if (!state.run) {
|
|
@@ -647,15 +737,16 @@ export function resumeRun(options) {
|
|
|
647
737
|
if (!bound) {
|
|
648
738
|
throw new OrchestratorError(`run ${runId} has no bound workspace; cannot resume`);
|
|
649
739
|
}
|
|
650
|
-
|
|
740
|
+
const worktreePath = resolveRepoRef(repoRoot, bound.worktreePath);
|
|
741
|
+
if (!existsSync(worktreePath)) {
|
|
651
742
|
throw new OrchestratorError(
|
|
652
|
-
|
|
743
|
+
"worktree missing; recovery requires a human decision",
|
|
653
744
|
);
|
|
654
745
|
}
|
|
655
746
|
const workspace = {
|
|
656
747
|
workspaceId: runId,
|
|
657
748
|
repoRoot,
|
|
658
|
-
worktreePath
|
|
749
|
+
worktreePath,
|
|
659
750
|
branch: bound.branch,
|
|
660
751
|
base: bound.base,
|
|
661
752
|
};
|
|
@@ -727,12 +818,12 @@ export function resumeRun(options) {
|
|
|
727
818
|
]);
|
|
728
819
|
return { runId, ledgerPath, state: ledger.state, resumed: true, reason: null };
|
|
729
820
|
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
821
|
+
// An interrupted attempt says nothing about the candidate, so the step
|
|
822
|
+
// itself reruns (the lost attempt still counts against its budget)
|
|
823
|
+
// instead of settling as a step failure — routing a crash through the
|
|
824
|
+
// failure edge dispatched a fixer with no verifier evidence (real
|
|
825
|
+
// incident: deploy-18's verify worker was killed by a host timeout).
|
|
826
|
+
startStep = step;
|
|
736
827
|
} else if (tree.dirty) {
|
|
737
828
|
context.waitHuman("resume-run", [
|
|
738
829
|
"worktree is dirty at resume with no step in flight; human triage required",
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
|
+
|
|
3
|
+
function outsideRepo(ref) {
|
|
4
|
+
return ref === ".." || ref.startsWith(`..${sep}`) || isAbsolute(ref);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// Runtime operations use absolute paths, but ledger/run-record references are
|
|
8
|
+
// durable evidence and must not capture host-specific checkout locations.
|
|
9
|
+
// Keep those references repository-relative and fail closed if a caller tries
|
|
10
|
+
// to publish something outside the repository.
|
|
11
|
+
export function toRepoRef(repoRoot, targetPath) {
|
|
12
|
+
const root = resolve(repoRoot);
|
|
13
|
+
const target = isAbsolute(targetPath) ? resolve(targetPath) : resolve(root, targetPath);
|
|
14
|
+
const ref = relative(root, target);
|
|
15
|
+
if (outsideRepo(ref)) {
|
|
16
|
+
throw new Error("runtime reference is outside the repository");
|
|
17
|
+
}
|
|
18
|
+
return ref === "" ? "." : ref.split(sep).join("/");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Compatibility boundary: ledgers written before repo-relative references
|
|
22
|
+
// contain absolute paths. They remain readable, while all newly written
|
|
23
|
+
// events and compacted records use toRepoRef().
|
|
24
|
+
export function resolveRepoRef(repoRoot, ref) {
|
|
25
|
+
if (typeof ref !== "string" || ref === "") {
|
|
26
|
+
throw new Error("runtime reference must be a non-empty string");
|
|
27
|
+
}
|
|
28
|
+
const root = resolve(repoRoot);
|
|
29
|
+
const target = isAbsolute(ref) ? resolve(ref) : resolve(root, ref);
|
|
30
|
+
if (outsideRepo(relative(root, target))) {
|
|
31
|
+
throw new Error("runtime reference is outside the repository");
|
|
32
|
+
}
|
|
33
|
+
return target;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function normalizeRepoRef(repoRoot, ref) {
|
|
37
|
+
return toRepoRef(repoRoot, resolveRepoRef(repoRoot, ref));
|
|
38
|
+
}
|
|
@@ -8,6 +8,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
|
|
8
8
|
import { join, relative } from "node:path";
|
|
9
9
|
|
|
10
10
|
import { canonicalJson } from "../storage/event-ledger.js";
|
|
11
|
+
import { normalizeRepoRef } from "./repo-ref.js";
|
|
11
12
|
|
|
12
13
|
const KERNEL = { kind: "kernel", id: "orchestrator" };
|
|
13
14
|
|
|
@@ -22,6 +23,20 @@ export function writeRunRecord({ repoRoot, ledger, ts }) {
|
|
|
22
23
|
for (const [step, info] of Object.entries(state.steps)) {
|
|
23
24
|
attempts[step] = info.attempts;
|
|
24
25
|
}
|
|
26
|
+
const workspaces = Object.fromEntries(
|
|
27
|
+
Object.entries(state.workspaces).map(([id, workspace]) => [
|
|
28
|
+
id,
|
|
29
|
+
{
|
|
30
|
+
...workspace,
|
|
31
|
+
repo: normalizeRepoRef(repoRoot, workspace.repo),
|
|
32
|
+
worktreePath: normalizeRepoRef(repoRoot, workspace.worktreePath),
|
|
33
|
+
},
|
|
34
|
+
]),
|
|
35
|
+
);
|
|
36
|
+
const evidence = state.evidence.map((item) => ({
|
|
37
|
+
...item,
|
|
38
|
+
ref: normalizeRepoRef(repoRoot, item.ref),
|
|
39
|
+
}));
|
|
25
40
|
const record = {
|
|
26
41
|
run: first.run,
|
|
27
42
|
work: first.work,
|
|
@@ -31,11 +46,11 @@ export function writeRunRecord({ repoRoot, ledger, ts }) {
|
|
|
31
46
|
finishedAt: last.ts,
|
|
32
47
|
attempts,
|
|
33
48
|
budgets: state.budgets,
|
|
34
|
-
workspaces
|
|
35
|
-
evidence
|
|
49
|
+
workspaces,
|
|
50
|
+
evidence,
|
|
36
51
|
decisions: state.decisions,
|
|
37
52
|
approvals: state.approvals,
|
|
38
|
-
unverified:
|
|
53
|
+
unverified: evidence
|
|
39
54
|
.filter((item) => item.status === "unverified")
|
|
40
55
|
.map((item) => item.ref),
|
|
41
56
|
};
|
|
@@ -19,7 +19,10 @@ function git(cwd, args) {
|
|
|
19
19
|
return execFileSync("git", ["-C", cwd, ...args], {
|
|
20
20
|
encoding: "utf8",
|
|
21
21
|
stdio: ["ignore", "pipe", "pipe"],
|
|
22
|
-
|
|
22
|
+
// Preserve porcelain's fixed-width leading status columns. Trimming the
|
|
23
|
+
// first leading space turns " M file" into "M file" and makes the
|
|
24
|
+
// downstream slice(3) drop the first path character.
|
|
25
|
+
}).trimEnd();
|
|
23
26
|
} catch (error) {
|
|
24
27
|
const stderr = error.stderr ? String(error.stderr).trim() : error.message;
|
|
25
28
|
throw new WorkspaceError(`git ${args.join(" ")} failed: ${stderr}`);
|