@celilo/cli 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CELILO_CORE_MODULES.md +5 -4
  2. package/CELILO_SUBSYSTEMS.md +34 -2
  3. package/drizzle/0024_module_pause.sql +20 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +5 -6
  6. package/src/__integration__/container-services-cli.integration.test.ts +8 -2
  7. package/src/api/remote-client.test.ts +6 -5
  8. package/src/api/serve.ts +41 -7
  9. package/src/api-clients/proxmox.ts +34 -0
  10. package/src/cli/commands/alerts-sweep.ts +2 -0
  11. package/src/cli/commands/events.test.ts +66 -0
  12. package/src/cli/commands/events.ts +106 -3
  13. package/src/cli/commands/module-deploy.ts +2 -2
  14. package/src/cli/commands/module-health.ts +1 -0
  15. package/src/cli/commands/module-import.ts +3 -3
  16. package/src/cli/commands/module-list.ts +12 -1
  17. package/src/cli/commands/module-pause.ts +317 -0
  18. package/src/cli/commands/module-remove.ts +78 -40
  19. package/src/cli/commands/module-status.ts +3 -4
  20. package/src/cli/commands/module-update.test.ts +1 -1
  21. package/src/cli/commands/proxmox-template-selection.ts +1 -1
  22. package/src/cli/commands/status.ts +25 -3
  23. package/src/cli/completion.ts +5 -0
  24. package/src/cli/fuel-gauge.ts +4 -4
  25. package/src/cli/index.ts +49 -20
  26. package/src/cli/json-output.test.ts +162 -0
  27. package/src/cli/prompts.ts +53 -74
  28. package/src/cli/service-credential.ts +3 -3
  29. package/src/cli/stdout-is-undecorated.test.ts +94 -0
  30. package/src/cli/types.ts +7 -2
  31. package/src/db/schema.ts +73 -15
  32. package/src/hooks/run-named-hook.ts +28 -0
  33. package/src/services/alerting/suppression.test.ts +5 -0
  34. package/src/services/alerting/suppression.ts +18 -1
  35. package/src/services/alerting/sweep-runner.test.ts +1 -0
  36. package/src/services/alerting/sweep-runner.ts +11 -1
  37. package/src/services/bus-interview.ts +2 -2
  38. package/src/services/bus-secret-flow.test.ts +1 -1
  39. package/src/services/dns-registrations.ts +12 -0
  40. package/src/services/fleet-checks.test.ts +46 -0
  41. package/src/services/fleet-checks.ts +63 -6
  42. package/src/services/module-deploy.ts +1 -1
  43. package/src/services/module-pause-observability.test.ts +224 -0
  44. package/src/services/module-pause-quiescence.test.ts +163 -0
  45. package/src/services/module-pause.test.ts +573 -0
  46. package/src/services/module-pause.ts +544 -0
  47. package/src/services/remove-guard.test.ts +175 -0
  48. package/src/services/remove-guard.ts +109 -0
  49. package/src/services/terminal-responder.ts +16 -16
  50. package/src/services/update/dep-graph.test.ts +33 -4
  51. package/src/services/update/dep-graph.ts +39 -17
  52. package/src/services/zone-detector.ts +2 -39
  53. package/src/test-utils/cli.ts +15 -14
  54. package/src/test-utils/integration-guard.ts +26 -0
  55. package/src/test-utils/setup-test-db.ts +13 -23
@@ -10,6 +10,7 @@
10
10
  * tail recent events
11
11
  * list-subscribers persistent subscribers
12
12
  * list-pending pending deliveries (subscriber fan-out — NOT questions)
13
+ * list-failed failed/abandoned deliveries + a TRUE total
13
14
  * list-unanswered interview questions nobody has answered yet
14
15
  * drain process pending deliveries once
15
16
  * run long-running dispatcher (foreground; SIGINT to stop)
@@ -24,7 +25,9 @@
24
25
  import { spawnSync } from 'node:child_process';
25
26
  import {
26
27
  BUS_VERSION,
28
+ type FailedBySubscriber,
27
29
  defineEvents,
30
+ describeError,
28
31
  drainOnce,
29
32
  openBus,
30
33
  recoverFromCrash,
@@ -52,7 +55,7 @@ import {
52
55
  unitInstalledInAnyScope,
53
56
  } from '../../services/events-daemon';
54
57
  import { getArg, hasFlag } from '../parser';
55
- import type { CommandResult } from '../types';
58
+ import type { CommandResult, CommandSuccess } from '../types';
56
59
 
57
60
  const NO_SCHEMAS = defineEvents({});
58
61
 
@@ -60,10 +63,16 @@ function openCliBus() {
60
63
  return openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
61
64
  }
62
65
 
66
+ /**
67
+ * Every `celilo events` JSON verb returns through here, so `rawOutput` is set
68
+ * once: without it the payload goes through the CLI's decorating renderer and
69
+ * reaches stdout prefixed and re-wrapped, and no longer parses.
70
+ */
63
71
  function jsonResult(data: unknown): CommandResult {
64
72
  return {
65
73
  success: true,
66
74
  message: JSON.stringify(data, null, 2),
75
+ rawOutput: true,
67
76
  data,
68
77
  };
69
78
  }
@@ -120,6 +129,26 @@ export async function handleEventsRunHook(args: string[]): Promise<CommandResult
120
129
  const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
121
130
  if (!module) return { success: false, error: `Module not found: ${moduleId}` };
122
131
 
132
+ // Quiescence (openspec/changes/module-pause-lifecycle, task 2.1/2.2). Pausing
133
+ // drops the module's bus subscriptions, so ordinarily nothing reaches here at
134
+ // all; this is the second line, and it is load-bearing rather than belt-and-
135
+ // braces. The subscribers table lives in a DIFFERENT database (events.db) to
136
+ // the module state, so the two can disagree — `events resync-subscriptions`
137
+ // rebuilds subscribers from celilo.db, a restore starts events.db empty, and
138
+ // a hand-written row is always possible. Every one of those paths ends here,
139
+ // where the module's actual state is readable.
140
+ //
141
+ // Success, not failure: the event was delivered correctly and the module is
142
+ // deliberately not listening. Reporting a failure would retry it up to
143
+ // max_attempts and then surface as an alert about the pause the operator
144
+ // themselves took.
145
+ if (module.state === 'PAUSED') {
146
+ return {
147
+ success: true,
148
+ message: `Skipped ${moduleId}.${subName}: module is paused`,
149
+ };
150
+ }
151
+
123
152
  const manifest = module.manifestData as ModuleManifest;
124
153
  const sub = (manifest.subscriptions ?? []).find((s) => s.name === subName);
125
154
  if (!sub) {
@@ -246,6 +275,75 @@ export async function handleEventsListPending(
246
275
  }
247
276
  }
248
277
 
278
+ /** One failed/abandoned delivery, as `events list-failed` reports it. */
279
+ export interface FailedDelivery {
280
+ eventId: number;
281
+ eventType: string | null;
282
+ subscriber: string | null;
283
+ status: string;
284
+ attempts: number;
285
+ finishedAt: number | null;
286
+ error: string | null;
287
+ }
288
+
289
+ /** What `events list-failed` returns: the true total, the shape, and a sample. */
290
+ export interface FailedDeliveryReport {
291
+ total: number;
292
+ bySubscriber: FailedBySubscriber[];
293
+ shown: number;
294
+ deliveries: FailedDelivery[];
295
+ }
296
+
297
+ /**
298
+ * `celilo events list-failed` — what has failed or been abandoned, who it was
299
+ * for, and why.
300
+ *
301
+ * The instrument celilo#623 lacked: `failedDeliveries` had exactly one caller
302
+ * (the doctor) and no operator surface at all, so the only signal was a count
303
+ * that was really a LIMIT. `total` here is a real COUNT — `deliveries` is the
304
+ * sample, capped by `--limit`, and `shown` says so.
305
+ *
306
+ * This is NOT `list-pending`, which reads deliveries still queued.
307
+ */
308
+ export async function handleEventsListFailed(
309
+ _args: string[],
310
+ flags: Record<string, string | boolean>,
311
+ ): Promise<CommandResult> {
312
+ const bus = openCliBus();
313
+ try {
314
+ const subscriber = typeof flags.subscriber === 'string' ? flags.subscriber : undefined;
315
+ const limit = flags.limit ? Number(flags.limit) : 50;
316
+ const { total, bySubscriber } = bus.failedDeliveryTotals();
317
+ // Subscriber/event names live in other tables; the delivery row has ids.
318
+ const named = new Map(
319
+ bus.db
320
+ .query<{ id: number; name: string }, []>('SELECT id, name FROM subscribers')
321
+ .all()
322
+ .map((r) => [r.id, r.name]),
323
+ );
324
+ const deliveries: FailedDelivery[] = bus.failedDeliveries({ limit, subscriber }).map((d) => ({
325
+ eventId: d.eventId,
326
+ eventType: bus.getEvent(d.eventId)?.type ?? null,
327
+ subscriber: named.get(d.subscriberId) ?? null,
328
+ status: d.status,
329
+ attempts: d.attempts,
330
+ finishedAt: d.finishedAt,
331
+ error: describeError(d.lastError),
332
+ }));
333
+ const report: FailedDeliveryReport = {
334
+ total: subscriber
335
+ ? (bySubscriber.find((s) => s.subscriber === subscriber)?.count ?? 0)
336
+ : total,
337
+ bySubscriber,
338
+ shown: deliveries.length,
339
+ deliveries,
340
+ };
341
+ return jsonResult(report);
342
+ } finally {
343
+ bus.close();
344
+ }
345
+ }
346
+
249
347
  /** One unanswered interview question, as `events list-unanswered` reports it. */
250
348
  export interface UnansweredInterview {
251
349
  eventId: number;
@@ -810,7 +908,7 @@ async function runProgrammaticResponder(
810
908
 
811
909
  let stopping = false;
812
910
  let stopReason: 'idle' | 'max-duration' | 'signal' = 'idle';
813
- const buildSummary = (): { success: true; message: string; data: unknown } => {
911
+ const buildSummary = (): CommandSuccess => {
814
912
  if (!stopping) {
815
913
  stopping = true;
816
914
  handle.close();
@@ -821,7 +919,12 @@ async function runProgrammaticResponder(
821
919
  answered: handle.answered(),
822
920
  missed: handle.missed(),
823
921
  };
824
- return { success: true, message: JSON.stringify(summary, null, 2), data: summary };
922
+ return {
923
+ success: true,
924
+ message: JSON.stringify(summary, null, 2),
925
+ rawOutput: true,
926
+ data: summary,
927
+ };
825
928
  };
826
929
 
827
930
  process.on('SIGINT', () => {
@@ -71,8 +71,8 @@ export async function handleModuleDeploy(
71
71
  }
72
72
 
73
73
  // Success message is emitted by deployModule via the active ProgressDisplay,
74
- // so we return an empty message to avoid the top-level clack outro printing
75
- // a duplicate line in a different style.
74
+ // so we return an empty message index.ts exits without writing anything
75
+ // more, rather than printing a duplicate line in a different style.
76
76
  return {
77
77
  success: true,
78
78
  message: '',
@@ -79,6 +79,7 @@ export async function handleModuleHealth(
79
79
  return {
80
80
  success: true,
81
81
  message: JSON.stringify({ modules: results }, null, 2),
82
+ rawOutput: true,
82
83
  };
83
84
  }
84
85
 
@@ -143,9 +143,9 @@ export async function handleAspectApprovalAfterImport(args: {
143
143
  `celilo will run the '${aspect.ansible_role}' Ansible role on every non-api_only system`,
144
144
  'in those zones.',
145
145
  ].join('\n');
146
- // Use process.stderr.write rather than the prompt UI so the
147
- // multi-line scope block is preserved verbatim — @clack/prompts
148
- // mangles multi-line message arguments.
146
+ // Written straight to stderr rather than through the prompt UI so the
147
+ // multi-line scope block is preserved verbatim — a prompt renderer owns
148
+ // its frame and reflows a multi-line message argument.
149
149
  process.stderr.write(`\n${scopeMsg}\n\n`);
150
150
 
151
151
  const accepted = await withInterviewSession(() =>
@@ -3,6 +3,7 @@ import { type DbClient, getDb } from '../../db/client';
3
3
  import { modules, modules as modulesTable, monitors } from '../../db/schema';
4
4
  import { moduleHealthCell } from '../../services/alerting/format';
5
5
  import { loadAllLiveAlerts, summariseByModule } from '../../services/alerting/store';
6
+ import { formatPausedDuration } from '../../services/module-pause';
6
7
  import { hasFlag } from '../parser';
7
8
  import type { CommandResult } from '../types';
8
9
 
@@ -50,7 +51,17 @@ export async function handleModuleList(
50
51
  for (const module of moduleRows) {
51
52
  const observed = health.get(module.id);
52
53
  const healthNote = observed ? ` [${observed}]` : '';
53
- lines.push(`${module.id} (v${module.version}) - ${module.state}${healthNote}`);
54
+ // A pause suppresses the alerting that would otherwise report this module
55
+ // as down, so the state alone is not enough — the AGE is what separates a
56
+ // maintenance window from an outage nobody remembers taking (design D7).
57
+ const stateCell =
58
+ module.state === 'PAUSED'
59
+ ? `PAUSED (${formatPausedDuration(module.pausedAt)})`
60
+ : module.state;
61
+ lines.push(`${module.id} (v${module.version}) - ${stateCell}${healthNote}`);
62
+ if (module.state === 'PAUSED' && module.pauseReason) {
63
+ lines.push(` Paused: ${module.pauseReason}`);
64
+ }
54
65
  if (module.description) {
55
66
  lines.push(` ${module.description}`);
56
67
  }
@@ -0,0 +1,317 @@
1
+ /**
2
+ * `celilo module pause <id>` / `celilo module unpause <id>`.
3
+ *
4
+ * A thin adapter (Rule 10.5): parse flags, build the plan, confirm, execute,
5
+ * render. All the decisions live in `services/module-pause.ts` (pure planning)
6
+ * and the injected deps below (the side effects).
7
+ *
8
+ * See openspec/changes/module-pause-lifecycle/.
9
+ */
10
+
11
+ import { join } from 'node:path';
12
+ import { eq } from 'drizzle-orm';
13
+ import { ProxmoxClient } from '../../api-clients/proxmox';
14
+ import { getModuleStoragePath } from '../../config/paths';
15
+ import { type DbClient, getDb } from '../../db/client';
16
+ import { moduleSystems, modules } from '../../db/schema';
17
+ import type { ModuleManifest } from '../../manifest/schema';
18
+ import { ModuleManifestSchema } from '../../manifest/schema';
19
+ import { askConfirm, withInterviewSession } from '../../services/bus-interview';
20
+ import { getServiceCredentials } from '../../services/container-service';
21
+ import { deployModule } from '../../services/module-deploy';
22
+ import { checkInFlight } from '../../services/module-operations';
23
+ import { completeOperation, failOperation, startOperation } from '../../services/module-operations';
24
+ import {
25
+ type ExecutionReport,
26
+ type InfraStopOutcome,
27
+ type ModuleSnapshot,
28
+ type PauseDeps,
29
+ type PausePlan,
30
+ PauseRefusedError,
31
+ actedOn,
32
+ describeMachineStopInfra,
33
+ executePause,
34
+ executeUnpause,
35
+ planPause,
36
+ planUnpause,
37
+ } from '../../services/module-pause';
38
+ import {
39
+ registerModuleSubscriptions,
40
+ unregisterModuleSubscriptions,
41
+ } from '../../services/module-subscriptions';
42
+ import { getArg, hasFlag } from '../parser';
43
+ import { log } from '../prompts';
44
+ import type { CommandResult } from '../types';
45
+
46
+ /**
47
+ * Every module celilo knows about, in the shape the planner wants. The planner
48
+ * is pure, so the whole fleet is read once here rather than queried per step.
49
+ *
50
+ * A module whose stored manifest no longer parses is dropped rather than
51
+ * failing the command: it cannot be a graph node, and refusing to pause the
52
+ * fleet because one unrelated manifest went stale would be the wrong trade.
53
+ */
54
+ function loadFleet(db: DbClient): ModuleSnapshot[] {
55
+ const snapshots: ModuleSnapshot[] = [];
56
+ for (const row of db.select().from(modules).all()) {
57
+ const parsed = ModuleManifestSchema.safeParse(row.manifestData);
58
+ if (!parsed.success) continue;
59
+ snapshots.push({
60
+ id: row.id,
61
+ state: row.state,
62
+ pausedAt: row.pausedAt,
63
+ pauseReason: row.pauseReason,
64
+ manifest: parsed.data,
65
+ });
66
+ }
67
+ return snapshots;
68
+ }
69
+
70
+ /** moduleId → description, for the in-flight refusal (task 3.8). */
71
+ function inFlightByModule(): Map<string, string> {
72
+ const map = new Map<string, string>();
73
+ for (const conflict of checkInFlight()) {
74
+ map.set(conflict.operation.moduleId, conflict.describe);
75
+ }
76
+ return map;
77
+ }
78
+
79
+ /**
80
+ * `--stop-infra` (design D2, revised). Opt-in, and deliberately NOT what a pause
81
+ * means: pausing `caddy` to swap the *firewall* must not take every website
82
+ * down.
83
+ *
84
+ * It acts ONLY on infrastructure celilo provisioned for this module:
85
+ * - celilo-provisioned LXC/VM -> stopped; celilo created it, so it is celilo's
86
+ * - machine-pool system -> not applicable, by design (see below)
87
+ * - systemless driver -> nothing to stop
88
+ *
89
+ * The flag is a convenience for "I actually want the box off". Pause's real job
90
+ * is control-plane quiescence, and the provider swap this feature exists for
91
+ * never needs the flag at all.
92
+ */
93
+ async function stopModuleInfrastructure(db: DbClient, moduleId: string): Promise<InfraStopOutcome> {
94
+ const systems = db.select().from(moduleSystems).where(eq(moduleSystems.moduleId, moduleId)).all();
95
+
96
+ if (systems.length === 0) {
97
+ // A driver module (`greenwave`, `axon`, `namecheap`) declares no
98
+ // `requires.system` — it talks to a device over HTTP. Nothing to stop, and
99
+ // that is a normal outcome to report, not an error (spec scenario
100
+ // "Shutdown on a systemless driver module reports nothing to stop").
101
+ return { stopped: false, detail: 'no infrastructure to stop' };
102
+ }
103
+
104
+ const detail: string[] = [];
105
+ for (const system of systems) {
106
+ if (system.infraType === 'machine') {
107
+ // NOT APPLICABLE, by design — not a missing feature (design D2, revised).
108
+ //
109
+ // `--stop-infra` acts only on infrastructure celilo PROVISIONED for the
110
+ // module. A machine-pool system is operator-pre-provisioned: it may
111
+ // predate celilo, and it may run things celilo has never heard of — not
112
+ // merely other celilo modules, but arbitrary operator work. Powering it
113
+ // off, or stopping services on it, reaches outside what celilo owns.
114
+ //
115
+ // Same principle as the sizing rule: a module must not own a host-level
116
+ // fact, because the host outlives any one module's config. Framing this
117
+ // as "celilo cannot identify the service unit" would be wrong — it
118
+ // implies a capability gap, when the answer is that this is not celilo's
119
+ // to stop.
120
+ detail.push(describeMachineStopInfra(system.hostname, moduleId));
121
+ continue;
122
+ }
123
+
124
+ if (system.vmid == null || !system.serviceId) {
125
+ detail.push(`${system.hostname}: no container recorded, nothing to stop`);
126
+ continue;
127
+ }
128
+
129
+ const credentials = await getServiceCredentials(system.serviceId);
130
+ if (!('api_url' in credentials)) {
131
+ detail.push(`${system.hostname}: container service is not Proxmox, nothing to stop`);
132
+ continue;
133
+ }
134
+
135
+ const client = new ProxmoxClient(credentials);
136
+ const result = await client.setGuestPower(system.vmid, 'lxc', 'shutdown');
137
+ detail.push(
138
+ result.success
139
+ ? `stopped ${system.hostname} (vmid ${system.vmid})`
140
+ : `could not stop ${system.hostname} (vmid ${system.vmid}): ${result.message}`,
141
+ );
142
+ }
143
+
144
+ return { stopped: true, detail: detail.join('; ') };
145
+ }
146
+
147
+ function buildDeps(db: DbClient): PauseDeps {
148
+ return {
149
+ db,
150
+ unsubscribe: (moduleId) => {
151
+ // The primary quiescence mechanism: with no subscriber rows the
152
+ // dispatcher has nothing to deliver to. `run-named-hook` guards the
153
+ // paths that do not go through the bus.
154
+ unregisterModuleSubscriptions(moduleId);
155
+ },
156
+ resubscribe: (moduleId) => {
157
+ // A deploy does NOT re-register subscriptions (only import and
158
+ // `module update` do), so unpause has to — otherwise the module comes
159
+ // back deployed but permanently deaf.
160
+ const row = db.select().from(modules).where(eq(modules.id, moduleId)).get();
161
+ if (!row) return;
162
+ const manifest = row.manifestData as ModuleManifest;
163
+ registerModuleSubscriptions(manifest, join(getModuleStoragePath(), moduleId));
164
+ },
165
+ redeploy: async (moduleId) => {
166
+ try {
167
+ const result = await deployModule(moduleId, db, {});
168
+ return { success: result.success, error: result.error };
169
+ } catch (err) {
170
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
171
+ }
172
+ },
173
+ stopInfrastructure: (moduleId) => stopModuleInfrastructure(db, moduleId),
174
+ startOperation,
175
+ completeOperation,
176
+ failOperation,
177
+ now: () => new Date(),
178
+ log: (message) => log.info(message),
179
+ };
180
+ }
181
+
182
+ /** The ordered plan, rendered for `--dry-run` and for the confirmation. */
183
+ function renderPlan(plan: PausePlan): string {
184
+ const verb = plan.action === 'pause' ? 'Pause' : 'Unpause';
185
+ const order = plan.action === 'pause' ? 'consumers first' : 'providers first';
186
+ const lines = [
187
+ plan.cascade
188
+ ? `${verb} ${plan.requested} and its transitive consumers (${order}):`
189
+ : `${verb} ${plan.requested}:`,
190
+ ];
191
+ plan.steps.forEach((step, index) => {
192
+ const suffix = step.disposition === 'act' ? '' : ` — skip (${step.note})`;
193
+ lines.push(` ${index + 1}. ${step.moduleId}${suffix}`);
194
+ });
195
+ if (plan.stopInfra) {
196
+ lines.push('', 'Infrastructure will also be stopped (--stop-infra).');
197
+ }
198
+ return lines.join('\n');
199
+ }
200
+
201
+ function renderReport(report: ExecutionReport): string {
202
+ const lines = report.outcomes.map((o) => {
203
+ const mark = o.result === 'acted' ? '✓' : o.result === 'skipped' ? '·' : '✗';
204
+ return ` ${mark} ${o.moduleId}${o.detail ? ` — ${o.detail}` : ''}`;
205
+ });
206
+ const acted = report.outcomes.filter((o) => o.result === 'acted').length;
207
+ const skipped = report.outcomes.filter((o) => o.result === 'skipped').length;
208
+ const failed = report.outcomes.filter((o) => o.result === 'failed').length;
209
+ lines.push('', `${acted} changed, ${skipped} already done, ${failed} failed.`);
210
+ return lines.join('\n');
211
+ }
212
+
213
+ /**
214
+ * Confirmation for a cascade. An event-bus interview question, never a stdin
215
+ * prompt (design D6), so the operation is drivable headlessly — by CI, by the
216
+ * MCP, by a remote responder. `--yes` satisfies it without asking.
217
+ */
218
+ async function confirmCascade(plan: PausePlan, yes: boolean): Promise<boolean> {
219
+ if (!plan.cascade || yes) return true;
220
+ const affected = actedOn(plan);
221
+ if (affected.length === 0) return true;
222
+
223
+ return withInterviewSession(() =>
224
+ askConfirm({
225
+ scope: `module-${plan.action}:${plan.requested}`,
226
+ key: 'cascade',
227
+ message: `${plan.action === 'pause' ? 'Pause' : 'Unpause'} ${affected.length} module(s): ${affected.join(', ')}?`,
228
+ description: renderPlan(plan),
229
+ defaultValue: false,
230
+ }),
231
+ );
232
+ }
233
+
234
+ export async function handleModulePause(
235
+ args: string[],
236
+ flags: Record<string, string | boolean> = {},
237
+ ): Promise<CommandResult> {
238
+ const moduleId = getArg(args, 0);
239
+ if (!moduleId) {
240
+ return {
241
+ success: false,
242
+ error:
243
+ 'Module ID is required\n\nUsage: celilo module pause <id> [--cascade] [--stop-infra] [--dry-run] [--yes] [--reason "..."]',
244
+ };
245
+ }
246
+
247
+ const db = getDb();
248
+ const reason = typeof flags.reason === 'string' ? flags.reason : null;
249
+
250
+ let plan: PausePlan;
251
+ try {
252
+ plan = planPause({
253
+ moduleId,
254
+ fleet: loadFleet(db),
255
+ cascade: hasFlag(flags, 'cascade'),
256
+ stopInfra: hasFlag(flags, 'stop-infra'),
257
+ inFlight: inFlightByModule(),
258
+ });
259
+ } catch (err) {
260
+ if (err instanceof PauseRefusedError) return { success: false, error: err.message };
261
+ throw err;
262
+ }
263
+
264
+ if (hasFlag(flags, 'dry-run')) {
265
+ return { success: true, message: `${renderPlan(plan)}\n\n(dry run — nothing was changed)` };
266
+ }
267
+
268
+ if (!(await confirmCascade(plan, hasFlag(flags, 'yes')))) {
269
+ return { success: false, error: 'Cancelled — nothing was paused' };
270
+ }
271
+
272
+ const report = await executePause(plan, buildDeps(db), reason);
273
+ return report.success
274
+ ? { success: true, message: renderReport(report) }
275
+ : { success: false, error: renderReport(report) };
276
+ }
277
+
278
+ export async function handleModuleUnpause(
279
+ args: string[],
280
+ flags: Record<string, string | boolean> = {},
281
+ ): Promise<CommandResult> {
282
+ const moduleId = getArg(args, 0);
283
+ if (!moduleId) {
284
+ return {
285
+ success: false,
286
+ error:
287
+ 'Module ID is required\n\nUsage: celilo module unpause <id> [--cascade] [--dry-run] [--yes]',
288
+ };
289
+ }
290
+
291
+ const db = getDb();
292
+
293
+ let plan: PausePlan;
294
+ try {
295
+ plan = planUnpause({
296
+ moduleId,
297
+ fleet: loadFleet(db),
298
+ cascade: hasFlag(flags, 'cascade'),
299
+ });
300
+ } catch (err) {
301
+ if (err instanceof PauseRefusedError) return { success: false, error: err.message };
302
+ throw err;
303
+ }
304
+
305
+ if (hasFlag(flags, 'dry-run')) {
306
+ return { success: true, message: `${renderPlan(plan)}\n\n(dry run — nothing was changed)` };
307
+ }
308
+
309
+ if (!(await confirmCascade(plan, hasFlag(flags, 'yes')))) {
310
+ return { success: false, error: 'Cancelled — nothing was unpaused' };
311
+ }
312
+
313
+ const report = await executeUnpause(plan, buildDeps(db));
314
+ return report.success
315
+ ? { success: true, message: renderReport(report) }
316
+ : { success: false, error: renderReport(report) };
317
+ }