@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.
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/dist/adapters/claude.d.ts +19 -0
- package/dist/adapters/claude.js +631 -0
- package/dist/adapters/codex-home.d.ts +61 -0
- package/dist/adapters/codex-home.js +234 -0
- package/dist/adapters/codex-protocol.d.ts +59 -0
- package/dist/adapters/codex-protocol.js +204 -0
- package/dist/adapters/codex.d.ts +61 -0
- package/dist/adapters/codex.js +1406 -0
- package/dist/adapters/types.d.ts +183 -0
- package/dist/adapters/types.js +5 -0
- package/dist/async-queue.d.ts +11 -0
- package/dist/async-queue.js +50 -0
- package/dist/attachments.d.ts +72 -0
- package/dist/attachments.js +149 -0
- package/dist/auth-relay.d.ts +57 -0
- package/dist/auth-relay.js +289 -0
- package/dist/config.d.ts +96 -0
- package/dist/config.js +73 -0
- package/dist/fsview.d.ts +20 -0
- package/dist/fsview.js +122 -0
- package/dist/git.d.ts +54 -0
- package/dist/git.js +168 -0
- package/dist/gitops.d.ts +136 -0
- package/dist/gitops.js +596 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +352 -0
- package/dist/journal.d.ts +118 -0
- package/dist/journal.js +300 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +19 -0
- package/dist/paths.d.ts +7 -0
- package/dist/paths.js +33 -0
- package/dist/policy.d.ts +17 -0
- package/dist/policy.js +272 -0
- package/dist/protocol.d.ts +754 -0
- package/dist/protocol.js +154 -0
- package/dist/self-update.d.ts +75 -0
- package/dist/self-update.js +221 -0
- package/dist/status-file.d.ts +14 -0
- package/dist/status-file.js +29 -0
- package/dist/supervisor.d.ts +216 -0
- package/dist/supervisor.js +1648 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/dist/ws-client.d.ts +30 -0
- package/dist/ws-client.js +171 -0
- 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
|