/** * `restart` — drain the fleet by default, then restart keeping the running daemon's port and project unless a flag overrides them. * * 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 { buildStopProvenance, liveWorkload, refuseBusyDaemon } from "../stop-provenance.ts"; import { setPaused } from "../daemon.ts"; import { restartDaemon, type RestartResult, type StopDelivery } from "../lifecycle.ts"; import { openStore, dbPath } from "../store.ts"; import { DEFAULT_DEPS, drainAndRestart, type UpgradeDeps } from "../upgrade.ts"; export async function restartCommand(ctx: CommandContext): Promise { const store = openStore(dbPath()); try { const forced = ctx.argv.includes("--force"); // Refusal (#545), the immediate path only. `restart --now` skips the drain // and replaces the running daemon at once, so it orphans any project's live // workers exactly like `stop`. The default restart is safe rather than // refused: it pauses and drains EVERY configured project before restarting // (resolveScope selects them all, #389), so it cannot orphan a sibling. // `--force` skips the gate — a wedged daemon stays restartable — and the // override is recorded in the provenance. if (ctx.argv.includes("--now") && !forced) { refuseBusyDaemon(liveWorkload(store), { override: false, verb: "restart" }); } // Provenance (#378): one record naming the request, every configured // project with its live-run count, and — via the lifecycle chokepoint — the // delivery method used. The shared daemon serves all projects, so a restart // is never silent even when the operator narrows nothing. A forced restart // keeps the override visible in the control path, never silent. const provenance = buildStopProvenance({ controlPath: forced ? "cli restart --force" : "cli restart", reason: "operator restart", scope: "global", }); const record = (signal: StopDelivery) => store.recordDaemonStop(signal.provenance); if (ctx.argv.includes("--now")) { // Inherit the running daemon's port and project: a restart that quietly // moved to the default port would leave every existing health check // pointing at nothing. When the unit owns the live pid, restartDaemon // goes through systemctl so the replacement stays supervised; a failed // installed unit is reset and started through systemd the same way and // is never replaced by an unmanaged daemon. Success is only reported // after the manager is proven to own the reported pid. const { previous, record: newRecord, via } = await restartDaemon({ port: ctx.portFlag(), project: ctx.projectFlag, provenance, record, }); if (previous !== undefined) { process.stdout.write( `stopped — pid ${previous.pid}${via === "systemctl" ? " (via systemctl)" : ""}\n`, ); } process.stdout.write( `started — pid ${newRecord.pid}, /healthz on :${newRecord.port}` + `${newRecord.project === undefined ? "" : `, project ${newRecord.project}`}` + `${via === "systemctl" ? " (via systemctl)" : ""}\nlog ${newRecord.logFile}\n`, ); return; } // Default: drain the fleet before restarting — pause new claims, wait for // live workers to reach 0/N (bounded by --timeout), restart, then restore // the prior dispatch state. A timed-out drain restarts nothing and leaves // dispatch paused, so it is safe to run while the daemon is wedged. const timeoutRaw = ctx.flag("timeout"); const timeoutSeconds = timeoutRaw === undefined ? 1800 : Number.parseInt(timeoutRaw, 10); const timeoutMs = (Number.isNaN(timeoutSeconds) ? 1800 : timeoutSeconds) * 1000; let result!: RestartResult; const deps: UpgradeDeps = { ...DEFAULT_DEPS, setPaused: (v, project) => setPaused(v, { source: "restart", reason: "restart, draining" }, project), restartDaemon: async () => { result = await restartDaemon({ port: ctx.portFlag(), project: ctx.projectFlag, provenance, record, }); }, }; // Record the request at ENTRY, before the drain: a caller that disconnects // or hits --timeout while workers drain never reaches the lifecycle // chokepoint, and the durable provenance must survive that — it is exactly // the stop-with-no-witness incident #378 exists for (#378). store.recordDaemonStop(provenance); try { await drainAndRestart(deps, { project: ctx.projectFlag, timeoutMs }); } catch (err) { process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); process.exit(1); } if (result.previous !== undefined) { process.stdout.write( `stopped — pid ${result.previous.pid}${result.via === "systemctl" ? " (via systemctl)" : ""}\n`, ); } process.stdout.write( `started — pid ${result.record.pid}, /healthz on :${result.record.port}` + `${result.record.project === undefined ? "" : `, project ${result.record.project}`}` + `${result.via === "systemctl" ? " (via systemctl)" : ""}\nlog ${result.record.logFile}\n`, ); } finally { store.close(); } }