@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 +1 -1
- package/dist/index.js +8 -2
- package/package.json +1 -1
- package/scripts/compute-node-reset-e2e.mjs +260 -1
package/README.md
CHANGED
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.
|
|
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
|
|
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
|
@@ -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 });
|