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>
151 lines
4.9 KiB
TypeScript
151 lines
4.9 KiB
TypeScript
// 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();
|
||
}
|