@linzumi/cli 1.0.88 → 1.0.90

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 CHANGED
@@ -58,7 +58,7 @@ Install the CLI or run it with `npx`:
58
58
  ```bash
59
59
  npm install -g @linzumi/cli@latest
60
60
  npx -y @linzumi/cli@latest signup
61
- npx -y @linzumi/cli@1.0.88 --version
61
+ npx -y @linzumi/cli@1.0.90 --version
62
62
  linzumi --version
63
63
  ```
64
64
 
package/dist/index.js CHANGED
@@ -1470,7 +1470,7 @@ To kick the agent off:
1470
1470
  `,L=Buffer.byteLength(P,"utf8");if(L>s)return l("runner_diagnostics.spool_entry_dropped",{reason:"entry_too_large",entry_bytes:L,max_entry_bytes:s}),!1;lve(n,{recursive:!0}),m(u)+L>i&&f(),_(L);let M=b()?`
1471
1471
  `:"";return dve(u,`${M}${P}`,"utf8"),!0}catch(R){return l("runner_diagnostics.spool_failed",{error:R instanceof Error?R.message:String(R)}),!1}},w=k=>{let R=[],P=fve(k,"utf8");for(let L of P.split(`
1472
1472
  `))if(L.trim()!=="")try{let M=JSON.parse(L);M!==null&&typeof M=="object"&&R.push({parsed:M,raw:L})}catch{}return R};return{append:S,drain:async k=>{if(c)return{uploadedBatches:0,uploadedEntries:0,remainingBatches:p().length,skipped:!0};c=!0;let R=0,P=0;try{f(),_(0);let L=a()-o;for(let M of p()){let j;try{j=w(M)}catch(Q){l("runner_diagnostics.spool_batch_unreadable",{file:M,error:Q instanceof Error?Q.message:String(Q)}),y(M,"unreadable");continue}let F=j.filter(Q=>{let he=Eve(Q.parsed);return he===void 0||he>=L});if(F.length===0){y(M,"empty_or_expired");continue}let A=DT(M,".jsonl"),B=F.map(Q=>Q.raw).join(`
1473
- `),K=F.map(Q=>Q.parsed.spooled_at).filter(Q=>typeof Q=="string"),G={bundle_name:"runner-diagnostics-spool",exported_at:new Date(a()).toISOString(),idempotency_key:A,diagnostics:{kind:"runner_diagnostic_log",spool_batch:!0,idempotency_key:A,entry_count:F.length,...K.length===0?{}:{first_spooled_at:K[0],last_spooled_at:K[K.length-1]}},telemetry_ndjson:B};try{await k(G)}catch(Q){l("runner_diagnostics.spool_upload_failed",{file:M,remaining_batches:p().length,error:Q instanceof Error?Q.message:String(Q)});break}R+=1,P+=F.length;try{PU(M)}catch(Q){l("runner_diagnostics.spool_delete_failed",{file:M,error:Q instanceof Error?Q.message:String(Q)})}}}catch(L){l("runner_diagnostics.spool_drain_failed",{error:L instanceof Error?L.message:String(L)})}finally{c=!1}return{uploadedBatches:R,uploadedEntries:P,remainingBatches:p().length,skipped:!1}},pendingBatchFiles:p}}var _ve,bve,wve,vve,Sve,IU,Cve,Tve,EU=z(()=>{"use strict";$y();_ve=32*1024*1024,bve=10080*60*1e3,wve=4*1024*1024,vve=1024*1024,Sve="spool.jsonl",IU=/^batch-(\d{1,16})-[0-9a-f-]+\.jsonl$/;Cve=/\b([A-Z][A-Z0-9_]{1,})=("[^"\n]*"|'[^'\n]*'|[^\s"'`;,)\]}]+)/g,Tve=/(?:\/(?:[A-Za-z0-9._@~+-]|%[0-9A-Fa-f]{2})+){2,}/g});var jt,AT,Ws=z(()=>{"use strict";jt="1.0.88",AT=`linzumi ${jt}`});import{closeSync as MU,existsSync as $T,mkdirSync as Dve,openSync as AU,readFileSync as OU,renameSync as Mve,unlinkSync as cd,writeFileSync as Ave,writeSync as $U}from"node:fs";import{dirname as jU,join as Ove}from"node:path";function jT(e){return e instanceof us||e instanceof Error&&e.name===zU&&"lockPath"in e&&"heldBy"in e}function LT(e,n=Kn(),t){let r=t===void 0?encodeURIComponent(e):rd(t);return Ove(jU(n),"runners",`${r}.lock`)}function Kx(e){let n=LT(e.machineId,e.configPath,e.linzumiUrl),t=e.isPidAlive??KU,r=e.now??(()=>new Date),o=e.killPid??Wve,i=r().toISOString(),s={version:1,machineId:e.machineId,runnerId:e.runnerId,pid:e.pid??process.pid,cwd:e.cwd,workspace:e.workspace,...e.linzumiUrl===void 0?{}:{linzumiUrl:zn(e.linzumiUrl)},...e.launchSource===void 0?{}:{launchSource:e.launchSource},startedAt:i,heartbeatAt:i,cliVersion:e.cliVersion??jt,...e.gracefulUpdateDrain===!0?{gracefulUpdateDrain:!0}:{}};zve(e.machineId,e.configPath,e.linzumiUrl,t,r,o,e.onTakeover),qve(n,s,t,r,o,e.onTakeover,e.beforeReadExistingLock,e.beforeReplaceStaleLock);let a=()=>qU(n,s,r),d=setInterval(a,LU);return d.unref(),{path:n,record:s,heartbeat:a,release:()=>{clearInterval(d),BU(n,s)}}}function qU(e,n,t){try{let r=Wx(e);if(r===void 0||r.machineId!==n.machineId||r.runnerId!==n.runnerId||r.pid!==n.pid||r.startedAt!==n.startedAt)return;let o={...r,heartbeatAt:t().toISOString()},i=`${e}.tmp`;Ave(i,`${JSON.stringify(o,null,2)}
1473
+ `),K=F.map(Q=>Q.parsed.spooled_at).filter(Q=>typeof Q=="string"),G={bundle_name:"runner-diagnostics-spool",exported_at:new Date(a()).toISOString(),idempotency_key:A,diagnostics:{kind:"runner_diagnostic_log",spool_batch:!0,idempotency_key:A,entry_count:F.length,...K.length===0?{}:{first_spooled_at:K[0],last_spooled_at:K[K.length-1]}},telemetry_ndjson:B};try{await k(G)}catch(Q){l("runner_diagnostics.spool_upload_failed",{file:M,remaining_batches:p().length,error:Q instanceof Error?Q.message:String(Q)});break}R+=1,P+=F.length;try{PU(M)}catch(Q){l("runner_diagnostics.spool_delete_failed",{file:M,error:Q instanceof Error?Q.message:String(Q)})}}}catch(L){l("runner_diagnostics.spool_drain_failed",{error:L instanceof Error?L.message:String(L)})}finally{c=!1}return{uploadedBatches:R,uploadedEntries:P,remainingBatches:p().length,skipped:!1}},pendingBatchFiles:p}}var _ve,bve,wve,vve,Sve,IU,Cve,Tve,EU=z(()=>{"use strict";$y();_ve=32*1024*1024,bve=10080*60*1e3,wve=4*1024*1024,vve=1024*1024,Sve="spool.jsonl",IU=/^batch-(\d{1,16})-[0-9a-f-]+\.jsonl$/;Cve=/\b([A-Z][A-Z0-9_]{1,})=("[^"\n]*"|'[^'\n]*'|[^\s"'`;,)\]}]+)/g,Tve=/(?:\/(?:[A-Za-z0-9._@~+-]|%[0-9A-Fa-f]{2})+){2,}/g});var jt,AT,Ws=z(()=>{"use strict";jt="1.0.90",AT=`linzumi ${jt}`});import{closeSync as MU,existsSync as $T,mkdirSync as Dve,openSync as AU,readFileSync as OU,renameSync as Mve,unlinkSync as cd,writeFileSync as Ave,writeSync as $U}from"node:fs";import{dirname as jU,join as Ove}from"node:path";function jT(e){return e instanceof us||e instanceof Error&&e.name===zU&&"lockPath"in e&&"heldBy"in e}function LT(e,n=Kn(),t){let r=t===void 0?encodeURIComponent(e):rd(t);return Ove(jU(n),"runners",`${r}.lock`)}function Kx(e){let n=LT(e.machineId,e.configPath,e.linzumiUrl),t=e.isPidAlive??KU,r=e.now??(()=>new Date),o=e.killPid??Wve,i=r().toISOString(),s={version:1,machineId:e.machineId,runnerId:e.runnerId,pid:e.pid??process.pid,cwd:e.cwd,workspace:e.workspace,...e.linzumiUrl===void 0?{}:{linzumiUrl:zn(e.linzumiUrl)},...e.launchSource===void 0?{}:{launchSource:e.launchSource},startedAt:i,heartbeatAt:i,cliVersion:e.cliVersion??jt,...e.gracefulUpdateDrain===!0?{gracefulUpdateDrain:!0}:{}};zve(e.machineId,e.configPath,e.linzumiUrl,t,r,o,e.onTakeover),qve(n,s,t,r,o,e.onTakeover,e.beforeReadExistingLock,e.beforeReplaceStaleLock);let a=()=>qU(n,s,r),d=setInterval(a,LU);return d.unref(),{path:n,record:s,heartbeat:a,release:()=>{clearInterval(d),BU(n,s)}}}function qU(e,n,t){try{let r=Wx(e);if(r===void 0||r.machineId!==n.machineId||r.runnerId!==n.runnerId||r.pid!==n.pid||r.startedAt!==n.startedAt)return;let o={...r,heartbeatAt:t().toISOString()},i=`${e}.tmp`;Ave(i,`${JSON.stringify(o,null,2)}
1474
1474
  `,"utf8"),Mve(i,e)}catch{}}function ld(e,n,t){if(!n(e.pid))return{state:"dead"};let r=e.heartbeatAt??e.startedAt,o=Date.parse(r);return Number.isNaN(o)||t().getTime()-o>$ve?{state:"wedged",reason:e.heartbeatAt===void 0?"missing_heartbeat":"stale_heartbeat"}:{state:"live"}}function NU(e,n,t,r){return t!==Vx||e.launchSource!==Vx?!1:r.state!=="live"?!0:jve(n,e.cliVersion)>0}function jve(e,n){let t=DU(e),r=DU(n);for(let o=0;o<3;o++){let i=t.core[o],s=r.core[o];if(i===void 0||s===void 0)throw new Error(`compareCliVersions: unexpected undefined at core index ${o}`);if(i!==s)return i>s?1:-1}return t.prerelease.length===0&&r.prerelease.length===0?0:t.prerelease.length===0?1:r.prerelease.length===0?-1:Lve(t.prerelease,r.prerelease)}function DU(e){let n=e.trim().replace(/^v/i,"").split("+",1)[0]??"",t=n.indexOf("-"),r=t===-1?n:n.slice(0,t),o=t===-1?"":n.slice(t+1),i=r.split("."),s=[OT(i[0]),OT(i[1]),OT(i[2])],a=o===""?[]:o.split(".").filter(d=>d!=="");return{core:s,prerelease:a}}function OT(e){let n=Number.parseInt(e??"",10);return Number.isInteger(n)&&n>=0?n:0}function Lve(e,n){let t=Math.max(e.length,n.length);for(let r=0;r<t;r++){let o=e[r],i=n[r];if(o===void 0)return-1;if(i===void 0)return 1;let s=Number.parseInt(o,10),a=Number.parseInt(i,10),d=s.toString()===o,u=a.toString()===i;if(d&&u){if(s!==a)return s>a?1:-1}else{if(d!==u)return d?-1:1;if(o!==i)return o>i?1:-1}}return 0}function FU(e,n,t,r,o){try{r(n.pid)}catch(i){let s=i!==null&&typeof i=="object"&&"code"in i?i.code:void 0;if(s!=="ESRCH")throw s==="EPERM"||s==="EACCES"?new Error(`Cannot take over the runner lock at ${e}: its holder process (pid ${n.pid}) is owned by another user and cannot be stopped. Stop that process, or remove the lock file, then retry.`):i}o?.({lockPath:e,holderPid:n.pid,holderRunnerId:n.runnerId,reason:t})}function zve(e,n,t,r,o,i,s){if(t===void 0||gr(t)!==gr(Ut))return;let a=LT(e,n),d=Wx(a);if(d!==void 0){if(ld(d,r,o).state==="live")throw new us(a,d);UU(a,r,()=>{let u=$T(a)?zT(a):void 0;if(u===void 0)return;let c=ld(u,r,o);if(c.state==="live")throw new us(a,u);c.state==="wedged"&&FU(a,u,c.reason,i,s),cd(a)})}}function qve(e,n,t,r,o,i,s,a){if(Jx(e,n))return;s?.();let d=Wx(e);if(d===void 0){if(Jx(e,n))return;throw new Error(`another Linzumi runner lock appeared while starting: ${e}`)}if(ld(d,t,r).state==="live")throw new us(e,d);a?.(),UU(e,t,()=>{let u=$T(e)?zT(e):void 0;if(u!==void 0){let c=ld(u,t,r);if(c.state==="live")throw new us(e,u);c.state==="wedged"&&FU(e,u,c.reason,o,i),cd(e)}if(!Jx(e,n))throw new Error(`another Linzumi runner lock appeared while starting: ${e}`)})}function Jx(e,n){Dve(jU(e),{recursive:!0});try{let t=AU(e,"wx");try{$U(t,`${JSON.stringify(n,null,2)}
1475
1475
  `)}finally{MU(t)}return!0}catch(t){if(Dc(t,"EEXIST"))return!1;throw t}}function UU(e,n,t){let r=`${e}.replace`;for(;;)try{let o=AU(r,"wx");try{$U(o,`${process.pid}
1476
1476
  `),t()}finally{MU(o),cd(r)}return}catch(o){if(Dc(o,"EEXIST")){let i=Nve(r);if(i===void 0)continue;if(n(i))throw new Error(["another Linzumi runner is already replacing a stale runner lock",`lock: ${e}`,"Wait for that startup to finish, then retry."].join(`
@@ -1709,7 +1709,7 @@ Subcommands:
1709
1709
  `),0):(process.stderr.write(`${i.error}
1710
1710
  `),1)}default:return process.stderr.write(`Unknown dev-services subcommand: ${n}
1711
1711
 
1712
- ${Tee()}`),2}}var Eee=z(()=>{"use strict";Jp()});var Mee,Dee=z(()=>{Mee={note:"Single source of truth for the linzumi CLI's compute-node GraphQL documents (compute-nodes program M1.5/M1.8). Consumed by BOTH computeNodeCli.ts (the CLI sends documentId + operationName + variables; text is the free-form fallback for servers with persisted-document enforcement disabled) AND kandan/server_v2/web/scripts/generate_persisted_documents.mjs (which registers these in the server's persisted-document manifest - prod enforces persisted documents on /api/v2/graphql, so unregistered documents are FREE_FORM_GRAPHQL_FORBIDDEN there). documentId = base64url(first 8 bytes of sha256(text)) - the generator recomputes it and hard-errors on drift; test/computeNodeCli.test.ts pins it CLI-side.",documents:[{operationName:"ComputeNodeCliList",operationKind:"query",documentId:"ACIiSxoZstI",text:`query ComputeNodeCliList($after: String) {
1712
+ ${Tee()}`),2}}var Eee=z(()=>{"use strict";Jp()});var Mee,Dee=z(()=>{Mee={note:"Single source of truth for the linzumi CLI's compute-node GraphQL documents (compute-nodes program M1.5/M1.8). Consumed by computeNodeCli.ts (the CLI sends documentId + operationName + variables; text is the free-form fallback for servers with persisted-document enforcement disabled), by scripts/compute-node-reset-e2e.mjs (the ComputeNodeCliRenamePropagation subscription backing the P5 rename-propagation leg), AND by kandan/server_v2/web/scripts/generate_persisted_documents.mjs (which registers these in the server's persisted-document manifest - prod enforces persisted documents on /api/v2/graphql and on the __absinthe__ socket doc path, so unregistered documents are FREE_FORM_GRAPHQL_FORBIDDEN there). documentId = base64url(first 8 bytes of sha256(text)) - the generator recomputes it and hard-errors on drift; test/computeNodeCli.test.ts pins it CLI-side.",documents:[{operationName:"ComputeNodeCliList",operationKind:"query",documentId:"ACIiSxoZstI",text:`query ComputeNodeCliList($after: String) {
1713
1713
  viewer {
1714
1714
  computeNodes(first: 100, after: $after) {
1715
1715
  edges {
@@ -1742,6 +1742,12 @@ ${Tee()}`),2}}var Eee=z(()=>{"use strict";Jp()});var Mee,Dee=z(()=>{Mee={note:"S
1742
1742
  ... on ComputeNodeNotOwnerError { message nodeId }
1743
1743
  ... on ComputeNodeInvalidNameError { message }
1744
1744
  }
1745
+ }`},{operationName:"ComputeNodeCliRenamePropagation",operationKind:"subscription",documentId:"tHnTnXwp5Nc",text:`subscription ComputeNodeCliRenamePropagation {
1746
+ viewerComputeNodesUpdated {
1747
+ id
1748
+ name
1749
+ resetGeneration
1750
+ }
1745
1751
  }`}]}});var Jee={};Jo(Jee,{buildRenameInput:()=>Fee,buildResetInput:()=>Nee,nodeHelpText:()=>Lee,parseNodeArgs:()=>zee,persistedDocumentId:()=>o9e,resolveTargetNode:()=>_A,runNodeCommand:()=>g9e,validateNodeName:()=>qee});import{createHash as t9e}from"node:crypto";import{hostname as $ee}from"node:os";import{createInterface as r9e}from"node:readline";function o9e(e){return t9e("sha256").update(e,"utf8").digest().subarray(0,8).toString("base64url")}function jee(e){let n=Mee.documents.find(t=>t.operationName===e);if(n===void 0)throw new Error(`unknown compute-node CLI document: ${e}`);return n}function Lee(){return`Usage: linzumi node <subcommand> [options]
1746
1752
 
1747
1753
  Manage this account's compute nodes (one node per (machine, OS user)).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linzumi/cli",
3
- "version": "1.0.88",
3
+ "version": "1.0.90",
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 });