@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.
Files changed (79) hide show
  1. package/AGENTS.md +6 -6
  2. package/BACKLOG.md +1 -189
  3. package/CHANGELOG.md +419 -402
  4. package/README.md +8 -6
  5. package/dist/index.js +7 -3
  6. package/dist/lib/async-runs.d.ts +2 -0
  7. package/dist/lib/async-runs.js +41 -34
  8. package/dist/lib/config.js +1 -0
  9. package/dist/lib/file-state.d.ts +3 -1
  10. package/dist/lib/file-state.js +25 -7
  11. package/dist/lib/inspector-overlay.js +46 -30
  12. package/dist/lib/inspector.js +8 -2
  13. package/dist/lib/limits.d.ts +10 -0
  14. package/dist/lib/limits.js +10 -0
  15. package/dist/lib/observability.d.ts +4 -2
  16. package/dist/lib/observability.js +43 -36
  17. package/dist/lib/recipes-discovery.js +20 -3
  18. package/dist/lib/recipes-references.d.ts +10 -0
  19. package/dist/lib/recipes-references.js +168 -5
  20. package/dist/lib/run-evidence-policy.d.ts +95 -0
  21. package/dist/lib/run-evidence-policy.js +177 -0
  22. package/dist/lib/run-ui-runtime.js +2 -0
  23. package/dist/lib/runs-controls.d.ts +7 -5
  24. package/dist/lib/runs-controls.js +186 -49
  25. package/dist/lib/runs-retention.js +27 -14
  26. package/dist/lib/runs-trace.d.ts +25 -1
  27. package/dist/lib/runs-trace.js +410 -21
  28. package/dist/lib/runtime-triage.js +6 -22
  29. package/dist/lib/schema.d.ts +3 -0
  30. package/dist/lib/schema.js +34 -1
  31. package/dist/lib/tools-inspect.js +58 -14
  32. package/dist/lib/tools-local.js +27 -10
  33. package/dist/lib/tools-spawn.js +2 -2
  34. package/dist/lib/trace-projection.js +90 -44
  35. package/dist/scripts/conformance.mjs +5 -0
  36. package/dist/scripts/locker.mjs +31 -74
  37. package/dist/scripts/music-player.mjs +41 -129
  38. package/dist/scripts/release-gates.mjs +26 -4
  39. package/dist/skills/actors/SKILL.md +9 -8
  40. package/dist/skills/swarm/SKILL.md +2 -2
  41. package/docs/README.md +0 -1
  42. package/docs/actor-inspector.md +3 -2
  43. package/docs/async-runs.md +10 -8
  44. package/docs/command-templates.md +2 -2
  45. package/docs/recipe-library.md +4 -2
  46. package/docs/template-recipes.md +10 -4
  47. package/docs/tool-registry.md +1 -1
  48. package/index.ts +9 -3
  49. package/lib/async-runs.ts +51 -54
  50. package/lib/config.ts +4 -0
  51. package/lib/file-state.ts +28 -7
  52. package/lib/inspector-overlay.ts +29 -17
  53. package/lib/inspector.ts +16 -2
  54. package/lib/limits.ts +10 -0
  55. package/lib/observability.ts +55 -57
  56. package/lib/recipes-discovery.ts +23 -3
  57. package/lib/recipes-references.ts +227 -7
  58. package/lib/run-evidence-policy.ts +242 -0
  59. package/lib/run-ui-runtime.ts +2 -0
  60. package/lib/runs-controls.ts +177 -108
  61. package/lib/runs-retention.ts +28 -20
  62. package/lib/runs-trace.ts +496 -21
  63. package/lib/runtime-triage.ts +11 -25
  64. package/lib/schema.ts +53 -1
  65. package/lib/tools-inspect.ts +56 -15
  66. package/lib/tools-local.ts +49 -19
  67. package/lib/tools-spawn.ts +2 -2
  68. package/lib/trace-projection.ts +137 -75
  69. package/package.json +1 -1
  70. package/scripts/conformance.mjs +5 -0
  71. package/scripts/locker.mjs +31 -74
  72. package/scripts/music-player.mjs +41 -129
  73. package/scripts/release-gates.mjs +26 -4
  74. package/skills/actors/SKILL.md +9 -8
  75. package/skills/swarm/SKILL.md +2 -2
  76. package/dist/lib/runtime-notifier.d.ts +0 -48
  77. package/dist/lib/runtime-notifier.js +0 -138
  78. package/docs/0.43-baseline.md +0 -39
  79. package/lib/runtime-notifier.ts +0 -211
@@ -1,28 +1,25 @@
1
1
  /**
2
2
  * Run Control journal.
3
- * Zones: durable Control records, claim locks, status transitions, bounded compaction
4
- * Owns Run-local Control persistence; owner/generation authorization and transport delivery stay in lifecycle adapters.
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 { writeFileSync } from "node:fs";
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
- readJsonlFileResilient,
14
- type JsonlReadResult,
15
- } from "./state-readers.ts";
16
-
17
- export const RUN_CONTROL_TERMINAL_LIMIT = 128;
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
- failed: number;
43
- handled: number;
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 TERMINAL_STATUSES = new Set<RunControlStatus>(["handled", "failed"]);
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
- export function readRunControlJournalFromStateDir(
53
- stateDir: string,
54
- ): JsonlReadResult<unknown> {
55
- return readJsonlFileResilient<unknown>(runControlsFile(stateDir));
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) => !TERMINAL_STATUSES.has(record.status));
68
- const terminal = records
69
- .filter((record) => TERMINAL_STATUSES.has(record.status))
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 compacted = compactRunControls(records);
77
- writeTextAtomic(
78
- runControlsFile(stateDir),
79
- compacted.length
80
- ? `${compacted.map((record) => JSON.stringify(record)).join("\n")}\n`
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: request.action,
93
- ...(request.input !== undefined ? { input: request.input } : {}),
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
- writeFileSync(runControlsFile(stateDir), `${JSON.stringify(record)}\n`, {
100
- flag: "a",
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 = readRunControlsFromStateDir(stateDir);
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 (TERMINAL_STATUSES.has(record.status) || nextStatus === "queued") return false;
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
- (nextStatus === "handled" && record.status !== "claimed")
138
- ) {
139
- return false;
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
- export function claimRunControlInStateDir(
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 = readRunControlsFromStateDir(stateDir);
163
- const index = records.findIndex(
164
- (record) =>
165
- record.run_instance_id === runInstanceId &&
166
- (record.status === "queued" || record.status === "delivered"),
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
- releaseLock();
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
- runInstanceId: string,
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
- updateRunControlStatusInStateDir(
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;
@@ -4,10 +4,12 @@
4
4
  */
5
5
 
6
6
  import { createHash, randomUUID } from "node:crypto";
7
- import { appendFileSync, cpSync, mkdirSync, renameSync, rmSync } from "node:fs";
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
- appendFileSync(
33
- path,
34
- `${JSON.stringify({
35
- action,
36
- ...(options.error ? { error: options.error } : {}),
37
- id,
38
- outcome,
39
- ...(options.result ? { result: options.result } : {}),
40
- run: String(status.run ?? basename(stateDir)),
41
- ...(typeof status.run_instance_id === "string"
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")