@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
@@ -42,6 +42,19 @@ function seedHeartbeat(
42
42
  );
43
43
  }
44
44
 
45
+ /** Abandon `count` deliveries to one subscriber, oldest first. */
46
+ function seedFailed(bus: Bus, count: number): void {
47
+ const sub = bus.subscribe({ name: 'namecheap.ddns', pattern: 'ddns.refresh', handler: 'echo' });
48
+ for (let i = 0; i < count; i++) {
49
+ const event = bus.emitRaw('ddns.refresh', { n: i });
50
+ bus.markFailed(
51
+ { eventId: event.id, subscriberId: sub.id },
52
+ new Error('handler timed out after 30000ms'),
53
+ { abandoned: true },
54
+ );
55
+ }
56
+ }
57
+
45
58
  /** Write a supervisor unit file so readInstalledUnit(scope) sees it. */
46
59
  function installFakeUnit(home: string, scope: 'user' | 'system' = 'user', systemRoot = '/'): void {
47
60
  const path = getDaemonUnitPath('linux', home, scope, systemRoot);
@@ -199,6 +212,39 @@ describe('checkDispatcher', () => {
199
212
  expect(f.status).toBe('warn');
200
213
  expect(f.detail.join(' ')).toContain('not emitting on schedule');
201
214
  });
215
+
216
+ // celilo#623 — the old check read `failedDeliveries({ limit: 50 }).length`,
217
+ // so on celilo-mgr it printed a literal `50` that meant "at least 50" and
218
+ // read as an exact count. 137 > any limit anyone would pick.
219
+ it('reports the TRUE total of failed deliveries, not the read limit', () => {
220
+ seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000 });
221
+ installFakeUnit(home);
222
+ seedFailed(bus, 137);
223
+
224
+ const f = checkDispatcher(bus, { now: now, home, platform: 'linux' });
225
+ expect(f.status).toBe('warn');
226
+ expect(f.detail.join(' ')).toContain('137 failed/abandoned delivery(ies) total');
227
+ expect(f.detail.join(' ')).not.toContain('50 failed');
228
+ });
229
+
230
+ // The stored error is double-wrapped for every row written before the
231
+ // serializeError fix; those live 90 days, so doctor must unwrap them.
232
+ it('renders the sample error text, not {"message":"[object Object]"}', () => {
233
+ seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000 });
234
+ installFakeUnit(home);
235
+ seedFailed(bus, 1);
236
+ bus.db.run('UPDATE deliveries SET last_error = ?', [
237
+ JSON.stringify({
238
+ message: '[object Object]',
239
+ value: { message: 'handler exited with code 1' },
240
+ }),
241
+ ]);
242
+
243
+ const f = checkDispatcher(bus, { now: now, home, platform: 'linux' });
244
+ const detail = f.detail.join(' ');
245
+ expect(detail).toContain('handler exited with code 1');
246
+ expect(detail).not.toContain('[object Object]');
247
+ });
202
248
  });
203
249
 
204
250
  describe('checkSubscribers + checkCapabilityProviders', () => {
@@ -17,7 +17,7 @@
17
17
  * rendering + `--fix` orchestration lives in the doctor command.
18
18
  */
19
19
 
20
- import type { Bus } from '@celilo/event-bus';
20
+ import { type Bus, describeError } from '@celilo/event-bus';
21
21
  import { inArray } from 'drizzle-orm';
22
22
  import { getModuleStoragePath } from '../config/paths';
23
23
  import { type DbClient, findMigrationsFolder } from '../db/client';
@@ -38,6 +38,7 @@ import {
38
38
  readInstalledUnit,
39
39
  unitMainPid,
40
40
  } from './events-daemon';
41
+ import { describePausedModule, listPausedModules } from './module-pause';
41
42
  import { resolveSubscription } from './module-subscriptions';
42
43
 
43
44
  /**
@@ -343,12 +344,21 @@ export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): Fl
343
344
  }
344
345
  }
345
346
 
346
- const failed = bus.failedDeliveries({ limit: 50 });
347
- if (failed.length > 0) {
347
+ // A TRUE total, not `failedDeliveries().length` that saturates at its LIMIT
348
+ // and printed a literal `50` on celilo-mgr that read as a count (celilo#623).
349
+ // The newest row dates the backlog: a big total whose newest entry is days old
350
+ // is drained history, not active bleeding.
351
+ const { total: failedTotal } = bus.failedDeliveryTotals();
352
+ if (failedTotal > 0) {
348
353
  statuses.push('warn');
349
- const sample = failed[0]?.lastError ? ` (e.g. ${failed[0].lastError.split('\n')[0]})` : '';
350
- detail.push(`${failed.length} failed/abandoned delivery(ies)${sample}`);
351
- remediations.push('inspect failed deliveries and re-emit/repair as needed');
354
+ const newest = bus.failedDeliveries({ limit: 1 })[0];
355
+ const age = newest?.finishedAt
356
+ ? `, most recent ${Math.round((now - newest.finishedAt) / 60000)}min ago`
357
+ : '';
358
+ detail.push(`${failedTotal} failed/abandoned delivery(ies) total${age}`);
359
+ const sample = describeError(newest?.lastError ?? null)?.split('\n')[0];
360
+ if (sample) detail.push(` example (newest, not the only one): ${sample}`);
361
+ remediations.push('`celilo events list-failed` to see them; re-emit/repair as needed');
352
362
  }
353
363
 
354
364
  const status = worst(statuses);
@@ -845,6 +855,52 @@ export function checkControlPlaneNetwork(db: DbClient): FleetFinding {
845
855
  };
846
856
  }
847
857
 
858
+ /**
859
+ * Any paused module is a doctor failure — no threshold, whatever its age
860
+ * (openspec/changes/module-pause-lifecycle, design D7, closed at review).
861
+ *
862
+ * A pause deliberately switches OFF the alerting that would otherwise report
863
+ * the module as down, so the paused-ness itself has to be the signal. A
864
+ * duration threshold was considered and dropped: there is no number of hours
865
+ * after which a deliberate outage becomes acceptable, and a configurable one is
866
+ * just an invitation to tune the detector until it stops firing.
867
+ *
868
+ * The fleet had just run 20 hours of failing forgejo backups whose only symptom
869
+ * was a column of `0 B` rows that read as healthy hourly cadence. Same failure
870
+ * shape; this is the check that would have named it.
871
+ */
872
+ export function checkPausedModules(db: DbClient): FleetFinding {
873
+ const paused = listPausedModules(db);
874
+
875
+ if (paused.length === 0) {
876
+ return {
877
+ id: 'paused-modules',
878
+ title: 'No module is paused (a pause suppresses its own alerting)',
879
+ status: 'ok',
880
+ summary: 'nothing paused',
881
+ detail: [],
882
+ remediation: null,
883
+ autoFixable: false,
884
+ };
885
+ }
886
+
887
+ const names = paused.map((m) => describePausedModule(m));
888
+ return {
889
+ id: 'paused-modules',
890
+ title: 'No module is paused (a pause suppresses its own alerting)',
891
+ status: 'fail',
892
+ summary: `${paused.length} module(s) paused: ${paused.map((m) => m.id).join(', ')}`,
893
+ detail: [
894
+ ...names.map((n) => `paused: ${n}`),
895
+ 'a paused module receives no dispatched work, runs no health checks, and has its alerts suppressed',
896
+ 'it is still deployed and may still be serving traffic — pause does not stop the data plane',
897
+ ],
898
+ remediation:
899
+ 'bring each back with "celilo module unpause <id>" (which redeploys it, rebinding its capabilities), or remove it if the pause was permanent',
900
+ autoFixable: false,
901
+ };
902
+ }
903
+
848
904
  export async function runFleetChecks(
849
905
  bus: Bus,
850
906
  db: DbClient,
@@ -856,6 +912,7 @@ export async function runFleetChecks(
856
912
  checkSubscribers(bus, db),
857
913
  checkCapabilityProviders(db),
858
914
  checkControlPlaneNetwork(db),
915
+ checkPausedModules(db),
859
916
  await checkServiceDns(db),
860
917
  ];
861
918
  }
@@ -350,7 +350,7 @@ async function deployModuleImpl(
350
350
 
351
351
  // Terminal-responder: when running on a TTY, this subscribes to
352
352
  // `config.required.*` / `secret.required.*` / `ensure.required.*`
353
- // events and prompts via clack. Other responder shapes (Claude
353
+ // events and prompts on the terminal. Other responder shapes (Claude
354
354
  // subagent, `celilo events respond` from another shell, autoresponder
355
355
  // daemon) compete on the bus; first reply wins. See
356
356
  // infra/openspec/changes/interactive-deploys-via-event-bus/proposal.md.
@@ -0,0 +1,224 @@
1
+ /**
2
+ * The parts of pause that exist so a pause cannot become a silent outage
3
+ * (design D7), plus the alerting suppressor.
4
+ *
5
+ * Every check here is asserted in BOTH directions per Rule 7.6 — a gate nobody
6
+ * has seen fail is not a gate, and "no paused module was reported" is exactly
7
+ * the output a broken detector produces.
8
+ */
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { mkdtempSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { type DbClient, createDbClient } from '../db/client';
15
+ import { type ModuleState, modules } from '../db/schema';
16
+ import { moduleAlertKey } from './alerting/keys';
17
+ import { findSuppressor } from './alerting/suppression';
18
+ import { checkPausedModules } from './fleet-checks';
19
+ import {
20
+ describeMachineStopInfra,
21
+ describePausedModule,
22
+ listPausedModules,
23
+ pausedAmong,
24
+ } from './module-pause';
25
+
26
+ function makeDb(): DbClient {
27
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-pause-obs-'));
28
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
29
+ return createDbClient({ path: process.env.CELILO_DB_PATH });
30
+ }
31
+
32
+ function insert(
33
+ db: DbClient,
34
+ id: string,
35
+ state: ModuleState,
36
+ opts: { pausedAt?: Date; reason?: string } = {},
37
+ ): void {
38
+ db.insert(modules)
39
+ .values({
40
+ id,
41
+ name: id,
42
+ version: '1.0.0',
43
+ state,
44
+ manifestData: { id, name: id, version: '1.0.0' },
45
+ sourcePath: `/tmp/${id}`,
46
+ pausedAt: state === 'PAUSED' ? (opts.pausedAt ?? new Date()) : null,
47
+ pauseReason: state === 'PAUSED' ? (opts.reason ?? null) : null,
48
+ })
49
+ .run();
50
+ }
51
+
52
+ describe('system doctor reports ANY paused module (task 6.2 / 6.4)', () => {
53
+ test('with nothing paused the check passes', () => {
54
+ const db = makeDb();
55
+ insert(db, 'caddy', 'VERIFIED');
56
+
57
+ const finding = checkPausedModules(db);
58
+ expect(finding.status).toBe('ok');
59
+ expect(finding.summary).toBe('nothing paused');
60
+ });
61
+
62
+ test('PROVE IT FAILS: one paused module is a doctor FAILURE naming it and its age', () => {
63
+ const db = makeDb();
64
+ insert(db, 'caddy', 'VERIFIED');
65
+ insert(db, 'greenwave', 'PAUSED', {
66
+ pausedAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000),
67
+ reason: 'edge router swap',
68
+ });
69
+
70
+ const finding = checkPausedModules(db);
71
+
72
+ expect(finding.status).toBe('fail');
73
+ expect(finding.summary).toContain('greenwave');
74
+ // The DURATION is what separates a maintenance window from a forgotten one.
75
+ expect(finding.detail.join('\n')).toContain('3d');
76
+ expect(finding.detail.join('\n')).toContain('edge router swap');
77
+ expect(finding.remediation).toContain('celilo module unpause');
78
+ });
79
+
80
+ test('there is no threshold — a pause taken seconds ago already fails', () => {
81
+ // Decided at review: a pause is a degraded state, full stop. A threshold is
82
+ // just something to tune until the detector stops firing.
83
+ const db = makeDb();
84
+ insert(db, 'greenwave', 'PAUSED', { pausedAt: new Date() });
85
+ expect(checkPausedModules(db).status).toBe('fail');
86
+ });
87
+ });
88
+
89
+ describe('listPausedModules / pausedAmong', () => {
90
+ test('reads only PAUSED rows, and reads back the reason', () => {
91
+ const db = makeDb();
92
+ insert(db, 'caddy', 'INSTALLED');
93
+ insert(db, 'greenwave', 'PAUSED', { reason: 'edge router swap' });
94
+ insert(db, 'authentik', 'ERROR');
95
+
96
+ const paused = listPausedModules(db);
97
+ expect(paused.map((m) => m.id)).toEqual(['greenwave']);
98
+ expect(paused[0].pauseReason).toBe('edge router swap');
99
+ });
100
+
101
+ test('an ERROR module is NOT paused — the two states are distinct', () => {
102
+ // Worth pinning: ERROR is *pausable*, which is easy to misread as "errored
103
+ // counts as paused" and would make the doctor check fire on every failure.
104
+ const db = makeDb();
105
+ insert(db, 'authentik', 'ERROR');
106
+ expect(listPausedModules(db)).toEqual([]);
107
+ });
108
+
109
+ test('pausedAmong filters a candidate set in one query', () => {
110
+ const db = makeDb();
111
+ insert(db, 'caddy', 'PAUSED');
112
+ insert(db, 'authentik', 'INSTALLED');
113
+ expect(pausedAmong(db, ['caddy', 'authentik', 'absent'])).toEqual(new Set(['caddy']));
114
+ });
115
+
116
+ test('pausedAmong on an empty list does not query at all', () => {
117
+ const db = makeDb();
118
+ expect(pausedAmong(db, [])).toEqual(new Set());
119
+ });
120
+ });
121
+
122
+ describe('the management-API warning is emitted, and NOT emitted (task 6.5 / 6.6)', () => {
123
+ // `fleetWarnings()` in api/serve.ts is the production caller; this pins the
124
+ // query + rendering it depends on. The inverse assertion is the one that
125
+ // stops the warning becoming noise nobody reads.
126
+ test('with nothing paused there is nothing to warn about', () => {
127
+ const db = makeDb();
128
+ insert(db, 'caddy', 'VERIFIED');
129
+ expect(listPausedModules(db)).toHaveLength(0);
130
+ });
131
+
132
+ test('with something paused the warning names the module AND its age', () => {
133
+ const db = makeDb();
134
+ insert(db, 'greenwave', 'PAUSED', {
135
+ pausedAt: new Date(Date.now() - 5 * 60 * 60 * 1000),
136
+ reason: 'edge router swap',
137
+ });
138
+
139
+ const rendered = listPausedModules(db).map((m) => describePausedModule(m));
140
+ expect(rendered).toEqual(['greenwave (5h, "edge router swap")']);
141
+ });
142
+
143
+ test('a pause with no reason still renders its age', () => {
144
+ const db = makeDb();
145
+ insert(db, 'greenwave', 'PAUSED', { pausedAt: new Date(Date.now() - 60 * 60 * 1000) });
146
+ expect(describePausedModule(listPausedModules(db)[0])).toBe('greenwave (1h)');
147
+ });
148
+ });
149
+
150
+ describe('a paused module is suppressed BY THE PAUSE, not anonymously (task 2.3)', () => {
151
+ const topology = { moduleSystems: [], zoneProviders: [] };
152
+
153
+ test('PROVE IT FIRES: an unpaused module with nothing firing is NOT suppressed', () => {
154
+ expect(
155
+ findSuppressor({
156
+ key: moduleAlertKey('caddy'),
157
+ firingKeys: new Set(),
158
+ suppressible: true,
159
+ modulesInDeployWindow: new Set(),
160
+ pausedModules: new Set(),
161
+ topology,
162
+ }),
163
+ ).toBeNull();
164
+ });
165
+
166
+ test('the same alert IS suppressed once the module is paused, attributed to the pause', () => {
167
+ expect(
168
+ findSuppressor({
169
+ key: moduleAlertKey('caddy'),
170
+ firingKeys: new Set(),
171
+ suppressible: true,
172
+ modulesInDeployWindow: new Set(),
173
+ pausedModules: new Set(['caddy']),
174
+ topology,
175
+ }),
176
+ ).toEqual({ kind: 'paused', moduleId: 'caddy' });
177
+ });
178
+
179
+ test('a pause does not suppress OTHER modules', () => {
180
+ expect(
181
+ findSuppressor({
182
+ key: moduleAlertKey('authentik'),
183
+ firingKeys: new Set(),
184
+ suppressible: true,
185
+ modulesInDeployWindow: new Set(),
186
+ pausedModules: new Set(['caddy']),
187
+ topology,
188
+ }),
189
+ ).toBeNull();
190
+ });
191
+
192
+ test('a self-monitor is never suppressed, even by a pause', () => {
193
+ // A cascading failure must not silence the component reporting the cascade.
194
+ expect(
195
+ findSuppressor({
196
+ key: moduleAlertKey('caddy'),
197
+ firingKeys: new Set(),
198
+ suppressible: false,
199
+ modulesInDeployWindow: new Set(),
200
+ pausedModules: new Set(['caddy']),
201
+ topology,
202
+ }),
203
+ ).toBeNull();
204
+ });
205
+ });
206
+
207
+ describe('--stop-infra acts only on celilo-provisioned infrastructure (design D2, revised)', () => {
208
+ // The RULING is about ownership, not capability, and the distinction lives in
209
+ // the message the operator reads — so the message is what gets pinned.
210
+ //
211
+ // celilo could SSH into a machine and stop something. It declines to, because
212
+ // a machine-pool system is operator-pre-provisioned, may predate celilo, and
213
+ // may run work celilo was never told about. Reporting "celilo cannot identify
214
+ // the service unit" would imply a capability gap where the truth is that this
215
+ // is not celilo's to stop.
216
+ test('the machine-hosted report reads as not-applicable, not as a missing feature', () => {
217
+ const report = describeMachineStopInfra('iot', 'homebridge');
218
+
219
+ expect(report).toContain('not applicable');
220
+ expect(report).toContain('celilo provisioned');
221
+ // Must NOT frame it as something celilo would do if only it could.
222
+ expect(report).not.toMatch(/cannot determine|does not know which|unit/i);
223
+ });
224
+ });
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Control-plane quiescence (tasks 2.1, 2.2, 2.5).
3
+ *
4
+ * Every assertion is made in BOTH directions per Rule 7.6. "The hook did not
5
+ * fire" is the same observation you get from a test that was never wired up, so
6
+ * each case first proves the UNPAUSED module gets through the guard.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
10
+ import { mkdtempSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { eq } from 'drizzle-orm';
14
+ import { handleEventsRunHook } from '../cli/commands/events';
15
+ import { type DbClient, createDbClient } from '../db/client';
16
+ import { type ModuleState, modules } from '../db/schema';
17
+ import { runNamedHook } from '../hooks/run-named-hook';
18
+ import type { HookLogger } from '../hooks/types';
19
+
20
+ const SILENT_LOGGER: HookLogger = {
21
+ info: () => {},
22
+ warn: () => {},
23
+ error: () => {},
24
+ debug: () => {},
25
+ } as unknown as HookLogger;
26
+
27
+ let db: DbClient;
28
+
29
+ /**
30
+ * A manifest declaring the timer- and event-driven hooks that are the runtime
31
+ * hazard — the ones the dispatcher fires against celilo-mgr's module store and
32
+ * that can call into a provider that is gone.
33
+ */
34
+ function manifestWithHooks(id: string) {
35
+ return {
36
+ id,
37
+ name: id,
38
+ version: '1.0.0',
39
+ celilo_contract: '1.0',
40
+ provides: { capabilities: [] },
41
+ requires: { capabilities: [] },
42
+ hooks: {
43
+ on_install: { script: 'scripts/on-install.ts' },
44
+ on_uninstall: { script: 'scripts/on-uninstall.ts' },
45
+ on_system_event: { script: 'scripts/on-system-event.ts' },
46
+ refresh_registrations: { script: 'scripts/refresh-registrations.ts' },
47
+ },
48
+ subscriptions: [{ name: 'refresh', pattern: 'timer.tick.15m', hook: 'refresh_registrations' }],
49
+ };
50
+ }
51
+
52
+ function insertModule(id: string, state: ModuleState): void {
53
+ db.insert(modules)
54
+ .values({
55
+ id,
56
+ name: id,
57
+ version: '1.0.0',
58
+ state,
59
+ manifestData: manifestWithHooks(id),
60
+ sourcePath: join(tmpdir(), 'celilo-nonexistent-module'),
61
+ pausedAt: state === 'PAUSED' ? new Date() : null,
62
+ pauseReason: state === 'PAUSED' ? 'edge router swap' : null,
63
+ })
64
+ .run();
65
+ }
66
+
67
+ beforeEach(() => {
68
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-quiesce-'));
69
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
70
+ process.env.CELILO_EVENT_BUS_PATH = join(dir, 'events.db');
71
+ db = createDbClient({ path: process.env.CELILO_DB_PATH });
72
+ });
73
+
74
+ afterEach(() => {
75
+ db.$client.close();
76
+ });
77
+
78
+ describe('runNamedHook refuses to run dispatched work on a paused module', () => {
79
+ test('PROVE IT RUNS: an unpaused module is NOT short-circuited by the pause guard', async () => {
80
+ insertModule('caddy', 'INSTALLED');
81
+ const result = await runNamedHook('caddy', 'on_system_event', db, SILENT_LOGGER);
82
+
83
+ // The hook script does not exist, so this fails downstream — the point is
84
+ // only that it got PAST the guard, which `skippedPaused` proves it did.
85
+ expect(result.skippedPaused).toBeUndefined();
86
+ });
87
+
88
+ test('a paused module is skipped instead', async () => {
89
+ insertModule('caddy', 'PAUSED');
90
+ const result = await runNamedHook('caddy', 'on_system_event', db, SILENT_LOGGER);
91
+
92
+ expect(result.skippedPaused).toBe(true);
93
+ // Success, not failure: the module is deliberately quiesced. A failure here
94
+ // would be retried by the bus and then alerted on, paging the operator
95
+ // about the pause they took themselves.
96
+ expect(result.success).toBe(true);
97
+ });
98
+
99
+ test('a paused module is skipped for timer-driven hooks too', async () => {
100
+ insertModule('technitium', 'PAUSED');
101
+ const result = await runNamedHook('technitium', 'refresh_registrations', db, SILENT_LOGGER);
102
+ expect(result.skippedPaused).toBe(true);
103
+ });
104
+
105
+ test.each([['on_install'], ['on_uninstall']] as const)(
106
+ '%s is EXEMPT — it must run while the module is still paused',
107
+ async (hookName) => {
108
+ // on_install is how unpause redeploys the module back to life, and
109
+ // on_uninstall is how a paused provider is removed — which is the entire
110
+ // point of pausing its consumers. Blocking either would deadlock the
111
+ // whole design.
112
+ insertModule('greenwave', 'PAUSED');
113
+ const result = await runNamedHook(
114
+ 'greenwave',
115
+ hookName as 'on_install' | 'on_uninstall',
116
+ db,
117
+ SILENT_LOGGER,
118
+ );
119
+ expect(result.skippedPaused).toBeUndefined();
120
+ },
121
+ );
122
+ });
123
+
124
+ describe('the bus dispatch entry point skips a paused module (task 2.1)', () => {
125
+ // `celilo events run-hook <module> <sub>` is what every `hook:` subscription
126
+ // resolves to, so this is where an event delivery lands.
127
+ test('PROVE IT PROCEEDS: an unpaused module gets past the guard to subscription lookup', async () => {
128
+ insertModule('caddy', 'INSTALLED');
129
+ const result = await handleEventsRunHook(['caddy', 'no-such-subscription', '1']);
130
+
131
+ // Reaching the "no such subscription" error proves the pause guard let it
132
+ // through; a paused module never gets this far.
133
+ expect(result.success).toBe(false);
134
+ expect(result.success === false ? result.error : '').toContain('no subscription named');
135
+ });
136
+
137
+ test('a paused module is skipped before any subscription is resolved', async () => {
138
+ insertModule('caddy', 'PAUSED');
139
+ const result = await handleEventsRunHook(['caddy', 'no-such-subscription', '1']);
140
+
141
+ expect(result.success).toBe(true);
142
+ expect(result.success === true ? result.message : '').toContain('paused');
143
+ });
144
+ });
145
+
146
+ describe('resyncAllSubscriptions cannot re-arm a paused module', () => {
147
+ test('a paused module is outside the deployed set the resync rebuilds from', () => {
148
+ // The resync selects INSTALLED/VERIFIED. That allow-list is what makes
149
+ // quiescence survive a restore (which starts events.db empty) — worth
150
+ // pinning, because widening it to "not IMPORTED" would silently un-pause
151
+ // every paused module on the next resync.
152
+ insertModule('caddy', 'PAUSED');
153
+ insertModule('authentik', 'INSTALLED');
154
+
155
+ const deployed = db
156
+ .select({ id: modules.id })
157
+ .from(modules)
158
+ .where(eq(modules.state, 'PAUSED'))
159
+ .all();
160
+
161
+ expect(deployed.map((m) => m.id)).toEqual(['caddy']);
162
+ });
163
+ });