@llblab/pi-actors 0.49.1 → 0.50.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/BACKLOG.md +1 -1
- package/CHANGELOG.md +14 -1
- package/README.md +19 -3
- package/dist/lib/extension-runtime.js +29 -10
- package/dist/lib/inspector-overlay.d.ts +3 -0
- package/dist/lib/inspector-overlay.js +120 -38
- package/dist/lib/limits.d.ts +1 -0
- package/dist/lib/limits.js +1 -0
- package/dist/lib/observability.js +6 -1
- package/dist/lib/run-ui-runtime.d.ts +10 -3
- package/dist/lib/run-ui-runtime.js +109 -52
- package/dist/lib/runs-artifacts.js +24 -4
- package/dist/lib/runtime.d.ts +2 -0
- package/dist/lib/runtime.js +24 -4
- package/dist/lib/session-evidence.d.ts +1 -0
- package/dist/lib/session-evidence.js +3 -2
- package/dist/lib/state-readers.d.ts +5 -1
- package/dist/lib/state-readers.js +30 -3
- package/dist/lib/tools-access.d.ts +1 -0
- package/dist/lib/tools-access.js +13 -11
- package/dist/lib/tools-inspect.js +5 -5
- package/dist/lib/tools-message.d.ts +1 -0
- package/dist/lib/tools-message.js +3 -1
- package/docs/README.md +1 -0
- package/docs/actor-inspector.md +21 -5
- package/docs/async-runs.md +16 -4
- package/docs/command-templates.md +5 -4
- package/docs/inspection.md +83 -0
- package/docs/recipe-library.md +1 -1
- package/docs/template-recipes.md +225 -66
- package/docs/tool-registry.md +24 -3
- package/lib/extension-runtime.ts +26 -10
- package/lib/inspector-overlay.ts +161 -33
- package/lib/limits.ts +1 -0
- package/lib/observability.ts +5 -1
- package/lib/run-ui-runtime.ts +128 -50
- package/lib/runs-artifacts.ts +34 -4
- package/lib/runtime.ts +26 -7
- package/lib/session-evidence.ts +7 -2
- package/lib/state-readers.ts +45 -3
- package/lib/tools-access.ts +26 -12
- package/lib/tools-inspect.ts +9 -5
- package/lib/tools-message.ts +8 -1
- package/package.json +1 -1
package/lib/run-ui-runtime.ts
CHANGED
|
@@ -11,25 +11,89 @@ import * as Pi from "./pi.ts";
|
|
|
11
11
|
|
|
12
12
|
export interface RunUiRuntime {
|
|
13
13
|
close(): void;
|
|
14
|
-
shutdown(
|
|
15
|
-
|
|
14
|
+
shutdown(
|
|
15
|
+
eventReason: string,
|
|
16
|
+
ownerId: string | undefined,
|
|
17
|
+
ctx?: Pi.ExtensionContext,
|
|
18
|
+
): void;
|
|
19
|
+
start(ctx: Pi.ExtensionContext, ownerId: string): void;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
export interface RunUiRuntimeDeps {
|
|
23
|
+
animationIntervalMs?: number;
|
|
24
|
+
createRunStateWatcher?: typeof Observability.createRunStateWatcher;
|
|
25
|
+
createRunTerminalReconciliationLoop?:
|
|
26
|
+
typeof Observability.createRunTerminalReconciliationLoop;
|
|
19
27
|
getActiveContext(): Pi.ExtensionContext | undefined;
|
|
20
|
-
|
|
28
|
+
notificationDelayMs?: number;
|
|
29
|
+
onCallbackError?: (error: unknown) => void;
|
|
21
30
|
onRunEvent(): void;
|
|
22
31
|
pi: Pi.ExtensionAPI;
|
|
32
|
+
teardownRunsOwnedByParent?: typeof AsyncRuns.teardownRunsOwnedByParent;
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
|
|
36
|
+
let activeContext: Pi.ExtensionContext | undefined;
|
|
37
|
+
let activeOwnerId: string | undefined;
|
|
26
38
|
let animationInterval: NodeJS.Timeout | undefined;
|
|
27
39
|
let notifyTimeout: NodeJS.Timeout | undefined;
|
|
40
|
+
let running = false;
|
|
28
41
|
let lastWatcherDiagnosticId = 0;
|
|
29
42
|
const observation = Observability.createRunUiObservationState();
|
|
30
43
|
const retirementAttempts = new Set<string>();
|
|
31
44
|
const terminalNotificationsInFlight = new Set<string>();
|
|
32
45
|
|
|
46
|
+
const close = (): void => {
|
|
47
|
+
running = false;
|
|
48
|
+
activeContext = undefined;
|
|
49
|
+
activeOwnerId = undefined;
|
|
50
|
+
try {
|
|
51
|
+
watcher.close();
|
|
52
|
+
} catch {
|
|
53
|
+
/* cleanup must not escape a host callback */
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
reconciliation.close();
|
|
57
|
+
} catch {
|
|
58
|
+
/* cleanup must not escape a host callback */
|
|
59
|
+
}
|
|
60
|
+
if (notifyTimeout) clearTimeout(notifyTimeout);
|
|
61
|
+
notifyTimeout = undefined;
|
|
62
|
+
if (animationInterval) clearInterval(animationInterval);
|
|
63
|
+
animationInterval = undefined;
|
|
64
|
+
};
|
|
65
|
+
const stopAfterCallbackFailure = (
|
|
66
|
+
label: string,
|
|
67
|
+
error: unknown,
|
|
68
|
+
expectedContext: Pi.ExtensionContext,
|
|
69
|
+
): void => {
|
|
70
|
+
if (activeContext !== expectedContext) return;
|
|
71
|
+
close();
|
|
72
|
+
try {
|
|
73
|
+
deps.onCallbackError?.(error);
|
|
74
|
+
} catch {
|
|
75
|
+
/* host callback containment must remain no-throw */
|
|
76
|
+
}
|
|
77
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
78
|
+
try {
|
|
79
|
+
expectedContext.ui.notify(`Actor ${label} failed: ${message}`, "error");
|
|
80
|
+
} catch {
|
|
81
|
+
/* stale context or unavailable UI */
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const runActiveCallback = (
|
|
85
|
+
label: string,
|
|
86
|
+
callback: (ctx: Pi.ExtensionContext, ownerId: string) => void,
|
|
87
|
+
): void => {
|
|
88
|
+
if (!running || !activeContext || !activeOwnerId) return;
|
|
89
|
+
const ctx = activeContext;
|
|
90
|
+
try {
|
|
91
|
+
if (deps.getActiveContext() !== ctx) return;
|
|
92
|
+
callback(ctx, activeOwnerId);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
stopAfterCallbackFailure(label, error, ctx);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
33
97
|
const retireCandidateRuns = (
|
|
34
98
|
ctx: Pi.ExtensionContext,
|
|
35
99
|
summary: Observability.RunSummary,
|
|
@@ -39,14 +103,16 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
|
|
|
39
103
|
cancelRun: (candidate) => AsyncRuns.cancelRun(candidate.stateDir),
|
|
40
104
|
notify: (message, level) => ctx.ui.notify(message, level),
|
|
41
105
|
sendStop: async (candidate) => AsyncRuns.cancelRun(candidate.stateDir),
|
|
42
|
-
})
|
|
106
|
+
}).catch((error) =>
|
|
107
|
+
stopAfterCallbackFailure("Run retirement callback", error, ctx),
|
|
108
|
+
);
|
|
43
109
|
};
|
|
44
110
|
const update = (
|
|
45
111
|
ctx: Pi.ExtensionContext,
|
|
112
|
+
ownerId: string,
|
|
46
113
|
notify = false,
|
|
47
114
|
terminalOnly = false,
|
|
48
115
|
): void => {
|
|
49
|
-
const ownerId = deps.getRunOwnerId(ctx);
|
|
50
116
|
const snapshot = Observability.readRunUiSnapshot(observation, ownerId);
|
|
51
117
|
ctx.ui.setStatus(
|
|
52
118
|
"zz-pi-actors-runs",
|
|
@@ -76,61 +142,63 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
|
|
|
76
142
|
}
|
|
77
143
|
};
|
|
78
144
|
const scheduleUpdate = (): void => {
|
|
145
|
+
if (!running) return;
|
|
79
146
|
if (notifyTimeout) clearTimeout(notifyTimeout);
|
|
80
147
|
notifyTimeout = setTimeout(() => {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}, 50);
|
|
148
|
+
runActiveCallback("Run watcher callback", (ctx, ownerId) => {
|
|
149
|
+
watcher.refresh();
|
|
150
|
+
update(ctx, ownerId, true);
|
|
151
|
+
deps.onRunEvent();
|
|
152
|
+
reportDiagnostics(ctx);
|
|
153
|
+
});
|
|
154
|
+
}, deps.notificationDelayMs ?? 50);
|
|
88
155
|
notifyTimeout.unref?.();
|
|
89
156
|
};
|
|
90
|
-
const watcher = Observability.createRunStateWatcher({
|
|
157
|
+
const watcher = (deps.createRunStateWatcher ?? Observability.createRunStateWatcher)({
|
|
91
158
|
stateRoot: Paths.EXTENSION_RUNTIME_PATHS.runStateRoot,
|
|
92
159
|
onChange: scheduleUpdate,
|
|
93
160
|
});
|
|
94
|
-
const reconciliation =
|
|
161
|
+
const reconciliation = (
|
|
162
|
+
deps.createRunTerminalReconciliationLoop ??
|
|
163
|
+
Observability.createRunTerminalReconciliationLoop
|
|
164
|
+
)({
|
|
95
165
|
onError: (error) => {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
166
|
+
if (!running || !activeContext) return;
|
|
167
|
+
stopAfterCallbackFailure(
|
|
168
|
+
"terminal reconciliation callback",
|
|
169
|
+
error,
|
|
170
|
+
activeContext,
|
|
171
|
+
);
|
|
100
172
|
},
|
|
101
173
|
reconcile: () => {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
174
|
+
runActiveCallback("terminal reconciliation callback", (ctx, ownerId) => {
|
|
175
|
+
Observability.reconcileRunTerminalNotifications({
|
|
176
|
+
inFlight: terminalNotificationsInFlight,
|
|
177
|
+
ownerId,
|
|
178
|
+
sink: Pi.createNotificationSink(deps.pi, ctx),
|
|
179
|
+
state: observation,
|
|
180
|
+
includeAttention: true,
|
|
181
|
+
});
|
|
182
|
+
reportDiagnostics(ctx);
|
|
110
183
|
});
|
|
111
|
-
reportDiagnostics(ctx);
|
|
112
184
|
},
|
|
113
|
-
refreshWatcher: () =>
|
|
185
|
+
refreshWatcher: () => {
|
|
186
|
+
if (running) watcher.refresh();
|
|
187
|
+
},
|
|
114
188
|
});
|
|
115
|
-
const close = (): void => {
|
|
116
|
-
watcher.close();
|
|
117
|
-
reconciliation.close();
|
|
118
|
-
if (notifyTimeout) clearTimeout(notifyTimeout);
|
|
119
|
-
notifyTimeout = undefined;
|
|
120
|
-
if (animationInterval) clearInterval(animationInterval);
|
|
121
|
-
animationInterval = undefined;
|
|
122
|
-
};
|
|
123
189
|
|
|
124
190
|
return {
|
|
125
191
|
close,
|
|
126
|
-
shutdown(eventReason, ctx) {
|
|
127
|
-
|
|
128
|
-
const teardown =
|
|
129
|
-
deps.
|
|
192
|
+
shutdown(eventReason, ownerId, ctx) {
|
|
193
|
+
if (!ownerId) return;
|
|
194
|
+
const teardown = (
|
|
195
|
+
deps.teardownRunsOwnedByParent ?? AsyncRuns.teardownRunsOwnedByParent
|
|
196
|
+
)(
|
|
197
|
+
ownerId,
|
|
130
198
|
Paths.EXTENSION_RUNTIME_PATHS.runStateRoot,
|
|
131
199
|
{ trigger: `session_shutdown:${eventReason}` },
|
|
132
200
|
);
|
|
133
|
-
if (teardown.failed === 0) return;
|
|
201
|
+
if (teardown.failed === 0 || !ctx) return;
|
|
134
202
|
try {
|
|
135
203
|
ctx.ui.notify(
|
|
136
204
|
`Actor shutdown teardown: killed=${teardown.killed} failed=${teardown.failed} skipped=${teardown.skipped} discovery_failed=${teardown.discoveryFailed}. Summary: ${teardown.summaryPath ?? "unavailable"}.`,
|
|
@@ -140,16 +208,26 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
|
|
|
140
208
|
/* stale shutdown context */
|
|
141
209
|
}
|
|
142
210
|
},
|
|
143
|
-
start(ctx) {
|
|
211
|
+
start(ctx, ownerId) {
|
|
144
212
|
close();
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
213
|
+
activeContext = ctx;
|
|
214
|
+
activeOwnerId = ownerId;
|
|
215
|
+
running = true;
|
|
216
|
+
try {
|
|
217
|
+
Observability.primeRunAttentionState(observation, ownerId);
|
|
218
|
+
update(ctx, ownerId, true, true);
|
|
219
|
+
watcher.refresh();
|
|
220
|
+
reconciliation.start();
|
|
221
|
+
animationInterval = setInterval(() => {
|
|
222
|
+
runActiveCallback("status animation callback", (current, currentOwnerId) =>
|
|
223
|
+
update(current, currentOwnerId),
|
|
224
|
+
);
|
|
225
|
+
}, deps.animationIntervalMs ?? 1000);
|
|
226
|
+
animationInterval.unref?.();
|
|
227
|
+
} catch (error) {
|
|
228
|
+
close();
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
153
231
|
},
|
|
154
232
|
};
|
|
155
233
|
}
|
package/lib/runs-artifacts.ts
CHANGED
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
closeSync,
|
|
9
|
+
fstatSync,
|
|
10
|
+
openSync,
|
|
11
|
+
readSync,
|
|
12
|
+
} from "node:fs";
|
|
8
13
|
|
|
9
14
|
import { substituteCommandTemplateToken } from "./command-templates.ts";
|
|
10
15
|
|
|
@@ -22,6 +27,31 @@ export interface RunArtifactManifestEntry {
|
|
|
22
27
|
size?: number;
|
|
23
28
|
}
|
|
24
29
|
|
|
30
|
+
function hashArtifactFile(path: string): { sha256: string; size: number } {
|
|
31
|
+
const fd = openSync(path, "r");
|
|
32
|
+
try {
|
|
33
|
+
const size = fstatSync(fd).size;
|
|
34
|
+
const hash = createHash("sha256");
|
|
35
|
+
const chunk = Buffer.allocUnsafe(64 * 1024);
|
|
36
|
+
let position = 0;
|
|
37
|
+
while (position < size) {
|
|
38
|
+
const bytesRead = readSync(
|
|
39
|
+
fd,
|
|
40
|
+
chunk,
|
|
41
|
+
0,
|
|
42
|
+
Math.min(chunk.byteLength, size - position),
|
|
43
|
+
position,
|
|
44
|
+
);
|
|
45
|
+
if (bytesRead === 0) break;
|
|
46
|
+
hash.update(chunk.subarray(0, bytesRead));
|
|
47
|
+
position += bytesRead;
|
|
48
|
+
}
|
|
49
|
+
return { sha256: hash.digest("hex"), size: position };
|
|
50
|
+
} finally {
|
|
51
|
+
closeSync(fd);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
25
55
|
export function resolveArtifactPaths(
|
|
26
56
|
artifacts: Record<string, RunArtifactDeclaration> | undefined,
|
|
27
57
|
values: Record<string, unknown>,
|
|
@@ -64,7 +94,7 @@ export function resolveArtifactManifest(
|
|
|
64
94
|
typeof artifact === "string" ? { path: artifact } : artifact;
|
|
65
95
|
if (!declaration?.path) continue;
|
|
66
96
|
try {
|
|
67
|
-
const
|
|
97
|
+
const hashed = hashArtifactFile(declaration.path);
|
|
68
98
|
manifest[name] = {
|
|
69
99
|
exists: true,
|
|
70
100
|
...(declaration.kind ? { kind: declaration.kind } : {}),
|
|
@@ -75,8 +105,8 @@ export function resolveArtifactManifest(
|
|
|
75
105
|
...(declaration.required !== undefined
|
|
76
106
|
? { required: declaration.required }
|
|
77
107
|
: {}),
|
|
78
|
-
sha256:
|
|
79
|
-
size:
|
|
108
|
+
sha256: hashed.sha256,
|
|
109
|
+
size: hashed.size,
|
|
80
110
|
};
|
|
81
111
|
} catch {
|
|
82
112
|
manifest[name] = {
|
package/lib/runtime.ts
CHANGED
|
@@ -308,7 +308,9 @@ export function createAutoToolsRuntime(
|
|
|
308
308
|
export interface RecipeToolReloadWatcherDeps {
|
|
309
309
|
exists?: (path: string) => boolean;
|
|
310
310
|
getResolutionContext?: () => RecipeResolutionContext | undefined;
|
|
311
|
+
onCallbackError?: (error: unknown) => void;
|
|
311
312
|
recipeRoot?: string;
|
|
313
|
+
reloadDelayMs?: number;
|
|
312
314
|
watchPath?: typeof watch;
|
|
313
315
|
}
|
|
314
316
|
|
|
@@ -336,22 +338,39 @@ export function createRecipeToolReloadWatcher(
|
|
|
336
338
|
reloadTimeout = undefined;
|
|
337
339
|
setWatchStatus("closed");
|
|
338
340
|
};
|
|
341
|
+
const reportCallbackError = (error: unknown): void => {
|
|
342
|
+
try {
|
|
343
|
+
deps.onCallbackError?.(error);
|
|
344
|
+
} catch {
|
|
345
|
+
/* host callback containment must remain no-throw */
|
|
346
|
+
}
|
|
347
|
+
};
|
|
339
348
|
const notifyFailure = (ctx: RuntimeContext): void => {
|
|
340
349
|
if (failureNotified) return;
|
|
341
350
|
failureNotified = true;
|
|
342
351
|
setWatchStatus("failed");
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
352
|
+
try {
|
|
353
|
+
ctx.ui.notify(
|
|
354
|
+
"Recipe live reload watcher failed; restart the session or use register_tool again to refresh recipe tools.",
|
|
355
|
+
"warning",
|
|
356
|
+
);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
reportCallbackError(error);
|
|
359
|
+
}
|
|
347
360
|
};
|
|
348
361
|
const scheduleReload = (ctx: RuntimeContext): void => {
|
|
349
362
|
failureNotified = false;
|
|
350
363
|
if (reloadTimeout) clearTimeout(reloadTimeout);
|
|
351
364
|
reloadTimeout = setTimeout(() => {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
365
|
+
reloadTimeout = undefined;
|
|
366
|
+
try {
|
|
367
|
+
runtime.loadTools(ctx, deps.getResolutionContext?.());
|
|
368
|
+
ctx.ui.notify("Recipe tools refreshed from ~/.pi/agent/recipes", "info");
|
|
369
|
+
} catch (error) {
|
|
370
|
+
notifyFailure(ctx);
|
|
371
|
+
reportCallbackError(error);
|
|
372
|
+
}
|
|
373
|
+
}, deps.reloadDelayMs ?? 150);
|
|
355
374
|
reloadTimeout.unref?.();
|
|
356
375
|
};
|
|
357
376
|
const watchParent = (ctx: RuntimeContext, recipeRoot: string): void => {
|
package/lib/session-evidence.ts
CHANGED
|
@@ -55,6 +55,7 @@ export interface SessionEvidence {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export interface SessionEvidenceReadOptions {
|
|
58
|
+
maxBytes?: number;
|
|
58
59
|
maxTextChars?: number;
|
|
59
60
|
maxToolCalls?: number;
|
|
60
61
|
maxTurns?: number;
|
|
@@ -195,6 +196,10 @@ export function readSessionEvidence(
|
|
|
195
196
|
path: string,
|
|
196
197
|
options: SessionEvidenceReadOptions = {},
|
|
197
198
|
): SessionEvidence {
|
|
199
|
+
const maxBytes = Math.max(
|
|
200
|
+
1,
|
|
201
|
+
options.maxBytes ?? Limits.SESSION_EVIDENCE_MAX_BYTES,
|
|
202
|
+
);
|
|
198
203
|
const maxTextChars = Math.max(
|
|
199
204
|
1,
|
|
200
205
|
options.maxTextChars ?? Limits.SESSION_EVIDENCE_TEXT_CHARS,
|
|
@@ -207,7 +212,7 @@ export function readSessionEvidence(
|
|
|
207
212
|
1,
|
|
208
213
|
options.maxTurns ?? Limits.SESSION_EVIDENCE_MAX_TURNS,
|
|
209
214
|
);
|
|
210
|
-
const read = readJsonlFileResilient<SessionEntry>(path);
|
|
215
|
+
const read = readJsonlFileResilient<SessionEntry>(path, { maxBytes });
|
|
211
216
|
const diagnostics = [...read.diagnostics];
|
|
212
217
|
const header = read.records.find((entry) => entry.type === "session");
|
|
213
218
|
const branch = activeBranch(read.records, path, diagnostics);
|
|
@@ -296,7 +301,7 @@ export function readSessionEvidence(
|
|
|
296
301
|
path,
|
|
297
302
|
...(header ? { session: asRecord(header) } : {}),
|
|
298
303
|
totalTurns: turns.length,
|
|
299
|
-
truncated: turns.length > visibleTurns.length,
|
|
304
|
+
truncated: read.truncated === true || turns.length > visibleTurns.length,
|
|
300
305
|
turns: visibleTurns,
|
|
301
306
|
};
|
|
302
307
|
}
|
package/lib/state-readers.ts
CHANGED
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
* Owns best-effort JSON and JSONL parsing helpers for operator-facing state reads.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
closeSync,
|
|
9
|
+
fstatSync,
|
|
10
|
+
openSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
readSync,
|
|
13
|
+
} from "node:fs";
|
|
8
14
|
|
|
9
15
|
export interface StateReadDiagnostic {
|
|
10
16
|
line?: number;
|
|
@@ -20,6 +26,11 @@ export interface JsonReadResult<T> {
|
|
|
20
26
|
export interface JsonlReadResult<T> {
|
|
21
27
|
diagnostics: StateReadDiagnostic[];
|
|
22
28
|
records: T[];
|
|
29
|
+
truncated?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface JsonlReadOptions {
|
|
33
|
+
maxBytes?: number;
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
function isEnoent(error: unknown): boolean {
|
|
@@ -30,6 +41,31 @@ function diagnosticMessage(error: unknown): string {
|
|
|
30
41
|
return error instanceof Error ? error.message : String(error);
|
|
31
42
|
}
|
|
32
43
|
|
|
44
|
+
function readTextFileCapped(path: string, maxBytes: number | undefined): string {
|
|
45
|
+
if (maxBytes === undefined) return readFileSync(path, "utf8");
|
|
46
|
+
const limit = Math.max(0, Math.floor(maxBytes));
|
|
47
|
+
const fd = openSync(path, "r");
|
|
48
|
+
try {
|
|
49
|
+
const size = fstatSync(fd).size;
|
|
50
|
+
if (size > limit) {
|
|
51
|
+
throw Object.assign(
|
|
52
|
+
new Error(`file exceeds bounded read limit (${size} > ${limit} bytes)`),
|
|
53
|
+
{ code: "EFBIG" },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const content = Buffer.allocUnsafe(size);
|
|
57
|
+
let offset = 0;
|
|
58
|
+
while (offset < size) {
|
|
59
|
+
const bytesRead = readSync(fd, content, offset, size - offset, offset);
|
|
60
|
+
if (bytesRead === 0) break;
|
|
61
|
+
offset += bytesRead;
|
|
62
|
+
}
|
|
63
|
+
return content.subarray(0, offset).toString("utf8");
|
|
64
|
+
} finally {
|
|
65
|
+
closeSync(fd);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
33
69
|
export function readJsonFileResilient<T>(path: string, fallback: T): JsonReadResult<T> {
|
|
34
70
|
try {
|
|
35
71
|
return {
|
|
@@ -50,11 +86,14 @@ export function readJsonFileResilient<T>(path: string, fallback: T): JsonReadRes
|
|
|
50
86
|
}
|
|
51
87
|
}
|
|
52
88
|
|
|
53
|
-
export function readJsonlFileResilient<T>(
|
|
89
|
+
export function readJsonlFileResilient<T>(
|
|
90
|
+
path: string,
|
|
91
|
+
options: JsonlReadOptions = {},
|
|
92
|
+
): JsonlReadResult<T> {
|
|
54
93
|
try {
|
|
55
94
|
const records: T[] = [];
|
|
56
95
|
const diagnostics: StateReadDiagnostic[] = [];
|
|
57
|
-
const lines =
|
|
96
|
+
const lines = readTextFileCapped(path, options.maxBytes).split("\n");
|
|
58
97
|
for (const [index, line] of lines.entries()) {
|
|
59
98
|
if (!line.trim()) continue;
|
|
60
99
|
try {
|
|
@@ -78,6 +117,9 @@ export function readJsonlFileResilient<T>(path: string): JsonlReadResult<T> {
|
|
|
78
117
|
},
|
|
79
118
|
],
|
|
80
119
|
records: [],
|
|
120
|
+
...((error as NodeJS.ErrnoException).code === "EFBIG"
|
|
121
|
+
? { truncated: true }
|
|
122
|
+
: {}),
|
|
81
123
|
};
|
|
82
124
|
}
|
|
83
125
|
}
|
package/lib/tools-access.ts
CHANGED
|
@@ -17,8 +17,11 @@ export function getContextSessionId(ctx: unknown): string | undefined {
|
|
|
17
17
|
export function requireContextSessionId(ctx: unknown, actor: string): string {
|
|
18
18
|
const sessionId = getContextSessionId(ctx);
|
|
19
19
|
if (!sessionId) {
|
|
20
|
-
throw
|
|
21
|
-
|
|
20
|
+
throw Object.assign(
|
|
21
|
+
new Error(
|
|
22
|
+
`${actor} reason=session_unavailable requires a current coordinator session; retry from an active coordinator session.`,
|
|
23
|
+
),
|
|
24
|
+
{ reason: "session_unavailable" },
|
|
22
25
|
);
|
|
23
26
|
}
|
|
24
27
|
return sessionId;
|
|
@@ -33,16 +36,13 @@ export function sessionMismatchError(input: {
|
|
|
33
36
|
const ownerSession = input.expectedSession ?? "none";
|
|
34
37
|
const currentSession = input.currentSession ?? "none";
|
|
35
38
|
const actor = input.run ? `run:${input.run}` : (input.target ?? "session");
|
|
36
|
-
const hintTarget = input.expectedSession
|
|
37
|
-
? `session:${input.expectedSession}`
|
|
38
|
-
: "session:all";
|
|
39
39
|
return Object.assign(
|
|
40
40
|
new Error(
|
|
41
|
-
`${actor} reason=session_mismatch owner_session=${ownerSession} current_session=${currentSession} hint=
|
|
41
|
+
`${actor} reason=session_mismatch owner_session=${ownerSession} current_session=${currentSession} hint=inspect_runtime_runs`,
|
|
42
42
|
),
|
|
43
43
|
{
|
|
44
44
|
current_session: input.currentSession,
|
|
45
|
-
hint:
|
|
45
|
+
hint: "inspect target=runtime view=runs",
|
|
46
46
|
owner_session: input.expectedSession,
|
|
47
47
|
reason: "session_mismatch",
|
|
48
48
|
run: input.run,
|
|
@@ -51,18 +51,32 @@ export function sessionMismatchError(input: {
|
|
|
51
51
|
);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
export function
|
|
54
|
+
export function assertRunStatusAccessibleToContext(
|
|
55
55
|
runId: string,
|
|
56
|
+
status: Record<string, unknown>,
|
|
56
57
|
ctx: unknown,
|
|
57
58
|
): Record<string, unknown> {
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
|
|
59
|
+
const sessionId = requireContextSessionId(ctx, `run:${runId}`);
|
|
60
|
+
const ownerId = typeof status.ownerId === "string" && status.ownerId
|
|
61
|
+
? status.ownerId
|
|
62
|
+
: undefined;
|
|
63
|
+
if (ownerId !== sessionId) {
|
|
61
64
|
throw sessionMismatchError({
|
|
62
65
|
currentSession: sessionId,
|
|
63
|
-
expectedSession:
|
|
66
|
+
expectedSession: ownerId,
|
|
64
67
|
run: runId,
|
|
65
68
|
});
|
|
66
69
|
}
|
|
67
70
|
return status;
|
|
68
71
|
}
|
|
72
|
+
|
|
73
|
+
export function assertRunAccessibleToContext(
|
|
74
|
+
runId: string,
|
|
75
|
+
ctx: unknown,
|
|
76
|
+
): Record<string, unknown> {
|
|
77
|
+
return assertRunStatusAccessibleToContext(
|
|
78
|
+
runId,
|
|
79
|
+
AsyncRuns.getRunStatus(runId),
|
|
80
|
+
ctx,
|
|
81
|
+
);
|
|
82
|
+
}
|
package/lib/tools-inspect.ts
CHANGED
|
@@ -42,7 +42,7 @@ export interface InspectToolDeps<TContext = unknown> {
|
|
|
42
42
|
|
|
43
43
|
function runtimeStatus(): Record<string, unknown> {
|
|
44
44
|
return {
|
|
45
|
-
automatic_review:
|
|
45
|
+
automatic_review: Paths.isAutomaticRecipeReviewEnabled(),
|
|
46
46
|
run_root: Paths.getRunStateRoot(),
|
|
47
47
|
state_schema: RuntimeIdentity.RUN_STATE_SCHEMA,
|
|
48
48
|
version: RuntimeIdentity.getPackageVersion(),
|
|
@@ -54,7 +54,7 @@ function runtimeRuns(
|
|
|
54
54
|
deps: InspectToolDeps,
|
|
55
55
|
status: string | undefined,
|
|
56
56
|
): Record<string, unknown> {
|
|
57
|
-
const session = ToolsAccess.
|
|
57
|
+
const session = ToolsAccess.requireContextSessionId(ctx, "runtime Run inventory");
|
|
58
58
|
const listed = deps.listRuns
|
|
59
59
|
? deps.listRuns()
|
|
60
60
|
: AsyncRuns.listRuns(undefined, status);
|
|
@@ -68,8 +68,8 @@ function runtimeRuns(
|
|
|
68
68
|
return run;
|
|
69
69
|
}
|
|
70
70
|
})
|
|
71
|
-
.filter((run) =>
|
|
72
|
-
return {
|
|
71
|
+
.filter((run) => run.ownerId === session);
|
|
72
|
+
return { owner_session: session, runs };
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
function runtimeTriage(
|
|
@@ -463,7 +463,11 @@ function inspectRun(
|
|
|
463
463
|
throw new Error("inspect run:<id> supports view=recipe, view=trace, or view=control.");
|
|
464
464
|
}
|
|
465
465
|
const status = deps.getRunStatus
|
|
466
|
-
?
|
|
466
|
+
? ToolsAccess.assertRunStatusAccessibleToContext(
|
|
467
|
+
run,
|
|
468
|
+
deps.getRunStatus(run),
|
|
469
|
+
ctx,
|
|
470
|
+
)
|
|
467
471
|
: ToolsAccess.assertRunAccessibleToContext(run, ctx);
|
|
468
472
|
const stateDir = String(status.state_dir);
|
|
469
473
|
if (view === "recipe") {
|
package/lib/tools-message.ts
CHANGED
|
@@ -46,6 +46,7 @@ function runNextActions(run: string, result: Record<string, unknown>): string[]
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
export interface ControlToolDeps {
|
|
49
|
+
getRunStatus?: (run: string) => Record<string, unknown>;
|
|
49
50
|
handleRuntimeControl?: (
|
|
50
51
|
action: string,
|
|
51
52
|
input: unknown,
|
|
@@ -94,7 +95,13 @@ export function createControlToolDefinition<TContext = unknown>(
|
|
|
94
95
|
result = deps.handleRuntimeControl(request.action, request.input);
|
|
95
96
|
} else {
|
|
96
97
|
const run = request.target.slice(4);
|
|
97
|
-
const status =
|
|
98
|
+
const status = deps.getRunStatus
|
|
99
|
+
? ToolsAccess.assertRunStatusAccessibleToContext(
|
|
100
|
+
run,
|
|
101
|
+
deps.getRunStatus(run),
|
|
102
|
+
ctx,
|
|
103
|
+
)
|
|
104
|
+
: ToolsAccess.assertRunAccessibleToContext(run, ctx);
|
|
98
105
|
const runInstanceId =
|
|
99
106
|
typeof status.run_instance_id === "string"
|
|
100
107
|
? status.run_instance_id
|