@linzumi/cli 1.0.98 → 1.0.100
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/README.md +1 -1
- package/dist/index.js +20 -20
- package/package.json +1 -1
- package/scripts/build-transport-parity-oracle.mjs +688 -0
package/package.json
CHANGED
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
// Spec: kandan/plans/2026-07-10-compute-nodes-rescript-commander-zero-ts-program.md
|
|
2
|
+
// (M3 cluster 4, transport-phoenix).
|
|
3
|
+
// Relationship: Builds the TS-side PARITY ORACLE for the transport cluster
|
|
4
|
+
// of the ReScript commander port, the sibling of
|
|
5
|
+
// build-protocol-parity-oracle.mjs (cluster 1) /
|
|
6
|
+
// build-config-identity-parity-oracle.mjs (cluster 2) /
|
|
7
|
+
// build-auth-parity-oracle.mjs (cluster 3). Two modes in one bundle:
|
|
8
|
+
//
|
|
9
|
+
// BATCH (default): a JSON array of pure-transform cases on stdin, a JSON
|
|
10
|
+
// array of results on stdout - phoenixWebsocketUrl / protocols, the
|
|
11
|
+
// frame codec, the typed push-error factory table with its
|
|
12
|
+
// connection-lost/wait classification, the exported reconnect-backoff
|
|
13
|
+
// config, and redactForCliLog (the structured log redactor the ported
|
|
14
|
+
// transport's audit snippet mirrors).
|
|
15
|
+
//
|
|
16
|
+
// FLOW (--flow): a long-lived JSON-lines scenario driver around the REAL
|
|
17
|
+
// connectPhoenixClient (phoenix.ts) - the TS leg of the transport
|
|
18
|
+
// differential. Commands arrive one JSON object per stdin line; the
|
|
19
|
+
// driver emits {"obs": ...} observation lines (controls dispatched, push
|
|
20
|
+
// outcomes with phase/reason, disconnect/reconnect firings, operator
|
|
21
|
+
// notices, cursor/epoch store calls) in the client's own event order and
|
|
22
|
+
// {"id", ...} completion lines per command. The commander-harness
|
|
23
|
+
// conformance suite (TransportPhoenixParityConformance.res) runs the
|
|
24
|
+
// SAME command list against the ReScript transport in-process and
|
|
25
|
+
// asserts byte-identical normalized transcripts - the transport-seam
|
|
26
|
+
// differential against the M2.2 scripted kandan server.
|
|
27
|
+
//
|
|
28
|
+
// Cursor stores in flow mode are the REAL runner.ts file stores
|
|
29
|
+
// (createControlCursorFileStore / createRunnerControlEpochStore), so the
|
|
30
|
+
// cross-impl cursor-file legs compare the exact bytes the TS commander
|
|
31
|
+
// would write and read.
|
|
32
|
+
//
|
|
33
|
+
// Determinism: wall-clock-shaped values never enter observations verbatim
|
|
34
|
+
// (session durations become presence flags; timeout-budget digits are
|
|
35
|
+
// normalized by the harness on both legs). The oracle only ever sees the
|
|
36
|
+
// conformance suite's synthetic tokens.
|
|
37
|
+
//
|
|
38
|
+
// The output is a local test artifact (.differential/ is gitignored, never
|
|
39
|
+
// published) and stays unminified for debuggability.
|
|
40
|
+
import { copyFile, mkdir } from 'node:fs/promises';
|
|
41
|
+
import { join, dirname } from 'node:path';
|
|
42
|
+
import { fileURLToPath } from 'node:url';
|
|
43
|
+
import { build } from 'esbuild';
|
|
44
|
+
|
|
45
|
+
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
46
|
+
|
|
47
|
+
const driverSource = `
|
|
48
|
+
import { createInterface } from 'node:readline';
|
|
49
|
+
import { readFileSync } from 'node:fs';
|
|
50
|
+
import {
|
|
51
|
+
connectPhoenixClient,
|
|
52
|
+
decodeFrame,
|
|
53
|
+
encodeFrame,
|
|
54
|
+
isPhoenixConnectionLostError,
|
|
55
|
+
isPhoenixConnectionWaitError,
|
|
56
|
+
isPhoenixPushError,
|
|
57
|
+
phoenixPushErrors,
|
|
58
|
+
phoenixReconnectBackoffConfig,
|
|
59
|
+
phoenixWebsocketProtocols,
|
|
60
|
+
phoenixWebsocketUrl,
|
|
61
|
+
type PhoenixClient,
|
|
62
|
+
type PhoenixControlCursorStore,
|
|
63
|
+
type PhoenixControlEpochStore,
|
|
64
|
+
type PhoenixPushErrorPhase,
|
|
65
|
+
} from './src/phoenix';
|
|
66
|
+
import {
|
|
67
|
+
createControlCursorFileStore,
|
|
68
|
+
createRunnerControlEpochStore,
|
|
69
|
+
} from './src/runner';
|
|
70
|
+
import { redactForCliLog } from './src/runnerLogger';
|
|
71
|
+
import type { JsonObject, JsonValue } from './src/protocol';
|
|
72
|
+
|
|
73
|
+
// --- Shared helpers -------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
function outcome(run: () => unknown): unknown {
|
|
76
|
+
try {
|
|
77
|
+
const value = run();
|
|
78
|
+
return value === undefined ? { defined: false } : { defined: true, value };
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return {
|
|
81
|
+
threw: true,
|
|
82
|
+
message: error instanceof Error ? error.message : String(error),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- BATCH mode ------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
type PushErrorRow = {
|
|
90
|
+
readonly name: string;
|
|
91
|
+
readonly phase: string;
|
|
92
|
+
readonly reason: string;
|
|
93
|
+
readonly message: string;
|
|
94
|
+
readonly isPushError: boolean;
|
|
95
|
+
readonly isLost: boolean;
|
|
96
|
+
readonly isWait: boolean;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
function pushErrorTable(): PushErrorRow[] {
|
|
100
|
+
const entries: ReadonlyArray<readonly [string, Error]> = [
|
|
101
|
+
['socketClosed', phoenixPushErrors.socketClosed()],
|
|
102
|
+
['reconnectFailed', phoenixPushErrors.reconnectFailed()],
|
|
103
|
+
['socketNotOpen', phoenixPushErrors.socketNotOpen()],
|
|
104
|
+
['clientClosedWait', phoenixPushErrors.clientClosed('wait_for_connection')],
|
|
105
|
+
['clientClosedReply', phoenixPushErrors.clientClosed('reply')],
|
|
106
|
+
['notConnected', phoenixPushErrors.notConnected()],
|
|
107
|
+
[
|
|
108
|
+
'connectionWaitTimeout',
|
|
109
|
+
phoenixPushErrors.connectionWaitTimeout(1234, 'scripted_event'),
|
|
110
|
+
],
|
|
111
|
+
['replyTimeout', phoenixPushErrors.replyTimeout(1234, 'scripted_event')],
|
|
112
|
+
];
|
|
113
|
+
return entries.map(([name, error]) => {
|
|
114
|
+
const typed = error as Error & { phase: string; reason: string };
|
|
115
|
+
return {
|
|
116
|
+
name,
|
|
117
|
+
phase: typed.phase,
|
|
118
|
+
reason: typed.reason,
|
|
119
|
+
message: typed.message,
|
|
120
|
+
isPushError: isPhoenixPushError(error),
|
|
121
|
+
isLost: isPhoenixConnectionLostError(error),
|
|
122
|
+
isWait: isPhoenixConnectionWaitError(error),
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function handleBatchCase(testCase: {
|
|
128
|
+
readonly op: string;
|
|
129
|
+
readonly baseUrl?: string;
|
|
130
|
+
readonly token?: string;
|
|
131
|
+
readonly text?: string;
|
|
132
|
+
readonly frame?: JsonValue;
|
|
133
|
+
readonly payload?: JsonObject;
|
|
134
|
+
}): unknown {
|
|
135
|
+
switch (testCase.op) {
|
|
136
|
+
case 'websocketUrl':
|
|
137
|
+
return outcome(() => phoenixWebsocketUrl(testCase.baseUrl ?? ''));
|
|
138
|
+
case 'websocketProtocols':
|
|
139
|
+
return outcome(() => phoenixWebsocketProtocols(testCase.token ?? ''));
|
|
140
|
+
case 'decodeFrame':
|
|
141
|
+
return outcome(() => decodeFrame(testCase.text ?? ''));
|
|
142
|
+
case 'encodeFrame':
|
|
143
|
+
return outcome(() =>
|
|
144
|
+
encodeFrame(testCase.frame as Parameters<typeof encodeFrame>[0])
|
|
145
|
+
);
|
|
146
|
+
case 'pushErrorTable':
|
|
147
|
+
return pushErrorTable();
|
|
148
|
+
case 'backoffConfig':
|
|
149
|
+
return phoenixReconnectBackoffConfig;
|
|
150
|
+
case 'redact':
|
|
151
|
+
return outcome(() => redactForCliLog(testCase.payload ?? {}));
|
|
152
|
+
default:
|
|
153
|
+
return { threw: true, message: 'unknown op ' + String(testCase.op) };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function runBatch(): Promise<void> {
|
|
158
|
+
let stdin = '';
|
|
159
|
+
process.stdin.setEncoding('utf8');
|
|
160
|
+
for await (const chunk of process.stdin) {
|
|
161
|
+
stdin += chunk;
|
|
162
|
+
}
|
|
163
|
+
const cases = JSON.parse(stdin) as unknown[];
|
|
164
|
+
process.stdout.write(
|
|
165
|
+
JSON.stringify(cases.map((testCase) => handleBatchCase(testCase as never)))
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- FLOW mode --------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
type Obs = Record<string, unknown>;
|
|
172
|
+
|
|
173
|
+
const observations: Obs[] = [];
|
|
174
|
+
const waiters: Array<() => void> = [];
|
|
175
|
+
let controlCount = 0;
|
|
176
|
+
let reconnectCount = 0;
|
|
177
|
+
let disconnectCount = 0;
|
|
178
|
+
let noticeCount = 0;
|
|
179
|
+
|
|
180
|
+
function emit(client: string, obs: Obs): void {
|
|
181
|
+
const entry = { client, ...obs };
|
|
182
|
+
observations.push(entry);
|
|
183
|
+
process.stdout.write(JSON.stringify({ obs: entry }) + '\\n');
|
|
184
|
+
for (const waiter of [...waiters]) {
|
|
185
|
+
waiter();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function reply(id: number, payload: Obs): void {
|
|
190
|
+
process.stdout.write(JSON.stringify({ id, ...payload }) + '\\n');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function awaitCondition(check: () => boolean): Promise<void> {
|
|
194
|
+
return new Promise((resolve) => {
|
|
195
|
+
const settle = () => {
|
|
196
|
+
if (check()) {
|
|
197
|
+
const index = waiters.indexOf(settle);
|
|
198
|
+
if (index !== -1) {
|
|
199
|
+
waiters.splice(index, 1);
|
|
200
|
+
}
|
|
201
|
+
resolve();
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
waiters.push(settle);
|
|
205
|
+
settle();
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function pushFailureObs(error: unknown): Obs {
|
|
210
|
+
if (isPhoenixPushError(error)) {
|
|
211
|
+
const typed = error as Error & { phase: string; reason: string };
|
|
212
|
+
return {
|
|
213
|
+
class: 'transport',
|
|
214
|
+
phase: typed.phase,
|
|
215
|
+
reason: typed.reason,
|
|
216
|
+
message: typed.message,
|
|
217
|
+
lost: isPhoenixConnectionLostError(error),
|
|
218
|
+
wait: isPhoenixConnectionWaitError(error),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
class: 'failed',
|
|
223
|
+
message: error instanceof Error ? error.message : String(error),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
type FlowConnectCommand = {
|
|
228
|
+
readonly id: number;
|
|
229
|
+
readonly op: 'connect';
|
|
230
|
+
readonly client?: string;
|
|
231
|
+
readonly url: string;
|
|
232
|
+
readonly token: string;
|
|
233
|
+
readonly pushTimeoutMs?: number;
|
|
234
|
+
readonly cursorStore?:
|
|
235
|
+
| { readonly kind: 'memory'; readonly initial?: Record<string, number> }
|
|
236
|
+
| { readonly kind: 'file'; readonly path: string };
|
|
237
|
+
readonly epochStore?:
|
|
238
|
+
| { readonly kind: 'memory'; readonly epoch?: string }
|
|
239
|
+
| {
|
|
240
|
+
readonly kind: 'file';
|
|
241
|
+
readonly cursorPath: string;
|
|
242
|
+
readonly startTurnPath: string;
|
|
243
|
+
};
|
|
244
|
+
readonly consumerThrowsOn?: string;
|
|
245
|
+
readonly ackMode?: 'immediate' | 'never';
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const clients = new Map<string, PhoenixClient>();
|
|
249
|
+
const manualAcks = new Map<string, () => void>();
|
|
250
|
+
|
|
251
|
+
function clientName(command: { readonly client?: unknown }): string {
|
|
252
|
+
return typeof command.client === 'string' ? command.client : 'main';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function clientOf(command: { readonly client?: unknown }): PhoenixClient {
|
|
256
|
+
const named = clients.get(clientName(command));
|
|
257
|
+
if (named === undefined) {
|
|
258
|
+
throw new Error('flow client not connected: ' + clientName(command));
|
|
259
|
+
}
|
|
260
|
+
return named;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function instrumentCursorStore(
|
|
264
|
+
name: string,
|
|
265
|
+
store: PhoenixControlCursorStore & { reset?: (topic: string, seq: number) => void }
|
|
266
|
+
): PhoenixControlCursorStore {
|
|
267
|
+
return {
|
|
268
|
+
initial: (topic) => {
|
|
269
|
+
const value = store.initial(topic);
|
|
270
|
+
emit(name, { kind: 'store', op: 'initial', topic, value: value ?? null });
|
|
271
|
+
return value;
|
|
272
|
+
},
|
|
273
|
+
onAdvance: (topic, seq) => {
|
|
274
|
+
emit(name, { kind: 'store', op: 'onAdvance', topic, seq });
|
|
275
|
+
store.onAdvance(topic, seq);
|
|
276
|
+
},
|
|
277
|
+
reset: (topic, seq) => {
|
|
278
|
+
emit(name, { kind: 'store', op: 'reset', topic, seq });
|
|
279
|
+
store.reset?.(topic, seq);
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function instrumentEpochStore(
|
|
285
|
+
name: string,
|
|
286
|
+
store: PhoenixControlEpochStore
|
|
287
|
+
): PhoenixControlEpochStore {
|
|
288
|
+
return {
|
|
289
|
+
current: (topic) => {
|
|
290
|
+
const value = store.current(topic);
|
|
291
|
+
emit(name, { kind: 'epoch', op: 'current', topic, value: value ?? null });
|
|
292
|
+
return value;
|
|
293
|
+
},
|
|
294
|
+
adopt: (topic, epoch) => {
|
|
295
|
+
const changed = store.adopt(topic, epoch);
|
|
296
|
+
emit(name, { kind: 'epoch', op: 'adopt', topic, epoch, changed });
|
|
297
|
+
return changed;
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const flowFileStores: Array<{ flush: () => void }> = [];
|
|
303
|
+
|
|
304
|
+
async function handleConnect(command: FlowConnectCommand): Promise<void> {
|
|
305
|
+
const name = clientName(command);
|
|
306
|
+
let cursorStore: PhoenixControlCursorStore | undefined;
|
|
307
|
+
if (command.cursorStore?.kind === 'memory') {
|
|
308
|
+
const cursors = new Map<string, number>(
|
|
309
|
+
Object.entries(command.cursorStore.initial ?? {})
|
|
310
|
+
);
|
|
311
|
+
cursorStore = instrumentCursorStore(name, {
|
|
312
|
+
initial: (topic) => cursors.get(topic),
|
|
313
|
+
onAdvance: (topic, seq) => {
|
|
314
|
+
const existing = cursors.get(topic);
|
|
315
|
+
if (existing === undefined || seq > existing) {
|
|
316
|
+
cursors.set(topic, seq);
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
reset: (topic, seq) => {
|
|
320
|
+
cursors.set(topic, seq);
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
} else if (command.cursorStore?.kind === 'file') {
|
|
324
|
+
const fileStore = createControlCursorFileStore(
|
|
325
|
+
command.cursorStore.path,
|
|
326
|
+
(event: string) => {
|
|
327
|
+
emit(name, { kind: 'storelog', event });
|
|
328
|
+
}
|
|
329
|
+
);
|
|
330
|
+
flowFileStores.push(fileStore);
|
|
331
|
+
cursorStore = instrumentCursorStore(name, fileStore);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
let epochStore: PhoenixControlEpochStore | undefined;
|
|
335
|
+
if (command.epochStore?.kind === 'memory') {
|
|
336
|
+
let epoch = command.epochStore.epoch;
|
|
337
|
+
epochStore = instrumentEpochStore(name, {
|
|
338
|
+
current: () => epoch,
|
|
339
|
+
adopt: (_topic, next) => {
|
|
340
|
+
const changed = epoch !== undefined && epoch !== next;
|
|
341
|
+
epoch = next;
|
|
342
|
+
return changed;
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
} else if (command.epochStore?.kind === 'file') {
|
|
346
|
+
const log = (event: string) => {
|
|
347
|
+
emit(name, { kind: 'storelog', event });
|
|
348
|
+
};
|
|
349
|
+
const cursorFileStore = createControlCursorFileStore(
|
|
350
|
+
command.epochStore.cursorPath,
|
|
351
|
+
log
|
|
352
|
+
);
|
|
353
|
+
const startTurnFileStore = createControlCursorFileStore(
|
|
354
|
+
command.epochStore.startTurnPath,
|
|
355
|
+
log,
|
|
356
|
+
{ discardSeqsOnFirstEpochAdoption: true }
|
|
357
|
+
);
|
|
358
|
+
flowFileStores.push(cursorFileStore, startTurnFileStore);
|
|
359
|
+
cursorStore = instrumentCursorStore(name, cursorFileStore);
|
|
360
|
+
epochStore = instrumentEpochStore(
|
|
361
|
+
name,
|
|
362
|
+
createRunnerControlEpochStore(cursorFileStore, startTurnFileStore, log, {
|
|
363
|
+
runnerId: 'scripted-transport',
|
|
364
|
+
kandanUrl: command.url,
|
|
365
|
+
})
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
let client: PhoenixClient;
|
|
370
|
+
try {
|
|
371
|
+
client = await connectPhoenixClient(command.url, command.token, undefined, {
|
|
372
|
+
...(command.pushTimeoutMs === undefined
|
|
373
|
+
? {}
|
|
374
|
+
: { pushTimeoutMs: command.pushTimeoutMs }),
|
|
375
|
+
...(cursorStore === undefined ? {} : { controlCursorStore: cursorStore }),
|
|
376
|
+
...(epochStore === undefined ? {} : { controlEpochStore: epochStore }),
|
|
377
|
+
onOperatorNotice: (message) => {
|
|
378
|
+
noticeCount += 1;
|
|
379
|
+
emit(name, { kind: 'notice', message });
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
} catch (error) {
|
|
383
|
+
emit(name, { kind: 'connect_error', ...pushFailureObs(error) });
|
|
384
|
+
reply(command.id, { ok: false });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
clients.set(name, client);
|
|
388
|
+
|
|
389
|
+
client.onControl((control, ack) => {
|
|
390
|
+
controlCount += 1;
|
|
391
|
+
emit(name, { kind: 'control', payload: control });
|
|
392
|
+
if (
|
|
393
|
+
command.consumerThrowsOn !== undefined &&
|
|
394
|
+
control.type === command.consumerThrowsOn
|
|
395
|
+
) {
|
|
396
|
+
throw new Error('scripted consumer failure: ' + String(control.type));
|
|
397
|
+
}
|
|
398
|
+
if (command.ackMode === 'never') {
|
|
399
|
+
const seq = control.control_seq;
|
|
400
|
+
if (typeof seq === 'number') {
|
|
401
|
+
manualAcks.set(name + ':' + String(seq), ack);
|
|
402
|
+
}
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
ack();
|
|
406
|
+
});
|
|
407
|
+
client.onEvent((topic, event, payload) => {
|
|
408
|
+
emit(name, { kind: 'event', topic, event, payload });
|
|
409
|
+
});
|
|
410
|
+
client.onReconnect((rejoinReplies) => {
|
|
411
|
+
reconnectCount += 1;
|
|
412
|
+
const replies: Record<string, unknown> = {};
|
|
413
|
+
for (const [topic, response] of rejoinReplies) {
|
|
414
|
+
replies[topic] = response;
|
|
415
|
+
}
|
|
416
|
+
emit(name, { kind: 'reconnect', replies });
|
|
417
|
+
});
|
|
418
|
+
client.onDisconnect?.((info) => {
|
|
419
|
+
disconnectCount += 1;
|
|
420
|
+
emit(name, {
|
|
421
|
+
kind: 'disconnect',
|
|
422
|
+
closeCode: info.closeCode ?? null,
|
|
423
|
+
closeReason: info.closeReason ?? null,
|
|
424
|
+
hadDuration: info.previousSessionDurationMs !== undefined,
|
|
425
|
+
reconnectCount: info.reconnectCount,
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
emit(name, { kind: 'connect_ok' });
|
|
429
|
+
reply(command.id, { ok: true });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
type FlowCommand = FlowConnectCommand | (Record<string, unknown> & { id: number; op: string });
|
|
433
|
+
|
|
434
|
+
async function handleFlowCommand(command: FlowCommand): Promise<void> {
|
|
435
|
+
switch (command.op) {
|
|
436
|
+
case 'connect':
|
|
437
|
+
await handleConnect(command as FlowConnectCommand);
|
|
438
|
+
return;
|
|
439
|
+
case 'join': {
|
|
440
|
+
const name = clientName(command);
|
|
441
|
+
const topic = String(command.topic);
|
|
442
|
+
const payload = command.payload as JsonObject;
|
|
443
|
+
const rejoin = command.rejoin as JsonObject | undefined;
|
|
444
|
+
const registrationId = command.registrationId as string | undefined;
|
|
445
|
+
try {
|
|
446
|
+
const response = await clientOf(command).join(topic, payload, {
|
|
447
|
+
...(rejoin === undefined ? {} : { rejoinPayload: () => rejoin }),
|
|
448
|
+
...(registrationId === undefined ? {} : { registrationId }),
|
|
449
|
+
});
|
|
450
|
+
emit(name, { kind: 'join_ok', topic, response });
|
|
451
|
+
} catch (error) {
|
|
452
|
+
emit(name, { kind: 'join_error', topic, ...pushFailureObs(error) });
|
|
453
|
+
}
|
|
454
|
+
reply(command.id, { done: true });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
case 'push': {
|
|
458
|
+
const name = clientName(command);
|
|
459
|
+
try {
|
|
460
|
+
const result = await clientOf(command).push(
|
|
461
|
+
String(command.topic),
|
|
462
|
+
String(command.event),
|
|
463
|
+
command.payload as JsonObject
|
|
464
|
+
);
|
|
465
|
+
emit(name, { kind: 'push_ok', event: command.event, reply: result });
|
|
466
|
+
} catch (error) {
|
|
467
|
+
emit(name, { kind: 'push_error', event: command.event, ...pushFailureObs(error) });
|
|
468
|
+
}
|
|
469
|
+
reply(command.id, { done: true });
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
case 'pushDetached': {
|
|
473
|
+
// Fire without awaiting: the outcome lands as an observation whenever
|
|
474
|
+
// it settles (the queued-push-across-outage legs).
|
|
475
|
+
const name = clientName(command);
|
|
476
|
+
void clientOf(command)
|
|
477
|
+
.push(
|
|
478
|
+
String(command.topic),
|
|
479
|
+
String(command.event),
|
|
480
|
+
command.payload as JsonObject
|
|
481
|
+
)
|
|
482
|
+
.then(
|
|
483
|
+
(result) => {
|
|
484
|
+
emit(name, { kind: 'push_ok', event: command.event, reply: result });
|
|
485
|
+
},
|
|
486
|
+
(error: unknown) => {
|
|
487
|
+
emit(name, { kind: 'push_error', event: command.event, ...pushFailureObs(error) });
|
|
488
|
+
}
|
|
489
|
+
);
|
|
490
|
+
reply(command.id, { done: true });
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
case 'pushIfConnected': {
|
|
494
|
+
const name = clientName(command);
|
|
495
|
+
try {
|
|
496
|
+
const result = await clientOf(command).pushIfConnected(
|
|
497
|
+
String(command.topic),
|
|
498
|
+
String(command.event),
|
|
499
|
+
command.payload as JsonObject
|
|
500
|
+
);
|
|
501
|
+
emit(name, { kind: 'push_ok', event: command.event, reply: result });
|
|
502
|
+
} catch (error) {
|
|
503
|
+
emit(name, { kind: 'push_error', event: command.event, ...pushFailureObs(error) });
|
|
504
|
+
}
|
|
505
|
+
reply(command.id, { done: true });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
case 'unregisterJoin':
|
|
509
|
+
clientOf(command).unregisterJoin?.(
|
|
510
|
+
String(command.topic),
|
|
511
|
+
String(command.registrationId)
|
|
512
|
+
);
|
|
513
|
+
reply(command.id, { done: true });
|
|
514
|
+
return;
|
|
515
|
+
case 'ackControl': {
|
|
516
|
+
const name = clientName(command);
|
|
517
|
+
const seq = Number(command.seq);
|
|
518
|
+
const ack = manualAcks.get(name + ':' + String(seq));
|
|
519
|
+
manualAcks.delete(name + ':' + String(seq));
|
|
520
|
+
ack?.();
|
|
521
|
+
emit(name, { kind: 'manual_ack', seq });
|
|
522
|
+
reply(command.id, { done: true });
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
case 'forceReconnect': {
|
|
526
|
+
const name = clientName(command);
|
|
527
|
+
const initiated = clientOf(command).forceReconnect?.(
|
|
528
|
+
command.reason as string | undefined
|
|
529
|
+
);
|
|
530
|
+
emit(name, { kind: 'force_reconnect_result', initiated: initiated ?? false });
|
|
531
|
+
reply(command.id, { done: true });
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
case 'hardReset': {
|
|
535
|
+
const name = clientName(command);
|
|
536
|
+
const initiated = clientOf(command).hardReset?.(
|
|
537
|
+
command.reason as string | undefined
|
|
538
|
+
);
|
|
539
|
+
emit(name, { kind: 'hard_reset_result', initiated: initiated ?? false });
|
|
540
|
+
reply(command.id, { done: true });
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
case 'awaitControls':
|
|
544
|
+
await awaitCondition(() => controlCount >= Number(command.count));
|
|
545
|
+
reply(command.id, { done: true });
|
|
546
|
+
return;
|
|
547
|
+
case 'awaitReconnects':
|
|
548
|
+
await awaitCondition(() => reconnectCount >= Number(command.count));
|
|
549
|
+
reply(command.id, { done: true });
|
|
550
|
+
return;
|
|
551
|
+
case 'awaitDisconnects':
|
|
552
|
+
await awaitCondition(() => disconnectCount >= Number(command.count));
|
|
553
|
+
reply(command.id, { done: true });
|
|
554
|
+
return;
|
|
555
|
+
case 'awaitNotices':
|
|
556
|
+
await awaitCondition(() => noticeCount >= Number(command.count));
|
|
557
|
+
reply(command.id, { done: true });
|
|
558
|
+
return;
|
|
559
|
+
case 'awaitAudits': {
|
|
560
|
+
// Audit events land in the LINZUMI_CLI_AUDIT_LOG file (phoenix.ts
|
|
561
|
+
// writes writeCliAuditEvent directly); poll its line count so
|
|
562
|
+
// choreography can sequence on the drain-progress contract.
|
|
563
|
+
const auditFile = process.env.LINZUMI_CLI_AUDIT_LOG ?? '';
|
|
564
|
+
const target = Number(command.count);
|
|
565
|
+
const countLines = () => {
|
|
566
|
+
try {
|
|
567
|
+
return readFileSync(auditFile, 'utf8')
|
|
568
|
+
.split('\\n')
|
|
569
|
+
.filter((line) => line !== '').length;
|
|
570
|
+
} catch {
|
|
571
|
+
return 0;
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
while (countLines() < target) {
|
|
575
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
576
|
+
}
|
|
577
|
+
reply(command.id, { done: true });
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
case 'awaitObs': {
|
|
581
|
+
const kind = String(command.kind);
|
|
582
|
+
const count = Number(command.count);
|
|
583
|
+
await awaitCondition(
|
|
584
|
+
() => observations.filter((entry) => entry.kind === kind).length >= count
|
|
585
|
+
);
|
|
586
|
+
reply(command.id, { done: true });
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
case 'close':
|
|
590
|
+
clients.get(clientName(command))?.close();
|
|
591
|
+
reply(command.id, { done: true });
|
|
592
|
+
return;
|
|
593
|
+
case 'finish': {
|
|
594
|
+
for (const store of flowFileStores) {
|
|
595
|
+
store.flush();
|
|
596
|
+
}
|
|
597
|
+
const auditFile = process.env.LINZUMI_CLI_AUDIT_LOG;
|
|
598
|
+
let audit: unknown[] = [];
|
|
599
|
+
if (auditFile !== undefined && auditFile !== '') {
|
|
600
|
+
try {
|
|
601
|
+
audit = readFileSync(auditFile, 'utf8')
|
|
602
|
+
.split('\\n')
|
|
603
|
+
.filter((line) => line !== '')
|
|
604
|
+
.map((line) => {
|
|
605
|
+
const parsed = JSON.parse(line) as Record<string, unknown>;
|
|
606
|
+
// Drop wall-clock + impl-varying members (ts, stack); keep the
|
|
607
|
+
// stable identity of each audit event.
|
|
608
|
+
delete parsed.ts;
|
|
609
|
+
delete parsed.stack;
|
|
610
|
+
return parsed;
|
|
611
|
+
});
|
|
612
|
+
} catch {
|
|
613
|
+
audit = [];
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
reply(command.id, { done: true, audit });
|
|
617
|
+
process.exit(0);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
default:
|
|
621
|
+
reply(command.id, { error: 'unknown op ' + String(command.op) });
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async function runFlow(): Promise<void> {
|
|
626
|
+
const lines = createInterface({ input: process.stdin, terminal: false });
|
|
627
|
+
// Commands run strictly sequentially: each line's handler completes
|
|
628
|
+
// (including its await conditions) before the next line is consumed -
|
|
629
|
+
// the same milestone-sequenced choreography the differential runner uses.
|
|
630
|
+
const queue: string[] = [];
|
|
631
|
+
let draining = false;
|
|
632
|
+
const drain = async () => {
|
|
633
|
+
if (draining) {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
draining = true;
|
|
637
|
+
while (queue.length > 0) {
|
|
638
|
+
const line = queue.shift()!;
|
|
639
|
+
if (line.trim() === '') {
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
const command = JSON.parse(line) as FlowCommand;
|
|
643
|
+
await handleFlowCommand(command);
|
|
644
|
+
}
|
|
645
|
+
draining = false;
|
|
646
|
+
};
|
|
647
|
+
lines.on('line', (line) => {
|
|
648
|
+
queue.push(line);
|
|
649
|
+
void drain();
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (process.argv.includes('--flow')) {
|
|
654
|
+
void runFlow();
|
|
655
|
+
} else {
|
|
656
|
+
void runBatch();
|
|
657
|
+
}
|
|
658
|
+
`;
|
|
659
|
+
|
|
660
|
+
await mkdir(join(packageRoot, '.differential'), { recursive: true });
|
|
661
|
+
|
|
662
|
+
// runner.ts's graph resolves assets/ relative to the bundle at import time
|
|
663
|
+
// (same requirement as build-differential-entry.mjs).
|
|
664
|
+
await mkdir(join(packageRoot, '.differential/assets'), { recursive: true });
|
|
665
|
+
await copyFile(
|
|
666
|
+
join(packageRoot, 'src/assets/linzumi-logo.svg'),
|
|
667
|
+
join(packageRoot, '.differential/assets/linzumi-logo.svg')
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
await build({
|
|
671
|
+
stdin: {
|
|
672
|
+
contents: driverSource,
|
|
673
|
+
resolveDir: packageRoot,
|
|
674
|
+
sourcefile: 'transport-parity-driver.ts',
|
|
675
|
+
loader: 'ts',
|
|
676
|
+
},
|
|
677
|
+
bundle: true,
|
|
678
|
+
platform: 'node',
|
|
679
|
+
target: 'node20',
|
|
680
|
+
format: 'esm',
|
|
681
|
+
outfile: join(packageRoot, '.differential/transport-parity-oracle.mjs'),
|
|
682
|
+
minify: false,
|
|
683
|
+
sourcemap: false,
|
|
684
|
+
legalComments: 'none',
|
|
685
|
+
// runner.ts pulls the wider commander graph; the same runtime externals
|
|
686
|
+
// as the differential entry build stay external.
|
|
687
|
+
external: ['ws', 'undici', 'blessed', '@anthropic-ai/claude-agent-sdk'],
|
|
688
|
+
});
|