@agentswf/engine 0.1.0-beta.2 → 0.1.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/package.json +5 -6
  2. package/src/agent-cli/cli.ts +571 -0
  3. package/src/agent-cli/client.ts +115 -0
  4. package/src/agent-launcher.ts +15 -25
  5. package/src/allowance-command.ts +31 -21
  6. package/src/attempt-ending.ts +23 -5
  7. package/src/attempt-output.ts +48 -18
  8. package/src/attempt-view.ts +25 -15
  9. package/src/commands.ts +176 -0
  10. package/src/control-plane.ts +73 -22
  11. package/src/coordinator-port.ts +29 -0
  12. package/src/coordinator-prompt.ts +237 -0
  13. package/src/coordinator-socket.ts +236 -0
  14. package/src/deadlines.ts +47 -10
  15. package/src/decisions/directory.ts +8 -6
  16. package/src/duration.ts +29 -0
  17. package/src/emit-command.ts +119 -0
  18. package/src/emit.ts +246 -0
  19. package/src/files.ts +11 -1
  20. package/src/help/allowance.txt +13 -0
  21. package/src/help/emit.txt +13 -0
  22. package/src/help/inbox.txt +12 -0
  23. package/src/help/init.txt +12 -0
  24. package/src/help/logs.txt +13 -0
  25. package/src/help/reply.txt +18 -0
  26. package/src/help/run.txt +44 -0
  27. package/src/help/runs.txt +11 -0
  28. package/src/help/test.txt +9 -0
  29. package/src/help/tick.txt +12 -0
  30. package/src/here.ts +9 -3
  31. package/src/hold-run-lock.fixture.ts +8 -0
  32. package/src/holder-command.ts +279 -0
  33. package/src/inbox-command.ts +715 -0
  34. package/src/init-command.ts +29 -11
  35. package/src/init-template/AGENTS.md.template +4 -4
  36. package/src/init-template/hello/workflow.test.ts.template +2 -2
  37. package/src/init-template/hello/workflow.ts.template +16 -20
  38. package/src/keep-alive.ts +40 -0
  39. package/src/logs-command.ts +106 -35
  40. package/src/machine.ts +12 -1
  41. package/src/mailbox.ts +990 -0
  42. package/src/message-operation.ts +26 -3
  43. package/src/messaging/limits.ts +1 -1
  44. package/src/messaging/messaging.ts +478 -85
  45. package/src/messaging/routes.ts +11 -4
  46. package/src/messaging/text.ts +25 -22
  47. package/src/operation-liveness.ts +55 -9
  48. package/src/operator-cli.ts +290 -119
  49. package/src/operator-runtime.ts +8 -8
  50. package/src/progress-view.ts +49 -7
  51. package/src/projects.ts +57 -0
  52. package/src/result-slots.ts +43 -19
  53. package/src/result-validation.ts +18 -27
  54. package/src/run-command.ts +29 -66
  55. package/src/run-commands.ts +13 -2
  56. package/src/run-lock.ts +123 -0
  57. package/src/run-prepare.ts +94 -22
  58. package/src/run-state.ts +125 -6
  59. package/src/run-usage.ts +2 -2
  60. package/src/run-view.ts +0 -9
  61. package/src/runs-command.ts +43 -25
  62. package/src/runs.ts +142 -23
  63. package/src/sandboxes.ts +11 -4
  64. package/src/simple-input.ts +268 -0
  65. package/src/socket-exchange.ts +26 -0
  66. package/src/sources.ts +70 -0
  67. package/src/stage-ledger.ts +89 -14
  68. package/src/stage-plan.ts +23 -0
  69. package/src/stop-crashed.ts +39 -0
  70. package/src/test-command.ts +34 -11
  71. package/src/testing.ts +72 -4
  72. package/src/tick-command.ts +76 -0
  73. package/src/tools.ts +645 -0
  74. package/src/wake.ts +264 -0
  75. package/src/workflow-loader.ts +32 -22
  76. package/src/workflow-runner.ts +1277 -97
  77. package/src/workflow-testing/coordinator.typecheck.ts +54 -0
  78. package/src/workflow-testing/coordinators.ts +137 -0
  79. package/src/workflow-testing/host.ts +67 -7
  80. package/src/workflow-testing/index.ts +488 -34
  81. package/src/workflow-testing/script.ts +74 -4
  82. package/src/workflow-testing/workflow-testing.typecheck.ts +19 -2
package/src/runs.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  type StageRecord,
15
15
  type TurnRecord,
16
16
  } from "@agentswf/contract/records";
17
- import type { AttemptOutcome } from "@agentswf/contract/workflow";
17
+ import { type AttemptOutcome, nameProblem } from "@agentswf/contract/workflow";
18
18
  import { exists, isCode, linkNew, writeJson } from "./files";
19
19
  import { appendLine, endTornLine, readLines } from "./jsonl";
20
20
  import { MESSAGE_LIMITS } from "./messaging/limits";
@@ -49,22 +49,20 @@ export type Attempt = { run: Run; file: string; record: AttemptRecord };
49
49
  /** The start time of process `pid` as an ISO time to the second, or undefined when it is gone. */
50
50
  export type ProcessProbe = (pid: number) => string | undefined;
51
51
 
52
- const ID = /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}$/;
52
+ // Case is kept, as a ticket's key has it; `createRun` refuses an id differing only in case.
53
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._@-]{0,127}$/;
53
54
 
54
55
  /** Why `id` can't name a run or a workflow, or undefined when it can. */
55
56
  export function idProblem(id: string): string | undefined {
56
- return ID.test(id)
57
+ return typeof id === "string" && ID.test(id)
57
58
  ? undefined
58
- : `${JSON.stringify(id)} is not a valid id: letters, digits, '.', '_' and '-', up to 128, not starting with '.'`;
59
+ : `${JSON.stringify(id)} is not a valid id: letters, digits, '.', '_', '@' and '-', starting with a letter or digit, up to 128`;
59
60
  }
60
61
 
61
- const STAGE_NAME = /^[a-z][a-z0-9-]*$/;
62
-
63
62
  /** Why `name` can't name a stage, which is a file in the run's `stages/`; undefined when it can. */
64
63
  export function stageNameProblem(name: string): string | undefined {
65
- return typeof name === "string" && STAGE_NAME.test(name)
66
- ? undefined
67
- : `${JSON.stringify(name)} is not a stage's name: lowercase letters, digits and '-', starting with a letter`;
64
+ const problem = typeof name === "string" ? nameProblem(name) : "a string";
65
+ return problem && `${JSON.stringify(name)} is not a stage's name: ${problem}`;
68
66
  }
69
67
 
70
68
  function checkIds(...ids: string[]): void {
@@ -138,7 +136,7 @@ async function claimRun(
138
136
  }
139
137
 
140
138
  function takenMessage(id: string): string {
141
- return `${id} exists; --continue it, or --id another to start over`;
139
+ return `${id} exists; --continue goes on with it, or --id another id starts over`;
142
140
  }
143
141
 
144
142
  /** Another run whose id differs from `id` only in case; the same id is the rename's to refuse. */
@@ -218,7 +216,7 @@ export function checkContinue(
218
216
  */
219
217
  export async function readAttempts(run: Run): Promise<AttemptRecord[]> {
220
218
  const dir = attemptsDir(run.dir);
221
- const numbered = (await entries(dir)).filter((name) => /^[1-9]\d*\.json$/.test(name));
219
+ const numbered = await attemptNames(run);
222
220
  const records = await Promise.all(
223
221
  numbered.map((name) =>
224
222
  readRecord<AttemptRecord>(join(dir, name), ATTEMPT_RECORD_VERSION, { missing: true }),
@@ -345,12 +343,12 @@ export async function claimAttempt(
345
343
  /** Writes the attempt's ending into its file, whole. */
346
344
  export async function endAttempt(
347
345
  attempt: Attempt,
348
- ending: Pick<AttemptRecord, "stage" | "reason" | "stages" | "accounting"> & {
346
+ ending: Pick<AttemptRecord, "stage" | "reason" | "stages" | "accounting" | "unacknowledged"> & {
349
347
  outcome: AttemptOutcome;
350
348
  },
351
349
  now: Date = new Date(),
352
350
  ): Promise<AttemptRecord> {
353
- const { stage, reason, stages, accounting } = ending;
351
+ const { stage, reason, stages, accounting, unacknowledged } = ending;
354
352
  const record: AttemptRecord = {
355
353
  ...attempt.record,
356
354
  ended: now.toISOString(),
@@ -359,6 +357,7 @@ export async function endAttempt(
359
357
  ...(reason === undefined ? {} : { reason }),
360
358
  ...(stages === undefined ? {} : { stages }),
361
359
  ...(accounting === undefined ? {} : { accounting }),
360
+ ...(unacknowledged === undefined ? {} : { unacknowledged }),
362
361
  };
363
362
  await writeJson(attempt.file, record);
364
363
  attempt.record = record;
@@ -403,13 +402,21 @@ export async function replaceStale(
403
402
  return stale;
404
403
  }
405
404
 
406
- /** Moves a stage's record, unchanged, to `replaced/{stage}.{attempt}.json`. */
405
+ /**
406
+ * Moves a stage's record, unchanged, to `replaced/{stage}.{attempt}.json`, or, for a stage by
407
+ * content replaced again in the same attempt, `{stage}.{attempt}+{n}.json` from 2: no name has a
408
+ * `+`, so neither reads as another stage's.
409
+ */
407
410
  export async function replaceRecord(runDir: string, stage: string): Promise<void> {
408
411
  const file = join(stagesDir(runDir), `${stage}.json`);
409
412
  const record = await readRecord<StageRecord>(file, STAGE_RECORD_VERSION);
410
413
  const replaced = join(runDir, "replaced");
411
414
  await mkdir(replaced, { recursive: true, mode: 0o700 });
412
- await rename(file, join(replaced, `${stage}.${record.attempt}.json`));
415
+ let target = join(replaced, `${stage}.${record.attempt}.json`);
416
+ for (let n = 2; await exists(target); n++) {
417
+ target = join(replaced, `${stage}.${record.attempt}+${n}.json`);
418
+ }
419
+ await rename(file, target);
413
420
  }
414
421
 
415
422
  /** Appends a line to the run's event log, `turns.jsonl`, which only the live attempt writes. */
@@ -434,22 +441,86 @@ export async function readEvents(runDir: string): Promise<RunLogLine[]> {
434
441
  export async function readNumberedEvents(
435
442
  runDir: string,
436
443
  ): Promise<{ number: number; line: RunLogLine }[]> {
437
- const text = await readFile(turnsFile(runDir), "utf8").catch((error) => {
438
- if (isCode(error, "ENOENT")) return "";
439
- throw error;
440
- });
441
- const lines = text.split("\n");
444
+ return (await readEventsFrom(runDir, { offset: 0, count: 0 })).lines;
445
+ }
446
+
447
+ /** Where a read of the event log stopped: the bytes up to its last ended line, and the lines in them. */
448
+ export type LogMark = { offset: number; count: number };
449
+
450
+ /**
451
+ * The event log's lines past `from`, numbered on from it, and where this read stopped: what was
452
+ * appended since a read that stopped at `from`.
453
+ */
454
+ export async function readEventsFrom(
455
+ runDir: string,
456
+ from: LogMark,
457
+ ): Promise<{ lines: { number: number; line: RunLogLine }[]; mark: LogMark }> {
458
+ let bytes: Buffer;
459
+ try {
460
+ const file = await open(turnsFile(runDir), "r");
461
+ try {
462
+ const { size } = await file.stat();
463
+ bytes = Buffer.alloc(Math.max(0, size - from.offset));
464
+ let read = 0;
465
+ while (read < bytes.length) {
466
+ const { bytesRead } = await file.read(bytes, read, bytes.length - read, from.offset + read);
467
+ if (bytesRead === 0) break;
468
+ read += bytesRead;
469
+ }
470
+ bytes = bytes.subarray(0, read);
471
+ } finally {
472
+ await file.close();
473
+ }
474
+ } catch (error) {
475
+ if (!isCode(error, "ENOENT")) throw error;
476
+ bytes = Buffer.alloc(0);
477
+ }
442
478
  // A last line not yet ended is still being written.
443
- lines.pop();
444
- return lines.flatMap((json, index) => {
479
+ const end = bytes.lastIndexOf(0x0a) + 1;
480
+ const texts =
481
+ end === 0
482
+ ? []
483
+ : bytes
484
+ .subarray(0, end - 1)
485
+ .toString("utf8")
486
+ .split("\n");
487
+ const lines = texts.flatMap((json, index) => {
445
488
  try {
446
489
  const value: unknown = JSON.parse(json);
447
490
  const line = typeof value === "object" && value !== null ? runLogLine(value) : undefined;
448
- return line ? [{ number: index + 1, line }] : [];
491
+ return line ? [{ number: from.count + index + 1, line }] : [];
449
492
  } catch {
450
493
  return [];
451
494
  }
452
495
  });
496
+ return { lines, mark: { offset: from.offset + end, count: from.count + texts.length } };
497
+ }
498
+
499
+ /** The event log's size in bytes, 0 before it is written. */
500
+ export async function logSize(runDir: string): Promise<number> {
501
+ return (await stat(turnsFile(runDir)).catch(() => undefined))?.size ?? 0;
502
+ }
503
+
504
+ async function attemptNames(run: Run): Promise<string[]> {
505
+ return (await entries(attemptsDir(run.dir))).filter((name) => /^[1-9]\d*\.json$/.test(name));
506
+ }
507
+
508
+ /**
509
+ * A token that changes when the run's attempt records do: one added, removed, or rewritten, as an
510
+ * attempt's end is.
511
+ */
512
+ export async function attemptsToken(run: Run): Promise<string> {
513
+ const dir = attemptsDir(run.dir);
514
+ const names = await attemptNames(run);
515
+ const stats = await Promise.all(
516
+ names.map((name) => stat(join(dir, name)).catch(() => undefined)),
517
+ );
518
+ return names
519
+ .map((name, index) => {
520
+ const info = stats[index];
521
+ return info ? `${name}:${info.ino}:${info.size}:${info.mtimeMs}` : `${name}:-`;
522
+ })
523
+ .join(",");
453
524
  }
454
525
 
455
526
  /** Every turn of the run, in the order they settled. */
@@ -643,3 +714,51 @@ const runPs: Ps = (pid) => {
643
714
  exitCode: done.exitCode,
644
715
  };
645
716
  };
717
+
718
+ /** How long a continue waits for the attempt before it to exit before it stops that process. */
719
+ const PREVIOUS_GONE_MS = 5 * 60_000;
720
+
721
+ /**
722
+ * Waits until the attempt before `n`, a parked one closing its agents or one that crashed with
723
+ * its process left, is gone; past five minutes, cancels it, then kills it if it ignores that.
724
+ * Its agents' sessions are then this attempt's.
725
+ */
726
+ export async function previousGone(
727
+ run: Run,
728
+ n: number,
729
+ notice: (text: string) => void,
730
+ ): Promise<void> {
731
+ const previous = (await readAttempts(run)).find((each) => each.attempt === n - 1);
732
+ if (!previous) return;
733
+ const alive = () => sameProcess(previous.pid, previous.processStart, processStart);
734
+ const started = Date.now();
735
+ const giveUp = started + PREVIOUS_GONE_MS;
736
+ let said = false;
737
+ while (alive()) {
738
+ if (!said && Date.now() > started + 10_000) {
739
+ said = true;
740
+ notice(
741
+ `awf: waiting for attempt ${previous.attempt} (pid ${previous.pid}) to finish closing`,
742
+ );
743
+ }
744
+ if (Date.now() > giveUp) {
745
+ notice(
746
+ `awf: attempt ${previous.attempt} (pid ${previous.pid}) is still running; stopping it`,
747
+ );
748
+ // A cancel first, which closes its agents and sandboxes; only a process that ignores it is killed.
749
+ const signal = (name: NodeJS.Signals) => {
750
+ try {
751
+ process.kill(previous.pid, name);
752
+ } catch {
753
+ // Gone between the look and the signal.
754
+ }
755
+ };
756
+ signal("SIGTERM");
757
+ const killAt = Date.now() + 30_000;
758
+ while (alive() && Date.now() < killAt) await Bun.sleep(200);
759
+ if (alive()) signal("SIGKILL");
760
+ return;
761
+ }
762
+ await Bun.sleep(100);
763
+ }
764
+ }
package/src/sandboxes.ts CHANGED
@@ -7,6 +7,7 @@ import type { SandboxRecord } from "@agentswf/contract/records";
7
7
  import type {
8
8
  AbsoluteDeadline,
9
9
  AgentExecution,
10
+ SandboxEnvironment,
10
11
  SandboxEnvironmentKey,
11
12
  SandboxRef,
12
13
  } from "@agentswf/contract/workflow";
@@ -23,6 +24,7 @@ import {
23
24
  withinReach,
24
25
  } from "@agentswf/sandbox";
25
26
  import { CONTROL_PLANE_ROOT } from "./control-plane";
27
+ import { harnessDeadline } from "./deadlines";
26
28
  import { messageOf } from "./errors";
27
29
  import { machinePaths } from "./machine";
28
30
  import { type CredentialLocks, placeCarried, type SeededHome, seedHome } from "./sandbox-homes";
@@ -38,6 +40,8 @@ export type RunSandboxOptions = {
38
40
  sandboxesDir: string;
39
41
  /** The operator's `~/.awf`, which no sandbox reaches. */
40
42
  machineRoot: string;
43
+ /** Where holders reach live runs' coordinators, `AWF_COORDINATOR_DIR` if it moved; no sandbox reaches it. */
44
+ coordinatorRoot?: string;
41
45
  /**
42
46
  * The operator's environment, which says where each harness keeps its state, the `PATH` an
43
47
  * agent's harness is found on and claude's token. Defaults to the engine's.
@@ -123,7 +127,7 @@ export class RunSandboxes {
123
127
  sandboxes?: RunSandboxOptions;
124
128
  runRoot: string;
125
129
  cwd: string;
126
- deadline: AbsoluteDeadline;
130
+ deadline?: AbsoluteDeadline;
127
131
  /** The run's credential write-backs, which a host agent's own home shares. */
128
132
  locks: CredentialLocks;
129
133
  log(message: string): void;
@@ -138,7 +142,7 @@ export class RunSandboxes {
138
142
  return this.options.sandboxes?.run !== undefined;
139
143
  }
140
144
 
141
- /** `workflow.sandboxes.open`. */
145
+ /** `wf.sandboxes.open`. */
142
146
  async open(spec: unknown): Promise<SandboxRef> {
143
147
  if (this.hasRunSandbox) throw new Error(RUN_SANDBOX_ONLY);
144
148
  const key = (spec as { key?: unknown } | null)?.key;
@@ -303,7 +307,7 @@ export class RunSandboxes {
303
307
  network: [...resolved.network],
304
308
  ...(resolved.sockets.length > 0 ? { sockets: [...resolved.sockets] } : {}),
305
309
  ...(resolved.webSearch ? { webSearch: true } : {}),
306
- [provider]: resolved.environment,
310
+ ...({ [provider]: resolved.environment } as SandboxEnvironment),
307
311
  },
308
312
  directory,
309
313
  gitdirs: resolved.gitdirs.map((gitdir) => ({ ...gitdir })),
@@ -396,7 +400,7 @@ export class RunSandboxes {
396
400
  const opened = await installed.open(resolved, {
397
401
  runRoot: options.runRoot,
398
402
  directory,
399
- deadline: this.options.deadline,
403
+ deadline: harnessDeadline(this.options.deadline),
400
404
  });
401
405
  return { key, provider, resolved, directory, opened, admitting: Promise.resolve(), agents: [] };
402
406
  }
@@ -411,6 +415,9 @@ export class RunSandboxes {
411
415
  providers: this.#providers,
412
416
  harnessState: Object.values(harnessState(this.#environment)),
413
417
  controlRoot: CONTROL_PLANE_ROOT,
418
+ ...(this.options.sandboxes?.coordinatorRoot === undefined
419
+ ? {}
420
+ : { coordinatorRoot: this.options.sandboxes.coordinatorRoot }),
414
421
  inline,
415
422
  };
416
423
  }
@@ -0,0 +1,268 @@
1
+ import {
2
+ ANNOTATION_KEYS,
3
+ formatErrors,
4
+ type JsonSchema,
5
+ validate,
6
+ } from "@agentswf/contract/schema";
7
+ import type { GivenInput } from "@agentswf/contract/wire";
8
+ import type { JsonObject } from "@agentswf/contract/workflow";
9
+ import { messageOf } from "./errors";
10
+
11
+ /**
12
+ * An input simple enough to give as flags, `--name 'value'`: an object of a few strings, numbers,
13
+ * booleans and enums, constrained only by what the usage line can say. Any other is given as JSON.
14
+ */
15
+ type Simple = { name: string; required: boolean; kind: Kind }[];
16
+ type Kind =
17
+ | { type: "string"; minLength?: number }
18
+ | { type: "number" | "integer"; minimum?: number; maximum?: number }
19
+ | { type: "boolean" }
20
+ | { type: "enum"; values: string[] };
21
+
22
+ /**
23
+ * Said once in a prompt, under the first block with a free-text flag, and with every refusal of
24
+ * flags that take text. Enum, number and boolean values are shell words as they stand.
25
+ */
26
+ export const QUOTING_RULE =
27
+ "Put every value in single quotes; a ' inside a value is written '\\''.";
28
+
29
+ /** Whether a flag takes free text, the only value the shell can break. */
30
+ export function takesText(simple: Simple): boolean {
31
+ return simple.some(({ kind }) => kind.type === "string");
32
+ }
33
+
34
+ const MAX_PROPERTIES = 6;
35
+ const NAME = /^[a-z][a-z0-9_]*$/;
36
+ /** An enum value the usage line can show between `|`s and the agent can type as it stands. */
37
+ const WORD = /^[A-Za-z0-9_.:-]+$/;
38
+ const HEREDOC = "send it as JSON on standard input, in a quoted heredoc (<<'WF_JSON' … WF_JSON)";
39
+ const ANNOTATIONS = new Set<string>(ANNOTATION_KEYS);
40
+
41
+ export function simpleInput(schema: JsonSchema): Simple | undefined {
42
+ if (!("type" in schema) || schema.type !== "object") return undefined;
43
+ const entries = Object.entries(schema.properties);
44
+ // No properties is an open object or nothing at all; neither reads as flags.
45
+ if (entries.length === 0 || entries.length > MAX_PROPERTIES) return undefined;
46
+ const required = new Set(schema.required ?? []);
47
+ if ([...required].some((name) => !Object.hasOwn(schema.properties, name))) return undefined;
48
+ const simple: Simple = [];
49
+ for (const [name, property] of entries) {
50
+ const kind = NAME.test(name) ? kindOf(property) : undefined;
51
+ if (!kind) return undefined;
52
+ simple.push({ name, required: required.has(name), kind });
53
+ }
54
+ return simple;
55
+ }
56
+
57
+ function kindOf(schema: JsonSchema): Kind | undefined {
58
+ const kind = anyKindOf(schema);
59
+ return kind?.type === "enum" && !kind.values.every((value) => WORD.test(value))
60
+ ? undefined
61
+ : kind;
62
+ }
63
+
64
+ function anyKindOf(schema: JsonSchema): Kind | undefined {
65
+ const keys = Object.keys(schema).filter((key) => !ANNOTATIONS.has(key));
66
+ const only = (...allowed: string[]) => keys.every((key) => allowed.includes(key));
67
+ if ("anyOf" in schema) {
68
+ if (!only("anyOf")) return undefined;
69
+ const values = schema.anyOf.map((each) =>
70
+ "type" in each && each.type === "string" ? stringConst(each) : undefined,
71
+ );
72
+ return values.every((value) => value !== undefined)
73
+ ? { type: "enum", values: values as string[] }
74
+ : undefined;
75
+ }
76
+ if (!("type" in schema)) {
77
+ return only("enum") && schema.enum.every((value) => typeof value === "string")
78
+ ? { type: "enum", values: schema.enum as string[] }
79
+ : undefined;
80
+ }
81
+ switch (schema.type) {
82
+ case "string": {
83
+ const constant = stringConst(schema);
84
+ if (constant !== undefined) return { type: "enum", values: [constant] };
85
+ if (schema.enum)
86
+ return only("type", "enum") ? { type: "enum", values: schema.enum } : undefined;
87
+ if (!only("type", "minLength")) return undefined;
88
+ return { type: "string", ...(schema.minLength ? { minLength: schema.minLength } : {}) };
89
+ }
90
+ case "number":
91
+ case "integer":
92
+ if (!only("type", "minimum", "maximum")) return undefined;
93
+ return {
94
+ type: schema.type,
95
+ ...(schema.minimum === undefined ? {} : { minimum: schema.minimum }),
96
+ ...(schema.maximum === undefined ? {} : { maximum: schema.maximum }),
97
+ };
98
+ case "boolean":
99
+ return only("type") ? { type: "boolean" } : undefined;
100
+ default:
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ /** A string schema that is one constant and nothing else, as TypeBox writes a literal. */
106
+ function stringConst(schema: JsonSchema): string | undefined {
107
+ if (!("type" in schema) || schema.type !== "string" || schema.const === undefined) return;
108
+ const keys = Object.keys(schema).filter((key) => !ANNOTATIONS.has(key));
109
+ return keys.every((key) => key === "type" || key === "const") ? schema.const : undefined;
110
+ }
111
+
112
+ /** The flags as the prompt teaches them: `--verdict noise|real --confidence <integer, 1 to 5>`. */
113
+ export function usageLine(simple: Simple): string {
114
+ return simple
115
+ .map(({ name, required, kind }) => {
116
+ const flag = `--${name} ${placeholder(kind)}`;
117
+ return required ? flag : `[${flag}]`;
118
+ })
119
+ .join(" ");
120
+ }
121
+
122
+ function placeholder(kind: Kind): string {
123
+ switch (kind.type) {
124
+ case "enum":
125
+ return kind.values.join("|");
126
+ case "boolean":
127
+ return "true|false";
128
+ case "string":
129
+ return kind.minLength
130
+ ? `<text, at least ${kind.minLength} character${kind.minLength === 1 ? "" : "s"}>`
131
+ : "<text>";
132
+ case "number":
133
+ case "integer": {
134
+ const { minimum, maximum } = kind;
135
+ if (minimum !== undefined && maximum !== undefined)
136
+ return `<${kind.type}, ${minimum} to ${maximum}>`;
137
+ if (minimum !== undefined) return `<${kind.type}, at least ${minimum}>`;
138
+ if (maximum !== undefined) return `<${kind.type}, at most ${maximum}>`;
139
+ return `<${kind.type}>`;
140
+ }
141
+ }
142
+ }
143
+
144
+ export type ReadFlags = { ok: true; value: JsonObject } | { ok: false; error: string };
145
+ export type ReadGiven = { ok: true; value: unknown } | { ok: false; error: string };
146
+
147
+ /**
148
+ * An input as `wfa` sent it, as a value: JSON parsed, flags read by the schema, nothing as `{}`.
149
+ * The schema's own check comes after, in `checkGiven`.
150
+ */
151
+ export function readGiven(schema: JsonSchema | undefined, given: GivenInput): ReadGiven {
152
+ if (given.raw !== undefined) {
153
+ try {
154
+ return { ok: true, value: JSON.parse(given.raw) };
155
+ } catch (error) {
156
+ const shape = schema ? JSON.stringify(schema) : "any JSON value";
157
+ return {
158
+ ok: false,
159
+ error: `the value is not valid JSON: ${messageOf(error)}\nexpected: ${shape}`,
160
+ };
161
+ }
162
+ }
163
+ if (given.args === undefined) {
164
+ // Nothing given is `{}` only where an object is wanted, so a text answer isn't told it's one.
165
+ return schema && "type" in schema && schema.type === "object"
166
+ ? { ok: true, value: {} }
167
+ : { ok: false, error: `nothing was given; ${HEREDOC}` };
168
+ }
169
+ if (!schema) return { ok: false, error: `this isn't taken as flags; ${HEREDOC}` };
170
+ return readFlags(schema, given.args);
171
+ }
172
+
173
+ /**
174
+ * The schema's errors on a value `readGiven` returned, with the usage line when it came as flags or
175
+ * as nothing and could have come as flags: a missing required flag names its property.
176
+ */
177
+ export function checkGiven(
178
+ schema: JsonSchema,
179
+ given: GivenInput,
180
+ value: unknown,
181
+ ): string | undefined {
182
+ const errors = validate(schema, value);
183
+ if (errors.length === 0) return undefined;
184
+ if (given.raw !== undefined) return formatErrors(errors);
185
+ const simple = simpleInput(schema);
186
+ if (simple) return `${formatErrors(errors)}\n${expected(simple)}`;
187
+ // Nothing given to a schema that takes JSON only: a lost heredoc, as likely as not.
188
+ return `${formatErrors(errors)}\n${HEREDOC}, matching this schema:\n${JSON.stringify(schema)}`;
189
+ }
190
+
191
+ function expected(simple: Simple): string {
192
+ return [`expected: ${usageLine(simple)}`, ...(takesText(simple) ? [QUOTING_RULE] : [])].join(
193
+ "\n",
194
+ );
195
+ }
196
+
197
+ /**
198
+ * Reads `--name 'value'` and `--name=value` by the schema, a number or boolean converted by its
199
+ * type. The word after a flag is always its value, so a value may start with `--`. A value the
200
+ * usage line rules out is refused with it; a missing required flag is left to the schema's check,
201
+ * which names the property.
202
+ */
203
+ export function readFlags(schema: JsonSchema, args: readonly string[]): ReadFlags {
204
+ const simple = simpleInput(schema);
205
+ if (!simple) {
206
+ return {
207
+ ok: false,
208
+ error: `this isn't taken as flags; ${HEREDOC}, matching this schema:\n${JSON.stringify(schema)}`,
209
+ };
210
+ }
211
+ const refuse = (problem: string): ReadFlags => ({
212
+ ok: false,
213
+ error: `${problem}\n${expected(simple)}`,
214
+ });
215
+ const value: JsonObject = {};
216
+ for (let i = 0; i < args.length; i++) {
217
+ const word = args[i]!;
218
+ if (!word.startsWith("--")) {
219
+ const brackets = word.startsWith("[") || word.endsWith("]");
220
+ return refuse(
221
+ brackets
222
+ ? `${JSON.stringify(word)} is not a flag: leave out the brackets around an optional flag`
223
+ : `${JSON.stringify(word)} is not a flag: each value goes right after its flag, in single quotes`,
224
+ );
225
+ }
226
+ const equals = word.indexOf("=");
227
+ const name = equals === -1 ? word.slice(2) : word.slice(2, equals);
228
+ const property = simple.find((each) => each.name === name);
229
+ if (!property) return refuse(`--${name} is not a flag this takes`);
230
+ if (Object.hasOwn(value, name)) return refuse(`--${name} is given twice`);
231
+ let text: string;
232
+ if (equals !== -1) text = word.slice(equals + 1);
233
+ else {
234
+ const next = args[i + 1];
235
+ if (next === undefined) return refuse(`--${name} needs a value`);
236
+ text = next;
237
+ i++;
238
+ }
239
+ const converted = convert(property.kind, text);
240
+ if (converted === undefined)
241
+ return refuse(`--${name} takes ${placeholder(property.kind)}; got ${JSON.stringify(text)}`);
242
+ value[name] = converted;
243
+ }
244
+ return { ok: true, value };
245
+ }
246
+
247
+ /** The value, or undefined when the usage line says it can't be, bounds included. */
248
+ function convert(kind: Kind, text: string): string | number | boolean | undefined {
249
+ switch (kind.type) {
250
+ case "string":
251
+ return text.length >= (kind.minLength ?? 0) ? text : undefined;
252
+ case "enum":
253
+ return kind.values.includes(text) ? text : undefined;
254
+ case "boolean":
255
+ return text === "true" ? true : text === "false" ? false : undefined;
256
+ case "number":
257
+ case "integer": {
258
+ const trimmed = text.trim();
259
+ if (!/^-?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(trimmed)) return undefined;
260
+ const number = Number(trimmed);
261
+ if (!Number.isFinite(number) || (kind.type === "integer" && !Number.isInteger(number)))
262
+ return undefined;
263
+ if (kind.minimum !== undefined && number < kind.minimum) return undefined;
264
+ if (kind.maximum !== undefined && number > kind.maximum) return undefined;
265
+ return number;
266
+ }
267
+ }
268
+ }
@@ -0,0 +1,26 @@
1
+ import { createConnection } from "node:net";
2
+
3
+ /**
4
+ * One request line to a Unix socket, and what comes back until the other side closes: empty when
5
+ * it closed without a word. Bounded, so a run that stopped answering does not hang a holder.
6
+ */
7
+ export function exchange(endpoint: string, frame: string, timeoutMs = 30_000): Promise<string> {
8
+ return new Promise((resolve, reject) => {
9
+ const socket = createConnection(endpoint);
10
+ const chunks: Buffer[] = [];
11
+ const timer = setTimeout(() => {
12
+ socket.destroy();
13
+ reject(new Error(`no answer within ${timeoutMs / 1000}s`));
14
+ }, timeoutMs);
15
+ socket.once("connect", () => socket.end(frame));
16
+ socket.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
17
+ socket.once("end", () => {
18
+ clearTimeout(timer);
19
+ resolve(Buffer.concat(chunks).toString("utf8").trim());
20
+ });
21
+ socket.once("error", (error) => {
22
+ clearTimeout(timer);
23
+ reject(error);
24
+ });
25
+ });
26
+ }
package/src/sources.ts ADDED
@@ -0,0 +1,70 @@
1
+ import type { WaitTake } from "@agentswf/contract/records";
2
+ import type { JsonValue, WorkflowEvent } from "@agentswf/contract/workflow";
3
+
4
+ /** What each source the run issued takes: only awf makes a source, so only these are. */
5
+ const takes = new WeakMap<object, WaitTake>();
6
+
7
+ /** Marks `value` as a source that takes `take`, and returns it. */
8
+ export function asSource<T extends object>(value: T, take: WaitTake): T {
9
+ takes.set(value, take);
10
+ return value;
11
+ }
12
+
13
+ /** A source of `fields` that takes `take`, its `is(e)` the test its waits use. */
14
+ export function source<T extends object>(
15
+ fields: T,
16
+ take: WaitTake,
17
+ ): T & { is(event: Looked): boolean } {
18
+ return asSource(Object.freeze({ ...fields, is: (event: Looked) => matches(event, take) }), take);
19
+ }
20
+
21
+ type Looked = { name: string; from: string; data?: unknown };
22
+
23
+ /** The first of `branches` that takes `event`. */
24
+ export function branchOf(
25
+ branches: readonly (readonly [string, WaitTake])[],
26
+ event: Looked,
27
+ ): string | undefined {
28
+ return branches.find(([, take]) => matches(event, take))?.[0];
29
+ }
30
+
31
+ /** What a source takes; undefined for anything the run didn't issue as one. */
32
+ export function takeOf(value: unknown): WaitTake | undefined {
33
+ return typeof value === "object" && value !== null ? takes.get(value) : undefined;
34
+ }
35
+
36
+ /** Whether a wait that takes `take` takes `event`. */
37
+ export function matches(
38
+ event: { name: string; from: string; data?: unknown },
39
+ take: WaitTake,
40
+ ): boolean {
41
+ if (event.name !== take.name) return false;
42
+ if (take.from !== undefined && event.from !== take.from) return false;
43
+ if (take.turn === undefined) return true;
44
+ const data = event.data as { turn?: unknown } | null | undefined;
45
+ return typeof data === "object" && data !== null && data.turn === take.turn;
46
+ }
47
+
48
+ /** Whether a wait taking `wanted`, or everything when it names nothing, takes `event`. */
49
+ export function wouldTake(
50
+ event: { name: string; from: string; data?: unknown },
51
+ wanted: readonly WaitTake[] | undefined,
52
+ ): boolean {
53
+ return wanted === undefined || wanted.some((take) => matches(event, take));
54
+ }
55
+
56
+ /**
57
+ * What a branch hands back of the event it took: awf's own events as their data with the event's id
58
+ * and arrival, and a workflow's own event whole.
59
+ */
60
+ export function branchValue(event: WorkflowEvent<string, JsonValue>): unknown {
61
+ switch (event.name) {
62
+ case "timer:due":
63
+ case "at:due":
64
+ case "participant:reply":
65
+ case "agent:ended":
66
+ return Object.freeze({ ...(event.data as object), id: event.id, at: event.at });
67
+ default:
68
+ return event;
69
+ }
70
+ }