@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
|
@@ -39,9 +39,9 @@ function seedHeartbeat(
|
|
|
39
39
|
);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/** Write a supervisor unit file so readInstalledUnit(
|
|
43
|
-
function installFakeUnit(home: string): void {
|
|
44
|
-
const path = getDaemonUnitPath('linux', home,
|
|
42
|
+
/** Write a supervisor unit file so readInstalledUnit(scope) sees it. */
|
|
43
|
+
function installFakeUnit(home: string, scope: 'user' | 'system' = 'user', systemRoot = '/'): void {
|
|
44
|
+
const path = getDaemonUnitPath('linux', home, scope, systemRoot);
|
|
45
45
|
mkdirSync(dirname(path), { recursive: true });
|
|
46
46
|
writeFileSync(path, '[Unit]\nDescription=fake\n');
|
|
47
47
|
}
|
|
@@ -114,6 +114,51 @@ describe('checkDispatcher', () => {
|
|
|
114
114
|
expect(f.detail.join(' ')).toContain('not under a supervisor');
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
// #610 — celilo-mgr had a dead system unit and a live user-scope unit of the
|
|
118
|
+
// SAME name. The old file-exists test called that "supervised".
|
|
119
|
+
it('fails when the running dispatcher is not the pid any installed unit supervises', () => {
|
|
120
|
+
seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000, pid: 588704 });
|
|
121
|
+
installFakeUnit(home);
|
|
122
|
+
const f = checkDispatcher(bus, {
|
|
123
|
+
now: now,
|
|
124
|
+
home,
|
|
125
|
+
platform: 'linux',
|
|
126
|
+
unitMainPid: () => 3639051, // systemd supervises a different process
|
|
127
|
+
});
|
|
128
|
+
expect(f.status).toBe('fail');
|
|
129
|
+
expect(f.detail.join(' ')).toContain('not the process any installed unit supervises');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('fails when both a user-scope and a system-scope unit are installed', () => {
|
|
133
|
+
seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000, pid: 4242 });
|
|
134
|
+
installFakeUnit(home);
|
|
135
|
+
installFakeUnit(home, 'system', dir);
|
|
136
|
+
const f = checkDispatcher(bus, {
|
|
137
|
+
now: now,
|
|
138
|
+
home,
|
|
139
|
+
systemRoot: dir,
|
|
140
|
+
platform: 'linux',
|
|
141
|
+
unitMainPid: () => 4242,
|
|
142
|
+
});
|
|
143
|
+
expect(f.status).toBe('fail');
|
|
144
|
+
expect(f.detail.join(' ')).toContain('same unit name, different services');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Null is ignorance, not evidence: a systemd probe that fails must not
|
|
148
|
+
// manufacture an orphan report.
|
|
149
|
+
it('makes no supervision claim when the unit pid cannot be determined', () => {
|
|
150
|
+
seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000, pid: 4242 });
|
|
151
|
+
installFakeUnit(home);
|
|
152
|
+
const f = checkDispatcher(bus, {
|
|
153
|
+
now: now,
|
|
154
|
+
home,
|
|
155
|
+
platform: 'linux',
|
|
156
|
+
installedCodeMtimeMs: now - 2 * MINUTE,
|
|
157
|
+
unitMainPid: () => null,
|
|
158
|
+
});
|
|
159
|
+
expect(f.status).toBe('ok');
|
|
160
|
+
});
|
|
161
|
+
|
|
117
162
|
it('warns when the dispatcher started before the installed code (stale)', () => {
|
|
118
163
|
seedHeartbeat(bus, { startedAt: now - 10 * MINUTE, lastHeartbeat: now - 1000 });
|
|
119
164
|
installFakeUnit(home);
|
|
@@ -425,7 +470,7 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
|
|
|
425
470
|
it('is ok when every schema table is present (fresh migrated DB)', () => {
|
|
426
471
|
const f = checkSchemaDrift(db);
|
|
427
472
|
expect(f.status).toBe('ok');
|
|
428
|
-
expect(f.summary).toContain('schema tables
|
|
473
|
+
expect(f.summary).toContain('schema tables');
|
|
429
474
|
});
|
|
430
475
|
|
|
431
476
|
it('fails and names a table the running CLI expects but the DB lacks', () => {
|
|
@@ -435,6 +480,32 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
|
|
|
435
480
|
expect(f.detail.join(' ')).toContain('dns_internal_records');
|
|
436
481
|
expect(f.remediation).toContain('migrations');
|
|
437
482
|
});
|
|
483
|
+
|
|
484
|
+
// celilo#604: this is the state the rollout could not check. Every table
|
|
485
|
+
// is present, one MIGRATED COLUMN is not, and the doctor must not call
|
|
486
|
+
// that "migrations applied".
|
|
487
|
+
it('fails and names a migrated COLUMN the DB lacks, with every table present', () => {
|
|
488
|
+
db.$client.run('ALTER TABLE backups DROP COLUMN pid');
|
|
489
|
+
const f = checkSchemaDrift(db);
|
|
490
|
+
expect(f.status).toBe('fail');
|
|
491
|
+
expect(f.detail.join(' ')).toContain('backups.pid');
|
|
492
|
+
expect(f.summary).not.toContain('present');
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('says it checked columns, not only tables', () => {
|
|
496
|
+
const f = checkSchemaDrift(db);
|
|
497
|
+
expect(f.status).toBe('ok');
|
|
498
|
+
expect(f.summary).toContain('columns present');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('fails when a journal migration has not been applied on this box', () => {
|
|
502
|
+
db.$client.run(
|
|
503
|
+
'DELETE FROM `__drizzle_migrations` WHERE created_at = (SELECT MAX(created_at) FROM `__drizzle_migrations`)',
|
|
504
|
+
);
|
|
505
|
+
const f = checkSchemaDrift(db);
|
|
506
|
+
expect(f.status).toBe('fail');
|
|
507
|
+
expect(f.detail.join(' ')).toContain('unapplied migration');
|
|
508
|
+
});
|
|
438
509
|
});
|
|
439
510
|
});
|
|
440
511
|
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
import type { Bus } from '@celilo/event-bus';
|
|
21
21
|
import { inArray } from 'drizzle-orm';
|
|
22
22
|
import { getModuleStoragePath } from '../config/paths';
|
|
23
|
-
import type
|
|
23
|
+
import { type DbClient, findMigrationsFolder } from '../db/client';
|
|
24
|
+
import { getMigrationStatus } from '../db/migration-status';
|
|
24
25
|
import { capabilities as capabilitiesTable, modules } from '../db/schema';
|
|
25
26
|
import { findSchemaDrift } from '../db/schema-introspection';
|
|
26
27
|
import { loadControlPlaneSubnet, resolveFirewallNatIp } from '../hooks/capability-loader';
|
|
@@ -30,7 +31,13 @@ import type { ModuleManifest } from '../manifest/schema';
|
|
|
30
31
|
const CONTROL_PLANE_MODULE = 'celilo-mgmt';
|
|
31
32
|
import { getModuleSystems } from './deployed-systems';
|
|
32
33
|
import { listDnsInternalRecords } from './dns-internal-records';
|
|
33
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
SUPERVISOR_SCOPES,
|
|
36
|
+
type SupervisorPlatform,
|
|
37
|
+
type SupervisorScope,
|
|
38
|
+
readInstalledUnit,
|
|
39
|
+
unitMainPid,
|
|
40
|
+
} from './events-daemon';
|
|
34
41
|
import { resolveSubscription } from './module-subscriptions';
|
|
35
42
|
|
|
36
43
|
/**
|
|
@@ -107,26 +114,43 @@ function worst(statuses: FleetFindingStatus[]): FleetFindingStatus {
|
|
|
107
114
|
* its schema is current, so presence is the honest signal.
|
|
108
115
|
*/
|
|
109
116
|
export function checkSchemaDrift(db: DbClient): FleetFinding {
|
|
110
|
-
const { missingTables, missingColumns, tableCount } = findSchemaDrift(db.$client);
|
|
117
|
+
const { missingTables, missingColumns, tableCount, columnCount } = findSchemaDrift(db.$client);
|
|
111
118
|
|
|
112
119
|
const detail: string[] = [];
|
|
113
120
|
if (missingTables.length > 0) detail.push(`missing table(s): ${missingTables.join(', ')}`);
|
|
114
121
|
if (missingColumns.length > 0) detail.push(`missing column(s): ${missingColumns.join(', ')}`);
|
|
122
|
+
|
|
123
|
+
// Also name unapplied migrations. Presence is the honest signal for schema
|
|
124
|
+
// objects, but a migration can carry an index or a data fix that presence
|
|
125
|
+
// can't see — and an operator reading "migrations applied" deserves to know
|
|
126
|
+
// when some aren't (celilo#604). Best-effort: an install layout where the
|
|
127
|
+
// journal can't be found must not fail the check.
|
|
128
|
+
let pending: string[] = [];
|
|
129
|
+
try {
|
|
130
|
+
pending = getMigrationStatus(db.$client, findMigrationsFolder()).pending;
|
|
131
|
+
} catch {
|
|
132
|
+
// No journal reachable — the presence check above still stands.
|
|
133
|
+
}
|
|
134
|
+
if (pending.length > 0) detail.push(`unapplied migration(s): ${pending.join(', ')}`);
|
|
135
|
+
|
|
115
136
|
const status: FleetFindingStatus = detail.length > 0 ? 'fail' : 'ok';
|
|
116
137
|
|
|
117
138
|
return {
|
|
118
139
|
id: 'schema',
|
|
119
140
|
title: 'database schema matches the running CLI (migrations applied)',
|
|
120
141
|
status,
|
|
142
|
+
// Say tables AND columns: "all 35 tables present" reads as though columns
|
|
143
|
+
// went unchecked, which is what sent a rollout to sqlite3 over SSH to
|
|
144
|
+
// confirm a column migration the doctor had in fact already verified.
|
|
121
145
|
summary:
|
|
122
146
|
status === 'ok'
|
|
123
|
-
? `all ${tableCount} schema tables present`
|
|
147
|
+
? `all ${tableCount} schema tables and ${columnCount} columns present, no unapplied migrations`
|
|
124
148
|
: 'database schema is behind the running CLI — migrations not applied',
|
|
125
149
|
detail,
|
|
126
150
|
remediation:
|
|
127
151
|
status === 'ok'
|
|
128
152
|
? null
|
|
129
|
-
: 'run `celilo system migrate` to apply pending migrations on this box — see ISS-0100',
|
|
153
|
+
: 'run `celilo system migrate` to apply pending migrations on this box (`celilo system migrate --status` names them) — see ISS-0100',
|
|
130
154
|
autoFixable: false,
|
|
131
155
|
};
|
|
132
156
|
}
|
|
@@ -152,6 +176,14 @@ export interface DispatcherCheckOptions {
|
|
|
152
176
|
/** Override for readInstalledUnit — tests point this at a temp home. */
|
|
153
177
|
home?: string;
|
|
154
178
|
platform?: SupervisorPlatform;
|
|
179
|
+
/** Prefix for system-scope unit paths. Test seam — see getDaemonUnitPath. */
|
|
180
|
+
systemRoot?: string;
|
|
181
|
+
/**
|
|
182
|
+
* Which pid each scope's unit supervises. Injected so the check is testable
|
|
183
|
+
* without systemd. Returning null means "can't tell" — the check then makes
|
|
184
|
+
* no supervision claim rather than guessing.
|
|
185
|
+
*/
|
|
186
|
+
unitMainPid?: (scope: SupervisorScope) => number | null;
|
|
155
187
|
}
|
|
156
188
|
|
|
157
189
|
/**
|
|
@@ -223,16 +255,54 @@ export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): Fl
|
|
|
223
255
|
);
|
|
224
256
|
}
|
|
225
257
|
|
|
226
|
-
// (2) supervised — a unit file exists
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
258
|
+
// (2) supervised — not just "a unit file exists on disk", but "the process
|
|
259
|
+
// that is actually running IS the one an installed unit supervises".
|
|
260
|
+
//
|
|
261
|
+
// The file-exists test this replaces reported green on celilo-mgr while the
|
|
262
|
+
// system unit was dead and a user-scope unit of the SAME NAME served
|
|
263
|
+
// production (#610). A check whose entire job is catching an unsupervised
|
|
264
|
+
// dispatcher cannot be satisfied by a file nobody is running.
|
|
265
|
+
const installedScopes = SUPERVISOR_SCOPES.filter(
|
|
266
|
+
(scope) =>
|
|
267
|
+
readInstalledUnit({
|
|
268
|
+
scope,
|
|
269
|
+
home: opts.home,
|
|
270
|
+
platform: opts.platform,
|
|
271
|
+
systemRoot: opts.systemRoot,
|
|
272
|
+
}).exists,
|
|
273
|
+
);
|
|
274
|
+
if (installedScopes.length === 0) {
|
|
233
275
|
statuses.push('warn');
|
|
234
276
|
detail.push('not under a supervisor unit — will not survive a reboot (orphan process)');
|
|
235
277
|
remediations.push('`celilo events install-daemon` then enable the unit so it is supervised');
|
|
278
|
+
} else {
|
|
279
|
+
if (installedScopes.length > 1) {
|
|
280
|
+
statuses.push('fail');
|
|
281
|
+
detail.push(
|
|
282
|
+
'both a user-scope AND a system-scope unit are installed — same unit name, different services; ' +
|
|
283
|
+
'one will lose the race on every boot and retry forever',
|
|
284
|
+
);
|
|
285
|
+
remediations.push(
|
|
286
|
+
'keep exactly one: `celilo events uninstall-daemon` (user) or `celilo events uninstall-daemon --system`, and disable it in systemd',
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
// Only accuse when systemd actually answered. A null probe is ignorance,
|
|
290
|
+
// not evidence of an orphan.
|
|
291
|
+
const probe =
|
|
292
|
+
opts.unitMainPid ?? ((scope: SupervisorScope) => unitMainPid(scope, opts.platform));
|
|
293
|
+
const supervisedPids = installedScopes
|
|
294
|
+
.map((scope) => ({ scope, pid: probe(scope) }))
|
|
295
|
+
.filter((entry): entry is { scope: SupervisorScope; pid: number } => entry.pid !== null);
|
|
296
|
+
if (supervisedPids.length > 0 && !supervisedPids.some((entry) => entry.pid === hb.pid)) {
|
|
297
|
+
statuses.push('fail');
|
|
298
|
+
detail.push(
|
|
299
|
+
`the running dispatcher (pid ${hb.pid}) is not the process any installed unit supervises ` +
|
|
300
|
+
`(${supervisedPids.map((e) => `${e.scope}=${e.pid}`).join(', ')}) — restarting the unit will not restart it`,
|
|
301
|
+
);
|
|
302
|
+
remediations.push(
|
|
303
|
+
'stop the unsupervised process and let the unit own the dispatcher, or reinstall the unit for the scope that is actually running it',
|
|
304
|
+
);
|
|
305
|
+
}
|
|
236
306
|
}
|
|
237
307
|
|
|
238
308
|
// (3) current — started before the installed code was last written ⇒
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two error types that mean "this interview question was not decided".
|
|
3
|
+
* Their own module so `responder-probe` (nobody is listening at all) and the
|
|
4
|
+
* session reaper (a parked question expired) can both throw without importing
|
|
5
|
+
* each other.
|
|
6
|
+
*
|
|
7
|
+
* Callers catch these to distinguish a question that was never decided from one
|
|
8
|
+
* that was answered — the distinction `module update` conflated when it reported
|
|
9
|
+
* a breaking update as "operator declined" that no operator had ever seen. The
|
|
10
|
+
* two are not interchangeable: *unanswered* means no responder could even be
|
|
11
|
+
* found, *abandoned* means a responder existed, the question stood, and the
|
|
12
|
+
* deadline passed with nobody deciding.
|
|
13
|
+
*/
|
|
14
|
+
export class InterviewUnansweredError extends Error {
|
|
15
|
+
constructor(
|
|
16
|
+
/** The interview event type that went unanswered, e.g. `interview.required.<scope>.<key>`. */
|
|
17
|
+
readonly queryType: string,
|
|
18
|
+
message: string,
|
|
19
|
+
) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'InterviewUnansweredError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The question was posted, stood unanswered past its session's TTL, and the
|
|
27
|
+
* reaper answered it `abandoned` to release what the parked command held.
|
|
28
|
+
*/
|
|
29
|
+
export class InterviewAbandonedError extends Error {
|
|
30
|
+
constructor(
|
|
31
|
+
readonly queryType: string,
|
|
32
|
+
message: string,
|
|
33
|
+
) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'InterviewAbandonedError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -76,3 +76,86 @@ test('answers responder.probe with kind daemon', async () => {
|
|
|
76
76
|
responder.close();
|
|
77
77
|
}
|
|
78
78
|
}, 15_000);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A responder that cannot reach a decider must emit NOTHING (celilo#609).
|
|
82
|
+
*
|
|
83
|
+
* PR #607 had it reply `{error}` here, which reads as "fail loudly" but carries
|
|
84
|
+
* `replyFor: <query id>` — it *consumes the query*. The question then no longer
|
|
85
|
+
* exists for anyone else to answer, and the command dies with it. Leaving it
|
|
86
|
+
* unanswered parks the command instead, so a later responder can still decide.
|
|
87
|
+
*
|
|
88
|
+
* Asserted on the value that comes back, not on liveness: the reply emitted
|
|
89
|
+
* afterwards correlates to the original query, which is only true if the
|
|
90
|
+
* responder left it standing.
|
|
91
|
+
*/
|
|
92
|
+
test('an ask that cannot be answered emits no reply — the query stays answerable', async () => {
|
|
93
|
+
const responder = startRemoteResponder({
|
|
94
|
+
busDbPath,
|
|
95
|
+
ask: async () => {
|
|
96
|
+
throw new Error("stdin isn't a terminal and no answer was pre-staged");
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
|
|
101
|
+
const type = 'interview.required.module-upgrade:iptables.apply_breaking';
|
|
102
|
+
try {
|
|
103
|
+
const query = bus.emitRaw(type, {
|
|
104
|
+
scope: 'module-upgrade:iptables',
|
|
105
|
+
key: 'apply_breaking',
|
|
106
|
+
kind: 'confirm',
|
|
107
|
+
message: 'Apply breaking update for iptables?',
|
|
108
|
+
required: true,
|
|
109
|
+
defaultValue: 'false',
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Well past the responder's watch latency: still nobody has answered.
|
|
113
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
114
|
+
expect(bus.recentEvents({ type: `${type}.reply` })).toHaveLength(0);
|
|
115
|
+
|
|
116
|
+
// And the query is still live: a reply emitted now is a genuine answer.
|
|
117
|
+
bus.emitRaw(
|
|
118
|
+
`${type}.reply`,
|
|
119
|
+
{ value: false },
|
|
120
|
+
{ replyFor: query.id, emittedBy: 'later-responder' },
|
|
121
|
+
);
|
|
122
|
+
const replies = bus.recentEvents({ type: `${type}.reply` });
|
|
123
|
+
expect(replies).toHaveLength(1);
|
|
124
|
+
expect((replies[0].payload as { value: unknown }).value).toBe(false);
|
|
125
|
+
expect(replies[0].replyFor).toBe(query.id);
|
|
126
|
+
} finally {
|
|
127
|
+
bus.close();
|
|
128
|
+
responder.close();
|
|
129
|
+
}
|
|
130
|
+
}, 15_000);
|
|
131
|
+
|
|
132
|
+
test('forwards the question scope/key so a client can pre-stage an answer', async () => {
|
|
133
|
+
const asked: WireInterview[] = [];
|
|
134
|
+
const responder = startRemoteResponder({
|
|
135
|
+
busDbPath,
|
|
136
|
+
ask: async (iv) => {
|
|
137
|
+
asked.push(iv);
|
|
138
|
+
return true;
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
|
|
143
|
+
try {
|
|
144
|
+
await bus.query(
|
|
145
|
+
'interview.required.module-upgrade:iptables.apply_breaking' as never,
|
|
146
|
+
{
|
|
147
|
+
scope: 'module-upgrade:iptables',
|
|
148
|
+
key: 'apply_breaking',
|
|
149
|
+
kind: 'confirm',
|
|
150
|
+
message: 'Apply breaking update for iptables?',
|
|
151
|
+
required: true,
|
|
152
|
+
} as never,
|
|
153
|
+
{ timeoutMs: 8000, pollIntervalMs: 100, expect: 'first' } as never,
|
|
154
|
+
);
|
|
155
|
+
expect(asked[0].scope).toBe('module-upgrade:iptables');
|
|
156
|
+
expect(asked[0].key).toBe('apply_breaking');
|
|
157
|
+
} finally {
|
|
158
|
+
bus.close();
|
|
159
|
+
responder.close();
|
|
160
|
+
}
|
|
161
|
+
}, 15_000);
|
|
@@ -20,6 +20,9 @@ const NO_SCHEMAS = defineEvents({});
|
|
|
20
20
|
/** The normalized question handed to the wire (mirrors InterviewMessage). */
|
|
21
21
|
export interface WireInterview {
|
|
22
22
|
id: string;
|
|
23
|
+
/** The question's stable identity, so a client can pre-stage `<scope>.<key>`. */
|
|
24
|
+
scope: string;
|
|
25
|
+
key: string;
|
|
23
26
|
kind: InterviewRequiredPayload['kind'];
|
|
24
27
|
message: string;
|
|
25
28
|
description?: string;
|
|
@@ -54,16 +57,34 @@ export function startRemoteResponder(opts: RemoteResponderOptions): RemoteRespon
|
|
|
54
57
|
return;
|
|
55
58
|
}
|
|
56
59
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
60
|
+
let value: unknown;
|
|
61
|
+
try {
|
|
62
|
+
value = await opts.ask({
|
|
63
|
+
id: String(event.id),
|
|
64
|
+
scope: payload.scope,
|
|
65
|
+
key: payload.key,
|
|
66
|
+
kind: payload.kind,
|
|
67
|
+
message: payload.message,
|
|
68
|
+
description: payload.description,
|
|
69
|
+
defaultValue: payload.defaultValue,
|
|
70
|
+
placeholder: payload.placeholder,
|
|
71
|
+
options: payload.options,
|
|
72
|
+
required: payload.required,
|
|
73
|
+
});
|
|
74
|
+
} catch (err) {
|
|
75
|
+
// The client had no way to answer — so we emit NOTHING. A reply of any
|
|
76
|
+
// shape consumes the query (it carries `replyFor: event.id`), destroying
|
|
77
|
+
// a question nobody has answered yet; the asking command then dies with
|
|
78
|
+
// it and no other responder can ever act. Leaving it unanswered parks the
|
|
79
|
+
// command instead, which is what `busInterview`'s `timeoutMs: 0` is for.
|
|
80
|
+
// The client learns it is parked from the `blocked` wire message.
|
|
81
|
+
process.stderr.write(
|
|
82
|
+
`[remote-responder] parked ${event.type} (#${event.id}): ${
|
|
83
|
+
err instanceof Error ? err.message : String(err)
|
|
84
|
+
}\n`,
|
|
85
|
+
);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
67
88
|
|
|
68
89
|
bus.emitRaw(`${event.type}.reply`, { value }, { replyFor: event.id, emittedBy: me });
|
|
69
90
|
});
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
16
16
|
import { getEventBusPath } from '../config/paths';
|
|
17
|
+
import { InterviewUnansweredError } from './interview-errors';
|
|
17
18
|
|
|
18
19
|
const NO_SCHEMAS = defineEvents({});
|
|
19
20
|
|
|
@@ -63,7 +64,8 @@ export async function ensureResponderForInterview(queryType: string): Promise<vo
|
|
|
63
64
|
if (process.stdin.isTTY) return;
|
|
64
65
|
const available = await probeForResponder(getEventBusPath());
|
|
65
66
|
if (available) return;
|
|
66
|
-
throw new
|
|
67
|
+
throw new InterviewUnansweredError(
|
|
68
|
+
queryType,
|
|
67
69
|
`No responder is listening and stdin isn't a TTY, so this interview prompt can't be answered (${queryType}).
|
|
68
70
|
|
|
69
71
|
Either:
|