@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.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/package.json +13 -9
  4. package/src/accounting/format.ts +4 -3
  5. package/src/accounting/spent.ts +128 -0
  6. package/src/accounting/summary.ts +40 -32
  7. package/src/agent-cli/cli.ts +28 -55
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +4 -4
  11. package/src/attempt-output.ts +13 -31
  12. package/src/attempt-view.ts +33 -32
  13. package/src/author-typebox.ts +13 -0
  14. package/src/client/checks.ts +94 -0
  15. package/src/client/exported.ts +17 -0
  16. package/src/client/index.ts +122 -0
  17. package/src/client/locate.ts +34 -0
  18. package/src/client/published.ts +3 -0
  19. package/src/client/runs.ts +337 -0
  20. package/src/command-line.ts +64 -0
  21. package/src/commands.ts +7 -1
  22. package/src/control-plane.ts +4 -6
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +27 -28
  25. package/src/deadlines.ts +6 -40
  26. package/src/decisions/directory.ts +50 -37
  27. package/src/decisions/fake.ts +1 -1
  28. package/src/decisions/openrouter.ts +4 -2
  29. package/src/decisions/seam.ts +4 -4
  30. package/src/display-path.ts +3 -3
  31. package/src/duration.ts +19 -33
  32. package/src/emit-command.ts +38 -22
  33. package/src/emit.ts +148 -59
  34. package/src/event-ids.ts +31 -0
  35. package/src/files.ts +44 -4
  36. package/src/hand-over.ts +150 -0
  37. package/src/help/allowance.txt +4 -3
  38. package/src/help/emit.txt +11 -7
  39. package/src/help/inbox.txt +5 -3
  40. package/src/help/logs.txt +7 -5
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +13 -8
  44. package/src/help/runs.txt +4 -2
  45. package/src/help/tick.txt +9 -4
  46. package/src/herdr-placement.ts +3 -33
  47. package/src/herdr-run-session.ts +5 -13
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +54 -136
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +42 -56
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.ts.template +3 -2
  55. package/src/jsonl.ts +10 -6
  56. package/src/keep-alive.ts +7 -19
  57. package/src/logs-command.ts +134 -53
  58. package/src/message-operation.ts +6 -3
  59. package/src/messaging/messaging.ts +8 -10
  60. package/src/messaging/text.ts +2 -2
  61. package/src/open-command.ts +770 -0
  62. package/src/operation-events.ts +4 -6
  63. package/src/operation-liveness.ts +19 -4
  64. package/src/operator-cli.ts +206 -90
  65. package/src/operator-runtime.ts +24 -2
  66. package/src/ops.ts +270 -0
  67. package/src/pane-layout.ts +49 -96
  68. package/src/progress-view.ts +2 -2
  69. package/src/project-engine.ts +79 -0
  70. package/src/projects.ts +13 -2
  71. package/src/refusals.ts +119 -0
  72. package/src/result-slots.ts +11 -31
  73. package/src/result-validation.ts +2 -2
  74. package/src/run-command.ts +27 -31
  75. package/src/run-commands.ts +42 -114
  76. package/src/run-event-log.ts +1 -1
  77. package/src/run-lines.ts +159 -0
  78. package/src/run-lock.ts +57 -1
  79. package/src/run-policy/stage-plan.ts +154 -0
  80. package/src/run-policy/stages.ts +113 -0
  81. package/src/run-policy/waits.ts +452 -0
  82. package/src/run-prepare.ts +165 -41
  83. package/src/run-read.ts +109 -41
  84. package/src/run-state.ts +170 -50
  85. package/src/run-text.ts +54 -0
  86. package/src/run-unreachable.ts +18 -0
  87. package/src/run-usage.ts +17 -11
  88. package/src/run-view.ts +73 -14
  89. package/src/runs-command.ts +81 -63
  90. package/src/runs.ts +284 -222
  91. package/src/sandbox-homes.ts +65 -13
  92. package/src/sandboxes.ts +182 -28
  93. package/src/session-lines.ts +93 -0
  94. package/src/skills/run-skills.ts +8 -1
  95. package/src/sources.ts +2 -39
  96. package/src/spend.ts +333 -0
  97. package/src/stage-ledger.ts +111 -153
  98. package/src/stop-crashed.ts +6 -5
  99. package/src/stopped.ts +4 -4
  100. package/src/test-command.ts +37 -19
  101. package/src/testing/operation-events.ts +3 -23
  102. package/src/testing.ts +66 -18
  103. package/src/tick-command.ts +145 -15
  104. package/src/tools.ts +11 -30
  105. package/src/turn-ends.ts +37 -0
  106. package/src/waits.ts +651 -0
  107. package/src/wake.ts +84 -54
  108. package/src/workflow-loader.ts +49 -2
  109. package/src/workflow-runner.ts +525 -618
  110. package/src/workflow-test-preload.ts +4 -1
  111. package/src/workflow-testing/coordinators.ts +1 -1
  112. package/src/workflow-testing/fake-client.ts +387 -0
  113. package/src/workflow-testing/host.ts +28 -29
  114. package/src/workflow-testing/index.ts +9 -27
  115. package/src/workflow-testing/run-test.ts +201 -286
  116. package/src/workflow-testing/script.ts +7 -39
  117. package/src/deprecations.ts +0 -177
  118. package/src/hold-run-lock.fixture.ts +0 -8
  119. package/src/mailbox.ts +0 -972
  120. package/src/stage-plan.ts +0 -186
  121. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  122. 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
- MAILBOX_RECORD_VERSION,
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 { appendMailbox, foldMailbox, readMailbox } from "./mailbox";
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 { createRun, idProblem, openRun, panesChanged, type Run, readAttempts } from "./runs";
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(message: string) {
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 projectOf(file, context.home).catch((error: unknown) => {
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: { id, name, data, from: context.from, at },
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 the mailbox for the run's next wait.
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) throw new Error(`${ref} has no file to check the event against`);
102
- const { workflow } = await loadWorkflowFile(file, run.record.cwd);
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 Error(`${file} is no longer ${run.record.workflow} with events, which ${ref} ran`);
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: replyRecord(key, id, event.data, at),
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 creates the run, appends
151
- * the event and wakes the run.
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
- export async function deliver(
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
- ({ v: 1, outcome, ref, eventId: event.id, ...woken }) satisfies EmitResultJson;
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 && foldMailbox(await readMailbox(run.dir)).events.has(event.id))
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
- const starts = !run;
176
- if (!run) {
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
- run = await createEventRun(runRoot, workflow, runId, start.project, file, context);
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 run = await createRun(runRoot, {
217
- id,
218
- workflow,
219
- argv: [...argv],
220
- cwd: project,
221
- sandbox: null,
222
- events: true,
223
- env: { PATH: context.environment.PATH ?? "" },
224
- ...(file ? { file } : {}),
225
- ...(panes ? { panes } : {}),
226
- });
227
- await recordProject(context.home, project);
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 projectOf(file, context.home).catch((error: unknown) => {
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, panes);
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 the mailbox records it: `participant:reply` from its key, whatever the
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(`${workflow}: its file, ${target.file}, is import.meta.path, absolute`);
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. */
@@ -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 async function linkNew(path: string, value: unknown): Promise<boolean> {
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, jsonText(value));
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(
@@ -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"]);
@@ -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
- harness names it and no price; what it prints otherwise is for reading. A harness that could
13
- not be read says why. Exits 0 once every harness was asked, 2 on a usage error.
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.