@agentswf/engine 0.1.0-beta.2 → 0.1.0-beta.3
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/package.json +5 -5
- package/src/allowance-command.ts +29 -7
- package/src/attempt-ending.ts +23 -5
- package/src/attempt-output.ts +48 -18
- package/src/attempt-view.ts +25 -15
- package/src/commands.ts +176 -0
- package/src/control-plane.ts +72 -21
- package/src/coordinator-port.ts +29 -0
- package/src/coordinator-prompt.ts +237 -0
- package/src/coordinator-socket.ts +236 -0
- package/src/deadlines.ts +47 -10
- package/src/decisions/directory.ts +8 -6
- package/src/duration.ts +29 -0
- package/src/emit-command.ts +130 -0
- package/src/emit.ts +246 -0
- package/src/files.ts +11 -1
- package/src/here.ts +8 -2
- package/src/hold-run-lock.fixture.ts +8 -0
- package/src/holder-command.ts +297 -0
- package/src/inbox-command.ts +727 -0
- package/src/init-command.ts +27 -0
- package/src/init-template/AGENTS.md.template +1 -1
- package/src/init-template/hello/workflow.test.ts.template +2 -2
- package/src/init-template/hello/workflow.ts.template +16 -20
- package/src/keep-alive.ts +40 -0
- package/src/logs-command.ts +104 -20
- package/src/machine.ts +12 -1
- package/src/mailbox.ts +977 -0
- package/src/message-operation.ts +26 -3
- package/src/messaging/messaging.ts +458 -65
- package/src/messaging/routes.ts +10 -3
- package/src/messaging/text.ts +8 -5
- package/src/operation-liveness.ts +54 -8
- package/src/operator-cli.ts +290 -119
- package/src/operator-runtime.ts +5 -2
- package/src/progress-view.ts +49 -7
- package/src/projects.ts +57 -0
- package/src/result-slots.ts +43 -19
- package/src/result-validation.ts +18 -27
- package/src/run-command.ts +36 -32
- package/src/run-commands.ts +13 -2
- package/src/run-lock.ts +123 -0
- package/src/run-prepare.ts +94 -22
- package/src/run-state.ts +125 -6
- package/src/run-usage.ts +1 -1
- package/src/run-view.ts +0 -9
- package/src/runs-command.ts +46 -17
- package/src/runs.ts +142 -23
- package/src/sandboxes.ts +8 -2
- package/src/simple-input.ts +258 -0
- package/src/socket-exchange.ts +26 -0
- package/src/sources.ts +70 -0
- package/src/stage-ledger.ts +88 -13
- package/src/stage-plan.ts +23 -0
- package/src/stop-crashed.ts +39 -0
- package/src/test-command.ts +32 -0
- package/src/testing.ts +71 -3
- package/src/tick-command.ts +87 -0
- package/src/tools.ts +636 -0
- package/src/wake.ts +264 -0
- package/src/workflow-loader.ts +32 -22
- package/src/workflow-runner.ts +1210 -77
- package/src/workflow-testing/coordinator.typecheck.ts +55 -0
- package/src/workflow-testing/coordinators.ts +137 -0
- package/src/workflow-testing/host.ts +61 -1
- package/src/workflow-testing/index.ts +488 -33
- package/src/workflow-testing/script.ts +72 -2
- 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
|
|
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
|
-
|
|
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 '-',
|
|
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
|
-
|
|
66
|
-
|
|
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
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
withinReach,
|
|
24
24
|
} from "@agentswf/sandbox";
|
|
25
25
|
import { CONTROL_PLANE_ROOT } from "./control-plane";
|
|
26
|
+
import { harnessDeadline } from "./deadlines";
|
|
26
27
|
import { messageOf } from "./errors";
|
|
27
28
|
import { machinePaths } from "./machine";
|
|
28
29
|
import { type CredentialLocks, placeCarried, type SeededHome, seedHome } from "./sandbox-homes";
|
|
@@ -38,6 +39,8 @@ export type RunSandboxOptions = {
|
|
|
38
39
|
sandboxesDir: string;
|
|
39
40
|
/** The operator's `~/.awf`, which no sandbox reaches. */
|
|
40
41
|
machineRoot: string;
|
|
42
|
+
/** Where holders reach live runs' coordinators, `AWF_COORDINATOR_DIR` if it moved; no sandbox reaches it. */
|
|
43
|
+
coordinatorRoot?: string;
|
|
41
44
|
/**
|
|
42
45
|
* The operator's environment, which says where each harness keeps its state, the `PATH` an
|
|
43
46
|
* agent's harness is found on and claude's token. Defaults to the engine's.
|
|
@@ -123,7 +126,7 @@ export class RunSandboxes {
|
|
|
123
126
|
sandboxes?: RunSandboxOptions;
|
|
124
127
|
runRoot: string;
|
|
125
128
|
cwd: string;
|
|
126
|
-
deadline
|
|
129
|
+
deadline?: AbsoluteDeadline;
|
|
127
130
|
/** The run's credential write-backs, which a host agent's own home shares. */
|
|
128
131
|
locks: CredentialLocks;
|
|
129
132
|
log(message: string): void;
|
|
@@ -396,7 +399,7 @@ export class RunSandboxes {
|
|
|
396
399
|
const opened = await installed.open(resolved, {
|
|
397
400
|
runRoot: options.runRoot,
|
|
398
401
|
directory,
|
|
399
|
-
deadline: this.options.deadline,
|
|
402
|
+
deadline: harnessDeadline(this.options.deadline),
|
|
400
403
|
});
|
|
401
404
|
return { key, provider, resolved, directory, opened, admitting: Promise.resolve(), agents: [] };
|
|
402
405
|
}
|
|
@@ -411,6 +414,9 @@ export class RunSandboxes {
|
|
|
411
414
|
providers: this.#providers,
|
|
412
415
|
harnessState: Object.values(harnessState(this.#environment)),
|
|
413
416
|
controlRoot: CONTROL_PLANE_ROOT,
|
|
417
|
+
...(this.options.sandboxes?.coordinatorRoot === undefined
|
|
418
|
+
? {}
|
|
419
|
+
: { coordinatorRoot: this.options.sandboxes.coordinatorRoot }),
|
|
414
420
|
inline,
|
|
415
421
|
};
|
|
416
422
|
}
|
|
@@ -0,0 +1,258 @@
|
|
|
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
|
+
/** Said once in a prompt, under the first block taught with flags, and with every refusal of them. */
|
|
23
|
+
export const QUOTING_RULE =
|
|
24
|
+
"Put every value in single quotes; a ' inside a value is written '\\''.";
|
|
25
|
+
|
|
26
|
+
const MAX_PROPERTIES = 6;
|
|
27
|
+
const NAME = /^[a-z][a-z0-9_]*$/;
|
|
28
|
+
/** An enum value the usage line can show between `|`s and the agent can type as it stands. */
|
|
29
|
+
const WORD = /^[A-Za-z0-9_.:-]+$/;
|
|
30
|
+
const HEREDOC = "send it as JSON on standard input, in a quoted heredoc (<<'WF_JSON' … WF_JSON)";
|
|
31
|
+
const ANNOTATIONS = new Set<string>(ANNOTATION_KEYS);
|
|
32
|
+
|
|
33
|
+
export function simpleInput(schema: JsonSchema): Simple | undefined {
|
|
34
|
+
if (!("type" in schema) || schema.type !== "object") return undefined;
|
|
35
|
+
const entries = Object.entries(schema.properties);
|
|
36
|
+
// No properties is an open object or nothing at all; neither reads as flags.
|
|
37
|
+
if (entries.length === 0 || entries.length > MAX_PROPERTIES) return undefined;
|
|
38
|
+
const required = new Set(schema.required ?? []);
|
|
39
|
+
if ([...required].some((name) => !Object.hasOwn(schema.properties, name))) return undefined;
|
|
40
|
+
const simple: Simple = [];
|
|
41
|
+
for (const [name, property] of entries) {
|
|
42
|
+
const kind = NAME.test(name) ? kindOf(property) : undefined;
|
|
43
|
+
if (!kind) return undefined;
|
|
44
|
+
simple.push({ name, required: required.has(name), kind });
|
|
45
|
+
}
|
|
46
|
+
return simple;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function kindOf(schema: JsonSchema): Kind | undefined {
|
|
50
|
+
const kind = anyKindOf(schema);
|
|
51
|
+
return kind?.type === "enum" && !kind.values.every((value) => WORD.test(value))
|
|
52
|
+
? undefined
|
|
53
|
+
: kind;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function anyKindOf(schema: JsonSchema): Kind | undefined {
|
|
57
|
+
const keys = Object.keys(schema).filter((key) => !ANNOTATIONS.has(key));
|
|
58
|
+
const only = (...allowed: string[]) => keys.every((key) => allowed.includes(key));
|
|
59
|
+
if ("anyOf" in schema) {
|
|
60
|
+
if (!only("anyOf")) return undefined;
|
|
61
|
+
const values = schema.anyOf.map((each) =>
|
|
62
|
+
"type" in each && each.type === "string" ? stringConst(each) : undefined,
|
|
63
|
+
);
|
|
64
|
+
return values.every((value) => value !== undefined)
|
|
65
|
+
? { type: "enum", values: values as string[] }
|
|
66
|
+
: undefined;
|
|
67
|
+
}
|
|
68
|
+
if (!("type" in schema)) {
|
|
69
|
+
return only("enum") && schema.enum.every((value) => typeof value === "string")
|
|
70
|
+
? { type: "enum", values: schema.enum as string[] }
|
|
71
|
+
: undefined;
|
|
72
|
+
}
|
|
73
|
+
switch (schema.type) {
|
|
74
|
+
case "string": {
|
|
75
|
+
const constant = stringConst(schema);
|
|
76
|
+
if (constant !== undefined) return { type: "enum", values: [constant] };
|
|
77
|
+
if (schema.enum)
|
|
78
|
+
return only("type", "enum") ? { type: "enum", values: schema.enum } : undefined;
|
|
79
|
+
if (!only("type", "minLength")) return undefined;
|
|
80
|
+
return { type: "string", ...(schema.minLength ? { minLength: schema.minLength } : {}) };
|
|
81
|
+
}
|
|
82
|
+
case "number":
|
|
83
|
+
case "integer":
|
|
84
|
+
if (!only("type", "minimum", "maximum")) return undefined;
|
|
85
|
+
return {
|
|
86
|
+
type: schema.type,
|
|
87
|
+
...(schema.minimum === undefined ? {} : { minimum: schema.minimum }),
|
|
88
|
+
...(schema.maximum === undefined ? {} : { maximum: schema.maximum }),
|
|
89
|
+
};
|
|
90
|
+
case "boolean":
|
|
91
|
+
return only("type") ? { type: "boolean" } : undefined;
|
|
92
|
+
default:
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A string schema that is one constant and nothing else, as TypeBox writes a literal. */
|
|
98
|
+
function stringConst(schema: JsonSchema): string | undefined {
|
|
99
|
+
if (!("type" in schema) || schema.type !== "string" || schema.const === undefined) return;
|
|
100
|
+
const keys = Object.keys(schema).filter((key) => !ANNOTATIONS.has(key));
|
|
101
|
+
return keys.every((key) => key === "type" || key === "const") ? schema.const : undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The flags as the prompt teaches them: `--verdict noise|real --confidence <integer, 1 to 5>`. */
|
|
105
|
+
export function usageLine(simple: Simple): string {
|
|
106
|
+
return simple
|
|
107
|
+
.map(({ name, required, kind }) => {
|
|
108
|
+
const flag = `--${name} ${placeholder(kind)}`;
|
|
109
|
+
return required ? flag : `[${flag}]`;
|
|
110
|
+
})
|
|
111
|
+
.join(" ");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function placeholder(kind: Kind): string {
|
|
115
|
+
switch (kind.type) {
|
|
116
|
+
case "enum":
|
|
117
|
+
return kind.values.join("|");
|
|
118
|
+
case "boolean":
|
|
119
|
+
return "true|false";
|
|
120
|
+
case "string":
|
|
121
|
+
return kind.minLength
|
|
122
|
+
? `<text, at least ${kind.minLength} character${kind.minLength === 1 ? "" : "s"}>`
|
|
123
|
+
: "<text>";
|
|
124
|
+
case "number":
|
|
125
|
+
case "integer": {
|
|
126
|
+
const { minimum, maximum } = kind;
|
|
127
|
+
if (minimum !== undefined && maximum !== undefined)
|
|
128
|
+
return `<${kind.type}, ${minimum} to ${maximum}>`;
|
|
129
|
+
if (minimum !== undefined) return `<${kind.type}, at least ${minimum}>`;
|
|
130
|
+
if (maximum !== undefined) return `<${kind.type}, at most ${maximum}>`;
|
|
131
|
+
return `<${kind.type}>`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type ReadFlags = { ok: true; value: JsonObject } | { ok: false; error: string };
|
|
137
|
+
export type ReadGiven = { ok: true; value: unknown } | { ok: false; error: string };
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* An input as `wf` sent it, as a value: JSON parsed, flags read by the schema, nothing as `{}`.
|
|
141
|
+
* The schema's own check comes after, in `checkGiven`.
|
|
142
|
+
*/
|
|
143
|
+
export function readGiven(schema: JsonSchema | undefined, given: GivenInput): ReadGiven {
|
|
144
|
+
if (given.raw !== undefined) {
|
|
145
|
+
try {
|
|
146
|
+
return { ok: true, value: JSON.parse(given.raw) };
|
|
147
|
+
} catch (error) {
|
|
148
|
+
const shape = schema ? JSON.stringify(schema) : "any JSON value";
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
error: `the value is not valid JSON: ${messageOf(error)}\nexpected: ${shape}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (given.args === undefined) {
|
|
156
|
+
// Nothing given is `{}` only where an object is wanted, so a text answer isn't told it's one.
|
|
157
|
+
return schema && "type" in schema && schema.type === "object"
|
|
158
|
+
? { ok: true, value: {} }
|
|
159
|
+
: { ok: false, error: `nothing was given; ${HEREDOC}` };
|
|
160
|
+
}
|
|
161
|
+
if (!schema) return { ok: false, error: `this isn't taken as flags; ${HEREDOC}` };
|
|
162
|
+
return readFlags(schema, given.args);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The schema's errors on a value `readGiven` returned, with the usage line when it came as flags or
|
|
167
|
+
* as nothing and could have come as flags: a missing required flag names its property.
|
|
168
|
+
*/
|
|
169
|
+
export function checkGiven(
|
|
170
|
+
schema: JsonSchema,
|
|
171
|
+
given: GivenInput,
|
|
172
|
+
value: unknown,
|
|
173
|
+
): string | undefined {
|
|
174
|
+
const errors = validate(schema, value);
|
|
175
|
+
if (errors.length === 0) return undefined;
|
|
176
|
+
if (given.raw !== undefined) return formatErrors(errors);
|
|
177
|
+
const simple = simpleInput(schema);
|
|
178
|
+
if (simple) return `${formatErrors(errors)}\n${expected(simple)}`;
|
|
179
|
+
// Nothing given to a schema that takes JSON only: a lost heredoc, as likely as not.
|
|
180
|
+
return `${formatErrors(errors)}\n${HEREDOC}, matching this schema:\n${JSON.stringify(schema)}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function expected(simple: Simple): string {
|
|
184
|
+
return `expected: ${usageLine(simple)}\n${QUOTING_RULE}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Reads `--name 'value'` and `--name=value` by the schema, a number or boolean converted by its
|
|
189
|
+
* type. The word after a flag is always its value, so a value may start with `--`. A value the
|
|
190
|
+
* usage line rules out is refused with it; a missing required flag is left to the schema's check,
|
|
191
|
+
* which names the property.
|
|
192
|
+
*/
|
|
193
|
+
export function readFlags(schema: JsonSchema, args: readonly string[]): ReadFlags {
|
|
194
|
+
const simple = simpleInput(schema);
|
|
195
|
+
if (!simple) {
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
error: `this isn't taken as flags; ${HEREDOC}, matching this schema:\n${JSON.stringify(schema)}`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const refuse = (problem: string): ReadFlags => ({
|
|
202
|
+
ok: false,
|
|
203
|
+
error: `${problem}\n${expected(simple)}`,
|
|
204
|
+
});
|
|
205
|
+
const value: JsonObject = {};
|
|
206
|
+
for (let i = 0; i < args.length; i++) {
|
|
207
|
+
const word = args[i]!;
|
|
208
|
+
if (!word.startsWith("--")) {
|
|
209
|
+
const brackets = word.startsWith("[") || word.endsWith("]");
|
|
210
|
+
return refuse(
|
|
211
|
+
brackets
|
|
212
|
+
? `${JSON.stringify(word)} is not a flag: leave out the brackets around an optional flag`
|
|
213
|
+
: `${JSON.stringify(word)} is not a flag: each value goes right after its flag, in single quotes`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const equals = word.indexOf("=");
|
|
217
|
+
const name = equals === -1 ? word.slice(2) : word.slice(2, equals);
|
|
218
|
+
const property = simple.find((each) => each.name === name);
|
|
219
|
+
if (!property) return refuse(`--${name} is not a flag this takes`);
|
|
220
|
+
if (Object.hasOwn(value, name)) return refuse(`--${name} is given twice`);
|
|
221
|
+
let text: string;
|
|
222
|
+
if (equals !== -1) text = word.slice(equals + 1);
|
|
223
|
+
else {
|
|
224
|
+
const next = args[i + 1];
|
|
225
|
+
if (next === undefined) return refuse(`--${name} needs a value`);
|
|
226
|
+
text = next;
|
|
227
|
+
i++;
|
|
228
|
+
}
|
|
229
|
+
const converted = convert(property.kind, text);
|
|
230
|
+
if (converted === undefined)
|
|
231
|
+
return refuse(`--${name} takes ${placeholder(property.kind)}; got ${JSON.stringify(text)}`);
|
|
232
|
+
value[name] = converted;
|
|
233
|
+
}
|
|
234
|
+
return { ok: true, value };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The value, or undefined when the usage line says it can't be, bounds included. */
|
|
238
|
+
function convert(kind: Kind, text: string): string | number | boolean | undefined {
|
|
239
|
+
switch (kind.type) {
|
|
240
|
+
case "string":
|
|
241
|
+
return text.length >= (kind.minLength ?? 0) ? text : undefined;
|
|
242
|
+
case "enum":
|
|
243
|
+
return kind.values.includes(text) ? text : undefined;
|
|
244
|
+
case "boolean":
|
|
245
|
+
return text === "true" ? true : text === "false" ? false : undefined;
|
|
246
|
+
case "number":
|
|
247
|
+
case "integer": {
|
|
248
|
+
const trimmed = text.trim();
|
|
249
|
+
if (!/^-?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(trimmed)) return undefined;
|
|
250
|
+
const number = Number(trimmed);
|
|
251
|
+
if (!Number.isFinite(number) || (kind.type === "integer" && !Number.isInteger(number)))
|
|
252
|
+
return undefined;
|
|
253
|
+
if (kind.minimum !== undefined && number < kind.minimum) return undefined;
|
|
254
|
+
if (kind.maximum !== undefined && number > kind.maximum) return undefined;
|
|
255
|
+
return number;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -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
|
+
}
|