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:
150
apps/cli/src/daemon/ipc/handlers/send.ts
Normal file
150
apps/cli/src/daemon/ipc/handlers/send.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user