@bridge4dev/runner 0.11.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -0
  3. package/dist/adapters/claude.d.ts +19 -0
  4. package/dist/adapters/claude.js +631 -0
  5. package/dist/adapters/codex-home.d.ts +61 -0
  6. package/dist/adapters/codex-home.js +234 -0
  7. package/dist/adapters/codex-protocol.d.ts +59 -0
  8. package/dist/adapters/codex-protocol.js +204 -0
  9. package/dist/adapters/codex.d.ts +61 -0
  10. package/dist/adapters/codex.js +1406 -0
  11. package/dist/adapters/types.d.ts +183 -0
  12. package/dist/adapters/types.js +5 -0
  13. package/dist/async-queue.d.ts +11 -0
  14. package/dist/async-queue.js +50 -0
  15. package/dist/attachments.d.ts +72 -0
  16. package/dist/attachments.js +149 -0
  17. package/dist/auth-relay.d.ts +57 -0
  18. package/dist/auth-relay.js +289 -0
  19. package/dist/config.d.ts +96 -0
  20. package/dist/config.js +73 -0
  21. package/dist/fsview.d.ts +20 -0
  22. package/dist/fsview.js +122 -0
  23. package/dist/git.d.ts +54 -0
  24. package/dist/git.js +168 -0
  25. package/dist/gitops.d.ts +136 -0
  26. package/dist/gitops.js +596 -0
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.js +352 -0
  29. package/dist/journal.d.ts +118 -0
  30. package/dist/journal.js +300 -0
  31. package/dist/log.d.ts +7 -0
  32. package/dist/log.js +19 -0
  33. package/dist/paths.d.ts +7 -0
  34. package/dist/paths.js +33 -0
  35. package/dist/policy.d.ts +17 -0
  36. package/dist/policy.js +272 -0
  37. package/dist/protocol.d.ts +754 -0
  38. package/dist/protocol.js +154 -0
  39. package/dist/self-update.d.ts +75 -0
  40. package/dist/self-update.js +221 -0
  41. package/dist/status-file.d.ts +14 -0
  42. package/dist/status-file.js +29 -0
  43. package/dist/supervisor.d.ts +216 -0
  44. package/dist/supervisor.js +1648 -0
  45. package/dist/version.d.ts +2 -0
  46. package/dist/version.js +3 -0
  47. package/dist/ws-client.d.ts +30 -0
  48. package/dist/ws-client.js +171 -0
  49. package/package.json +52 -0
package/dist/index.js ADDED
@@ -0,0 +1,352 @@
1
+ #!/usr/bin/env node
2
+ import { execFile } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ import { ClaudeAdapter } from './adapters/claude.js';
8
+ import { CodexAdapter } from './adapters/codex.js';
9
+ import { ensureCodexHome } from './adapters/codex-home.js';
10
+ import { loadConfig, requireConfig, saveConfig } from './config.js';
11
+ import { log } from './log.js';
12
+ import { isSupervisedProcess, resolveInstalledPackageDir } from './self-update.js';
13
+ import { Supervisor } from './supervisor.js';
14
+ import { readStatusFile, isPidAlive, writeStatusFile, STATUS_FRESH_MS } from './status-file.js';
15
+ import { RunnerWsClient } from './ws-client.js';
16
+ import { RUNNER_VERSION } from './version.js';
17
+ const execFileAsync = promisify(execFile);
18
+ function print(line) {
19
+ process.stdout.write(line + '\n');
20
+ }
21
+ function fail(message) {
22
+ process.stderr.write(`error: ${message}\n`);
23
+ process.exit(1);
24
+ }
25
+ function argValue(args, flag) {
26
+ const idx = args.indexOf(flag);
27
+ return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : undefined;
28
+ }
29
+ /**
30
+ * Which agent CLIs this server actually has. Reported at pairing and in every
31
+ * `hello`, so the dashboard can grey out an agent instead of letting the user
32
+ * create a session that immediately fails.
33
+ */
34
+ function installedAgents() {
35
+ const agents = [];
36
+ // Claude is bundled inside the Agent SDK, so it is always available.
37
+ agents.push('claude');
38
+ if (hasExecutable('codex'))
39
+ agents.push('codex');
40
+ return agents;
41
+ }
42
+ /**
43
+ * Can this process replace itself? Both halves matter: npm must have a real
44
+ * installation to upgrade, and something must bring the daemon back after it
45
+ * exits. Reported in `hello`, so the dashboard shows the copyable command
46
+ * instead of a button that would fail on tap.
47
+ */
48
+ function selfUpdatable() {
49
+ return resolveInstalledPackageDir() !== null && isSupervisedProcess();
50
+ }
51
+ function hasExecutable(name) {
52
+ const dirs = (process.env['PATH'] ?? '').split(path.delimiter).filter(Boolean);
53
+ return dirs.some((dir) => {
54
+ try {
55
+ fs.accessSync(path.join(dir, name), fs.constants.X_OK);
56
+ return true;
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ });
62
+ }
63
+ /**
64
+ * What this runner can do, sent in `hello` and stored on DevServer. The API
65
+ * checks these instead of parsing a version string — and it MUST, because an
66
+ * older runner drops a frame it cannot parse without ever replying, so a
67
+ * command it does not know would just hang until the gateway timeout.
68
+ */
69
+ function runnerCapabilities() {
70
+ const localLimit = loadConfig()?.limits?.max_sessions;
71
+ return {
72
+ agents: installedAgents(),
73
+ git: true,
74
+ /** Session 7: resume a terminal session without replaying its old status. */
75
+ resumeEpoch: true,
76
+ /** Session 8: understands `maxSessions` and runs sessions side by side. */
77
+ parallelSessions: true,
78
+ /**
79
+ * Session 9: can update itself on command. Reported as a capability rather
80
+ * than inferred from the version, because the dashboard must not offer a
81
+ * button whose frame an older runner would silently drop. Only true for an
82
+ * installed package under a supervisor — a source checkout or a hand-started
83
+ * daemon says so here, so the button never appears where it cannot work.
84
+ */
85
+ ...(selfUpdatable() ? { selfUpdate: true } : {}),
86
+ /**
87
+ * A stricter ceiling set on the machine itself (layer 1). Reported so the
88
+ * dashboard can explain why raising the number there changed nothing.
89
+ */
90
+ ...(localLimit ? { maxSessionsLimit: localLimit } : {}),
91
+ /**
92
+ * Session 10: understands `attachments` on a session message and can pull
93
+ * the files onto this machine. The dashboard hides the paperclip without it.
94
+ */
95
+ messageAttachments: true,
96
+ /**
97
+ * Session 11: `git_log`/`git_show`. Without it the dashboard hides the
98
+ * History tab rather than showing a tab that answers "unknown command".
99
+ */
100
+ gitHistory: true,
101
+ /** Commands beyond the stage-A/B set. */
102
+ commands: ['purge_session', 'git_log', 'git_show'],
103
+ };
104
+ }
105
+ // ─── pair ────────────────────────────────────────────────────────────
106
+ async function cmdPair(args) {
107
+ const code = args[0];
108
+ if (!code || !/^DBR-[A-Z2-9]{8}$/.test(code)) {
109
+ fail('usage: devbridge-runner pair DBR-XXXXXXXX --api https://api.bridge4.dev');
110
+ }
111
+ const apiUrl = (argValue(args, '--api') ?? 'https://api.bridge4.dev').replace(/\/$/, '');
112
+ const name = argValue(args, '--name') ?? os.hostname();
113
+ const response = await fetch(`${apiUrl}/api/v1/dev/servers/claim`, {
114
+ method: 'POST',
115
+ headers: { 'content-type': 'application/json' },
116
+ body: JSON.stringify({
117
+ pairingCode: code,
118
+ name,
119
+ runnerVersion: RUNNER_VERSION,
120
+ osInfo: `${os.type()} ${os.release()} ${os.arch()}`.slice(0, 200),
121
+ capabilities: runnerCapabilities(),
122
+ }),
123
+ });
124
+ const body = (await response.json().catch(() => null));
125
+ if (!response.ok || !body?.success || !body.data) {
126
+ fail(`pairing failed (HTTP ${response.status}): ${body?.error?.message ?? 'unknown error'}. ` +
127
+ 'The code is one-time and expires in 10 minutes — generate a fresh one in the dashboard if needed.');
128
+ }
129
+ const existing = loadConfig();
130
+ const config = {
131
+ api: { url: apiUrl, ws_url: body.data.wsUrl },
132
+ server: { id: body.data.serverId, name: body.data.serverName, token: body.data.token },
133
+ ...(existing?.mcp ? { mcp: existing.mcp } : {}),
134
+ // Carry settings the user wrote by hand — saveConfig re-parses through the
135
+ // schema, so a section dropped here is a section deleted from disk.
136
+ ...(existing?.codex ? { codex: existing.codex } : {}),
137
+ };
138
+ saveConfig(config);
139
+ print(`Paired as "${body.data.serverName}" (server ${body.data.serverId}).`);
140
+ print('Start the daemon with: devbridge-runner install-service (or: devbridge-runner daemon)');
141
+ }
142
+ // ─── daemon ──────────────────────────────────────────────────────────
143
+ /**
144
+ * Prepare the runner's own CODEX_HOME once, at startup. It is per-runner, not
145
+ * per-session: a fresh home clones ~90 MB of plugin marketplace on first use.
146
+ * A failure here disables Codex rather than the whole daemon.
147
+ */
148
+ function bootstrapCodex(config) {
149
+ try {
150
+ const home = ensureCodexHome({ auth: config.codex?.auth ?? 'link' });
151
+ log.info('codex: isolated home ready', { path: home.path, auth: home.auth });
152
+ // Deliberately NOT passing `codexHome`: the adapter re-asserts the home on
153
+ // every session. Freezing this snapshot is how a credential that went
154
+ // missing mid-day kept being reported as present while sessions failed.
155
+ // The mode does travel, so those repairs honour `[codex] auth = "own"`.
156
+ return new CodexAdapter({ authMode: config.codex?.auth ?? 'link' });
157
+ }
158
+ catch (error) {
159
+ log.error('codex: could not prepare an isolated home — Codex disabled', {
160
+ error: String(error),
161
+ });
162
+ return null;
163
+ }
164
+ }
165
+ /** Long enough for the `command_result` frame to leave the socket. */
166
+ const RESTART_DELAY_MS = 1_500;
167
+ async function cmdDaemon() {
168
+ const config = requireConfig();
169
+ log.info('daemon starting', { version: RUNNER_VERSION, server: config.server.name });
170
+ const agents = installedAgents();
171
+ const ws = new RunnerWsClient(config.api.ws_url, config.server.token, runnerCapabilities());
172
+ const codex = agents.includes('codex') ? bootstrapCodex(config) : null;
173
+ const supervisor = new Supervisor(ws, {
174
+ adapters: {
175
+ CLAUDE: new ClaudeAdapter(),
176
+ ...(codex ? { CODEX: codex } : {}),
177
+ },
178
+ ...(config.mcp ? { mcp: { url: config.mcp.url, token: config.mcp.token } } : {}),
179
+ ...(config.limits?.max_sessions ? { maxSessionsLimit: config.limits.max_sessions } : {}),
180
+ apiUrl: config.api.url,
181
+ // Used to fetch the files a user attaches to a message (session 10) — the
182
+ // same token the WS connection authenticates with, never passed onwards.
183
+ runnerToken: config.server.token,
184
+ // A successful update ends with this: exit cleanly and let systemd start
185
+ // the build that was just installed. The delay is for the WS frame that
186
+ // carries the answer — closing the socket first would leave the dashboard
187
+ // showing a timeout for an update that worked.
188
+ onRestartRequested: (outcome) => {
189
+ log.info('daemon: restarting into the updated build', {
190
+ from: outcome.fromVersion,
191
+ to: outcome.toVersion ?? 'unknown',
192
+ });
193
+ const timer = setTimeout(() => {
194
+ supervisor.shutdown();
195
+ ws.stop();
196
+ process.exit(0);
197
+ }, RESTART_DELAY_MS);
198
+ timer.unref();
199
+ },
200
+ });
201
+ const updateStatus = () => writeStatusFile({
202
+ pid: process.pid,
203
+ connected: ws.connected,
204
+ serverId: config.server.id,
205
+ serverName: config.server.name,
206
+ apiUrl: config.api.url,
207
+ activeSessionIds: supervisor.activeSessionIds,
208
+ updatedAt: new Date().toISOString(),
209
+ });
210
+ ws.on('open', updateStatus);
211
+ ws.on('close', updateStatus);
212
+ ws.on('revoked', (reason) => {
213
+ log.error(`daemon: access revoked (${reason}) — exiting`);
214
+ supervisor.shutdown();
215
+ updateStatus();
216
+ process.exit(3);
217
+ });
218
+ const statusTimer = setInterval(updateStatus, 30_000);
219
+ statusTimer.unref();
220
+ // Journal housekeeping also runs on every reconnect; this covers a runner
221
+ // that stays connected for weeks.
222
+ const pruneTimer = setInterval(() => supervisor.pruneJournals(), 6 * 3_600_000);
223
+ pruneTimer.unref();
224
+ const shutdown = (signal) => {
225
+ log.info(`daemon: ${signal} received, shutting down`);
226
+ supervisor.shutdown();
227
+ ws.stop();
228
+ updateStatus();
229
+ process.exit(0);
230
+ };
231
+ process.on('SIGINT', () => shutdown('SIGINT'));
232
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
233
+ ws.start();
234
+ updateStatus();
235
+ // Keep the process alive forever; ws timers drive everything.
236
+ await new Promise(() => undefined);
237
+ }
238
+ // ─── status ──────────────────────────────────────────────────────────
239
+ function cmdStatus() {
240
+ const config = loadConfig();
241
+ if (!config) {
242
+ print('NOT PAIRED — run: devbridge-runner pair <code> --api <url>');
243
+ process.exit(1);
244
+ }
245
+ const status = readStatusFile();
246
+ const fresh = status &&
247
+ isPidAlive(status.pid) &&
248
+ Date.now() - new Date(status.updatedAt).getTime() < STATUS_FRESH_MS;
249
+ if (!fresh) {
250
+ print(`NOT RUNNING — server "${config.server.name}" (${config.api.url})`);
251
+ print('Start with: devbridge-runner install-service (or: devbridge-runner daemon)');
252
+ process.exit(1);
253
+ }
254
+ if (status.connected) {
255
+ print(`CONNECTED — server "${status.serverName}" → ${status.apiUrl}`);
256
+ if (status.activeSessionIds.length) {
257
+ print(`Active sessions: ${status.activeSessionIds.join(', ')}`);
258
+ }
259
+ process.exit(0);
260
+ }
261
+ print(`DISCONNECTED — daemon is running (pid ${status.pid}) but not connected; check network/logs`);
262
+ process.exit(1);
263
+ }
264
+ // ─── install-service ─────────────────────────────────────────────────
265
+ function systemdUnit() {
266
+ const script = fs.realpathSync(process.argv[1] ?? '');
267
+ return [
268
+ '[Unit]',
269
+ 'Description=DevBridge Dev Runner',
270
+ 'After=network-online.target',
271
+ '',
272
+ '[Service]',
273
+ `ExecStart=${process.execPath} ${script} daemon`,
274
+ 'Restart=always',
275
+ 'RestartSec=5',
276
+ 'CPUQuota=80%',
277
+ 'MemoryMax=2G',
278
+ '',
279
+ '[Install]',
280
+ 'WantedBy=default.target',
281
+ ].join('\n');
282
+ }
283
+ async function cmdInstallService() {
284
+ requireConfig(); // fail early if not paired
285
+ if (process.platform !== 'linux')
286
+ fail('install-service supports Linux/systemd only');
287
+ const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
288
+ fs.mkdirSync(unitDir, { recursive: true });
289
+ const unitPath = path.join(unitDir, 'devbridge-runner.service');
290
+ fs.writeFileSync(unitPath, systemdUnit() + '\n');
291
+ print(`Wrote ${unitPath}`);
292
+ try {
293
+ await execFileAsync('systemctl', ['--user', 'daemon-reload']);
294
+ await execFileAsync('systemctl', ['--user', 'enable', '--now', 'devbridge-runner']);
295
+ print('Service enabled and started (systemctl --user).');
296
+ }
297
+ catch (error) {
298
+ fail(`systemctl failed: ${String(error instanceof Error ? error.message : error)}. ` +
299
+ 'Start manually with `systemctl --user enable --now devbridge-runner` or run `devbridge-runner daemon` under your supervisor.');
300
+ }
301
+ try {
302
+ await execFileAsync('loginctl', ['enable-linger', os.userInfo().username]);
303
+ print('Linger enabled — the runner survives logout.');
304
+ }
305
+ catch {
306
+ print('warning: could not enable linger; run `loginctl enable-linger $USER` manually.');
307
+ }
308
+ print('Verify with: devbridge-runner status');
309
+ }
310
+ // ─── set-token ───────────────────────────────────────────────────────
311
+ function cmdSetToken(args) {
312
+ const token = args[0];
313
+ if (!token || !token.startsWith('dbr_'))
314
+ fail('usage: devbridge-runner set-token dbr_…');
315
+ const config = requireConfig();
316
+ saveConfig({ ...config, server: { ...config.server, token } });
317
+ print('Token updated. Restart the daemon: systemctl --user restart devbridge-runner');
318
+ }
319
+ // ─── main ────────────────────────────────────────────────────────────
320
+ const HELP = `devbridge-runner ${RUNNER_VERSION}
321
+
322
+ Usage:
323
+ devbridge-runner pair <DBR-code> [--api <url>] [--name <name>] pair this server with DevBridge
324
+ devbridge-runner daemon run the runner (foreground)
325
+ devbridge-runner install-service install + start systemd user service
326
+ devbridge-runner status connection status (exit 0 = connected)
327
+ devbridge-runner set-token <dbr_token> store a rotated runner token
328
+ `;
329
+ async function main() {
330
+ const [command, ...args] = process.argv.slice(2);
331
+ switch (command) {
332
+ case 'pair':
333
+ return cmdPair(args);
334
+ case 'daemon':
335
+ return cmdDaemon();
336
+ case 'status':
337
+ return cmdStatus();
338
+ case 'install-service':
339
+ return cmdInstallService();
340
+ case 'set-token':
341
+ return cmdSetToken(args);
342
+ case '--version':
343
+ case 'version':
344
+ print(RUNNER_VERSION);
345
+ return;
346
+ default:
347
+ print(HELP);
348
+ process.exit(command ? 1 : 0);
349
+ }
350
+ }
351
+ main().catch((error) => fail(String(error instanceof Error ? error.message : error)));
352
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,118 @@
1
+ export interface JournalEvent {
2
+ seq: number;
3
+ eventType: string;
4
+ payload: Record<string, unknown>;
5
+ }
6
+ /**
7
+ * A user message the runner accepted but has not handed to an agent yet —
8
+ * because the worktree was still being prepared, or every session slot was
9
+ * taken. It lives in the journal for the same reason events do: the API
10
+ * considers it delivered the moment it hands it over, so losing it to a daemon
11
+ * restart means the user sees their own bubble in the feed and never an answer
12
+ * (QA-102 tail, session 9).
13
+ */
14
+ export interface PendingMessage {
15
+ id: string;
16
+ text: string;
17
+ /**
18
+ * Files that came with the message (session 10). Held as metadata, not bytes:
19
+ * they are downloaded at delivery time, which is also the first moment the
20
+ * session worktree is guaranteed to exist.
21
+ */
22
+ attachments?: PendingAttachment[];
23
+ }
24
+ export interface PendingAttachment {
25
+ id: string;
26
+ fileName: string;
27
+ mimeType: string;
28
+ fileSize: number;
29
+ }
30
+ export declare class SessionJournal {
31
+ readonly sessionId: string;
32
+ private readonly file;
33
+ private nextSeq;
34
+ private readonly unackedBySeq;
35
+ /** Messages accepted from the API but not yet handed to an agent. */
36
+ private readonly pendingById;
37
+ private pendingCounter;
38
+ /** Bytes written since the file was last rewritten from live state. */
39
+ private bytesOnDisk;
40
+ /** Last status reported for this session — replayed after a reconnect. */
41
+ lastStatus: {
42
+ status: string;
43
+ extra?: Record<string, unknown>;
44
+ epoch?: number;
45
+ } | null;
46
+ constructor(sessionId: string, dir?: string);
47
+ private replay;
48
+ private write;
49
+ /**
50
+ * Rewrite the file from live state: the seq counter, the events still waiting
51
+ * for an ack, and the last reported status. Everything else is history the
52
+ * runner never reads again.
53
+ *
54
+ * Atomic (tmp + rename) so a crash mid-compaction leaves the previous file
55
+ * intact rather than a truncated one.
56
+ */
57
+ compact(): void;
58
+ private compactIfLarge;
59
+ /**
60
+ * Statuses are fire-and-forget on the wire; journaling the latest one lets
61
+ * the supervisor re-report it after a reconnect (QA-96 F1).
62
+ */
63
+ recordStatus(status: string, extra?: Record<string, unknown>, epoch?: number): void;
64
+ /** Assign the next seq and persist the event before it is sent. */
65
+ append(eventType: string, payload: Record<string, unknown>): JournalEvent;
66
+ /**
67
+ * Never reuse a seq the API already stored: after a runner state-dir wipe the
68
+ * local counter restarts at 1 and every replayed event would collide with an
69
+ * existing (sessionId, seq) row and be swallowed as a duplicate — the session
70
+ * would look mute in the dashboard (QA-99 MAJOR-3).
71
+ */
72
+ ensureSeqAbove(lastStoredSeq: number): void;
73
+ ack(seq: number): void;
74
+ unacked(): JournalEvent[];
75
+ /**
76
+ * Record a message that could not be handed to an agent yet. Persisted before
77
+ * it is queued in memory, so the ordering is "on disk, then held" — a crash
78
+ * between the two costs a duplicate delivery at worst, never a lost message.
79
+ */
80
+ appendPending(text: string, attachments?: PendingAttachment[]): PendingMessage;
81
+ /** The message reached an agent — stop replaying it after a restart. */
82
+ resolvePending(id: string): void;
83
+ /** Messages still waiting, oldest first (ids are minted in order). */
84
+ pending(): PendingMessage[];
85
+ get lastAssignedSeq(): number;
86
+ /** Delete the journal file — used when a terminal session is fully acked. */
87
+ destroy(): void;
88
+ }
89
+ export declare class JournalStore {
90
+ private readonly dir;
91
+ private readonly journals;
92
+ constructor(dir?: string);
93
+ open(sessionId: string): SessionJournal;
94
+ exists(sessionId: string): boolean;
95
+ /** Sessions with journal files on disk (used for redelivery on reconnect). */
96
+ persistedSessionIds(): string[];
97
+ closeAndDelete(sessionId: string): void;
98
+ /**
99
+ * Drop journals of sessions that ended long ago.
100
+ *
101
+ * Two ages, on purpose. A journal with unacked events is the *only* copy of
102
+ * those events, and the recorded terminal status is what gets replayed after
103
+ * a reconnect (QA-96 F1) — so within `maxAgeMs` a non-empty journal is never
104
+ * touched. `hardMaxAgeMs` is the backstop for a session whose events the API
105
+ * will never accept (an org deleted server-side, say), so the directory
106
+ * cannot grow without bound.
107
+ *
108
+ * Returns the ids actually removed.
109
+ */
110
+ prune(options: {
111
+ maxAgeMs: number;
112
+ hardMaxAgeMs: number;
113
+ /** Session ids the supervisor still tracks — never pruned. */
114
+ skip: ReadonlySet<string>;
115
+ now?: number;
116
+ }): string[];
117
+ }
118
+ //# sourceMappingURL=journal.d.ts.map