@sagentlab/navarch-runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +351 -0
- package/bin/navarch.cjs +12 -0
- package/dist/adapter.cjs +22 -0
- package/dist/adapters/claude.cjs +135 -0
- package/dist/adapters/codex.cjs +167 -0
- package/dist/adapters/index.cjs +32 -0
- package/dist/adapters/types.cjs +2 -0
- package/dist/api.cjs +140 -0
- package/dist/capacity.cjs +41 -0
- package/dist/claim-loop.cjs +64 -0
- package/dist/cli.cjs +203 -0
- package/dist/config.cjs +55 -0
- package/dist/exit-conditions.cjs +191 -0
- package/dist/heartbeat-loop.cjs +47 -0
- package/dist/logger.cjs +20 -0
- package/dist/machine-store.cjs +58 -0
- package/dist/mcp-config.cjs +41 -0
- package/dist/prompt.cjs +33 -0
- package/dist/redact.cjs +61 -0
- package/dist/sandbox.cjs +166 -0
- package/dist/session.cjs +196 -0
- package/dist/types.cjs +19 -0
- package/dist/upload.cjs +19 -0
- package/package.json +50 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.codexAdapter = void 0;
|
|
4
|
+
exports.runCodexAdapter = runCodexAdapter;
|
|
5
|
+
const node_child_process_1 = require("node:child_process");
|
|
6
|
+
const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
|
|
7
|
+
/**
|
|
8
|
+
* Headless OpenAI Codex CLI adapter — the Codex sibling of claude.cts's
|
|
9
|
+
* `runClaudeCodeAdapter`, implementing the same AgentAdapter interface
|
|
10
|
+
* (adapters/types.cts) so session.cts can pick either one at runtime via
|
|
11
|
+
* FLOTILLA_AGENT (config.cts's `agentType`). Structurally this mirrors
|
|
12
|
+
* claude.cts exactly: same host-vs-docker-exec split, same
|
|
13
|
+
* timeout/AbortSignal handling, same "attach best-effort usage onto the raw
|
|
14
|
+
* result" shape — only the CLI invocation and output parsing differ.
|
|
15
|
+
*
|
|
16
|
+
* ASSUMED — EVERYTHING BELOW ABOUT THE REAL `codex` BINARY MUST BE CONFIRMED
|
|
17
|
+
* AGAINST A REAL INSTALL. There is no `codex` binary available in this
|
|
18
|
+
* offline build environment, so (mirroring how claude.cts documents its own
|
|
19
|
+
* `claude -p --output-format json` assumption) this adapter is a best-effort
|
|
20
|
+
* implementation against the Codex CLI's publicly documented shape, not a
|
|
21
|
+
* verified one:
|
|
22
|
+
*
|
|
23
|
+
* codex exec "<prompt>" --json [--mcp-config <path>] [...extraArgs]
|
|
24
|
+
*
|
|
25
|
+
* - `exec <prompt>` — ASSUMED to be Codex CLI's non-interactive/headless
|
|
26
|
+
* subcommand (the `codex exec` "automation mode" analog of `claude -p`):
|
|
27
|
+
* runs the prompt to completion without the interactive TUI and exits,
|
|
28
|
+
* printing its result to stdout.
|
|
29
|
+
* - `--json` — ASSUMED to switch Codex CLI's output to newline-delimited
|
|
30
|
+
* JSON ("JSONL") event objects rather than a single JSON result object
|
|
31
|
+
* like Claude Code's `--output-format json`. See exit-conditions.cts's
|
|
32
|
+
* parseCodexJsonEvents doc comment for the exact assumed event shape and
|
|
33
|
+
* what happens when this guess is wrong (graceful degradation, never a
|
|
34
|
+
* thrown error).
|
|
35
|
+
* - `--mcp-config <path>` — ASSUMED by analogy with the Claude adapter; there
|
|
36
|
+
* is no confirmed Codex CLI flag of this name. Codex CLI is documented
|
|
37
|
+
* elsewhere to configure MCP servers via a `~/.codex/config.toml`
|
|
38
|
+
* `mcp_servers` table rather than a per-invocation flag, so this flag may
|
|
39
|
+
* need to become "write a config.toml fragment into the sandbox before
|
|
40
|
+
* exec" instead once a real binary is available to test against.
|
|
41
|
+
* - Sandboxing/approvals: a real `codex exec` may prompt for
|
|
42
|
+
* approval/sandbox-escalation on some actions by default; because this
|
|
43
|
+
* runtime already isolates the session in its own Docker container (or,
|
|
44
|
+
* in `FLOTILLA_SANDBOX_MODE=host`, trusts the host), the intent is to pass
|
|
45
|
+
* whatever flag disables Codex's own approval gate for a fully
|
|
46
|
+
* non-interactive run (something like `--full-auto` or
|
|
47
|
+
* `--dangerously-bypass-approvals-and-sandbox` in published Codex CLI
|
|
48
|
+
* documentation) — deliberately NOT hardcoded here since getting an
|
|
49
|
+
* unverified flag wrong could silently change sandboxing behavior; left to
|
|
50
|
+
* be supplied via FLOTILLA_CODEX_EXTRA_ARGS until confirmed.
|
|
51
|
+
* - `FLOTILLA_CODEX_EXTRA_ARGS` (`extraArgs`) wins over the default `--json`
|
|
52
|
+
* exactly like the Claude adapter's `--output-format` opt-out, so an
|
|
53
|
+
* operator can fall back to plain-text output (or add the real
|
|
54
|
+
* approval-bypass flag) without an adapter code change.
|
|
55
|
+
*/
|
|
56
|
+
async function runCodexAdapter(options) {
|
|
57
|
+
const args = ["exec", options.prompt];
|
|
58
|
+
if (options.mcpConfigPath) {
|
|
59
|
+
// ASSUMED flag/support — see module doc comment above.
|
|
60
|
+
args.push("--mcp-config", options.mcpConfigPath);
|
|
61
|
+
}
|
|
62
|
+
if (!options.extraArgs.includes("--json")) {
|
|
63
|
+
args.push("--json");
|
|
64
|
+
}
|
|
65
|
+
args.push(...options.extraArgs);
|
|
66
|
+
const raw = options.dockerExec ? await runViaDocker(options, args) : await runOnHost(options, args);
|
|
67
|
+
return attachUsage(raw);
|
|
68
|
+
}
|
|
69
|
+
/** Parses stdout for `codex exec --json` usage/final-message events and folds them onto the raw result (best-effort; leaves tokensIn/tokensOut/costUsd/reportText unset when nothing parses — see exit-conditions.cts#parseCodexJsonEvents). */
|
|
70
|
+
function attachUsage(result) {
|
|
71
|
+
const events = (0, exit_conditions_cjs_1.parseCodexJsonEvents)(result.stdout);
|
|
72
|
+
if (events.length === 0)
|
|
73
|
+
return result;
|
|
74
|
+
const usage = (0, exit_conditions_cjs_1.extractUsageFromCodexEvents)(events);
|
|
75
|
+
const reportText = (0, exit_conditions_cjs_1.extractFinalMessageFromCodexEvents)(events) ?? undefined;
|
|
76
|
+
return {
|
|
77
|
+
...result,
|
|
78
|
+
tokensIn: usage.tokensIn,
|
|
79
|
+
tokensOut: usage.tokensOut,
|
|
80
|
+
costUsd: usage.costUsd,
|
|
81
|
+
...(reportText !== undefined ? { reportText } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
async function runOnHost(options, args) {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
let stdout = "";
|
|
87
|
+
let stderr = "";
|
|
88
|
+
let timedOut = false;
|
|
89
|
+
let killedByLeaseLoss = false;
|
|
90
|
+
const child = (0, node_child_process_1.spawn)(options.bin, args, {
|
|
91
|
+
cwd: options.cwd,
|
|
92
|
+
env: { ...process.env, ...options.env },
|
|
93
|
+
});
|
|
94
|
+
const timer = setTimeout(() => {
|
|
95
|
+
timedOut = true;
|
|
96
|
+
child.kill("SIGKILL");
|
|
97
|
+
}, options.timeoutMs);
|
|
98
|
+
const onAbort = () => {
|
|
99
|
+
killedByLeaseLoss = true;
|
|
100
|
+
child.kill("SIGKILL");
|
|
101
|
+
};
|
|
102
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
103
|
+
child.stdout.on("data", (d) => {
|
|
104
|
+
stdout += d.toString();
|
|
105
|
+
});
|
|
106
|
+
child.stderr.on("data", (d) => {
|
|
107
|
+
stderr += d.toString();
|
|
108
|
+
});
|
|
109
|
+
child.on("error", (err) => {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
112
|
+
stderr += `\n${String(err)}`;
|
|
113
|
+
resolve({ exitCode: null, timedOut, killedByLeaseLoss, stdout, stderr });
|
|
114
|
+
});
|
|
115
|
+
child.on("close", (code) => {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
118
|
+
resolve({ exitCode: code, timedOut, killedByLeaseLoss, stdout, stderr });
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
async function runViaDocker(options, args) {
|
|
123
|
+
const { containerName, runner } = options.dockerExec;
|
|
124
|
+
const quoted = [options.bin, ...args].map(shellQuote).join(" ");
|
|
125
|
+
const command = `[ -f /tmp/session.env ] && . /tmp/session.env; cd repo 2>/dev/null; ${quoted}`;
|
|
126
|
+
let killedByLeaseLoss = false;
|
|
127
|
+
const onAbort = () => {
|
|
128
|
+
killedByLeaseLoss = true;
|
|
129
|
+
// Best-effort: kill the exec'd process inside the container — see
|
|
130
|
+
// claude.cts's identical runViaDocker for the same caveat about
|
|
131
|
+
// nodeCommandRunner not exposing process-group tracking.
|
|
132
|
+
runner.run("docker", ["kill", containerName]).catch(() => undefined);
|
|
133
|
+
};
|
|
134
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
135
|
+
try {
|
|
136
|
+
const result = await runner.run("docker", ["exec", containerName, "sh", "-c", command], {
|
|
137
|
+
timeoutMs: options.timeoutMs,
|
|
138
|
+
});
|
|
139
|
+
return {
|
|
140
|
+
exitCode: result.code,
|
|
141
|
+
timedOut: false,
|
|
142
|
+
killedByLeaseLoss,
|
|
143
|
+
stdout: result.stdout,
|
|
144
|
+
stderr: result.stderr,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
return {
|
|
149
|
+
exitCode: null,
|
|
150
|
+
timedOut: false,
|
|
151
|
+
killedByLeaseLoss,
|
|
152
|
+
stdout: "",
|
|
153
|
+
stderr: String(err),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function shellQuote(value) {
|
|
161
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
162
|
+
}
|
|
163
|
+
/** The AgentAdapter (adapters/types.cts) wrapper session.cts selects via FLOTILLA_AGENT=codex. */
|
|
164
|
+
exports.codexAdapter = {
|
|
165
|
+
agentType: "codex",
|
|
166
|
+
run: runCodexAdapter,
|
|
167
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runCodexAdapter = exports.codexAdapter = exports.runClaudeCodeAdapter = exports.claudeCodeAdapter = void 0;
|
|
4
|
+
exports.selectAdapter = selectAdapter;
|
|
5
|
+
const claude_cjs_1 = require("./claude.cjs");
|
|
6
|
+
Object.defineProperty(exports, "claudeCodeAdapter", { enumerable: true, get: function () { return claude_cjs_1.claudeCodeAdapter; } });
|
|
7
|
+
Object.defineProperty(exports, "runClaudeCodeAdapter", { enumerable: true, get: function () { return claude_cjs_1.runClaudeCodeAdapter; } });
|
|
8
|
+
const codex_cjs_1 = require("./codex.cjs");
|
|
9
|
+
Object.defineProperty(exports, "codexAdapter", { enumerable: true, get: function () { return codex_cjs_1.codexAdapter; } });
|
|
10
|
+
Object.defineProperty(exports, "runCodexAdapter", { enumerable: true, get: function () { return codex_cjs_1.runCodexAdapter; } });
|
|
11
|
+
/**
|
|
12
|
+
* Picks the AgentAdapter (adapters/types.cts) session.cts should run a
|
|
13
|
+
* session with, keyed off config.cts's `agentType` (FLOTILLA_AGENT). This is
|
|
14
|
+
* the one place agent-type branching happens outside config loading itself —
|
|
15
|
+
* session.cts calls the returned adapter's `run()` uniformly regardless of
|
|
16
|
+
* which one it got.
|
|
17
|
+
*/
|
|
18
|
+
function selectAdapter(agentType) {
|
|
19
|
+
switch (agentType) {
|
|
20
|
+
case "codex":
|
|
21
|
+
return codex_cjs_1.codexAdapter;
|
|
22
|
+
case "claude-code":
|
|
23
|
+
return claude_cjs_1.claudeCodeAdapter;
|
|
24
|
+
default: {
|
|
25
|
+
// Exhaustiveness guard: config.cts only ever produces the two values
|
|
26
|
+
// above, but fall back to Claude Code rather than throwing if this
|
|
27
|
+
// widens in the future without every caller being updated.
|
|
28
|
+
const _exhaustive = agentType;
|
|
29
|
+
return claude_cjs_1.claudeCodeAdapter;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
package/dist/api.cjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NavarchApiClient = exports.NavarchApiError = void 0;
|
|
4
|
+
class NavarchApiError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
body;
|
|
7
|
+
constructor(message, status, body) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.body = body;
|
|
11
|
+
this.name = "NavarchApiError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
exports.NavarchApiError = NavarchApiError;
|
|
15
|
+
/**
|
|
16
|
+
* Typed client for the Navarch control-plane API surface WP-07 depends on:
|
|
17
|
+
* dispatch/claim, per-lease heartbeat, complete, and broker/issue
|
|
18
|
+
* (schema-design.md §7, quoted verbatim in the method docs below), plus the
|
|
19
|
+
* machine-registration / machine-heartbeat / transcript-upload-url routes
|
|
20
|
+
* this package assumes (see types.cts doc comments — flagged "ASSUMED").
|
|
21
|
+
*
|
|
22
|
+
* Every request path lives in exactly one method here so wiring up the real
|
|
23
|
+
* deployment, once WP-01/WP-04/WP-05 land, is a base URL + token (and at most
|
|
24
|
+
* a one-line path fix for the assumed routes) — not a rewrite.
|
|
25
|
+
*/
|
|
26
|
+
class NavarchApiClient {
|
|
27
|
+
baseUrl;
|
|
28
|
+
token;
|
|
29
|
+
fetchImpl;
|
|
30
|
+
constructor(opts) {
|
|
31
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
32
|
+
this.token = opts.token;
|
|
33
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
34
|
+
}
|
|
35
|
+
/** The control-plane base URL this client talks to (used to build the per-session platform MCP config -- see mcp-config.cts). */
|
|
36
|
+
getBaseUrl() {
|
|
37
|
+
return this.baseUrl;
|
|
38
|
+
}
|
|
39
|
+
/** The machine's own bearer token, if this client was constructed with one (absent only for self-registration). Needed to authenticate the session's platform MCP config -- see mcp-config.cts. */
|
|
40
|
+
getToken() {
|
|
41
|
+
return this.token;
|
|
42
|
+
}
|
|
43
|
+
async request(method, pathname, body, opts) {
|
|
44
|
+
const auth = opts?.auth ?? true;
|
|
45
|
+
const headers = { "content-type": "application/json" };
|
|
46
|
+
if (auth) {
|
|
47
|
+
if (!this.token) {
|
|
48
|
+
throw new Error(`Cannot call ${pathname} without a machine token.`);
|
|
49
|
+
}
|
|
50
|
+
headers.authorization = `Bearer ${this.token}`;
|
|
51
|
+
}
|
|
52
|
+
const response = await this.fetchImpl(`${this.baseUrl}${pathname}`, {
|
|
53
|
+
method,
|
|
54
|
+
headers,
|
|
55
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
56
|
+
});
|
|
57
|
+
if (response.status === 204)
|
|
58
|
+
return null;
|
|
59
|
+
const text = await response.text();
|
|
60
|
+
const parsed = text ? safeJsonParse(text) : null;
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
throw new NavarchApiError(`Navarch API ${method} ${pathname} failed with ${response.status}`, response.status, parsed ?? text);
|
|
63
|
+
}
|
|
64
|
+
if (opts?.allowEmpty && parsed === null)
|
|
65
|
+
return null;
|
|
66
|
+
return parsed;
|
|
67
|
+
}
|
|
68
|
+
/** ASSUMED endpoint — see types.cts RegisterMachineRequest doc comment. */
|
|
69
|
+
async registerMachine(req) {
|
|
70
|
+
const result = await this.request("POST", "/api/machines/register", req, { auth: false });
|
|
71
|
+
if (!result)
|
|
72
|
+
throw new Error("registerMachine: empty response from control plane.");
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* `POST /api/machines/connect` — "Connect an agent to a project"
|
|
77
|
+
* (docs/flotilla/schema-design.md §7 "Agent connect"). CONFIRMED endpoint,
|
|
78
|
+
* the project-scoped sibling of {@link registerMachine}: redeems a
|
|
79
|
+
* single-use, project-scoped enrollment token a project owner minted
|
|
80
|
+
* (POST /api/projects/:id/enrollment-tokens) instead of the global
|
|
81
|
+
* FLOTILLA_ENROLLMENT_SECRET. Unauthenticated like registerMachine — the
|
|
82
|
+
* enrollment token in the body is the auth.
|
|
83
|
+
*/
|
|
84
|
+
async connectMachine(req) {
|
|
85
|
+
const result = await this.request("POST", "/api/machines/connect", req, { auth: false });
|
|
86
|
+
if (!result)
|
|
87
|
+
throw new Error("connectMachine: empty response from control plane.");
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
/** ASSUMED endpoint — see types.cts MachineHeartbeatRequest doc comment. */
|
|
91
|
+
async machineHeartbeat(machineId, req) {
|
|
92
|
+
const result = await this.request("POST", `/api/machines/${encodeURIComponent(machineId)}/heartbeat`, req);
|
|
93
|
+
if (!result)
|
|
94
|
+
throw new Error("machineHeartbeat: empty response from control plane.");
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
/** schema-design.md §7 — `POST /api/dispatch/claim {machine_capacity, capabilities} → task + context bundle`. */
|
|
98
|
+
async claim(req) {
|
|
99
|
+
const result = await this.request("POST", "/api/dispatch/claim", req, { allowEmpty: true });
|
|
100
|
+
if (!result || result.task == null)
|
|
101
|
+
return null;
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
/** schema-design.md §7 — `POST /api/dispatch/:leaseId/heartbeat`. */
|
|
105
|
+
async heartbeatLease(leaseId) {
|
|
106
|
+
const result = await this.request("POST", `/api/dispatch/${encodeURIComponent(leaseId)}/heartbeat`, {});
|
|
107
|
+
if (!result)
|
|
108
|
+
throw new Error("heartbeatLease: empty response from control plane.");
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
/** schema-design.md §7 — `POST /api/dispatch/:leaseId/complete {status, report, evidence_urls, cost}`. */
|
|
112
|
+
async completeLease(leaseId, req) {
|
|
113
|
+
await this.request("POST", `/api/dispatch/${encodeURIComponent(leaseId)}/complete`, req, {
|
|
114
|
+
allowEmpty: true,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/** schema-design.md §7 — `POST /api/broker/issue {lease_id, secret names} → values (scoped, logged)`. */
|
|
118
|
+
async issueSecrets(req) {
|
|
119
|
+
const result = await this.request("POST", "/api/broker/issue", req);
|
|
120
|
+
if (!result)
|
|
121
|
+
throw new Error("issueSecrets: empty response from control plane.");
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
/** ASSUMED endpoint — see types.cts TranscriptUploadUrlResult doc comment. */
|
|
125
|
+
async getTranscriptUploadUrl(leaseId) {
|
|
126
|
+
const result = await this.request("POST", `/api/dispatch/${encodeURIComponent(leaseId)}/transcript-upload-url`, {});
|
|
127
|
+
if (!result)
|
|
128
|
+
throw new Error("getTranscriptUploadUrl: empty response from control plane.");
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
exports.NavarchApiClient = NavarchApiClient;
|
|
133
|
+
function safeJsonParse(text) {
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(text);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CapacityTracker = void 0;
|
|
4
|
+
exports.computeAvailableCapacity = computeAvailableCapacity;
|
|
5
|
+
/** Pure capacity math (implementation-plan.md WP-07: "capacity control (max_sessions)"). */
|
|
6
|
+
function computeAvailableCapacity(maxSessions, activeSessions) {
|
|
7
|
+
return Math.max(0, maxSessions - activeSessions);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Tracks which session (lease) ids currently occupy a slot on this machine so
|
|
11
|
+
* the heartbeat loop reports accurate available_capacity and the claim loop
|
|
12
|
+
* never over-claims beyond FLOTILLA_MAX_SESSIONS.
|
|
13
|
+
*/
|
|
14
|
+
class CapacityTracker {
|
|
15
|
+
maxSessions;
|
|
16
|
+
active = new Set();
|
|
17
|
+
constructor(maxSessions) {
|
|
18
|
+
this.maxSessions = maxSessions;
|
|
19
|
+
}
|
|
20
|
+
get activeCount() {
|
|
21
|
+
return this.active.size;
|
|
22
|
+
}
|
|
23
|
+
available() {
|
|
24
|
+
return computeAvailableCapacity(this.maxSessions, this.active.size);
|
|
25
|
+
}
|
|
26
|
+
hasCapacity() {
|
|
27
|
+
return this.available() > 0;
|
|
28
|
+
}
|
|
29
|
+
acquire(sessionId) {
|
|
30
|
+
if (this.active.has(sessionId))
|
|
31
|
+
return;
|
|
32
|
+
if (!this.hasCapacity()) {
|
|
33
|
+
throw new Error(`No capacity available: ${this.active.size}/${this.maxSessions} sessions active.`);
|
|
34
|
+
}
|
|
35
|
+
this.active.add(sessionId);
|
|
36
|
+
}
|
|
37
|
+
release(sessionId) {
|
|
38
|
+
this.active.delete(sessionId);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
exports.CapacityTracker = CapacityTracker;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ClaimLoop = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const logger_cjs_1 = require("./logger.cjs");
|
|
6
|
+
const log = (0, logger_cjs_1.createLogger)("claim");
|
|
7
|
+
/**
|
|
8
|
+
* Polls dispatch/claim on an interval, gated by available capacity
|
|
9
|
+
* (implementation-plan.md WP-07 "Claim loop: poll dispatch, receive context
|
|
10
|
+
* bundle"). Each successful claim acquires a capacity slot immediately and
|
|
11
|
+
* releases it when the session (run asynchronously, not awaited here so
|
|
12
|
+
* multiple sessions can run concurrently) finishes.
|
|
13
|
+
*/
|
|
14
|
+
class ClaimLoop {
|
|
15
|
+
api;
|
|
16
|
+
config;
|
|
17
|
+
capacity;
|
|
18
|
+
runSession;
|
|
19
|
+
timer = null;
|
|
20
|
+
stopped = false;
|
|
21
|
+
constructor(api, config, capacity, runSession) {
|
|
22
|
+
this.api = api;
|
|
23
|
+
this.config = config;
|
|
24
|
+
this.capacity = capacity;
|
|
25
|
+
this.runSession = runSession;
|
|
26
|
+
}
|
|
27
|
+
start() {
|
|
28
|
+
if (this.timer)
|
|
29
|
+
return;
|
|
30
|
+
this.timer = setInterval(() => void this.tick(), this.config.pollIntervalMs);
|
|
31
|
+
}
|
|
32
|
+
stop() {
|
|
33
|
+
this.stopped = true;
|
|
34
|
+
if (this.timer)
|
|
35
|
+
clearInterval(this.timer);
|
|
36
|
+
this.timer = null;
|
|
37
|
+
}
|
|
38
|
+
async tick() {
|
|
39
|
+
if (this.stopped || !this.capacity.hasCapacity())
|
|
40
|
+
return;
|
|
41
|
+
try {
|
|
42
|
+
// Pre-allocate the session id BEFORE claiming (author-exclusion timing):
|
|
43
|
+
// the dispatcher records it on the lease and excludes it from review
|
|
44
|
+
// tasks of PRs this session authors.
|
|
45
|
+
const sessionId = (0, node_crypto_1.randomUUID)();
|
|
46
|
+
const claimed = await this.api.claim({
|
|
47
|
+
available_capacity: this.capacity.available(),
|
|
48
|
+
capabilities: this.config.capabilities,
|
|
49
|
+
session_id: sessionId,
|
|
50
|
+
});
|
|
51
|
+
if (!claimed)
|
|
52
|
+
return;
|
|
53
|
+
this.capacity.acquire(claimed.lease_id);
|
|
54
|
+
log.info(`claimed task ${claimed.task.id} (${claimed.task.task_type}) as lease ${claimed.lease_id}, session ${sessionId}`);
|
|
55
|
+
this.runSession(claimed, sessionId)
|
|
56
|
+
.catch((err) => log.error(`session ${sessionId} failed: ${String(err)}`))
|
|
57
|
+
.finally(() => this.capacity.release(claimed.lease_id));
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
log.warn(`claim failed: ${String(err)}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
exports.ClaimLoop = ClaimLoop;
|
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.main = main;
|
|
4
|
+
const config_cjs_1 = require("./config.cjs");
|
|
5
|
+
const machine_store_cjs_1 = require("./machine-store.cjs");
|
|
6
|
+
const api_cjs_1 = require("./api.cjs");
|
|
7
|
+
const capacity_cjs_1 = require("./capacity.cjs");
|
|
8
|
+
const heartbeat_loop_cjs_1 = require("./heartbeat-loop.cjs");
|
|
9
|
+
const claim_loop_cjs_1 = require("./claim-loop.cjs");
|
|
10
|
+
const session_cjs_1 = require("./session.cjs");
|
|
11
|
+
const sandbox_cjs_1 = require("./sandbox.cjs");
|
|
12
|
+
const logger_cjs_1 = require("./logger.cjs");
|
|
13
|
+
const log = (0, logger_cjs_1.createLogger)("cli");
|
|
14
|
+
function parseArgs(argv) {
|
|
15
|
+
const [command, ...rest] = argv;
|
|
16
|
+
const flags = {};
|
|
17
|
+
for (let i = 0; i < rest.length; i++) {
|
|
18
|
+
const arg = rest[i];
|
|
19
|
+
if (arg?.startsWith("--")) {
|
|
20
|
+
const key = arg.slice(2);
|
|
21
|
+
const next = rest[i + 1];
|
|
22
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
23
|
+
flags[key] = next;
|
|
24
|
+
i++;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
flags[key] = "true";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { command: command ?? "help", flags };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* `navarch-runtime register` — the one command that prints the machine
|
|
35
|
+
* token, exactly once, per implementation-plan.md WP-07. See types.cts
|
|
36
|
+
* RegisterMachineRequest for the (assumed, to-confirm) endpoint contract.
|
|
37
|
+
*/
|
|
38
|
+
async function registerCommand(flags) {
|
|
39
|
+
const config = (0, config_cjs_1.loadRuntimeConfig)();
|
|
40
|
+
const apiBase = flags["api-base"] ?? config.apiBase;
|
|
41
|
+
const enrollmentToken = flags.token ?? process.env.FLOTILLA_ENROLLMENT_TOKEN;
|
|
42
|
+
const name = flags.name ?? process.env.FLOTILLA_MACHINE_NAME;
|
|
43
|
+
if (!enrollmentToken) {
|
|
44
|
+
throw new Error("Missing --token (or FLOTILLA_ENROLLMENT_TOKEN) — get one from the admin console.");
|
|
45
|
+
}
|
|
46
|
+
if (!name) {
|
|
47
|
+
throw new Error("Missing --name (or FLOTILLA_MACHINE_NAME) for this machine.");
|
|
48
|
+
}
|
|
49
|
+
const maxSessions = Number(flags["max-sessions"] ?? config.maxSessions);
|
|
50
|
+
const capabilities = (flags.capabilities ?? config.capabilities.join(","))
|
|
51
|
+
.split(",")
|
|
52
|
+
.map((s) => s.trim())
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
const ownerZone = flags["owner-zone"] ?? config.ownerZone;
|
|
55
|
+
const client = new api_cjs_1.NavarchApiClient({ baseUrl: apiBase });
|
|
56
|
+
const result = await client.registerMachine({
|
|
57
|
+
enrollment_token: enrollmentToken,
|
|
58
|
+
name,
|
|
59
|
+
owner_zone: ownerZone,
|
|
60
|
+
capabilities,
|
|
61
|
+
max_sessions: Number.isFinite(maxSessions) && maxSessions > 0 ? maxSessions : config.maxSessions,
|
|
62
|
+
});
|
|
63
|
+
await (0, machine_store_cjs_1.saveMachineIdentity)(config.configDir, {
|
|
64
|
+
machine_id: result.machine_id,
|
|
65
|
+
token: result.token,
|
|
66
|
+
name,
|
|
67
|
+
api_base: apiBase,
|
|
68
|
+
});
|
|
69
|
+
// Printed exactly once. Never logged or echoed again after this point.
|
|
70
|
+
console.log("Machine registered.");
|
|
71
|
+
console.log(` machine_id: ${result.machine_id}`);
|
|
72
|
+
console.log(` token: ${result.token}`);
|
|
73
|
+
console.log(`\nStored in ${config.configDir}/machine.json (mode 0600). Run \`navarch-runtime start\` to begin serving tasks.`);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* `navarch-runtime connect` — "Connect an agent to a project"
|
|
77
|
+
* (docs/flotilla/schema-design.md §7 "Agent connect"; self-hosted-runner
|
|
78
|
+
* style). The project-scoped sibling of `register`: redeems a short-lived,
|
|
79
|
+
* single-use enrollment token a project owner minted from the platform UI
|
|
80
|
+
* (ConnectAgentPanel → POST /api/projects/:id/enrollment-tokens) instead of
|
|
81
|
+
* the global FLOTILLA_ENROLLMENT_SECRET `register` needs. Prints the
|
|
82
|
+
* machine token exactly once, same discipline as `register`.
|
|
83
|
+
*/
|
|
84
|
+
async function connectCommand(flags) {
|
|
85
|
+
const config = (0, config_cjs_1.loadRuntimeConfig)();
|
|
86
|
+
const apiBase = flags["api-base"] ?? config.apiBase;
|
|
87
|
+
const enrollmentToken = flags.token ?? process.env.FLOTILLA_ENROLLMENT_TOKEN;
|
|
88
|
+
const name = flags.name ?? process.env.FLOTILLA_MACHINE_NAME;
|
|
89
|
+
const projectId = flags.project ?? process.env.FLOTILLA_PROJECT_ID;
|
|
90
|
+
if (!enrollmentToken) {
|
|
91
|
+
throw new Error("Missing --token (or FLOTILLA_ENROLLMENT_TOKEN) — get one from a project owner's \"Connect an agent\" panel.");
|
|
92
|
+
}
|
|
93
|
+
if (!name) {
|
|
94
|
+
throw new Error("Missing --name (or FLOTILLA_MACHINE_NAME) for this machine.");
|
|
95
|
+
}
|
|
96
|
+
const maxSessions = Number(flags["max-sessions"] ?? config.maxSessions);
|
|
97
|
+
const capabilities = (flags.capabilities ?? config.capabilities.join(","))
|
|
98
|
+
.split(",")
|
|
99
|
+
.map((s) => s.trim())
|
|
100
|
+
.filter(Boolean);
|
|
101
|
+
const client = new api_cjs_1.NavarchApiClient({ baseUrl: apiBase });
|
|
102
|
+
const result = await client.connectMachine({
|
|
103
|
+
enrollment_token: enrollmentToken,
|
|
104
|
+
name,
|
|
105
|
+
capabilities,
|
|
106
|
+
max_sessions: Number.isFinite(maxSessions) && maxSessions > 0 ? maxSessions : config.maxSessions,
|
|
107
|
+
project_id: projectId,
|
|
108
|
+
});
|
|
109
|
+
await (0, machine_store_cjs_1.saveMachineIdentity)(config.configDir, {
|
|
110
|
+
machine_id: result.machine_id,
|
|
111
|
+
token: result.token,
|
|
112
|
+
name,
|
|
113
|
+
api_base: apiBase,
|
|
114
|
+
});
|
|
115
|
+
// Printed exactly once. Never logged or echoed again after this point.
|
|
116
|
+
console.log("Machine connected.");
|
|
117
|
+
console.log(` machine_id: ${result.machine_id}`);
|
|
118
|
+
console.log(` token: ${result.token}`);
|
|
119
|
+
console.log(`\nStored in ${config.configDir}/machine.json (mode 0600). Run \`navarch-runtime start\` to begin serving tasks.`);
|
|
120
|
+
}
|
|
121
|
+
async function startCommand() {
|
|
122
|
+
const config = (0, config_cjs_1.loadRuntimeConfig)();
|
|
123
|
+
const identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(config.configDir, config.apiBase);
|
|
124
|
+
const api = new api_cjs_1.NavarchApiClient({ baseUrl: identity.api_base, token: identity.token });
|
|
125
|
+
const capacity = new capacity_cjs_1.CapacityTracker(config.maxSessions);
|
|
126
|
+
const heartbeat = new heartbeat_loop_cjs_1.MachineHeartbeatLoop(api, identity.machine_id, config, capacity);
|
|
127
|
+
const claimLoop = new claim_loop_cjs_1.ClaimLoop(api, config, capacity, (claimed, sessionId) => (0, session_cjs_1.runSession)({ api, config }, claimed, sessionId));
|
|
128
|
+
heartbeat.start();
|
|
129
|
+
claimLoop.start();
|
|
130
|
+
log.info(`navarch-runtime started: machine=${identity.name} max_sessions=${config.maxSessions} api_base=${identity.api_base}`);
|
|
131
|
+
const shutdown = () => {
|
|
132
|
+
log.info("shutting down...");
|
|
133
|
+
heartbeat.stop();
|
|
134
|
+
claimLoop.stop();
|
|
135
|
+
process.exit(0);
|
|
136
|
+
};
|
|
137
|
+
process.on("SIGINT", shutdown);
|
|
138
|
+
process.on("SIGTERM", shutdown);
|
|
139
|
+
}
|
|
140
|
+
async function doctorCommand() {
|
|
141
|
+
const config = (0, config_cjs_1.loadRuntimeConfig)();
|
|
142
|
+
const dockerOk = await (0, sandbox_cjs_1.isDockerAvailable)();
|
|
143
|
+
console.log(`api_base: ${config.apiBase}`);
|
|
144
|
+
console.log(`config_dir: ${config.configDir}`);
|
|
145
|
+
console.log(`workspace_root: ${config.workspaceRoot}`);
|
|
146
|
+
console.log(`max_sessions: ${config.maxSessions}`);
|
|
147
|
+
console.log(`capabilities: ${config.capabilities.join(", ")}`);
|
|
148
|
+
console.log(`sandbox_mode: ${config.sandboxMode}`);
|
|
149
|
+
console.log(`docker: ${dockerOk ? "available" : "NOT AVAILABLE (docker-backed sessions will fail)"}`);
|
|
150
|
+
try {
|
|
151
|
+
const identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(config.configDir, config.apiBase);
|
|
152
|
+
console.log(`machine: ${identity.name} (${identity.machine_id})`);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
console.log("machine: not registered — run `navarch-runtime register`");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function helpText() {
|
|
159
|
+
return `navarch-runtime — Navarch machine-side session manager
|
|
160
|
+
|
|
161
|
+
Usage:
|
|
162
|
+
navarch-runtime register --token <enrollment-token> --name <machine-name> \\
|
|
163
|
+
[--capabilities a,b] [--max-sessions N] [--owner-zone z] [--api-base url]
|
|
164
|
+
navarch-runtime connect --token <enrollment-token> --name <machine-name> \\
|
|
165
|
+
[--project <project-id>] [--capabilities a,b] [--max-sessions N] [--api-base url]
|
|
166
|
+
navarch-runtime start
|
|
167
|
+
navarch-runtime doctor
|
|
168
|
+
|
|
169
|
+
Configuration is via FLOTILLA_* environment variables; see runtime/README.md.
|
|
170
|
+
`;
|
|
171
|
+
}
|
|
172
|
+
async function main(argv = process.argv.slice(2)) {
|
|
173
|
+
const { command, flags } = parseArgs(argv);
|
|
174
|
+
try {
|
|
175
|
+
switch (command) {
|
|
176
|
+
case "register":
|
|
177
|
+
await registerCommand(flags);
|
|
178
|
+
break;
|
|
179
|
+
case "connect":
|
|
180
|
+
await connectCommand(flags);
|
|
181
|
+
break;
|
|
182
|
+
case "start":
|
|
183
|
+
await startCommand();
|
|
184
|
+
break;
|
|
185
|
+
case "doctor":
|
|
186
|
+
await doctorCommand();
|
|
187
|
+
break;
|
|
188
|
+
case "help":
|
|
189
|
+
case "--help":
|
|
190
|
+
case "-h":
|
|
191
|
+
console.log(helpText());
|
|
192
|
+
break;
|
|
193
|
+
default:
|
|
194
|
+
console.error(`Unknown command: ${command}\n`);
|
|
195
|
+
console.log(helpText());
|
|
196
|
+
process.exitCode = 1;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
log.error(err instanceof Error ? err.message : String(err));
|
|
201
|
+
process.exitCode = 1;
|
|
202
|
+
}
|
|
203
|
+
}
|