/** * `report` — hand a rendered report to the daemon's durable outbox; the daemon owns delivery with bounded retries. * * Moved out of cli.ts's switch by the per-verb module split (#462); * only the case wrapper, the injected `ctx` lookups and the imports * changed from the original bodies. */ import type { CommandContext } from "./context.ts"; import { randomUUID } from "node:crypto"; import { interruptDisposition } from "../availability.ts"; import { findProject, loadConfig } from "../config.ts"; import { digestDue } from "../digest-schedule.ts"; import { dbPath, openStore } from "../store.ts"; import { digestDedupeKey } from "../reports.ts"; import { DEFAULT_REPORT_POLICY, INTERRUPT_CATEGORIES, REPORT_KINDS, SESSION_ROLE_ENV, type InterruptCategory, type ReportKind, } from "../types.ts"; async function withdrawHandoff(ctx: CommandContext): Promise { const id = ctx.argv[2]; const reason = ctx.flag("reason")?.trim(); if (id === undefined || id.startsWith("--")) { process.stderr.write("omp-conductor: report withdraw needs the report or held-notice id\n"); process.exit(2); } if (reason === undefined || reason.length === 0 || reason.startsWith("--")) { process.stderr.write("omp-conductor: report withdraw needs --reason with why the handoff is obsolete\n"); process.exit(2); } for (let i = 3; i < ctx.argv.length; i++) { const token = ctx.argv[i]; if (token === "--project" || token === "--reason") { i += 1; continue; } if (token?.startsWith("--project=") || token?.startsWith("--reason=")) continue; process.stderr.write(`omp-conductor: report withdraw: unexpected argument "${token ?? ""}"\n`); process.exit(2); } const sessionRole = process.env[SESSION_ROLE_ENV]; if (sessionRole === "worker") { process.stderr.write("omp-conductor: report withdraw is an operator/orchestrator action, not a worker action\n"); process.exit(3); } const project = findProject(loadConfig(), ctx.projectFlag); const inHerdrPane = process.env["HERDR_ENV"] !== undefined && process.env["HERDR_ENV"] !== ""; const actor = sessionRole ?? (inHerdrPane ? "orchestrator" : "operator"); const store = openStore(dbPath()); try { const result = store.withdrawHandoff( id, project.name, actor, reason, Date.now(), ); if (result.kind === "not-found") { process.stderr.write(`omp-conductor: no report or held notice ${id} for ${project.name}\n`); process.exit(1); } if (result.kind === "refused") { process.stderr.write( `omp-conductor: cannot withdraw ${result.target} ${id}: state is ${result.state}; ` + "only an unsent pending report or unassigned held notice may be withdrawn\n", ); process.exit(1); } process.stdout.write( `${result.withdrawal.target} ${id} withdrawn for ${project.name} — ${reason}\n`, ); } finally { store.close(); } } export async function reportCommand(ctx: CommandContext): Promise { if (ctx.argv[1] === "withdraw") { await withdrawHandoff(ctx); return; } const rawText = ctx.flag("text"); const body = rawText?.trim(); if (body === undefined || body.length === 0 || rawText?.startsWith("--") === true) { process.stderr.write("omp-conductor: report needs --text with the rendered report\n"); process.exit(2); } const rawKind = ctx.flag("kind") ?? "material"; // Fail closed on an unknown kind rather than defaulting to `material`: a // typo'd `--kind diggest` that silently became a material report would // bypass the daily-digest guard, which is the one thing the kind is for. if (!(REPORT_KINDS as readonly string[]).includes(rawKind)) { process.stderr.write( `omp-conductor: report --kind must be one of: ${REPORT_KINDS.join(", ")}\n`, ); process.exit(2); } const kind = rawKind as ReportKind; if (kind !== "digest" && (ctx.argv.includes("--events") || ctx.argv.includes("--notices"))) { process.stderr.write( "omp-conductor: report --events and --notices are valid only with --kind digest\n", ); process.exit(2); } // Escalation tiers are not a worker session's to claim, like every other // privileged surface (#508). The daemon stamps the session role on spawned // sessions; a value outside the closed vocabulary is refused, never assumed. // A bare CLI run (operator's shell) has the env absent and stays the // orchestrator surface, so this refuses precisely the worker who would // otherwise page the operator from inside a worktree. const sessionRole = process.env[SESSION_ROLE_ENV]; if ( sessionRole === "worker" && INTERRUPT_CATEGORIES.some((category) => category !== "material" && category === kind) ) { process.stderr.write( `omp-conductor: report: escalation tier is not a worker session's to claim; ` + `only the operator or orchestrator may hand off a ${kind} report\n`, ); process.exit(3); } const project = findProject(loadConfig(), ctx.projectFlag); const store = openStore(dbPath()); try { const at = Date.now(); // #229: the policy decides what may interrupt the phone. A kind the // policy defers is refused here rather than silently turning into a // page, or a digest going out off-schedule. const policy = project.reporting; const digestPolicy = policy?.digest ?? { cadence: "per-tick" }; if (kind === "digest") { if (digestPolicy.cadence === "none") { process.stderr.write(`omp-conductor: report: digest cadence is "none" for this project\n`); process.exit(2); } if (digestPolicy.cadence === "daily" && digestPolicy.at !== undefined) { const lastKey = store.lastDigestDedupeKey(project.name); const lastDay = lastKey === undefined ? undefined : lastKey.slice("digest:".length); if (!digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at)) { process.stderr.write( `omp-conductor: report: the daily digest is not due until ${digestPolicy.at}` + `${digestPolicy.timezone === undefined ? "" : ` ${digestPolicy.timezone}`}\n`, ); process.exit(2); } } } // Every non-digest kind is an interrupt category, and the caller's kind // IS the category: admission runs the shared interruptDisposition with // that category, so a `fleet-stopped` report under scope=escalations is // admitted while `material` stays digest-only under that same policy. if (kind !== "digest") { const category: InterruptCategory = kind; const disposition = interruptDisposition(policy, category, at); if (disposition === "digest") { // Name the flags that would have worked rather than only the digest: // the categories this policy actually pages are how a handoff like a // tier-2 install notice gets through, and there is no other spelling to // guess at (#508). const interruptable = INTERRUPT_CATEGORIES.filter( (candidate) => interruptDisposition(policy, candidate, at) === "interrupt", ); const flags = interruptable.length > 0 ? `hand it off as a category this policy pages (--kind ${interruptable.join("|")}) or fold it ` + "into the next digest (--kind digest)" : "fold it into the next digest (--kind digest)"; process.stderr.write( `omp-conductor: report: ${category} updates are digest-only under this reporting policy; ${flags}\n`, ); process.exit(2); } // One escalation handoff per event while the first has not landed. // Material reports describe one ordinary outcome each time, so they // keep their historical no-dedup behaviour. The escalation categories // refuse an identical retry only while the earlier handoff is still // owed — an outbox report awaiting delivery or a held notice not yet // digested (#453). The notifications ledger cannot decide this: its // rows are permanent, so a same-text recurrence would be refused // forever once the first occurrence was marked; a delivered handoff // means the identical text is a new event, not a double-send. if (category !== "material") { if (store.hasOpenEscalationHandoff(project.name, category, body)) { process.stderr.write( `omp-conductor: report: an identical ${category} handoff for ${project.name} is still queued undelivered ` + "— this retry was refused so the operator is not paged twice; nothing new was queued, " + "check `omp-conductor status` for what is still awaiting delivery\n", ); process.exit(2); } if (disposition === "availability") { const noticeId = randomUUID(); store.addHeldNotice({ id: noticeId, project: project.name, category, summary: body.split("\n", 1)[0]!.slice(0, 240), detail: body, createdAt: at, releaseOnAvailable: true, }); process.stdout.write( `held notice ${noticeId} queued for ${project.name} (${category}; quiet hours)\n` + "the daemon will include it in the next digest or working-hours catch-up\n", ); return; } const { report } = store.enqueueReport({ project: project.name, kind, body, at }); process.stdout.write( `report ${report.id} queued for ${project.name} (${kind})\n` + "the daemon owns delivery from here; omp-conductor status shows it until it lands\n", ); return; } if (disposition === "availability") { const noticeId = randomUUID(); store.addHeldNotice({ id: noticeId, project: project.name, category: "material", summary: body.split("\n", 1)[0]!.slice(0, 240), detail: body, createdAt: at, releaseOnAvailable: true, }); process.stdout.write( `held notice ${noticeId} queued for ${project.name} (material; quiet hours)\n` + "the daemon will include it in the next digest or working-hours catch-up\n", ); return; } } const draft = { project: project.name, kind, body, // Only a daily digest is at-most-once. Per-tick digests and material // reports describe new outcomes each time, so they carry no daily key. ...(kind === "digest" && digestPolicy.cadence === "daily" ? { dedupeKey: digestDedupeKey(at, digestPolicy.timezone) } : {}), at, }; const { report, deduped } = kind === "digest" ? store.enqueueDigestReport( draft, ctx.digestIdsFlag("events"), ctx.digestIdsFlag("notices"), ) : store.enqueueReport(draft); process.stdout.write( deduped ? `today's digest was already handed over as report ${report.id} (${report.state}) — nothing queued\n` + "the ledger decides this, not your memory of the last tick; newly named rows remain owed for a later digest\n" : `report ${report.id} queued for ${project.name} (${kind})\n` + "the daemon owns delivery from here; omp-conductor status shows it until it lands\n", ); } finally { store.close(); } }