@kontourai/flow-agents 3.5.0 → 3.6.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 +8 -0
- package/build/src/builder-flow-run-adapter.d.ts +10 -1
- package/build/src/builder-flow-run-adapter.js +29 -1
- package/build/src/builder-flow-runtime.d.ts +18 -0
- package/build/src/builder-flow-runtime.js +205 -13
- 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 +10 -0
- package/build/src/cli/assignment-provider.js +61 -52
- package/build/src/cli/builder-run.js +46 -5
- package/build/src/cli/workflow-artifact-cleanup-audit.js +3 -0
- package/build/src/cli/workflow-sidecar.d.ts +3 -0
- package/build/src/cli/workflow-sidecar.js +28 -6
- package/build/src/cli/workflow.d.ts +2 -0
- package/build/src/cli/workflow.js +521 -0
- package/build/src/cli.js +2 -0
- package/build/src/index.d.ts +4 -0
- package/build/src/index.js +2 -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/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/stop-goal-fit.js +1 -1
- package/docs/context-map.md +2 -0
- package/docs/public-workflow-cli.md +63 -0
- package/docs/spec/builder-flow-runtime.md +37 -0
- package/docs/workflow-usage-guide.md +5 -0
- package/evals/ci/run-baseline.sh +2 -0
- package/evals/integration/test_builder_entry_enforcement.sh +5 -4
- package/evals/integration/test_bundle_install.sh +59 -5
- package/evals/integration/test_public_workflow_cli.sh +259 -0
- 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/stop-goal-fit.js +1 -1
- package/src/builder-flow-run-adapter.ts +47 -0
- package/src/builder-flow-runtime.ts +216 -4
- package/src/builder-lifecycle-authority.ts +218 -0
- package/src/cli/assignment-provider.ts +29 -9
- package/src/cli/builder-flow-runtime.test.mjs +404 -1
- package/src/cli/builder-run.ts +56 -5
- package/src/cli/workflow-artifact-cleanup-audit.ts +3 -0
- package/src/cli/workflow-sidecar.ts +28 -6
- package/src/cli/workflow.ts +471 -0
- package/src/cli.ts +2 -0
- package/src/index.ts +14 -0
- package/src/lib/package-version.ts +15 -0
- package/src/lib/pinned-cli-command.ts +23 -0
|
@@ -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
|
+
}
|
|
@@ -121,6 +121,8 @@ function loadActorIdentityHelper(): {
|
|
|
121
121
|
isUnresolvedActor: (actor: string) => boolean;
|
|
122
122
|
sanitizeSegment: (value: unknown) => string;
|
|
123
123
|
detectRuntime: (env: NodeJS.ProcessEnv) => string;
|
|
124
|
+
runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
|
|
125
|
+
ancestorActorSeed: () => string;
|
|
124
126
|
detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
|
|
125
127
|
} {
|
|
126
128
|
const _req = createRequire(import.meta.url);
|
|
@@ -131,6 +133,8 @@ function loadActorIdentityHelper(): {
|
|
|
131
133
|
isUnresolvedActor: (actor: string) => boolean;
|
|
132
134
|
sanitizeSegment: (value: unknown) => string;
|
|
133
135
|
detectRuntime: (env: NodeJS.ProcessEnv) => string;
|
|
136
|
+
runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
|
|
137
|
+
ancestorActorSeed: () => string;
|
|
134
138
|
detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
|
|
135
139
|
};
|
|
136
140
|
}
|
|
@@ -199,6 +203,10 @@ function loadActorStructFromFile(file: string): ActorStruct {
|
|
|
199
203
|
function loadActorStruct(args: ParsedArgs): { actor: ActorStruct; actorKey?: string } {
|
|
200
204
|
const actorJsonPath = flagString(args.flags, "actor-json");
|
|
201
205
|
if (actorJsonPath) return { actor: loadActorStructFromFile(actorJsonPath) };
|
|
206
|
+
return resolveCurrentAssignmentActor();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function resolveCurrentAssignmentActor(): { actor: ActorStruct; actorKey: string } {
|
|
202
210
|
const helper = loadActorIdentityHelper();
|
|
203
211
|
const resolved = helper.resolveActor(process.env);
|
|
204
212
|
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 +216,15 @@ function loadActorStruct(args: ParsedArgs): { actor: ActorStruct; actorKey?: str
|
|
|
208
216
|
// CI session — actor_key stays correct (so no false-block), but record.actor is malformed and the
|
|
209
217
|
// audit-trail / `assignment-provider status` output for CI sessions would be corrupt.
|
|
210
218
|
const ci = resolved.source.startsWith("ci-runtime") ? helper.detectCiActor(process.env) : null;
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
219
|
+
const runtimeSessionId = resolved.source.startsWith("runtime-session-id") ? helper.runtimeSessionId(process.env) : "";
|
|
220
|
+
const ancestrySeed = resolved.source === "process-ancestry" ? helper.ancestorActorSeed() : "";
|
|
221
|
+
const actor: ActorStruct = resolved.source === "explicit-override"
|
|
222
|
+
? { runtime: "explicit-override", session_id: resolved.actor, host: os.hostname(), human: null }
|
|
223
|
+
: ci && ci.session_id
|
|
224
|
+
? { runtime: ci.runtime, session_id: ci.session_id, host: os.hostname(), human: null }
|
|
225
|
+
: runtimeSessionId
|
|
226
|
+
? { runtime: helper.detectRuntime(process.env), session_id: runtimeSessionId, host: os.hostname(), human: null }
|
|
227
|
+
: { runtime: helper.detectRuntime(process.env), session_id: ancestrySeed ? `anc-${ancestrySeed}` : resolved.actor, host: os.hostname(), human: null };
|
|
214
228
|
return { actor, actorKey: resolved.actor };
|
|
215
229
|
}
|
|
216
230
|
|
|
@@ -658,15 +672,22 @@ export function performLocalRelease(
|
|
|
658
672
|
subjectId: string,
|
|
659
673
|
releasedBy: ActorStruct | null,
|
|
660
674
|
opts: { reason?: string; actorKey?: string; tolerateNoActiveClaim?: boolean } = {},
|
|
675
|
+
): AssignmentClaimRecord | null {
|
|
676
|
+
return withSubjectLock(artifactRoot, subjectId, () => performLocalReleaseUnderLock(artifactRoot, subjectId, releasedBy, opts));
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Caller must already hold this subject's assignment lock through withSubjectLock(). */
|
|
680
|
+
export function performLocalReleaseUnderLock(
|
|
681
|
+
artifactRoot: string,
|
|
682
|
+
subjectId: string,
|
|
683
|
+
releasedBy: ActorStruct | null,
|
|
684
|
+
opts: { reason?: string; actorKey?: string; tolerateNoActiveClaim?: boolean } = {},
|
|
661
685
|
): AssignmentClaimRecord | null {
|
|
662
686
|
const helper = loadActorIdentityHelper();
|
|
663
687
|
const reason = opts.reason ?? "released";
|
|
664
688
|
const tolerateNoActiveClaim = opts.tolerateNoActiveClaim ?? false;
|
|
665
689
|
|
|
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);
|
|
690
|
+
const existing = readLocalRecord(artifactRoot, subjectId);
|
|
670
691
|
if (!existing || existing.status !== "claimed") {
|
|
671
692
|
if (tolerateNoActiveClaim) return null;
|
|
672
693
|
throw new Error(`no active claim to release for subject: ${subjectId}`);
|
|
@@ -718,8 +739,7 @@ export function performLocalRelease(
|
|
|
718
739
|
audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "release", from_actor: existing.actor, to_actor: releasedBy, reason }],
|
|
719
740
|
};
|
|
720
741
|
writeLocalRecord(artifactRoot, subjectId, record);
|
|
721
|
-
|
|
722
|
-
});
|
|
742
|
+
return record;
|
|
723
743
|
}
|
|
724
744
|
|
|
725
745
|
function releaseLocalFile(argv: string[]): number {
|