@relayflows/sdk 2.0.26 → 2.0.28
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/dist/authored-flow-executor.d.ts +8 -1
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +16 -7
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-flow-lifecycle.d.ts +68 -1
- package/dist/authored-flow-lifecycle.d.ts.map +1 -1
- package/dist/authored-flow-lifecycle.js +108 -10
- package/dist/authored-flow-lifecycle.js.map +1 -1
- package/dist/authored-flow-operation.d.ts +9 -1
- package/dist/authored-flow-operation.d.ts.map +1 -1
- package/dist/authored-flow-operation.js +7 -1
- package/dist/authored-flow-operation.js.map +1 -1
- package/dist/authored-node-entry.js +4 -0
- package/dist/authored-node-entry.js.map +1 -1
- package/dist/authored-node-runner.d.ts.map +1 -1
- package/dist/authored-node-runner.js +2 -1
- package/dist/authored-node-runner.js.map +1 -1
- package/dist/authored-promise-graph.d.ts +19 -4
- package/dist/authored-promise-graph.d.ts.map +1 -1
- package/dist/authored-promise-graph.js +44 -19
- package/dist/authored-promise-graph.js.map +1 -1
- package/dist/authored-root.d.ts +2 -0
- package/dist/authored-root.d.ts.map +1 -1
- package/dist/authored-root.js +2 -0
- package/dist/authored-root.js.map +1 -1
- package/dist/authored-step-graph.d.ts +83 -0
- package/dist/authored-step-graph.d.ts.map +1 -0
- package/dist/authored-step-graph.js +217 -0
- package/dist/authored-step-graph.js.map +1 -0
- package/dist/authored-step-index.d.ts +30 -0
- package/dist/authored-step-index.d.ts.map +1 -1
- package/dist/authored-step-index.js +26 -1
- package/dist/authored-step-index.js.map +1 -1
- package/dist/authored-step-output.d.ts +3 -0
- package/dist/authored-step-output.d.ts.map +1 -1
- package/dist/authored-step-output.js +4 -2
- package/dist/authored-step-output.js.map +1 -1
- package/dist/authored-worker-step.d.ts +3 -1
- package/dist/authored-worker-step.d.ts.map +1 -1
- package/dist/authored-worker-step.js +25 -6
- package/dist/authored-worker-step.js.map +1 -1
- package/dist/cli/direct-run.d.ts.map +1 -1
- package/dist/cli/direct-run.js +5 -2
- package/dist/cli/direct-run.js.map +1 -1
- package/dist/cli/run.d.ts +2 -0
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +9 -5
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/step-excerpt.d.ts +48 -0
- package/dist/cli/step-excerpt.d.ts.map +1 -0
- package/dist/cli/step-excerpt.js +302 -0
- package/dist/cli/step-excerpt.js.map +1 -0
- package/dist/cli/step-failure.d.ts.map +1 -1
- package/dist/cli/step-failure.js +9 -20
- package/dist/cli/step-failure.js.map +1 -1
- package/dist/cli-commands.d.ts +8 -0
- package/dist/cli-commands.d.ts.map +1 -1
- package/dist/cli-commands.js +6 -0
- package/dist/cli-commands.js.map +1 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +38 -7
- package/dist/cli.js.map +1 -1
- package/dist/cloud-transcript-codex.d.ts +42 -0
- package/dist/cloud-transcript-codex.d.ts.map +1 -0
- package/dist/cloud-transcript-codex.js +411 -0
- package/dist/cloud-transcript-codex.js.map +1 -0
- package/dist/cloud-transcript-types.d.ts +161 -0
- package/dist/cloud-transcript-types.d.ts.map +1 -0
- package/dist/cloud-transcript-types.js +47 -0
- package/dist/cloud-transcript-types.js.map +1 -0
- package/dist/cloud-transcript.d.ts +2 -78
- package/dist/cloud-transcript.d.ts.map +1 -1
- package/dist/cloud-transcript.js +36 -38
- package/dist/cloud-transcript.js.map +1 -1
- package/dist/failure-kinds.d.ts +10 -2
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js.map +1 -1
- package/dist/journal-client.d.ts +8 -2
- package/dist/journal-client.d.ts.map +1 -1
- package/dist/journal-client.js +22 -6
- package/dist/journal-client.js.map +1 -1
- package/dist/llm-worker.d.ts +2 -1
- package/dist/llm-worker.d.ts.map +1 -1
- package/dist/llm-worker.js +5 -2
- package/dist/llm-worker.js.map +1 -1
- package/dist/local-agent.d.ts +1 -1
- package/dist/local-agent.d.ts.map +1 -1
- package/dist/local-agent.js +8 -3
- package/dist/local-agent.js.map +1 -1
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +29 -3
- package/dist/preflight.js.map +1 -1
- package/dist/promise-ancestry.d.ts +20 -0
- package/dist/promise-ancestry.d.ts.map +1 -0
- package/dist/promise-ancestry.js +104 -0
- package/dist/promise-ancestry.js.map +1 -0
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +7 -0
- package/dist/validate.js.map +1 -1
- package/dist/worker-cli.d.ts +7 -0
- package/dist/worker-cli.d.ts.map +1 -1
- package/dist/worker-cli.js +42 -10
- package/dist/worker-cli.js.map +1 -1
- package/dist/worker-slots.d.ts +42 -0
- package/dist/worker-slots.d.ts.map +1 -0
- package/dist/worker-slots.js +94 -0
- package/dist/worker-slots.js.map +1 -0
- package/package.json +2 -2
- package/src/authored-flow-executor.ts +22 -3
- package/src/authored-flow-lifecycle.ts +111 -16
- package/src/authored-flow-operation.ts +10 -0
- package/src/authored-node-entry.ts +4 -1
- package/src/authored-node-runner.ts +2 -1
- package/src/authored-promise-graph.ts +46 -19
- package/src/authored-root.ts +4 -0
- package/src/authored-step-graph.ts +246 -0
- package/src/authored-step-index.ts +44 -1
- package/src/authored-step-output.ts +7 -3
- package/src/authored-worker-step.ts +25 -6
- package/src/cli/direct-run.ts +5 -2
- package/src/cli/run.ts +11 -4
- package/src/cli/step-excerpt.ts +332 -0
- package/src/cli/step-failure.ts +9 -20
- package/src/cli-commands.ts +6 -0
- package/src/cli.ts +40 -9
- package/src/cloud-transcript-codex.ts +429 -0
- package/src/cloud-transcript-types.ts +214 -0
- package/src/cloud-transcript.ts +44 -136
- package/src/failure-kinds.ts +10 -2
- package/src/journal-client.ts +25 -6
- package/src/llm-worker.ts +7 -2
- package/src/local-agent.ts +8 -2
- package/src/preflight.ts +37 -2
- package/src/promise-ancestry.ts +98 -0
- package/src/validate.ts +7 -0
- package/src/worker-cli.ts +41 -9
- package/src/worker-slots.ts +92 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
1
2
|
import type { AuthoredBudget } from './authored-budget.js';
|
|
2
3
|
import { parseBudget } from './budget.js';
|
|
3
4
|
import type { AgentOptions, AgentResult, LlmOptions, NamedGate } from '@relayflows/surface';
|
|
@@ -14,6 +15,7 @@ import { snapshotJsonValue } from './json-value.js';
|
|
|
14
15
|
import { authoredChildAdmissionKey } from './authored-admission.js';
|
|
15
16
|
import { alsoRecord, recordAuthoredChild } from './authored-step-index.js';
|
|
16
17
|
import type { StepFailedDetails } from './failure-kinds.js';
|
|
18
|
+
import { WorkerSlots } from './worker-slots.js';
|
|
17
19
|
|
|
18
20
|
const WORKSPACE_PERMISSION_ANNOTATION = /:\s*(readonly|readwrite)\s*$/i;
|
|
19
21
|
|
|
@@ -22,11 +24,16 @@ export function authoredWorkerRunner(
|
|
|
22
24
|
definition: { name: string }, journal: JournalClient, flowPath: string,
|
|
23
25
|
journalSteps: AuthoredFlowJournalStep[], waitOptions: RunLifecycleOptions,
|
|
24
26
|
localAgentStream?: string, budget?: AuthoredBudget, headerBudget?: unknown,
|
|
25
|
-
rootRunId?: string,
|
|
27
|
+
rootRunId?: string, workerCapacity?: number, stepEdges?: AuthoredStepContext['stepEdges'],
|
|
26
28
|
) {
|
|
29
|
+
// Sized to the attached local workers, so concurrent calls wait here for a
|
|
30
|
+
// slot instead of being admitted and parked for want of a free worker.
|
|
31
|
+
const slots = workerCapacity === undefined ? undefined
|
|
32
|
+
: { agent: new WorkerSlots(workerCapacity), llm: new WorkerSlots(workerCapacity) };
|
|
27
33
|
const context: AuthoredStepContext = {
|
|
28
34
|
...(rootRunId === undefined ? {} : { rootRunId }),
|
|
29
35
|
...(waitOptions.dataDir === undefined ? {} : { dataDir: waitOptions.dataDir }),
|
|
36
|
+
...(stepEdges === undefined ? {} : { stepEdges }),
|
|
30
37
|
};
|
|
31
38
|
async function run(step: StepSpec): Promise<unknown> {
|
|
32
39
|
const id = step.id;
|
|
@@ -55,7 +62,9 @@ export function authoredWorkerRunner(
|
|
|
55
62
|
// Written BEFORE the wait, not after it. An agent runs for as long as its
|
|
56
63
|
// lease allows; if this process dies mid-step, this record is the only
|
|
57
64
|
// thing that still names the child run holding the evidence.
|
|
58
|
-
await recordAuthoredChild(journal, rootRunId, {
|
|
65
|
+
await recordAuthoredChild(journal, rootRunId, {
|
|
66
|
+
step: id, runId: outcome.run_id, state: 'admitted', ...stepEdges?.(id),
|
|
67
|
+
});
|
|
59
68
|
// Reuse the declarative CLI's own wait/classification (cli/run.ts) rather
|
|
60
69
|
// than a hand-rolled poll: `step.completed` and the run's own terminal
|
|
61
70
|
// state are appended as two SEPARATE actions (kernel/relayflowd-core/src/machine.rs
|
|
@@ -95,12 +104,13 @@ export function authoredWorkerRunner(
|
|
|
95
104
|
// a manufactured completion.
|
|
96
105
|
if (isSurfaceCompletionReason(details?.completionReason)) {
|
|
97
106
|
journalSteps.push(Object.freeze({
|
|
98
|
-
id, runId: outcome.run_id, completionReason: details.completionReason,
|
|
107
|
+
id, runId: outcome.run_id, completionReason: details.completionReason, ...stepEdges?.(id),
|
|
99
108
|
}));
|
|
100
109
|
message += await alsoRecord(journal, rootRunId, {
|
|
101
110
|
step: id, runId: outcome.run_id, state: 'completed',
|
|
102
111
|
completionReason: details.completionReason,
|
|
103
112
|
...(details.stepId === undefined ? {} : { kernelStep: details.stepId }),
|
|
113
|
+
...stepEdges?.(id),
|
|
104
114
|
});
|
|
105
115
|
}
|
|
106
116
|
throw new AuthoredFlowExecutionError(
|
|
@@ -113,12 +123,18 @@ export function authoredWorkerRunner(
|
|
|
113
123
|
return readCompletedStepOutput(journal, outcome.run_id, id, journalSteps, context);
|
|
114
124
|
};
|
|
115
125
|
const admissionKey = authoredChildAdmissionKey(rootRunId, id);
|
|
116
|
-
|
|
126
|
+
const admit = async () => budget === undefined
|
|
117
127
|
? consume(await journal.runStart(spec, undefined, admissionKey))
|
|
118
128
|
: budget.execute(journal, spec, consume, admissionKey);
|
|
129
|
+
return slots === undefined ? admit() : slots[step.type === 'llm' ? 'llm' : 'agent'].run(admit);
|
|
119
130
|
}
|
|
120
131
|
|
|
121
132
|
return {
|
|
133
|
+
/** Refuse every agent/LLM call still waiting for a worker slot (body teardown). */
|
|
134
|
+
stop(reason: unknown): void {
|
|
135
|
+
slots?.agent.close(reason);
|
|
136
|
+
slots?.llm.close(reason);
|
|
137
|
+
},
|
|
122
138
|
async agent(id: string, options: AgentOptions, verification?: NamedGate): Promise<AgentResult> {
|
|
123
139
|
if (options.workspace !== undefined && localAgentStream !== undefined) {
|
|
124
140
|
throw new AuthoredFlowExecutionError('unsupported_workspace_permission',
|
|
@@ -167,7 +183,8 @@ export function authoredWorkerRunner(
|
|
|
167
183
|
...(options.workspace === undefined ? {} : { surfaces: { workspace: [{ surface: options.workspace }] } }),
|
|
168
184
|
...(options.cli === undefined ? {} : { cli: options.cli }),
|
|
169
185
|
...(options.model === undefined ? {} : { model: options.model }),
|
|
170
|
-
|
|
186
|
+
// The kernel takes only an absolute cwd; a relative one means the runner's own directory.
|
|
187
|
+
...(options.cwd === undefined ? {} : { cwd: resolve(options.cwd) }),
|
|
171
188
|
...(options.transport === undefined ? {} : { transport: options.transport }),
|
|
172
189
|
...(verification === undefined ? {} : { verification }),
|
|
173
190
|
});
|
|
@@ -229,7 +246,9 @@ export function authoredDeterministicRunner(
|
|
|
229
246
|
}));
|
|
230
247
|
const admissionKey = authoredChildAdmissionKey(rootRunId, id);
|
|
231
248
|
const consume = async (outcome: import('./protocol.js').RunOutcome): Promise<string> => {
|
|
232
|
-
await recordAuthoredChild(journal, rootRunId, {
|
|
249
|
+
await recordAuthoredChild(journal, rootRunId, {
|
|
250
|
+
step: id, runId: outcome.run_id, state: 'admitted', ...context.stepEdges?.(id),
|
|
251
|
+
});
|
|
233
252
|
return readSuccessfulOutput(journal, outcome, id, journalSteps, context);
|
|
234
253
|
};
|
|
235
254
|
if (terminal) return consume(await journal.runStart(spec, undefined, admissionKey));
|
package/src/cli/direct-run.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { authoredLocalAgentStream } from '../authored-admission.js';
|
|
|
4
4
|
import { McpPreflightError } from './check-typescript.js';
|
|
5
5
|
import { attachLocalAgent } from '../local-agent.js';
|
|
6
6
|
import { LlmWorker } from '../llm-worker.js';
|
|
7
|
+
import { DEFAULT_LOCAL_AGENT_CAPACITY } from '../worker-slots.js';
|
|
7
8
|
import {
|
|
8
9
|
AuthoredFlowExecutionError,
|
|
9
10
|
} from '../authored-flow-executor.js';
|
|
@@ -67,19 +68,20 @@ export async function runDirectFlow(
|
|
|
67
68
|
let localLlm: LlmWorker | undefined;
|
|
68
69
|
let llmClient: JournalClient | undefined;
|
|
69
70
|
let llmFailure: unknown;
|
|
71
|
+
const workerCapacity = options.agentCapacity ?? DEFAULT_LOCAL_AGENT_CAPACITY;
|
|
70
72
|
const admissionIdentity = options.authoredAdmissionKey
|
|
71
73
|
?? process.env['RELAYFLOW_AUTHORED_ADMISSION_KEY'] ?? randomUUID();
|
|
72
74
|
try {
|
|
73
75
|
if (options.localAgent) {
|
|
74
76
|
localAgent = await attachLocalAgent(
|
|
75
|
-
client, dataDir, options.onPtyReady, authoredLocalAgentStream(admissionIdentity),
|
|
77
|
+
client, dataDir, options.onPtyReady, authoredLocalAgentStream(admissionIdentity), workerCapacity,
|
|
76
78
|
);
|
|
77
79
|
// A session owns one worker registration. Keep the workspace-free LLM
|
|
78
80
|
// worker on its own connection so it cannot replace the agent worker.
|
|
79
81
|
llmClient = new JournalClient(socketPath);
|
|
80
82
|
await llmClient.connect();
|
|
81
83
|
await llmClient.hello('flows-local-llm');
|
|
82
|
-
localLlm = new LlmWorker(llmClient, `${localAgent.stream}-llm
|
|
84
|
+
localLlm = new LlmWorker(llmClient, `${localAgent.stream}-llm`, workerCapacity);
|
|
83
85
|
localLlm.on('error', error => { llmFailure = error; client.close(); });
|
|
84
86
|
await localLlm.attach();
|
|
85
87
|
}
|
|
@@ -89,6 +91,7 @@ export async function runDirectFlow(
|
|
|
89
91
|
dataDir,
|
|
90
92
|
admissionKey: admissionIdentity,
|
|
91
93
|
localAgentStream: localAgent?.stream,
|
|
94
|
+
...(localAgent === undefined ? {} : { workerCapacity }),
|
|
92
95
|
lifecycle: {
|
|
93
96
|
onProgress: options.onProgress,
|
|
94
97
|
...(options.signal !== undefined ? { signal: options.signal } : {}),
|
package/src/cli/run.ts
CHANGED
|
@@ -26,6 +26,7 @@ import type {
|
|
|
26
26
|
RunStatus,
|
|
27
27
|
} from '../protocol.js';
|
|
28
28
|
import type { StepType } from '../spec.js';
|
|
29
|
+
import { DEFAULT_LOCAL_AGENT_CAPACITY } from '../worker-slots.js';
|
|
29
30
|
import type { LoweredCompletionReason } from '../authored-flow-executor.js';
|
|
30
31
|
import {
|
|
31
32
|
checkFlow,
|
|
@@ -91,6 +92,8 @@ export interface RunLifecycleOptions {
|
|
|
91
92
|
reuseFromRunId?: string;
|
|
92
93
|
onProgress?: (event: ProgressEvent) => void;
|
|
93
94
|
localAgent?: boolean;
|
|
95
|
+
/** `--agent-capacity`: the local workers' concurrency; the default is `DEFAULT_LOCAL_AGENT_CAPACITY`. */
|
|
96
|
+
agentCapacity?: number;
|
|
94
97
|
signal?: AbortSignal;
|
|
95
98
|
onWait?: (progress: RunProgress) => void;
|
|
96
99
|
/**
|
|
@@ -148,7 +151,9 @@ async function executeCheckedFlow(
|
|
|
148
151
|
const spec = toKernelSpec(checked.flow!);
|
|
149
152
|
// Use the checked CLI/model and declared surfaces unchanged. The worker
|
|
150
153
|
// advertises its existing pins; the daemon still owns surface matching.
|
|
151
|
-
if (options.localAgent)
|
|
154
|
+
if (options.localAgent) {
|
|
155
|
+
localAgent = await attachLocalAgent(client, dataDir, options.onPtyReady, undefined, options.agentCapacity);
|
|
156
|
+
}
|
|
152
157
|
if (options.localAgent && spec.steps.some(step => step.type === 'agent' && communicationInstruction(step.instruction))) {
|
|
153
158
|
const { attachCommunicationWorkers } = await import('../communication/local.js');
|
|
154
159
|
communicationWorkers = await attachCommunicationWorkers(spec, socketPath, dataDir);
|
|
@@ -192,6 +197,7 @@ export async function resumeFlow(
|
|
|
192
197
|
let authoredAgent: Awaited<ReturnType<typeof attachLocalAgent>> | undefined;
|
|
193
198
|
let authoredLlm: LlmWorker | undefined;
|
|
194
199
|
let authoredLlmClient: JournalClient | undefined;
|
|
200
|
+
const workerCapacity = options.agentCapacity ?? DEFAULT_LOCAL_AGENT_CAPACITY;
|
|
195
201
|
try {
|
|
196
202
|
const authoredRoot = await readAuthoredRootMetadata(client, runId);
|
|
197
203
|
if (authoredRoot !== undefined) {
|
|
@@ -203,17 +209,18 @@ export async function resumeFlow(
|
|
|
203
209
|
}
|
|
204
210
|
if (options.localAgent) {
|
|
205
211
|
authoredAgent = await attachLocalAgent(
|
|
206
|
-
client, dataDir, options.onPtyReady, authoredRoot.localAgentStream,
|
|
212
|
+
client, dataDir, options.onPtyReady, authoredRoot.localAgentStream, workerCapacity,
|
|
207
213
|
);
|
|
208
214
|
authoredLlmClient = new JournalClient(socketPath);
|
|
209
215
|
await authoredLlmClient.connect();
|
|
210
216
|
await authoredLlmClient.hello('flows-authored-resume-llm');
|
|
211
|
-
authoredLlm = new LlmWorker(authoredLlmClient, `${authoredAgent.stream}-llm
|
|
217
|
+
authoredLlm = new LlmWorker(authoredLlmClient, `${authoredAgent.stream}-llm`, workerCapacity);
|
|
212
218
|
await authoredLlm.attach();
|
|
213
219
|
}
|
|
214
220
|
const result = await resumeDurableAuthoredFlow(runId, client, {
|
|
215
221
|
dataDir,
|
|
216
222
|
localAgentStream: authoredAgent?.stream,
|
|
223
|
+
...(authoredAgent === undefined ? {} : { workerCapacity }),
|
|
217
224
|
lifecycle: options,
|
|
218
225
|
});
|
|
219
226
|
if (result === undefined) throw new Error('authored root disappeared during resume');
|
|
@@ -224,7 +231,7 @@ export async function resumeFlow(
|
|
|
224
231
|
// second call the earlier rebase left is a stale reference from before
|
|
225
232
|
// the helper fanout renamed the API.
|
|
226
233
|
if (options.localAgent) {
|
|
227
|
-
authoredAgent = await attachLocalAgent(client, dataDir, options.onPtyReady);
|
|
234
|
+
authoredAgent = await attachLocalAgent(client, dataDir, options.onPtyReady, undefined, options.agentCapacity);
|
|
228
235
|
const entries = (await client.journalRead(runId, 1)).entries as Array<{ entry_type: string; payload?: { spec?: import('../spec.js').KernelRunSpec } }>;
|
|
229
236
|
const spec = entries.find(entry => entry.entry_type === 'run.spawned')?.payload?.spec;
|
|
230
237
|
if (spec?.steps.some(step => step.type === 'agent' && communicationInstruction(step.instruction))) {
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One captured stream, cut to something a terminal can print WITHOUT throwing
|
|
3
|
+
* away the line that says what failed.
|
|
4
|
+
*
|
|
5
|
+
* The render this replaces kept the last 1,024 bytes. For any runner that
|
|
6
|
+
* streams results and prints its summary last — TAP (`node --test`), cargo,
|
|
7
|
+
* vitest, pytest — the last kilobyte is by construction the totals and the
|
|
8
|
+
* final few passing cases, so the one `not ok` was always the part that got
|
|
9
|
+
* cut. A report that proves a failure happened and withholds which one costs
|
|
10
|
+
* the reader a local re-run, which is the loop the gate exists to avoid.
|
|
11
|
+
*
|
|
12
|
+
* So the excerpt is three disjoint ranges of the same source, in source order:
|
|
13
|
+
* a head, the lines in the elided middle that look like a failing case, and a
|
|
14
|
+
* tail. The elision is stated in band, in bytes, so the reader is never left
|
|
15
|
+
* guessing whether what they are looking at is all of it.
|
|
16
|
+
*
|
|
17
|
+
* What this can and cannot promise:
|
|
18
|
+
*
|
|
19
|
+
* - It selects from the field the journal carried, which for a deterministic
|
|
20
|
+
* step is the last 64 KiB the kernel captured (`OUTPUT_TAIL_BYTES`,
|
|
21
|
+
* relayflowd/src/exec_det.rs). A failure printed before that window is not
|
|
22
|
+
* here to be found, and no marker can say so — hence `captured excerpt`
|
|
23
|
+
* rather than a claim about the command's whole output.
|
|
24
|
+
* - The absence of an elision marker means the supplied field fit whole. It
|
|
25
|
+
* does not mean the command printed nothing more.
|
|
26
|
+
* - The markers are hints, not a parser. They find the common runners' failing
|
|
27
|
+
* lines; ordinary prose can match one, and an unknown format matches none
|
|
28
|
+
* and still gets head-and-tail context.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Default excerpt budget, in UTF-8 bytes, for one captured stream. */
|
|
32
|
+
export const EXCERPT_BYTES = 4_096;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Below this there is no room to both elide and say so, and an excerpt that
|
|
36
|
+
* silently drops the claim it makes about itself is the bug being fixed. The
|
|
37
|
+
* budget is a constant of this module, not configuration, so a caller that
|
|
38
|
+
* asks for less has made a programming error and is told at the call.
|
|
39
|
+
*/
|
|
40
|
+
export const MIN_EXCERPT_BYTES = 256;
|
|
41
|
+
|
|
42
|
+
/** How many matched lines the elided middle may contribute. */
|
|
43
|
+
const MAX_HIGHLIGHT_LINES = 20;
|
|
44
|
+
|
|
45
|
+
/** A highlight cut short is only worth showing if some of the line survives. */
|
|
46
|
+
const MIN_HIGHLIGHT_BYTES = 24;
|
|
47
|
+
|
|
48
|
+
/** Newlines this module inserts between ranges and markers. */
|
|
49
|
+
const SEPARATOR_RESERVE = 16;
|
|
50
|
+
|
|
51
|
+
const END_MARKER = '… end of elided region …';
|
|
52
|
+
const TRUNCATED_MARKER = '… line truncated …';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Lines that name a failing case, for the runners this repo actually runs.
|
|
56
|
+
*
|
|
57
|
+
* Each is anchored after optional indentation (nested TAP indents its
|
|
58
|
+
* subtests) and is matched against a comparison view of the line with
|
|
59
|
+
* terminal escapes removed, so a coloured `FAIL` header still matches. They
|
|
60
|
+
* are constants rather than user input, which is why `RegExp` is correct
|
|
61
|
+
* here; a spec-supplied pattern would be untrusted and would have to go
|
|
62
|
+
* through `re2js`.
|
|
63
|
+
*/
|
|
64
|
+
const FAILURE_MARKERS: readonly RegExp[] = [
|
|
65
|
+
/^\s*not ok\b/u, // TAP: node --test, prove
|
|
66
|
+
/^\s*FAILED\b/u, // pytest short test summary: `FAILED tests/x.py::y - ...`
|
|
67
|
+
/^\s*FAIL\b/u, // vitest / jest file header: `FAIL src/x.test.ts`
|
|
68
|
+
/^\s*[✖✗×]\s/u, // vitest / jest failure glyph
|
|
69
|
+
/\.\.\.\s*FAILED\b/u, // cargo: `test pty::exit ... FAILED`
|
|
70
|
+
/^\s*---- .* ----\s*$/u, // cargo: `---- pty::exit stdout ----`
|
|
71
|
+
/\bpanicked at\b/u, // rust panic
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
interface Range { start: number; end: number }
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Cut `value` to at most `budget` UTF-8 bytes, markers included.
|
|
78
|
+
*
|
|
79
|
+
* A stream that already fits is returned whole (sanitised) with no marker. One
|
|
80
|
+
* that does not keeps a head of whole lines, up to `MAX_HIGHLIGHT_LINES`
|
|
81
|
+
* matched lines from the elided middle, and a tail that fills what is left.
|
|
82
|
+
* Every cut is at a code-point boundary, so slicing never invents a U+FFFD.
|
|
83
|
+
*/
|
|
84
|
+
export function formatStepExcerpt(value: string, budget: number = EXCERPT_BYTES): string {
|
|
85
|
+
if (!Number.isSafeInteger(budget) || budget < MIN_EXCERPT_BYTES) {
|
|
86
|
+
throw new Error(`step excerpt budget must be an integer of at least ${MIN_EXCERPT_BYTES} bytes`);
|
|
87
|
+
}
|
|
88
|
+
const source = Buffer.from(value, 'utf8');
|
|
89
|
+
// Sanitised from the original string, not from the round trip: a lone
|
|
90
|
+
// surrogate the wire delivered is the caller's to keep, and re-encoding it
|
|
91
|
+
// would be this module inventing a replacement character.
|
|
92
|
+
if (source.length <= budget) return sanitise(value);
|
|
93
|
+
|
|
94
|
+
const lines = lineRanges(source);
|
|
95
|
+
const matches = lines.filter(isFailureLine(source));
|
|
96
|
+
// Content space is what is left after the markers can be afforded at their
|
|
97
|
+
// widest, so the excerpt can always state its own elision.
|
|
98
|
+
const content = Math.max(0, budget - markerReserve(source.length, matches.length));
|
|
99
|
+
const tailShare = Math.floor(content / 2);
|
|
100
|
+
const headShare = Math.floor(content / 4);
|
|
101
|
+
const highlightShare = content - tailShare - headShare;
|
|
102
|
+
|
|
103
|
+
const head = headRange(source, lines, headShare);
|
|
104
|
+
// Provisional tail, which with the head defines the middle the highlights
|
|
105
|
+
// are drawn from.
|
|
106
|
+
const highlights = selectHighlights(source, matches, highlightShare,
|
|
107
|
+
head.end, tailRange(source, tailShare, head.end).start);
|
|
108
|
+
|
|
109
|
+
// Every byte the head and the highlights did not need goes to the tail,
|
|
110
|
+
// which is where a runner puts the summary. The tail may not cross the last
|
|
111
|
+
// highlight, so the ranges stay disjoint and in source order.
|
|
112
|
+
const spare = headShare - size(head)
|
|
113
|
+
+ highlightShare - highlights.reduce((sum, highlight) => sum + size(highlight.range), 0);
|
|
114
|
+
let tail = tailRange(source, tailShare + spare, highlights.at(-1)?.range.end ?? head.end);
|
|
115
|
+
// A highlight the tail resumes at the very byte it ends is not a range of
|
|
116
|
+
// its own: the two are one unbroken run of source, so the tail starts at
|
|
117
|
+
// the highlight instead. The same bytes either way, with no marker between
|
|
118
|
+
// them claiming a cut the reader can see did not happen.
|
|
119
|
+
while (highlights.length > 0 && highlights.at(-1)!.range.end === tail.start) {
|
|
120
|
+
tail = { start: highlights.pop()!.range.start, end: tail.end };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const selected = [head, ...highlights.map(highlight => highlight.range), tail]
|
|
124
|
+
.filter(range => size(range) > 0);
|
|
125
|
+
const elided = source.length - selected.reduce((sum, range) => sum + size(range), 0);
|
|
126
|
+
// Positional, not textual: two identical failing lines at two positions are
|
|
127
|
+
// two observations, and one of them may be visible while the other is not.
|
|
128
|
+
// Shown means the line's own start is on screen, because the start is what
|
|
129
|
+
// names the failing case: a highlight cut short still reports it, while a
|
|
130
|
+
// range that begins in the middle of the line — a tail whose cut landed
|
|
131
|
+
// there — shows only the part after the name and reports nothing.
|
|
132
|
+
const omitted = matches.filter(match =>
|
|
133
|
+
!selected.some(range => range.start <= match.start && range.end > match.start)).length;
|
|
134
|
+
|
|
135
|
+
const parts: string[] = [];
|
|
136
|
+
const terminated = (chunk: string): string => chunk.endsWith('\n') ? chunk : `${chunk}\n`;
|
|
137
|
+
if (size(head) > 0) parts.push(terminated(text(source, head)));
|
|
138
|
+
parts.push(`${openMarker(elided, highlights.length, omitted)}\n`);
|
|
139
|
+
for (const highlight of highlights) {
|
|
140
|
+
parts.push(terminated(text(source, highlight.range) + (highlight.truncated ? ` ${TRUNCATED_MARKER}` : '')));
|
|
141
|
+
}
|
|
142
|
+
if (highlights.length > 0) {
|
|
143
|
+
if (omitted > 0) parts.push(`${moreMarker(omitted)}\n`);
|
|
144
|
+
parts.push(`${END_MARKER}\n`);
|
|
145
|
+
}
|
|
146
|
+
if (size(tail) > 0) parts.push(text(source, tail));
|
|
147
|
+
// Sanitising replaces, never expands, so it cannot push the result over the
|
|
148
|
+
// budget; the clamp is where the byte bound is enforced, for every path.
|
|
149
|
+
return clampBytes(sanitise(parts.join('')), budget);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* `N bytes elided` counts raw source bytes represented by no selected range,
|
|
154
|
+
* including the omitted portion of a line shown only in part. Source bytes
|
|
155
|
+
* shown plus source bytes elided is the input, exactly; the markers this
|
|
156
|
+
* module inserts are not source bytes, and sanitising can shrink what is
|
|
157
|
+
* displayed without changing what was selected.
|
|
158
|
+
*/
|
|
159
|
+
function openMarker(elided: number, shown: number, omitted: number): string {
|
|
160
|
+
const matched = shown > 0
|
|
161
|
+
? `; ${plural(shown, 'line')} matched a failure marker`
|
|
162
|
+
: omitted > 0 ? `; ${plural(omitted, 'line')} matched a failure marker, none shown` : '';
|
|
163
|
+
return `… ${group(elided)} bytes elided${matched} …`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function moreMarker(omitted: number): string {
|
|
167
|
+
return `… +${group(omitted)} more matching ${omitted === 1 ? 'line' : 'lines'} …`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Every marker at its widest, so content space can be assigned once. */
|
|
171
|
+
function markerReserve(sourceBytes: number, matches: number): number {
|
|
172
|
+
const open = Math.max(
|
|
173
|
+
byteLength(openMarker(sourceBytes, matches, matches)),
|
|
174
|
+
byteLength(openMarker(sourceBytes, 0, matches)),
|
|
175
|
+
byteLength(openMarker(sourceBytes, 0, 0)),
|
|
176
|
+
);
|
|
177
|
+
const highlighted = matches === 0 ? 0
|
|
178
|
+
: byteLength(moreMarker(matches)) + byteLength(END_MARKER) + byteLength(TRUNCATED_MARKER) + 1;
|
|
179
|
+
return open + highlighted + SEPARATOR_RESERVE;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Matched lines from the middle, in source order, bounded by both the line cap
|
|
184
|
+
* and the byte share. A whole line is preferred; when the first candidate does
|
|
185
|
+
* not fit at all, a code-point-safe prefix of it is shown under a truncation
|
|
186
|
+
* marker, because a cut failing line still names the failing case and no
|
|
187
|
+
* highlight at all does not.
|
|
188
|
+
*
|
|
189
|
+
* The middle ends where the tail begins, but a line the tail begins in the
|
|
190
|
+
* middle of belongs to the middle: the tail shows its tail, and the marker
|
|
191
|
+
* that names the failing case is ahead of the cut. Such a line is a candidate
|
|
192
|
+
* like any other, and the caller keeps the ranges disjoint by starting the
|
|
193
|
+
* tail after whatever prefix is kept.
|
|
194
|
+
*/
|
|
195
|
+
function selectHighlights(
|
|
196
|
+
source: Buffer, matches: readonly Range[], share: number, from: number, to: number,
|
|
197
|
+
): Array<{ range: Range; truncated: boolean }> {
|
|
198
|
+
const selected: Array<{ range: Range; truncated: boolean }> = [];
|
|
199
|
+
let remaining = share;
|
|
200
|
+
for (const match of matches) {
|
|
201
|
+
// Reported once, at its own position: a line whose own start is already
|
|
202
|
+
// on screen — inside the head, or at or after the start of the tail —
|
|
203
|
+
// needs no highlight.
|
|
204
|
+
if (match.start < from || match.start >= to) continue;
|
|
205
|
+
if (selected.length >= MAX_HIGHLIGHT_LINES) break;
|
|
206
|
+
if (size(match) <= remaining) {
|
|
207
|
+
selected.push({ range: match, truncated: false });
|
|
208
|
+
remaining -= size(match);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (selected.length === 0 && remaining >= MIN_HIGHLIGHT_BYTES) {
|
|
212
|
+
const end = floorBoundary(source, match.start + remaining);
|
|
213
|
+
if (end > match.start) selected.push({ range: { start: match.start, end }, truncated: true });
|
|
214
|
+
}
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
return selected;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Whole lines from the start, or a bounded prefix when the first line alone is
|
|
222
|
+
* longer than the share — a long first line must not consume the excerpt.
|
|
223
|
+
* Whatever whole lines leave unspent is returned to the tail by the caller.
|
|
224
|
+
*/
|
|
225
|
+
function headRange(source: Buffer, lines: readonly Range[], limit: number): Range {
|
|
226
|
+
let end = 0;
|
|
227
|
+
for (const line of lines) {
|
|
228
|
+
if (line.end > limit) break;
|
|
229
|
+
end = line.end;
|
|
230
|
+
}
|
|
231
|
+
return { start: 0, end: end === 0 ? floorBoundary(source, limit) : end };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The last `limit` bytes, cut at a code-point boundary and never crossing
|
|
236
|
+
* `stop` (the end of the head or of the last highlight).
|
|
237
|
+
*
|
|
238
|
+
* Unlike the head, the tail fills its share rather than stopping at a line
|
|
239
|
+
* boundary. A stream can be one enormous line followed by a one-byte last
|
|
240
|
+
* line; taking whole lines only would spend one byte of a two-kilobyte share
|
|
241
|
+
* and drop the summary that the old last-1,024-bytes render always kept.
|
|
242
|
+
*/
|
|
243
|
+
function tailRange(source: Buffer, limit: number, stop: number): Range {
|
|
244
|
+
return { start: Math.max(ceilBoundary(source, source.length - limit), stop), end: source.length };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function isFailureLine(source: Buffer): (line: Range) => boolean {
|
|
248
|
+
return line => {
|
|
249
|
+
const comparable = comparableLine(text(source, line));
|
|
250
|
+
return FAILURE_MARKERS.some(marker => marker.test(comparable));
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The view a marker is matched against: terminal escape sequences and control
|
|
256
|
+
* characters removed so a coloured `not ok` or `FAIL` header still matches.
|
|
257
|
+
* This never touches what is rendered — journal data is not mutated, and the
|
|
258
|
+
* display sanitiser is a separate, later step.
|
|
259
|
+
*/
|
|
260
|
+
function comparableLine(line: string): string {
|
|
261
|
+
return line
|
|
262
|
+
.replace(/\u001b\[[0-9;:?]*[ -/]*[@-~]|\u001b[@-Z\\-_]|\u009b[0-9;:?]*[ -/]*[@-~]/gu, '')
|
|
263
|
+
.replace(/[\p{Cc}\p{Cf}]/gu, character => character === '\t' ? ' ' : '');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Line ranges over the raw bytes; each end includes its own `\n` when it has one. */
|
|
267
|
+
function lineRanges(source: Buffer): Range[] {
|
|
268
|
+
const ranges: Range[] = [];
|
|
269
|
+
let start = 0;
|
|
270
|
+
for (let index = 0; index < source.length; index++) {
|
|
271
|
+
if (source[index] === 0x0a) {
|
|
272
|
+
ranges.push({ start, end: index + 1 });
|
|
273
|
+
start = index + 1;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (start < source.length) ranges.push({ start, end: source.length });
|
|
277
|
+
return ranges;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Preserve tabs and newlines; replace binary controls (including ESC and CR),
|
|
282
|
+
* C1 controls and Unicode formatting controls without growing the excerpt.
|
|
283
|
+
* Applied on every path, short inputs included: a control sequence in a
|
|
284
|
+
* two-line stderr is as able to rewrite the terminal as one in a long stream.
|
|
285
|
+
*/
|
|
286
|
+
function sanitise(value: string): string {
|
|
287
|
+
return value.replace(/[\p{Cc}\p{Cf}]/gu, character =>
|
|
288
|
+
character === '\n' || character === '\t' ? character : '?');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function clampBytes(value: string, budget: number): string {
|
|
292
|
+
const bytes = Buffer.from(value, 'utf8');
|
|
293
|
+
if (bytes.length <= budget) return value;
|
|
294
|
+
return bytes.subarray(0, floorBoundary(bytes, budget)).toString('utf8');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function text(source: Buffer, range: Range): string {
|
|
298
|
+
return source.subarray(range.start, range.end).toString('utf8');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function size(range: Range): number {
|
|
302
|
+
return range.end - range.start;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function isBoundary(source: Buffer, index: number): boolean {
|
|
306
|
+
return index >= source.length || (source[index]! & 0xc0) !== 0x80;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function floorBoundary(source: Buffer, index: number): number {
|
|
310
|
+
let at = Math.max(0, Math.min(index, source.length));
|
|
311
|
+
while (at > 0 && !isBoundary(source, at)) at -= 1;
|
|
312
|
+
return at;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function ceilBoundary(source: Buffer, index: number): number {
|
|
316
|
+
let at = Math.max(0, Math.min(index, source.length));
|
|
317
|
+
while (at < source.length && !isBoundary(source, at)) at += 1;
|
|
318
|
+
return at;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function byteLength(value: string): number {
|
|
322
|
+
return Buffer.byteLength(value, 'utf8');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function plural(count: number, noun: string): string {
|
|
326
|
+
return `${group(count)} ${noun}${count === 1 ? '' : 's'}`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Locale-independent digit grouping: the same excerpt everywhere it is read. */
|
|
330
|
+
function group(value: number): string {
|
|
331
|
+
return String(value).replace(/\B(?=(\d{3})+(?!\d))/gu, ',');
|
|
332
|
+
}
|
package/src/cli/step-failure.ts
CHANGED
|
@@ -2,8 +2,7 @@ import { join } from 'node:path';
|
|
|
2
2
|
import { DEFAULT_DATA_DIR } from '../daemon-connection.js';
|
|
3
3
|
import type { StepFailedDetails } from '../failure-kinds.js';
|
|
4
4
|
import type { JournalClient } from '../journal-client.js';
|
|
5
|
-
|
|
6
|
-
const TAIL_BYTES = 1_024;
|
|
5
|
+
import { formatStepExcerpt } from './step-excerpt.js';
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* Read what a failed step left in the journal — for any step type.
|
|
@@ -117,8 +116,8 @@ export function renderStepEvidence(details: StepFailedDetails): string {
|
|
|
117
116
|
+ (details.exitCode === undefined ? '' : ` exit=${details.exitCode}`)
|
|
118
117
|
+ '.'
|
|
119
118
|
+ (details.detail === undefined ? '' : `\nDetail: ${details.detail}`)
|
|
120
|
-
+ (details.stdoutTail ? `\nStdout (
|
|
121
|
-
+ (details.stderrTail ? `\nStderr (
|
|
119
|
+
+ (details.stdoutTail ? `\nStdout (captured excerpt):\n${details.stdoutTail}` : '')
|
|
120
|
+
+ (details.stderrTail ? `\nStderr (captured excerpt):\n${details.stderrTail}` : '')
|
|
122
121
|
+ (details.transcriptPath === undefined ? '' : `\nTranscript: ${details.transcriptPath}`);
|
|
123
122
|
}
|
|
124
123
|
|
|
@@ -195,22 +194,22 @@ function evidence(payload: Record<string, unknown>): Partial<StepFailedDetails>
|
|
|
195
194
|
const structuredShape = structured !== undefined && processShaped(structured);
|
|
196
195
|
const transcript = record(record(payload['trajectory_tail'])?.['transcript']);
|
|
197
196
|
const failure = record(transcript?.['failure']);
|
|
198
|
-
const
|
|
197
|
+
const failureExcerpt = failure?.['excerpt'];
|
|
199
198
|
const transcriptPath = record(transcript?.['file'])?.['path'];
|
|
200
199
|
// The excerpt is the worker's own pick of the failure; a `stderr` excerpt
|
|
201
200
|
// is the same bytes as `stderrTail`, so it is not printed twice.
|
|
202
|
-
const excerptDetail = typeof
|
|
201
|
+
const excerptDetail = typeof failureExcerpt === 'string' && failureExcerpt.length > 0
|
|
203
202
|
&& !(failure?.['kind'] === 'stderr' && typeof stderr === 'string')
|
|
204
|
-
?
|
|
203
|
+
? formatStepExcerpt(failureExcerpt) : undefined;
|
|
205
204
|
return {
|
|
206
205
|
...(typeof exitCode === 'number' && Number.isSafeInteger(exitCode) ? { exitCode } : {}),
|
|
207
|
-
...(typeof stdout === 'string' && stdout.length > 0 ? { stdoutTail:
|
|
208
|
-
...(typeof stderr === 'string' ? { stderrTail:
|
|
206
|
+
...(typeof stdout === 'string' && stdout.length > 0 ? { stdoutTail: formatStepExcerpt(stdout) } : {}),
|
|
207
|
+
...(typeof stderr === 'string' ? { stderrTail: formatStepExcerpt(stderr) } : {}),
|
|
209
208
|
// Keep the daemon's account only when it was NOT just a render of the
|
|
210
209
|
// fields above — otherwise the same bytes print twice.
|
|
211
210
|
...(excerptDetail !== undefined ? { detail: excerptDetail }
|
|
212
211
|
: typeof detail === 'string' && detail.length > 0 && !structuredShape
|
|
213
|
-
? { detail:
|
|
212
|
+
? { detail: formatStepExcerpt(detail) } : {}),
|
|
214
213
|
...(typeof transcriptPath === 'string' && transcriptPath.length > 0 ? { transcriptPath } : {}),
|
|
215
214
|
};
|
|
216
215
|
}
|
|
@@ -233,16 +232,6 @@ function record(value: unknown): Record<string, unknown> | undefined {
|
|
|
233
232
|
? value as Record<string, unknown> : undefined;
|
|
234
233
|
}
|
|
235
234
|
|
|
236
|
-
function tail(value: string): string {
|
|
237
|
-
const bytes = Buffer.from(value, 'utf8');
|
|
238
|
-
let start = Math.max(0, bytes.length - TAIL_BYTES);
|
|
239
|
-
// Drop a partial leading code point, avoiding replacement-byte expansion.
|
|
240
|
-
while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start += 1;
|
|
241
|
-
// Preserve tabs/newlines; replace binary controls (including ESC and CR),
|
|
242
|
-
// C1 controls and Unicode formatting controls without growing the excerpt.
|
|
243
|
-
return bytes.subarray(start).toString('utf8')
|
|
244
|
-
.replace(/[\p{Cc}\p{Cf}]/gu, character => character === '\n' || character === '\t' ? character : '?');
|
|
245
|
-
}
|
|
246
235
|
|
|
247
236
|
function shellQuote(value: string): string {
|
|
248
237
|
return /^[A-Za-z0-9_-]+$/.test(value) && !value.startsWith('-')
|
package/src/cli-commands.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_DATA_DIR } from './daemon-connection.js';
|
|
2
2
|
import { DEFAULT_RUN_LIMIT } from './cli/cloud-read.js';
|
|
3
|
+
import { DEFAULT_LOCAL_AGENT_CAPACITY, MAX_LOCAL_AGENT_CAPACITY } from './worker-slots.js';
|
|
3
4
|
import type { ParsedArgs } from './cli.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -88,6 +89,11 @@ const LOCAL_EXECUTION_OPTIONS = [
|
|
|
88
89
|
JSON_OPTION,
|
|
89
90
|
DATA_DIR_OPTION,
|
|
90
91
|
{ flags: '--local-agent', description: 'Run agent steps in this process instead of a worker' },
|
|
92
|
+
{
|
|
93
|
+
flags: '--agent-capacity <n>',
|
|
94
|
+
description: `With --local-agent, how many agent steps (and, separately, LLM steps) run at once (1-${MAX_LOCAL_AGENT_CAPACITY})`,
|
|
95
|
+
defaultValue: String(DEFAULT_LOCAL_AGENT_CAPACITY),
|
|
96
|
+
},
|
|
91
97
|
{ flags: '--no-spawn', description: 'Require a running relayflowd rather than starting one' },
|
|
92
98
|
{ flags: '--no-observer-link', description: 'Do not mint an observer link for this run' },
|
|
93
99
|
{
|