diff --git a/plugins/agentbridge/hooks/hooks.json b/plugins/agentbridge/hooks/hooks.json index 04b0f9e2..fae1951b 100644 --- a/plugins/agentbridge/hooks/hooks.json +++ b/plugins/agentbridge/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "Hint-only health checks for AgentBridge startup. These hooks never orchestrate the daemon.", + "description": "Hint-only health checks for AgentBridge startup + a fail-open task_completed announcer. These hooks never orchestrate the daemon.", "hooks": { "SessionStart": [ { @@ -11,6 +11,17 @@ } ] } + ], + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/publish-completion.sh\"" + } + ] + } ] } } diff --git a/plugins/agentbridge/scripts/publish-completion.sh b/plugins/agentbridge/scripts/publish-completion.sh new file mode 100755 index 00000000..50ddff67 --- /dev/null +++ b/plugins/agentbridge/scripts/publish-completion.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# +# Stop hook: announce a `task_completed` room event (§3.3). +# +# Best-effort and FAIL-OPEN by construction — a missing CLI, a down broker, a +# non-git dir, or a cwd with no collab room must NEVER block the agent's turn. +# The actual decision (room resolution, git summary, per-commit dedup, broker +# connect timeout) lives in `abg publish --from-hook`; this wrapper only locates +# the CLI and fires it detached so the Stop hook returns immediately. + +set -uo pipefail + +# Drain (and ignore) the hook's JSON stdin — the summary comes from git, not here. +cat >/dev/null 2>&1 || true + +# Resolve the installed CLI; if neither name is on PATH, the user hasn't installed +# AgentBridge globally — nothing to do. +cli="" +if command -v abg >/dev/null 2>&1; then + cli="abg" +elif command -v agentbridge >/dev/null 2>&1; then + cli="agentbridge" +else + exit 0 +fi + +# Run in the project dir so `publish` resolves the cwd→room map for THIS repo. +workspace="${CLAUDE_PROJECT_DIR:-${PWD}}" + +# Detach so the Stop hook never waits on the broker connect timeout. nohup keeps +# the child alive past this hook process; all output is dropped (fire-and-forget). +nohup sh -c 'cd "$1" && "$2" publish --from-hook' _ "$workspace" "$cli" >/dev/null 2>&1 & + +exit 0 diff --git a/plugins/agentbridge/server/bridge-server.js b/plugins/agentbridge/server/bridge-server.js index 71b2b0a6..ff7e7a0d 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("cdb0dcc", "source"), + commit: defineString("bdfea8e", "source"), bundle: defineBundle("plugin"), contractVersion: defineNumber(1, CONTRACT_VERSION), - codeHash: defineString("3908e9b66df5", "source") + codeHash: defineString("ac631ba2670f", "source") }); function sameRuntimeContract(a, b) { if (!a || !b) diff --git a/plugins/agentbridge/server/daemon.js b/plugins/agentbridge/server/daemon.js index 2da647bf..83f0f089 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("cdb0dcc", "source"), + commit: defineString("bdfea8e", "source"), bundle: defineBundle("plugin"), contractVersion: defineNumber(1, CONTRACT_VERSION), - codeHash: defineString("3908e9b66df5", "source") + codeHash: defineString("ac631ba2670f", "source") }); function daemonStatusBuildInfo() { return { ...BUILD_INFO }; diff --git a/scripts/install-safety.cjs b/scripts/install-safety.cjs index e89ebb12..23cf27cc 100644 --- a/scripts/install-safety.cjs +++ b/scripts/install-safety.cjs @@ -24,6 +24,7 @@ const REQUIRED_ARTIFACTS = Object.freeze([ "plugins/agentbridge/hooks/hooks.json", "plugins/agentbridge/scripts/health-check.sh", "plugins/agentbridge/scripts/plugin-update-notice.mjs", + "plugins/agentbridge/scripts/publish-completion.sh", "plugins/agentbridge/server/bridge-server.js", "plugins/agentbridge/server/daemon.js", "package.json", diff --git a/scripts/smoke-pack.mjs b/scripts/smoke-pack.mjs index 3eca9882..b48023ac 100644 --- a/scripts/smoke-pack.mjs +++ b/scripts/smoke-pack.mjs @@ -114,6 +114,7 @@ function main() { "plugins/agentbridge/hooks/hooks.json", "plugins/agentbridge/scripts/health-check.sh", "plugins/agentbridge/scripts/plugin-update-notice.mjs", + "plugins/agentbridge/scripts/publish-completion.sh", "plugins/agentbridge/server/bridge-server.js", "plugins/agentbridge/server/daemon.js", "package.json", diff --git a/src/cli.ts b/src/cli.ts index 20d9f302..c6fc7f85 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -135,6 +135,11 @@ async function main(command: string | undefined, restArgs: string[]) { const { runJoin } = await import("./cli/room"); await runJoin(restArgs); break; + case "publish": + case "announce": + const { runPublish } = await import("./cli/publish"); + await runPublish(restArgs); + break; case "--help": case "-h": case undefined: diff --git a/src/cli/publish.ts b/src/cli/publish.ts new file mode 100644 index 00000000..77ee240b --- /dev/null +++ b/src/cli/publish.ts @@ -0,0 +1,271 @@ +/** + * `abg publish` / `abg announce` — emit a `task_completed` room event (§3.3). + * + * Two ways in: + * - `abg publish --from-hook` (wired into the agent Stop hook): the completion is + * derived from git — last commit subject = summary, repo/branch/commit = the + * data-plane POINTERS (never file contents, §2.6). A burst of Stop hooks + * collapses to one event via a cross-process throttle keyed on + * (agentId, repo, branch). It FAILS OPEN: a down broker, a non-git dir, or a + * cwd with no room never breaks the agent's turn (always exit 0). + * - `abg announce --summary ""` (manual / MCP `announce`): an explicit + * one-liner, same envelope. + * + * The room is resolved from the cwd→room map (§2.4); no mapping ⇒ nothing to + * announce. Auth + collab Store reuse the same 0700-locked dir as `abg auth login`. + */ + +import { execFileSync } from "node:child_process"; +import { chmodSync, mkdirSync, readFileSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { BrokerClient } from "../broker-client"; +import { buildTaskCompletedEnvelope } from "../task-completed"; +import { PublishThrottle } from "../publish-throttle"; +import { RoomService } from "../room-service"; +import { SqliteStore } from "../backbone/store/sqlite-store"; +import type { Store } from "../backbone/store"; +import { StateDirResolver } from "../state-dir"; + +const DEFAULT_THROTTLE_MS = 8 * 60 * 60 * 1000; // 8h: a given commit announces ~once per session +const DEFAULT_CONNECT_TIMEOUT_MS = 3_000; + +export type PublishStatus = + | "published" + | "skipped-empty" // no summary to announce + | "skipped-no-login" // not logged in (abg auth login) + | "skipped-no-room" // cwd not mapped to a room + | "skipped-throttled" // inside the dedup window + | "skipped-offline"; // broker unreachable within the connect timeout + +export interface PublishResult { + status: PublishStatus; + roomId?: string; +} + +export interface PublishOptions { + argv?: string[]; + cwd?: string; + dbPath?: string; + brokerUrl?: string; + throttleWindowMs?: number; + connectTimeoutMs?: number; + /** Clock injection (throttle + envelope timestamp) for tests. */ + now?: () => number; + /** Store injection for tests; defaults to a 0700-locked SqliteStore at dbPath. */ + store?: Store; +} + +interface ParsedArgs { + fromHook: boolean; + summary?: string; + repo?: string; + branch?: string; + commit?: string; + contract?: string; + unblocks?: string[]; + agentType: string; +} + +function parseArgs(argv: string[]): ParsedArgs { + const out: ParsedArgs = { fromHook: false, agentType: "claude" }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]!; + const eat = (): string | undefined => (a.includes("=") ? a.slice(a.indexOf("=") + 1) : argv[++i]); + if (a === "--from-hook") out.fromHook = true; + else if (a === "--summary" || a.startsWith("--summary=")) out.summary = eat(); + else if (a === "--repo" || a.startsWith("--repo=")) out.repo = eat(); + else if (a === "--branch" || a.startsWith("--branch=")) out.branch = eat(); + else if (a === "--commit" || a.startsWith("--commit=")) out.commit = eat(); + else if (a === "--contract" || a.startsWith("--contract=")) out.contract = eat(); + else if (a === "--agent-type" || a.startsWith("--agent-type=")) out.agentType = eat() ?? out.agentType; + else if (a === "--unblocks" || a.startsWith("--unblocks=")) { + out.unblocks = (eat() ?? "") + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + } + } + return out; +} + +/** Run a git command in `cwd`, returning trimmed stdout or null (not a repo / no commits / git missing). */ +function git(args: string[], cwd: string): string | null { + try { + const out = execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }); + const trimmed = out.trim(); + return trimmed === "" ? null : trimmed; + } catch { + return null; + } +} + +/** Fill repo/branch/commit (and, in --from-hook, summary) from git — the data-plane pointers (§2.6). */ +function gitContext(cwd: string): { repo?: string; branch?: string; commit?: string; subject?: string } { + const top = git(["rev-parse", "--show-toplevel"], cwd); + return { + repo: top ? basename(top) : undefined, + branch: git(["rev-parse", "--abbrev-ref", "HEAD"], cwd) ?? undefined, + commit: git(["rev-parse", "--short", "HEAD"], cwd) ?? undefined, + subject: git(["log", "-1", "--format=%s"], cwd) ?? undefined, + }; +} + +function resolveDbPath(explicit?: string): string { + if (explicit) return explicit; + const env = process.env.AGENTBRIDGE_COLLAB_DB; + if (env && env.length > 0) return env; + return join(new StateDirResolver().dir, "collab.db"); +} + +function resolveBrokerUrl(explicit?: string): string { + if (explicit) return explicit; + const env = process.env.AGENTBRIDGE_BROKER_URL; + if (env && env.length > 0) return env; + return `ws://127.0.0.1:4700/ws`; +} + +/** Open the collab Store with the same 0700 lockdown as `abg auth login` (raw PSK tokens + PII). */ +function openStore(dbPath: string): SqliteStore { + const dir = dirname(dbPath); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + chmodSync(dir, 0o700); + return new SqliteStore(dbPath); +} + +/** Read the logged-in token from `/auth-token` and resolve it to an identity id. */ +/** Read the logged-in PSK token from `/auth-token`. No Store needed — cheap login gate. */ +function readAuthToken(dbPath: string): string | null { + try { + const token = readFileSync(join(dirname(dbPath), "auth-token"), "utf-8").trim(); + return token === "" ? null : token; + } catch { + return null; + } +} + +/** connect() reconnects forever against a down broker — race it against a timeout so the hook never hangs. */ +async function connectWithTimeout(client: BrokerClient, ms: number): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("broker connect timeout")), ms); + }); + try { + await Promise.race([client.connect(), timeout]); + return true; + } catch { + return false; + } finally { + if (timer) clearTimeout(timer); + } +} + +/** + * Build + publish a task_completed envelope. Fully in-process testable: inject a + * test broker URL, a temp dbPath/store, and a clock. Returns a PublishStatus for + * every EXPECTED outcome (skip reasons + published) rather than throwing. An + * unexpected I/O error (e.g. opening the collab Store) still propagates — the + * Stop-hook entrypoint {@link runPublish} is the fail-open boundary that turns + * any escape into exit 0 so the agent's turn never fails. + */ +export async function publishCompletion(opts: PublishOptions = {}): Promise { + const cwd = opts.cwd ?? process.cwd(); + const dbPath = resolveDbPath(opts.dbPath); + const args = parseArgs(opts.argv ?? []); + + // Gate on the login token BEFORE opening the Store. The Stop hook fires for + // EVERY plugin user (incl. v1-only / never-logged-in), so opening the Store + // here would create collab.db + chmod the shared state dir to 0700 for users + // who never opted into v3 collab. Reading the token file touches nothing. + const token = readAuthToken(dbPath); + if (!token) return { status: "skipped-no-login" }; + + const ownStore = !opts.store; + const store = opts.store ?? openStore(dbPath); + try { + const identityId = await store.resolveToken(token); + if (!identityId) return { status: "skipped-no-login" }; + + const roomId = await new RoomService(store).resolveRoomForCwd(cwd); + if (!roomId) return { status: "skipped-no-room" }; + + const ctx = gitContext(cwd); + const summary = (args.summary ?? (args.fromHook ? ctx.subject : undefined))?.trim(); + if (!summary) return { status: "skipped-empty" }; + + const repo = args.repo ?? ctx.repo; + const branch = args.branch ?? ctx.branch; + const commit = args.commit ?? ctx.commit; + + // Dedup ONLY the automatic Stop-hook path (a manual `announce` is explicit + // intent — always goes through). Keyed on the commit so each completion is + // announced ~once: re-fires for the same commit are suppressed within the + // window, while a NEW commit (new hash) is a new key and goes straight out. + // Peek now, record ONLY after a confirmed publish — a broker-offline attempt + // must not burn the window (else a transient outage suppresses the commit for + // the whole window, even after the broker recovers). + let throttle: PublishThrottle | undefined; + let throttleKey: string | undefined; + if (args.fromHook) { + throttle = new PublishThrottle({ + filePath: join(dirname(dbPath), "publish-throttle.json"), + windowMs: opts.throttleWindowMs ?? DEFAULT_THROTTLE_MS, + now: opts.now, + }); + throttleKey = `${identityId}|${repo ?? ""}|${branch ?? ""}|${commit ?? ""}`; + if (!throttle.peek(throttleKey)) return { status: "skipped-throttled", roomId }; + } + + const envelope = buildTaskCompletedEnvelope({ + roomId, + from: { agentId: identityId, agentType: args.agentType }, + summary, + repo, + branch, + commit, + contract: args.contract, + unblocks: args.unblocks, + now: opts.now, + }); + + const client = new BrokerClient({ url: resolveBrokerUrl(opts.brokerUrl), token }); + try { + if (!(await connectWithTimeout(client, opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS))) { + return { status: "skipped-offline", roomId }; + } + client.publish(roomId, envelope); // topic = roomId; broker stamps from.agentId + handles store_if_offline + // Let the frame flush before close() tears the socket down — publish() + // is fire-and-forget, so without this the close handshake can race the + // data frame and drop it. ponytail: fixed 100ms; switch to a bufferedAmount + // poll if a slow link ever needs longer. + await new Promise((r) => setTimeout(r, 100)); + // Consume the dedup window only now that the publish actually went out. + if (throttle && throttleKey) throttle.record(throttleKey); + return { status: "published", roomId }; + } finally { + client.close(); + } + } finally { + if (ownStore) await store.close(); + } +} + +const STATUS_MESSAGE: Record = { + published: "已广播完成事件", + "skipped-empty": "无摘要可广播(跳过)", + "skipped-no-login": "未登录,跳过(abg auth login 后生效)", + "skipped-no-room": "当前目录未关联协作房间,跳过(abg join / abg room create)", + "skipped-throttled": "在去重窗口内,跳过", + "skipped-offline": "broker 不可达,跳过", +}; + +/** Dispatch `abg publish` / `abg announce`. Always exits 0 — a completion notice must never fail the caller. */ +export async function runPublish(argv: string[]): Promise { + try { + const result = await publishCompletion({ argv }); + const suffix = result.roomId ? `(房间 ${result.roomId})` : ""; + console.log(`${STATUS_MESSAGE[result.status]}${result.status === "published" ? suffix : ""}`); + } catch (e) { + // Fail open: never let a publish error break the agent's turn / hook. + console.error(`[publish] 跳过:${String(e)}`); + } +} diff --git a/src/integration-test/publish.test.ts b/src/integration-test/publish.test.ts new file mode 100644 index 00000000..d3acfcaf --- /dev/null +++ b/src/integration-test/publish.test.ts @@ -0,0 +1,264 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +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 { RoomService } from "../room-service"; +import { publishCompletion, runPublish } from "../cli/publish"; + +const ROOM = "checkout"; + +async function delay(ms: number): Promise { + await new Promise((r) => setTimeout(r, ms)); +} +async function waitFor(cond: () => boolean, timeoutMs = 2000): Promise { + const start = performance.now(); + while (!cond()) { + if (performance.now() - start > timeoutMs) throw new Error("waitFor timed out"); + await delay(10); + } +} + +/** Stand up a broker + shared store, seed two identities, return a temp collab dir with alice's auth-token written. */ +async function setup(opts: { mapCwd?: boolean; writeToken?: boolean } = {}) { + const dir = mkdtempSync(join(tmpdir(), "agentbridge-publish-")); + const store = new InMemoryStore(); + const svc = new IdentityService(store); + await svc.registerIdentity("alice@x.com", "Alice"); + await svc.registerIdentity("bob@x.com", "Bob"); + const token = await svc.issueToken("alice@x.com"); + const tokenB = await svc.issueToken("bob@x.com"); + + const rooms = new RoomService(store); + await rooms.createRoom(ROOM, "Checkout", "alice@x.com"); + await rooms.join(ROOM, "alice@x.com"); + await rooms.join(ROOM, "bob@x.com"); + if (opts.mapCwd !== false) await rooms.mapCwd(dir, ROOM); + if (opts.writeToken !== false) writeFileSync(join(dir, "auth-token"), token, { mode: 0o600 }); + + const broker = new Broker({ + store, + identityProvider: new StorePskIdentityProvider(store), + host: "127.0.0.1", + port: 0, + log: () => {}, + }); + const { port } = broker.start(); + const url = `ws://127.0.0.1:${port}/ws`; + return { dir, store, token, tokenB, broker, url, dbPath: join(dir, "collab.db") }; +} + +describe("publishCompletion — task_completed end-to-end (§3.3)", () => { + let cleanup: Array<() => void> = []; + afterEach(() => { + for (const fn of cleanup) fn(); + cleanup = []; + }); + + test("a manual announce is delivered to a room subscriber, sender stamped by the broker", async () => { + const { dir, store, tokenB, broker, url, dbPath } = await setup(); + cleanup.push(() => broker.stop(), () => rmSync(dir, { recursive: true, force: true })); + + const received: any[] = []; + const sub = new BrokerClient({ url, token: tokenB }); + sub.onEvent((_topic, env) => received.push(env)); + await sub.connect(); + sub.subscribe(ROOM); + await delay(50); // let the subscribe register before we publish + + const res = await publishCompletion({ + store, + dbPath, + cwd: dir, + brokerUrl: url, + argv: ["--summary", "auth contract landed", "--repo", "app", "--unblocks", "bob@x.com,topic:checkout"], + }); + expect(res.status).toBe("published"); + expect(res.roomId).toBe(ROOM); + + await waitFor(() => received.length > 0); + sub.close(); + expect(received[0].kind).toBe("task_completed"); + expect(received[0].deliveryMode).toBe("store_if_offline"); + expect(received[0].from.agentId).toBe("alice@x.com"); // broker re-stamps the authenticated sender + expect(received[0].payload).toMatchObject({ + summary: "auth contract landed", + repo: "app", + unblocks: ["bob@x.com", "topic:checkout"], + }); + }); + + test("skips (never throws) when not logged in / cwd has no room / summary is empty", async () => { + const noToken = await setup({ writeToken: false }); + cleanup.push(() => noToken.broker.stop(), () => rmSync(noToken.dir, { recursive: true, force: true })); + expect( + (await publishCompletion({ store: noToken.store, dbPath: noToken.dbPath, cwd: noToken.dir, brokerUrl: noToken.url, argv: ["--summary", "x"] })).status, + ).toBe("skipped-no-login"); + + const noRoom = await setup({ mapCwd: false }); + cleanup.push(() => noRoom.broker.stop(), () => rmSync(noRoom.dir, { recursive: true, force: true })); + expect( + (await publishCompletion({ store: noRoom.store, dbPath: noRoom.dbPath, cwd: noRoom.dir, brokerUrl: noRoom.url, argv: ["--summary", "x"] })).status, + ).toBe("skipped-no-room"); + + const empty = await setup(); + cleanup.push(() => empty.broker.stop(), () => rmSync(empty.dir, { recursive: true, force: true })); + // No --summary and no --from-hook (so no git subject) ⇒ nothing to announce. + expect( + (await publishCompletion({ store: empty.store, dbPath: empty.dbPath, cwd: empty.dir, brokerUrl: empty.url, argv: [] })).status, + ).toBe("skipped-empty"); + }); + + test("a broker that never accepts the connection yields skipped-offline within the timeout", async () => { + const { dir, store, broker, dbPath } = await setup(); + broker.stop(); // nothing listening on a fresh port now + cleanup.push(() => rmSync(dir, { recursive: true, force: true })); + const res = await publishCompletion({ + store, + dbPath, + cwd: dir, + brokerUrl: "ws://127.0.0.1:1/ws", // unroutable + connectTimeoutMs: 300, + argv: ["--summary", "x"], + }); + expect(res.status).toBe("skipped-offline"); + }); + + test("--from-hook dedups the same commit (2nd call throttled) but a new commit announces", async () => { + const { dir, store, tokenB, broker, url, dbPath } = await setup(); + cleanup.push(() => broker.stop(), () => rmSync(dir, { recursive: true, force: true })); + + // A real git repo in the workspace so --from-hook can derive summary/commit. + const g = (args: string[]) => + execFileSync("git", args, { + cwd: dir, + encoding: "utf-8", + env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" }, + stdio: ["ignore", "pipe", "ignore"], + }); + g(["init", "-q"]); + writeFileSync(join(dir, "a.txt"), "1"); + g(["add", "-A"]); + g(["commit", "-q", "-m", "first task done"]); + + const sub = new BrokerClient({ url, token: tokenB }); + const received: any[] = []; + sub.onEvent((_t, env) => received.push(env)); + await sub.connect(); + sub.subscribe(ROOM); + await delay(50); + + const first = await publishCompletion({ store, dbPath, cwd: dir, brokerUrl: url, argv: ["--from-hook"] }); + expect(first.status).toBe("published"); + await waitFor(() => received.length === 1); + expect(received[0].payload.summary).toBe("first task done"); + + // Same commit again → throttled (no second event). + const again = await publishCompletion({ store, dbPath, cwd: dir, brokerUrl: url, argv: ["--from-hook"] }); + expect(again.status).toBe("skipped-throttled"); + + // A new commit (new hash) is a new throttle key → announces. + writeFileSync(join(dir, "b.txt"), "2"); + g(["add", "-A"]); + g(["commit", "-q", "-m", "second task done"]); + const third = await publishCompletion({ store, dbPath, cwd: dir, brokerUrl: url, argv: ["--from-hook"] }); + expect(third.status).toBe("published"); + await waitFor(() => received.length === 2); + sub.close(); + expect(received[1].payload.summary).toBe("second task done"); + }); + + test("--from-hook: a broker-offline first attempt does NOT burn the window — a later retry still announces", async () => { + const { dir, store, tokenB, broker, url, dbPath } = await setup(); + cleanup.push(() => broker.stop(), () => rmSync(dir, { recursive: true, force: true })); + + const g = (args: string[]) => + execFileSync("git", args, { + cwd: dir, + encoding: "utf-8", + env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" }, + stdio: ["ignore", "pipe", "ignore"], + }); + g(["init", "-q"]); + writeFileSync(join(dir, "a.txt"), "1"); + g(["add", "-A"]); + g(["commit", "-q", "-m", "task done"]); + + // First Stop hook fires while the broker is unreachable → offline, slot NOT consumed. + const offline = await publishCompletion({ + store, + dbPath, + cwd: dir, + brokerUrl: "ws://127.0.0.1:1/ws", + connectTimeoutMs: 300, + argv: ["--from-hook"], + }); + expect(offline.status).toBe("skipped-offline"); + + // Broker is back; a later Stop hook for the SAME commit must still get through. + const sub = new BrokerClient({ url, token: tokenB }); + const received: any[] = []; + sub.onEvent((_t, env) => received.push(env)); + await sub.connect(); + sub.subscribe(ROOM); + await delay(50); + + const retry = await publishCompletion({ store, dbPath, cwd: dir, brokerUrl: url, argv: ["--from-hook"] }); + expect(retry.status).toBe("published"); + await waitFor(() => received.length === 1); + + // And NOW the slot is consumed — a further same-commit fire is deduped. + const again = await publishCompletion({ store, dbPath, cwd: dir, brokerUrl: url, argv: ["--from-hook"] }); + expect(again.status).toBe("skipped-throttled"); + sub.close(); + expect(received[0].payload.summary).toBe("task done"); + }); + + test("not-logged-in: leaves NO collab.db and does NOT tighten the shared state dir (v1-only inert)", async () => { + // No store injection → exercises the real openStore-avoidance path. No + // auth-token written → the user never opted into v3 collab. + const dir = mkdtempSync(join(tmpdir(), "agentbridge-v1only-")); + cleanup.push(() => rmSync(dir, { recursive: true, force: true })); + const before = statSync(dir).mode & 0o777; + const dbPath = join(dir, "collab.db"); + + const res = await publishCompletion({ dbPath, cwd: dir, argv: ["--from-hook"], connectTimeoutMs: 200 }); + + expect(res.status).toBe("skipped-no-login"); + expect(existsSync(dbPath)).toBe(false); // no collab.db created + expect(statSync(dir).mode & 0o777).toBe(before); // state dir permissions untouched (no 0700 chmod) + }); + + test("logged-in but cwd is not a git repo: --from-hook has no commit subject ⇒ skipped-empty", async () => { + const { dir, store, broker, url, dbPath } = await setup(); + broker.stop(); + cleanup.push(() => rmSync(dir, { recursive: true, force: true })); + // setup() wrote the auth-token + mapped the cwd, but never `git init`'d the dir. + const res = await publishCompletion({ store, dbPath, cwd: dir, brokerUrl: url, argv: ["--from-hook"] }); + expect(res.status).toBe("skipped-empty"); + }); + + test("runPublish is the fail-open boundary: a thrown publishCompletion never escapes (no reject)", async () => { + // Force publishCompletion to throw AFTER the login gate: a present auth-token + // gets past readAuthToken, then openStore's `new SqliteStore()` + // throws. runPublish must swallow it (console.error) and resolve, never reject. + const dir = mkdtempSync(join(tmpdir(), "agentbridge-failopen-")); + cleanup.push(() => rmSync(dir, { recursive: true, force: true })); + writeFileSync(join(dir, "auth-token"), "tok", { mode: 0o600 }); + const dbAsDir = join(dir, "collab.db"); + mkdirSync(dbAsDir); // dbPath is a directory ⇒ opening it as sqlite throws + const prev = process.env.AGENTBRIDGE_COLLAB_DB; + process.env.AGENTBRIDGE_COLLAB_DB = dbAsDir; + try { + await expect(runPublish(["--summary", "x"])).resolves.toBeUndefined(); + } finally { + if (prev === undefined) delete process.env.AGENTBRIDGE_COLLAB_DB; + else process.env.AGENTBRIDGE_COLLAB_DB = prev; + } + }); +}); diff --git a/src/publish-throttle.ts b/src/publish-throttle.ts new file mode 100644 index 00000000..1f193043 --- /dev/null +++ b/src/publish-throttle.ts @@ -0,0 +1,63 @@ +import { readFileSync } from "node:fs"; +import { atomicWriteJson } from "./atomic-json"; + +export interface PublishThrottleOptions { + filePath: string; + windowMs: number; + /** Clock injection for tests. */ + now?: () => number; +} + +/** + * Cross-process publish throttle (§3.3 noise control). + * + * `abg publish --from-hook` runs as a FRESH process per completion, so the + * last-publish time per caller-chosen key (the publish path uses + * `agentId|repo|branch|commit`) is persisted to a JSON file. A second completion + * for the same key within `windowMs` is suppressed, so + * a burst of Stop hooks collapses to one room event instead of spamming every + * member. The file is written 0600 (it sits next to the collab secrets). + * + * ponytail: best-effort noise control, not exactly-once. The load-modify-write + * is unlocked, so two truly-concurrent publishers for the same key can both pass + * peek() and double-announce — harmless (one extra soft notice, no data loss); + * add an O_EXCL/flock guard only if duplicate notices ever actually bite. + */ +export class PublishThrottle { + constructor(private readonly opts: PublishThrottleOptions) {} + + private load(): Record { + try { + const parsed = JSON.parse(readFileSync(this.opts.filePath, "utf-8")); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } + } + + /** True iff a publish for `key` is allowed now (no prior publish still inside the window). Pure read — does NOT consume the slot. */ + peek(key: string): boolean { + const now = (this.opts.now ?? Date.now)(); + const last = this.load()[key]; + return last === undefined || now - last >= this.opts.windowMs; + } + + /** Stamp `key` as published now (load-modify-write so other keys are preserved). */ + record(key: string): void { + const state = this.load(); + state[key] = (this.opts.now ?? Date.now)(); + atomicWriteJson(this.opts.filePath, state, { mode: 0o600 }); + } + + /** + * Check-and-consume a publish for `key` in one call: false if throttled, + * else stamp + true. Prefer the {@link peek}/{@link record} pair when the + * publish can fail — record only AFTER a confirmed send so a failed attempt + * doesn't burn the dedup window. + */ + allow(key: string): boolean { + if (!this.peek(key)) return false; + this.record(key); + return true; + } +} diff --git a/src/task-completed.ts b/src/task-completed.ts new file mode 100644 index 00000000..a8c025e7 --- /dev/null +++ b/src/task-completed.ts @@ -0,0 +1,48 @@ +import { randomUUID } from "node:crypto"; +import type { Envelope } from "./backbone/envelope"; + +export interface TaskCompletedPayload { + /** One-line human-readable summary (the only large field stored in the ledger, §4.1). */ + summary: string; + repo?: string; + branch?: string; + commit?: string; + /** The contract/interface this completion touches (§3.3). */ + contract?: string; + /** In-room relevance highlight: agentIds or "topic:xxx" this unblocks (§3.3). */ + unblocks?: string[]; +} + +export interface BuildTaskCompletedInput extends TaskCompletedPayload { + roomId: string; + from: { agentId: string; agentType: string; sessionId?: string; name?: string }; + /** Clock injection for tests. */ + now?: () => number; +} + +/** + * Assemble a `task_completed` Envelope (§3.3, Appendix A). Broadcast to the room + * (no `to`), `store_if_offline` so absent members catch up on reconnect. The + * `unblocks` field (agentIds / "topic:xxx") is the in-room relevance highlight, + * carried in the payload for the receiving adapter to render. + */ +export function buildTaskCompletedEnvelope(input: BuildTaskCompletedInput): Envelope { + const now = (input.now ?? Date.now)(); + const payload: TaskCompletedPayload = { summary: input.summary }; + if (input.repo) payload.repo = input.repo; + if (input.branch) payload.branch = input.branch; + if (input.commit) payload.commit = input.commit; + if (input.contract) payload.contract = input.contract; + if (input.unblocks && input.unblocks.length > 0) payload.unblocks = input.unblocks; + return { + roomId: input.roomId, + messageId: randomUUID(), + traceId: randomUUID(), + idempotencyKey: randomUUID(), + from: input.from, + kind: "task_completed", + payload, + timestamp: now, + deliveryMode: "store_if_offline", + }; +} diff --git a/src/unit-test/publish-throttle.test.ts b/src/unit-test/publish-throttle.test.ts new file mode 100644 index 00000000..9d5ec5b8 --- /dev/null +++ b/src/unit-test/publish-throttle.test.ts @@ -0,0 +1,43 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PublishThrottle } from "../publish-throttle"; + +describe("PublishThrottle — cross-process windowed throttle (§3.3)", () => { + let dir: string | undefined; + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = undefined; + }); + + test("first publish allowed; repeat within window suppressed; allowed after window", () => { + dir = mkdtempSync(join(tmpdir(), "agentbridge-throttle-")); + const file = join(dir, "throttle.json"); + let t = 1000; + const th = new PublishThrottle({ filePath: file, windowMs: 5000, now: () => t }); + expect(th.allow("ag|repo|main")).toBe(true); + expect(th.allow("ag|repo|main")).toBe(false); + t = 1000 + 4999; + expect(th.allow("ag|repo|main")).toBe(false); + t = 1000 + 5000; + expect(th.allow("ag|repo|main")).toBe(true); + }); + + test("distinct (agent, repo, branch) keys throttle independently", () => { + dir = mkdtempSync(join(tmpdir(), "agentbridge-throttle-")); + const file = join(dir, "throttle.json"); + const th = new PublishThrottle({ filePath: file, windowMs: 5000, now: () => 1000 }); + expect(th.allow("ag|repo|main")).toBe(true); + expect(th.allow("ag|repo|feature")).toBe(true); + expect(th.allow("ag2|repo|main")).toBe(true); + expect(th.allow("ag|repo|main")).toBe(false); + }); + + test("state persists across instances (a fresh `abg publish` process sees the throttle)", () => { + dir = mkdtempSync(join(tmpdir(), "agentbridge-throttle-")); + const file = join(dir, "throttle.json"); + expect(new PublishThrottle({ filePath: file, windowMs: 5000, now: () => 1000 }).allow("k")).toBe(true); + expect(new PublishThrottle({ filePath: file, windowMs: 5000, now: () => 2000 }).allow("k")).toBe(false); + }); +}); diff --git a/src/unit-test/task-completed.test.ts b/src/unit-test/task-completed.test.ts new file mode 100644 index 00000000..79924e6f --- /dev/null +++ b/src/unit-test/task-completed.test.ts @@ -0,0 +1,45 @@ +import { describe, test, expect } from "bun:test"; +import { buildTaskCompletedEnvelope } from "../task-completed"; + +describe("buildTaskCompletedEnvelope (§3.3 / Appendix A)", () => { + test("assembles a broadcast store_if_offline task_completed with a full payload", () => { + const env = buildTaskCompletedEnvelope({ + roomId: "r1", + from: { agentId: "ag-1", agentType: "claude" }, + summary: "auth contract landed", + repo: "app", + branch: "main", + commit: "abc123", + contract: "auth/v1", + unblocks: ["ag-3", "topic:checkout"], + now: () => 42, + }); + expect(env.roomId).toBe("r1"); + expect(env.kind).toBe("task_completed"); + expect(env.deliveryMode).toBe("store_if_offline"); + expect(env.to).toBeUndefined(); // broadcast, not a DM + expect(env.timestamp).toBe(42); + expect(env.from).toEqual({ agentId: "ag-1", agentType: "claude" }); + expect(env.payload).toEqual({ + summary: "auth contract landed", + repo: "app", + branch: "main", + commit: "abc123", + contract: "auth/v1", + unblocks: ["ag-3", "topic:checkout"], + }); + expect(typeof env.idempotencyKey).toBe("string"); + expect(typeof env.traceId).toBe("string"); + expect(typeof env.messageId).toBe("string"); + }); + + test("omits absent optional fields and an empty unblocks list", () => { + const env = buildTaskCompletedEnvelope({ + roomId: "r1", + from: { agentId: "ag-1", agentType: "claude" }, + summary: "done", + unblocks: [], + }); + expect(env.payload).toEqual({ summary: "done" }); + }); +});