/** * `watch` — set a condition for the orchestrator itself, with no human in the * loop (#459). * * The split exists because the old `decision open --resolves-when` kept * producing rows rendered as questions put to the operator — the fleet looked * like it was waiting on a person when it was waiting on GitHub. A watch is a * row the orchestrator opened for itself: either a condition the daemon checks * (`--resolves-when`) that wakes the next tick when met, or a carry note the * next tick should read, neither of which ever needs an operator answer. It is * distinguished durably by its `kind`, never by whether it carries a * condition — a real question may carry one too. * * Watches are decision rows with `kind === "watch"`, so `watch withdraw` * delegates to the same store resolution `decision withdraw` uses rather than * inventing a second mechanism: both verbs write the same durable terminal * state with a recorded reason, and a watch id remains acceptable to `decision * withdraw` for anyone who learned that vocabulary first (#664). */ import type { CommandContext } from "./context.ts"; import { findProject, loadConfig } from "../config.ts"; import { CONDITION_FORMS, parseCondition } from "../decisions.ts"; import { watchMonitoringForProject } from "../fleet.ts"; import { shellQuote } from "../shell.ts"; import { dbPath, openStore } from "../store.ts"; /** The flags each watch subcommand accepts, after the subcommand itself. * `add`'s positional note and `withdraw`'s positional id are validated by * the branches below, not through this table. */ const WATCH_FLAGS: Readonly> = { add: ["--blocks", "--note", "--project", "--resolves-when"], list: ["--json", "--project"], withdraw: ["--project", "--reason"], }; /** Flags that take no value — `list --json` must not consume the token after it. */ const WATCH_BOOL_FLAGS: Readonly> = { "--json": true }; /** * Rejects any token after the subcommand that its surface does not declare, * so a typo'd flag can never be silently ignored — the defect where * `--condition` created a watch with no condition at all. Both `--flag VALUE` * and `--flag=VALUE` are accepted, matching the shared `flag()` parser; a * value flag consumes the next token, a `--flag=VALUE` form or a boolean flag * never does. The same exit-2 scan `drain` and `stats` run (#462). */ function assertKnownWatchArgs(ctx: CommandContext, sub: string, from: number): void { const allowed = WATCH_FLAGS[sub] ?? []; for (let i = from; i < ctx.argv.length; i++) { const token = ctx.argv[i]; if (token === undefined) continue; const eq = token.startsWith("--") ? token.indexOf("=") : -1; const name = eq < 0 ? token : token.slice(0, eq); if (allowed.includes(name)) { if (eq < 0 && WATCH_BOOL_FLAGS[name] !== true) i += 1; // consume the flag's value token continue; } process.stderr.write( `omp-conductor: watch ${sub}: unexpected argument "${token}" (known: ${allowed.join(" ")}) — nothing was recorded\n`, ); process.exit(2); } } export async function watchCommand(ctx: CommandContext): Promise { // `--help`/`-h` never arrives here: the CLI dispatch gate answers a real // help request from the manifest before this handler runs (#863), which is // why the long-form prose lives on the manifest's `details` field rather // than in a usage string this module would print itself. const sub = ctx.argv[1]; const project = findProject(loadConfig(), ctx.projectFlag); const store = openStore(dbPath()); try { if (sub === "add") { assertKnownWatchArgs(ctx, "add", 2); const note = ctx.flag("note")?.trim(); if (note === undefined || note.length === 0 || note.startsWith("--")) { process.stderr.write("omp-conductor: watch add needs --note with what the next tick should know\n"); process.exit(2); } const condition = ctx.flag("resolves-when")?.trim(); if (condition !== undefined && parseCondition(condition) === undefined) { process.stderr.write( `omp-conductor: --resolves-when must be one of:\n${CONDITION_FORMS.map((f) => ` ${f}`).join("\n")}\n`, ); process.exit(2); } const blocks = ctx.flag("blocks")?.trim(); const watch = store.createDecision({ project: project.name, question: note, kind: "watch", ...(blocks === undefined || blocks.length === 0 ? {} : { blocks }), ...(condition === undefined ? {} : { condition }), at: Date.now(), }); const monitoring = watchMonitoringForProject(project.name); const wake = monitoring.monitored ? condition === undefined ? "read by the next tick" : "the daemon checks it and wakes the next available tick when it is met" : `durable but currently unmonitored (${monitoring.reason}); it becomes wake-capable automatically when the daemon and heartbeat are active`; // The verb that ends the row names the project it was created in: this // command is copied, and on a host with several projects the bare form is // ambiguous (#810). process.stdout.write( `watch ${watch.id} added — ${wake} (no operator answer needed); end with: omp-conductor watch withdraw ${watch.id} --project ${shellQuote(project.name)}\n`, ); return; } if (sub === "list" || sub === undefined) { assertKnownWatchArgs(ctx, "list", 2); const open = store.openDecisions(project.name).filter((d) => d.kind === "watch"); const monitoring = watchMonitoringForProject(project.name); const now = Date.now(); const watches = open.map((d) => ({ id: d.id, ageHours: Math.max(0, Math.round((now - d.askedAt) / 3_600_000)), blocks: d.blocks ?? null, condition: d.condition === undefined ? null : d.conditionMetAt === undefined ? "pending" : "met", note: d.question, })); if (ctx.argv.includes("--json")) { process.stdout.write(`${JSON.stringify({ project: project.name, monitoring, watches }, null, 2)}\n`); return; } if (watches.length === 0) { process.stdout.write("no watches\n"); return; } process.stdout.write( monitoring.monitored ? "watch monitoring: active\n" : `watch monitoring: durable, unmonitored (${monitoring.reason})\n`, ); for (const watch of watches) { process.stdout.write( `${watch.id} ${watch.ageHours}h blocks:${watch.blocks ?? "-"} ` + // The listing already resolved the project, so the cleanup command // it displays must carry it: an operator copying a command from // this output should get one that runs as written (#810). `condition:${watch.condition ?? "-"} ${watch.note} (end: omp-conductor watch withdraw ${watch.id} --project ${shellQuote(project.name)})\n`, ); } return; } if (sub === "withdraw") { assertKnownWatchArgs(ctx, "withdraw", 3); const id = ctx.argv[2]; if (id === undefined || id.startsWith("--")) { process.stderr.write("omp-conductor: watch withdraw needs the watch id\n"); process.exit(2); } const row = store.decision(id); if (row !== undefined && row.kind !== "watch") { // The same underlying row, but a question answers to a human: the // watch verb must not silently close an operator decision. process.stderr.write( `omp-conductor: watch withdraw targets watches — ${id} is an operator decision; use decision withdraw\n`, ); process.exit(2); } const reason = ctx.flag("reason")?.trim(); const ok = store.resolveDecision( id, "withdrawn", reason === undefined || reason.length === 0 ? "withdrawn" : reason, Date.now(), ); if (!ok) { // A watch that is not open is a different mistake from an id that // never existed, and the operator can only act on one of them. process.stderr.write( `omp-conductor: no open watch ${id} for ${project.name} — it was already withdrawn, or the id is wrong\n`, ); process.exit(1); } process.stdout.write(`watch ${id} withdrawn\n`); return; } process.stderr.write( `omp-conductor: unknown watch subcommand "${sub}" — expected add, list or withdraw\n`, ); process.exit(2); } finally { store.close(); } }