@agentswf/engine 0.1.0-beta.4 → 0.1.0
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/LICENSE +21 -0
- package/README.md +16 -0
- package/package.json +13 -9
- package/src/accounting/format.ts +4 -3
- package/src/accounting/spent.ts +128 -0
- package/src/accounting/summary.ts +40 -32
- package/src/agent-cli/cli.ts +35 -22
- package/src/agent-cli/client.ts +1 -27
- package/src/allowance-command.ts +8 -10
- package/src/attempt-ending.ts +5 -7
- package/src/attempt-output.ts +15 -32
- package/src/attempt-view.ts +34 -33
- package/src/author-typebox.ts +13 -0
- package/src/client/checks.ts +94 -0
- package/src/client/exported.ts +17 -0
- package/src/client/index.ts +122 -0
- package/src/client/locate.ts +34 -0
- package/src/client/published.ts +3 -0
- package/src/client/runs.ts +337 -0
- package/src/command-line.ts +64 -0
- package/src/commands.ts +7 -1
- package/src/control-plane.ts +41 -11
- package/src/coordinator-prompt.ts +4 -3
- package/src/coordinator-socket.ts +41 -33
- package/src/deadlines.ts +40 -7
- package/src/decisions/directory.ts +51 -40
- package/src/decisions/fake.ts +1 -1
- package/src/decisions/openrouter.ts +4 -2
- package/src/decisions/seam.ts +4 -4
- package/src/display-path.ts +3 -3
- package/src/duration.ts +43 -27
- package/src/emit-command.ts +88 -58
- package/src/emit.ts +254 -56
- package/src/event-ids.ts +31 -0
- package/src/files.ts +44 -4
- package/src/hand-over.ts +150 -0
- package/src/help/allowance.txt +4 -3
- package/src/help/emit.txt +18 -6
- package/src/help/inbox.txt +6 -4
- package/src/help/logs.txt +9 -6
- package/src/help/open.txt +29 -0
- package/src/help/reply.txt +6 -8
- package/src/help/run.txt +30 -11
- package/src/help/runs.txt +4 -2
- package/src/help/tick.txt +9 -4
- package/src/herdr-placement.ts +3 -33
- package/src/herdr-run-session.ts +7 -15
- package/src/herdr-workspace-marks.ts +7 -17
- package/src/holder-command.ts +56 -133
- package/src/in-lines.ts +28 -0
- package/src/inbox-command.ts +40 -57
- package/src/index.ts +1 -1
- package/src/init-command.ts +8 -17
- package/src/init-template/hello/workflow.test.ts.template +3 -3
- package/src/init-template/hello/workflow.ts.template +5 -4
- package/src/jsonl.ts +10 -6
- package/src/keep-alive.ts +15 -12
- package/src/logs-command.ts +128 -47
- package/src/message-operation.ts +6 -3
- package/src/messaging/limits.ts +4 -4
- package/src/messaging/messaging.ts +31 -28
- package/src/messaging/routes.ts +1 -1
- package/src/messaging/text.ts +9 -7
- package/src/open-command.ts +770 -0
- package/src/operation-events.ts +4 -6
- package/src/operation-liveness.ts +19 -4
- package/src/operator-cli.ts +224 -94
- package/src/operator-runtime.ts +29 -7
- package/src/ops.ts +270 -0
- package/src/pane-layout.ts +105 -59
- package/src/progress-view.ts +10 -10
- package/src/project-engine.ts +79 -0
- package/src/projects.ts +13 -2
- package/src/refusals.ts +119 -0
- package/src/result-slots.ts +11 -31
- package/src/result-validation.ts +2 -2
- package/src/run-command.ts +45 -28
- package/src/run-commands.ts +43 -115
- package/src/run-event-log.ts +1 -1
- package/src/run-lines.ts +159 -0
- package/src/run-lock.ts +57 -1
- package/src/{stage-plan.ts → run-policy/stage-plan.ts} +46 -42
- package/src/run-policy/stages.ts +113 -0
- package/src/run-policy/waits.ts +452 -0
- package/src/run-prepare.ts +205 -43
- package/src/run-read.ts +109 -41
- package/src/run-state.ts +170 -50
- package/src/run-text.ts +54 -0
- package/src/run-unreachable.ts +18 -0
- package/src/run-usage.ts +18 -12
- package/src/run-view.ts +81 -17
- package/src/runs-command.ts +83 -63
- package/src/runs.ts +310 -212
- package/src/sandbox-homes.ts +65 -13
- package/src/sandboxes.ts +182 -28
- package/src/session-lines.ts +93 -0
- package/src/skills/run-skills.ts +8 -1
- package/src/sources.ts +28 -29
- package/src/spend.ts +333 -0
- package/src/stage-ledger.ts +119 -131
- package/src/stop-crashed.ts +6 -5
- package/src/stopped.ts +4 -4
- package/src/test-command.ts +36 -14
- package/src/testing/operation-events.ts +3 -23
- package/src/testing.ts +73 -26
- package/src/tick-command.ts +145 -15
- package/src/tools.ts +25 -16
- package/src/turn-ends.ts +37 -0
- package/src/waits.ts +651 -0
- package/src/wake.ts +84 -54
- package/src/workflow-loader.ts +54 -4
- package/src/workflow-runner.ts +805 -580
- package/src/workflow-test-preload.ts +4 -1
- package/src/workflow-testing/coordinators.ts +3 -3
- package/src/workflow-testing/fake-client.ts +387 -0
- package/src/workflow-testing/host.ts +121 -53
- package/src/workflow-testing/index.ts +33 -842
- package/src/workflow-testing/run-test.ts +1128 -0
- package/src/workflow-testing/script.ts +74 -45
- package/src/hold-run-lock.fixture.ts +0 -8
- package/src/mailbox.ts +0 -990
- package/src/workflow-testing/coordinator.typecheck.ts +0 -54
- package/src/workflow-testing/workflow-testing.typecheck.ts +0 -64
package/src/attempt-ending.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { constants } from "node:os";
|
|
2
|
-
import type {
|
|
2
|
+
import type { StageLine } from "@agentswf/contract/records";
|
|
3
3
|
import {
|
|
4
4
|
type AbsoluteDeadline,
|
|
5
5
|
type AttemptOutcome,
|
|
@@ -12,9 +12,9 @@ import {
|
|
|
12
12
|
import { PUBLISHED_PRICES } from "./accounting/prices";
|
|
13
13
|
import { summarizeRun } from "./accounting/summary";
|
|
14
14
|
import { messageOf } from "./errors";
|
|
15
|
-
import { RunParked } from "./mailbox";
|
|
16
15
|
import type { Paint } from "./progress-view";
|
|
17
16
|
import { FromStageUnreached, primaryFailure, WorkflowStopped } from "./stopped";
|
|
17
|
+
import { RunParked } from "./waits";
|
|
18
18
|
import {
|
|
19
19
|
type SettledRun,
|
|
20
20
|
WorkflowCancelledError,
|
|
@@ -36,9 +36,7 @@ export type AttemptEnd = {
|
|
|
36
36
|
* Given when the attempt never reached its `--from-stage`, which no one command goes on from:
|
|
37
37
|
* the run's stages to choose from.
|
|
38
38
|
*/
|
|
39
|
-
choose?: readonly
|
|
40
|
-
/** The stages whose values `--values` gives, to start at a later one. */
|
|
41
|
-
needs?: readonly StageNeed[];
|
|
39
|
+
choose?: readonly StageLine[];
|
|
42
40
|
};
|
|
43
41
|
|
|
44
42
|
/** What a started run came to: the result it returned, or what it threw. */
|
|
@@ -107,12 +105,12 @@ export function decideEnding(
|
|
|
107
105
|
reason: stop?.reason ?? errorDetail(error),
|
|
108
106
|
stages,
|
|
109
107
|
continue: goOn(stop, stages),
|
|
108
|
+
...(stop?.needs ? { needs: [...stop.needs] } : {}),
|
|
110
109
|
},
|
|
111
110
|
...(settled ? { settled } : {}),
|
|
112
111
|
exitCode: cancellation ? signalExitCode(cancellation.reason) : OUTCOMES[kind].exitCode!,
|
|
113
112
|
...(beside ? { alsoFailed: beside } : {}),
|
|
114
113
|
...(stop instanceof FromStageUnreached ? { choose: stop.recorded } : {}),
|
|
115
|
-
...(stop?.needs ? { needs: stop.needs } : {}),
|
|
116
114
|
};
|
|
117
115
|
}
|
|
118
116
|
|
|
@@ -181,7 +179,7 @@ export function runOutcome(
|
|
|
181
179
|
const stop = failure instanceof WorkflowStopped ? failure : undefined;
|
|
182
180
|
const timedOut =
|
|
183
181
|
failure instanceof DeadlineExceededError &&
|
|
184
|
-
failure.deadline.
|
|
182
|
+
failure.deadline.getTime() === deadline?.unixMilliseconds;
|
|
185
183
|
const kind = cancellation ? "cancelled" : stop ? "stopped" : timedOut ? "timed-out" : "failed";
|
|
186
184
|
return { kind, ...(stop ? { stop } : {}), ...(cancellation ? { cancellation } : {}) };
|
|
187
185
|
}
|
package/src/attempt-output.ts
CHANGED
|
@@ -5,12 +5,10 @@ import {
|
|
|
5
5
|
RUN_EVENT_VERSIONS,
|
|
6
6
|
} from "@agentswf/contract/records";
|
|
7
7
|
import type { Ending, JsonValue } from "@agentswf/contract/workflow";
|
|
8
|
-
import { attemptAccounting } from "./accounting/summary";
|
|
9
8
|
import type { AttemptEnd } from "./attempt-ending";
|
|
10
9
|
import { messageOf } from "./errors";
|
|
11
10
|
import { writeJson, writeWhole } from "./files";
|
|
12
|
-
import {
|
|
13
|
-
import { type Attempt, appendEvent, endAttempt, outputFile, reportFile } from "./runs";
|
|
11
|
+
import { type Attempt, appendEvent, endAttempt, outFile, reportFile } from "./runs";
|
|
14
12
|
import { withoutValue } from "./stage-ledger";
|
|
15
13
|
import type { AnyWorkflow, LoadedWorkflow } from "./workflow-loader";
|
|
16
14
|
|
|
@@ -18,15 +16,16 @@ import type { AnyWorkflow, LoadedWorkflow } from "./workflow-loader";
|
|
|
18
16
|
export type Kept = {
|
|
19
17
|
/** Absent when the run never settled, so there is nothing to record but its ending. */
|
|
20
18
|
json?: string;
|
|
21
|
-
/**
|
|
19
|
+
/** out.json, once written. */
|
|
22
20
|
output?: string;
|
|
23
21
|
report?: string;
|
|
24
22
|
};
|
|
25
23
|
|
|
26
24
|
/**
|
|
27
|
-
* Writes an attempt's records, each from its one ending: report.md
|
|
28
|
-
* its attempt file. The ending goes last: an ended
|
|
29
|
-
*
|
|
25
|
+
* Writes an attempt's records, each from its one ending: report.md and out.json, its views, then the
|
|
26
|
+
* ending in its attempt file, a completed one's with its value. The ending goes last: an ended
|
|
27
|
+
* attempt lets the next start, whose views this one's must not overwrite. A record that fails to
|
|
28
|
+
* write is said, and costs no other.
|
|
30
29
|
*/
|
|
31
30
|
export async function keepRecords(
|
|
32
31
|
end: AttemptEnd,
|
|
@@ -58,18 +57,19 @@ export async function keepRecords(
|
|
|
58
57
|
outcome: ending.kind,
|
|
59
58
|
reason: ending.reason,
|
|
60
59
|
...at,
|
|
61
|
-
...(
|
|
60
|
+
...(ending.needs ? { needs: ending.needs } : {}),
|
|
62
61
|
};
|
|
63
62
|
const kept: Kept = report ? { report } : {};
|
|
64
|
-
const output =
|
|
63
|
+
const output = outFile(dir);
|
|
65
64
|
if (!settled) {
|
|
66
65
|
// An earlier attempt's output is gone with it, as its report is.
|
|
67
66
|
await rm(output, { force: true }).catch((error) =>
|
|
68
|
-
stderr(`awf:
|
|
67
|
+
stderr(`awf: out.json: ${messageOf(error)}`),
|
|
69
68
|
);
|
|
70
69
|
} else {
|
|
71
70
|
const record: OutputRecord = {
|
|
72
71
|
version: OUTPUT_RECORD_VERSION,
|
|
72
|
+
ref: `${context.loaded.workflow.meta.name}/${settled.runId}`,
|
|
73
73
|
runId: settled.runId,
|
|
74
74
|
attempt: attempt.record.attempt,
|
|
75
75
|
workflow: { name: context.loaded.workflow.meta.name, file: context.loaded.file },
|
|
@@ -89,7 +89,7 @@ export async function keepRecords(
|
|
|
89
89
|
await writeJson(output, record);
|
|
90
90
|
kept.output = output;
|
|
91
91
|
} catch (error) {
|
|
92
|
-
stderr(`awf:
|
|
92
|
+
stderr(`awf: out.json: ${messageOf(error)}`);
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
// Before the ending: once the attempt has ended, the next may start appending.
|
|
@@ -101,30 +101,13 @@ export async function keepRecords(
|
|
|
101
101
|
outcome: ending.kind,
|
|
102
102
|
}).catch((error) => stderr(`awf: the run's event log was not written: ${messageOf(error)}`));
|
|
103
103
|
// An event run's ending is written holding its run's lock, as parking's is: a sender after it sees
|
|
104
|
-
// the run returned and is refused, so what no wait took is
|
|
105
|
-
// handled, as the code returned after it.
|
|
104
|
+
// the run returned and is refused, so what no wait took is what its lines say.
|
|
106
105
|
const writeEnding = async () => {
|
|
107
|
-
const unacknowledged =
|
|
108
|
-
ending.kind === "completed" && attempt.run.record.events
|
|
109
|
-
? await readMailbox(dir).then(
|
|
110
|
-
// The event that started the run is `wf.started`'s, taken or not.
|
|
111
|
-
(records) => {
|
|
112
|
-
const state = foldMailbox(records);
|
|
113
|
-
return state.unacknowledged.filter((id) => id !== state.started?.id).length;
|
|
114
|
-
},
|
|
115
|
-
(error: unknown) => {
|
|
116
|
-
stderr(`awf: what no wait took was not counted: ${messageOf(error)}`);
|
|
117
|
-
return undefined;
|
|
118
|
-
},
|
|
119
|
-
)
|
|
120
|
-
: undefined;
|
|
121
106
|
await endAttempt(attempt, {
|
|
122
107
|
outcome: ending.kind,
|
|
123
|
-
...(
|
|
108
|
+
...(ending.kind === "completed" ? { value: ending.value } : {}),
|
|
124
109
|
...(unfinished ? { reason: unfinished.reason } : {}),
|
|
125
110
|
...at,
|
|
126
|
-
...(stages ? { stages } : {}),
|
|
127
|
-
...(settled ? { accounting: attemptAccounting(settled.accounting) } : {}),
|
|
128
111
|
});
|
|
129
112
|
};
|
|
130
113
|
try {
|
|
@@ -145,7 +128,7 @@ async function writeReport(
|
|
|
145
128
|
// An earlier attempt's report is gone with it: report.md is the last ended attempt's.
|
|
146
129
|
const none = () => rm(file, { force: true }).then(() => undefined);
|
|
147
130
|
if (!workflow.report) return none();
|
|
148
|
-
// The result is still in
|
|
131
|
+
// The result is still in out.json and the attempt file; a report that cannot be rendered or saved should not fail
|
|
149
132
|
// the run, nor cost it its record.
|
|
150
133
|
try {
|
|
151
134
|
const markdown = workflow.report(
|
|
@@ -156,7 +139,7 @@ async function writeReport(
|
|
|
156
139
|
await writeWhole(file, `${markdown.trimEnd()}\n`);
|
|
157
140
|
return file;
|
|
158
141
|
} catch (error) {
|
|
159
|
-
stderr(`awf: report: ${messageOf(error)}; see
|
|
142
|
+
stderr(`awf: report: ${messageOf(error)}; see out.json instead`);
|
|
160
143
|
return undefined;
|
|
161
144
|
}
|
|
162
145
|
}
|
package/src/attempt-view.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AgentPaneRecord,
|
|
3
|
-
|
|
3
|
+
ClaimedAttempt,
|
|
4
4
|
RunAccounting,
|
|
5
|
+
StageLine,
|
|
5
6
|
StageNeed,
|
|
6
|
-
StageRecord,
|
|
7
7
|
} from "@agentswf/contract/records";
|
|
8
8
|
import type { Ending, JsonValue, StageSummary } from "@agentswf/contract/workflow";
|
|
9
9
|
import { ago, describeAccounting, describeAttempts } from "./accounting/format";
|
|
10
|
-
import {
|
|
10
|
+
import type { EarlierSpent } from "./accounting/spent";
|
|
11
|
+
import { attemptAccounting, sumAttempts } from "./accounting/summary";
|
|
11
12
|
import { type AttemptEnd, OUTCOMES } from "./attempt-ending";
|
|
12
13
|
import type { Kept } from "./attempt-output";
|
|
14
|
+
import { placeFlags, shellWord } from "./command-line";
|
|
13
15
|
import { pathFrom } from "./display-path";
|
|
14
16
|
import { messageOf } from "./errors";
|
|
15
17
|
import { ANSI, type Paint, PLAIN } from "./progress-view";
|
|
@@ -32,7 +34,9 @@ export function printEnding(
|
|
|
32
34
|
/** The run's id and folder. */
|
|
33
35
|
run: { id: string; dir: string };
|
|
34
36
|
n: number;
|
|
35
|
-
earlier: readonly
|
|
37
|
+
earlier: readonly ClaimedAttempt[];
|
|
38
|
+
/** What the attempts before this one spent, from their operations, priced now. */
|
|
39
|
+
spentBefore?: EarlierSpent;
|
|
36
40
|
json: boolean;
|
|
37
41
|
/** Whether the attempt redid from a stage, which no repeated stop then hints at. */
|
|
38
42
|
fromStage: boolean;
|
|
@@ -66,12 +70,12 @@ export function printEnding(
|
|
|
66
70
|
named: `${context.workflow.meta.name} ${context.run.id}`,
|
|
67
71
|
n: context.n,
|
|
68
72
|
...(settled ? { accounting: settled.accounting } : {}),
|
|
69
|
-
|
|
73
|
+
...(context.spentBefore ? { spentBefore: context.spentBefore } : {}),
|
|
70
74
|
stages: context.terminal === undefined,
|
|
71
75
|
records: pathFrom(shellCwd, context.run.dir, { home: context.home }),
|
|
72
76
|
...(kept.report ? { report: pathFrom(shellCwd, kept.report, { home: context.home }) } : {}),
|
|
73
77
|
...(end.choose?.length ? { choose: listRecorded(end.choose, context.now) } : {}),
|
|
74
|
-
...(end.needs ? { needs: end.needs } : {}),
|
|
78
|
+
...("needs" in end.ending && end.ending.needs ? { needs: end.ending.needs } : {}),
|
|
75
79
|
...(settled?.panes ? { panes: settled.panes } : {}),
|
|
76
80
|
// As the run's Herdr workspace is labelled.
|
|
77
81
|
runWorkspace: `awf ${context.workflow.meta.name} ${context.run.id} #${context.n}`,
|
|
@@ -105,7 +109,7 @@ function describeEnding(
|
|
|
105
109
|
named: string;
|
|
106
110
|
n: number;
|
|
107
111
|
accounting?: RunAccounting;
|
|
108
|
-
|
|
112
|
+
spentBefore?: EarlierSpent;
|
|
109
113
|
stages: boolean;
|
|
110
114
|
records: string;
|
|
111
115
|
report?: string;
|
|
@@ -127,7 +131,9 @@ function describeEnding(
|
|
|
127
131
|
context.named,
|
|
128
132
|
...(n > 1 ? [`attempt ${n}`] : []),
|
|
129
133
|
...(totals === undefined ? [] : [totals]),
|
|
130
|
-
...(n > 1 && accounting
|
|
134
|
+
...(n > 1 && accounting && context.spentBefore
|
|
135
|
+
? [runTotal(n, accounting, context.spentBefore)]
|
|
136
|
+
: []),
|
|
131
137
|
].join(" · ");
|
|
132
138
|
const word = OUTCOMES[ending.kind].word;
|
|
133
139
|
if (ending.kind === "completed") {
|
|
@@ -229,14 +235,11 @@ function whereKept(
|
|
|
229
235
|
: `${pane} in workspace "${label}" of herdr session ${session}`;
|
|
230
236
|
}
|
|
231
237
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
return describeAttempts(sumAttempts(recorded), n, {
|
|
238
|
-
interrupted,
|
|
239
|
-
ended: unknown.length - interrupted,
|
|
238
|
+
/** The run's total over its attempts: those before priced now, this one as it ran. */
|
|
239
|
+
function runTotal(n: number, current: RunAccounting, before: EarlierSpent): string {
|
|
240
|
+
return describeAttempts(sumAttempts([...before.recorded, attemptAccounting(current)]), n, {
|
|
241
|
+
interrupted: before.interrupted,
|
|
242
|
+
ended: before.ended,
|
|
240
243
|
});
|
|
241
244
|
}
|
|
242
245
|
|
|
@@ -261,7 +264,7 @@ function present(
|
|
|
261
264
|
*/
|
|
262
265
|
function sameStop(
|
|
263
266
|
ending: Ending<JsonValue>,
|
|
264
|
-
earlier: readonly
|
|
267
|
+
earlier: readonly ClaimedAttempt[],
|
|
265
268
|
): string | undefined {
|
|
266
269
|
if (ending.kind !== "stopped" || ending.stage !== undefined) return undefined;
|
|
267
270
|
const reused = ending.stages.at(-1)?.stage;
|
|
@@ -285,22 +288,25 @@ export function refusalMessage(id: string, refusal: AttemptRefusal, now: number)
|
|
|
285
288
|
if (refusal.kind === "running") {
|
|
286
289
|
return `attempt ${refusal.attempt.attempt} of ${id} is still running, as process ${refusal.attempt.pid}`;
|
|
287
290
|
}
|
|
291
|
+
if (refusal.kind === "opened") {
|
|
292
|
+
return `${id} is open in awf open --no-fork (pid ${refusal.pid}); ${refusal.events ? "it is woken once that exits" : "continue it once that exits"}`;
|
|
293
|
+
}
|
|
288
294
|
if (refusal.stages.length === 0) return `${id} completed; there is nothing to continue`;
|
|
289
295
|
return `${id} completed; to redo from a stage, --from-stage one of:\n${listRecorded(refusal.stages, now).join("\n")}`;
|
|
290
296
|
}
|
|
291
297
|
|
|
292
|
-
/** Stage
|
|
293
|
-
function listRecorded(
|
|
294
|
-
const name = Math.max(0, ...
|
|
295
|
-
const summary = Math.max(0, ...
|
|
296
|
-
return
|
|
298
|
+
/** Stage lines, one each, in the order given: summary, attempt, and how long ago. */
|
|
299
|
+
function listRecorded(lines: readonly StageLine[], now: number): string[] {
|
|
300
|
+
const name = Math.max(0, ...lines.map((line) => line.name.length));
|
|
301
|
+
const summary = Math.max(0, ...lines.map((line) => line.summary?.length ?? 0));
|
|
302
|
+
return lines.map((line) =>
|
|
297
303
|
[
|
|
298
|
-
` ${
|
|
299
|
-
...(summary > 0 ? [(
|
|
304
|
+
` ${line.name.padEnd(name)}`,
|
|
305
|
+
...(summary > 0 ? [(line.summary ?? "").padEnd(summary)] : []),
|
|
300
306
|
[
|
|
301
|
-
...(
|
|
302
|
-
`attempt ${
|
|
303
|
-
ago(now - Date.parse(
|
|
307
|
+
...(line.outcome === "succeeded" ? [] : [line.outcome]),
|
|
308
|
+
`attempt ${line.attempt}`,
|
|
309
|
+
ago(now - Date.parse(line.at)),
|
|
304
310
|
].join(" · "),
|
|
305
311
|
].join(" "),
|
|
306
312
|
);
|
|
@@ -349,8 +355,7 @@ export function continueCommand(
|
|
|
349
355
|
return [
|
|
350
356
|
"awf",
|
|
351
357
|
"run",
|
|
352
|
-
...(command
|
|
353
|
-
...(command.cwdGiven ? ["--cwd", command.cwd] : []),
|
|
358
|
+
...placeFlags(command),
|
|
354
359
|
pathFrom(command.shellCwd, command.workflowFile),
|
|
355
360
|
"--id",
|
|
356
361
|
id,
|
|
@@ -366,7 +371,3 @@ export function continueCommand(
|
|
|
366
371
|
const STAGE = "{stage}";
|
|
367
372
|
/** The placeholder for the file of values a go-on needs and none was given. */
|
|
368
373
|
const FILE = "{file}";
|
|
369
|
-
|
|
370
|
-
export function shellWord(word: string): string {
|
|
371
|
-
return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`;
|
|
372
|
-
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// `agentswf/typebox`: a TypeBox schema carrying its type to `run()`, a tool's `input` and an
|
|
2
|
+
// event's `data`. Here rather than in the contract, which imports no schema library.
|
|
3
|
+
import type { JsonValue, OutputSchema } from "@agentswf/contract/workflow";
|
|
4
|
+
import type Type from "typebox";
|
|
5
|
+
|
|
6
|
+
type Typed<Schema extends Type.TSchema> = Extract<Type.Static<Schema>, JsonValue>;
|
|
7
|
+
|
|
8
|
+
/** The schema itself, typed so awf infers its value; a schema of a non-JSON value is a type error. */
|
|
9
|
+
export function typed<Schema extends Type.TSchema>(
|
|
10
|
+
schema: Schema & (Type.Static<Schema> extends JsonValue ? unknown : never),
|
|
11
|
+
): Schema & OutputSchema<Typed<Schema>> {
|
|
12
|
+
return schema;
|
|
13
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The client's checks of what a program gives it that need no file: the fake client runs these
|
|
2
|
+
// too, so a program's tests see the very `ClientError` the real client throws.
|
|
3
|
+
import { isAbsolute } from "node:path";
|
|
4
|
+
import { ClientError } from "@agentswf/contract/client";
|
|
5
|
+
import { isEventWorkflow, isJsonValue, type JsonValue } from "@agentswf/contract/workflow";
|
|
6
|
+
import { messageOf } from "../errors";
|
|
7
|
+
import { idProblem } from "../runs";
|
|
8
|
+
import { type AnyWorkflow, assertJsonValue, type EventWorkflow } from "../workflow-loader";
|
|
9
|
+
|
|
10
|
+
/** `workflow`'s `file`, by which a program finds it and its runs. */
|
|
11
|
+
export function fileOf(workflow: AnyWorkflow): string {
|
|
12
|
+
const file = (workflow as { file?: unknown })?.file;
|
|
13
|
+
if (typeof file !== "string" || !isAbsolute(file))
|
|
14
|
+
throw new ClientError(
|
|
15
|
+
"file",
|
|
16
|
+
`${workflow?.meta?.name ?? "the workflow"} gives no file, by which a program finds it and its runs: file: import.meta.path`,
|
|
17
|
+
);
|
|
18
|
+
return file;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `workflow`'s `meta.name`, checked as an id is. */
|
|
22
|
+
export function nameOf(workflow: AnyWorkflow): string {
|
|
23
|
+
const name = workflow?.meta?.name;
|
|
24
|
+
const problem = idProblem(name);
|
|
25
|
+
if (problem) throw new ClientError("event", `meta.name: ${problem}`);
|
|
26
|
+
return name;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function assertEventWorkflow(workflow: AnyWorkflow): asserts workflow is EventWorkflow {
|
|
30
|
+
if (!isEventWorkflow(workflow))
|
|
31
|
+
throw new ClientError("event", `${workflow?.meta?.name} declares no events; start runs it`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What the types rule out in an emit, refused as the caller's mistake when it comes anyway. */
|
|
35
|
+
export function checkEmit(
|
|
36
|
+
ref: string,
|
|
37
|
+
runId: string,
|
|
38
|
+
name: string,
|
|
39
|
+
data: unknown,
|
|
40
|
+
given: { eventId?: string; source?: string },
|
|
41
|
+
): asserts data is JsonValue {
|
|
42
|
+
if (typeof name !== "string") throw new ClientError("event", "an event's name is a string", ref);
|
|
43
|
+
if (typeof runId !== "string") throw new ClientError("id", "a run's id is a string");
|
|
44
|
+
for (const [field, value] of [
|
|
45
|
+
["eventId", given.eventId],
|
|
46
|
+
["source", given.source],
|
|
47
|
+
] as const)
|
|
48
|
+
if (value !== undefined && typeof value !== "string")
|
|
49
|
+
throw new ClientError(field === "eventId" ? "id" : "event", `${field} is a string`, ref);
|
|
50
|
+
// What tsc lets through in a value that isn't a literal: in.jsonl keeps JSON only.
|
|
51
|
+
if (!isJsonValue(data))
|
|
52
|
+
throw new ClientError("event", `${name}'s data must contain only JSON values`, ref);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function checkArgs(name: string, args: unknown): asserts args is JsonValue {
|
|
56
|
+
try {
|
|
57
|
+
assertJsonValue(args, `${name} arguments`);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw new ClientError("prepare", messageOf(error));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The id `id(args)` gives, checked; a workflow with `starts` and none is refused. */
|
|
64
|
+
export function derivedId(workflow: AnyWorkflow, args: JsonValue): string | undefined {
|
|
65
|
+
const name = workflow.meta.name;
|
|
66
|
+
if (!workflow.id) {
|
|
67
|
+
if (isEventWorkflow(workflow) && workflow.starts !== undefined)
|
|
68
|
+
throw new ClientError(
|
|
69
|
+
"no-id",
|
|
70
|
+
`a run of ${name}, which starts on events, is named by its senders: start(${name}, args, { id }), or an id(args) on the workflow`,
|
|
71
|
+
);
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
let id: unknown;
|
|
75
|
+
try {
|
|
76
|
+
id = workflow.id(args as never);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new ClientError("prepare", `${name}'s id(args) failed: ${messageOf(error)}`);
|
|
79
|
+
}
|
|
80
|
+
const problem = typeof id === "string" ? idProblem(id) : `it returned a ${typeof id}`;
|
|
81
|
+
if (problem) throw new ClientError("id", `${name}'s id(args): ${problem}`);
|
|
82
|
+
return id as string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function checkRunId(id: string, ref: string): void {
|
|
86
|
+
const problem = idProblem(id);
|
|
87
|
+
if (problem) throw new ClientError("id", `the run's id: ${problem}`, ref);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A cursor is an item's, never an option of the command it is read with. */
|
|
91
|
+
export function checkCursor(after: unknown): void {
|
|
92
|
+
if (after !== undefined && (typeof after !== "string" || after === "" || after.startsWith("-")))
|
|
93
|
+
throw new ClientError("cursor", `${JSON.stringify(after)} is no cursor an item carried`);
|
|
94
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { messageOf } from "../errors";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Why `file` isn't the module whose default export is `workflow`, as awf loads it; undefined when
|
|
5
|
+
* it is. The program imported it already, so this import is its module cache's.
|
|
6
|
+
*/
|
|
7
|
+
export async function exportProblem(workflow: unknown, file: string): Promise<string | undefined> {
|
|
8
|
+
let exported: unknown;
|
|
9
|
+
try {
|
|
10
|
+
exported = ((await import(file)) as { default?: unknown }).default;
|
|
11
|
+
} catch (error) {
|
|
12
|
+
return `its file, ${file}, could not be imported: ${messageOf(error)}`;
|
|
13
|
+
}
|
|
14
|
+
return exported === workflow
|
|
15
|
+
? undefined
|
|
16
|
+
: `its file, ${file}, doesn't export it as its default, as awf loads a workflow; file is the path of the module whose default export it is`;
|
|
17
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { type Client, ClientError, type ClientOptions } from "@agentswf/contract/client";
|
|
3
|
+
import type { EmitResultJson, InboxItemJson } from "@agentswf/contract/records";
|
|
4
|
+
import { awfEventOf, type JsonValue } from "@agentswf/contract/workflow";
|
|
5
|
+
import { checkEvent, deliverReply, emitEvent } from "../emit";
|
|
6
|
+
import { inboxCommand } from "../inbox-command";
|
|
7
|
+
import { asClientError, clientCode } from "../refusals";
|
|
8
|
+
import type { WakeContext } from "../wake";
|
|
9
|
+
import type { AnyWorkflow, EventWorkflow } from "../workflow-loader";
|
|
10
|
+
import { assertEventWorkflow, checkCursor, checkEmit, checkRunId } from "./checks";
|
|
11
|
+
import { locate } from "./locate";
|
|
12
|
+
import { get, openExact, start } from "./runs";
|
|
13
|
+
|
|
14
|
+
/** What the client runs each call with: the operator's home and environment, and how it launches. */
|
|
15
|
+
export type ClientContext = Omit<WakeContext, "file"> & {
|
|
16
|
+
/** How often a wait reads the run's files. */
|
|
17
|
+
pollMs?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A program's way to its workflows' runs, in its own process: the code `awf emit`, `awf inbox` and
|
|
22
|
+
* the run readers run, on the engine the workflow's project depends on (ADR 0015).
|
|
23
|
+
*/
|
|
24
|
+
export function client(options: ClientOptions = {}): Client {
|
|
25
|
+
return clientWith(options);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `client`, with what a test fakes: how an attempt is launched, a live run nudged, and the time.
|
|
30
|
+
* Not published: a program that launched nothing would record events no run ever takes.
|
|
31
|
+
*/
|
|
32
|
+
export function clientWith(
|
|
33
|
+
options: ClientOptions & Pick<WakeContext, "launch" | "nudge" | "now"> & { pollMs?: number },
|
|
34
|
+
): Client {
|
|
35
|
+
const context: ClientContext = {
|
|
36
|
+
home: options.home ?? homedir(),
|
|
37
|
+
environment: options.environment ?? process.env,
|
|
38
|
+
...(options.launch ? { launch: options.launch } : {}),
|
|
39
|
+
...(options.nudge ? { nudge: options.nudge } : {}),
|
|
40
|
+
...(options.now ? { now: options.now } : {}),
|
|
41
|
+
...(options.pollMs === undefined ? {} : { pollMs: options.pollMs }),
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
emit: (workflow, run, name, data, given) =>
|
|
45
|
+
emit(workflow as EventWorkflow, run, name, data as JsonValue, given ?? {}, context),
|
|
46
|
+
inbox: (workflow, given) => inbox(workflow as AnyWorkflow, given?.after, context),
|
|
47
|
+
start: (workflow, args, given) =>
|
|
48
|
+
start(workflow as AnyWorkflow, args, given ?? {}, context) as never,
|
|
49
|
+
get: (workflow, id) => get(workflow as AnyWorkflow, id, context) as never,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function emit(
|
|
54
|
+
workflow: EventWorkflow,
|
|
55
|
+
runId: string,
|
|
56
|
+
name: string,
|
|
57
|
+
data: JsonValue,
|
|
58
|
+
given: { eventId?: string; source?: string },
|
|
59
|
+
context: ClientContext,
|
|
60
|
+
): Promise<EmitResultJson> {
|
|
61
|
+
assertEventWorkflow(workflow);
|
|
62
|
+
const located = await locate(workflow, context.home);
|
|
63
|
+
const ref = `${workflow.meta.name}/${runId}`;
|
|
64
|
+
checkEmit(ref, runId, name, data, given);
|
|
65
|
+
const event = {
|
|
66
|
+
name,
|
|
67
|
+
data,
|
|
68
|
+
...(given.eventId === undefined ? {} : { id: given.eventId }),
|
|
69
|
+
...(given.source === undefined ? {} : { source: given.source }),
|
|
70
|
+
};
|
|
71
|
+
try {
|
|
72
|
+
const reply = awfEventOf(name);
|
|
73
|
+
if (reply?.kind !== "reply")
|
|
74
|
+
return await emitEvent(workflow, { run: runId, ...event }, { from: "operator", ...context });
|
|
75
|
+
// Checked against the value given, not the run's file: the program holds the workflow, and no
|
|
76
|
+
// author surface is served to it.
|
|
77
|
+
checkEvent(workflow, event, true);
|
|
78
|
+
checkRunId(runId, ref);
|
|
79
|
+
const run = await openExact(located.runRoot, workflow.meta.name, runId, "id");
|
|
80
|
+
if (!run)
|
|
81
|
+
throw new ClientError(
|
|
82
|
+
"no-run",
|
|
83
|
+
`no run ${ref} in ${located.project}: a reply never starts one`,
|
|
84
|
+
ref,
|
|
85
|
+
);
|
|
86
|
+
return await deliverReply({ run }, reply.key, event, context);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw asClientError(error, ref);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function inbox(
|
|
93
|
+
workflow: AnyWorkflow,
|
|
94
|
+
after: string | undefined,
|
|
95
|
+
context: ClientContext,
|
|
96
|
+
): Promise<InboxItemJson[]> {
|
|
97
|
+
const located = await locate(workflow, context.home);
|
|
98
|
+
checkCursor(after);
|
|
99
|
+
const lines: string[] = [];
|
|
100
|
+
const errors: string[] = [];
|
|
101
|
+
// `awf inbox --workflow --json`'s own reader, so what a program reads is what the command lists.
|
|
102
|
+
const code = await inboxCommand(
|
|
103
|
+
[
|
|
104
|
+
"--workflow",
|
|
105
|
+
workflow.meta.name,
|
|
106
|
+
"--run-root",
|
|
107
|
+
located.runRoot,
|
|
108
|
+
"--json",
|
|
109
|
+
...(after === undefined ? [] : ["--after", after]),
|
|
110
|
+
],
|
|
111
|
+
located.project,
|
|
112
|
+
{ stdout: (text) => lines.push(text), stderr: (text) => errors.push(text) },
|
|
113
|
+
{ home: context.home },
|
|
114
|
+
);
|
|
115
|
+
const items = lines.flatMap((line) => line.split("\n")).filter((line) => line !== "");
|
|
116
|
+
if (code === 0) return items.map((line) => JSON.parse(line) as InboxItemJson);
|
|
117
|
+
const refused =
|
|
118
|
+
items.length > 0 ? (JSON.parse(items.at(-1)!) as { code?: string; error?: string }) : {};
|
|
119
|
+
const known = clientCode(refused.code);
|
|
120
|
+
if (known) throw new ClientError(known, refused.error ?? known);
|
|
121
|
+
throw new Error(refused.error ?? (errors.join("\n") || `awf inbox exited ${code}`));
|
|
122
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ClientError } from "@agentswf/contract/client";
|
|
2
|
+
import { messageOf } from "../errors";
|
|
3
|
+
import { checkProjectEngine } from "../project-engine";
|
|
4
|
+
import { projectOf, projectRunRoot } from "../projects";
|
|
5
|
+
import { asClientError } from "../refusals";
|
|
6
|
+
import type { AnyWorkflow } from "../workflow-loader";
|
|
7
|
+
import { fileOf, nameOf } from "./checks";
|
|
8
|
+
import { exportProblem } from "./exported";
|
|
9
|
+
|
|
10
|
+
/** A workflow as the client finds it: its file, checked to export it, and its project's runs. */
|
|
11
|
+
export type Found = { workflow: AnyWorkflow; file: string; project: string; runRoot: string };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where `workflow`'s runs are: its `file`, which must export it as its default, in a project that
|
|
15
|
+
* runs on this engine. `file` is imported, which the program's module cache already holds.
|
|
16
|
+
*/
|
|
17
|
+
export async function locate(workflow: AnyWorkflow, home: string): Promise<Found> {
|
|
18
|
+
const file = fileOf(workflow);
|
|
19
|
+
const exported = await exportProblem(workflow, file);
|
|
20
|
+
if (exported) throw new ClientError("file", `${workflow?.meta?.name}: ${exported}`);
|
|
21
|
+
nameOf(workflow);
|
|
22
|
+
let project: string;
|
|
23
|
+
try {
|
|
24
|
+
project = await projectOf(file, home);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
throw new ClientError("project", messageOf(error));
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
checkProjectEngine(project, { file, client: true });
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw asClientError(error);
|
|
32
|
+
}
|
|
33
|
+
return { workflow, file, project, runRoot: projectRunRoot(project) };
|
|
34
|
+
}
|