@celilo/cli 0.16.2 → 0.18.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 (64) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +39 -9
  3. package/drizzle/0019_backup_pid.sql +18 -0
  4. package/drizzle/meta/_journal.json +7 -0
  5. package/package.json +5 -5
  6. package/schemas/system_config.json +1 -1
  7. package/src/cli/command-tree-parser.ts +0 -1
  8. package/src/cli/commands/alerts-poll.ts +26 -1
  9. package/src/cli/commands/backup-sweep.ts +62 -0
  10. package/src/cli/commands/module-operations.test.ts +45 -1
  11. package/src/cli/commands/module-operations.ts +35 -12
  12. package/src/cli/commands/module-show.ts +1 -0
  13. package/src/cli/commands/storage-set-path.test.ts +281 -0
  14. package/src/cli/commands/storage-set-path.ts +190 -0
  15. package/src/cli/commands/system-audit.ts +14 -0
  16. package/src/cli/commands/system-migrate.ts +40 -0
  17. package/src/cli/commands/system-update.ts +6 -0
  18. package/src/cli/completion.ts +24 -3
  19. package/src/cli/fuel-gauge.ts +0 -1
  20. package/src/cli/generate-zsh-completion.ts +1 -1
  21. package/src/cli/index.ts +12 -0
  22. package/src/cli/tui/audit-state.test.ts +15 -1
  23. package/src/cli/tui/audit-state.ts +6 -0
  24. package/src/cli/tui/audit-tui.test.tsx +0 -1
  25. package/src/db/schema.ts +53 -9
  26. package/src/hooks/capability-loader.ts +30 -1
  27. package/src/ipam/allocator.ts +13 -3
  28. package/src/services/alerting/builtin-monitors.test.ts +42 -0
  29. package/src/services/alerting/builtin-monitors.ts +3 -0
  30. package/src/services/alerting/builtin-source.ts +15 -0
  31. package/src/services/alerting/inbound-poller.test.ts +63 -1
  32. package/src/services/alerting/inbound-poller.ts +42 -0
  33. package/src/services/alerting/read-records.ts +85 -0
  34. package/src/services/audit/abandoned-operations.test.ts +73 -0
  35. package/src/services/audit/abandoned-operations.ts +0 -0
  36. package/src/services/audit/disk-space.test.ts +111 -0
  37. package/src/services/audit/disk-space.ts +114 -0
  38. package/src/services/audit/index.test.ts +2 -0
  39. package/src/services/audit/index.ts +12 -0
  40. package/src/services/audit/transport-reads.test.ts +113 -0
  41. package/src/services/audit/transport-reads.ts +120 -0
  42. package/src/services/audit/types.ts +3 -0
  43. package/src/services/backup-create.ts +4 -4
  44. package/src/services/backup-in-flight-refusal.test.ts +2 -0
  45. package/src/services/backup-metadata.ts +4 -0
  46. package/src/services/backup-staging.test.ts +134 -0
  47. package/src/services/backup-staging.ts +192 -0
  48. package/src/services/backup-storage.ts +29 -0
  49. package/src/services/backup-sweep.test.ts +68 -0
  50. package/src/services/backup-sweep.ts +62 -0
  51. package/src/services/config-interview.ts +1 -1
  52. package/src/services/deploy-ansible.ts +0 -1
  53. package/src/services/disk-probe.test.ts +74 -0
  54. package/src/services/disk-probe.ts +145 -0
  55. package/src/services/fleet-checks.ts +15 -0
  56. package/src/services/module-operations.test.ts +22 -0
  57. package/src/services/module-operations.ts +48 -1
  58. package/src/services/module-subscriptions.test.ts +39 -6
  59. package/src/services/module-subscriptions.ts +6 -4
  60. package/src/services/module-types-generator.test.ts +6 -3
  61. package/src/services/module-types-generator.ts +12 -7
  62. package/src/services/storage-providers/local.ts +2 -1
  63. package/src/services/update/orchestrator.test.ts +2 -0
  64. package/src/variables/context.ts +6 -1
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import {
3
+ ABANDONED_THRESHOLD,
4
+ ABANDONED_WINDOW_MS,
5
+ type AbandonedOperationRecord,
6
+ auditAbandonedOperations,
7
+ } from './abandoned-operations';
8
+
9
+ const NOW = Date.parse('2026-08-05T12:00:00Z');
10
+ const now = () => NOW;
11
+
12
+ function records(
13
+ moduleId: string,
14
+ operation: 'deploy' | 'backup',
15
+ count: number,
16
+ ageMs = 60_000,
17
+ ): AbandonedOperationRecord[] {
18
+ return Array.from({ length: count }, () => ({
19
+ moduleId,
20
+ operation,
21
+ startedAt: NOW - ageMs,
22
+ }));
23
+ }
24
+
25
+ describe('auditAbandonedOperations', () => {
26
+ it('says nothing about a single abandonment — that is an operator hitting Ctrl-C', () => {
27
+ const findings = auditAbandonedOperations({
28
+ records: records('forgejo', 'backup', ABANDONED_THRESHOLD - 1),
29
+ now,
30
+ });
31
+ expect(findings).toEqual([]);
32
+ });
33
+
34
+ it('flags a module whose same operation keeps dying', () => {
35
+ const findings = auditAbandonedOperations({
36
+ records: records('forgejo', 'backup', 63),
37
+ now,
38
+ });
39
+
40
+ expect(findings).toHaveLength(1);
41
+ expect(findings[0]?.subject).toBe('forgejo');
42
+ expect(findings[0]?.severity).toBe('drift');
43
+ expect(findings[0]?.message).toContain('63 backup operations abandoned');
44
+ });
45
+
46
+ // The point of grouping: a module whose backups are being killed and whose
47
+ // deploys are fine should say exactly that.
48
+ it('reports each operation kind separately', () => {
49
+ const findings = auditAbandonedOperations({
50
+ records: [...records('forgejo', 'backup', 5), ...records('technitium', 'deploy', 5)],
51
+ now,
52
+ });
53
+
54
+ expect(findings.map((f) => f.subject)).toEqual(['forgejo', 'technitium']);
55
+ expect(findings[1]?.message).toContain('deploy operations');
56
+ });
57
+
58
+ it('ignores abandonments older than the window, so a fixed module stops being flagged', () => {
59
+ const findings = auditAbandonedOperations({
60
+ records: records('forgejo', 'backup', 60, ABANDONED_WINDOW_MS + 60_000),
61
+ now,
62
+ });
63
+ expect(findings).toEqual([]);
64
+ });
65
+
66
+ it('does not merge two modules into one unactionable count', () => {
67
+ const findings = auditAbandonedOperations({
68
+ records: [...records('forgejo', 'backup', 2), ...records('lunacycle', 'backup', 2)],
69
+ now,
70
+ });
71
+ expect(findings).toEqual([]);
72
+ });
73
+ });
@@ -0,0 +1,111 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ DISK_BLOCKED_PERCENT,
4
+ DISK_DRIFT_PERCENT,
5
+ type DiskUsageResult,
6
+ auditDiskSpace,
7
+ } from './disk-space';
8
+
9
+ function usage(over: Partial<DiskUsageResult> = {}): DiskUsageResult {
10
+ return {
11
+ hostname: 'celilo-mgr',
12
+ ipAddress: '10.0.120.10',
13
+ usedPercent: 10,
14
+ availableBytes: 102 * 1024 ** 3,
15
+ ...over,
16
+ };
17
+ }
18
+
19
+ describe('auditDiskSpace', () => {
20
+ test('a healthy filesystem produces no finding', () => {
21
+ expect(auditDiskSpace({ results: [usage({ usedPercent: 10 })] })).toEqual([]);
22
+ });
23
+
24
+ test('just below the threshold stays quiet', () => {
25
+ const findings = auditDiskSpace({ results: [usage({ usedPercent: DISK_DRIFT_PERCENT - 1 })] });
26
+ expect(findings).toEqual([]);
27
+ });
28
+
29
+ test('at the drift threshold reports drift, naming host and usage', () => {
30
+ const findings = auditDiskSpace({ results: [usage({ usedPercent: DISK_DRIFT_PERCENT })] });
31
+
32
+ expect(findings).toHaveLength(1);
33
+ expect(findings[0]?.severity).toBe('drift');
34
+ expect(findings[0]?.code).toBe('disk_low');
35
+ expect(findings[0]?.message).toContain('celilo-mgr');
36
+ expect(findings[0]?.message).toContain('85%');
37
+ });
38
+
39
+ test('at the blocked threshold escalates to blocked', () => {
40
+ const findings = auditDiskSpace({ results: [usage({ usedPercent: DISK_BLOCKED_PERCENT })] });
41
+
42
+ expect(findings[0]?.severity).toBe('blocked');
43
+ expect(findings[0]?.code).toBe('disk_critical');
44
+ });
45
+
46
+ // The alert has to name the host to act on, and suppression resolves a
47
+ // machine's ancestor key from its HOSTNAME (alerting/suppression.ts). A UUID
48
+ // subject produces a key suppression can never match — that is #596, filed
49
+ // against machines_reachable. This check must not repeat it.
50
+ test('subjects the finding on the hostname, never a UUID', () => {
51
+ const findings = auditDiskSpace({
52
+ results: [usage({ hostname: 'iot', usedPercent: 99 })],
53
+ });
54
+
55
+ expect(findings[0]?.subject).toBe('iot');
56
+ });
57
+
58
+ test('only the offending host is reported in a mixed fleet', () => {
59
+ const findings = auditDiskSpace({
60
+ results: [
61
+ usage({ hostname: 'celilo-mgr', usedPercent: 96 }),
62
+ usage({ hostname: 'iot', usedPercent: 12 }),
63
+ usage({ hostname: 'dns-ext', usedPercent: 40 }),
64
+ ],
65
+ });
66
+
67
+ expect(findings).toHaveLength(1);
68
+ expect(findings[0]?.subject).toBe('celilo-mgr');
69
+ });
70
+
71
+ test('reports free space alongside the percentage', () => {
72
+ const findings = auditDiskSpace({
73
+ results: [usage({ usedPercent: 96, availableBytes: 6 * 1024 ** 3 })],
74
+ });
75
+
76
+ expect(findings[0]?.message).toContain('6.0 GB free');
77
+ });
78
+
79
+ // Unmeasurable must not read as healthy — but it must not page either, since
80
+ // machines_reachable is already alerting for the same dead host.
81
+ test('an unmeasurable host is recorded as todo, not as healthy', () => {
82
+ const findings = auditDiskSpace({
83
+ results: [usage({ hostname: 'iot', usedPercent: null, message: 'ssh: connect timed out' })],
84
+ });
85
+
86
+ expect(findings).toHaveLength(1);
87
+ expect(findings[0]?.severity).toBe('todo');
88
+ expect(findings[0]?.code).toBe('disk_unmeasured');
89
+ expect(findings[0]?.details).toContain('timed out');
90
+ });
91
+
92
+ test('an unreachable host does not hide a filling one', () => {
93
+ const findings = auditDiskSpace({
94
+ results: [
95
+ usage({ hostname: 'iot', usedPercent: null, message: 'unreachable' }),
96
+ usage({ hostname: 'celilo-mgr', usedPercent: 97 }),
97
+ ],
98
+ });
99
+
100
+ expect(findings.map((f) => f.subject).sort()).toEqual(['celilo-mgr', 'iot']);
101
+ expect(findings.find((f) => f.subject === 'celilo-mgr')?.severity).toBe('blocked');
102
+ });
103
+
104
+ test('every finding carries the disk_space category', () => {
105
+ const findings = auditDiskSpace({
106
+ results: [usage({ usedPercent: 99 }), usage({ hostname: 'iot', usedPercent: null })],
107
+ });
108
+
109
+ expect(findings.every((f) => f.category === 'disk_space')).toBe(true);
110
+ });
111
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Disk-space check.
3
+ *
4
+ * Catches the failure that had no detector at all: a filesystem filling up.
5
+ * celilo-mgr reached 34% and climbing at ~4.8 GB/hour from leaked backup
6
+ * staging, and the only reason anyone noticed was an operator running `df` by
7
+ * eye. Every one of the eighteen configured monitors watched module health or
8
+ * machine reachability; not one looked at disk.
9
+ *
10
+ * Reports EARLY, not at exhaustion. A check that fires once a filesystem is
11
+ * full reports an outage instead of preventing one, so the thresholds leave
12
+ * room to act: `drift` at 85% is "you have time", `blocked` at 95% is "you do
13
+ * not". On a 117 GB root that is ~17 GB and ~6 GB of headroom respectively —
14
+ * hours at the leak rate that motivated this, days at any normal one.
15
+ *
16
+ * The audit consumes pre-computed measurements so it stays unit-testable
17
+ * without a live filesystem or SSH client, exactly like `machines-reachable`.
18
+ */
19
+
20
+ import type { DriftFinding } from './types';
21
+
22
+ /** Usage at or above this is divergence worth an operator's attention. */
23
+ export const DISK_DRIFT_PERCENT = 85;
24
+
25
+ /** Usage at or above this is close enough to exhaustion to gate on. */
26
+ export const DISK_BLOCKED_PERCENT = 95;
27
+
28
+ export interface DiskUsageResult {
29
+ /**
30
+ * User-facing hostname, and the identifier the finding is keyed by.
31
+ *
32
+ * NOT the machine's UUID. Suppression resolves a machine's ancestor key from
33
+ * its hostname (`machineAlertKey` in alerting/suppression.ts), so a finding
34
+ * subjected on the UUID produces an alert key suppression can never match —
35
+ * which is exactly the bug filed as #596 against `machines_reachable`. Using
36
+ * the hostname here also satisfies CLAUDE.md: users never see UUIDs.
37
+ */
38
+ hostname: string;
39
+ ipAddress: string;
40
+ /** Percent of the root filesystem in use, or null when it could not be measured. */
41
+ usedPercent: number | null;
42
+ /** Bytes still available. Omitted when unmeasured. */
43
+ availableBytes?: number;
44
+ /** Why the measurement failed, when `usedPercent` is null. */
45
+ message?: string;
46
+ }
47
+
48
+ export interface DiskSpaceAuditDeps {
49
+ results: DiskUsageResult[];
50
+ }
51
+
52
+ function humanBytes(bytes: number): string {
53
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
54
+ let value = bytes;
55
+ let unit = 0;
56
+ while (value >= 1024 && unit < units.length - 1) {
57
+ value /= 1024;
58
+ unit++;
59
+ }
60
+ return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`;
61
+ }
62
+
63
+ export function auditDiskSpace(deps: DiskSpaceAuditDeps): DriftFinding[] {
64
+ const findings: DriftFinding[] = [];
65
+
66
+ for (const result of deps.results) {
67
+ // Unmeasurable is NOT healthy — but it does not page either. The host is
68
+ // already unreachable, `machines_reachable` is already alerting on it, and
69
+ // a second page for one dead host is noise. `todo` records without
70
+ // notifying, which is the existing severity for exactly that.
71
+ if (result.usedPercent === null) {
72
+ findings.push({
73
+ category: 'disk_space',
74
+ severity: 'todo',
75
+ code: 'disk_unmeasured',
76
+ message: `${result.hostname}: disk usage could not be measured`,
77
+ details: result.message,
78
+ remediation:
79
+ 'The host is unreachable or `df` failed on it. `machines_reachable` covers reachability; this finding only records that disk is currently unknown, not that it is healthy.',
80
+ actionable: false,
81
+ subject: result.hostname,
82
+ });
83
+ continue;
84
+ }
85
+
86
+ if (result.usedPercent < DISK_DRIFT_PERCENT) continue;
87
+
88
+ const critical = result.usedPercent >= DISK_BLOCKED_PERCENT;
89
+ const free =
90
+ result.availableBytes === undefined ? '' : `, ${humanBytes(result.availableBytes)} free`;
91
+
92
+ findings.push({
93
+ category: 'disk_space',
94
+ severity: critical ? 'blocked' : 'drift',
95
+ code: critical ? 'disk_critical' : 'disk_low',
96
+ message: `${result.hostname}: root filesystem ${result.usedPercent}% full${free}`,
97
+ details: critical
98
+ ? `At or above ${DISK_BLOCKED_PERCENT}% the host is close enough to exhaustion that writes can begin failing. On the management server a full root filesystem takes the event bus and dispatcher with it, including whatever scheduled work would otherwise clean up.`
99
+ : `At or above ${DISK_DRIFT_PERCENT}% there is still room to act. Find the growth before it becomes an outage rather than after.`,
100
+ remediation: [
101
+ `Find what is growing on ${result.hostname}:`,
102
+ ' df -h /',
103
+ ' sudo du -xh --max-depth=2 / | sort -h | tail -30',
104
+ 'Then fix the source — a retention policy, a size cap, or a prune',
105
+ 'schedule. A one-off delete leaves the same thing growing.',
106
+ ].join('\n'),
107
+ // Multi-step diagnosis, not a one-shot celilo command.
108
+ actionable: false,
109
+ subject: result.hostname,
110
+ });
111
+ }
112
+
113
+ return findings;
114
+ }
@@ -26,12 +26,14 @@ const emptyDeps = {
26
26
  moduleConfigs: { modules: [] },
27
27
  health: { results: [] },
28
28
  backups: { modules: [] },
29
+ abandonedOperations: { records: [] },
29
30
  undeployedModules: { modules: [] },
30
31
  unconfiguredModules: { modules: [] },
31
32
  servicesCredentials: { results: [] },
32
33
  secretsDecryptable: { results: [] },
33
34
  servicesReachable: { results: [] },
34
35
  machinesReachable: { results: [] },
36
+ transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
35
37
  trustedSources: { firewalls: [] },
36
38
  };
37
39
 
@@ -10,6 +10,10 @@
10
10
  * truth for what audit can detect is this file's `AuditDeps` shape.
11
11
  */
12
12
 
13
+ import {
14
+ type AbandonedOperationsAuditDeps,
15
+ auditAbandonedOperations,
16
+ } from './abandoned-operations';
13
17
  import { type BackupsAuditDeps, auditBackups } from './backups';
14
18
  import { type CapabilityAbiAuditDeps, auditCapabilityAbi } from './capability-abi';
15
19
  import { type CliVersionAuditDeps, auditCliVersion } from './cli-version';
@@ -25,6 +29,7 @@ import {
25
29
  } from './services-credentials';
26
30
  import { type ServicesReachableAuditDeps, auditServicesReachable } from './services-reachable';
27
31
  import { type TerraformPlanAuditDeps, auditTerraformPlan } from './terraform-plan';
32
+ import { type TransportReadsAuditDeps, auditTransportReads } from './transport-reads';
28
33
  import { type TrustedSourcesAuditDeps, auditTrustedSources } from './trusted-sources';
29
34
  import {
30
35
  type DriftCategory,
@@ -47,12 +52,14 @@ export interface AuditDeps {
47
52
  moduleConfigs: ModuleConfigsAuditDeps;
48
53
  health: HealthAuditDeps;
49
54
  backups: BackupsAuditDeps;
55
+ abandonedOperations: AbandonedOperationsAuditDeps;
50
56
  undeployedModules: UndeployedModulesAuditDeps;
51
57
  unconfiguredModules: UnconfiguredModulesAuditDeps;
52
58
  servicesCredentials: ServicesCredentialsAuditDeps;
53
59
  secretsDecryptable: SecretsDecryptableAuditDeps;
54
60
  servicesReachable: ServicesReachableAuditDeps;
55
61
  machinesReachable: MachinesReachableAuditDeps;
62
+ transportReads: TransportReadsAuditDeps;
56
63
  trustedSources: TrustedSourcesAuditDeps;
57
64
  /** Defaults to `Date.now()`-based ISO string. */
58
65
  now?: () => Date;
@@ -97,12 +104,17 @@ export async function runAudit(
97
104
  wrap('module_configs', auditModuleConfigs(deps.moduleConfigs)),
98
105
  wrap('health', auditHealth(deps.health)),
99
106
  wrap('backups', auditBackups(deps.backups)),
107
+ wrap(
108
+ 'abandoned_operations',
109
+ Promise.resolve(auditAbandonedOperations(deps.abandonedOperations)),
110
+ ),
100
111
  wrap('undeployed_modules', auditUndeployedModules(deps.undeployedModules)),
101
112
  wrap('unconfigured_modules', auditUnconfiguredModules(deps.unconfiguredModules)),
102
113
  wrap('services_credentials', auditServicesCredentials(deps.servicesCredentials)),
103
114
  wrap('secrets_decryptable', auditSecretsDecryptable(deps.secretsDecryptable)),
104
115
  wrap('services_reachable', auditServicesReachable(deps.servicesReachable)),
105
116
  wrap('machines_reachable', auditMachinesReachable(deps.machinesReachable)),
117
+ wrap('transport_reads', auditTransportReads(deps.transportReads)),
106
118
  wrap('trusted_sources', auditTrustedSources(deps.trustedSources)),
107
119
  ]);
108
120
 
@@ -0,0 +1,113 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { TransportReadStatus } from '../alerting/read-records';
3
+ import { auditTransportReads } from './transport-reads';
4
+
5
+ const NOW = new Date('2026-08-01T12:00:00Z');
6
+ const STALE_AFTER = 30 * 60_000;
7
+
8
+ const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString();
9
+
10
+ function status(over: Partial<TransportReadStatus['last']> | null): TransportReadStatus {
11
+ return {
12
+ transportModuleId: 'signal',
13
+ last:
14
+ over === null
15
+ ? null
16
+ : { at: ago(0), outcome: 'received', messages: 0, lastSuccessAt: ago(0), ...over },
17
+ };
18
+ }
19
+
20
+ const run = (statuses: TransportReadStatus[]) =>
21
+ auditTransportReads({ statuses, now: NOW, staleAfterMs: STALE_AFTER });
22
+
23
+ describe('auditTransportReads', () => {
24
+ test('a transport read successfully just now is not a finding', async () => {
25
+ expect(await run([status({})])).toEqual([]);
26
+ });
27
+
28
+ // THE case this check exists for. Six tokens were issued and zero consumed
29
+ // over a week, and nothing anywhere was red (#501).
30
+ test('a transport not read successfully for hours is drift', async () => {
31
+ const findings = await run([
32
+ status({
33
+ at: ago(60_000),
34
+ outcome: 'failed',
35
+ error: 'refused',
36
+ lastSuccessAt: ago(3 * 3600_000),
37
+ }),
38
+ ]);
39
+ expect(findings).toHaveLength(1);
40
+ expect(findings[0]).toMatchObject({
41
+ category: 'transport_reads',
42
+ code: 'transport_reads_stale',
43
+ severity: 'drift',
44
+ subject: 'signal',
45
+ });
46
+ expect(findings[0].message).toContain('3h ago');
47
+ });
48
+
49
+ // The decision that makes staleness safe to page on: an EMPTY read is a
50
+ // success, so a quiet transport keeps refreshing lastSuccessAt. If empty
51
+ // reads counted as failure this check would page on every quiet afternoon
52
+ // and be switched off within a week.
53
+ test('a quiet transport — successful reads, zero messages — is NOT stale', async () => {
54
+ const findings = await run([
55
+ status({ at: ago(0), outcome: 'received', messages: 0, lastSuccessAt: ago(0) }),
56
+ ]);
57
+ expect(findings).toEqual([]);
58
+ });
59
+
60
+ test('a transport that has never succeeded is drift, even if attempts are recent', async () => {
61
+ const findings = await run([
62
+ status({
63
+ at: ago(0),
64
+ outcome: 'failed',
65
+ error: 'connection refused',
66
+ lastSuccessAt: undefined,
67
+ }),
68
+ ]);
69
+ expect(findings[0]).toMatchObject({ code: 'transport_never_read', severity: 'drift' });
70
+ expect(findings[0].details).toContain('connection refused');
71
+ });
72
+
73
+ test('a transport with no record at all is drift, not silently fine', async () => {
74
+ const findings = await run([status(null)]);
75
+ expect(findings[0]).toMatchObject({ code: 'transport_never_polled', severity: 'drift' });
76
+ });
77
+
78
+ // A transport with no `receive` is working as designed. Paging about it
79
+ // would be paging about a healthy system, which teaches operators to ignore
80
+ // the check.
81
+ test('a unidirectional transport is never a finding', async () => {
82
+ const findings = await run([
83
+ status({ at: ago(10 * 3600_000), outcome: 'unidirectional', lastSuccessAt: undefined }),
84
+ ]);
85
+ expect(findings).toEqual([]);
86
+ });
87
+
88
+ test('just inside the threshold is not yet drift', async () => {
89
+ expect(await run([status({ lastSuccessAt: ago(STALE_AFTER - 1_000) })])).toEqual([]);
90
+ });
91
+
92
+ test('just outside the threshold is drift', async () => {
93
+ const findings = await run([status({ lastSuccessAt: ago(STALE_AFTER + 1_000) })]);
94
+ expect(findings).toHaveLength(1);
95
+ });
96
+
97
+ // The remediation must point at something that does not consume the queue.
98
+ // `celilo alerts poll` would READ, and a suggestion that eats the operator's
99
+ // acknowledgement is worse than no suggestion (#541).
100
+ test('a stale finding sends you to the journal, not to a read', async () => {
101
+ const findings = await run([status({ lastSuccessAt: ago(3 * 3600_000) })]);
102
+ expect(findings[0].remediation).toBe('celilo module journal signal');
103
+ });
104
+
105
+ test('each transport is judged on its own record', async () => {
106
+ const findings = await run([
107
+ { transportModuleId: 'signal', last: status({}).last },
108
+ { transportModuleId: 'sms', last: status({ lastSuccessAt: ago(9 * 3600_000) }).last },
109
+ ]);
110
+ expect(findings).toHaveLength(1);
111
+ expect(findings[0].subject).toBe('sms');
112
+ });
113
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Transport-reads check — is celilo still able to READ replies?
3
+ *
4
+ * Every other signal that a notification transport is working proves the wrong
5
+ * half. `send` succeeding proves outbound. A daemon answering its API proves a
6
+ * socket. Neither can see the return leg fail, and for a week none of them did:
7
+ * six alert tokens were issued, zero were consumed, and nothing anywhere was
8
+ * red (#501).
9
+ *
10
+ * This is the check that would have caught it. It asserts the contract —
11
+ * celilo can read this transport — using first-hand evidence rather than a
12
+ * proxy: the recorded outcome of the reads the poller already performs every
13
+ * few minutes.
14
+ *
15
+ * WHY STALENESS IS A SOUND SIGNAL HERE, and it rests on one decision:
16
+ * an empty read is recorded as a SUCCESS. A transport nobody has replied on
17
+ * still records a successful read on every poll. So "no successful read in a
18
+ * while" cannot mean "quiet" — it can only mean the reads stopped happening or
19
+ * stopped working. Had zero-messages been recorded as failure, this check would
20
+ * page every time an operator had a peaceful afternoon, and would be turned off
21
+ * within a week.
22
+ *
23
+ * Deliberately consumes pre-computed state rather than reading the store
24
+ * itself, so it stays unit-testable and cannot become a second thing that
25
+ * performs reads. A check that drained the queue to find out whether the queue
26
+ * could be drained would eat the acknowledgement it was protecting (#541).
27
+ */
28
+
29
+ import type { TransportReadStatus } from '../alerting/read-records';
30
+ import type { DriftFinding } from './types';
31
+
32
+ export type { TransportReadStatus };
33
+
34
+ export interface TransportReadsAuditDeps {
35
+ /** One entry per transport that has at least one route pointing at it. */
36
+ statuses: TransportReadStatus[];
37
+ now: Date;
38
+ /**
39
+ * How long without a successful read before it counts as drift.
40
+ *
41
+ * The poller runs every five minutes, so this is a multiple of that rather
42
+ * than a guess: it has to absorb a missed tick, a slow sweep, and a restart
43
+ * without crying wolf, while still catching a transport that has genuinely
44
+ * stopped being readable.
45
+ */
46
+ staleAfterMs: number;
47
+ }
48
+
49
+ const CATEGORY = 'transport_reads' as const;
50
+
51
+ function describeAge(ms: number): string {
52
+ const mins = Math.floor(ms / 60_000);
53
+ if (mins < 60) return `${mins}m`;
54
+ const hours = Math.floor(mins / 60);
55
+ return hours < 48 ? `${hours}h` : `${Math.floor(hours / 24)}d`;
56
+ }
57
+
58
+ export async function auditTransportReads(deps: TransportReadsAuditDeps): Promise<DriftFinding[]> {
59
+ const findings: DriftFinding[] = [];
60
+
61
+ for (const status of deps.statuses) {
62
+ const id = status.transportModuleId;
63
+
64
+ // Nothing recorded at all. Either the poller has never run, or this
65
+ // transport was added and never polled. Both mean no reply from here has
66
+ // ever been collectable, which is worth saying out loud rather than
67
+ // treating an empty store as "fine so far".
68
+ if (!status.last) {
69
+ findings.push({
70
+ category: CATEGORY,
71
+ severity: 'drift',
72
+ code: 'transport_never_polled',
73
+ message: `${id}: celilo has never recorded a read attempt`,
74
+ details:
75
+ 'No inbound read has been attempted for this transport, so a\n' +
76
+ 'reply sent to it would not be collected. This is the state a\n' +
77
+ 'newly-added transport is in until the first poll runs.',
78
+ remediation: 'celilo alerts poll',
79
+ actionable: true,
80
+ subject: id,
81
+ });
82
+ continue;
83
+ }
84
+
85
+ // A transport with no `receive` is unidirectional BY DESIGN — pages go out,
86
+ // replies were never possible, and the route's can_ack already records
87
+ // that. Flagging it would be flagging a working system.
88
+ if (status.last.outcome === 'unidirectional') continue;
89
+
90
+ if (!status.last.lastSuccessAt) {
91
+ findings.push({
92
+ category: CATEGORY,
93
+ severity: 'drift',
94
+ code: 'transport_never_read',
95
+ message: `${id}: no read has ever SUCCEEDED`,
96
+ details: `Reads have been attempted — the most recent was ${status.last.outcome}${status.last.error ? ` (${status.last.error})` : ''} — but none has\never succeeded. Acknowledgements sent to this transport cannot be\ncollected, and outbound paging will keep working, so nothing else\nwill report this.`,
97
+ remediation: `celilo module journal ${id}`,
98
+ actionable: true,
99
+ subject: id,
100
+ });
101
+ continue;
102
+ }
103
+
104
+ const age = deps.now.getTime() - new Date(status.last.lastSuccessAt).getTime();
105
+ if (age > deps.staleAfterMs) {
106
+ findings.push({
107
+ category: CATEGORY,
108
+ severity: 'drift',
109
+ code: 'transport_reads_stale',
110
+ message: `${id}: last successful read ${describeAge(age)} ago`,
111
+ details: `The most recent attempt was ${status.last.outcome}${status.last.error ? `: ${status.last.error}` : ''}.\nAn empty read still counts as a success, so this is not "nobody\nreplied" — reads are either not happening or not working, and a\nreply sent now would not be collected.`,
112
+ remediation: `celilo module journal ${id}`,
113
+ actionable: true,
114
+ subject: id,
115
+ });
116
+ }
117
+ }
118
+
119
+ return findings;
120
+ }
@@ -30,12 +30,15 @@ export type DriftCategory =
30
30
  | 'module_configs'
31
31
  | 'health'
32
32
  | 'backups'
33
+ | 'abandoned_operations'
33
34
  | 'undeployed_modules'
34
35
  | 'unconfigured_modules'
35
36
  | 'services_credentials'
36
37
  | 'secrets_decryptable'
37
38
  | 'services_reachable'
38
39
  | 'machines_reachable'
40
+ | 'disk_space'
41
+ | 'transport_reads'
39
42
  | 'trusted_sources';
40
43
 
41
44
  export type DriftSeverity = 'todo' | 'drift' | 'blocked';
@@ -4,7 +4,6 @@
4
4
  */
5
5
 
6
6
  import { copyFileSync, existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
7
- import { tmpdir } from 'node:os';
8
7
  import { join } from 'node:path';
9
8
  import { eq } from 'drizzle-orm';
10
9
  import { getDbPath } from '../config/paths';
@@ -20,6 +19,7 @@ import { encryptFileToFile } from './backup-cipher';
20
19
  import { buildManifest } from './backup-manifest';
21
20
  import { completeBackup, createBackupRecord, failBackup, listBackups } from './backup-metadata';
22
21
  import type { BackupSchedule } from './backup-schedule';
22
+ import { stagingDirFor } from './backup-staging';
23
23
  import {
24
24
  createStorageProvider,
25
25
  getBackupStorage,
@@ -113,7 +113,7 @@ export async function createSystemStateBackup(
113
113
  });
114
114
  const opId = startOperation('__system__', 'backup');
115
115
 
116
- const tempDir = join(tmpdir(), `celilo-backup-${record.id}`);
116
+ const tempDir = stagingDirFor(record.id);
117
117
 
118
118
  try {
119
119
  mkdirSync(tempDir, { recursive: true });
@@ -328,7 +328,7 @@ export async function createModuleBackup(
328
328
  // Envelope layout (v1.0):
329
329
  // manifest.json - schema version, host, moduleId, dataSchemaVersion
330
330
  // data/ - on_backup hook artifacts (the hook treats this as backup_dir)
331
- const tempDir = join(tmpdir(), `celilo-backup-${record.id}`);
331
+ const tempDir = stagingDirFor(record.id);
332
332
  const envelopeDir = join(tempDir, 'envelope');
333
333
  const dataDir = join(envelopeDir, 'data');
334
334
 
@@ -496,7 +496,7 @@ export async function importModuleBackup(
496
496
  moduleVersion: manifest.version,
497
497
  });
498
498
 
499
- const tempDir = join(tmpdir(), `celilo-backup-${record.id}`);
499
+ const tempDir = stagingDirFor(record.id);
500
500
  const artifactDir = join(tempDir, 'artifacts');
501
501
 
502
502
  try {
@@ -129,6 +129,7 @@ describe('backup/restore in-flight refusal', () => {
129
129
  status: 'completed' as const,
130
130
  errorMessage: null,
131
131
  name: null,
132
+ pid: process.pid,
132
133
  startedAt: new Date(),
133
134
  completedAt: null,
134
135
  };
@@ -152,6 +153,7 @@ describe('backup/restore in-flight refusal', () => {
152
153
  status: 'completed' as const,
153
154
  errorMessage: null,
154
155
  name: null,
156
+ pid: process.pid,
155
157
  startedAt: new Date(),
156
158
  completedAt: null,
157
159
  };