@agentswf/engine 0.1.0-beta.5 → 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 +28 -55
- package/src/agent-cli/client.ts +1 -27
- package/src/allowance-command.ts +8 -10
- package/src/attempt-ending.ts +4 -4
- package/src/attempt-output.ts +13 -31
- package/src/attempt-view.ts +33 -32
- 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 +4 -6
- package/src/coordinator-prompt.ts +4 -3
- package/src/coordinator-socket.ts +27 -28
- package/src/deadlines.ts +6 -40
- package/src/decisions/directory.ts +50 -37
- 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 +19 -33
- package/src/emit-command.ts +38 -22
- package/src/emit.ts +148 -59
- 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 +11 -7
- package/src/help/inbox.txt +5 -3
- package/src/help/logs.txt +7 -5
- package/src/help/open.txt +29 -0
- package/src/help/reply.txt +6 -8
- package/src/help/run.txt +13 -8
- 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 +5 -13
- package/src/herdr-workspace-marks.ts +7 -17
- package/src/holder-command.ts +54 -136
- package/src/in-lines.ts +28 -0
- package/src/inbox-command.ts +42 -56
- package/src/index.ts +1 -1
- package/src/init-command.ts +8 -17
- package/src/init-template/hello/workflow.ts.template +3 -2
- package/src/jsonl.ts +10 -6
- package/src/keep-alive.ts +7 -19
- package/src/logs-command.ts +134 -53
- package/src/message-operation.ts +6 -3
- package/src/messaging/messaging.ts +8 -10
- package/src/messaging/text.ts +2 -2
- 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 +206 -90
- package/src/operator-runtime.ts +24 -2
- package/src/ops.ts +270 -0
- package/src/pane-layout.ts +49 -96
- package/src/progress-view.ts +2 -2
- 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 +27 -31
- package/src/run-commands.ts +42 -114
- package/src/run-event-log.ts +1 -1
- package/src/run-lines.ts +159 -0
- package/src/run-lock.ts +57 -1
- package/src/run-policy/stage-plan.ts +154 -0
- package/src/run-policy/stages.ts +113 -0
- package/src/run-policy/waits.ts +452 -0
- package/src/run-prepare.ts +165 -41
- 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 +17 -11
- package/src/run-view.ts +73 -14
- package/src/runs-command.ts +81 -63
- package/src/runs.ts +284 -222
- 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 +2 -39
- package/src/spend.ts +333 -0
- package/src/stage-ledger.ts +111 -153
- package/src/stop-crashed.ts +6 -5
- package/src/stopped.ts +4 -4
- package/src/test-command.ts +37 -19
- package/src/testing/operation-events.ts +3 -23
- package/src/testing.ts +66 -18
- package/src/tick-command.ts +145 -15
- package/src/tools.ts +11 -30
- package/src/turn-ends.ts +37 -0
- package/src/waits.ts +651 -0
- package/src/wake.ts +84 -54
- package/src/workflow-loader.ts +49 -2
- package/src/workflow-runner.ts +525 -618
- package/src/workflow-test-preload.ts +4 -1
- package/src/workflow-testing/coordinators.ts +1 -1
- package/src/workflow-testing/fake-client.ts +387 -0
- package/src/workflow-testing/host.ts +28 -29
- package/src/workflow-testing/index.ts +9 -27
- package/src/workflow-testing/run-test.ts +201 -286
- package/src/workflow-testing/script.ts +7 -39
- package/src/deprecations.ts +0 -177
- package/src/hold-run-lock.fixture.ts +0 -8
- package/src/mailbox.ts +0 -972
- package/src/stage-plan.ts +0 -186
- package/src/workflow-testing/coordinator.typecheck.ts +0 -54
- package/src/workflow-testing/workflow-testing.typecheck.ts +0 -67
package/src/agent-cli/client.ts
CHANGED
|
@@ -1,37 +1,11 @@
|
|
|
1
1
|
import { createConnection, type Socket } from "node:net";
|
|
2
|
-
import {
|
|
3
|
-
type ControlRequest,
|
|
4
|
-
decodeResponseFor,
|
|
5
|
-
type ResponseFor,
|
|
6
|
-
type ResultSubmitRequest,
|
|
7
|
-
type ResultSubmitResponse,
|
|
8
|
-
type WaitingRequest,
|
|
9
|
-
type WaitingResponse,
|
|
10
|
-
} from "@agentswf/contract/wire";
|
|
2
|
+
import { type ControlRequest, decodeResponseFor, type ResponseFor } from "@agentswf/contract/wire";
|
|
11
3
|
|
|
12
4
|
const MAX_RESPONSE_BYTES = 256 * 1024;
|
|
13
5
|
|
|
14
6
|
/** The connection never opened: nothing was written, so nothing reached the engine. */
|
|
15
7
|
export class NotSentError extends Error {}
|
|
16
8
|
|
|
17
|
-
export function submitResult(
|
|
18
|
-
endpoint: string,
|
|
19
|
-
request: ResultSubmitRequest,
|
|
20
|
-
timeoutSeconds = 30,
|
|
21
|
-
connect: (endpoint: string) => Socket = createConnection,
|
|
22
|
-
): Promise<ResultSubmitResponse> {
|
|
23
|
-
return submitControl(endpoint, request, timeoutSeconds, connect);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function submitWaiting(
|
|
27
|
-
endpoint: string,
|
|
28
|
-
request: WaitingRequest,
|
|
29
|
-
timeoutSeconds = 30,
|
|
30
|
-
connect: (endpoint: string) => Socket = createConnection,
|
|
31
|
-
): Promise<WaitingResponse> {
|
|
32
|
-
return submitControl(endpoint, request, timeoutSeconds, connect);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
9
|
/** Sends one request and resolves with a response that answers its command. */
|
|
36
10
|
export function submitControl<R extends ControlRequest>(
|
|
37
11
|
endpoint: string,
|
package/src/allowance-command.ts
CHANGED
|
@@ -5,19 +5,21 @@ import type {
|
|
|
5
5
|
} from "@agentswf/contract/records";
|
|
6
6
|
import { HARNESS_NAMES, type Harness } from "@agentswf/harness";
|
|
7
7
|
import { planPrice } from "./accounting/plans";
|
|
8
|
-
import {
|
|
8
|
+
import type { Out } from "./command-line";
|
|
9
9
|
import help from "./help/allowance.txt" with { type: "text" };
|
|
10
|
-
import
|
|
10
|
+
import { asksJson, printRefusal } from "./refusals";
|
|
11
|
+
|
|
12
|
+
/** `awf allowance --json`'s `"v"`, which its refusals carry too. */
|
|
13
|
+
export const ALLOWANCE_V = 1 satisfies AllowanceReport["v"];
|
|
11
14
|
|
|
12
15
|
export const allowanceUsage = help.trimEnd();
|
|
13
16
|
|
|
14
|
-
|
|
17
|
+
type AllowanceCommand = { harnesses: Harness[]; json: boolean };
|
|
15
18
|
|
|
16
19
|
export function parseAllowanceCommand(argv: readonly string[]): AllowanceCommand {
|
|
17
20
|
const harnesses: Harness[] = [];
|
|
18
21
|
let json = false;
|
|
19
22
|
for (const arg of argv) {
|
|
20
|
-
if (arg === "-h" || arg === "--help") return "help";
|
|
21
23
|
if (arg === "--json") json = true;
|
|
22
24
|
else if ((HARNESS_NAMES as readonly string[]).includes(arg)) {
|
|
23
25
|
if (!harnesses.includes(arg as Harness)) harnesses.push(arg as Harness);
|
|
@@ -35,13 +37,9 @@ export async function allowanceCommand(
|
|
|
35
37
|
try {
|
|
36
38
|
command = parseAllowanceCommand(argv);
|
|
37
39
|
} catch (error) {
|
|
38
|
-
io
|
|
40
|
+
printRefusal(io, { json: asksJson(argv), v: ALLOWANCE_V }, error, "usage", allowanceUsage);
|
|
39
41
|
return 2;
|
|
40
42
|
}
|
|
41
|
-
if (command === "help") {
|
|
42
|
-
io.stdout(allowanceUsage);
|
|
43
|
-
return 0;
|
|
44
|
-
}
|
|
45
43
|
const report = await readReport(command.harnesses, io.read, io.now());
|
|
46
44
|
io.stdout(command.json ? JSON.stringify(report, null, 2) : describeReport(report));
|
|
47
45
|
return 0;
|
|
@@ -54,7 +52,7 @@ export async function readReport(
|
|
|
54
52
|
now: number,
|
|
55
53
|
): Promise<AllowanceReport> {
|
|
56
54
|
return {
|
|
57
|
-
v:
|
|
55
|
+
v: ALLOWANCE_V,
|
|
58
56
|
readAt: new Date(now).toISOString(),
|
|
59
57
|
harnesses: await Promise.all(harnesses.map(read)),
|
|
60
58
|
};
|
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,7 +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
|
|
39
|
+
choose?: readonly StageLine[];
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
/** What a started run came to: the result it returned, or what it threw. */
|
|
@@ -179,7 +179,7 @@ export function runOutcome(
|
|
|
179
179
|
const stop = failure instanceof WorkflowStopped ? failure : undefined;
|
|
180
180
|
const timedOut =
|
|
181
181
|
failure instanceof DeadlineExceededError &&
|
|
182
|
-
failure.deadline.
|
|
182
|
+
failure.deadline.getTime() === deadline?.unixMilliseconds;
|
|
183
183
|
const kind = cancellation ? "cancelled" : stop ? "stopped" : timedOut ? "timed-out" : "failed";
|
|
184
184
|
return { kind, ...(stop ? { stop } : {}), ...(cancellation ? { cancellation } : {}) };
|
|
185
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,
|
|
@@ -61,11 +60,11 @@ export async function keepRecords(
|
|
|
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 = {
|
|
@@ -90,7 +89,7 @@ export async function keepRecords(
|
|
|
90
89
|
await writeJson(output, record);
|
|
91
90
|
kept.output = output;
|
|
92
91
|
} catch (error) {
|
|
93
|
-
stderr(`awf:
|
|
92
|
+
stderr(`awf: out.json: ${messageOf(error)}`);
|
|
94
93
|
}
|
|
95
94
|
}
|
|
96
95
|
// Before the ending: once the attempt has ended, the next may start appending.
|
|
@@ -102,30 +101,13 @@ export async function keepRecords(
|
|
|
102
101
|
outcome: ending.kind,
|
|
103
102
|
}).catch((error) => stderr(`awf: the run's event log was not written: ${messageOf(error)}`));
|
|
104
103
|
// An event run's ending is written holding its run's lock, as parking's is: a sender after it sees
|
|
105
|
-
// the run returned and is refused, so what no wait took is
|
|
106
|
-
// handled, as the code returned after it.
|
|
104
|
+
// the run returned and is refused, so what no wait took is what its lines say.
|
|
107
105
|
const writeEnding = async () => {
|
|
108
|
-
const unacknowledged =
|
|
109
|
-
ending.kind === "completed" && attempt.run.record.events
|
|
110
|
-
? await readMailbox(dir).then(
|
|
111
|
-
// The event that started the run is `wf.started`'s, taken or not.
|
|
112
|
-
(records) => {
|
|
113
|
-
const state = foldMailbox(records);
|
|
114
|
-
return state.unacknowledged.filter((id) => id !== state.started?.id).length;
|
|
115
|
-
},
|
|
116
|
-
(error: unknown) => {
|
|
117
|
-
stderr(`awf: what no wait took was not counted: ${messageOf(error)}`);
|
|
118
|
-
return undefined;
|
|
119
|
-
},
|
|
120
|
-
)
|
|
121
|
-
: undefined;
|
|
122
106
|
await endAttempt(attempt, {
|
|
123
107
|
outcome: ending.kind,
|
|
124
|
-
...(
|
|
108
|
+
...(ending.kind === "completed" ? { value: ending.value } : {}),
|
|
125
109
|
...(unfinished ? { reason: unfinished.reason } : {}),
|
|
126
110
|
...at,
|
|
127
|
-
...(stages ? { stages } : {}),
|
|
128
|
-
...(settled ? { accounting: attemptAccounting(settled.accounting) } : {}),
|
|
129
111
|
});
|
|
130
112
|
};
|
|
131
113
|
try {
|
|
@@ -146,7 +128,7 @@ async function writeReport(
|
|
|
146
128
|
// An earlier attempt's report is gone with it: report.md is the last ended attempt's.
|
|
147
129
|
const none = () => rm(file, { force: true }).then(() => undefined);
|
|
148
130
|
if (!workflow.report) return none();
|
|
149
|
-
// 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
|
|
150
132
|
// the run, nor cost it its record.
|
|
151
133
|
try {
|
|
152
134
|
const markdown = workflow.report(
|
|
@@ -157,7 +139,7 @@ async function writeReport(
|
|
|
157
139
|
await writeWhole(file, `${markdown.trimEnd()}\n`);
|
|
158
140
|
return file;
|
|
159
141
|
} catch (error) {
|
|
160
|
-
stderr(`awf: report: ${messageOf(error)}; see
|
|
142
|
+
stderr(`awf: report: ${messageOf(error)}; see out.json instead`);
|
|
161
143
|
return undefined;
|
|
162
144
|
}
|
|
163
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,7 +70,7 @@ 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 }) } : {}),
|
|
@@ -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
|
+
}
|