@celilo/cli 0.17.0 → 0.19.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 (72) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +38 -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/api/remote-client.test.ts +62 -0
  8. package/src/api/serve.ts +14 -6
  9. package/src/cli/command-tree-parser.ts +0 -1
  10. package/src/cli/commands/apt-upgrade.test.ts +20 -1
  11. package/src/cli/commands/apt-upgrade.ts +12 -2
  12. package/src/cli/commands/backup-sweep.ts +62 -0
  13. package/src/cli/commands/events.ts +90 -0
  14. package/src/cli/commands/module-operations.test.ts +45 -1
  15. package/src/cli/commands/module-operations.ts +35 -12
  16. package/src/cli/commands/module-show.ts +1 -0
  17. package/src/cli/commands/module-update.test.ts +72 -1
  18. package/src/cli/commands/module-update.ts +45 -22
  19. package/src/cli/commands/system-audit.ts +2 -0
  20. package/src/cli/commands/system-migrate.test.ts +56 -0
  21. package/src/cli/commands/system-migrate.ts +92 -4
  22. package/src/cli/commands/system-update.ts +5 -0
  23. package/src/cli/completion.ts +19 -0
  24. package/src/cli/fuel-gauge.ts +0 -1
  25. package/src/cli/generate-zsh-completion.ts +1 -1
  26. package/src/cli/index.ts +5 -1
  27. package/src/cli/tui/audit-state.ts +4 -0
  28. package/src/cli/tui/audit-tui.test.tsx +0 -1
  29. package/src/db/migration-status.test.ts +114 -0
  30. package/src/db/migration-status.ts +78 -0
  31. package/src/db/schema-introspection.ts +8 -1
  32. package/src/db/schema.ts +53 -9
  33. package/src/hooks/capability-loader.ts +30 -1
  34. package/src/ipam/allocator.ts +13 -3
  35. package/src/services/alerting/builtin-monitors.test.ts +42 -0
  36. package/src/services/alerting/builtin-monitors.ts +2 -0
  37. package/src/services/alerting/builtin-source.ts +15 -0
  38. package/src/services/audit/abandoned-operations.test.ts +73 -0
  39. package/src/services/audit/abandoned-operations.ts +0 -0
  40. package/src/services/audit/disk-space.test.ts +111 -0
  41. package/src/services/audit/disk-space.ts +114 -0
  42. package/src/services/audit/index.test.ts +1 -0
  43. package/src/services/audit/index.ts +9 -0
  44. package/src/services/audit/types.ts +2 -0
  45. package/src/services/backup-create.ts +4 -4
  46. package/src/services/backup-in-flight-refusal.test.ts +2 -0
  47. package/src/services/backup-metadata.ts +4 -0
  48. package/src/services/backup-staging.test.ts +134 -0
  49. package/src/services/backup-staging.ts +192 -0
  50. package/src/services/backup-sweep.test.ts +68 -0
  51. package/src/services/backup-sweep.ts +62 -0
  52. package/src/services/bus-interview.ts +11 -5
  53. package/src/services/config-interview.ts +1 -1
  54. package/src/services/deploy-ansible.ts +0 -1
  55. package/src/services/disk-probe.test.ts +74 -0
  56. package/src/services/disk-probe.ts +145 -0
  57. package/src/services/events-daemon.test.ts +244 -0
  58. package/src/services/events-daemon.ts +295 -8
  59. package/src/services/fleet-checks.test.ts +75 -4
  60. package/src/services/fleet-checks.ts +97 -12
  61. package/src/services/interview-errors.ts +20 -0
  62. package/src/services/module-operations.test.ts +22 -0
  63. package/src/services/module-operations.ts +48 -1
  64. package/src/services/module-subscriptions.test.ts +39 -6
  65. package/src/services/module-subscriptions.ts +6 -4
  66. package/src/services/module-types-generator.test.ts +6 -3
  67. package/src/services/module-types-generator.ts +12 -7
  68. package/src/services/remote-responder.test.ts +70 -0
  69. package/src/services/remote-responder.ts +27 -10
  70. package/src/services/responder-probe.ts +3 -1
  71. package/src/services/update/orchestrator.test.ts +1 -0
  72. package/src/variables/context.ts +6 -1
@@ -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,6 +26,7 @@ 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: [] },
@@ -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';
@@ -48,6 +52,7 @@ export interface AuditDeps {
48
52
  moduleConfigs: ModuleConfigsAuditDeps;
49
53
  health: HealthAuditDeps;
50
54
  backups: BackupsAuditDeps;
55
+ abandonedOperations: AbandonedOperationsAuditDeps;
51
56
  undeployedModules: UndeployedModulesAuditDeps;
52
57
  unconfiguredModules: UnconfiguredModulesAuditDeps;
53
58
  servicesCredentials: ServicesCredentialsAuditDeps;
@@ -99,6 +104,10 @@ export async function runAudit(
99
104
  wrap('module_configs', auditModuleConfigs(deps.moduleConfigs)),
100
105
  wrap('health', auditHealth(deps.health)),
101
106
  wrap('backups', auditBackups(deps.backups)),
107
+ wrap(
108
+ 'abandoned_operations',
109
+ Promise.resolve(auditAbandonedOperations(deps.abandonedOperations)),
110
+ ),
102
111
  wrap('undeployed_modules', auditUndeployedModules(deps.undeployedModules)),
103
112
  wrap('unconfigured_modules', auditUnconfiguredModules(deps.unconfiguredModules)),
104
113
  wrap('services_credentials', auditServicesCredentials(deps.servicesCredentials)),
@@ -30,12 +30,14 @@ 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'
39
41
  | 'transport_reads'
40
42
  | 'trusted_sources';
41
43
 
@@ -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
  };
@@ -43,6 +43,10 @@ export function createBackupRecord(params: CreateBackupParams): BackupRecord {
43
43
  metadata: {},
44
44
  status: 'in_progress' as BackupStatus,
45
45
  errorMessage: null,
46
+ // Recorded so the staging reaper can distinguish a live backup from one
47
+ // whose process was killed before its `finally` ran — see
48
+ // services/backup-staging.ts.
49
+ pid: process.pid,
46
50
  startedAt: now,
47
51
  completedAt: null,
48
52
  };
@@ -0,0 +1,134 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ type ReapStagingDeps,
4
+ STAGING_PREFIX,
5
+ STAGING_TTL_MS,
6
+ type StagingOwner,
7
+ reapOrphanedStaging,
8
+ stagingDirFor,
9
+ } from './backup-staging';
10
+
11
+ const NOW = new Date('2026-08-06T12:00:00Z').getTime();
12
+
13
+ function deps(
14
+ owners: Record<string, StagingOwner | null>,
15
+ options: {
16
+ dirs?: string[];
17
+ runnablePids?: number[];
18
+ unremovable?: string[];
19
+ } = {},
20
+ ): ReapStagingDeps & { removed: string[] } {
21
+ const removed: string[] = [];
22
+ return {
23
+ removed,
24
+ listStagingDirs: () => options.dirs ?? Object.keys(owners).map((id) => stagingDirFor(id)),
25
+ lookupOwner: (id) => owners[id] ?? null,
26
+ isPidRunnable: (pid) => (options.runnablePids ?? []).includes(pid),
27
+ remove: (path) => {
28
+ if (options.unremovable?.includes(path)) throw new Error('EACCES');
29
+ removed.push(path);
30
+ },
31
+ now: () => NOW,
32
+ };
33
+ }
34
+
35
+ function owner(over: Partial<StagingOwner> = {}): StagingOwner {
36
+ return { status: 'in_progress', pid: 111, startedAt: new Date(NOW - 60_000), ...over };
37
+ }
38
+
39
+ describe('reapOrphanedStaging', () => {
40
+ test('reclaims staging whose backup record no longer exists', () => {
41
+ const d = deps({ 'gone-id': null });
42
+ const report = reapOrphanedStaging(d);
43
+
44
+ expect(report.reclaimed).toHaveLength(1);
45
+ expect(report.reclaimed[0]?.reason).toBe('record-absent');
46
+ expect(d.removed).toEqual([stagingDirFor('gone-id')]);
47
+ });
48
+
49
+ test.each([['completed'], ['failed']])('reclaims staging for a %s record', (status) => {
50
+ const d = deps({ 'done-id': owner({ status }) });
51
+ const report = reapOrphanedStaging(d);
52
+
53
+ expect(report.reclaimed[0]?.reason).toBe('record-terminal');
54
+ expect(d.removed).toEqual([stagingDirFor('done-id')]);
55
+ });
56
+
57
+ test('reclaims staging whose owning process is dead', () => {
58
+ // in_progress, inside the TTL — only the liveness probe can tell.
59
+ const d = deps({ 'dead-id': owner({ pid: 999 }) }, { runnablePids: [] });
60
+ const report = reapOrphanedStaging(d);
61
+
62
+ expect(report.reclaimed[0]?.reason).toBe('process-dead');
63
+ expect(d.removed).toEqual([stagingDirFor('dead-id')]);
64
+ });
65
+
66
+ // The one that must never regress: a running backup writing gigabytes into
67
+ // its staging dir must survive a concurrent sweep.
68
+ test('KEEPS staging owned by a live backup', () => {
69
+ const d = deps({ 'live-id': owner({ pid: 111 }) }, { runnablePids: [111] });
70
+ const report = reapOrphanedStaging(d);
71
+
72
+ expect(report.reclaimed).toHaveLength(0);
73
+ expect(report.kept).toEqual([stagingDirFor('live-id')]);
74
+ expect(d.removed).toEqual([]);
75
+ });
76
+
77
+ test('reclaims a record past the TTL even when its pid looks alive', () => {
78
+ // Guards pid reuse: after the pid space wraps, a stale record's pid names
79
+ // an unrelated live process and liveness alone would strand this forever.
80
+ const d = deps(
81
+ { 'stale-id': owner({ pid: 111, startedAt: new Date(NOW - STAGING_TTL_MS - 1) }) },
82
+ { runnablePids: [111] },
83
+ );
84
+ const report = reapOrphanedStaging(d);
85
+
86
+ expect(report.reclaimed[0]?.reason).toBe('expired');
87
+ });
88
+
89
+ test('keeps a pid-less record until it expires, then reclaims it', () => {
90
+ const fresh = deps({ 'old-fmt': owner({ pid: null }) });
91
+ expect(reapOrphanedStaging(fresh).kept).toHaveLength(1);
92
+
93
+ const expired = deps({
94
+ 'old-fmt': owner({ pid: null, startedAt: new Date(NOW - STAGING_TTL_MS - 1) }),
95
+ });
96
+ expect(reapOrphanedStaging(expired).reclaimed[0]?.reason).toBe('expired');
97
+ });
98
+
99
+ test('ignores directories that are not staging, rather than deleting them', () => {
100
+ const d = deps(
101
+ {},
102
+ {
103
+ dirs: [
104
+ '/tmp/something-else',
105
+ '/tmp/celilo-unrelated',
106
+ `/tmp/${STAGING_PREFIX}`, // prefix with no record id
107
+ ],
108
+ },
109
+ );
110
+ const report = reapOrphanedStaging(d);
111
+
112
+ expect(report.ignored).toHaveLength(3);
113
+ expect(report.reclaimed).toHaveLength(0);
114
+ expect(d.removed).toEqual([]);
115
+ });
116
+
117
+ test('an undeletable directory is reported as kept, not as reclaimed space', () => {
118
+ const path = stagingDirFor('locked-id');
119
+ const d = deps({ 'locked-id': null }, { unremovable: [path] });
120
+ const report = reapOrphanedStaging(d);
121
+
122
+ expect(report.reclaimed).toHaveLength(0);
123
+ expect(report.kept).toEqual([path]);
124
+ });
125
+
126
+ test('one undeletable directory does not stop the rest of the pass', () => {
127
+ const locked = stagingDirFor('a-locked');
128
+ const d = deps({ 'a-locked': null, 'b-orphan': null }, { unremovable: [locked] });
129
+ const report = reapOrphanedStaging(d);
130
+
131
+ expect(report.reclaimed.map((r) => r.recordId)).toEqual(['b-orphan']);
132
+ expect(report.kept).toEqual([locked]);
133
+ });
134
+ });
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Reclaiming backup staging directories whose owner is gone.
3
+ *
4
+ * `backup-create.ts` assembles every envelope in a temp directory and removes
5
+ * it in a `finally`. That is correct and it is not enough: a `finally` does not
6
+ * run when the process is killed by a signal it cannot intercept — a dispatcher
7
+ * timeout, an OOM, an operator's Ctrl-C, a host reboot. Those are exactly the
8
+ * cases that strand the LARGEST directories, because the longer a backup has
9
+ * run the more it has written.
10
+ *
11
+ * Measured on celilo-mgr 2026-08-05: the scheduled sweep inherited the event
12
+ * bus's 60s default timeout against a forgejo backup that needs ~5.5 minutes,
13
+ * so every attempt was SIGTERMed at ~1.4 GB of staging, three times an hour,
14
+ * for days. 15 GB stranded in 26 hours, then 27 GB in the next 5.7. Nothing
15
+ * reclaimed it, because the only cleanup was the `finally` that never ran.
16
+ *
17
+ * So reclamation must not be a property of how a backup ends. This pass asks a
18
+ * different question — "is anyone still using this directory?" — and answers it
19
+ * from state that outlives the process.
20
+ *
21
+ * Identity comes free: a staging directory is named `celilo-backup-<record.id>`,
22
+ * so the directory names its own backup record. No lockfile, no marker, no
23
+ * second source of truth that could itself be stranded — and notably nothing
24
+ * written BY the process whose death is the problem.
25
+ *
26
+ * Dependencies are injected so the decision logic tests with no filesystem and
27
+ * no database.
28
+ */
29
+
30
+ import { tmpdir } from 'node:os';
31
+ import { join } from 'node:path';
32
+
33
+ /** Every staging directory starts with this. The rest of the name is the record id. */
34
+ export const STAGING_PREFIX = 'celilo-backup-';
35
+
36
+ /**
37
+ * Where a backup record's staging lives. Single source of truth — `backup-create.ts`
38
+ * builds its temp dir from this so the reaper can never drift from the writer.
39
+ */
40
+ export function stagingDirFor(recordId: string): string {
41
+ return join(tmpdir(), `${STAGING_PREFIX}${recordId}`);
42
+ }
43
+
44
+ /**
45
+ * How long an `in_progress` backup record may vouch for its staging before the
46
+ * directory is reclaimed regardless of what its pid appears to be doing.
47
+ *
48
+ * This is not redundancy on the liveness check — it is the only check that
49
+ * survives pid reuse, for the same reason `OPERATION_TTL_MS` exists in
50
+ * `module-operations.ts`. A pid is a recycled number: once the pid space wraps,
51
+ * a stale record's pid names an unrelated live process and the liveness probe
52
+ * reports "still running" forever, stranding the directory permanently.
53
+ *
54
+ * Six hours is comfortably longer than any real backup (the largest measured is
55
+ * ~5.5 minutes) and short enough that a wedged record costs one cycle rather
56
+ * than a filesystem.
57
+ */
58
+ export const STAGING_TTL_MS = 6 * 60 * 60 * 1000;
59
+
60
+ /** What the backup record says about the process that owns a staging directory. */
61
+ export interface StagingOwner {
62
+ status: string;
63
+ /** Null for records written before backups recorded their pid. */
64
+ pid: number | null;
65
+ startedAt: Date;
66
+ }
67
+
68
+ export interface ReapStagingDeps {
69
+ /** Absolute paths of every `celilo-backup-*` entry in the temp dir. */
70
+ listStagingDirs(): string[];
71
+ /** The backup record for this id, or null when it no longer exists. */
72
+ lookupOwner(recordId: string): StagingOwner | null;
73
+ isPidRunnable(pid: number): boolean;
74
+ remove(path: string): void;
75
+ now(): number;
76
+ }
77
+
78
+ export interface ReapedStaging {
79
+ path: string;
80
+ recordId: string;
81
+ /** Why it was reclaimable — surfaced so a sweep can explain itself. */
82
+ reason: 'record-absent' | 'record-terminal' | 'process-dead' | 'expired';
83
+ }
84
+
85
+ /**
86
+ * Written to a backup record reclaimed while it still claimed to be running.
87
+ *
88
+ * A record left `in_progress` after its process died misreports the system
89
+ * twice: `celilo backup list` shows work apparently underway, and the `backups`
90
+ * drift check can read a module as recently backed up when every attempt in
91
+ * fact died. Nine such rows were live on celilo-mgr while forgejo had no usable
92
+ * backup at all.
93
+ */
94
+ export const ABANDONED_BACKUP_MESSAGE =
95
+ 'abandoned — the backup process ended without recording an outcome';
96
+
97
+ /**
98
+ * Whether reclaiming this directory also means its record was lying about
99
+ * being in progress.
100
+ *
101
+ * Only the two reasons reached from the `in_progress` branch qualify:
102
+ * `record-terminal` already has an outcome and `record-absent` has no row to
103
+ * correct.
104
+ */
105
+ export function impliesAbandonedRecord(reason: ReapedStaging['reason']): boolean {
106
+ return reason === 'process-dead' || reason === 'expired';
107
+ }
108
+
109
+ export interface ReapStagingReport {
110
+ reclaimed: ReapedStaging[];
111
+ /** Left alone because a live backup is using it. */
112
+ kept: string[];
113
+ /** Names that did not look like staging at all. Never touched. */
114
+ ignored: string[];
115
+ }
116
+
117
+ /**
118
+ * Decide, for one staging directory, whether its owner is gone.
119
+ *
120
+ * Returns null when the directory must be left alone. Every branch that
121
+ * reclaims must be able to say why; "I could not prove it is alive" is not a
122
+ * reason to delete, which is why an unrecognised state keeps the directory.
123
+ */
124
+ function reclaimReason(
125
+ owner: StagingOwner | null,
126
+ deps: Pick<ReapStagingDeps, 'isPidRunnable' | 'now'>,
127
+ ): ReapedStaging['reason'] | null {
128
+ // The record was deleted, or never committed. Nothing will ever finish this.
129
+ if (!owner) return 'record-absent';
130
+
131
+ // Completed or failed: the writer reached an ending and either cleaned up
132
+ // already (in which case we will not see the directory) or was killed after
133
+ // recording its outcome.
134
+ if (owner.status !== 'in_progress') return 'record-terminal';
135
+
136
+ const age = deps.now() - owner.startedAt.getTime();
137
+ if (age > STAGING_TTL_MS) return 'expired';
138
+
139
+ // A record from before backups carried a pid. Age is the only signal
140
+ // available, and it has not expired — leave it.
141
+ if (owner.pid === null) return null;
142
+
143
+ return deps.isPidRunnable(owner.pid) ? null : 'process-dead';
144
+ }
145
+
146
+ /**
147
+ * Reclaim every staging directory whose owning backup is no longer running.
148
+ *
149
+ * Conservative by construction: a directory is removed only when its owner is
150
+ * provably gone. A running backup — `in_progress` record, live pid, inside the
151
+ * TTL — is always kept, so a long-running or concurrent backup can never be
152
+ * destroyed by this pass.
153
+ *
154
+ * A name that is not `celilo-backup-<id>` is ignored rather than removed. This
155
+ * runs against a shared temp directory as a privileged user; deleting something
156
+ * it does not understand is not its job.
157
+ */
158
+ export function reapOrphanedStaging(deps: ReapStagingDeps): ReapStagingReport {
159
+ const report: ReapStagingReport = { reclaimed: [], kept: [], ignored: [] };
160
+
161
+ for (const path of deps.listStagingDirs()) {
162
+ const name = path.slice(path.lastIndexOf('/') + 1);
163
+ if (!name.startsWith(STAGING_PREFIX)) {
164
+ report.ignored.push(path);
165
+ continue;
166
+ }
167
+
168
+ const recordId = name.slice(STAGING_PREFIX.length);
169
+ if (recordId.length === 0) {
170
+ report.ignored.push(path);
171
+ continue;
172
+ }
173
+
174
+ const reason = reclaimReason(deps.lookupOwner(recordId), deps);
175
+ if (!reason) {
176
+ report.kept.push(path);
177
+ continue;
178
+ }
179
+
180
+ // A directory that cannot be removed is not fatal: the next pass retries,
181
+ // and failing the whole sweep over one undeletable path would stop backups
182
+ // entirely. Treated as kept so the report never claims space it did not free.
183
+ try {
184
+ deps.remove(path);
185
+ report.reclaimed.push({ path, recordId, reason });
186
+ } catch {
187
+ report.kept.push(path);
188
+ }
189
+ }
190
+
191
+ return report;
192
+ }