@llblab/pi-actors 0.43.1 → 0.45.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/AGENTS.md +6 -6
- package/BACKLOG.md +1 -189
- package/CHANGELOG.md +419 -402
- package/README.md +8 -6
- package/dist/index.js +7 -3
- package/dist/lib/async-runs.d.ts +2 -0
- package/dist/lib/async-runs.js +41 -34
- package/dist/lib/config.js +1 -0
- package/dist/lib/file-state.d.ts +3 -1
- package/dist/lib/file-state.js +25 -7
- package/dist/lib/inspector-overlay.js +46 -30
- package/dist/lib/inspector.js +8 -2
- package/dist/lib/limits.d.ts +10 -0
- package/dist/lib/limits.js +10 -0
- package/dist/lib/observability.d.ts +4 -2
- package/dist/lib/observability.js +43 -36
- package/dist/lib/recipes-discovery.js +20 -3
- package/dist/lib/recipes-references.d.ts +10 -0
- package/dist/lib/recipes-references.js +168 -5
- package/dist/lib/run-evidence-policy.d.ts +95 -0
- package/dist/lib/run-evidence-policy.js +177 -0
- package/dist/lib/run-ui-runtime.js +2 -0
- package/dist/lib/runs-controls.d.ts +7 -5
- package/dist/lib/runs-controls.js +186 -49
- package/dist/lib/runs-retention.js +27 -14
- package/dist/lib/runs-trace.d.ts +25 -1
- package/dist/lib/runs-trace.js +410 -21
- package/dist/lib/runtime-triage.js +6 -22
- package/dist/lib/schema.d.ts +3 -0
- package/dist/lib/schema.js +34 -1
- package/dist/lib/tools-inspect.js +58 -14
- package/dist/lib/tools-local.js +27 -10
- package/dist/lib/tools-spawn.js +2 -2
- package/dist/lib/trace-projection.js +90 -44
- package/dist/scripts/conformance.mjs +5 -0
- package/dist/scripts/locker.mjs +31 -74
- package/dist/scripts/music-player.mjs +41 -129
- package/dist/scripts/release-gates.mjs +26 -4
- package/dist/skills/actors/SKILL.md +9 -8
- package/dist/skills/swarm/SKILL.md +2 -2
- package/docs/README.md +0 -1
- package/docs/actor-inspector.md +3 -2
- package/docs/async-runs.md +10 -8
- package/docs/command-templates.md +2 -2
- package/docs/recipe-library.md +4 -2
- package/docs/template-recipes.md +10 -4
- package/docs/tool-registry.md +1 -1
- package/index.ts +9 -3
- package/lib/async-runs.ts +51 -54
- package/lib/config.ts +4 -0
- package/lib/file-state.ts +28 -7
- package/lib/inspector-overlay.ts +29 -17
- package/lib/inspector.ts +16 -2
- package/lib/limits.ts +10 -0
- package/lib/observability.ts +55 -57
- package/lib/recipes-discovery.ts +23 -3
- package/lib/recipes-references.ts +227 -7
- package/lib/run-evidence-policy.ts +242 -0
- package/lib/run-ui-runtime.ts +2 -0
- package/lib/runs-controls.ts +177 -108
- package/lib/runs-retention.ts +28 -20
- package/lib/runs-trace.ts +496 -21
- package/lib/runtime-triage.ts +11 -25
- package/lib/schema.ts +53 -1
- package/lib/tools-inspect.ts +56 -15
- package/lib/tools-local.ts +49 -19
- package/lib/tools-spawn.ts +2 -2
- package/lib/trace-projection.ts +137 -75
- package/package.json +1 -1
- package/scripts/conformance.mjs +5 -0
- package/scripts/locker.mjs +31 -74
- package/scripts/music-player.mjs +41 -129
- package/scripts/release-gates.mjs +26 -4
- package/skills/actors/SKILL.md +9 -8
- package/skills/swarm/SKILL.md +2 -2
- package/dist/lib/runtime-notifier.d.ts +0 -48
- package/dist/lib/runtime-notifier.js +0 -138
- package/docs/0.43-baseline.md +0 -39
- package/lib/runtime-notifier.ts +0 -211
package/lib/runs-controls.ts
CHANGED
|
@@ -1,28 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Run Control journal.
|
|
3
|
-
* Zones: durable
|
|
4
|
-
* Owns Run-local Control persistence;
|
|
3
|
+
* Zones: durable records, atomic admission, claims, transitions, compaction
|
|
4
|
+
* Owns Run-local Control persistence; lifecycle authorization and transport stay in adapters.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
|
-
import {
|
|
8
|
+
import { closeSync, fstatSync, openSync, readSync, statSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
|
|
11
|
+
import * as Control from "./control.ts";
|
|
11
12
|
import { acquireFileMutationLock, writeTextAtomic } from "./file-state.ts";
|
|
13
|
+
import * as Limits from "./limits.ts";
|
|
12
14
|
import {
|
|
13
|
-
|
|
14
|
-
type
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
export type RunControlStatus =
|
|
20
|
-
| "queued"
|
|
21
|
-
| "delivered"
|
|
22
|
-
| "claimed"
|
|
23
|
-
| "handled"
|
|
24
|
-
| "failed";
|
|
15
|
+
classifyRunControlRecord, computeRunControlCapacity, decideRunControlAdmission,
|
|
16
|
+
runControlStatusTimestamp, type RunControlAdmissionErrorDetails,
|
|
17
|
+
type RunControlAdmissionIntegrity, type RunControlStatus,
|
|
18
|
+
} from "./run-evidence-policy.ts";
|
|
19
|
+
import type { JsonlReadResult, StateReadDiagnostic } from "./state-readers.ts";
|
|
25
20
|
|
|
21
|
+
export { RUN_CONTROL_TERMINAL_LIMIT } from "./limits.ts";
|
|
22
|
+
export type { RunControlStatus } from "./run-evidence-policy.ts";
|
|
26
23
|
export interface RunControlRecord {
|
|
27
24
|
id: string;
|
|
28
25
|
run_instance_id: string;
|
|
@@ -36,115 +33,196 @@ export interface RunControlRecord {
|
|
|
36
33
|
failed_at?: string;
|
|
37
34
|
error?: string;
|
|
38
35
|
}
|
|
39
|
-
|
|
40
36
|
export interface ProcessRunControlsResult {
|
|
41
|
-
claimed: number;
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
claimed: number; failed: number; handled: number;
|
|
38
|
+
}
|
|
39
|
+
function admissionError(details: RunControlAdmissionErrorDetails): Error {
|
|
40
|
+
return Object.assign(new Error(details.reason === "control_backpressure"
|
|
41
|
+
? `Run Control admission backpressured: pending=${details.pending}/${details.limit}`
|
|
42
|
+
: `Run Control journal integrity failed: ${details.integrity_reason ?? "unknown"}`), details);
|
|
44
43
|
}
|
|
45
44
|
|
|
46
|
-
const
|
|
47
|
-
|
|
45
|
+
const STATUSES = new Set<RunControlStatus>([
|
|
46
|
+
"queued", "delivered", "claimed", "handled", "failed",
|
|
47
|
+
]);
|
|
48
48
|
export function runControlsFile(stateDir: string): string {
|
|
49
49
|
return join(stateDir, "controls.jsonl");
|
|
50
50
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
)
|
|
55
|
-
|
|
51
|
+
export function readRunControlJournalFromStateDir(stateDir: string): JsonlReadResult<unknown> {
|
|
52
|
+
const path = runControlsFile(stateDir);
|
|
53
|
+
let fd: number;
|
|
54
|
+
try { fd = openSync(path, "r"); }
|
|
55
|
+
catch (error) {
|
|
56
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { diagnostics: [], records: [] };
|
|
57
|
+
return { diagnostics: [{ message: String(error), path }], records: [] };
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const bytes = fstatSync(fd).size;
|
|
61
|
+
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > Limits.RUN_CONTROL_JOURNAL_MAX_BYTES) {
|
|
62
|
+
return { diagnostics: [{ message: "Run Control journal exceeds bounded read limit", path }], records: [] };
|
|
63
|
+
}
|
|
64
|
+
const buffer = Buffer.allocUnsafe(bytes);
|
|
65
|
+
let consumed = 0;
|
|
66
|
+
while (consumed < bytes) {
|
|
67
|
+
const count = readSync(fd, buffer, consumed, bytes - consumed, consumed);
|
|
68
|
+
if (count === 0) return { diagnostics: [{ message: "Run Control journal changed during bounded read", path }], records: [] };
|
|
69
|
+
consumed += count;
|
|
70
|
+
}
|
|
71
|
+
if (fstatSync(fd).size !== bytes) return { diagnostics: [{ message: "Run Control journal changed during bounded read", path }], records: [] };
|
|
72
|
+
const records: unknown[] = [];
|
|
73
|
+
const diagnostics: StateReadDiagnostic[] = [];
|
|
74
|
+
for (const [index, line] of buffer.toString("utf8").split("\n").entries()) {
|
|
75
|
+
if (!line.trim()) continue;
|
|
76
|
+
try { records.push(JSON.parse(line)); }
|
|
77
|
+
catch (error) { diagnostics.push({ line: index + 1, message: String(error), path }); }
|
|
78
|
+
}
|
|
79
|
+
return { diagnostics, records };
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return { diagnostics: [{ message: String(error), path }], records: [] };
|
|
82
|
+
} finally { closeSync(fd); }
|
|
56
83
|
}
|
|
57
|
-
|
|
58
84
|
export function readRunControlsFromStateDir(stateDir: string): RunControlRecord[] {
|
|
59
85
|
return readRunControlJournalFromStateDir(stateDir).records as RunControlRecord[];
|
|
60
86
|
}
|
|
61
|
-
|
|
62
87
|
function acquireRunControlsLock(stateDir: string): () => void {
|
|
63
88
|
return acquireFileMutationLock(runControlsFile(stateDir));
|
|
64
89
|
}
|
|
65
90
|
|
|
66
91
|
function compactRunControls(records: RunControlRecord[]): RunControlRecord[] {
|
|
67
|
-
const active = records.filter((record) =>
|
|
68
|
-
const terminal = records
|
|
69
|
-
.
|
|
70
|
-
.slice(-RUN_CONTROL_TERMINAL_LIMIT);
|
|
92
|
+
const active = records.filter((record) => classifyRunControlRecord(record) !== "terminal");
|
|
93
|
+
const terminal = records.filter((record) => classifyRunControlRecord(record) === "terminal")
|
|
94
|
+
.slice(-Limits.RUN_CONTROL_TERMINAL_LIMIT);
|
|
71
95
|
const retained = new Set([...active, ...terminal]);
|
|
72
|
-
return records.filter((record) => retained.has(record))
|
|
96
|
+
return records.filter((record) => retained.has(record)).map((record) =>
|
|
97
|
+
typeof record.error === "string" && Buffer.byteLength(record.error) > Limits.RUN_CONTROL_ERROR_MAX_BYTES
|
|
98
|
+
? { ...record, error: boundedControlError(record.error) } : record);
|
|
99
|
+
}
|
|
100
|
+
function encodeRunControls(records: RunControlRecord[]): string {
|
|
101
|
+
return records.length ? `${records.map((record) => JSON.stringify(record)).join("\n")}\n` : "";
|
|
102
|
+
}
|
|
103
|
+
function boundedControlError(value: string): string {
|
|
104
|
+
if (Buffer.byteLength(value) <= Limits.RUN_CONTROL_ERROR_MAX_BYTES) return value;
|
|
105
|
+
const suffix = "… [truncated]";
|
|
106
|
+
const limit = Limits.RUN_CONTROL_ERROR_MAX_BYTES - Buffer.byteLength(suffix);
|
|
107
|
+
const bytes = Buffer.from(value);
|
|
108
|
+
let end = limit;
|
|
109
|
+
while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1;
|
|
110
|
+
return `${bytes.subarray(0, end).toString("utf8")}${suffix}`;
|
|
73
111
|
}
|
|
74
|
-
|
|
75
112
|
function writeRunControls(stateDir: string, records: RunControlRecord[]): void {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
113
|
+
const encoded = encodeRunControls(compactRunControls(records));
|
|
114
|
+
if (Buffer.byteLength(encoded) > Limits.RUN_CONTROL_JOURNAL_MAX_BYTES) {
|
|
115
|
+
throw new Error("Run Control journal transition exceeds bounded byte limit");
|
|
116
|
+
}
|
|
117
|
+
writeTextAtomic(runControlsFile(stateDir), encoded);
|
|
118
|
+
}
|
|
119
|
+
function readMutableRunControls(
|
|
120
|
+
stateDir: string, runInstanceId?: string,
|
|
121
|
+
): RunControlRecord[] | undefined {
|
|
122
|
+
const journal = readRunControlJournalFromStateDir(stateDir);
|
|
123
|
+
if (journal.diagnostics.length) return undefined;
|
|
124
|
+
const records = journal.records as RunControlRecord[];
|
|
125
|
+
if (runInstanceId && records.some((record) => controlIntegrity(record, runInstanceId) !== "valid"))
|
|
126
|
+
return undefined;
|
|
127
|
+
return records;
|
|
128
|
+
}
|
|
129
|
+
function controlIntegrity(value: unknown, runInstanceId: string): RunControlAdmissionIntegrity {
|
|
130
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return "invalid_record";
|
|
131
|
+
const record = value as Record<string, unknown>;
|
|
132
|
+
if (typeof record.id !== "string" || !record.id ||
|
|
133
|
+
typeof record.run_instance_id !== "string" || !record.run_instance_id ||
|
|
134
|
+
typeof record.status !== "string" || !STATUSES.has(record.status as RunControlStatus) ||
|
|
135
|
+
(record.error !== undefined && typeof record.error !== "string")) return "invalid_record";
|
|
136
|
+
try {
|
|
137
|
+
Control.normalizeControlAction(record.action);
|
|
138
|
+
if (record.input !== undefined) Control.normalizeControlInput(record.input);
|
|
139
|
+
} catch { return "invalid_record"; }
|
|
140
|
+
const timestamp = runControlStatusTimestamp(record, record.status as RunControlStatus);
|
|
141
|
+
if (typeof timestamp !== "string" || !Number.isFinite(Date.parse(timestamp)))
|
|
142
|
+
return "invalid_status_timestamp";
|
|
143
|
+
if (classifyRunControlRecord(record) === "pending" &&
|
|
144
|
+
record.run_instance_id !== runInstanceId) return "generation_mismatch";
|
|
145
|
+
return "valid";
|
|
83
146
|
}
|
|
84
|
-
|
|
85
147
|
export function appendRunControlInStateDir(
|
|
86
|
-
stateDir: string,
|
|
87
|
-
request: { run_instance_id: string; action: string; input?: unknown },
|
|
148
|
+
stateDir: string, request: { run_instance_id: string; action: string; input?: unknown },
|
|
88
149
|
): RunControlRecord {
|
|
150
|
+
const action = Control.normalizeControlAction(request.action);
|
|
151
|
+
const input = request.input === undefined ? undefined : Control.normalizeControlInput(request.input);
|
|
89
152
|
const record: RunControlRecord = {
|
|
90
153
|
id: randomUUID(),
|
|
91
154
|
run_instance_id: request.run_instance_id,
|
|
92
|
-
action
|
|
93
|
-
...(
|
|
155
|
+
action,
|
|
156
|
+
...(input !== undefined ? { input } : {}),
|
|
94
157
|
status: "queued",
|
|
95
158
|
queued_at: new Date().toISOString(),
|
|
96
159
|
};
|
|
160
|
+
const encodedRecordBytes = Buffer.byteLength(`${JSON.stringify(record)}\n`);
|
|
97
161
|
const releaseLock = acquireRunControlsLock(stateDir);
|
|
98
162
|
try {
|
|
99
|
-
|
|
100
|
-
|
|
163
|
+
const path = runControlsFile(stateDir);
|
|
164
|
+
let bytes = 0;
|
|
165
|
+
try { bytes = statSync(path).size; }
|
|
166
|
+
catch (error) {
|
|
167
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") bytes = Number.MAX_SAFE_INTEGER;
|
|
168
|
+
}
|
|
169
|
+
const journal = bytes > Limits.RUN_CONTROL_JOURNAL_MAX_BYTES
|
|
170
|
+
? { diagnostics: [], records: [] } : readRunControlJournalFromStateDir(stateDir);
|
|
171
|
+
let integrity: RunControlAdmissionIntegrity = bytes > Limits.RUN_CONTROL_JOURNAL_MAX_BYTES
|
|
172
|
+
? "journal_bytes"
|
|
173
|
+
: journal.diagnostics.some((item) => item.line !== undefined)
|
|
174
|
+
? "malformed"
|
|
175
|
+
: journal.diagnostics.length ? "unreadable" : "valid";
|
|
176
|
+
for (const value of journal.records) {
|
|
177
|
+
if (integrity !== "valid") break;
|
|
178
|
+
integrity = controlIntegrity(value, request.run_instance_id);
|
|
179
|
+
}
|
|
180
|
+
const records = journal.records as RunControlRecord[];
|
|
181
|
+
const retained = encodeRunControls(compactRunControls(records));
|
|
182
|
+
const decision = decideRunControlAdmission({
|
|
183
|
+
integrity,
|
|
184
|
+
journalBytes: bytes,
|
|
185
|
+
newRecordBytes: encodedRecordBytes,
|
|
186
|
+
records,
|
|
187
|
+
retainedJournalBytes: Buffer.byteLength(retained),
|
|
188
|
+
runInstanceId: request.run_instance_id,
|
|
189
|
+
capacity: computeRunControlCapacity(records),
|
|
101
190
|
});
|
|
191
|
+
if (!decision.admitted) throw admissionError(decision.error);
|
|
192
|
+
writeTextAtomic(path, `${retained}${JSON.stringify(record)}\n`);
|
|
193
|
+
return record;
|
|
102
194
|
} finally {
|
|
103
195
|
releaseLock();
|
|
104
196
|
}
|
|
105
|
-
return record;
|
|
106
197
|
}
|
|
107
|
-
|
|
108
198
|
export function updateRunControlStatusInStateDir(
|
|
109
|
-
stateDir: string,
|
|
110
|
-
id: string,
|
|
111
|
-
nextStatus: RunControlStatus,
|
|
199
|
+
stateDir: string, id: string, nextStatus: RunControlStatus,
|
|
112
200
|
metadata: Pick<RunControlRecord, "error"> = {},
|
|
113
201
|
expectedStatuses?: readonly RunControlStatus[],
|
|
114
202
|
): boolean {
|
|
115
203
|
const releaseLock = acquireRunControlsLock(stateDir);
|
|
116
204
|
try {
|
|
117
|
-
const records =
|
|
205
|
+
const records = readMutableRunControls(stateDir);
|
|
206
|
+
if (!records) return false;
|
|
118
207
|
const index = records.findIndex((record) => record.id === id);
|
|
119
208
|
if (index < 0) return false;
|
|
120
209
|
const record = records[index]!;
|
|
210
|
+
if (records.some((value) => controlIntegrity(value, record.run_instance_id) !== "valid")) return false;
|
|
121
211
|
if (expectedStatuses && !expectedStatuses.includes(record.status)) return false;
|
|
122
212
|
if (record.status === nextStatus) return false;
|
|
123
213
|
const timestamp = new Date().toISOString();
|
|
124
214
|
if (nextStatus === "delivered") {
|
|
125
215
|
if (record.delivered_at) return false;
|
|
126
|
-
records[index] = {
|
|
127
|
-
...record
|
|
128
|
-
delivered_at: timestamp,
|
|
129
|
-
...(record.status === "queued" ? { status: "delivered" as const } : {}),
|
|
130
|
-
};
|
|
216
|
+
records[index] = { ...record, delivered_at: timestamp,
|
|
217
|
+
...(record.status === "queued" ? { status: "delivered" as const } : {}) };
|
|
131
218
|
} else {
|
|
132
|
-
if (
|
|
133
|
-
if (
|
|
134
|
-
(nextStatus === "claimed" &&
|
|
135
|
-
record.status !== "queued" &&
|
|
219
|
+
if (classifyRunControlRecord(record) === "terminal" || nextStatus === "queued") return false;
|
|
220
|
+
if ((nextStatus === "claimed" && record.status !== "queued" &&
|
|
136
221
|
record.status !== "delivered") ||
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const timestampKey = `${nextStatus}_at` as const;
|
|
142
|
-
records[index] = {
|
|
143
|
-
...record,
|
|
144
|
-
...metadata,
|
|
145
|
-
[timestampKey]: timestamp,
|
|
146
|
-
status: nextStatus,
|
|
147
|
-
};
|
|
222
|
+
(nextStatus === "handled" && record.status !== "claimed")) return false;
|
|
223
|
+
records[index] = { ...record,
|
|
224
|
+
...(metadata.error !== undefined ? { error: boundedControlError(metadata.error) } : {}),
|
|
225
|
+
[`${nextStatus}_at`]: timestamp, status: nextStatus };
|
|
148
226
|
}
|
|
149
227
|
writeRunControls(stateDir, records);
|
|
150
228
|
return true;
|
|
@@ -152,38 +230,38 @@ export function updateRunControlStatusInStateDir(
|
|
|
152
230
|
releaseLock();
|
|
153
231
|
}
|
|
154
232
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
stateDir: string,
|
|
158
|
-
runInstanceId: string,
|
|
233
|
+
function claimRunControl(
|
|
234
|
+
stateDir: string, runInstanceId: string, controlId?: string,
|
|
159
235
|
): RunControlRecord | undefined {
|
|
160
236
|
const releaseLock = acquireRunControlsLock(stateDir);
|
|
161
237
|
try {
|
|
162
|
-
const records =
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
238
|
+
const records = readMutableRunControls(stateDir, runInstanceId);
|
|
239
|
+
if (!records) return undefined;
|
|
240
|
+
const index = records.findIndex((record) =>
|
|
241
|
+
(controlId === undefined || record.id === controlId) &&
|
|
242
|
+
record.run_instance_id === runInstanceId &&
|
|
243
|
+
(record.status === "queued" || record.status === "delivered"));
|
|
168
244
|
if (index < 0) return undefined;
|
|
169
245
|
const claimed: RunControlRecord = {
|
|
170
|
-
...records[index]!,
|
|
171
|
-
claimed_at: new Date().toISOString(),
|
|
172
|
-
status: "claimed",
|
|
173
|
-
};
|
|
246
|
+
...records[index]!, claimed_at: new Date().toISOString(), status: "claimed" };
|
|
174
247
|
records[index] = claimed;
|
|
175
248
|
writeRunControls(stateDir, records);
|
|
176
249
|
return claimed;
|
|
177
|
-
} finally {
|
|
178
|
-
|
|
179
|
-
|
|
250
|
+
} finally { releaseLock(); }
|
|
251
|
+
}
|
|
252
|
+
export function claimRunControlInStateDir(
|
|
253
|
+
stateDir: string, runInstanceId: string,
|
|
254
|
+
): RunControlRecord | undefined {
|
|
255
|
+
return claimRunControl(stateDir, runInstanceId);
|
|
256
|
+
}
|
|
257
|
+
export function claimRunControlByIdInStateDir(
|
|
258
|
+
stateDir: string, runInstanceId: string, controlId: string,
|
|
259
|
+
): RunControlRecord | undefined {
|
|
260
|
+
return claimRunControl(stateDir, runInstanceId, controlId);
|
|
180
261
|
}
|
|
181
|
-
|
|
182
262
|
export async function processRunControlsInStateDir(
|
|
183
|
-
stateDir: string,
|
|
184
|
-
|
|
185
|
-
handler: (control: RunControlRecord) => Promise<void> | void,
|
|
186
|
-
limit = 1,
|
|
263
|
+
stateDir: string, runInstanceId: string,
|
|
264
|
+
handler: (control: RunControlRecord) => Promise<void> | void, limit = 1,
|
|
187
265
|
): Promise<ProcessRunControlsResult> {
|
|
188
266
|
const result: ProcessRunControlsResult = { claimed: 0, failed: 0, handled: 0 };
|
|
189
267
|
for (let index = 0; index < Math.max(1, limit); index += 1) {
|
|
@@ -192,21 +270,12 @@ export async function processRunControlsInStateDir(
|
|
|
192
270
|
result.claimed += 1;
|
|
193
271
|
try {
|
|
194
272
|
await handler(control);
|
|
195
|
-
if (updateRunControlStatusInStateDir(stateDir, control.id, "handled", {}, ["claimed"]))
|
|
273
|
+
if (updateRunControlStatusInStateDir(stateDir, control.id, "handled", {}, ["claimed"]))
|
|
196
274
|
result.handled += 1;
|
|
197
|
-
}
|
|
198
275
|
} catch (error) {
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
stateDir,
|
|
202
|
-
control.id,
|
|
203
|
-
"failed",
|
|
204
|
-
{ error: error instanceof Error ? error.message : String(error) },
|
|
205
|
-
["claimed"],
|
|
206
|
-
)
|
|
207
|
-
) {
|
|
276
|
+
if (updateRunControlStatusInStateDir(stateDir, control.id, "failed",
|
|
277
|
+
{ error: error instanceof Error ? error.message : String(error) }, ["claimed"]))
|
|
208
278
|
result.failed += 1;
|
|
209
|
-
}
|
|
210
279
|
}
|
|
211
280
|
}
|
|
212
281
|
return result;
|
package/lib/runs-retention.ts
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
-
import {
|
|
7
|
+
import { cpSync, mkdirSync, renameSync, rmSync } from "node:fs";
|
|
8
8
|
import { basename, dirname, join } from "node:path";
|
|
9
9
|
|
|
10
|
-
import { acquireFileMutationLock, writeJsonAtomic } from "./file-state.ts";
|
|
10
|
+
import { acquireFileMutationLock, writeJsonAtomic, writeTextAtomic } from "./file-state.ts";
|
|
11
|
+
import * as Limits from "./limits.ts";
|
|
12
|
+
import { readJsonlFileResilient } from "./state-readers.ts";
|
|
11
13
|
import {
|
|
12
14
|
resolveArtifactManifest,
|
|
13
15
|
type RunArtifactDeclaration,
|
|
@@ -27,29 +29,35 @@ export function appendRunRetentionEvidence(
|
|
|
27
29
|
const stateDir = String(status.state_dir);
|
|
28
30
|
const path = join(dirname(stateDir), "retention.jsonl");
|
|
29
31
|
const id = options.id ?? randomUUID();
|
|
32
|
+
const record = {
|
|
33
|
+
action,
|
|
34
|
+
...(options.error ? { error: options.error } : {}),
|
|
35
|
+
id,
|
|
36
|
+
outcome,
|
|
37
|
+
...(options.result ? { result: options.result } : {}),
|
|
38
|
+
run: String(status.run ?? basename(stateDir)),
|
|
39
|
+
...(typeof status.run_instance_id === "string" ? { run_instance_id: status.run_instance_id } : {}),
|
|
40
|
+
ts: new Date().toISOString(),
|
|
41
|
+
};
|
|
30
42
|
const release = acquireFileMutationLock(path);
|
|
31
43
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
? { run_instance_id: status.run_instance_id }
|
|
43
|
-
: {}),
|
|
44
|
-
ts: new Date().toISOString(),
|
|
45
|
-
})}\n`,
|
|
46
|
-
);
|
|
47
|
-
} finally {
|
|
48
|
-
release();
|
|
49
|
-
}
|
|
44
|
+
const records = [...readJsonlFileResilient<Record<string, unknown>>(path).records, record]
|
|
45
|
+
.slice(-Limits.RUN_RETENTION_MAX_RECORDS);
|
|
46
|
+
let content = encodeRecords(records);
|
|
47
|
+
while (records.length && Buffer.byteLength(content) > Limits.RUN_RETENTION_MAX_BYTES) {
|
|
48
|
+
records.shift();
|
|
49
|
+
content = encodeRecords(records);
|
|
50
|
+
}
|
|
51
|
+
if (!records.includes(record)) throw new Error("Run retention record exceeds journal byte limit");
|
|
52
|
+
writeTextAtomic(path, content);
|
|
53
|
+
} finally { release(); }
|
|
50
54
|
return id;
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
function encodeRecords(records: Record<string, unknown>[]): string {
|
|
58
|
+
return records.length ? `${records.map((value) => JSON.stringify(value)).join("\n")}\n` : "";
|
|
59
|
+
}
|
|
60
|
+
|
|
53
61
|
function retainedArtifactFilename(name: string, path: string): string {
|
|
54
62
|
const readableName = name.replace(/[^A-Za-z0-9_.-]+/g, "_") || "artifact";
|
|
55
63
|
const identity = createHash("sha256")
|