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 ---
|
// --- 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
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