@agentswf/engine 0.1.0-beta.4 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/package.json +13 -9
- package/src/accounting/format.ts +4 -3
- package/src/accounting/spent.ts +128 -0
- package/src/accounting/summary.ts +40 -32
- package/src/agent-cli/cli.ts +35 -22
- package/src/agent-cli/client.ts +1 -27
- package/src/allowance-command.ts +8 -10
- package/src/attempt-ending.ts +5 -7
- package/src/attempt-output.ts +15 -32
- package/src/attempt-view.ts +34 -33
- package/src/author-typebox.ts +13 -0
- package/src/client/checks.ts +94 -0
- package/src/client/exported.ts +17 -0
- package/src/client/index.ts +122 -0
- package/src/client/locate.ts +34 -0
- package/src/client/published.ts +3 -0
- package/src/client/runs.ts +337 -0
- package/src/command-line.ts +64 -0
- package/src/commands.ts +7 -1
- package/src/control-plane.ts +41 -11
- package/src/coordinator-prompt.ts +4 -3
- package/src/coordinator-socket.ts +41 -33
- package/src/deadlines.ts +40 -7
- package/src/decisions/directory.ts +51 -40
- package/src/decisions/fake.ts +1 -1
- package/src/decisions/openrouter.ts +4 -2
- package/src/decisions/seam.ts +4 -4
- package/src/display-path.ts +3 -3
- package/src/duration.ts +43 -27
- package/src/emit-command.ts +88 -58
- package/src/emit.ts +254 -56
- package/src/event-ids.ts +31 -0
- package/src/files.ts +44 -4
- package/src/hand-over.ts +150 -0
- package/src/help/allowance.txt +4 -3
- package/src/help/emit.txt +18 -6
- package/src/help/inbox.txt +6 -4
- package/src/help/logs.txt +9 -6
- package/src/help/open.txt +29 -0
- package/src/help/reply.txt +6 -8
- package/src/help/run.txt +30 -11
- package/src/help/runs.txt +4 -2
- package/src/help/tick.txt +9 -4
- package/src/herdr-placement.ts +3 -33
- package/src/herdr-run-session.ts +7 -15
- package/src/herdr-workspace-marks.ts +7 -17
- package/src/holder-command.ts +56 -133
- package/src/in-lines.ts +28 -0
- package/src/inbox-command.ts +40 -57
- package/src/index.ts +1 -1
- package/src/init-command.ts +8 -17
- package/src/init-template/hello/workflow.test.ts.template +3 -3
- package/src/init-template/hello/workflow.ts.template +5 -4
- package/src/jsonl.ts +10 -6
- package/src/keep-alive.ts +15 -12
- package/src/logs-command.ts +128 -47
- package/src/message-operation.ts +6 -3
- package/src/messaging/limits.ts +4 -4
- package/src/messaging/messaging.ts +31 -28
- package/src/messaging/routes.ts +1 -1
- package/src/messaging/text.ts +9 -7
- package/src/open-command.ts +770 -0
- package/src/operation-events.ts +4 -6
- package/src/operation-liveness.ts +19 -4
- package/src/operator-cli.ts +224 -94
- package/src/operator-runtime.ts +29 -7
- package/src/ops.ts +270 -0
- package/src/pane-layout.ts +105 -59
- package/src/progress-view.ts +10 -10
- package/src/project-engine.ts +79 -0
- package/src/projects.ts +13 -2
- package/src/refusals.ts +119 -0
- package/src/result-slots.ts +11 -31
- package/src/result-validation.ts +2 -2
- package/src/run-command.ts +45 -28
- package/src/run-commands.ts +43 -115
- package/src/run-event-log.ts +1 -1
- package/src/run-lines.ts +159 -0
- package/src/run-lock.ts +57 -1
- package/src/{stage-plan.ts → run-policy/stage-plan.ts} +46 -42
- package/src/run-policy/stages.ts +113 -0
- package/src/run-policy/waits.ts +452 -0
- package/src/run-prepare.ts +205 -43
- package/src/run-read.ts +109 -41
- package/src/run-state.ts +170 -50
- package/src/run-text.ts +54 -0
- package/src/run-unreachable.ts +18 -0
- package/src/run-usage.ts +18 -12
- package/src/run-view.ts +81 -17
- package/src/runs-command.ts +83 -63
- package/src/runs.ts +310 -212
- package/src/sandbox-homes.ts +65 -13
- package/src/sandboxes.ts +182 -28
- package/src/session-lines.ts +93 -0
- package/src/skills/run-skills.ts +8 -1
- package/src/sources.ts +28 -29
- package/src/spend.ts +333 -0
- package/src/stage-ledger.ts +119 -131
- package/src/stop-crashed.ts +6 -5
- package/src/stopped.ts +4 -4
- package/src/test-command.ts +36 -14
- package/src/testing/operation-events.ts +3 -23
- package/src/testing.ts +73 -26
- package/src/tick-command.ts +145 -15
- package/src/tools.ts +25 -16
- package/src/turn-ends.ts +37 -0
- package/src/waits.ts +651 -0
- package/src/wake.ts +84 -54
- package/src/workflow-loader.ts +54 -4
- package/src/workflow-runner.ts +805 -580
- package/src/workflow-test-preload.ts +4 -1
- package/src/workflow-testing/coordinators.ts +3 -3
- package/src/workflow-testing/fake-client.ts +387 -0
- package/src/workflow-testing/host.ts +121 -53
- package/src/workflow-testing/index.ts +33 -842
- package/src/workflow-testing/run-test.ts +1128 -0
- package/src/workflow-testing/script.ts +74 -45
- package/src/hold-run-lock.fixture.ts +0 -8
- package/src/mailbox.ts +0 -990
- package/src/workflow-testing/coordinator.typecheck.ts +0 -54
- package/src/workflow-testing/workflow-testing.typecheck.ts +0 -64
package/src/emit-command.ts
CHANGED
|
@@ -1,14 +1,27 @@
|
|
|
1
1
|
import type { EmitResultJson } from "@agentswf/contract/records";
|
|
2
2
|
import { isEventWorkflow, type JsonValue } from "@agentswf/contract/workflow";
|
|
3
|
-
import {
|
|
3
|
+
import { type Out, parsePlace, type ReadPlace } from "./command-line";
|
|
4
|
+
import { EmitError, emitEvent, emitToRun } from "./emit";
|
|
4
5
|
import { messageOf } from "./errors";
|
|
5
6
|
import help from "./help/emit.txt" with { type: "text" };
|
|
6
|
-
import
|
|
7
|
-
import
|
|
7
|
+
import { asksJson, printRefusal, readFailureCode } from "./refusals";
|
|
8
|
+
import { findRunByRef } from "./run-state";
|
|
9
|
+
import type { Launch, Woken } from "./wake";
|
|
8
10
|
import { loadWorkflowFile } from "./workflow-loader";
|
|
9
11
|
|
|
12
|
+
/** `awf emit --json`'s `"v"`, which its refusals carry too. */
|
|
13
|
+
export const EMIT_V = 1 satisfies EmitResultJson["v"];
|
|
14
|
+
|
|
10
15
|
export const emitUsage = help.trimEnd();
|
|
11
16
|
|
|
17
|
+
type Event = { name: string; data: JsonValue; id?: string; source?: string };
|
|
18
|
+
|
|
19
|
+
/** `--target {file} --id {id}`, which may start the run; or a run that is there, by its ref. */
|
|
20
|
+
type Parsed = Event & { json: boolean } & (
|
|
21
|
+
| { target: string; run: string; ref?: undefined }
|
|
22
|
+
| { ref: string; place: ReadPlace; target?: undefined }
|
|
23
|
+
);
|
|
24
|
+
|
|
12
25
|
export async function emitCommand(
|
|
13
26
|
argv: readonly string[],
|
|
14
27
|
shellCwd: string,
|
|
@@ -19,91 +32,101 @@ export async function emitCommand(
|
|
|
19
32
|
launch?: Launch;
|
|
20
33
|
},
|
|
21
34
|
): Promise<number> {
|
|
22
|
-
|
|
23
|
-
out.stdout(emitUsage);
|
|
24
|
-
return 0;
|
|
25
|
-
}
|
|
26
|
-
let parsed: {
|
|
27
|
-
target: string;
|
|
28
|
-
run: string;
|
|
29
|
-
name: string;
|
|
30
|
-
data: JsonValue;
|
|
31
|
-
id?: string;
|
|
32
|
-
json: boolean;
|
|
33
|
-
};
|
|
35
|
+
let parsed: Parsed;
|
|
34
36
|
try {
|
|
35
|
-
parsed = parse(argv);
|
|
37
|
+
parsed = parse(argv, shellCwd);
|
|
36
38
|
} catch (error) {
|
|
37
|
-
out
|
|
39
|
+
printRefusal(out, { json: asksJson(argv), v: EMIT_V }, error, "usage", emitUsage);
|
|
38
40
|
return 2;
|
|
39
41
|
}
|
|
42
|
+
const json = { json: parsed.json, v: EMIT_V };
|
|
43
|
+
const event = {
|
|
44
|
+
name: parsed.name,
|
|
45
|
+
data: parsed.data,
|
|
46
|
+
...(parsed.id === undefined ? {} : { id: parsed.id }),
|
|
47
|
+
...(parsed.source === undefined ? {} : { source: parsed.source }),
|
|
48
|
+
};
|
|
40
49
|
let result: EmitResultJson;
|
|
41
50
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
51
|
+
if (parsed.ref !== undefined) {
|
|
52
|
+
const status = await findRunByRef(parsed.ref, parsed.place, context.home);
|
|
53
|
+
result = await emitToRun(status, event, context);
|
|
54
|
+
} else {
|
|
55
|
+
const loaded = await loadWorkflowFile(parsed.target, shellCwd, context.home);
|
|
56
|
+
if (!isEventWorkflow(loaded.workflow)) {
|
|
57
|
+
printRefusal(out, json, `${loaded.file} declares no events; awf run starts it`, "event");
|
|
58
|
+
return 2;
|
|
59
|
+
}
|
|
60
|
+
result = await emitEvent(
|
|
61
|
+
loaded.workflow,
|
|
62
|
+
{ run: parsed.run, ...event },
|
|
63
|
+
{ from: "operator", ...context },
|
|
64
|
+
);
|
|
46
65
|
}
|
|
47
|
-
result = await emitEvent(
|
|
48
|
-
loaded.workflow,
|
|
49
|
-
{
|
|
50
|
-
run: parsed.run,
|
|
51
|
-
name: parsed.name,
|
|
52
|
-
data: parsed.data,
|
|
53
|
-
...(parsed.id === undefined ? {} : { id: parsed.id }),
|
|
54
|
-
},
|
|
55
|
-
{ from: "operator", ...context },
|
|
56
|
-
);
|
|
57
66
|
} catch (error) {
|
|
58
|
-
out
|
|
67
|
+
printRefusal(out, json, error, readFailureCode(error));
|
|
59
68
|
return error instanceof EmitError ? 2 : 1;
|
|
60
69
|
}
|
|
61
70
|
return printEmit(result, parsed.json, out);
|
|
62
71
|
}
|
|
63
72
|
|
|
64
|
-
/** Prints an emit's result,
|
|
65
|
-
|
|
66
|
-
out.stdout(json ? JSON.stringify(result) :
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
/** Prints an emit's result, `accepted dd-case/kombo: started`: 1 when the run's attempt didn't start. */
|
|
74
|
+
function printEmit(result: EmitResultJson, json: boolean, out: Out): number {
|
|
75
|
+
out.stdout(json ? JSON.stringify(result) : `${result.outcome} ${result.ref}${didOf(result)}`);
|
|
76
|
+
return launchExit(result, out);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** What waking a run did, `: started`, or why it did nothing; empty when neither is said. */
|
|
80
|
+
export function didOf(woken: Pick<Woken, "did" | "why">): string {
|
|
81
|
+
const did = woken.did === "none" ? woken.why : woken.did;
|
|
82
|
+
return did === undefined ? "" : `: ${did}`;
|
|
70
83
|
}
|
|
71
84
|
|
|
72
|
-
/**
|
|
73
|
-
function
|
|
74
|
-
|
|
75
|
-
|
|
85
|
+
/** 0; or 1, said on stderr, when the run's attempt did not start. */
|
|
86
|
+
export function launchExit(woken: Pick<Woken, "why" | "error">, out: Out): number {
|
|
87
|
+
if (woken.why !== "launch-failed") return 0;
|
|
88
|
+
out.stderr(`awf: the run's attempt did not start: ${woken.error}`);
|
|
89
|
+
return 1;
|
|
76
90
|
}
|
|
77
91
|
|
|
78
|
-
function parse(argv: readonly string[]): {
|
|
79
|
-
|
|
80
|
-
run: string;
|
|
81
|
-
name: string;
|
|
82
|
-
data: JsonValue;
|
|
83
|
-
id?: string;
|
|
84
|
-
json: boolean;
|
|
85
|
-
} {
|
|
92
|
+
function parse(argv: readonly string[], shellCwd: string): Parsed {
|
|
93
|
+
const { place, json, rest } = parsePlace(argv, shellCwd);
|
|
86
94
|
let target: string | undefined;
|
|
87
95
|
let run: string | undefined;
|
|
88
96
|
let id: string | undefined;
|
|
89
|
-
let
|
|
97
|
+
let source: string | undefined;
|
|
90
98
|
const words: string[] = [];
|
|
91
|
-
for (let i = 0; i <
|
|
92
|
-
const word =
|
|
99
|
+
for (let i = 0; i < rest.length; i++) {
|
|
100
|
+
const word = rest[i]!;
|
|
93
101
|
const value = () => {
|
|
94
|
-
const next =
|
|
102
|
+
const next = rest[++i];
|
|
95
103
|
if (next === undefined) throw new Error(`${word} needs a value`);
|
|
96
104
|
return next;
|
|
97
105
|
};
|
|
98
106
|
if (word === "--target") target = value();
|
|
99
107
|
else if (word === "--id") run = value();
|
|
100
108
|
else if (word === "--event-id") id = value();
|
|
101
|
-
else if (word === "--
|
|
109
|
+
else if (word === "--source") source = value();
|
|
102
110
|
else if (word.startsWith("--")) throw new Error(`unknown option ${word}`);
|
|
103
111
|
else words.push(word);
|
|
104
112
|
}
|
|
105
|
-
|
|
106
|
-
if (
|
|
113
|
+
let form: { target: string; run: string } | { ref: string; place: ReadPlace };
|
|
114
|
+
if (target !== undefined) {
|
|
115
|
+
if (run === undefined) throw new Error("--id names the run the event is for, by its id");
|
|
116
|
+
if (place.repeat.length > 0)
|
|
117
|
+
throw new Error(
|
|
118
|
+
"--cwd and --run-root find a run by its ref; --target's file names its project",
|
|
119
|
+
);
|
|
120
|
+
form = { target, run };
|
|
121
|
+
} else {
|
|
122
|
+
if (run !== undefined) throw new Error("--id goes with --target; a run's ref names it alone");
|
|
123
|
+
const ref = words.shift();
|
|
124
|
+
if (ref === undefined)
|
|
125
|
+
throw new Error(
|
|
126
|
+
"name the run, <workflow>/<id> or <id>, or the workflow by --target and --id",
|
|
127
|
+
);
|
|
128
|
+
form = { ref, place };
|
|
129
|
+
}
|
|
107
130
|
const [name, text, ...more] = words;
|
|
108
131
|
if (name === undefined) throw new Error("the event's name is missing");
|
|
109
132
|
if (more.length > 0) throw new Error(`one event at a time; also given: ${more.join(" ")}`);
|
|
@@ -115,5 +138,12 @@ function parse(argv: readonly string[]): {
|
|
|
115
138
|
throw new Error(`the event's data is not JSON: ${messageOf(error)}`);
|
|
116
139
|
}
|
|
117
140
|
}
|
|
118
|
-
return {
|
|
141
|
+
return {
|
|
142
|
+
...form,
|
|
143
|
+
name,
|
|
144
|
+
data,
|
|
145
|
+
...(id === undefined ? {} : { id }),
|
|
146
|
+
...(source === undefined ? {} : { source }),
|
|
147
|
+
json,
|
|
148
|
+
};
|
|
119
149
|
}
|
package/src/emit.ts
CHANGED
|
@@ -1,26 +1,58 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { isAbsolute, join } from "node:path";
|
|
3
|
-
import
|
|
3
|
+
import type { ClientErrorCode } from "@agentswf/contract/client";
|
|
4
|
+
import {
|
|
5
|
+
type EmitResultJson,
|
|
6
|
+
type EventLine,
|
|
7
|
+
emitOk,
|
|
8
|
+
type RunPanes,
|
|
9
|
+
} from "@agentswf/contract/records";
|
|
4
10
|
import { parseJsonSchema, validate } from "@agentswf/contract/schema";
|
|
5
11
|
import {
|
|
6
12
|
AWF_ID_PREFIX,
|
|
13
|
+
awfEventOf,
|
|
7
14
|
eventWorkflowProblem,
|
|
15
|
+
isEventWorkflow,
|
|
8
16
|
type JsonValue,
|
|
9
17
|
nameProblem,
|
|
18
|
+
ParticipantReply,
|
|
10
19
|
} from "@agentswf/contract/workflow";
|
|
11
20
|
import { messageOf } from "./errors";
|
|
12
21
|
import { exists } from "./files";
|
|
13
|
-
import {
|
|
22
|
+
import { appendIn, readIn } from "./in-lines";
|
|
23
|
+
import { checkProjectEngine, EngineRefused } from "./project-engine";
|
|
14
24
|
import { projectOf, projectRunRoot, recordProject } from "./projects";
|
|
15
25
|
import { runLockFile, withRunLock } from "./run-lock";
|
|
16
26
|
import { prepareArgs } from "./run-prepare";
|
|
17
|
-
import
|
|
27
|
+
import type { RunStatus } from "./run-state";
|
|
28
|
+
import {
|
|
29
|
+
createRun,
|
|
30
|
+
idProblem,
|
|
31
|
+
openRun,
|
|
32
|
+
panesChanged,
|
|
33
|
+
type Run,
|
|
34
|
+
RunTaken,
|
|
35
|
+
readAttempts,
|
|
36
|
+
} from "./runs";
|
|
18
37
|
import { type WakeContext, type Woken, wake } from "./wake";
|
|
19
|
-
import type
|
|
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
|
+
>;
|
|
20
49
|
|
|
21
50
|
/** An event awf refuses to take at all: a name, shape or id no run of the target could have. */
|
|
22
51
|
export class EmitError extends Error {
|
|
23
|
-
constructor(
|
|
52
|
+
constructor(
|
|
53
|
+
message: string,
|
|
54
|
+
readonly code: EmitCode = "event",
|
|
55
|
+
) {
|
|
24
56
|
super(message);
|
|
25
57
|
this.name = "EmitError";
|
|
26
58
|
}
|
|
@@ -37,41 +69,113 @@ export type EmitContext = Omit<WakeContext, "file"> & {
|
|
|
37
69
|
*/
|
|
38
70
|
export async function emitEvent(
|
|
39
71
|
target: EventWorkflow,
|
|
40
|
-
event: { run: string; name: string; data: JsonValue; id?: string },
|
|
72
|
+
event: { run: string; name: string; data: JsonValue; id?: string; source?: string },
|
|
41
73
|
context: EmitContext,
|
|
42
74
|
): Promise<EmitResultJson> {
|
|
43
75
|
const workflow = target.meta.name;
|
|
44
|
-
const { name, data } = event;
|
|
76
|
+
const { name, data, source } = event;
|
|
45
77
|
checkEvent(target, event);
|
|
78
|
+
const badSource = source === undefined ? undefined : nameProblem(source);
|
|
79
|
+
if (badSource) throw new EmitError(`the source ${JSON.stringify(source)}: ${badSource}`);
|
|
80
|
+
const file = fileOf(target);
|
|
46
81
|
const id = event.id ?? `${AWF_ID_PREFIX}${randomBytes(8).toString("hex")}`;
|
|
47
82
|
const at = new Date(context.now?.() ?? Date.now()).toISOString();
|
|
48
83
|
const runId = event.run;
|
|
49
84
|
const problem = idProblem(runId);
|
|
50
|
-
if (problem) throw new EmitError(`the run's id: ${problem}
|
|
51
|
-
const project = await
|
|
52
|
-
throw new EmitError(messageOf(error));
|
|
53
|
-
});
|
|
85
|
+
if (problem) throw new EmitError(`the run's id: ${problem}`, "id");
|
|
86
|
+
const project = await targetProject(file, context.home);
|
|
54
87
|
const starts = target.starts?.includes(name) ?? true;
|
|
55
88
|
return deliver(
|
|
56
89
|
{
|
|
57
90
|
runRoot: projectRunRoot(project),
|
|
58
91
|
workflow,
|
|
59
92
|
runId,
|
|
60
|
-
event: {
|
|
93
|
+
event: {
|
|
94
|
+
id,
|
|
95
|
+
name,
|
|
96
|
+
data,
|
|
97
|
+
from: context.from,
|
|
98
|
+
...(source === undefined ? {} : { source }),
|
|
99
|
+
at,
|
|
100
|
+
},
|
|
61
101
|
deduplicate: event.id !== undefined,
|
|
62
|
-
file
|
|
102
|
+
file,
|
|
63
103
|
...(starts ? { start: { project, target } } : {}),
|
|
64
104
|
},
|
|
65
105
|
context,
|
|
66
106
|
);
|
|
67
107
|
}
|
|
68
108
|
|
|
109
|
+
/**
|
|
110
|
+
* `awf emit {ref} {key}:reply …`: a participant's message to the code of a run that is there,
|
|
111
|
+
* checked against the definition in the file a wake would run, its last attempt's, and delivered.
|
|
112
|
+
* It never starts a run, and doesn't ask whether the participant has connected: one sent before
|
|
113
|
+
* the run connects it waits in `in.jsonl` for the run's next wait.
|
|
114
|
+
*/
|
|
115
|
+
export async function emitToRun(
|
|
116
|
+
status: RunStatus,
|
|
117
|
+
event: { name: string; data: JsonValue; id?: string; source?: string },
|
|
118
|
+
context: Omit<WakeContext, "file">,
|
|
119
|
+
): Promise<EmitResultJson> {
|
|
120
|
+
const { run, ref } = status;
|
|
121
|
+
const reply = awfEventOf(event.name);
|
|
122
|
+
if (reply?.kind !== "reply")
|
|
123
|
+
throw new EmitError(
|
|
124
|
+
`awf emit {run} sends a participant's message to the code, {key}:reply, not ${event.name}; the workflow's own events go by --target {file} --id {id}`,
|
|
125
|
+
);
|
|
126
|
+
if (!run.record.events) throw new EmitError(`${ref} is a run of a workflow without events`);
|
|
127
|
+
// A returned run takes nothing, so its file, since moved or changed, is not read: a late reply is
|
|
128
|
+
// refused as returned, not failed for a retry that never ends.
|
|
129
|
+
if (status.attempts.at(-1)?.outcome === "completed")
|
|
130
|
+
return deliverReply(status, reply.key, event, context);
|
|
131
|
+
const file = status.attempts.at(-1)?.file ?? run.record.file;
|
|
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);
|
|
135
|
+
if (!isEventWorkflow(workflow) || workflow.meta.name !== run.record.workflow)
|
|
136
|
+
throw new WorkflowFileError(
|
|
137
|
+
`${file} is no longer ${run.record.workflow} with events, which ${ref} ran`,
|
|
138
|
+
);
|
|
139
|
+
checkEvent(workflow, event, true);
|
|
140
|
+
return deliverReply(status, reply.key, event, context);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* `{key}:reply` appended to a run that is there, and the run woken: a duplicate id is a duplicate.
|
|
145
|
+
* The caller has checked it.
|
|
146
|
+
*/
|
|
147
|
+
export function deliverReply(
|
|
148
|
+
status: Pick<RunStatus, "run">,
|
|
149
|
+
key: string,
|
|
150
|
+
event: { data: JsonValue; id?: string; source?: string },
|
|
151
|
+
context: Omit<WakeContext, "file">,
|
|
152
|
+
): Promise<EmitResultJson> {
|
|
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}`);
|
|
156
|
+
const at = new Date(context.now?.() ?? Date.now()).toISOString();
|
|
157
|
+
const id = event.id ?? `${AWF_ID_PREFIX}${randomBytes(8).toString("hex")}`;
|
|
158
|
+
return deliver(
|
|
159
|
+
{
|
|
160
|
+
runRoot: run.root,
|
|
161
|
+
workflow: run.record.workflow,
|
|
162
|
+
runId: run.record.id,
|
|
163
|
+
event: {
|
|
164
|
+
...replyRecord(key, id, event.data, at),
|
|
165
|
+
...(event.source === undefined ? {} : { source: event.source }),
|
|
166
|
+
},
|
|
167
|
+
deduplicate: event.id !== undefined,
|
|
168
|
+
},
|
|
169
|
+
context,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
69
173
|
/** An event for a run of a workflow with events, as `deliver` takes it. */
|
|
70
174
|
export type Delivery = {
|
|
71
175
|
runRoot: string;
|
|
72
176
|
workflow: string;
|
|
73
177
|
runId: string;
|
|
74
|
-
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 };
|
|
75
179
|
/** False for an id awf made at random, which nothing repeats. */
|
|
76
180
|
deduplicate: boolean;
|
|
77
181
|
/** Given when the event may start the run: what a new run records, and the workflow it prepares. */
|
|
@@ -82,10 +186,10 @@ export type Delivery = {
|
|
|
82
186
|
|
|
83
187
|
/**
|
|
84
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
|
|
85
|
-
* own. Under the run's lock it finds a duplicate, refuses the event, or
|
|
86
|
-
* the
|
|
189
|
+
* own. Under the run's lock it finds a duplicate, refuses the event, or appends the event, to a run
|
|
190
|
+
* there or into a new one's `in.jsonl` before the run exists, and wakes the run.
|
|
87
191
|
*/
|
|
88
|
-
|
|
192
|
+
async function deliver(
|
|
89
193
|
delivery: Delivery,
|
|
90
194
|
context: Omit<WakeContext, "file">,
|
|
91
195
|
): Promise<EmitResultJson> {
|
|
@@ -98,27 +202,46 @@ export async function deliver(
|
|
|
98
202
|
: undefined;
|
|
99
203
|
// What was due is `awf tick`'s to say; an emit's caller knows it sent an event.
|
|
100
204
|
const result = (outcome: EmitResultJson["outcome"], { cause: _, ...woken }: Woken) =>
|
|
101
|
-
({
|
|
205
|
+
({
|
|
206
|
+
v: 1,
|
|
207
|
+
ok: emitOk(outcome, woken.why),
|
|
208
|
+
outcome,
|
|
209
|
+
ref,
|
|
210
|
+
eventId: event.id,
|
|
211
|
+
...woken,
|
|
212
|
+
}) satisfies EmitResultJson;
|
|
102
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`);
|
|
103
222
|
// Not appended again, but it still wakes the run: a sender retrying after a launch that failed
|
|
104
223
|
// sends the same id.
|
|
105
|
-
if (delivery.deduplicate &&
|
|
224
|
+
if (delivery.deduplicate && (await readIn(run.dir)).some((line) => line.id === event.id))
|
|
106
225
|
return result("duplicate", await wake(run, { ...woken, nudgeLive: false }));
|
|
107
226
|
if ((await readAttempts(run)).at(-1)?.outcome === "completed")
|
|
108
227
|
return result("refused", { did: "none", why: "returned" });
|
|
109
228
|
}
|
|
110
|
-
|
|
111
|
-
|
|
229
|
+
if (run) await appendIn(run.dir, event);
|
|
230
|
+
else {
|
|
112
231
|
if (!start) return result("refused", { did: "none", why: "not-a-start" });
|
|
113
232
|
checkStartArgs(start.target, start.project);
|
|
114
|
-
|
|
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
|
+
}
|
|
115
244
|
}
|
|
116
|
-
await appendMailbox(run.dir, {
|
|
117
|
-
version: MAILBOX_RECORD_VERSION,
|
|
118
|
-
kind: "event",
|
|
119
|
-
...event,
|
|
120
|
-
...(starts ? { starts: true as const } : {}),
|
|
121
|
-
});
|
|
122
245
|
return result("accepted", await wake(run, woken));
|
|
123
246
|
});
|
|
124
247
|
}
|
|
@@ -133,6 +256,7 @@ function checkStartArgs(target: EventWorkflow, project: string): void {
|
|
|
133
256
|
} catch (error) {
|
|
134
257
|
throw new EmitError(
|
|
135
258
|
`${target.meta.name}: a run an event starts has no arguments, which its prepare refuses: ${messageOf(error)}`,
|
|
259
|
+
"prepare",
|
|
136
260
|
);
|
|
137
261
|
}
|
|
138
262
|
}
|
|
@@ -146,18 +270,31 @@ async function createEventRun(
|
|
|
146
270
|
file: string | undefined,
|
|
147
271
|
context: Pick<WakeContext, "home" | "environment">,
|
|
148
272
|
argv: readonly string[] = [],
|
|
273
|
+
given: { panes?: RunPanes; first?: EventLine } = {},
|
|
149
274
|
): Promise<Run> {
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
+
}
|
|
161
298
|
return run;
|
|
162
299
|
}
|
|
163
300
|
|
|
@@ -167,61 +304,122 @@ export type Opened = { run: string; created: boolean } & Woken;
|
|
|
167
304
|
/**
|
|
168
305
|
* `awf run --id` for a workflow with events: creates the run with no event, under its lock, and
|
|
169
306
|
* starts it, so it runs to its first wait and parks there. One already there is only woken, as
|
|
170
|
-
* `awf tick` would; one that returned is refused, as its events are.
|
|
307
|
+
* `awf tick` would; one that returned is refused, as its events are. `panes`, the operator's
|
|
308
|
+
* `--workspace` and `--keep-panes`, are recorded with a new run, and may not change one there.
|
|
171
309
|
*/
|
|
172
310
|
export async function openEventRun(
|
|
173
311
|
target: EventWorkflow,
|
|
174
312
|
id: string,
|
|
175
313
|
argv: readonly string[],
|
|
176
314
|
context: Omit<WakeContext, "file">,
|
|
315
|
+
panes?: RunPanes,
|
|
177
316
|
): Promise<Opened> {
|
|
178
317
|
const workflow = target.meta.name;
|
|
179
318
|
const problem = idProblem(id);
|
|
180
|
-
if (problem) throw new EmitError(problem);
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
});
|
|
319
|
+
if (problem) throw new EmitError(problem, "id");
|
|
320
|
+
const file = fileOf(target);
|
|
321
|
+
const project = await targetProject(file, context.home);
|
|
184
322
|
const runRoot = projectRunRoot(project);
|
|
185
323
|
const ref = `${workflow}/${id}`;
|
|
186
324
|
return withRunLock(runLockFile(runRoot, workflow, id), async () => {
|
|
187
|
-
const woken = { ...context, file
|
|
325
|
+
const woken = { ...context, file };
|
|
188
326
|
if (await exists(join(runRoot, workflow, id, "run.json"))) {
|
|
189
327
|
const run = await openRun(runRoot, workflow, id);
|
|
190
328
|
if ((await readAttempts(run)).at(-1)?.outcome === "completed")
|
|
191
329
|
return { run: ref, created: false, did: "none", why: "returned" };
|
|
192
330
|
if (argv.length > 0 && JSON.stringify(argv) !== JSON.stringify(run.record.argv))
|
|
193
331
|
throw new EmitError(
|
|
194
|
-
`${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",
|
|
195
334
|
);
|
|
335
|
+
const changed = panesChanged(run.record, panes);
|
|
336
|
+
if (changed) throw new EmitError(changed, "other-args");
|
|
196
337
|
return { run: ref, created: false, ...(await wake(run, woken)) };
|
|
197
338
|
}
|
|
198
339
|
try {
|
|
199
340
|
prepareArgs(target, argv, project);
|
|
200
341
|
} catch (error) {
|
|
201
|
-
throw new EmitError(`prepare: ${messageOf(error)}
|
|
342
|
+
throw new EmitError(`prepare: ${messageOf(error)}`, "prepare");
|
|
202
343
|
}
|
|
203
|
-
const run = await createEventRun(runRoot, workflow, id, project,
|
|
344
|
+
const run = await createEventRun(runRoot, workflow, id, project, file, context, argv, {
|
|
345
|
+
...(panes ? { panes } : {}),
|
|
346
|
+
});
|
|
204
347
|
return { run: ref, created: true, ...(await wake(run, woken)) };
|
|
205
348
|
});
|
|
206
349
|
}
|
|
207
350
|
|
|
208
|
-
/** The
|
|
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
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The target's file, which says which project its runs live in and which a start or wake runs:
|
|
367
|
+
* `file`, or the path awf loaded it from. Only a sender holding the workflow itself, as `wf.emit`
|
|
368
|
+
* does, can be without one.
|
|
369
|
+
*/
|
|
370
|
+
function fileOf(target: EventWorkflow): string {
|
|
371
|
+
if (target.file === undefined)
|
|
372
|
+
throw new EmitError(
|
|
373
|
+
`${target.meta.name} gives no file, by which a sender holding it finds it: file: import.meta.path`,
|
|
374
|
+
"file",
|
|
375
|
+
);
|
|
376
|
+
return target.file;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* `{key}:reply`'s event as `in.jsonl` records it: `participant:reply` from its key, whatever the
|
|
381
|
+
* workflow declares it as, so a wait takes it by one name.
|
|
382
|
+
*/
|
|
383
|
+
export function replyRecord(
|
|
384
|
+
key: string,
|
|
385
|
+
id: string,
|
|
386
|
+
data: JsonValue,
|
|
387
|
+
at: string,
|
|
388
|
+
): Delivery["event"] {
|
|
389
|
+
return { id, name: "participant:reply", data, from: key, at };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The event's name, shape and id, as the target declares them; an `EmitError` when they don't fit.
|
|
394
|
+
* A name with a colon is awf's: only `{key}:reply`, from the operator (`replies`), is sent.
|
|
395
|
+
*/
|
|
209
396
|
export function checkEvent(
|
|
210
397
|
target: EventWorkflow,
|
|
211
398
|
event: { name: string; data: JsonValue; id?: string },
|
|
399
|
+
replies = false,
|
|
212
400
|
): void {
|
|
213
401
|
const workflow = target.meta.name;
|
|
214
402
|
const problem = eventWorkflowProblem(target);
|
|
215
403
|
if (problem) throw new EmitError(`${workflow}: ${problem}`);
|
|
216
|
-
if (!isAbsolute(target.file))
|
|
217
|
-
throw new EmitError(
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
404
|
+
if (target.file !== undefined && !isAbsolute(target.file))
|
|
405
|
+
throw new EmitError(
|
|
406
|
+
`${workflow}: its file, ${target.file}, is import.meta.path, absolute`,
|
|
407
|
+
"file",
|
|
408
|
+
);
|
|
409
|
+
const reply = awfEventOf(event.name)?.kind === "reply";
|
|
410
|
+
if (event.name.includes(":") && !(replies && reply))
|
|
411
|
+
throw new EmitError(
|
|
412
|
+
reply
|
|
413
|
+
? `${event.name}: a participant's message goes to a run that is there, awf emit ${workflow}/{id} ${event.name} '{"text":…}'`
|
|
414
|
+
: `${event.name}: a name with a colon is awf's own, and not sent`,
|
|
415
|
+
);
|
|
416
|
+
const declared = target.events[event.name];
|
|
417
|
+
if (!declared)
|
|
222
418
|
throw new EmitError(
|
|
223
419
|
`${workflow} declares no event ${event.name}; its events are ${Object.keys(target.events).join(", ")}`,
|
|
224
420
|
);
|
|
421
|
+
// A reply is checked against awf's own schema, not the workflow's copy, which may be older.
|
|
422
|
+
const schema = reply ? ParticipantReply : declared;
|
|
225
423
|
let parsed: ReturnType<typeof parseJsonSchema>;
|
|
226
424
|
try {
|
|
227
425
|
parsed = parseJsonSchema(schema);
|
|
@@ -234,7 +432,7 @@ export function checkEvent(
|
|
|
234
432
|
`${event.name}'s data does not fit its schema: ${errors.map((e) => `${e.path || "/"}: ${e.message}`).join("; ")}`,
|
|
235
433
|
);
|
|
236
434
|
const badId = event.id === undefined ? undefined : eventIdProblem(event.id);
|
|
237
|
-
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");
|
|
238
436
|
}
|
|
239
437
|
|
|
240
438
|
/** What is wrong with an id a sender gives an event, if anything: ids starting `awf.` are awf's. */
|