Files
claudemesh/apps/cli/src/daemon/ipc/handlers/send.ts
Alejandro Gutiérrez abaa4bcf87 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>
2026-05-03 20:03:05 +01:00

151 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
}