@celilo/cli 4.0.0 → 5.0.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 (119) hide show
  1. package/AGENTS.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +1 -0
  3. package/drizzle/0034_private_routes.sql +41 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +5 -4
  6. package/src/api/sessions.test.ts +1 -1
  7. package/src/capabilities/registration.test.ts +11 -11
  8. package/src/capabilities/secrets.test.ts +18 -18
  9. package/src/capabilities/validation.test.ts +24 -24
  10. package/src/cli/cli.test.ts +3 -3
  11. package/src/cli/command-tree-parser.test.ts +18 -0
  12. package/src/cli/command-tree-parser.ts +12 -0
  13. package/src/cli/commands/api.ts +67 -14
  14. package/src/cli/commands/events.ts +5 -2
  15. package/src/cli/commands/machine-reclassify.ts +79 -0
  16. package/src/cli/commands/module-changeset.test.ts +1 -1
  17. package/src/cli/commands/module-generate.test.ts +2 -2
  18. package/src/cli/commands/module-import-aspect.test.ts +2 -2
  19. package/src/cli/commands/module-update.test.ts +14 -14
  20. package/src/cli/commands/module-upgrade-gate.test.ts +26 -1
  21. package/src/cli/commands/module-upgrade.test.ts +3 -3
  22. package/src/cli/commands/module-upgrade.ts +12 -0
  23. package/src/cli/commands/module-version.test.ts +1 -1
  24. package/src/cli/commands/system-audit.ts +22 -0
  25. package/src/cli/commands/system-reboot.ts +126 -0
  26. package/src/cli/commands/system-update.ts +5 -0
  27. package/src/cli/completion.ts +3 -2
  28. package/src/cli/index.ts +41 -2
  29. package/src/cli/restore-migration-failure.test.ts +1 -1
  30. package/src/cli/stdout-pipe-flush.test.ts +35 -0
  31. package/src/cli/tui/audit-state.ts +4 -0
  32. package/src/db/private-routes.test.ts +159 -0
  33. package/src/db/schema.ts +49 -0
  34. package/src/hooks/broker.ts +31 -0
  35. package/src/hooks/capability-loader.ts +185 -12
  36. package/src/hooks/executor.test.ts +13 -13
  37. package/src/hooks/executor.ts +20 -2
  38. package/src/hooks/hook-state-dir.test.ts +2 -2
  39. package/src/hooks/test-fixtures/slow-capability-hook.ts +28 -0
  40. package/src/hooks/test-fixtures/store-backed.ts +5 -3
  41. package/src/hooks/timeout-names-provider.test.ts +87 -0
  42. package/src/manifest/contracts/index.ts +28 -7
  43. package/src/manifest/contracts/v2.ts +32 -0
  44. package/src/manifest/icon-schema.test.ts +1 -1
  45. package/src/manifest/schema.ts +13 -2
  46. package/src/manifest/template-validator.test.ts +1 -1
  47. package/src/manifest/validate-privileged.test.ts +1 -1
  48. package/src/manifest/validate.test.ts +68 -29
  49. package/src/manifest/validate.ts +14 -1
  50. package/src/module/import.test.ts +7 -7
  51. package/src/module/packaging/build.test.ts +1 -1
  52. package/src/packaging/nfpm-depends.test.ts +42 -0
  53. package/src/policy/capability-shape-baseline.ts +7 -1
  54. package/src/policy/fixture-capability-coverage.test.ts +5 -5
  55. package/src/policy/module-business-baseline.ts +33 -4
  56. package/src/services/api-access.test.ts +44 -0
  57. package/src/services/aspect-approvals.test.ts +2 -2
  58. package/src/services/aspect-reconcile.test.ts +2 -2
  59. package/src/services/aspect-runner.test.ts +4 -4
  60. package/src/services/audit/backups.test.ts +1 -1
  61. package/src/services/audit/capability-abi.test.ts +1 -1
  62. package/src/services/audit/index.test.ts +7 -0
  63. package/src/services/audit/index.ts +15 -0
  64. package/src/services/audit/machine-interface-zones.test.ts +30 -0
  65. package/src/services/audit/machine-interface-zones.ts +43 -0
  66. package/src/services/audit/module-configs.test.ts +1 -1
  67. package/src/services/audit/reboot-pending.test.ts +63 -0
  68. package/src/services/audit/reboot-pending.ts +85 -0
  69. package/src/services/audit/recurrence-gate.test.ts +21 -0
  70. package/src/services/audit/server-bun-pin.test.ts +44 -0
  71. package/src/services/audit/server-bun-pin.ts +95 -0
  72. package/src/services/audit/types.ts +2 -0
  73. package/src/services/build-bus/hook-dispatch-executor.test.ts +3 -3
  74. package/src/services/bus-interview-park.test.ts +1 -1
  75. package/src/services/bus-secret-flow.test.ts +1 -1
  76. package/src/services/capability-compat.test.ts +22 -0
  77. package/src/services/capability-compat.ts +15 -2
  78. package/src/services/capability-table-rows.test.ts +20 -2
  79. package/src/services/celilo-events.test.ts +119 -6
  80. package/src/services/celilo-events.ts +156 -16
  81. package/src/services/celilo-mgmt-hooks.test.ts +6 -3
  82. package/src/services/cross-module-read.test.ts +2 -2
  83. package/src/services/deploy-preflight.ts +12 -3
  84. package/src/services/deploy-validation.test.ts +86 -7
  85. package/src/services/deploy-validation.ts +19 -0
  86. package/src/services/dns-provider-backfill.test.ts +2 -2
  87. package/src/services/dns-registrations.test.ts +1 -1
  88. package/src/services/fleet-checks.test.ts +2 -2
  89. package/src/services/fleet-checks.ts +40 -10
  90. package/src/services/health-runner.test.ts +1 -1
  91. package/src/services/infrastructure-variable-resolver.test.ts +11 -11
  92. package/src/services/instance-ops.test.ts +2 -2
  93. package/src/services/machine-interface-zones.test.ts +73 -0
  94. package/src/services/machine-interface-zones.ts +101 -0
  95. package/src/services/module-deploy.dns-repoint.test.ts +1 -1
  96. package/src/services/module-deploy.stale-provider-guard.test.ts +98 -0
  97. package/src/services/module-deploy.ts +31 -10
  98. package/src/services/module-pause-quiescence.test.ts +1 -1
  99. package/src/services/module-pause.test.ts +1 -1
  100. package/src/services/module-subscriptions.test.ts +2 -2
  101. package/src/services/module-types-generator.test.ts +1 -1
  102. package/src/services/module-validator/contract-version.test.ts +3 -3
  103. package/src/services/network-ensure.test.ts +1 -1
  104. package/src/services/provider-arrival.test.ts +62 -46
  105. package/src/services/provider-arrival.ts +41 -10
  106. package/src/services/proxmox-reconcile.test.ts +2 -2
  107. package/src/services/remove-guard.test.ts +1 -1
  108. package/src/services/system-reboot.test.ts +231 -0
  109. package/src/services/system-reboot.ts +357 -0
  110. package/src/services/update/dep-graph.test.ts +1 -1
  111. package/src/services/update/orchestrator.test.ts +165 -1
  112. package/src/services/update/orchestrator.ts +43 -12
  113. package/src/services/update/types.ts +10 -1
  114. package/src/templates/generator.test.ts +15 -4
  115. package/src/templates/generator.ts +12 -4
  116. package/src/variables/capability-self-ref.test.ts +2 -2
  117. package/src/variables/computed/computed-integration.test.ts +1 -1
  118. package/src/variables/context.ts +14 -1
  119. package/src/variables/declarative-derivation.test.ts +19 -19
@@ -121,7 +121,7 @@ describe('the dispatcher dispatches on_upstream_publish through the ordinary exe
121
121
  id: 'upstream-fixture',
122
122
  name: 'upstream-fixture',
123
123
  version: '1.0.0',
124
- celilo_contract: '1.0',
124
+ celilo_contract: '2.0',
125
125
  provides: { capabilities: [] },
126
126
  requires: { capabilities: [] },
127
127
  hooks: { on_upstream_publish: [hookEntry] },
@@ -178,7 +178,7 @@ describe('the dispatcher dispatches on_upstream_publish through the ordinary exe
178
178
  id: 'upstream-fixture',
179
179
  name: 'upstream-fixture',
180
180
  version: '1.0.0',
181
- celilo_contract: '1.0',
181
+ celilo_contract: '2.0',
182
182
  provides: { capabilities: [] },
183
183
  requires: { capabilities: [] },
184
184
  hooks: { on_upstream_publish: [hookEntry] },
@@ -232,7 +232,7 @@ test('a failing hook run lands in the build_bus_hook_runs ledger', async () => {
232
232
  id: 'upstream-fixture',
233
233
  name: 'upstream-fixture',
234
234
  version: '1.0.0',
235
- celilo_contract: '1.0',
235
+ celilo_contract: '2.0',
236
236
  provides: { capabilities: [] },
237
237
  requires: { capabilities: [] },
238
238
  hooks: { on_upstream_publish: [hookEntry] },
@@ -71,7 +71,7 @@ beforeEach(() => {
71
71
  name: 'iptables',
72
72
  sourcePath: join(dir, 'installed'),
73
73
  version: '1.0.2+9',
74
- manifestData: { celilo_contract: '1.0', id: 'iptables', name: 'iptables', version: '1.0.2' },
74
+ manifestData: { celilo_contract: '2.0', id: 'iptables', name: 'iptables', version: '1.0.2' },
75
75
  })
76
76
  .run();
77
77
 
@@ -396,7 +396,7 @@ describe('bus-mediated interviewForMissingSecrets', () => {
396
396
  writeFileSync(
397
397
  join(tempDir, 'manifest.yml'),
398
398
  `
399
- celilo_contract: "1.0"
399
+ celilo_contract: "2.0"
400
400
  id: testmod
401
401
  name: Test Module
402
402
  version: 1.0.0
@@ -24,6 +24,28 @@ function provider(
24
24
  }
25
25
 
26
26
  describe('unservedCapabilityRequirements', () => {
27
+ /**
28
+ * celilo#1384. Optional in PRESENCE, not in contract: with no provider the
29
+ * module is fine, but an installed provider on another major is called by
30
+ * the module's hooks and fails there — as a warning, silently.
31
+ */
32
+ test('an optional capability with no provider is not a blocker', () => {
33
+ expect(
34
+ unservedCapabilityRequirements([], [], [{ name: 'external_web', version: '1.0.0' }]),
35
+ ).toEqual([]);
36
+ });
37
+
38
+ test('an optional capability whose installed provider is another major IS a blocker', () => {
39
+ const result = unservedCapabilityRequirements(
40
+ [],
41
+ [provider('external_web', 'generic-cpanel', '2.0.0')],
42
+ [{ name: 'external_web', version: '1.0.0' }],
43
+ );
44
+ expect(result).toHaveLength(1);
45
+ expect(result[0]?.capability).toBe('external_web');
46
+ expect(result[0]?.providerModuleId).toBe('generic-cpanel');
47
+ });
48
+
27
49
  test('a requirement any deployed provider serves is not a blocker', () => {
28
50
  const providers = [provider('public_web', 'caddy', '4.0.0')];
29
51
  expect(
@@ -53,15 +53,27 @@ export interface UnservedCapability {
53
53
  export function unservedCapabilityRequirements(
54
54
  requirements: CapabilityRequirement[] | undefined,
55
55
  providers: DeployedCapabilityProvider[],
56
+ /**
57
+ * Capabilities the module uses only if present. Their provider may be
58
+ * absent, but an installed provider on another major breaks at runtime
59
+ * exactly as a required one would (celilo#1384), so they take the version
60
+ * check and skip the presence check.
61
+ */
62
+ optional: CapabilityRequirement[] | undefined = undefined,
56
63
  ): UnservedCapability[] {
57
64
  const unserved: UnservedCapability[] = [];
58
- for (const cap of requirements ?? []) {
65
+ const consumed = [
66
+ ...(requirements ?? []).map((cap) => ({ cap, required: true })),
67
+ ...(optional ?? []).map((cap) => ({ cap, required: false })),
68
+ ];
69
+ for (const { cap, required } of consumed) {
59
70
  // Framework-granted privileges (e.g. cross_module_read) are not
60
71
  // provider-backed — the same skip `deploy-preflight` makes.
61
72
  if (isPrivilegedCapability(cap.name)) continue;
62
73
 
63
74
  const installed = providers.filter((p) => p.capabilityName === cap.name);
64
75
  if (installed.length === 0) {
76
+ if (!required) continue;
65
77
  unserved.push({
66
78
  capability: cap.name,
67
79
  required: cap.version,
@@ -119,10 +131,11 @@ export function readDeployedProviders(db: DbClient): DeployedCapabilityProvider[
119
131
  */
120
132
  export function capabilityBlockersForManifest(
121
133
  db: DbClient,
122
- manifest: Pick<ModuleManifest, 'requires' | 'id'>,
134
+ manifest: Pick<ModuleManifest, 'requires' | 'optional' | 'id'>,
123
135
  ): UnservedCapability[] {
124
136
  return unservedCapabilityRequirements(
125
137
  manifest.requires?.capabilities as CapabilityRequirement[] | undefined,
126
138
  readDeployedProviders(db),
139
+ manifest.optional?.capabilities as CapabilityRequirement[] | undefined,
127
140
  );
128
141
  }
@@ -18,7 +18,14 @@ import { tmpdir } from 'node:os';
18
18
  import { join } from 'node:path';
19
19
  import { allDeclaredTables } from '@celilo/capabilities';
20
20
  import type { DbClient } from '../db/client';
21
- import { dnsInternalRecords, modules, portForwards, trustedSources, webRoutes } from '../db/schema';
21
+ import {
22
+ dnsInternalRecords,
23
+ modules,
24
+ portForwards,
25
+ privateRoutes,
26
+ trustedSources,
27
+ webRoutes,
28
+ } from '../db/schema';
22
29
  import { setupTestDatabaseAt } from '../test-utils/database';
23
30
  import { resetTestDbPath } from '../test-utils/db-path';
24
31
  import { deleteClaimedRows, planClaimedRowDeletion } from './capability-table-rows';
@@ -89,6 +96,15 @@ describe('deleteClaimedRows', () => {
89
96
  hostname,
90
97
  })
91
98
  .run();
99
+ db.insert(privateRoutes)
100
+ .values({
101
+ slug: `${consumer}-private-slug`,
102
+ moduleId: consumer,
103
+ type: 'reverse_proxy',
104
+ path: `/${consumer}`,
105
+ hostname: `private-${hostname}`,
106
+ })
107
+ .run();
92
108
  db.insert(portForwards)
93
109
  .values({
94
110
  firewallIp: FW,
@@ -109,7 +125,7 @@ describe('deleteClaimedRows', () => {
109
125
  .run();
110
126
  }
111
127
 
112
- it('clears the departing consumer from all four declared tables at once', () => {
128
+ it('clears the departing consumer from every declared table at once', () => {
113
129
  seed('caddy', 'a.example.org', 443, '10.1.0.0/24');
114
130
 
115
131
  const cleared = deleteClaimedRows(db, 'caddy');
@@ -117,10 +133,12 @@ describe('deleteClaimedRows', () => {
117
133
  expect(cleared.map((c) => c.table).sort()).toEqual([
118
134
  'dns_internal_records',
119
135
  'port_forwards',
136
+ 'private_routes',
120
137
  'trusted_sources',
121
138
  'web_routes',
122
139
  ]);
123
140
  expect(db.select().from(webRoutes).all()).toHaveLength(0);
141
+ expect(db.select().from(privateRoutes).all()).toHaveLength(0);
124
142
  expect(db.select().from(portForwards).all()).toHaveLength(0);
125
143
  expect(db.select().from(trustedSources).all()).toHaveLength(0);
126
144
  expect(db.select().from(dnsInternalRecords).all()).toHaveLength(0);
@@ -16,6 +16,7 @@ import {
16
16
  emitWebRoutesChanged,
17
17
  emitWebRoutesChangedAndWait,
18
18
  routesChangedHighWater,
19
+ subscriberBudgetMs,
19
20
  waitForRouteReconcile,
20
21
  } from './celilo-events';
21
22
 
@@ -171,12 +172,13 @@ describe('celilo lifecycle events', () => {
171
172
  });
172
173
 
173
174
  describe('deploy-waits for web-route reconcile (ISS-0035)', () => {
174
- function subscribeReconciler(): void {
175
+ function subscribeReconciler(budget?: { maxAttempts: number; timeoutMs: number }): void {
175
176
  const bus = openBus({ dbPath, events: defineEvents({}) });
176
177
  bus.subscribe({
177
178
  name: 'caddy.reconcile-web-routes',
178
179
  pattern: 'public_web.routes_changed',
179
180
  handler: 'unused',
181
+ ...budget,
180
182
  });
181
183
  bus.close();
182
184
  }
@@ -208,6 +210,30 @@ describe('celilo lifecycle events', () => {
208
210
  bus.close();
209
211
  }
210
212
 
213
+ function settleAllDeliveries(): void {
214
+ const bus = openBus({ dbPath, events: defineEvents({}) });
215
+ const sub = bus.getSubscriberByName('caddy.reconcile-web-routes');
216
+ if (!sub) throw new Error('expected a subscriber');
217
+ for (const event of bus.recentEvents({ type: 'public_web.routes_changed', limit: 100 })) {
218
+ bus.markSucceeded({ eventId: event.id, subscriberId: sub.id });
219
+ }
220
+ bus.close();
221
+ }
222
+
223
+ /** One failed attempt that will be retried — the delivery stays pending. */
224
+ function recordRetryingFailure(message: string): void {
225
+ const bus = openBus({ dbPath, events: defineEvents({}) });
226
+ const sub = bus.getSubscriberByName('caddy.reconcile-web-routes');
227
+ const event = bus.recentEvents({ type: 'public_web.routes_changed', limit: 1 })[0];
228
+ if (!sub || !event) {
229
+ throw new Error('expected a subscriber and an emitted event');
230
+ }
231
+ bus.markFailed({ eventId: event.id, subscriberId: sub.id }, message, {
232
+ retryAfter: Date.now() + 60_000,
233
+ });
234
+ bus.close();
235
+ }
236
+
211
237
  it('routesChangedHighWater is 0 with no events, then the latest id', () => {
212
238
  expect(routesChangedHighWater()).toBe(0);
213
239
  emitWebRoutesChanged('celilo-website');
@@ -217,11 +243,12 @@ describe('celilo lifecycle events', () => {
217
243
  it('returns immediately when the deploy changed no routes', async () => {
218
244
  const since = routesChangedHighWater();
219
245
  const result = await waitForRouteReconcile(since, { timeoutMs: 1000 });
220
- expect(result).toEqual({
246
+ expect(result).toMatchObject({
221
247
  events: 0,
222
248
  succeeded: 0,
223
249
  failed: 0,
224
250
  timedOut: false,
251
+ lastError: null,
225
252
  noDispatcher: false,
226
253
  });
227
254
  });
@@ -232,11 +259,12 @@ describe('celilo lifecycle events', () => {
232
259
  emitWebRoutesChanged('celilo-website');
233
260
  // No heartbeat stamped → health() is no_dispatcher.
234
261
  const result = await waitForRouteReconcile(since, { timeoutMs: 5000, pollMs: 50 });
235
- expect(result).toEqual({
262
+ expect(result).toMatchObject({
236
263
  events: 1,
237
264
  succeeded: 0,
238
265
  failed: 0,
239
266
  timedOut: false,
267
+ lastError: null,
240
268
  noDispatcher: true,
241
269
  });
242
270
  });
@@ -246,11 +274,12 @@ describe('celilo lifecycle events', () => {
246
274
  const since = routesChangedHighWater();
247
275
  emitWebRoutesChanged('celilo-website');
248
276
  const result = await waitForRouteReconcile(since, { timeoutMs: 1000, pollMs: 50 });
249
- expect(result).toEqual({
277
+ expect(result).toMatchObject({
250
278
  events: 1,
251
279
  succeeded: 0,
252
280
  failed: 0,
253
281
  timedOut: false,
282
+ lastError: null,
254
283
  noDispatcher: false,
255
284
  });
256
285
  });
@@ -265,6 +294,26 @@ describe('celilo lifecycle events', () => {
265
294
  expect(result.timedOut).toBe(true);
266
295
  });
267
296
 
297
+ /**
298
+ * celilo#1401. A handler that CRASHES is retried, so between attempts its
299
+ * delivery is `pending` with the error already recorded. The deadline hits
300
+ * in that window and the caller used to report a bare timeout — `0 ok, 0
301
+ * failed of 1` — which is what hid a caddy crash for days.
302
+ */
303
+ it('carries the crashing handler error out of a timeout', async () => {
304
+ markDispatcherAlive();
305
+ subscribeReconciler();
306
+ const since = routesChangedHighWater();
307
+ emitWebRoutesChanged('celilo-website');
308
+ // Failed but NOT abandoned: it goes back to pending for the next attempt.
309
+ recordRetryingFailure('{} is not iterable');
310
+
311
+ const result = await waitForRouteReconcile(since, { timeoutMs: 300, pollMs: 50 });
312
+ expect(result.timedOut).toBe(true);
313
+ expect(result.failed).toBe(0);
314
+ expect(result.lastError).toContain('{} is not iterable');
315
+ });
316
+
268
317
  it('completes once the provider delivery succeeds', async () => {
269
318
  markDispatcherAlive();
270
319
  subscribeReconciler();
@@ -273,11 +322,12 @@ describe('celilo lifecycle events', () => {
273
322
  settleDelivery('succeed');
274
323
 
275
324
  const result = await waitForRouteReconcile(since, { timeoutMs: 1000, pollMs: 50 });
276
- expect(result).toEqual({
325
+ expect(result).toMatchObject({
277
326
  events: 1,
278
327
  succeeded: 1,
279
328
  failed: 0,
280
329
  timedOut: false,
330
+ lastError: null,
281
331
  noDispatcher: false,
282
332
  });
283
333
  });
@@ -295,6 +345,68 @@ describe('celilo lifecycle events', () => {
295
345
  expect(result.timedOut).toBe(false);
296
346
  });
297
347
 
348
+ /**
349
+ * celilo#1416. The waiter's deadline was the constant 90s while caddy's
350
+ * subscription is `timeout_ms: 90000, max_attempts: 3` — so the ENTIRE wait
351
+ * equalled ONE attempt and a successful retry could never be observed. The
352
+ * budget must come from the subscriber's own registration.
353
+ */
354
+ it('budgets for the subscriber whole retry allowance, not one attempt', async () => {
355
+ markDispatcherAlive();
356
+ subscribeReconciler({ maxAttempts: 3, timeoutMs: 90_000 });
357
+ const since = routesChangedHighWater();
358
+ emitWebRoutesChanged('celilo-website');
359
+ settleDelivery('succeed');
360
+
361
+ const result = await waitForRouteReconcile(since, { pollMs: 50 });
362
+ expect(result.succeeded).toBe(1);
363
+ expect(result.budgetMs).toBeGreaterThanOrEqual(subscriberBudgetMs(3, 90_000));
364
+ });
365
+
366
+ /** A deploy emitting N change events waits for N of them, not for one. */
367
+ it('scales the budget with the number of change events', async () => {
368
+ markDispatcherAlive();
369
+ subscribeReconciler({ maxAttempts: 2, timeoutMs: 60_000 });
370
+ const since = routesChangedHighWater();
371
+ emitWebRoutesChanged('celilo-website');
372
+ emitWebRoutesChanged('celilo-website');
373
+ settleAllDeliveries();
374
+
375
+ const result = await waitForRouteReconcile(since, { pollMs: 50 });
376
+ expect(result.events).toBe(2);
377
+ expect(result.budgetMs).toBeGreaterThanOrEqual(2 * subscriberBudgetMs(2, 60_000));
378
+ });
379
+
380
+ /** The end-to-end shape: one failed attempt, then a success, reports SUCCESS. */
381
+ it('reports success when the provider fails once and then succeeds', async () => {
382
+ markDispatcherAlive();
383
+ subscribeReconciler({ maxAttempts: 3, timeoutMs: 400 });
384
+ const since = routesChangedHighWater();
385
+ emitWebRoutesChanged('celilo-website');
386
+ recordRetryingFailure('transient boom');
387
+
388
+ const pending = waitForRouteReconcile(since, { pollMs: 50 });
389
+ await new Promise((r) => setTimeout(r, 120));
390
+ settleDelivery('succeed');
391
+
392
+ const result = await pending;
393
+ expect(result.timedOut).toBe(false);
394
+ expect(result.succeeded).toBe(1);
395
+ });
396
+
397
+ /** An expiry with deliveries in flight is not "nobody consumed it". */
398
+ it('separates deliveries still running from settled failures on expiry', async () => {
399
+ markDispatcherAlive();
400
+ subscribeReconciler();
401
+ const since = routesChangedHighWater();
402
+ emitWebRoutesChanged('celilo-website');
403
+
404
+ const result = await waitForRouteReconcile(since, { timeoutMs: 300, pollMs: 50 });
405
+ expect(result.timedOut).toBe(true);
406
+ expect(result.stillRunning).toBe(1);
407
+ expect(result.failed).toBe(0);
408
+ });
409
+
298
410
  it('emitWebRoutesChangedAndWait emits then waits for the reconcile to settle', async () => {
299
411
  markDispatcherAlive();
300
412
  subscribeReconciler();
@@ -308,11 +420,12 @@ describe('celilo lifecycle events', () => {
308
420
  settleDelivery('succeed');
309
421
 
310
422
  const result = await pending;
311
- expect(result).toEqual({
423
+ expect(result).toMatchObject({
312
424
  events: 1,
313
425
  succeeded: 1,
314
426
  failed: 0,
315
427
  timedOut: false,
428
+ lastError: null,
316
429
  noDispatcher: false,
317
430
  });
318
431
  });
@@ -184,12 +184,36 @@ export function emitSystemDestroyed(payload: SystemDestroyedPayload): void {
184
184
  * Best-effort — a failed emit never breaks register_route; the route is already
185
185
  * persisted in web_routes and the provider's next deploy reconciles it anyway.
186
186
  */
187
- export function emitWebRoutesChanged(triggeredBy: string): void {
188
- emitBest('public_web.routes_changed', { triggeredBy });
187
+ export function emitWebRoutesChanged(
188
+ triggeredBy: string,
189
+ type: RoutesChangedType = ROUTES_CHANGED_TYPE,
190
+ ): void {
191
+ emitBest(type, { triggeredBy });
189
192
  }
190
193
 
191
194
  const ROUTES_CHANGED_TYPE = 'public_web.routes_changed';
192
195
 
196
+ /**
197
+ * The private ingress's twin of {@link ROUTES_CHANGED_TYPE}
198
+ * (`providers-converge-declared-state` slice 2). The two ingresses are
199
+ * deliberately SEPARATE event types rather than one shared signal: a private
200
+ * route lives in `private_routes`, a public one in `web_routes` (celilo#846),
201
+ * and waking the wrong provider would make it re-render from a table it does
202
+ * not serve.
203
+ */
204
+ const PRIVATE_ROUTES_CHANGED_TYPE = 'private_web.routes_changed';
205
+
206
+ /**
207
+ * Which ingress's route table changed. Every function below defaults to the
208
+ * public type, so existing callers are unaffected by construction.
209
+ */
210
+ export type RoutesChangedType = typeof ROUTES_CHANGED_TYPE | typeof PRIVATE_ROUTES_CHANGED_TYPE;
211
+
212
+ /** Emit `private_web.routes_changed` — the private ingress's coarse signal. */
213
+ export function emitPrivateRoutesChanged(triggeredBy: string): void {
214
+ emitWebRoutesChanged(triggeredBy, PRIVATE_ROUTES_CHANGED_TYPE);
215
+ }
216
+
193
217
  /**
194
218
  * Emit `public_web.routes_changed` and block until the provider's reconcile
195
219
  * delivery settles (ISS-0035). This is what `register_route` / `unregister_routes`
@@ -205,10 +229,30 @@ const ROUTES_CHANGED_TYPE = 'public_web.routes_changed';
205
229
  export async function emitWebRoutesChangedAndWait(
206
230
  triggeredBy: string,
207
231
  opts?: { timeoutMs?: number; pollMs?: number },
232
+ type: RoutesChangedType = ROUTES_CHANGED_TYPE,
208
233
  ): Promise<RouteReconcileWaitResult> {
209
- const since = routesChangedHighWater();
210
- emitWebRoutesChanged(triggeredBy);
211
- return waitForRouteReconcile(since, opts);
234
+ const since = routesChangedHighWater(type);
235
+ emitWebRoutesChanged(triggeredBy, type);
236
+ return waitForRouteReconcile(since, opts, type);
237
+ }
238
+
239
+ /**
240
+ * The private ingress's twin of {@link emitWebRoutesChangedAndWait}
241
+ * (`providers-converge-declared-state` slice 2, D7).
242
+ *
243
+ * `registerReverseProxy` awaits THIS, so it still returns only once the route
244
+ * is actually live — the synchronous guarantee the in-call reconcile used to
245
+ * give, now provider-agnostic and on one path with the deploy.
246
+ *
247
+ * ⚠️ Callers must honour `noDispatcher` (ISS-0081): it means the event was
248
+ * persisted but never DELIVERED, so the route is NOT live. Treating it as a
249
+ * benign zero is the silent-success shape this change exists to remove.
250
+ */
251
+ export async function emitPrivateRoutesChangedAndWait(
252
+ triggeredBy: string,
253
+ opts?: { timeoutMs?: number; pollMs?: number },
254
+ ): Promise<RouteReconcileWaitResult> {
255
+ return emitWebRoutesChangedAndWait(triggeredBy, opts, PRIVATE_ROUTES_CHANGED_TYPE);
212
256
  }
213
257
 
214
258
  /**
@@ -222,6 +266,17 @@ export interface RouteReconcileWaitResult {
222
266
  succeeded: number;
223
267
  failed: number;
224
268
  timedOut: boolean;
269
+ /**
270
+ * The handler's own error text from the most recent settled-or-retrying
271
+ * delivery, or null when no delivery has reported one.
272
+ *
273
+ * A handler that CRASHES is retried, so for `max_attempts` retries its
274
+ * delivery is back in `pending` with an error already recorded. The deadline
275
+ * can hit in that window, and the caller then reported a timeout for what was
276
+ * a crash — `0 ok, 0 failed of 1` with the real cause nowhere in sight
277
+ * (celilo#1401, which hid a caddy crash for days behind a deadline message).
278
+ */
279
+ lastError: string | null;
225
280
  /**
226
281
  * True when no event dispatcher was running, so the `routes_changed` event
227
282
  * was persisted but never DELIVERED to the provider (caddy). The route is
@@ -229,6 +284,19 @@ export interface RouteReconcileWaitResult {
229
284
  * failure, not a benign "0 deliveries" success.
230
285
  */
231
286
  noDispatcher: boolean;
287
+ /**
288
+ * Deliveries still `pending`/`running` when the deadline hit. Non-zero means
289
+ * the provider had NOT given up — its retries were still in flight — which is
290
+ * a different thing from a failure and must not be reported as one
291
+ * (celilo#1416: "0 ok, 0 failed of 1" read as "nobody consumed it").
292
+ */
293
+ stillRunning: number;
294
+ /**
295
+ * The deadline actually used, in ms. Derived from the subscribers' own retry
296
+ * budgets unless the caller passed `timeoutMs`, so the message can say what
297
+ * it waited for rather than quoting a constant.
298
+ */
299
+ budgetMs: number;
232
300
  }
233
301
 
234
302
  /**
@@ -236,11 +304,11 @@ export interface RouteReconcileWaitResult {
236
304
  * exist yet. A deploy captures this before it runs so {@link waitForRouteReconcile}
237
305
  * can tell which route-change events the deploy itself produced.
238
306
  */
239
- export function routesChangedHighWater(): number {
307
+ export function routesChangedHighWater(type: RoutesChangedType = ROUTES_CHANGED_TYPE): number {
240
308
  let bus: ReturnType<typeof openBus> | undefined;
241
309
  try {
242
310
  bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
243
- return bus.recentEvents({ type: ROUTES_CHANGED_TYPE, limit: 1 })[0]?.id ?? 0;
311
+ return bus.recentEvents({ type, limit: 1 })[0]?.id ?? 0;
244
312
  } catch (err) {
245
313
  const msg = err instanceof Error ? err.message : String(err);
246
314
  console.warn(`[celilo] failed to read routes-changed high-water: ${msg}`);
@@ -250,6 +318,27 @@ export function routesChangedHighWater(): number {
250
318
  }
251
319
  }
252
320
 
321
+ /**
322
+ * A subscriber's whole retry budget: every attempt's handler timeout plus the
323
+ * dispatcher's exponential backoff between them (1s, 2s, 4s..., capped at 5m —
324
+ * `backoffMs` in the event bus's dispatcher).
325
+ *
326
+ * Exported so the manifest gate that keeps consumer hook timeouts above this
327
+ * can compute the same number from the same formula rather than a second copy
328
+ * of it (celilo#1416).
329
+ */
330
+ export function subscriberBudgetMs(maxAttempts: number, timeoutMs: number): number {
331
+ const attempts = Math.max(1, maxAttempts);
332
+ let budget = attempts * timeoutMs;
333
+ for (let attempt = 1; attempt < attempts; attempt++) {
334
+ budget += Math.min(1000 * 2 ** (attempt - 1), 5 * 60 * 1000);
335
+ }
336
+ return budget;
337
+ }
338
+
339
+ /** Slack over the derived budget, so the wait outlives the last attempt. */
340
+ const RECONCILE_BUDGET_SLACK_MS = 5_000;
341
+
253
342
  function sleep(ms: number): Promise<void> {
254
343
  return new Promise((resolve) => setTimeout(resolve, ms));
255
344
  }
@@ -273,10 +362,15 @@ function sleep(ms: number): Promise<void> {
273
362
  export async function waitForRouteReconcile(
274
363
  sinceEventId: number,
275
364
  opts: { timeoutMs?: number; pollMs?: number } = {},
365
+ type: RoutesChangedType = ROUTES_CHANGED_TYPE,
276
366
  ): Promise<RouteReconcileWaitResult> {
277
- const timeoutMs = opts.timeoutMs ?? 90_000;
278
367
  const pollMs = opts.pollMs ?? 250;
279
- const deadline = Date.now() + timeoutMs;
368
+ // The deadline is DERIVED below, once we know which subscribers are on the
369
+ // hook and what retry budget each was registered with. A constant here is
370
+ // what celilo#1416 was: 90s, exactly ONE attempt of a subscriber configured
371
+ // for three, so a successful retry could never be observed.
372
+ let budgetMs = opts.timeoutMs ?? 0;
373
+ let deadline = opts.timeoutMs !== undefined ? Date.now() + opts.timeoutMs : 0;
280
374
 
281
375
  let bus: ReturnType<typeof openBus> | undefined;
282
376
  try {
@@ -285,24 +379,44 @@ export async function waitForRouteReconcile(
285
379
 
286
380
  // The deploy's emits already happened, so the set of route-change events
287
381
  // is fixed; only their deliveries transition as the dispatcher works.
288
- const events = b
289
- .recentEvents({ type: ROUTES_CHANGED_TYPE, limit: 100 })
290
- .filter((e) => e.id > sinceEventId);
382
+ const events = b.recentEvents({ type, limit: 100 }).filter((e) => e.id > sinceEventId);
291
383
 
292
384
  if (events.length === 0) {
293
- return { events: 0, succeeded: 0, failed: 0, timedOut: false, noDispatcher: false };
385
+ return {
386
+ events: 0,
387
+ succeeded: 0,
388
+ failed: 0,
389
+ timedOut: false,
390
+ lastError: null,
391
+ noDispatcher: false,
392
+ stillRunning: 0,
393
+ budgetMs,
394
+ };
294
395
  }
295
396
 
296
- if (b.health().status === 'no_dispatcher') {
397
+ // Confirmed, not instant: a dispatcher starved by the deploy's own load looks
398
+ // exactly like a dead one for a single sample, and abandoning the reconcile on
399
+ // that reading is celilo#1399 — it failed consumers' re-registration during the
400
+ // one operation that generates route changes. `lagging` waits like any healthy
401
+ // bus; the deadline below still bounds it.
402
+ const health = await b.healthConfirmed();
403
+ if (health.status === 'no_dispatcher' || health.status === 'wedged') {
404
+ const why =
405
+ health.status === 'wedged'
406
+ ? `dispatcher heartbeat stale with nothing running (wedged, ${Math.round((health.lastHeartbeatAgeMs ?? 0) / 1000)}s)`
407
+ : 'no dispatcher running';
297
408
  console.warn(
298
- `[celilo] route-reconcile: no dispatcher running — not waiting; ${events.length} change event(s) persisted, the provider will reconcile on its next run`,
409
+ `[celilo] route-reconcile: ${why} — not waiting; ${events.length} change event(s) persisted, the provider will reconcile on its next run`,
299
410
  );
300
411
  return {
301
412
  events: events.length,
302
413
  succeeded: 0,
303
414
  failed: 0,
304
415
  timedOut: false,
416
+ lastError: null,
305
417
  noDispatcher: true,
418
+ stillRunning: 0,
419
+ budgetMs,
306
420
  };
307
421
  }
308
422
 
@@ -313,13 +427,30 @@ export async function waitForRouteReconcile(
313
427
  (d) => d.status === 'pending' || d.status === 'running',
314
428
  ).length;
315
429
 
430
+ if (deadline === 0) {
431
+ // Every subscriber gets its whole registered retry budget, and a deploy
432
+ // that emitted N change events is waiting on N of them — budgeting for
433
+ // one is how a two-event deploy charged both against a single 90s wall.
434
+ const subscriberIds = [...new Set(deliveries.map((d) => d.subscriberId))];
435
+ const worst = subscriberIds.reduce((max, id) => {
436
+ const sub = b.getSubscriber(id);
437
+ if (!sub) return max;
438
+ return Math.max(max, subscriberBudgetMs(sub.maxAttempts, sub.timeoutMs));
439
+ }, 0);
440
+ budgetMs = worst * events.length + RECONCILE_BUDGET_SLACK_MS;
441
+ deadline = Date.now() + budgetMs;
442
+ }
443
+
316
444
  if (pending === 0 || Date.now() >= deadline) {
317
445
  return {
318
446
  events: events.length,
319
447
  succeeded: settled('succeeded'),
320
448
  failed: settled('failed') + settled('abandoned'),
321
449
  timedOut: pending > 0,
450
+ lastError: deliveries.find((d) => d.lastError)?.lastError ?? null,
322
451
  noDispatcher: false,
452
+ stillRunning: pending,
453
+ budgetMs,
323
454
  };
324
455
  }
325
456
 
@@ -328,7 +459,16 @@ export async function waitForRouteReconcile(
328
459
  } catch (err) {
329
460
  const msg = err instanceof Error ? err.message : String(err);
330
461
  console.warn(`[celilo] route-reconcile wait errored: ${msg}`);
331
- return { events: 0, succeeded: 0, failed: 0, timedOut: false, noDispatcher: false };
462
+ return {
463
+ events: 0,
464
+ succeeded: 0,
465
+ failed: 0,
466
+ timedOut: false,
467
+ lastError: null,
468
+ noDispatcher: false,
469
+ stillRunning: 0,
470
+ budgetMs,
471
+ };
332
472
  } finally {
333
473
  bus?.close();
334
474
  }