@celilo/cli 0.19.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.
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Parked-session registry for `api-serve` (celilo#609).
3
+ *
4
+ * A command that parks on an unanswerable question must outlive the ssh session
5
+ * that started it, so a *different* responder can answer later and the caller
6
+ * can come back for the outcome. Two consequences shape this file:
7
+ *
8
+ * 1. **The registry is on disk, not in memory.** Each ssh connection is its own
9
+ * `api-serve` process, so the process that later handles `attach` is not the
10
+ * one holding the parked child. A directory under the data dir is the
11
+ * handoff: metadata in `session.json`, the command's output appended to
12
+ * `output.ndjson`. The attaching process replays the file and tails it.
13
+ * 2. **The record is retained after the session ends.** A repeatedly-parking
14
+ * command is only detectable if the abandonments are still there to see —
15
+ * the same reason `module operations` keeps its rows.
16
+ *
17
+ * ponytail: files + polling rather than a socket/IPC server. Same process model,
18
+ * a fraction of the machinery, and the buffered output has to be durable across
19
+ * processes anyway. Move to a socket only if attach latency becomes a problem.
20
+ */
21
+
22
+ import {
23
+ appendFileSync,
24
+ existsSync,
25
+ mkdirSync,
26
+ readFileSync,
27
+ readdirSync,
28
+ writeFileSync,
29
+ } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ import type { ServerMessage } from '@celilo/core';
32
+ import { defineEvents, openBus } from '@celilo/event-bus';
33
+ import { z } from 'zod';
34
+ import { getDataDir } from '../config/paths';
35
+
36
+ const NO_SCHEMAS = defineEvents({});
37
+
38
+ /** How long a parked session may hold its child before the reaper abandons it. */
39
+ export const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
40
+
41
+ /** Lines of buffered output an attaching client is replayed at most. */
42
+ const REPLAY_LIMIT = 2000;
43
+
44
+ export const SessionStateSchema = z.enum(['running', 'parked', 'finished', 'abandoned']);
45
+ export type SessionState = z.infer<typeof SessionStateSchema>;
46
+
47
+ export const SessionRecordSchema = z.object({
48
+ sessionId: z.string(),
49
+ principal: z.string(),
50
+ argv: z.array(z.string()),
51
+ startedAt: z.number(),
52
+ expiresAt: z.number(),
53
+ state: SessionStateSchema,
54
+ /** Bus event id of the question this session is parked on, when parked. */
55
+ parkedEventId: z.string().nullable(),
56
+ /** Bus event *type* of that question — what the reaper replies to. */
57
+ parkedEventType: z.string().nullable(),
58
+ question: z.string().nullable(),
59
+ /** The parked question's `<scope>.<key>`. */
60
+ questionKey: z.string().nullable(),
61
+ });
62
+ export type SessionRecord = z.infer<typeof SessionRecordSchema>;
63
+
64
+ export function getSessionsDir(): string {
65
+ return join(getDataDir(), 'api-sessions');
66
+ }
67
+
68
+ function sessionDir(sessionId: string): string {
69
+ return join(getSessionsDir(), sessionId);
70
+ }
71
+
72
+ function recordPath(sessionId: string): string {
73
+ return join(sessionDir(sessionId), 'session.json');
74
+ }
75
+
76
+ function outputPath(sessionId: string): string {
77
+ return join(sessionDir(sessionId), 'output.ndjson');
78
+ }
79
+
80
+ export function readSession(sessionId: string): SessionRecord | null {
81
+ const path = recordPath(sessionId);
82
+ if (!existsSync(path)) return null;
83
+ // Untrusted only in the sense of "written by another process" — validate it
84
+ // rather than trusting the shape (Rule 3.7).
85
+ const parsed = SessionRecordSchema.safeParse(JSON.parse(readFileSync(path, 'utf-8')));
86
+ return parsed.success ? parsed.data : null;
87
+ }
88
+
89
+ export function listSessions(): SessionRecord[] {
90
+ const dir = getSessionsDir();
91
+ if (!existsSync(dir)) return [];
92
+ return readdirSync(dir)
93
+ .map(readSession)
94
+ .filter((s): s is SessionRecord => s !== null)
95
+ .sort((a, b) => b.startedAt - a.startedAt);
96
+ }
97
+
98
+ /** The session parked on a given bus query, if one is. */
99
+ export function sessionParkedOn(eventId: string): SessionRecord | null {
100
+ return listSessions().find((s) => s.state === 'parked' && s.parkedEventId === eventId) ?? null;
101
+ }
102
+
103
+ function write(record: SessionRecord): void {
104
+ mkdirSync(sessionDir(record.sessionId), { recursive: true });
105
+ writeFileSync(recordPath(record.sessionId), `${JSON.stringify(record, null, 2)}\n`);
106
+ }
107
+
108
+ /**
109
+ * A live session's handle: the writer side of the registry. Held by the
110
+ * `api-serve` process running the command.
111
+ */
112
+ export class SessionWriter {
113
+ private record: SessionRecord;
114
+
115
+ private constructor(record: SessionRecord) {
116
+ this.record = record;
117
+ write(record);
118
+ }
119
+
120
+ static create(opts: {
121
+ principal: string;
122
+ argv: string[];
123
+ ttlMs?: number;
124
+ now?: number;
125
+ }): SessionWriter {
126
+ const now = opts.now ?? Date.now();
127
+ return new SessionWriter({
128
+ sessionId: crypto.randomUUID(),
129
+ principal: opts.principal,
130
+ argv: opts.argv,
131
+ startedAt: now,
132
+ expiresAt: now + (opts.ttlMs ?? DEFAULT_SESSION_TTL_MS),
133
+ state: 'running',
134
+ parkedEventId: null,
135
+ parkedEventType: null,
136
+ question: null,
137
+ questionKey: null,
138
+ });
139
+ }
140
+
141
+ get id(): string {
142
+ return this.record.sessionId;
143
+ }
144
+
145
+ get current(): SessionRecord {
146
+ return this.record;
147
+ }
148
+
149
+ /** Append a message to the buffer so a client attaching later sees it. */
150
+ append(msg: ServerMessage): void {
151
+ appendFileSync(outputPath(this.record.sessionId), `${JSON.stringify(msg)}\n`);
152
+ }
153
+
154
+ park(opts: { eventId: string; eventType: string; question: string; questionKey?: string }): void {
155
+ this.record = {
156
+ ...this.record,
157
+ state: 'parked',
158
+ parkedEventId: opts.eventId,
159
+ parkedEventType: opts.eventType,
160
+ question: opts.question,
161
+ questionKey: opts.questionKey ?? null,
162
+ };
163
+ write(this.record);
164
+ }
165
+
166
+ /** The question was answered (by anyone) — the command is running again. */
167
+ unpark(): void {
168
+ if (this.record.state !== 'parked') return;
169
+ this.record = {
170
+ ...this.record,
171
+ state: 'running',
172
+ parkedEventId: null,
173
+ parkedEventType: null,
174
+ question: null,
175
+ questionKey: null,
176
+ };
177
+ write(this.record);
178
+ }
179
+
180
+ /**
181
+ * `abandoned` outranks `finished`: the command exits *because* it was
182
+ * abandoned, and that exit must not overwrite why. Retained either way.
183
+ */
184
+ finish(state: 'finished' | 'abandoned'): void {
185
+ if (this.record.state === 'abandoned') return;
186
+ this.record = { ...this.record, state };
187
+ write(this.record);
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Answer a parked session's outstanding query as `abandoned` and retain the
193
+ * record as history.
194
+ *
195
+ * The reply is what releases everything the parked command holds: the child
196
+ * resumes, throws `InterviewAbandonedError`, and unwinds — releasing its own
197
+ * module-operation lock on the way out, exactly as any other failure does. That
198
+ * is the whole point of answering rather than killing.
199
+ *
200
+ * ponytail: no forced kill if the child ignores its own unwinding. Add one only
201
+ * once a real command is seen to survive an abandoned answer.
202
+ */
203
+ export function abandonSession(
204
+ record: SessionRecord,
205
+ opts: { busDbPath: string; reason: string; emittedBy?: string },
206
+ ): boolean {
207
+ if (record.parkedEventId && record.parkedEventType) {
208
+ const bus = openBus({ dbPath: opts.busDbPath, events: NO_SCHEMAS });
209
+ try {
210
+ // Someone decided it while we were on our way to reap. Abandoning now
211
+ // would overwrite a real answer with "nobody decided" — the exact
212
+ // fabrication this whole change exists to remove, only in reverse.
213
+ if (bus.repliesFor(Number(record.parkedEventId)).length > 0) return false;
214
+ bus.emitRaw(
215
+ `${record.parkedEventType}.reply`,
216
+ { abandoned: { reason: opts.reason } },
217
+ {
218
+ replyFor: Number(record.parkedEventId),
219
+ emittedBy: opts.emittedBy ?? 'api-session-reaper',
220
+ },
221
+ );
222
+ } finally {
223
+ bus.close();
224
+ }
225
+ }
226
+ write({ ...record, state: 'abandoned' });
227
+ return true;
228
+ }
229
+
230
+ /**
231
+ * Is this session still waiting on a decision *according to the bus*?
232
+ *
233
+ * The record's `state` is the owning process's view and can lag: the answer
234
+ * arrives on the bus from a third party, which the owner never sees. The bus is
235
+ * the source of truth for whether a question stands — ask it, don't infer.
236
+ */
237
+ export function stillParked(record: SessionRecord, busDbPath: string): boolean {
238
+ if (record.state !== 'parked' || !record.parkedEventId) return false;
239
+ const bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
240
+ try {
241
+ return bus.repliesFor(Number(record.parkedEventId)).length === 0;
242
+ } finally {
243
+ bus.close();
244
+ }
245
+ }
246
+
247
+ /** Why a reaped session's question was never decided. */
248
+ export function expiryReason(record: SessionRecord): string {
249
+ const what = record.question ?? 'the question';
250
+ return `Session ${record.sessionId} expired with "${what}" still unanswered — nobody decided it.`;
251
+ }
252
+
253
+ /**
254
+ * Abandon every parked session past its TTL. Run by the process that owns a
255
+ * parked child on its own timer, and again at `api-serve` startup so a session
256
+ * whose owner died (reboot, OOM) doesn't sit parked forever — the failure mode
257
+ * that left a `module deploy` holding every backup lock on the fleet for 20 days.
258
+ */
259
+ export function reapExpiredSessions(opts: { busDbPath: string; now?: number }): SessionRecord[] {
260
+ const now = opts.now ?? Date.now();
261
+ const expired = listSessions().filter((s) => s.state === 'parked' && s.expiresAt <= now);
262
+ for (const record of expired) {
263
+ abandonSession(record, { busDbPath: opts.busDbPath, reason: expiryReason(record) });
264
+ }
265
+ return expired;
266
+ }
267
+
268
+ /** Buffered messages an attaching client should be replayed. */
269
+ export function replayOutput(
270
+ sessionId: string,
271
+ fromLine = 0,
272
+ ): { messages: string[]; next: number } {
273
+ const path = outputPath(sessionId);
274
+ if (!existsSync(path)) return { messages: [], next: fromLine };
275
+ const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean);
276
+ const start = Math.max(fromLine, lines.length - REPLAY_LIMIT);
277
+ return { messages: lines.slice(start), next: lines.length };
278
+ }
@@ -17,13 +17,12 @@ import {
17
17
  findBackupEligibleModules,
18
18
  isBackupDue,
19
19
  } from '../../services/backup-create';
20
- import { failBackup, getBackup } from '../../services/backup-metadata';
20
+ import { failBackup, getBackup, listInProgressBackups } from '../../services/backup-metadata';
21
21
  import { pruneBackupsForModule } from '../../services/backup-retention';
22
22
  import {
23
- ABANDONED_BACKUP_MESSAGE,
24
23
  STAGING_PREFIX,
25
- impliesAbandonedRecord,
26
24
  reapOrphanedStaging,
25
+ resolveAbandonedBackups,
27
26
  } from '../../services/backup-staging';
28
27
  import { type BackupSweepReport, runBackupSweep } from '../../services/backup-sweep';
29
28
  import { isPidRunnable } from '../../services/module-operations';
@@ -59,18 +58,30 @@ function reapStaging() {
59
58
  now: () => Date.now(),
60
59
  });
61
60
 
62
- for (const reclaimed of report.reclaimed) {
63
- if (impliesAbandonedRecord(reclaimed.reason)) {
64
- failBackup(reclaimed.recordId, ABANDONED_BACKUP_MESSAGE);
65
- }
66
- }
67
-
68
61
  return report;
69
62
  }
70
63
 
64
+ /**
65
+ * Correct every record that still claims to be running, staging or no staging.
66
+ *
67
+ * This replaces deriving the fix-up from what the reaper reclaimed. That
68
+ * version only ever saw records whose directory still existed, so anything
69
+ * cleared by a reboot or by hand stayed `in_progress` forever (#616).
70
+ */
71
+ function resolveAbandonedRecords() {
72
+ return resolveAbandonedBackups({
73
+ listInProgress: () =>
74
+ listInProgressBackups().map((b) => ({ id: b.id, pid: b.pid, startedAt: b.startedAt })),
75
+ isPidRunnable,
76
+ fail: (id, message) => failBackup(id, message),
77
+ now: () => Date.now(),
78
+ });
79
+ }
80
+
71
81
  export async function handleBackupSweep(): Promise<CommandResult> {
72
82
  const report = await runBackupSweep({
73
83
  reapStaging,
84
+ resolveAbandonedRecords,
74
85
  listEligible: () =>
75
86
  findBackupEligibleModules().map(({ module, manifest }) => ({ id: module.id, manifest })),
76
87
  isDue: (moduleId, schedule) => isBackupDue(moduleId, schedule),
@@ -117,6 +128,11 @@ function formatReport(report: BackupSweepReport): string {
117
128
  lines.push(` ${reclaimed.recordId} (${reclaimed.reason})`);
118
129
  }
119
130
  }
131
+ if (report.records.resolved.length > 0) {
132
+ lines.push(
133
+ ` corrected ${report.records.resolved.length} record(s) that still claimed to be running`,
134
+ );
135
+ }
120
136
  for (const moduleId of report.skippedLocked) {
121
137
  lines.push(` skipped ${moduleId}: another operation is in flight — retrying next tick`);
122
138
  }
@@ -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)
@@ -30,6 +31,7 @@ import {
30
31
  runDispatcher,
31
32
  } from '@celilo/event-bus';
32
33
  import { eq } from 'drizzle-orm';
34
+ import { sessionParkedOn } from '../../api/sessions';
33
35
  import { getEventBusPath, shortenPath } from '../../config/paths';
34
36
  import { getDb } from '../../db/client';
35
37
  import { modules } from '../../db/schema';
@@ -241,6 +243,57 @@ export async function handleEventsListPending(
241
243
  }
242
244
  }
243
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
+
244
297
  export async function handleEventsDrain(
245
298
  _args: string[],
246
299
  flags: Record<string, string | boolean>,
@@ -412,9 +465,12 @@ function interviewFamily(type: string): InterviewFamily | null {
412
465
  /**
413
466
  * `celilo events reply <query-event-id> <value-json>` — answer ONE pending
414
467
  * interview query by its event id. The one-shot reply primitive a
415
- * `claude-config-responder` uses: read the question with
416
- * `events tail --type 'config.required.*'`, ask the operator, emit the answer
417
- * here. Unlike `events respond` (which must be subscribed BEFORE the query is
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
418
474
  * emitted — bus watches don't replay history) this looks the query up by id
419
475
  * and emits a correlated reply carrying `replyFor`, which plain `events emit`
420
476
  * can't set.
@@ -23,7 +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 { InterviewUnansweredError } from '../../services/interview-errors';
26
+ import { InterviewAbandonedError, InterviewUnansweredError } from '../../services/interview-errors';
27
27
  import { getFlag } from '../parser';
28
28
  import { log } from '../prompts';
29
29
  import type { CommandResult } from '../types';
@@ -499,6 +499,7 @@ async function runRegistrySweep(
499
499
  let appliedBreaking = 0;
500
500
  let declinedBreaking = 0;
501
501
  const unanswered: Array<{ moduleId: string; error: string }> = [];
502
+ const abandoned: Array<{ moduleId: string; error: string }> = [];
502
503
 
503
504
  if (breaking.length > 0) {
504
505
  log.info('\nBreaking updates available — review required (semver-major bump):');
@@ -521,15 +522,25 @@ async function runRegistrySweep(
521
522
  }),
522
523
  );
523
524
  } catch (err) {
524
- // Nobody could answer. That is NOT a decline record it as unanswered
525
- // so the summary says so and the sweep exits non-zero, and keep going
526
- // so the updates already applied aren't thrown away with a hard abort.
527
- if (!(err instanceof InterviewUnansweredError)) throw err;
528
- unanswered.push({ moduleId: plan.moduleId, error: err.message });
529
- console.log(
530
- ` ? ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion} (major, UNANSWERED)`,
531
- );
532
- continue;
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;
533
544
  }
534
545
  if (!proceed) {
535
546
  declinedBreaking++;
@@ -572,6 +583,15 @@ async function runRegistrySweep(
572
583
  )}). Re-run with a responder attached, or pre-stage the answer under "module-upgrade:<module>.apply_breaking".`,
573
584
  );
574
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
+ );
594
+ }
575
595
  if (notInRegistry.length > 0) {
576
596
  summary.push(`Not in registry (${notInRegistry.length}): ${notInRegistry.join(', ')}`);
577
597
  }
@@ -580,7 +600,7 @@ async function runRegistrySweep(
580
600
  `Registry errors (${errored.length}): ${errored.map((e) => `${e.moduleId} — ${e.error}`).join('; ')}`,
581
601
  );
582
602
  }
583
- if (failed.length > 0 || unanswered.length > 0) {
603
+ if (failed.length > 0 || unanswered.length > 0 || abandoned.length > 0) {
584
604
  const detail: string[] = [];
585
605
  if (failed.length > 0) {
586
606
  detail.push('', 'Failures:', ...failed.map((f) => ` ${f.moduleId}: ${f.error}`));
@@ -588,6 +608,9 @@ async function runRegistrySweep(
588
608
  if (unanswered.length > 0) {
589
609
  detail.push('', 'Unanswered:', ...unanswered.map((u) => ` ${u.moduleId}: ${u.error}`));
590
610
  }
611
+ if (abandoned.length > 0) {
612
+ detail.push('', 'Abandoned:', ...abandoned.map((a) => ` ${a.moduleId}: ${a.error}`));
613
+ }
591
614
  return { success: false, error: [...summary, ...detail].join('\n') };
592
615
  }
593
616
  return { success: true, message: summary.join('\n') };
@@ -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',
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 { COMMANDS, type CommandDef, resolveRemote, runRemoteClient } from '@celilo/core';
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,6 +34,7 @@ import {
28
34
  handleEventsInstallDaemon,
29
35
  handleEventsListPending,
30
36
  handleEventsListSubscribers,
37
+ handleEventsListUnanswered,
31
38
  handleEventsRepair,
32
39
  handleEventsReply,
33
40
  handleEventsRespond,
@@ -1418,6 +1425,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1418
1425
  return handleEventsResyncSubscriptions();
1419
1426
  case 'list-pending':
1420
1427
  return handleEventsListPending(parsed.args, parsed.flags);
1428
+ case 'list-unanswered':
1429
+ return handleEventsListUnanswered(parsed.args, parsed.flags);
1421
1430
  case 'drain':
1422
1431
  return handleEventsDrain(parsed.args, parsed.flags);
1423
1432
  case 'run':
@@ -2427,7 +2436,18 @@ export async function main(): Promise<void> {
2427
2436
  // SSH to the remote celilo-mgr and drive its api-serve over the wire.
2428
2437
  const remote = resolveRemote(process.argv);
2429
2438
  if (remote) {
2430
- process.exit(await runRemoteClient(remote.dest, remote.commandArgv));
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);
2431
2451
  }
2432
2452
 
2433
2453
  // Normal single-command execution
@@ -156,6 +156,23 @@ export function listBackups(options?: {
156
156
  return db.select().from(backups).orderBy(desc(backups.startedAt)).limit(limit).all();
157
157
  }
158
158
 
159
+ /**
160
+ * Every backup record still claiming to be in progress, oldest first.
161
+ *
162
+ * Feeds the record-resolution pass (`resolveAbandonedBackups`), which corrects
163
+ * the ones whose process is gone. Unbounded on purpose — there is no sensible
164
+ * limit on "how many lies to fix", and celilo-mgr had 107 of them.
165
+ */
166
+ export function listInProgressBackups(): Backup[] {
167
+ const db = getDb();
168
+ return db
169
+ .select()
170
+ .from(backups)
171
+ .where(eq(backups.status, 'in_progress'))
172
+ .orderBy(backups.startedAt)
173
+ .all();
174
+ }
175
+
159
176
  /**
160
177
  * List completed backups for a specific module, ordered newest first
161
178
  */