@linzumi/cli 1.0.87 → 1.0.89

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linzumi/cli",
3
- "version": "1.0.87",
3
+ "version": "1.0.89",
4
4
  "description": "Linzumi CLI \u2014 point a Codex agent at the real code on your laptop, with your team watching and steering from shared threads.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,16 @@
14
14
  // so there is nothing local whose loss could fork identity).
15
15
  // P4 rename: `linzumi node rename` renames the node and the list
16
16
  // read-back shows the new name (M1.8).
17
+ // P5 propagation: M1.8's leg of the plan's "Rename propagation test": with
18
+ // a live viewerComputeNodesUpdated subscription open (the
19
+ // same subscription class an open web client holds), a
20
+ // CLI-issued rename is DELIVERED as a push frame carrying
21
+ // the new name - no reload, no re-poll; the assertion
22
+ // consumes only what the socket pushes. Subscribes with the
23
+ // persisted ComputeNodeCliRenamePropagation document first
24
+ // (the only shape an enforcing server accepts on the
25
+ // __absinthe__ doc path), free-form fallback when the
26
+ // server's manifest predates it (dev default posture).
17
27
  //
18
28
  // REQUIREMENTS: a server running M1.1 (join adoption) + M1.3 (the
19
29
  // executeComputeNodeCommand mutation) - D29 gates the CLI merge on M1.3
@@ -29,15 +39,35 @@
29
39
  // The script never touches the real ~/.linzumi: all CLI state is scoped into
30
40
  // a temp dir via LINZUMI_CONFIG_FILE, and the "config wipe" leg wipes THAT.
31
41
  import { spawn, spawnSync } from 'node:child_process';
32
- import { mkdtempSync, rmSync } from 'node:fs';
42
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
33
43
  import { tmpdir } from 'node:os';
34
44
  import { dirname, join } from 'node:path';
35
45
  import { fileURLToPath } from 'node:url';
46
+ import WebSocket from 'ws';
36
47
 
37
48
  const here = dirname(fileURLToPath(import.meta.url));
38
49
  const pkgRoot = dirname(here);
39
50
  const cliBin = join(pkgRoot, 'bin', 'linzumi.js');
40
51
 
52
+ // The subscription document for the P5 propagation leg - same single-source
53
+ // JSON the CLI transport and the server manifest generator consume, so an
54
+ // enforcing server (prod posture) accepts it by documentId.
55
+ const renamePropagationDocument = JSON.parse(
56
+ readFileSync(
57
+ join(pkgRoot, 'src', 'computeNodePersistedDocuments.json'),
58
+ 'utf8'
59
+ )
60
+ ).documents.find(
61
+ (document) => document.operationName === 'ComputeNodeCliRenamePropagation'
62
+ );
63
+ if (renamePropagationDocument === undefined) {
64
+ // Loud at module load: without this, a renamed/removed document in the JSON
65
+ // surfaces as an opaque TypeError deep inside subscribe().
66
+ throw new Error(
67
+ 'ComputeNodeCliRenamePropagation not found in src/computeNodePersistedDocuments.json - the P5 leg needs it (test/computeNodeCli.test.ts pins it)'
68
+ );
69
+ }
70
+
41
71
  const serverUrl = process.env.LINZUMI_E2E_URL ?? 'ws://127.0.0.1:4000';
42
72
  const token = process.env.LINZUMI_E2E_TOKEN;
43
73
  // Extra args appended to `linzumi connect` (space-separated), e.g.
@@ -77,6 +107,188 @@ function runCli(args, options = {}) {
77
107
  };
78
108
  }
79
109
 
110
+ // Async variant for legs that must keep the event loop live while the CLI
111
+ // runs (P5 holds an open websocket whose frames a spawnSync would starve).
112
+ function runCliAsync(args, options = {}) {
113
+ return new Promise((resolve, reject) => {
114
+ const child = spawn('node', [cliBin, ...args], {
115
+ env: cliEnv(),
116
+ stdio: ['ignore', 'pipe', 'pipe'],
117
+ });
118
+ let stdout = '';
119
+ let stderr = '';
120
+ child.stdout.on('data', (chunk) => (stdout += chunk));
121
+ child.stderr.on('data', (chunk) => (stderr += chunk));
122
+ const timer = setTimeout(() => {
123
+ child.kill('SIGKILL');
124
+ reject(new Error(`CLI timed out: linzumi ${args.join(' ')}`));
125
+ }, options.timeoutMs ?? 60_000);
126
+ child.on('error', (error) => {
127
+ clearTimeout(timer);
128
+ reject(error);
129
+ });
130
+ child.on('close', (code) => {
131
+ clearTimeout(timer);
132
+ resolve({ code, stdout, stderr });
133
+ });
134
+ });
135
+ }
136
+
137
+ // A minimal Phoenix-v2 GraphQL subscription client, standing in for the open
138
+ // web client of the plan's Rename propagation test: connect /socket, join
139
+ // __absinthe__:control, push the subscription "doc" (documentId first;
140
+ // free-form fallback when the server's persisted-document store predates it),
141
+ // then surface every subscription:data frame. No polling anywhere.
142
+ function openRenamePropagationSubscription() {
143
+ const socketUrl = new URL(
144
+ serverUrl.replace(/^http/, 'ws').replace(/\/$/, '') + '/socket/websocket'
145
+ );
146
+ socketUrl.searchParams.set('vsn', '2.0.0');
147
+ socketUrl.searchParams.set('token', token);
148
+
149
+ const ws = new WebSocket(socketUrl.toString());
150
+ const controlTopic = '__absinthe__:control';
151
+ const pending = new Map(); // ref -> {resolve, reject}
152
+ const dataListeners = new Set();
153
+ // Cancel hooks for in-flight waitForNodeFrame waits, so close() can clear
154
+ // their timers/listeners on error paths (never an orphaned 20s timeout).
155
+ const activeWaitCancels = new Set();
156
+ let nextRef = 0;
157
+
158
+ function push(event, payload) {
159
+ const ref = String((nextRef += 1));
160
+ // Phoenix v2 JSON frames: [join_ref, ref, topic, event, payload]; the
161
+ // control topic's join_ref is the join push's own ref ("1").
162
+ ws.send(JSON.stringify(['1', ref, controlTopic, event, payload]));
163
+ return new Promise((resolve, reject) => {
164
+ pending.set(ref, { resolve, reject });
165
+ setTimeout(() => {
166
+ if (pending.delete(ref)) {
167
+ reject(new Error(`no phx_reply for ${event} within 15s`));
168
+ }
169
+ }, 15_000);
170
+ });
171
+ }
172
+
173
+ ws.on('message', (raw) => {
174
+ let frame;
175
+ try {
176
+ frame = JSON.parse(String(raw));
177
+ } catch {
178
+ return;
179
+ }
180
+ const [, ref, topic, event, payload] = frame;
181
+ if (event === 'phx_reply' && pending.has(ref)) {
182
+ const waiter = pending.get(ref);
183
+ pending.delete(ref);
184
+ waiter.resolve(payload);
185
+ return;
186
+ }
187
+ if (event === 'subscription:data') {
188
+ for (const listener of dataListeners) {
189
+ listener(topic, payload);
190
+ }
191
+ }
192
+ });
193
+
194
+ const opened = new Promise((resolve, reject) => {
195
+ ws.on('open', resolve);
196
+ ws.on('error', reject);
197
+ });
198
+
199
+ return {
200
+ async subscribe() {
201
+ await opened;
202
+ const joinReply = await push('phx_join', {});
203
+ if (joinReply?.status !== 'ok') {
204
+ throw new Error(
205
+ `__absinthe__:control join failed: ${JSON.stringify(joinReply)}`
206
+ );
207
+ }
208
+
209
+ // Persisted documentId first (the only shape an enforcing server
210
+ // accepts); free-form fallback mirrors the CLI's HTTP transport.
211
+ let docReply = await push('doc', {
212
+ documentId: renamePropagationDocument.documentId,
213
+ operationName: renamePropagationDocument.operationName,
214
+ });
215
+ const replyCodes = (docReply?.response?.errors ?? [])
216
+ .map((error) => error?.extensions?.code)
217
+ .filter((code) => typeof code === 'string');
218
+ if (
219
+ docReply?.status !== 'ok' &&
220
+ replyCodes.includes('PERSISTED_DOCUMENT_NOT_FOUND')
221
+ ) {
222
+ docReply = await push('doc', { query: renamePropagationDocument.text });
223
+ }
224
+ if (
225
+ docReply?.status !== 'ok' ||
226
+ typeof docReply?.response?.subscriptionId !== 'string'
227
+ ) {
228
+ throw new Error(
229
+ `viewerComputeNodesUpdated subscribe failed: ${JSON.stringify(docReply)}`
230
+ );
231
+ }
232
+ return docReply.response.subscriptionId;
233
+ },
234
+ // Resolves with the first PUSHED viewerComputeNodesUpdated frame matching
235
+ // `predicate`; other frames on the topic (e.g. adoption events) are
236
+ // skipped, never treated as failures. The returned promise is
237
+ // pre-guarded against unhandled rejection (a P5 error path abandons it
238
+ // before awaiting) and close() cancels its timer + listener so a failed
239
+ // run never leaves a live timeout keeping the process alive.
240
+ waitForNodeFrame(subscriptionId, predicate, timeoutMs = 20_000) {
241
+ const framePromise = new Promise((resolve, reject) => {
242
+ const timer = setTimeout(() => {
243
+ cancel(
244
+ new Error(
245
+ `no matching viewerComputeNodesUpdated push within ${timeoutMs}ms`
246
+ )
247
+ );
248
+ }, timeoutMs);
249
+ const listener = (topic, payload) => {
250
+ if (
251
+ topic !== subscriptionId &&
252
+ payload?.subscriptionId !== subscriptionId
253
+ ) {
254
+ return;
255
+ }
256
+ const node = payload?.result?.data?.viewerComputeNodesUpdated;
257
+ if (node !== undefined && predicate(node)) {
258
+ clearTimeout(timer);
259
+ dataListeners.delete(listener);
260
+ activeWaitCancels.delete(cancel);
261
+ resolve(node);
262
+ }
263
+ };
264
+ const cancel = (error) => {
265
+ clearTimeout(timer);
266
+ dataListeners.delete(listener);
267
+ activeWaitCancels.delete(cancel);
268
+ reject(error);
269
+ };
270
+ activeWaitCancels.add(cancel);
271
+ dataListeners.add(listener);
272
+ });
273
+ // An error before the await abandons this promise; the guard keeps the
274
+ // eventual timeout/cancel rejection from becoming an unhandled
275
+ // rejection (Node exits non-zero on those before cleanup finishes).
276
+ framePromise.catch(() => {});
277
+ return framePromise;
278
+ },
279
+ close() {
280
+ for (const cancel of [...activeWaitCancels]) {
281
+ cancel(new Error('subscription closed'));
282
+ }
283
+ try {
284
+ ws.close();
285
+ } catch {
286
+ // Best-effort teardown only.
287
+ }
288
+ },
289
+ };
290
+ }
291
+
80
292
  function nodeList() {
81
293
  const result = runCli([
82
294
  'node',
@@ -238,6 +450,53 @@ try {
238
450
  renamed?.name === newName,
239
451
  JSON.stringify(renamed)
240
452
  );
453
+
454
+ // P5: rename propagation (M1.8's contribution to the plan's Rename
455
+ // propagation test - program plan Definitions + M1 acceptance (b)). The
456
+ // subscription is opened BEFORE the rename runs and the assertion consumes
457
+ // only pushed frames: delivery without reload or re-poll.
458
+ const subscription = openRenamePropagationSubscription();
459
+ try {
460
+ const subscriptionId = await subscription.subscribe();
461
+ const propagatedName = `e2e propagated ${Date.now()}`;
462
+ const framePromise = subscription.waitForNodeFrame(
463
+ subscriptionId,
464
+ (node) => node.id === successor.id && node.name === propagatedName
465
+ );
466
+
467
+ const propagatedRename = await runCliAsync([
468
+ 'node',
469
+ 'rename',
470
+ propagatedName,
471
+ '--node',
472
+ successor.id,
473
+ '--linzumi-url',
474
+ serverUrl,
475
+ '--token',
476
+ token,
477
+ ]);
478
+ check(
479
+ 'P5 rename exits 0',
480
+ propagatedRename.code === 0,
481
+ propagatedRename.stderr
482
+ );
483
+
484
+ const frame = await framePromise;
485
+ check(
486
+ 'P5 subscription push delivered the renamed node (no re-poll)',
487
+ frame.id === successor.id && frame.name === propagatedName,
488
+ JSON.stringify(frame)
489
+ );
490
+ check(
491
+ 'P5 pushed frame carries the successor generation',
492
+ frame.resetGeneration === successor.resetGeneration,
493
+ JSON.stringify(frame)
494
+ );
495
+ } catch (error) {
496
+ check('P5 rename propagation', false, error.message);
497
+ } finally {
498
+ subscription.close();
499
+ }
241
500
  } finally {
242
501
  rmSync(stateRoot, { recursive: true, force: true });
243
502
  rmSync(workDir, { recursive: true, force: true });