@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/emit.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import type { ClientErrorCode } from "@agentswf/contract/client";
|
|
3
4
|
import {
|
|
4
5
|
type EmitResultJson,
|
|
5
|
-
|
|
6
|
+
type EventLine,
|
|
7
|
+
emitOk,
|
|
6
8
|
type RunPanes,
|
|
7
9
|
} from "@agentswf/contract/records";
|
|
8
10
|
import { parseJsonSchema, validate } from "@agentswf/contract/schema";
|
|
@@ -17,18 +19,40 @@ import {
|
|
|
17
19
|
} from "@agentswf/contract/workflow";
|
|
18
20
|
import { messageOf } from "./errors";
|
|
19
21
|
import { exists } from "./files";
|
|
20
|
-
import {
|
|
22
|
+
import { appendIn, readIn } from "./in-lines";
|
|
23
|
+
import { checkProjectEngine, EngineRefused } from "./project-engine";
|
|
21
24
|
import { projectOf, projectRunRoot, recordProject } from "./projects";
|
|
22
25
|
import { runLockFile, withRunLock } from "./run-lock";
|
|
23
26
|
import { prepareArgs } from "./run-prepare";
|
|
24
27
|
import type { RunStatus } from "./run-state";
|
|
25
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
createRun,
|
|
30
|
+
idProblem,
|
|
31
|
+
openRun,
|
|
32
|
+
panesChanged,
|
|
33
|
+
type Run,
|
|
34
|
+
RunTaken,
|
|
35
|
+
readAttempts,
|
|
36
|
+
} from "./runs";
|
|
26
37
|
import { type WakeContext, type Woken, wake } from "./wake";
|
|
27
|
-
import { type EventWorkflow, loadWorkflowFile } from "./workflow-loader";
|
|
38
|
+
import { type EventWorkflow, loadWorkflowFile, WorkflowFileError } from "./workflow-loader";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What an `EmitError` is about, as a program's `ClientError` names it: the event, an id, the
|
|
42
|
+
* target's `prepare` or `id(args)`, its file, its project, the project's engine, or a run there
|
|
43
|
+
* started with other arguments or options.
|
|
44
|
+
*/
|
|
45
|
+
export type EmitCode = Extract<
|
|
46
|
+
ClientErrorCode,
|
|
47
|
+
"event" | "id" | "prepare" | "file" | "project" | "other-engine" | "no-engine" | "other-args"
|
|
48
|
+
>;
|
|
28
49
|
|
|
29
50
|
/** An event awf refuses to take at all: a name, shape or id no run of the target could have. */
|
|
30
51
|
export class EmitError extends Error {
|
|
31
|
-
constructor(
|
|
52
|
+
constructor(
|
|
53
|
+
message: string,
|
|
54
|
+
readonly code: EmitCode = "event",
|
|
55
|
+
) {
|
|
32
56
|
super(message);
|
|
33
57
|
this.name = "EmitError";
|
|
34
58
|
}
|
|
@@ -45,28 +69,35 @@ export type EmitContext = Omit<WakeContext, "file"> & {
|
|
|
45
69
|
*/
|
|
46
70
|
export async function emitEvent(
|
|
47
71
|
target: EventWorkflow,
|
|
48
|
-
event: { run: string; name: string; data: JsonValue; id?: string },
|
|
72
|
+
event: { run: string; name: string; data: JsonValue; id?: string; source?: string },
|
|
49
73
|
context: EmitContext,
|
|
50
74
|
): Promise<EmitResultJson> {
|
|
51
75
|
const workflow = target.meta.name;
|
|
52
|
-
const { name, data } = event;
|
|
76
|
+
const { name, data, source } = event;
|
|
53
77
|
checkEvent(target, event);
|
|
78
|
+
const badSource = source === undefined ? undefined : nameProblem(source);
|
|
79
|
+
if (badSource) throw new EmitError(`the source ${JSON.stringify(source)}: ${badSource}`);
|
|
54
80
|
const file = fileOf(target);
|
|
55
81
|
const id = event.id ?? `${AWF_ID_PREFIX}${randomBytes(8).toString("hex")}`;
|
|
56
82
|
const at = new Date(context.now?.() ?? Date.now()).toISOString();
|
|
57
83
|
const runId = event.run;
|
|
58
84
|
const problem = idProblem(runId);
|
|
59
|
-
if (problem) throw new EmitError(`the run's id: ${problem}
|
|
60
|
-
const project = await
|
|
61
|
-
throw new EmitError(messageOf(error));
|
|
62
|
-
});
|
|
85
|
+
if (problem) throw new EmitError(`the run's id: ${problem}`, "id");
|
|
86
|
+
const project = await targetProject(file, context.home);
|
|
63
87
|
const starts = target.starts?.includes(name) ?? true;
|
|
64
88
|
return deliver(
|
|
65
89
|
{
|
|
66
90
|
runRoot: projectRunRoot(project),
|
|
67
91
|
workflow,
|
|
68
92
|
runId,
|
|
69
|
-
event: {
|
|
93
|
+
event: {
|
|
94
|
+
id,
|
|
95
|
+
name,
|
|
96
|
+
data,
|
|
97
|
+
from: context.from,
|
|
98
|
+
...(source === undefined ? {} : { source }),
|
|
99
|
+
at,
|
|
100
|
+
},
|
|
70
101
|
deduplicate: event.id !== undefined,
|
|
71
102
|
file,
|
|
72
103
|
...(starts ? { start: { project, target } } : {}),
|
|
@@ -79,11 +110,11 @@ export async function emitEvent(
|
|
|
79
110
|
* `awf emit {ref} {key}:reply …`: a participant's message to the code of a run that is there,
|
|
80
111
|
* checked against the definition in the file a wake would run, its last attempt's, and delivered.
|
|
81
112
|
* It never starts a run, and doesn't ask whether the participant has connected: one sent before
|
|
82
|
-
* the run connects it waits in
|
|
113
|
+
* the run connects it waits in `in.jsonl` for the run's next wait.
|
|
83
114
|
*/
|
|
84
115
|
export async function emitToRun(
|
|
85
116
|
status: RunStatus,
|
|
86
|
-
event: { name: string; data: JsonValue; id?: string },
|
|
117
|
+
event: { name: string; data: JsonValue; id?: string; source?: string },
|
|
87
118
|
context: Omit<WakeContext, "file">,
|
|
88
119
|
): Promise<EmitResultJson> {
|
|
89
120
|
const { run, ref } = status;
|
|
@@ -98,10 +129,13 @@ export async function emitToRun(
|
|
|
98
129
|
if (status.attempts.at(-1)?.outcome === "completed")
|
|
99
130
|
return deliverReply(status, reply.key, event, context);
|
|
100
131
|
const file = status.attempts.at(-1)?.file ?? run.record.file;
|
|
101
|
-
if (file === undefined)
|
|
102
|
-
|
|
132
|
+
if (file === undefined)
|
|
133
|
+
throw new WorkflowFileError(`${ref} has no file to check the event against`);
|
|
134
|
+
const { workflow } = await loadWorkflowFile(file, run.record.cwd, context.home);
|
|
103
135
|
if (!isEventWorkflow(workflow) || workflow.meta.name !== run.record.workflow)
|
|
104
|
-
throw new
|
|
136
|
+
throw new WorkflowFileError(
|
|
137
|
+
`${file} is no longer ${run.record.workflow} with events, which ${ref} ran`,
|
|
138
|
+
);
|
|
105
139
|
checkEvent(workflow, event, true);
|
|
106
140
|
return deliverReply(status, reply.key, event, context);
|
|
107
141
|
}
|
|
@@ -111,12 +145,14 @@ export async function emitToRun(
|
|
|
111
145
|
* The caller has checked it.
|
|
112
146
|
*/
|
|
113
147
|
export function deliverReply(
|
|
114
|
-
status: RunStatus,
|
|
148
|
+
status: Pick<RunStatus, "run">,
|
|
115
149
|
key: string,
|
|
116
|
-
event: { data: JsonValue; id?: string },
|
|
150
|
+
event: { data: JsonValue; id?: string; source?: string },
|
|
117
151
|
context: Omit<WakeContext, "file">,
|
|
118
152
|
): Promise<EmitResultJson> {
|
|
119
153
|
const { run } = status;
|
|
154
|
+
const problem = event.source === undefined ? undefined : nameProblem(event.source);
|
|
155
|
+
if (problem) throw new EmitError(`the source ${JSON.stringify(event.source)}: ${problem}`);
|
|
120
156
|
const at = new Date(context.now?.() ?? Date.now()).toISOString();
|
|
121
157
|
const id = event.id ?? `${AWF_ID_PREFIX}${randomBytes(8).toString("hex")}`;
|
|
122
158
|
return deliver(
|
|
@@ -124,7 +160,10 @@ export function deliverReply(
|
|
|
124
160
|
runRoot: run.root,
|
|
125
161
|
workflow: run.record.workflow,
|
|
126
162
|
runId: run.record.id,
|
|
127
|
-
event:
|
|
163
|
+
event: {
|
|
164
|
+
...replyRecord(key, id, event.data, at),
|
|
165
|
+
...(event.source === undefined ? {} : { source: event.source }),
|
|
166
|
+
},
|
|
128
167
|
deduplicate: event.id !== undefined,
|
|
129
168
|
},
|
|
130
169
|
context,
|
|
@@ -136,7 +175,7 @@ export type Delivery = {
|
|
|
136
175
|
runRoot: string;
|
|
137
176
|
workflow: string;
|
|
138
177
|
runId: string;
|
|
139
|
-
event: { id: string; name: string; data: JsonValue; from: string; at: string };
|
|
178
|
+
event: { id: string; name: string; data: JsonValue; from: string; source?: string; at: string };
|
|
140
179
|
/** False for an id awf made at random, which nothing repeats. */
|
|
141
180
|
deduplicate: boolean;
|
|
142
181
|
/** Given when the event may start the run: what a new run records, and the workflow it prepares. */
|
|
@@ -147,10 +186,10 @@ export type Delivery = {
|
|
|
147
186
|
|
|
148
187
|
/**
|
|
149
188
|
* The one way in, for every event a run of a workflow with events gets: a sender's, a participant's reply, or awf's
|
|
150
|
-
* own. Under the run's lock it finds a duplicate, refuses the event, or
|
|
151
|
-
* the
|
|
189
|
+
* own. Under the run's lock it finds a duplicate, refuses the event, or appends the event, to a run
|
|
190
|
+
* there or into a new one's `in.jsonl` before the run exists, and wakes the run.
|
|
152
191
|
*/
|
|
153
|
-
|
|
192
|
+
async function deliver(
|
|
154
193
|
delivery: Delivery,
|
|
155
194
|
context: Omit<WakeContext, "file">,
|
|
156
195
|
): Promise<EmitResultJson> {
|
|
@@ -163,27 +202,46 @@ export async function deliver(
|
|
|
163
202
|
: undefined;
|
|
164
203
|
// What was due is `awf tick`'s to say; an emit's caller knows it sent an event.
|
|
165
204
|
const result = (outcome: EmitResultJson["outcome"], { cause: _, ...woken }: Woken) =>
|
|
166
|
-
({
|
|
205
|
+
({
|
|
206
|
+
v: 1,
|
|
207
|
+
ok: emitOk(outcome, woken.why),
|
|
208
|
+
outcome,
|
|
209
|
+
ref,
|
|
210
|
+
eventId: event.id,
|
|
211
|
+
...woken,
|
|
212
|
+
}) satisfies EmitResultJson;
|
|
167
213
|
if (run) {
|
|
214
|
+
// A disk that doesn't tell case apart opens another run for an id differing only in case.
|
|
215
|
+
if (run.record.id !== runId)
|
|
216
|
+
throw new EmitError(
|
|
217
|
+
`${ref} differs only in case from ${run.record.id}, which is there`,
|
|
218
|
+
"id",
|
|
219
|
+
);
|
|
220
|
+
if (!run.record.events)
|
|
221
|
+
throw new EmitError(`${ref} is a run of a workflow without events, which take none`);
|
|
168
222
|
// Not appended again, but it still wakes the run: a sender retrying after a launch that failed
|
|
169
223
|
// sends the same id.
|
|
170
|
-
if (delivery.deduplicate &&
|
|
224
|
+
if (delivery.deduplicate && (await readIn(run.dir)).some((line) => line.id === event.id))
|
|
171
225
|
return result("duplicate", await wake(run, { ...woken, nudgeLive: false }));
|
|
172
226
|
if ((await readAttempts(run)).at(-1)?.outcome === "completed")
|
|
173
227
|
return result("refused", { did: "none", why: "returned" });
|
|
174
228
|
}
|
|
175
|
-
|
|
176
|
-
|
|
229
|
+
if (run) await appendIn(run.dir, event);
|
|
230
|
+
else {
|
|
177
231
|
if (!start) return result("refused", { did: "none", why: "not-a-start" });
|
|
178
232
|
checkStartArgs(start.target, start.project);
|
|
179
|
-
|
|
233
|
+
// In the folder before it is renamed into place: no run exists without its first event.
|
|
234
|
+
const first: EventLine = { ...event, starts: true };
|
|
235
|
+
try {
|
|
236
|
+
run = await createEventRun(runRoot, workflow, runId, start.project, file, context, [], {
|
|
237
|
+
first,
|
|
238
|
+
});
|
|
239
|
+
} catch (error) {
|
|
240
|
+
// A disk that tells case apart: another case's run is found only as the folder is claimed.
|
|
241
|
+
if (error instanceof RunTaken) throw new EmitError(messageOf(error), "id");
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
180
244
|
}
|
|
181
|
-
await appendMailbox(run.dir, {
|
|
182
|
-
version: MAILBOX_RECORD_VERSION,
|
|
183
|
-
kind: "event",
|
|
184
|
-
...event,
|
|
185
|
-
...(starts ? { starts: true as const } : {}),
|
|
186
|
-
});
|
|
187
245
|
return result("accepted", await wake(run, woken));
|
|
188
246
|
});
|
|
189
247
|
}
|
|
@@ -198,6 +256,7 @@ function checkStartArgs(target: EventWorkflow, project: string): void {
|
|
|
198
256
|
} catch (error) {
|
|
199
257
|
throw new EmitError(
|
|
200
258
|
`${target.meta.name}: a run an event starts has no arguments, which its prepare refuses: ${messageOf(error)}`,
|
|
259
|
+
"prepare",
|
|
201
260
|
);
|
|
202
261
|
}
|
|
203
262
|
}
|
|
@@ -211,20 +270,31 @@ async function createEventRun(
|
|
|
211
270
|
file: string | undefined,
|
|
212
271
|
context: Pick<WakeContext, "home" | "environment">,
|
|
213
272
|
argv: readonly string[] = [],
|
|
214
|
-
panes?: RunPanes,
|
|
273
|
+
given: { panes?: RunPanes; first?: EventLine } = {},
|
|
215
274
|
): Promise<Run> {
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
275
|
+
const { panes, first } = given;
|
|
276
|
+
const run = await createRun(
|
|
277
|
+
runRoot,
|
|
278
|
+
{
|
|
279
|
+
id,
|
|
280
|
+
workflow,
|
|
281
|
+
argv: [...argv],
|
|
282
|
+
cwd: project,
|
|
283
|
+
sandbox: null,
|
|
284
|
+
events: true,
|
|
285
|
+
env: { PATH: context.environment.PATH ?? "" },
|
|
286
|
+
...(file ? { file } : {}),
|
|
287
|
+
...(panes ? { panes } : {}),
|
|
288
|
+
},
|
|
289
|
+
undefined,
|
|
290
|
+
first,
|
|
291
|
+
);
|
|
292
|
+
try {
|
|
293
|
+
await recordProject(context.home, project);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
// awf's own record of projects, not the run's: `internal-error`, as `awf run` refuses it.
|
|
296
|
+
throw new Error(`~/.awf/projects was not written: ${messageOf(error)}`);
|
|
297
|
+
}
|
|
228
298
|
return run;
|
|
229
299
|
}
|
|
230
300
|
|
|
@@ -246,11 +316,9 @@ export async function openEventRun(
|
|
|
246
316
|
): Promise<Opened> {
|
|
247
317
|
const workflow = target.meta.name;
|
|
248
318
|
const problem = idProblem(id);
|
|
249
|
-
if (problem) throw new EmitError(problem);
|
|
319
|
+
if (problem) throw new EmitError(problem, "id");
|
|
250
320
|
const file = fileOf(target);
|
|
251
|
-
const project = await
|
|
252
|
-
throw new EmitError(messageOf(error));
|
|
253
|
-
});
|
|
321
|
+
const project = await targetProject(file, context.home);
|
|
254
322
|
const runRoot = projectRunRoot(project);
|
|
255
323
|
const ref = `${workflow}/${id}`;
|
|
256
324
|
return withRunLock(runLockFile(runRoot, workflow, id), async () => {
|
|
@@ -261,22 +329,39 @@ export async function openEventRun(
|
|
|
261
329
|
return { run: ref, created: false, did: "none", why: "returned" };
|
|
262
330
|
if (argv.length > 0 && JSON.stringify(argv) !== JSON.stringify(run.record.argv))
|
|
263
331
|
throw new EmitError(
|
|
264
|
-
`${ref} is there already, started with ${run.record.argv.length > 0 ? "arguments of its own" : "none"}; awf run --id ${id} with none wakes it`,
|
|
332
|
+
`${ref} is there already, started with ${run.record.argv.length > 0 || run.record.args !== undefined ? "arguments of its own" : "none"}; awf run --id ${id} with none wakes it`,
|
|
333
|
+
"other-args",
|
|
265
334
|
);
|
|
266
335
|
const changed = panesChanged(run.record, panes);
|
|
267
|
-
if (changed) throw new EmitError(changed);
|
|
336
|
+
if (changed) throw new EmitError(changed, "other-args");
|
|
268
337
|
return { run: ref, created: false, ...(await wake(run, woken)) };
|
|
269
338
|
}
|
|
270
339
|
try {
|
|
271
340
|
prepareArgs(target, argv, project);
|
|
272
341
|
} catch (error) {
|
|
273
|
-
throw new EmitError(`prepare: ${messageOf(error)}
|
|
342
|
+
throw new EmitError(`prepare: ${messageOf(error)}`, "prepare");
|
|
274
343
|
}
|
|
275
|
-
const run = await createEventRun(runRoot, workflow, id, project, file, context, argv,
|
|
344
|
+
const run = await createEventRun(runRoot, workflow, id, project, file, context, argv, {
|
|
345
|
+
...(panes ? { panes } : {}),
|
|
346
|
+
});
|
|
276
347
|
return { run: ref, created: true, ...(await wake(run, woken)) };
|
|
277
348
|
});
|
|
278
349
|
}
|
|
279
350
|
|
|
351
|
+
/** The project `file`'s runs live in, which must run on this engine (ADR 0015). */
|
|
352
|
+
async function targetProject(file: string, home: string): Promise<string> {
|
|
353
|
+
const project = await projectOf(file, home).catch((error: unknown) => {
|
|
354
|
+
throw new EmitError(messageOf(error), "project");
|
|
355
|
+
});
|
|
356
|
+
try {
|
|
357
|
+
checkProjectEngine(project, { file });
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (error instanceof EngineRefused) throw new EmitError(error.message, error.code);
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
return project;
|
|
363
|
+
}
|
|
364
|
+
|
|
280
365
|
/**
|
|
281
366
|
* The target's file, which says which project its runs live in and which a start or wake runs:
|
|
282
367
|
* `file`, or the path awf loaded it from. Only a sender holding the workflow itself, as `wf.emit`
|
|
@@ -286,12 +371,13 @@ function fileOf(target: EventWorkflow): string {
|
|
|
286
371
|
if (target.file === undefined)
|
|
287
372
|
throw new EmitError(
|
|
288
373
|
`${target.meta.name} gives no file, by which a sender holding it finds it: file: import.meta.path`,
|
|
374
|
+
"file",
|
|
289
375
|
);
|
|
290
376
|
return target.file;
|
|
291
377
|
}
|
|
292
378
|
|
|
293
379
|
/**
|
|
294
|
-
* `{key}:reply`'s event as
|
|
380
|
+
* `{key}:reply`'s event as `in.jsonl` records it: `participant:reply` from its key, whatever the
|
|
295
381
|
* workflow declares it as, so a wait takes it by one name.
|
|
296
382
|
*/
|
|
297
383
|
export function replyRecord(
|
|
@@ -316,7 +402,10 @@ export function checkEvent(
|
|
|
316
402
|
const problem = eventWorkflowProblem(target);
|
|
317
403
|
if (problem) throw new EmitError(`${workflow}: ${problem}`);
|
|
318
404
|
if (target.file !== undefined && !isAbsolute(target.file))
|
|
319
|
-
throw new EmitError(
|
|
405
|
+
throw new EmitError(
|
|
406
|
+
`${workflow}: its file, ${target.file}, is import.meta.path, absolute`,
|
|
407
|
+
"file",
|
|
408
|
+
);
|
|
320
409
|
const reply = awfEventOf(event.name)?.kind === "reply";
|
|
321
410
|
if (event.name.includes(":") && !(replies && reply))
|
|
322
411
|
throw new EmitError(
|
|
@@ -343,7 +432,7 @@ export function checkEvent(
|
|
|
343
432
|
`${event.name}'s data does not fit its schema: ${errors.map((e) => `${e.path || "/"}: ${e.message}`).join("; ")}`,
|
|
344
433
|
);
|
|
345
434
|
const badId = event.id === undefined ? undefined : eventIdProblem(event.id);
|
|
346
|
-
if (badId) throw new EmitError(`the id ${JSON.stringify(event.id)}: ${badId}
|
|
435
|
+
if (badId) throw new EmitError(`the id ${JSON.stringify(event.id)}: ${badId}`, "id");
|
|
347
436
|
}
|
|
348
437
|
|
|
349
438
|
/** What is wrong with an id a sender gives an event, if anything: ids starting `awf.` are awf's. */
|
package/src/event-ids.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { AWF_ID_PREFIX } from "@agentswf/contract/workflow";
|
|
2
|
+
import type { DerivedIds } from "./run-policy/waits";
|
|
3
|
+
|
|
4
|
+
function hashed(kind: string, text: string): string {
|
|
5
|
+
const hash = new Bun.CryptoHasher("sha256").update(text).digest("hex");
|
|
6
|
+
return `${AWF_ID_PREFIX}${kind}.${hash.slice(0, 16)}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** The id timer `name` is taken under: made from its name, so it fires once. */
|
|
10
|
+
export function timerEventId(name: string): string {
|
|
11
|
+
return hashed("timer", name);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The id of branch `branch`'s time read from state, `due` an ISO time: made from both, so a time is
|
|
16
|
+
* returned once by a branch of that name, whichever loop reads it.
|
|
17
|
+
*/
|
|
18
|
+
export function atEventId(branch: string, due: string): string {
|
|
19
|
+
return hashed("at", `${branch}\n${due}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The id of the end of `agent`'s turn `turn`, begun in attempt `began`: made from the three, so it
|
|
24
|
+
* fits the grammar whatever each is, and is the same whoever writes it, so one end is ever kept.
|
|
25
|
+
*/
|
|
26
|
+
export function endIdOf(agent: string, turn: string, began: number): string {
|
|
27
|
+
return hashed("end", `${agent}\0${turn}\0${began}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The derived ids, as the waits' policy takes them. */
|
|
31
|
+
export const DERIVED_IDS: DerivedIds = { timer: timerEventId, at: atEventId };
|
package/src/files.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { realpathSync } from "node:fs";
|
|
2
|
+
import { realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { link, lstat, open, rename, unlink } from "node:fs/promises";
|
|
4
|
-
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
|
|
6
6
|
/** `path` with its symlinks resolved, or only made absolute when it doesn't exist. */
|
|
7
7
|
export function realPath(path: string): string {
|
|
@@ -32,14 +32,38 @@ export async function writeWhole(path: string, text: string): Promise<void> {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* `text` at `path` whole, synchronously, for a writer that must not wait between two steps: a temp
|
|
37
|
+
* file renamed into place, mode `0600`. Not synced: it needs only to exist and be whole.
|
|
38
|
+
*/
|
|
39
|
+
export function writeWholeSync(path: string, text: string): void {
|
|
40
|
+
const temporary = temporaryBeside(path);
|
|
41
|
+
try {
|
|
42
|
+
writeFileSync(temporary, text, { mode: 0o600, flag: "wx" });
|
|
43
|
+
renameSync(temporary, path);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
try {
|
|
46
|
+
unlinkSync(temporary);
|
|
47
|
+
} catch {
|
|
48
|
+
// Never made, or gone already.
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
35
54
|
/**
|
|
36
55
|
* Links a complete file holding `value` to `path`, which fails when `path` exists: the first to
|
|
37
56
|
* link holds it. False when it was taken.
|
|
38
57
|
*/
|
|
39
|
-
export
|
|
58
|
+
export function linkNew(path: string, value: unknown): Promise<boolean> {
|
|
59
|
+
return linkNewText(path, jsonText(value));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** As `linkNew`, for text. */
|
|
63
|
+
export async function linkNewText(path: string, text: string): Promise<boolean> {
|
|
40
64
|
const temporary = temporaryBeside(path);
|
|
41
65
|
try {
|
|
42
|
-
await writeSynced(temporary,
|
|
66
|
+
await writeSynced(temporary, text);
|
|
43
67
|
await link(temporary, path);
|
|
44
68
|
return true;
|
|
45
69
|
} catch (error) {
|
|
@@ -62,6 +86,22 @@ async function writeSynced(path: string, text: string): Promise<void> {
|
|
|
62
86
|
}
|
|
63
87
|
}
|
|
64
88
|
|
|
89
|
+
/** Makes the entries of folder `dir`, a rename or link into it, durable. */
|
|
90
|
+
export async function syncDir(dir: string): Promise<void> {
|
|
91
|
+
const handle = await open(dir, "r");
|
|
92
|
+
try {
|
|
93
|
+
await handle.sync();
|
|
94
|
+
} finally {
|
|
95
|
+
await handle.close();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Whether `path` is below `root`, not `root` itself; a child named `..x` is below it. */
|
|
100
|
+
export function isInside(root: string, path: string): boolean {
|
|
101
|
+
const inside = relative(root, path);
|
|
102
|
+
return inside !== "" && inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside);
|
|
103
|
+
}
|
|
104
|
+
|
|
65
105
|
/** Whether `path` names anything, a dangling link included. */
|
|
66
106
|
export async function exists(path: string): Promise<boolean> {
|
|
67
107
|
return lstat(path).then(
|
package/src/hand-over.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// The global `awf`'s first step, before the engine is imported: a project whose installed `agentswf`
|
|
2
|
+
// is another version runs on that one (ADR 0015). Imports nothing of the engine, so a hand-over
|
|
3
|
+
// costs a Bun start and no more, and the engine's own checks can share how a project's engine is
|
|
4
|
+
// found.
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
7
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
/** The `agentswf` installed in a `node_modules` at or above a directory. */
|
|
10
|
+
export type InstalledEngine = {
|
|
11
|
+
/** Its package's folder, its real path: what `AWF_ENGINE` names. */
|
|
12
|
+
dir: string;
|
|
13
|
+
/** The directory whose `node_modules` holds it: where its `awf` is run from. */
|
|
14
|
+
root: string;
|
|
15
|
+
/** Its `package.json`'s `version`, not the range a project declares. */
|
|
16
|
+
version: string;
|
|
17
|
+
/** Its `awf`, as its `package.json`'s `bin` gives it. */
|
|
18
|
+
bin: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A project's engine: installed, declared in the nearest `package.json` at or above it and not
|
|
23
|
+
* installed (or installed broken), or none, which any engine runs.
|
|
24
|
+
*/
|
|
25
|
+
export type ProjectEngine =
|
|
26
|
+
| ({ kind: "installed" } & InstalledEngine)
|
|
27
|
+
| { kind: "declared"; manifest: string }
|
|
28
|
+
| { kind: "none" };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The `agentswf` found by walking up from `from` for `node_modules/agentswf/package.json`. Not
|
|
32
|
+
* Bun's resolver, whose auto-install answers with a cache copy where nothing is installed. One
|
|
33
|
+
* whose manifest gives no version or `awf` is broken, and its `package.json` is returned instead.
|
|
34
|
+
*/
|
|
35
|
+
export function installedEngine(from: string): InstalledEngine | undefined {
|
|
36
|
+
const found = findEngine(from);
|
|
37
|
+
return found && "version" in found ? found : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function findEngine(from: string): InstalledEngine | { broken: string } | undefined {
|
|
41
|
+
for (let dir = resolve(from); ; dir = dirname(dir)) {
|
|
42
|
+
const manifest = join(dir, "node_modules", "agentswf", "package.json");
|
|
43
|
+
if (existsSync(manifest)) {
|
|
44
|
+
const read = readManifest(manifest);
|
|
45
|
+
const bin = typeof read?.bin === "object" ? read.bin?.awf : read?.bin;
|
|
46
|
+
if (typeof read?.version !== "string" || typeof bin !== "string") return { broken: manifest };
|
|
47
|
+
const root = realpathSync(dirname(manifest));
|
|
48
|
+
return { dir: root, root: dir, version: read.version, bin: resolve(root, bin) };
|
|
49
|
+
}
|
|
50
|
+
if (dirname(dir) === dir) return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `project`'s engine: the one installed above it, else one the nearest `package.json` at or above
|
|
56
|
+
* it declares.
|
|
57
|
+
*/
|
|
58
|
+
export function projectEngine(project: string): ProjectEngine {
|
|
59
|
+
const found = findEngine(project);
|
|
60
|
+
if (found && "broken" in found) return { kind: "declared", manifest: found.broken };
|
|
61
|
+
if (found) return { kind: "installed", ...found };
|
|
62
|
+
for (let dir = resolve(project); ; dir = dirname(dir)) {
|
|
63
|
+
const manifest = join(dir, "package.json");
|
|
64
|
+
if (existsSync(manifest)) {
|
|
65
|
+
const read = readManifest(manifest);
|
|
66
|
+
const declared = ["dependencies", "devDependencies", "optionalDependencies"].some((field) => {
|
|
67
|
+
const deps = read?.[field];
|
|
68
|
+
return typeof deps === "object" && deps !== null && "agentswf" in deps;
|
|
69
|
+
});
|
|
70
|
+
return declared ? { kind: "declared", manifest } : { kind: "none" };
|
|
71
|
+
}
|
|
72
|
+
if (dirname(dir) === dir) return { kind: "none" };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type Manifest = Record<string, unknown> & {
|
|
77
|
+
version?: unknown;
|
|
78
|
+
bin?: string | { awf?: unknown };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function readManifest(file: string): Manifest | undefined {
|
|
82
|
+
try {
|
|
83
|
+
const value: unknown = JSON.parse(readFileSync(file, "utf8"));
|
|
84
|
+
return typeof value === "object" && value !== null ? (value as Manifest) : undefined;
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Set on an `awf` a hand-over runs: the engine it is, so that one doesn't hand over again. */
|
|
91
|
+
export const ENGINE_VARIABLE = "AWF_ENGINE";
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* What the global `awf` does first: when the `agentswf` installed above the shell's directory is
|
|
95
|
+
* another version than its own, it warns, runs that one's `awf` with the same argv, and exits as
|
|
96
|
+
* it does. Returns when this `awf` runs the command itself. `init`, `help`, `--version` and
|
|
97
|
+
* `tick --all` name no project's runs, and are never handed over.
|
|
98
|
+
*/
|
|
99
|
+
export async function handOver(own: {
|
|
100
|
+
/** This `awf`'s `agentswf` folder. */
|
|
101
|
+
dir: string;
|
|
102
|
+
version: string;
|
|
103
|
+
argv?: readonly string[];
|
|
104
|
+
cwd?: string;
|
|
105
|
+
environment?: Readonly<Record<string, string | undefined>>;
|
|
106
|
+
}): Promise<void> {
|
|
107
|
+
const argv = own.argv ?? process.argv.slice(2);
|
|
108
|
+
const environment = own.environment ?? process.env;
|
|
109
|
+
const target = handOverTarget(argv, own.cwd ?? process.cwd(), own.version);
|
|
110
|
+
if (!target) return;
|
|
111
|
+
// The child, walking from the same directory, finds itself; the guard is for a loop all the same.
|
|
112
|
+
if (environment[ENGINE_VARIABLE] === realpathSync(own.dir)) return;
|
|
113
|
+
process.stderr.write(
|
|
114
|
+
`awf: awf ${own.version} is not this project's ${target.version}; running the project's. Update one, or run bunx awf here.\n`,
|
|
115
|
+
);
|
|
116
|
+
const child = Bun.spawn({
|
|
117
|
+
cmd: [process.execPath, "--no-env-file", target.bin, ...argv],
|
|
118
|
+
env: { ...environment, [ENGINE_VARIABLE]: target.dir },
|
|
119
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
120
|
+
});
|
|
121
|
+
const forward = (signal: NodeJS.Signals) => () => child.kill(signal);
|
|
122
|
+
// Each is forwarded, for a signal sent to this process alone. The terminal's Ctrl-C reaches the
|
|
123
|
+
// child twice, through the process group too, and a run takes a repeat within a second as one;
|
|
124
|
+
// the prompt comes back once the child has ended, not before.
|
|
125
|
+
const listeners: [NodeJS.Signals, () => void][] = [
|
|
126
|
+
["SIGTERM", forward("SIGTERM")],
|
|
127
|
+
["SIGHUP", forward("SIGHUP")],
|
|
128
|
+
["SIGINT", forward("SIGINT")],
|
|
129
|
+
];
|
|
130
|
+
for (const [signal, listener] of listeners) process.on(signal, listener);
|
|
131
|
+
const code = await child.exited;
|
|
132
|
+
for (const [signal, listener] of listeners) process.off(signal, listener);
|
|
133
|
+
if (child.signalCode) process.kill(process.pid, child.signalCode);
|
|
134
|
+
process.exit(code ?? 1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The engine `argv`, typed in `cwd`, is handed over to; undefined when this one runs it. */
|
|
138
|
+
export function handOverTarget(
|
|
139
|
+
argv: readonly string[],
|
|
140
|
+
cwd: string,
|
|
141
|
+
version: string,
|
|
142
|
+
): InstalledEngine | undefined {
|
|
143
|
+
const [word] = argv;
|
|
144
|
+
if (word === undefined || NOT_HANDED_OVER.has(word)) return undefined;
|
|
145
|
+
if (word === "tick" && argv.includes("--all")) return undefined;
|
|
146
|
+
const installed = installedEngine(cwd);
|
|
147
|
+
return installed && installed.version !== version ? installed : undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const NOT_HANDED_OVER = new Set(["init", "help", "-h", "--help", "--version"]);
|
package/src/help/allowance.txt
CHANGED
|
@@ -8,6 +8,7 @@ none is named: claude, codex, pi, cursor.
|
|
|
8
8
|
A plan's monthly price is an estimate at the vendor's list price as of the table's date:
|
|
9
9
|
plans 2026-10-05.
|
|
10
10
|
|
|
11
|
-
--json prints one document, the interface for scripts, with the plan as the
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
--json prints one document, the interface for scripts, with the plan as the harness names it and
|
|
12
|
+
no price; what it prints otherwise is for reading. A harness that could not be read says why; a
|
|
13
|
+
failure is a refusal whose code says why: usage or internal-error. Exits 0 once every harness
|
|
14
|
+
was asked, 2 on a usage error.
|