From 33a54008e0f8a962b2b2f7af63a6925dbf1f1e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:05:53 +0100 Subject: [PATCH] fix(cli): the short id `send` prints resolves in `message status` (bug8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `send` prints the first 8 chars of the daemon's client_message_id (a UUID; the broker id doesn't exist while the send is queued), but `message status` only searched the broker's own id → «no message id starts with». It now asks the local outbox first (new client_message_id_prefix filter on GET /v1/outbox, hyphens ignored): queued/dead rows report their local state and last_error; delivered rows are looked up on the broker by their broker id. Spec: .artifacts/specs/2026-09-26-session-identity-and-routing.md §6 Co-Authored-By: Claude Opus 5.5 --- apps/cli/src/commands/broker-actions.ts | 53 +++++++++++++++++++ apps/cli/src/daemon/db/outbox.ts | 7 +++ apps/cli/src/daemon/ipc/server.ts | 2 + .../cli/tests/unit/outbox-cmid-prefix.test.ts | 37 +++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 apps/cli/tests/unit/outbox-cmid-prefix.test.ts diff --git a/apps/cli/src/commands/broker-actions.ts b/apps/cli/src/commands/broker-actions.ts index f0c89d3..7582548 100644 --- a/apps/cli/src/commands/broker-actions.ts +++ b/apps/cli/src/commands/broker-actions.ts @@ -144,6 +144,33 @@ export async function runForget(id: string | undefined, opts: StateFlags): Promi // --- msg-status --- export async function runMsgStatus(id: string | undefined, opts: StateFlags): Promise { + // bug8 (2026-09-26): `send` prints the first 8 chars of the daemon's + // client_message_id (a UUID) — the broker id doesn't exist yet when the + // send is queued. Resolve it through the local outbox first: it maps + // client id → broker id once delivered, and knows the local state + // (queued / dead + last_error) before the broker ever saw the message. + if (id && /^[0-9a-f-]{8,36}$/i.test(id)) { + const local = await lookupOutbox(id); + if (local === "ambiguous") { + render.err(`"${id}" matches several queued messages — paste more characters.`); + return EXIT.INVALID_ARGS; + } + if (local && !local.broker_message_id) { + if (opts.json) { + console.log(JSON.stringify({ ok: true, source: "daemon_outbox", ...local }, null, 2)); + } else { + render.section(`message ${local.client_message_id.slice(0, 12)}… (local outbox)`); + render.kv([ + ["state", local.status], + ["attempts", String(local.attempts)], + ...(local.last_error ? [["last error", local.last_error] as [string, string]] : []), + ["enqueued", local.enqueued_at], + ]); + } + return EXIT.SUCCESS; + } + if (local?.broker_message_id) id = local.broker_message_id; + } // Validate input shape *before* we open a WS connection, so a typo // returns a structured error instead of "not found or timed out". const v = validateMessageId(id); @@ -322,3 +349,29 @@ export async function runTaskComplete(id: string | undefined, result: string | u render.ok(`completed ${dim(id.slice(0, 8))}`, result); return EXIT.SUCCESS; } + +interface OutboxItem { + client_message_id: string; + broker_message_id: string | null; + status: string; + attempts: number; + last_error: string | null; + enqueued_at: string; +} + +/** Look a client-id prefix up in the local daemon outbox. null = not found + * or no daemon; "ambiguous" = several rows share the prefix. */ +async function lookupOutbox(prefix: string): Promise { + try { + const { ipc } = await import("~/daemon/ipc/client.js"); + const res = await ipc<{ items?: OutboxItem[] }>({ + path: `/v1/outbox?limit=2&client_message_id_prefix=${encodeURIComponent(prefix)}`, + timeoutMs: 1_500, + }); + const items = res.status === 200 ? res.body.items ?? [] : []; + if (items.length > 1) return "ambiguous"; + return items[0] ?? null; + } catch { + return null; + } +} diff --git a/apps/cli/src/daemon/db/outbox.ts b/apps/cli/src/daemon/db/outbox.ts index 086ad4d..299540f 100644 --- a/apps/cli/src/daemon/db/outbox.ts +++ b/apps/cli/src/daemon/db/outbox.ts @@ -162,12 +162,19 @@ export function fingerprintsEqual(a: Uint8Array, b: Uint8Array): boolean { export interface ListOutboxParams { status?: OutboxStatus; limit?: number; + /** 1.38.0 (bug8): the short id `send` prints is a client_message_id + * prefix; match it with or without the UUID's hyphens. */ + clientMessageIdPrefix?: string; } export function listOutbox(db: SqliteDb, p: ListOutboxParams = {}): OutboxRow[] { const where: string[] = []; const args: unknown[] = []; if (p.status) { where.push("status = ?"); args.push(p.status); } + if (p.clientMessageIdPrefix) { + where.push("replace(client_message_id, '-', '') LIKE ?"); + args.push(`${p.clientMessageIdPrefix.replace(/-/g, "").toLowerCase()}%`); + } const sql = ` SELECT id, client_message_id, request_fingerprint, payload, enqueued_at, attempts, next_attempt_at, status, last_error, delivered_at, diff --git a/apps/cli/src/daemon/ipc/server.ts b/apps/cli/src/daemon/ipc/server.ts index 181a49f..39b5221 100644 --- a/apps/cli/src/daemon/ipc/server.ts +++ b/apps/cli/src/daemon/ipc/server.ts @@ -833,9 +833,11 @@ function makeHandler(opts: { : undefined; const limitRaw = url.searchParams.get("limit"); const limit = limitRaw ? Number.parseInt(limitRaw, 10) : undefined; + const cmidPrefix = url.searchParams.get("client_message_id_prefix") ?? undefined; const rows = listOutbox(opts.outboxDb, { status, limit: Number.isFinite(limit ?? NaN) ? limit : undefined, + ...(cmidPrefix && /^[0-9a-f-]{8,36}$/i.test(cmidPrefix) ? { clientMessageIdPrefix: cmidPrefix } : {}), }); respond(res, 200, { items: rows.map((r) => ({ diff --git a/apps/cli/tests/unit/outbox-cmid-prefix.test.ts b/apps/cli/tests/unit/outbox-cmid-prefix.test.ts new file mode 100644 index 0000000..532311c --- /dev/null +++ b/apps/cli/tests/unit/outbox-cmid-prefix.test.ts @@ -0,0 +1,37 @@ +/** bug8 (2026-09-26): the 8-char id `send` prints must resolve. */ + +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 { insertPending, listOutbox, migrateOutbox } from "~/daemon/db/outbox.js"; + +describe("listOutbox by client_message_id prefix", () => { + let dir: string; + let db: SqliteDb; + beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), "cmh-outbox-")); + db = await openSqlite(join(dir, "outbox.db")); + migrateOutbox(db); + for (const [id, cmid] of [["a", "5b8526af-1111-4222-8333-444455556666"], ["b", "5b8599aa-1111-4222-8333-444455556666"]] as const) { + insertPending(db, { + id, client_message_id: cmid, request_fingerprint: new Uint8Array(32), payload: new Uint8Array([1]), + now: Date.now(), mesh: "m", target_spec: "t", nonce: "n", ciphertext: "c", priority: "next", + }); + } + }); + afterEach(() => { try { db.close(); } catch { /* ignore */ } rmSync(dir, { recursive: true, force: true }); }); + + it("matches the 8 chars send printed", () => { + expect(listOutbox(db, { clientMessageIdPrefix: "5b8526af" }).map((r) => r.id)).toEqual(["a"]); + }); + it("ignores hyphens either way", () => { + expect(listOutbox(db, { clientMessageIdPrefix: "5b8526af1111" }).map((r) => r.id)).toEqual(["a"]); + expect(listOutbox(db, { clientMessageIdPrefix: "5b8526af-1111-4222" }).map((r) => r.id)).toEqual(["a"]); + }); + it("a shared prefix returns both (caller reports ambiguity)", () => { + expect(listOutbox(db, { clientMessageIdPrefix: "5b85" }).length).toBe(2); + }); +});