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,150 @@
// IPC accept handler for POST /v1/send. Implements the §4.5.1 lookup table:
// daemon-local idempotency over outbox states × fingerprint match/mismatch.
//
// Broker delivery (drain → broker WS) is a separate concern and not part of
// this handler — this only serializes the daemon-local accept.
import { randomUUID } from "node:crypto";
import {
findByClientId,
fingerprintsEqual,
insertPending,
type OutboxRow,
} from "../../db/outbox.js";
import { inImmediateTx, type SqliteDb } from "../../db/sqlite.js";
import {
computeRequestFingerprint,
fingerprintHexPrefix,
type DestKind,
type Priority,
} from "../../fingerprint.js";
export interface SendRequest {
to: string; // peer name | pubkey hex | @group | * | topic name
message: string;
priority?: Priority;
meta?: Record<string, unknown>;
reply_to_id?: string;
/** Optional caller-supplied id. Wins over Idempotency-Key header. */
client_message_id?: string;
/** Destination kind + ref must be supplied by the IPC layer after parsing `to`. */
destination_kind: DestKind;
destination_ref: string;
}
export type AcceptOutcome =
| { kind: "accepted_pending"; status: 202; client_message_id: string }
| { kind: "accepted_inflight"; status: 202; client_message_id: string }
| { kind: "accepted_done"; status: 200; client_message_id: string; broker_message_id: string | null }
| { kind: "conflict"; status: 409; reason: string; daemon_fingerprint_prefix: string; broker_message_id?: string | null };
export interface AcceptDeps {
db: SqliteDb;
/** Override for testing. */
now?: () => number;
/** Override for testing. */
newId?: () => string;
}
export const ENVELOPE_VERSION = 1;
/**
* Daemon-local idempotency: serialized via BEGIN IMMEDIATE so concurrent
* IPC requests with the same client_message_id produce one outcome.
*/
export function acceptSend(req: SendRequest, deps: AcceptDeps): AcceptOutcome {
const now = (deps.now ?? Date.now)();
const newId = deps.newId ?? randomUUID;
// Per spec, caller-supplied client_message_id wins; otherwise daemon mints one.
const clientId = req.client_message_id?.trim() || ulidLike(newId);
const body = Buffer.from(req.message, "utf8");
const fingerprint = computeRequestFingerprint({
envelope_version: ENVELOPE_VERSION,
destination_kind: req.destination_kind,
destination_ref: req.destination_ref,
reply_to_id: req.reply_to_id ?? null,
priority: req.priority ?? "next",
meta: req.meta ?? null,
body,
});
return inImmediateTx(deps.db, () => {
const existing = findByClientId(deps.db, clientId);
if (!existing) {
insertPending(deps.db, {
id: newId(),
client_message_id: clientId,
request_fingerprint: fingerprint,
payload: body,
now,
});
return { kind: "accepted_pending", status: 202, client_message_id: clientId };
}
return decideForExistingRow(existing, fingerprint);
});
}
function decideForExistingRow(row: OutboxRow, fp: Buffer): AcceptOutcome {
const match = fingerprintsEqual(fp, row.request_fingerprint);
const fpPrefix = fingerprintHexPrefix(fp);
// Spec §4.5.1 lookup table.
switch (row.status) {
case "pending":
return match
? { kind: "accepted_pending", status: 202, client_message_id: row.client_message_id }
: conflict("outbox_pending_fingerprint_mismatch", fpPrefix);
case "inflight":
return match
? { kind: "accepted_inflight", status: 202, client_message_id: row.client_message_id }
: conflict("outbox_inflight_fingerprint_mismatch", fpPrefix);
case "done":
return match
? {
kind: "accepted_done",
status: 200,
client_message_id: row.client_message_id,
broker_message_id: row.broker_message_id,
}
: conflict("outbox_done_fingerprint_mismatch", fpPrefix, row.broker_message_id);
case "dead":
return match
? conflict("outbox_dead_fingerprint_match", fpPrefix, row.broker_message_id)
: conflict("outbox_dead_fingerprint_mismatch", fpPrefix);
case "aborted":
return match
? conflict("outbox_aborted_fingerprint_match", fpPrefix)
: conflict("outbox_aborted_fingerprint_mismatch", fpPrefix);
default: {
// Exhaustiveness check.
const _: never = row.status;
throw new Error(`unknown outbox status: ${String(_)}`);
}
}
}
function conflict(reason: string, fpPrefix: string, brokerMessageId: string | null = null): AcceptOutcome {
return {
kind: "conflict",
status: 409,
reason,
daemon_fingerprint_prefix: fpPrefix,
broker_message_id: brokerMessageId,
};
}
/** Tiny ULID-ish generator: 26-char Crockford-base32 from time + random. */
function ulidLike(newId: () => string): string {
// We don't ship a full ULID lib for one fallback path; uuid is fine here.
// The wire-stable id is whatever we return; downstream just uses it as text.
return newId();
}