@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.
- package/package.json +4 -4
- package/src/api/remote-client.test.ts +34 -12
- package/src/api/serve.ts +234 -38
- package/src/api/sessions.test.ts +196 -0
- package/src/api/sessions.ts +278 -0
- package/src/cli/commands/backup-sweep.ts +25 -9
- package/src/cli/commands/events.ts +60 -4
- package/src/cli/commands/module-update.ts +34 -11
- package/src/cli/completion.ts +1 -0
- package/src/cli/index.ts +22 -2
- 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 +13 -8
- package/src/services/interview-errors.ts +24 -7
- package/src/services/remote-responder.test.ts +33 -20
- package/src/services/remote-responder.ts +10 -6
|
@@ -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:
|
|
416
|
-
* `events
|
|
417
|
-
*
|
|
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
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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') };
|
package/src/cli/completion.ts
CHANGED
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,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
|
-
|
|
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
|
*/
|