feat(cli): claudemesh daemon — peer mesh runtime (v0.9.0)

Long-lived process that holds a persistent WS to the broker and exposes
a local IPC surface (UDS + bearer-auth TCP loopback). Implements the
v0.9.0 spec under .artifacts/specs/.

Core:
- daemon up | status | version | down | accept-host
- daemon outbox list [--failed|--pending|--inflight|--done|--aborted]
- daemon outbox requeue <id> [--new-client-id <id>]
- daemon install-service / uninstall-service (macOS launchd, Linux systemd)

IPC routes:
- /v1/version, /v1/health
- /v1/send  (POST)  — full §4.5.1 idempotency lookup table
- /v1/inbox (GET)   — paged history
- /v1/events        — SSE stream of message/peer_join/peer_leave/broker_status
- /v1/peers         — broker passthrough
- /v1/profile       — summary/status/visible/avatar/title/bio/capabilities
- /v1/outbox + /v1/outbox/requeue — operator recovery

Storage (SQLite via node:sqlite / bun:sqlite):
- outbox.db: pending/inflight/done/dead/aborted with audit columns
- inbox.db: dedupe by client_message_id, decrypts DMs via existing crypto
- BEGIN IMMEDIATE serialization for daemon-local accept races

Identity:
- host_fingerprint.json (machine-id || first-stable-mac)
- refuse-on-mismatch policy with `daemon accept-host` recovery

CLI integration:
- claudemesh send detects the daemon and routes through /v1/send when
  present, falling back to bridge socket / cold path otherwise

Tests: 15-case coverage of the §4.5.1 IPC duplicate lookup table.

Spec arc preserved at .artifacts/specs/2026-05-03-daemon-{v1..v10}.md;
v0.9.0 implementation target locked at 2026-05-03-daemon-spec-v0.9.0.md;
deferred items at 2026-05-03-daemon-spec-broker-hardening-followups.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-03 20:03:05 +01:00
parent 65e63b0b27
commit abaa4bcf87
34 changed files with 9067 additions and 0 deletions

View File

@@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { openSqlite, type SqliteDb } from "~/daemon/db/sqlite.js";
import { migrateOutbox } from "~/daemon/db/outbox.js";
import { acceptSend, type SendRequest } from "~/daemon/ipc/handlers/send.js";
// Shared base request — every test mutates a copy.
const baseReq = (over: Partial<SendRequest> = {}): SendRequest => ({
to: "alice",
message: "hello",
destination_kind: "dm",
destination_ref: "alice",
priority: "next",
client_message_id: "key-A",
...over,
});
describe("daemon acceptSend — §4.5.1 IPC duplicate lookup table", () => {
let dir: string;
let db: SqliteDb;
let now = 0;
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), "claudemesh-accept-"));
db = await openSqlite(join(dir, "outbox.db"));
migrateOutbox(db);
now = 1_730_000_000_000;
});
afterEach(() => {
try { db.close(); } catch { /* */ }
rmSync(dir, { recursive: true, force: true });
});
// No-row branch -------------------------------------------------------------
it("first send → 202 accepted_pending and persists row", () => {
const r = acceptSend(baseReq(), { db, now: () => now, newId: () => "row-1" });
expect(r).toMatchObject({ kind: "accepted_pending", status: 202, client_message_id: "key-A" });
const row = db.prepare(`SELECT id, client_message_id, status FROM outbox WHERE client_message_id = ?`)
.get<{ id: string; client_message_id: string; status: string }>("key-A");
expect(row).toMatchObject({ id: "row-1", client_message_id: "key-A", status: "pending" });
});
it("auto-mints client_message_id when caller omits", () => {
let n = 0;
const newId = () => `id-${++n}`;
const r = acceptSend(baseReq({ client_message_id: undefined }), { db, now: () => now, newId });
expect(r.kind).toBe("accepted_pending");
if (r.kind !== "accepted_pending") return;
expect(r.client_message_id).toBe("id-1"); // ulidLike returned the first id
});
// pending row ---------------------------------------------------------------
it("pending + match → 202 accepted_pending without inserting a new row", () => {
let calls = 0;
const newId = () => `row-${++calls}`;
acceptSend(baseReq(), { db, now: () => now, newId });
const r = acceptSend(baseReq(), { db, now: () => now, newId });
expect(r).toMatchObject({ kind: "accepted_pending", client_message_id: "key-A" });
const count = db.prepare(`SELECT COUNT(*) AS c FROM outbox`).get<{ c: number }>()!.c;
expect(Number(count)).toBe(1);
});
it("pending + mismatch → 409 with conflict reason and fingerprint prefix", () => {
acceptSend(baseReq(), { db, now: () => now, newId: () => "row-1" });
const r = acceptSend(baseReq({ message: "DIFFERENT BODY" }), { db, now: () => now, newId: () => "row-x" });
expect(r.kind).toBe("conflict");
if (r.kind !== "conflict") return;
expect(r.status).toBe(409);
expect(r.reason).toBe("outbox_pending_fingerprint_mismatch");
expect(r.daemon_fingerprint_prefix).toMatch(/^[0-9a-f]{16}$/);
});
it("treats a different `to` / destination_ref as a fingerprint mismatch", () => {
acceptSend(baseReq(), { db, now: () => now, newId: () => "row-1" });
const r = acceptSend(baseReq({ to: "bob", destination_ref: "bob" }), {
db, now: () => now, newId: () => "row-x",
});
expect(r.kind).toBe("conflict");
});
it("treats different priority as a fingerprint mismatch", () => {
acceptSend(baseReq(), { db, now: () => now, newId: () => "row-1" });
const r = acceptSend(baseReq({ priority: "now" }), { db, now: () => now, newId: () => "row-x" });
expect(r.kind).toBe("conflict");
});
it("ignores meta key ordering when computing fingerprint", () => {
acceptSend(baseReq({ meta: { z: 1, a: 2 } }), { db, now: () => now, newId: () => "row-1" });
const r = acceptSend(baseReq({ meta: { a: 2, z: 1 } }), { db, now: () => now, newId: () => "row-x" });
expect(r.kind).toBe("accepted_pending"); // same canonical JSON
});
// inflight ------------------------------------------------------------------
it("inflight + match → 202 accepted_inflight", () => {
acceptSend(baseReq(), { db, now: () => now, newId: () => "row-1" });
db.prepare(`UPDATE outbox SET status = 'inflight' WHERE client_message_id = ?`).run("key-A");
const r = acceptSend(baseReq(), { db, now: () => now, newId: () => "row-x" });
expect(r.kind).toBe("accepted_inflight");
});
it("inflight + mismatch → 409 outbox_inflight_fingerprint_mismatch", () => {
acceptSend(baseReq(), { db, now: () => now, newId: () => "row-1" });
db.prepare(`UPDATE outbox SET status = 'inflight' WHERE client_message_id = ?`).run("key-A");
const r = acceptSend(baseReq({ message: "X" }), { db, now: () => now, newId: () => "row-x" });
expect(r).toMatchObject({ kind: "conflict", reason: "outbox_inflight_fingerprint_mismatch" });
});
// done ----------------------------------------------------------------------
it("done + match → 200 with broker_message_id", () => {
acceptSend(baseReq(), { db, now: () => now, newId: () => "row-1" });
db.prepare(`UPDATE outbox SET status = 'done', broker_message_id = ? WHERE client_message_id = ?`)
.run("bm-1", "key-A");
const r = acceptSend(baseReq(), { db, now: () => now, newId: () => "row-x" });
expect(r).toMatchObject({ kind: "accepted_done", status: 200, broker_message_id: "bm-1" });
});
it("done + mismatch → 409 outbox_done_fingerprint_mismatch with broker_message_id surfaced", () => {
acceptSend(baseReq(), { db, now: () => now, newId: () => "row-1" });
db.prepare(`UPDATE outbox SET status = 'done', broker_message_id = ? WHERE client_message_id = ?`)
.run("bm-1", "key-A");
const r = acceptSend(baseReq({ message: "X" }), { db, now: () => now, newId: () => "row-x" });
expect(r).toMatchObject({
kind: "conflict",
reason: "outbox_done_fingerprint_mismatch",
broker_message_id: "bm-1",
});
});
// dead ----------------------------------------------------------------------
it("dead + match → 409 outbox_dead_fingerprint_match (id never auto-retried)", () => {
acceptSend(baseReq(), { db, now: () => now, newId: () => "row-1" });
db.prepare(`UPDATE outbox SET status = 'dead', last_error = ? WHERE client_message_id = ?`)
.run("payload too large", "key-A");
const r = acceptSend(baseReq(), { db, now: () => now, newId: () => "row-x" });
expect(r).toMatchObject({ kind: "conflict", reason: "outbox_dead_fingerprint_match" });
});
it("dead + mismatch → 409 outbox_dead_fingerprint_mismatch", () => {
acceptSend(baseReq(), { db, now: () => now, newId: () => "row-1" });
db.prepare(`UPDATE outbox SET status = 'dead' WHERE client_message_id = ?`).run("key-A");
const r = acceptSend(baseReq({ message: "X" }), { db, now: () => now, newId: () => "row-x" });
expect(r).toMatchObject({ kind: "conflict", reason: "outbox_dead_fingerprint_mismatch" });
});
// aborted -------------------------------------------------------------------
it("aborted + match → 409 outbox_aborted_fingerprint_match (operator-retired id is permanently dead)", () => {
acceptSend(baseReq(), { db, now: () => now, newId: () => "row-1" });
db.prepare(`UPDATE outbox SET status = 'aborted', aborted_at = ?, aborted_by = ? WHERE client_message_id = ?`)
.run(now, "operator", "key-A");
const r = acceptSend(baseReq(), { db, now: () => now, newId: () => "row-x" });
expect(r).toMatchObject({ kind: "conflict", reason: "outbox_aborted_fingerprint_match" });
});
it("aborted + mismatch → 409 outbox_aborted_fingerprint_mismatch", () => {
acceptSend(baseReq(), { db, now: () => now, newId: () => "row-1" });
db.prepare(`UPDATE outbox SET status = 'aborted', aborted_at = ?, aborted_by = ? WHERE client_message_id = ?`)
.run(now, "operator", "key-A");
const r = acceptSend(baseReq({ message: "X" }), { db, now: () => now, newId: () => "row-x" });
expect(r).toMatchObject({ kind: "conflict", reason: "outbox_aborted_fingerprint_mismatch" });
});
});