@kontourai/flow-agents 3.5.0 → 3.7.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/.github/workflows/ci.yml +4 -0
- package/CHANGELOG.md +15 -0
- package/build/src/builder-flow-run-adapter.d.ts +32 -3
- package/build/src/builder-flow-run-adapter.js +113 -20
- package/build/src/builder-flow-runtime.d.ts +26 -3
- package/build/src/builder-flow-runtime.js +502 -49
- package/build/src/builder-lifecycle-authority.d.ts +35 -0
- package/build/src/builder-lifecycle-authority.js +219 -0
- package/build/src/cli/assignment-provider.d.ts +14 -7
- package/build/src/cli/assignment-provider.js +128 -65
- package/build/src/cli/builder-run.js +46 -9
- package/build/src/cli/workflow-artifact-cleanup-audit.js +3 -0
- package/build/src/cli/workflow-sidecar.d.ts +30 -3
- package/build/src/cli/workflow-sidecar.js +825 -99
- package/build/src/cli/workflow.d.ts +2 -0
- package/build/src/cli/workflow.js +769 -0
- package/build/src/cli.js +2 -0
- package/build/src/flow-kit/validate.d.ts +17 -0
- package/build/src/flow-kit/validate.js +340 -2
- package/build/src/index.d.ts +4 -0
- package/build/src/index.js +7 -5
- package/build/src/lib/observed-command.d.ts +7 -0
- package/build/src/lib/observed-command.js +100 -0
- package/build/src/lib/package-version.d.ts +2 -0
- package/build/src/lib/package-version.js +13 -0
- package/build/src/lib/pinned-cli-command.d.ts +6 -0
- package/build/src/lib/pinned-cli-command.js +21 -0
- package/build/src/tools/generate-context-map.js +5 -3
- package/context/contracts/artifact-contract.md +1 -1
- package/context/scripts/hooks/config-protection.js +8 -1
- package/context/scripts/hooks/lib/config-protection-remedies.js +3 -0
- package/context/scripts/hooks/lib/kit-catalog.js +1 -1
- package/context/scripts/hooks/stop-goal-fit.js +79 -10
- package/docs/context-map.md +24 -20
- package/docs/developer-architecture.md +1 -1
- package/docs/public-workflow-cli.md +132 -0
- package/docs/skills-map.md +51 -27
- package/docs/spec/builder-flow-runtime.md +78 -40
- package/docs/workflow-usage-guide.md +110 -38
- package/evals/ci/run-baseline.sh +2 -0
- package/evals/fixtures/hook-influence/cases.json +2 -2
- package/evals/integration/test_builder_entry_enforcement.sh +57 -45
- package/evals/integration/test_builder_step_producers.sh +297 -6
- package/evals/integration/test_bundle_install.sh +258 -55
- package/evals/integration/test_critique_supersession_roundtrip.sh +21 -8
- package/evals/integration/test_current_json_per_actor.sh +12 -0
- package/evals/integration/test_dual_emit_flow_step.sh +62 -43
- package/evals/integration/test_flowdef_session_activation.sh +145 -25
- package/evals/integration/test_flowdef_session_history_preservation.sh +23 -21
- package/evals/integration/test_gate_lockdown.sh +3 -5
- package/evals/integration/test_goal_fit_hook.sh +60 -2
- package/evals/integration/test_liveness_verdict.sh +14 -17
- package/evals/integration/test_phase_map_and_gate_claim.sh +63 -38
- package/evals/integration/test_public_workflow_cli.sh +573 -0
- package/evals/integration/test_pull_work_liveness_preflight.sh +22 -66
- package/evals/integration/test_sidecar_field_preservation.sh +36 -11
- package/evals/integration/test_workflow_sidecar_writer.sh +277 -44
- package/evals/integration/test_workflow_steering_hook.sh +15 -38
- package/evals/run.sh +2 -0
- package/evals/static/test_builder_skill_coherence.sh +247 -0
- package/evals/static/test_library_exports.sh +5 -2
- package/evals/static/test_workflow_skills.sh +13 -325
- package/kits/builder/flows/build.flow.json +22 -0
- package/kits/builder/flows/shape.flow.json +9 -9
- package/kits/builder/kit.json +70 -16
- package/kits/builder/skills/builder-shape/SKILL.md +75 -75
- package/kits/builder/skills/continue-work/SKILL.md +45 -106
- package/kits/builder/skills/deliver/SKILL.md +96 -442
- package/kits/builder/skills/design-probe/SKILL.md +40 -139
- package/kits/builder/skills/evidence-gate/SKILL.md +59 -201
- package/kits/builder/skills/execute-plan/SKILL.md +54 -125
- package/kits/builder/skills/fix-bug/SKILL.md +42 -132
- package/kits/builder/skills/gate-review/SKILL.md +60 -211
- package/kits/builder/skills/idea-to-backlog/SKILL.md +63 -244
- package/kits/builder/skills/learning-review/SKILL.md +63 -170
- package/kits/builder/skills/pickup-probe/SKILL.md +54 -111
- package/kits/builder/skills/plan-work/SKILL.md +51 -185
- package/kits/builder/skills/pull-work/SKILL.md +136 -485
- package/kits/builder/skills/release-readiness/SKILL.md +66 -107
- package/kits/builder/skills/review-work/SKILL.md +89 -176
- package/kits/builder/skills/tdd-workflow/SKILL.md +53 -147
- package/kits/builder/skills/verify-work/SKILL.md +101 -113
- package/package.json +2 -2
- package/schemas/builder-lifecycle-authorization.schema.json +57 -0
- package/schemas/lifecycle-authority-keys.schema.json +25 -0
- package/schemas/workflow-state.schema.json +1 -1
- package/scripts/hooks/config-protection.js +8 -1
- package/scripts/hooks/lib/config-protection-remedies.js +3 -0
- package/scripts/hooks/lib/kit-catalog.js +1 -1
- package/scripts/hooks/stop-goal-fit.js +79 -10
- package/src/builder-flow-run-adapter.ts +156 -23
- package/src/builder-flow-runtime.ts +535 -53
- package/src/builder-lifecycle-authority.ts +218 -0
- package/src/cli/assignment-provider-lock.test.mjs +83 -0
- package/src/cli/assignment-provider.ts +91 -22
- package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
- package/src/cli/builder-flow-runtime.test.mjs +597 -8
- package/src/cli/builder-run.ts +54 -9
- package/src/cli/kit-metadata-security.test.mjs +232 -6
- package/src/cli/public-api.test.mjs +15 -0
- package/src/cli/workflow-artifact-cleanup-audit.ts +3 -0
- package/src/cli/workflow-sidecar-execution-proof.test.mjs +90 -0
- package/src/cli/workflow-sidecar.ts +748 -99
- package/src/cli/workflow.ts +708 -0
- package/src/cli.ts +2 -0
- package/src/flow-kit/validate.ts +320 -2
- package/src/index.ts +20 -5
- package/src/lib/observed-command.ts +96 -0
- package/src/lib/package-version.ts +15 -0
- package/src/lib/pinned-cli-command.ts +23 -0
- package/src/tools/generate-context-map.ts +5 -3
|
@@ -3,17 +3,33 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
+
import { createHash, generateKeyPairSync, sign } from "node:crypto";
|
|
6
7
|
|
|
7
8
|
import { FLOW_RUN_EVIDENCE_MANIFEST_PATH, runDir } from "@kontourai/flow";
|
|
8
9
|
import {
|
|
10
|
+
archiveBuilderFlowSession,
|
|
11
|
+
cancelBuilderFlowSession,
|
|
12
|
+
captureReviewWorkspaceSnapshot,
|
|
13
|
+
pauseBuilderFlowSession,
|
|
9
14
|
recoverBuilderFlowSession,
|
|
15
|
+
releaseBuilderFlowAssignment,
|
|
16
|
+
resumeBuilderFlowSession,
|
|
10
17
|
startBuilderFlowSession,
|
|
11
18
|
syncBuilderFlowSession,
|
|
12
19
|
} from "../../build/src/builder-flow-runtime.js";
|
|
20
|
+
import { builderLifecycleAuthorizationPayload, loadBuilderLifecycleAuthorization, recordAuthorizationConsumed } from "../../build/src/builder-lifecycle-authority.js";
|
|
21
|
+
import { cancelBuilderBuildRun } from "../../build/src/builder-flow-run-adapter.js";
|
|
22
|
+
import { performLocalClaim, readLocalAssignmentStatus, resolveCurrentAssignmentActor } from "../../build/src/cli/assignment-provider.js";
|
|
13
23
|
import { main as builderRunMain } from "../../build/src/cli/builder-run.js";
|
|
24
|
+
import { inferExecutedTestCount } from "../../build/src/cli/workflow-sidecar.js";
|
|
14
25
|
|
|
15
26
|
const SUBJECT = "local:work-item/runtime-projection";
|
|
16
27
|
const NOW = "2026-07-09T20:00:00.000Z";
|
|
28
|
+
const PACKAGE_VERSION = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
|
|
29
|
+
const ACTOR = { runtime: "codex", session_id: "runtime-projection", host: "test-host", human: null };
|
|
30
|
+
const ACTOR_KEY = "codex:runtime-projection:test-host";
|
|
31
|
+
const AUTHORITY_KEY_ID = "runtime-test";
|
|
32
|
+
const AUTHORITY_KEYS = generateKeyPairSync("ed25519");
|
|
17
33
|
|
|
18
34
|
function makeSession(slug = "runtime-projection") {
|
|
19
35
|
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-builder-runtime-"));
|
|
@@ -33,6 +49,13 @@ function makeSession(slug = "runtime-projection") {
|
|
|
33
49
|
artifact_dir: `.kontourai/flow-agents/${slug}`,
|
|
34
50
|
updated_at: NOW,
|
|
35
51
|
});
|
|
52
|
+
writeJson(path.join(projectRoot, ".flow-agents", "lifecycle-authority-keys.json"), {
|
|
53
|
+
schema_version: "1.0",
|
|
54
|
+
keys: [{ id: AUTHORITY_KEY_ID, algorithm: "ed25519", public_key_pem: AUTHORITY_KEYS.publicKey.export({ type: "spki", format: "pem" }) }],
|
|
55
|
+
});
|
|
56
|
+
fs.mkdirSync(path.join(projectRoot, "review-target"), { recursive: true });
|
|
57
|
+
fs.writeFileSync(path.join(projectRoot, "review-target", "implementation.txt"), "reviewed implementation\n");
|
|
58
|
+
fs.writeFileSync(path.join(projectRoot, "review-target", "delivery.md"), "reviewed delivery artifact\n");
|
|
36
59
|
return { projectRoot, artifactRoot, sessionDir, slug };
|
|
37
60
|
}
|
|
38
61
|
|
|
@@ -41,10 +64,117 @@ function writeJson(file, value) {
|
|
|
41
64
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
42
65
|
}
|
|
43
66
|
|
|
67
|
+
test("shell output cannot spoof an executed-test count", () => {
|
|
68
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-test-count-"));
|
|
69
|
+
fs.mkdirSync(path.join(root, "checks"), { recursive: true });
|
|
70
|
+
fs.writeFileSync(path.join(root, "checks", "fake-test.sh"), "#!/bin/sh\nset -e\nprintf '1 passed\\n'\n");
|
|
71
|
+
fs.writeFileSync(path.join(root, "checks", "real-test.sh"), "#!/bin/sh\nset -e\ntest -f checks/real-test.sh\n");
|
|
72
|
+
assert.equal(inferExecutedTestCount("sh checks/fake-test.sh", root, "1 passed\n"), 0);
|
|
73
|
+
assert.equal(inferExecutedTestCount("sh checks/real-test.sh", root, "1..1\nok 1 - file exists\n"), 1);
|
|
74
|
+
});
|
|
75
|
+
|
|
44
76
|
function readJson(file) {
|
|
45
77
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
46
78
|
}
|
|
47
79
|
|
|
80
|
+
function consumedAuthorizationRecords(session) {
|
|
81
|
+
const directory = path.join(session.artifactRoot, "lifecycle-authority", "consumed");
|
|
82
|
+
if (!fs.existsSync(directory)) return [];
|
|
83
|
+
return fs.readdirSync(directory).sort().map((name) => readJson(path.join(directory, name)));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function claimSessionAssignment(session) {
|
|
87
|
+
performLocalClaim(session.artifactRoot, session.slug, ACTOR, {
|
|
88
|
+
ttlSeconds: 1800,
|
|
89
|
+
actorKey: ACTOR_KEY,
|
|
90
|
+
branch: `agent/${session.slug}`,
|
|
91
|
+
artifactDir: session.sessionDir,
|
|
92
|
+
workItemRef: SUBJECT,
|
|
93
|
+
reason: "test",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function claimAmbientSessionAssignment(session) {
|
|
98
|
+
const ambient = resolveCurrentAssignmentActor();
|
|
99
|
+
performLocalClaim(session.artifactRoot, session.slug, ambient.actor, {
|
|
100
|
+
ttlSeconds: 1800,
|
|
101
|
+
actorKey: ambient.actorKey,
|
|
102
|
+
branch: `agent/${session.slug}`,
|
|
103
|
+
artifactDir: session.sessionDir,
|
|
104
|
+
workItemRef: SUBJECT,
|
|
105
|
+
reason: "test",
|
|
106
|
+
});
|
|
107
|
+
return ambient;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function lifecycleAuthorization(session, operation, name, overrides = {}) {
|
|
111
|
+
const file = path.join(session.projectRoot, `${name}.authorization.json`);
|
|
112
|
+
const unsigned = {
|
|
113
|
+
schema_version: "1.0",
|
|
114
|
+
operation,
|
|
115
|
+
run_id: session.slug,
|
|
116
|
+
subject: SUBJECT,
|
|
117
|
+
assignment_actor_key: ACTOR_KEY,
|
|
118
|
+
assignment_actor: ACTOR,
|
|
119
|
+
nonce: `${session.slug}:${name}`,
|
|
120
|
+
expires_at: "2026-07-09T21:00:00.000Z",
|
|
121
|
+
request: {
|
|
122
|
+
reason: `${name} requested by fixture`,
|
|
123
|
+
authority: {
|
|
124
|
+
kind: "user_request",
|
|
125
|
+
actor: "fixture-user",
|
|
126
|
+
request_ref: `fixture://request/${name}`,
|
|
127
|
+
requested_at: NOW,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
...overrides,
|
|
131
|
+
};
|
|
132
|
+
const value = {
|
|
133
|
+
...unsigned,
|
|
134
|
+
signature: {
|
|
135
|
+
algorithm: "ed25519",
|
|
136
|
+
key_id: AUTHORITY_KEY_ID,
|
|
137
|
+
value: sign(null, Buffer.from(builderLifecycleAuthorizationPayload(unsigned)), AUTHORITY_KEYS.privateKey).toString("base64"),
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
writeJson(file, value);
|
|
141
|
+
return file;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function liveLifecycleAuthorization(session, operation, name, overrides = {}) {
|
|
145
|
+
const requestedAt = new Date();
|
|
146
|
+
return lifecycleAuthorization(session, operation, name, {
|
|
147
|
+
expires_at: new Date(requestedAt.getTime() + 60 * 60_000).toISOString(),
|
|
148
|
+
request: {
|
|
149
|
+
reason: `${name} requested by fixture`,
|
|
150
|
+
authority: {
|
|
151
|
+
kind: "user_request",
|
|
152
|
+
actor: "fixture-user",
|
|
153
|
+
request_ref: `fixture://request/${name}`,
|
|
154
|
+
requested_at: requestedAt.toISOString(),
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
...overrides,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function expiredLifecycleAuthorization(session, operation, name, overrides = {}) {
|
|
162
|
+
const requestedAt = new Date(Date.now() - 120_000);
|
|
163
|
+
return lifecycleAuthorization(session, operation, name, {
|
|
164
|
+
expires_at: new Date(Date.now() - 60_000).toISOString(),
|
|
165
|
+
request: {
|
|
166
|
+
reason: `${name} requested by fixture`,
|
|
167
|
+
authority: {
|
|
168
|
+
kind: "user_request",
|
|
169
|
+
actor: "fixture-user",
|
|
170
|
+
request_ref: `fixture://request/${name}`,
|
|
171
|
+
requested_at: requestedAt.toISOString(),
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
...overrides,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
48
178
|
function snapshotFile(file) {
|
|
49
179
|
return fs.existsSync(file) ? fs.readFileSync(file).toString("base64") : null;
|
|
50
180
|
}
|
|
@@ -84,7 +214,7 @@ async function assertRecoveryRejectsWithoutWrites(session, pattern) {
|
|
|
84
214
|
assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
|
|
85
215
|
}
|
|
86
216
|
|
|
87
|
-
function bundleClaim({ expectation, claimType, subjectType, status = "pass", routeReason, subject = SUBJECT }) {
|
|
217
|
+
function bundleClaim({ expectation, claimType, subjectType, status = "pass", routeReason, subject = SUBJECT, testCount = 1, timestamp = NOW }) {
|
|
88
218
|
const claimId = `claim.${expectation}`;
|
|
89
219
|
return {
|
|
90
220
|
claim: {
|
|
@@ -96,6 +226,11 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
|
|
|
96
226
|
value: status,
|
|
97
227
|
metadata: {
|
|
98
228
|
workflow_subject_ref: subject,
|
|
229
|
+
...(expectation === "tests-evidence" ? {
|
|
230
|
+
recorded_by: "implementation-actor",
|
|
231
|
+
artifact_refs: [{ kind: "command", excerpt: "node --test src/cli/builder-flow-runtime.test.mjs", summary: "Runtime fixture assertion." }],
|
|
232
|
+
observed_commands: [{ command: "node --test src/cli/builder-flow-runtime.test.mjs", exit_code: 0, test_count: testCount, output_sha256: "0".repeat(64) }],
|
|
233
|
+
} : {}),
|
|
99
234
|
gate_claim: {
|
|
100
235
|
expectation_id: expectation,
|
|
101
236
|
claim_type: claimType,
|
|
@@ -103,8 +238,8 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
|
|
|
103
238
|
...(routeReason ? { route_reason: routeReason } : {}),
|
|
104
239
|
},
|
|
105
240
|
},
|
|
106
|
-
createdAt:
|
|
107
|
-
updatedAt:
|
|
241
|
+
createdAt: timestamp,
|
|
242
|
+
updatedAt: timestamp,
|
|
108
243
|
},
|
|
109
244
|
evidence: {
|
|
110
245
|
id: `evidence.${expectation}`,
|
|
@@ -113,7 +248,7 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
|
|
|
113
248
|
method: "attestation",
|
|
114
249
|
sourceRef: "src/cli/builder-flow-runtime.test.mjs",
|
|
115
250
|
excerptOrSummary: `${expectation} fixture`,
|
|
116
|
-
observedAt:
|
|
251
|
+
observedAt: timestamp,
|
|
117
252
|
collectedBy: "flow-agents-test",
|
|
118
253
|
},
|
|
119
254
|
event: {
|
|
@@ -123,10 +258,47 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
|
|
|
123
258
|
actor: "flow-agents-test",
|
|
124
259
|
method: "attestation",
|
|
125
260
|
evidenceIds: [`evidence.${expectation}`],
|
|
126
|
-
createdAt:
|
|
127
|
-
verifiedAt:
|
|
261
|
+
createdAt: timestamp,
|
|
262
|
+
verifiedAt: timestamp,
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function verifiedTestsPrerequisites(session, timestamp = NOW) {
|
|
268
|
+
const reviewArtifact = path.join(session.projectRoot, "review-target", "delivery.md");
|
|
269
|
+
const implementation = path.join(session.projectRoot, "review-target", "implementation.txt");
|
|
270
|
+
const implementationFile = path.relative(session.projectRoot, implementation);
|
|
271
|
+
const implementationBytes = fs.readFileSync(implementation);
|
|
272
|
+
const implementationSha256 = createHash("sha256").update(implementationBytes).digest("hex");
|
|
273
|
+
const workspaceDigest = createHash("sha256")
|
|
274
|
+
.update("flow-agents:reviewed-files:v1\0")
|
|
275
|
+
.update(implementationFile).update("\0").update(implementationBytes).update("\0")
|
|
276
|
+
.digest("hex");
|
|
277
|
+
const critique = bundleClaim({ expectation: "clean-critique", claimType: "workflow.critique.review", subjectType: "workflow-critique", timestamp });
|
|
278
|
+
critique.claim.metadata = {
|
|
279
|
+
workflow_subject_ref: SUBJECT,
|
|
280
|
+
origin: "critique",
|
|
281
|
+
reviewer: "reviewer-actor",
|
|
282
|
+
findings: [],
|
|
283
|
+
lanes: [{ id: "code", status: "pass" }],
|
|
284
|
+
review_target: {
|
|
285
|
+
artifacts: [{ file: path.relative(session.projectRoot, reviewArtifact), sha256: createHash("sha256").update(fs.readFileSync(reviewArtifact)).digest("hex") }],
|
|
286
|
+
workspace_snapshot: {
|
|
287
|
+
version: 1,
|
|
288
|
+
kind: "reviewed-files",
|
|
289
|
+
algorithm: "sha256",
|
|
290
|
+
digest: workspaceDigest,
|
|
291
|
+
files: [{ file: implementationFile, sha256: implementationSha256 }],
|
|
292
|
+
},
|
|
128
293
|
},
|
|
129
294
|
};
|
|
295
|
+
const criterion = bundleClaim({ expectation: "verified-criterion", claimType: "workflow.acceptance.criterion", subjectType: "flow-step", timestamp });
|
|
296
|
+
criterion.claim.metadata = {
|
|
297
|
+
workflow_subject_ref: SUBJECT,
|
|
298
|
+
origin: "acceptance",
|
|
299
|
+
criterion: { id: "ac-runtime", status: "pass", evidence_refs: [{ kind: "command", excerpt: "node --test src/cli/builder-flow-runtime.test.mjs", summary: "Runtime fixture assertion." }] },
|
|
300
|
+
};
|
|
301
|
+
return [critique, criterion];
|
|
130
302
|
}
|
|
131
303
|
|
|
132
304
|
function writeBundle(sessionDir, entries) {
|
|
@@ -152,7 +324,10 @@ test("small-model client can start and advance from projected actions without ch
|
|
|
152
324
|
assert.equal(started.run.state.current_step, "pull-work");
|
|
153
325
|
assert.deepEqual(started.projection.next_action.skills, ["pull-work"]);
|
|
154
326
|
assert.deepEqual(started.projection.next_action.operations, []);
|
|
155
|
-
assert.
|
|
327
|
+
assert.match(started.projection.next_action.command, /^sh -c /);
|
|
328
|
+
assert.match(started.projection.next_action.command, /--prefix "\$root"/);
|
|
329
|
+
assert.ok(started.projection.next_action.command.includes(`'@kontourai/flow-agents@${PACKAGE_VERSION}'`));
|
|
330
|
+
assert.ok(started.projection.next_action.command.includes(`'workflow' 'status' '--session-dir' '.kontourai/flow-agents/${session.slug}' '--json'`));
|
|
156
331
|
assert.ok(fs.existsSync(runDir(session.slug, session.projectRoot)));
|
|
157
332
|
assert.ok(!fs.existsSync(path.join(session.projectRoot, ".flow", "runs")), "retired runtime path must not be created");
|
|
158
333
|
|
|
@@ -172,6 +347,331 @@ test("small-model client can start and advance from projected actions without ch
|
|
|
172
347
|
assert.equal(duplicate.run.manifest.evidence.length, advanced.run.manifest.evidence.length);
|
|
173
348
|
});
|
|
174
349
|
|
|
350
|
+
test("sync attaches the staged trust.bundle bytes when the session bundle is replaced", async () => {
|
|
351
|
+
const session = makeSession("snapshot-attachment");
|
|
352
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
353
|
+
const originalEntries = [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })];
|
|
354
|
+
writeBundle(session.sessionDir, originalEntries);
|
|
355
|
+
const bundleFile = path.join(session.sessionDir, "trust.bundle");
|
|
356
|
+
const originalDigest = createHash("sha256").update(fs.readFileSync(bundleFile)).digest("hex");
|
|
357
|
+
let replaceBundle;
|
|
358
|
+
const replaced = new Promise((resolve) => { replaceBundle = resolve; });
|
|
359
|
+
const watcher = fs.watch(session.sessionDir, (_event, filename) => {
|
|
360
|
+
if (String(filename) !== ".trust-bundle-snapshots") return;
|
|
361
|
+
fs.writeFileSync(bundleFile, "{\"claims\":[]}");
|
|
362
|
+
replaceBundle();
|
|
363
|
+
});
|
|
364
|
+
try {
|
|
365
|
+
const syncing = syncBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
366
|
+
await Promise.race([
|
|
367
|
+
replaced,
|
|
368
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("trust.bundle snapshot was not staged")), 2_000)),
|
|
369
|
+
]);
|
|
370
|
+
const synced = await syncing;
|
|
371
|
+
assert.equal(synced.attached, true);
|
|
372
|
+
} finally {
|
|
373
|
+
watcher.close();
|
|
374
|
+
}
|
|
375
|
+
const manifest = readJson(path.join(runDir(session.slug, session.projectRoot), FLOW_RUN_EVIDENCE_MANIFEST_PATH));
|
|
376
|
+
assert.equal(manifest.evidence.at(-1).sha256, originalDigest);
|
|
377
|
+
assert.equal(fs.existsSync(path.join(session.sessionDir, ".trust-bundle-snapshots")), false);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("start rejects a requested Builder flow that differs from the existing run before projection mutation", async (t) => {
|
|
381
|
+
for (const [existingFlowId, requestedFlowId] of [["builder.shape", "builder.build"], ["builder.build", "builder.shape"]]) {
|
|
382
|
+
await t.test(`${existingFlowId} cannot resume as ${requestedFlowId}`, async () => {
|
|
383
|
+
const session = makeSession(`flow-mismatch-${existingFlowId.replace('.', '-')}`);
|
|
384
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir, flowId: existingFlowId });
|
|
385
|
+
const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
|
|
386
|
+
const beforeProjection = snapshotProjectionTargets(session);
|
|
387
|
+
await assert.rejects(
|
|
388
|
+
() => startBuilderFlowSession({ sessionDir: session.sessionDir, flowId: requestedFlowId }),
|
|
389
|
+
new RegExp(`requested ${requestedFlowId} does not match the existing ${existingFlowId} run`),
|
|
390
|
+
);
|
|
391
|
+
assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
|
|
392
|
+
assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("pause and resume preserve the current Flow step and active assignment", async () => {
|
|
398
|
+
const session = makeSession("lifecycle-pause-resume");
|
|
399
|
+
claimAmbientSessionAssignment(session);
|
|
400
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
401
|
+
const before = readJson(path.join(runDir(session.slug, session.projectRoot), "state.json"));
|
|
402
|
+
|
|
403
|
+
const paused = await pauseBuilderFlowSession({
|
|
404
|
+
sessionDir: session.sessionDir,
|
|
405
|
+
reason: "fixture pause",
|
|
406
|
+
});
|
|
407
|
+
assert.equal(paused.run.state.status, "paused");
|
|
408
|
+
assert.equal(paused.run.state.current_step, before.current_step);
|
|
409
|
+
assert.deepEqual(paused.run.state.transitions, before.transitions);
|
|
410
|
+
assert.equal(paused.projection.status, "blocked");
|
|
411
|
+
assert.equal(readLocalAssignmentStatus(session.artifactRoot, session.slug).record.status, "claimed");
|
|
412
|
+
|
|
413
|
+
const resumed = await resumeBuilderFlowSession({
|
|
414
|
+
sessionDir: session.sessionDir,
|
|
415
|
+
reason: "fixture resume",
|
|
416
|
+
});
|
|
417
|
+
assert.equal(resumed.run.state.status, "active");
|
|
418
|
+
assert.equal(resumed.run.state.current_step, before.current_step);
|
|
419
|
+
assert.deepEqual(resumed.run.state.transitions, before.transitions);
|
|
420
|
+
assert.equal(readLocalAssignmentStatus(session.artifactRoot, session.slug).record.status, "claimed");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("authorized cancellation is canonical, terminal, and releases the assignment exactly once", async () => {
|
|
424
|
+
const session = makeSession("lifecycle-cancel");
|
|
425
|
+
claimSessionAssignment(session);
|
|
426
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
427
|
+
const authorizationFile = liveLifecycleAuthorization(session, "cancel", "cancel");
|
|
428
|
+
|
|
429
|
+
const canceled = await cancelBuilderFlowSession({
|
|
430
|
+
sessionDir: session.sessionDir,
|
|
431
|
+
authorizationFile,
|
|
432
|
+
});
|
|
433
|
+
assert.equal(canceled.run.state.status, "canceled");
|
|
434
|
+
assert.equal(canceled.projection.status, "canceled");
|
|
435
|
+
assert.equal(canceled.assignmentReleased, true);
|
|
436
|
+
assert.equal(canceled.idempotent, false);
|
|
437
|
+
const assignmentFile = path.join(session.artifactRoot, "assignment", `${session.slug}.json`);
|
|
438
|
+
assert.equal(readJson(assignmentFile).status, "released");
|
|
439
|
+
const firstAudit = readJson(assignmentFile).audit_trail;
|
|
440
|
+
|
|
441
|
+
await assert.rejects(() => cancelBuilderFlowSession({
|
|
442
|
+
sessionDir: session.sessionDir,
|
|
443
|
+
authorizationFile,
|
|
444
|
+
}), /nonce has already been consumed/);
|
|
445
|
+
assert.deepEqual(readJson(assignmentFile).audit_trail, firstAudit);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("assignment release is independent of Flow lifecycle", async () => {
|
|
449
|
+
const session = makeSession("lifecycle-release-assignment");
|
|
450
|
+
claimAmbientSessionAssignment(session);
|
|
451
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
452
|
+
const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
|
|
453
|
+
|
|
454
|
+
const released = await releaseBuilderFlowAssignment({
|
|
455
|
+
sessionDir: session.sessionDir,
|
|
456
|
+
reason: "fixture assignment release",
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
assert.equal(released.assignmentReleased, true);
|
|
460
|
+
assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
|
|
461
|
+
assert.equal(readJson(path.join(session.artifactRoot, "assignment", `${session.slug}.json`)).status, "released");
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("archive rejects active runs and retains canceled Flow and session artifacts", async () => {
|
|
465
|
+
const session = makeSession("lifecycle-archive");
|
|
466
|
+
claimSessionAssignment(session);
|
|
467
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
468
|
+
const cancelAuthorization = liveLifecycleAuthorization(session, "cancel", "archive-cancel");
|
|
469
|
+
const authorizationFile = liveLifecycleAuthorization(session, "archive", "archive");
|
|
470
|
+
const beforeReject = snapshotTree(session.sessionDir);
|
|
471
|
+
await assert.rejects(() => archiveBuilderFlowSession({
|
|
472
|
+
sessionDir: session.sessionDir,
|
|
473
|
+
authorizationFile,
|
|
474
|
+
}), /must be completed or canceled/);
|
|
475
|
+
assert.deepEqual(snapshotTree(session.sessionDir), beforeReject);
|
|
476
|
+
|
|
477
|
+
await cancelBuilderFlowSession({
|
|
478
|
+
sessionDir: session.sessionDir,
|
|
479
|
+
authorizationFile: cancelAuthorization,
|
|
480
|
+
});
|
|
481
|
+
const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
|
|
482
|
+
const beforeSession = snapshotTree(session.sessionDir);
|
|
483
|
+
const archived = await archiveBuilderFlowSession({
|
|
484
|
+
sessionDir: session.sessionDir,
|
|
485
|
+
authorizationFile,
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
assert.equal(archived.archiveDir, path.join(session.artifactRoot, "archive", session.slug));
|
|
489
|
+
assert.equal(fs.existsSync(session.sessionDir), false);
|
|
490
|
+
assert.equal(readJson(path.join(archived.archiveDir, "state.json")).status, "archived");
|
|
491
|
+
assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
|
|
492
|
+
const archivedFiles = snapshotTree(archived.archiveDir).map(([name]) => name);
|
|
493
|
+
for (const [name] of beforeSession) assert.ok(archivedFiles.includes(name), `archive retained ${name}`);
|
|
494
|
+
assert.deepEqual(consumedAuthorizationRecords(session).map((record) => record.operation).sort(), ["archive", "cancel"]);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("archive rejects a symlinked archive root without moving the session", async () => {
|
|
498
|
+
const session = makeSession("lifecycle-archive-symlink");
|
|
499
|
+
claimSessionAssignment(session);
|
|
500
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
501
|
+
const cancelAuthorization = liveLifecycleAuthorization(session, "cancel", "archive-symlink-cancel");
|
|
502
|
+
const authorizationFile = liveLifecycleAuthorization(session, "archive", "archive-symlink");
|
|
503
|
+
await cancelBuilderFlowSession({
|
|
504
|
+
sessionDir: session.sessionDir,
|
|
505
|
+
authorizationFile: cancelAuthorization,
|
|
506
|
+
});
|
|
507
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-archive-outside-"));
|
|
508
|
+
fs.symlinkSync(outside, path.join(session.artifactRoot, "archive"), "dir");
|
|
509
|
+
const beforeSession = snapshotTree(session.sessionDir);
|
|
510
|
+
|
|
511
|
+
await assert.rejects(() => archiveBuilderFlowSession({
|
|
512
|
+
sessionDir: session.sessionDir,
|
|
513
|
+
authorizationFile,
|
|
514
|
+
}), /archive root.*symbolic link/);
|
|
515
|
+
|
|
516
|
+
assert.deepEqual(snapshotTree(session.sessionDir), beforeSession);
|
|
517
|
+
assert.deepEqual(fs.readdirSync(outside), []);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test("archive retries the exact consumed authorization after an interrupted prepared move", async () => {
|
|
521
|
+
const session = makeSession("lifecycle-archive-recovery");
|
|
522
|
+
claimSessionAssignment(session);
|
|
523
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
524
|
+
await cancelBuilderFlowSession({
|
|
525
|
+
sessionDir: session.sessionDir,
|
|
526
|
+
authorizationFile: liveLifecycleAuthorization(session, "cancel", "archive-recovery-cancel"),
|
|
527
|
+
});
|
|
528
|
+
const authorizationFile = expiredLifecycleAuthorization(session, "archive", "archive-recovery");
|
|
529
|
+
const rawAuthorization = readJson(authorizationFile);
|
|
530
|
+
const authorization = loadBuilderLifecycleAuthorization(authorizationFile, {
|
|
531
|
+
projectRoot: session.projectRoot,
|
|
532
|
+
operation: "archive",
|
|
533
|
+
runId: session.slug,
|
|
534
|
+
subject: SUBJECT,
|
|
535
|
+
actorKey: ACTOR_KEY,
|
|
536
|
+
now: new Date(Date.parse(rawAuthorization.request.authority.requested_at) + 30_000).toISOString(),
|
|
537
|
+
});
|
|
538
|
+
const preparedState = readJson(path.join(session.sessionDir, "state.json"));
|
|
539
|
+
preparedState.status = "archived";
|
|
540
|
+
preparedState.phase = "done";
|
|
541
|
+
preparedState.next_action = { status: "done", summary: "Builder session archived; canonical Flow artifacts remain retained." };
|
|
542
|
+
writeJson(path.join(session.sessionDir, "state.json"), preparedState);
|
|
543
|
+
recordAuthorizationConsumed(session.artifactRoot, authorization);
|
|
544
|
+
|
|
545
|
+
const conflictingAuthorization = liveLifecycleAuthorization(session, "archive", "archive-recovery-conflict", {
|
|
546
|
+
nonce: authorization.nonce,
|
|
547
|
+
});
|
|
548
|
+
await assert.rejects(
|
|
549
|
+
() => archiveBuilderFlowSession({ sessionDir: session.sessionDir, authorizationFile: conflictingAuthorization }),
|
|
550
|
+
/does not match its integrity key/,
|
|
551
|
+
);
|
|
552
|
+
assert.equal(fs.existsSync(session.sessionDir), true);
|
|
553
|
+
|
|
554
|
+
const recovered = await archiveBuilderFlowSession({ sessionDir: session.sessionDir, authorizationFile });
|
|
555
|
+
assert.equal(fs.existsSync(session.sessionDir), false);
|
|
556
|
+
assert.equal(readJson(path.join(recovered.archiveDir, "state.json")).status, "archived");
|
|
557
|
+
assert.equal(consumedAuthorizationRecords(session).length, 2);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("mismatched and expired cancellation authority fail before Flow or sidecar mutation", async (t) => {
|
|
561
|
+
for (const [name, overrides, pattern] of [
|
|
562
|
+
["wrong-run", { run_id: "another-run" }, /run_id does not match/],
|
|
563
|
+
["wrong-subject", { subject: "local:other" }, /subject does not match/],
|
|
564
|
+
["wrong-actor", { assignment_actor_key: "another-actor" }, /assignment_actor_key does not match/],
|
|
565
|
+
["wrong-actor-struct", { assignment_actor: { ...ACTOR, session_id: "another-session" } }, /assignment_actor.*active assignment holder/],
|
|
566
|
+
["wrong-operation", { operation: "archive" }, /operation does not match/],
|
|
567
|
+
["expired", {}, /expired/],
|
|
568
|
+
]) {
|
|
569
|
+
await t.test(name, async () => {
|
|
570
|
+
const session = makeSession(`lifecycle-reject-${name}`);
|
|
571
|
+
claimSessionAssignment(session);
|
|
572
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
573
|
+
const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
|
|
574
|
+
const beforeProjection = snapshotProjectionTargets(session);
|
|
575
|
+
const beforeAssignment = snapshotFile(path.join(session.artifactRoot, "assignment", `${session.slug}.json`));
|
|
576
|
+
await assert.rejects(() => cancelBuilderFlowSession({
|
|
577
|
+
sessionDir: session.sessionDir,
|
|
578
|
+
authorizationFile: name === "expired"
|
|
579
|
+
? expiredLifecycleAuthorization(session, "cancel", name, overrides)
|
|
580
|
+
: liveLifecycleAuthorization(session, "cancel", name, overrides),
|
|
581
|
+
}), pattern);
|
|
582
|
+
assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
|
|
583
|
+
assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
|
|
584
|
+
assert.equal(snapshotFile(path.join(session.artifactRoot, "assignment", `${session.slug}.json`)), beforeAssignment);
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("conflicting cancellation request replay is rejected without a second transition or release", async () => {
|
|
590
|
+
const session = makeSession("lifecycle-conflicting-replay");
|
|
591
|
+
claimSessionAssignment(session);
|
|
592
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
593
|
+
await cancelBuilderFlowSession({
|
|
594
|
+
sessionDir: session.sessionDir,
|
|
595
|
+
authorizationFile: liveLifecycleAuthorization(session, "cancel", "cancel-original"),
|
|
596
|
+
});
|
|
597
|
+
const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
|
|
598
|
+
const assignmentFile = path.join(session.artifactRoot, "assignment", `${session.slug}.json`);
|
|
599
|
+
const beforeAssignment = snapshotFile(assignmentFile);
|
|
600
|
+
await assert.rejects(() => cancelBuilderFlowSession({
|
|
601
|
+
sessionDir: session.sessionDir,
|
|
602
|
+
authorizationFile: liveLifecycleAuthorization(session, "cancel", "cancel-conflict"),
|
|
603
|
+
}), /does not match the canonical cancellation/);
|
|
604
|
+
assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
|
|
605
|
+
assert.equal(snapshotFile(assignmentFile), beforeAssignment);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("tampered or unsigned cancellation authority fails before mutation", async () => {
|
|
609
|
+
const session = makeSession("lifecycle-signature-reject");
|
|
610
|
+
claimSessionAssignment(session);
|
|
611
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
612
|
+
const authorizationFile = liveLifecycleAuthorization(session, "cancel", "signature-reject");
|
|
613
|
+
const tampered = readJson(authorizationFile);
|
|
614
|
+
tampered.request.reason = "agent-authored replacement";
|
|
615
|
+
writeJson(authorizationFile, tampered);
|
|
616
|
+
const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
|
|
617
|
+
const beforeAssignment = snapshotFile(path.join(session.artifactRoot, "assignment", `${session.slug}.json`));
|
|
618
|
+
|
|
619
|
+
await assert.rejects(() => cancelBuilderFlowSession({ sessionDir: session.sessionDir, authorizationFile }), /signature is invalid/);
|
|
620
|
+
assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
|
|
621
|
+
assert.equal(snapshotFile(path.join(session.artifactRoot, "assignment", `${session.slug}.json`)), beforeAssignment);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test("expired authority can finish side effects for its matching canonical cancellation", async () => {
|
|
625
|
+
const session = makeSession("lifecycle-cancel-recovery");
|
|
626
|
+
claimSessionAssignment(session);
|
|
627
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
628
|
+
const authorizationFile = expiredLifecycleAuthorization(session, "cancel", "cancel-recovery");
|
|
629
|
+
const authorization = readJson(authorizationFile);
|
|
630
|
+
await cancelBuilderBuildRun({
|
|
631
|
+
cwd: session.projectRoot,
|
|
632
|
+
runId: session.slug,
|
|
633
|
+
request: authorization.request,
|
|
634
|
+
at: new Date(Date.parse(authorization.expires_at) - 1_000).toISOString(),
|
|
635
|
+
});
|
|
636
|
+
assert.equal(readJson(path.join(runDir(session.slug, session.projectRoot), "state.json")).status, "canceled");
|
|
637
|
+
assert.equal(readJson(path.join(session.artifactRoot, "assignment", `${session.slug}.json`)).status, "claimed");
|
|
638
|
+
assert.deepEqual(consumedAuthorizationRecords(session), []);
|
|
639
|
+
|
|
640
|
+
const recovered = await cancelBuilderFlowSession({ sessionDir: session.sessionDir, authorizationFile });
|
|
641
|
+
assert.equal(recovered.idempotent, true);
|
|
642
|
+
assert.equal(recovered.assignmentReleased, true);
|
|
643
|
+
assert.equal(recovered.projection.status, "canceled");
|
|
644
|
+
assert.equal(consumedAuthorizationRecords(session).length, 1);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test("builder-run exposes lifecycle actions without caller-selected Flow identity", async () => {
|
|
648
|
+
const session = makeSession("lifecycle-cli");
|
|
649
|
+
const ambient = resolveCurrentAssignmentActor();
|
|
650
|
+
performLocalClaim(session.artifactRoot, session.slug, ambient.actor, { actorKey: ambient.actorKey, ttlSeconds: 1800, branch: `agent/${session.slug}`, artifactDir: session.sessionDir, workItemRef: SUBJECT, reason: "test" });
|
|
651
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
652
|
+
assert.equal(await builderRunMain(["start", "--session-dir", session.sessionDir]), 64);
|
|
653
|
+
assert.equal(await builderRunMain(["sync", "--session-dir", session.sessionDir]), 64);
|
|
654
|
+
assert.equal(await builderRunMain([
|
|
655
|
+
"pause", "--session-dir", session.sessionDir,
|
|
656
|
+
"--reason", "cli pause",
|
|
657
|
+
]), 0);
|
|
658
|
+
assert.equal(await builderRunMain([
|
|
659
|
+
"resume", "--session-dir", session.sessionDir,
|
|
660
|
+
"--reason", "cli resume",
|
|
661
|
+
]), 0);
|
|
662
|
+
const cliCancel = liveLifecycleAuthorization(session, "cancel", "cli-cancel", { assignment_actor_key: ambient.actorKey, assignment_actor: ambient.actor });
|
|
663
|
+
assert.equal(await builderRunMain([
|
|
664
|
+
"cancel", "--session-dir", session.sessionDir,
|
|
665
|
+
"--authorization-file", cliCancel,
|
|
666
|
+
]), 0);
|
|
667
|
+
assert.equal(readJson(path.join(runDir(session.slug, session.projectRoot), "state.json")).status, "canceled");
|
|
668
|
+
assert.equal(await builderRunMain([
|
|
669
|
+
"cancel", "--session-dir", session.sessionDir,
|
|
670
|
+
"--authorization-file", liveLifecycleAuthorization(session, "cancel", "cli-clock-override", { assignment_actor_key: ambient.actorKey, assignment_actor: ambient.actor }),
|
|
671
|
+
"--now", "2020-01-01T00:00:00.000Z",
|
|
672
|
+
]), 64);
|
|
673
|
+
});
|
|
674
|
+
|
|
175
675
|
test("automatic start refuses a slug-bound run for another Work Item without mutation", async () => {
|
|
176
676
|
const session = makeSession("start-subject-mismatch");
|
|
177
677
|
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
@@ -249,10 +749,92 @@ test("failed verification projects Flow-owned route-back attempt and budget", as
|
|
|
249
749
|
assert.equal(routed.projection.flow_run.route_back_max_attempts, 3);
|
|
250
750
|
assert.match(routed.projection.next_action.summary, /Route-back history: attempt 1\/3 returned to `execute` for `implementation_defect`/);
|
|
251
751
|
assert.deepEqual(routed.projection.next_action.skills, ["execute-plan"]);
|
|
752
|
+
|
|
753
|
+
const reentered = await writeAndSync(session, [bundleClaim({
|
|
754
|
+
expectation: "implementation-scope",
|
|
755
|
+
claimType: "builder.execute.scope",
|
|
756
|
+
subjectType: "change",
|
|
757
|
+
timestamp: new Date().toISOString(),
|
|
758
|
+
})]);
|
|
759
|
+
assert.equal(reentered.run.state.current_step, "verify");
|
|
760
|
+
const correctedAt = new Date(Date.parse(reentered.run.state.transitions.at(-1).at) + 1).toISOString();
|
|
761
|
+
const corrected = await writeAndSync(session, [
|
|
762
|
+
bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step", timestamp: correctedAt }),
|
|
763
|
+
...verifiedTestsPrerequisites(session, correctedAt),
|
|
764
|
+
]);
|
|
765
|
+
assert.equal(corrected.run.state.current_step, "merge-ready");
|
|
766
|
+
const verifyEvidence = corrected.run.manifest.evidence.filter((entry) => entry.gate_id === "verify-gate");
|
|
767
|
+
assert.equal(verifyEvidence.length, 2);
|
|
768
|
+
assert.equal(verifyEvidence[0].superseded_by, verifyEvidence[1].id);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
test("passing tests-evidence rejects a critique whose reviewed artifact changed", async () => {
|
|
772
|
+
const session = makeSession("stale-critique-artifact");
|
|
773
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
774
|
+
await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
|
|
775
|
+
await writeAndSync(session, [
|
|
776
|
+
bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
|
|
777
|
+
bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
|
|
778
|
+
]);
|
|
779
|
+
await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
|
|
780
|
+
await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
|
|
781
|
+
const prerequisites = verifiedTestsPrerequisites(session);
|
|
782
|
+
fs.writeFileSync(path.join(session.projectRoot, "review-target", "delivery.md"), "changed after review\n");
|
|
783
|
+
await assert.rejects(
|
|
784
|
+
() => writeAndSync(session, [bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }), ...prerequisites]),
|
|
785
|
+
/review_target\.artifacts\.sha256.*does not match/,
|
|
786
|
+
);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
test("passing tests-evidence rejects a successful command that executed zero tests", async () => {
|
|
790
|
+
const session = makeSession("zero-test-evidence");
|
|
791
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
792
|
+
await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
|
|
793
|
+
await writeAndSync(session, [
|
|
794
|
+
bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
|
|
795
|
+
bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
|
|
796
|
+
]);
|
|
797
|
+
await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
|
|
798
|
+
await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
|
|
799
|
+
await assert.rejects(
|
|
800
|
+
() => writeAndSync(session, [
|
|
801
|
+
bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step", testCount: 0 }),
|
|
802
|
+
...verifiedTestsPrerequisites(session),
|
|
803
|
+
]),
|
|
804
|
+
/positive executed-test count/,
|
|
805
|
+
);
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
test("passing tests-evidence rejects a critique after implementation source changed", async () => {
|
|
809
|
+
const session = makeSession("stale-critique-workspace");
|
|
810
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
811
|
+
await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
|
|
812
|
+
await writeAndSync(session, [
|
|
813
|
+
bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
|
|
814
|
+
bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
|
|
815
|
+
]);
|
|
816
|
+
await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
|
|
817
|
+
await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
|
|
818
|
+
const prerequisites = verifiedTestsPrerequisites(session);
|
|
819
|
+
fs.writeFileSync(path.join(session.projectRoot, "review-target", "implementation.txt"), "source changed after review\n");
|
|
820
|
+
await assert.rejects(
|
|
821
|
+
() => writeAndSync(session, [bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }), ...prerequisites]),
|
|
822
|
+
/review_target\.workspace_snapshot\.digest.*does not match/,
|
|
823
|
+
);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
test("workspace review fails closed when a Git worktree marker cannot be inspected", () => {
|
|
827
|
+
const session = makeSession("git-inspection-unavailable");
|
|
828
|
+
fs.mkdirSync(path.join(session.projectRoot, ".git"));
|
|
829
|
+
assert.throws(
|
|
830
|
+
() => captureReviewWorkspaceSnapshot(session.projectRoot, [{ file: "review-target/delivery.md", sha256: createHash("sha256").update("reviewed delivery\n").digest("hex") }]),
|
|
831
|
+
/could not inspect the Git worktree/,
|
|
832
|
+
);
|
|
252
833
|
});
|
|
253
834
|
|
|
254
835
|
test("verified sidecar claims drive the composed publish and learning prefix to completion", async () => {
|
|
255
836
|
const session = makeSession("composed-completion");
|
|
837
|
+
claimSessionAssignment(session);
|
|
256
838
|
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
257
839
|
const steps = [
|
|
258
840
|
[bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })],
|
|
@@ -262,7 +844,7 @@ test("verified sidecar claims drive the composed publish and learning prefix to
|
|
|
262
844
|
],
|
|
263
845
|
[bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })],
|
|
264
846
|
[bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })],
|
|
265
|
-
[bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" })],
|
|
847
|
+
[bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }), ...verifiedTestsPrerequisites(session)],
|
|
266
848
|
[bundleClaim({ expectation: "merge-readiness", claimType: "builder.merge-ready.readiness", subjectType: "change" })],
|
|
267
849
|
];
|
|
268
850
|
for (const entries of steps) await writeAndSync(session, entries);
|
|
@@ -297,6 +879,13 @@ test("verified sidecar claims drive the composed publish and learning prefix to
|
|
|
297
879
|
assert.equal(recovered.projection.phase, "done");
|
|
298
880
|
assert.deepEqual(recovered.projection.next_action, { status: "done", summary: "Canonical Flow run is complete." });
|
|
299
881
|
assert.deepEqual(snapshotTree(flowDirectory), beforeFlow);
|
|
882
|
+
|
|
883
|
+
const archived = await archiveBuilderFlowSession({
|
|
884
|
+
sessionDir: session.sessionDir,
|
|
885
|
+
authorizationFile: liveLifecycleAuthorization(session, "archive", "completed-archive"),
|
|
886
|
+
});
|
|
887
|
+
assert.equal(readJson(path.join(archived.archiveDir, "state.json")).status, "archived");
|
|
888
|
+
assert.deepEqual(snapshotTree(flowDirectory), beforeFlow);
|
|
300
889
|
});
|
|
301
890
|
|
|
302
891
|
test("recovery loads the slug-bound run, restores every matching projection, and preserves every Flow byte", async () => {
|