@relayflows/sdk 2.0.27 → 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/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/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
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
|
{
|
package/src/cli.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { parsePluginArgs, runPluginCommand, type PluginArgs } from './cli/plugin
|
|
|
4
4
|
import { watchCheck } from './cli-watch.js';
|
|
5
5
|
import { checkHelperBody } from './cli/check-helper-body.js';
|
|
6
6
|
import { describeFlowRequirements } from './flow-requirements.js';
|
|
7
|
+
import type { CliModelSource } from './cli-adapter.js';
|
|
7
8
|
|
|
8
9
|
import { renderProgress, type ProgressEvent } from './progress.js';
|
|
9
10
|
import { realpathSync } from 'node:fs';
|
|
@@ -44,6 +45,7 @@ import { runHnMonitor } from './cli/hn-monitor.js';
|
|
|
44
45
|
import { runTickRunner } from './cli/tick-runner.js';
|
|
45
46
|
import { DEFAULT_DATA_DIR } from './daemon-connection.js';
|
|
46
47
|
import { CLI_VERB_NAMES } from './cli-commands.js';
|
|
48
|
+
import { isAgentCapacity } from './worker-slots.js';
|
|
47
49
|
import {
|
|
48
50
|
mintObserverUrl,
|
|
49
51
|
resolveObserverLinkEnv,
|
|
@@ -80,8 +82,8 @@ export type ParsedArgs =
|
|
|
80
82
|
| { command: 'schedules'; json: boolean }
|
|
81
83
|
| { command: 'unschedule'; scheduleId: string; json: boolean }
|
|
82
84
|
| { command: 'check'; json: boolean; watch: boolean; value: string }
|
|
83
|
-
| { command: 'run'; bucket: string | undefined; reuseFromRunId: string | undefined; localAgent: boolean; dataDir: string; input: string | undefined; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
|
|
84
|
-
| { command: 'resume'; localAgent: boolean; dataDir: string; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
|
|
85
|
+
| { command: 'run'; bucket: string | undefined; reuseFromRunId: string | undefined; localAgent: boolean; agentCapacity: number | undefined; dataDir: string; input: string | undefined; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
|
|
86
|
+
| { command: 'resume'; localAgent: boolean; agentCapacity: number | undefined; dataDir: string; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
|
|
85
87
|
| { command: 'answer'; dataDir: string; json: boolean; spawn: boolean; note: string | undefined; by: string | undefined; runId: string; waitId: string; answer: boolean }
|
|
86
88
|
| RunsArgs
|
|
87
89
|
| LogsArgs
|
|
@@ -111,13 +113,13 @@ const USAGE = [
|
|
|
111
113
|
'flows run <flow>@sha256:<digest> [--bucket <file-bucket-uri>] [--data-dir <dir>] [--json]',
|
|
112
114
|
'flows check [--watch] [--json] <flow.ts|flow.yaml|spec.json>',
|
|
113
115
|
'flows serve-webhook --data-dir <dir> --port <p> [--allow <name>[,<name>]]',
|
|
114
|
-
'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] [--reuse-from <run-id>] <flow.yaml|spec.json>',
|
|
116
|
+
'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] [--reuse-from <run-id>] <flow.yaml|spec.json>',
|
|
115
117
|
'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.yaml|spec.json>',
|
|
116
118
|
'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.ts> --input <inline-json-or-file>',
|
|
117
119
|
'flows sync [--json] [--dry-run] [--dir <path>] <run-id>',
|
|
118
|
-
'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] <flow.ts> --input <inline-json-or-file>',
|
|
120
|
+
'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] <flow.ts> --input <inline-json-or-file>',
|
|
119
121
|
'flows tick start --schedule-id <id> --interval-ms <ms> [--epoch-ms <ms>] [--max-catch-up <n>] [--poll-interval-ms <ms>] [--data-dir <dir>] <spec.json>',
|
|
120
|
-
'flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] <run-id>',
|
|
122
|
+
'flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] <run-id>',
|
|
121
123
|
'flows answer [--json] [--no-spawn] [--data-dir <dir>] [--note <text>] [--by <identity>] <run-id> <wait-id> <yes|no>',
|
|
122
124
|
'flows replay [--allow-human-influenced] [--json] [--data-dir <dir>] <run-id> [--at <step-id>]',
|
|
123
125
|
'flows status [--json] [--data-dir <dir>] [--tail <n>] [<run-id>]',
|
|
@@ -295,6 +297,7 @@ export async function runCli(
|
|
|
295
297
|
onPtyReady: (path: string) => io.stderr(`PTY ${path}`),
|
|
296
298
|
...(parsed.command === 'run' && parsed.reuseFromRunId !== undefined ? { reuseFromRunId: parsed.reuseFromRunId } : {}),
|
|
297
299
|
localAgent: parsed.localAgent,
|
|
300
|
+
...(parsed.agentCapacity === undefined ? {} : { agentCapacity: parsed.agentCapacity }),
|
|
298
301
|
onProgress: showProgress,
|
|
299
302
|
onWait: (progress: RunProgress) => {
|
|
300
303
|
emitWait(progress, io);
|
|
@@ -581,6 +584,7 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
|
|
|
581
584
|
let syncCode = false;
|
|
582
585
|
let noConnect = false;
|
|
583
586
|
let localAgent = false;
|
|
587
|
+
let agentCapacity: number | undefined;
|
|
584
588
|
let allowHumanInfluenced = false;
|
|
585
589
|
let dataDir = DEFAULT_DATA_DIR;
|
|
586
590
|
let sawDataDir = false;
|
|
@@ -612,6 +616,14 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
|
|
|
612
616
|
localAgent = true;
|
|
613
617
|
continue;
|
|
614
618
|
}
|
|
619
|
+
if (argument === '--agent-capacity') {
|
|
620
|
+
// Decimal digits only: `Number` would also read "0x4", "4e0" and " 4".
|
|
621
|
+
const value = args[++index];
|
|
622
|
+
if (command === 'check' || agentCapacity !== undefined || value === undefined || !/^[0-9]+$/.test(value)) return undefined;
|
|
623
|
+
agentCapacity = Number(value);
|
|
624
|
+
if (!isAgentCapacity(agentCapacity)) return undefined;
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
615
627
|
if (argument === '--watch') {
|
|
616
628
|
if (command !== 'check' || watch) return undefined;
|
|
617
629
|
watch = true;
|
|
@@ -669,6 +681,8 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
|
|
|
669
681
|
positionals.push(argument);
|
|
670
682
|
}
|
|
671
683
|
if (positionals.length !== 1) return undefined;
|
|
684
|
+
// It sizes the in-process worker, so without `--local-agent` it describes nothing.
|
|
685
|
+
if (agentCapacity !== undefined && !localAgent) return undefined;
|
|
672
686
|
|
|
673
687
|
if (bucket !== undefined && (cloud || !parseDigestReference(positionals[0]!))) return undefined;
|
|
674
688
|
if (cloud) {
|
|
@@ -688,8 +702,8 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
|
|
|
688
702
|
return command === 'check'
|
|
689
703
|
? { command, json, watch, value: positionals[0]! }
|
|
690
704
|
: command === 'run'
|
|
691
|
-
? { command, bucket, reuseFromRunId, localAgent, dataDir, input, json, spawn, noObserverLink, allowHumanInfluenced, value: positionals[0]! }
|
|
692
|
-
: { command, localAgent, dataDir, json, spawn, noObserverLink, allowHumanInfluenced, value: positionals[0]! };
|
|
705
|
+
? { command, bucket, reuseFromRunId, localAgent, agentCapacity, dataDir, input, json, spawn, noObserverLink, allowHumanInfluenced, value: positionals[0]! }
|
|
706
|
+
: { command, localAgent, agentCapacity, dataDir, json, spawn, noObserverLink, allowHumanInfluenced, value: positionals[0]! };
|
|
693
707
|
}
|
|
694
708
|
|
|
695
709
|
/**
|
|
@@ -927,6 +941,17 @@ function parseTickArgs(rest: readonly string[]): ParsedArgs | undefined {
|
|
|
927
941
|
};
|
|
928
942
|
}
|
|
929
943
|
|
|
944
|
+
const MODEL_PROVENANCE: Readonly<Record<CliModelSource, string>> = {
|
|
945
|
+
step: 'step',
|
|
946
|
+
named: 'named agent',
|
|
947
|
+
adapter: 'adapter default',
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
/** A resolution carrying no model source prints no provenance rather than a guess. */
|
|
951
|
+
function modelProvenance(source: CliModelSource | undefined): string {
|
|
952
|
+
return source === undefined ? '' : ` from ${MODEL_PROVENANCE[source]}`;
|
|
953
|
+
}
|
|
954
|
+
|
|
930
955
|
function emitCheckReport(report: CheckReport, json: boolean, io: CliIo): void {
|
|
931
956
|
emitDiagnostics(report.diagnostics, io);
|
|
932
957
|
if (json) {
|
|
@@ -963,8 +988,14 @@ function emitCheckReport(report: CheckReport, json: boolean, io: CliIo): void {
|
|
|
963
988
|
const config = resolution.source === 'project' && report.projectConfigPath !== undefined
|
|
964
989
|
? ` (${report.projectConfigPath})`
|
|
965
990
|
: '';
|
|
966
|
-
|
|
967
|
-
|
|
991
|
+
// `from <source>` describes whatever noun precedes it, and the source here
|
|
992
|
+
// is the CLI's. Printing it after the model made every step whose CLI and
|
|
993
|
+
// model come from different places claim the model was declared where the
|
|
994
|
+
// CLI was — a step `cli:` plus an adapter default read as `from step`.
|
|
995
|
+
const model = resolution.model === undefined
|
|
996
|
+
? ''
|
|
997
|
+
: ` model "${resolution.model}"${modelProvenance(resolution.modelSource)}`;
|
|
998
|
+
io.stdout(`RESOLVED step "${resolution.stepId}" cli "${resolution.cli}" from ${resolution.source}${config}${model}`);
|
|
968
999
|
}
|
|
969
1000
|
// What the workspace must have connected before this flow can run there;
|
|
970
1001
|
// the hosted verbs check the same list against Cloud before submitting.
|
package/src/failure-kinds.ts
CHANGED
|
@@ -177,9 +177,17 @@ export interface StepFailedDetails {
|
|
|
177
177
|
*/
|
|
178
178
|
maxIterations?: number;
|
|
179
179
|
exitCode?: number;
|
|
180
|
-
/**
|
|
180
|
+
/**
|
|
181
|
+
* Terminal-safe UTF-8 excerpt of the captured stdout, at most
|
|
182
|
+
* `EXCERPT_BYTES` (cli/step-excerpt.ts). The field name is kept for
|
|
183
|
+
* compatibility; it is no longer a plain tail. When the captured field did
|
|
184
|
+
* not fit, the excerpt keeps a head, the lines from the elided middle that
|
|
185
|
+
* match a failure marker, and a tail, and states the elision in band. The
|
|
186
|
+
* absence of an elision marker means the field the journal carried fit
|
|
187
|
+
* whole — not that the command printed no more than this.
|
|
188
|
+
*/
|
|
181
189
|
stdoutTail?: string;
|
|
182
|
-
/** Terminal-safe UTF-8 excerpt
|
|
190
|
+
/** Terminal-safe UTF-8 excerpt of the captured stderr; see `stdoutTail`. */
|
|
183
191
|
stderrTail?: string;
|
|
184
192
|
/**
|
|
185
193
|
* The daemon's own account when it was not a render of the fields above, or
|