@agentpulselabs/cli-plugin-pulse 0.2.0 → 1.1.2
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 +115 -32
- package/package.json +14 -19
- package/src/clients/hub-bridge.mjs +99 -0
- package/src/clients/hub.mjs +324 -0
- package/src/commands/pulse/cutover.js +39 -0
- package/src/commands/pulse/doctor.js +15 -0
- package/src/commands/pulse/hub-write.js +26 -0
- package/src/commands/pulse/import.js +62 -0
- package/src/commands/pulse/install.js +14 -0
- package/src/commands/pulse/log-write.js +26 -0
- package/src/commands/pulse/login.js +20 -0
- package/src/commands/pulse/mirror.js +27 -0
- package/src/commands/pulse/pair.js +38 -0
- package/src/commands/pulse/parity.js +20 -0
- package/src/commands/pulse/restart.js +14 -0
- package/src/commands/pulse/shadow.js +19 -0
- package/src/commands/pulse/start.js +6 -18
- package/src/commands/pulse/status.js +6 -6
- package/src/commands/pulse/stop.js +24 -0
- package/src/daemon/main.js +43 -0
- package/src/hub/core/auth.mjs +80 -0
- package/src/hub/core/changes.mjs +38 -0
- package/src/hub/core/conversations.mjs +121 -0
- package/src/hub/core/db.mjs +263 -0
- package/src/hub/core/edgeIds.mjs +9 -0
- package/src/hub/core/hub.mjs +20 -0
- package/src/hub/core/ids.mjs +39 -0
- package/src/hub/core/leases.mjs +55 -0
- package/src/hub/core/messaging.mjs +322 -0
- package/src/hub/core/registry.mjs +281 -0
- package/src/hub/core/ticks.mjs +58 -0
- package/src/hub/core/wake.mjs +125 -0
- package/src/hub/home.mjs +16 -0
- package/src/hub/hooks/hub-bridge.mjs +167 -0
- package/src/hub/keepawake/agent.mjs +96 -0
- package/src/hub/lib/brief.mjs +133 -0
- package/src/hub/lib/channels.mjs +268 -0
- package/src/hub/lib/chat.mjs +163 -0
- package/src/hub/lib/closure.mjs +92 -0
- package/src/hub/lib/collect-worker.mjs +20 -0
- package/src/hub/lib/collect.mjs +342 -0
- package/src/hub/lib/deliveries.mjs +222 -0
- package/src/hub/lib/git.mjs +93 -0
- package/src/hub/lib/me.mjs +123 -0
- package/src/hub/lib/personas.mjs +143 -0
- package/src/hub/lib/status.mjs +147 -0
- package/src/hub/lib/store.mjs +106 -0
- package/src/hub/lib/titles.mjs +29 -0
- package/src/hub/lib/transcript-feed.mjs +72 -0
- package/src/hub/lib/transcripts.mjs +364 -0
- package/src/hub/lib/writing.mjs +18 -0
- package/src/hub/projections/deliveries.mjs +31 -0
- package/src/hub/projections/graph.mjs +149 -0
- package/src/hub/runners/drivers.mjs +162 -0
- package/src/hub/workers/collector.mjs +128 -0
- package/src/hub/workers/manager.mjs +366 -0
- package/src/hub/workers/runner.mjs +145 -0
- package/src/lib/BaseCommand.js +23 -23
- package/src/lib/agentSessions.js +279 -0
- package/src/lib/application.js +704 -0
- package/src/lib/backoff.js +8 -0
- package/src/lib/budgets.js +85 -0
- package/src/lib/channelScanner.js +99 -0
- package/src/lib/cliDefaults.js +20 -0
- package/src/lib/clientInstaller.js +111 -0
- package/src/lib/cloudAuth.js +189 -0
- package/src/lib/cloudFirstCommands.js +293 -0
- package/src/lib/contract.sha256 +2 -0
- package/src/lib/cutover.js +376 -0
- package/src/lib/cutoverWiring.js +137 -0
- package/src/lib/daemonComposition.js +338 -0
- package/src/lib/daemonLock.js +57 -0
- package/src/lib/daemonLoop.js +72 -0
- package/src/lib/daemonProcess.js +380 -0
- package/src/lib/daemonRuntime.js +828 -0
- package/src/lib/doctorWarnings.js +53 -0
- package/src/lib/drivers.js +280 -0
- package/src/lib/errors.js +33 -0
- package/src/lib/graphWindows.js +149 -0
- package/src/lib/http.js +84 -0
- package/src/lib/hubActions.js +74 -0
- package/src/lib/hubDbReader.js +132 -0
- package/src/lib/hubMapping.js +108 -0
- package/src/lib/hubScan.js +611 -0
- package/src/lib/hubSnapshot.js +64 -0
- package/src/lib/identity.js +102 -0
- package/src/lib/intents.js +590 -0
- package/src/lib/ledger.js +535 -0
- package/src/lib/lifecycle.js +123 -0
- package/src/lib/localHub.js +217 -0
- package/src/lib/localHubWriter.js +69 -0
- package/src/lib/mirror-contract.json +4299 -0
- package/src/lib/mirrorContract.js +513 -0
- package/src/lib/mirrorDaemon.js +127 -0
- package/src/lib/mirrorService.js +365 -0
- package/src/lib/mirrorSource.js +430 -0
- package/src/lib/mirrorSync.js +353 -0
- package/src/lib/nodeFloor.js +19 -0
- package/src/lib/oauthLogin.js +132 -0
- package/src/lib/outboxFlusher.js +75 -0
- package/src/lib/ownerLock.js +99 -0
- package/src/lib/pairedConfig.js +40 -0
- package/src/lib/pairingLifecycle.js +179 -0
- package/src/lib/pathRedaction.js +89 -0
- package/src/lib/planContract.js +141 -0
- package/src/lib/priorityLane.js +80 -0
- package/src/lib/processOwnership.js +252 -0
- package/src/lib/processSupervisor.js +376 -0
- package/src/lib/reconcile.js +316 -0
- package/src/lib/reconcileView.js +129 -0
- package/src/lib/redaction.js +128 -0
- package/src/lib/rootPolicy.js +372 -0
- package/src/lib/rulePacks.js +162 -0
- package/src/lib/runnerStore.js +418 -0
- package/src/lib/secretStore.js +169 -0
- package/src/lib/selfRestart.js +261 -0
- package/src/lib/serviceManager.js +419 -0
- package/src/lib/shadowService.js +435 -0
- package/src/lib/sqlite.js +28 -0
- package/src/lib/storage.js +131 -0
- package/src/lib/subprocess.js +45 -0
- package/src/lib/tokenStore.js +136 -0
- package/src/lib/transport.js +334 -0
- package/src/lib/transportContract.js +40 -0
- package/src/lib/uploadRedactor.js +102 -0
- package/src/runner/applier.js +297 -0
- package/src/runner/bridge.js +289 -0
- package/src/runner/busIdentity.js +499 -0
- package/src/runner/capture.js +103 -0
- package/src/runner/captureMapping.js +21 -0
- package/src/runner/channelDiscipline.js +425 -0
- package/src/runner/deliveryStates.js +220 -0
- package/src/runner/facade.js +876 -0
- package/src/runner/hubCapture.js +160 -0
- package/src/runner/lease.js +46 -0
- package/src/runner/main.js +35 -0
- package/src/runner/render.js +91 -0
- package/src/runner/runnerChild.js +57 -0
- package/src/runner/runnerHub.js +104 -0
- package/src/runner/runnerProcess.js +124 -0
- package/src/runner/runnerSchema.js +51 -0
- package/src/runner/runnerService.js +140 -0
- package/src/runner/spool.js +86 -0
- package/src/runner/spoolInto.js +22 -0
- package/src/runner/statusPage.js +30 -0
- package/LICENSE +0 -21
- package/src/commands/pulse/gate.js +0 -25
- package/src/commands/pulse/next.js +0 -14
- package/src/commands/pulse/submit.js +0 -21
- package/src/commands/pulse/verify.js +0 -21
- package/src/runPulse.js +0 -58
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// agents-hub workers/runner.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// Hub-owned agents. The hub starts them, owns their sessions, and delivers every message by running one turn of that
|
|
3
|
+
// session with the message as the prompt; the turn's final answer is the reply. No parent chat has to stay alive, and
|
|
4
|
+
// any provider with a driver can run them.
|
|
5
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
import { isFyi } from '../core/ids.mjs';
|
|
11
|
+
import { withTx } from '../core/db.mjs';
|
|
12
|
+
import { RETRY_MS, guardTick, retryBusy } from '../core/ticks.mjs';
|
|
13
|
+
import { CHANNEL_POST_RULE, DECISION_RULE, WRITING_GUIDE } from '../lib/writing.mjs';
|
|
14
|
+
import { hubHome } from '../home.mjs';
|
|
15
|
+
|
|
16
|
+
const execFile = promisify(execFileCb);
|
|
17
|
+
const TICK_MS = 1500;
|
|
18
|
+
const LEASE_MS = 60e3;
|
|
19
|
+
const MAX_PARALLEL = 2;
|
|
20
|
+
const MAX_ATTEMPTS = 3;
|
|
21
|
+
const ROLE = /^[A-Z][A-Z -]{1,30}$/;
|
|
22
|
+
const HUB_CLI = path.join(hubHome(), 'hub.mjs');
|
|
23
|
+
|
|
24
|
+
/** What every hub-owned agent is told on its first turn, whatever its provider. */
|
|
25
|
+
export function briefPrompt(run, brief) {
|
|
26
|
+
return [
|
|
27
|
+
`You are the ${run.role} on a delivery run by the Agent Hub. Your hub address is ${run.id}.`,
|
|
28
|
+
`Work only inside ${run.workspace}. Never push, release or touch production; never request extra permissions.`,
|
|
29
|
+
run.stream ? `Your task channel is ${run.workspace}/.agents/${run.stream.split('/').pop()}/channel.md: append posts to it (never rewrite), each starting with a header line \`## [${run.role}] HH:MM:SS TYPE — summary\`.` : null,
|
|
30
|
+
`To message another agent: node "${HUB_CLI}" bus publish --as ${run.id} --topic agent/<id> --type MESSAGE --body "<text>".`,
|
|
31
|
+
'Each message to you arrives as a new turn; your final answer in that turn is sent back to the sender as your reply.',
|
|
32
|
+
`${WRITING_GUIDE} ${CHANNEL_POST_RULE} ${DECISION_RULE}`,
|
|
33
|
+
'',
|
|
34
|
+
'## Your brief',
|
|
35
|
+
brief,
|
|
36
|
+
].filter((x) => x !== null).join('\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function messagePrompt(m) {
|
|
40
|
+
return [`Message from ${m.fromName || m.from} (${m.type}, id ${m.id}):`, '', m.text,
|
|
41
|
+
'', 'Answer it now; your final answer is sent back as your reply. Then carry on with your task if work remains.'].join('\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createRunner({ hub, drivers, run = execFile, workspaceRoot, onChange = () => {}, now = Date.now, log = console.error, retryMs = RETRY_MS }) {
|
|
45
|
+
const { registry, messaging } = hub;
|
|
46
|
+
const busy = new Map();
|
|
47
|
+
const aborts = new Map();
|
|
48
|
+
let timer = null;
|
|
49
|
+
const save = (r) => { registry.setFact('run', r.id, { ...r, updatedAt: Date.now() }); onChange(); return r; };
|
|
50
|
+
const runs = () => registry.facts('run').filter((r) => r.state !== 'stopped');
|
|
51
|
+
|
|
52
|
+
function inWorkspace(dir) {
|
|
53
|
+
try {
|
|
54
|
+
const real = fs.realpathSync(dir);
|
|
55
|
+
const root = fs.realpathSync(workspaceRoot);
|
|
56
|
+
return (real === root || real.startsWith(root + path.sep)) && fs.statSync(real).isDirectory() ? real : null;
|
|
57
|
+
} catch { return null; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Start a hub-owned agent: create its session, register it, and queue its brief as its first message. */
|
|
61
|
+
async function start({ driver, role, workspace, brief, stream = null, parent = 'manager', name = null, mode = 'write' }) {
|
|
62
|
+
const d = drivers.get(driver);
|
|
63
|
+
if (!d) return { error: `unknown driver ${driver}` };
|
|
64
|
+
if (!ROLE.test(String(role || ''))) return { error: 'bad role' };
|
|
65
|
+
const ws = inWorkspace(workspace);
|
|
66
|
+
if (!ws) return { error: 'workspace must be a folder inside the hub workspace' };
|
|
67
|
+
if (typeof brief !== 'string' || !brief.trim()) return { error: 'empty brief' };
|
|
68
|
+
if (mode === 'write' && !d.can.edit) return { error: `${d.label} cannot edit; give it a read-only role` };
|
|
69
|
+
const id = (await d.newSession({ run, workspace: ws })) || crypto.randomUUID();
|
|
70
|
+
const rec = { id, driver, role, workspace: ws, stream, parent, name, mode, state: 'queued', sessionId: d.id === 'cursor' ? id : null, createdAt: Date.now(), turns: 0, costUsd: 0, lastError: null };
|
|
71
|
+
registry.upsertInferred({ id, node_type: 'agent', role, parent_id: parent, delivery_id: parent, stream_key: stream, persona_name: name, source: 'registered', repo: path.basename(ws), channel: stream ? stream.split('/').pop() : null, started_at: rec.createdAt, last_at: rec.createdAt });
|
|
72
|
+
save(rec);
|
|
73
|
+
messaging.publish({ from: 'user', fromName: 'You', topic: `agent/${id}`, type: 'BRIEF', body: brief.slice(0, 4000), needsAck: true, meta: { briefFull: brief.length > 4000 } }, registry.directory());
|
|
74
|
+
rec.brief = brief;
|
|
75
|
+
save(rec);
|
|
76
|
+
return { id, driver, state: rec.state };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function turn(r, m) {
|
|
80
|
+
const d = drivers.get(r.driver);
|
|
81
|
+
const prompt = m.type === 'BRIEF' ? briefPrompt(r, r.brief || m.text) : messagePrompt(m);
|
|
82
|
+
save({ ...r, state: 'working', current: m.id, lastError: null });
|
|
83
|
+
registry.heartbeat(r.id, { event: 'runner', tool: d.id });
|
|
84
|
+
const keep = setInterval(() => {
|
|
85
|
+
try {
|
|
86
|
+
messaging.renewClaim(r.id, m.id, m.leaseToken, { leaseMs: LEASE_MS });
|
|
87
|
+
registry.heartbeat(r.id, { event: 'runner', tool: d.id });
|
|
88
|
+
} catch (e) { log(`runner keepalive for ${r.id} failed: ${e.message}`); }
|
|
89
|
+
}, 20e3);
|
|
90
|
+
const abort = new AbortController();
|
|
91
|
+
aborts.set(r.id, abort);
|
|
92
|
+
try {
|
|
93
|
+
const out = await d.turn({ run, sessionId: r.sessionId, workspace: r.workspace, prompt, mode: r.mode, signal: abort.signal });
|
|
94
|
+
if (abort.signal.aborted) return;
|
|
95
|
+
// The answer is delivered, acked and recorded together; a busy database retries that, never the turn.
|
|
96
|
+
await retryBusy(() => withTx(hub.db, () => {
|
|
97
|
+
const fresh = registry.fact('run', r.id) || r;
|
|
98
|
+
messaging.markDelivered(r.id, [m.id], { via: 'runner' });
|
|
99
|
+
messaging.ack(r.id, m.id, { reply: out.text || '(no answer)', fromName: fresh.name || fresh.role }, registry.directory());
|
|
100
|
+
save({ ...fresh, sessionId: out.sessionId || fresh.sessionId, state: 'idle', current: null, turns: (fresh.turns || 0) + 1, lastAt: Date.now(), costUsd: (fresh.costUsd || 0) + ((out.usage && out.usage.costUsd) || 0) });
|
|
101
|
+
registry.heartbeat(r.id, { event: 'stop', tool: d.id });
|
|
102
|
+
}, 'runner answer'), { delaysMs: retryMs, label: `runner answer for ${r.id}`, log });
|
|
103
|
+
} catch (e) {
|
|
104
|
+
if (abort.signal.aborted) return;
|
|
105
|
+
const failed = String(e && e.message ? e.message : e).slice(0, 500);
|
|
106
|
+
try {
|
|
107
|
+
const fresh = registry.fact('run', r.id) || r;
|
|
108
|
+
save({ ...fresh, state: 'error', current: null, lastError: failed, lastAt: Date.now() });
|
|
109
|
+
} catch (e2) { log(`runner ${r.id}: turn failed (${failed}) and recording it failed too: ${e2.message}`); }
|
|
110
|
+
} finally { clearInterval(keep); aborts.delete(r.id); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const tick = guardTick('runner', () => {
|
|
114
|
+
for (const r of runs()) {
|
|
115
|
+
if (busy.has(r.id) || busy.size >= MAX_PARALLEL) continue;
|
|
116
|
+
const [m] = messaging.claim(r.id, { via: 'runner', limit: 1, leaseMs: LEASE_MS });
|
|
117
|
+
if (!m) continue;
|
|
118
|
+
if (isFyi(m) && m.type !== 'BRIEF') { messaging.ack(r.id, m.id, {}, registry.directory()); continue; }
|
|
119
|
+
if (m.attempts > MAX_ATTEMPTS) {
|
|
120
|
+
messaging.ack(r.id, m.id, { reply: `I could not run this after ${MAX_ATTEMPTS} tries. Last error: ${r.lastError || 'unknown'}` }, registry.directory());
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
busy.set(r.id, turn(r, m).catch((e) => log(`runner turn for ${r.id} failed: ${e.message}`)).finally(() => busy.delete(r.id)));
|
|
124
|
+
}
|
|
125
|
+
}, { now, log });
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
start,
|
|
129
|
+
/** Stop a run: kill a turn in progress (its process) and take no more messages. */
|
|
130
|
+
stop(id) {
|
|
131
|
+
const r = registry.fact('run', id);
|
|
132
|
+
if (!r) return { error: 'no such run' };
|
|
133
|
+
const a = aborts.get(id);
|
|
134
|
+
if (a) a.abort();
|
|
135
|
+
save({ ...r, state: 'stopped', current: null });
|
|
136
|
+
return { ok: true, killed: !!a };
|
|
137
|
+
},
|
|
138
|
+
list() { return registry.facts('run').map(({ brief, ...r }) => ({ ...r, briefChars: brief ? brief.length : 0 })); },
|
|
139
|
+
drivers() { return [...drivers.values()].map((d) => ({ id: d.id, label: d.label, can: d.can })); },
|
|
140
|
+
tick,
|
|
141
|
+
begin() { if (!timer) { timer = setInterval(tick, TICK_MS); timer.unref(); } },
|
|
142
|
+
end() { if (timer) clearInterval(timer); timer = null; },
|
|
143
|
+
idle: () => Promise.all([...busy.values()]),
|
|
144
|
+
};
|
|
145
|
+
}
|
package/src/lib/BaseCommand.js
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
1
|
import { Command } from '@oclif/core';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* BaseCommand — the pulse plugin's half of the machine-readable CLI contract (docs/PLAN.md M0), matching the host
|
|
5
|
-
* CLI's BaseCommand so `agentpulse pulse <verb> --json` yields the SAME standard envelope as first-party commands:
|
|
6
|
-
*
|
|
7
|
-
* success: { tool:'agentpulse', command:'pulse status', version, ok:true, result }
|
|
8
|
-
* error: { tool:'agentpulse', command:'pulse status', version, ok:false, error:{ code, message } }
|
|
9
|
-
*
|
|
10
|
-
* Behavior note (deliberate, back-compat): pulse output is machine-oriented (work-lists with prompts + JSON
|
|
11
|
-
* schemas an agent consumes), so the DEFAULT (no --json) still prints the raw engine result as pretty JSON —
|
|
12
|
-
* exactly as before. `--json` layers the standard envelope on top. Both are JSON; --json is the stable contract.
|
|
13
|
-
*/
|
|
14
3
|
export default class BaseCommand extends Command {
|
|
15
4
|
static enableJsonFlag = true;
|
|
16
5
|
|
|
17
6
|
toSuccessJson(result) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
7
|
+
return {
|
|
8
|
+
tool: 'agentpulse',
|
|
9
|
+
command: this.commandName(),
|
|
10
|
+
version: this.config?.version,
|
|
11
|
+
ok: true,
|
|
12
|
+
result,
|
|
13
|
+
};
|
|
22
14
|
}
|
|
23
15
|
|
|
24
|
-
toErrorJson(
|
|
16
|
+
toErrorJson(error) {
|
|
25
17
|
return {
|
|
26
|
-
tool: 'agentpulse',
|
|
27
|
-
|
|
18
|
+
tool: 'agentpulse',
|
|
19
|
+
command: this.commandName(),
|
|
20
|
+
version: this.config?.version,
|
|
21
|
+
ok: false,
|
|
22
|
+
error: {
|
|
23
|
+
code: error?.code || 'HUB_ERROR',
|
|
24
|
+
message: error?.message || String(error),
|
|
25
|
+
},
|
|
28
26
|
};
|
|
29
27
|
}
|
|
30
28
|
|
|
31
|
-
commandName() {
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
commandName() {
|
|
30
|
+
return String(this.id || this.ctor?.id || '').replace(/:/g, ' ');
|
|
31
|
+
}
|
|
34
32
|
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
fail(code, message, exit = 1) {
|
|
34
|
+
process.exitCode = exit;
|
|
35
|
+
this.error(message, { code, exit });
|
|
36
|
+
}
|
|
37
37
|
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { HubError } from './errors.js';
|
|
3
|
+
import { ensureOwnerOnlyDirectory, readOwnerOnlyJson, writeOwnerOnlyJson } from './storage.js';
|
|
4
|
+
|
|
5
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
6
|
+
const PROVIDERS = new Set(['cursor', 'claude']);
|
|
7
|
+
const BINDING_FIELDS = [
|
|
8
|
+
'agentSessionId',
|
|
9
|
+
'agentId',
|
|
10
|
+
'deliveryId',
|
|
11
|
+
'streamId',
|
|
12
|
+
'rootId',
|
|
13
|
+
'provider',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function invalid(message = 'Agent session binding is invalid') {
|
|
17
|
+
return new HubError('HUB_AGENT_SESSION_INVALID', message);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function bindingMismatch() {
|
|
21
|
+
return new HubError(
|
|
22
|
+
'HUB_AGENT_SESSION_BINDING_MISMATCH',
|
|
23
|
+
'Continuation does not exactly match its durable cloud and local session binding'
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sameFields(left, right, fields) {
|
|
28
|
+
return fields.every((field) => left?.[field] === right?.[field]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function assertBinding(value) {
|
|
32
|
+
if (
|
|
33
|
+
!value ||
|
|
34
|
+
Object.keys(value).length !== BINDING_FIELDS.length ||
|
|
35
|
+
!BINDING_FIELDS.every((field) => Object.hasOwn(value, field)) ||
|
|
36
|
+
!UUID.test(String(value.agentSessionId || '')) ||
|
|
37
|
+
!UUID.test(String(value.agentId || '')) ||
|
|
38
|
+
!UUID.test(String(value.deliveryId || '')) ||
|
|
39
|
+
!UUID.test(String(value.streamId || '')) ||
|
|
40
|
+
typeof value.rootId !== 'string' ||
|
|
41
|
+
value.rootId.length < 1 ||
|
|
42
|
+
value.rootId.length > 80 ||
|
|
43
|
+
!PROVIDERS.has(value.provider)
|
|
44
|
+
) {
|
|
45
|
+
throw invalid();
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function assertLocalSession(value) {
|
|
51
|
+
if (
|
|
52
|
+
!value ||
|
|
53
|
+
!UUID.test(String(value.agentId || '')) ||
|
|
54
|
+
!UUID.test(String(value.deliveryId || '')) ||
|
|
55
|
+
!UUID.test(String(value.streamId || '')) ||
|
|
56
|
+
typeof value.rootId !== 'string' ||
|
|
57
|
+
value.rootId.length < 1 ||
|
|
58
|
+
value.rootId.length > 80 ||
|
|
59
|
+
!PROVIDERS.has(value.provider) ||
|
|
60
|
+
typeof value.providerSessionId !== 'string' ||
|
|
61
|
+
value.providerSessionId.length < 1 ||
|
|
62
|
+
value.providerSessionId.length > 180 ||
|
|
63
|
+
typeof value.model !== 'string' ||
|
|
64
|
+
value.model.length < 1 ||
|
|
65
|
+
value.model.length > 100 ||
|
|
66
|
+
!['read', 'write'].includes(value.mode) ||
|
|
67
|
+
!['architect', 'coder', 'reviewer', 'auditor'].includes(value.role) ||
|
|
68
|
+
!['active', 'stopped'].includes(value.state) ||
|
|
69
|
+
(value.worktreeId !== null &&
|
|
70
|
+
(typeof value.worktreeId !== 'string' ||
|
|
71
|
+
value.worktreeId.length < 1 ||
|
|
72
|
+
value.worktreeId.length > 180)) ||
|
|
73
|
+
(value.mode === 'write' && typeof value.worktreeId !== 'string') ||
|
|
74
|
+
(value.processIdentity !== undefined &&
|
|
75
|
+
value.processIdentity !== null &&
|
|
76
|
+
(typeof value.processIdentity !== 'object' ||
|
|
77
|
+
typeof value.processIdentity.processRef !== 'string' ||
|
|
78
|
+
!Number.isInteger(value.processIdentity.pid) ||
|
|
79
|
+
!Number.isInteger(value.processIdentity.pgid) ||
|
|
80
|
+
typeof value.processIdentity.bootId !== 'string' ||
|
|
81
|
+
typeof value.processIdentity.startToken !== 'string'))
|
|
82
|
+
) {
|
|
83
|
+
throw invalid();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class AgentSessionStore {
|
|
88
|
+
#queue = Promise.resolve();
|
|
89
|
+
|
|
90
|
+
static async open(directory) {
|
|
91
|
+
await ensureOwnerOnlyDirectory(directory);
|
|
92
|
+
const file = path.join(directory, 'agent-sessions.json');
|
|
93
|
+
const data = await readOwnerOnlyJson(file, {
|
|
94
|
+
schemaVersion: 2,
|
|
95
|
+
pendingStarts: {},
|
|
96
|
+
sessions: {},
|
|
97
|
+
startBindings: {},
|
|
98
|
+
});
|
|
99
|
+
if (
|
|
100
|
+
data.schemaVersion !== 2 ||
|
|
101
|
+
!data.pendingStarts ||
|
|
102
|
+
typeof data.pendingStarts !== 'object' ||
|
|
103
|
+
!data.sessions ||
|
|
104
|
+
typeof data.sessions !== 'object' ||
|
|
105
|
+
!data.startBindings ||
|
|
106
|
+
typeof data.startBindings !== 'object'
|
|
107
|
+
) {
|
|
108
|
+
throw new HubError('HUB_AGENT_SESSION_STORE_INVALID', 'Agent session store is invalid');
|
|
109
|
+
}
|
|
110
|
+
if (
|
|
111
|
+
Object.keys(data.sessions).length > 1_000 ||
|
|
112
|
+
Object.keys(data.pendingStarts).length > 100 ||
|
|
113
|
+
Object.keys(data.startBindings).length > 1_000 ||
|
|
114
|
+
Buffer.byteLength(JSON.stringify(data), 'utf8') > 2 * 1024 * 1024
|
|
115
|
+
) {
|
|
116
|
+
throw new HubError('HUB_AGENT_SESSION_STORE_INVALID', 'Agent session store exceeds safety bounds');
|
|
117
|
+
}
|
|
118
|
+
for (const [id, session] of Object.entries(data.sessions)) {
|
|
119
|
+
assertBinding({
|
|
120
|
+
agentSessionId: id,
|
|
121
|
+
agentId: session.agentId,
|
|
122
|
+
deliveryId: session.deliveryId,
|
|
123
|
+
streamId: session.streamId,
|
|
124
|
+
rootId: session.rootId,
|
|
125
|
+
provider: session.provider,
|
|
126
|
+
});
|
|
127
|
+
assertLocalSession(session);
|
|
128
|
+
}
|
|
129
|
+
for (const [jobId, session] of Object.entries(data.pendingStarts)) {
|
|
130
|
+
if (!UUID.test(jobId)) throw invalid();
|
|
131
|
+
assertLocalSession(session);
|
|
132
|
+
}
|
|
133
|
+
for (const [jobId, binding] of Object.entries(data.startBindings)) {
|
|
134
|
+
if (!UUID.test(jobId)) throw invalid();
|
|
135
|
+
assertBinding(binding);
|
|
136
|
+
}
|
|
137
|
+
return new AgentSessionStore(file, data);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
constructor(file, data) {
|
|
141
|
+
this.file = file;
|
|
142
|
+
this.data = data;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
get(id) {
|
|
146
|
+
const value = Object.hasOwn(this.data.sessions, id) ? this.data.sessions[id] : null;
|
|
147
|
+
return value ? structuredClone(value) : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
list() {
|
|
151
|
+
return Object.entries(this.data.sessions).map(([id, value]) => ({
|
|
152
|
+
id,
|
|
153
|
+
...structuredClone(value),
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async #mutate(operation) {
|
|
158
|
+
const pending = this.#queue.then(async () => {
|
|
159
|
+
const draft = structuredClone(this.data);
|
|
160
|
+
const result = operation(draft);
|
|
161
|
+
if (Buffer.byteLength(JSON.stringify(draft), 'utf8') > 2 * 1024 * 1024) {
|
|
162
|
+
throw new HubError('HUB_AGENT_SESSION_STORE_FULL', 'Agent session store exceeds safety bounds');
|
|
163
|
+
}
|
|
164
|
+
await writeOwnerOnlyJson(this.file, draft);
|
|
165
|
+
this.data = draft;
|
|
166
|
+
return structuredClone(result);
|
|
167
|
+
});
|
|
168
|
+
this.#queue = pending.catch(() => {});
|
|
169
|
+
return pending;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async recordPendingStart(jobId, value) {
|
|
173
|
+
if (!UUID.test(String(jobId || ''))) throw invalid('Start job id is invalid');
|
|
174
|
+
assertLocalSession(value);
|
|
175
|
+
return this.#mutate((draft) => {
|
|
176
|
+
const existing = draft.pendingStarts[jobId];
|
|
177
|
+
if (existing && JSON.stringify(existing) !== JSON.stringify(value)) throw bindingMismatch();
|
|
178
|
+
if (!existing && Object.keys(draft.pendingStarts).length >= 100) {
|
|
179
|
+
throw new HubError('HUB_AGENT_SESSION_STORE_FULL', 'Pending start store is full');
|
|
180
|
+
}
|
|
181
|
+
draft.pendingStarts[jobId] = structuredClone(value);
|
|
182
|
+
return value;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async applyStartAcknowledgement(jobId, acknowledgement) {
|
|
187
|
+
if (
|
|
188
|
+
!UUID.test(String(jobId || '')) ||
|
|
189
|
+
acknowledgement?.job?.id !== jobId ||
|
|
190
|
+
acknowledgement.job.type !== 'agent.start' ||
|
|
191
|
+
!['succeeded', 'failed', 'cancelled'].includes(acknowledgement.job.state)
|
|
192
|
+
) {
|
|
193
|
+
throw invalid('Agent start acknowledgement is invalid');
|
|
194
|
+
}
|
|
195
|
+
return this.#mutate((draft) => {
|
|
196
|
+
const pending = draft.pendingStarts[jobId];
|
|
197
|
+
if (acknowledgement.job.state !== 'succeeded') {
|
|
198
|
+
if (acknowledgement.sessionBinding !== undefined) throw bindingMismatch();
|
|
199
|
+
delete draft.pendingStarts[jobId];
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
const binding = assertBinding(acknowledgement.sessionBinding);
|
|
203
|
+
const priorBinding = draft.startBindings[jobId];
|
|
204
|
+
if (priorBinding) {
|
|
205
|
+
if (!sameFields(priorBinding, binding, BINDING_FIELDS)) throw bindingMismatch();
|
|
206
|
+
const priorSession = draft.sessions[binding.agentSessionId];
|
|
207
|
+
if (!priorSession) throw bindingMismatch();
|
|
208
|
+
return priorSession;
|
|
209
|
+
}
|
|
210
|
+
if (!pending || !sameFields(pending, binding, BINDING_FIELDS.slice(1))) {
|
|
211
|
+
throw bindingMismatch();
|
|
212
|
+
}
|
|
213
|
+
const existing = draft.sessions[binding.agentSessionId];
|
|
214
|
+
const session = {
|
|
215
|
+
...structuredClone(pending),
|
|
216
|
+
agentId: binding.agentId,
|
|
217
|
+
deliveryId: binding.deliveryId,
|
|
218
|
+
streamId: binding.streamId,
|
|
219
|
+
rootId: binding.rootId,
|
|
220
|
+
provider: binding.provider,
|
|
221
|
+
};
|
|
222
|
+
if (existing && JSON.stringify(existing) !== JSON.stringify(session)) throw bindingMismatch();
|
|
223
|
+
if (!existing && Object.keys(draft.sessions).length >= 1_000) {
|
|
224
|
+
throw new HubError('HUB_AGENT_SESSION_STORE_FULL', 'Agent session store is full');
|
|
225
|
+
}
|
|
226
|
+
draft.sessions[binding.agentSessionId] = session;
|
|
227
|
+
draft.startBindings[jobId] = structuredClone(binding);
|
|
228
|
+
delete draft.pendingStarts[jobId];
|
|
229
|
+
return session;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async rejectStartSettlement(jobId) {
|
|
234
|
+
if (!UUID.test(String(jobId || ''))) throw invalid('Start job id is invalid');
|
|
235
|
+
return this.#mutate((draft) => {
|
|
236
|
+
if (!Object.hasOwn(draft.pendingStarts, jobId)) return null;
|
|
237
|
+
delete draft.pendingStarts[jobId];
|
|
238
|
+
return null;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
resolveContinuation(job) {
|
|
243
|
+
if (!['agent.turn', 'agent.stop'].includes(job?.type)) return null;
|
|
244
|
+
const binding = assertBinding(job.sessionBinding);
|
|
245
|
+
const session = this.get(binding.agentSessionId);
|
|
246
|
+
const expectedCapability = `agent.${binding.provider}.invoke`;
|
|
247
|
+
if (
|
|
248
|
+
!session ||
|
|
249
|
+
job.arguments?.agentSessionId !== binding.agentSessionId ||
|
|
250
|
+
job.arguments?.rootId !== binding.rootId ||
|
|
251
|
+
job.delivery_id !== binding.deliveryId ||
|
|
252
|
+
job.stream_id !== binding.streamId ||
|
|
253
|
+
job.root_id !== binding.rootId ||
|
|
254
|
+
job.capability !== expectedCapability ||
|
|
255
|
+
(session.state !== 'active' && !(job.type === 'agent.stop' && session.state === 'stopped')) ||
|
|
256
|
+
!sameFields(session, binding, BINDING_FIELDS.slice(1))
|
|
257
|
+
) {
|
|
258
|
+
throw bindingMismatch();
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
provider: binding.provider,
|
|
262
|
+
cloudSessionId: binding.agentSessionId,
|
|
263
|
+
session,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async put(id, value) {
|
|
268
|
+
if (!UUID.test(String(id || ''))) throw invalid();
|
|
269
|
+
assertLocalSession(value);
|
|
270
|
+
return this.#mutate((draft) => {
|
|
271
|
+
const existing = draft.sessions[id];
|
|
272
|
+
if (!existing || !sameFields(existing, value, BINDING_FIELDS.slice(1))) {
|
|
273
|
+
throw bindingMismatch();
|
|
274
|
+
}
|
|
275
|
+
draft.sessions[id] = structuredClone(value);
|
|
276
|
+
return value;
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|