@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
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { createHash, createPublicKey, verify } from "node:crypto";
|
|
4
|
+
import type { FlowLifecycleRequest } from "@kontourai/flow";
|
|
5
|
+
import type { ActorStruct } from "./cli/assignment-provider.js";
|
|
6
|
+
import { durableFlowAgentsRoot } from "./lib/local-artifact-root.js";
|
|
7
|
+
|
|
8
|
+
type JsonRecord = Record<string, unknown>;
|
|
9
|
+
export type AuthorizedBuilderLifecycleOperation = "cancel" | "archive";
|
|
10
|
+
|
|
11
|
+
export interface BuilderLifecycleAuthorization {
|
|
12
|
+
schema_version: "1.0";
|
|
13
|
+
operation: AuthorizedBuilderLifecycleOperation;
|
|
14
|
+
run_id: string;
|
|
15
|
+
subject: string;
|
|
16
|
+
assignment_actor_key: string;
|
|
17
|
+
assignment_actor: ActorStruct;
|
|
18
|
+
nonce: string;
|
|
19
|
+
expires_at: string;
|
|
20
|
+
request: FlowLifecycleRequest;
|
|
21
|
+
signature: { algorithm: "ed25519"; key_id: string; value: string };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function lifecycleAuthorityKeysPath(projectRoot: string): string {
|
|
25
|
+
return path.join(durableFlowAgentsRoot(projectRoot), "lifecycle-authority-keys.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function loadBuilderLifecycleAuthorization(
|
|
29
|
+
fileInput: string,
|
|
30
|
+
expected: { projectRoot: string; operation: AuthorizedBuilderLifecycleOperation; runId: string; subject: string; actorKey: string; now?: string; allowExpired?: boolean },
|
|
31
|
+
): BuilderLifecycleAuthorization {
|
|
32
|
+
const value = readRegularJson(fileInput, "lifecycle authorization");
|
|
33
|
+
assertExactKeys(value, ["schema_version", "operation", "run_id", "subject", "assignment_actor_key", "assignment_actor", "nonce", "expires_at", "request", "signature"], "authorization");
|
|
34
|
+
if (value.schema_version !== "1.0") throw new Error("lifecycle authorization schema_version must be 1.0");
|
|
35
|
+
assertEqual(value.operation, expected.operation, "operation");
|
|
36
|
+
assertEqual(value.run_id, expected.runId, "run_id");
|
|
37
|
+
assertEqual(value.subject, expected.subject, "subject");
|
|
38
|
+
assertEqual(value.assignment_actor_key, expected.actorKey, "assignment_actor_key");
|
|
39
|
+
const assignmentActor = validateActor(value.assignment_actor);
|
|
40
|
+
const request = validateRequest(value.request);
|
|
41
|
+
const nonce = boundedText(value.nonce, "nonce", 256);
|
|
42
|
+
const expiresAt = dateTime(value.expires_at, "expires_at");
|
|
43
|
+
const requestedAt = Date.parse(request.authority.requested_at);
|
|
44
|
+
const now = Date.parse(expected.now ?? new Date().toISOString());
|
|
45
|
+
if (expiresAt < requestedAt) throw new Error("lifecycle authorization expires_at must not precede request.authority.requested_at");
|
|
46
|
+
if (now > expiresAt && !expected.allowExpired) throw new Error("lifecycle authorization is expired");
|
|
47
|
+
if (requestedAt > now + 5 * 60_000) throw new Error("lifecycle authorization request time is in the future");
|
|
48
|
+
const signature = validateSignature(value.signature);
|
|
49
|
+
const authorization = {
|
|
50
|
+
schema_version: "1.0",
|
|
51
|
+
operation: expected.operation,
|
|
52
|
+
run_id: expected.runId,
|
|
53
|
+
subject: expected.subject,
|
|
54
|
+
assignment_actor_key: expected.actorKey,
|
|
55
|
+
assignment_actor: assignmentActor,
|
|
56
|
+
nonce,
|
|
57
|
+
expires_at: value.expires_at as string,
|
|
58
|
+
request,
|
|
59
|
+
signature,
|
|
60
|
+
} satisfies BuilderLifecycleAuthorization;
|
|
61
|
+
verifyAuthorizationSignature(authorization, lifecycleAuthorityKeysPath(expected.projectRoot));
|
|
62
|
+
return authorization;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function builderLifecycleAuthorizationPayload(value: Omit<BuilderLifecycleAuthorization, "signature">): string {
|
|
66
|
+
return JSON.stringify(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function assertAuthorizationUnused(artifactRoot: string, authorization: BuilderLifecycleAuthorization): void {
|
|
70
|
+
if (!readAuthorizationConsumption(artifactRoot, authorization)) return;
|
|
71
|
+
throw new Error("lifecycle authorization nonce has already been consumed");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function readAuthorizationConsumption(artifactRoot: string, authorization: BuilderLifecycleAuthorization): JsonRecord | null {
|
|
75
|
+
const file = consumedAuthorizationPath(artifactRoot, authorization);
|
|
76
|
+
if (!pathExistsNoFollow(file)) return null;
|
|
77
|
+
const record = readRegularJson(file, "consumed lifecycle authorization record");
|
|
78
|
+
if (record.run_id !== authorization.run_id
|
|
79
|
+
|| record.operation !== authorization.operation
|
|
80
|
+
|| record.nonce !== authorization.nonce
|
|
81
|
+
|| record.key_id !== authorization.signature.key_id
|
|
82
|
+
|| record.authorization_sha256 !== authorizationDigest(authorization)) {
|
|
83
|
+
throw new Error("consumed lifecycle authorization record does not match its integrity key");
|
|
84
|
+
}
|
|
85
|
+
return record;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function recordAuthorizationConsumed(artifactRoot: string, authorization: BuilderLifecycleAuthorization, at = new Date().toISOString()): void {
|
|
89
|
+
const file = consumedAuthorizationPath(artifactRoot, authorization);
|
|
90
|
+
const directory = path.dirname(file);
|
|
91
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
92
|
+
const stat = fs.lstatSync(directory);
|
|
93
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || !pathIsWithin(fs.realpathSync(directory), fs.realpathSync(artifactRoot))) throw new Error("lifecycle authorization registry directory is unsafe");
|
|
94
|
+
const temporary = path.join(directory, `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
|
|
95
|
+
const descriptor = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
96
|
+
try {
|
|
97
|
+
fs.writeFileSync(descriptor, `${JSON.stringify({ run_id: authorization.run_id, operation: authorization.operation, nonce: authorization.nonce, key_id: authorization.signature.key_id, authorization_sha256: authorizationDigest(authorization), at })}\n`);
|
|
98
|
+
fs.fsyncSync(descriptor);
|
|
99
|
+
} finally {
|
|
100
|
+
fs.closeSync(descriptor);
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
fs.linkSync(temporary, file);
|
|
104
|
+
const directoryDescriptor = fs.openSync(directory, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
105
|
+
try { fs.fsyncSync(directoryDescriptor); } finally { fs.closeSync(directoryDescriptor); }
|
|
106
|
+
} finally {
|
|
107
|
+
fs.rmSync(temporary, { force: true });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function consumedAuthorizationPath(artifactRoot: string, authorization: BuilderLifecycleAuthorization): string {
|
|
112
|
+
const integrityKey = createHash("sha256").update(authorization.run_id).update("\0").update(authorization.nonce).digest("hex");
|
|
113
|
+
return path.join(artifactRoot, "lifecycle-authority", "consumed", `${integrityKey}.json`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function authorizationDigest(authorization: BuilderLifecycleAuthorization): string {
|
|
117
|
+
return createHash("sha256").update(JSON.stringify(authorization)).digest("hex");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function verifyAuthorizationSignature(authorization: BuilderLifecycleAuthorization, keysFile: string): void {
|
|
121
|
+
const registry = readRegularJson(keysFile, "lifecycle authority key registry", true);
|
|
122
|
+
assertExactKeys(registry, ["schema_version", "keys"], "key registry");
|
|
123
|
+
if (registry.schema_version !== "1.0" || !Array.isArray(registry.keys)) throw new Error("lifecycle authority key registry must contain schema_version 1.0 and keys[]");
|
|
124
|
+
const key = registry.keys.find((candidate) => isRecord(candidate) && candidate.id === authorization.signature.key_id);
|
|
125
|
+
if (!isRecord(key) || key.algorithm !== "ed25519" || typeof key.public_key_pem !== "string" || key.public_key_pem.trim().length === 0) {
|
|
126
|
+
throw new Error(`lifecycle authorization key ${authorization.signature.key_id} is not trusted`);
|
|
127
|
+
}
|
|
128
|
+
const { signature: _signature, ...unsigned } = authorization;
|
|
129
|
+
let verified = false;
|
|
130
|
+
try {
|
|
131
|
+
verified = verify(null, Buffer.from(builderLifecycleAuthorizationPayload(unsigned)), createPublicKey(key.public_key_pem), Buffer.from(authorization.signature.value, "base64"));
|
|
132
|
+
} catch {
|
|
133
|
+
verified = false;
|
|
134
|
+
}
|
|
135
|
+
if (!verified) throw new Error("lifecycle authorization signature is invalid");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function readRegularJson(fileInput: string, label: string, requireProtected = false): JsonRecord {
|
|
139
|
+
const file = path.resolve(fileInput);
|
|
140
|
+
const descriptor = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
141
|
+
try {
|
|
142
|
+
const stat = fs.fstatSync(descriptor);
|
|
143
|
+
if (!stat.isFile()) throw new Error(`${label} must be a regular file`);
|
|
144
|
+
if (stat.size > 64 * 1024) throw new Error(`${label} exceeds 64 KiB`);
|
|
145
|
+
if (requireProtected && (stat.mode & 0o022) !== 0) throw new Error(`${label} must not be group- or world-writable`);
|
|
146
|
+
const value = JSON.parse(fs.readFileSync(descriptor, "utf8")) as unknown;
|
|
147
|
+
if (!isRecord(value)) throw new Error(`${label} must be a JSON object`);
|
|
148
|
+
return value;
|
|
149
|
+
} finally {
|
|
150
|
+
fs.closeSync(descriptor);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function validateActor(value: unknown): ActorStruct {
|
|
155
|
+
if (!isRecord(value)) throw new Error("lifecycle authorization assignment_actor must be an object");
|
|
156
|
+
assertExactKeys(value, ["runtime", "session_id", "host", "human"], "assignment_actor");
|
|
157
|
+
if (!Object.prototype.hasOwnProperty.call(value, "human")) throw new Error("lifecycle authorization assignment_actor.human is required");
|
|
158
|
+
for (const field of ["runtime", "session_id", "host"] as const) boundedText(value[field], `assignment_actor.${field}`, 256);
|
|
159
|
+
if (value.human !== undefined && value.human !== null) boundedText(value.human, "assignment_actor.human", 256);
|
|
160
|
+
return { runtime: value.runtime as string, session_id: value.session_id as string, host: value.host as string, human: value.human == null ? null : value.human as string };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function validateRequest(value: unknown): FlowLifecycleRequest {
|
|
164
|
+
if (!isRecord(value)) throw new Error("lifecycle authorization request must be an object");
|
|
165
|
+
assertExactKeys(value, ["reason", "authority"], "request");
|
|
166
|
+
const reason = boundedText(value.reason, "request.reason", 4096);
|
|
167
|
+
if (!isRecord(value.authority)) throw new Error("lifecycle authorization request.authority must be an object");
|
|
168
|
+
assertExactKeys(value.authority, ["kind", "actor", "request_ref", "requested_at"], "request.authority");
|
|
169
|
+
if (value.authority.kind !== "user_request" && value.authority.kind !== "operator_request") throw new Error("lifecycle authorization request.authority.kind must be user_request or operator_request");
|
|
170
|
+
const actor = boundedText(value.authority.actor, "request.authority.actor", 256);
|
|
171
|
+
const requestRef = boundedText(value.authority.request_ref, "request.authority.request_ref", 2048);
|
|
172
|
+
dateTime(value.authority.requested_at, "request.authority.requested_at");
|
|
173
|
+
return { reason, authority: { kind: value.authority.kind, actor, request_ref: requestRef, requested_at: value.authority.requested_at as string } };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function validateSignature(value: unknown): BuilderLifecycleAuthorization["signature"] {
|
|
177
|
+
if (!isRecord(value)) throw new Error("lifecycle authorization signature must be an object");
|
|
178
|
+
assertExactKeys(value, ["algorithm", "key_id", "value"], "signature");
|
|
179
|
+
if (value.algorithm !== "ed25519") throw new Error("lifecycle authorization signature.algorithm must be ed25519");
|
|
180
|
+
return { algorithm: "ed25519", key_id: boundedText(value.key_id, "signature.key_id", 256), value: boundedText(value.value, "signature.value", 1024) };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function assertEqual(actual: unknown, expected: string, field: string): void {
|
|
184
|
+
if (actual !== expected) throw new Error(`lifecycle authorization ${field} does not match the requested Builder operation`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function assertExactKeys(value: JsonRecord, allowed: string[], field: string): void {
|
|
188
|
+
const unexpected = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
189
|
+
if (unexpected.length) throw new Error(`lifecycle authorization ${field} contains unsupported field ${unexpected[0]}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function dateTime(value: unknown, field: string): number {
|
|
193
|
+
const text = boundedText(value, field, 128);
|
|
194
|
+
if (!Number.isFinite(Date.parse(text))) throw new Error(`lifecycle authorization ${field} must be a date-time`);
|
|
195
|
+
return Date.parse(text);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function boundedText(value: unknown, field: string, limit: number): string {
|
|
199
|
+
if (!nonEmpty(value) || [...value].length > limit) throw new Error(`lifecycle authorization ${field} must be a non-empty string of at most ${limit} characters`);
|
|
200
|
+
return value;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function nonEmpty(value: unknown): value is string {
|
|
204
|
+
return typeof value === "string" && value.trim().length > 0 && !/[\x00-\x1f\x7f]/.test(value);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function isRecord(value: unknown): value is JsonRecord {
|
|
208
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function pathExistsNoFollow(candidate: string): boolean {
|
|
212
|
+
try { fs.lstatSync(candidate); return true; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function pathIsWithin(candidate: string, root: string): boolean {
|
|
216
|
+
const relative = path.relative(root, candidate);
|
|
217
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
218
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { syncBuiltinESMExports } from "node:module";
|
|
7
|
+
|
|
8
|
+
import { withSubjectLock } from "../../build/src/cli/assignment-provider.js";
|
|
9
|
+
|
|
10
|
+
test("a displaced lock owner cannot heartbeat or release a replacement lock", async () => {
|
|
11
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-lock-aba-"));
|
|
12
|
+
const subject = "lock-aba";
|
|
13
|
+
const lockDir = path.join(root, "assignment", ".lock-aba.lockdir");
|
|
14
|
+
let finish;
|
|
15
|
+
const held = withSubjectLock(root, subject, () => new Promise((resolve) => { finish = resolve; }));
|
|
16
|
+
const original = JSON.parse(fs.readFileSync(path.join(lockDir, "owner.json"), "utf8"));
|
|
17
|
+
|
|
18
|
+
const quarantine = `${lockDir}.stale-test`;
|
|
19
|
+
fs.renameSync(lockDir, quarantine);
|
|
20
|
+
fs.mkdirSync(lockDir);
|
|
21
|
+
const replacement = { token: "replacement-owner", stale_after_ms: 300000 };
|
|
22
|
+
fs.writeFileSync(path.join(lockDir, "owner.json"), `${JSON.stringify(replacement)}\n`);
|
|
23
|
+
fs.rmSync(quarantine, { recursive: true, force: true });
|
|
24
|
+
|
|
25
|
+
finish();
|
|
26
|
+
await held;
|
|
27
|
+
assert.equal(fs.existsSync(lockDir), true);
|
|
28
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(lockDir, "owner.json"), "utf8")), replacement);
|
|
29
|
+
assert.notEqual(original.token, replacement.token);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("stale locks fail closed instead of risking concurrent reclamation", () => {
|
|
33
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-lock-stale-"));
|
|
34
|
+
const lockDir = path.join(root, "assignment", ".lock-stale.lockdir");
|
|
35
|
+
fs.mkdirSync(lockDir, { recursive: true });
|
|
36
|
+
const ownerFile = path.join(lockDir, "owner.json");
|
|
37
|
+
fs.writeFileSync(ownerFile, `${JSON.stringify({ token: "old-owner", stale_after_ms: 1 })}\n`);
|
|
38
|
+
// The owner-provided one-millisecond threshold is ignored. Reclamation uses
|
|
39
|
+
// the contender's bounded policy, so this must be older than its default.
|
|
40
|
+
const old = new Date(Date.now() - 10 * 60 * 1000);
|
|
41
|
+
fs.utimesSync(ownerFile, old, old);
|
|
42
|
+
|
|
43
|
+
assert.throws(() => withSubjectLock(root, "lock-stale", () => "unreachable"), /requires explicit operator cleanup/);
|
|
44
|
+
assert.equal(fs.existsSync(lockDir), true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("old ownerless and malformed locks fail closed without a busy-spin", () => {
|
|
48
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-lock-malformed-"));
|
|
49
|
+
const assignmentDir = path.join(root, "assignment");
|
|
50
|
+
for (const [subject, owner] of [["ownerless", null], ["malformed", "not-json\n"]]) {
|
|
51
|
+
const lockDir = path.join(assignmentDir, `.${subject}.lockdir`);
|
|
52
|
+
fs.mkdirSync(lockDir, { recursive: true });
|
|
53
|
+
if (owner !== null) fs.writeFileSync(path.join(lockDir, "owner.json"), owner);
|
|
54
|
+
const old = new Date(Date.now() - 10 * 60 * 1000);
|
|
55
|
+
fs.utimesSync(lockDir, old, old);
|
|
56
|
+
if (owner !== null) fs.utimesSync(path.join(lockDir, "owner.json"), old, old);
|
|
57
|
+
const started = Date.now();
|
|
58
|
+
assert.throws(() => withSubjectLock(root, subject, () => "unreachable"), /requires explicit operator cleanup/);
|
|
59
|
+
assert.ok(Date.now() - started < 1_000, `${subject} stale lock should fail without spinning until deadline`);
|
|
60
|
+
assert.equal(fs.existsSync(lockDir), true);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("failed owner metadata creation removes the ownerless lock directory", () => {
|
|
65
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-lock-owner-write-"));
|
|
66
|
+
const originalWriteFileSync = fs.writeFileSync;
|
|
67
|
+
fs.writeFileSync = function injectedOwnerWriteFailure(file, ...args) {
|
|
68
|
+
if (path.basename(String(file)) === "owner.json") {
|
|
69
|
+
const error = new Error("injected owner write failure");
|
|
70
|
+
error.code = "EACCES";
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
return originalWriteFileSync.call(this, file, ...args);
|
|
74
|
+
};
|
|
75
|
+
syncBuiltinESMExports();
|
|
76
|
+
try {
|
|
77
|
+
assert.throws(() => withSubjectLock(root, "owner-write", () => "unreachable"), /failed to acquire assignment lock/);
|
|
78
|
+
} finally {
|
|
79
|
+
fs.writeFileSync = originalWriteFileSync;
|
|
80
|
+
syncBuiltinESMExports();
|
|
81
|
+
}
|
|
82
|
+
assert.equal(fs.existsSync(path.join(root, "assignment", ".owner-write.lockdir")), false);
|
|
83
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
4
5
|
import { createRequire } from "node:module";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { parseArgs, flagString, type ParsedArgs } from "../lib/args.js";
|
|
@@ -121,6 +122,8 @@ function loadActorIdentityHelper(): {
|
|
|
121
122
|
isUnresolvedActor: (actor: string) => boolean;
|
|
122
123
|
sanitizeSegment: (value: unknown) => string;
|
|
123
124
|
detectRuntime: (env: NodeJS.ProcessEnv) => string;
|
|
125
|
+
runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
|
|
126
|
+
ancestorActorSeed: () => string;
|
|
124
127
|
detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
|
|
125
128
|
} {
|
|
126
129
|
const _req = createRequire(import.meta.url);
|
|
@@ -131,6 +134,8 @@ function loadActorIdentityHelper(): {
|
|
|
131
134
|
isUnresolvedActor: (actor: string) => boolean;
|
|
132
135
|
sanitizeSegment: (value: unknown) => string;
|
|
133
136
|
detectRuntime: (env: NodeJS.ProcessEnv) => string;
|
|
137
|
+
runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
|
|
138
|
+
ancestorActorSeed: () => string;
|
|
134
139
|
detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
|
|
135
140
|
};
|
|
136
141
|
}
|
|
@@ -199,6 +204,10 @@ function loadActorStructFromFile(file: string): ActorStruct {
|
|
|
199
204
|
function loadActorStruct(args: ParsedArgs): { actor: ActorStruct; actorKey?: string } {
|
|
200
205
|
const actorJsonPath = flagString(args.flags, "actor-json");
|
|
201
206
|
if (actorJsonPath) return { actor: loadActorStructFromFile(actorJsonPath) };
|
|
207
|
+
return resolveCurrentAssignmentActor();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function resolveCurrentAssignmentActor(): { actor: ActorStruct; actorKey: string } {
|
|
202
211
|
const helper = loadActorIdentityHelper();
|
|
203
212
|
const resolved = helper.resolveActor(process.env);
|
|
204
213
|
if (helper.isUnresolvedActor(resolved.actor)) throw new Error("could not resolve an actor identity (no --actor-json and no resolvable environment actor); pass --actor-json explicitly");
|
|
@@ -208,9 +217,15 @@ function loadActorStruct(args: ParsedArgs): { actor: ActorStruct; actorKey?: str
|
|
|
208
217
|
// CI session — actor_key stays correct (so no false-block), but record.actor is malformed and the
|
|
209
218
|
// audit-trail / `assignment-provider status` output for CI sessions would be corrupt.
|
|
210
219
|
const ci = resolved.source.startsWith("ci-runtime") ? helper.detectCiActor(process.env) : null;
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
220
|
+
const runtimeSessionId = resolved.source.startsWith("runtime-session-id") ? helper.runtimeSessionId(process.env) : "";
|
|
221
|
+
const ancestrySeed = resolved.source === "process-ancestry" ? helper.ancestorActorSeed() : "";
|
|
222
|
+
const actor: ActorStruct = resolved.source === "explicit-override"
|
|
223
|
+
? { runtime: "explicit-override", session_id: resolved.actor, host: os.hostname(), human: null }
|
|
224
|
+
: ci && ci.session_id
|
|
225
|
+
? { runtime: ci.runtime, session_id: ci.session_id, host: os.hostname(), human: null }
|
|
226
|
+
: runtimeSessionId
|
|
227
|
+
? { runtime: helper.detectRuntime(process.env), session_id: runtimeSessionId, host: os.hostname(), human: null }
|
|
228
|
+
: { runtime: helper.detectRuntime(process.env), session_id: ancestrySeed ? `anc-${ancestrySeed}` : resolved.actor, host: os.hostname(), human: null };
|
|
214
229
|
return { actor, actorKey: resolved.actor };
|
|
215
230
|
}
|
|
216
231
|
|
|
@@ -295,18 +310,28 @@ function subjectLockDir(artifactRoot: string, subjectId: string): string {
|
|
|
295
310
|
return path.join(assignmentDir, `.${sanitized}.lockdir`);
|
|
296
311
|
}
|
|
297
312
|
|
|
313
|
+
// Lock age is adjudicated by the current contender, never by metadata written
|
|
314
|
+
// by the lock owner. The environment is only an operator tuning input; clamp it
|
|
315
|
+
// so a caller cannot turn a transient owner-file write into immediate takeover.
|
|
316
|
+
const SUBJECT_LOCK_STALE_MIN_MS = 1_000;
|
|
317
|
+
const SUBJECT_LOCK_STALE_MAX_MS = 30 * 60 * 1_000;
|
|
318
|
+
const SUBJECT_LOCK_STALE_DEFAULT_MS = 5 * 60 * 1_000;
|
|
319
|
+
|
|
320
|
+
function trustedSubjectLockStaleMs(): number {
|
|
321
|
+
const configured = Number(process.env.FLOW_AGENTS_ASSIGNMENT_STALE_LOCK_MS);
|
|
322
|
+
if (!Number.isFinite(configured)) return SUBJECT_LOCK_STALE_DEFAULT_MS;
|
|
323
|
+
return Math.min(SUBJECT_LOCK_STALE_MAX_MS, Math.max(SUBJECT_LOCK_STALE_MIN_MS, Math.floor(configured)));
|
|
324
|
+
}
|
|
325
|
+
|
|
298
326
|
/**
|
|
299
327
|
* F1 fix (fix-plan iteration 1, CRITICAL): claimLocalFile/releaseLocalFile/supersedeLocalFile were
|
|
300
328
|
* a plain read -> compare-actor -> write with no lock, so two concurrently-launched OS processes
|
|
301
329
|
* could both read "no conflicting claim" before either wrote, and the second write would silently
|
|
302
330
|
* clobber the first with zero error and zero audit-trail entry for the loser (reproduced 29/40
|
|
303
|
-
* races against the built CLI).
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
* is reclaimed rather than waited on forever) and a bounded deadline, `finally` rmSync release —
|
|
308
|
-
* as a small LOCAL helper (not a cross-import of that private function, which would pull the
|
|
309
|
-
* entire workflow-sidecar module in for one primitive). Deliberately synchronous (sleepSync's
|
|
331
|
+
* races against the built CLI). Atomic directory creation establishes ownership before metadata
|
|
332
|
+
* is written; contenders treat even an ownerless directory as held. Live contention waits with a
|
|
333
|
+
* bounded deadline; stale or malformed residue fails closed for explicit operator cleanup because portable Node
|
|
334
|
+
* filesystem APIs cannot compare-and-swap a directory identity safely. Deliberately synchronous (sleepSync's
|
|
310
335
|
* Atomics.wait spin, not setTimeout/await) so claim/release/supersede can stay sync `number`
|
|
311
336
|
* -returning functions and the CLI dispatcher (src/cli.ts, `number | Promise<number>`) does not
|
|
312
337
|
* need any ripple to async. On lock-acquire failure (any error other than a live contested lock,
|
|
@@ -317,22 +342,31 @@ function subjectLockDir(artifactRoot: string, subjectId: string): string {
|
|
|
317
342
|
*/
|
|
318
343
|
export function withSubjectLock<T>(artifactRoot: string, subjectId: string, body: () => T): T {
|
|
319
344
|
const lockDir = subjectLockDir(artifactRoot, subjectId);
|
|
320
|
-
const staleMs =
|
|
345
|
+
const staleMs = trustedSubjectLockStaleMs();
|
|
346
|
+
const token = randomBytes(16).toString("hex");
|
|
347
|
+
const ownerFile = path.join(lockDir, "owner.json");
|
|
321
348
|
const deadline = Date.now() + 30000;
|
|
322
349
|
while (true) {
|
|
350
|
+
let createdLockDir = false;
|
|
323
351
|
try {
|
|
324
352
|
fs.mkdirSync(lockDir);
|
|
353
|
+
createdLockDir = true;
|
|
354
|
+
fs.writeFileSync(ownerFile, `${JSON.stringify({ token, pid: process.pid, acquired_at: isoNow() })}\n`, { flag: "wx", mode: 0o600 });
|
|
325
355
|
break;
|
|
326
356
|
} catch (error) {
|
|
327
357
|
const lockError = error as NodeJS.ErrnoException;
|
|
358
|
+
if (createdLockDir) fs.rmSync(lockDir, { recursive: true, force: true });
|
|
328
359
|
if (lockError.code !== "EEXIST") {
|
|
329
360
|
throw new Error(`failed to acquire assignment lock for subject ${subjectId}: ${lockDir}: ${lockError.message || lockError.code || String(lockError)}`);
|
|
330
361
|
}
|
|
331
362
|
try {
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
363
|
+
const owner = readSubjectLockOwner(ownerFile);
|
|
364
|
+
const stat = fs.lstatSync(owner?.token ? ownerFile : lockDir);
|
|
365
|
+
if (stat.isSymbolicLink() || !(owner?.token ? stat.isFile() : stat.isDirectory())) {
|
|
366
|
+
throw new Error(`assignment lock has an unsafe ${owner?.token ? "owner file" : "directory"}: ${lockDir}`);
|
|
367
|
+
}
|
|
368
|
+
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
369
|
+
throw new Error(`assignment lock is stale or malformed and requires explicit operator cleanup after confirming no owner is active: ${lockDir}`);
|
|
336
370
|
}
|
|
337
371
|
} catch (statError) {
|
|
338
372
|
if ((statError as NodeJS.ErrnoException).code === "ENOENT") continue; // lock released between mkdir/EEXIST and stat; retry immediately
|
|
@@ -344,7 +378,12 @@ export function withSubjectLock<T>(artifactRoot: string, subjectId: string, body
|
|
|
344
378
|
sleepSync(20);
|
|
345
379
|
}
|
|
346
380
|
}
|
|
347
|
-
|
|
381
|
+
let heartbeat: NodeJS.Timeout | undefined;
|
|
382
|
+
const ownsLock = (): boolean => readSubjectLockOwner(ownerFile)?.token === token;
|
|
383
|
+
const release = (): void => {
|
|
384
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
385
|
+
if (ownsLock()) fs.rmSync(lockDir, { recursive: true, force: true });
|
|
386
|
+
};
|
|
348
387
|
let result: T;
|
|
349
388
|
try {
|
|
350
389
|
result = body();
|
|
@@ -353,12 +392,36 @@ export function withSubjectLock<T>(artifactRoot: string, subjectId: string, body
|
|
|
353
392
|
throw error;
|
|
354
393
|
}
|
|
355
394
|
if (result && typeof (result as { then?: unknown }).then === "function") {
|
|
395
|
+
// An async owner can legitimately hold the lock longer than the stale-lock
|
|
396
|
+
// threshold while an authority-bound command is running. Keep its mtime fresh
|
|
397
|
+
// so lifecycle operations and takeovers continue to observe the live lock.
|
|
398
|
+
const heartbeatMs = Math.max(10, Math.min(1_000, Math.floor(staleMs > 0 ? staleMs / 3 : 1_000)));
|
|
399
|
+
heartbeat = setInterval(() => {
|
|
400
|
+
try {
|
|
401
|
+
if (!ownsLock()) return;
|
|
402
|
+
const timestamp = new Date();
|
|
403
|
+
fs.utimesSync(ownerFile, timestamp, timestamp);
|
|
404
|
+
fs.utimesSync(lockDir, timestamp, timestamp);
|
|
405
|
+
} catch { /* release, reclamation, or process teardown owns cleanup */ }
|
|
406
|
+
}, heartbeatMs);
|
|
356
407
|
return Promise.resolve(result).finally(release) as T;
|
|
357
408
|
}
|
|
358
409
|
release();
|
|
359
410
|
return result;
|
|
360
411
|
}
|
|
361
412
|
|
|
413
|
+
function readSubjectLockOwner(file: string): { token?: string } | null {
|
|
414
|
+
try {
|
|
415
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
|
|
416
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
417
|
+
? value as { token?: string }
|
|
418
|
+
: null;
|
|
419
|
+
} catch (error) {
|
|
420
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT" || error instanceof SyntaxError) return null;
|
|
421
|
+
throw error;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
362
425
|
/**
|
|
363
426
|
* The assignment ⋈ liveness join (contract doc's "assignment ⋈ liveness join" section, ADR 0021
|
|
364
427
|
* §1). Pure function: `{ assignment, freshHoldersList, selfActor, nowMs }` -> one of five
|
|
@@ -658,15 +721,22 @@ export function performLocalRelease(
|
|
|
658
721
|
subjectId: string,
|
|
659
722
|
releasedBy: ActorStruct | null,
|
|
660
723
|
opts: { reason?: string; actorKey?: string; tolerateNoActiveClaim?: boolean } = {},
|
|
724
|
+
): AssignmentClaimRecord | null {
|
|
725
|
+
return withSubjectLock(artifactRoot, subjectId, () => performLocalReleaseUnderLock(artifactRoot, subjectId, releasedBy, opts));
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Caller must already hold this subject's assignment lock through withSubjectLock(). */
|
|
729
|
+
export function performLocalReleaseUnderLock(
|
|
730
|
+
artifactRoot: string,
|
|
731
|
+
subjectId: string,
|
|
732
|
+
releasedBy: ActorStruct | null,
|
|
733
|
+
opts: { reason?: string; actorKey?: string; tolerateNoActiveClaim?: boolean } = {},
|
|
661
734
|
): AssignmentClaimRecord | null {
|
|
662
735
|
const helper = loadActorIdentityHelper();
|
|
663
736
|
const reason = opts.reason ?? "released";
|
|
664
737
|
const tolerateNoActiveClaim = opts.tolerateNoActiveClaim ?? false;
|
|
665
738
|
|
|
666
|
-
|
|
667
|
-
// do, under the same per-subject lock (see withSubjectLock()'s doc comment).
|
|
668
|
-
return withSubjectLock(artifactRoot, subjectId, (): AssignmentClaimRecord | null => {
|
|
669
|
-
const existing = readLocalRecord(artifactRoot, subjectId);
|
|
739
|
+
const existing = readLocalRecord(artifactRoot, subjectId);
|
|
670
740
|
if (!existing || existing.status !== "claimed") {
|
|
671
741
|
if (tolerateNoActiveClaim) return null;
|
|
672
742
|
throw new Error(`no active claim to release for subject: ${subjectId}`);
|
|
@@ -718,8 +788,7 @@ export function performLocalRelease(
|
|
|
718
788
|
audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "release", from_actor: existing.actor, to_actor: releasedBy, reason }],
|
|
719
789
|
};
|
|
720
790
|
writeLocalRecord(artifactRoot, subjectId, record);
|
|
721
|
-
|
|
722
|
-
});
|
|
791
|
+
return record;
|
|
723
792
|
}
|
|
724
793
|
|
|
725
794
|
function releaseLocalFile(argv: string[]): number {
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
BUILDER_BUILD_FLOW_ID,
|
|
18
18
|
evaluateBuilderBuildRun,
|
|
19
19
|
startBuilderBuildRun,
|
|
20
|
-
} from "../../build/src/
|
|
20
|
+
} from "../../build/src/builder-flow-run-adapter.js";
|
|
21
21
|
|
|
22
22
|
const REPO_ROOT = path.resolve(import.meta.dirname, "../..");
|
|
23
23
|
const BUILDER_BUILD_DEFINITION = path.join(REPO_ROOT, "kits/builder/flows/build.flow.json");
|
|
@@ -245,6 +245,8 @@ test("all verified parent selectors advance only their persisted gates through p
|
|
|
245
245
|
subjectType: "flow-step",
|
|
246
246
|
name: "verify-all-selectors",
|
|
247
247
|
bundle: trustBundle({ claims: [
|
|
248
|
+
claim("workflow.critique.review", "workflow-critique"),
|
|
249
|
+
claim("workflow.acceptance.criterion", "flow-step"),
|
|
248
250
|
claim("builder.verify.tests", "flow-step"),
|
|
249
251
|
claim("builder.verify.policy-compliance", "artifact"),
|
|
250
252
|
] }),
|
|
@@ -271,7 +273,7 @@ test("all verified parent selectors advance only their persisted gates through p
|
|
|
271
273
|
{ gate_id: "design-probe-gate", status: "pass", matched_expectations: ["pickup-probe-readiness", "probe-decisions-or-accepted-gaps"] },
|
|
272
274
|
{ gate_id: "plan-gate", status: "pass", matched_expectations: ["implementation-plan"] },
|
|
273
275
|
{ gate_id: "execute-gate", status: "pass", matched_expectations: ["implementation-scope"] },
|
|
274
|
-
{ gate_id: "verify-gate", status: "pass", matched_expectations: ["tests-evidence", "policy-compliance"] },
|
|
276
|
+
{ gate_id: "verify-gate", status: "pass", matched_expectations: ["clean-critique", "acceptance-criteria", "tests-evidence", "policy-compliance"] },
|
|
275
277
|
{ gate_id: "merge-ready-gate", status: "pass", matched_expectations: ["merge-readiness"] },
|
|
276
278
|
]);
|
|
277
279
|
});
|