@gigaai/newton 0.3.0 → 0.4.1
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/README.md +30 -6
- package/dist/acp.js +38 -2
- package/dist/cli.js +185 -2
- package/dist/control.js +169 -0
- package/dist/executor.js +300 -73
- package/dist/journal.js +40 -3
- package/dist/mcp.js +102 -0
- package/dist/sandbox.js +157 -0
- package/dist/version.js +1 -1
- package/dist/worktree.js +51 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -61,19 +61,26 @@ script body. The body runs as an async function with these in scope:
|
|
|
61
61
|
|
|
62
62
|
| Primitive | What it does |
|
|
63
63
|
|---|---|
|
|
64
|
-
| `agent(prompt, opts?)` | Run one agent turn; returns its final text, or a schema-validated object when `opts.schema` is set.
|
|
64
|
+
| `agent(prompt, opts?)` | Run one agent turn; returns its final text, or a schema-validated object when `opts.schema` is set. Transient failures (provider 5xx/overload, rate limits, dropped connections) retry up to twice with backoff; returns `null` on lasting failure — filter with `.filter(Boolean)`. |
|
|
65
65
|
| `parallel(thunks)` | Run `() => …` tasks concurrently; a failed task resolves to `null`. Barrier: waits for all. |
|
|
66
66
|
| `pipeline(items, ...stages)` | Each item flows through the stages independently — no barrier between stages. Stage signature: `(prev, item, index)`. |
|
|
67
67
|
| `phase(title)` | Group subsequent agents in progress output. |
|
|
68
68
|
| `log(message)` | Narrator line in progress output and the journal. |
|
|
69
|
+
| `workflow(nameOrPath, args?)` | Run one child workflow. Bare names resolve from `.newton/workflows/<name>.js`; paths resolve relative to the calling workflow. Nesting is limited to one child level. |
|
|
69
70
|
| `args` | Whatever you passed via `--args` (JSON-parsed when possible). |
|
|
70
71
|
| `budget` | `{ total, spent(), remaining() }` for the `--budget` output-token ceiling. |
|
|
71
72
|
|
|
72
73
|
`agent()` options: `provider` (see below), `model`, `effort` (`low`–`xhigh`),
|
|
73
74
|
`access` (`read` | `write` | `full` — sandbox posture), `schema` (JSON Schema
|
|
74
75
|
for structured output, validated with automatic retries), `label`, `phase`,
|
|
75
|
-
`cwd`, `timeoutMs`,
|
|
76
|
-
|
|
76
|
+
`cwd`, `timeoutMs`, `isolation: 'worktree'` for disposable per-agent git
|
|
77
|
+
worktrees, `mcp` (`true` for all `.mcp.json` servers, or a list of names/inline
|
|
78
|
+
servers), `config` (ACP `configId` to string value), and `command` — an escape
|
|
79
|
+
hatch to run any ACP agent, e.g. `command: ['my-agent', '--acp']`.
|
|
80
|
+
|
|
81
|
+
Workflow scripts run in a sandboxed JavaScript realm: Node globals such as
|
|
82
|
+
`process`, `fetch`, and `setTimeout` are not available, and `console.log()`
|
|
83
|
+
routes through `log()` into progress output and the journal.
|
|
77
84
|
|
|
78
85
|
## Providers
|
|
79
86
|
|
|
@@ -98,7 +105,7 @@ the agent advertises over ACP (session config options) and selects it
|
|
|
98
105
|
protocol-side. `newton models <provider>` shows what an agent exposes;
|
|
99
106
|
requesting a model an agent doesn't offer fails with the available list.
|
|
100
107
|
|
|
101
|
-
`Date.now()`, argless `new Date()`, and `Math.random()` throw inside
|
|
108
|
+
`Date.now()`, argless `new Date()`, and `Math.random()` also throw inside
|
|
102
109
|
workflows: nondeterminism would silently break `--resume` replay. Pass
|
|
103
110
|
timestamps in via `--args`.
|
|
104
111
|
|
|
@@ -113,15 +120,32 @@ workspace:
|
|
|
113
120
|
chunk, tool call, and permission decision.
|
|
114
121
|
|
|
115
122
|
`newton run workflow.js --resume <runId>` replays successful agent results
|
|
116
|
-
from a previous run's journal
|
|
117
|
-
|
|
123
|
+
from a previous run's journal. The default `--resume-mode prefix` replays by
|
|
124
|
+
call position until the first changed agent call, then runs that call and
|
|
125
|
+
everything after it live. `--resume-mode hash` uses the legacy hash replay:
|
|
126
|
+
identical calls return instantly no matter where they appear.
|
|
127
|
+
|
|
128
|
+
Long runs can be detached and controlled from another shell:
|
|
129
|
+
|
|
130
|
+
- `newton run workflow.js --detach` starts in the background and prints
|
|
131
|
+
`{ runId, pid }`.
|
|
132
|
+
- `newton runs [--json]` lists `.newton/runs/*` with status, agent count,
|
|
133
|
+
output tokens, and pid.
|
|
134
|
+
- `newton watch <runId> [--json]` replays the journal from the beginning and
|
|
135
|
+
keeps tailing until the run ends.
|
|
136
|
+
- `newton skip <runId> <agentId>` asks a queued or live agent to cancel;
|
|
137
|
+
`agent()` returns `null` and the workflow continues.
|
|
118
138
|
|
|
119
139
|
## CLI
|
|
120
140
|
|
|
121
141
|
```
|
|
122
142
|
newton run <workflow.js> --args <json> --cwd <dir> --resume <runId> --json
|
|
143
|
+
--resume-mode <prefix|hash> --detach
|
|
123
144
|
--budget <tokens> --max-agents <n> --timeout <seconds>
|
|
124
145
|
--concurrency <n>
|
|
146
|
+
newton runs [--json] list runs under .newton/runs
|
|
147
|
+
newton watch <runId> [--json] replay/tail a run's journal
|
|
148
|
+
newton skip <runId> <agentId> cancel one agent; workflow continues
|
|
125
149
|
newton validate <workflow.js> parse + compile without spawning agents
|
|
126
150
|
newton agents [--json] supported agents: installed? signed in? (alias: doctor)
|
|
127
151
|
newton models <provider> ask an agent which models/modes it exposes
|
package/dist/acp.js
CHANGED
|
@@ -50,8 +50,14 @@ export class AcpAgentSession {
|
|
|
50
50
|
toolCalls = 0;
|
|
51
51
|
usage = null;
|
|
52
52
|
stderrTail = [];
|
|
53
|
+
aborted;
|
|
54
|
+
abortSignal;
|
|
55
|
+
resolveAbortSignal;
|
|
53
56
|
constructor(request) {
|
|
54
57
|
this.request = request;
|
|
58
|
+
this.abortSignal = new Promise((resolveAbort) => {
|
|
59
|
+
this.resolveAbortSignal = resolveAbort;
|
|
60
|
+
});
|
|
55
61
|
}
|
|
56
62
|
async prompt() {
|
|
57
63
|
try {
|
|
@@ -72,6 +78,15 @@ export class AcpAgentSession {
|
|
|
72
78
|
}
|
|
73
79
|
async promptAgain(prompt) {
|
|
74
80
|
try {
|
|
81
|
+
if (this.aborted) {
|
|
82
|
+
return {
|
|
83
|
+
text: this.text,
|
|
84
|
+
stopReason: "cancelled",
|
|
85
|
+
usage: this.usage,
|
|
86
|
+
toolCalls: this.toolCalls,
|
|
87
|
+
error: this.aborted,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
75
90
|
const connection = this.connection;
|
|
76
91
|
const sessionId = this.sessionId;
|
|
77
92
|
this.text = "";
|
|
@@ -84,7 +99,7 @@ export class AcpAgentSession {
|
|
|
84
99
|
sessionId,
|
|
85
100
|
prompt: [{ type: "text", text: prompt }],
|
|
86
101
|
});
|
|
87
|
-
const outcome = await Promise.race([turn, timedOut]);
|
|
102
|
+
const outcome = await Promise.race([turn, timedOut, this.abortSignal]);
|
|
88
103
|
clearTimeout(timer);
|
|
89
104
|
if (outcome === "timeout") {
|
|
90
105
|
await connection.cancel({ sessionId }).catch(() => { });
|
|
@@ -99,6 +114,19 @@ export class AcpAgentSession {
|
|
|
99
114
|
error: `agent timed out after ${this.request.timeoutMs}ms`,
|
|
100
115
|
};
|
|
101
116
|
}
|
|
117
|
+
if (outcome === "aborted") {
|
|
118
|
+
await connection.cancel({ sessionId }).catch(() => { });
|
|
119
|
+
// Give the agent a moment to flush final updates, then hard-stop.
|
|
120
|
+
await Promise.race([turn.catch(() => { }), new Promise((r) => setTimeout(r, 3000))]);
|
|
121
|
+
this.close();
|
|
122
|
+
return {
|
|
123
|
+
text: this.text,
|
|
124
|
+
stopReason: "cancelled",
|
|
125
|
+
usage: this.usage,
|
|
126
|
+
toolCalls: this.toolCalls,
|
|
127
|
+
error: this.aborted,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
102
130
|
this.usage = outcome.usage ?? this.usage;
|
|
103
131
|
return {
|
|
104
132
|
text: this.text,
|
|
@@ -111,6 +139,10 @@ export class AcpAgentSession {
|
|
|
111
139
|
return this.failure(error);
|
|
112
140
|
}
|
|
113
141
|
}
|
|
142
|
+
abort(reason) {
|
|
143
|
+
this.aborted = reason;
|
|
144
|
+
this.resolveAbortSignal("aborted");
|
|
145
|
+
}
|
|
114
146
|
close() {
|
|
115
147
|
if (this.child && !this.child.killed)
|
|
116
148
|
this.child.kill("SIGTERM");
|
|
@@ -187,13 +219,17 @@ export class AcpAgentSession {
|
|
|
187
219
|
session: { configOptions: {} },
|
|
188
220
|
},
|
|
189
221
|
});
|
|
190
|
-
const session = await connection.newSession({ cwd: resolve(cwd), mcpServers: [] });
|
|
222
|
+
const session = await connection.newSession({ cwd: resolve(cwd), mcpServers: this.request.mcpServers ?? [] });
|
|
191
223
|
this.sessionId = session.sessionId;
|
|
192
224
|
this.sessionInfo = session;
|
|
193
225
|
trace({ type: "session", sessionId: session.sessionId, configOptions: session.configOptions ?? null });
|
|
194
226
|
if (this.request.spec.model && !resolved.modelHandled) {
|
|
195
227
|
await this.selectModelViaProtocol(session, this.request.spec.model);
|
|
196
228
|
}
|
|
229
|
+
for (const [configId, value] of Object.entries(this.request.config ?? {})) {
|
|
230
|
+
await connection.setSessionConfigOption({ sessionId: this.sessionId, configId, value });
|
|
231
|
+
trace({ type: "config_set", configId, value });
|
|
232
|
+
}
|
|
197
233
|
}
|
|
198
234
|
/**
|
|
199
235
|
* Providers without a CLI/env model knob may still expose model choice as an
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { accessSync, closeSync, constants, mkdirSync, openSync } from "node:fs";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
5
|
import { AcpAgentSession } from "./acp.js";
|
|
6
|
+
import { listRuns, readStatus, requestSkip, tailJournal } from "./control.js";
|
|
4
7
|
import { compileWorkflow, loadWorkflow, runWorkflow } from "./executor.js";
|
|
8
|
+
import { newRunId } from "./journal.js";
|
|
5
9
|
import { HumanReporter, JsonReporter } from "./progress.js";
|
|
6
10
|
import { describeProviders } from "./providers.js";
|
|
7
11
|
import { VERSION } from "./version.js";
|
|
@@ -9,6 +13,10 @@ const USAGE = `newton — deterministic multi-agent workflows over ACP
|
|
|
9
13
|
|
|
10
14
|
Usage:
|
|
11
15
|
newton run <workflow.js> [options] Run a workflow in the current directory
|
|
16
|
+
newton run <workflow.js> --detach Start in the background; prints { runId, pid }
|
|
17
|
+
newton runs [--json] List runs under .newton/runs with status
|
|
18
|
+
newton watch <runId> [--json] Tail a run's journal as live progress
|
|
19
|
+
newton skip <runId> <agentId> Cancel one agent; agent() returns null, run continues
|
|
12
20
|
newton validate <workflow.js> Parse + compile a workflow without running agents
|
|
13
21
|
newton agents [--json] Supported agents: installed? signed in? (alias: doctor)
|
|
14
22
|
newton models <provider> [--json] Ask an agent which models it exposes
|
|
@@ -17,6 +25,8 @@ Run options:
|
|
|
17
25
|
--args <json> Value exposed to the script as \`args\`
|
|
18
26
|
--cwd <dir> Workspace agents operate in (default: current directory)
|
|
19
27
|
--resume <runId> Replay cached agent results from a previous run
|
|
28
|
+
--resume-mode <prefix|hash> Resume matching: positional prefix (default) or hash replay
|
|
29
|
+
--detach Start the run in the background
|
|
20
30
|
--json NDJSON events on stdout instead of human output
|
|
21
31
|
--budget <tokens> Output-token ceiling; agent() throws once exhausted
|
|
22
32
|
--max-agents <n> Agent-call cap (default 200)
|
|
@@ -28,7 +38,11 @@ followed by a script body using agent()/parallel()/pipeline()/phase()/log(),
|
|
|
28
38
|
with \`args\` and \`budget\` in scope. agent() opts: provider (codex | claude |
|
|
29
39
|
gemini | qwen | cursor | opencode | goose | copilot), model, effort, access
|
|
30
40
|
(read | write | full), schema, label, phase, cwd, command (any ACP agent,
|
|
31
|
-
e.g. ["my-agent", "--acp"])
|
|
41
|
+
e.g. ["my-agent", "--acp"]), isolation ('worktree'), mcp (true | names/servers
|
|
42
|
+
from .mcp.json), config ({configId: value}).
|
|
43
|
+
Workflows may call workflow(nameOrPath, args) one level deep; bare names resolve
|
|
44
|
+
from .newton/workflows/<name>.js.
|
|
45
|
+
Scripts run sandboxed: no process/fetch/setTimeout; console.log routes to log().
|
|
32
46
|
|
|
33
47
|
Runs are recorded under .newton/runs/<runId>/ — journal.jsonl plus a full
|
|
34
48
|
per-agent ACP trace.`;
|
|
@@ -66,10 +80,47 @@ function intFlag(flags, name) {
|
|
|
66
80
|
fail(`--${name} requires a positive integer`);
|
|
67
81
|
return value;
|
|
68
82
|
}
|
|
83
|
+
function resumeModeFlag(flags) {
|
|
84
|
+
const raw = flags["resume-mode"];
|
|
85
|
+
if (raw === undefined)
|
|
86
|
+
return undefined;
|
|
87
|
+
if (raw === "prefix" || raw === "hash")
|
|
88
|
+
return raw;
|
|
89
|
+
fail("--resume-mode requires prefix or hash");
|
|
90
|
+
}
|
|
91
|
+
function commandRunDetached(file, flags) {
|
|
92
|
+
const cwd = typeof flags.cwd === "string" ? resolve(flags.cwd) : process.cwd();
|
|
93
|
+
const runId = newRunId();
|
|
94
|
+
const runDir = join(cwd, ".newton", "runs", runId);
|
|
95
|
+
mkdirSync(runDir, { recursive: true });
|
|
96
|
+
const fd = openSync(join(runDir, "detach.log"), "a");
|
|
97
|
+
const argv = [process.argv[1], "run", resolve(file), "--json", "--run-id", runId, "--cwd", cwd];
|
|
98
|
+
for (const name of ["args", "resume", "resume-mode", "budget", "max-agents", "timeout", "concurrency"]) {
|
|
99
|
+
const value = flags[name];
|
|
100
|
+
if (typeof value === "string")
|
|
101
|
+
argv.push(`--${name}`, value);
|
|
102
|
+
}
|
|
103
|
+
let child;
|
|
104
|
+
try {
|
|
105
|
+
child = spawn(process.execPath, argv, { detached: true, stdio: ["ignore", fd, fd] });
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
closeSync(fd);
|
|
109
|
+
}
|
|
110
|
+
if (!child)
|
|
111
|
+
fail("failed to start detached run");
|
|
112
|
+
child.unref();
|
|
113
|
+
process.stdout.write(`${JSON.stringify({ runId, pid: child.pid }, null, 2)}\n`);
|
|
114
|
+
process.exit(0);
|
|
115
|
+
}
|
|
69
116
|
async function commandRun(positional, flags) {
|
|
70
117
|
const file = positional[0];
|
|
71
118
|
if (!file)
|
|
72
119
|
fail("newton run requires a workflow file");
|
|
120
|
+
const resumeMode = resumeModeFlag(flags);
|
|
121
|
+
if (flags.detach === true) {
|
|
122
|
+
commandRunDetached(file, flags);
|
|
123
|
+
}
|
|
73
124
|
let args;
|
|
74
125
|
if (typeof flags.args === "string") {
|
|
75
126
|
try {
|
|
@@ -87,6 +138,9 @@ async function commandRun(positional, flags) {
|
|
|
87
138
|
args,
|
|
88
139
|
cwd: typeof flags.cwd === "string" ? flags.cwd : process.cwd(),
|
|
89
140
|
resumeRunId: typeof flags.resume === "string" ? flags.resume : undefined,
|
|
141
|
+
resumeMode,
|
|
142
|
+
runId: typeof flags["run-id"] === "string" ? flags["run-id"] : undefined,
|
|
143
|
+
detached: typeof flags["run-id"] === "string",
|
|
90
144
|
budgetTokens: intFlag(flags, "budget"),
|
|
91
145
|
maxAgents: intFlag(flags, "max-agents"),
|
|
92
146
|
agentTimeoutMs: timeoutSeconds ? timeoutSeconds * 1000 : undefined,
|
|
@@ -99,6 +153,126 @@ async function commandRun(positional, flags) {
|
|
|
99
153
|
}
|
|
100
154
|
process.exit(result.status === "ok" ? 0 : 1);
|
|
101
155
|
}
|
|
156
|
+
function commandRuns(flags) {
|
|
157
|
+
const workspace = process.cwd();
|
|
158
|
+
const rows = listRuns(workspace);
|
|
159
|
+
if (flags.json === true) {
|
|
160
|
+
process.stdout.write(`${JSON.stringify(rows.map(({ runId, stale, status }) => ({ runId, stale, ...(status ?? {}) })), null, 2)}\n`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (rows.length === 0) {
|
|
164
|
+
process.stdout.write("no runs under .newton/runs\n");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const table = [
|
|
168
|
+
["runId", "status", "agents", "out-tok", "pid", "name"],
|
|
169
|
+
...rows.map(({ runId, stale, status }) => [
|
|
170
|
+
runId,
|
|
171
|
+
stale ? "stale" : (status?.status ?? "—"),
|
|
172
|
+
status ? String(status.agents) : "",
|
|
173
|
+
status ? String(status.outputTokens) : "",
|
|
174
|
+
status ? String(status.pid) : "",
|
|
175
|
+
status?.name ?? "",
|
|
176
|
+
]),
|
|
177
|
+
];
|
|
178
|
+
const widths = table[0].map((_, index) => Math.max(...table.map((row) => row[index].length)));
|
|
179
|
+
for (const row of table) {
|
|
180
|
+
process.stdout.write(`${row.map((value, index) => value.padEnd(widths[index])).join(" ")}\n`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async function commandWatch(positional, flags) {
|
|
184
|
+
const runId = positional[0];
|
|
185
|
+
if (!runId)
|
|
186
|
+
fail("newton watch requires a run id");
|
|
187
|
+
const runDir = join(process.cwd(), ".newton", "runs", runId);
|
|
188
|
+
try {
|
|
189
|
+
accessSync(runDir, constants.F_OK);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
fail(`no run "${runId}"`);
|
|
193
|
+
}
|
|
194
|
+
const journalPath = join(runDir, "journal.jsonl");
|
|
195
|
+
const json = flags.json === true;
|
|
196
|
+
const reporter = new HumanReporter();
|
|
197
|
+
let startT;
|
|
198
|
+
let exitCode = 0;
|
|
199
|
+
const keepAlive = setInterval(() => { }, 1 << 30);
|
|
200
|
+
let handle;
|
|
201
|
+
handle = tailJournal(journalPath, (entry) => {
|
|
202
|
+
if (json) {
|
|
203
|
+
process.stdout.write(`${JSON.stringify(entry)}\n`);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
switch (entry.type) {
|
|
207
|
+
case "workflow_start":
|
|
208
|
+
startT = Date.parse(String(entry.t));
|
|
209
|
+
reporter.start({ name: String(entry.name), description: "" }, String(entry.runId), dirname(journalPath));
|
|
210
|
+
break;
|
|
211
|
+
case "phase":
|
|
212
|
+
reporter.phase(String(entry.title));
|
|
213
|
+
break;
|
|
214
|
+
case "log":
|
|
215
|
+
reporter.log(String(entry.message));
|
|
216
|
+
break;
|
|
217
|
+
case "agent_start":
|
|
218
|
+
reporter.agentStart(Number(entry.id), String(entry.label), entry.spec, entry.phase, false);
|
|
219
|
+
break;
|
|
220
|
+
case "agent_end": {
|
|
221
|
+
if (entry.cached === true) {
|
|
222
|
+
reporter.agentStart(Number(entry.id), String(entry.label), {}, entry.phase, true);
|
|
223
|
+
}
|
|
224
|
+
const ok = entry.status === "ok";
|
|
225
|
+
reporter.agentEnd(Number(entry.id), ok, Number(entry.seconds ?? 0), ok ? (entry.cached ? "cached" : `${entry.toolCalls ?? 0} tools`) : String(entry.error ?? "error"));
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
case "workflow_child_start":
|
|
229
|
+
reporter.log(`▸ workflow ${entry.name}`);
|
|
230
|
+
break;
|
|
231
|
+
case "workflow_child_end":
|
|
232
|
+
reporter.log(`${entry.status === "ok" ? "✔" : "✖"} workflow ${entry.name}`);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (entry.type === "workflow_end") {
|
|
237
|
+
const endT = Date.parse(String(entry.t));
|
|
238
|
+
exitCode = entry.status === "ok" ? 0 : 1;
|
|
239
|
+
if (!json) {
|
|
240
|
+
reporter.done({
|
|
241
|
+
runId,
|
|
242
|
+
status: entry.status,
|
|
243
|
+
result: entry.result,
|
|
244
|
+
error: entry.error,
|
|
245
|
+
agents: Number(entry.agents ?? 0),
|
|
246
|
+
outputTokens: Number(entry.outputTokens ?? 0),
|
|
247
|
+
durationMs: startT ? Math.max(0, endT - startT) : 0,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
clearInterval(keepAlive);
|
|
251
|
+
handle.stop();
|
|
252
|
+
process.exit(exitCode);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
function commandSkip(positional) {
|
|
257
|
+
const runId = positional[0];
|
|
258
|
+
const rawAgentId = positional[1];
|
|
259
|
+
if (!runId || !rawAgentId)
|
|
260
|
+
fail("newton skip requires a run id and agent id");
|
|
261
|
+
const agentId = Number(rawAgentId);
|
|
262
|
+
if (!Number.isInteger(agentId) || agentId <= 0)
|
|
263
|
+
fail("agentId requires a positive integer");
|
|
264
|
+
try {
|
|
265
|
+
requestSkip(process.cwd(), runId, agentId);
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
fail(error.message);
|
|
269
|
+
}
|
|
270
|
+
process.stdout.write(`skip requested: run ${runId} agent #${agentId}\n`);
|
|
271
|
+
const status = readStatus(join(process.cwd(), ".newton", "runs", runId));
|
|
272
|
+
if (status?.status !== "running") {
|
|
273
|
+
process.stderr.write(`warning: run ${runId} status is ${status?.status ?? "unknown"}; skip may have no effect\n`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
102
276
|
function commandValidate(positional) {
|
|
103
277
|
const file = positional[0];
|
|
104
278
|
if (!file)
|
|
@@ -184,6 +358,15 @@ async function main() {
|
|
|
184
358
|
case "run":
|
|
185
359
|
await commandRun(positional, flags);
|
|
186
360
|
break;
|
|
361
|
+
case "runs":
|
|
362
|
+
commandRuns(flags);
|
|
363
|
+
break;
|
|
364
|
+
case "watch":
|
|
365
|
+
await commandWatch(positional, flags);
|
|
366
|
+
break;
|
|
367
|
+
case "skip":
|
|
368
|
+
commandSkip(positional);
|
|
369
|
+
break;
|
|
187
370
|
case "validate":
|
|
188
371
|
try {
|
|
189
372
|
commandValidate(positional);
|
package/dist/control.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { closeSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export function writeStatus(runDir, status) {
|
|
4
|
+
const tmpPath = join(runDir, "status.json.tmp");
|
|
5
|
+
const statusPath = join(runDir, "status.json");
|
|
6
|
+
writeFileSync(tmpPath, `${JSON.stringify(status, null, 2)}\n`, "utf8");
|
|
7
|
+
renameSync(tmpPath, statusPath);
|
|
8
|
+
}
|
|
9
|
+
export function readStatus(runDir) {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(join(runDir, "status.json"), "utf8"));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function pidAlive(pid) {
|
|
18
|
+
try {
|
|
19
|
+
process.kill(pid, 0);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
return errorCode(error) === "EPERM";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function listRuns(workspace) {
|
|
27
|
+
const runsDir = join(workspace, ".newton", "runs");
|
|
28
|
+
let runIds;
|
|
29
|
+
try {
|
|
30
|
+
runIds = readdirSync(runsDir, { withFileTypes: true })
|
|
31
|
+
.filter((entry) => entry.isDirectory())
|
|
32
|
+
.map((entry) => entry.name)
|
|
33
|
+
.sort()
|
|
34
|
+
.reverse();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
return runIds.map((runId) => {
|
|
40
|
+
const status = readStatus(join(runsDir, runId));
|
|
41
|
+
return {
|
|
42
|
+
runId,
|
|
43
|
+
status,
|
|
44
|
+
stale: status?.status === "running" && !pidAlive(status.pid),
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
export function requestSkip(workspace, runId, agentId) {
|
|
49
|
+
const runDir = join(workspace, ".newton", "runs", runId);
|
|
50
|
+
try {
|
|
51
|
+
if (!statSync(runDir).isDirectory()) {
|
|
52
|
+
throw new Error("not a directory");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new Error(`no run "${runId}" under .newton/runs`);
|
|
57
|
+
}
|
|
58
|
+
const controlDir = join(runDir, "control");
|
|
59
|
+
mkdirSync(controlDir, { recursive: true });
|
|
60
|
+
writeFileSync(join(controlDir, `skip-${agentId}`), "", "utf8");
|
|
61
|
+
}
|
|
62
|
+
export function readSkipRequests(runDir) {
|
|
63
|
+
const ids = new Set();
|
|
64
|
+
let entries;
|
|
65
|
+
try {
|
|
66
|
+
entries = readdirSync(join(runDir, "control"));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return ids;
|
|
70
|
+
}
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const match = /^skip-(\d+)$/.exec(entry);
|
|
73
|
+
if (!match)
|
|
74
|
+
continue;
|
|
75
|
+
const id = Number(match[1]);
|
|
76
|
+
if (Number.isInteger(id))
|
|
77
|
+
ids.add(id);
|
|
78
|
+
}
|
|
79
|
+
return ids;
|
|
80
|
+
}
|
|
81
|
+
export function tailJournal(journalPath, onEntry, pollMs = 300) {
|
|
82
|
+
let offset = 0;
|
|
83
|
+
let partial = Buffer.alloc(0);
|
|
84
|
+
let stopped = false;
|
|
85
|
+
function emitLine(line) {
|
|
86
|
+
const text = line.toString("utf8");
|
|
87
|
+
if (!text.trim())
|
|
88
|
+
return;
|
|
89
|
+
let entry;
|
|
90
|
+
try {
|
|
91
|
+
entry = JSON.parse(text);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Ignore incomplete/corrupt journal lines; later valid lines still stream.
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) {
|
|
98
|
+
onEntry(entry);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function consume(chunk) {
|
|
102
|
+
const data = partial.length === 0 ? chunk : Buffer.concat([partial, chunk]);
|
|
103
|
+
let lineStart = 0;
|
|
104
|
+
for (let i = 0; i < data.length; i++) {
|
|
105
|
+
if (data[i] !== 10)
|
|
106
|
+
continue;
|
|
107
|
+
emitLine(data.subarray(lineStart, i));
|
|
108
|
+
lineStart = i + 1;
|
|
109
|
+
}
|
|
110
|
+
partial = lineStart < data.length ? data.subarray(lineStart) : Buffer.alloc(0);
|
|
111
|
+
}
|
|
112
|
+
function tick() {
|
|
113
|
+
if (stopped)
|
|
114
|
+
return;
|
|
115
|
+
let size;
|
|
116
|
+
try {
|
|
117
|
+
const stat = statSync(journalPath);
|
|
118
|
+
if (!stat.isFile())
|
|
119
|
+
return;
|
|
120
|
+
size = stat.size;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (size < offset) {
|
|
126
|
+
offset = 0;
|
|
127
|
+
partial = Buffer.alloc(0);
|
|
128
|
+
}
|
|
129
|
+
if (size === offset)
|
|
130
|
+
return;
|
|
131
|
+
let fd;
|
|
132
|
+
try {
|
|
133
|
+
fd = openSync(journalPath, "r");
|
|
134
|
+
const length = size - offset;
|
|
135
|
+
const chunk = Buffer.alloc(length);
|
|
136
|
+
const bytesRead = readSync(fd, chunk, 0, length, offset);
|
|
137
|
+
if (bytesRead <= 0)
|
|
138
|
+
return;
|
|
139
|
+
offset += bytesRead;
|
|
140
|
+
consume(bytesRead === chunk.length ? chunk : chunk.subarray(0, bytesRead));
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
if (fd !== undefined) {
|
|
147
|
+
try {
|
|
148
|
+
closeSync(fd);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// A close failure should not stop journal polling.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const timer = setInterval(tick, pollMs);
|
|
157
|
+
timer.unref();
|
|
158
|
+
return {
|
|
159
|
+
stop() {
|
|
160
|
+
stopped = true;
|
|
161
|
+
clearInterval(timer);
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function errorCode(error) {
|
|
166
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
167
|
+
? error.code
|
|
168
|
+
: undefined;
|
|
169
|
+
}
|