@celilo/cli 0.18.0 → 0.20.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.
- package/CELILO_SUBSYSTEMS.md +4 -2
- package/package.json +4 -4
- package/src/api/remote-client.test.ts +86 -2
- package/src/api/serve.ts +242 -38
- package/src/api/sessions.test.ts +196 -0
- package/src/api/sessions.ts +278 -0
- package/src/cli/commands/apt-upgrade.test.ts +20 -1
- package/src/cli/commands/apt-upgrade.ts +12 -2
- package/src/cli/commands/backup-sweep.ts +25 -9
- package/src/cli/commands/events.ts +150 -4
- package/src/cli/commands/module-update.test.ts +72 -1
- package/src/cli/commands/module-update.ts +68 -22
- package/src/cli/commands/system-migrate.test.ts +56 -0
- package/src/cli/commands/system-migrate.ts +52 -4
- package/src/cli/completion.ts +2 -0
- package/src/cli/index.ts +27 -3
- package/src/db/migration-status.test.ts +114 -0
- package/src/db/migration-status.ts +78 -0
- package/src/db/schema-introspection.ts +8 -1
- package/src/services/backup-metadata.ts +17 -0
- package/src/services/backup-staging.test.ts +98 -0
- package/src/services/backup-staging.ts +73 -1
- package/src/services/backup-sweep.test.ts +15 -0
- package/src/services/backup-sweep.ts +17 -1
- package/src/services/bus-interview-park.test.ts +179 -0
- package/src/services/bus-interview.ts +17 -6
- package/src/services/events-daemon.test.ts +244 -0
- package/src/services/events-daemon.ts +295 -8
- package/src/services/fleet-checks.test.ts +75 -4
- package/src/services/fleet-checks.ts +82 -12
- package/src/services/interview-errors.ts +37 -0
- package/src/services/remote-responder.test.ts +83 -0
- package/src/services/remote-responder.ts +31 -10
- package/src/services/responder-probe.ts +3 -1
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* status bus.health() as JSON
|
|
10
10
|
* tail recent events
|
|
11
11
|
* list-subscribers persistent subscribers
|
|
12
|
-
* list-pending pending deliveries
|
|
12
|
+
* list-pending pending deliveries (subscriber fan-out — NOT questions)
|
|
13
|
+
* list-unanswered interview questions nobody has answered yet
|
|
13
14
|
* drain process pending deliveries once
|
|
14
15
|
* run long-running dispatcher (foreground; SIGINT to stop)
|
|
15
16
|
* emit <type> [json] emit an event (operator/test path; bypasses schema)
|
|
@@ -20,7 +21,9 @@
|
|
|
20
21
|
* resume alias for repair (acknowledges halt-on-recovery)
|
|
21
22
|
*/
|
|
22
23
|
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
23
25
|
import {
|
|
26
|
+
BUS_VERSION,
|
|
24
27
|
defineEvents,
|
|
25
28
|
drainOnce,
|
|
26
29
|
openBus,
|
|
@@ -28,6 +31,7 @@ import {
|
|
|
28
31
|
runDispatcher,
|
|
29
32
|
} from '@celilo/event-bus';
|
|
30
33
|
import { eq } from 'drizzle-orm';
|
|
34
|
+
import { sessionParkedOn } from '../../api/sessions';
|
|
31
35
|
import { getEventBusPath, shortenPath } from '../../config/paths';
|
|
32
36
|
import { getDb } from '../../db/client';
|
|
33
37
|
import { modules } from '../../db/schema';
|
|
@@ -37,10 +41,15 @@ import type { HookName } from '../../hooks/types';
|
|
|
37
41
|
import type { ModuleManifest } from '../../manifest/schema';
|
|
38
42
|
import type { EnsureRequiredPayload } from '../../services/bus-interview';
|
|
39
43
|
import {
|
|
44
|
+
detectPlatform,
|
|
40
45
|
installDaemon,
|
|
41
46
|
planDaemonInstall,
|
|
42
47
|
readInstalledUnit,
|
|
48
|
+
resolveRestartScope,
|
|
49
|
+
restartDaemon,
|
|
50
|
+
supervisorCommands,
|
|
43
51
|
uninstallDaemon,
|
|
52
|
+
unitInstalledInAnyScope,
|
|
44
53
|
} from '../../services/events-daemon';
|
|
45
54
|
import { getArg, hasFlag } from '../parser';
|
|
46
55
|
import type { CommandResult } from '../types';
|
|
@@ -234,6 +243,57 @@ export async function handleEventsListPending(
|
|
|
234
243
|
}
|
|
235
244
|
}
|
|
236
245
|
|
|
246
|
+
/** One unanswered interview question, as `events list-unanswered` reports it. */
|
|
247
|
+
export interface UnansweredInterview {
|
|
248
|
+
eventId: number;
|
|
249
|
+
type: string;
|
|
250
|
+
family: InterviewFamily;
|
|
251
|
+
/** `<scope>.<key>` — the identity a responder pre-stages an answer under. */
|
|
252
|
+
key: string;
|
|
253
|
+
question: string;
|
|
254
|
+
ageMs: number;
|
|
255
|
+
/** The parked api-serve session waiting on this answer, if any. */
|
|
256
|
+
sessionId: string | null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* `celilo events list-unanswered` — interview queries with no correlated reply:
|
|
261
|
+
* what is waiting on a decision right now.
|
|
262
|
+
*
|
|
263
|
+
* The instrument celilo#609 lacked. `events list-pending` was reached for and
|
|
264
|
+
* silently answered a different question (it reads subscriber *deliveries*), so
|
|
265
|
+
* a parked command looked like no command at all. Non-empty here for as long as
|
|
266
|
+
* something is parked is the recurrence gate for that whole class of bug.
|
|
267
|
+
*/
|
|
268
|
+
export async function handleEventsListUnanswered(
|
|
269
|
+
_args: string[],
|
|
270
|
+
flags: Record<string, string | boolean>,
|
|
271
|
+
): Promise<CommandResult> {
|
|
272
|
+
const bus = openCliBus();
|
|
273
|
+
try {
|
|
274
|
+
const limit = flags.limit ? Number(flags.limit) : 50;
|
|
275
|
+
const now = Date.now();
|
|
276
|
+
const rows: UnansweredInterview[] = [];
|
|
277
|
+
for (const event of bus.unansweredQueries({ limit })) {
|
|
278
|
+
const family = interviewFamily(event.type);
|
|
279
|
+
if (!family) continue; // e.g. responder.probe — not a question for an operator.
|
|
280
|
+
const payload = (event.payload ?? {}) as { message?: string; description?: string };
|
|
281
|
+
rows.push({
|
|
282
|
+
eventId: event.id,
|
|
283
|
+
type: event.type,
|
|
284
|
+
family,
|
|
285
|
+
key: event.type.slice(`${family}.required.`.length),
|
|
286
|
+
question: payload.message ?? payload.description ?? event.type,
|
|
287
|
+
ageMs: now - event.emittedAt,
|
|
288
|
+
sessionId: sessionParkedOn(String(event.id))?.sessionId ?? null,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return jsonResult(rows);
|
|
292
|
+
} finally {
|
|
293
|
+
bus.close();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
237
297
|
export async function handleEventsDrain(
|
|
238
298
|
_args: string[],
|
|
239
299
|
flags: Record<string, string | boolean>,
|
|
@@ -405,9 +465,12 @@ function interviewFamily(type: string): InterviewFamily | null {
|
|
|
405
465
|
/**
|
|
406
466
|
* `celilo events reply <query-event-id> <value-json>` — answer ONE pending
|
|
407
467
|
* interview query by its event id. The one-shot reply primitive a
|
|
408
|
-
* `claude-config-responder` uses:
|
|
409
|
-
* `events
|
|
410
|
-
*
|
|
468
|
+
* `claude-config-responder` uses: find the question with
|
|
469
|
+
* `celilo events list-unanswered`, ask the operator, emit the answer here.
|
|
470
|
+
* (It used to say "read the log with `events tail --type '…'`" — hand-scraping
|
|
471
|
+
* the log because no command listed unanswered questions. `list-unanswered` is
|
|
472
|
+
* that command; it also names the parked session, which `tail` cannot.)
|
|
473
|
+
* Unlike `events respond` (which must be subscribed BEFORE the query is
|
|
411
474
|
* emitted — bus watches don't replay history) this looks the query up by id
|
|
412
475
|
* and emits a correlated reply carrying `replyFor`, which plain `events emit`
|
|
413
476
|
* can't set.
|
|
@@ -897,6 +960,89 @@ export async function handleEventsUninstallDaemon(
|
|
|
897
960
|
}
|
|
898
961
|
}
|
|
899
962
|
|
|
963
|
+
/**
|
|
964
|
+
* `celilo events restart-daemon` — cycle the dispatcher through its supervisor
|
|
965
|
+
* and PROVE the new process is live on the installed code (celilo#604).
|
|
966
|
+
*
|
|
967
|
+
* The interesting case is the orphan: a dispatcher the supervisor doesn't own
|
|
968
|
+
* can't be killed by `systemctl restart`, and the one-dispatcher-per-bus guard
|
|
969
|
+
* (#584) then crash-loops the unit while the old code keeps serving. So this
|
|
970
|
+
* stops unmanaged dispatchers first, and verifies on the bus afterwards rather
|
|
971
|
+
* than trusting systemctl's exit code.
|
|
972
|
+
*/
|
|
973
|
+
export async function handleEventsRestartDaemon(
|
|
974
|
+
_args: string[],
|
|
975
|
+
flags: Record<string, string | boolean>,
|
|
976
|
+
): Promise<CommandResult> {
|
|
977
|
+
const bus = openCliBus();
|
|
978
|
+
try {
|
|
979
|
+
const platform = detectPlatform();
|
|
980
|
+
// No unit installed at all. apt-upgrade runs this on every box, including
|
|
981
|
+
// ones that never installed the daemon — failing those would break an
|
|
982
|
+
// upgrade that has nothing stale to fix. Distinguish the two cases:
|
|
983
|
+
// nothing running is a genuine no-op; something running is unsupervised
|
|
984
|
+
// and may be stale, and there is no supervisor to cycle it through.
|
|
985
|
+
if (!flags.system && !unitInstalledInAnyScope(platform)) {
|
|
986
|
+
const live = bus.liveDispatchers();
|
|
987
|
+
if (live.length === 0) {
|
|
988
|
+
return {
|
|
989
|
+
success: true,
|
|
990
|
+
message: 'No supervisor unit installed and no dispatcher running — nothing to restart.',
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
return {
|
|
994
|
+
success: false,
|
|
995
|
+
error: `A dispatcher is running unsupervised (pid ${live.map((d) => d.pid).join(', ')}, code v${live[0]?.version ?? '?'}) and no supervisor unit is installed, so it cannot be cycled — it may be serving stale code. Run \`celilo events install-daemon\`, enable the unit, then retry.`,
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
const scope = resolveRestartScope({ platform, scope: flags.system ? 'system' : undefined });
|
|
999
|
+
const cmds = supervisorCommands(platform, scope);
|
|
1000
|
+
const result = await restartDaemon(
|
|
1001
|
+
{ platform, scope, expectedVersion: BUS_VERSION },
|
|
1002
|
+
{
|
|
1003
|
+
liveDispatchers: () =>
|
|
1004
|
+
bus.liveDispatchers().map((d) => ({ pid: d.pid, version: d.version })),
|
|
1005
|
+
supervisorPid: () => {
|
|
1006
|
+
if (!cmds.mainPid) return null;
|
|
1007
|
+
const out = spawnSync(cmds.mainPid[0], cmds.mainPid.slice(1), { encoding: 'utf-8' });
|
|
1008
|
+
const pid = Number((out.stdout ?? '').trim());
|
|
1009
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
1010
|
+
},
|
|
1011
|
+
kill: (pid, signal) => {
|
|
1012
|
+
try {
|
|
1013
|
+
process.kill(pid, signal);
|
|
1014
|
+
} catch {
|
|
1015
|
+
// Already gone, or not ours to signal — the bus poll is the arbiter.
|
|
1016
|
+
}
|
|
1017
|
+
},
|
|
1018
|
+
restartUnit: () => {
|
|
1019
|
+
const out = spawnSync(cmds.restart[0], cmds.restart.slice(1), { encoding: 'utf-8' });
|
|
1020
|
+
if (out.status !== 0) {
|
|
1021
|
+
throw new Error(
|
|
1022
|
+
`${cmds.restart.join(' ')} failed (exit ${out.status ?? 'signal'}): ${(out.stderr ?? '').trim()}`,
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
},
|
|
1026
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
1027
|
+
},
|
|
1028
|
+
);
|
|
1029
|
+
const lines = [
|
|
1030
|
+
`Dispatcher restarted under the ${result.scope}-scope unit: ${shortenPath(result.unitPath)}`,
|
|
1031
|
+
` now running: pid ${result.dispatcher.pid}, code v${result.dispatcher.version}`,
|
|
1032
|
+
...(result.orphansKilled.length > 0
|
|
1033
|
+
? [
|
|
1034
|
+
` stopped ${result.orphansKilled.length} unsupervised dispatcher(s): pid ${result.orphansKilled.join(', ')}`,
|
|
1035
|
+
]
|
|
1036
|
+
: []),
|
|
1037
|
+
];
|
|
1038
|
+
return { success: true, message: lines.join('\n'), data: result };
|
|
1039
|
+
} catch (err) {
|
|
1040
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
1041
|
+
} finally {
|
|
1042
|
+
bus.close();
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
900
1046
|
/**
|
|
901
1047
|
* `celilo events show-daemon` — print whatever unit file is currently
|
|
902
1048
|
* installed (`--system` for the system-scope unit) so the operator can
|
|
@@ -11,7 +11,7 @@ import { join } from 'node:path';
|
|
|
11
11
|
import { eq } from 'drizzle-orm';
|
|
12
12
|
import { type DbClient, getDb } from '../../db/client';
|
|
13
13
|
import { modules } from '../../db/schema';
|
|
14
|
-
import { classifyVersionChange, updateOne } from './module-update';
|
|
14
|
+
import { classifyVersionChange, handleModuleUpdate, updateOne } from './module-update';
|
|
15
15
|
|
|
16
16
|
describe('classifyVersionChange', () => {
|
|
17
17
|
test('identical versions are up-to-date', () => {
|
|
@@ -250,3 +250,74 @@ subscriptions:
|
|
|
250
250
|
}
|
|
251
251
|
});
|
|
252
252
|
});
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Regression for the fabricated decline: driven headlessly with no responder,
|
|
256
|
+
* the registry sweep reported a breaking update as "operator declined" — a
|
|
257
|
+
* decision nobody was asked to make. An unanswerable question is not a "no".
|
|
258
|
+
*/
|
|
259
|
+
describe('registry sweep — an unanswered breaking update is not a decline', () => {
|
|
260
|
+
let tempDir: string;
|
|
261
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
262
|
+
let registryUrl: string;
|
|
263
|
+
|
|
264
|
+
beforeEach(() => {
|
|
265
|
+
tempDir = mkdtempSync(join(tmpdir(), 'celilo-sweep-'));
|
|
266
|
+
process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
|
|
267
|
+
process.env.CELILO_ORIGINAL_CWD = tempDir;
|
|
268
|
+
// Isolated bus with no responder attached — the headless case.
|
|
269
|
+
process.env.EVENT_BUS_DB = join(tempDir, 'events.db');
|
|
270
|
+
|
|
271
|
+
getDb()
|
|
272
|
+
.insert(modules)
|
|
273
|
+
.values({
|
|
274
|
+
id: 'iptables',
|
|
275
|
+
name: 'iptables',
|
|
276
|
+
sourcePath: join(tempDir, 'installed'),
|
|
277
|
+
version: '1.0.2+9',
|
|
278
|
+
manifestData: {
|
|
279
|
+
celilo_contract: '1.0',
|
|
280
|
+
id: 'iptables',
|
|
281
|
+
name: 'iptables',
|
|
282
|
+
version: '1.0.2',
|
|
283
|
+
},
|
|
284
|
+
})
|
|
285
|
+
.run();
|
|
286
|
+
|
|
287
|
+
// Minimal sparse-index server offering a major bump for `iptables`.
|
|
288
|
+
server = Bun.serve({
|
|
289
|
+
port: 0,
|
|
290
|
+
fetch(req) {
|
|
291
|
+
const path = new URL(req.url).pathname;
|
|
292
|
+
if (path === '/index/ip/ta/iptables') {
|
|
293
|
+
return new Response(
|
|
294
|
+
`${JSON.stringify({ name: 'iptables', vers: '2.0.0+1', deps: [], cksum: 'x' })}\n`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
return new Response('not found', { status: 404 });
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
registryUrl = `http://localhost:${server.port}`;
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
afterEach(() => {
|
|
304
|
+
server.stop(true);
|
|
305
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
306
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
307
|
+
process.env.CELILO_ORIGINAL_CWD = undefined;
|
|
308
|
+
process.env.EVENT_BUS_DB = undefined;
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('reports it as unanswered, never as declined, and fails the sweep', async () => {
|
|
312
|
+
const result = await handleModuleUpdate([], { registry: registryUrl });
|
|
313
|
+
|
|
314
|
+
const report = result.success ? (result.message ?? '') : (result.error ?? '');
|
|
315
|
+
expect(report).not.toContain('operator declined');
|
|
316
|
+
expect(report).toContain('NOT declined');
|
|
317
|
+
expect(report).toContain('iptables');
|
|
318
|
+
// A breaking update that silently didn't land must not read as success.
|
|
319
|
+
expect(result.success).toBe(false);
|
|
320
|
+
// And the module is still on the old version — no accidental upgrade.
|
|
321
|
+
expect(getDb().select().from(modules).all()[0].version).toBe('1.0.2+9');
|
|
322
|
+
});
|
|
323
|
+
});
|
|
@@ -23,6 +23,7 @@ import type { ModuleManifest } from '../../manifest/schema';
|
|
|
23
23
|
import { cleanupTempDir, extractPackage } from '../../module/packaging/extract';
|
|
24
24
|
import { RegistryClient } from '../../registry/client';
|
|
25
25
|
import { askConfirm, withInterviewSession } from '../../services/bus-interview';
|
|
26
|
+
import { InterviewAbandonedError, InterviewUnansweredError } from '../../services/interview-errors';
|
|
26
27
|
import { getFlag } from '../parser';
|
|
27
28
|
import { log } from '../prompts';
|
|
28
29
|
import type { CommandResult } from '../types';
|
|
@@ -496,7 +497,9 @@ async function runRegistrySweep(
|
|
|
496
497
|
}
|
|
497
498
|
|
|
498
499
|
let appliedBreaking = 0;
|
|
499
|
-
let
|
|
500
|
+
let declinedBreaking = 0;
|
|
501
|
+
const unanswered: Array<{ moduleId: string; error: string }> = [];
|
|
502
|
+
const abandoned: Array<{ moduleId: string; error: string }> = [];
|
|
500
503
|
|
|
501
504
|
if (breaking.length > 0) {
|
|
502
505
|
log.info('\nBreaking updates available — review required (semver-major bump):');
|
|
@@ -508,16 +511,39 @@ async function runRegistrySweep(
|
|
|
508
511
|
log.message('Each breaking update will be applied only on explicit confirmation.\n');
|
|
509
512
|
|
|
510
513
|
for (const plan of breaking) {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
514
|
+
let proceed: boolean;
|
|
515
|
+
try {
|
|
516
|
+
proceed = await withInterviewSession(() =>
|
|
517
|
+
askConfirm({
|
|
518
|
+
scope: `module-upgrade:${plan.moduleId}`,
|
|
519
|
+
key: 'apply_breaking',
|
|
520
|
+
message: `Apply breaking update for ${plan.moduleId} (${plan.installedVersion} → ${plan.targetVersion})?`,
|
|
521
|
+
defaultValue: false,
|
|
522
|
+
}),
|
|
523
|
+
);
|
|
524
|
+
} catch (err) {
|
|
525
|
+
// Neither of these is a decline, and they are not each other either:
|
|
526
|
+
// UNANSWERED = no responder was listening at all (fail-fast probe);
|
|
527
|
+
// ABANDONED = the question stood and its parked session expired.
|
|
528
|
+
// Record and keep going so updates already applied aren't thrown away.
|
|
529
|
+
if (err instanceof InterviewUnansweredError) {
|
|
530
|
+
unanswered.push({ moduleId: plan.moduleId, error: err.message });
|
|
531
|
+
console.log(
|
|
532
|
+
` ? ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion} (major, UNANSWERED)`,
|
|
533
|
+
);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (err instanceof InterviewAbandonedError) {
|
|
537
|
+
abandoned.push({ moduleId: plan.moduleId, error: err.message });
|
|
538
|
+
console.log(
|
|
539
|
+
` ? ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion} (major, ABANDONED)`,
|
|
540
|
+
);
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
throw err;
|
|
544
|
+
}
|
|
519
545
|
if (!proceed) {
|
|
520
|
-
|
|
546
|
+
declinedBreaking++;
|
|
521
547
|
continue;
|
|
522
548
|
}
|
|
523
549
|
const result = await fetchAndUpdate(client, plan.moduleId, plan.targetVersion, db, flags);
|
|
@@ -545,8 +571,26 @@ async function runRegistrySweep(
|
|
|
545
571
|
} else {
|
|
546
572
|
summary.push('No updates applied.');
|
|
547
573
|
}
|
|
548
|
-
if (
|
|
549
|
-
summary.push(`Skipped ${
|
|
574
|
+
if (declinedBreaking > 0) {
|
|
575
|
+
summary.push(`Skipped ${declinedBreaking} breaking update(s) (operator declined).`);
|
|
576
|
+
}
|
|
577
|
+
if (unanswered.length > 0) {
|
|
578
|
+
summary.push(
|
|
579
|
+
`Skipped ${unanswered.length} breaking update(s) — NOT declined: the confirmation could not be answered (${unanswered
|
|
580
|
+
.map((u) => u.moduleId)
|
|
581
|
+
.join(
|
|
582
|
+
', ',
|
|
583
|
+
)}). Re-run with a responder attached, or pre-stage the answer under "module-upgrade:<module>.apply_breaking".`,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
if (abandoned.length > 0) {
|
|
587
|
+
summary.push(
|
|
588
|
+
`Skipped ${abandoned.length} breaking update(s) — NOT declined: the confirmation was never decided and its session expired (${abandoned
|
|
589
|
+
.map((a) => a.moduleId)
|
|
590
|
+
.join(
|
|
591
|
+
', ',
|
|
592
|
+
)}). Answer it next time with "celilo events list-unanswered" + "celilo events reply <id> <value>".`,
|
|
593
|
+
);
|
|
550
594
|
}
|
|
551
595
|
if (notInRegistry.length > 0) {
|
|
552
596
|
summary.push(`Not in registry (${notInRegistry.length}): ${notInRegistry.join(', ')}`);
|
|
@@ -556,16 +600,18 @@ async function runRegistrySweep(
|
|
|
556
600
|
`Registry errors (${errored.length}): ${errored.map((e) => `${e.moduleId} — ${e.error}`).join('; ')}`,
|
|
557
601
|
);
|
|
558
602
|
}
|
|
559
|
-
if (failed.length > 0) {
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
603
|
+
if (failed.length > 0 || unanswered.length > 0 || abandoned.length > 0) {
|
|
604
|
+
const detail: string[] = [];
|
|
605
|
+
if (failed.length > 0) {
|
|
606
|
+
detail.push('', 'Failures:', ...failed.map((f) => ` ${f.moduleId}: ${f.error}`));
|
|
607
|
+
}
|
|
608
|
+
if (unanswered.length > 0) {
|
|
609
|
+
detail.push('', 'Unanswered:', ...unanswered.map((u) => ` ${u.moduleId}: ${u.error}`));
|
|
610
|
+
}
|
|
611
|
+
if (abandoned.length > 0) {
|
|
612
|
+
detail.push('', 'Abandoned:', ...abandoned.map((a) => ` ${a.moduleId}: ${a.error}`));
|
|
613
|
+
}
|
|
614
|
+
return { success: false, error: [...summary, ...detail].join('\n') };
|
|
569
615
|
}
|
|
570
616
|
return { success: true, message: summary.join('\n') };
|
|
571
617
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
1
2
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
3
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
@@ -37,4 +38,59 @@ describe('handleSystemMigrate', () => {
|
|
|
37
38
|
const second = await handleSystemMigrate();
|
|
38
39
|
expect(second.success).toBe(true);
|
|
39
40
|
});
|
|
41
|
+
|
|
42
|
+
// celilo#604: the runbook asserts "applied 19 -> 20, backups.pid present".
|
|
43
|
+
// Before this, the only answer was a table COUNT, which cannot see a column.
|
|
44
|
+
describe('--status', () => {
|
|
45
|
+
it('names the applied count and the latest applied migration', async () => {
|
|
46
|
+
await handleSystemMigrate();
|
|
47
|
+
closeDb();
|
|
48
|
+
|
|
49
|
+
const result = await handleSystemMigrate([], { status: true });
|
|
50
|
+
|
|
51
|
+
expect(result.success).toBe(true);
|
|
52
|
+
if (result.success) {
|
|
53
|
+
expect(result.message).toMatch(/Applied migrations: \d+/);
|
|
54
|
+
expect(result.message).toContain('0019_backup_pid');
|
|
55
|
+
expect(result.message).toContain('Pending: none');
|
|
56
|
+
expect(result.message).toContain('columns');
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('reports a pending migration WITHOUT applying it', async () => {
|
|
61
|
+
await handleSystemMigrate();
|
|
62
|
+
closeDb();
|
|
63
|
+
// Rewind one migration, the way an upgrade that never ran would look.
|
|
64
|
+
const raw = new Database(process.env.CELILO_DB_PATH as string);
|
|
65
|
+
raw.run(
|
|
66
|
+
'DELETE FROM `__drizzle_migrations` WHERE created_at = (SELECT MAX(created_at) FROM `__drizzle_migrations`)',
|
|
67
|
+
);
|
|
68
|
+
raw.run('ALTER TABLE backups DROP COLUMN pid');
|
|
69
|
+
const countBefore = raw
|
|
70
|
+
.query<{ c: number }, []>('SELECT COUNT(*) AS c FROM `__drizzle_migrations`')
|
|
71
|
+
.get()?.c;
|
|
72
|
+
raw.close();
|
|
73
|
+
|
|
74
|
+
const result = await handleSystemMigrate([], { status: true });
|
|
75
|
+
|
|
76
|
+
expect(result.success).toBe(false);
|
|
77
|
+
if (!result.success) {
|
|
78
|
+
expect(result.error).toContain('0019_backup_pid');
|
|
79
|
+
expect(result.error).toContain('backups.pid');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A status that repaired what it reports would always read clean — the
|
|
83
|
+
// exact placebo this command exists to replace.
|
|
84
|
+
const after = new Database(process.env.CELILO_DB_PATH as string);
|
|
85
|
+
const countAfter = after
|
|
86
|
+
.query<{ c: number }, []>('SELECT COUNT(*) AS c FROM `__drizzle_migrations`')
|
|
87
|
+
.get()?.c;
|
|
88
|
+
const cols = after
|
|
89
|
+
.query<{ name: string }, []>("SELECT name FROM pragma_table_info('backups')")
|
|
90
|
+
.all();
|
|
91
|
+
after.close();
|
|
92
|
+
expect(countAfter).toBe(countBefore as number);
|
|
93
|
+
expect(cols.map((c) => c.name)).not.toContain('pid');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
40
96
|
});
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
import type { Database } from 'bun:sqlite';
|
|
11
11
|
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
12
12
|
import { getEventBusPath } from '../../config/paths';
|
|
13
|
-
import { getDb } from '../../db/client';
|
|
13
|
+
import { createDbClient, findMigrationsFolder, getDb } from '../../db/client';
|
|
14
14
|
import { runMigrationsOn } from '../../db/migrate';
|
|
15
|
+
import { getMigrationStatus } from '../../db/migration-status';
|
|
15
16
|
import { findSchemaDrift } from '../../db/schema-introspection';
|
|
16
17
|
import { ensureBackupSweepSubscriber } from '../../services/backup-sweep';
|
|
17
18
|
import { ensureOperationsSweepSubscriber } from '../../services/module-operations';
|
|
@@ -62,7 +63,50 @@ function countApplied(sqlite: Database): number {
|
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
|
|
66
|
+
/**
|
|
67
|
+
* `celilo system migrate --status` — read-only interrogation (celilo#604).
|
|
68
|
+
*
|
|
69
|
+
* A rollout runbook that says "assert applied 19 → 20 and `backups.pid`
|
|
70
|
+
* present" needs a product surface to assert against; the table count that used
|
|
71
|
+
* to be the only answer cannot see a column migration at all.
|
|
72
|
+
*/
|
|
73
|
+
export function migrationStatusResult(sqlite: Database): CommandResult {
|
|
74
|
+
const status = getMigrationStatus(sqlite, findMigrationsFolder());
|
|
75
|
+
const missing = [...status.missingTables, ...status.missingColumns];
|
|
76
|
+
const lines = [
|
|
77
|
+
`Applied migrations: ${status.appliedCount}`,
|
|
78
|
+
`Latest applied: ${status.latestApplied ?? '(none)'}`,
|
|
79
|
+
status.pending.length > 0
|
|
80
|
+
? `Pending: ${status.pending.join(', ')}`
|
|
81
|
+
: 'Pending: none',
|
|
82
|
+
`Schema present: ${status.tableCount} tables, ${status.columnCount} columns`,
|
|
83
|
+
...(missing.length > 0 ? [`Missing: ${missing.join(', ')}`] : []),
|
|
84
|
+
];
|
|
85
|
+
if (status.pending.length > 0 || missing.length > 0) {
|
|
86
|
+
return {
|
|
87
|
+
success: false,
|
|
88
|
+
error: `${lines.join('\n')}\n\nRun \`celilo system migrate\` to apply pending migrations on this box.`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return { success: true, message: lines.join('\n'), data: status };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function handleSystemMigrate(
|
|
95
|
+
_args: string[] = [],
|
|
96
|
+
flags: Record<string, string | boolean> = {},
|
|
97
|
+
): Promise<CommandResult> {
|
|
98
|
+
// --status must NOT migrate. getDb() auto-migrates on open, so a status that
|
|
99
|
+
// went through it would repair the very state it claims to be reporting and
|
|
100
|
+
// could never say "pending" — the placebo shape this command exists to end.
|
|
101
|
+
if (flags.status) {
|
|
102
|
+
const ro = createDbClient({ readonly: true });
|
|
103
|
+
try {
|
|
104
|
+
return migrationStatusResult(ro.$client);
|
|
105
|
+
} finally {
|
|
106
|
+
ro.$client.close();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
66
110
|
// getDb() auto-migrates on open; do it inside try so an existing DB that
|
|
67
111
|
// predates the drizzle-authoritative change fails with an actionable message
|
|
68
112
|
// instead of a raw migrator error.
|
|
@@ -97,9 +141,13 @@ export async function handleSystemMigrate(): Promise<CommandResult> {
|
|
|
97
141
|
|
|
98
142
|
ensureCoreSubscribers();
|
|
99
143
|
|
|
144
|
+
// Name the latest migration, not just a table count: "35 tables" reads the
|
|
145
|
+
// same whether a column migration applied or silently did nothing (celilo#604).
|
|
146
|
+
const status = getMigrationStatus(sqlite, findMigrationsFolder());
|
|
100
147
|
const lines = [
|
|
101
148
|
applied > 0 ? `Applied ${applied} migration(s).` : 'Schema already up to date.',
|
|
102
|
-
`
|
|
149
|
+
`Applied migrations: ${status.appliedCount} (latest: ${status.latestApplied ?? 'none'})`,
|
|
150
|
+
`Schema current: ${drift.tableCount} tables, ${drift.columnCount} columns.`,
|
|
103
151
|
];
|
|
104
|
-
return { success: true, message: lines.join('\n') };
|
|
152
|
+
return { success: true, message: lines.join('\n'), data: status };
|
|
105
153
|
}
|
package/src/cli/completion.ts
CHANGED
|
@@ -113,6 +113,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
113
113
|
'list-subscribers',
|
|
114
114
|
'resync-subscriptions',
|
|
115
115
|
'list-pending',
|
|
116
|
+
'list-unanswered',
|
|
116
117
|
'drain',
|
|
117
118
|
'run',
|
|
118
119
|
'run-hook',
|
|
@@ -125,6 +126,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
125
126
|
'respond',
|
|
126
127
|
'install-daemon',
|
|
127
128
|
'uninstall-daemon',
|
|
129
|
+
'restart-daemon',
|
|
128
130
|
'show-daemon',
|
|
129
131
|
];
|
|
130
132
|
return filterSuggestions(subcommands, args[1] || '');
|
package/src/cli/index.ts
CHANGED
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
* Orchestration function (Rule 10.1) - routes commands to handlers
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
COMMANDS,
|
|
9
|
+
type CommandDef,
|
|
10
|
+
EXIT_BLOCKED,
|
|
11
|
+
resolveRemote,
|
|
12
|
+
runRemoteClient,
|
|
13
|
+
} from '@celilo/core';
|
|
8
14
|
import * as p from '@clack/prompts';
|
|
9
15
|
import { CLIServerRequestSchema, parseJsonWithValidation } from '../validation/schemas';
|
|
10
16
|
import {
|
|
@@ -28,9 +34,11 @@ import {
|
|
|
28
34
|
handleEventsInstallDaemon,
|
|
29
35
|
handleEventsListPending,
|
|
30
36
|
handleEventsListSubscribers,
|
|
37
|
+
handleEventsListUnanswered,
|
|
31
38
|
handleEventsRepair,
|
|
32
39
|
handleEventsReply,
|
|
33
40
|
handleEventsRespond,
|
|
41
|
+
handleEventsRestartDaemon,
|
|
34
42
|
handleEventsResyncSubscriptions,
|
|
35
43
|
handleEventsRun,
|
|
36
44
|
handleEventsRunHook,
|
|
@@ -303,6 +311,7 @@ Subcommands:
|
|
|
303
311
|
respond Run the terminal responder; answer deploy prompts from another shell
|
|
304
312
|
install-daemon [--system] Write a systemd/launchd unit for the dispatcher (--system: management-plane scope)
|
|
305
313
|
uninstall-daemon [--system] Remove the installed supervisor unit
|
|
314
|
+
restart-daemon [--system] Restart the dispatcher and verify the new process is on current code
|
|
306
315
|
show-daemon [--system] Print the currently installed unit file
|
|
307
316
|
|
|
308
317
|
Description:
|
|
@@ -1416,6 +1425,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1416
1425
|
return handleEventsResyncSubscriptions();
|
|
1417
1426
|
case 'list-pending':
|
|
1418
1427
|
return handleEventsListPending(parsed.args, parsed.flags);
|
|
1428
|
+
case 'list-unanswered':
|
|
1429
|
+
return handleEventsListUnanswered(parsed.args, parsed.flags);
|
|
1419
1430
|
case 'drain':
|
|
1420
1431
|
return handleEventsDrain(parsed.args, parsed.flags);
|
|
1421
1432
|
case 'run':
|
|
@@ -1439,6 +1450,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1439
1450
|
return handleEventsInstallDaemon(parsed.args, parsed.flags);
|
|
1440
1451
|
case 'uninstall-daemon':
|
|
1441
1452
|
return handleEventsUninstallDaemon(parsed.args, parsed.flags);
|
|
1453
|
+
case 'restart-daemon':
|
|
1454
|
+
return handleEventsRestartDaemon(parsed.args, parsed.flags);
|
|
1442
1455
|
case 'show-daemon':
|
|
1443
1456
|
return handleEventsShowDaemon(parsed.args, parsed.flags);
|
|
1444
1457
|
default:
|
|
@@ -2133,7 +2146,7 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
2133
2146
|
}
|
|
2134
2147
|
|
|
2135
2148
|
if (parsed.subcommand === 'migrate') {
|
|
2136
|
-
return handleSystemMigrate();
|
|
2149
|
+
return handleSystemMigrate(parsed.args, parsed.flags);
|
|
2137
2150
|
}
|
|
2138
2151
|
|
|
2139
2152
|
return {
|
|
@@ -2423,7 +2436,18 @@ export async function main(): Promise<void> {
|
|
|
2423
2436
|
// SSH to the remote celilo-mgr and drive its api-serve over the wire.
|
|
2424
2437
|
const remote = resolveRemote(process.argv);
|
|
2425
2438
|
if (remote) {
|
|
2426
|
-
|
|
2439
|
+
const outcome = await runRemoteClient(remote.dest, remote.commandArgv);
|
|
2440
|
+
if (outcome.status === 'blocked') {
|
|
2441
|
+
// Not a failure and not a decline: the command is parked server-side on a
|
|
2442
|
+
// question this client couldn't decide, and is still alive.
|
|
2443
|
+
process.stderr.write(
|
|
2444
|
+
`Command is parked on an unanswered question: ${outcome.question}\n` +
|
|
2445
|
+
` answer it: celilo events reply ${outcome.eventId} <value>\n` +
|
|
2446
|
+
` it resumes server-side under session ${outcome.sessionId}\n`,
|
|
2447
|
+
);
|
|
2448
|
+
process.exit(EXIT_BLOCKED);
|
|
2449
|
+
}
|
|
2450
|
+
process.exit(outcome.exitCode);
|
|
2427
2451
|
}
|
|
2428
2452
|
|
|
2429
2453
|
// Normal single-command execution
|