diff --git a/plugins/agentbridge/server/bridge-server.js b/plugins/agentbridge/server/bridge-server.js index 86833ff..7a61776 100755 --- a/plugins/agentbridge/server/bridge-server.js +++ b/plugins/agentbridge/server/bridge-server.js @@ -14707,10 +14707,10 @@ function defineNumber(value, fallback) { } var BUILD_INFO = Object.freeze({ version: defineString("0.1.24", "0.0.0-source"), - commit: defineString("851f82a", "source"), + commit: defineString("6b31d53", "source"), bundle: defineBundle("plugin"), contractVersion: defineNumber(1, CONTRACT_VERSION), - codeHash: defineString("29db7e466211", "source") + codeHash: defineString("539a761f8766", "source") }); function sameRuntimeContract(a, b) { if (!a || !b) diff --git a/plugins/agentbridge/server/daemon.js b/plugins/agentbridge/server/daemon.js index d383adc..11a576b 100755 --- a/plugins/agentbridge/server/daemon.js +++ b/plugins/agentbridge/server/daemon.js @@ -30,10 +30,10 @@ function defineNumber(value, fallback) { } var BUILD_INFO = Object.freeze({ version: defineString("0.1.24", "0.0.0-source"), - commit: defineString("851f82a", "source"), + commit: defineString("6b31d53", "source"), bundle: defineBundle("plugin"), contractVersion: defineNumber(1, CONTRACT_VERSION), - codeHash: defineString("29db7e466211", "source") + codeHash: defineString("539a761f8766", "source") }); function daemonStatusBuildInfo() { return { ...BUILD_INFO }; diff --git a/src/agent-adapter.ts b/src/agent-adapter.ts new file mode 100644 index 0000000..ea58821 --- /dev/null +++ b/src/agent-adapter.ts @@ -0,0 +1,28 @@ +import type { Envelope } from "./backbone/envelope"; + +export interface AgentRegistration { + /** The logical agent id this session registered as (§2.1). */ + agentId: string; + /** The room(s) the session joined (cwd→room map / explicit join, §2.4). */ + roomIds: string[]; +} + +/** + * §5.2 thin adapter contract (Appendix D) — the Edge seam each supported agent + * implements; the broker side is unchanged per agent. + * + * 1. register — on session start, resolve identity + join room(s) (§2.4). + * 2. onCompletion — hook the agent's native completion event; publish a + * structured task_completed envelope into the active room. + * 3. receiveIntoSession — push an inbound envelope into the agent's live session. + * + * Adapters stay THIN: a wrapper over each agent's native hooks (publish) + a + * message channel (receive). Agents with no hooks are unsupported (§5.1). The + * real Claude / Codex / OpenCode adapters implement this in later PRs; here it is + * the frozen contract they target. + */ +export interface AgentAdapter { + register(ctx: { cwd: string; agentType: string }): Promise; + onCompletion(publish: (envelope: Envelope) => void): void; + receiveIntoSession(envelope: Envelope): Promise; +} diff --git a/src/broker-client.ts b/src/broker-client.ts new file mode 100644 index 0000000..10a7097 --- /dev/null +++ b/src/broker-client.ts @@ -0,0 +1,246 @@ +import type { Envelope } from "./backbone/envelope"; +import type { Identity } from "./backbone/identity"; + +export interface BrokerClientOptions { + url: string; + token: string; + log?: (msg: string) => void; + /** Initial reconnect backoff (default 250ms), doubled up to {@link reconnectMaxMs}. */ + reconnectBaseMs?: number; + /** Max reconnect backoff (default 10s). */ + reconnectMaxMs?: number; + /** Max queued offline envelopes before the oldest is dropped (default 1000). */ + maxOutbox?: number; + /** WebSocket factory — injectable so tests can drive reconnect without a real socket. */ + wsFactory?: (url: string) => WebSocket; +} + +type EventHandler = (topic: string, envelope: Envelope) => void; + +/** + * Edge-side client to the control-plane broker (§5 adapter transport + §8.2 + * resilience foundation). + * + * Connects over WS, authenticates by PSK, and exposes subscribe/publish/onEvent. + * Resilience (A-class, §8.2): on disconnect it auto-reconnects with exponential + * backoff, re-subscribes every topic, and flushes a local outbox of envelopes + * published while offline — so the agent session doesn't flap on a transient + * broker blip. There is exactly ONE in-flight socket at a time (openSocket tears + * the old one down first), and connect() is idempotent. + * + * Limits (by design here): the outbox is bounded (drop-oldest); flushing after a + * full broker RESTART only reaches peers that have already re-subscribed — + * crash-durable, store-backed redelivery to offline peers is PR11 (§8.2 B / §3.2 + * store_if_offline), not this in-memory layer. + */ +export class BrokerClient { + private ws: WebSocket | null = null; + private identity: Identity | null = null; + private readonly subscriptions = new Set(); + private readonly outbox: Array<{ topic: string; envelope: Envelope }> = []; + private readonly eventHandlers: EventHandler[] = []; + private closed = false; + /** Set on auth_error: a bad token must NOT trigger an infinite reconnect loop. */ + private authFailed = false; + private reconnectAttempt = 0; + private reconnectTimer: ReturnType | null = null; + private connectPromise: Promise | null = null; + private resolveConnect: ((id: Identity) => void) | null = null; + private rejectConnect: ((e: Error) => void) | null = null; + private readonly log: (msg: string) => void; + private readonly mkWs: (url: string) => WebSocket; + private readonly baseMs: number; + private readonly maxMs: number; + private readonly maxOutbox: number; + + constructor(private readonly opts: BrokerClientOptions) { + this.log = opts.log ?? (() => {}); + this.mkWs = opts.wsFactory ?? ((url) => new WebSocket(url)); + this.baseMs = opts.reconnectBaseMs ?? 250; + this.maxMs = opts.reconnectMaxMs ?? 10_000; + this.maxOutbox = opts.maxOutbox ?? 1000; + } + + get connected(): boolean { + return this.ws !== null && this.ws.readyState === WebSocket.OPEN && this.identity !== null; + } + get whoami(): Identity | null { + return this.identity; + } + get queuedCount(): number { + return this.outbox.length; + } + + /** + * Connect + authenticate. Idempotent: repeated calls return the SAME promise + * (one in-flight socket). Resolves on the first welcome (possibly after a + * transient reconnect); rejects only on auth failure or close(). A transient + * pre-welcome drop does NOT reject — the background reconnect retries and the + * eventual welcome resolves this promise. + */ + connect(): Promise { + if (this.closed) return Promise.reject(new Error("client closed")); + if (this.connectPromise) return this.connectPromise; + this.connectPromise = new Promise((resolve, reject) => { + this.resolveConnect = resolve; + this.rejectConnect = reject; + }); + this.openSocket(); + return this.connectPromise; + } + + subscribe(topic: string): void { + this.subscriptions.add(topic); + if (this.connected) this.sendRaw({ type: "subscribe", topic }); + } + + unsubscribe(topic: string): void { + this.subscriptions.delete(topic); + if (this.connected) this.sendRaw({ type: "unsubscribe", topic }); + } + + /** Publish an envelope; if offline, queue it (bounded) and flush on reconnect. */ + publish(topic: string, envelope: Envelope): void { + if (this.connected) { + this.sendRaw({ type: "publish", topic, envelope }); + return; + } + if (this.outbox.length >= this.maxOutbox) { + this.outbox.shift(); // drop oldest — bounded, logged loss beats OOM + this.log(`outbox full (${this.maxOutbox}) — dropped oldest queued message`); + } + this.outbox.push({ topic, envelope }); + } + + onEvent(handler: EventHandler): void { + this.eventHandlers.push(handler); + } + + close(): void { + this.closed = true; + this.clearReconnectTimer(); + this.teardownSocket(); + if (this.rejectConnect) { + const reject = this.rejectConnect; + this.resolveConnect = null; + this.rejectConnect = null; + reject(new Error("client closed")); + } + } + + /** Open exactly one socket, tearing down any prior socket + pending reconnect first. */ + private openSocket(): void { + this.clearReconnectTimer(); + this.teardownSocket(); + const ws = this.mkWs(this.opts.url); + this.ws = ws; + + ws.onopen = () => { + this.sendRaw({ type: "hello", token: this.opts.token }); + }; + ws.onmessage = (ev) => { + let msg: any; + try { + msg = JSON.parse((ev as MessageEvent).data as string); + } catch { + return; + } + // A buggy/hostile broker could send a non-object frame (null/number/array); + // accessing `.type` on it would throw out of this WS callback (uncaught). + if (typeof msg !== "object" || msg === null || typeof msg.type !== "string") return; + if (msg.type === "welcome") { + this.identity = msg.identity; + this.reconnectAttempt = 0; + for (const topic of this.subscriptions) this.sendRaw({ type: "subscribe", topic }); + this.flushOutbox(); + this.log(`connected as ${msg.identity.id}`); + if (this.resolveConnect) { + const resolve = this.resolveConnect; + this.resolveConnect = null; + this.rejectConnect = null; + resolve(msg.identity); + } + } else if (msg.type === "auth_error") { + this.authFailed = true; // a bad token won't get better by retrying + if (this.rejectConnect) { + const reject = this.rejectConnect; + this.resolveConnect = null; + this.rejectConnect = null; + reject(new Error("broker auth failed")); + } + } else if (msg.type === "event") { + for (const h of this.eventHandlers) { + try { + h(msg.topic, msg.envelope); + } catch (e) { + this.log(`event handler threw: ${String(e)}`); + } + } + } + }; + ws.onclose = () => { + if (this.ws !== ws) return; // a stale/torn-down socket closing — ignore + this.ws = null; + this.identity = null; + // A transient drop does NOT reject connect() — reconnect retries and the + // next welcome resolves the still-pending promise. Only auth failure / close() + // settle it. Avoids the "reject-but-secretly-reconnect" contract that induced + // racing retries (multiple sockets / leak / duplicate delivery). + if (!this.closed && !this.authFailed) this.scheduleReconnect(); + }; + ws.onerror = () => { + // onclose follows; reconnect handled there. + }; + } + + /** Detach handlers and close the current socket WITHOUT triggering a reconnect. */ + private teardownSocket(): void { + const old = this.ws; + if (!old) return; + this.ws = null; + this.identity = null; + old.onopen = null; + old.onmessage = null; + old.onclose = null; + old.onerror = null; + try { + old.close(); + } catch { + /* already closing */ + } + } + + private clearReconnectTimer(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + + private flushOutbox(): void { + if (this.outbox.length === 0) return; + const pending = this.outbox.splice(0, this.outbox.length); + for (const { topic, envelope } of pending) this.sendRaw({ type: "publish", topic, envelope }); + this.log(`flushed ${pending.length} queued message(s)`); + } + + private sendRaw(msg: unknown): void { + try { + this.ws?.send(JSON.stringify(msg)); + } catch (e) { + this.log(`send failed: ${String(e)}`); + } + } + + private scheduleReconnect(): void { + if (this.closed || this.reconnectTimer) return; + const delay = Math.min(this.maxMs, this.baseMs * 2 ** this.reconnectAttempt); + this.reconnectAttempt++; + this.log(`reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + if (this.closed) return; + this.openSocket(); + }, delay); + } +} diff --git a/src/integration-test/broker-client.test.ts b/src/integration-test/broker-client.test.ts new file mode 100644 index 0000000..dc4580d --- /dev/null +++ b/src/integration-test/broker-client.test.ts @@ -0,0 +1,80 @@ +import { describe, test, expect } from "bun:test"; +import { Broker } from "../broker"; +import { BrokerClient } from "../broker-client"; +import { InMemoryStore } from "../backbone/store/memory-store"; +import { IdentityService } from "../backbone/identity-service"; +import { StorePskIdentityProvider } from "../backbone/identity/store-psk-identity-provider"; +import { makeEnvelope } from "../unit-test/backbone-fixtures"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function startBroker() { + const store = new InMemoryStore(); + const svc = new IdentityService(store); + await svc.registerIdentity("alice@x.com", "Alice"); + const token = await svc.issueToken("alice@x.com"); + const broker = new Broker({ + store, + identityProvider: new StorePskIdentityProvider(store), + host: "127.0.0.1", + port: 0, + log: () => {}, + }); + const { port } = broker.start(); + return { broker, store, token, url: `ws://127.0.0.1:${port}/ws` }; +} + +describe("BrokerClient ↔ real Broker", () => { + test("connects, authenticates, and round-trips an event", async () => { + const { broker, store, token, url } = await startBroker(); + const c = new BrokerClient({ url, token, log: () => {} }); + try { + expect(await c.connect()).toEqual({ id: "alice@x.com", displayName: "Alice" }); + const got: string[] = []; + c.onEvent((_topic, env) => got.push(env.messageId)); + c.subscribe("room-1"); + await sleep(60); // let subscribe + ack land + c.publish("room-1", makeEnvelope({ messageId: "e1" })); + await sleep(60); + expect(got).toEqual(["e1"]); + } finally { + c.close(); + broker.stop(); + await store.close(); + } + }); + + test("fans out across clients: A publishes, B receives", async () => { + const { broker, store, token, url } = await startBroker(); + const a = new BrokerClient({ url, token, log: () => {} }); + const b = new BrokerClient({ url, token, log: () => {} }); + try { + await a.connect(); + await b.connect(); + const got: string[] = []; + b.onEvent((_topic, env) => got.push(env.messageId)); + b.subscribe("room-x"); + await sleep(60); + a.publish("room-x", makeEnvelope({ messageId: "x1" })); + await sleep(60); + expect(got).toEqual(["x1"]); + } finally { + a.close(); + b.close(); + broker.stop(); + await store.close(); + } + }); + + test("rejects a bad token", async () => { + const { broker, store, url } = await startBroker(); + const c = new BrokerClient({ url, token: "not-a-real-token", log: () => {} }); + try { + await expect(c.connect()).rejects.toThrow(); + } finally { + c.close(); + broker.stop(); + await store.close(); + } + }); +}); diff --git a/src/session-ledger.ts b/src/session-ledger.ts new file mode 100644 index 0000000..b74812a --- /dev/null +++ b/src/session-ledger.ts @@ -0,0 +1,43 @@ +import type { Store } from "./backbone/store"; + +export type SessionContinuity = "new" | "resumed"; + +export interface SessionStartResult { + continuity: SessionContinuity; + /** The prior session id for this workspace+agentType (what to resume from), or null. */ + previousSessionId: string | null; +} + +/** + * §2.5 session accounting (Edge-local — a workspace path is per-machine). + * + * Records `(workspace, agentType) → lastSessionId` and reports whether a starting + * session is a cold "new" start or a "resumed" continuation, so presence (§3.4) + * can tell the room whether the joining member needs catch-up context. + * + * AgentBridge only STORES + GIVES the sessionId; the actual context resume is the + * agent's own native command (Claude `--resume `, etc.), driven by its + * adapter — "记账在 AgentBridge,恢复在 adapter+agent" (§2.5). + */ +export class SessionLedger { + constructor(private readonly store: Store) {} + + /** Record a starting session; returns whether it continues a prior one + the id to resume. */ + async recordSessionStart( + workspacePath: string, + agentType: string, + sessionId: string, + ): Promise { + const previousSessionId = await this.store.getLastSession(workspacePath, agentType); + await this.store.setLastSession(workspacePath, agentType, sessionId); + return { + continuity: previousSessionId ? "resumed" : "new", + previousSessionId, + }; + } + + /** The last recorded session id for this workspace+agentType, or null. */ + async lastSession(workspacePath: string, agentType: string): Promise { + return this.store.getLastSession(workspacePath, agentType); + } +} diff --git a/src/unit-test/broker-client.test.ts b/src/unit-test/broker-client.test.ts new file mode 100644 index 0000000..56a3477 --- /dev/null +++ b/src/unit-test/broker-client.test.ts @@ -0,0 +1,187 @@ +import { describe, test, expect } from "bun:test"; +import { BrokerClient } from "../broker-client"; +import { makeEnvelope } from "./backbone-fixtures"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** Drivable fake WebSocket so reconnect/queue logic is testable deterministically. */ +class FakeWs { + readyState = 0; // CONNECTING + onopen: ((e: unknown) => void) | null = null; + onmessage: ((e: { data: string }) => void) | null = null; + onclose: ((e: unknown) => void) | null = null; + onerror: ((e: unknown) => void) | null = null; + sent: any[] = []; + send(s: string) { + this.sent.push(JSON.parse(s)); + } + close() { + this.readyState = 3; + this.onclose?.({}); + } + simOpen() { + this.readyState = 1; + this.onopen?.({}); + } + simMessage(m: unknown) { + this.onmessage?.({ data: JSON.stringify(m) }); + } + simDrop() { + this.readyState = 3; + this.onclose?.({}); + } +} + +function mkClient(factories: FakeWs[]) { + return new BrokerClient({ + url: "ws://x/ws", + token: "tok", + reconnectBaseMs: 5, + reconnectMaxMs: 20, + wsFactory: () => { + const w = new FakeWs(); + factories.push(w); + return w as unknown as WebSocket; + }, + }); +} + +const welcome = { type: "welcome", identity: { id: "a@x", displayName: "A" } }; + +describe("BrokerClient — auth + reconnect + offline queue", () => { + test("connects, authenticates, and subscribes", async () => { + const fs: FakeWs[] = []; + const c = mkClient(fs); + const p = c.connect(); + fs[0]!.simOpen(); + expect(fs[0]!.sent).toContainEqual({ type: "hello", token: "tok" }); + fs[0]!.simMessage(welcome); + expect(await p).toEqual({ id: "a@x", displayName: "A" }); + expect(c.connected).toBe(true); + c.subscribe("room1"); + expect(fs[0]!.sent).toContainEqual({ type: "subscribe", topic: "room1" }); + c.close(); + }); + + test("publish while offline is queued, then re-subscribed + flushed on reconnect", async () => { + const fs: FakeWs[] = []; + const c = mkClient(fs); + const p = c.connect(); + fs[0]!.simOpen(); + fs[0]!.simMessage(welcome); + await p; + c.subscribe("room1"); + fs[0]!.simDrop(); // server dropped the connection + expect(c.connected).toBe(false); + c.publish("room1", makeEnvelope({ messageId: "off1" })); // queued + expect(c.queuedCount).toBe(1); + + await sleep(30); // reconnect timer (5ms) fires → new ws + expect(fs.length).toBe(2); + fs[1]!.simOpen(); + fs[1]!.simMessage(welcome); + await sleep(5); + expect(fs[1]!.sent).toContainEqual({ type: "subscribe", topic: "room1" }); // re-subscribed + expect( + fs[1]!.sent.some((m: any) => m.type === "publish" && m.envelope.messageId === "off1"), + ).toBe(true); // flushed + expect(c.queuedCount).toBe(0); + c.close(); + }); + + test("a bad token rejects and does NOT trigger a reconnect loop", async () => { + const fs: FakeWs[] = []; + const c = mkClient(fs); + const p = c.connect(); + fs[0]!.simOpen(); + fs[0]!.simMessage({ type: "auth_error", reason: "invalid token" }); + fs[0]!.simDrop(); + await expect(p).rejects.toThrow(); + await sleep(30); + expect(fs.length).toBe(1); // no reconnect attempt on auth failure + c.close(); + }); + + test("connect() is idempotent — repeated calls share ONE socket + promise", async () => { + const fs: FakeWs[] = []; + const c = mkClient(fs); + const p1 = c.connect(); + const p2 = c.connect(); + expect(p2).toBe(p1); // same promise + expect(fs.length).toBe(1); // exactly one socket + fs[0]!.simOpen(); + fs[0]!.simMessage(welcome); + expect(await p1).toEqual({ id: "a@x", displayName: "A" }); + c.close(); + }); + + test("a transient pre-welcome drop does NOT reject; retry stays idempotent (no overlapping sockets)", async () => { + const fs: FakeWs[] = []; + const c = mkClient(fs); + const p1 = c.connect(); + fs[0]!.simOpen(); // hello sent, no welcome yet + fs[0]!.simDrop(); // transient drop BEFORE welcome → reconnect scheduled, p1 still pending + const p2 = c.connect(); // a defensive caller "retries" + expect(p2).toBe(p1); // idempotent — does not spawn another socket + await sleep(30); // reconnect fires → exactly ONE new socket + expect(fs.length).toBe(2); // initial + one reconnect, never 3+ + fs[1]!.simOpen(); + fs[1]!.simMessage(welcome); + expect(await p1).toEqual({ id: "a@x", displayName: "A" }); // same promise resolves + expect(c.connected).toBe(true); + c.close(); + }); + + test("outbox is bounded (drop-oldest) under a long disconnect", async () => { + const fs: FakeWs[] = []; + const c = new BrokerClient({ + url: "ws://x/ws", + token: "tok", + reconnectBaseMs: 100000, // don't reconnect during the test + maxOutbox: 3, + wsFactory: () => { + const w = new FakeWs(); + fs.push(w); + return w as unknown as WebSocket; + }, + }); + c.connect(); + fs[0]!.simOpen(); + fs[0]!.simMessage(welcome); + fs[0]!.simDrop(); // offline + for (let i = 0; i < 5; i++) c.publish("r", makeEnvelope({ messageId: `m${i}` })); + expect(c.queuedCount).toBe(3); // capped at maxOutbox + c.close(); + }); + + test("incoming events reach onEvent handlers", async () => { + const fs: FakeWs[] = []; + const c = mkClient(fs); + const p = c.connect(); + fs[0]!.simOpen(); + fs[0]!.simMessage(welcome); + await p; + const got: string[] = []; + c.onEvent((topic, env) => got.push(`${topic}:${env.messageId}`)); + fs[0]!.simMessage({ type: "event", topic: "room1", envelope: makeEnvelope({ messageId: "e9" }) }); + expect(got).toEqual(["room1:e9"]); + c.close(); + }); + + test("a malformed inbound frame (null / number) does not throw out of onmessage", async () => { + const fs: FakeWs[] = []; + const c = mkClient(fs); + const p = c.connect(); + fs[0]!.simOpen(); + fs[0]!.simMessage(welcome); + await p; + expect(() => fs[0]!.simMessage(null)).not.toThrow(); + expect(() => fs[0]!.simMessage(42)).not.toThrow(); + // still functional after the bad frames + const got: string[] = []; + c.onEvent((_t, e) => got.push(e.messageId)); + fs[0]!.simMessage({ type: "event", topic: "r", envelope: makeEnvelope({ messageId: "ok" }) }); + expect(got).toEqual(["ok"]); + c.close(); + }); +}); diff --git a/src/unit-test/session-ledger.test.ts b/src/unit-test/session-ledger.test.ts new file mode 100644 index 0000000..f11d6a5 --- /dev/null +++ b/src/unit-test/session-ledger.test.ts @@ -0,0 +1,34 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { SessionLedger } from "../session-ledger"; +import { InMemoryStore } from "../backbone/store/memory-store"; + +describe("SessionLedger — new vs resumed (§2.5)", () => { + let store: InMemoryStore; + let ledger: SessionLedger; + beforeEach(() => { + store = new InMemoryStore(); + ledger = new SessionLedger(store); + }); + + test("first start in a workspace is 'new' with no previous", async () => { + expect(await ledger.recordSessionStart("/repo", "claude", "sess-1")).toEqual({ + continuity: "new", + previousSessionId: null, + }); + }); + + test("second start is 'resumed' and reports the prior session id to resume from", async () => { + await ledger.recordSessionStart("/repo", "claude", "sess-1"); + expect(await ledger.recordSessionStart("/repo", "claude", "sess-2")).toEqual({ + continuity: "resumed", + previousSessionId: "sess-1", + }); + expect(await ledger.lastSession("/repo", "claude")).toBe("sess-2"); + }); + + test("distinct (workspace, agentType) keys are independent", async () => { + await ledger.recordSessionStart("/repo", "claude", "s1"); + expect((await ledger.recordSessionStart("/repo", "codex", "s2")).continuity).toBe("new"); + expect((await ledger.recordSessionStart("/other", "claude", "s3")).continuity).toBe("new"); + }); +});