@celilo/cli 0.22.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 (51) hide show
  1. package/CELILO_SUBSYSTEMS.md +34 -2
  2. package/drizzle/0024_module_pause.sql +20 -0
  3. package/drizzle/meta/_journal.json +8 -1
  4. package/package.json +4 -5
  5. package/src/__integration__/container-services-cli.integration.test.ts +8 -2
  6. package/src/api/remote-client.test.ts +6 -5
  7. package/src/api/serve.ts +41 -7
  8. package/src/api-clients/proxmox.ts +34 -0
  9. package/src/cli/commands/alerts-sweep.ts +2 -0
  10. package/src/cli/commands/events.ts +34 -3
  11. package/src/cli/commands/module-deploy.ts +2 -2
  12. package/src/cli/commands/module-health.ts +1 -0
  13. package/src/cli/commands/module-import.ts +3 -3
  14. package/src/cli/commands/module-list.ts +12 -1
  15. package/src/cli/commands/module-pause.ts +317 -0
  16. package/src/cli/commands/module-remove.ts +78 -40
  17. package/src/cli/commands/module-status.ts +3 -4
  18. package/src/cli/commands/module-update.test.ts +1 -1
  19. package/src/cli/commands/proxmox-template-selection.ts +1 -1
  20. package/src/cli/commands/status.ts +25 -3
  21. package/src/cli/completion.ts +4 -0
  22. package/src/cli/fuel-gauge.ts +4 -4
  23. package/src/cli/index.ts +45 -20
  24. package/src/cli/json-output.test.ts +162 -0
  25. package/src/cli/prompts.ts +53 -74
  26. package/src/cli/service-credential.ts +3 -3
  27. package/src/cli/stdout-is-undecorated.test.ts +94 -0
  28. package/src/cli/types.ts +7 -2
  29. package/src/db/schema.ts +73 -15
  30. package/src/hooks/run-named-hook.ts +28 -0
  31. package/src/services/alerting/suppression.test.ts +5 -0
  32. package/src/services/alerting/suppression.ts +18 -1
  33. package/src/services/alerting/sweep-runner.test.ts +1 -0
  34. package/src/services/alerting/sweep-runner.ts +11 -1
  35. package/src/services/bus-interview.ts +2 -2
  36. package/src/services/bus-secret-flow.test.ts +1 -1
  37. package/src/services/fleet-checks.ts +48 -0
  38. package/src/services/module-deploy.ts +1 -1
  39. package/src/services/module-pause-observability.test.ts +224 -0
  40. package/src/services/module-pause-quiescence.test.ts +163 -0
  41. package/src/services/module-pause.test.ts +573 -0
  42. package/src/services/module-pause.ts +544 -0
  43. package/src/services/remove-guard.test.ts +175 -0
  44. package/src/services/remove-guard.ts +109 -0
  45. package/src/services/terminal-responder.ts +16 -16
  46. package/src/services/update/dep-graph.test.ts +33 -4
  47. package/src/services/update/dep-graph.ts +39 -17
  48. package/src/services/zone-detector.ts +2 -39
  49. package/src/test-utils/cli.ts +15 -14
  50. package/src/test-utils/integration-guard.ts +26 -0
  51. package/src/test-utils/setup-test-db.ts +13 -23
@@ -0,0 +1,544 @@
1
+ /**
2
+ * Module pause / unpause — taking a module out of celilo's control plane
3
+ * without uninstalling it, so a capability provider its consumers depend on can
4
+ * be removed and replaced.
5
+ *
6
+ * See openspec/changes/module-pause-lifecycle/. The shape of this file follows
7
+ * Rule 10.4: `planPause`/`planUnpause` are pure and produce an explicit ordered
8
+ * plan; `executePause`/`executeUnpause` perform the side effects. `--dry-run`
9
+ * renders the plan, which for a fleet-wide operation is the most valuable part
10
+ * of the feature.
11
+ *
12
+ * What makes pause cheap is that capability consumption is DEPLOY-time: every
13
+ * consumer of `firewall` / `dhcp_server` calls it from `on_install`, and
14
+ * nothing calls it while merely serving traffic. So a consumer only has to stop
15
+ * participating in the control plane — not stop running — for its provider to
16
+ * be replaced underneath it. That is why the data plane is left alone by
17
+ * default (design D2).
18
+ */
19
+
20
+ import { eq, inArray } from 'drizzle-orm';
21
+ import type { DbClient } from '../db/client';
22
+ import { IN_FLIGHT_STATES, type ModuleState, PAUSABLE_STATES, modules } from '../db/schema';
23
+ import type { ModuleManifest } from '../manifest/schema';
24
+ import {
25
+ DependencyCycleError,
26
+ type ModuleGraph,
27
+ buildModuleGraph,
28
+ topologicalOrder,
29
+ transitiveConsumers,
30
+ } from './update/dep-graph';
31
+
32
+ export type PauseAction = 'pause' | 'unpause';
33
+
34
+ /** The read-only view of a module the planner needs. No DB handle, no I/O. */
35
+ export interface ModuleSnapshot {
36
+ id: string;
37
+ state: ModuleState;
38
+ pausedAt: Date | null;
39
+ pauseReason: string | null;
40
+ manifest: ModuleManifest;
41
+ }
42
+
43
+ /**
44
+ * `act` — the module is not yet in the target condition and will be changed.
45
+ *
46
+ * `skip_already` — it is already there. Crucially NOT an error and NOT a reason
47
+ * to halt: a cascade must walk THROUGH members already in the target condition
48
+ * to reach the ones beyond them, which is what makes a half-finished cascade
49
+ * resumable (design D5, task 4.7).
50
+ *
51
+ * `skip_undeployed` — swept in by `--cascade` but never deployed, so there is
52
+ * nothing to quiesce and nothing bound to the provider. Only ever applies to a
53
+ * module the operator did NOT name; naming an undeployed module directly is
54
+ * still refused, per the spec scenario "An undeployed module cannot be paused".
55
+ */
56
+ export type StepDisposition = 'act' | 'skip_already' | 'skip_undeployed';
57
+
58
+ export interface PlanStep {
59
+ moduleId: string;
60
+ disposition: StepDisposition;
61
+ /** Present on `skip_already`, so the report explains itself. */
62
+ note?: string;
63
+ }
64
+
65
+ export interface PausePlan {
66
+ action: PauseAction;
67
+ /** The module the operator named. */
68
+ requested: string;
69
+ cascade: boolean;
70
+ /** Whether the module's infrastructure should also be stopped (pause only). */
71
+ stopInfra: boolean;
72
+ /** Ordered: cascade pause is consumers-first, cascade unpause providers-first. */
73
+ steps: PlanStep[];
74
+ }
75
+
76
+ /** Modules a plan would actually change — what confirmation must name. */
77
+ export function actedOn(plan: PausePlan): string[] {
78
+ return plan.steps.filter((s) => s.disposition === 'act').map((s) => s.moduleId);
79
+ }
80
+
81
+ export class PauseRefusedError extends Error {
82
+ constructor(message: string) {
83
+ super(message);
84
+ this.name = 'PauseRefusedError';
85
+ }
86
+ }
87
+
88
+ export interface PlanRequest {
89
+ moduleId: string;
90
+ /** Every module celilo knows about — the planner filters, the caller does not. */
91
+ fleet: ModuleSnapshot[];
92
+ cascade: boolean;
93
+ stopInfra?: boolean;
94
+ /**
95
+ * moduleId → operator-readable description of an operation currently in
96
+ * flight for it. Pause is refused for those (design, closed question 3).
97
+ */
98
+ inFlight?: ReadonlyMap<string, string>;
99
+ }
100
+
101
+ /**
102
+ * Has this module ever reached a deployed state? Anything settled-or-in-flight
103
+ * past CONFIGURED has something on a machine; the earlier states do not.
104
+ */
105
+ function isDeployed(state: ModuleState): boolean {
106
+ return !(['IMPORTED', 'VALIDATED', 'CONFIGURED'] as readonly ModuleState[]).includes(state);
107
+ }
108
+
109
+ function snapshotById(fleet: ModuleSnapshot[]): Map<string, ModuleSnapshot> {
110
+ return new Map(fleet.map((m) => [m.id, m]));
111
+ }
112
+
113
+ /**
114
+ * The ordered set a cascade covers: the named module plus every transitive
115
+ * consumer of it.
116
+ *
117
+ * Computed from the dependency GRAPH, never from which modules happen to be
118
+ * paused — otherwise an already-unpaused module would truncate the set and the
119
+ * cascade would stop at the first member that needed no work (task 4.7).
120
+ */
121
+ function cascadeOrder(graph: ModuleGraph, moduleId: string, action: PauseAction): string[] {
122
+ const affected = new Set([moduleId, ...transitiveConsumers(graph, moduleId)]);
123
+ const providersFirst = topologicalOrder(graph).filter((id) => affected.has(id));
124
+
125
+ // Unpausing redeploys, and a redeploy resolves capabilities, so a provider
126
+ // must be live before any consumer redeploys. Pausing is the mirror: a
127
+ // consumer must stop depending before its provider goes quiet.
128
+ return action === 'unpause' ? providersFirst : providersFirst.slice().reverse();
129
+ }
130
+
131
+ function buildSteps(request: PlanRequest, action: PauseAction): PlanStep[] {
132
+ const byId = snapshotById(request.fleet);
133
+ const target = byId.get(request.moduleId);
134
+ if (!target) {
135
+ throw new PauseRefusedError(`Module not found: ${request.moduleId}`);
136
+ }
137
+
138
+ let ordered: string[];
139
+ if (request.cascade) {
140
+ try {
141
+ ordered = cascadeOrder(
142
+ buildModuleGraph(request.fleet.map((m) => m.manifest)),
143
+ request.moduleId,
144
+ action,
145
+ );
146
+ } catch (err) {
147
+ // A cycle means there is no safe order, so acting on part of the set
148
+ // would leave the fleet in a state no re-run can reason about. Refuse and
149
+ // name the cycle (task 4.3) rather than pausing a partial set.
150
+ if (err instanceof DependencyCycleError) {
151
+ throw new PauseRefusedError(
152
+ `Cannot ${action} with cascade: the affected modules declare a dependency cycle.\n ${err.cycle.join(' → ')} → ${err.cycle[0]}\nFix the manifests, or ${action} each module individually.`,
153
+ );
154
+ }
155
+ throw err;
156
+ }
157
+ } else {
158
+ ordered = [request.moduleId];
159
+ }
160
+
161
+ return ordered.map((id) => {
162
+ const snapshot = byId.get(id);
163
+ // A graph node with no DB row cannot happen (the graph is built from the
164
+ // fleet), but the map lookup is nullable and a silent skip would be worse
165
+ // than a loud one.
166
+ if (!snapshot) {
167
+ throw new PauseRefusedError(`Module '${id}' is in the dependency graph but has no record`);
168
+ }
169
+ const isPaused = snapshot.state === 'PAUSED';
170
+ const alreadyDone = action === 'pause' ? isPaused : !isPaused;
171
+ if (alreadyDone) {
172
+ return {
173
+ moduleId: id,
174
+ disposition: 'skip_already' as const,
175
+ note: action === 'pause' ? 'already paused' : 'not paused',
176
+ };
177
+ }
178
+
179
+ // A cascade sweeps in every transitive consumer from the manifest graph,
180
+ // including ones that were imported but never deployed. Those are not bound
181
+ // to the provider and have nothing to quiesce, so refusing the whole cascade
182
+ // over them would wedge exactly the migration this feature exists to enable
183
+ // — one stray imported module would block the swap. Skip them instead.
184
+ //
185
+ // The module the operator NAMED is not eligible for this: asking to pause an
186
+ // undeployed module is a mistake worth reporting, and the spec says so.
187
+ const named = id === request.moduleId;
188
+ if (action === 'pause' && !named && !isDeployed(snapshot.state)) {
189
+ return {
190
+ moduleId: id,
191
+ disposition: 'skip_undeployed' as const,
192
+ note: `${snapshot.state} — never deployed, nothing to quiesce`,
193
+ };
194
+ }
195
+
196
+ return { moduleId: id, disposition: 'act' as const };
197
+ });
198
+ }
199
+
200
+ /**
201
+ * Why a module cannot be paused right now, or null if it can. Split out so the
202
+ * message names the specific case — "nothing deployed to quiesce" and "would
203
+ * strand the transition" are different problems with different fixes.
204
+ */
205
+ function pauseRefusal(
206
+ snapshot: ModuleSnapshot,
207
+ inFlight: ReadonlyMap<string, string>,
208
+ ): string | null {
209
+ const operation = inFlight.get(snapshot.id);
210
+ if (operation) {
211
+ return `'${snapshot.id}' has an operation in progress (${operation}). Wait for it to finish, or release it with "celilo module operations clear".`;
212
+ }
213
+ if ((PAUSABLE_STATES as readonly ModuleState[]).includes(snapshot.state)) return null;
214
+ if ((IN_FLIGHT_STATES as readonly ModuleState[]).includes(snapshot.state)) {
215
+ return `'${snapshot.id}' is ${snapshot.state} — pausing mid-transition would strand it. Wait for the transition to finish.`;
216
+ }
217
+ return `'${snapshot.id}' is ${snapshot.state} — it has never been deployed, so there is nothing to quiesce.`;
218
+ }
219
+
220
+ /**
221
+ * Plan a pause. Pure: no DB, no bus, no filesystem.
222
+ *
223
+ * Refuses the WHOLE plan when any module it would act on cannot be paused,
224
+ * rather than pausing a partial set and leaving the operator to work out which
225
+ * half happened.
226
+ */
227
+ export function planPause(request: PlanRequest): PausePlan {
228
+ const steps = buildSteps(request, 'pause');
229
+ const byId = snapshotById(request.fleet);
230
+ const inFlight = request.inFlight ?? new Map<string, string>();
231
+
232
+ const refusals = steps
233
+ .filter((s) => s.disposition === 'act')
234
+ .map((s) => byId.get(s.moduleId))
235
+ .filter((s): s is ModuleSnapshot => s !== undefined)
236
+ .map((s) => pauseRefusal(s, inFlight))
237
+ .filter((r): r is string => r !== null);
238
+
239
+ if (refusals.length > 0) {
240
+ throw new PauseRefusedError(`Cannot pause:\n${refusals.map((r) => ` • ${r}`).join('\n')}`);
241
+ }
242
+
243
+ return {
244
+ action: 'pause',
245
+ requested: request.moduleId,
246
+ cascade: request.cascade,
247
+ stopInfra: request.stopInfra ?? false,
248
+ steps,
249
+ };
250
+ }
251
+
252
+ /**
253
+ * Plan an unpause. Pure.
254
+ *
255
+ * There is no source-state gate here: the only module an unpause acts on is one
256
+ * in `PAUSED`, which is settled by construction.
257
+ */
258
+ export function planUnpause(request: PlanRequest): PausePlan {
259
+ return {
260
+ action: 'unpause',
261
+ requested: request.moduleId,
262
+ cascade: request.cascade,
263
+ stopInfra: false,
264
+ steps: buildSteps(request, 'unpause'),
265
+ };
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Reading paused state
270
+ // ---------------------------------------------------------------------------
271
+
272
+ export interface PausedModule {
273
+ id: string;
274
+ pausedAt: Date | null;
275
+ pauseReason: string | null;
276
+ }
277
+
278
+ /**
279
+ * Every currently paused module. One indexed query (`modules_state_idx`) — it
280
+ * runs on every management-API response (design D7), so it has to stay cheap.
281
+ */
282
+ export function listPausedModules(db: DbClient): PausedModule[] {
283
+ return db
284
+ .select({
285
+ id: modules.id,
286
+ pausedAt: modules.pausedAt,
287
+ pauseReason: modules.pauseReason,
288
+ })
289
+ .from(modules)
290
+ .where(eq(modules.state, 'PAUSED'))
291
+ .all();
292
+ }
293
+
294
+ /** True if this module is paused. Used by the remove guard and hook dispatch. */
295
+ export function isModulePaused(db: DbClient, moduleId: string): boolean {
296
+ const row = db
297
+ .select({ state: modules.state })
298
+ .from(modules)
299
+ .where(eq(modules.id, moduleId))
300
+ .get();
301
+ return row?.state === 'PAUSED';
302
+ }
303
+
304
+ /** The subset of `moduleIds` that are paused. One query, for guard/plan use. */
305
+ export function pausedAmong(db: DbClient, moduleIds: string[]): Set<string> {
306
+ if (moduleIds.length === 0) return new Set();
307
+ const rows = db
308
+ .select({ id: modules.id, state: modules.state })
309
+ .from(modules)
310
+ .where(inArray(modules.id, moduleIds))
311
+ .all();
312
+ return new Set(rows.filter((r) => r.state === 'PAUSED').map((r) => r.id));
313
+ }
314
+
315
+ /**
316
+ * How long a module has been paused, human-readable. One implementation so no
317
+ * call site formats the age by hand (task 1.3) — `module list`, `module
318
+ * status`, `system doctor` and the management-API warning all render the same
319
+ * string for the same pause.
320
+ */
321
+ export function formatPausedDuration(pausedAt: Date | null, now: Date = new Date()): string {
322
+ if (!pausedAt) return 'unknown';
323
+ const ms = Math.max(0, now.getTime() - pausedAt.getTime());
324
+ const minutes = Math.floor(ms / 60_000);
325
+ if (minutes < 1) return 'just now';
326
+ if (minutes < 60) return `${minutes}m`;
327
+ const hours = Math.floor(minutes / 60);
328
+ if (hours < 24) return `${hours}h`;
329
+ return `${Math.floor(hours / 24)}d`;
330
+ }
331
+
332
+ /**
333
+ * What `--stop-infra` reports for a module hosted on a MACHINE (machine-pool
334
+ * sense): not applicable, by design (design D2, revised).
335
+ *
336
+ * Extracted so the semantics are testable rather than buried in a string. The
337
+ * wording is load-bearing: `--stop-infra` acts only on infrastructure celilo
338
+ * PROVISIONED, and a machine is operator-pre-provisioned — it may predate
339
+ * celilo and may run work celilo has never been told about. Saying celilo
340
+ * "cannot determine the service unit" would imply a capability gap where the
341
+ * truth is that this host is not celilo's to stop.
342
+ */
343
+ export function describeMachineStopInfra(hostname: string, moduleId: string): string {
344
+ return `${hostname}: machine-hosted — not applicable. --stop-infra acts only on infrastructure celilo provisioned; this machine is operator-managed and may run more than '${moduleId}'.`;
345
+ }
346
+
347
+ /** `caddy (3d, "swapping the edge router")` — the shared one-line rendering. */
348
+ export function describePausedModule(module: PausedModule, now: Date = new Date()): string {
349
+ const age = formatPausedDuration(module.pausedAt, now);
350
+ return module.pauseReason
351
+ ? `${module.id} (${age}, "${module.pauseReason}")`
352
+ : `${module.id} (${age})`;
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // Execution
357
+ // ---------------------------------------------------------------------------
358
+
359
+ /**
360
+ * The side-effecting collaborators, injected so the executor is testable
361
+ * without a Proxmox, a bus, or a real deploy (Rule 2.3).
362
+ */
363
+ export interface PauseDeps {
364
+ db: DbClient;
365
+ /** Quiesce: drop the module's bus subscriptions so nothing is delivered. */
366
+ unsubscribe(moduleId: string): void;
367
+ /** Re-arm them after a successful unpause redeploy. */
368
+ resubscribe(moduleId: string): void;
369
+ /** Unpause's rebinding mechanism (design D4). Resolves false on failure. */
370
+ redeploy(moduleId: string): Promise<{ success: boolean; error?: string }>;
371
+ /** `--stop-infra`. Returns what it did, for the report. */
372
+ stopInfrastructure(moduleId: string): Promise<InfraStopOutcome>;
373
+ /** Progress + resumability substrate (`module_operations`). */
374
+ startOperation(moduleId: string, operation: 'pause' | 'unpause'): string;
375
+ completeOperation(operationId: string): void;
376
+ failOperation(operationId: string, error: unknown): void;
377
+ now(): Date;
378
+ log(message: string): void;
379
+ }
380
+
381
+ export interface InfraStopOutcome {
382
+ stopped: boolean;
383
+ /** Operator-readable: "stopped container vmid 231", "no infrastructure to stop". */
384
+ detail: string;
385
+ }
386
+
387
+ export interface StepOutcome {
388
+ moduleId: string;
389
+ /** `acted` changed it; `skipped` was already there; `failed` did not happen. */
390
+ result: 'acted' | 'skipped' | 'failed';
391
+ detail?: string;
392
+ }
393
+
394
+ export interface ExecutionReport {
395
+ action: PauseAction;
396
+ outcomes: StepOutcome[];
397
+ /** False if any step failed — the caller renders a non-zero result. */
398
+ success: boolean;
399
+ }
400
+
401
+ function markPaused(deps: PauseDeps, moduleId: string, reason: string | null): void {
402
+ deps.db
403
+ .update(modules)
404
+ .set({ state: 'PAUSED', pausedAt: deps.now(), pauseReason: reason, updatedAt: deps.now() })
405
+ .where(eq(modules.id, moduleId))
406
+ .run();
407
+ }
408
+
409
+ /**
410
+ * Pause every module the plan acts on, in the plan's order.
411
+ *
412
+ * A step that fails does NOT abort the rest: the remaining modules are
413
+ * independent, the half-paused state is durable and safe (paused modules are
414
+ * quiesced, not broken), and re-running the cascade completes the outstanding
415
+ * work. Aborting would leave a smaller done-set for no benefit.
416
+ */
417
+ export async function executePause(
418
+ plan: PausePlan,
419
+ deps: PauseDeps,
420
+ reason: string | null,
421
+ ): Promise<ExecutionReport> {
422
+ const outcomes: StepOutcome[] = [];
423
+
424
+ for (const step of plan.steps) {
425
+ if (step.disposition !== 'act') {
426
+ // Deliberately not a halt (task 4.7) and deliberately not a re-write:
427
+ // re-pausing preserves the ORIGINAL pausedAt/pauseReason, so the age
428
+ // keeps measuring the real outage rather than resetting on every retry.
429
+ outcomes.push({ moduleId: step.moduleId, result: 'skipped', detail: step.note });
430
+ continue;
431
+ }
432
+
433
+ const opId = deps.startOperation(step.moduleId, 'pause');
434
+ try {
435
+ deps.unsubscribe(step.moduleId);
436
+ markPaused(deps, step.moduleId, reason);
437
+
438
+ let detail = 'paused';
439
+ if (plan.stopInfra) {
440
+ const outcome = await deps.stopInfrastructure(step.moduleId);
441
+ detail = `paused; ${outcome.detail}`;
442
+ }
443
+ deps.completeOperation(opId);
444
+ deps.log(`${step.moduleId}: ${detail}`);
445
+ outcomes.push({ moduleId: step.moduleId, result: 'acted', detail });
446
+ } catch (err) {
447
+ const message = err instanceof Error ? err.message : String(err);
448
+ deps.failOperation(opId, err);
449
+ deps.log(`${step.moduleId}: pause failed — ${message}`);
450
+ outcomes.push({ moduleId: step.moduleId, result: 'failed', detail: message });
451
+ }
452
+ }
453
+
454
+ return { action: 'pause', outcomes, success: outcomes.every((o) => o.result !== 'failed') };
455
+ }
456
+
457
+ /**
458
+ * Unpause every paused module the plan covers, providers first.
459
+ *
460
+ * A failure DOES stop the cascade here, unlike pause: the plan is ordered so
461
+ * providers come first, so continuing past a failed provider would redeploy its
462
+ * consumers against a provider that is not there — precisely the mis-binding
463
+ * the whole design exists to prevent. The unreached modules stay paused, which
464
+ * is safe and visible, and re-running completes the remainder.
465
+ */
466
+ export async function executeUnpause(plan: PausePlan, deps: PauseDeps): Promise<ExecutionReport> {
467
+ const outcomes: StepOutcome[] = [];
468
+
469
+ for (const step of plan.steps) {
470
+ if (step.disposition !== 'act') {
471
+ // An already-unpaused member is skipped WITHOUT redeploying (task 4.8) —
472
+ // safe because a failed unpause leaves its module PAUSED, so there is no
473
+ // "unpaused but never redeployed" state to repair.
474
+ outcomes.push({ moduleId: step.moduleId, result: 'skipped', detail: step.note });
475
+ continue;
476
+ }
477
+
478
+ // Read the pause metadata BEFORE the redeploy overwrites `state`, so a
479
+ // failure can restore the module to the pause it was actually in rather
480
+ // than stamping a fresh one.
481
+ const previous = deps.db
482
+ .select({ pausedAt: modules.pausedAt, pauseReason: modules.pauseReason })
483
+ .from(modules)
484
+ .where(eq(modules.id, step.moduleId))
485
+ .get();
486
+
487
+ const opId = deps.startOperation(step.moduleId, 'unpause');
488
+ let deployResult: { success: boolean; error?: string };
489
+ try {
490
+ deployResult = await deps.redeploy(step.moduleId);
491
+ } catch (err) {
492
+ deployResult = { success: false, error: err instanceof Error ? err.message : String(err) };
493
+ }
494
+
495
+ if (!deployResult.success) {
496
+ // The redeploy has already moved `state` off PAUSED (and possibly to
497
+ // ERROR). Put it back, keeping the original timestamp, and re-drop the
498
+ // subscriptions in case the deploy re-registered them: a module that is
499
+ // reported as paused must actually BE quiesced.
500
+ deps.db
501
+ .update(modules)
502
+ .set({
503
+ state: 'PAUSED',
504
+ pausedAt: previous?.pausedAt ?? deps.now(),
505
+ pauseReason: previous?.pauseReason ?? null,
506
+ updatedAt: deps.now(),
507
+ })
508
+ .where(eq(modules.id, step.moduleId))
509
+ .run();
510
+ deps.unsubscribe(step.moduleId);
511
+ deps.failOperation(opId, deployResult.error ?? 'redeploy failed');
512
+
513
+ const message = deployResult.error ?? 'redeploy failed';
514
+ deps.log(`${step.moduleId}: unpause failed — ${message}; left paused`);
515
+ outcomes.push({ moduleId: step.moduleId, result: 'failed', detail: message });
516
+
517
+ // Everything after this depends on it. Stop rather than mis-bind.
518
+ const unreached = plan.steps
519
+ .slice(plan.steps.indexOf(step) + 1)
520
+ .filter((s) => s.disposition === 'act');
521
+ for (const s of unreached) {
522
+ outcomes.push({
523
+ moduleId: s.moduleId,
524
+ result: 'skipped',
525
+ detail: 'left paused — a provider it depends on failed to unpause',
526
+ });
527
+ }
528
+ return { action: 'unpause', outcomes, success: false };
529
+ }
530
+
531
+ // The deploy set the live state; all that is left is to clear the pause.
532
+ deps.db
533
+ .update(modules)
534
+ .set({ pausedAt: null, pauseReason: null, updatedAt: deps.now() })
535
+ .where(eq(modules.id, step.moduleId))
536
+ .run();
537
+ deps.resubscribe(step.moduleId);
538
+ deps.completeOperation(opId);
539
+ deps.log(`${step.moduleId}: unpaused and redeployed`);
540
+ outcomes.push({ moduleId: step.moduleId, result: 'acted', detail: 'unpaused and redeployed' });
541
+ }
542
+
543
+ return { action: 'unpause', outcomes, success: true };
544
+ }