@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.
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
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
+ }