@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,281 @@
1
+ /**
2
+ * Tests for `storage set-path`.
3
+ *
4
+ * The motivating case (#566) is celilo-mgr: `local-backups` points at
5
+ * `/Users/pbanka/hobby/backups/celilo-backups/`, a macOS path that came
6
+ * across when the database was restored onto a Linux host. The directory
7
+ * does not exist there. Relocating must therefore succeed with nothing
8
+ * migrated — not error, and not claim files were moved — and must not
9
+ * carry the four-month-old `✓ Verified` stamp onto the new path.
10
+ *
11
+ * Isolation: CELILO_DB_PATH / CELILO_DATA_DIR are set before the SUT is
12
+ * imported, so nothing touches the production database.
13
+ */
14
+
15
+ import { afterAll, describe, expect, test } from 'bun:test';
16
+ import {
17
+ chmodSync,
18
+ existsSync,
19
+ mkdirSync,
20
+ mkdtempSync,
21
+ rmSync,
22
+ statSync,
23
+ utimesSync,
24
+ writeFileSync,
25
+ } from 'node:fs';
26
+ import { homedir, tmpdir } from 'node:os';
27
+ import { join } from 'node:path';
28
+
29
+ const testRoot = mkdtempSync(join(tmpdir(), 'celilo-setpath-'));
30
+ process.env.CELILO_DB_PATH = join(testRoot, 'celilo.db');
31
+ process.env.CELILO_DATA_DIR = join(testRoot, 'data');
32
+
33
+ const {
34
+ addBackupStorage,
35
+ getBackupStorageByStorageId,
36
+ getStorageCredentials,
37
+ updateStorageCredentials,
38
+ } = await import('../../services/backup-storage');
39
+ const { handleStorageSetPath, inspectSourceDir, planRelocation, resolveTargetPath } = await import(
40
+ './storage-set-path'
41
+ );
42
+
43
+ afterAll(() => {
44
+ rmSync(testRoot, { recursive: true, force: true });
45
+ });
46
+
47
+ describe('planRelocation (pure)', () => {
48
+ const base = { sourceDir: '/old/celilo-backups', targetDir: '/new/celilo-backups' };
49
+
50
+ test('migrates when the source has files', () => {
51
+ const plan = planRelocation({ ...base, sourceState: 'populated', migrateRequested: true });
52
+ expect(plan.migrate).toBe(true);
53
+ expect(plan.note).toContain('Migrating archives');
54
+ });
55
+
56
+ test('PRODUCTION CASE: source missing — skips migration, says so, does not error', () => {
57
+ const plan = planRelocation({ ...base, sourceState: 'missing', migrateRequested: true });
58
+ expect(plan.migrate).toBe(false);
59
+ expect(plan.note).toContain('does not exist');
60
+ expect(plan.note).toContain('nothing to migrate');
61
+ });
62
+
63
+ test('source unreadable — skips migration and says nothing was moved', () => {
64
+ const plan = planRelocation({ ...base, sourceState: 'unreadable', migrateRequested: true });
65
+ expect(plan.migrate).toBe(false);
66
+ expect(plan.note).toContain('not readable');
67
+ });
68
+
69
+ test('source empty — skips migration', () => {
70
+ const plan = planRelocation({ ...base, sourceState: 'empty', migrateRequested: true });
71
+ expect(plan.migrate).toBe(false);
72
+ expect(plan.note).toContain('empty');
73
+ });
74
+
75
+ test('--no-migrate wins even over a populated source', () => {
76
+ const plan = planRelocation({ ...base, sourceState: 'populated', migrateRequested: false });
77
+ expect(plan.migrate).toBe(false);
78
+ expect(plan.note).toContain('--no-migrate');
79
+ });
80
+ });
81
+
82
+ describe('resolveTargetPath (pure)', () => {
83
+ test('rejects a path identical to the current one', () => {
84
+ const result = resolveTargetPath('/var/backups', '/var/backups/');
85
+ expect(result).toMatchObject({ success: false });
86
+ });
87
+
88
+ test('preserves a path containing a space', () => {
89
+ const result = resolveTargetPath('/tmp/back ups/celilo', '/somewhere/else');
90
+ expect(result).toEqual({ path: '/tmp/back ups/celilo' });
91
+ });
92
+
93
+ test('expands a leading tilde', () => {
94
+ const result = resolveTargetPath('~/backups', '/somewhere/else');
95
+ expect(result).toMatchObject({ path: join(homedir(), 'backups') });
96
+ });
97
+ });
98
+
99
+ describe('inspectSourceDir', () => {
100
+ test('missing directory reports missing, not unreadable', () => {
101
+ expect(inspectSourceDir(join(testRoot, 'no-such-dir'))).toBe('missing');
102
+ });
103
+
104
+ test('empty directory reports empty', () => {
105
+ const dir = join(testRoot, 'empty-dir');
106
+ mkdirSync(dir, { recursive: true });
107
+ expect(inspectSourceDir(dir)).toBe('empty');
108
+ });
109
+
110
+ test('directory with files reports populated', () => {
111
+ const dir = join(testRoot, 'full-dir');
112
+ mkdirSync(dir, { recursive: true });
113
+ writeFileSync(join(dir, 'a.backup'), 'x');
114
+ expect(inspectSourceDir(dir)).toBe('populated');
115
+ });
116
+
117
+ test('unreadable directory reports unreadable, not missing', () => {
118
+ const parent = join(testRoot, 'locked');
119
+ const dir = join(parent, 'celilo-backups');
120
+ mkdirSync(dir, { recursive: true });
121
+ chmodSync(parent, 0o000);
122
+ try {
123
+ expect(inspectSourceDir(dir)).toBe('unreadable');
124
+ } finally {
125
+ chmodSync(parent, 0o700);
126
+ }
127
+ });
128
+ });
129
+
130
+ describe('updateStorageCredentials', () => {
131
+ test('clears the verification stamp, so a stale ✓ never survives a credential change (#566)', async () => {
132
+ const storage = await addBackupStorage({
133
+ name: 'Stamp Backups',
134
+ providerName: 'local',
135
+ credentials: { path: '/Users/nobody/old' },
136
+ });
137
+
138
+ const { getDb } = await import('../../db/client');
139
+ const { backupStorages } = await import('../../db/schema');
140
+ const { eq } = await import('drizzle-orm');
141
+ getDb()
142
+ .update(backupStorages)
143
+ .set({ verified: true, verifiedAt: new Date('2026-04-08T02:24:43.000Z') })
144
+ .where(eq(backupStorages.id, storage.id))
145
+ .run();
146
+
147
+ await updateStorageCredentials(storage.id, { path: '/var/lib/celilo/backups' });
148
+
149
+ // Asserted WITHOUT a follow-up verify: even if celilo dies between
150
+ // the path change and re-verification, the row must not still claim
151
+ // the destination was verified.
152
+ const after = getBackupStorageByStorageId(storage.storageId);
153
+ expect(after?.verified).toBe(false);
154
+ expect(after?.verifiedAt).toBeNull();
155
+ });
156
+ });
157
+
158
+ describe('handleStorageSetPath (end to end, isolated DB)', () => {
159
+ test('PRODUCTION CASE: current path does not exist — path changes, nothing migrated, re-verified against the new path', async () => {
160
+ const storage = await addBackupStorage({
161
+ name: 'Ghost Backups',
162
+ providerName: 'local',
163
+ credentials: { path: '/Users/nobody/hobby/backups' },
164
+ });
165
+
166
+ // Simulate the stale stamp: the row says Verified from a host that
167
+ // no longer exists.
168
+ const { getDb } = await import('../../db/client');
169
+ const { backupStorages } = await import('../../db/schema');
170
+ const { eq } = await import('drizzle-orm');
171
+ getDb()
172
+ .update(backupStorages)
173
+ .set({ verified: true, verifiedAt: new Date('2026-04-08T02:24:43.000Z') })
174
+ .where(eq(backupStorages.id, storage.id))
175
+ .run();
176
+
177
+ const newPath = join(testRoot, 'relocated');
178
+ const result = await handleStorageSetPath([storage.storageId, newPath]);
179
+
180
+ expect(result.success).toBe(true);
181
+
182
+ const creds = await getStorageCredentials(storage.id);
183
+ expect(creds).toMatchObject({ path: newPath });
184
+
185
+ // The stamp must describe the NEW path, not the dead one.
186
+ const after = getBackupStorageByStorageId(storage.storageId);
187
+ expect(after?.verified).toBe(true);
188
+ expect(after?.verifiedAt?.getTime()).toBeGreaterThan(
189
+ new Date('2026-04-08T02:24:43.000Z').getTime(),
190
+ );
191
+ });
192
+
193
+ test('moves existing archives to the new location', async () => {
194
+ const oldPath = join(testRoot, 'movable-old');
195
+ const newPath = join(testRoot, 'movable-new');
196
+ mkdirSync(join(oldPath, 'celilo-backups', '2026-08-01'), { recursive: true });
197
+ writeFileSync(join(oldPath, 'celilo-backups', '2026-08-01', 'x.backup'), 'payload');
198
+
199
+ const storage = await addBackupStorage({
200
+ name: 'Movable Backups',
201
+ providerName: 'local',
202
+ credentials: { path: oldPath },
203
+ });
204
+
205
+ const result = await handleStorageSetPath([storage.storageId, newPath]);
206
+
207
+ expect(result.success).toBe(true);
208
+ expect(existsSync(join(newPath, 'celilo-backups', '2026-08-01', 'x.backup'))).toBe(true);
209
+ expect(existsSync(join(oldPath, 'celilo-backups'))).toBe(false);
210
+ });
211
+
212
+ test('preserves archive mtimes — a move does not restamp backups', async () => {
213
+ const oldPath = join(testRoot, 'mtime-old');
214
+ const newPath = join(testRoot, 'mtime-new');
215
+ const archive = join(oldPath, 'celilo-backups', 'a.backup');
216
+ mkdirSync(join(oldPath, 'celilo-backups'), { recursive: true });
217
+ writeFileSync(archive, 'payload');
218
+ const stamp = new Date('2026-04-26T01:37:53.740Z');
219
+ utimesSync(archive, stamp, stamp);
220
+
221
+ const storage = await addBackupStorage({
222
+ name: 'Mtime Backups',
223
+ providerName: 'local',
224
+ credentials: { path: oldPath },
225
+ });
226
+
227
+ await handleStorageSetPath([storage.storageId, newPath]);
228
+
229
+ const moved = statSync(join(newPath, 'celilo-backups', 'a.backup'));
230
+ expect(Math.round(moved.mtimeMs)).toBe(stamp.getTime());
231
+ });
232
+
233
+ test('--no-migrate leaves the old archives where they are', async () => {
234
+ const oldPath = join(testRoot, 'kept-old');
235
+ const newPath = join(testRoot, 'kept-new');
236
+ mkdirSync(join(oldPath, 'celilo-backups'), { recursive: true });
237
+ writeFileSync(join(oldPath, 'celilo-backups', 'y.backup'), 'payload');
238
+
239
+ const storage = await addBackupStorage({
240
+ name: 'Kept Backups',
241
+ providerName: 'local',
242
+ credentials: { path: oldPath },
243
+ });
244
+
245
+ const result = await handleStorageSetPath([storage.storageId, newPath], {
246
+ 'no-migrate': true,
247
+ });
248
+
249
+ expect(result.success).toBe(true);
250
+ expect(existsSync(join(oldPath, 'celilo-backups', 'y.backup'))).toBe(true);
251
+ expect(existsSync(join(newPath, 'celilo-backups', 'y.backup'))).toBe(false);
252
+ });
253
+
254
+ test('handles a new path containing a space', async () => {
255
+ const oldPath = join(testRoot, 'spacey-old');
256
+ const newPath = join(testRoot, 'back ups', "Bob's celilo");
257
+ mkdirSync(join(oldPath, 'celilo-backups'), { recursive: true });
258
+ writeFileSync(join(oldPath, 'celilo-backups', 'z.backup'), 'payload');
259
+
260
+ const storage = await addBackupStorage({
261
+ name: 'Spacey Backups',
262
+ providerName: 'local',
263
+ credentials: { path: oldPath },
264
+ });
265
+
266
+ const result = await handleStorageSetPath([storage.storageId, newPath]);
267
+
268
+ expect(result.success).toBe(true);
269
+ expect(existsSync(join(newPath, 'celilo-backups', 'z.backup'))).toBe(true);
270
+ });
271
+
272
+ test('rejects an unknown storage id', async () => {
273
+ const result = await handleStorageSetPath(['no-such-storage', join(testRoot, 'x')]);
274
+ expect(result).toMatchObject({ success: false });
275
+ });
276
+
277
+ test('requires both a storage id and a path', async () => {
278
+ const result = await handleStorageSetPath(['only-one-arg']);
279
+ expect(result).toMatchObject({ success: false });
280
+ });
281
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Storage Set Path Command
3
+ * Relocate a local storage destination to a new directory, migrating
4
+ * any archives that are actually there.
5
+ *
6
+ * Motivating case (#566): celilo-mgr's `local-backups` points at
7
+ * `/Users/pbanka/...`, a macOS path carried across when the database was
8
+ * restored onto a Linux host. The directory does not exist there, so the
9
+ * relocation must complete cleanly with nothing to migrate rather than
10
+ * erroring — and must not carry the old `✓ Verified` stamp onto the new
11
+ * path.
12
+ */
13
+
14
+ import { cpSync, readdirSync, rmSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { join, resolve } from 'node:path';
17
+ import {
18
+ getBackupStorageByStorageId,
19
+ getStorageCredentials,
20
+ updateStorageCredentials,
21
+ verifyBackupStorage,
22
+ } from '../../services/backup-storage';
23
+ import { BACKUP_PREFIX } from '../../services/storage-providers/local';
24
+ import { celiloIntro, celiloOutro } from '../prompts';
25
+ import type { CommandResult } from '../types';
26
+ import { probePathWriteable } from './storage-add-local';
27
+
28
+ /** What the current archive directory turned out to be, on disk. */
29
+ export type SourceState = 'missing' | 'unreadable' | 'empty' | 'populated';
30
+
31
+ export interface RelocationPlan {
32
+ /** Whether to copy the source tree to the target. */
33
+ migrate: boolean;
34
+ /** One line for the operator explaining what will (not) happen. */
35
+ note: string;
36
+ }
37
+
38
+ /**
39
+ * Decide whether to migrate. Pure — takes an already-observed source
40
+ * state so it is testable without a filesystem (Rule 10.4).
41
+ */
42
+ export function planRelocation(input: {
43
+ sourceState: SourceState;
44
+ sourceDir: string;
45
+ targetDir: string;
46
+ migrateRequested: boolean;
47
+ }): RelocationPlan {
48
+ const { sourceState, sourceDir, targetDir, migrateRequested } = input;
49
+
50
+ if (!migrateRequested) {
51
+ return { migrate: false, note: `Migration skipped (--no-migrate). ${sourceDir} left as-is.` };
52
+ }
53
+
54
+ switch (sourceState) {
55
+ case 'missing':
56
+ return { migrate: false, note: `${sourceDir} does not exist — nothing to migrate.` };
57
+ case 'unreadable':
58
+ return { migrate: false, note: `${sourceDir} is not readable — nothing migrated.` };
59
+ case 'empty':
60
+ return { migrate: false, note: `${sourceDir} is empty — nothing to migrate.` };
61
+ case 'populated':
62
+ return { migrate: true, note: `Migrating archives from ${sourceDir} to ${targetDir}.` };
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Validate and normalise the requested path. Pure apart from `~`
68
+ * expansion, which reads the environment but touches no filesystem.
69
+ */
70
+ export function resolveTargetPath(
71
+ raw: string,
72
+ currentPath: string,
73
+ ): CommandResult | { path: string } {
74
+ const expanded = raw.startsWith('~/') || raw === '~' ? raw.replace('~', homedir()) : raw;
75
+ const resolved = resolve(expanded);
76
+
77
+ if (resolved === resolve(currentPath)) {
78
+ return { success: false, error: `Storage path is already '${resolved}' — nothing to do.` };
79
+ }
80
+
81
+ return { path: resolved };
82
+ }
83
+
84
+ /** Observe the archive directory. Distinguishes absent from unreadable. */
85
+ export function inspectSourceDir(dir: string): SourceState {
86
+ try {
87
+ return readdirSync(dir).length === 0 ? 'empty' : 'populated';
88
+ } catch (error) {
89
+ return (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'missing' : 'unreadable';
90
+ }
91
+ }
92
+
93
+ export async function handleStorageSetPath(
94
+ args: string[],
95
+ flags: Record<string, boolean | string> = {},
96
+ ): Promise<CommandResult> {
97
+ try {
98
+ celiloIntro('Relocate Backup Storage');
99
+
100
+ const storageId = args[0];
101
+ const newPathArg = args[1];
102
+ if (!storageId || !newPathArg) {
103
+ return {
104
+ success: false,
105
+ error:
106
+ 'Storage ID and new path are required\n\nUsage: celilo storage set-path <storage-id> <new-path> [--no-migrate]',
107
+ };
108
+ }
109
+
110
+ const storage = getBackupStorageByStorageId(storageId);
111
+ if (!storage) {
112
+ return { success: false, error: `Storage not found: ${storageId}` };
113
+ }
114
+ if (storage.providerName !== 'local') {
115
+ return {
116
+ success: false,
117
+ error: `'${storage.storageId}' is a ${storage.providerName} destination — set-path only applies to local storage.`,
118
+ };
119
+ }
120
+
121
+ const credentials = await getStorageCredentials(storage.id);
122
+ if (!('path' in credentials)) {
123
+ return { success: false, error: `Storage '${storage.storageId}' has no path configured.` };
124
+ }
125
+ const currentPath = credentials.path;
126
+
127
+ const resolved = resolveTargetPath(newPathArg, currentPath);
128
+ if ('success' in resolved) return resolved;
129
+ const newPath = resolved.path;
130
+
131
+ const writeError = probePathWriteable(newPath);
132
+ if (writeError !== null) {
133
+ return { success: false, error: `Path '${newPath}' is not writeable: ${writeError}` };
134
+ }
135
+
136
+ const sourceDir = join(currentPath, BACKUP_PREFIX);
137
+ const targetDir = join(newPath, BACKUP_PREFIX);
138
+ const plan = planRelocation({
139
+ sourceState: inspectSourceDir(sourceDir),
140
+ sourceDir,
141
+ targetDir,
142
+ migrateRequested: flags['no-migrate'] !== true,
143
+ });
144
+
145
+ console.log(`\n${plan.note}`);
146
+
147
+ if (plan.migrate) {
148
+ // A backup archive's mtime is part of what it is; a move must not
149
+ // restamp it. Bun's cpSync already preserves timestamps, so the
150
+ // flag is a no-op today — it is here for Node semantics, where the
151
+ // default is the other way. The mtime test guards the behavior,
152
+ // not this flag.
153
+ cpSync(sourceDir, targetDir, { recursive: true, force: true, preserveTimestamps: true });
154
+ }
155
+
156
+ await updateStorageCredentials(storage.id, { ...credentials, path: newPath });
157
+ console.log(`✓ Path updated: ${currentPath} → ${newPath}`);
158
+
159
+ if (plan.migrate) {
160
+ // Only after the DB points at the copy, so a failure here leaves
161
+ // archives duplicated rather than orphaned.
162
+ try {
163
+ rmSync(sourceDir, { recursive: true, force: true });
164
+ } catch (error) {
165
+ console.log(
166
+ `⚠ Copied, but could not remove ${sourceDir}: ${error instanceof Error ? error.message : String(error)}`,
167
+ );
168
+ }
169
+ }
170
+
171
+ const { result } = await verifyBackupStorage(storage.id);
172
+ if (!result.success) {
173
+ console.log(`✗ ${result.message}`);
174
+ celiloOutro(
175
+ `Path changed but verification failed.\n\nFix the path and re-verify: celilo storage verify ${storage.storageId}`,
176
+ );
177
+ return { success: false, error: result.message };
178
+ }
179
+
180
+ console.log(`✓ ${result.message}`);
181
+ celiloOutro(`'${storage.storageId}' now stores backups at ${targetDir}/`);
182
+
183
+ return { success: true, message: `Relocated ${storage.storageId} to ${newPath}` };
184
+ } catch (error) {
185
+ return {
186
+ success: false,
187
+ error: `Failed to set storage path: ${error instanceof Error ? error.message : String(error)}`,
188
+ };
189
+ }
190
+ }
@@ -35,8 +35,10 @@ import type { ModuleManifest } from '../../manifest/schema';
35
35
  import { RegistryClient } from '../../registry/client';
36
36
  import { decryptSecret } from '../../secrets/encryption';
37
37
  import { getOrCreateMasterKey } from '../../secrets/master-key';
38
+ import { readAllTransportStatuses } from '../../services/alerting/read-records';
38
39
  import { runAudit } from '../../services/audit';
39
40
  import type { DriftFinding, SystemAuditReport } from '../../services/audit';
41
+ import { loadAbandonedOperations } from '../../services/audit/abandoned-operations';
40
42
  import { loadBackupAuditInfo } from '../../services/audit/backup-source';
41
43
  import {
42
44
  type LatestCliVersionFetcher,
@@ -362,6 +364,7 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
362
364
  moduleConfigs: { modules: installedConfigs },
363
365
  health: { results: healthResults },
364
366
  backups: { modules: installedBackupInfo },
367
+ abandonedOperations: { records: loadAbandonedOperations(db) },
365
368
  undeployedModules: {
366
369
  modules: installed.map((m) => ({ id: m.id, state: m.state })),
367
370
  },
@@ -378,6 +381,17 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
378
381
  secretsDecryptable: { results: secretResults },
379
382
  servicesReachable: { results: serviceReachableResults },
380
383
  machinesReachable: { results: machineReachableResults },
384
+ // Reads the record the poller already writes — this check never performs a
385
+ // read of its own. One that did would drain the queue and eat the
386
+ // acknowledgement it exists to protect (#541).
387
+ transportReads: {
388
+ statuses: readAllTransportStatuses(db),
389
+ now: new Date(),
390
+ // Six missed polls. The poller runs every five minutes, so this absorbs a
391
+ // slow sweep, a restart, and a missed tick without crying wolf — while
392
+ // still catching a transport that has genuinely stopped being readable.
393
+ staleAfterMs: 30 * 60_000,
394
+ },
381
395
  trustedSources: { firewalls: collectFirewallReach(db) },
382
396
  };
383
397
  }
@@ -8,11 +8,49 @@
8
8
  */
9
9
 
10
10
  import type { Database } from 'bun:sqlite';
11
+ import { defineEvents, openBus } from '@celilo/event-bus';
12
+ import { getEventBusPath } from '../../config/paths';
11
13
  import { getDb } from '../../db/client';
12
14
  import { runMigrationsOn } from '../../db/migrate';
13
15
  import { findSchemaDrift } from '../../db/schema-introspection';
16
+ import { ensureBackupSweepSubscriber } from '../../services/backup-sweep';
17
+ import { ensureOperationsSweepSubscriber } from '../../services/module-operations';
14
18
  import type { CommandResult } from '../types';
15
19
 
20
+ /**
21
+ * Arm celilo's own housekeeping subscribers.
22
+ *
23
+ * Here because this command is what the .deb postinst runs on every apt
24
+ * upgrade — the one moment guaranteed to happen after new CLI code lands.
25
+ * A subscriber registered only from module install/update would not appear
26
+ * until some module happened to be touched next, which can be weeks and
27
+ * looks exactly like a feature that shipped and silently does nothing.
28
+ *
29
+ * Best-effort: a bus that can't be opened must not fail a schema migration.
30
+ */
31
+ function ensureCoreSubscribers(): void {
32
+ try {
33
+ const bus = openBus({ dbPath: getEventBusPath(), events: defineEvents({}) });
34
+ try {
35
+ ensureOperationsSweepSubscriber(bus);
36
+ // The backup sweep is armed here for the same reason, plus a sharper one:
37
+ // its row already exists on every deployed fleet, carrying the 60s bus
38
+ // default that made scheduled backups impossible. Correcting the default
39
+ // in code does nothing until something re-registers, and module
40
+ // install/update can be weeks away. This is the upgrade path.
41
+ //
42
+ // This also re-arms a sweep an operator paused by hand. Deliberate: the
43
+ // pause exists only because staging leaked, and the reaper that stops it
44
+ // leaking ships in this same binary.
45
+ ensureBackupSweepSubscriber(bus);
46
+ } finally {
47
+ bus.close();
48
+ }
49
+ } catch {
50
+ // Nothing to do — the next module install/update arms it.
51
+ }
52
+ }
53
+
16
54
  function countApplied(sqlite: Database): number {
17
55
  try {
18
56
  const row = sqlite
@@ -57,6 +95,8 @@ export async function handleSystemMigrate(): Promise<CommandResult> {
57
95
  };
58
96
  }
59
97
 
98
+ ensureCoreSubscribers();
99
+
60
100
  const lines = [
61
101
  applied > 0 ? `Applied ${applied} migration(s).` : 'Schema already up to date.',
62
102
  `Schema current: ${drift.tableCount} tables.`,
@@ -26,6 +26,7 @@ import { backups, moduleConfigs as moduleConfigsTbl, modules } from '../../db/sc
26
26
  import type { ModuleManifest } from '../../manifest/schema';
27
27
  import { RegistryClient } from '../../registry/client';
28
28
  import { runAudit } from '../../services/audit';
29
+ import { loadAbandonedOperations } from '../../services/audit/abandoned-operations';
29
30
  import { fetchLatestCliVersion } from '../../services/audit/cli-version';
30
31
  import { makeJournalReader, readAppliedMigrations } from '../../services/audit/schema';
31
32
  import { createModuleBackup, createSystemStateBackup } from '../../services/backup-create';
@@ -567,6 +568,7 @@ export async function handleSystemUpdate(
567
568
  lastSuccessfulBackupAt: latestBackupByModule.get(m.id) ?? null,
568
569
  })),
569
570
  },
571
+ abandonedOperations: { records: loadAbandonedOperations(db) },
570
572
  undeployedModules: {
571
573
  modules: installed.map((m) => ({ id: m.id, state: m.state })),
572
574
  },
@@ -585,6 +587,7 @@ export async function handleSystemUpdate(
585
587
  secretsDecryptable: { results: [] },
586
588
  servicesReachable: { results: [] },
587
589
  machinesReachable: { results: [] },
590
+ transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
588
591
  trustedSources: { firewalls: [] },
589
592
  };
590
593
 
@@ -777,6 +780,9 @@ export function rebuildAuditDepsForRerun(
777
780
  configs: configsByModule.get(m.id) ?? {},
778
781
  })),
779
782
  },
783
+ // Unchanged across an orchestrator run — an upgrade does not reclaim
784
+ // abandoned operations, so re-reading them would be the same rows.
785
+ abandonedOperations: original.abandonedOperations,
780
786
  backups: {
781
787
  ...original.backups,
782
788
  modules: upgradable.map((m) => ({
@@ -7,6 +7,7 @@ import { eq } from 'drizzle-orm';
7
7
  import { getDb } from '../db/client';
8
8
  import { capabilities, modules } from '../db/schema';
9
9
  import type { ModuleManifest } from '../manifest/schema';
10
+ import { SCHEDULABLE_BUILTIN_CHECKS } from '../services/alerting/builtin-source';
10
11
  import { listPrincipals } from '../services/api-access';
11
12
  import { listBackups } from '../services/backup-metadata';
12
13
  import { listBackupStorages } from '../services/backup-storage';
@@ -479,6 +480,23 @@ export async function getCompletions(words: string[], current: number): Promise<
479
480
  return filterSuggestions(['list', 'add', 'run', 'enable', 'disable'], args[1] || '');
480
481
  }
481
482
 
483
+ // Monitor targets - a module ID or one of celilo's own schedulable checks.
484
+ // Sourced from SCHEDULABLE_BUILTIN_CHECKS rather than a hand-copied list, so
485
+ // a new built-in check is completable the moment it is schedulable.
486
+ if (
487
+ command === 'monitor' &&
488
+ (args[1] === 'add' || args[1] === 'run' || args[1] === 'enable' || args[1] === 'disable') &&
489
+ currentIndex === 2
490
+ ) {
491
+ const db = getDb();
492
+ const moduleIds = db
493
+ .select({ id: modules.id })
494
+ .from(modules)
495
+ .all()
496
+ .map((m) => m.id);
497
+ return filterSuggestions([...SCHEDULABLE_BUILTIN_CHECKS, ...moduleIds], args[2] || '');
498
+ }
499
+
482
500
  // Alerts subcommands
483
501
  if (command === 'alerts' && currentIndex === 1) {
484
502
  return filterSuggestions(['list', 'ack', 'silence', 'resolve', 'sweep', 'poll'], args[1] || '');
@@ -486,7 +504,7 @@ export async function getCompletions(words: string[], current: number): Promise<
486
504
 
487
505
  // Storage subcommands
488
506
  if (command === 'storage' && currentIndex === 1) {
489
- const subcommands = ['add', 'list', 'remove', 'verify', 'set-default'];
507
+ const subcommands = ['add', 'list', 'remove', 'verify', 'set-default', 'set-path'];
490
508
  return filterSuggestions(subcommands, args[1] || '');
491
509
  }
492
510
 
@@ -496,10 +514,13 @@ export async function getCompletions(words: string[], current: number): Promise<
496
514
  return filterSuggestions(providers, args[2] || '');
497
515
  }
498
516
 
499
- // Storage remove/verify/set-default - complete with storage IDs
517
+ // Storage remove/verify/set-default/set-path - complete with storage IDs
500
518
  if (
501
519
  command === 'storage' &&
502
- (args[1] === 'remove' || args[1] === 'verify' || args[1] === 'set-default') &&
520
+ (args[1] === 'remove' ||
521
+ args[1] === 'verify' ||
522
+ args[1] === 'set-default' ||
523
+ args[1] === 'set-path') &&
503
524
  currentIndex === 2
504
525
  ) {
505
526
  const storages = listBackupStorages();
@@ -334,7 +334,6 @@ export class FuelGauge {
334
334
  * Strip ANSI codes from text
335
335
  */
336
336
  private stripAnsi(text: string): string {
337
- // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC character needed for ANSI code matching
338
337
  return text.replace(/\u001b\[[0-9;]*m/g, '');
339
338
  }
340
339
 
@@ -361,7 +361,7 @@ _celilo_system_config_keys() {
361
361
  'network.internal.gateway:Internal gateway IP'
362
362
  'network.secure-mgmt.subnet:Control-plane subnet (celilo-mgr own network)'
363
363
  'network.secure-mgmt.gateway:Control-plane gateway IP'
364
- 'network.vpn.subnet:VPN client subnet (WireGuard remote access)'
364
+ 'network.control-plane-vpn.subnet:Administrative VPN client subnet (WireGuard remote access)'
365
365
  'firewall.trusted_subnets:Extra subnets that reach every managed zone (comma-separated CIDRs)'
366
366
  'dns.primary:Primary DNS server'
367
367
  'dns.fallback:Fallback DNS servers'