@agentswf/engine 0.0.0-stage → 0.1.0-beta.1
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/package.json +39 -4
- package/src/accounting/format.ts +252 -0
- package/src/accounting/plans.ts +38 -0
- package/src/accounting/prices.ts +98 -0
- package/src/accounting/summary.ts +385 -0
- package/src/accounting/tokens.ts +34 -0
- package/src/agent-launcher.ts +127 -0
- package/src/allowance-command.ts +105 -0
- package/src/attempt-ending.ts +198 -0
- package/src/attempt-output.ts +132 -0
- package/src/attempt-view.ts +362 -0
- package/src/canonical-json.ts +12 -0
- package/src/control-plane.ts +424 -0
- package/src/deadlines.ts +106 -0
- package/src/decisions/directory.ts +432 -0
- package/src/decisions/fake.ts +60 -0
- package/src/decisions/openrouter.ts +161 -0
- package/src/decisions/seam.ts +70 -0
- package/src/display-path.ts +16 -0
- package/src/duration.ts +12 -0
- package/src/errors.ts +4 -0
- package/src/files.ts +78 -0
- package/src/herdr-placement.ts +182 -0
- package/src/herdr-run-session.ts +530 -0
- package/src/herdr-workspace-marks.ts +194 -0
- package/src/here.ts +289 -0
- package/src/index.ts +5 -0
- package/src/init-command.ts +240 -0
- package/src/init-template/AGENTS.md.template +16 -0
- package/src/init-template/hello/workflow.test.ts.template +17 -0
- package/src/init-template/hello/workflow.ts.template +26 -0
- package/src/init-template/tsconfig.json.template +11 -0
- package/src/jsonl.ts +74 -0
- package/src/logs-command.ts +605 -0
- package/src/machine.ts +27 -0
- package/src/message-operation.ts +245 -0
- package/src/messaging/limits.ts +35 -0
- package/src/messaging/messaging.ts +1303 -0
- package/src/messaging/routes.ts +79 -0
- package/src/messaging/text.ts +251 -0
- package/src/operation-events.ts +182 -0
- package/src/operation-liveness.ts +674 -0
- package/src/operator-aliases.ts +10 -0
- package/src/operator-cli.ts +600 -0
- package/src/operator-runtime.ts +503 -0
- package/src/pane-layout.ts +125 -0
- package/src/progress-view.ts +568 -0
- package/src/result-slots.ts +325 -0
- package/src/result-validation.ts +47 -0
- package/src/run-command.ts +242 -0
- package/src/run-commands.ts +215 -0
- package/src/run-event-log.ts +72 -0
- package/src/run-prepare.ts +178 -0
- package/src/run-progress.ts +137 -0
- package/src/run-read.ts +425 -0
- package/src/run-state.ts +120 -0
- package/src/run-usage.ts +393 -0
- package/src/run-view.ts +225 -0
- package/src/runs-command.ts +356 -0
- package/src/runs.ts +645 -0
- package/src/sandbox-homes.ts +211 -0
- package/src/sandboxes.ts +521 -0
- package/src/skills/fetch.ts +305 -0
- package/src/skills/run-skills.ts +181 -0
- package/src/skills/sources.ts +87 -0
- package/src/skills/tree.ts +60 -0
- package/src/stage-ledger.ts +535 -0
- package/src/stage-plan.ts +127 -0
- package/src/stage-schema.ts +88 -0
- package/src/stopped.ts +36 -0
- package/src/test-command.ts +110 -0
- package/src/testing/operation-events.ts +89 -0
- package/src/testing.ts +164 -0
- package/src/workflow-loader.ts +113 -0
- package/src/workflow-runner.ts +3666 -0
- package/src/workflow-test-preload.ts +13 -0
- package/src/workflow-testing/decisions.ts +129 -0
- package/src/workflow-testing/host.ts +811 -0
- package/src/workflow-testing/index.ts +420 -0
- package/src/workflow-testing/script.ts +447 -0
- package/src/workflow-testing/workflow-testing.typecheck.ts +47 -0
- package/README.md +0 -3
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { constants } from "node:os";
|
|
2
|
+
import type { StageNeed, StageRecord } from "@agentswf/contract/records";
|
|
3
|
+
import {
|
|
4
|
+
type AbsoluteDeadline,
|
|
5
|
+
type AttemptOutcome,
|
|
6
|
+
DeadlineExceededError,
|
|
7
|
+
type Ending,
|
|
8
|
+
type JsonValue,
|
|
9
|
+
type StageSummary,
|
|
10
|
+
type UnfinishedOutcome,
|
|
11
|
+
} from "@agentswf/contract/workflow";
|
|
12
|
+
import { PUBLISHED_PRICES } from "./accounting/prices";
|
|
13
|
+
import { summarizeRun } from "./accounting/summary";
|
|
14
|
+
import { messageOf } from "./errors";
|
|
15
|
+
import type { Paint } from "./progress-view";
|
|
16
|
+
import { FromStageUnreached, primaryFailure, WorkflowStopped } from "./stopped";
|
|
17
|
+
import {
|
|
18
|
+
type SettledRun,
|
|
19
|
+
WorkflowCancelledError,
|
|
20
|
+
WorkflowRunError,
|
|
21
|
+
type WorkflowRunResult,
|
|
22
|
+
} from "./workflow-runner";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How an attempt ended, decided once: every record, message and exit code of it follows this.
|
|
26
|
+
* `settled` is what the run left, absent when it failed before it started.
|
|
27
|
+
*/
|
|
28
|
+
export type AttemptEnd = {
|
|
29
|
+
ending: Ending<JsonValue>;
|
|
30
|
+
settled?: SettledRun;
|
|
31
|
+
exitCode: number;
|
|
32
|
+
/** What else failed beside a stop, which its reason leaves out. */
|
|
33
|
+
alsoFailed?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Given when the attempt never reached its `--from-stage`, which no one command goes on from:
|
|
36
|
+
* the run's stages to choose from.
|
|
37
|
+
*/
|
|
38
|
+
choose?: readonly StageRecord[];
|
|
39
|
+
/** The stages whose values `--values` gives, to start at a later one. */
|
|
40
|
+
needs?: readonly StageNeed[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** What a started run came to: the result it returned, or what it threw. */
|
|
44
|
+
export type Finished = { result: WorkflowRunResult<JsonValue> } | { error: unknown };
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Each outcome: its word as awf reports it and as the calling session is told it, its mark, as its
|
|
48
|
+
* last stage's would be, and its exit code, but for a cancellation's, which is its signal's. A
|
|
49
|
+
* stop's is apart from a failure's: the run can go on with `--continue`.
|
|
50
|
+
*/
|
|
51
|
+
export const OUTCOMES: Record<
|
|
52
|
+
AttemptOutcome,
|
|
53
|
+
{ word: string; told: string; mark: (paint: Paint) => string; exitCode?: number }
|
|
54
|
+
> = {
|
|
55
|
+
completed: { word: "completed", told: "completed", mark: (p) => p.ok("✓"), exitCode: 0 },
|
|
56
|
+
stopped: { word: "stopped", told: "stopped", mark: (p) => p.busy("■"), exitCode: 3 },
|
|
57
|
+
cancelled: { word: "cancelled", told: "was cancelled", mark: (p) => p.busy("■") },
|
|
58
|
+
"timed-out": { word: "timed out", told: "timed out", mark: (p) => p.bad("✗"), exitCode: 1 },
|
|
59
|
+
failed: { word: "failed", told: "failed", mark: (p) => p.bad("✗"), exitCode: 1 },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** The command that goes on from an attempt, given the stop that ended it and the stages it entered. */
|
|
63
|
+
export type GoOn = (stop: WorkflowStopped | undefined, entered: readonly StageSummary[]) => string;
|
|
64
|
+
|
|
65
|
+
/** The ending of a run that returned or threw. */
|
|
66
|
+
export function decideEnding(run: Finished, deadline: AbsoluteDeadline, goOn: GoOn): AttemptEnd {
|
|
67
|
+
if ("result" in run) {
|
|
68
|
+
const { result } = run;
|
|
69
|
+
return {
|
|
70
|
+
ending: { kind: "completed", value: result.value, stages: result.stages ?? [] },
|
|
71
|
+
settled: result,
|
|
72
|
+
exitCode: OUTCOMES.completed.exitCode!,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const { error } = run;
|
|
76
|
+
const settled = error instanceof WorkflowRunError ? error : undefined;
|
|
77
|
+
const { kind, stop, cancellation } = runOutcome(error, deadline);
|
|
78
|
+
const cause = unwrapped(error);
|
|
79
|
+
const beside =
|
|
80
|
+
stop && cause instanceof AggregateError
|
|
81
|
+
? cause.errors.slice(1).map(errorDetail).join("; ")
|
|
82
|
+
: "";
|
|
83
|
+
const stages = settled?.stages ?? [];
|
|
84
|
+
return {
|
|
85
|
+
ending: {
|
|
86
|
+
kind,
|
|
87
|
+
...(settled?.endedIn === undefined ? {} : { stage: settled.endedIn }),
|
|
88
|
+
// A stop's own, which the next attempt compares with its own stop's.
|
|
89
|
+
reason: stop?.reason ?? errorDetail(error),
|
|
90
|
+
stages,
|
|
91
|
+
continue: goOn(stop, stages),
|
|
92
|
+
},
|
|
93
|
+
...(settled ? { settled } : {}),
|
|
94
|
+
exitCode: cancellation ? signalExitCode(cancellation.reason) : OUTCOMES[kind].exitCode!,
|
|
95
|
+
...(beside ? { alsoFailed: beside } : {}),
|
|
96
|
+
...(stop instanceof FromStageUnreached ? { choose: stop.recorded } : {}),
|
|
97
|
+
...(stop?.needs ? { needs: stop.needs } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The ending of an attempt whose run never started, from what stopped it: it settled nothing, so
|
|
103
|
+
* what it cost is nothing but the time from `run.startedAt` to `run.endedAt`.
|
|
104
|
+
*/
|
|
105
|
+
export function endedBeforeStart(
|
|
106
|
+
error: unknown,
|
|
107
|
+
deadline: AbsoluteDeadline,
|
|
108
|
+
run: { runId: string; startedAt: number; endedAt: number },
|
|
109
|
+
goOn: GoOn,
|
|
110
|
+
): AttemptEnd & { ending: Exclude<Ending<JsonValue>, { kind: "completed" }> } {
|
|
111
|
+
const { kind, cancellation } = runOutcome(error, deadline);
|
|
112
|
+
const times = {
|
|
113
|
+
startedAt: new Date(run.startedAt).toISOString(),
|
|
114
|
+
finishedAt: new Date(run.endedAt).toISOString(),
|
|
115
|
+
};
|
|
116
|
+
return {
|
|
117
|
+
ending: {
|
|
118
|
+
kind,
|
|
119
|
+
reason: cancellation ? "cancelled before it started" : errorDetail(error),
|
|
120
|
+
stages: [],
|
|
121
|
+
continue: goOn(undefined, []),
|
|
122
|
+
},
|
|
123
|
+
settled: {
|
|
124
|
+
runId: run.runId,
|
|
125
|
+
usage: [],
|
|
126
|
+
...times,
|
|
127
|
+
accounting: summarizeRun([], PUBLISHED_PRICES, times, []),
|
|
128
|
+
},
|
|
129
|
+
exitCode: cancellation ? signalExitCode(cancellation.reason) : OUTCOMES[kind].exitCode!,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A completed attempt whose runtime then failed to clean up is failed: no record of it may say it
|
|
135
|
+
* completed. Any other ending stands, the cleanup's failure said beside it.
|
|
136
|
+
*/
|
|
137
|
+
export function cleanupFailed(end: AttemptEnd, error: unknown, goOn: string): AttemptEnd {
|
|
138
|
+
if (end.ending.kind !== "completed") return end;
|
|
139
|
+
return {
|
|
140
|
+
...end,
|
|
141
|
+
ending: {
|
|
142
|
+
kind: "failed",
|
|
143
|
+
reason: `runtime cleanup failed: ${messageOf(error)}`,
|
|
144
|
+
stages: end.ending.stages,
|
|
145
|
+
continue: goOn,
|
|
146
|
+
},
|
|
147
|
+
exitCode: OUTCOMES.failed.exitCode!,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* How a run that did not succeed ended, with the stop or the cancellation that decided it. The
|
|
153
|
+
* operator cancelling wins. It timed out when its own deadline ended it: the body's failure, or the
|
|
154
|
+
* first error of its aggregate, is a deadline error carrying the run's deadline. A deadline the
|
|
155
|
+
* workflow set and let escape is its own failure.
|
|
156
|
+
*/
|
|
157
|
+
export function runOutcome(
|
|
158
|
+
error: unknown,
|
|
159
|
+
deadline: AbsoluteDeadline,
|
|
160
|
+
): { kind: UnfinishedOutcome; stop?: WorkflowStopped; cancellation?: WorkflowCancelledError } {
|
|
161
|
+
const cancellation = findCancellation(error);
|
|
162
|
+
const failure = primaryFailure(unwrapped(error));
|
|
163
|
+
const stop = failure instanceof WorkflowStopped ? failure : undefined;
|
|
164
|
+
const timedOut =
|
|
165
|
+
failure instanceof DeadlineExceededError &&
|
|
166
|
+
failure.deadline.unixMilliseconds === deadline.unixMilliseconds;
|
|
167
|
+
const kind = cancellation ? "cancelled" : stop ? "stopped" : timedOut ? "timed-out" : "failed";
|
|
168
|
+
return { kind, ...(stop ? { stop } : {}), ...(cancellation ? { cancellation } : {}) };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** What the run threw, out of the wrapping that carries what it settled. */
|
|
172
|
+
function unwrapped(error: unknown): unknown {
|
|
173
|
+
return error instanceof WorkflowRunError ? error.cause : error;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function findCancellation(error: unknown): WorkflowCancelledError | undefined {
|
|
177
|
+
if (error instanceof WorkflowCancelledError) return error;
|
|
178
|
+
if (error instanceof WorkflowRunError) return findCancellation(error.cause);
|
|
179
|
+
if (!(error instanceof AggregateError)) return undefined;
|
|
180
|
+
for (const nested of error.errors) {
|
|
181
|
+
const cancellation = findCancellation(nested);
|
|
182
|
+
if (cancellation) return cancellation;
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function errorDetail(error: unknown): string {
|
|
188
|
+
if (error instanceof WorkflowRunError) return errorDetail(error.cause);
|
|
189
|
+
return error instanceof AggregateError
|
|
190
|
+
? error.errors.map(errorDetail).join("; ")
|
|
191
|
+
: messageOf(error);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A shell's code for a process ended by `reason`, as if the signal had killed it. */
|
|
195
|
+
export function signalExitCode(reason: unknown): number {
|
|
196
|
+
const number = constants.signals[reason as keyof typeof constants.signals];
|
|
197
|
+
return 128 + (number ?? constants.signals.SIGINT);
|
|
198
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import {
|
|
3
|
+
OUTPUT_RECORD_VERSION,
|
|
4
|
+
type OutputRecord,
|
|
5
|
+
RUN_EVENT_VERSIONS,
|
|
6
|
+
} from "@agentswf/contract/records";
|
|
7
|
+
import type { Ending, ExecutableWorkflow, JsonValue } from "@agentswf/contract/workflow";
|
|
8
|
+
import { attemptAccounting } from "./accounting/summary";
|
|
9
|
+
import type { AttemptEnd } from "./attempt-ending";
|
|
10
|
+
import { messageOf } from "./errors";
|
|
11
|
+
import { writeJson, writeWhole } from "./files";
|
|
12
|
+
import { type Attempt, appendEvent, endAttempt, outputFile, reportFile } from "./runs";
|
|
13
|
+
import { withoutValue } from "./stage-ledger";
|
|
14
|
+
|
|
15
|
+
/** Where an attempt's records went, and its output record as `--json` prints it. */
|
|
16
|
+
export type Kept = {
|
|
17
|
+
/** Absent when the run never settled, so there is nothing to record but its ending. */
|
|
18
|
+
json?: string;
|
|
19
|
+
/** output.json, once written. */
|
|
20
|
+
output?: string;
|
|
21
|
+
report?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Writes an attempt's records, each from its one ending: report.md, output.json, then the ending in
|
|
26
|
+
* its attempt file. The ending goes last: an ended attempt lets the next start, whose report and
|
|
27
|
+
* output this one's must not overwrite. A record that fails to write is said, and costs no other.
|
|
28
|
+
*/
|
|
29
|
+
export async function keepRecords(
|
|
30
|
+
end: AttemptEnd,
|
|
31
|
+
context: {
|
|
32
|
+
attempt: Attempt;
|
|
33
|
+
executable: ExecutableWorkflow<JsonValue, JsonValue>;
|
|
34
|
+
workflow: { name: string; file: string };
|
|
35
|
+
stderr: (text: string) => void;
|
|
36
|
+
},
|
|
37
|
+
): Promise<Kept> {
|
|
38
|
+
const { attempt, stderr } = context;
|
|
39
|
+
const { ending, settled } = end;
|
|
40
|
+
const dir = attempt.run.dir;
|
|
41
|
+
const report = await writeReport(context.executable, ending, dir, stderr);
|
|
42
|
+
const stages = settled?.stages?.map(withoutValue);
|
|
43
|
+
const at =
|
|
44
|
+
ending.kind !== "completed" && ending.stage !== undefined ? { stage: ending.stage } : {};
|
|
45
|
+
const ended =
|
|
46
|
+
ending.kind === "completed"
|
|
47
|
+
? { outcome: "completed" as const, value: ending.value }
|
|
48
|
+
: {
|
|
49
|
+
outcome: ending.kind,
|
|
50
|
+
reason: ending.reason,
|
|
51
|
+
...at,
|
|
52
|
+
...(end.needs ? { needs: [...end.needs] } : {}),
|
|
53
|
+
};
|
|
54
|
+
const kept: Kept = report ? { report } : {};
|
|
55
|
+
const output = outputFile(dir);
|
|
56
|
+
if (!settled) {
|
|
57
|
+
// An earlier attempt's output is gone with it, as its report is.
|
|
58
|
+
await rm(output, { force: true }).catch((error) =>
|
|
59
|
+
stderr(`awf: output.json: ${messageOf(error)}`),
|
|
60
|
+
);
|
|
61
|
+
} else {
|
|
62
|
+
const record: OutputRecord = {
|
|
63
|
+
version: OUTPUT_RECORD_VERSION,
|
|
64
|
+
runId: settled.runId,
|
|
65
|
+
attempt: attempt.record.attempt,
|
|
66
|
+
workflow: context.workflow,
|
|
67
|
+
accounting: settled.accounting,
|
|
68
|
+
usage: settled.usage,
|
|
69
|
+
artifacts: dir,
|
|
70
|
+
...(settled.sandboxes ? { sandboxes: settled.sandboxes } : {}),
|
|
71
|
+
...(settled.skills ? { skills: settled.skills } : {}),
|
|
72
|
+
...(settled.panes ? { panes: settled.panes } : {}),
|
|
73
|
+
...(settled.decisions ? { decisions: settled.decisions } : {}),
|
|
74
|
+
...(stages ? { stages } : {}),
|
|
75
|
+
...(report ? { report } : {}),
|
|
76
|
+
...ended,
|
|
77
|
+
};
|
|
78
|
+
kept.json = JSON.stringify(record, null, 2);
|
|
79
|
+
try {
|
|
80
|
+
await writeJson(output, record);
|
|
81
|
+
kept.output = output;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
stderr(`awf: output.json: ${messageOf(error)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Before the ending: once the attempt has ended, the next may start appending.
|
|
87
|
+
await appendEvent(dir, {
|
|
88
|
+
version: RUN_EVENT_VERSIONS["attempt-ended"],
|
|
89
|
+
attempt: attempt.record.attempt,
|
|
90
|
+
at: new Date().toISOString(),
|
|
91
|
+
kind: "attempt-ended",
|
|
92
|
+
outcome: ending.kind,
|
|
93
|
+
}).catch((error) => stderr(`awf: the run's event log was not written: ${messageOf(error)}`));
|
|
94
|
+
try {
|
|
95
|
+
await endAttempt(attempt, {
|
|
96
|
+
outcome: ending.kind,
|
|
97
|
+
...(ending.kind === "completed" ? {} : { reason: ending.reason }),
|
|
98
|
+
...at,
|
|
99
|
+
...(stages ? { stages } : {}),
|
|
100
|
+
...(settled ? { accounting: attemptAccounting(settled.accounting) } : {}),
|
|
101
|
+
});
|
|
102
|
+
} catch (error) {
|
|
103
|
+
stderr(`awf: the attempt's ending was not written to ${attempt.file}: ${messageOf(error)}`);
|
|
104
|
+
}
|
|
105
|
+
return kept;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function writeReport(
|
|
109
|
+
executable: ExecutableWorkflow<JsonValue, JsonValue>,
|
|
110
|
+
ending: Ending<JsonValue>,
|
|
111
|
+
dir: string,
|
|
112
|
+
stderr: (text: string) => void,
|
|
113
|
+
): Promise<string | undefined> {
|
|
114
|
+
const file = reportFile(dir);
|
|
115
|
+
// An earlier attempt's report is gone with it: report.md is the last ended attempt's.
|
|
116
|
+
const none = () => rm(file, { force: true }).then(() => undefined);
|
|
117
|
+
if (!executable.report) return none();
|
|
118
|
+
// The result is still in output.json; a report that cannot be rendered or saved should not fail
|
|
119
|
+
// the run, nor cost it its record.
|
|
120
|
+
try {
|
|
121
|
+
const markdown = executable.report(
|
|
122
|
+
ending.kind === "completed" ? ending.value : undefined,
|
|
123
|
+
ending,
|
|
124
|
+
);
|
|
125
|
+
if (markdown === undefined) return await none();
|
|
126
|
+
await writeWhole(file, `${markdown.trimEnd()}\n`);
|
|
127
|
+
return file;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
stderr(`awf: report: ${messageOf(error)}; see output.json instead`);
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentPaneRecord,
|
|
3
|
+
AttemptRecord,
|
|
4
|
+
RunAccounting,
|
|
5
|
+
StageNeed,
|
|
6
|
+
StageRecord,
|
|
7
|
+
} from "@agentswf/contract/records";
|
|
8
|
+
import type {
|
|
9
|
+
Ending,
|
|
10
|
+
ExecutableWorkflow,
|
|
11
|
+
JsonValue,
|
|
12
|
+
StageSummary,
|
|
13
|
+
} from "@agentswf/contract/workflow";
|
|
14
|
+
import { ago, describeAccounting, describeAttempts } from "./accounting/format";
|
|
15
|
+
import { sumAttempts } from "./accounting/summary";
|
|
16
|
+
import { type AttemptEnd, OUTCOMES } from "./attempt-ending";
|
|
17
|
+
import type { Kept } from "./attempt-output";
|
|
18
|
+
import { pathFrom } from "./display-path";
|
|
19
|
+
import { messageOf } from "./errors";
|
|
20
|
+
import { ANSI, type Paint, PLAIN } from "./progress-view";
|
|
21
|
+
import type { RunCommand } from "./run-command";
|
|
22
|
+
import type { AttemptRefusal } from "./runs";
|
|
23
|
+
import { schemaLines } from "./stage-schema";
|
|
24
|
+
import { FromStageUnreached, type WorkflowStopped } from "./stopped";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Prints how the attempt ended, and returns its exit code. A completed one's result goes to stdout;
|
|
28
|
+
* otherwise stdout stays empty but for the record `--json` asks for, since a caller reading it
|
|
29
|
+
* without checking the exit code would take it for a result.
|
|
30
|
+
*/
|
|
31
|
+
export function printEnding(
|
|
32
|
+
end: AttemptEnd,
|
|
33
|
+
kept: Kept,
|
|
34
|
+
context: {
|
|
35
|
+
executable: ExecutableWorkflow<JsonValue, JsonValue>;
|
|
36
|
+
/** The run's id and folder. */
|
|
37
|
+
run: { id: string; dir: string };
|
|
38
|
+
n: number;
|
|
39
|
+
earlier: readonly AttemptRecord[];
|
|
40
|
+
json: boolean;
|
|
41
|
+
/** Whether the attempt redid from a stage, which no repeated stop then hints at. */
|
|
42
|
+
fromStage: boolean;
|
|
43
|
+
now: number;
|
|
44
|
+
shellCwd: string;
|
|
45
|
+
home: string;
|
|
46
|
+
cleanupAlso?: string;
|
|
47
|
+
/** Given when the stages and their cost were drawn on a terminal, not written as a log. */
|
|
48
|
+
terminal?: { color: boolean };
|
|
49
|
+
stdout: (text: string) => void;
|
|
50
|
+
stderr: (text: string) => void;
|
|
51
|
+
/** For what is said beside a problem, not one. */
|
|
52
|
+
notice: (text: string) => void;
|
|
53
|
+
},
|
|
54
|
+
): number {
|
|
55
|
+
const { stdout, stderr, shellCwd } = context;
|
|
56
|
+
const { ending, settled } = end;
|
|
57
|
+
const presented =
|
|
58
|
+
ending.kind === "completed" && !context.json
|
|
59
|
+
? present(context.executable, ending, stderr)
|
|
60
|
+
: undefined;
|
|
61
|
+
if (end.alsoFailed) stderr(`awf: also failed: ${end.alsoFailed}`);
|
|
62
|
+
const again =
|
|
63
|
+
ending.kind !== "completed" && !context.fromStage
|
|
64
|
+
? sameStop(ending, context.earlier)
|
|
65
|
+
: undefined;
|
|
66
|
+
if (again) context.notice(again);
|
|
67
|
+
if (context.cleanupAlso) stderr(`awf: runtime cleanup also failed: ${context.cleanupAlso}`);
|
|
68
|
+
// Beside the result rather than in it: stdout stays the workflow's report or the JSON.
|
|
69
|
+
const lines = describeEnding(ending, {
|
|
70
|
+
named: `${context.executable.definition.meta.name} ${context.run.id}`,
|
|
71
|
+
n: context.n,
|
|
72
|
+
...(settled ? { accounting: settled.accounting } : {}),
|
|
73
|
+
earlier: context.earlier,
|
|
74
|
+
stages: context.terminal === undefined,
|
|
75
|
+
records: pathFrom(shellCwd, context.run.dir, { home: context.home }),
|
|
76
|
+
...(kept.report ? { report: pathFrom(shellCwd, kept.report, { home: context.home }) } : {}),
|
|
77
|
+
...(end.choose?.length ? { choose: listRecorded(end.choose, context.now) } : {}),
|
|
78
|
+
...(end.needs ? { needs: end.needs } : {}),
|
|
79
|
+
...(settled?.panes ? { panes: settled.panes } : {}),
|
|
80
|
+
// As the run's Herdr workspace is labelled.
|
|
81
|
+
runWorkspace: `awf ${context.executable.definition.meta.name} ${context.run.id} #${context.n}`,
|
|
82
|
+
paint: context.terminal?.color ? ANSI : PLAIN,
|
|
83
|
+
});
|
|
84
|
+
for (const line of ["", ...lines]) stderr(line);
|
|
85
|
+
if (ending.kind === "completed") stdout(presented ?? kept.json ?? JSON.stringify(ending.value));
|
|
86
|
+
else if (context.json && kept.json !== undefined) stdout(kept.json);
|
|
87
|
+
return end.exitCode;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** What the calling session is told of how the run ended, and where its record is. */
|
|
91
|
+
export function toldOf(end: AttemptEnd, kept: Kept): string {
|
|
92
|
+
const { ending } = end;
|
|
93
|
+
const told = `${OUTCOMES[ending.kind].told}${kept.output ? `; its record is ${kept.output}` : ""}`;
|
|
94
|
+
return ending.kind === "completed" ? told : `${told}: ${ending.reason}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* How an attempt ended, once, after its stages: the outcome, where and why; the run and what the
|
|
99
|
+
* attempt cost, and for a later attempt the whole run, counting the attempts whose cost is
|
|
100
|
+
* unknown; then the value it stopped for, the command that goes on, or the stages to choose from,
|
|
101
|
+
* and where the records are. `stages` lists what each stage cost, where no view beside them did.
|
|
102
|
+
*/
|
|
103
|
+
function describeEnding(
|
|
104
|
+
ending: Ending<JsonValue>,
|
|
105
|
+
context: {
|
|
106
|
+
/** The workflow's name and the run's id. */
|
|
107
|
+
named: string;
|
|
108
|
+
n: number;
|
|
109
|
+
accounting?: RunAccounting;
|
|
110
|
+
earlier: readonly AttemptRecord[];
|
|
111
|
+
stages: boolean;
|
|
112
|
+
records: string;
|
|
113
|
+
report?: string;
|
|
114
|
+
/** The recorded stages, a line each, one of which the go-on's `{stage}` needs. */
|
|
115
|
+
choose?: readonly string[];
|
|
116
|
+
/** The stages whose values `--values` gives, and their schemas. */
|
|
117
|
+
needs?: readonly StageNeed[];
|
|
118
|
+
panes?: readonly AgentPaneRecord[];
|
|
119
|
+
runWorkspace: string;
|
|
120
|
+
paint: Paint;
|
|
121
|
+
},
|
|
122
|
+
): string[] {
|
|
123
|
+
const { accounting, n } = context;
|
|
124
|
+
const mark = OUTCOMES[ending.kind].mark(context.paint);
|
|
125
|
+
const [totals, ...figures] = accounting
|
|
126
|
+
? describeAccounting(accounting, { stages: context.stages })
|
|
127
|
+
: [];
|
|
128
|
+
const run = [
|
|
129
|
+
context.named,
|
|
130
|
+
...(n > 1 ? [`attempt ${n}`] : []),
|
|
131
|
+
...(totals === undefined ? [] : [totals]),
|
|
132
|
+
...(n > 1 && accounting ? [runTotal(n, accounting, context.earlier)] : []),
|
|
133
|
+
].join(" · ");
|
|
134
|
+
const word = OUTCOMES[ending.kind].word;
|
|
135
|
+
if (ending.kind === "completed") {
|
|
136
|
+
return [`${mark} ${word} · ${run}`, ...figures, ...rows([], context)];
|
|
137
|
+
}
|
|
138
|
+
// A reason's later lines, such as a schema's problems, sit under it, apart from the rows.
|
|
139
|
+
const [first, ...more] = ending.reason.split("\n");
|
|
140
|
+
const goOn = context.choose
|
|
141
|
+
? [ending.continue, `${STAGE} one of:`, ...context.choose]
|
|
142
|
+
: [ending.continue];
|
|
143
|
+
return [
|
|
144
|
+
`${mark} ${word}${ending.stage === undefined ? "" : ` in ${ending.stage}`}: ${first}`,
|
|
145
|
+
...more.map((line) => ` ${line.trim()}`),
|
|
146
|
+
` ${run}`,
|
|
147
|
+
...figures,
|
|
148
|
+
...rows(goOn, context),
|
|
149
|
+
];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The rows under an ending: the value it stopped for, the command that goes on, its lines given,
|
|
154
|
+
* the report, the records.
|
|
155
|
+
*/
|
|
156
|
+
function rows(
|
|
157
|
+
goOn: readonly string[],
|
|
158
|
+
context: {
|
|
159
|
+
records: string;
|
|
160
|
+
report?: string;
|
|
161
|
+
needs?: readonly StageNeed[];
|
|
162
|
+
panes?: readonly AgentPaneRecord[];
|
|
163
|
+
runWorkspace: string;
|
|
164
|
+
},
|
|
165
|
+
): string[] {
|
|
166
|
+
const row = (key: string, value: string) => ` ${key.padEnd(7)} ${value}`;
|
|
167
|
+
const under = (line: string) => `${" ".repeat(11)}${line}`;
|
|
168
|
+
const [command, ...choices] = goOn;
|
|
169
|
+
// A stage a line, and a schema with branches a line per branch under it.
|
|
170
|
+
const [first, ...needs] = (context.needs ?? []).flatMap(({ stage, schema }) => {
|
|
171
|
+
const [only, ...more] = schemaLines(schema);
|
|
172
|
+
return more.length === 0
|
|
173
|
+
? [`${stage}: ${only}`]
|
|
174
|
+
: [`${stage}, one of:`, ...[only!, ...more].map((line) => ` ${line}`)];
|
|
175
|
+
});
|
|
176
|
+
return [
|
|
177
|
+
...(first === undefined ? [] : [row("needs", first), ...needs.map(under)]),
|
|
178
|
+
...(command === undefined ? [] : [row("go on", command)]),
|
|
179
|
+
...choices.map(under),
|
|
180
|
+
...paneRows(context.panes ?? [], context.runWorkspace).map(([key, value]) => row(key, value)),
|
|
181
|
+
...(context.report ? [row("report", context.report)] : []),
|
|
182
|
+
row("records", context.records),
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Each pane that fell back from its layout, and why, and each kept one, with where to find it:
|
|
188
|
+
* once each, as nothing else says them.
|
|
189
|
+
*/
|
|
190
|
+
export function paneRows(
|
|
191
|
+
panes: readonly AgentPaneRecord[],
|
|
192
|
+
/** The run's own workspace's label. */
|
|
193
|
+
runWorkspace: string,
|
|
194
|
+
): [string, string][] {
|
|
195
|
+
return panes.flatMap(({ agent, placed }): [string, string][] => [
|
|
196
|
+
...(placed.fallback === undefined
|
|
197
|
+
? []
|
|
198
|
+
: [["pane", `${agent} in a tab of its own: ${placed.fallback}`] as [string, string]]),
|
|
199
|
+
...(placed.notKept === undefined
|
|
200
|
+
? []
|
|
201
|
+
: [["pane", `${agent} closed, not kept: ${placed.notKept}`] as [string, string]]),
|
|
202
|
+
...(placed.kept
|
|
203
|
+
? [["kept", `${agent} · ${whereKept(placed, runWorkspace)}`] as [string, string]]
|
|
204
|
+
: []),
|
|
205
|
+
]);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Sessions awf starts, headless, and an operator attaches to from a terminal outside Herdr. */
|
|
209
|
+
const OWN_SESSION = /^awf(-|$)/;
|
|
210
|
+
|
|
211
|
+
function whereKept(
|
|
212
|
+
{ session, workspace, tab, beside }: AgentPaneRecord["placed"],
|
|
213
|
+
runWorkspace: string,
|
|
214
|
+
): string {
|
|
215
|
+
const pane = beside === undefined ? `tab "${tab}"` : `beside ${beside}`;
|
|
216
|
+
if (workspace === "origin") return `${pane}, where awf run was typed`;
|
|
217
|
+
const label = workspace === "run" ? runWorkspace : workspace.name;
|
|
218
|
+
return OWN_SESSION.test(session)
|
|
219
|
+
? `${pane} in workspace "${label}" · herdr session attach ${session}`
|
|
220
|
+
: `${pane} in workspace "${label}" of herdr session ${session}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function runTotal(n: number, current: RunAccounting, earlier: readonly AttemptRecord[]): string {
|
|
224
|
+
const before = earlier.filter((record) => record.attempt < n);
|
|
225
|
+
const recorded = [...before.flatMap((record) => record.accounting ?? []), current];
|
|
226
|
+
const unknown = before.filter((record) => record.accounting === undefined);
|
|
227
|
+
const interrupted = unknown.filter((record) => record.ended === undefined).length;
|
|
228
|
+
return describeAttempts(sumAttempts(recorded), n, {
|
|
229
|
+
interrupted,
|
|
230
|
+
ended: unknown.length - interrupted,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** A completed attempt's value as the workflow presents it; undefined to print the JSON. */
|
|
235
|
+
function present(
|
|
236
|
+
executable: ExecutableWorkflow<JsonValue, JsonValue>,
|
|
237
|
+
ending: Extract<Ending<JsonValue>, { kind: "completed" }>,
|
|
238
|
+
stderr: (text: string) => void,
|
|
239
|
+
): string | undefined {
|
|
240
|
+
if (!executable.present) return undefined;
|
|
241
|
+
try {
|
|
242
|
+
return executable.present(ending.value, ending)?.trimEnd();
|
|
243
|
+
} catch (error) {
|
|
244
|
+
stderr(`awf: present: ${messageOf(error)}; printing the full result instead`);
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* A plain continue that ran no stage and stopped between stages as the attempt before did: the
|
|
251
|
+
* check on a reused stage's value belongs inside that stage, which a continue would redo.
|
|
252
|
+
*/
|
|
253
|
+
function sameStop(
|
|
254
|
+
ending: Ending<JsonValue>,
|
|
255
|
+
earlier: readonly AttemptRecord[],
|
|
256
|
+
): string | undefined {
|
|
257
|
+
if (ending.kind !== "stopped" || ending.stage !== undefined) return undefined;
|
|
258
|
+
const reused = ending.stages.at(-1)?.stage;
|
|
259
|
+
// No stage ran, and one was reused: its value is what the check between stages saw.
|
|
260
|
+
if (reused === undefined || ending.stages.some((entered) => entered.source === "ran")) {
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
const before = earlier.at(-1);
|
|
264
|
+
if (
|
|
265
|
+
before?.outcome !== "stopped" ||
|
|
266
|
+
before.stage !== undefined ||
|
|
267
|
+
before.reason !== ending.reason
|
|
268
|
+
) {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
return `awf: the same stop as attempt ${before.attempt}; if a stage's value caused it, --from-stage ${reused}, and move the check into that stage`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Why a run takes no attempt now, as awf says it, at time `now`. */
|
|
275
|
+
export function refusalMessage(id: string, refusal: AttemptRefusal, now: number): string {
|
|
276
|
+
if (refusal.kind === "running") {
|
|
277
|
+
return `attempt ${refusal.attempt.attempt} of ${id} is still running, as process ${refusal.attempt.pid}`;
|
|
278
|
+
}
|
|
279
|
+
if (refusal.stages.length === 0) return `${id} completed; there is nothing to continue`;
|
|
280
|
+
return `${id} completed; to redo from a stage, --from-stage one of:\n${listRecorded(refusal.stages, now).join("\n")}`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Stage records, a line each, in the order given: summary, attempt, and how long ago. */
|
|
284
|
+
function listRecorded(records: readonly StageRecord[], now: number): string[] {
|
|
285
|
+
const name = Math.max(0, ...records.map((record) => record.stage.length));
|
|
286
|
+
const summary = Math.max(0, ...records.map((record) => record.summary?.length ?? 0));
|
|
287
|
+
return records.map((record) =>
|
|
288
|
+
[
|
|
289
|
+
` ${record.stage.padEnd(name)}`,
|
|
290
|
+
...(summary > 0 ? [(record.summary ?? "").padEnd(summary)] : []),
|
|
291
|
+
[
|
|
292
|
+
...(record.outcome === "succeeded" ? [] : [record.outcome]),
|
|
293
|
+
`attempt ${record.attempt}`,
|
|
294
|
+
ago(now - Date.parse(record.ended)),
|
|
295
|
+
].join(" · "),
|
|
296
|
+
].join(" "),
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The command that goes on from an attempt that didn't complete: the same file and run root, and
|
|
302
|
+
* `--from-stage` when a plain continue would stop at the same record again, or would drop the redo
|
|
303
|
+
* this attempt asked for and never reached. A `--from-stage` the body returned without reaching
|
|
304
|
+
* has no one command: it names `{stage}`, for one of the run's stages, and isn't runnable as is.
|
|
305
|
+
* Going on from that start point repeats `--values`, or names `{file}` for a value it stopped for.
|
|
306
|
+
*/
|
|
307
|
+
export function continueCommand(
|
|
308
|
+
command: Pick<
|
|
309
|
+
RunCommand,
|
|
310
|
+
| "shellCwd"
|
|
311
|
+
| "cwd"
|
|
312
|
+
| "cwdGiven"
|
|
313
|
+
| "runRoot"
|
|
314
|
+
| "runRootGiven"
|
|
315
|
+
| "workflowFile"
|
|
316
|
+
| "fromStage"
|
|
317
|
+
| "values"
|
|
318
|
+
>,
|
|
319
|
+
id: string,
|
|
320
|
+
stop: WorkflowStopped | undefined,
|
|
321
|
+
entered: readonly Pick<StageSummary, "stage">[],
|
|
322
|
+
): string {
|
|
323
|
+
const { fromStage } = command;
|
|
324
|
+
const redo =
|
|
325
|
+
stop instanceof FromStageUnreached
|
|
326
|
+
? STAGE
|
|
327
|
+
: stop?.redo && stop.stage !== undefined
|
|
328
|
+
? stop.stage
|
|
329
|
+
: fromStage !== undefined && !entered.some(({ stage }) => stage === fromStage)
|
|
330
|
+
? fromStage
|
|
331
|
+
: undefined;
|
|
332
|
+
const values =
|
|
333
|
+
redo === undefined || redo !== fromStage
|
|
334
|
+
? undefined
|
|
335
|
+
: command.values
|
|
336
|
+
? pathFrom(command.shellCwd, command.values.file)
|
|
337
|
+
: stop?.needs
|
|
338
|
+
? FILE
|
|
339
|
+
: undefined;
|
|
340
|
+
return [
|
|
341
|
+
"awf",
|
|
342
|
+
"run",
|
|
343
|
+
...(command.runRootGiven ? ["--run-root", command.runRoot] : []),
|
|
344
|
+
...(command.cwdGiven ? ["--cwd", command.cwd] : []),
|
|
345
|
+
pathFrom(command.shellCwd, command.workflowFile),
|
|
346
|
+
"--continue",
|
|
347
|
+
id,
|
|
348
|
+
...(redo === undefined ? [] : ["--from-stage", redo]),
|
|
349
|
+
...(values === undefined ? [] : ["--values", values]),
|
|
350
|
+
]
|
|
351
|
+
.map((word) => (word === STAGE || word === FILE ? word : shellWord(word)))
|
|
352
|
+
.join(" ");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The placeholder a go-on names when the operator chooses the stage. */
|
|
356
|
+
const STAGE = "{stage}";
|
|
357
|
+
/** The placeholder for the file of values a go-on needs and none was given. */
|
|
358
|
+
const FILE = "{file}";
|
|
359
|
+
|
|
360
|
+
export function shellWord(word: string): string {
|
|
361
|
+
return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`;
|
|
362
|
+
}
|