@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,35 @@
|
|
|
1
|
+
import type { FlowLifecycleRequest } from "@kontourai/flow";
|
|
2
|
+
import type { ActorStruct } from "./cli/assignment-provider.js";
|
|
3
|
+
type JsonRecord = Record<string, unknown>;
|
|
4
|
+
export type AuthorizedBuilderLifecycleOperation = "cancel" | "archive";
|
|
5
|
+
export interface BuilderLifecycleAuthorization {
|
|
6
|
+
schema_version: "1.0";
|
|
7
|
+
operation: AuthorizedBuilderLifecycleOperation;
|
|
8
|
+
run_id: string;
|
|
9
|
+
subject: string;
|
|
10
|
+
assignment_actor_key: string;
|
|
11
|
+
assignment_actor: ActorStruct;
|
|
12
|
+
nonce: string;
|
|
13
|
+
expires_at: string;
|
|
14
|
+
request: FlowLifecycleRequest;
|
|
15
|
+
signature: {
|
|
16
|
+
algorithm: "ed25519";
|
|
17
|
+
key_id: string;
|
|
18
|
+
value: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export declare function lifecycleAuthorityKeysPath(projectRoot: string): string;
|
|
22
|
+
export declare function loadBuilderLifecycleAuthorization(fileInput: string, expected: {
|
|
23
|
+
projectRoot: string;
|
|
24
|
+
operation: AuthorizedBuilderLifecycleOperation;
|
|
25
|
+
runId: string;
|
|
26
|
+
subject: string;
|
|
27
|
+
actorKey: string;
|
|
28
|
+
now?: string;
|
|
29
|
+
allowExpired?: boolean;
|
|
30
|
+
}): BuilderLifecycleAuthorization;
|
|
31
|
+
export declare function builderLifecycleAuthorizationPayload(value: Omit<BuilderLifecycleAuthorization, "signature">): string;
|
|
32
|
+
export declare function assertAuthorizationUnused(artifactRoot: string, authorization: BuilderLifecycleAuthorization): void;
|
|
33
|
+
export declare function readAuthorizationConsumption(artifactRoot: string, authorization: BuilderLifecycleAuthorization): JsonRecord | null;
|
|
34
|
+
export declare function recordAuthorizationConsumed(artifactRoot: string, authorization: BuilderLifecycleAuthorization, at?: string): void;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { createHash, createPublicKey, verify } from "node:crypto";
|
|
4
|
+
import { durableFlowAgentsRoot } from "./lib/local-artifact-root.js";
|
|
5
|
+
export function lifecycleAuthorityKeysPath(projectRoot) {
|
|
6
|
+
return path.join(durableFlowAgentsRoot(projectRoot), "lifecycle-authority-keys.json");
|
|
7
|
+
}
|
|
8
|
+
export function loadBuilderLifecycleAuthorization(fileInput, expected) {
|
|
9
|
+
const value = readRegularJson(fileInput, "lifecycle authorization");
|
|
10
|
+
assertExactKeys(value, ["schema_version", "operation", "run_id", "subject", "assignment_actor_key", "assignment_actor", "nonce", "expires_at", "request", "signature"], "authorization");
|
|
11
|
+
if (value.schema_version !== "1.0")
|
|
12
|
+
throw new Error("lifecycle authorization schema_version must be 1.0");
|
|
13
|
+
assertEqual(value.operation, expected.operation, "operation");
|
|
14
|
+
assertEqual(value.run_id, expected.runId, "run_id");
|
|
15
|
+
assertEqual(value.subject, expected.subject, "subject");
|
|
16
|
+
assertEqual(value.assignment_actor_key, expected.actorKey, "assignment_actor_key");
|
|
17
|
+
const assignmentActor = validateActor(value.assignment_actor);
|
|
18
|
+
const request = validateRequest(value.request);
|
|
19
|
+
const nonce = boundedText(value.nonce, "nonce", 256);
|
|
20
|
+
const expiresAt = dateTime(value.expires_at, "expires_at");
|
|
21
|
+
const requestedAt = Date.parse(request.authority.requested_at);
|
|
22
|
+
const now = Date.parse(expected.now ?? new Date().toISOString());
|
|
23
|
+
if (expiresAt < requestedAt)
|
|
24
|
+
throw new Error("lifecycle authorization expires_at must not precede request.authority.requested_at");
|
|
25
|
+
if (now > expiresAt && !expected.allowExpired)
|
|
26
|
+
throw new Error("lifecycle authorization is expired");
|
|
27
|
+
if (requestedAt > now + 5 * 60_000)
|
|
28
|
+
throw new Error("lifecycle authorization request time is in the future");
|
|
29
|
+
const signature = validateSignature(value.signature);
|
|
30
|
+
const authorization = {
|
|
31
|
+
schema_version: "1.0",
|
|
32
|
+
operation: expected.operation,
|
|
33
|
+
run_id: expected.runId,
|
|
34
|
+
subject: expected.subject,
|
|
35
|
+
assignment_actor_key: expected.actorKey,
|
|
36
|
+
assignment_actor: assignmentActor,
|
|
37
|
+
nonce,
|
|
38
|
+
expires_at: value.expires_at,
|
|
39
|
+
request,
|
|
40
|
+
signature,
|
|
41
|
+
};
|
|
42
|
+
verifyAuthorizationSignature(authorization, lifecycleAuthorityKeysPath(expected.projectRoot));
|
|
43
|
+
return authorization;
|
|
44
|
+
}
|
|
45
|
+
export function builderLifecycleAuthorizationPayload(value) {
|
|
46
|
+
return JSON.stringify(value);
|
|
47
|
+
}
|
|
48
|
+
export function assertAuthorizationUnused(artifactRoot, authorization) {
|
|
49
|
+
if (!readAuthorizationConsumption(artifactRoot, authorization))
|
|
50
|
+
return;
|
|
51
|
+
throw new Error("lifecycle authorization nonce has already been consumed");
|
|
52
|
+
}
|
|
53
|
+
export function readAuthorizationConsumption(artifactRoot, authorization) {
|
|
54
|
+
const file = consumedAuthorizationPath(artifactRoot, authorization);
|
|
55
|
+
if (!pathExistsNoFollow(file))
|
|
56
|
+
return null;
|
|
57
|
+
const record = readRegularJson(file, "consumed lifecycle authorization record");
|
|
58
|
+
if (record.run_id !== authorization.run_id
|
|
59
|
+
|| record.operation !== authorization.operation
|
|
60
|
+
|| record.nonce !== authorization.nonce
|
|
61
|
+
|| record.key_id !== authorization.signature.key_id
|
|
62
|
+
|| record.authorization_sha256 !== authorizationDigest(authorization)) {
|
|
63
|
+
throw new Error("consumed lifecycle authorization record does not match its integrity key");
|
|
64
|
+
}
|
|
65
|
+
return record;
|
|
66
|
+
}
|
|
67
|
+
export function recordAuthorizationConsumed(artifactRoot, authorization, at = new Date().toISOString()) {
|
|
68
|
+
const file = consumedAuthorizationPath(artifactRoot, authorization);
|
|
69
|
+
const directory = path.dirname(file);
|
|
70
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
71
|
+
const stat = fs.lstatSync(directory);
|
|
72
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || !pathIsWithin(fs.realpathSync(directory), fs.realpathSync(artifactRoot)))
|
|
73
|
+
throw new Error("lifecycle authorization registry directory is unsafe");
|
|
74
|
+
const temporary = path.join(directory, `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
|
|
75
|
+
const descriptor = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
76
|
+
try {
|
|
77
|
+
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`);
|
|
78
|
+
fs.fsyncSync(descriptor);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
fs.closeSync(descriptor);
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
fs.linkSync(temporary, file);
|
|
85
|
+
const directoryDescriptor = fs.openSync(directory, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
86
|
+
try {
|
|
87
|
+
fs.fsyncSync(directoryDescriptor);
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
fs.closeSync(directoryDescriptor);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
fs.rmSync(temporary, { force: true });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function consumedAuthorizationPath(artifactRoot, authorization) {
|
|
98
|
+
const integrityKey = createHash("sha256").update(authorization.run_id).update("\0").update(authorization.nonce).digest("hex");
|
|
99
|
+
return path.join(artifactRoot, "lifecycle-authority", "consumed", `${integrityKey}.json`);
|
|
100
|
+
}
|
|
101
|
+
function authorizationDigest(authorization) {
|
|
102
|
+
return createHash("sha256").update(JSON.stringify(authorization)).digest("hex");
|
|
103
|
+
}
|
|
104
|
+
function verifyAuthorizationSignature(authorization, keysFile) {
|
|
105
|
+
const registry = readRegularJson(keysFile, "lifecycle authority key registry", true);
|
|
106
|
+
assertExactKeys(registry, ["schema_version", "keys"], "key registry");
|
|
107
|
+
if (registry.schema_version !== "1.0" || !Array.isArray(registry.keys))
|
|
108
|
+
throw new Error("lifecycle authority key registry must contain schema_version 1.0 and keys[]");
|
|
109
|
+
const key = registry.keys.find((candidate) => isRecord(candidate) && candidate.id === authorization.signature.key_id);
|
|
110
|
+
if (!isRecord(key) || key.algorithm !== "ed25519" || typeof key.public_key_pem !== "string" || key.public_key_pem.trim().length === 0) {
|
|
111
|
+
throw new Error(`lifecycle authorization key ${authorization.signature.key_id} is not trusted`);
|
|
112
|
+
}
|
|
113
|
+
const { signature: _signature, ...unsigned } = authorization;
|
|
114
|
+
let verified = false;
|
|
115
|
+
try {
|
|
116
|
+
verified = verify(null, Buffer.from(builderLifecycleAuthorizationPayload(unsigned)), createPublicKey(key.public_key_pem), Buffer.from(authorization.signature.value, "base64"));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
verified = false;
|
|
120
|
+
}
|
|
121
|
+
if (!verified)
|
|
122
|
+
throw new Error("lifecycle authorization signature is invalid");
|
|
123
|
+
}
|
|
124
|
+
function readRegularJson(fileInput, label, requireProtected = false) {
|
|
125
|
+
const file = path.resolve(fileInput);
|
|
126
|
+
const descriptor = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
127
|
+
try {
|
|
128
|
+
const stat = fs.fstatSync(descriptor);
|
|
129
|
+
if (!stat.isFile())
|
|
130
|
+
throw new Error(`${label} must be a regular file`);
|
|
131
|
+
if (stat.size > 64 * 1024)
|
|
132
|
+
throw new Error(`${label} exceeds 64 KiB`);
|
|
133
|
+
if (requireProtected && (stat.mode & 0o022) !== 0)
|
|
134
|
+
throw new Error(`${label} must not be group- or world-writable`);
|
|
135
|
+
const value = JSON.parse(fs.readFileSync(descriptor, "utf8"));
|
|
136
|
+
if (!isRecord(value))
|
|
137
|
+
throw new Error(`${label} must be a JSON object`);
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
fs.closeSync(descriptor);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function validateActor(value) {
|
|
145
|
+
if (!isRecord(value))
|
|
146
|
+
throw new Error("lifecycle authorization assignment_actor must be an object");
|
|
147
|
+
assertExactKeys(value, ["runtime", "session_id", "host", "human"], "assignment_actor");
|
|
148
|
+
if (!Object.prototype.hasOwnProperty.call(value, "human"))
|
|
149
|
+
throw new Error("lifecycle authorization assignment_actor.human is required");
|
|
150
|
+
for (const field of ["runtime", "session_id", "host"])
|
|
151
|
+
boundedText(value[field], `assignment_actor.${field}`, 256);
|
|
152
|
+
if (value.human !== undefined && value.human !== null)
|
|
153
|
+
boundedText(value.human, "assignment_actor.human", 256);
|
|
154
|
+
return { runtime: value.runtime, session_id: value.session_id, host: value.host, human: value.human == null ? null : value.human };
|
|
155
|
+
}
|
|
156
|
+
function validateRequest(value) {
|
|
157
|
+
if (!isRecord(value))
|
|
158
|
+
throw new Error("lifecycle authorization request must be an object");
|
|
159
|
+
assertExactKeys(value, ["reason", "authority"], "request");
|
|
160
|
+
const reason = boundedText(value.reason, "request.reason", 4096);
|
|
161
|
+
if (!isRecord(value.authority))
|
|
162
|
+
throw new Error("lifecycle authorization request.authority must be an object");
|
|
163
|
+
assertExactKeys(value.authority, ["kind", "actor", "request_ref", "requested_at"], "request.authority");
|
|
164
|
+
if (value.authority.kind !== "user_request" && value.authority.kind !== "operator_request")
|
|
165
|
+
throw new Error("lifecycle authorization request.authority.kind must be user_request or operator_request");
|
|
166
|
+
const actor = boundedText(value.authority.actor, "request.authority.actor", 256);
|
|
167
|
+
const requestRef = boundedText(value.authority.request_ref, "request.authority.request_ref", 2048);
|
|
168
|
+
dateTime(value.authority.requested_at, "request.authority.requested_at");
|
|
169
|
+
return { reason, authority: { kind: value.authority.kind, actor, request_ref: requestRef, requested_at: value.authority.requested_at } };
|
|
170
|
+
}
|
|
171
|
+
function validateSignature(value) {
|
|
172
|
+
if (!isRecord(value))
|
|
173
|
+
throw new Error("lifecycle authorization signature must be an object");
|
|
174
|
+
assertExactKeys(value, ["algorithm", "key_id", "value"], "signature");
|
|
175
|
+
if (value.algorithm !== "ed25519")
|
|
176
|
+
throw new Error("lifecycle authorization signature.algorithm must be ed25519");
|
|
177
|
+
return { algorithm: "ed25519", key_id: boundedText(value.key_id, "signature.key_id", 256), value: boundedText(value.value, "signature.value", 1024) };
|
|
178
|
+
}
|
|
179
|
+
function assertEqual(actual, expected, field) {
|
|
180
|
+
if (actual !== expected)
|
|
181
|
+
throw new Error(`lifecycle authorization ${field} does not match the requested Builder operation`);
|
|
182
|
+
}
|
|
183
|
+
function assertExactKeys(value, allowed, field) {
|
|
184
|
+
const unexpected = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
185
|
+
if (unexpected.length)
|
|
186
|
+
throw new Error(`lifecycle authorization ${field} contains unsupported field ${unexpected[0]}`);
|
|
187
|
+
}
|
|
188
|
+
function dateTime(value, field) {
|
|
189
|
+
const text = boundedText(value, field, 128);
|
|
190
|
+
if (!Number.isFinite(Date.parse(text)))
|
|
191
|
+
throw new Error(`lifecycle authorization ${field} must be a date-time`);
|
|
192
|
+
return Date.parse(text);
|
|
193
|
+
}
|
|
194
|
+
function boundedText(value, field, limit) {
|
|
195
|
+
if (!nonEmpty(value) || [...value].length > limit)
|
|
196
|
+
throw new Error(`lifecycle authorization ${field} must be a non-empty string of at most ${limit} characters`);
|
|
197
|
+
return value;
|
|
198
|
+
}
|
|
199
|
+
function nonEmpty(value) {
|
|
200
|
+
return typeof value === "string" && value.trim().length > 0 && !/[\x00-\x1f\x7f]/.test(value);
|
|
201
|
+
}
|
|
202
|
+
function isRecord(value) {
|
|
203
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
204
|
+
}
|
|
205
|
+
function pathExistsNoFollow(candidate) {
|
|
206
|
+
try {
|
|
207
|
+
fs.lstatSync(candidate);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
if (error.code === "ENOENT")
|
|
212
|
+
return false;
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function pathIsWithin(candidate, root) {
|
|
217
|
+
const relative = path.relative(root, candidate);
|
|
218
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
219
|
+
}
|
|
@@ -54,6 +54,10 @@ export type AssignmentStatus = {
|
|
|
54
54
|
record: AssignmentClaimRecord | null;
|
|
55
55
|
has_claim_label?: boolean;
|
|
56
56
|
};
|
|
57
|
+
export declare function resolveCurrentAssignmentActor(): {
|
|
58
|
+
actor: ActorStruct;
|
|
59
|
+
actorKey: string;
|
|
60
|
+
};
|
|
57
61
|
export declare function assignmentFilePath(artifactRoot: string, subjectId: string): string;
|
|
58
62
|
export declare function readLocalRecord(artifactRoot: string, subjectId: string): AssignmentClaimRecord | null;
|
|
59
63
|
export declare function writeLocalRecord(artifactRoot: string, subjectId: string, record: AssignmentClaimRecord): void;
|
|
@@ -62,13 +66,10 @@ export declare function writeLocalRecord(artifactRoot: string, subjectId: string
|
|
|
62
66
|
* a plain read -> compare-actor -> write with no lock, so two concurrently-launched OS processes
|
|
63
67
|
* could both read "no conflicting claim" before either wrote, and the second write would silently
|
|
64
68
|
* clobber the first with zero error and zero audit-trail entry for the loser (reproduced 29/40
|
|
65
|
-
* races against the built CLI).
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* is reclaimed rather than waited on forever) and a bounded deadline, `finally` rmSync release —
|
|
70
|
-
* as a small LOCAL helper (not a cross-import of that private function, which would pull the
|
|
71
|
-
* entire workflow-sidecar module in for one primitive). Deliberately synchronous (sleepSync's
|
|
69
|
+
* races against the built CLI). Atomic directory creation establishes ownership before metadata
|
|
70
|
+
* is written; contenders treat even an ownerless directory as held. Live contention waits with a
|
|
71
|
+
* bounded deadline; stale or malformed residue fails closed for explicit operator cleanup because portable Node
|
|
72
|
+
* filesystem APIs cannot compare-and-swap a directory identity safely. Deliberately synchronous (sleepSync's
|
|
72
73
|
* Atomics.wait spin, not setTimeout/await) so claim/release/supersede can stay sync `number`
|
|
73
74
|
* -returning functions and the CLI dispatcher (src/cli.ts, `number | Promise<number>`) does not
|
|
74
75
|
* need any ripple to async. On lock-acquire failure (any error other than a live contested lock,
|
|
@@ -164,6 +165,12 @@ export declare function performLocalRelease(artifactRoot: string, subjectId: str
|
|
|
164
165
|
actorKey?: string;
|
|
165
166
|
tolerateNoActiveClaim?: boolean;
|
|
166
167
|
}): AssignmentClaimRecord | null;
|
|
168
|
+
/** Caller must already hold this subject's assignment lock through withSubjectLock(). */
|
|
169
|
+
export declare function performLocalReleaseUnderLock(artifactRoot: string, subjectId: string, releasedBy: ActorStruct | null, opts?: {
|
|
170
|
+
reason?: string;
|
|
171
|
+
actorKey?: string;
|
|
172
|
+
tolerateNoActiveClaim?: boolean;
|
|
173
|
+
}): AssignmentClaimRecord | null;
|
|
167
174
|
/**
|
|
168
175
|
* Wave 1 (#291) extraction: the durable-write body previously inlined inside supersedeLocalFile's
|
|
169
176
|
* withSubjectLock() closure, now a parameter-driven pure function so ensure-session's
|
|
@@ -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 } from "../lib/args.js";
|
|
@@ -76,6 +77,9 @@ function loadActorStruct(args) {
|
|
|
76
77
|
const actorJsonPath = flagString(args.flags, "actor-json");
|
|
77
78
|
if (actorJsonPath)
|
|
78
79
|
return { actor: loadActorStructFromFile(actorJsonPath) };
|
|
80
|
+
return resolveCurrentAssignmentActor();
|
|
81
|
+
}
|
|
82
|
+
export function resolveCurrentAssignmentActor() {
|
|
79
83
|
const helper = loadActorIdentityHelper();
|
|
80
84
|
const resolved = helper.resolveActor(process.env);
|
|
81
85
|
if (helper.isUnresolvedActor(resolved.actor))
|
|
@@ -86,9 +90,15 @@ function loadActorStruct(args) {
|
|
|
86
90
|
// CI session — actor_key stays correct (so no false-block), but record.actor is malformed and the
|
|
87
91
|
// audit-trail / `assignment-provider status` output for CI sessions would be corrupt.
|
|
88
92
|
const ci = resolved.source.startsWith("ci-runtime") ? helper.detectCiActor(process.env) : null;
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
const runtimeSessionId = resolved.source.startsWith("runtime-session-id") ? helper.runtimeSessionId(process.env) : "";
|
|
94
|
+
const ancestrySeed = resolved.source === "process-ancestry" ? helper.ancestorActorSeed() : "";
|
|
95
|
+
const actor = resolved.source === "explicit-override"
|
|
96
|
+
? { runtime: "explicit-override", session_id: resolved.actor, host: os.hostname(), human: null }
|
|
97
|
+
: ci && ci.session_id
|
|
98
|
+
? { runtime: ci.runtime, session_id: ci.session_id, host: os.hostname(), human: null }
|
|
99
|
+
: runtimeSessionId
|
|
100
|
+
? { runtime: helper.detectRuntime(process.env), session_id: runtimeSessionId, host: os.hostname(), human: null }
|
|
101
|
+
: { runtime: helper.detectRuntime(process.env), session_id: ancestrySeed ? `anc-${ancestrySeed}` : resolved.actor, host: os.hostname(), human: null };
|
|
92
102
|
return { actor, actorKey: resolved.actor };
|
|
93
103
|
}
|
|
94
104
|
export function assignmentFilePath(artifactRoot, subjectId) {
|
|
@@ -177,18 +187,27 @@ function subjectLockDir(artifactRoot, subjectId) {
|
|
|
177
187
|
const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
|
|
178
188
|
return path.join(assignmentDir, `.${sanitized}.lockdir`);
|
|
179
189
|
}
|
|
190
|
+
// Lock age is adjudicated by the current contender, never by metadata written
|
|
191
|
+
// by the lock owner. The environment is only an operator tuning input; clamp it
|
|
192
|
+
// so a caller cannot turn a transient owner-file write into immediate takeover.
|
|
193
|
+
const SUBJECT_LOCK_STALE_MIN_MS = 1_000;
|
|
194
|
+
const SUBJECT_LOCK_STALE_MAX_MS = 30 * 60 * 1_000;
|
|
195
|
+
const SUBJECT_LOCK_STALE_DEFAULT_MS = 5 * 60 * 1_000;
|
|
196
|
+
function trustedSubjectLockStaleMs() {
|
|
197
|
+
const configured = Number(process.env.FLOW_AGENTS_ASSIGNMENT_STALE_LOCK_MS);
|
|
198
|
+
if (!Number.isFinite(configured))
|
|
199
|
+
return SUBJECT_LOCK_STALE_DEFAULT_MS;
|
|
200
|
+
return Math.min(SUBJECT_LOCK_STALE_MAX_MS, Math.max(SUBJECT_LOCK_STALE_MIN_MS, Math.floor(configured)));
|
|
201
|
+
}
|
|
180
202
|
/**
|
|
181
203
|
* F1 fix (fix-plan iteration 1, CRITICAL): claimLocalFile/releaseLocalFile/supersedeLocalFile were
|
|
182
204
|
* a plain read -> compare-actor -> write with no lock, so two concurrently-launched OS processes
|
|
183
205
|
* could both read "no conflicting claim" before either wrote, and the second write would silently
|
|
184
206
|
* clobber the first with zero error and zero audit-trail entry for the loser (reproduced 29/40
|
|
185
|
-
* races against the built CLI).
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* is reclaimed rather than waited on forever) and a bounded deadline, `finally` rmSync release —
|
|
190
|
-
* as a small LOCAL helper (not a cross-import of that private function, which would pull the
|
|
191
|
-
* entire workflow-sidecar module in for one primitive). Deliberately synchronous (sleepSync's
|
|
207
|
+
* races against the built CLI). Atomic directory creation establishes ownership before metadata
|
|
208
|
+
* is written; contenders treat even an ownerless directory as held. Live contention waits with a
|
|
209
|
+
* bounded deadline; stale or malformed residue fails closed for explicit operator cleanup because portable Node
|
|
210
|
+
* filesystem APIs cannot compare-and-swap a directory identity safely. Deliberately synchronous (sleepSync's
|
|
192
211
|
* Atomics.wait spin, not setTimeout/await) so claim/release/supersede can stay sync `number`
|
|
193
212
|
* -returning functions and the CLI dispatcher (src/cli.ts, `number | Promise<number>`) does not
|
|
194
213
|
* need any ripple to async. On lock-acquire failure (any error other than a live contested lock,
|
|
@@ -199,23 +218,33 @@ function subjectLockDir(artifactRoot, subjectId) {
|
|
|
199
218
|
*/
|
|
200
219
|
export function withSubjectLock(artifactRoot, subjectId, body) {
|
|
201
220
|
const lockDir = subjectLockDir(artifactRoot, subjectId);
|
|
202
|
-
const staleMs =
|
|
221
|
+
const staleMs = trustedSubjectLockStaleMs();
|
|
222
|
+
const token = randomBytes(16).toString("hex");
|
|
223
|
+
const ownerFile = path.join(lockDir, "owner.json");
|
|
203
224
|
const deadline = Date.now() + 30000;
|
|
204
225
|
while (true) {
|
|
226
|
+
let createdLockDir = false;
|
|
205
227
|
try {
|
|
206
228
|
fs.mkdirSync(lockDir);
|
|
229
|
+
createdLockDir = true;
|
|
230
|
+
fs.writeFileSync(ownerFile, `${JSON.stringify({ token, pid: process.pid, acquired_at: isoNow() })}\n`, { flag: "wx", mode: 0o600 });
|
|
207
231
|
break;
|
|
208
232
|
}
|
|
209
233
|
catch (error) {
|
|
210
234
|
const lockError = error;
|
|
235
|
+
if (createdLockDir)
|
|
236
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
211
237
|
if (lockError.code !== "EEXIST") {
|
|
212
238
|
throw new Error(`failed to acquire assignment lock for subject ${subjectId}: ${lockDir}: ${lockError.message || lockError.code || String(lockError)}`);
|
|
213
239
|
}
|
|
214
240
|
try {
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
241
|
+
const owner = readSubjectLockOwner(ownerFile);
|
|
242
|
+
const stat = fs.lstatSync(owner?.token ? ownerFile : lockDir);
|
|
243
|
+
if (stat.isSymbolicLink() || !(owner?.token ? stat.isFile() : stat.isDirectory())) {
|
|
244
|
+
throw new Error(`assignment lock has an unsafe ${owner?.token ? "owner file" : "directory"}: ${lockDir}`);
|
|
245
|
+
}
|
|
246
|
+
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
247
|
+
throw new Error(`assignment lock is stale or malformed and requires explicit operator cleanup after confirming no owner is active: ${lockDir}`);
|
|
219
248
|
}
|
|
220
249
|
}
|
|
221
250
|
catch (statError) {
|
|
@@ -229,7 +258,14 @@ export function withSubjectLock(artifactRoot, subjectId, body) {
|
|
|
229
258
|
sleepSync(20);
|
|
230
259
|
}
|
|
231
260
|
}
|
|
232
|
-
|
|
261
|
+
let heartbeat;
|
|
262
|
+
const ownsLock = () => readSubjectLockOwner(ownerFile)?.token === token;
|
|
263
|
+
const release = () => {
|
|
264
|
+
if (heartbeat)
|
|
265
|
+
clearInterval(heartbeat);
|
|
266
|
+
if (ownsLock())
|
|
267
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
268
|
+
};
|
|
233
269
|
let result;
|
|
234
270
|
try {
|
|
235
271
|
result = body();
|
|
@@ -239,11 +275,38 @@ export function withSubjectLock(artifactRoot, subjectId, body) {
|
|
|
239
275
|
throw error;
|
|
240
276
|
}
|
|
241
277
|
if (result && typeof result.then === "function") {
|
|
278
|
+
// An async owner can legitimately hold the lock longer than the stale-lock
|
|
279
|
+
// threshold while an authority-bound command is running. Keep its mtime fresh
|
|
280
|
+
// so lifecycle operations and takeovers continue to observe the live lock.
|
|
281
|
+
const heartbeatMs = Math.max(10, Math.min(1_000, Math.floor(staleMs > 0 ? staleMs / 3 : 1_000)));
|
|
282
|
+
heartbeat = setInterval(() => {
|
|
283
|
+
try {
|
|
284
|
+
if (!ownsLock())
|
|
285
|
+
return;
|
|
286
|
+
const timestamp = new Date();
|
|
287
|
+
fs.utimesSync(ownerFile, timestamp, timestamp);
|
|
288
|
+
fs.utimesSync(lockDir, timestamp, timestamp);
|
|
289
|
+
}
|
|
290
|
+
catch { /* release, reclamation, or process teardown owns cleanup */ }
|
|
291
|
+
}, heartbeatMs);
|
|
242
292
|
return Promise.resolve(result).finally(release);
|
|
243
293
|
}
|
|
244
294
|
release();
|
|
245
295
|
return result;
|
|
246
296
|
}
|
|
297
|
+
function readSubjectLockOwner(file) {
|
|
298
|
+
try {
|
|
299
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
300
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
301
|
+
? value
|
|
302
|
+
: null;
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
if (error.code === "ENOENT" || error instanceof SyntaxError)
|
|
306
|
+
return null;
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
247
310
|
/**
|
|
248
311
|
* The assignment ⋈ liveness join (contract doc's "assignment ⋈ liveness join" section, ADR 0021
|
|
249
312
|
* §1). Pure function: `{ assignment, freshHoldersList, selfActor, nowMs }` -> one of five
|
|
@@ -522,61 +585,61 @@ function claimLocalFile(argv) {
|
|
|
522
585
|
* than allowed to silently no-op or wrongly refuse.
|
|
523
586
|
*/
|
|
524
587
|
export function performLocalRelease(artifactRoot, subjectId, releasedBy, opts = {}) {
|
|
588
|
+
return withSubjectLock(artifactRoot, subjectId, () => performLocalReleaseUnderLock(artifactRoot, subjectId, releasedBy, opts));
|
|
589
|
+
}
|
|
590
|
+
/** Caller must already hold this subject's assignment lock through withSubjectLock(). */
|
|
591
|
+
export function performLocalReleaseUnderLock(artifactRoot, subjectId, releasedBy, opts = {}) {
|
|
525
592
|
const helper = loadActorIdentityHelper();
|
|
526
593
|
const reason = opts.reason ?? "released";
|
|
527
594
|
const tolerateNoActiveClaim = opts.tolerateNoActiveClaim ?? false;
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
595
|
+
const existing = readLocalRecord(artifactRoot, subjectId);
|
|
596
|
+
if (!existing || existing.status !== "claimed") {
|
|
597
|
+
if (tolerateNoActiveClaim)
|
|
598
|
+
return null;
|
|
599
|
+
throw new Error(`no active claim to release for subject: ${subjectId}`);
|
|
600
|
+
}
|
|
601
|
+
if (releasedBy) {
|
|
602
|
+
// Contract guard (hardening fix, #292 review): a caller that supplies `releasedBy` but NOT
|
|
603
|
+
// `opts.actorKey` against a record that already carries `actor_key` cannot reliably prove
|
|
604
|
+
// ownership — see this function's doc comment. This is the ONLY combination that fires: it
|
|
605
|
+
// does NOT fire when `existing.actor_key` is absent (the CLI/fixture path, where both sides
|
|
606
|
+
// fall back to serializeActor() and legitimately compare equal). Fail loudly rather than
|
|
607
|
+
// silently no-op (tolerant callers) or wrongly refuse (throwing callers) — never silent.
|
|
608
|
+
if (!opts.actorKey && existing.actor_key) {
|
|
609
|
+
if (tolerateNoActiveClaim) {
|
|
610
|
+
console.error(`[performLocalRelease] cannot verify ownership of an actor_key-stamped record without opts.actorKey; skipping release for ${subjectId}`);
|
|
534
611
|
return null;
|
|
535
|
-
throw new Error(`no active claim to release for subject: ${subjectId}`);
|
|
536
|
-
}
|
|
537
|
-
if (releasedBy) {
|
|
538
|
-
// Contract guard (hardening fix, #292 review): a caller that supplies `releasedBy` but NOT
|
|
539
|
-
// `opts.actorKey` against a record that already carries `actor_key` cannot reliably prove
|
|
540
|
-
// ownership — see this function's doc comment. This is the ONLY combination that fires: it
|
|
541
|
-
// does NOT fire when `existing.actor_key` is absent (the CLI/fixture path, where both sides
|
|
542
|
-
// fall back to serializeActor() and legitimately compare equal). Fail loudly rather than
|
|
543
|
-
// silently no-op (tolerant callers) or wrongly refuse (throwing callers) — never silent.
|
|
544
|
-
if (!opts.actorKey && existing.actor_key) {
|
|
545
|
-
if (tolerateNoActiveClaim) {
|
|
546
|
-
console.error(`[performLocalRelease] cannot verify ownership of an actor_key-stamped record without opts.actorKey; skipping release for ${subjectId}`);
|
|
547
|
-
return null;
|
|
548
|
-
}
|
|
549
|
-
throw new Error("performLocalRelease: pass opts.actorKey (the canonical resolveActor().actor string) when releasedBy is set and the record carries actor_key — serializeActor(releasedBy) is not a valid ownership key for actor_key-stamped records");
|
|
550
|
-
}
|
|
551
|
-
// AC6: never force-release a claim held by a different actor. Mirrors
|
|
552
|
-
// computeEffectiveState()'s canonical self-recognition comparison EXACTLY —
|
|
553
|
-
// `holderActorKey` prefers the stored `actor_key` (the canonical resolveActor(env).actor
|
|
554
|
-
// string, present on records written by the fixed performLocalClaim/performLocalSupersede
|
|
555
|
-
// paths) and only falls back to `serializeActor(existing.actor)` when `actor_key` is
|
|
556
|
-
// absent (every pre-fix record, every #290 eval fixture). The releaser's side must use the
|
|
557
|
-
// SAME canonical form: `opts.actorKey` (the caller's resolveActor(env).actor string, e.g.
|
|
558
|
-
// scripts/hooks/stop-goal-fit.js's Stop hook) when provided, else re-derived via
|
|
559
|
-
// serializeActor(releasedBy) — never serializeActor() unconditionally on both sides, which
|
|
560
|
-
// would compare the bare actor_key form against a re-derived triple form for an
|
|
561
|
-
// explicit-override actor and spuriously reject a legitimate same-actor release (the #291
|
|
562
|
-
// seam, relocated to this write path).
|
|
563
|
-
const holderActorKey = existing.actor_key || helper.serializeActor(existing.actor);
|
|
564
|
-
const releasedByActorKey = opts.actorKey || helper.serializeActor(releasedBy);
|
|
565
|
-
if (holderActorKey !== releasedByActorKey) {
|
|
566
|
-
if (tolerateNoActiveClaim)
|
|
567
|
-
return null;
|
|
568
|
-
throw new Error(`--actor-json does not match the current holder (${holderActorKey}); refusing to release a claim held by someone else`);
|
|
569
612
|
}
|
|
613
|
+
throw new Error("performLocalRelease: pass opts.actorKey (the canonical resolveActor().actor string) when releasedBy is set and the record carries actor_key — serializeActor(releasedBy) is not a valid ownership key for actor_key-stamped records");
|
|
570
614
|
}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
615
|
+
// AC6: never force-release a claim held by a different actor. Mirrors
|
|
616
|
+
// computeEffectiveState()'s canonical self-recognition comparison EXACTLY —
|
|
617
|
+
// `holderActorKey` prefers the stored `actor_key` (the canonical resolveActor(env).actor
|
|
618
|
+
// string, present on records written by the fixed performLocalClaim/performLocalSupersede
|
|
619
|
+
// paths) and only falls back to `serializeActor(existing.actor)` when `actor_key` is
|
|
620
|
+
// absent (every pre-fix record, every #290 eval fixture). The releaser's side must use the
|
|
621
|
+
// SAME canonical form: `opts.actorKey` (the caller's resolveActor(env).actor string, e.g.
|
|
622
|
+
// scripts/hooks/stop-goal-fit.js's Stop hook) when provided, else re-derived via
|
|
623
|
+
// serializeActor(releasedBy) — never serializeActor() unconditionally on both sides, which
|
|
624
|
+
// would compare the bare actor_key form against a re-derived triple form for an
|
|
625
|
+
// explicit-override actor and spuriously reject a legitimate same-actor release (the #291
|
|
626
|
+
// seam, relocated to this write path).
|
|
627
|
+
const holderActorKey = existing.actor_key || helper.serializeActor(existing.actor);
|
|
628
|
+
const releasedByActorKey = opts.actorKey || helper.serializeActor(releasedBy);
|
|
629
|
+
if (holderActorKey !== releasedByActorKey) {
|
|
630
|
+
if (tolerateNoActiveClaim)
|
|
631
|
+
return null;
|
|
632
|
+
throw new Error(`--actor-json does not match the current holder (${holderActorKey}); refusing to release a claim held by someone else`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
const record = {
|
|
636
|
+
...existing,
|
|
637
|
+
...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
|
|
638
|
+
status: "released",
|
|
639
|
+
audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "release", from_actor: existing.actor, to_actor: releasedBy, reason }],
|
|
640
|
+
};
|
|
641
|
+
writeLocalRecord(artifactRoot, subjectId, record);
|
|
642
|
+
return record;
|
|
580
643
|
}
|
|
581
644
|
function releaseLocalFile(argv) {
|
|
582
645
|
const args = parseArgs(argv);
|