@agentswf/engine 0.1.0-beta.4 → 0.1.0-beta.5
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 +4 -4
- package/src/agent-cli/cli.ts +57 -17
- package/src/attempt-ending.ts +2 -4
- package/src/attempt-output.ts +2 -1
- package/src/attempt-view.ts +1 -1
- package/src/control-plane.ts +37 -5
- package/src/coordinator-socket.ts +15 -6
- package/src/deadlines.ts +69 -2
- package/src/decisions/directory.ts +9 -11
- package/src/deprecations.ts +177 -0
- package/src/duration.ts +50 -20
- package/src/emit-command.ts +55 -41
- package/src/emit.ts +124 -15
- package/src/help/emit.txt +12 -4
- package/src/help/inbox.txt +2 -2
- package/src/help/logs.txt +5 -4
- package/src/help/reply.txt +5 -5
- package/src/help/run.txt +21 -7
- package/src/herdr-run-session.ts +2 -2
- package/src/holder-command.ts +36 -31
- package/src/inbox-command.ts +7 -10
- package/src/init-template/hello/workflow.test.ts.template +3 -3
- package/src/init-template/hello/workflow.ts.template +2 -2
- package/src/keep-alive.ts +28 -13
- package/src/logs-command.ts +12 -12
- package/src/mailbox.ts +2 -20
- package/src/messaging/limits.ts +4 -4
- package/src/messaging/messaging.ts +26 -21
- package/src/messaging/routes.ts +1 -1
- package/src/messaging/text.ts +7 -5
- package/src/operator-cli.ts +23 -9
- package/src/operator-runtime.ts +5 -5
- package/src/pane-layout.ts +125 -32
- package/src/progress-view.ts +8 -8
- package/src/run-command.ts +25 -4
- package/src/run-commands.ts +1 -1
- package/src/run-prepare.ts +46 -8
- package/src/run-usage.ts +1 -1
- package/src/run-view.ts +9 -4
- package/src/runs-command.ts +12 -10
- package/src/runs.ts +44 -8
- package/src/sources.ts +42 -6
- package/src/stage-ledger.ts +42 -12
- package/src/stage-plan.ts +37 -1
- package/src/test-command.ts +7 -3
- package/src/testing.ts +7 -8
- package/src/tools.ts +46 -18
- package/src/workflow-loader.ts +5 -2
- package/src/workflow-runner.ts +553 -235
- package/src/workflow-testing/coordinator.typecheck.ts +7 -7
- package/src/workflow-testing/coordinators.ts +2 -2
- package/src/workflow-testing/host.ts +107 -38
- package/src/workflow-testing/index.ts +45 -836
- package/src/workflow-testing/run-test.ts +1213 -0
- package/src/workflow-testing/script.ts +102 -41
- package/src/workflow-testing/workflow-testing.typecheck.ts +11 -8
package/src/run-view.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import type {
|
|
3
3
|
Activity,
|
|
4
|
+
AgentStateJson,
|
|
4
5
|
AttemptRecord,
|
|
5
6
|
OperationLivenessRecord,
|
|
6
7
|
RunLogLine,
|
|
8
|
+
StageStateJson,
|
|
7
9
|
} from "@agentswf/contract/records";
|
|
8
|
-
import type { AgentKey, HarnessKind
|
|
10
|
+
import type { AgentKey, HarnessKind } from "@agentswf/contract/workflow";
|
|
9
11
|
import { readLines } from "./jsonl";
|
|
10
12
|
import { currentStage, estimateOf } from "./run-commands";
|
|
11
13
|
import { type Cursor, type FileCache, readRun, timeOf } from "./run-read";
|
|
@@ -14,7 +16,7 @@ import type { RunStatus } from "./run-state";
|
|
|
14
16
|
/** A stage of the attempt shown: running, reused or given from an earlier one, or how it ended. */
|
|
15
17
|
export type StageView = {
|
|
16
18
|
stage: string;
|
|
17
|
-
state:
|
|
19
|
+
state: StageStateJson;
|
|
18
20
|
startedAt?: number;
|
|
19
21
|
endedAt?: number;
|
|
20
22
|
};
|
|
@@ -29,7 +31,7 @@ export type AgentView = {
|
|
|
29
31
|
caller: boolean;
|
|
30
32
|
/** The stage its last operation ran in. */
|
|
31
33
|
stage?: string;
|
|
32
|
-
state:
|
|
34
|
+
state: AgentStateJson;
|
|
33
35
|
turns: number;
|
|
34
36
|
/** When its current operation, or its last, started. */
|
|
35
37
|
since?: number;
|
|
@@ -137,7 +139,7 @@ export async function viewRun(status: RunStatus): Promise<RunView> {
|
|
|
137
139
|
const stage = currentStage(status, read.log.lines);
|
|
138
140
|
// A run whose process is gone left what it was doing undone.
|
|
139
141
|
if (status.state === "interrupted") {
|
|
140
|
-
for (const each of agents.values()) if (each.state === "working") each.state = "
|
|
142
|
+
for (const each of agents.values()) if (each.state === "working") each.state = "interrupted";
|
|
141
143
|
for (const each of stages) if (each.state === "running") each.state = "interrupted";
|
|
142
144
|
}
|
|
143
145
|
if (status.state === "running") {
|
|
@@ -153,6 +155,9 @@ export async function viewRun(status: RunStatus): Promise<RunView> {
|
|
|
153
155
|
} else if (phase?.kind === "awaiting-reply" && phase.peer !== undefined) {
|
|
154
156
|
agent(key).live = { phase: "awaiting-reply", peer: phase.peer, ...until };
|
|
155
157
|
}
|
|
158
|
+
// One word with `wfa peers`: the phase is the state; `live` keeps its detail.
|
|
159
|
+
const { live } = agent(key);
|
|
160
|
+
if (live && agent(key).state === "working") agent(key).state = live.phase;
|
|
156
161
|
}
|
|
157
162
|
}
|
|
158
163
|
const since = attempt ? Date.parse(attempt.started) : Number.NEGATIVE_INFINITY;
|
package/src/runs-command.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import type { RunJson, RunsJson } from "@agentswf/contract/records";
|
|
3
|
-
import {
|
|
3
|
+
import { formatDuration } from "./duration";
|
|
4
4
|
import { messageOf } from "./errors";
|
|
5
5
|
import help from "./help/runs.txt" with { type: "text" };
|
|
6
6
|
import {
|
|
@@ -133,7 +133,7 @@ async function list(place: ReadPlace, limit: number, json: boolean, out: Out, no
|
|
|
133
133
|
status.ref.padEnd(width),
|
|
134
134
|
states[index]!.padEnd(stateWidth),
|
|
135
135
|
dayTime(started).padEnd(13),
|
|
136
|
-
|
|
136
|
+
formatDuration(ms).padEnd(8),
|
|
137
137
|
money(estimate)
|
|
138
138
|
.join("")
|
|
139
139
|
.concat(waiting ? " " : ""),
|
|
@@ -162,13 +162,15 @@ function dayTime(at: number): string {
|
|
|
162
162
|
return `${two(date.getMonth() + 1)}-${two(date.getDate())} ${clockTime(at).slice(0, 5)}`;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
const
|
|
165
|
+
const inTurn = (agent: AgentView) =>
|
|
166
|
+
agent.state === "working" || agent.state === "waiting" || agent.state === "awaiting-reply";
|
|
167
|
+
const busy = (agent: AgentView) => inTurn(agent) || agent.state === "interrupted";
|
|
166
168
|
|
|
167
169
|
function agentLine(agent: AgentView, indent: string, now: number, width: number): string {
|
|
168
170
|
const { live } = agent;
|
|
169
171
|
const parts = [
|
|
170
172
|
said(agent.key, 60).padEnd(width),
|
|
171
|
-
|
|
173
|
+
agent.state.padEnd(14),
|
|
172
174
|
busy(agent) ? `turn ${agent.turns + 1}` : `${agent.turns} turn${agent.turns === 1 ? "" : "s"}`,
|
|
173
175
|
...(live?.phase === "waiting"
|
|
174
176
|
? [
|
|
@@ -181,10 +183,10 @@ function agentLine(agent: AgentView, indent: string, now: number, width: number)
|
|
|
181
183
|
]
|
|
182
184
|
: live
|
|
183
185
|
? [
|
|
184
|
-
|
|
186
|
+
`from ${said(live.peer, 60)}${live.until ? ` (until ${clockTime(live.until).slice(0, 5)})` : ""}`,
|
|
185
187
|
]
|
|
186
188
|
: []),
|
|
187
|
-
...(agent.since && agent
|
|
189
|
+
...(agent.since && inTurn(agent) ? [formatDuration(now - agent.since)] : []),
|
|
188
190
|
...(agent.last
|
|
189
191
|
? [`last: ${describeActivity(agent.last.activity)} (${clockTime(agent.last.at)})`]
|
|
190
192
|
: []),
|
|
@@ -236,13 +238,13 @@ function describe(view: RunView, place: ReadPlace, now: number): string {
|
|
|
236
238
|
...(view.stage ? [`stage ${view.stage}`] : []),
|
|
237
239
|
status.state === "interrupted" && view.lastEventAt
|
|
238
240
|
? `last event ${clockTime(view.lastEventAt)}`
|
|
239
|
-
:
|
|
241
|
+
: formatDuration(span(status, now).ms),
|
|
240
242
|
...money(view.estimate),
|
|
241
243
|
].join(" "),
|
|
242
244
|
];
|
|
243
245
|
for (const earlier of status.attempts.slice(0, -1)) {
|
|
244
246
|
const took = earlier.ended
|
|
245
|
-
? ` ${
|
|
247
|
+
? ` ${formatDuration(Date.parse(earlier.ended) - Date.parse(earlier.started))}`
|
|
246
248
|
: "";
|
|
247
249
|
const how = `${earlier.outcome ?? "interrupted"}${earlier.stage ? ` in ${earlier.stage}` : ""}`;
|
|
248
250
|
lines.push(` attempt ${earlier.attempt} ${how}${took}`);
|
|
@@ -254,7 +256,7 @@ function describe(view: RunView, place: ReadPlace, now: number): string {
|
|
|
254
256
|
const took =
|
|
255
257
|
stage.state === "reused" || stage.state === "provided" || stage.startedAt === undefined
|
|
256
258
|
? ""
|
|
257
|
-
:
|
|
259
|
+
: formatDuration((stage.endedAt ?? now) - stage.startedAt);
|
|
258
260
|
lines.push(
|
|
259
261
|
`${indent} stage ${stage.stage.padEnd(8)} ${stage.state.padEnd(10)} ${took}`.trimEnd(),
|
|
260
262
|
);
|
|
@@ -309,7 +311,7 @@ function runJson(view: RunView, place: ReadPlace, now: number): RunJson {
|
|
|
309
311
|
? undefined
|
|
310
312
|
: continueOf(status, view.lines, place);
|
|
311
313
|
return {
|
|
312
|
-
v:
|
|
314
|
+
v: 2,
|
|
313
315
|
run: {
|
|
314
316
|
ref: status.ref,
|
|
315
317
|
state: status.state,
|
package/src/runs.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
copyFile,
|
|
4
|
+
mkdir,
|
|
5
|
+
open,
|
|
6
|
+
readdir,
|
|
7
|
+
readFile,
|
|
8
|
+
rename,
|
|
9
|
+
rm,
|
|
10
|
+
stat,
|
|
11
|
+
unlink,
|
|
12
|
+
} from "node:fs/promises";
|
|
3
13
|
import { join } from "node:path";
|
|
14
|
+
import { isDeepStrictEqual } from "node:util";
|
|
4
15
|
import {
|
|
5
16
|
ATTEMPT_RECORD_VERSION,
|
|
6
17
|
type AttemptRecord,
|
|
@@ -8,6 +19,7 @@ import {
|
|
|
8
19
|
type Candidate,
|
|
9
20
|
RUN_RECORD_VERSION,
|
|
10
21
|
type RunLogLine,
|
|
22
|
+
type RunPanes,
|
|
11
23
|
type RunRecord,
|
|
12
24
|
runLogLine,
|
|
13
25
|
STAGE_RECORD_VERSION,
|
|
@@ -190,13 +202,15 @@ export async function openRun(root: string, workflow: string, id: string): Promi
|
|
|
190
202
|
|
|
191
203
|
/**
|
|
192
204
|
* What a continue may be given: nothing that changes the run. argv is the run's, and its working
|
|
193
|
-
* directory and
|
|
205
|
+
* directory, sandbox and panes are those it was started with.
|
|
194
206
|
*/
|
|
195
207
|
export function checkContinue(
|
|
196
208
|
run: Run,
|
|
197
|
-
given: { argv: readonly string[]; cwd?: string; sandbox?: unknown },
|
|
209
|
+
given: { argv: readonly string[]; cwd?: string; sandbox?: unknown; panes?: RunPanes },
|
|
198
210
|
): void {
|
|
199
211
|
const { id, cwd, sandbox } = run.record;
|
|
212
|
+
const panes = panesChanged(run.record, given.panes);
|
|
213
|
+
if (panes) throw new RunRefused(panes);
|
|
200
214
|
if (given.argv.length > 0) {
|
|
201
215
|
throw new RunRefused(
|
|
202
216
|
`${id} keeps the arguments it was started with; a run with others is a new run (--id)`,
|
|
@@ -210,11 +224,32 @@ export function checkContinue(
|
|
|
210
224
|
}
|
|
211
225
|
}
|
|
212
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Why `given` would change the panes `record`'s run was started with; a flag given again with its
|
|
229
|
+
* value, or with its default where the run was started without it, changes nothing.
|
|
230
|
+
*/
|
|
231
|
+
export function panesChanged(record: RunRecord, given: RunPanes | undefined): string | undefined {
|
|
232
|
+
const flags = [
|
|
233
|
+
["--workspace", "workspace", "run"],
|
|
234
|
+
["--keep-panes", "keep", "never"],
|
|
235
|
+
] as const;
|
|
236
|
+
for (const [flag, field, byDefault] of flags) {
|
|
237
|
+
const value = given?.[field];
|
|
238
|
+
const recorded = record.panes?.[field];
|
|
239
|
+
if (value === undefined || isDeepStrictEqual(value, recorded ?? byDefault)) continue;
|
|
240
|
+
// A run an event or an older awf started has none, and keeps the defaults.
|
|
241
|
+
return recorded === undefined
|
|
242
|
+
? `${record.id} was started without ${flag}, so with ${flag} ${byDefault}; leave it out`
|
|
243
|
+
: `${record.id} keeps the ${flag} it was started with; leave ${flag} out`;
|
|
244
|
+
}
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
213
248
|
/**
|
|
214
249
|
* The run's attempts, in order. One that doesn't parse, or is newer than this awf, refuses; one
|
|
215
250
|
* gone since the folder was listed was a refused attempt removing its own claim.
|
|
216
251
|
*/
|
|
217
|
-
export async function readAttempts(run: Run): Promise<AttemptRecord[]> {
|
|
252
|
+
export async function readAttempts(run: Pick<Run, "dir">): Promise<AttemptRecord[]> {
|
|
218
253
|
const dir = attemptsDir(run.dir);
|
|
219
254
|
const numbered = await attemptNames(run);
|
|
220
255
|
const records = await Promise.all(
|
|
@@ -405,9 +440,10 @@ export async function replaceStale(
|
|
|
405
440
|
/**
|
|
406
441
|
* Moves a stage's record, unchanged, to `replaced/{stage}.{attempt}.json`, or, for a stage by
|
|
407
442
|
* content replaced again in the same attempt, `{stage}.{attempt}+{n}.json` from 2: no name has a
|
|
408
|
-
* `+`, so neither reads as another stage's.
|
|
443
|
+
* `+`, so neither reads as another stage's. `copy` leaves it in `stages/` for the new record to
|
|
444
|
+
* overwrite, so an attempt that dies between the two leaves the old one, not none.
|
|
409
445
|
*/
|
|
410
|
-
export async function replaceRecord(runDir: string, stage: string): Promise<void> {
|
|
446
|
+
export async function replaceRecord(runDir: string, stage: string, copy = false): Promise<void> {
|
|
411
447
|
const file = join(stagesDir(runDir), `${stage}.json`);
|
|
412
448
|
const record = await readRecord<StageRecord>(file, STAGE_RECORD_VERSION);
|
|
413
449
|
const replaced = join(runDir, "replaced");
|
|
@@ -416,7 +452,7 @@ export async function replaceRecord(runDir: string, stage: string): Promise<void
|
|
|
416
452
|
for (let n = 2; await exists(target); n++) {
|
|
417
453
|
target = join(replaced, `${stage}.${record.attempt}+${n}.json`);
|
|
418
454
|
}
|
|
419
|
-
await rename(file, target);
|
|
455
|
+
await (copy ? copyFile(file, target) : rename(file, target));
|
|
420
456
|
}
|
|
421
457
|
|
|
422
458
|
/** Appends a line to the run's event log, `turns.jsonl`, which only the live attempt writes. */
|
|
@@ -501,7 +537,7 @@ export async function logSize(runDir: string): Promise<number> {
|
|
|
501
537
|
return (await stat(turnsFile(runDir)).catch(() => undefined))?.size ?? 0;
|
|
502
538
|
}
|
|
503
539
|
|
|
504
|
-
async function attemptNames(run: Run): Promise<string[]> {
|
|
540
|
+
async function attemptNames(run: Pick<Run, "dir">): Promise<string[]> {
|
|
505
541
|
return (await entries(attemptsDir(run.dir))).filter((name) => /^[1-9]\d*\.json$/.test(name));
|
|
506
542
|
}
|
|
507
543
|
|
package/src/sources.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { WaitTake } from "@agentswf/contract/records";
|
|
2
|
-
import type
|
|
2
|
+
import { awfEventOf, type JsonValue, type WorkflowEvent } from "@agentswf/contract/workflow";
|
|
3
3
|
|
|
4
4
|
/** What each source the run issued takes: only awf makes a source, so only these are. */
|
|
5
5
|
const takes = new WeakMap<object, WaitTake>();
|
|
6
6
|
|
|
7
|
+
/** Signals: a branch on one hands back the whole event, not its data. */
|
|
8
|
+
const signals = new WeakSet<object>();
|
|
9
|
+
|
|
7
10
|
/** Marks `value` as a source that takes `take`, and returns it. */
|
|
8
11
|
export function asSource<T extends object>(value: T, take: WaitTake): T {
|
|
9
12
|
takes.set(value, take);
|
|
@@ -18,8 +21,40 @@ export function source<T extends object>(
|
|
|
18
21
|
return asSource(Object.freeze({ ...fields, is: (event: Looked) => matches(event, take) }), take);
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
/** As `source`, for a signal. */
|
|
25
|
+
export function signalOf<T extends object>(fields: T, take: WaitTake): T {
|
|
26
|
+
const made = source(fields, take);
|
|
27
|
+
signals.add(made);
|
|
28
|
+
return made;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether `value` is a signal, whose branch hands back the whole event. */
|
|
32
|
+
export function isSignal(value: unknown): boolean {
|
|
33
|
+
return typeof value === "object" && value !== null && signals.has(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
type Looked = { name: string; from: string; data?: unknown };
|
|
22
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The name the mailbox records an event under: awf's keep `agent:ended` and `participant:reply`,
|
|
40
|
+
* from their key, whatever name the workflow declares and is handed, `{key}:ended` or `{key}:reply`.
|
|
41
|
+
*/
|
|
42
|
+
export function recordName(event: { name: string; from: string }): string {
|
|
43
|
+
const awf = awfEventOf(event.name);
|
|
44
|
+
if (!awf || awf.key !== event.from) return event.name;
|
|
45
|
+
return awf.kind === "ended" ? "agent:ended" : "participant:reply";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `event` under the name the workflow declares for it, where it declares one. */
|
|
49
|
+
export function presented<E extends WorkflowEvent<string, JsonValue>>(
|
|
50
|
+
event: E,
|
|
51
|
+
declared: ReadonlySet<string>,
|
|
52
|
+
): E {
|
|
53
|
+
const kind = { "agent:ended": "ended", "participant:reply": "reply" }[event.name as string];
|
|
54
|
+
if (kind === undefined || !declared.has(`${event.from}:${kind}`)) return event;
|
|
55
|
+
return Object.freeze({ ...event, name: `${event.from}:${kind}` });
|
|
56
|
+
}
|
|
57
|
+
|
|
23
58
|
/** The first of `branches` that takes `event`. */
|
|
24
59
|
export function branchOf(
|
|
25
60
|
branches: readonly (readonly [string, WaitTake])[],
|
|
@@ -33,12 +68,12 @@ export function takeOf(value: unknown): WaitTake | undefined {
|
|
|
33
68
|
return typeof value === "object" && value !== null ? takes.get(value) : undefined;
|
|
34
69
|
}
|
|
35
70
|
|
|
36
|
-
/** Whether a wait that takes `take` takes `event
|
|
71
|
+
/** Whether a wait that takes `take` takes `event`, under its recorded name or its declared one. */
|
|
37
72
|
export function matches(
|
|
38
73
|
event: { name: string; from: string; data?: unknown },
|
|
39
74
|
take: WaitTake,
|
|
40
75
|
): boolean {
|
|
41
|
-
if (event
|
|
76
|
+
if (recordName(event) !== take.name) return false;
|
|
42
77
|
if (take.from !== undefined && event.from !== take.from) return false;
|
|
43
78
|
if (take.turn === undefined) return true;
|
|
44
79
|
const data = event.data as { turn?: unknown } | null | undefined;
|
|
@@ -55,10 +90,11 @@ export function wouldTake(
|
|
|
55
90
|
|
|
56
91
|
/**
|
|
57
92
|
* What a branch hands back of the event it took: awf's own events as their data with the event's id
|
|
58
|
-
* and arrival, and a workflow's own event whole.
|
|
93
|
+
* and arrival, and a workflow's own event, or any a signal takes, whole.
|
|
59
94
|
*/
|
|
60
|
-
export function branchValue(event: WorkflowEvent<string, JsonValue
|
|
61
|
-
|
|
95
|
+
export function branchValue(event: WorkflowEvent<string, JsonValue>, whole = false): unknown {
|
|
96
|
+
if (whole) return event;
|
|
97
|
+
switch (recordName(event)) {
|
|
62
98
|
case "timer:due":
|
|
63
99
|
case "at:due":
|
|
64
100
|
case "participant:reply":
|
package/src/stage-ledger.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { JsonSchema } from "@agentswf/contract/schema";
|
|
|
8
8
|
import type { JsonValue, StageOutcome, StageSummary } from "@agentswf/contract/workflow";
|
|
9
9
|
import type { RunEventBody } from "./run-event-log";
|
|
10
10
|
import { replaceRecord, replaceStale, stageNameProblem, writeStageRecord } from "./runs";
|
|
11
|
-
import { planStage, planStageByContent } from "./stage-plan";
|
|
11
|
+
import { planStage, planStageByContent, type StageRedo } from "./stage-plan";
|
|
12
12
|
import { placeholderOf } from "./stage-schema";
|
|
13
13
|
import { primaryFailure, WorkflowStopped } from "./stopped";
|
|
14
14
|
|
|
@@ -89,6 +89,11 @@ export class StageLedger {
|
|
|
89
89
|
/** The run's stage records as they stand, this attempt's included: what a stage by content reuses. */
|
|
90
90
|
readonly #records: Map<string, StageRecord>;
|
|
91
91
|
|
|
92
|
+
/** The redo stale records are judged by: the options', then this attempt's own. */
|
|
93
|
+
#redo: StageRedo | undefined;
|
|
94
|
+
/** Whether this attempt has entered the redo's stage, after which a stale record reruns. */
|
|
95
|
+
#afterRedo = false;
|
|
96
|
+
|
|
92
97
|
constructor(
|
|
93
98
|
private readonly options: {
|
|
94
99
|
runDir: string;
|
|
@@ -102,9 +107,11 @@ export class StageLedger {
|
|
|
102
107
|
fromStage?: string;
|
|
103
108
|
/** `--values`: the values of stages before `fromStage` that have no record to reuse. */
|
|
104
109
|
values?: ReadonlyMap<string, JsonValue>;
|
|
110
|
+
/** A run of a workflow with `events`: the last `--from-stage` an attempt redid. */
|
|
111
|
+
redo?: StageRedo;
|
|
105
112
|
/**
|
|
106
113
|
* A run of a workflow with `events`: a stage runs once ever, reused wherever it is entered,
|
|
107
|
-
* as `planStageByContent` says
|
|
114
|
+
* as `planStageByContent` says, and no `values`. Its start point is `fromStage` alone.
|
|
108
115
|
*/
|
|
109
116
|
byContent?: boolean;
|
|
110
117
|
/** Writes the run's event log. */
|
|
@@ -113,6 +120,7 @@ export class StageLedger {
|
|
|
113
120
|
},
|
|
114
121
|
) {
|
|
115
122
|
this.#records = new Map(options.records);
|
|
123
|
+
this.#redo = options.redo;
|
|
116
124
|
}
|
|
117
125
|
|
|
118
126
|
/** The stage open now, if one is. */
|
|
@@ -313,10 +321,23 @@ export class StageLedger {
|
|
|
313
321
|
|
|
314
322
|
/**
|
|
315
323
|
* Enters a stage by content: reused from its succeeded record, this attempt's included, run when
|
|
316
|
-
* it has none or a failed one, which its own record then replaces.
|
|
324
|
+
* it has none or a failed one, which its own record then replaces. After the redo's stage in this
|
|
325
|
+
* attempt, a record older than the redo is stale and runs again: see `StageRedo`.
|
|
317
326
|
*/
|
|
318
327
|
async #enterByContent(name: string, entry: StageEntry, at: number): Promise<EnteredStage> {
|
|
319
|
-
|
|
328
|
+
if (!this.#started && name === this.options.fromStage) return this.#startByContent(name, at);
|
|
329
|
+
const record = this.#records.get(name);
|
|
330
|
+
const redo = this.#redo;
|
|
331
|
+
// Entered already in this attempt, it has its value here: one stage, one value.
|
|
332
|
+
const stale =
|
|
333
|
+
redo !== undefined &&
|
|
334
|
+
this.#afterRedo &&
|
|
335
|
+
record !== undefined &&
|
|
336
|
+
record.attempt < redo.attempt &&
|
|
337
|
+
!this.#stages.has(name);
|
|
338
|
+
if (name === redo?.stage) this.#afterRedo = true;
|
|
339
|
+
if (stale) return { kind: "run", stage: this.#opened(name, at) };
|
|
340
|
+
const decision = planStageByContent(record, entry.result);
|
|
320
341
|
if (decision.kind === "stop") {
|
|
321
342
|
this.#stopped = new WorkflowStopped(decision.reason, name, true);
|
|
322
343
|
this.#stoppedAt(name, at);
|
|
@@ -347,6 +368,20 @@ export class StageLedger {
|
|
|
347
368
|
return { kind: "run", stage: this.#opened(name, at) };
|
|
348
369
|
}
|
|
349
370
|
|
|
371
|
+
/**
|
|
372
|
+
* `--from-stage` reached by content: it runs, and this attempt is the redo. Its old record moves to
|
|
373
|
+
* `replaced/` only as its new one is written, as a failed record's does, so an attempt that dies
|
|
374
|
+
* while it runs leaves the old one, and the redo, which nothing then shows, is issued again. The
|
|
375
|
+
* stages after it rerun as they are entered, not all at once: one never entered keeps its record.
|
|
376
|
+
*/
|
|
377
|
+
#startByContent(name: string, at: number): EnteredStage {
|
|
378
|
+
const stage = this.#opened(name, at);
|
|
379
|
+
this.#started = true;
|
|
380
|
+
this.#redo = { stage: name, attempt: this.options.attempt };
|
|
381
|
+
this.#afterRedo = true;
|
|
382
|
+
return { kind: "run", stage };
|
|
383
|
+
}
|
|
384
|
+
|
|
350
385
|
/** A stage the plan stopped as it was entered: shown stopped, though it ran nothing. */
|
|
351
386
|
#stoppedAt(name: string, at: number): void {
|
|
352
387
|
this.options.event?.({ kind: "stage-ended", stage: name, outcome: "stopped" }, at);
|
|
@@ -506,14 +541,9 @@ export class StageLedger {
|
|
|
506
541
|
});
|
|
507
542
|
// By content, a failed record this one replaces is kept, as a redone stage's is.
|
|
508
543
|
const replacing = this.options.byContent && this.#records.has(name);
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
// Moved: a write that fails now leaves nothing in `stages/` to move again.
|
|
513
|
-
this.#records.delete(name);
|
|
514
|
-
})
|
|
515
|
-
: Promise.resolve()
|
|
516
|
-
)
|
|
544
|
+
// Copied, then overwritten: dying between the two leaves the old record, as dying while the
|
|
545
|
+
// stage runs does, never a redo's stage without one and stale records after it.
|
|
546
|
+
ended = (replacing ? replaceRecord(this.options.runDir, name, true) : Promise.resolve())
|
|
517
547
|
.then(() => writeStageRecord(this.options.runDir, record))
|
|
518
548
|
.then(
|
|
519
549
|
() => {
|
package/src/stage-plan.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
AttemptRecord,
|
|
3
|
+
AttemptStage,
|
|
4
|
+
StageNeed,
|
|
5
|
+
StageRecord,
|
|
6
|
+
} from "@agentswf/contract/records";
|
|
2
7
|
import { type JsonSchema, type SchemaError, validate } from "@agentswf/contract/schema";
|
|
3
8
|
import type { JsonValue } from "@agentswf/contract/workflow";
|
|
4
9
|
|
|
@@ -148,3 +153,34 @@ export function planStageByContent(
|
|
|
148
153
|
? { kind: "reuse", record, value: record.value }
|
|
149
154
|
: { kind: "stop", reason: misfit };
|
|
150
155
|
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A `--from-stage {stage}` that attempt `attempt` redid, in a run of a workflow with `events`. In
|
|
159
|
+
* that attempt and every later one, a stage first entered after `stage` whose record an earlier
|
|
160
|
+
* attempt than `attempt` wrote runs again, its record replaced: it was made against the stage
|
|
161
|
+
* before the redo. A record never entered after the stage keeps its reuse.
|
|
162
|
+
*/
|
|
163
|
+
export type StageRedo = { stage: string; attempt: number };
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The last `--from-stage` an earlier attempt of a run with `events` redid: the latest attempt with
|
|
167
|
+
* one that ran its stage. Its attempt record says so in `stages`, which a stop for a stage never
|
|
168
|
+
* reached, a refusal and a crash leave without it; a crash after the stage's new record was written
|
|
169
|
+
* leaves that record, the attempt's own. A redo whose stage failed and ran again on a wake is still
|
|
170
|
+
* the redo. Only attempt and stage records are read, which a continue may read.
|
|
171
|
+
*/
|
|
172
|
+
export function lastRedo(
|
|
173
|
+
attempts: readonly (Pick<AttemptRecord, "attempt" | "flags"> & {
|
|
174
|
+
stages?: readonly Pick<AttemptStage, "stage" | "source">[];
|
|
175
|
+
})[],
|
|
176
|
+
records: ReadonlyMap<string, StageRecord>,
|
|
177
|
+
before: number,
|
|
178
|
+
): StageRedo | undefined {
|
|
179
|
+
for (const { attempt, flags, stages } of [...attempts].sort((a, b) => b.attempt - a.attempt)) {
|
|
180
|
+
const stage = flags.fromStage;
|
|
181
|
+
if (attempt >= before || stage === undefined) continue;
|
|
182
|
+
const ran = stages?.some((each) => each.stage === stage && each.source === "ran") ?? false;
|
|
183
|
+
if (ran || records.get(stage)?.attempt === attempt) return { stage, attempt };
|
|
184
|
+
}
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
package/src/test-command.ts
CHANGED
|
@@ -24,7 +24,7 @@ export async function testCommand(
|
|
|
24
24
|
): Promise<number> {
|
|
25
25
|
let tests: TestCommand | "help";
|
|
26
26
|
try {
|
|
27
|
-
tests = parseTestCommand(argv, cwd);
|
|
27
|
+
tests = parseTestCommand(argv, cwd, (line) => io.stderr(line));
|
|
28
28
|
} catch (error) {
|
|
29
29
|
io.stderr(`awf: ${messageOf(error)}\n\n${testUsage}`);
|
|
30
30
|
return 2;
|
|
@@ -43,7 +43,11 @@ export async function testCommand(
|
|
|
43
43
|
* Only these flags pass through, so the published command is awf's, not the whole of `bun test`'s,
|
|
44
44
|
* and a later runner can take its place. A path must exist; Bun would take it as a filter.
|
|
45
45
|
*/
|
|
46
|
-
export function parseTestCommand(
|
|
46
|
+
export function parseTestCommand(
|
|
47
|
+
argv: readonly string[],
|
|
48
|
+
cwd: string,
|
|
49
|
+
deprecated: (line: string) => void = () => {},
|
|
50
|
+
): TestCommand | "help" {
|
|
47
51
|
const paths: string[] = [];
|
|
48
52
|
const bunArgs: string[] = [];
|
|
49
53
|
for (let index = 0; index < argv.length; index += 1) {
|
|
@@ -58,7 +62,7 @@ export function parseTestCommand(argv: readonly string[], cwd: string): TestComm
|
|
|
58
62
|
index += 1;
|
|
59
63
|
} else if (option === "--timeout") {
|
|
60
64
|
if (value === undefined) throw new Error("--timeout needs a duration such as 10s");
|
|
61
|
-
bunArgs.push("--timeout", String(parseDuration(value)));
|
|
65
|
+
bunArgs.push("--timeout", String(parseDuration(value, "--timeout", deprecated)));
|
|
62
66
|
index += 1;
|
|
63
67
|
} else if (option.startsWith("-")) {
|
|
64
68
|
throw new Error(`unknown option: ${option}`);
|
package/src/testing.ts
CHANGED
|
@@ -6,11 +6,9 @@ import type { AttemptRecord } from "@agentswf/contract/records";
|
|
|
6
6
|
import { type ResultSubmitResponse, WIRE_VERSION } from "@agentswf/contract/wire";
|
|
7
7
|
import {
|
|
8
8
|
defineWorkflow,
|
|
9
|
-
type EventWorkflowContext,
|
|
10
9
|
type JsonValue,
|
|
11
10
|
type OutputSchema,
|
|
12
11
|
type WorkflowContext,
|
|
13
|
-
type WorkflowDefinition,
|
|
14
12
|
} from "@agentswf/contract/workflow";
|
|
15
13
|
import { createSingleSessionHostFactory } from "@agentswf/harness";
|
|
16
14
|
import type { AgentSessionAdapter } from "@agentswf/harness/adapter";
|
|
@@ -19,6 +17,7 @@ import Type from "typebox";
|
|
|
19
17
|
import { writeJson } from "./files";
|
|
20
18
|
import { OPERATOR_ALIASES } from "./operator-aliases";
|
|
21
19
|
import { createRun, openRun, type Run, readAttempts } from "./runs";
|
|
20
|
+
import type { RunnableWorkflow } from "./workflow-runner";
|
|
22
21
|
import { type RunWorkflowOptions, runWorkflow, startWorkflow } from "./workflow-runner";
|
|
23
22
|
|
|
24
23
|
export function createTempRunDirs(): {
|
|
@@ -81,11 +80,11 @@ const Changes: OutputSchema<{ case: string }> = {
|
|
|
81
80
|
properties: { case: { type: "string" } },
|
|
82
81
|
required: ["case"],
|
|
83
82
|
};
|
|
84
|
-
const caseEvents = { "dd.changes": Changes };
|
|
83
|
+
export const caseEvents = { "dd.changes": Changes };
|
|
85
84
|
|
|
86
85
|
/** A workflow with one event, `dd.changes`, keyed by its case, whose body is `run`. */
|
|
87
86
|
export const caseRun = (
|
|
88
|
-
run: (workflow:
|
|
87
|
+
run: (workflow: WorkflowContext<typeof caseEvents>) => Promise<JsonValue>,
|
|
89
88
|
) =>
|
|
90
89
|
defineWorkflow({
|
|
91
90
|
meta: { name: "dd-case", description: "A case." },
|
|
@@ -140,7 +139,7 @@ export const DOC = Type.Object({ path: Type.String() }, { additionalProperties:
|
|
|
140
139
|
export function workflowOf<Result extends JsonValue>(
|
|
141
140
|
run: (workflow: WorkflowContext) => Promise<Result>,
|
|
142
141
|
meta: { name?: string; version?: string } = {},
|
|
143
|
-
):
|
|
142
|
+
): RunnableWorkflow<null, Result> {
|
|
144
143
|
const { name = "staged", version } = meta;
|
|
145
144
|
return {
|
|
146
145
|
meta: { name, description: name, ...(version === undefined ? {} : { version }) },
|
|
@@ -168,7 +167,7 @@ export function answering(): AgentSessionAdapter {
|
|
|
168
167
|
* `answering()`. Its folder is `{runRoot}/{workflow}/r1`.
|
|
169
168
|
*/
|
|
170
169
|
export async function runAttempt<Result extends JsonValue>(
|
|
171
|
-
workflow:
|
|
170
|
+
workflow: RunnableWorkflow<null, Result>,
|
|
172
171
|
options: {
|
|
173
172
|
runRoot: string;
|
|
174
173
|
attempt?: number;
|
|
@@ -206,7 +205,7 @@ export async function runAttempt<Result extends JsonValue>(
|
|
|
206
205
|
|
|
207
206
|
/** `startWorkflow` as the first attempt of a new run of the workflow under `options.runRoot`. */
|
|
208
207
|
export async function startNew<Args extends JsonValue, Result extends JsonValue>(
|
|
209
|
-
definition:
|
|
208
|
+
definition: RunnableWorkflow<Args, Result>,
|
|
210
209
|
args: Args,
|
|
211
210
|
options: Omit<RunWorkflowOptions, "run">,
|
|
212
211
|
) {
|
|
@@ -224,7 +223,7 @@ export async function startNew<Args extends JsonValue, Result extends JsonValue>
|
|
|
224
223
|
|
|
225
224
|
/** `runWorkflow` as the first attempt of a new run of the workflow under `options.runRoot`. */
|
|
226
225
|
export async function runNew<Args extends JsonValue, Result extends JsonValue>(
|
|
227
|
-
definition:
|
|
226
|
+
definition: RunnableWorkflow<Args, Result>,
|
|
228
227
|
args: Args,
|
|
229
228
|
options: Omit<RunWorkflowOptions, "run">,
|
|
230
229
|
) {
|