@staix/agent-hub 0.3.2 → 0.5.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/CHANGELOG.md +22 -0
- package/README.md +58 -3
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +148 -20
- package/src/adapters/acp.ts +15 -3
- package/src/adapters/claude-channel.ts +9 -3
- package/src/adapters/codex-appserver.ts +47 -9
- package/src/adapters/local-worker.ts +4 -0
- package/src/cli/main.ts +188 -68
- package/src/cli/recovery-package.ts +99 -0
- package/src/cli/statusline-tee.ts +11 -2
- package/src/cli/terminal-recovery.ts +580 -0
- package/src/cli/upgrade-runtime.ts +299 -0
- package/src/cli/upgrade.ts +295 -0
- package/src/hub/budget.ts +32 -1
- package/src/hub/bus.ts +79 -4
- package/src/hub/child-process.ts +55 -0
- package/src/hub/control-client.ts +50 -9
- package/src/hub/daemon.ts +326 -45
- package/src/hub/lifecycle.ts +189 -0
- package/src/hub/manager.ts +232 -0
- package/src/hub/peers.ts +2 -0
- package/src/hub/ports.ts +40 -11
- package/src/hub/project.ts +93 -0
- package/src/hub/recovery-store.ts +89 -0
- package/src/hub/registry.ts +204 -0
- package/src/hub/restart.ts +97 -0
- package/src/hub/ui.ts +29 -4
- package/src/memory/recall.ts +26 -3
- package/src/switchyard/sidecar.ts +39 -5
- package/src/ui/index.html +103 -18
package/src/hub/budget.ts
CHANGED
|
@@ -76,6 +76,9 @@ export class Budget {
|
|
|
76
76
|
/** `ahub budget resume`: the owner overrode a pause; readings over the gate are ignored for that peer until then. */
|
|
77
77
|
private readonly ignoreUntil = new Map<PeerId, number>();
|
|
78
78
|
private closed = false;
|
|
79
|
+
private recoveryHeld = false;
|
|
80
|
+
private deferred = false;
|
|
81
|
+
private readonly deferredHard = new Set<PeerId>();
|
|
79
82
|
|
|
80
83
|
constructor(
|
|
81
84
|
dbPath: string,
|
|
@@ -88,11 +91,33 @@ export class Budget {
|
|
|
88
91
|
reason TEXT NOT NULL, summary TEXT, handed_off INTEGER NOT NULL DEFAULT 0, moved TEXT NOT NULL DEFAULT '[]')`);
|
|
89
92
|
}
|
|
90
93
|
|
|
94
|
+
/** Freeze pause/resume and handoff transitions while a controlled restart is fenced. Readings continue to update. */
|
|
95
|
+
setRecoveryHold(held: boolean): void {
|
|
96
|
+
this.recoveryHeld = held;
|
|
97
|
+
if (!held && this.deferred && !this.closed) {
|
|
98
|
+
this.deferred = false;
|
|
99
|
+
this.tick();
|
|
100
|
+
for (const peer of this.readings.keys()) {
|
|
101
|
+
const hard = this.deferredHard.delete(peer);
|
|
102
|
+
void this.evaluate(peer, hard).catch((e: Error) => this.closed || this.hooks.notify(`budget: evaluating ${peer} failed: ${e.message}`));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
get recoverySettled(): boolean {
|
|
108
|
+
return this.pausing.size === 0 && this.handingOff.size === 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
91
111
|
record(peer: PeerId): PauseRecord | undefined {
|
|
92
112
|
const r = this.db.query("SELECT * FROM budget_pauses WHERE peer = ?").get(peer) as Record<string, any> | null;
|
|
93
113
|
return r ? { peer: r.peer, since: r.since, resetsAt: r.resets_at, reason: r.reason, summary: r.summary, handedOff: !!r.handed_off, moved: JSON.parse(r.moved) } : undefined;
|
|
94
114
|
}
|
|
95
115
|
|
|
116
|
+
/** Recovery integrity input: persisted pause records only, excluding live usage readings and private summaries. */
|
|
117
|
+
persistedPauseDigestRows(): { peer: PeerId; since: number; resetsAt: number; reason: string }[] {
|
|
118
|
+
return this.records().map(({ peer, since, resetsAt, reason }) => ({ peer, since, resetsAt, reason }));
|
|
119
|
+
}
|
|
120
|
+
|
|
96
121
|
private records(): PauseRecord[] {
|
|
97
122
|
return (this.db.query("SELECT peer FROM budget_pauses").all() as { peer: string }[]).map((r) => this.record(r.peer)!);
|
|
98
123
|
}
|
|
@@ -122,6 +147,7 @@ export class Budget {
|
|
|
122
147
|
const mine = this.readings.get(peer) ?? new Map<string, Reading>();
|
|
123
148
|
this.readings.set(peer, mine);
|
|
124
149
|
for (const w of windows) mine.set(w.id, { ...w, used: Math.max(0, Math.min(1, w.used)), at });
|
|
150
|
+
if (this.recoveryHeld) { this.deferred = true; if (hard) this.deferredHard.add(peer); return; }
|
|
125
151
|
this.evaluate(peer, hard).catch((e: Error) => this.closed || this.hooks.notify(`budget: evaluating ${peer} failed: ${e.message}`));
|
|
126
152
|
}
|
|
127
153
|
|
|
@@ -141,6 +167,7 @@ export class Budget {
|
|
|
141
167
|
}
|
|
142
168
|
|
|
143
169
|
private async evaluate(peer: PeerId, hard: boolean): Promise<void> {
|
|
170
|
+
if (this.recoveryHeld) { this.deferred = true; if (hard) this.deferredHard.add(peer); return; }
|
|
144
171
|
const fresh = this.fresh(peer);
|
|
145
172
|
const over = fresh.filter((r) => r.used >= this.cfg.gate);
|
|
146
173
|
const open = this.record(peer);
|
|
@@ -160,6 +187,7 @@ export class Budget {
|
|
|
160
187
|
const reason = `${worst.id} window at ${Math.round(worst.used * 100)}% (${worst.source})`;
|
|
161
188
|
// Checkpoint first, pause second: a paused peer receives nothing, and a turn in flight is never cut by the coordinator.
|
|
162
189
|
const summary = hard ? undefined : await this.hooks.requestCheckpoint(peer).catch(() => undefined);
|
|
190
|
+
if (this.recoveryHeld) { this.deferred = true; return; }
|
|
163
191
|
if (this.closed) return; // the hub is shutting down: nothing is recorded, the next run sees the reading again
|
|
164
192
|
this.db.query("INSERT OR REPLACE INTO budget_pauses (peer, since, resets_at, reason, summary) VALUES (?, ?, ?, ?, ?)").run(peer, this.now(), resetsAt, reason, summary ?? null);
|
|
165
193
|
this.hooks.pause(peer);
|
|
@@ -171,10 +199,12 @@ export class Budget {
|
|
|
171
199
|
}
|
|
172
200
|
|
|
173
201
|
private async finishHandoff(peer: PeerId, summary: string | undefined): Promise<void> {
|
|
174
|
-
if (this.closed || this.
|
|
202
|
+
if (this.closed || this.recoveryHeld) { this.deferred = true; return; }
|
|
203
|
+
if (this.handingOff.has(peer) || !this.hooks.canHandOff(peer)) return; // retried by the next tick
|
|
175
204
|
this.handingOff.add(peer);
|
|
176
205
|
try {
|
|
177
206
|
await this.handOff(peer, summary);
|
|
207
|
+
if (this.recoveryHeld) { this.deferred = true; return; }
|
|
178
208
|
} finally {
|
|
179
209
|
this.handingOff.delete(peer);
|
|
180
210
|
}
|
|
@@ -202,6 +232,7 @@ export class Budget {
|
|
|
202
232
|
|
|
203
233
|
/** Called on a timer. Resumes every peer whose window has reset. */
|
|
204
234
|
tick(): void {
|
|
235
|
+
if (this.recoveryHeld) { this.deferred = true; return; }
|
|
205
236
|
for (const r of this.records()) {
|
|
206
237
|
if (this.now() >= r.resetsAt + 60_000) {
|
|
207
238
|
if (this.hooks.attached(r.peer)) this.resume(r.peer, "the window has reset"); // else: wait, the notice needs somebody to receive it
|
package/src/hub/bus.ts
CHANGED
|
@@ -17,6 +17,17 @@ export interface BusOptions {
|
|
|
17
17
|
/** Optional: rewrite a delivery before it goes out (M6 digest condensation). Must return its input on any failure. */
|
|
18
18
|
condense?: (envs: Envelope[]) => Promise<Envelope[]>;
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
/** Serializable delivery state used by the controlled restart coordinator. Bodies stay in the private daemon file. */
|
|
22
|
+
export interface BusSnapshot {
|
|
23
|
+
schemaVersion: 1;
|
|
24
|
+
queues: Record<string, Envelope[]>;
|
|
25
|
+
prefaces: Record<string, Envelope>;
|
|
26
|
+
seen: Envelope[];
|
|
27
|
+
attempts: Record<string, number>;
|
|
28
|
+
withdrawn: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
export const DEFAULT_BUS: BusOptions = { retryMs: 1000, batchMax: 3, batchMs: 15_000, queueCap: 200 };
|
|
21
32
|
|
|
22
33
|
const SEEN_CAP = 2048;
|
|
@@ -42,6 +53,9 @@ export class Bus {
|
|
|
42
53
|
/** What each peer was last handed, next to what it stands for: an adapter that reports a failure later hands back the former. */
|
|
43
54
|
private readonly lastDelivery = new Map<PeerId, { out: Envelope[]; originals: Envelope[] }>();
|
|
44
55
|
private readonly attempts = new Map<string, number>(); // `${peer}:${envelope id}` -> failed deliveries
|
|
56
|
+
private recoveryHeld = false;
|
|
57
|
+
private steering = 0;
|
|
58
|
+
private condensing = 0;
|
|
45
59
|
|
|
46
60
|
constructor(opts: Partial<BusOptions> = {}) {
|
|
47
61
|
this.opts = { ...DEFAULT_BUS, ...opts };
|
|
@@ -66,6 +80,57 @@ export class Bus {
|
|
|
66
80
|
};
|
|
67
81
|
}
|
|
68
82
|
|
|
83
|
+
/** Stop new deliveries while a coordinator takes a stable snapshot. Existing adapter turns are left alone. */
|
|
84
|
+
setRecoveryHold(held: boolean): void {
|
|
85
|
+
this.recoveryHeld = held;
|
|
86
|
+
if (!held) for (const id of this.peers.keys()) void this.drain(id);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get isRecoveryHeld(): boolean {
|
|
90
|
+
return this.recoveryHeld;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Wait until a hold has fenced condensation, steering and queue drains already in flight. */
|
|
94
|
+
async fenceRecovery(): Promise<void> {
|
|
95
|
+
this.recoveryHeld = true;
|
|
96
|
+
while (this.draining.size || this.steering || this.condensing) await Bun.sleep(0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A bounded, JSON-safe representation of queued work and retry/dedupe state. */
|
|
100
|
+
snapshot(): BusSnapshot {
|
|
101
|
+
return {
|
|
102
|
+
schemaVersion: 1,
|
|
103
|
+
queues: Object.fromEntries([...this.queues].map(([id, queue]) => [id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) }))])),
|
|
104
|
+
prefaces: Object.fromEntries([...this.prefaces].map(([id, e]) => [id, { ...e, ...(e.to ? { to: [...e.to] } : {}) }])),
|
|
105
|
+
seen: [...this.seen.values()].map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) })),
|
|
106
|
+
attempts: Object.fromEntries(this.attempts),
|
|
107
|
+
withdrawn: [...this.withdrawn],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Restore state before peers attach. Queues remain held until the coordinator calls setRecoveryHold(false). */
|
|
112
|
+
restore(snapshot: BusSnapshot): void {
|
|
113
|
+
if (snapshot.schemaVersion !== 1) throw new Error("unsupported bus recovery snapshot");
|
|
114
|
+
this.queues.clear();
|
|
115
|
+
for (const [id, queue] of Object.entries(snapshot.queues ?? {})) this.queues.set(id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) })));
|
|
116
|
+
this.prefaces.clear();
|
|
117
|
+
for (const [id, e] of Object.entries(snapshot.prefaces ?? {})) this.prefaces.set(id, { ...e, ...(e.to ? { to: [...e.to] } : {}) });
|
|
118
|
+
this.seen.clear();
|
|
119
|
+
for (const e of snapshot.seen ?? []) this.seen.set(e.id, { ...e, ...(e.to ? { to: [...e.to] } : {}) });
|
|
120
|
+
this.attempts.clear();
|
|
121
|
+
for (const [key, n] of Object.entries(snapshot.attempts ?? {})) if (Number.isInteger(n) && n > 0) this.attempts.set(key, n);
|
|
122
|
+
this.withdrawn.clear();
|
|
123
|
+
for (const id of snapshot.withdrawn ?? []) this.withdrawn.add(id);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
queueIds(id: PeerId): string[] {
|
|
127
|
+
return (this.queues.get(id) ?? []).map((e) => e.id);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
attemptState(): Record<string, number> {
|
|
131
|
+
return Object.fromEntries(this.attempts);
|
|
132
|
+
}
|
|
133
|
+
|
|
69
134
|
tap(fn: (e: BusEvent) => void): () => void {
|
|
70
135
|
this.taps.add(fn);
|
|
71
136
|
return () => this.taps.delete(fn);
|
|
@@ -90,6 +155,13 @@ export class Bus {
|
|
|
90
155
|
|
|
91
156
|
/** Context the hub wants the peer to see once: it rides in front of the next delivery instead of costing a turn of its own. */
|
|
92
157
|
preface(id: PeerId, body: string): void {
|
|
158
|
+
const existing = this.prefaces.get(id);
|
|
159
|
+
if (existing) {
|
|
160
|
+
// Recovery and session recall may both contribute context. Keep the original id/hop so a queued
|
|
161
|
+
// preface remains deduplicated and append the new, separately generated context.
|
|
162
|
+
this.prefaces.set(id, { ...existing, body: `${existing.body}\n\n${body}` });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
93
165
|
this.prefaces.set(id, newEnvelope(HUB, body, { to: [id], kind: "presence" }));
|
|
94
166
|
}
|
|
95
167
|
|
|
@@ -107,9 +179,10 @@ export class Bus {
|
|
|
107
179
|
const targets = (env.to ?? [...this.peers.keys()]).filter((id) => id !== env.from && this.peers.has(id));
|
|
108
180
|
for (const id of targets) {
|
|
109
181
|
const peer = this.peers.get(id)!;
|
|
110
|
-
if (env.priority === "important" && peer.steer && this.stateOf(id) === "busy") {
|
|
182
|
+
if (!this.recoveryHeld && env.priority === "important" && peer.steer && this.stateOf(id) === "busy") {
|
|
111
183
|
// Not queued while the steer is in flight, or an idle transition would deliver it a second time.
|
|
112
|
-
|
|
184
|
+
this.steering++;
|
|
185
|
+
peer.steer([env]).catch(() => this.enqueue(id, env, true)).finally(() => { this.steering--; });
|
|
113
186
|
} else this.enqueue(id, env);
|
|
114
187
|
}
|
|
115
188
|
return targets;
|
|
@@ -165,7 +238,7 @@ export class Bus {
|
|
|
165
238
|
try {
|
|
166
239
|
const peer = this.peers.get(id)!;
|
|
167
240
|
const queue = this.queues.get(id)!;
|
|
168
|
-
while (queue.length && this.stateOf(id) === "idle") {
|
|
241
|
+
while (!this.recoveryHeld && queue.length && this.stateOf(id) === "idle") {
|
|
169
242
|
const wait = this.wait(queue);
|
|
170
243
|
if (wait > 0) {
|
|
171
244
|
this.arm(id, wait);
|
|
@@ -178,8 +251,10 @@ export class Bus {
|
|
|
178
251
|
// What goes out may be condensed; what comes back on failure is always the originals. An important envelope
|
|
179
252
|
// is why the queue became ready, so it never waits on a model call.
|
|
180
253
|
const mayCondense = this.opts.condense && !delivery.some((e) => e.priority === "important");
|
|
254
|
+
this.condensing += mayCondense ? 1 : 0;
|
|
181
255
|
const out = mayCondense ? await this.opts.condense!(delivery).catch(() => delivery) : delivery;
|
|
182
|
-
|
|
256
|
+
this.condensing -= mayCondense ? 1 : 0;
|
|
257
|
+
if (this.recoveryHeld || (mayCondense && this.stateOf(id) !== "idle")) {
|
|
183
258
|
// The peer got busy while the delivery was prepared. Nothing was attempted: back to the head of the queue,
|
|
184
259
|
// without counting against the envelopes.
|
|
185
260
|
if (preface) this.prefaces.set(id, preface);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Recovery authority belongs to the short-lived ahub wrapper/launcher. Native
|
|
5
|
+
* agents and their long-lived children must never inherit it, or their later
|
|
6
|
+
* lifecycle calls could bypass the machine recovery lock.
|
|
7
|
+
*/
|
|
8
|
+
export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
9
|
+
const env = { ...source };
|
|
10
|
+
delete env.AGENTHUB_RECOVERY_OPERATION;
|
|
11
|
+
return env;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Stop a child owned by this hub and confirm that it exited. A timeout is an
|
|
16
|
+
* incomplete shutdown, even after SIGKILL, because callers must not reuse a
|
|
17
|
+
* port or claim ownership while the old process may still be alive.
|
|
18
|
+
*/
|
|
19
|
+
export async function stopOwnedProcess(proc: ChildProcess, termMs = 1_000, killMs = 2_000): Promise<void> {
|
|
20
|
+
if (proc.exitCode !== null || proc.signalCode !== null || proc.pid === undefined) return;
|
|
21
|
+
|
|
22
|
+
const waitForExit = (timeoutMs: number): Promise<boolean> =>
|
|
23
|
+
new Promise((resolve) => {
|
|
24
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
25
|
+
const done = (exited: boolean) => {
|
|
26
|
+
if (timer) clearTimeout(timer);
|
|
27
|
+
proc.off("exit", onExit);
|
|
28
|
+
proc.off("error", onError);
|
|
29
|
+
resolve(exited);
|
|
30
|
+
};
|
|
31
|
+
const onExit = () => done(true);
|
|
32
|
+
const onError = () => done(true); // spawn errors have no owned process to wait for
|
|
33
|
+
proc.once("exit", onExit);
|
|
34
|
+
proc.once("error", onError);
|
|
35
|
+
timer = setTimeout(() => done(false), timeoutMs);
|
|
36
|
+
timer.unref?.();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
proc.kill("SIGTERM");
|
|
41
|
+
} catch {
|
|
42
|
+
if (proc.exitCode !== null || proc.signalCode !== null) return;
|
|
43
|
+
throw new Error(`owned child ${proc.pid} could not be terminated`);
|
|
44
|
+
}
|
|
45
|
+
if (proc.exitCode !== null || proc.signalCode !== null || (await waitForExit(termMs))) return;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
proc.kill("SIGKILL");
|
|
49
|
+
} catch {
|
|
50
|
+
if (proc.exitCode !== null || proc.signalCode !== null) return;
|
|
51
|
+
throw new Error(`owned child ${proc.pid} could not be killed`);
|
|
52
|
+
}
|
|
53
|
+
if (proc.exitCode !== null || proc.signalCode !== null || (await waitForExit(killMs))) return;
|
|
54
|
+
throw new Error(`owned child ${proc.pid} shutdown incomplete after SIGKILL`);
|
|
55
|
+
}
|
|
@@ -1,25 +1,41 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { projectContext } from "./project.ts";
|
|
3
4
|
|
|
4
5
|
export function stateDirFor(cwd: string): string {
|
|
5
|
-
return
|
|
6
|
+
return projectContext(cwd).stateDir;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap. The plugin is installed apart from the daemon, so they can drift. */
|
|
9
|
-
export const PROTOCOL =
|
|
10
|
+
export const PROTOCOL = 8; // controlled daemon restart/recovery RPC and project/instance identity
|
|
10
11
|
|
|
11
12
|
export interface Hello {
|
|
12
13
|
/** `tools`: acts for `peer` (task tools, hub_send) without being a delivery target: the MCP server Kimi and Codex run. */
|
|
13
14
|
role: "peer" | "console" | "tools";
|
|
14
15
|
peer?: string;
|
|
16
|
+
projectId?: string;
|
|
17
|
+
instanceId?: string;
|
|
18
|
+
projectRoot?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ControlDescriptor {
|
|
22
|
+
url: string;
|
|
23
|
+
token: string;
|
|
24
|
+
projectId?: string;
|
|
25
|
+
instanceId?: string;
|
|
26
|
+
cwd?: string;
|
|
27
|
+
protocol?: number;
|
|
28
|
+
pid?: number;
|
|
15
29
|
}
|
|
16
30
|
|
|
17
31
|
/** Where the daemon of this state dir listens. Re-read before every connect: port and token change per run. */
|
|
18
|
-
export function readControl(stateDir: string):
|
|
32
|
+
export function readControl(stateDir: string): ControlDescriptor | undefined {
|
|
19
33
|
try {
|
|
20
34
|
const status = JSON.parse(readFileSync(join(stateDir, "status.json"), "utf8"));
|
|
21
35
|
const token = readFileSync(join(stateDir, "control-token"), "utf8").trim();
|
|
22
|
-
|
|
36
|
+
if (!Number.isInteger(status.controlPort) || status.controlPort < 1 || status.controlPort > 65535 || !token) return undefined;
|
|
37
|
+
return { url: `ws://127.0.0.1:${status.controlPort}`, token, projectId: status.projectId,
|
|
38
|
+
instanceId: status.instanceId, cwd: status.cwd, protocol: status.protocol, pid: status.pid };
|
|
23
39
|
} catch {
|
|
24
40
|
return undefined;
|
|
25
41
|
}
|
|
@@ -35,14 +51,27 @@ export class ControlClient {
|
|
|
35
51
|
|
|
36
52
|
private constructor(private readonly ws: WebSocket) {}
|
|
37
53
|
|
|
38
|
-
static connect(stateDir: string, hello: Hello): Promise<ControlClient> {
|
|
54
|
+
static connect(stateDir: string, hello: Hello, timeoutMs = 3000): Promise<ControlClient> {
|
|
39
55
|
const control = readControl(stateDir);
|
|
40
56
|
if (!control) return Promise.reject(new Error(`no hub running for ${stateDir} (run: ahub up)`));
|
|
57
|
+
if (control.protocol !== undefined && control.protocol !== PROTOCOL) {
|
|
58
|
+
return Promise.reject(Object.assign(new Error(`wire version mismatch: hub speaks ${control.protocol}, CLI speaks ${PROTOCOL}; stop it with its matching CLI, then upgrade and restart`), { code: 4426 }));
|
|
59
|
+
}
|
|
60
|
+
if ((hello.projectId && hello.projectId !== control.projectId) ||
|
|
61
|
+
(hello.instanceId && hello.instanceId !== control.instanceId) ||
|
|
62
|
+
(hello.projectRoot && hello.projectRoot !== control.cwd)) {
|
|
63
|
+
return Promise.reject(Object.assign(new Error("hub project or instance does not match the selected project"), { code: 4404 }));
|
|
64
|
+
}
|
|
65
|
+
const expected = { projectId: hello.projectId ?? control.projectId, instanceId: hello.instanceId ?? control.instanceId,
|
|
66
|
+
projectRoot: hello.projectRoot ?? control.cwd };
|
|
41
67
|
return new Promise((resolve, reject) => {
|
|
42
68
|
const ws = new WebSocket(control.url);
|
|
43
69
|
const client = new ControlClient(ws);
|
|
44
|
-
|
|
70
|
+
const timer = setTimeout(() => refuse(new Error(`hub connection timed out at ${control.url}`)), timeoutMs);
|
|
71
|
+
const refuse = (error: Error) => { clearTimeout(timer); reject(error); ws.close(); };
|
|
72
|
+
ws.onerror = () => refuse(new Error(`cannot reach hub at ${control.url}`));
|
|
45
73
|
ws.onclose = (ev) => {
|
|
74
|
+
clearTimeout(timer);
|
|
46
75
|
// A refused hello carries its reason (bad token, wire version, peer id); say that, not a guess.
|
|
47
76
|
reject(Object.assign(new Error(`hub closed the connection: ${ev.reason || "stale token?"}`), { code: ev.code }));
|
|
48
77
|
for (const done of client.pending.values()) done({ ok: false, error: "hub connection closed" });
|
|
@@ -50,18 +79,30 @@ export class ControlClient {
|
|
|
50
79
|
client.onClose(ev.code, ev.reason);
|
|
51
80
|
};
|
|
52
81
|
ws.onmessage = (ev) => {
|
|
53
|
-
|
|
82
|
+
let msg: any;
|
|
83
|
+
try { msg = JSON.parse(String(ev.data)); } catch { return refuse(new Error("invalid hub response")); }
|
|
84
|
+
if (!msg || typeof msg !== "object" || Array.isArray(msg)) return refuse(new Error("invalid hub response"));
|
|
54
85
|
const done = msg.rid !== undefined ? client.pending.get(msg.rid) : undefined;
|
|
55
86
|
if (!done) return client.onPush(msg);
|
|
56
87
|
client.pending.delete(msg.rid);
|
|
57
88
|
done(msg);
|
|
58
89
|
};
|
|
59
|
-
ws.onopen = () => void client.request({ t: "hello", v: PROTOCOL, token: control.token, ...hello }).then(() =>
|
|
90
|
+
ws.onopen = () => void client.request({ t: "hello", v: PROTOCOL, token: control.token, ...hello, ...expected }, timeoutMs).then((reply) => {
|
|
91
|
+
if (reply.t !== "welcome" || reply.ok === false) return refuse(new Error(reply.error ?? "hub refused handshake"));
|
|
92
|
+
if ((expected.projectId && reply.projectId !== expected.projectId) ||
|
|
93
|
+
(expected.instanceId && reply.instanceId !== expected.instanceId) ||
|
|
94
|
+
(expected.projectRoot && reply.cwd !== expected.projectRoot)) {
|
|
95
|
+
return refuse(Object.assign(new Error("connected hub has a different project or instance"), { code: 4404 }));
|
|
96
|
+
}
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
resolve(client);
|
|
99
|
+
});
|
|
60
100
|
});
|
|
61
101
|
}
|
|
62
102
|
|
|
63
103
|
/** `timeoutMs`: for calls a person waits on. The hub has its own budget; this one only makes sure the CLI never hangs. */
|
|
64
|
-
request(msg: Record<string, unknown>, timeoutMs
|
|
104
|
+
request(msg: Record<string, unknown>, timeoutMs = 30_000): Promise<any> {
|
|
105
|
+
if (this.ws.readyState !== WebSocket.OPEN) return Promise.resolve({ ok: false, error: "hub connection is not open" });
|
|
65
106
|
const rid = this.nextRid++;
|
|
66
107
|
return new Promise((resolve) => {
|
|
67
108
|
const timer = timeoutMs ? setTimeout(() => (this.pending.delete(rid), resolve({ ok: false, error: `no answer from the hub within ${Math.round(timeoutMs / 1000)} s` })), timeoutMs) : undefined;
|