fix(cli): the short id send prints resolves in message status (bug8)

`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 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-09-26 21:05:53 +01:00
parent f66f085a41
commit 33a54008e0
4 changed files with 99 additions and 0 deletions

View File

@@ -144,6 +144,33 @@ export async function runForget(id: string | undefined, opts: StateFlags): Promi
// --- msg-status --- // --- msg-status ---
export async function runMsgStatus(id: string | undefined, opts: StateFlags): Promise<number> { export async function runMsgStatus(id: string | undefined, opts: StateFlags): Promise<number> {
// 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 // Validate input shape *before* we open a WS connection, so a typo
// returns a structured error instead of "not found or timed out". // returns a structured error instead of "not found or timed out".
const v = validateMessageId(id); 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); render.ok(`completed ${dim(id.slice(0, 8))}`, result);
return EXIT.SUCCESS; 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<OutboxItem | "ambiguous" | null> {
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;
}
}

View File

@@ -162,12 +162,19 @@ export function fingerprintsEqual(a: Uint8Array, b: Uint8Array): boolean {
export interface ListOutboxParams { export interface ListOutboxParams {
status?: OutboxStatus; status?: OutboxStatus;
limit?: number; 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[] { export function listOutbox(db: SqliteDb, p: ListOutboxParams = {}): OutboxRow[] {
const where: string[] = []; const where: string[] = [];
const args: unknown[] = []; const args: unknown[] = [];
if (p.status) { where.push("status = ?"); args.push(p.status); } 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 = ` const sql = `
SELECT id, client_message_id, request_fingerprint, payload, enqueued_at, SELECT id, client_message_id, request_fingerprint, payload, enqueued_at,
attempts, next_attempt_at, status, last_error, delivered_at, attempts, next_attempt_at, status, last_error, delivered_at,

View File

@@ -833,9 +833,11 @@ function makeHandler(opts: {
: undefined; : undefined;
const limitRaw = url.searchParams.get("limit"); const limitRaw = url.searchParams.get("limit");
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : undefined; const limit = limitRaw ? Number.parseInt(limitRaw, 10) : undefined;
const cmidPrefix = url.searchParams.get("client_message_id_prefix") ?? undefined;
const rows = listOutbox(opts.outboxDb, { const rows = listOutbox(opts.outboxDb, {
status, status,
limit: Number.isFinite(limit ?? NaN) ? limit : undefined, limit: Number.isFinite(limit ?? NaN) ? limit : undefined,
...(cmidPrefix && /^[0-9a-f-]{8,36}$/i.test(cmidPrefix) ? { clientMessageIdPrefix: cmidPrefix } : {}),
}); });
respond(res, 200, { respond(res, 200, {
items: rows.map((r) => ({ items: rows.map((r) => ({

View File

@@ -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);
});
});