@milpa/comal 0.2.1 → 0.2.3
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/backend/contracts/remote-commands-v1.json +0 -12
- package/backend/contracts/remote-relay-answers-v1.json +209 -0
- package/package.json +3 -1
- package/skills/mv3/SKILL.md +44 -0
- package/skills/mv3/skill.json +4 -0
- package/src/agent.mjs +299 -32
- package/src/auto-guard.mjs +40 -3
- package/src/autocompact.mjs +233 -0
- package/src/bash-timeouts.mjs +29 -0
- package/src/child-env.mjs +23 -0
- package/src/cli.mjs +232 -63
- package/src/code-run-artifact.mjs +30 -0
- package/src/code-run-journal.mjs +135 -0
- package/src/code-run-session-proof.mjs +50 -0
- package/src/code-run-session.mjs +97 -0
- package/src/code-run-worker.mjs +20 -4
- package/src/code-run.mjs +49 -14
- package/src/codex-provider.mjs +4 -4
- package/src/compaction-notice.mjs +22 -0
- package/src/compaction.mjs +162 -23
- package/src/connection-settings.mjs +36 -4
- package/src/context-pressure.mjs +5 -4
- package/src/context.mjs +27 -22
- package/src/execution-metrics.mjs +36 -5
- package/src/file-recovery-proof.mjs +9 -1
- package/src/file-recovery.mjs +36 -3
- package/src/gemini-provider.mjs +5 -3
- package/src/git-integration-operations.mjs +2 -1
- package/src/git-plumbing.mjs +2 -1
- package/src/git-source.mjs +2 -1
- package/src/git-workspaces.mjs +2 -1
- package/src/house.mjs +13 -1
- package/src/i18n/cli-es.mjs +12 -1
- package/src/i18n/es.mjs +133 -2
- package/src/judges.mjs +2 -1
- package/src/mcp.mjs +2 -1
- package/src/model-controls.mjs +16 -2
- package/src/model-deadline.mjs +2 -2
- package/src/openai-provider.mjs +8 -4
- package/src/operations.mjs +17 -2
- package/src/package-identity.mjs +68 -0
- package/src/peer-probe.mjs +16 -0
- package/src/permissions.mjs +99 -6
- package/src/plugin-catalog.mjs +19 -4
- package/src/plugin-config.mjs +18 -1
- package/src/plugin-controls.mjs +31 -8
- package/src/plugin-trust.mjs +15 -6
- package/src/plugins/chrome-lab/bridge.mjs +19 -6
- package/src/plugins/chrome-lab/client.mjs +242 -12
- package/src/plugins/chrome-lab/index.mjs +106 -24
- package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
- package/src/plugins/chrome-lab/native-reply.mjs +31 -0
- package/src/plugins/omarchy/catalog.mjs +96 -0
- package/src/plugins/omarchy/environment.mjs +78 -0
- package/src/plugins/omarchy/guide.mjs +54 -0
- package/src/plugins/omarchy/index.mjs +213 -0
- package/src/plugins/omarchy/runner.mjs +62 -0
- package/src/plugins/omarchy/text.mjs +26 -0
- package/src/plugins/omarchy/tiers.mjs +157 -0
- package/src/plugins/open-relay/actions.mjs +212 -16
- package/src/plugins/open-relay/client.mjs +93 -35
- package/src/plugins/open-relay/decision.mjs +89 -0
- package/src/plugins/open-relay/dossier.mjs +23 -5
- package/src/plugins/open-relay/index.mjs +68 -28
- package/src/plugins/open-relay/lookup.mjs +41 -4
- package/src/plugins/open-relay/notices.mjs +48 -4
- package/src/plugins/open-relay/panel.mjs +59 -0
- package/src/plugins/open-relay/watch.mjs +198 -85
- package/src/plugins.mjs +12 -1
- package/src/process-lock.mjs +20 -12
- package/src/provider-auth.mjs +6 -3
- package/src/provider-profiles.mjs +13 -3
- package/src/provider.mjs +175 -30
- package/src/read-only-check.mjs +12 -4
- package/src/relay/broker.mjs +160 -0
- package/src/relay/cards.mjs +702 -0
- package/src/relay/flow.mjs +886 -0
- package/src/relay/follow.mjs +119 -0
- package/src/relay/git.mjs +23 -0
- package/src/relay/hub-client.mjs +163 -0
- package/src/relay/state.mjs +55 -0
- package/src/remote-control.mjs +115 -12
- package/src/remote-relay.mjs +468 -0
- package/src/session-bus.mjs +13 -3
- package/src/session-provider-settings.mjs +2 -2
- package/src/skill-runtime.mjs +8 -6
- package/src/source-ownership.mjs +3 -1
- package/src/swarm-view.mjs +53 -0
- package/src/tool-manifest.mjs +71 -0
- package/src/tool-packs.mjs +22 -6
- package/src/tool-schema.mjs +14 -2
- package/src/tools.mjs +96 -27
- package/src/tui/connection-transition.mjs +10 -0
- package/src/tui/controller.mjs +35 -9
- package/src/tui/discovery.mjs +13 -8
- package/src/tui/inbound-blocks.mjs +5 -1
- package/src/tui/screen.mjs +78 -15
- package/src/tui/swarm-panel.mjs +217 -0
- package/src/turn-timing.mjs +82 -0
- package/src/usage-accounting.mjs +12 -1
- package/src/vision.mjs +6 -1
- package/src/visual/chrome.mjs +379 -0
- package/src/visual/fingerprint.mjs +30 -0
- package/src/visual/fixtures.mjs +201 -0
- package/src/visual/gate.mjs +92 -0
- package/src/visual/judge.mjs +114 -0
- package/src/visual/ledger.mjs +160 -0
- package/src/visual/measure.mjs +145 -0
- package/src/visual/names.mjs +7 -0
- package/src/visual/operations.mjs +211 -0
- package/src/visual/pixel-diff.mjs +96 -0
- package/src/visual/png.mjs +103 -0
- package/src/visual/registry.mjs +265 -0
- package/src/visual/report.mjs +64 -0
- package/src/visual/verify.mjs +292 -0
- package/src/work-assertions.mjs +15 -4
- package/src/work-plan.mjs +166 -30
- package/src/work-state.mjs +4 -4
- package/src/workflows/chrome-task-adoption.mjs +7 -3
- package/src/workspace-serve.mjs +59 -0
|
@@ -22,14 +22,14 @@ export async function remoteBridge(config, lines) {
|
|
|
22
22
|
if (!valid) throw new Error('Invalid private attach diagnostic arguments.');
|
|
23
23
|
return option;
|
|
24
24
|
};
|
|
25
|
-
const cdp = (method, params = {}, sessionId, diagnostic) => new Promise((resolve, reject) => {
|
|
25
|
+
const cdp = (method, params = {}, sessionId, diagnostic, replyOwner) => new Promise((resolve, reject) => {
|
|
26
26
|
const id = ++sequence, started = performance.now();
|
|
27
27
|
const outcome = (state,reason,error) => ({diagnostic:{version:1,method,session_id:sessionId,state,reason,remote_dispatched:true,timeout_ms:diagnostic.timeout_ms,elapsed_ms:performance.now()-started,...(error?{error}: {})}});
|
|
28
28
|
const timer = setTimeout(() => {
|
|
29
29
|
pending.delete(id);
|
|
30
30
|
diagnostic ? resolve(outcome('unknown','command_timeout')) : reject(new Error(`CDP timeout: ${method}; no se reintentó.`));
|
|
31
31
|
}, diagnostic?.timeout_ms ?? config.timeoutMs);
|
|
32
|
-
pending.set(id, { resolve, reject, timer, method, params, diagnostic, outcome });
|
|
32
|
+
pending.set(id, { resolve, reject, timer, method, params, sessionId, diagnostic, outcome, replyOwner });
|
|
33
33
|
try { socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }
|
|
34
34
|
catch(error) { pending.delete(id); clearTimeout(timer); reject(error); }
|
|
35
35
|
});
|
|
@@ -60,6 +60,19 @@ export async function remoteBridge(config, lines) {
|
|
|
60
60
|
await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, { once: true }); socket.addEventListener('error', () => reject(new Error('No se pudo abrir WebSocket CDP.')), { once: true }); });
|
|
61
61
|
socket.addEventListener('message', event => {
|
|
62
62
|
const message = JSON.parse(event.data);
|
|
63
|
+
const matched = pending.get(message.id);
|
|
64
|
+
// Only the actual matched browser response can populate this lexical owner.
|
|
65
|
+
// Caller-shaped Errors, optional diagnostics and internal bridge commands
|
|
66
|
+
// never supply native reply provenance. Keep the untruncated raw response.
|
|
67
|
+
if (matched?.replyOwner && ['Page.bringToFront','DOM.focus'].includes(matched.method) &&
|
|
68
|
+
typeof event.data === 'string' && Buffer.byteLength(event.data) <= 8192) {
|
|
69
|
+
matched.replyOwner.nativeReply = {format:'comal-cdp-native-reply/1',
|
|
70
|
+
outer_request_id:matched.replyOwner.outerId, cdp_request_id:message.id,
|
|
71
|
+
method:matched.method, session_id:matched.sessionId,
|
|
72
|
+
params_sha256:digest(JSON.stringify(matched.params)),
|
|
73
|
+
browser_endpoint_id:/^\/devtools\/browser\/([^/]+)$/.exec(endpoint.pathname)?.[1] || null,
|
|
74
|
+
remote_dispatched:true, reply_json:event.data};
|
|
75
|
+
}
|
|
63
76
|
if (message.id) { const entry = pending.get(message.id); if (!entry) return; pending.delete(message.id); clearTimeout(entry.timer); if (!message.error) { if (entry.method === 'Target.attachToTarget' && message.result?.sessionId) { attached.add(message.result.sessionId); if(entry.params.flatten === true) flattened.add(message.result.sessionId); } if (entry.method === 'Target.detachFromTarget') { attached.delete(entry.params.sessionId); flattened.delete(entry.params.sessionId); } } if(entry.diagnostic) { if(message.error && (!Number.isInteger(message.error.code) || typeof message.error.message !== 'string')) entry.reject(new Error('Malformed diagnostic CDP error.')); else entry.resolve(message.error ? entry.outcome('rejected','command_error',{code:message.error.code,message:message.error.message.slice(0,1000)}) : entry.outcome('enabled','command_reply')); } else message.error ? entry.reject(Object.assign(new Error(`${message.error.code}: ${message.error.message}`), { cdpError: { method: entry.method, code: message.error.code, message: String(message.error.message).slice(0, 1000) } })) : entry.resolve(message.result); }
|
|
64
77
|
else if (['Runtime.consoleAPICalled', 'Runtime.exceptionThrown', 'Runtime.executionContextCreated', 'Runtime.executionContextDestroyed', 'Runtime.executionContextsCleared', 'Log.entryAdded', 'Network.loadingFailed', 'Inspector.detached', 'Target.detachedFromTarget'].includes(message.method)) {
|
|
65
78
|
if(message.method === 'Target.detachedFromTarget') { attached.delete(message.params?.sessionId); flattened.delete(message.params?.sessionId); }
|
|
@@ -204,12 +217,12 @@ export async function remoteBridge(config, lines) {
|
|
|
204
217
|
return { url: `http://127.0.0.1:${server.address().port}/${file.split('/').map(encodeURIComponent).join('/')}`, lifetime: 'plugin_connection' };
|
|
205
218
|
};
|
|
206
219
|
lines.on('line', async line => {
|
|
207
|
-
let request;
|
|
220
|
+
let request, replyOwner;
|
|
208
221
|
try {
|
|
209
222
|
if (line.length > config.maxDeployBytes * 1.5 + 1000000) throw new Error('Mensaje demasiado grande.');
|
|
210
223
|
request = JSON.parse(line); let result;
|
|
211
224
|
const diagnostic = Object.hasOwn(request,'attachDiagnostic') ? diagnosticPolicy(request) : undefined;
|
|
212
|
-
if (request.action === 'cdp') result = await cdp(request.method, request.params, request.sessionId, diagnostic);
|
|
225
|
+
if (request.action === 'cdp') { replyOwner={outerId:request.id};result = await cdp(request.method, request.params, request.sessionId, diagnostic, replyOwner); }
|
|
213
226
|
else if (request.action === 'status') result = await ownExtensions();
|
|
214
227
|
else if (request.action === 'checkTarget') result = await checkTarget(request.params.targetId);
|
|
215
228
|
else if (request.action === 'open') result = await cdp('Target.createTarget', { url: await checkURL(request.params.url) });
|
|
@@ -223,10 +236,10 @@ export async function remoteBridge(config, lines) {
|
|
|
223
236
|
else if (request.action === 'preview') result = await preview(request.params);
|
|
224
237
|
else if (request.action === 'unload') { const extension = await extensionFor(request.params.project); await cdp('Extensions.uninstall', { id: extension.id }); result = { extension_id: extension.id, unloaded: true }; }
|
|
225
238
|
else throw new Error('Acción del bridge desconocida.');
|
|
226
|
-
const output = JSON.stringify({ id: request.id, result });
|
|
239
|
+
const output = JSON.stringify({ id: request.id, result, ...(replyOwner?.nativeReply ? {native_reply:replyOwner.nativeReply} : {}) });
|
|
227
240
|
if (output.length > 12000000) throw new Error('Respuesta excesiva; usa consultas acotadas.');
|
|
228
241
|
process.stdout.write(output + '\n');
|
|
229
|
-
} catch (error) { send({ id: request?.id, error: error.message, ...(error.cdpError ? { cdp_error: error.cdpError } : {}) }); }
|
|
242
|
+
} catch (error) { send({ id: request?.id, error: error.message, ...(error.cdpError ? { cdp_error: error.cdpError } : {}), ...(replyOwner?.nativeReply ? {native_reply:replyOwner.nativeReply} : {}) }); }
|
|
230
243
|
});
|
|
231
244
|
send({ ready: { browser: version.Browser, browser_endpoint_id: /^\/devtools\/browser\/([^/]+)$/.exec(endpoint.pathname)?.[1] || null, protocol_version: version['Protocol-Version'], extensions: protocol.domains.find(domain => domain.domain === 'Extensions')?.commands.map(command => command.name) || [], remote_root: root, node: process.version } });
|
|
232
245
|
}
|
|
@@ -1,15 +1,135 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { createInterface } from 'node:readline';
|
|
3
|
-
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
4
|
+
import { performance } from 'node:perf_hooks';
|
|
5
|
+
import {AsyncLocalStorage} from 'node:async_hooks';
|
|
4
6
|
import { remoteBridge } from './bridge.mjs';
|
|
5
7
|
import { OPTIONAL_PHASE_MS, PUBLIC_DIAGNOSTIC_BYTES, diagnosticEnvelope } from './diagnostics.mjs';
|
|
8
|
+
import { parseNativeReply } from './native-reply.mjs';
|
|
9
|
+
import {legacyFill,takeLegacyFillOrigin} from './legacy-fill.mjs';
|
|
10
|
+
const nativeSignalAborted=Object.getOwnPropertyDescriptor(AbortSignal.prototype,'aborted').get;
|
|
11
|
+
const originalSignalState=signal=>{if(signal===undefined)return false;try{return nativeSignalAborted.call(signal);}catch{return null;}};
|
|
6
12
|
|
|
7
13
|
export class ChromeLabClient {
|
|
14
|
+
#nativeReplies = new WeakMap();
|
|
15
|
+
#pendingReplies = new Map();
|
|
16
|
+
#replyConnectionGeneration = 0;
|
|
17
|
+
#replySessionGenerations = new Map();
|
|
18
|
+
#requestSequence = 0;
|
|
19
|
+
#replyAttachments = new Map();
|
|
20
|
+
#recentRequests = new Map();
|
|
21
|
+
#fillCompletions = new WeakMap();
|
|
22
|
+
#fillObservations = new WeakMap();
|
|
23
|
+
#fillOrder = 0;
|
|
24
|
+
#fillOwner = new AsyncLocalStorage();
|
|
25
|
+
async fillLegacy(input,options) {
|
|
26
|
+
const originalOptions=Object.freeze({signal:options.signal,sessionId:options.sessionId});
|
|
27
|
+
const frame=Object.freeze({owner:Object.freeze({}),start:this.#requestSequence,order:++this.#fillOrder,signal:originalOptions.signal});
|
|
28
|
+
return this.#fillOwner.run(frame,async()=>{
|
|
29
|
+
const result=await legacyFill(this,input,originalOptions),origin=takeLegacyFillOrigin(result);
|
|
30
|
+
if(origin)this.#recordFillCompletion(result,origin,frame);
|
|
31
|
+
return result;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
#recordFillCompletion(result,origin,frame) {
|
|
35
|
+
if(origin.client!==this || typeof origin.input.selector!=='string'||origin.options.signal!==frame.signal||originalSignalState(frame.signal)!==false)return;
|
|
36
|
+
const a=this.#nativeReplies.get(origin.activation),f=this.#nativeReplies.get(origin.error);
|
|
37
|
+
const activation=this.nativeReplyProvenance(origin.activation),focus=this.nativeReplyProvenance(origin.error);
|
|
38
|
+
if(!a||!f||!activation||!focus||activation.method!=='Page.bringToFront'||activation.outcome!=='native_success_reply'||
|
|
39
|
+
focus.method!=='DOM.focus'||focus.outcome!=='native_error_reply')return;
|
|
40
|
+
const error=JSON.parse(focus.reply_json).error;
|
|
41
|
+
if(error.code!==-32000||error.message!=='Element is not focusable')return;
|
|
42
|
+
const first=a.capture.guard.id,last=f.capture.guard.id;
|
|
43
|
+
if(first!==frame.start+1||last!==first+3||last!==this.#requestSequence)return;
|
|
44
|
+
const captures=Array.from({length:4},(_,i)=>this.#recentRequests.get(first+i));
|
|
45
|
+
const methods=['Page.bringToFront','DOM.getDocument','DOM.querySelector','DOM.focus'];
|
|
46
|
+
if(captures.some((c,i)=>!c||c.guard.fillOwner!==frame.owner||c.guard.method!==methods[i]||c.guard.action!=='cdp'||!c.state.settled||!c.state.observedReply||c.state.nativeFailed!==(i===3)||
|
|
47
|
+
c.state.writeState!=='acknowledged'||c.state.failed!==(i===3)||
|
|
48
|
+
originalSignalState(c.guard.signal)!==false||c.guard.sessionId!==origin.options.sessionId||
|
|
49
|
+
!c.guard.binding||Object.keys(c.guard.binding).some(k=>c.guard.binding[k]!==a.capture.guard.binding[k])))return;
|
|
50
|
+
if(captures[0]!==a.capture||captures[3]!==f.capture||activation.target_id!==origin.input.target_id||focus.target_id!==origin.input.target_id)return;
|
|
51
|
+
const root=captures[1].state.nodeId,node=captures[2].state.nodeId;
|
|
52
|
+
if(!Number.isSafeInteger(root)||root<1||!Number.isSafeInteger(node)||node<1)return;
|
|
53
|
+
const params=[{}, {depth:0}, {nodeId:root,selector:origin.input.selector}, {nodeId:node}];
|
|
54
|
+
if(captures.some((c,i)=>c.guard.paramsSha256!==createHash('sha256').update(JSON.stringify(params[i])).digest('hex')))return;
|
|
55
|
+
Object.freeze(result.observation.cdp_error);Object.freeze(result.observation);Object.freeze(result);
|
|
56
|
+
this.#fillCompletions.set(result,{origin,captures,last,frame});
|
|
57
|
+
}
|
|
58
|
+
beginLegacyFillObservation(signal) {
|
|
59
|
+
if(signal===undefined||originalSignalState(signal)!==false)throw new Error('A live original handler signal is required for input observation.');
|
|
60
|
+
const token=Object.freeze({});this.#fillObservations.set(token,Object.freeze({signal,start:this.#requestSequence,order:++this.#fillOrder}));return token;
|
|
61
|
+
}
|
|
62
|
+
closeLegacyFillObservation(token){this.#fillObservations.delete(token);}
|
|
63
|
+
takeLegacyFillCompletion(result,observation) {
|
|
64
|
+
let boundary=null;
|
|
65
|
+
if(arguments.length>1){boundary=this.#fillObservations.get(observation);this.#fillObservations.delete(observation);if(!boundary||originalSignalState(boundary.signal)!==false)return null;}
|
|
66
|
+
const record=result&&typeof result==='object'?this.#fillCompletions.get(result):null;
|
|
67
|
+
if(!record)return null;this.#fillCompletions.delete(result);
|
|
68
|
+
const {origin,captures,last,frame}=record,activation=this.nativeReplyProvenance(origin.activation),focus=this.nativeReplyProvenance(origin.error);
|
|
69
|
+
if(boundary&&(frame.signal!==boundary.signal||frame.start<boundary.start||frame.order<=boundary.order))return null;
|
|
70
|
+
if(originalSignalState(frame.signal)!==false||last!==this.#requestSequence||!activation||!focus||captures.some(c=>c.state.writeState!=='acknowledged'||
|
|
71
|
+
originalSignalState(c.guard.signal)!==false||performance.now()>=c.guard.requestDeadline))return null;
|
|
72
|
+
return Object.freeze({format:'comal-legacy-fill-completion/1',outcome:'completed_blocked_before_keyboard',
|
|
73
|
+
target_id:origin.input.target_id,selector:origin.input.selector,
|
|
74
|
+
request_ids:Object.freeze(captures.map(c=>c.guard.id)),activation,focus,input_commands_started:0,
|
|
75
|
+
original_handler_signal_bound:frame.signal!==undefined,
|
|
76
|
+
native_boundary_bound:boundary!==null,
|
|
77
|
+
authority:false,behavior_acceptance:false,retry_authorized:false,remote_nonexecution_proved:false,phase_lifetime_bound:false});
|
|
78
|
+
}
|
|
79
|
+
#replyBinding(sessionId) {
|
|
80
|
+
const targets = [...this.sessions].filter(([, id]) => id === sessionId);
|
|
81
|
+
if (this.closed || this.failure || !this.connectionId || !this.info?.browser_endpoint_id ||
|
|
82
|
+
typeof sessionId !== 'string' || !sessionId || targets.length !== 1) return null;
|
|
83
|
+
const attached=this.#replyAttachments.get(sessionId);
|
|
84
|
+
if(!attached || attached.targetId!==targets[0][0] || attached.connectionId!==this.connectionId ||
|
|
85
|
+
attached.browserEndpointId!==this.info.browser_endpoint_id || attached.generation!==this.#replyConnectionGeneration)return null;
|
|
86
|
+
if(!this.#replySessionGenerations.has(sessionId))this.#replySessionGenerations.set(sessionId,Object.freeze({}));
|
|
87
|
+
return {connectionId:this.connectionId, browserEndpointId:this.info.browser_endpoint_id,
|
|
88
|
+
sessionId, targetId:targets[0][0], contextEpoch:this.collections.get(sessionId)?.epoch ?? null,
|
|
89
|
+
connectionGeneration:this.#replyConnectionGeneration,sessionGeneration:this.#replySessionGenerations.get(sessionId)};
|
|
90
|
+
}
|
|
91
|
+
#recordNativeReply(message, entry, error, result) {
|
|
92
|
+
const capture=this.#pendingReplies.get(message.id),guard=capture?.guard;
|
|
93
|
+
if (!guard || guard.id!==message.id || capture.entry!==entry || capture.state.settled || guard.action !== 'cdp')return;
|
|
94
|
+
capture.state.observedReply=true;capture.state.nativeFailed=Boolean(error);
|
|
95
|
+
capture.state.nodeId=guard.method==='DOM.getDocument'?result?.root?.nodeId:guard.method==='DOM.querySelector'?result?.nodeId:null;
|
|
96
|
+
const current=guard.connectionId===this.connectionId && guard.browserEndpointId===this.info?.browser_endpoint_id && guard.connectionGeneration===this.#replyConnectionGeneration;
|
|
97
|
+
if(current && !error && guard.method==='Target.attachToTarget' && guard.attachTargetId &&
|
|
98
|
+
result && Object.keys(result).join()==='sessionId' && typeof result.sessionId==='string' && result.sessionId) {
|
|
99
|
+
this.#replySessionGenerations.delete(result.sessionId);
|
|
100
|
+
this.#replyAttachments.set(result.sessionId,this.#replyAttachments.has(result.sessionId)?null:Object.freeze({targetId:guard.attachTargetId,
|
|
101
|
+
connectionId:guard.connectionId,browserEndpointId:guard.browserEndpointId,generation:guard.connectionGeneration}));
|
|
102
|
+
}
|
|
103
|
+
if(current && !error && guard.method==='Target.detachFromTarget'){this.#replyAttachments.delete(guard.detachSessionId);this.#replySessionGenerations.delete(guard.detachSessionId);}
|
|
104
|
+
if (!guard.binding ||
|
|
105
|
+
!Number.isFinite(guard.requestDeadline) || !Number.isSafeInteger(guard.requestBudgetMs) ||
|
|
106
|
+
guard.requestBudgetMs<1 || guard.requestBudgetMs>2147483647) return;
|
|
107
|
+
const proof = parseNativeReply(message.native_reply, {id:guard.id, method:guard.method,
|
|
108
|
+
sessionId:guard.sessionId, paramsSha256:guard.paramsSha256,
|
|
109
|
+
browserEndpointId:guard.binding.browserEndpointId, error, cdpError:error?.cdpError, result});
|
|
110
|
+
const value = error || result;
|
|
111
|
+
if (proof && value && typeof value === 'object') this.#nativeReplies.set(value, {capture, proof});
|
|
112
|
+
}
|
|
113
|
+
nativeReplyProvenance(value) {
|
|
114
|
+
const record = value && typeof value === 'object' ? this.#nativeReplies.get(value) : null;
|
|
115
|
+
if (!record) return null;
|
|
116
|
+
const {capture, proof} = record,{guard,state}=capture,binding = this.#replyBinding(guard.sessionId);
|
|
117
|
+
if (state.writeState !== 'acknowledged' || !state.settled || originalSignalState(guard.signal)!==false ||
|
|
118
|
+
performance.now() >= guard.requestDeadline || !binding ||
|
|
119
|
+
Object.keys(binding).some(k => binding[k] !== guard.binding[k])) return null;
|
|
120
|
+
// This is an observation of this original object, never a live host-phase
|
|
121
|
+
// capability. The admitting House invocation/deadline must be bound separately.
|
|
122
|
+
return Object.freeze({...proof, target_id:binding.targetId, connection_id:binding.connectionId,
|
|
123
|
+
original_signal_bound:guard.signal!==undefined});
|
|
124
|
+
}
|
|
8
125
|
constructor(config, { spawnProcess = spawn } = {}) {
|
|
9
126
|
this.config = config; this.spawnProcess = spawnProcess; this.pending = new Map(); this.sessions = new Map(); this.attaching = new Map(); this.targetInfo = new Map(); this.contexts = new Map(); this.collections = new Map(); this.events = []; this.sequence = 0; this.closed = false; this.attachDiagnostics = new Map();
|
|
127
|
+
// Ownership recorded by this connection: only these are cleaned up in relay mode.
|
|
128
|
+
this.openedTargets = new Set(); this.deployedProjects = new Set(); this.lastCleanup = null;
|
|
10
129
|
}
|
|
11
130
|
async connect(signal) {
|
|
12
131
|
signal?.throwIfAborted(); if (this.closed) throw new Error('Plugin Chrome Lab cerrado.');
|
|
132
|
+
if (this.preempted() && !this.pending.size && !this.attaching.size && !this.reconnecting) this.forgetPreemptedTransport();
|
|
13
133
|
if (!this.connection) this.connection = this.start();
|
|
14
134
|
if (!signal) return this.connection;
|
|
15
135
|
return new Promise((resolve, reject) => {
|
|
@@ -19,17 +139,31 @@ export class ChromeLabClient {
|
|
|
19
139
|
if (signal.aborted) abort();
|
|
20
140
|
});
|
|
21
141
|
}
|
|
142
|
+
// Relay mode: relayd cut the SSH bridge because another attempt or check was waiting for the
|
|
143
|
+
// host (it says so on stderr) and closed this attempt's tabs. That is not an uncertain
|
|
144
|
+
// transport: whatever was pending already failed, so the next operation is new and may open
|
|
145
|
+
// another connection, which waits for its turn in relayd's lease queue. Nothing is replayed.
|
|
146
|
+
preempted() {
|
|
147
|
+
return Boolean(this.failure) && this.failureReason === 'connection_lost' && Boolean(process.env.RELAY_REMOTE_NAMESPACE) && /relay-ssh: LEASE_EXPIRED/.test(this.transportStderr?.() || '');
|
|
148
|
+
}
|
|
149
|
+
forgetPreemptedTransport() {
|
|
150
|
+
for (const record of this.attachDiagnostics.values()) this.retireDiagnostic(record.session_id, 'lease_preempted');
|
|
151
|
+
this.child = undefined; this.connection = undefined; this.failure = undefined; this.failureReason = undefined; this.info = undefined; this.transportStderr = undefined;
|
|
152
|
+
this.attachDiagnostics.clear(); this.sessions.clear(); this.contexts.clear(); this.collections.clear(); this.targetInfo.clear(); this.events = []; this.openedTargets.clear();
|
|
153
|
+
}
|
|
22
154
|
start() {
|
|
155
|
+
this.#replyConnectionGeneration++;
|
|
23
156
|
this.connectionId = randomUUID();
|
|
24
157
|
return new Promise((resolve, reject) => {
|
|
25
158
|
let ready = false, stderr = '';
|
|
159
|
+
this.transportStderr = () => stderr;
|
|
26
160
|
const bootstrap = "const r=require('readline').createInterface({input:process.stdin});r.once('line',l=>{const p=JSON.parse(l);eval(p.source)(p.config,r).catch(e=>{console.log(JSON.stringify({fatal:e.message}));process.exit(1)})})";
|
|
27
161
|
const child = this.child = this.spawnProcess('ssh', ['-T', '-o', 'BatchMode=yes', '-o', 'ClearAllForwardings=yes', '-o', 'ConnectTimeout=10', '-o', 'ServerAliveInterval=10', '-o', 'ServerAliveCountMax=2', this.config.sshHost, `node -e "${bootstrap}"`], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
28
162
|
const timer = setTimeout(() => { fail(new Error('Timeout iniciando Chrome Lab por SSH.')); child.kill(); }, this.config.timeoutMs);
|
|
29
|
-
const fail = (error,reason='transport_uncertain') => { clearTimeout(timer); if (this.child !== child) return; for(const record of this.attachDiagnostics.values()) this.retireDiagnostic(record.session_id,reason); this.failure = error; if (!ready) reject(error); for (const entry of this.pending.values()) entry.finish(error); this.pending.clear(); };
|
|
163
|
+
const fail = (error,reason='transport_uncertain') => { clearTimeout(timer); if (this.child !== child) return; this.#replyConnectionGeneration++;this.#replyAttachments.clear();for(const record of this.attachDiagnostics.values()) this.retireDiagnostic(record.session_id,reason); this.failure = error; this.failureReason = reason; if (!ready) reject(error); for (const entry of this.pending.values()) entry.finish(error); this.pending.clear(); };
|
|
30
164
|
child.on('error', fail); child.stdin.on('error', fail);
|
|
31
165
|
child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString()).slice(-1500); });
|
|
32
|
-
child.on('exit', (code, sig) => fail(new Error(`Chrome Lab desconectado (${code ?? sig}); no se reintentaron efectos pendientes. ${stderr}`),'connection_lost'));
|
|
166
|
+
child.on('exit', (code, sig) => fail(new Error(`Chrome Lab desconectado (${code ?? sig}); no se reintentaron efectos pendientes. ${stderr}${process.env.RELAY_REMOTE_NAMESPACE ? ' Si relayd cedió el host (LEASE_EXPIRED), la siguiente operación reconecta y espera su turno; tus pestañas se cerraron, vuelve a abrirlas.' : ''}`),'connection_lost'));
|
|
33
167
|
const lines = createInterface({ input: child.stdout });
|
|
34
168
|
lines.on('line', line => {
|
|
35
169
|
if (this.child !== child) return;
|
|
@@ -39,7 +173,7 @@ export class ChromeLabClient {
|
|
|
39
173
|
if (message.fatal) { fail(new Error(message.fatal)); child.kill(); }
|
|
40
174
|
else if (message.ready) { ready = true; clearTimeout(timer); this.info = message.ready; resolve(message.ready); }
|
|
41
175
|
else if (message.event) this.observe(message.event);
|
|
42
|
-
else { const entry = this.pending.get(message.id); if (entry) { const error = message.error ? new Error(message.error) : null; if(error && entry.action==='cdp' && message.cdp_error?.method===entry.method && Number.isInteger(message.cdp_error.code) && typeof message.cdp_error.message==='string')error.cdpError={method:entry.method,code:message.cdp_error.code,message:message.cdp_error.message.slice(0,1000)};let result=message.result; if(!error && entry.attachDiagnostic) diagnosticEnvelope(result,{method:entry.method,sessionId:entry.sessionId,timeout_ms:entry.attachDiagnostic.timeout_ms}); entry.finish(error,result); } }
|
|
176
|
+
else { const entry = this.pending.get(message.id); if (entry) { const error = message.error ? new Error(message.error) : null; if(error && entry.action==='cdp' && message.cdp_error?.method===entry.method && Number.isInteger(message.cdp_error.code) && typeof message.cdp_error.message==='string')error.cdpError={method:entry.method,code:message.cdp_error.code,message:message.cdp_error.message.slice(0,1000)};let result=message.result; if(!error && entry.attachDiagnostic) diagnosticEnvelope(result,{method:entry.method,sessionId:entry.sessionId,timeout_ms:entry.attachDiagnostic.timeout_ms}); this.#recordNativeReply(message,entry,error,result); entry.finish(error,result); } }
|
|
43
177
|
} catch (error) { fail(error); child.kill(); }
|
|
44
178
|
});
|
|
45
179
|
child.stdin.write(JSON.stringify({ source: `(${remoteBridge.toString()})`, config: this.config }) + '\n');
|
|
@@ -47,6 +181,9 @@ export class ChromeLabClient {
|
|
|
47
181
|
}
|
|
48
182
|
observe(event) {
|
|
49
183
|
const { method, params = {}, sessionId } = event;
|
|
184
|
+
const retiredSession=method==='Target.detachedFromTarget'?params.sessionId:sessionId;
|
|
185
|
+
if(['Target.detachedFromTarget','Inspector.detached'].includes(method)){this.#replySessionGenerations.delete(retiredSession);this.#replyAttachments.delete(retiredSession);}
|
|
186
|
+
else if(['Runtime.executionContextCreated','Runtime.executionContextDestroyed','Runtime.executionContextsCleared'].includes(method)&&this.#replySessionGenerations.has(sessionId))this.#replySessionGenerations.set(sessionId,Object.freeze({}));
|
|
50
187
|
if (method === 'Target.detachedFromTarget') { this.retireDiagnostic(params.sessionId); for (const [target, session] of this.sessions) if (session === params.sessionId) this.sessions.delete(target); this.contexts.delete(params.sessionId); }
|
|
51
188
|
if (method === 'Inspector.detached') { this.retireDiagnostic(sessionId); for (const [target, session] of this.sessions) if (session === sessionId) this.sessions.delete(target); this.contexts.delete(sessionId); }
|
|
52
189
|
if (method === 'Runtime.executionContextCreated') {
|
|
@@ -74,18 +211,54 @@ export class ChromeLabClient {
|
|
|
74
211
|
if (this.events.length > 200) this.events.shift();
|
|
75
212
|
}
|
|
76
213
|
async request(action, params, { signal, expectedBinding, onDispatch, ...extra } = {}) {
|
|
214
|
+
if(['id','action','params','toJSON'].some(key=>Object.hasOwn(extra,key)))throw Object.assign(new Error('Reserved Chrome Lab request option.'),{code:'CHROME_REQUEST_OPTIONS'});
|
|
215
|
+
const fillFrame=this.#fillOwner.getStore();
|
|
216
|
+
// Preserve the private handler signal even when an instrumentation layer
|
|
217
|
+
// adds its original expiry signal or supplies a different request signal.
|
|
218
|
+
if(fillFrame?.signal!==undefined && signal!==fillFrame.signal)signal=AbortSignal.any(signal===undefined?[fillFrame.signal]:[fillFrame.signal,signal]);
|
|
77
219
|
// Optional original transport ownership, checked again after connect's async boundary.
|
|
78
220
|
// This protects a pinned caller from known local replacement before the write.
|
|
79
221
|
const checkBinding=()=>{if(expectedBinding&&(this.closed||this.failure||this.connectionId!==expectedBinding.connectionId||this.info?.browser_endpoint_id!==expectedBinding.browserEndpointId||extra.sessionId!==expectedBinding.sessionId||this.sessions.get(expectedBinding.targetId)!==expectedBinding.sessionId))throw Object.assign(new Error('Original CDP connection/session binding changed before dispatch.'),{code:'CHROME_CDP_BINDING_CHANGED',details:{remote_dispatched:false}});};
|
|
80
222
|
checkBinding();await this.connect(signal); signal?.throwIfAborted();checkBinding();if (this.failure) throw this.failure;
|
|
81
223
|
return new Promise((resolve, reject) => {
|
|
82
|
-
const id = ++this.sequence;
|
|
224
|
+
const id = ++this.#requestSequence;this.sequence=id;
|
|
225
|
+
if(!Number.isSafeInteger(id))throw new Error('Chrome Lab request sequence exhausted.');
|
|
226
|
+
const requestBudgetMs=this.config.timeoutMs+2000,requestDeadline=performance.now()+requestBudgetMs;
|
|
83
227
|
const abort = () => finish(new Error('Operación abortada tras despacho; el efecto remoto puede haber ocurrido. Verifica antes de repetir.'));
|
|
84
|
-
const timer = setTimeout(() => finish(new Error('Timeout Chrome Lab; efecto remoto desconocido, no se reintentó.')),
|
|
85
|
-
const
|
|
86
|
-
|
|
228
|
+
const timer = setTimeout(() => finish(new Error('Timeout Chrome Lab; efecto remoto desconocido, no se reintentó.')), requestBudgetMs);
|
|
229
|
+
const entry = {action, method:extra.method, sessionId:extra.sessionId, attachDiagnostic:extra.attachDiagnostic};
|
|
230
|
+
// The public pending registry remains useful transport bookkeeping. Its
|
|
231
|
+
// entries never own provenance's original signal, deadline or binding.
|
|
232
|
+
const capture={entry,guard:null,state:{settled:false,writeState:'not_sent'}};
|
|
233
|
+
const finish = (error, value) => { if(capture.state.settled)return;capture.state.settled=true;capture.state.failed=Boolean(error);clearTimeout(timer); signal?.removeEventListener('abort', abort); this.pending.delete(id);this.#pendingReplies.delete(id);
|
|
234
|
+
// Open/Preview targets and deployed projects stay owned by this connection.
|
|
235
|
+
if (!error) { if (action === 'open' && typeof value?.targetId === 'string' && value.targetId) this.openedTargets.add(value.targetId); if (action === 'deploy' && typeof value?.project === 'string' && value.project) this.deployedProjects.add(value.project); if (action === 'unload' && typeof params?.project === 'string') this.deployedProjects.delete(params.project); }
|
|
236
|
+
error ? reject(error) : resolve(value); };
|
|
237
|
+
this.#pendingReplies.set(id,capture);
|
|
238
|
+
entry.finish=finish;this.pending.set(id, entry); signal?.addEventListener('abort', abort, { once: true });
|
|
87
239
|
try { onDispatch?.(); } catch(error) { finish(error); return; }
|
|
88
|
-
|
|
240
|
+
try {
|
|
241
|
+
signal?.throwIfAborted();if(capture.state.settled)return;checkBinding();
|
|
242
|
+
const envelope=Object.assign(Object.create(null),extra,{id,action,params});
|
|
243
|
+
const wire=JSON.stringify(envelope),sent=JSON.parse(wire);
|
|
244
|
+
if(!sent || Array.isArray(sent) || sent.id!==id || sent.action!==action)throw Object.assign(new Error('Chrome Lab serialized request identity changed.'),{code:'CHROME_REQUEST_OPTIONS'});
|
|
245
|
+
entry.method=sent.method;entry.sessionId=sent.sessionId;
|
|
246
|
+
const binding=this.#replyBinding(sent.sessionId);
|
|
247
|
+
capture.guard=Object.freeze({id,action:sent.action,method:sent.method,sessionId:sent.sessionId,fillOwner:fillFrame?.owner??null,
|
|
248
|
+
connectionId:this.connectionId,browserEndpointId:this.info?.browser_endpoint_id,connectionGeneration:this.#replyConnectionGeneration,
|
|
249
|
+
attachTargetId:sent.method==='Target.attachToTarget'&&typeof sent.params?.targetId==='string'?sent.params.targetId:null,
|
|
250
|
+
detachSessionId:sent.method==='Target.detachFromTarget'?sent.params?.sessionId:null,
|
|
251
|
+
binding:binding?Object.freeze(binding):null,signal,requestDeadline,requestBudgetMs,
|
|
252
|
+
paramsSha256:createHash('sha256').update(JSON.stringify(sent.params ?? {})).digest('hex')});
|
|
253
|
+
this.#recentRequests.set(id,capture);if(this.#recentRequests.size>32)this.#recentRequests.delete(this.#recentRequests.keys().next().value);
|
|
254
|
+
capture.state.writeState='pending';
|
|
255
|
+
this.child.stdin.write(wire + '\n', error => {
|
|
256
|
+
if(error)capture.state.writeState='failed';
|
|
257
|
+
else if(capture.state.writeState==='pending')capture.state.writeState='acknowledged';
|
|
258
|
+
if(error)finish(error);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
catch(error){capture.state.writeState='failed';finish(error);}
|
|
89
262
|
});
|
|
90
263
|
}
|
|
91
264
|
cdp(method, params = {}, { signal, sessionId, expectedBinding, attachDiagnostic, onDispatch } = {}) { return this.request('cdp', params, { signal, method, sessionId, expectedBinding, onDispatch, ...(attachDiagnostic !== undefined ? {attachDiagnostic} : {}) }); }
|
|
@@ -112,6 +285,27 @@ export class ChromeLabClient {
|
|
|
112
285
|
if (!this.connectionId || !this.info?.browser_endpoint_id || !session || !collection || !Number.isInteger(context.id) || context.id < 1 || typeof context.uniqueId !== 'string' || !context.uniqueId || context.origin !== target.url) return null;
|
|
113
286
|
return { realm: 'extension_service_worker', target_id: targetId, url: target.url, connection_id: this.connectionId, browser_endpoint_id: this.info.browser_endpoint_id, session_id: session, context_unique_id: context.uniqueId, context_id: context.id, context_epoch: collection.epoch, collection_start: collection.started_at };
|
|
114
287
|
}
|
|
288
|
+
// A page this connection opened stops being owned once CloseTab closed it (or found it gone).
|
|
289
|
+
forgetTarget(targetId) { this.openedTargets.delete(targetId); }
|
|
290
|
+
// Relay mode: the SSH bridge is what holds relayd's lease on the remote host. Once this
|
|
291
|
+
// connection owns nothing (every own tab closed, every own extension unloaded) and nothing
|
|
292
|
+
// is in flight, close the transport so relayd can free the host after its idle grace; the
|
|
293
|
+
// next operation reconnects lazily. Nothing is replayed because nothing is pending. Local
|
|
294
|
+
// mode keeps the connection as before.
|
|
295
|
+
async releaseIfIdle() {
|
|
296
|
+
if (!process.env.RELAY_REMOTE_NAMESPACE) return { released: false, reason: 'not_relay_mode' };
|
|
297
|
+
const child = this.child;
|
|
298
|
+
if (this.closed || this.reconnecting || this.failure || !child || child.exitCode !== null || child.signalCode !== null) return { released: false, reason: 'no_live_connection' };
|
|
299
|
+
if (this.openedTargets.size || this.deployedProjects.size) return { released: false, reason: 'still_owned', open_targets: this.openedTargets.size, deployed_projects: this.deployedProjects.size };
|
|
300
|
+
if (this.pending.size || this.attaching.size) return { released: false, reason: 'busy' };
|
|
301
|
+
// Detach first: the exit handler ignores a child that is no longer this.child, so the
|
|
302
|
+
// release is not recorded as a transport failure.
|
|
303
|
+
for (const record of this.attachDiagnostics.values()) this.retireDiagnostic(record.session_id, 'connection_released');
|
|
304
|
+
this.child = undefined; this.connection = undefined; this.failure = undefined; this.info = undefined;
|
|
305
|
+
this.attachDiagnostics.clear(); this.sessions.clear(); this.contexts.clear(); this.collections.clear(); this.targetInfo.clear(); this.events = [];
|
|
306
|
+
await new Promise(resolve => { const timer = setTimeout(() => { child.kill('SIGKILL'); resolve(); }, 1500); child.once('exit', () => { clearTimeout(timer); resolve(); }); child.stdin.end(); });
|
|
307
|
+
return { released: true, reason: 'nothing_owned' };
|
|
308
|
+
}
|
|
115
309
|
// Explicit recovery only. A new connection does not replay any request or resolve its effect.
|
|
116
310
|
async reconnect(signal) {
|
|
117
311
|
signal?.throwIfAborted();
|
|
@@ -143,14 +337,18 @@ export class ChromeLabClient {
|
|
|
143
337
|
const current=()=>!record.retired && this.attachDiagnostics.get(targetId)===record && !this.closed && !this.failure && this.connectionId===binding.connectionId && this.info?.browser_endpoint_id===binding.browserEndpointId && this.sessions.get(targetId)===sessionId;
|
|
144
338
|
const check=()=>{signal?.throwIfAborted();if(!current())throw Object.assign(new Error('Original attach diagnostic session retired.'),{code:'CHROME_CDP_BINDING_CHANGED'});};
|
|
145
339
|
const started=performance.now(), budget=Math.min(OPTIONAL_PHASE_MS,this.config.timeoutMs);
|
|
340
|
+
let phaseExhausted=false;
|
|
146
341
|
for(const domain of record.domains) {
|
|
147
342
|
check();const remaining=Math.floor(budget-(performance.now()-started));
|
|
148
|
-
if(remaining < 1) {domain.completed_at=new Date().toISOString();continue;}
|
|
343
|
+
if(phaseExhausted || remaining < 1) {domain.completed_at=new Date().toISOString();continue;}
|
|
149
344
|
domain.timeout_ms=remaining;const begin=performance.now();
|
|
150
345
|
try {
|
|
151
346
|
const value=await this.cdp(domain.method,domain.method==='Log.enable'?{}:{maxTotalBufferSize:100000,maxResourceBufferSize:10000},{signal,sessionId,expectedBinding:binding,attachDiagnostic:{timeout_ms:remaining},onDispatch:()=>{check();if(performance.now()-started>=budget)throw Object.assign(new Error('Optional phase expired before dispatch.'),{code:'CHROME_DIAGNOSTIC_BUDGET'});domain.command_count=1;domain.started_at=new Date().toISOString();}});
|
|
152
347
|
check();const result=diagnosticEnvelope(value,{method:domain.method,sessionId,timeout_ms:remaining});
|
|
153
348
|
Object.assign(domain,{state:result.state,reason:result.reason,remote_dispatched:result.remote_dispatched,bridge_elapsed_ms:result.elapsed_ms,elapsed_ms:performance.now()-begin,completed_at:new Date().toISOString(),...(result.error?{error:result.error}: {})});
|
|
349
|
+
// A wait for the whole remaining phase has timed out. Do not spend a
|
|
350
|
+
// timer-rounding residue on another command in the other process.
|
|
351
|
+
if(result.reason==='command_timeout')phaseExhausted=true;
|
|
154
352
|
if(result.state==='enabled')domain.enabled_acknowledged_at=domain.completed_at;
|
|
155
353
|
} catch(error) {
|
|
156
354
|
if(error.code==='CHROME_DIAGNOSTIC_BUDGET' && domain.command_count===0) {Object.assign(domain,{reason:'phase_budget_exhausted',completed_at:new Date().toISOString(),elapsed_ms:performance.now()-begin});continue;}
|
|
@@ -193,9 +391,41 @@ export class ChromeLabClient {
|
|
|
193
391
|
if (serialized.length > 24000) return { ok: true, truncated: true, preview: serialized.subarray(0, 24000).toString('utf8') };
|
|
194
392
|
return { ok: true, value };
|
|
195
393
|
}
|
|
394
|
+
// Relay mode owns a per-attempt remoteRoot: closing that connection closes only the
|
|
395
|
+
// targets it opened (Open/Preview) and unloads the extensions the bridge reports as
|
|
396
|
+
// own (same ownExtensions rule). Foreign targets/extensions are never selected.
|
|
397
|
+
async relayCleanup() {
|
|
398
|
+
const namespace = process.env.RELAY_REMOTE_NAMESPACE;
|
|
399
|
+
if (!namespace) return { mode: 'local', cleaned: false, reason: 'not_relay_mode' };
|
|
400
|
+
if (this.reconnecting) return { mode: 'relay', namespace, cleaned: false, reason: 'reconnect' };
|
|
401
|
+
if (this.failure || !this.child || this.child.exitCode !== null || this.child.signalCode !== null) return { mode: 'relay', namespace, cleaned: false, reason: 'no_live_connection' };
|
|
402
|
+
if (!this.openedTargets.size && !this.deployedProjects.size) return { mode: 'relay', namespace, cleaned: false, reason: 'nothing_owned' };
|
|
403
|
+
const closed_targets = [], already_closed = [], unloaded = [], errors = [];
|
|
404
|
+
for (const targetId of [...this.openedTargets]) {
|
|
405
|
+
try { await this.cdp('Target.closeTarget', { targetId }); closed_targets.push(targetId); }
|
|
406
|
+
catch (error) { if (/No target|not found/i.test(error.message)) already_closed.push(targetId); else errors.push({ target_id: targetId, error: error.message }); }
|
|
407
|
+
}
|
|
408
|
+
let other_extensions_untouched = null;
|
|
409
|
+
try {
|
|
410
|
+
const status = await this.request('status', {});
|
|
411
|
+
other_extensions_untouched = status.other_extensions_count ?? null;
|
|
412
|
+
for (const extension of status.extensions) {
|
|
413
|
+
try { const result = await this.request('unload', { project: extension.project }); unloaded.push({ project: extension.project, extension_id: result.extension_id ?? extension.id }); }
|
|
414
|
+
catch (error) { errors.push({ project: extension.project, error: error.message }); }
|
|
415
|
+
}
|
|
416
|
+
} catch (error) { errors.push({ status: error.message }); }
|
|
417
|
+
return { mode: 'relay', namespace, cleaned: true, closed_targets, already_closed, unloaded, other_extensions_untouched, errors,
|
|
418
|
+
note: 'Cierre en modo relay: sólo los targets abiertos por esta conexión y las extensiones propias bajo su remoteRoot.' };
|
|
419
|
+
}
|
|
196
420
|
async close() {
|
|
197
|
-
if (this.closed) return
|
|
198
|
-
|
|
421
|
+
if (this.closed) return this.lastCleanup;
|
|
422
|
+
// Cleanup happens before the transport is marked closed: it is the only chance to
|
|
423
|
+
// ask the bridge which extensions are still owned under remoteRoot. A cleanup
|
|
424
|
+
// failure is reported in the result, never silently dropped and never fatal.
|
|
425
|
+
const cleanup = await this.relayCleanup().catch(error => ({ mode: process.env.RELAY_REMOTE_NAMESPACE ? 'relay' : 'local', cleaned: false, errors: [{ cleanup: error.message }] }));
|
|
426
|
+
this.lastCleanup = cleanup; this.closed = true;this.#replyConnectionGeneration++;this.#replySessionGenerations.clear();this.#replyAttachments.clear();for(const record of this.attachDiagnostics.values())this.retireDiagnostic(record.session_id);
|
|
427
|
+
if (!this.child || this.child.exitCode !== null || this.child.signalCode !== null) return cleanup;
|
|
199
428
|
await new Promise(resolve => { const timer = setTimeout(() => { this.child.kill('SIGKILL'); resolve(); }, 1500); this.child.once('exit', () => { clearTimeout(timer); resolve(); }); this.child.stdin.end(); });
|
|
429
|
+
return cleanup;
|
|
200
430
|
}
|
|
201
431
|
}
|