@tea-agent/loop-agent 0.15.0 → 0.16.1-beta.0
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 +7 -11
- package/dist/executors/dag-pi-executor.js +44 -4
- package/dist/worker/cli.js +6 -3
- package/dist/worker/delivery/final-verification.js +96 -8
- package/dist/worker/delivery/package.js +23 -4
- package/dist/worker/delivery/verification-bundle.js +510 -0
- package/dist/worker/feature/fullstack-validate.js +337 -0
- package/dist/worker/feature/profile-schema.js +44 -0
- package/dist/worker/feature/ready-plan-projection.js +1 -0
- package/dist/worker/feature/reducer.js +2 -0
- package/dist/worker/feature/review.js +105 -11
- package/dist/worker/materialize/harness-task-materializer.js +5 -0
- package/dist/worker/observability/read-model.js +7 -0
- package/dist/worker/observe/static/views/task.js +1 -0
- package/dist/worker/outcomes/adapters.js +141 -0
- package/dist/worker/outcomes/gate.js +41 -0
- package/dist/worker/outcomes/projector.js +176 -0
- package/dist/worker/outcomes/registry.js +1 -0
- package/dist/worker/outcomes/store.js +131 -0
- package/dist/worker/outcomes/types.js +76 -0
- package/dist/worker/report/morning-report.js +4 -3
- package/dist/worker/run-task/run-task.js +66 -2
- package/dist/worker/runner/run-ready.js +32 -1
- package/dist/worker/task-graph/acceptance-schema.js +12 -0
- package/dist/worker/task-graph/ready-planner.js +125 -0
- package/dist/worker/task-graph/task-graph-schema.js +29 -0
- package/dist/worker/task-graph/validate.js +44 -4
- package/dist/worker/task-spec/schema.js +9 -0
- package/dist/worker/task-spec/validate.js +39 -0
- package/dist/worker/task-spec/workflow-routing.js +149 -0
- package/dist/workflows/dag/init-hybrid.js +3 -2
- package/dist/workflows/dag/types.js +1 -0
- package/docs/templates/agent-dag.schema.json +5 -0
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/frontend-implementation/references/node-contracts.md +2 -2
- package/skills/loop-agent/references/hybrid-dag.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -59,17 +59,13 @@
|
|
|
59
59
|
|
|
60
60
|
## [Unreleased]
|
|
61
61
|
|
|
62
|
-
-
|
|
63
|
-
-
|
|
64
|
-
- 新增
|
|
65
|
-
-
|
|
66
|
-
-
|
|
67
|
-
-
|
|
68
|
-
-
|
|
69
|
-
- 发布说明支持 OpenAI-compatible 模型,模型不可用时自动使用保守摘要。
|
|
70
|
-
- 修复 PR #34 发布风险:合并到 main 后包版本被错误提升为 `0.14.1-beta.0`,现已恢复为 `0.14.0`;发布门禁由仅按 frontend 分支判断改为按版本号的 prerelease 状态限制 dist-tag(beta prerelease 只允许 `beta` tag,使用 `latest`/空/默认 tag 时 fail-closed;非 beta prerelease 如 alpha/rc 全局拒绝),避免在 main 上执行默认 `npm publish` 时把 prerelease 误发布到 `latest`。
|
|
71
|
-
- `frontend` 分支继续保留本地 npm beta 发布门禁,并在合入 main 0.14.0 后把测试版本线调整为 `0.14.1-beta.0`,避免误推正式版本或 `latest`。
|
|
72
|
-
- 前端实现 DAG 的 `frontendMock.policy=auto` 不再因为目标项目缺少 Mock 能力而阻塞;无确认 Mock 能力时跳过 Mock、保留真实请求默认路径,并在未真实联调时记录 `Frontend status: locally-validated` 与 `Real integration: pending`。
|
|
62
|
+
- 修复前端 Mock 策略节点偶尔因解释性前言出现在 `MOCK_STRATEGY:` 之前而被 contract gate 误判失败的问题;节点现在通过显式首行协议生成稳定 canonical 输出,同时保留全局 `first-non-empty` 精确校验和原始 Pi 执行证据。
|
|
63
|
+
- `agent-worker feature verify-final` 现可聚合 hash-bound `backend-test` / `frontend-test` Outcome,生成 Feature Verification Bundle v1;Delivery 与 Closeout 会重新验证 bundle、typed outcome、Feature/Task/workflow/controller identity 和 Delivery HEAD,任一漂移或篡改都会阻止交付。既有 `qa-execute` QA aggregate 继续兼容。
|
|
64
|
+
- 新增 `fullstack-v1` Feature Profile:Feature Packet 可选 `feature.yaml` 声明 profile 与 scope,`agent-worker task validate-feature` 对 `fullstack-v1` packet 执行确定性结构门禁(required backend/frontend/backend-test/frontend-test workflow、frontend-test 依赖实现与后端验证、final-verify 覆盖、测试 workflow 不含产品写路径、writer writeSet 重叠须串行)。fullstack profile 必须有 scope;required AC 必须声明实现、验证与 evidence refs,任一门禁失败 fail closed 并输出阶段缺口与修复建议;未声明 profile 的 generic/legacy packet 行为零变化。
|
|
65
|
+
- AcceptanceSpec `verification` 新增全部 optional 的 `implementation_task_refs`、`verification_task_refs`、`required_evidence`、`integration`(`not-applicable`/`mock-allowed`/`real-required`),保留 legacy `expected_task_refs`,schema 仍为 `.strict()`;Feature review 的 coverage 投影在 dual-mode 下复用 Task Outcome envelope 的 `integrationStatus`:实现 Done 但缺验证 evidence、或 `real-required` 仅由 mock/local 满足时,required AC 派生为 `awaiting-verification`(只读派生,非新状态机),Feature 不得被报告为 deliverable。
|
|
66
|
+
- 为 TaskGraph 节点新增可选 `consumes`/`produces` 声明与 Ready Planner artifact gate:上游 Done 但必需产物(路径/sha256/schema/Feature/producer/source binding)不可验证时,下游以 `required-artifact-missing` 阻塞;artifact 资格是额外门禁,priority 不得越过 gate。gate 原因经 `ReadyExecutionPlan.blocked[*].artifactGate` 投影,供 Feature review、晨报与 Observe 共用。
|
|
67
|
+
- Worker 现为四类 TaskSpec workflow 生成 hash-anchored Task Outcome,并在 promotion 前校验 `run_record`、`dag_json`、`shell_verification` 等已知 required output;Feature review、晨报与 Observe 可引用 outcome 证据,缺失或不一致会以 ContractMismatch/EnvFailure 失败收口。
|
|
68
|
+
- TaskSpec 现支持可选 `execution.workflow`,可确定性物化为任务 DAG 类型并展示在 Worker、Task Pool、晨报与 Observe;旧 QA 任务保持原有执行行为,同时给出需人工选择测试 workflow 的迁移提示。
|
|
73
69
|
|
|
74
70
|
## [0.13.0] - 2026-07-18
|
|
75
71
|
|
|
@@ -272,7 +272,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
|
|
|
272
272
|
persona,
|
|
273
273
|
step,
|
|
274
274
|
});
|
|
275
|
-
const mapped = mapPiResultToDagNodeResult(result);
|
|
275
|
+
const mapped = mapPiResultToDagNodeResult(result, input.task.firstProtocolLine);
|
|
276
276
|
if (!isWriteTask) {
|
|
277
277
|
return mapped;
|
|
278
278
|
}
|
|
@@ -313,17 +313,57 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
|
|
|
313
313
|
durationMs: mapped.durationMs || Date.now() - started,
|
|
314
314
|
};
|
|
315
315
|
}
|
|
316
|
-
export function mapPiResultToDagNodeResult(result) {
|
|
316
|
+
export function mapPiResultToDagNodeResult(result, firstProtocolLine) {
|
|
317
|
+
const assistantText = canonicalizeProtocolFirstLine(result.assistantText, firstProtocolLine);
|
|
317
318
|
return {
|
|
318
319
|
ok: result.ok,
|
|
319
|
-
stdout:
|
|
320
|
+
stdout: assistantText || result.stdout,
|
|
320
321
|
stderr: result.stderr,
|
|
321
322
|
failureCategory: result.failureCategory,
|
|
322
323
|
durationMs: result.durationMs,
|
|
323
|
-
assistantText
|
|
324
|
+
assistantText,
|
|
324
325
|
backend: result.backend,
|
|
325
326
|
sdkAttempted: result.sdkAttempted,
|
|
326
327
|
tokensUsed: result.tokensUsed,
|
|
327
328
|
parsedEvents: result.parsedEvents,
|
|
328
329
|
};
|
|
329
330
|
}
|
|
331
|
+
function canonicalizeProtocolFirstLine(assistantText, firstProtocolLine) {
|
|
332
|
+
if (!assistantText || !firstProtocolLine)
|
|
333
|
+
return assistantText;
|
|
334
|
+
const lines = assistantText.split(/\r?\n/);
|
|
335
|
+
let protocolIndex = -1;
|
|
336
|
+
let protocolLine = "";
|
|
337
|
+
for (const [index, line] of lines.entries()) {
|
|
338
|
+
const normalized = normalizeProtocolLine(line);
|
|
339
|
+
if (normalized.startsWith(firstProtocolLine)) {
|
|
340
|
+
protocolIndex = index;
|
|
341
|
+
protocolLine = normalized;
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (protocolIndex < 0)
|
|
346
|
+
return assistantText;
|
|
347
|
+
if (protocolIndex === 0) {
|
|
348
|
+
return [protocolLine, ...lines.slice(1)].join("\n");
|
|
349
|
+
}
|
|
350
|
+
const before = lines.slice(0, protocolIndex);
|
|
351
|
+
const after = lines.slice(protocolIndex + 1);
|
|
352
|
+
while (before.at(-1)?.trim() === "" &&
|
|
353
|
+
after.at(0)?.trim() === "") {
|
|
354
|
+
after.shift();
|
|
355
|
+
}
|
|
356
|
+
const bodyLines = [...before, ...after];
|
|
357
|
+
while (bodyLines.at(0)?.trim() === "")
|
|
358
|
+
bodyLines.shift();
|
|
359
|
+
while (bodyLines.at(-1)?.trim() === "")
|
|
360
|
+
bodyLines.pop();
|
|
361
|
+
return bodyLines.length > 0
|
|
362
|
+
? `${protocolLine}\n\n${bodyLines.join("\n")}`
|
|
363
|
+
: protocolLine;
|
|
364
|
+
}
|
|
365
|
+
function normalizeProtocolLine(line) {
|
|
366
|
+
const trimmed = line.trim();
|
|
367
|
+
const emphasized = trimmed.match(/^(\*{1,3})\s*(.*?)\s*\1$/);
|
|
368
|
+
return (emphasized?.[2] ?? trimmed).trim();
|
|
369
|
+
}
|
package/dist/worker/cli.js
CHANGED
|
@@ -176,17 +176,20 @@ export function buildAgentWorkerProgram() {
|
|
|
176
176
|
.command("verify-final")
|
|
177
177
|
.requiredOption("--feature-dir <dir>", "Feature directory containing acceptance.yaml and tasks/")
|
|
178
178
|
.requiredOption("--repo <repo-root>", "Target repo root")
|
|
179
|
-
.requiredOption("--task-id <id>", "Completed
|
|
179
|
+
.requiredOption("--task-id <id>", "Completed final verification TaskSpec; typed backend/frontend outcomes are aggregated when present")
|
|
180
180
|
.option("--loop-agent-bin <bin>", "loop-agent binary", "loop-agent")
|
|
181
181
|
.option("--json", "Emit stable JSON")
|
|
182
|
+
.option("--evidence-mode <mode>", "Evidence aggregate: auto, qa-execute, or typed", "auto")
|
|
182
183
|
.option("--expected-controller-version <version>", "Exact expected controller version (fail before writes if mismatched)")
|
|
183
184
|
.option("--expected-controller-fingerprint <value>", "Exact expected controller fingerprint sha256:<hex> (fail before writes if mismatched)")
|
|
184
185
|
.description("Run an independent HEAD-bound final QA verification and project Delivery evidence")
|
|
185
186
|
.action(async (options) => {
|
|
187
|
+
if (options.evidenceMode !== "auto" && options.evidenceMode !== "qa-execute" && options.evidenceMode !== "typed")
|
|
188
|
+
throw new Error("feature verify-final --evidence-mode must be auto, qa-execute, or typed");
|
|
186
189
|
const repoRoot = path.resolve(options.repo);
|
|
187
190
|
const client = new LoopAgentClient({ loopAgentBin: options.loopAgentBin, artifactRoot: path.join(getTaskPoolRoot(repoRoot), "artifacts", `final-verification-${Date.now()}`), resolveIdentity: true });
|
|
188
|
-
const result = await runFeatureFinalVerification({ featureDir: path.resolve(options.featureDir), repoRoot, taskId: options.taskId, client, controllerIdentity: client.getIdentity(), controllerExpectation: buildIdentityExpectation(options) });
|
|
189
|
-
process.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : `Feature: ${result.featureId}\nFinal verification: ${result.workerRunId}\nQA evidence: ${result.qaEvidencePath}\nFinal evidence: ${result.finalVerificationPath}\n`);
|
|
191
|
+
const result = await runFeatureFinalVerification({ featureDir: path.resolve(options.featureDir), repoRoot, taskId: options.taskId, client, controllerIdentity: client.getIdentity(), controllerExpectation: buildIdentityExpectation(options), evidenceMode: options.evidenceMode });
|
|
192
|
+
process.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : `Feature: ${result.featureId}\nFinal verification: ${result.workerRunId}\nEvidence mode: ${result.evidenceMode ?? "qa-execute"}\nQA evidence: ${result.qaEvidencePath}\nFinal evidence: ${result.finalVerificationPath}${result.bundleEvidencePath ? `\nVerification bundle: ${result.bundleEvidencePath}` : ""}\n`);
|
|
190
193
|
});
|
|
191
194
|
feature
|
|
192
195
|
.command("delivery")
|
|
@@ -8,11 +8,13 @@ import { controllerIdentitiesMatch, controllerIdentityExpectationFailure, resolv
|
|
|
8
8
|
import { acceptanceSpecSchema } from "../task-graph/acceptance-schema.js";
|
|
9
9
|
import { taskGraphSpecSchema } from "../task-graph/task-graph-schema.js";
|
|
10
10
|
import { validateFeatureTaskGraph } from "../task-graph/validate.js";
|
|
11
|
-
import { getRunsJsonlPath, getTaskPoolRoot, readJsonlFile, recordTaskPoolRun } from "../pool/run-store.js";
|
|
11
|
+
import { getRunsJsonlPath, getTaskPoolRoot, readFeatureTaskPoolStates, readJsonlFile, recordTaskPoolRun } from "../pool/run-store.js";
|
|
12
|
+
import { readVerifiedOutcome } from "../outcomes/store.js";
|
|
12
13
|
import { buildWorkerRunId, runTaskSpec } from "../run-task/run-task.js";
|
|
13
14
|
import { taskSpecSchema } from "../task-spec/schema.js";
|
|
14
15
|
import { preflightTargetRepo } from "../preflight.js";
|
|
15
16
|
import { gitTransactionRecordSchema, transactionRecordPath } from "./git-transaction.js";
|
|
17
|
+
import { buildFeatureVerificationBundle, writeFeatureVerificationBundle, } from "./verification-bundle.js";
|
|
16
18
|
const execFileAsync = promisify(execFile);
|
|
17
19
|
export async function runFeatureFinalVerification(input) {
|
|
18
20
|
const dependencies = {
|
|
@@ -103,11 +105,60 @@ export async function runFeatureFinalVerification(input) {
|
|
|
103
105
|
allSpecs.set(graphNode.id, taskSpecSchema.parse(YAML.parse(await readFile(path.join(featureDir, "tasks", graphNode.task), "utf-8"))));
|
|
104
106
|
}
|
|
105
107
|
const acceptance = acceptanceSpecSchema.parse(YAML.parse(await readFile(path.join(featureDir, "acceptance.yaml"), "utf-8")));
|
|
106
|
-
const qaRuns = latestSuccessfulQaRuns(runs, allSpecs, taskSpec.id, featureId);
|
|
107
|
-
if (qaRuns.length === 0)
|
|
108
|
-
throw new Error("no prior successful qa-execute runs are available for the QA aggregate");
|
|
109
|
-
const coveredAcIds = [...new Set(qaRuns.flatMap((run) => allSpecs.get(run.taskId)?.acceptance_refs ?? []))];
|
|
110
108
|
const requiredAcIds = acceptance.acceptance.filter((item) => item.priority === "must").map((item) => item.id);
|
|
109
|
+
const states = await readFeatureTaskPoolStates(repoRoot, featureId);
|
|
110
|
+
for (const [taskId, spec] of allSpecs) {
|
|
111
|
+
if ((spec.execution?.workflow === "backend-test" || spec.execution?.workflow === "frontend-test") && ["Failed", "Blocked"].includes(states[taskId]?.status ?? "")) {
|
|
112
|
+
throw new Error(`final verification has an unresolved typed test task: ${taskId}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const evidenceMode = input.evidenceMode ?? "auto";
|
|
116
|
+
let bundleRef;
|
|
117
|
+
let bundlePayload;
|
|
118
|
+
let qaRuns = latestSuccessfulQaRuns(runs, allSpecs, taskSpec.id, featureId);
|
|
119
|
+
let coveredAcIds = [...new Set(qaRuns.flatMap((run) => allSpecs.get(run.taskId)?.acceptance_refs ?? []))];
|
|
120
|
+
const typedCapable = requiredAcIds.length > 0 && requiredAcIds.every((acId) => {
|
|
121
|
+
const item = acceptance.acceptance.find((candidate) => candidate.id === acId);
|
|
122
|
+
const refs = item?.verification.verification_task_refs ?? [];
|
|
123
|
+
return refs.length > 0 && refs.every((taskId) => {
|
|
124
|
+
const workflow = allSpecs.get(taskId)?.execution?.workflow;
|
|
125
|
+
return workflow === "backend-test" || workflow === "frontend-test";
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
if (evidenceMode === "qa-execute" && !qaRuns.length) {
|
|
129
|
+
throw new Error("evidence-mode qa-execute requires at least one successful qa-execute run");
|
|
130
|
+
}
|
|
131
|
+
if ((evidenceMode === "auto" && typedCapable) || evidenceMode === "typed") {
|
|
132
|
+
const typedRuns = selectCurrentTypedVerificationRuns({
|
|
133
|
+
featureId,
|
|
134
|
+
acceptance: acceptance.acceptance,
|
|
135
|
+
specs: allSpecs,
|
|
136
|
+
states,
|
|
137
|
+
runs,
|
|
138
|
+
});
|
|
139
|
+
const outcomes = new Map();
|
|
140
|
+
for (const run of typedRuns) {
|
|
141
|
+
const outcome = await readVerifiedOutcome({ repoRoot, workerRunId: run.workerRunId, outcomePath: run.outcomePath, outcomeSha256: run.outcomeSha256 });
|
|
142
|
+
if (outcome)
|
|
143
|
+
outcomes.set(run.workerRunId, outcome);
|
|
144
|
+
}
|
|
145
|
+
const bundle = buildFeatureVerificationBundle({
|
|
146
|
+
featureId,
|
|
147
|
+
headSha,
|
|
148
|
+
runs: typedRuns,
|
|
149
|
+
specs: allSpecs,
|
|
150
|
+
outcomes,
|
|
151
|
+
acceptance: acceptance.acceptance,
|
|
152
|
+
implementationTaskIds: new Set(acceptance.acceptance.flatMap((item) => item.verification.implementation_task_refs ?? []).filter((taskId) => states[taskId]?.status === "Done")),
|
|
153
|
+
controllerIdentity,
|
|
154
|
+
});
|
|
155
|
+
bundleRef = await writeFeatureVerificationBundle(repoRoot, bundle);
|
|
156
|
+
bundlePayload = bundle;
|
|
157
|
+
qaRuns = typedRuns;
|
|
158
|
+
coveredAcIds = bundle.acceptance.filter((item) => item.status === "covered").map((item) => item.acId);
|
|
159
|
+
}
|
|
160
|
+
if (qaRuns.length === 0)
|
|
161
|
+
throw new Error("no prior successful typed test or qa-execute runs are available for the QA aggregate");
|
|
111
162
|
for (const acId of requiredAcIds)
|
|
112
163
|
if (!coveredAcIds.includes(acId))
|
|
113
164
|
throw new Error(`QA aggregate does not cover required acceptance: ${acId}`);
|
|
@@ -118,10 +169,11 @@ export async function runFeatureFinalVerification(input) {
|
|
|
118
169
|
const qaEvidencePath = path.join(evidenceDir, "qa-pass.json");
|
|
119
170
|
const finalVerificationPath = path.join(evidenceDir, "final-verification.json");
|
|
120
171
|
await writeEvidencePairAtomic(evidenceDir, {
|
|
121
|
-
qa: { schemaVersion: 1, featureId, verdict: "passed", acIds: requiredAcIds, runs: qaRuns.map(runRef) },
|
|
122
|
-
final: { schemaVersion: 1, featureId, kind: "final-verification", status: "passed", headSha, run: runRef(finalRun), shellSummary: { path: summaryRelative, sha256: createHash("sha256").update(summary).digest("hex") } },
|
|
172
|
+
qa: { schemaVersion: 1, featureId, verdict: "passed", acIds: requiredAcIds, runs: qaRuns.map(runRef), ...(bundleRef ? { bundle: bundleRef } : {}) },
|
|
173
|
+
final: { schemaVersion: 1, featureId, kind: "final-verification", status: "passed", headSha, run: runRef(finalRun), shellSummary: { path: summaryRelative, sha256: createHash("sha256").update(summary).digest("hex") }, ...(bundleRef ? { bundle: bundleRef } : {}) },
|
|
174
|
+
...(bundlePayload ? { bundle: bundlePayload } : {}),
|
|
123
175
|
});
|
|
124
|
-
return { schemaVersion: 1, featureId, taskId: taskSpec.id, workerRunId: finalRun.workerRunId, headSha, qaEvidencePath: repoRef(repoRoot, qaEvidencePath), finalVerificationPath: repoRef(repoRoot, finalVerificationPath), qaRunCount: qaRuns.length, ...(controllerIdentity ? { controllerIdentity } : {}) };
|
|
176
|
+
return { schemaVersion: 1, featureId, taskId: taskSpec.id, workerRunId: finalRun.workerRunId, headSha, qaEvidencePath: repoRef(repoRoot, qaEvidencePath), finalVerificationPath: repoRef(repoRoot, finalVerificationPath), qaRunCount: qaRuns.length, ...(bundleRef ? { bundleEvidencePath: bundleRef.path, bundleSha256: bundleRef.sha256, evidenceMode: "typed" } : { evidenceMode: "qa-execute" }), ...(controllerIdentity ? { controllerIdentity } : {}) };
|
|
125
177
|
}
|
|
126
178
|
export function latestSuccessfulQaRuns(runs, specs, excludedTaskId, featureId) {
|
|
127
179
|
const byTask = new Map();
|
|
@@ -130,6 +182,40 @@ export function latestSuccessfulQaRuns(runs, specs, excludedTaskId, featureId) {
|
|
|
130
182
|
byTask.set(run.taskId, run);
|
|
131
183
|
return [...byTask.values()].sort((a, b) => a.taskId.localeCompare(b.taskId));
|
|
132
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Typed verification is bound to the canonical Feature-scoped Task Pool state,
|
|
187
|
+
* never to an arbitrary historical success in runs.jsonl. Each required AC
|
|
188
|
+
* verification task must be Done and identify exactly one succeeded run.
|
|
189
|
+
*/
|
|
190
|
+
function selectCurrentTypedVerificationRuns(input) {
|
|
191
|
+
const requiredTaskIds = [
|
|
192
|
+
...new Set(input.acceptance.flatMap((item) => item.verification.verification_task_refs ?? [])),
|
|
193
|
+
];
|
|
194
|
+
const selected = [];
|
|
195
|
+
for (const taskId of requiredTaskIds) {
|
|
196
|
+
const spec = input.specs.get(taskId);
|
|
197
|
+
const workflow = spec?.execution?.workflow;
|
|
198
|
+
if (workflow !== "backend-test" && workflow !== "frontend-test") {
|
|
199
|
+
throw new Error(`typed verification TaskSpec is not a typed test: ${taskId}`);
|
|
200
|
+
}
|
|
201
|
+
const state = input.states[taskId];
|
|
202
|
+
if (state?.status !== "Done" || !state.workerRunId) {
|
|
203
|
+
throw new Error(`required typed verification task is not Done: ${taskId}`);
|
|
204
|
+
}
|
|
205
|
+
const matches = input.runs.filter((run) => run.featureId === input.featureId &&
|
|
206
|
+
run.taskId === taskId &&
|
|
207
|
+
run.workerRunId === state.workerRunId);
|
|
208
|
+
if (matches.length !== 1) {
|
|
209
|
+
throw new Error(`current Task Pool run is missing or ambiguous: ${taskId}`);
|
|
210
|
+
}
|
|
211
|
+
const run = matches[0];
|
|
212
|
+
if (run.status !== "succeeded" || run.workflow !== workflow) {
|
|
213
|
+
throw new Error(`current Task Pool run is not a succeeded typed test: ${taskId}`);
|
|
214
|
+
}
|
|
215
|
+
selected.push(run);
|
|
216
|
+
}
|
|
217
|
+
return selected;
|
|
218
|
+
}
|
|
133
219
|
async function reusableFinalRun(repoRoot, runs, taskId, featureId, record, now, controllerIdentity) {
|
|
134
220
|
const latestCheckpointAt = Math.max(...record.checkpoints.map((entry) => new Date(entry.createdAt).getTime()));
|
|
135
221
|
for (const run of [...runs].reverse()) {
|
|
@@ -162,6 +248,8 @@ export async function writeEvidencePairAtomic(evidenceDir, value, fs = { rm }) {
|
|
|
162
248
|
await mkdir(staging, { recursive: true });
|
|
163
249
|
await writeFile(path.join(staging, "qa-pass.json"), `${JSON.stringify(value.qa, null, 2)}\n`);
|
|
164
250
|
await writeFile(path.join(staging, "final-verification.json"), `${JSON.stringify(value.final, null, 2)}\n`);
|
|
251
|
+
if (value.bundle)
|
|
252
|
+
await writeFile(path.join(staging, "feature-verification-bundle.json"), `${JSON.stringify(value.bundle, null, 2)}\n`);
|
|
165
253
|
let backedUp = false;
|
|
166
254
|
try {
|
|
167
255
|
await rename(evidenceDir, backup);
|
|
@@ -12,6 +12,7 @@ import { taskGraphSpecSchema } from "../task-graph/task-graph-schema.js";
|
|
|
12
12
|
import { validateFeatureTaskGraph } from "../task-graph/validate.js";
|
|
13
13
|
import { taskSpecSchema } from "../task-spec/schema.js";
|
|
14
14
|
import { gitTransactionRecordSchema, transactionRecordPath } from "./git-transaction.js";
|
|
15
|
+
import { readVerifiedBundle, verifyBundleCurrentStateBindings, verifyBundleOutcomes, verifyBundleTaskSpecBindings } from "./verification-bundle.js";
|
|
15
16
|
const execFileAsync = promisify(execFile);
|
|
16
17
|
const hashedRefSchema = z.object({ path: z.string().min(1), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict();
|
|
17
18
|
export const acceptanceCoverageArtifactSchema = z.object({
|
|
@@ -76,8 +77,8 @@ const evidenceRunSchema = z.object({
|
|
|
76
77
|
recordedAt: z.string().datetime(),
|
|
77
78
|
controllerIdentity: controllerIdentityEvidenceSchema.optional(),
|
|
78
79
|
}).strict();
|
|
79
|
-
const qaEvidenceSchema = z.object({ schemaVersion: z.literal(1), featureId: z.string(), verdict: z.literal("passed"), acIds: z.array(z.string()).min(1), runs: z.array(evidenceRunSchema).min(1) }).strict();
|
|
80
|
-
const finalVerificationSchema = z.object({ schemaVersion: z.literal(1), featureId: z.string(), kind: z.literal("final-verification"), status: z.literal("passed"), headSha: z.string().regex(/^[a-f0-9]{40}$/), run: evidenceRunSchema, shellSummary: hashedRefSchema }).strict();
|
|
80
|
+
const qaEvidenceSchema = z.object({ schemaVersion: z.literal(1), featureId: z.string(), verdict: z.literal("passed"), acIds: z.array(z.string()).min(1), runs: z.array(evidenceRunSchema).min(1), bundle: hashedRefSchema.optional() }).strict();
|
|
81
|
+
const finalVerificationSchema = z.object({ schemaVersion: z.literal(1), featureId: z.string(), kind: z.literal("final-verification"), status: z.literal("passed"), headSha: z.string().regex(/^[a-f0-9]{40}$/), run: evidenceRunSchema, shellSummary: hashedRefSchema, bundle: hashedRefSchema.optional() }).strict();
|
|
81
82
|
const workerRunEvidenceSchema = z.object({ schemaVersion: z.literal(1), status: z.literal("succeeded"), workerRunId: z.string(), businessId: z.string(), featureId: z.string(), reportDecision: z.object({ succeeded: z.literal(true) }).passthrough(), commands: z.array(z.object({ name: z.string(), result: z.object({ ok: z.literal(true) }).passthrough() }).passthrough()).min(1), controllerIdentity: controllerIdentityEvidenceSchema.optional() }).passthrough();
|
|
82
83
|
export async function prepareFeatureDelivery(input) {
|
|
83
84
|
const repoRoot = path.resolve(input.repoRoot);
|
|
@@ -111,6 +112,10 @@ export async function prepareFeatureDelivery(input) {
|
|
|
111
112
|
taskSpecs.set(node.id, taskSpecSchema.parse(YAML.parse(await readFile(path.join(featureDir, "tasks", node.task), "utf-8"))));
|
|
112
113
|
const qaFact = await canonicalQaEvidence(repoRoot, input.qaEvidencePath, featureId, runs, taskSpecs, now, blockers);
|
|
113
114
|
const finalFact = record ? await canonicalFinalEvidence(repoRoot, input.finalVerificationPath, featureId, runs, taskSpecs, record, now, blockers) : undefined;
|
|
115
|
+
if (qaFact?.data.bundle || finalFact?.data.bundle) {
|
|
116
|
+
if (!qaFact?.data.bundle || !finalFact?.data.bundle || qaFact.data.bundle.path !== finalFact.data.bundle.path || qaFact.data.bundle.sha256 !== finalFact.data.bundle.sha256)
|
|
117
|
+
blockers.push("QA and final verification do not bind the same Feature Verification Bundle");
|
|
118
|
+
}
|
|
114
119
|
if (qaFact && finalFact && qaFact.data.runs.some((entry) => entry.workerRunId === finalFact.data.run.workerRunId))
|
|
115
120
|
blockers.push("final verification must use a dedicated run not included in the QA aggregate");
|
|
116
121
|
const qaEvidence = qaFact?.ref;
|
|
@@ -340,11 +345,20 @@ async function canonicalQaEvidence(repoRoot, ref, featureId, runs, specs, now, b
|
|
|
340
345
|
const data = qaEvidenceSchema.parse(JSON.parse(await readFile(await safeRepoPath(repoRoot, ref), "utf-8")));
|
|
341
346
|
if (data.featureId !== featureId)
|
|
342
347
|
throw new Error("Feature ownership mismatch");
|
|
348
|
+
const bundle = data.bundle ? await readVerifiedBundle({ repoRoot, ref: data.bundle }) : undefined;
|
|
349
|
+
if (data.bundle && (!bundle || bundle.featureId !== featureId || !await verifyBundleOutcomes(repoRoot, bundle) || !verifyBundleTaskSpecBindings(bundle, specs) || !await verifyBundleCurrentStateBindings(repoRoot, bundle)))
|
|
350
|
+
throw new Error("Feature Verification Bundle is missing, changed, has invalid typed outcomes, no longer matches TaskSpecs, or no longer matches current Task Pool state");
|
|
351
|
+
const typedByRun = new Map((bundle ? [...bundle.backendTests, ...bundle.frontendTests] : []).map((entry) => [entry.workerRunId, entry]));
|
|
343
352
|
for (const entry of data.runs) {
|
|
344
|
-
const run = assertEvidenceRun(entry, featureId, runs, specs, now,
|
|
353
|
+
const run = assertEvidenceRun(entry, featureId, runs, specs, now, !bundle);
|
|
354
|
+
if (bundle) {
|
|
355
|
+
const typed = typedByRun.get(entry.workerRunId);
|
|
356
|
+
if (!typed || typed.taskId !== entry.taskId || run.workflow !== typed.workflow)
|
|
357
|
+
throw new Error(`QA bundle run is not a declared typed outcome: ${entry.workerRunId}`);
|
|
358
|
+
}
|
|
345
359
|
await readCanonicalWorkerRun(repoRoot, run);
|
|
346
360
|
}
|
|
347
|
-
const allowedAcIds = new Set(data.runs.flatMap((entry) => specs.get(entry.taskId)?.acceptance_refs ?? []));
|
|
361
|
+
const allowedAcIds = bundle ? new Set(bundle.acceptance.filter((item) => item.status === "covered").map((item) => item.acId)) : new Set(data.runs.flatMap((entry) => specs.get(entry.taskId)?.acceptance_refs ?? []));
|
|
348
362
|
for (const acId of data.acIds)
|
|
349
363
|
if (!allowedAcIds.has(acId))
|
|
350
364
|
throw new Error(`QA run scope does not cover ${acId}`);
|
|
@@ -363,6 +377,11 @@ async function canonicalFinalEvidence(repoRoot, ref, featureId, runs, specs, rec
|
|
|
363
377
|
const data = finalVerificationSchema.parse(JSON.parse(await readFile(await safeRepoPath(repoRoot, ref), "utf-8")));
|
|
364
378
|
if (data.featureId !== featureId)
|
|
365
379
|
throw new Error("Feature ownership mismatch");
|
|
380
|
+
if (data.bundle) {
|
|
381
|
+
const bundle = await readVerifiedBundle({ repoRoot, ref: data.bundle });
|
|
382
|
+
if (!bundle || bundle.featureId !== featureId || bundle.headSha !== record.lastCheckpoint || !await verifyBundleOutcomes(repoRoot, bundle) || !verifyBundleTaskSpecBindings(bundle, specs) || !await verifyBundleCurrentStateBindings(repoRoot, bundle))
|
|
383
|
+
throw new Error("Feature Verification Bundle is missing, changed, does not bind the Delivery HEAD, no longer matches TaskSpecs, or no longer matches current Task Pool state");
|
|
384
|
+
}
|
|
366
385
|
const taskPoolRun = assertEvidenceRun(data.run, featureId, runs, specs, now, true);
|
|
367
386
|
if (data.headSha !== record.lastCheckpoint)
|
|
368
387
|
throw new Error("final verification is not bound to Delivery HEAD");
|