@agentswf/engine 0.1.0-beta.3 → 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 +5 -6
- package/src/agent-cli/cli.ts +611 -0
- package/src/agent-cli/client.ts +115 -0
- package/src/agent-launcher.ts +15 -25
- package/src/allowance-command.ts +3 -15
- package/src/attempt-ending.ts +2 -4
- package/src/attempt-output.ts +3 -2
- package/src/attempt-view.ts +1 -1
- package/src/control-plane.ts +38 -6
- 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 +57 -54
- package/src/emit.ts +124 -15
- package/src/help/allowance.txt +13 -0
- package/src/help/emit.txt +21 -0
- package/src/help/inbox.txt +12 -0
- package/src/help/init.txt +12 -0
- package/src/help/logs.txt +14 -0
- package/src/help/reply.txt +18 -0
- package/src/help/run.txt +58 -0
- package/src/help/runs.txt +11 -0
- package/src/help/test.txt +9 -0
- package/src/help/tick.txt +12 -0
- package/src/herdr-run-session.ts +2 -2
- package/src/here.ts +1 -1
- package/src/holder-command.ts +38 -51
- package/src/inbox-command.ts +9 -24
- package/src/init-command.ts +2 -11
- package/src/init-template/AGENTS.md.template +3 -3
- package/src/init-template/hello/workflow.test.ts.template +3 -3
- package/src/init-template/hello/workflow.ts.template +6 -6
- package/src/keep-alive.ts +28 -13
- package/src/logs-command.ts +14 -27
- package/src/mailbox.ts +16 -21
- package/src/messaging/limits.ts +5 -5
- package/src/messaging/messaging.ts +48 -43
- package/src/messaging/routes.ts +2 -2
- package/src/messaging/text.ts +24 -22
- package/src/operation-liveness.ts +1 -1
- package/src/operator-cli.ts +23 -9
- package/src/operator-runtime.ts +9 -12
- package/src/pane-layout.ts +125 -32
- package/src/progress-view.ts +8 -8
- package/src/result-validation.ts +1 -1
- package/src/run-command.ts +27 -47
- package/src/run-commands.ts +1 -1
- package/src/run-prepare.ts +46 -8
- package/src/run-usage.ts +2 -2
- package/src/run-view.ts +9 -4
- package/src/runs-command.ts +14 -23
- package/src/runs.ts +44 -8
- package/src/sandboxes.ts +3 -2
- package/src/simple-input.ts +13 -3
- package/src/sources.ts +42 -6
- package/src/stage-ledger.ts +43 -13
- package/src/stage-plan.ts +37 -1
- package/src/test-command.ts +9 -14
- package/src/testing.ts +8 -9
- package/src/tick-command.ts +2 -13
- package/src/tools.ts +62 -25
- package/src/workflow-loader.ts +5 -2
- package/src/workflow-runner.ts +636 -271
- package/src/workflow-testing/coordinator.typecheck.ts +12 -13
- package/src/workflow-testing/coordinators.ts +2 -2
- package/src/workflow-testing/host.ts +115 -46
- package/src/workflow-testing/index.ts +45 -837
- package/src/workflow-testing/run-test.ts +1213 -0
- package/src/workflow-testing/script.ts +106 -45
- package/src/workflow-testing/workflow-testing.typecheck.ts +13 -10
package/src/run-prepare.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import {
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { type AttemptRecord, RUN_EVENT_VERSIONS, type RunPanes } from "@agentswf/contract/records";
|
|
4
5
|
import { isEventWorkflow, type JsonValue } from "@agentswf/contract/workflow";
|
|
5
6
|
import { refusalMessage } from "./attempt-view";
|
|
6
7
|
import { messageOf } from "./errors";
|
|
8
|
+
import { exists } from "./files";
|
|
7
9
|
import { projectOf, projectRunRoot } from "./projects";
|
|
8
10
|
import type { RunCommand } from "./run-command";
|
|
9
11
|
import {
|
|
@@ -14,6 +16,7 @@ import {
|
|
|
14
16
|
checkFree,
|
|
15
17
|
claimAttempt,
|
|
16
18
|
endTurnsLine,
|
|
19
|
+
generateId,
|
|
17
20
|
idProblem,
|
|
18
21
|
openRun,
|
|
19
22
|
previousGone,
|
|
@@ -38,6 +41,14 @@ export type PreparedRun = {
|
|
|
38
41
|
continued?: { run: Run; attempts: AttemptRecord[]; warnings: string[] };
|
|
39
42
|
};
|
|
40
43
|
|
|
44
|
+
/** `awf run --id` of a workflow with events, which starts the run detached, or wakes it. */
|
|
45
|
+
export type OpensEventRun = {
|
|
46
|
+
loaded: LoadedWorkflow;
|
|
47
|
+
id: string;
|
|
48
|
+
argv: readonly string[];
|
|
49
|
+
panes?: RunPanes;
|
|
50
|
+
};
|
|
51
|
+
|
|
41
52
|
/**
|
|
42
53
|
* The command's workflow loaded and its run prepared, or why not, as awf says it: in the shell that
|
|
43
54
|
* typed the command, before anything starts.
|
|
@@ -49,7 +60,7 @@ export async function loadAndPrepare(
|
|
|
49
60
|
): Promise<
|
|
50
61
|
| { loaded: LoadedWorkflow; prepared: PreparedRun; command: RunCommand }
|
|
51
62
|
| { refused: string }
|
|
52
|
-
| { opens:
|
|
63
|
+
| { opens: OpensEventRun }
|
|
53
64
|
> {
|
|
54
65
|
let loaded: LoadedWorkflow;
|
|
55
66
|
try {
|
|
@@ -74,12 +85,23 @@ export async function loadAndPrepare(
|
|
|
74
85
|
try {
|
|
75
86
|
const id =
|
|
76
87
|
command.id ??
|
|
77
|
-
derivedId(loaded.workflow, prepareArgs(loaded.workflow, command.workflowArgs, project))
|
|
88
|
+
derivedId(loaded.workflow, prepareArgs(loaded.workflow, command.workflowArgs, project)) ??
|
|
89
|
+
// Without starts no sender names its runs, so one may be generated, as a plain run's is.
|
|
90
|
+
(loaded.workflow.starts === undefined
|
|
91
|
+
? await unusedId(command.runRoot, loaded.workflow.meta.name, now)
|
|
92
|
+
: undefined);
|
|
78
93
|
if (id === undefined)
|
|
79
94
|
return {
|
|
80
|
-
refused: `a run of ${loaded.file} is named by its id, which --id or the workflow's id(args) gives: awf run --id {id} starts one, awf emit --target ${loaded.file} --id {id} {event} {json} starts one with an event`,
|
|
95
|
+
refused: `a run of ${loaded.file}, which starts on events, is named by its id, which --id or the workflow's id(args) gives: awf run --id {id} starts one, awf emit --target ${loaded.file} --id {id} {event} {json} starts one with an event`,
|
|
81
96
|
};
|
|
82
|
-
return {
|
|
97
|
+
return {
|
|
98
|
+
opens: {
|
|
99
|
+
loaded,
|
|
100
|
+
id,
|
|
101
|
+
argv: command.workflowArgs,
|
|
102
|
+
...(command.panes ? { panes: command.panes } : {}),
|
|
103
|
+
},
|
|
104
|
+
};
|
|
83
105
|
} catch (error) {
|
|
84
106
|
return { refused: `${error instanceof RunRefused ? "" : "prepare: "}${messageOf(error)}` };
|
|
85
107
|
}
|
|
@@ -115,6 +137,7 @@ async function prepareRun(
|
|
|
115
137
|
argv: command.workflowArgs,
|
|
116
138
|
...(command.cwdGiven ? { cwd: command.cwd } : {}),
|
|
117
139
|
...(command.sandbox === undefined ? {} : { sandbox: command.sandbox }),
|
|
140
|
+
...(command.panes === undefined ? {} : { panes: command.panes }),
|
|
118
141
|
});
|
|
119
142
|
const { id, argv, cwd } = run.record;
|
|
120
143
|
if (!statSync(cwd, { throwIfNoEntry: false })?.isDirectory()) {
|
|
@@ -155,13 +178,28 @@ export function prepareArgs(
|
|
|
155
178
|
return args;
|
|
156
179
|
}
|
|
157
180
|
|
|
181
|
+
/**
|
|
182
|
+
* A generated id no run of `workflow` holds: `awf run` of one already there would wake it. Taken
|
|
183
|
+
* between this and the open, by a run started the same minute with the same four digits, it is.
|
|
184
|
+
*/
|
|
185
|
+
async function unusedId(runRoot: string, workflow: string, now: number): Promise<string> {
|
|
186
|
+
for (let tries = 1; ; tries += 1) {
|
|
187
|
+
const id = generateId(new Date(now));
|
|
188
|
+
if (!(await exists(join(runRoot, workflow, id)))) return id;
|
|
189
|
+
if (tries >= 5) throw new RunRefused("every id generated was taken; --id gives one");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
158
193
|
/**
|
|
159
194
|
* Why `awf run` doesn't take a workflow with `events` as asked: every attempt after the first is
|
|
160
|
-
* started by a wake, which passes on none of the flags a run would have to keep.
|
|
195
|
+
* started by a wake, which passes on none of the flags a run would have to keep. A `--from-stage`
|
|
196
|
+
* is the one attempt's, and its records keep what it redid.
|
|
161
197
|
*/
|
|
162
198
|
function eventRunRefusal(command: RunCommand): string | undefined {
|
|
163
|
-
if (command.
|
|
164
|
-
return "a run of a workflow with events takes no --from-stage
|
|
199
|
+
if (command.values !== undefined)
|
|
200
|
+
return "a run of a workflow with events takes no --values: before --from-stage, a stage with no record runs";
|
|
201
|
+
if (command.fromStage !== undefined && command.continueId === undefined)
|
|
202
|
+
return "a run of a workflow with events takes --from-stage only with --continue";
|
|
165
203
|
if (command.continueId !== undefined) {
|
|
166
204
|
if (command.workflowArgs.length > 0)
|
|
167
205
|
return "a continue takes no arguments: it goes on with those its run was started with";
|
package/src/run-usage.ts
CHANGED
|
@@ -31,11 +31,11 @@ export type AccountedAgent = {
|
|
|
31
31
|
cwd: string;
|
|
32
32
|
/** A sandboxed agent's own harness home, where its sessions are. */
|
|
33
33
|
home?: string;
|
|
34
|
-
/** Every native session id seen for the agent so far, from its `
|
|
34
|
+
/** Every native session id seen for the agent so far, from its `wfa` calls and its adapter. */
|
|
35
35
|
sessions(): readonly string[];
|
|
36
36
|
/** When its host first prompted it, where that waits on the agent first; see `window`. */
|
|
37
37
|
promptedAt?(): number | undefined;
|
|
38
|
-
/** The caller handed back early by
|
|
38
|
+
/** The caller handed back early by its close(). */
|
|
39
39
|
releasedAt?(): number | undefined;
|
|
40
40
|
/** When a fork's session was made: what it holds from before is its parent's (ADR 0009). */
|
|
41
41
|
forkedAt?(): number | undefined;
|
package/src/run-view.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import type {
|
|
3
3
|
Activity,
|
|
4
|
+
AgentStateJson,
|
|
4
5
|
AttemptRecord,
|
|
5
6
|
OperationLivenessRecord,
|
|
6
7
|
RunLogLine,
|
|
8
|
+
StageStateJson,
|
|
7
9
|
} from "@agentswf/contract/records";
|
|
8
|
-
import type { AgentKey, HarnessKind
|
|
10
|
+
import type { AgentKey, HarnessKind } from "@agentswf/contract/workflow";
|
|
9
11
|
import { readLines } from "./jsonl";
|
|
10
12
|
import { currentStage, estimateOf } from "./run-commands";
|
|
11
13
|
import { type Cursor, type FileCache, readRun, timeOf } from "./run-read";
|
|
@@ -14,7 +16,7 @@ import type { RunStatus } from "./run-state";
|
|
|
14
16
|
/** A stage of the attempt shown: running, reused or given from an earlier one, or how it ended. */
|
|
15
17
|
export type StageView = {
|
|
16
18
|
stage: string;
|
|
17
|
-
state:
|
|
19
|
+
state: StageStateJson;
|
|
18
20
|
startedAt?: number;
|
|
19
21
|
endedAt?: number;
|
|
20
22
|
};
|
|
@@ -29,7 +31,7 @@ export type AgentView = {
|
|
|
29
31
|
caller: boolean;
|
|
30
32
|
/** The stage its last operation ran in. */
|
|
31
33
|
stage?: string;
|
|
32
|
-
state:
|
|
34
|
+
state: AgentStateJson;
|
|
33
35
|
turns: number;
|
|
34
36
|
/** When its current operation, or its last, started. */
|
|
35
37
|
since?: number;
|
|
@@ -137,7 +139,7 @@ export async function viewRun(status: RunStatus): Promise<RunView> {
|
|
|
137
139
|
const stage = currentStage(status, read.log.lines);
|
|
138
140
|
// A run whose process is gone left what it was doing undone.
|
|
139
141
|
if (status.state === "interrupted") {
|
|
140
|
-
for (const each of agents.values()) if (each.state === "working") each.state = "
|
|
142
|
+
for (const each of agents.values()) if (each.state === "working") each.state = "interrupted";
|
|
141
143
|
for (const each of stages) if (each.state === "running") each.state = "interrupted";
|
|
142
144
|
}
|
|
143
145
|
if (status.state === "running") {
|
|
@@ -153,6 +155,9 @@ export async function viewRun(status: RunStatus): Promise<RunView> {
|
|
|
153
155
|
} else if (phase?.kind === "awaiting-reply" && phase.peer !== undefined) {
|
|
154
156
|
agent(key).live = { phase: "awaiting-reply", peer: phase.peer, ...until };
|
|
155
157
|
}
|
|
158
|
+
// One word with `wfa peers`: the phase is the state; `live` keeps its detail.
|
|
159
|
+
const { live } = agent(key);
|
|
160
|
+
if (live && agent(key).state === "working") agent(key).state = live.phase;
|
|
156
161
|
}
|
|
157
162
|
}
|
|
158
163
|
const since = attempt ? Date.parse(attempt.started) : Number.NEGATIVE_INFINITY;
|
package/src/runs-command.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import type { RunJson, RunsJson } from "@agentswf/contract/records";
|
|
3
|
-
import {
|
|
3
|
+
import { formatDuration } from "./duration";
|
|
4
4
|
import { messageOf } from "./errors";
|
|
5
|
+
import help from "./help/runs.txt" with { type: "text" };
|
|
5
6
|
import {
|
|
6
7
|
clockTime,
|
|
7
8
|
command,
|
|
@@ -23,19 +24,7 @@ import { encodeCursor } from "./run-read";
|
|
|
23
24
|
import { findRunByRef, listRuns, type RunStatus, runStatus } from "./run-state";
|
|
24
25
|
import { type AgentView, describeActivity, type RunView, viewRun } from "./run-view";
|
|
25
26
|
|
|
26
|
-
export const runsUsage =
|
|
27
|
-
"usage: awf runs [--limit <n>] [--cwd <directory>] [--run-root <directory>] [--json]",
|
|
28
|
-
" awf runs <run> [--cwd <directory>] [--run-root <directory>] [--json]",
|
|
29
|
-
"",
|
|
30
|
-
"The runs here, newest first, or one run in depth: its attempts, the stages of the last, what",
|
|
31
|
-
"each agent is doing, and what to run next. The list shows 20 unless --limit says another",
|
|
32
|
-
"number. A run is <workflow>/<id>, or an <id> only one",
|
|
33
|
-
"workflow has. Runs are listed under --cwd's .awf/runs, or --run-root, as awf run keeps them; a",
|
|
34
|
-
"run named is found there first, then in each project awf has started a run with events in.",
|
|
35
|
-
"",
|
|
36
|
-
"--json prints one document, the interface for scripts. Exits 0 on any read that worked, 1 when",
|
|
37
|
-
"there is no such run, 2 on a usage error, 3 when the run's files could not be read.",
|
|
38
|
-
].join("\n");
|
|
27
|
+
export const runsUsage = help.trimEnd();
|
|
39
28
|
|
|
40
29
|
const LISTED = 20;
|
|
41
30
|
|
|
@@ -144,7 +133,7 @@ async function list(place: ReadPlace, limit: number, json: boolean, out: Out, no
|
|
|
144
133
|
status.ref.padEnd(width),
|
|
145
134
|
states[index]!.padEnd(stateWidth),
|
|
146
135
|
dayTime(started).padEnd(13),
|
|
147
|
-
|
|
136
|
+
formatDuration(ms).padEnd(8),
|
|
148
137
|
money(estimate)
|
|
149
138
|
.join("")
|
|
150
139
|
.concat(waiting ? " " : ""),
|
|
@@ -173,13 +162,15 @@ function dayTime(at: number): string {
|
|
|
173
162
|
return `${two(date.getMonth() + 1)}-${two(date.getDate())} ${clockTime(at).slice(0, 5)}`;
|
|
174
163
|
}
|
|
175
164
|
|
|
176
|
-
const
|
|
165
|
+
const inTurn = (agent: AgentView) =>
|
|
166
|
+
agent.state === "working" || agent.state === "waiting" || agent.state === "awaiting-reply";
|
|
167
|
+
const busy = (agent: AgentView) => inTurn(agent) || agent.state === "interrupted";
|
|
177
168
|
|
|
178
169
|
function agentLine(agent: AgentView, indent: string, now: number, width: number): string {
|
|
179
170
|
const { live } = agent;
|
|
180
171
|
const parts = [
|
|
181
172
|
said(agent.key, 60).padEnd(width),
|
|
182
|
-
|
|
173
|
+
agent.state.padEnd(14),
|
|
183
174
|
busy(agent) ? `turn ${agent.turns + 1}` : `${agent.turns} turn${agent.turns === 1 ? "" : "s"}`,
|
|
184
175
|
...(live?.phase === "waiting"
|
|
185
176
|
? [
|
|
@@ -192,10 +183,10 @@ function agentLine(agent: AgentView, indent: string, now: number, width: number)
|
|
|
192
183
|
]
|
|
193
184
|
: live
|
|
194
185
|
? [
|
|
195
|
-
|
|
186
|
+
`from ${said(live.peer, 60)}${live.until ? ` (until ${clockTime(live.until).slice(0, 5)})` : ""}`,
|
|
196
187
|
]
|
|
197
188
|
: []),
|
|
198
|
-
...(agent.since && agent
|
|
189
|
+
...(agent.since && inTurn(agent) ? [formatDuration(now - agent.since)] : []),
|
|
199
190
|
...(agent.last
|
|
200
191
|
? [`last: ${describeActivity(agent.last.activity)} (${clockTime(agent.last.at)})`]
|
|
201
192
|
: []),
|
|
@@ -247,13 +238,13 @@ function describe(view: RunView, place: ReadPlace, now: number): string {
|
|
|
247
238
|
...(view.stage ? [`stage ${view.stage}`] : []),
|
|
248
239
|
status.state === "interrupted" && view.lastEventAt
|
|
249
240
|
? `last event ${clockTime(view.lastEventAt)}`
|
|
250
|
-
:
|
|
241
|
+
: formatDuration(span(status, now).ms),
|
|
251
242
|
...money(view.estimate),
|
|
252
243
|
].join(" "),
|
|
253
244
|
];
|
|
254
245
|
for (const earlier of status.attempts.slice(0, -1)) {
|
|
255
246
|
const took = earlier.ended
|
|
256
|
-
? ` ${
|
|
247
|
+
? ` ${formatDuration(Date.parse(earlier.ended) - Date.parse(earlier.started))}`
|
|
257
248
|
: "";
|
|
258
249
|
const how = `${earlier.outcome ?? "interrupted"}${earlier.stage ? ` in ${earlier.stage}` : ""}`;
|
|
259
250
|
lines.push(` attempt ${earlier.attempt} ${how}${took}`);
|
|
@@ -265,7 +256,7 @@ function describe(view: RunView, place: ReadPlace, now: number): string {
|
|
|
265
256
|
const took =
|
|
266
257
|
stage.state === "reused" || stage.state === "provided" || stage.startedAt === undefined
|
|
267
258
|
? ""
|
|
268
|
-
:
|
|
259
|
+
: formatDuration((stage.endedAt ?? now) - stage.startedAt);
|
|
269
260
|
lines.push(
|
|
270
261
|
`${indent} stage ${stage.stage.padEnd(8)} ${stage.state.padEnd(10)} ${took}`.trimEnd(),
|
|
271
262
|
);
|
|
@@ -320,7 +311,7 @@ function runJson(view: RunView, place: ReadPlace, now: number): RunJson {
|
|
|
320
311
|
? undefined
|
|
321
312
|
: continueOf(status, view.lines, place);
|
|
322
313
|
return {
|
|
323
|
-
v:
|
|
314
|
+
v: 2,
|
|
324
315
|
run: {
|
|
325
316
|
ref: status.ref,
|
|
326
317
|
state: status.state,
|
package/src/runs.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
copyFile,
|
|
4
|
+
mkdir,
|
|
5
|
+
open,
|
|
6
|
+
readdir,
|
|
7
|
+
readFile,
|
|
8
|
+
rename,
|
|
9
|
+
rm,
|
|
10
|
+
stat,
|
|
11
|
+
unlink,
|
|
12
|
+
} from "node:fs/promises";
|
|
3
13
|
import { join } from "node:path";
|
|
14
|
+
import { isDeepStrictEqual } from "node:util";
|
|
4
15
|
import {
|
|
5
16
|
ATTEMPT_RECORD_VERSION,
|
|
6
17
|
type AttemptRecord,
|
|
@@ -8,6 +19,7 @@ import {
|
|
|
8
19
|
type Candidate,
|
|
9
20
|
RUN_RECORD_VERSION,
|
|
10
21
|
type RunLogLine,
|
|
22
|
+
type RunPanes,
|
|
11
23
|
type RunRecord,
|
|
12
24
|
runLogLine,
|
|
13
25
|
STAGE_RECORD_VERSION,
|
|
@@ -190,13 +202,15 @@ export async function openRun(root: string, workflow: string, id: string): Promi
|
|
|
190
202
|
|
|
191
203
|
/**
|
|
192
204
|
* What a continue may be given: nothing that changes the run. argv is the run's, and its working
|
|
193
|
-
* directory and
|
|
205
|
+
* directory, sandbox and panes are those it was started with.
|
|
194
206
|
*/
|
|
195
207
|
export function checkContinue(
|
|
196
208
|
run: Run,
|
|
197
|
-
given: { argv: readonly string[]; cwd?: string; sandbox?: unknown },
|
|
209
|
+
given: { argv: readonly string[]; cwd?: string; sandbox?: unknown; panes?: RunPanes },
|
|
198
210
|
): void {
|
|
199
211
|
const { id, cwd, sandbox } = run.record;
|
|
212
|
+
const panes = panesChanged(run.record, given.panes);
|
|
213
|
+
if (panes) throw new RunRefused(panes);
|
|
200
214
|
if (given.argv.length > 0) {
|
|
201
215
|
throw new RunRefused(
|
|
202
216
|
`${id} keeps the arguments it was started with; a run with others is a new run (--id)`,
|
|
@@ -210,11 +224,32 @@ export function checkContinue(
|
|
|
210
224
|
}
|
|
211
225
|
}
|
|
212
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Why `given` would change the panes `record`'s run was started with; a flag given again with its
|
|
229
|
+
* value, or with its default where the run was started without it, changes nothing.
|
|
230
|
+
*/
|
|
231
|
+
export function panesChanged(record: RunRecord, given: RunPanes | undefined): string | undefined {
|
|
232
|
+
const flags = [
|
|
233
|
+
["--workspace", "workspace", "run"],
|
|
234
|
+
["--keep-panes", "keep", "never"],
|
|
235
|
+
] as const;
|
|
236
|
+
for (const [flag, field, byDefault] of flags) {
|
|
237
|
+
const value = given?.[field];
|
|
238
|
+
const recorded = record.panes?.[field];
|
|
239
|
+
if (value === undefined || isDeepStrictEqual(value, recorded ?? byDefault)) continue;
|
|
240
|
+
// A run an event or an older awf started has none, and keeps the defaults.
|
|
241
|
+
return recorded === undefined
|
|
242
|
+
? `${record.id} was started without ${flag}, so with ${flag} ${byDefault}; leave it out`
|
|
243
|
+
: `${record.id} keeps the ${flag} it was started with; leave ${flag} out`;
|
|
244
|
+
}
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
213
248
|
/**
|
|
214
249
|
* The run's attempts, in order. One that doesn't parse, or is newer than this awf, refuses; one
|
|
215
250
|
* gone since the folder was listed was a refused attempt removing its own claim.
|
|
216
251
|
*/
|
|
217
|
-
export async function readAttempts(run: Run): Promise<AttemptRecord[]> {
|
|
252
|
+
export async function readAttempts(run: Pick<Run, "dir">): Promise<AttemptRecord[]> {
|
|
218
253
|
const dir = attemptsDir(run.dir);
|
|
219
254
|
const numbered = await attemptNames(run);
|
|
220
255
|
const records = await Promise.all(
|
|
@@ -405,9 +440,10 @@ export async function replaceStale(
|
|
|
405
440
|
/**
|
|
406
441
|
* Moves a stage's record, unchanged, to `replaced/{stage}.{attempt}.json`, or, for a stage by
|
|
407
442
|
* 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.
|
|
443
|
+
* `+`, so neither reads as another stage's. `copy` leaves it in `stages/` for the new record to
|
|
444
|
+
* overwrite, so an attempt that dies between the two leaves the old one, not none.
|
|
409
445
|
*/
|
|
410
|
-
export async function replaceRecord(runDir: string, stage: string): Promise<void> {
|
|
446
|
+
export async function replaceRecord(runDir: string, stage: string, copy = false): Promise<void> {
|
|
411
447
|
const file = join(stagesDir(runDir), `${stage}.json`);
|
|
412
448
|
const record = await readRecord<StageRecord>(file, STAGE_RECORD_VERSION);
|
|
413
449
|
const replaced = join(runDir, "replaced");
|
|
@@ -416,7 +452,7 @@ export async function replaceRecord(runDir: string, stage: string): Promise<void
|
|
|
416
452
|
for (let n = 2; await exists(target); n++) {
|
|
417
453
|
target = join(replaced, `${stage}.${record.attempt}+${n}.json`);
|
|
418
454
|
}
|
|
419
|
-
await rename(file, target);
|
|
455
|
+
await (copy ? copyFile(file, target) : rename(file, target));
|
|
420
456
|
}
|
|
421
457
|
|
|
422
458
|
/** Appends a line to the run's event log, `turns.jsonl`, which only the live attempt writes. */
|
|
@@ -501,7 +537,7 @@ export async function logSize(runDir: string): Promise<number> {
|
|
|
501
537
|
return (await stat(turnsFile(runDir)).catch(() => undefined))?.size ?? 0;
|
|
502
538
|
}
|
|
503
539
|
|
|
504
|
-
async function attemptNames(run: Run): Promise<string[]> {
|
|
540
|
+
async function attemptNames(run: Pick<Run, "dir">): Promise<string[]> {
|
|
505
541
|
return (await entries(attemptsDir(run.dir))).filter((name) => /^[1-9]\d*\.json$/.test(name));
|
|
506
542
|
}
|
|
507
543
|
|
package/src/sandboxes.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { SandboxRecord } from "@agentswf/contract/records";
|
|
|
7
7
|
import type {
|
|
8
8
|
AbsoluteDeadline,
|
|
9
9
|
AgentExecution,
|
|
10
|
+
SandboxEnvironment,
|
|
10
11
|
SandboxEnvironmentKey,
|
|
11
12
|
SandboxRef,
|
|
12
13
|
} from "@agentswf/contract/workflow";
|
|
@@ -141,7 +142,7 @@ export class RunSandboxes {
|
|
|
141
142
|
return this.options.sandboxes?.run !== undefined;
|
|
142
143
|
}
|
|
143
144
|
|
|
144
|
-
/** `
|
|
145
|
+
/** `wf.sandboxes.open`. */
|
|
145
146
|
async open(spec: unknown): Promise<SandboxRef> {
|
|
146
147
|
if (this.hasRunSandbox) throw new Error(RUN_SANDBOX_ONLY);
|
|
147
148
|
const key = (spec as { key?: unknown } | null)?.key;
|
|
@@ -306,7 +307,7 @@ export class RunSandboxes {
|
|
|
306
307
|
network: [...resolved.network],
|
|
307
308
|
...(resolved.sockets.length > 0 ? { sockets: [...resolved.sockets] } : {}),
|
|
308
309
|
...(resolved.webSearch ? { webSearch: true } : {}),
|
|
309
|
-
[provider]: resolved.environment,
|
|
310
|
+
...({ [provider]: resolved.environment } as SandboxEnvironment),
|
|
310
311
|
},
|
|
311
312
|
directory,
|
|
312
313
|
gitdirs: resolved.gitdirs.map((gitdir) => ({ ...gitdir })),
|
package/src/simple-input.ts
CHANGED
|
@@ -19,10 +19,18 @@ type Kind =
|
|
|
19
19
|
| { type: "boolean" }
|
|
20
20
|
| { type: "enum"; values: string[] };
|
|
21
21
|
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Said once in a prompt, under the first block with a free-text flag, and with every refusal of
|
|
24
|
+
* flags that take text. Enum, number and boolean values are shell words as they stand.
|
|
25
|
+
*/
|
|
23
26
|
export const QUOTING_RULE =
|
|
24
27
|
"Put every value in single quotes; a ' inside a value is written '\\''.";
|
|
25
28
|
|
|
29
|
+
/** Whether a flag takes free text, the only value the shell can break. */
|
|
30
|
+
export function takesText(simple: Simple): boolean {
|
|
31
|
+
return simple.some(({ kind }) => kind.type === "string");
|
|
32
|
+
}
|
|
33
|
+
|
|
26
34
|
const MAX_PROPERTIES = 6;
|
|
27
35
|
const NAME = /^[a-z][a-z0-9_]*$/;
|
|
28
36
|
/** An enum value the usage line can show between `|`s and the agent can type as it stands. */
|
|
@@ -137,7 +145,7 @@ export type ReadFlags = { ok: true; value: JsonObject } | { ok: false; error: st
|
|
|
137
145
|
export type ReadGiven = { ok: true; value: unknown } | { ok: false; error: string };
|
|
138
146
|
|
|
139
147
|
/**
|
|
140
|
-
* An input as `
|
|
148
|
+
* An input as `wfa` sent it, as a value: JSON parsed, flags read by the schema, nothing as `{}`.
|
|
141
149
|
* The schema's own check comes after, in `checkGiven`.
|
|
142
150
|
*/
|
|
143
151
|
export function readGiven(schema: JsonSchema | undefined, given: GivenInput): ReadGiven {
|
|
@@ -181,7 +189,9 @@ export function checkGiven(
|
|
|
181
189
|
}
|
|
182
190
|
|
|
183
191
|
function expected(simple: Simple): string {
|
|
184
|
-
return `expected: ${usageLine(simple)}
|
|
192
|
+
return [`expected: ${usageLine(simple)}`, ...(takesText(simple) ? [QUOTING_RULE] : [])].join(
|
|
193
|
+
"\n",
|
|
194
|
+
);
|
|
185
195
|
}
|
|
186
196
|
|
|
187
197
|
/**
|
package/src/sources.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { WaitTake } from "@agentswf/contract/records";
|
|
2
|
-
import type
|
|
2
|
+
import { awfEventOf, type JsonValue, type WorkflowEvent } from "@agentswf/contract/workflow";
|
|
3
3
|
|
|
4
4
|
/** What each source the run issued takes: only awf makes a source, so only these are. */
|
|
5
5
|
const takes = new WeakMap<object, WaitTake>();
|
|
6
6
|
|
|
7
|
+
/** Signals: a branch on one hands back the whole event, not its data. */
|
|
8
|
+
const signals = new WeakSet<object>();
|
|
9
|
+
|
|
7
10
|
/** Marks `value` as a source that takes `take`, and returns it. */
|
|
8
11
|
export function asSource<T extends object>(value: T, take: WaitTake): T {
|
|
9
12
|
takes.set(value, take);
|
|
@@ -18,8 +21,40 @@ export function source<T extends object>(
|
|
|
18
21
|
return asSource(Object.freeze({ ...fields, is: (event: Looked) => matches(event, take) }), take);
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
/** As `source`, for a signal. */
|
|
25
|
+
export function signalOf<T extends object>(fields: T, take: WaitTake): T {
|
|
26
|
+
const made = source(fields, take);
|
|
27
|
+
signals.add(made);
|
|
28
|
+
return made;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether `value` is a signal, whose branch hands back the whole event. */
|
|
32
|
+
export function isSignal(value: unknown): boolean {
|
|
33
|
+
return typeof value === "object" && value !== null && signals.has(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
type Looked = { name: string; from: string; data?: unknown };
|
|
22
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The name the mailbox records an event under: awf's keep `agent:ended` and `participant:reply`,
|
|
40
|
+
* from their key, whatever name the workflow declares and is handed, `{key}:ended` or `{key}:reply`.
|
|
41
|
+
*/
|
|
42
|
+
export function recordName(event: { name: string; from: string }): string {
|
|
43
|
+
const awf = awfEventOf(event.name);
|
|
44
|
+
if (!awf || awf.key !== event.from) return event.name;
|
|
45
|
+
return awf.kind === "ended" ? "agent:ended" : "participant:reply";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `event` under the name the workflow declares for it, where it declares one. */
|
|
49
|
+
export function presented<E extends WorkflowEvent<string, JsonValue>>(
|
|
50
|
+
event: E,
|
|
51
|
+
declared: ReadonlySet<string>,
|
|
52
|
+
): E {
|
|
53
|
+
const kind = { "agent:ended": "ended", "participant:reply": "reply" }[event.name as string];
|
|
54
|
+
if (kind === undefined || !declared.has(`${event.from}:${kind}`)) return event;
|
|
55
|
+
return Object.freeze({ ...event, name: `${event.from}:${kind}` });
|
|
56
|
+
}
|
|
57
|
+
|
|
23
58
|
/** The first of `branches` that takes `event`. */
|
|
24
59
|
export function branchOf(
|
|
25
60
|
branches: readonly (readonly [string, WaitTake])[],
|
|
@@ -33,12 +68,12 @@ export function takeOf(value: unknown): WaitTake | undefined {
|
|
|
33
68
|
return typeof value === "object" && value !== null ? takes.get(value) : undefined;
|
|
34
69
|
}
|
|
35
70
|
|
|
36
|
-
/** Whether a wait that takes `take` takes `event
|
|
71
|
+
/** Whether a wait that takes `take` takes `event`, under its recorded name or its declared one. */
|
|
37
72
|
export function matches(
|
|
38
73
|
event: { name: string; from: string; data?: unknown },
|
|
39
74
|
take: WaitTake,
|
|
40
75
|
): boolean {
|
|
41
|
-
if (event
|
|
76
|
+
if (recordName(event) !== take.name) return false;
|
|
42
77
|
if (take.from !== undefined && event.from !== take.from) return false;
|
|
43
78
|
if (take.turn === undefined) return true;
|
|
44
79
|
const data = event.data as { turn?: unknown } | null | undefined;
|
|
@@ -55,10 +90,11 @@ export function wouldTake(
|
|
|
55
90
|
|
|
56
91
|
/**
|
|
57
92
|
* 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.
|
|
93
|
+
* and arrival, and a workflow's own event, or any a signal takes, whole.
|
|
59
94
|
*/
|
|
60
|
-
export function branchValue(event: WorkflowEvent<string, JsonValue
|
|
61
|
-
|
|
95
|
+
export function branchValue(event: WorkflowEvent<string, JsonValue>, whole = false): unknown {
|
|
96
|
+
if (whole) return event;
|
|
97
|
+
switch (recordName(event)) {
|
|
62
98
|
case "timer:due":
|
|
63
99
|
case "at:due":
|
|
64
100
|
case "participant:reply":
|
package/src/stage-ledger.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { JsonSchema } from "@agentswf/contract/schema";
|
|
|
8
8
|
import type { JsonValue, StageOutcome, StageSummary } from "@agentswf/contract/workflow";
|
|
9
9
|
import type { RunEventBody } from "./run-event-log";
|
|
10
10
|
import { replaceRecord, replaceStale, stageNameProblem, writeStageRecord } from "./runs";
|
|
11
|
-
import { planStage, planStageByContent } from "./stage-plan";
|
|
11
|
+
import { planStage, planStageByContent, type StageRedo } from "./stage-plan";
|
|
12
12
|
import { placeholderOf } from "./stage-schema";
|
|
13
13
|
import { primaryFailure, WorkflowStopped } from "./stopped";
|
|
14
14
|
|
|
@@ -89,6 +89,11 @@ export class StageLedger {
|
|
|
89
89
|
/** The run's stage records as they stand, this attempt's included: what a stage by content reuses. */
|
|
90
90
|
readonly #records: Map<string, StageRecord>;
|
|
91
91
|
|
|
92
|
+
/** The redo stale records are judged by: the options', then this attempt's own. */
|
|
93
|
+
#redo: StageRedo | undefined;
|
|
94
|
+
/** Whether this attempt has entered the redo's stage, after which a stale record reruns. */
|
|
95
|
+
#afterRedo = false;
|
|
96
|
+
|
|
92
97
|
constructor(
|
|
93
98
|
private readonly options: {
|
|
94
99
|
runDir: string;
|
|
@@ -102,9 +107,11 @@ export class StageLedger {
|
|
|
102
107
|
fromStage?: string;
|
|
103
108
|
/** `--values`: the values of stages before `fromStage` that have no record to reuse. */
|
|
104
109
|
values?: ReadonlyMap<string, JsonValue>;
|
|
110
|
+
/** A run of a workflow with `events`: the last `--from-stage` an attempt redid. */
|
|
111
|
+
redo?: StageRedo;
|
|
105
112
|
/**
|
|
106
113
|
* A run of a workflow with `events`: a stage runs once ever, reused wherever it is entered,
|
|
107
|
-
* as `planStageByContent` says
|
|
114
|
+
* as `planStageByContent` says, and no `values`. Its start point is `fromStage` alone.
|
|
108
115
|
*/
|
|
109
116
|
byContent?: boolean;
|
|
110
117
|
/** Writes the run's event log. */
|
|
@@ -113,6 +120,7 @@ export class StageLedger {
|
|
|
113
120
|
},
|
|
114
121
|
) {
|
|
115
122
|
this.#records = new Map(options.records);
|
|
123
|
+
this.#redo = options.redo;
|
|
116
124
|
}
|
|
117
125
|
|
|
118
126
|
/** The stage open now, if one is. */
|
|
@@ -174,7 +182,7 @@ export class StageLedger {
|
|
|
174
182
|
}
|
|
175
183
|
|
|
176
184
|
/**
|
|
177
|
-
* `
|
|
185
|
+
* `wf.stop`, called in `stage` or between stages: what to throw. The first stop is kept,
|
|
178
186
|
* and any later stop throws it again; once it was found caught, that instead. After the
|
|
179
187
|
* workflow's body ended, a stop ends nothing.
|
|
180
188
|
*/
|
|
@@ -313,10 +321,23 @@ export class StageLedger {
|
|
|
313
321
|
|
|
314
322
|
/**
|
|
315
323
|
* Enters a stage by content: reused from its succeeded record, this attempt's included, run when
|
|
316
|
-
* it has none or a failed one, which its own record then replaces.
|
|
324
|
+
* it has none or a failed one, which its own record then replaces. After the redo's stage in this
|
|
325
|
+
* attempt, a record older than the redo is stale and runs again: see `StageRedo`.
|
|
317
326
|
*/
|
|
318
327
|
async #enterByContent(name: string, entry: StageEntry, at: number): Promise<EnteredStage> {
|
|
319
|
-
|
|
328
|
+
if (!this.#started && name === this.options.fromStage) return this.#startByContent(name, at);
|
|
329
|
+
const record = this.#records.get(name);
|
|
330
|
+
const redo = this.#redo;
|
|
331
|
+
// Entered already in this attempt, it has its value here: one stage, one value.
|
|
332
|
+
const stale =
|
|
333
|
+
redo !== undefined &&
|
|
334
|
+
this.#afterRedo &&
|
|
335
|
+
record !== undefined &&
|
|
336
|
+
record.attempt < redo.attempt &&
|
|
337
|
+
!this.#stages.has(name);
|
|
338
|
+
if (name === redo?.stage) this.#afterRedo = true;
|
|
339
|
+
if (stale) return { kind: "run", stage: this.#opened(name, at) };
|
|
340
|
+
const decision = planStageByContent(record, entry.result);
|
|
320
341
|
if (decision.kind === "stop") {
|
|
321
342
|
this.#stopped = new WorkflowStopped(decision.reason, name, true);
|
|
322
343
|
this.#stoppedAt(name, at);
|
|
@@ -347,6 +368,20 @@ export class StageLedger {
|
|
|
347
368
|
return { kind: "run", stage: this.#opened(name, at) };
|
|
348
369
|
}
|
|
349
370
|
|
|
371
|
+
/**
|
|
372
|
+
* `--from-stage` reached by content: it runs, and this attempt is the redo. Its old record moves to
|
|
373
|
+
* `replaced/` only as its new one is written, as a failed record's does, so an attempt that dies
|
|
374
|
+
* while it runs leaves the old one, and the redo, which nothing then shows, is issued again. The
|
|
375
|
+
* stages after it rerun as they are entered, not all at once: one never entered keeps its record.
|
|
376
|
+
*/
|
|
377
|
+
#startByContent(name: string, at: number): EnteredStage {
|
|
378
|
+
const stage = this.#opened(name, at);
|
|
379
|
+
this.#started = true;
|
|
380
|
+
this.#redo = { stage: name, attempt: this.options.attempt };
|
|
381
|
+
this.#afterRedo = true;
|
|
382
|
+
return { kind: "run", stage };
|
|
383
|
+
}
|
|
384
|
+
|
|
350
385
|
/** A stage the plan stopped as it was entered: shown stopped, though it ran nothing. */
|
|
351
386
|
#stoppedAt(name: string, at: number): void {
|
|
352
387
|
this.options.event?.({ kind: "stage-ended", stage: name, outcome: "stopped" }, at);
|
|
@@ -506,14 +541,9 @@ export class StageLedger {
|
|
|
506
541
|
});
|
|
507
542
|
// By content, a failed record this one replaces is kept, as a redone stage's is.
|
|
508
543
|
const replacing = this.options.byContent && this.#records.has(name);
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
// Moved: a write that fails now leaves nothing in `stages/` to move again.
|
|
513
|
-
this.#records.delete(name);
|
|
514
|
-
})
|
|
515
|
-
: Promise.resolve()
|
|
516
|
-
)
|
|
544
|
+
// Copied, then overwritten: dying between the two leaves the old record, as dying while the
|
|
545
|
+
// stage runs does, never a redo's stage without one and stale records after it.
|
|
546
|
+
ended = (replacing ? replaceRecord(this.options.runDir, name, true) : Promise.resolve())
|
|
517
547
|
.then(() => writeStageRecord(this.options.runDir, record))
|
|
518
548
|
.then(
|
|
519
549
|
() => {
|