@agentswf/engine 0.1.0-beta.4 → 0.1.0-beta.5
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 +4 -4
- package/src/agent-cli/cli.ts +57 -17
- package/src/attempt-ending.ts +2 -4
- package/src/attempt-output.ts +2 -1
- package/src/attempt-view.ts +1 -1
- package/src/control-plane.ts +37 -5
- package/src/coordinator-socket.ts +15 -6
- package/src/deadlines.ts +69 -2
- package/src/decisions/directory.ts +9 -11
- package/src/deprecations.ts +177 -0
- package/src/duration.ts +50 -20
- package/src/emit-command.ts +55 -41
- package/src/emit.ts +124 -15
- package/src/help/emit.txt +12 -4
- package/src/help/inbox.txt +2 -2
- package/src/help/logs.txt +5 -4
- package/src/help/reply.txt +5 -5
- package/src/help/run.txt +21 -7
- package/src/herdr-run-session.ts +2 -2
- package/src/holder-command.ts +36 -31
- package/src/inbox-command.ts +7 -10
- package/src/init-template/hello/workflow.test.ts.template +3 -3
- package/src/init-template/hello/workflow.ts.template +2 -2
- package/src/keep-alive.ts +28 -13
- package/src/logs-command.ts +12 -12
- package/src/mailbox.ts +2 -20
- package/src/messaging/limits.ts +4 -4
- package/src/messaging/messaging.ts +26 -21
- package/src/messaging/routes.ts +1 -1
- package/src/messaging/text.ts +7 -5
- package/src/operator-cli.ts +23 -9
- package/src/operator-runtime.ts +5 -5
- package/src/pane-layout.ts +125 -32
- package/src/progress-view.ts +8 -8
- package/src/run-command.ts +25 -4
- package/src/run-commands.ts +1 -1
- package/src/run-prepare.ts +46 -8
- package/src/run-usage.ts +1 -1
- package/src/run-view.ts +9 -4
- package/src/runs-command.ts +12 -10
- package/src/runs.ts +44 -8
- package/src/sources.ts +42 -6
- package/src/stage-ledger.ts +42 -12
- package/src/stage-plan.ts +37 -1
- package/src/test-command.ts +7 -3
- package/src/testing.ts +7 -8
- package/src/tools.ts +46 -18
- package/src/workflow-loader.ts +5 -2
- package/src/workflow-runner.ts +553 -235
- package/src/workflow-testing/coordinator.typecheck.ts +7 -7
- package/src/workflow-testing/coordinators.ts +2 -2
- package/src/workflow-testing/host.ts +107 -38
- package/src/workflow-testing/index.ts +45 -836
- package/src/workflow-testing/run-test.ts +1213 -0
- package/src/workflow-testing/script.ts +102 -41
- package/src/workflow-testing/workflow-testing.typecheck.ts +11 -8
package/src/duration.ts
CHANGED
|
@@ -1,18 +1,48 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
import { durationMilliseconds, positiveDurationMilliseconds } from "@agentswf/contract/workflow";
|
|
2
|
+
import { deprecationLine } from "./deprecations";
|
|
3
|
+
import { messageOf } from "./errors";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A flag's duration in milliseconds, longer than none: the one grammar, `"90s"`, `"1.5h"`, `"1d"`.
|
|
7
|
+
* A whole number of `ms`, which the CLI took before, is read until 0.1.0 and `deprecated` told.
|
|
8
|
+
*/
|
|
9
|
+
export function parseDuration(
|
|
10
|
+
value: string,
|
|
11
|
+
flag: string,
|
|
12
|
+
deprecated: (line: string) => void,
|
|
13
|
+
): number {
|
|
14
|
+
const ms = /^(\d+)ms$/.exec(value);
|
|
15
|
+
if (!ms) return positiveDurationMilliseconds(value, flag);
|
|
16
|
+
const milliseconds = Number(ms[1]);
|
|
17
|
+
if (!Number.isSafeInteger(milliseconds) || milliseconds <= 0)
|
|
18
|
+
throw new Error(`${flag} is a positive duration: not ${value}`);
|
|
19
|
+
deprecated(deprecationLine("duration-ms", value));
|
|
11
20
|
return milliseconds;
|
|
12
21
|
}
|
|
13
22
|
|
|
23
|
+
/**
|
|
24
|
+
* `--wait`, in seconds from none to `maxSeconds`: a duration, `"90s"`. A bare number of seconds,
|
|
25
|
+
* which it took before, is read until 0.1.0 and `deprecated` told.
|
|
26
|
+
*/
|
|
27
|
+
export function parseWait(
|
|
28
|
+
value: string,
|
|
29
|
+
maxSeconds: number,
|
|
30
|
+
deprecated: (line: string) => void,
|
|
31
|
+
): number {
|
|
32
|
+
const bare = /^\d+(\.\d+)?$/.test(value);
|
|
33
|
+
let seconds: number;
|
|
34
|
+
try {
|
|
35
|
+
seconds = bare ? Number(value) : durationMilliseconds(value) / 1000;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
throw new Error(`--wait takes a duration up to ${maxSeconds}s: ${messageOf(error)}`);
|
|
38
|
+
}
|
|
39
|
+
if (seconds > maxSeconds) throw new Error(`--wait takes a duration up to ${maxSeconds}s`);
|
|
40
|
+
if (bare) deprecated(deprecationLine("wait-seconds", value));
|
|
41
|
+
return seconds;
|
|
42
|
+
}
|
|
43
|
+
|
|
14
44
|
/** `45s`, `6m40s`, `1h02m`. */
|
|
15
|
-
export function
|
|
45
|
+
export function formatDuration(milliseconds: number): string {
|
|
16
46
|
const seconds = Math.max(0, Math.round(milliseconds / 1000));
|
|
17
47
|
if (seconds < 60) return `${seconds}s`;
|
|
18
48
|
const minutes = Math.floor(seconds / 60);
|
|
@@ -22,8 +52,8 @@ export function duration(milliseconds: number): string {
|
|
|
22
52
|
|
|
23
53
|
/**
|
|
24
54
|
* When a timer given `when` is due, epoch ms, from `now`, and `when` as its record keeps it: a
|
|
25
|
-
*
|
|
26
|
-
*
|
|
55
|
+
* `Duration` as given, or a `Date` as an ISO time. A workflow's grammar, apart from the CLI's
|
|
56
|
+
* `parseDuration`: a wait is days long, and `1.5h` reads as written.
|
|
27
57
|
*/
|
|
28
58
|
export function parseWhen(when: unknown, now: number): { due: number; when: string } {
|
|
29
59
|
if (when instanceof Date) {
|
|
@@ -31,11 +61,11 @@ export function parseWhen(when: unknown, now: number): { due: number; when: stri
|
|
|
31
61
|
if (!Number.isFinite(due)) throw new Error("a timer's time is a valid Date");
|
|
32
62
|
return { due, when: when.toISOString() };
|
|
33
63
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
return { due: now +
|
|
64
|
+
let milliseconds: number;
|
|
65
|
+
try {
|
|
66
|
+
milliseconds = durationMilliseconds(when);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
throw new Error(`a timer is due in a duration or at a Date: ${messageOf(error)}`);
|
|
69
|
+
}
|
|
70
|
+
return { due: now + milliseconds, when: when as string };
|
|
41
71
|
}
|
package/src/emit-command.ts
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import type { EmitResultJson } from "@agentswf/contract/records";
|
|
2
2
|
import { isEventWorkflow, type JsonValue } from "@agentswf/contract/workflow";
|
|
3
|
-
import { EmitError, emitEvent } from "./emit";
|
|
3
|
+
import { EmitError, emitEvent, emitToRun } from "./emit";
|
|
4
4
|
import { messageOf } from "./errors";
|
|
5
5
|
import help from "./help/emit.txt" with { type: "text" };
|
|
6
|
-
import type
|
|
6
|
+
import { type Out, parsePlace, type ReadPlace } from "./run-commands";
|
|
7
|
+
import { findRunByRef } from "./run-state";
|
|
7
8
|
import type { Launch } from "./wake";
|
|
8
9
|
import { loadWorkflowFile } from "./workflow-loader";
|
|
9
10
|
|
|
10
11
|
export const emitUsage = help.trimEnd();
|
|
11
12
|
|
|
13
|
+
type Event = { name: string; data: JsonValue; id?: string };
|
|
14
|
+
|
|
15
|
+
/** `--target {file} --id {id}`, which may start the run; or a run that is there, by its ref. */
|
|
16
|
+
type Parsed = Event & { json: boolean } & (
|
|
17
|
+
| { target: string; run: string; ref?: undefined }
|
|
18
|
+
| { ref: string; place: ReadPlace; target?: undefined }
|
|
19
|
+
);
|
|
20
|
+
|
|
12
21
|
export async function emitCommand(
|
|
13
22
|
argv: readonly string[],
|
|
14
23
|
shellCwd: string,
|
|
@@ -23,37 +32,35 @@ export async function emitCommand(
|
|
|
23
32
|
out.stdout(emitUsage);
|
|
24
33
|
return 0;
|
|
25
34
|
}
|
|
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
39
|
out.stderr(`awf: ${messageOf(error)}\n\n${emitUsage}`);
|
|
38
40
|
return 2;
|
|
39
41
|
}
|
|
42
|
+
const event = {
|
|
43
|
+
name: parsed.name,
|
|
44
|
+
data: parsed.data,
|
|
45
|
+
...(parsed.id === undefined ? {} : { id: parsed.id }),
|
|
46
|
+
};
|
|
40
47
|
let result: EmitResultJson;
|
|
41
48
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
if (parsed.ref !== undefined) {
|
|
50
|
+
const status = await findRunByRef(parsed.ref, parsed.place, context.home);
|
|
51
|
+
result = await emitToRun(status, event, context);
|
|
52
|
+
} else {
|
|
53
|
+
const loaded = await loadWorkflowFile(parsed.target, shellCwd);
|
|
54
|
+
if (!isEventWorkflow(loaded.workflow)) {
|
|
55
|
+
out.stderr(`awf: ${loaded.file} declares no events; awf run starts it`);
|
|
56
|
+
return 2;
|
|
57
|
+
}
|
|
58
|
+
result = await emitEvent(
|
|
59
|
+
loaded.workflow,
|
|
60
|
+
{ run: parsed.run, ...event },
|
|
61
|
+
{ from: "operator", ...context },
|
|
62
|
+
);
|
|
46
63
|
}
|
|
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
64
|
} catch (error) {
|
|
58
65
|
out.stderr(`awf: ${messageOf(error)}`);
|
|
59
66
|
return error instanceof EmitError ? 2 : 1;
|
|
@@ -75,35 +82,42 @@ function emitLine(result: EmitResultJson): string {
|
|
|
75
82
|
return `${result.outcome} ${result.ref}${did === undefined ? "" : `: ${did}`}`;
|
|
76
83
|
}
|
|
77
84
|
|
|
78
|
-
function parse(argv: readonly string[]): {
|
|
79
|
-
|
|
80
|
-
run: string;
|
|
81
|
-
name: string;
|
|
82
|
-
data: JsonValue;
|
|
83
|
-
id?: string;
|
|
84
|
-
json: boolean;
|
|
85
|
-
} {
|
|
85
|
+
function parse(argv: readonly string[], shellCwd: string): Parsed {
|
|
86
|
+
const { place, json, rest } = parsePlace(argv, shellCwd);
|
|
86
87
|
let target: string | undefined;
|
|
87
88
|
let run: string | undefined;
|
|
88
89
|
let id: string | undefined;
|
|
89
|
-
let json = false;
|
|
90
90
|
const words: string[] = [];
|
|
91
|
-
for (let i = 0; i <
|
|
92
|
-
const word =
|
|
91
|
+
for (let i = 0; i < rest.length; i++) {
|
|
92
|
+
const word = rest[i]!;
|
|
93
93
|
const value = () => {
|
|
94
|
-
const next =
|
|
94
|
+
const next = rest[++i];
|
|
95
95
|
if (next === undefined) throw new Error(`${word} needs a value`);
|
|
96
96
|
return next;
|
|
97
97
|
};
|
|
98
98
|
if (word === "--target") target = value();
|
|
99
99
|
else if (word === "--id") run = value();
|
|
100
100
|
else if (word === "--event-id") id = value();
|
|
101
|
-
else if (word === "--json") json = true;
|
|
102
101
|
else if (word.startsWith("--")) throw new Error(`unknown option ${word}`);
|
|
103
102
|
else words.push(word);
|
|
104
103
|
}
|
|
105
|
-
|
|
106
|
-
if (
|
|
104
|
+
let form: { target: string; run: string } | { ref: string; place: ReadPlace };
|
|
105
|
+
if (target !== undefined) {
|
|
106
|
+
if (run === undefined) throw new Error("--id names the run the event is for, by its id");
|
|
107
|
+
if (place.repeat.length > 0)
|
|
108
|
+
throw new Error(
|
|
109
|
+
"--cwd and --run-root find a run by its ref; --target's file names its project",
|
|
110
|
+
);
|
|
111
|
+
form = { target, run };
|
|
112
|
+
} else {
|
|
113
|
+
if (run !== undefined) throw new Error("--id goes with --target; a run's ref names it alone");
|
|
114
|
+
const ref = words.shift();
|
|
115
|
+
if (ref === undefined)
|
|
116
|
+
throw new Error(
|
|
117
|
+
"name the run, <workflow>/<id> or <id>, or the workflow by --target and --id",
|
|
118
|
+
);
|
|
119
|
+
form = { ref, place };
|
|
120
|
+
}
|
|
107
121
|
const [name, text, ...more] = words;
|
|
108
122
|
if (name === undefined) throw new Error("the event's name is missing");
|
|
109
123
|
if (more.length > 0) throw new Error(`one event at a time; also given: ${more.join(" ")}`);
|
|
@@ -115,5 +129,5 @@ function parse(argv: readonly string[]): {
|
|
|
115
129
|
throw new Error(`the event's data is not JSON: ${messageOf(error)}`);
|
|
116
130
|
}
|
|
117
131
|
}
|
|
118
|
-
return {
|
|
132
|
+
return { ...form, name, data, ...(id === undefined ? {} : { id }), json };
|
|
119
133
|
}
|
package/src/emit.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { isAbsolute, join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type EmitResultJson,
|
|
5
|
+
MAILBOX_RECORD_VERSION,
|
|
6
|
+
type RunPanes,
|
|
7
|
+
} from "@agentswf/contract/records";
|
|
4
8
|
import { parseJsonSchema, validate } from "@agentswf/contract/schema";
|
|
5
9
|
import {
|
|
6
10
|
AWF_ID_PREFIX,
|
|
11
|
+
awfEventOf,
|
|
7
12
|
eventWorkflowProblem,
|
|
13
|
+
isEventWorkflow,
|
|
8
14
|
type JsonValue,
|
|
9
15
|
nameProblem,
|
|
16
|
+
ParticipantReply,
|
|
10
17
|
} from "@agentswf/contract/workflow";
|
|
11
18
|
import { messageOf } from "./errors";
|
|
12
19
|
import { exists } from "./files";
|
|
@@ -14,9 +21,10 @@ import { appendMailbox, foldMailbox, readMailbox } from "./mailbox";
|
|
|
14
21
|
import { projectOf, projectRunRoot, recordProject } from "./projects";
|
|
15
22
|
import { runLockFile, withRunLock } from "./run-lock";
|
|
16
23
|
import { prepareArgs } from "./run-prepare";
|
|
17
|
-
import
|
|
24
|
+
import type { RunStatus } from "./run-state";
|
|
25
|
+
import { createRun, idProblem, openRun, panesChanged, type Run, readAttempts } from "./runs";
|
|
18
26
|
import { type WakeContext, type Woken, wake } from "./wake";
|
|
19
|
-
import type
|
|
27
|
+
import { type EventWorkflow, loadWorkflowFile } from "./workflow-loader";
|
|
20
28
|
|
|
21
29
|
/** An event awf refuses to take at all: a name, shape or id no run of the target could have. */
|
|
22
30
|
export class EmitError extends Error {
|
|
@@ -43,12 +51,13 @@ export async function emitEvent(
|
|
|
43
51
|
const workflow = target.meta.name;
|
|
44
52
|
const { name, data } = event;
|
|
45
53
|
checkEvent(target, event);
|
|
54
|
+
const file = fileOf(target);
|
|
46
55
|
const id = event.id ?? `${AWF_ID_PREFIX}${randomBytes(8).toString("hex")}`;
|
|
47
56
|
const at = new Date(context.now?.() ?? Date.now()).toISOString();
|
|
48
57
|
const runId = event.run;
|
|
49
58
|
const problem = idProblem(runId);
|
|
50
59
|
if (problem) throw new EmitError(`the run's id: ${problem}`);
|
|
51
|
-
const project = await projectOf(
|
|
60
|
+
const project = await projectOf(file, context.home).catch((error: unknown) => {
|
|
52
61
|
throw new EmitError(messageOf(error));
|
|
53
62
|
});
|
|
54
63
|
const starts = target.starts?.includes(name) ?? true;
|
|
@@ -59,13 +68,69 @@ export async function emitEvent(
|
|
|
59
68
|
runId,
|
|
60
69
|
event: { id, name, data, from: context.from, at },
|
|
61
70
|
deduplicate: event.id !== undefined,
|
|
62
|
-
file
|
|
71
|
+
file,
|
|
63
72
|
...(starts ? { start: { project, target } } : {}),
|
|
64
73
|
},
|
|
65
74
|
context,
|
|
66
75
|
);
|
|
67
76
|
}
|
|
68
77
|
|
|
78
|
+
/**
|
|
79
|
+
* `awf emit {ref} {key}:reply …`: a participant's message to the code of a run that is there,
|
|
80
|
+
* checked against the definition in the file a wake would run, its last attempt's, and delivered.
|
|
81
|
+
* It never starts a run, and doesn't ask whether the participant has connected: one sent before
|
|
82
|
+
* the run connects it waits in the mailbox for the run's next wait.
|
|
83
|
+
*/
|
|
84
|
+
export async function emitToRun(
|
|
85
|
+
status: RunStatus,
|
|
86
|
+
event: { name: string; data: JsonValue; id?: string },
|
|
87
|
+
context: Omit<WakeContext, "file">,
|
|
88
|
+
): Promise<EmitResultJson> {
|
|
89
|
+
const { run, ref } = status;
|
|
90
|
+
const reply = awfEventOf(event.name);
|
|
91
|
+
if (reply?.kind !== "reply")
|
|
92
|
+
throw new EmitError(
|
|
93
|
+
`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}`,
|
|
94
|
+
);
|
|
95
|
+
if (!run.record.events) throw new EmitError(`${ref} is a run of a workflow without events`);
|
|
96
|
+
// A returned run takes nothing, so its file, since moved or changed, is not read: a late reply is
|
|
97
|
+
// refused as returned, not failed for a retry that never ends.
|
|
98
|
+
if (status.attempts.at(-1)?.outcome === "completed")
|
|
99
|
+
return deliverReply(status, reply.key, event, context);
|
|
100
|
+
const file = status.attempts.at(-1)?.file ?? run.record.file;
|
|
101
|
+
if (file === undefined) throw new Error(`${ref} has no file to check the event against`);
|
|
102
|
+
const { workflow } = await loadWorkflowFile(file, run.record.cwd);
|
|
103
|
+
if (!isEventWorkflow(workflow) || workflow.meta.name !== run.record.workflow)
|
|
104
|
+
throw new Error(`${file} is no longer ${run.record.workflow} with events, which ${ref} ran`);
|
|
105
|
+
checkEvent(workflow, event, true);
|
|
106
|
+
return deliverReply(status, reply.key, event, context);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `{key}:reply` appended to a run that is there, and the run woken: a duplicate id is a duplicate.
|
|
111
|
+
* The caller has checked it.
|
|
112
|
+
*/
|
|
113
|
+
export function deliverReply(
|
|
114
|
+
status: RunStatus,
|
|
115
|
+
key: string,
|
|
116
|
+
event: { data: JsonValue; id?: string },
|
|
117
|
+
context: Omit<WakeContext, "file">,
|
|
118
|
+
): Promise<EmitResultJson> {
|
|
119
|
+
const { run } = status;
|
|
120
|
+
const at = new Date(context.now?.() ?? Date.now()).toISOString();
|
|
121
|
+
const id = event.id ?? `${AWF_ID_PREFIX}${randomBytes(8).toString("hex")}`;
|
|
122
|
+
return deliver(
|
|
123
|
+
{
|
|
124
|
+
runRoot: run.root,
|
|
125
|
+
workflow: run.record.workflow,
|
|
126
|
+
runId: run.record.id,
|
|
127
|
+
event: replyRecord(key, id, event.data, at),
|
|
128
|
+
deduplicate: event.id !== undefined,
|
|
129
|
+
},
|
|
130
|
+
context,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
69
134
|
/** An event for a run of a workflow with events, as `deliver` takes it. */
|
|
70
135
|
export type Delivery = {
|
|
71
136
|
runRoot: string;
|
|
@@ -146,6 +211,7 @@ async function createEventRun(
|
|
|
146
211
|
file: string | undefined,
|
|
147
212
|
context: Pick<WakeContext, "home" | "environment">,
|
|
148
213
|
argv: readonly string[] = [],
|
|
214
|
+
panes?: RunPanes,
|
|
149
215
|
): Promise<Run> {
|
|
150
216
|
const run = await createRun(runRoot, {
|
|
151
217
|
id,
|
|
@@ -156,6 +222,7 @@ async function createEventRun(
|
|
|
156
222
|
events: true,
|
|
157
223
|
env: { PATH: context.environment.PATH ?? "" },
|
|
158
224
|
...(file ? { file } : {}),
|
|
225
|
+
...(panes ? { panes } : {}),
|
|
159
226
|
});
|
|
160
227
|
await recordProject(context.home, project);
|
|
161
228
|
return run;
|
|
@@ -167,24 +234,27 @@ export type Opened = { run: string; created: boolean } & Woken;
|
|
|
167
234
|
/**
|
|
168
235
|
* `awf run --id` for a workflow with events: creates the run with no event, under its lock, and
|
|
169
236
|
* 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.
|
|
237
|
+
* `awf tick` would; one that returned is refused, as its events are. `panes`, the operator's
|
|
238
|
+
* `--workspace` and `--keep-panes`, are recorded with a new run, and may not change one there.
|
|
171
239
|
*/
|
|
172
240
|
export async function openEventRun(
|
|
173
241
|
target: EventWorkflow,
|
|
174
242
|
id: string,
|
|
175
243
|
argv: readonly string[],
|
|
176
244
|
context: Omit<WakeContext, "file">,
|
|
245
|
+
panes?: RunPanes,
|
|
177
246
|
): Promise<Opened> {
|
|
178
247
|
const workflow = target.meta.name;
|
|
179
248
|
const problem = idProblem(id);
|
|
180
249
|
if (problem) throw new EmitError(problem);
|
|
181
|
-
const
|
|
250
|
+
const file = fileOf(target);
|
|
251
|
+
const project = await projectOf(file, context.home).catch((error: unknown) => {
|
|
182
252
|
throw new EmitError(messageOf(error));
|
|
183
253
|
});
|
|
184
254
|
const runRoot = projectRunRoot(project);
|
|
185
255
|
const ref = `${workflow}/${id}`;
|
|
186
256
|
return withRunLock(runLockFile(runRoot, workflow, id), async () => {
|
|
187
|
-
const woken = { ...context, file
|
|
257
|
+
const woken = { ...context, file };
|
|
188
258
|
if (await exists(join(runRoot, workflow, id, "run.json"))) {
|
|
189
259
|
const run = await openRun(runRoot, workflow, id);
|
|
190
260
|
if ((await readAttempts(run)).at(-1)?.outcome === "completed")
|
|
@@ -193,6 +263,8 @@ export async function openEventRun(
|
|
|
193
263
|
throw new EmitError(
|
|
194
264
|
`${ref} is there already, started with ${run.record.argv.length > 0 ? "arguments of its own" : "none"}; awf run --id ${id} with none wakes it`,
|
|
195
265
|
);
|
|
266
|
+
const changed = panesChanged(run.record, panes);
|
|
267
|
+
if (changed) throw new EmitError(changed);
|
|
196
268
|
return { run: ref, created: false, ...(await wake(run, woken)) };
|
|
197
269
|
}
|
|
198
270
|
try {
|
|
@@ -200,28 +272,65 @@ export async function openEventRun(
|
|
|
200
272
|
} catch (error) {
|
|
201
273
|
throw new EmitError(`prepare: ${messageOf(error)}`);
|
|
202
274
|
}
|
|
203
|
-
const run = await createEventRun(runRoot, workflow, id, project,
|
|
275
|
+
const run = await createEventRun(runRoot, workflow, id, project, file, context, argv, panes);
|
|
204
276
|
return { run: ref, created: true, ...(await wake(run, woken)) };
|
|
205
277
|
});
|
|
206
278
|
}
|
|
207
279
|
|
|
208
|
-
/**
|
|
280
|
+
/**
|
|
281
|
+
* The target's file, which says which project its runs live in and which a start or wake runs:
|
|
282
|
+
* `file`, or the path awf loaded it from. Only a sender holding the workflow itself, as `wf.emit`
|
|
283
|
+
* does, can be without one.
|
|
284
|
+
*/
|
|
285
|
+
function fileOf(target: EventWorkflow): string {
|
|
286
|
+
if (target.file === undefined)
|
|
287
|
+
throw new EmitError(
|
|
288
|
+
`${target.meta.name} gives no file, by which a sender holding it finds it: file: import.meta.path`,
|
|
289
|
+
);
|
|
290
|
+
return target.file;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* `{key}:reply`'s event as the mailbox records it: `participant:reply` from its key, whatever the
|
|
295
|
+
* workflow declares it as, so a wait takes it by one name.
|
|
296
|
+
*/
|
|
297
|
+
export function replyRecord(
|
|
298
|
+
key: string,
|
|
299
|
+
id: string,
|
|
300
|
+
data: JsonValue,
|
|
301
|
+
at: string,
|
|
302
|
+
): Delivery["event"] {
|
|
303
|
+
return { id, name: "participant:reply", data, from: key, at };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The event's name, shape and id, as the target declares them; an `EmitError` when they don't fit.
|
|
308
|
+
* A name with a colon is awf's: only `{key}:reply`, from the operator (`replies`), is sent.
|
|
309
|
+
*/
|
|
209
310
|
export function checkEvent(
|
|
210
311
|
target: EventWorkflow,
|
|
211
312
|
event: { name: string; data: JsonValue; id?: string },
|
|
313
|
+
replies = false,
|
|
212
314
|
): void {
|
|
213
315
|
const workflow = target.meta.name;
|
|
214
316
|
const problem = eventWorkflowProblem(target);
|
|
215
317
|
if (problem) throw new EmitError(`${workflow}: ${problem}`);
|
|
216
|
-
if (!isAbsolute(target.file))
|
|
318
|
+
if (target.file !== undefined && !isAbsolute(target.file))
|
|
217
319
|
throw new EmitError(`${workflow}: its file, ${target.file}, is import.meta.path, absolute`);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
320
|
+
const reply = awfEventOf(event.name)?.kind === "reply";
|
|
321
|
+
if (event.name.includes(":") && !(replies && reply))
|
|
322
|
+
throw new EmitError(
|
|
323
|
+
reply
|
|
324
|
+
? `${event.name}: a participant's message goes to a run that is there, awf emit ${workflow}/{id} ${event.name} '{"text":…}'`
|
|
325
|
+
: `${event.name}: a name with a colon is awf's own, and not sent`,
|
|
326
|
+
);
|
|
327
|
+
const declared = target.events[event.name];
|
|
328
|
+
if (!declared)
|
|
222
329
|
throw new EmitError(
|
|
223
330
|
`${workflow} declares no event ${event.name}; its events are ${Object.keys(target.events).join(", ")}`,
|
|
224
331
|
);
|
|
332
|
+
// A reply is checked against awf's own schema, not the workflow's copy, which may be older.
|
|
333
|
+
const schema = reply ? ParticipantReply : declared;
|
|
225
334
|
let parsed: ReturnType<typeof parseJsonSchema>;
|
|
226
335
|
try {
|
|
227
336
|
parsed = parseJsonSchema(schema);
|
package/src/help/emit.txt
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
usage: awf emit --target <file> --id <id> <event> [<json>] [--event-id <id>] [--json]
|
|
2
|
+
awf emit <run> <key>:reply '{"text": …, "author": …}' [--event-id <id>] [--cwd <directory>] [--run-root <directory>] [--json]
|
|
2
3
|
|
|
3
4
|
Sends an event to the run of a workflow with events that --id names. That run starts if it has
|
|
4
5
|
none and the event is one that starts one, and is woken if it has one: told while it runs,
|
|
@@ -6,8 +7,15 @@ continued when it is parked or crashed. The run lives in the project of <file>,
|
|
|
6
7
|
directory above it holding .awf/, whoever sends. --event-id names the occurrence: the same id
|
|
7
8
|
again is a duplicate.
|
|
8
9
|
|
|
10
|
+
The second form sends a participant's message to the code of a run that is there, <workflow>/<id>
|
|
11
|
+
or an <id> only one workflow has, here or in a project awf knows: <key>:reply, which the workflow
|
|
12
|
+
declares, checked against the file the run's last attempt ran. "author" is optional. Its
|
|
13
|
+
--event-id is the message's own id, so a resend is a duplicate. It never starts a run, and may
|
|
14
|
+
arrive before the run connects the participant: it waits for the run's next wait. The first form
|
|
15
|
+
takes only the workflow's own events.
|
|
16
|
+
|
|
9
17
|
Prints what became of the event, and what was done to the run: accepted, duplicate or refused.
|
|
10
|
-
--json prints one document. Exits 0 for any of the three; 1 when <file> could not be loaded,
|
|
11
|
-
nothing is kept, or when the run's attempt could not be started (the
|
|
12
|
-
event or awf tick tries again); 2 on a usage error, for a workflow
|
|
13
|
-
event is wrong for its target: its name, its data, or its id.
|
|
18
|
+
--json prints one document. Exits 0 for any of the three; 1 when <file> could not be loaded, or
|
|
19
|
+
the run isn't found, and nothing is kept, or when the run's attempt could not be started (the
|
|
20
|
+
event is kept, and the next event or awf tick tries again); 2 on a usage error, for a workflow
|
|
21
|
+
with no events, or when the event is wrong for its target: its name, its data, or its id.
|
package/src/help/inbox.txt
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
usage: awf inbox [<run>] [--open] [--workflow <name>] [--as <key>] [--after <cursor>] [--wait <
|
|
1
|
+
usage: awf inbox [<run>] [--open] [--workflow <name>] [--as <key>] [--after <cursor>] [--wait <duration>]
|
|
2
2
|
[--cwd <directory>] [--run-root <directory>] [--json]
|
|
3
3
|
|
|
4
4
|
What agents said to a participant outside the run, and what it said back: routes, messages,
|
|
5
5
|
answers, questions that closed unanswered, and each attempt's end, oldest first. Without a run,
|
|
6
6
|
every run here that connected one; --workflow narrows that. A run is <workflow>/<id>, or an <id>
|
|
7
7
|
only one workflow has. --after reads on from a cursor the last answer gave; --wait
|
|
8
|
-
waits up to that
|
|
8
|
+
waits up to that long, at most 5m, when nothing is new. --open lists the questions
|
|
9
9
|
still waiting for an answer, a snapshot. --json prints one item per line.
|
|
10
10
|
|
|
11
11
|
Exits 0 on a read that worked, 1 when there is no such run, it has no participant, or the
|
package/src/help/logs.txt
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
usage: awf logs [<run>] [@<handle>] [--agent <key>] [--stage <name>] [--after <cursor>]
|
|
2
|
-
[--limit <n>] [--wait <
|
|
2
|
+
[--limit <n>] [--wait <duration> | --follow] [--full] [--cwd <directory>]
|
|
3
3
|
[--run-root <directory>] [--json]
|
|
4
4
|
|
|
5
5
|
What a run did and is doing: its events and what its agents did, read from their own session
|
|
6
6
|
files, merged by time. With no run, the newest here. Without --after, the last 40 events; with
|
|
7
7
|
it, what came after, oldest first. Every answer ends with the command that gets only what is
|
|
8
|
-
new. --wait: with nothing to show yet, wait for the first event, at most that
|
|
9
|
-
|
|
10
|
-
and --full prints its text, with common credential
|
|
8
|
+
new. --wait: with nothing to show yet, wait for the first event, at most that long, up to 5m,
|
|
9
|
+
such as 90s (a bare number, seconds, is deprecated); --follow prints each event as it comes until
|
|
10
|
+
the run ends. @<handle> names one event, and --full prints its text, with common credential
|
|
11
|
+
shapes masked; --json carries it always.
|
|
11
12
|
|
|
12
13
|
Exits 0 on any read that worked, 1 when there is no such run or the cursor or handle does not
|
|
13
14
|
fit it, 2 on a usage error, 3 when the run's files could not be read.
|
package/src/help/reply.txt
CHANGED
|
@@ -8,11 +8,11 @@ Answers a question an agent asked a participant outside the run, or messages an
|
|
|
8
8
|
over a route the workflow allowed. The text is the argument, or stdin without one. --to names
|
|
9
9
|
the question, as awf inbox prints it; it may be left out when the agent has one open. --as
|
|
10
10
|
names the participant when the workflow connects more than one. Neither waits for an answer:
|
|
11
|
-
it comes back in awf inbox.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
it comes back in awf inbox. send --event-id is deprecated: awf emit <run> <participant>:reply
|
|
12
|
+
'{"text":…,"author":…}' --event-id <id> replaces it, and it forwards there. It still sends a
|
|
13
|
+
message on a participant to the run's code, the id the message's own, and prints as awf emit
|
|
14
|
+
does; it exits 0 for whatever the run did with it (accepted, duplicate, or refused by a run that
|
|
15
|
+
returned); 1 when the run or participant isn't found, or its attempt didn't start.
|
|
16
16
|
|
|
17
17
|
Exits 0 when delivered, 1 when refused (the code says why), 2 on a usage error, 3 when the run
|
|
18
18
|
could not be reached.
|
package/src/help/run.txt
CHANGED
|
@@ -2,17 +2,20 @@ usage: awf run [options] <workflow-file> [options] [-- workflow arguments...]
|
|
|
2
2
|
awf run [options] <workflow-file> --id <id> --continue
|
|
3
3
|
run options: --id <id>, --continue, --from-stage <stage>, --values <file>,
|
|
4
4
|
--timeout <duration>, --run-root <directory>, --cwd <directory>, --sandbox <file>,
|
|
5
|
+
--workspace <run|origin|name>, --keep-panes <never|on-failure|always>,
|
|
5
6
|
--json, --no-watch, --here
|
|
6
7
|
|
|
7
8
|
Each awf run is an attempt of a run: a new run, with --id's id or a generated one, or, with
|
|
8
|
-
--continue, the run --id names, with the arguments, --cwd
|
|
9
|
-
|
|
9
|
+
--continue, the run --id names, with the arguments, --cwd, --sandbox, --workspace and
|
|
10
|
+
--keep-panes it was started with. A continue reuses the stages that succeeded, up to the first
|
|
11
|
+
with no record or to --from-stage, and runs the rest.
|
|
10
12
|
A new run with --from-stage starts there. A stage before it with no record to reuse takes its
|
|
11
13
|
value from --values, a JSON object by stage name, checked by its result; one that returns
|
|
12
14
|
nothing is passed. Without one the attempt stops, naming the stage and its schema.
|
|
13
15
|
A run is kept in .awf/runs/<workflow>/<id> under --cwd, unless --run-root names another folder
|
|
14
16
|
for .awf/runs.
|
|
15
|
-
A run has no deadline unless --timeout gives it one, per attempt
|
|
17
|
+
A run has no deadline unless --timeout gives it one, per attempt: a duration, a decimal and s, m,
|
|
18
|
+
h or d, such as 20m or 1d.
|
|
16
19
|
A workflow that knows how to present its result prints that; --json prints the full result instead.
|
|
17
20
|
Either way the full result is kept as output.json in the run's folder, beside report.md
|
|
18
21
|
when the workflow writes one. A run that fails or is cancelled once its agents have started keeps
|
|
@@ -24,13 +27,23 @@ such as {"read": ["/data/request.md"], "srt": {}}, naming srt or docker, whose r
|
|
|
24
27
|
from --cwd. A workflow that opens a sandbox of its own is refused.
|
|
25
28
|
A sandbox with its own Herdr, as a docker box has, gets a tab in the run's workspace showing its
|
|
26
29
|
panes; --no-watch leaves it out, and awf still prints the command that shows them.
|
|
30
|
+
--workspace says where each TUI agent's tab opens: run, the run's own Herdr workspace (the
|
|
31
|
+
default); origin, the one awf run was typed in; or any other word, the workspace with that label,
|
|
32
|
+
found or made. --keep-panes says whether an agent's pane stays once it is done: never (the
|
|
33
|
+
default), on-failure, when its last turn wasn't answered, or always. A kept pane's harness is left
|
|
34
|
+
running, and yours. Both are kept with the run for every attempt, a woken one too, and win over a
|
|
35
|
+
workflow's deprecated layout and keepPane. A run an event started has neither, and takes the
|
|
36
|
+
defaults. A continue may give either again with the run's value, or its default where the run had
|
|
37
|
+
none, and no other.
|
|
27
38
|
--here, run by an agent in a Herdr pane, starts the run in a new tab and has it take that agent's
|
|
28
39
|
session over as one of its agents, from its next turn until the run ends. It prints a line for
|
|
29
40
|
the agent to end its turn with, which is how the run finds the session.
|
|
30
|
-
A run of a workflow with events is named by --id, or by the workflow's id(args)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
are what prepare makes of none.
|
|
41
|
+
A run of a workflow with events is named by --id, or by the workflow's id(args); one without
|
|
42
|
+
starts may have an id made, as any run. awf run starts it with its arguments and no event,
|
|
43
|
+
detached in its project, as awf emit would, and the run waits at its first receive. A run already
|
|
44
|
+
there is only woken. A run an event starts has no argv: its args are what prepare makes of none.
|
|
45
|
+
Its --continue takes --from-stage, as any run's does, and no --values. A --from-stage the attempt
|
|
46
|
+
never reaches runs the attempt until it would park or return, and stops it there.
|
|
34
47
|
Exits 0 when the run completed or parked, 1 when it failed or timed out, 2 on a usage error or
|
|
35
48
|
when the run is refused, 3 when it stopped and can go on with --continue, and 128 plus the
|
|
36
49
|
signal's number when it was interrupted.
|
|
@@ -40,5 +53,6 @@ Examples:
|
|
|
40
53
|
awf run --timeout 20m examples/minimum-review/review-loop.ts
|
|
41
54
|
awf run examples/minimum-review/review-loop.ts -- packages/engine/src
|
|
42
55
|
awf run examples/minimum-review/review-loop.ts --cwd ../other-repo
|
|
56
|
+
awf run examples/pane-layout/workflow.ts --workspace origin --keep-panes on-failure
|
|
43
57
|
|
|
44
58
|
Workflow files are trusted code and run with your filesystem and process authority.
|
package/src/herdr-run-session.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { machinePaths } from "./machine";
|
|
|
17
17
|
import { SESSION_NAME } from "./pane-layout";
|
|
18
18
|
import type { ProcessProbe } from "./runs";
|
|
19
19
|
|
|
20
|
-
/** Where a run's
|
|
20
|
+
/** Where a run's TUI agents open when `AWF_HERDR_SESSION` names nowhere else. */
|
|
21
21
|
const DEFAULT_RUN_SESSION = "awf";
|
|
22
22
|
|
|
23
23
|
/** What may start: the shared session is awf's by its name, since which run created it is not knowable. */
|
|
@@ -101,7 +101,7 @@ export function runSessionName(environment: Readonly<Record<string, string | und
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
|
-
* The Herdr session a run's
|
|
104
|
+
* The Herdr session a run's TUI agents open in, running and kept: started detached when down and
|
|
105
105
|
* named `awf` or `awf-…`, from a minimal environment and a quiet config, so it outlives this run
|
|
106
106
|
* for the next. Any other name that is down is refused: it may be an operator's stopped session,
|
|
107
107
|
* and bringing it back headless is not awf's call. Then the workspaces of runs that ended are
|