@celilo/cli 0.4.1 → 0.5.0-alpha.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.4.1",
3
+ "version": "0.5.0-alpha.1",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { findNodeForVmid } from './proxmox';
3
+
4
+ describe('findNodeForVmid (ISS-0090 — Proxmox is source of truth for current location)', () => {
5
+ // A trimmed /cluster/resources payload: guest rows carry a vmid; node/storage
6
+ // rows do not (only a node name).
7
+ const resources = [
8
+ { node: 'node2' }, // node row — no vmid
9
+ { node: 'node3' }, // storage row — no vmid
10
+ { vmid: 200, node: 'node2' }, // caddy
11
+ { vmid: 201, node: 'node3' }, // authentik
12
+ ];
13
+
14
+ test('returns the node a vmid currently lives on', () => {
15
+ expect(findNodeForVmid(resources, 200)).toBe('node2');
16
+ expect(findNodeForVmid(resources, 201)).toBe('node3');
17
+ });
18
+
19
+ test('returns null when the vmid is absent — container not created yet (first deploy)', () => {
20
+ expect(findNodeForVmid(resources, 999)).toBeNull();
21
+ });
22
+
23
+ test('never matches a node/storage row that has no vmid', () => {
24
+ expect(findNodeForVmid([{ node: 'node2' }, { node: 'node3' }], 200)).toBeNull();
25
+ });
26
+
27
+ test('returns null for an empty inventory', () => {
28
+ expect(findNodeForVmid([], 200)).toBeNull();
29
+ });
30
+ });
@@ -114,6 +114,18 @@ async function makeProxmoxRequest<T>(
114
114
  });
115
115
  });
116
116
 
117
+ // Fail fast instead of hanging on an unreachable host (no implicit timeout
118
+ // on https.request). Callers treat a failed result as "couldn't reach
119
+ // Proxmox" and fall back accordingly.
120
+ req.setTimeout(15_000, () => {
121
+ req.destroy();
122
+ resolve({
123
+ success: false,
124
+ message: 'Request timed out',
125
+ details: { timeoutMs: 15_000 },
126
+ });
127
+ });
128
+
117
129
  req.end();
118
130
  } catch (error) {
119
131
  resolve({
@@ -486,6 +498,51 @@ export async function listNodeStorage(
486
498
  return makeProxmoxRequest(credentials, `/nodes/${nodeName}/storage`);
487
499
  }
488
500
 
501
+ /**
502
+ * Find which Proxmox node a given VMID currently lives on.
503
+ *
504
+ * Queries the cluster resource inventory (`/cluster/resources`), which lists
505
+ * every guest across all nodes with its current node, and matches by VMID
506
+ * (unique cluster-wide). Returns the node name, or `null` if the VMID isn't
507
+ * present — i.e. the container hasn't been created yet (a first deploy).
508
+ *
509
+ * ISS-0090: this is celilo's source of truth for WHERE a system currently is.
510
+ * A redeploy must target the node Proxmox reports here, NOT re-derive placement
511
+ * from the service's `default_target_node` (which only governs new placement) —
512
+ * otherwise a changed default tries to relocate every running container.
513
+ */
514
+ export async function getNodeForVmid(
515
+ credentials: ProxmoxCredentials,
516
+ vmid: number,
517
+ ): Promise<ProxmoxResult<string | null>> {
518
+ // makeProxmoxRequest sends only url.pathname, so a `?type=vm` filter would be
519
+ // dropped — fetch the full inventory and match by vmid client-side instead.
520
+ const result = await makeProxmoxRequest<Array<{ vmid?: number; node?: string }>>(
521
+ credentials,
522
+ '/cluster/resources',
523
+ );
524
+
525
+ if (!result.success) {
526
+ return result;
527
+ }
528
+
529
+ return { success: true, data: findNodeForVmid(result.data, vmid) };
530
+ }
531
+
532
+ /**
533
+ * Find the node a VMID lives on within a Proxmox cluster-resource list. Pure
534
+ * matching logic, split out from the network call for testability (Rule 10).
535
+ * Non-guest entries (storage/node rows) have no `vmid` and are skipped. Returns
536
+ * the node name, or `null` when the VMID isn't present.
537
+ */
538
+ export function findNodeForVmid(
539
+ resources: Array<{ vmid?: number; node?: string }>,
540
+ vmid: number,
541
+ ): string | null {
542
+ const match = resources.find((r) => typeof r.vmid === 'number' && r.vmid === vmid);
543
+ return match?.node ?? null;
544
+ }
545
+
489
546
  /**
490
547
  * List available LXC templates in storage
491
548
  */
@@ -6,7 +6,7 @@
6
6
  import { eq } from 'drizzle-orm';
7
7
  import { getDb } from '../../db/client';
8
8
  import { modules } from '../../db/schema';
9
- import type { ModuleManifest } from '../../manifest/schema';
9
+ import { type ModuleManifest, getSingularSystemSpec } from '../../manifest/schema';
10
10
  import { buildResolutionContext } from '../../variables/context';
11
11
  import { getArg, validateRequiredArgs } from '../parser';
12
12
  import type { CommandResult } from '../types';
@@ -60,7 +60,7 @@ export async function handleModuleShowConfig(args: string[]): Promise<CommandRes
60
60
 
61
61
  for (const [key, value] of Object.entries(context.selfConfig)) {
62
62
  // Skip internal/derived keys that aren't useful for display
63
- if (key.startsWith('requires.machine.')) continue;
63
+ if (key.startsWith('requires.system.')) continue;
64
64
 
65
65
  const line = ` ${key} = ${value}`;
66
66
 
@@ -152,7 +152,7 @@ export async function handleModuleShowZone(args: string[]): Promise<CommandResul
152
152
  const context = await buildResolutionContext(moduleId, db);
153
153
  const manifest = module.manifestData as ModuleManifest;
154
154
 
155
- const zone = context.selfConfig.zone || manifest.requires?.machine?.zone;
155
+ const zone = context.selfConfig.zone || getSingularSystemSpec(manifest)?.zone;
156
156
 
157
157
  if (!zone) {
158
158
  return {
@@ -9,7 +9,7 @@ import { join } from 'node:path';
9
9
  import { eq } from 'drizzle-orm';
10
10
  import { getDb } from '../../db/client';
11
11
  import { capabilities, moduleConfigs, modules, systemConfig, systemSecrets } from '../../db/schema';
12
- import type { ModuleManifest } from '../../manifest/schema';
12
+ import { type ModuleManifest, getSingularSystemSpec } from '../../manifest/schema';
13
13
  import type { CommandResult } from '../types';
14
14
 
15
15
  /**
@@ -131,7 +131,7 @@ export async function handleStatus(): Promise<CommandResult> {
131
131
  lines.push(` Status: ${statusInfo.status}`);
132
132
 
133
133
  // Type: VPS or Container
134
- const zone = manifest.requires?.machine?.zone;
134
+ const zone = getSingularSystemSpec(manifest)?.zone;
135
135
  if (zone) {
136
136
  lines.push(` Type: Container (${zone.toUpperCase()})`);
137
137
 
@@ -368,13 +368,24 @@ export async function loadCapabilityFunctions(
368
368
  // otherwise race the async reconcile.
369
369
  onRoutesChanged: async () => {
370
370
  const reconcile = await emitWebRoutesChangedAndWait(consumingModuleId);
371
+ // Authoritative (ISS-0081): a route the provider (caddy) never
372
+ // reconciled is a deploy FAILURE, not a warning. These used to be
373
+ // logger.warn while register_route returned success anyway, so a
374
+ // consumer could report "ready" while caddy never learned the
375
+ // hostname — no site block, no cert, TLS internal_error for clients.
376
+ if (reconcile.noDispatcher) {
377
+ throw new Error(
378
+ `public_web route for ${consumingModuleId} was persisted but NOT delivered to the provider (caddy): no event dispatcher is running, so caddy never reconciled and the hostname has no site block or cert. Run this through \`celilo module deploy\` (which runs the dispatcher) rather than a bare hook.`,
379
+ );
380
+ }
371
381
  if (reconcile.timedOut) {
372
- logger.warn(
373
- `public_web reconcile for ${consumingModuleId} did not finish within the deadline (${reconcile.succeeded} ok, ${reconcile.failed} failed of ${reconcile.events} change event(s)); the route is persisted and the provider will reconcile on its next run`,
382
+ throw new Error(
383
+ `public_web reconcile for ${consumingModuleId} did not finish within the deadline (${reconcile.succeeded} ok, ${reconcile.failed} failed of ${reconcile.events} change event(s)) caddy did not confirm the route is live.`,
374
384
  );
375
- } else if (reconcile.failed > 0) {
376
- logger.warn(
377
- `public_web reconcile for ${consumingModuleId}: ${reconcile.failed} delivery(ies) failed; the route is persisted but the provider config may be stale`,
385
+ }
386
+ if (reconcile.failed > 0) {
387
+ throw new Error(
388
+ `public_web reconcile for ${consumingModuleId}: ${reconcile.failed} delivery(ies) failed — caddy could not apply the route, so the hostname is not served.`,
378
389
  );
379
390
  }
380
391
  },
@@ -306,7 +306,7 @@ export const UpstreamPublishHookSchema = z.object({
306
306
  * Machine resource recommendations
307
307
  * Module declares recommended machine resources (CPU, memory, disk, storage)
308
308
  */
309
- export const MachineResourceSchema = z.object({
309
+ export const SystemResourceSchema = z.object({
310
310
  cpu: z.number().int().positive().optional().describe('Recommended CPU cores'),
311
311
  memory: z.number().int().positive().optional().describe('Recommended memory in MB'),
312
312
  disk: z.number().int().positive().optional().describe('Recommended disk size in GB'),
@@ -328,7 +328,7 @@ export const SystemDeclarationSchema = z.object({
328
328
  .string()
329
329
  .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'system name must be kebab-case')
330
330
  .describe('Stable handle for this system; used in $infra:<name> and as the per-system key'),
331
- resources: MachineResourceSchema,
331
+ resources: SystemResourceSchema,
332
332
  });
333
333
 
334
334
  /**
@@ -439,17 +439,17 @@ export const ModuleManifestSchema = z
439
439
  capabilities: z.array(CapabilityRequirementSchema).default([]),
440
440
  /**
441
441
  * The 0..N systems this module deploys (v2/MODULE_SYSTEMS_ADDRESSING.md).
442
- * Preferred over the legacy singular `machine`. A module declaring
442
+ * Preferred over the singular `system`. A module declaring
443
443
  * `systems` gets one host per entry, each addressable as `$infra:<name>`.
444
444
  */
445
445
  systems: z.array(SystemDeclarationSchema).optional(),
446
446
  /**
447
- * Legacy singular form — sugar for a single unnamed system. Normalized
448
- * to one `systems` entry (name `main`) by `getDeclaredSystems`. Optional
449
- * because config-only providers (e.g. namecheap) deploy no infrastructure.
450
- * New modules should prefer `systems`.
447
+ * Singular form — sugar for a single unnamed system. Normalized to one
448
+ * `systems` entry (name `main`) by `getDeclaredSystems`. Optional because
449
+ * config-only providers (e.g. namecheap) deploy no infrastructure.
450
+ * Modules declaring more than one host should use `systems`.
451
451
  */
452
- machine: MachineResourceSchema.optional(),
452
+ system: SystemResourceSchema.optional(),
453
453
  })
454
454
  .default({ capabilities: [] }),
455
455
 
@@ -730,25 +730,36 @@ export type LifecycleHook = z.infer<typeof LifecycleHookSchema>;
730
730
  export type VariableSource = z.infer<typeof VariableSourceSchema>;
731
731
  export type VariableType = z.infer<typeof VariableTypeSchema>;
732
732
  export type AnsibleCollection = z.infer<typeof AnsibleCollectionSchema>;
733
- export type MachineResource = z.infer<typeof MachineResourceSchema>;
733
+ export type SystemResource = z.infer<typeof SystemResourceSchema>;
734
734
  export type SystemDeclaration = z.infer<typeof SystemDeclarationSchema>;
735
735
  export type BaseModuleAspect = NonNullable<ModuleManifest['base_module_aspect']>;
736
736
  export type BaseModuleAspectTrigger = BaseModuleAspect['triggers'][number];
737
737
 
738
738
  /**
739
739
  * Normalize a manifest's declared systems (v2/MODULE_SYSTEMS_ADDRESSING.md).
740
- * Returns `requires.systems` if present; else the legacy singular
741
- * `requires.machine` as one entry named `main`; else `[]` (config-only module
742
- * like namecheap). This is the single place the machine→systems sugar lives, so
743
- * the rest of the codebase only ever reasons about the 0..N collection.
740
+ * Returns `requires.systems` if present; else the singular `requires.system`
741
+ * as one entry named `main`; else `[]` (config-only module like namecheap).
742
+ * This is the single place the system→systems sugar lives, so the rest of the
743
+ * codebase only ever reasons about the 0..N collection.
744
744
  */
745
745
  export function getDeclaredSystems(manifest: ModuleManifest): SystemDeclaration[] {
746
746
  const requires = manifest.requires;
747
747
  if (requires?.systems && requires.systems.length > 0) {
748
748
  return requires.systems;
749
749
  }
750
- if (requires?.machine) {
751
- return [{ name: 'main', resources: requires.machine }];
750
+ const singular = getSingularSystemSpec(manifest);
751
+ if (singular) {
752
+ return [{ name: 'main', resources: singular }];
752
753
  }
753
754
  return [];
754
755
  }
756
+
757
+ /**
758
+ * The singular system resource spec a module declares: `requires.system`. The
759
+ * one place callers read the single-system spec (zone, sizing, "does this module
760
+ * need infra?"). Returns undefined for config-only modules or those declaring the
761
+ * plural `requires.systems`.
762
+ */
763
+ export function getSingularSystemSpec(manifest: ModuleManifest): SystemResource | undefined {
764
+ return manifest.requires?.system;
765
+ }
@@ -14,7 +14,7 @@ function createTestManifest(overrides?: Partial<ModuleManifest>): ModuleManifest
14
14
  description: 'Test module',
15
15
  requires: {
16
16
  capabilities: [],
17
- machine: {
17
+ system: {
18
18
  cpu: 2,
19
19
  memory: 2048,
20
20
  disk: 20,
@@ -54,16 +54,16 @@ function createTestManifest(overrides?: Partial<ModuleManifest>): ModuleManifest
54
54
 
55
55
  describe('template-validator', () => {
56
56
  describe('validateModuleTemplates - valid references', () => {
57
- test('validates nested manifest paths (requires.machine.*)', async () => {
57
+ test('validates nested manifest paths (requires.system.*)', async () => {
58
58
  const manifest = createTestManifest();
59
59
 
60
- // Test that requires.machine.cpu is valid
61
- const machine = manifest.requires.machine;
60
+ // Test that requires.system.cpu is valid
61
+ const system = manifest.requires.system;
62
62
 
63
- expect(machine?.cpu).toBe(2);
64
- expect(machine?.memory).toBe(2048);
65
- expect(machine?.disk).toBe(20);
66
- expect(machine?.storage).toBe('local-lvm');
63
+ expect(system?.cpu).toBe(2);
64
+ expect(system?.memory).toBe(2048);
65
+ expect(system?.disk).toBe(20);
66
+ expect(system?.storage).toBe('local-lvm');
67
67
  });
68
68
 
69
69
  test('validates capability references', async () => {
@@ -26,7 +26,7 @@ export interface TemplateValidationResult {
26
26
  * Returns undefined if path doesn't exist
27
27
  *
28
28
  * @param obj - Object to traverse
29
- * @param path - Dot-separated path (e.g., 'requires.machine.cpu')
29
+ * @param path - Dot-separated path (e.g., 'requires.system.cpu')
30
30
  * @returns Value if found, undefined otherwise
31
31
  */
32
32
  function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
@@ -50,7 +50,7 @@ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
50
50
  * Check if a path exists in the manifest
51
51
  *
52
52
  * @param manifest - Module manifest
53
- * @param path - Dot-separated path (e.g., 'requires.machine.cpu')
53
+ * @param path - Dot-separated path (e.g., 'requires.system.cpu')
54
54
  * @returns True if path exists in manifest
55
55
  */
56
56
  function pathExistsInManifest(manifest: ModuleManifest, path: string): boolean {
@@ -126,7 +126,7 @@ function validateSelfVariable(
126
126
  return `Self variable '${path}' not found in module configuration`;
127
127
  }
128
128
 
129
- // Check if it's a nested path (e.g., $self:requires.machine.cpu)
129
+ // Check if it's a nested path (e.g., $self:requires.system.cpu)
130
130
  if (pathExistsInManifest(manifest, path)) {
131
131
  return null; // Valid
132
132
  }
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
+ import { getSingularSystemSpec } from './schema';
2
3
  import {
3
4
  validateCapabilityNames,
4
5
  validateCapabilityRequirements,
@@ -42,7 +43,7 @@ description: HomeKit bridge for smart home devices
42
43
 
43
44
  requires:
44
45
  capabilities: []
45
- machine:
46
+ system:
46
47
  cpu: 1
47
48
  memory: 1024
48
49
  disk: 20
@@ -71,8 +72,8 @@ variables:
71
72
  if (result.success) {
72
73
  expect(result.data.id).toBe('homebridge');
73
74
  expect(result.data.variables.owns).toHaveLength(2);
74
- expect(result.data.requires.machine?.cpu).toBe(1);
75
- expect(result.data.requires.machine?.zone).toBe('app');
75
+ expect(result.data.requires.system?.cpu).toBe(1);
76
+ expect(result.data.requires.system?.zone).toBe('app');
76
77
  }
77
78
  });
78
79
 
@@ -442,7 +443,7 @@ build:
442
443
  }
443
444
  });
444
445
 
445
- test('should validate manifest with VM resource recommendations under requires.machine', () => {
446
+ test('should validate manifest with VM resources under requires.system (canonical name)', () => {
446
447
  const yaml = `
447
448
  ${CONTRACT_LINE}
448
449
  id: grafana
@@ -451,7 +452,7 @@ version: 1.0.0
451
452
 
452
453
  requires:
453
454
  capabilities: []
454
- machine:
455
+ system:
455
456
  cpu: 2
456
457
  memory: 2048
457
458
  disk: 20
@@ -463,11 +464,11 @@ requires:
463
464
 
464
465
  expect(result.success).toBe(true);
465
466
  if (result.success) {
466
- expect(result.data.requires.machine?.cpu).toBe(2);
467
- expect(result.data.requires.machine?.memory).toBe(2048);
468
- expect(result.data.requires.machine?.disk).toBe(20);
469
- expect(result.data.requires.machine?.storage).toBe('local-lvm');
470
- expect(result.data.requires.machine?.zone).toBe('app');
467
+ expect(result.data.requires.system?.cpu).toBe(2);
468
+ expect(result.data.requires.system?.memory).toBe(2048);
469
+ expect(result.data.requires.system?.zone).toBe('app');
470
+ // getSingularSystemSpec resolves the canonical field…
471
+ expect(getSingularSystemSpec(result.data)?.cpu).toBe(2);
471
472
  }
472
473
  });
473
474
 
@@ -480,7 +481,7 @@ version: 1.0.0
480
481
 
481
482
  requires:
482
483
  capabilities: []
483
- machine:
484
+ system:
484
485
  zone: dmz
485
486
  `;
486
487
 
@@ -488,9 +489,9 @@ requires:
488
489
 
489
490
  expect(result.success).toBe(true);
490
491
  if (result.success) {
491
- expect(result.data.requires.machine?.zone).toBe('dmz');
492
- expect(result.data.requires.machine?.cpu).toBeUndefined();
493
- expect(result.data.requires.machine?.memory).toBeUndefined();
492
+ expect(result.data.requires.system?.zone).toBe('dmz');
493
+ expect(result.data.requires.system?.cpu).toBeUndefined();
494
+ expect(result.data.requires.system?.memory).toBeUndefined();
494
495
  }
495
496
  });
496
497
 
@@ -503,7 +504,7 @@ version: 1.0.0
503
504
 
504
505
  requires:
505
506
  capabilities: []
506
- machine:
507
+ system:
507
508
  cpu: -2
508
509
  zone: app
509
510
  `;
@@ -521,7 +522,7 @@ version: 1.0.0
521
522
 
522
523
  requires:
523
524
  capabilities: []
524
- machine:
525
+ system:
525
526
  zone: invalid
526
527
  `;
527
528
 
@@ -538,7 +539,7 @@ version: 1.0.0
538
539
 
539
540
  requires:
540
541
  capabilities: []
541
- machine:
542
+ system:
542
543
  cpu: 2.5
543
544
  zone: app
544
545
  `;
@@ -3,7 +3,7 @@ import { parse as parseYaml } from 'yaml';
3
3
  import type { ZodError } from 'zod';
4
4
  import { validateModuleZoneRequirements } from '../services/zone-policy';
5
5
  import { resolveContract, supportedContractVersions } from './contracts';
6
- import { ModuleManifestSchema } from './schema';
6
+ import { ModuleManifestSchema, getSingularSystemSpec } from './schema';
7
7
  import type { ModuleManifest } from './schema';
8
8
 
9
9
  /**
@@ -304,16 +304,16 @@ export function validateVariableSources(manifest: ModuleManifest): ValidationErr
304
304
  export function validateZoneRequirements(manifest: ModuleManifest): ValidationError | null {
305
305
  const errors: Array<{ path: string; message: string }> = [];
306
306
 
307
- // Check if module has infrastructure spec (requires.machine)
308
- const hasInfrastructureSpec = manifest.requires?.machine;
307
+ // Check if module has infrastructure spec (requires.system)
308
+ const hasInfrastructureSpec = getSingularSystemSpec(manifest);
309
309
 
310
310
  // If module requires infrastructure, zone field is mandatory
311
311
  if (hasInfrastructureSpec) {
312
- const zone = manifest.requires?.machine?.zone;
312
+ const zone = hasInfrastructureSpec.zone;
313
313
 
314
314
  if (!zone) {
315
315
  errors.push({
316
- path: 'requires.machine.zone',
316
+ path: 'requires.system.zone',
317
317
  message: 'Zone field is required for modules with infrastructure requirements',
318
318
  });
319
319
  // Can't validate zone requirements without a zone
@@ -327,7 +327,7 @@ export function validateZoneRequirements(manifest: ModuleManifest): ValidationEr
327
327
 
328
328
  if (!zoneValidation.valid && zoneValidation.error) {
329
329
  errors.push({
330
- path: 'requires.machine.zone',
330
+ path: 'requires.system.zone',
331
331
  message: zoneValidation.error,
332
332
  });
333
333
  }
@@ -10,7 +10,7 @@ import { getModuleStoragePath } from '../config/paths';
10
10
  import { type DbClient, getDb } from '../db/client';
11
11
  import { capabilities, moduleIntegrity, modules } from '../db/schema';
12
12
  import type { NewModule, NewModuleIntegrity } from '../db/schema';
13
- import type { ModuleManifest } from '../manifest/schema';
13
+ import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema';
14
14
  import {
15
15
  validateCapabilityNames,
16
16
  validateDeriveFromSources,
@@ -348,7 +348,7 @@ export async function validateWellKnownCapabilities(
348
348
  }
349
349
 
350
350
  // Check 2: Zone enforcement - module must be in the correct zone
351
- const moduleZone = manifest.requires?.machine?.zone;
351
+ const moduleZone = getSingularSystemSpec(manifest)?.zone;
352
352
 
353
353
  if (wellKnown.zone_enforced && moduleZone) {
354
354
  if (moduleZone !== wellKnown.required_zone) {
@@ -217,7 +217,13 @@ describe('celilo lifecycle events', () => {
217
217
  it('returns immediately when the deploy changed no routes', async () => {
218
218
  const since = routesChangedHighWater();
219
219
  const result = await waitForRouteReconcile(since, { timeoutMs: 1000 });
220
- expect(result).toEqual({ events: 0, succeeded: 0, failed: 0, timedOut: false });
220
+ expect(result).toEqual({
221
+ events: 0,
222
+ succeeded: 0,
223
+ failed: 0,
224
+ timedOut: false,
225
+ noDispatcher: false,
226
+ });
221
227
  });
222
228
 
223
229
  it('skips the wait (no time-out) when no dispatcher is running', async () => {
@@ -226,7 +232,13 @@ describe('celilo lifecycle events', () => {
226
232
  emitWebRoutesChanged('celilo-website');
227
233
  // No heartbeat stamped → health() is no_dispatcher.
228
234
  const result = await waitForRouteReconcile(since, { timeoutMs: 5000, pollMs: 50 });
229
- expect(result).toEqual({ events: 1, succeeded: 0, failed: 0, timedOut: false });
235
+ expect(result).toEqual({
236
+ events: 1,
237
+ succeeded: 0,
238
+ failed: 0,
239
+ timedOut: false,
240
+ noDispatcher: true,
241
+ });
230
242
  });
231
243
 
232
244
  it('settles immediately when no provider subscribes (zero deliveries)', async () => {
@@ -234,7 +246,13 @@ describe('celilo lifecycle events', () => {
234
246
  const since = routesChangedHighWater();
235
247
  emitWebRoutesChanged('celilo-website');
236
248
  const result = await waitForRouteReconcile(since, { timeoutMs: 1000, pollMs: 50 });
237
- expect(result).toEqual({ events: 1, succeeded: 0, failed: 0, timedOut: false });
249
+ expect(result).toEqual({
250
+ events: 1,
251
+ succeeded: 0,
252
+ failed: 0,
253
+ timedOut: false,
254
+ noDispatcher: false,
255
+ });
238
256
  });
239
257
 
240
258
  it('times out while the provider delivery is still pending', async () => {
@@ -255,7 +273,13 @@ describe('celilo lifecycle events', () => {
255
273
  settleDelivery('succeed');
256
274
 
257
275
  const result = await waitForRouteReconcile(since, { timeoutMs: 1000, pollMs: 50 });
258
- expect(result).toEqual({ events: 1, succeeded: 1, failed: 0, timedOut: false });
276
+ expect(result).toEqual({
277
+ events: 1,
278
+ succeeded: 1,
279
+ failed: 0,
280
+ timedOut: false,
281
+ noDispatcher: false,
282
+ });
259
283
  });
260
284
 
261
285
  it('reports a failed delivery without timing out', async () => {
@@ -284,7 +308,13 @@ describe('celilo lifecycle events', () => {
284
308
  settleDelivery('succeed');
285
309
 
286
310
  const result = await pending;
287
- expect(result).toEqual({ events: 1, succeeded: 1, failed: 0, timedOut: false });
311
+ expect(result).toEqual({
312
+ events: 1,
313
+ succeeded: 1,
314
+ failed: 0,
315
+ timedOut: false,
316
+ noDispatcher: false,
317
+ });
288
318
  });
289
319
  });
290
320
  });
@@ -222,6 +222,13 @@ export interface RouteReconcileWaitResult {
222
222
  succeeded: number;
223
223
  failed: number;
224
224
  timedOut: boolean;
225
+ /**
226
+ * True when no event dispatcher was running, so the `routes_changed` event
227
+ * was persisted but never DELIVERED to the provider (caddy). The route is
228
+ * therefore NOT live. Authoritative callers (ISS-0081) must treat this as a
229
+ * failure, not a benign "0 deliveries" success.
230
+ */
231
+ noDispatcher: boolean;
225
232
  }
226
233
 
227
234
  /**
@@ -283,14 +290,20 @@ export async function waitForRouteReconcile(
283
290
  .filter((e) => e.id > sinceEventId);
284
291
 
285
292
  if (events.length === 0) {
286
- return { events: 0, succeeded: 0, failed: 0, timedOut: false };
293
+ return { events: 0, succeeded: 0, failed: 0, timedOut: false, noDispatcher: false };
287
294
  }
288
295
 
289
296
  if (b.health().status === 'no_dispatcher') {
290
297
  console.warn(
291
298
  `[celilo] route-reconcile: no dispatcher running — not waiting; ${events.length} change event(s) persisted, the provider will reconcile on its next run`,
292
299
  );
293
- return { events: events.length, succeeded: 0, failed: 0, timedOut: false };
300
+ return {
301
+ events: events.length,
302
+ succeeded: 0,
303
+ failed: 0,
304
+ timedOut: false,
305
+ noDispatcher: true,
306
+ };
294
307
  }
295
308
 
296
309
  while (true) {
@@ -306,6 +319,7 @@ export async function waitForRouteReconcile(
306
319
  succeeded: settled('succeeded'),
307
320
  failed: settled('failed') + settled('abandoned'),
308
321
  timedOut: pending > 0,
322
+ noDispatcher: false,
309
323
  };
310
324
  }
311
325
 
@@ -314,7 +328,7 @@ export async function waitForRouteReconcile(
314
328
  } catch (err) {
315
329
  const msg = err instanceof Error ? err.message : String(err);
316
330
  console.warn(`[celilo] route-reconcile wait errored: ${msg}`);
317
- return { events: 0, succeeded: 0, failed: 0, timedOut: false };
331
+ return { events: 0, succeeded: 0, failed: 0, timedOut: false, noDispatcher: false };
318
332
  } finally {
319
333
  bus?.close();
320
334
  }