@agentswf/engine 0.1.0-beta.2 → 0.1.0-beta.4
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 +5 -6
- package/src/agent-cli/cli.ts +571 -0
- package/src/agent-cli/client.ts +115 -0
- package/src/agent-launcher.ts +15 -25
- package/src/allowance-command.ts +31 -21
- package/src/attempt-ending.ts +23 -5
- package/src/attempt-output.ts +48 -18
- package/src/attempt-view.ts +25 -15
- package/src/commands.ts +176 -0
- package/src/control-plane.ts +73 -22
- package/src/coordinator-port.ts +29 -0
- package/src/coordinator-prompt.ts +237 -0
- package/src/coordinator-socket.ts +236 -0
- package/src/deadlines.ts +47 -10
- package/src/decisions/directory.ts +8 -6
- package/src/duration.ts +29 -0
- package/src/emit-command.ts +119 -0
- package/src/emit.ts +246 -0
- package/src/files.ts +11 -1
- package/src/help/allowance.txt +13 -0
- package/src/help/emit.txt +13 -0
- package/src/help/inbox.txt +12 -0
- package/src/help/init.txt +12 -0
- package/src/help/logs.txt +13 -0
- package/src/help/reply.txt +18 -0
- package/src/help/run.txt +44 -0
- package/src/help/runs.txt +11 -0
- package/src/help/test.txt +9 -0
- package/src/help/tick.txt +12 -0
- package/src/here.ts +9 -3
- package/src/hold-run-lock.fixture.ts +8 -0
- package/src/holder-command.ts +279 -0
- package/src/inbox-command.ts +715 -0
- package/src/init-command.ts +29 -11
- package/src/init-template/AGENTS.md.template +4 -4
- package/src/init-template/hello/workflow.test.ts.template +2 -2
- package/src/init-template/hello/workflow.ts.template +16 -20
- package/src/keep-alive.ts +40 -0
- package/src/logs-command.ts +106 -35
- package/src/machine.ts +12 -1
- package/src/mailbox.ts +990 -0
- package/src/message-operation.ts +26 -3
- package/src/messaging/limits.ts +1 -1
- package/src/messaging/messaging.ts +478 -85
- package/src/messaging/routes.ts +11 -4
- package/src/messaging/text.ts +25 -22
- package/src/operation-liveness.ts +55 -9
- package/src/operator-cli.ts +290 -119
- package/src/operator-runtime.ts +8 -8
- package/src/progress-view.ts +49 -7
- package/src/projects.ts +57 -0
- package/src/result-slots.ts +43 -19
- package/src/result-validation.ts +18 -27
- package/src/run-command.ts +29 -66
- package/src/run-commands.ts +13 -2
- package/src/run-lock.ts +123 -0
- package/src/run-prepare.ts +94 -22
- package/src/run-state.ts +125 -6
- package/src/run-usage.ts +2 -2
- package/src/run-view.ts +0 -9
- package/src/runs-command.ts +43 -25
- package/src/runs.ts +142 -23
- package/src/sandboxes.ts +11 -4
- package/src/simple-input.ts +268 -0
- package/src/socket-exchange.ts +26 -0
- package/src/sources.ts +70 -0
- package/src/stage-ledger.ts +89 -14
- package/src/stage-plan.ts +23 -0
- package/src/stop-crashed.ts +39 -0
- package/src/test-command.ts +34 -11
- package/src/testing.ts +72 -4
- package/src/tick-command.ts +76 -0
- package/src/tools.ts +645 -0
- package/src/wake.ts +264 -0
- package/src/workflow-loader.ts +32 -22
- package/src/workflow-runner.ts +1277 -97
- package/src/workflow-testing/coordinator.typecheck.ts +54 -0
- package/src/workflow-testing/coordinators.ts +137 -0
- package/src/workflow-testing/host.ts +67 -7
- package/src/workflow-testing/index.ts +488 -34
- package/src/workflow-testing/script.ts +74 -4
- package/src/workflow-testing/workflow-testing.typecheck.ts +19 -2
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 } from "./stage-plan";
|
|
11
|
+
import { planStage, planStageByContent } from "./stage-plan";
|
|
12
12
|
import { placeholderOf } from "./stage-schema";
|
|
13
13
|
import { primaryFailure, WorkflowStopped } from "./stopped";
|
|
14
14
|
|
|
@@ -86,6 +86,8 @@ export class StageLedger {
|
|
|
86
86
|
*/
|
|
87
87
|
readonly #looked = new Set<string>();
|
|
88
88
|
#needed: WorkflowStopped | undefined;
|
|
89
|
+
/** The run's stage records as they stand, this attempt's included: what a stage by content reuses. */
|
|
90
|
+
readonly #records: Map<string, StageRecord>;
|
|
89
91
|
|
|
90
92
|
constructor(
|
|
91
93
|
private readonly options: {
|
|
@@ -100,11 +102,23 @@ export class StageLedger {
|
|
|
100
102
|
fromStage?: string;
|
|
101
103
|
/** `--values`: the values of stages before `fromStage` that have no record to reuse. */
|
|
102
104
|
values?: ReadonlyMap<string, JsonValue>;
|
|
105
|
+
/**
|
|
106
|
+
* A run of a workflow with `events`: a stage runs once ever, reused wherever it is entered,
|
|
107
|
+
* as `planStageByContent` says; no start point, and no `fromStage` or `values`.
|
|
108
|
+
*/
|
|
109
|
+
byContent?: boolean;
|
|
103
110
|
/** Writes the run's event log. */
|
|
104
111
|
event?(body: RunEventBody, at?: number): void;
|
|
105
112
|
now?: () => Date;
|
|
106
113
|
},
|
|
107
|
-
) {
|
|
114
|
+
) {
|
|
115
|
+
this.#records = new Map(options.records);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The stage open now, if one is. */
|
|
119
|
+
get openStage(): string | undefined {
|
|
120
|
+
return this.#open?.name;
|
|
121
|
+
}
|
|
108
122
|
|
|
109
123
|
/** A `--from-stage` the attempt never reached: a typo, or a branch not taken. */
|
|
110
124
|
get fromStageUnreached(): string | undefined {
|
|
@@ -144,9 +158,10 @@ export class StageLedger {
|
|
|
144
158
|
* entered yet, in the order they first started.
|
|
145
159
|
*/
|
|
146
160
|
progress(): { stages: StageProgress[]; upcoming: string[] } {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
161
|
+
// By content, an earlier event's stage may never be entered again: none is upcoming.
|
|
162
|
+
const upcoming = this.options.byContent
|
|
163
|
+
? []
|
|
164
|
+
: this.recorded.map((record) => record.stage).filter((stage) => !this.#stages.has(stage));
|
|
150
165
|
const recorded = (stage: string) => this.options.records?.get(stage)?.ended;
|
|
151
166
|
return {
|
|
152
167
|
stages: [...this.#stages.values()].map((entered) => {
|
|
@@ -159,7 +174,7 @@ export class StageLedger {
|
|
|
159
174
|
}
|
|
160
175
|
|
|
161
176
|
/**
|
|
162
|
-
* `
|
|
177
|
+
* `wf.stop`, called in `stage` or between stages: what to throw. The first stop is kept,
|
|
163
178
|
* and any later stop throws it again; once it was found caught, that instead. After the
|
|
164
179
|
* workflow's body ended, a stop ends nothing.
|
|
165
180
|
*/
|
|
@@ -196,6 +211,7 @@ export class StageLedger {
|
|
|
196
211
|
if (caught) throw caught;
|
|
197
212
|
this.#check(name);
|
|
198
213
|
const at = this.#now().getTime();
|
|
214
|
+
if (this.options.byContent) return this.#enterByContent(name, entry, at);
|
|
199
215
|
const decision = planStage(
|
|
200
216
|
{
|
|
201
217
|
records: this.options.records ?? new Map(),
|
|
@@ -295,6 +311,42 @@ export class StageLedger {
|
|
|
295
311
|
return { kind: "run", stage };
|
|
296
312
|
}
|
|
297
313
|
|
|
314
|
+
/**
|
|
315
|
+
* 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.
|
|
317
|
+
*/
|
|
318
|
+
async #enterByContent(name: string, entry: StageEntry, at: number): Promise<EnteredStage> {
|
|
319
|
+
const decision = planStageByContent(this.#records.get(name), entry.result);
|
|
320
|
+
if (decision.kind === "stop") {
|
|
321
|
+
this.#stopped = new WorkflowStopped(decision.reason, name, true);
|
|
322
|
+
this.#stoppedAt(name, at);
|
|
323
|
+
throw this.#stopped;
|
|
324
|
+
}
|
|
325
|
+
if (decision.kind === "reuse") {
|
|
326
|
+
const held = this.#hold(name, decision.value);
|
|
327
|
+
this.#reused.add(name);
|
|
328
|
+
// Entered again in the attempt that ran or reused it: nothing more to show or log.
|
|
329
|
+
if (this.#stages.get(name)?.outcome === "succeeded") return held;
|
|
330
|
+
const { attempt, summary } = decision.record;
|
|
331
|
+
this.options.event?.(
|
|
332
|
+
{ kind: "stage-ended", stage: name, outcome: "succeeded", source: "reused" },
|
|
333
|
+
at,
|
|
334
|
+
);
|
|
335
|
+
this.#stages.set(name, {
|
|
336
|
+
stage: name,
|
|
337
|
+
source: "reused",
|
|
338
|
+
outcome: "succeeded",
|
|
339
|
+
attempt,
|
|
340
|
+
startedAt: at,
|
|
341
|
+
endedAt: at,
|
|
342
|
+
...(summary === undefined ? {} : { summary }),
|
|
343
|
+
...(decision.value === undefined ? {} : { value: decision.value }),
|
|
344
|
+
});
|
|
345
|
+
return held;
|
|
346
|
+
}
|
|
347
|
+
return { kind: "run", stage: this.#opened(name, at) };
|
|
348
|
+
}
|
|
349
|
+
|
|
298
350
|
/** A stage the plan stopped as it was entered: shown stopped, though it ran nothing. */
|
|
299
351
|
#stoppedAt(name: string, at: number): void {
|
|
300
352
|
this.options.event?.({ kind: "stage-ended", stage: name, outcome: "stopped" }, at);
|
|
@@ -411,7 +463,7 @@ export class StageLedger {
|
|
|
411
463
|
`stage ${name} was entered while stage ${this.#open.name} is open; one stage runs at a time, and parallel work goes inside one`,
|
|
412
464
|
);
|
|
413
465
|
}
|
|
414
|
-
if (this.#stages.has(name) || this.#looked.has(name)) {
|
|
466
|
+
if (!this.options.byContent && (this.#stages.has(name) || this.#looked.has(name))) {
|
|
415
467
|
throw new Error(`stage ${name} was entered twice; a loop goes inside one stage`);
|
|
416
468
|
}
|
|
417
469
|
}
|
|
@@ -452,13 +504,26 @@ export class StageLedger {
|
|
|
452
504
|
...(record.summary === undefined ? {} : { summary: record.summary }),
|
|
453
505
|
...(record.value === undefined ? {} : { value: record.value }),
|
|
454
506
|
});
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
507
|
+
// By content, a failed record this one replaces is kept, as a redone stage's is.
|
|
508
|
+
const replacing = this.options.byContent && this.#records.has(name);
|
|
509
|
+
ended = (
|
|
510
|
+
replacing
|
|
511
|
+
? replaceRecord(this.options.runDir, name).then(() => {
|
|
512
|
+
// Moved: a write that fails now leaves nothing in `stages/` to move again.
|
|
513
|
+
this.#records.delete(name);
|
|
514
|
+
})
|
|
515
|
+
: Promise.resolve()
|
|
516
|
+
)
|
|
517
|
+
.then(() => writeStageRecord(this.options.runDir, record))
|
|
518
|
+
.then(
|
|
519
|
+
() => {
|
|
520
|
+
this.#records.set(name, record);
|
|
521
|
+
},
|
|
522
|
+
(error: unknown) => {
|
|
523
|
+
entered.outcome = "failed";
|
|
524
|
+
throw error;
|
|
525
|
+
},
|
|
526
|
+
);
|
|
462
527
|
// Its outcome once the record is written: one that is not fails the stage.
|
|
463
528
|
const logEnd = () =>
|
|
464
529
|
this.options.event?.(
|
|
@@ -484,6 +549,16 @@ export class StageLedger {
|
|
|
484
549
|
return stage;
|
|
485
550
|
}
|
|
486
551
|
|
|
552
|
+
/**
|
|
553
|
+
* Leaves the open stage `name` unended and unrecorded, as the run parked at a wait in it: the
|
|
554
|
+
* next attempt runs it again.
|
|
555
|
+
*/
|
|
556
|
+
abandon(name: string): void {
|
|
557
|
+
if (this.#open?.name !== name) return;
|
|
558
|
+
this.#open = undefined;
|
|
559
|
+
this.#stages.delete(name);
|
|
560
|
+
}
|
|
561
|
+
|
|
487
562
|
/** Once the workflow's body has ended, why: no stage is entered after it. */
|
|
488
563
|
seal(reason: string): void {
|
|
489
564
|
if (this.#sealed !== undefined) return;
|
package/src/stage-plan.ts
CHANGED
|
@@ -92,6 +92,12 @@ function unreusable(
|
|
|
92
92
|
if (recorded !== undefined && now !== undefined && recorded !== now) {
|
|
93
93
|
return `${stage} was recorded by ${record.workflowVersion}; this is ${state.workflowVersion}`;
|
|
94
94
|
}
|
|
95
|
+
return misfitOf(record, result);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Why a succeeded record's value no longer fits the stage's `result`; undefined when it does. */
|
|
99
|
+
function misfitOf(record: StageRecord, result: JsonSchema | undefined): string | undefined {
|
|
100
|
+
const { stage } = record;
|
|
95
101
|
if (result === undefined) {
|
|
96
102
|
return record.value === undefined
|
|
97
103
|
? undefined
|
|
@@ -125,3 +131,20 @@ function majorOf(version: string | undefined): string | undefined {
|
|
|
125
131
|
const [major, minor] = version.split(".");
|
|
126
132
|
return major === "0" ? `0.${minor}` : major;
|
|
127
133
|
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* In a run of a workflow with `events`, a stage runs once ever: a succeeded record is reused
|
|
137
|
+
* wherever and however often its name is entered, whatever version of the code recorded it, as
|
|
138
|
+
* long as its value still fits the stage's `result`. One that doesn't fit stops the attempt; a
|
|
139
|
+
* failed record, or none, runs.
|
|
140
|
+
*/
|
|
141
|
+
export function planStageByContent(
|
|
142
|
+
record: StageRecord | undefined,
|
|
143
|
+
result: JsonSchema | undefined,
|
|
144
|
+
): Extract<StagePlanDecision, { kind: "reuse" | "stop" }> | { kind: "run" } {
|
|
145
|
+
if (record?.outcome !== "succeeded") return { kind: "run" };
|
|
146
|
+
const misfit = misfitOf(record, result);
|
|
147
|
+
return misfit === undefined
|
|
148
|
+
? { kind: "reuse", record, value: record.value }
|
|
149
|
+
: { kind: "stop", reason: misfit };
|
|
150
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { AttemptRecord } from "@agentswf/contract/records";
|
|
2
|
+
import { runProcess } from "@agentswf/harness";
|
|
3
|
+
import { ensureRunSession, runSessionName } from "./herdr-run-session";
|
|
4
|
+
import { readMarks } from "./herdr-workspace-marks";
|
|
5
|
+
import { probeProcess } from "./runs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Stops what a crashed attempt left working before a continue writes its turns' failed ends (Q17):
|
|
9
|
+
* what is left of its process group, which a detached `awf run` leads, and its panes, by sweeping
|
|
10
|
+
* its Herdr session, which closes the workspaces of runs whose process is gone. A run whose attempt
|
|
11
|
+
* opened no pane starts no Herdr. A harness's own process runs in a session of its own, so one the
|
|
12
|
+
* attempt orphaned isn't reached here; it is not stopped yet.
|
|
13
|
+
*/
|
|
14
|
+
export async function stopCrashed(
|
|
15
|
+
previous: AttemptRecord,
|
|
16
|
+
label: string,
|
|
17
|
+
context: { home: string; environment: Readonly<Record<string, string | undefined>> },
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
// Only once its leader is gone for good: a live group's id is never handed out again, so a group
|
|
20
|
+
// still there is the attempt's leftovers, never another process that took its pid since. One an
|
|
21
|
+
// `awf run` typed in a terminal led is its pipeline's, which goes with it.
|
|
22
|
+
if (probeProcess(previous.pid) === "gone") {
|
|
23
|
+
try {
|
|
24
|
+
process.kill(-previous.pid, "SIGTERM");
|
|
25
|
+
await Bun.sleep(500);
|
|
26
|
+
process.kill(-previous.pid, "SIGKILL");
|
|
27
|
+
} catch {
|
|
28
|
+
// No such group, or it ended at the first signal.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const name = runSessionName(context.environment);
|
|
32
|
+
const marks = await readMarks(context.home, name);
|
|
33
|
+
if (!marks.some((mark) => mark.label === label)) return;
|
|
34
|
+
await ensureRunSession(name, {
|
|
35
|
+
run: runProcess,
|
|
36
|
+
environment: context.environment,
|
|
37
|
+
home: context.home,
|
|
38
|
+
});
|
|
39
|
+
}
|
package/src/test-command.ts
CHANGED
|
@@ -3,23 +3,42 @@ import { constants } from "node:os";
|
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { parseDuration } from "./duration";
|
|
6
|
+
import { messageOf } from "./errors";
|
|
7
|
+
import help from "./help/test.txt" with { type: "text" };
|
|
8
|
+
import type { Out } from "./run-commands";
|
|
6
9
|
|
|
7
10
|
const PRELOAD = fileURLToPath(new URL("./workflow-test-preload.ts", import.meta.url));
|
|
8
11
|
|
|
9
|
-
export const testUsage =
|
|
10
|
-
"usage: awf test [paths...] [-t <pattern>] [--watch] [--timeout <duration>]",
|
|
11
|
-
"",
|
|
12
|
-
"Runs a workflow's tests (*.test.ts and *.spec.ts files) under the current directory, or under",
|
|
13
|
-
"each file or directory given, with agentswf/workflow, agentswf/testing and typebox served as",
|
|
14
|
-
"awf run serves them: nothing is installed. -t runs the tests whose names match the pattern;",
|
|
15
|
-
"--watch reruns them on a change; --timeout bounds each test, 5s by default.",
|
|
16
|
-
"",
|
|
17
|
-
"Exits 0 when every test passed, 1 when one failed or none was found, 2 on a usage error. What",
|
|
18
|
-
"it prints is for reading, not parsing.",
|
|
19
|
-
].join("\n");
|
|
12
|
+
export const testUsage = help.trimEnd();
|
|
20
13
|
|
|
21
14
|
export type TestCommand = { paths: string[]; bunArgs: string[] };
|
|
22
15
|
|
|
16
|
+
/**
|
|
17
|
+
* `awf test`: the workflow tests under the given paths, with Bun's test runner. Their output goes
|
|
18
|
+
* to `io.output` when given, else straight to this process's.
|
|
19
|
+
*/
|
|
20
|
+
export async function testCommand(
|
|
21
|
+
argv: readonly string[],
|
|
22
|
+
cwd: string,
|
|
23
|
+
io: Out & { output?: Out; signal?: AbortSignal },
|
|
24
|
+
): Promise<number> {
|
|
25
|
+
let tests: TestCommand | "help";
|
|
26
|
+
try {
|
|
27
|
+
tests = parseTestCommand(argv, cwd);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
io.stderr(`awf: ${messageOf(error)}\n\n${testUsage}`);
|
|
30
|
+
return 2;
|
|
31
|
+
}
|
|
32
|
+
if (tests === "help") {
|
|
33
|
+
io.stdout(testUsage);
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
return runWorkflowTests(tests, cwd, {
|
|
37
|
+
...(io.signal ? { signal: io.signal } : {}),
|
|
38
|
+
...(io.output ? { output: io.output } : {}),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
23
42
|
/**
|
|
24
43
|
* Only these flags pass through, so the published command is awf's, not the whole of `bun test`'s,
|
|
25
44
|
* and a later runner can take its place. A path must exist; Bun would take it as a filter.
|
|
@@ -91,6 +110,9 @@ export async function runWorkflowTests(
|
|
|
91
110
|
);
|
|
92
111
|
if (signal?.aborted) stop();
|
|
93
112
|
signal?.addEventListener("abort", stop, { once: true });
|
|
113
|
+
// awf exiting at a second signal does not wait for the tests, which would outlive it.
|
|
114
|
+
const orphaned = () => child.kill("SIGKILL");
|
|
115
|
+
process.on("exit", orphaned);
|
|
94
116
|
try {
|
|
95
117
|
if (output) {
|
|
96
118
|
const [out, err] = await Promise.all([
|
|
@@ -106,5 +128,6 @@ export async function runWorkflowTests(
|
|
|
106
128
|
return ended ? 128 + constants.signals[ended] : code;
|
|
107
129
|
} finally {
|
|
108
130
|
signal?.removeEventListener("abort", stop);
|
|
131
|
+
process.off("exit", orphaned);
|
|
109
132
|
}
|
|
110
133
|
}
|
package/src/testing.ts
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
|
-
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { createConnection } from "node:net";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import type { AttemptRecord } from "@agentswf/contract/records";
|
|
5
6
|
import { type ResultSubmitResponse, WIRE_VERSION } from "@agentswf/contract/wire";
|
|
6
|
-
import
|
|
7
|
+
import {
|
|
8
|
+
defineWorkflow,
|
|
9
|
+
type EventWorkflowContext,
|
|
10
|
+
type JsonValue,
|
|
11
|
+
type OutputSchema,
|
|
12
|
+
type WorkflowContext,
|
|
13
|
+
type WorkflowDefinition,
|
|
14
|
+
} from "@agentswf/contract/workflow";
|
|
7
15
|
import { createSingleSessionHostFactory } from "@agentswf/harness";
|
|
8
16
|
import type { AgentSessionAdapter } from "@agentswf/harness/adapter";
|
|
9
17
|
import { createFakeAdapter } from "@agentswf/harness/testing";
|
|
10
18
|
import Type from "typebox";
|
|
19
|
+
import { writeJson } from "./files";
|
|
11
20
|
import { OPERATOR_ALIASES } from "./operator-aliases";
|
|
12
|
-
import { createRun, openRun } from "./runs";
|
|
21
|
+
import { createRun, openRun, type Run, readAttempts } from "./runs";
|
|
13
22
|
import { type RunWorkflowOptions, runWorkflow, startWorkflow } from "./workflow-runner";
|
|
14
23
|
|
|
15
24
|
export function createTempRunDirs(): {
|
|
@@ -30,13 +39,72 @@ export function createTempRunDirs(): {
|
|
|
30
39
|
};
|
|
31
40
|
}
|
|
32
41
|
|
|
42
|
+
/** A project, `.awf/` at its root, with `source` at `flows/{name}`, and a home of its own. */
|
|
43
|
+
export function eventProject(
|
|
44
|
+
dirs: ReturnType<typeof createTempRunDirs>,
|
|
45
|
+
name: string,
|
|
46
|
+
source: string,
|
|
47
|
+
): { root: string; file: string; home: string } {
|
|
48
|
+
const root = dirs.tempRunDir();
|
|
49
|
+
mkdirSync(join(root, ".awf"));
|
|
50
|
+
mkdirSync(join(root, "flows"));
|
|
51
|
+
const file = join(root, "flows", name);
|
|
52
|
+
writeFileSync(file, source);
|
|
53
|
+
return { root, file, home: dirs.tempRunDir() };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Polls `check` until it holds; throws after `ms`. */
|
|
57
|
+
export async function until(check: () => Promise<boolean>, ms = 20_000): Promise<void> {
|
|
58
|
+
const end = Date.now() + ms;
|
|
59
|
+
while (!(await check())) {
|
|
60
|
+
if (Date.now() > end) throw new Error("timed out");
|
|
61
|
+
await Bun.sleep(50);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Writes the run's next attempt record, as `awf run` claims one, with `fields` over defaults. */
|
|
66
|
+
export async function writeAttempt(run: Run, fields: Partial<AttemptRecord>): Promise<void> {
|
|
67
|
+
const n = (await readAttempts(run)).length + 1;
|
|
68
|
+
mkdirSync(join(run.dir, "attempts"), { recursive: true });
|
|
69
|
+
await writeJson(join(run.dir, "attempts", `${n}.json`), {
|
|
70
|
+
version: 1,
|
|
71
|
+
attempt: n,
|
|
72
|
+
file: "/flows/case.ts",
|
|
73
|
+
flags: {},
|
|
74
|
+
started: new Date().toISOString(),
|
|
75
|
+
...fields,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const Changes: OutputSchema<{ case: string }> = {
|
|
80
|
+
type: "object",
|
|
81
|
+
properties: { case: { type: "string" } },
|
|
82
|
+
required: ["case"],
|
|
83
|
+
};
|
|
84
|
+
const caseEvents = { "dd.changes": Changes };
|
|
85
|
+
|
|
86
|
+
/** A workflow with one event, `dd.changes`, keyed by its case, whose body is `run`. */
|
|
87
|
+
export const caseRun = (
|
|
88
|
+
run: (workflow: EventWorkflowContext<typeof caseEvents>) => Promise<JsonValue>,
|
|
89
|
+
) =>
|
|
90
|
+
defineWorkflow({
|
|
91
|
+
meta: { name: "dd-case", description: "A case." },
|
|
92
|
+
file: import.meta.path,
|
|
93
|
+
events: caseEvents,
|
|
94
|
+
starts: ["dd.changes"],
|
|
95
|
+
run,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/** The options that start a `caseRun` under the key `k`. */
|
|
99
|
+
export const startCase = { id: "k", event: { name: "dd.changes" as const, data: { case: "k" } } };
|
|
100
|
+
|
|
33
101
|
export { COUNT_SCHEMA } from "@agentswf/contract/testing";
|
|
34
102
|
|
|
35
103
|
export function future(milliseconds = 60_000): { unixMilliseconds: number } {
|
|
36
104
|
return { unixMilliseconds: Date.now() + milliseconds };
|
|
37
105
|
}
|
|
38
106
|
|
|
39
|
-
/** Answers a call the way an agent's `
|
|
107
|
+
/** Answers a call the way an agent's `wfa` does: one framed request over its own socket. */
|
|
40
108
|
export async function submit(
|
|
41
109
|
binding: { endpoint: string; operationId: string },
|
|
42
110
|
value: unknown,
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { TickResultJson } from "@agentswf/contract/records";
|
|
2
|
+
import { messageOf } from "./errors";
|
|
3
|
+
import { exists } from "./files";
|
|
4
|
+
import help from "./help/tick.txt" with { type: "text" };
|
|
5
|
+
import { listProjects, projectRunRoot } from "./projects";
|
|
6
|
+
import type { Out } from "./run-commands";
|
|
7
|
+
import { parsePlace } from "./run-commands";
|
|
8
|
+
import { runLockFile, withRunLock } from "./run-lock";
|
|
9
|
+
import { listRuns } from "./run-state";
|
|
10
|
+
import { type Launch, wake } from "./wake";
|
|
11
|
+
|
|
12
|
+
export const tickUsage = help.trimEnd();
|
|
13
|
+
|
|
14
|
+
export async function tickCommand(
|
|
15
|
+
argv: readonly string[],
|
|
16
|
+
shellCwd: string,
|
|
17
|
+
out: Out,
|
|
18
|
+
context: {
|
|
19
|
+
home: string;
|
|
20
|
+
environment: Readonly<Record<string, string | undefined>>;
|
|
21
|
+
launch?: Launch;
|
|
22
|
+
now?: () => number;
|
|
23
|
+
},
|
|
24
|
+
): Promise<number> {
|
|
25
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
26
|
+
out.stdout(tickUsage);
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
let parsed: ReturnType<typeof parsePlace>;
|
|
30
|
+
try {
|
|
31
|
+
parsed = parsePlace(argv, shellCwd);
|
|
32
|
+
const unknown = parsed.rest.find((word) => word !== "--all");
|
|
33
|
+
if (unknown !== undefined) throw new Error(`tick takes no ${unknown}`);
|
|
34
|
+
if (parsed.rest.includes("--all") && parsed.place.repeat.length > 0)
|
|
35
|
+
throw new Error("--all wakes every project's runs; it takes no --cwd or --run-root");
|
|
36
|
+
} catch (error) {
|
|
37
|
+
out.stderr(`awf: ${messageOf(error)}\n\n${tickUsage}`);
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
40
|
+
const { place, json } = parsed;
|
|
41
|
+
const woke: TickResultJson["woke"] = [];
|
|
42
|
+
let failed = false;
|
|
43
|
+
const roots: string[] = [];
|
|
44
|
+
if (!parsed.rest.includes("--all")) roots.push(place.runRoot);
|
|
45
|
+
else
|
|
46
|
+
for (const project of await listProjects(context.home)) {
|
|
47
|
+
if (await exists(project)) roots.push(projectRunRoot(project));
|
|
48
|
+
else {
|
|
49
|
+
failed = true;
|
|
50
|
+
out.stderr(`awf: ${project}, in ~/.awf/projects, is gone; its runs are not woken`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const root of roots) {
|
|
54
|
+
for (const run of await listRuns(root)) {
|
|
55
|
+
if (!run.record.events) continue;
|
|
56
|
+
const ref = `${run.record.workflow}/${run.record.id}`;
|
|
57
|
+
try {
|
|
58
|
+
const { workflow, id } = run.record;
|
|
59
|
+
const why = await withRunLock(runLockFile(root, workflow, id), async () => {
|
|
60
|
+
const woken = await wake(run, { ...context, nudgeLive: false });
|
|
61
|
+
if (woken.why === "launch-failed") throw new Error(woken.error ?? "launch failed");
|
|
62
|
+
if ((woken.did !== "started" && woken.did !== "continued") || !woken.cause)
|
|
63
|
+
return undefined;
|
|
64
|
+
return { did: woken.did, why: woken.cause };
|
|
65
|
+
});
|
|
66
|
+
if (why) woke.push({ ref, ...why });
|
|
67
|
+
} catch (error) {
|
|
68
|
+
failed = true;
|
|
69
|
+
out.stderr(`awf: ${ref}: ${messageOf(error)}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (json) out.stdout(JSON.stringify({ v: 1, woke } satisfies TickResultJson));
|
|
74
|
+
else for (const each of woke) out.stdout(`${each.did} ${each.ref}: ${each.why}`);
|
|
75
|
+
return failed ? 1 : 0;
|
|
76
|
+
}
|