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:
@@ -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<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
|
||||
// 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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
37
apps/cli/tests/unit/outbox-cmid-prefix.test.ts
Normal file
37
apps/cli/tests/unit/outbox-cmid-prefix.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user