@proagentstore/cli 0.4.91 → 0.4.93
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.
|
@@ -20,6 +20,8 @@ export const LOCAL_ARTIFACT_TASK_TYPE = "local_artifact.generate";
|
|
|
20
20
|
export const LOCAL_ARTIFACT_RUN_PATH = "/local-artifact/run";
|
|
21
21
|
export const LOCAL_ARTIFACT_STATUS_PATH = "/local-artifact/status";
|
|
22
22
|
export const LOCAL_ARTIFACT_CANCEL_PATH = "/local-artifact/cancel";
|
|
23
|
+
/** First CLI that consumes an explicit uploaded source instead of its local master files. */
|
|
24
|
+
export const LOCAL_ARTIFACT_UPLOADED_SOURCES_MIN_CLI = "0.4.93";
|
|
23
25
|
/** The readiness event a completed tailoring run emits, once, through the connection outbox. */
|
|
24
26
|
export const MATERIALS_READY_EVENT = "job.application.materials_ready";
|
|
25
27
|
export const LOCAL_ARTIFACT_ENGINES = ["claude", "codex"];
|
|
@@ -67,6 +67,26 @@ export function parseArtifactEnvelope(raw) {
|
|
|
67
67
|
throw bad(`source ${String(r.role)} is listed twice`);
|
|
68
68
|
sources.push({ role: r.role, path: r.path });
|
|
69
69
|
}
|
|
70
|
+
const uploadedSources = [];
|
|
71
|
+
if (o.uploadedSources !== undefined) {
|
|
72
|
+
if (!Array.isArray(o.uploadedSources) || o.uploadedSources.length !== 2)
|
|
73
|
+
throw bad("uploadedSources must contain the selected resume and profile");
|
|
74
|
+
for (const source of o.uploadedSources) {
|
|
75
|
+
const r = (source && typeof source === "object" ? source : {});
|
|
76
|
+
if ((r.role !== "resume" && r.role !== "profile") || uploadedSources.some((x) => x.role === r.role))
|
|
77
|
+
throw bad("uploaded sources need one resume and one profile");
|
|
78
|
+
if (typeof r.fileId !== "string" || !PATH_SEGMENT.test(r.fileId) || typeof r.version !== "string" || !r.version || r.version.length > 300 || typeof r.originalSha256 !== "string" || !/^[a-f0-9]{64}$/.test(r.originalSha256) || typeof r.extractedTextSha256 !== "string" || !/^[a-f0-9]{64}$/.test(r.extractedTextSha256) || typeof r.extractedAt !== "string" || Number.isNaN(Date.parse(r.extractedAt)) || typeof r.text !== "string")
|
|
79
|
+
throw bad("uploaded source provenance is invalid");
|
|
80
|
+
const bytes = Buffer.byteLength(r.text, "utf8");
|
|
81
|
+
if (!bytes || bytes > LOCAL_ARTIFACT_CAPS.sourceBytes)
|
|
82
|
+
throw bad(`uploaded source must be 1-${LOCAL_ARTIFACT_CAPS.sourceBytes} UTF-8 bytes`);
|
|
83
|
+
if (sha256(r.text) !== r.extractedTextSha256)
|
|
84
|
+
throw bad("uploaded source text does not match its extracted-text hash");
|
|
85
|
+
uploadedSources.push({ role: r.role, fileId: r.fileId, version: r.version, originalSha256: r.originalSha256, extractedTextSha256: r.extractedTextSha256, extractedAt: r.extractedAt, text: r.text });
|
|
86
|
+
}
|
|
87
|
+
if (uploadedSources.length !== 2 || !uploadedSources.some((s) => s.role === "resume") || !uploadedSources.some((s) => s.role === "profile"))
|
|
88
|
+
throw bad("uploaded sources need one resume and one profile");
|
|
89
|
+
}
|
|
70
90
|
const p = (o.policy && typeof o.policy === "object" ? o.policy : {});
|
|
71
91
|
const retainDays = intIn(p.retainDays, 0, 3650);
|
|
72
92
|
const maxMinutes = intIn(p.maxMinutes, 1, 60);
|
|
@@ -83,6 +103,7 @@ export function parseArtifactEnvelope(raw) {
|
|
|
83
103
|
authMode: o.authMode,
|
|
84
104
|
workspace: o.workspace,
|
|
85
105
|
sources,
|
|
106
|
+
...(uploadedSources.length ? { uploadedSources } : {}),
|
|
86
107
|
policy: { retainDays, maxMinutes, maxConcurrent },
|
|
87
108
|
...("lead" in lead ? { lead: lead.lead } : { lead: null, leadError: lead.error }),
|
|
88
109
|
};
|
|
@@ -163,31 +184,53 @@ export class LocalArtifactRuntime {
|
|
|
163
184
|
throw new Pause("workspace_unavailable", [`The workspace ${e.workspace} cannot be used: ${err instanceof Error ? err.message : String(err)}`]);
|
|
164
185
|
}
|
|
165
186
|
this.sweepArtifacts(run, workspace);
|
|
166
|
-
// Sources:
|
|
187
|
+
// Sources: uploaded selections arrive only over the authenticated relay, are written beneath
|
|
188
|
+
// this run's private scratch folder under fixed role names, and never use a user filename/path.
|
|
189
|
+
// Local mode remains the existing path. Content never reaches events/results/DB.
|
|
167
190
|
const texts = [];
|
|
168
191
|
const hashes = [];
|
|
169
192
|
const missing = [];
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
193
|
+
if (e.uploadedSources?.length) {
|
|
194
|
+
const sourceDir = join(run.scratch, "sources");
|
|
195
|
+
mkdirSync(sourceDir, { recursive: true, mode: 0o700 });
|
|
196
|
+
for (const s of e.uploadedSources) {
|
|
197
|
+
const safePath = join(sourceDir, `${s.role}.txt`);
|
|
198
|
+
try {
|
|
199
|
+
writeFileSync(safePath, s.text, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
200
|
+
const bytes = Buffer.byteLength(s.text, "utf8");
|
|
201
|
+
const hash = { role: s.role, path: `uploaded/${s.fileId}@${s.version}`, sha256: s.extractedTextSha256, bytes };
|
|
202
|
+
texts.push({ role: s.role, text: readFileSync(safePath, "utf8") });
|
|
203
|
+
hashes.push(hash);
|
|
204
|
+
this.emit(run, { type: "source.read", detail: hash });
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
this.emit(run, { type: "source.missing", detail: { role: s.role, path: `uploaded/${s.fileId}` } });
|
|
208
|
+
missing.push(`The selected uploaded ${s.role} could not be materialized (${err instanceof Error ? err.message : "unknown error"}).`);
|
|
209
|
+
}
|
|
187
210
|
}
|
|
188
211
|
}
|
|
212
|
+
else
|
|
213
|
+
for (const s of e.sources) {
|
|
214
|
+
const shown = `${e.workspace}/${s.path}`;
|
|
215
|
+
try {
|
|
216
|
+
const file = resolveSource(workspace, s.path);
|
|
217
|
+
const buf = readFileSync(file);
|
|
218
|
+
if (buf.length > LOCAL_ARTIFACT_CAPS.sourceBytes)
|
|
219
|
+
throw new Error(`larger than ${LOCAL_ARTIFACT_CAPS.sourceBytes / 1024} KB`);
|
|
220
|
+
if (!buf.length)
|
|
221
|
+
throw new Error("empty");
|
|
222
|
+
texts.push({ role: s.role, text: buf.toString("utf8") });
|
|
223
|
+
const hash = { role: s.role, path: shown, sha256: sha256(buf), bytes: buf.length };
|
|
224
|
+
hashes.push(hash);
|
|
225
|
+
this.emit(run, { type: "source.read", detail: hash });
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
this.emit(run, { type: "source.missing", detail: { role: s.role, path: shown } });
|
|
229
|
+
missing.push(`Your ${s.role} at ${shown} could not be read (${err instanceof Error && !/ENOENT/.test(err.message) ? err.message : "not found"}). Add it, or choose another file in the Application Tailor settings.`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
189
232
|
for (const role of REQUIRED_SOURCE_ROLES) {
|
|
190
|
-
if (!e.sources.some((s) => s.role === role))
|
|
233
|
+
if (e.uploadedSources?.length ? !e.uploadedSources.some((s) => s.role === role) : !e.sources.some((s) => s.role === role))
|
|
191
234
|
missing.push(`No ${role} source is configured. Choose one in the Application Tailor settings.`);
|
|
192
235
|
}
|
|
193
236
|
if (missing.length)
|
package/dist/index.js
CHANGED
|
@@ -1716,6 +1716,152 @@ function mayAutomaticallyRestart(input) {
|
|
|
1716
1716
|
return input.authoritative && input.enabled && input.workObserved && input.busy.length === 0;
|
|
1717
1717
|
}
|
|
1718
1718
|
|
|
1719
|
+
// src/commands/runner/auto-update-controller.ts
|
|
1720
|
+
var AutomaticUpdateController = class {
|
|
1721
|
+
constructor(deps) {
|
|
1722
|
+
this.deps = deps;
|
|
1723
|
+
this.setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
1724
|
+
this.clearTimer = deps.clearTimer ?? ((timer) => clearTimeout(timer));
|
|
1725
|
+
}
|
|
1726
|
+
deps;
|
|
1727
|
+
timer = null;
|
|
1728
|
+
checking = false;
|
|
1729
|
+
waiting = false;
|
|
1730
|
+
installing = false;
|
|
1731
|
+
failures = 0;
|
|
1732
|
+
setTimer;
|
|
1733
|
+
clearTimer;
|
|
1734
|
+
/** Called only for a true policy transition. `keep` deliberately leaves a due heartbeat timer alone. */
|
|
1735
|
+
onPolicy(action) {
|
|
1736
|
+
if (action === "cancel") return this.cancel();
|
|
1737
|
+
if (action === "start") this.schedule(true);
|
|
1738
|
+
}
|
|
1739
|
+
/** Testable immediate entry point; production reaches it through the scheduled timer. */
|
|
1740
|
+
async runNow() {
|
|
1741
|
+
const current = this.deps.policy();
|
|
1742
|
+
if (this.checking || this.waiting || !current.authoritative || !current.value.autoUpdate) return;
|
|
1743
|
+
this.checking = true;
|
|
1744
|
+
try {
|
|
1745
|
+
if (!await this.deps.refreshPolicy() || !this.enabled()) {
|
|
1746
|
+
this.deps.status("offline", { reason: "The owner policy could not be refreshed; automatic update is deferred." });
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
await this.decide(false);
|
|
1750
|
+
} catch (error) {
|
|
1751
|
+
this.fail(error);
|
|
1752
|
+
} finally {
|
|
1753
|
+
this.checking = false;
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
/** Explicit remote updates and unattended checks share this single flight. */
|
|
1757
|
+
async installManually(plan) {
|
|
1758
|
+
return this.install(plan, false);
|
|
1759
|
+
}
|
|
1760
|
+
enabled() {
|
|
1761
|
+
const { value, authoritative } = this.deps.policy();
|
|
1762
|
+
return authoritative && value.autoUpdate;
|
|
1763
|
+
}
|
|
1764
|
+
schedule(initial = false) {
|
|
1765
|
+
if (!this.enabled() || this.timer || this.waiting) return;
|
|
1766
|
+
const delay = initial ? Math.round(5e3 + (this.deps.random ?? Math.random)() * 25e3) : nextAutoUpdateDelayMs(this.failures, this.deps.random);
|
|
1767
|
+
this.timer = this.setTimer(() => {
|
|
1768
|
+
this.timer = null;
|
|
1769
|
+
void this.runNow();
|
|
1770
|
+
}, delay);
|
|
1771
|
+
this.timer.unref?.();
|
|
1772
|
+
}
|
|
1773
|
+
cancel() {
|
|
1774
|
+
if (this.timer) this.clearTimer(this.timer);
|
|
1775
|
+
this.timer = null;
|
|
1776
|
+
}
|
|
1777
|
+
fail(error) {
|
|
1778
|
+
this.failures++;
|
|
1779
|
+
this.deps.status("failure", { reason: error instanceof Error ? error.message : String(error) });
|
|
1780
|
+
this.schedule();
|
|
1781
|
+
}
|
|
1782
|
+
async decide(deferred) {
|
|
1783
|
+
const facts = await this.deps.facts();
|
|
1784
|
+
if (!this.enabled()) return;
|
|
1785
|
+
const plan = this.deps.plan(facts);
|
|
1786
|
+
if (plan.action === "up-to-date") {
|
|
1787
|
+
this.failures = 0;
|
|
1788
|
+
this.deps.status("verified-success", { latestVersion: facts.latest ?? void 0, reason: void 0 });
|
|
1789
|
+
this.schedule();
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
if (plan.action === "refused") {
|
|
1793
|
+
this.deps.status("unsupported", { reason: plan.reason });
|
|
1794
|
+
this.schedule();
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
if (plan.action === "wait") {
|
|
1798
|
+
this.deps.status("waiting-for-idle", { latestVersion: plan.latest, reason: void 0 });
|
|
1799
|
+
this.defer();
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
this.deps.status("running", { latestVersion: plan.latest, reason: void 0 });
|
|
1803
|
+
const restarted = await this.install(plan, true);
|
|
1804
|
+
if (restarted) this.deps.status("restarting", { latestVersion: plan.latest, reason: void 0 });
|
|
1805
|
+
else if (!deferred) this.defer(plan.latest);
|
|
1806
|
+
}
|
|
1807
|
+
defer(latest) {
|
|
1808
|
+
if (this.waiting) return;
|
|
1809
|
+
this.waiting = true;
|
|
1810
|
+
this.timer = this.setTimer(() => {
|
|
1811
|
+
this.timer = null;
|
|
1812
|
+
void (async () => {
|
|
1813
|
+
this.waiting = false;
|
|
1814
|
+
try {
|
|
1815
|
+
if (!await this.deps.refreshPolicy() || !this.enabled()) {
|
|
1816
|
+
this.deps.status("offline", { reason: "The owner policy could not be refreshed; automatic update is deferred." });
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
await this.decide(true);
|
|
1820
|
+
} catch (error) {
|
|
1821
|
+
this.fail(error);
|
|
1822
|
+
}
|
|
1823
|
+
})();
|
|
1824
|
+
}, 15e3);
|
|
1825
|
+
this.timer.unref?.();
|
|
1826
|
+
this.deps.status("waiting-for-idle", latest ? { latestVersion: latest } : void 0);
|
|
1827
|
+
}
|
|
1828
|
+
async install(plan, automatic) {
|
|
1829
|
+
if (this.installing) return false;
|
|
1830
|
+
this.installing = true;
|
|
1831
|
+
try {
|
|
1832
|
+
return await this.deps.installAndRestart(plan, automatic);
|
|
1833
|
+
} finally {
|
|
1834
|
+
this.installing = false;
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
};
|
|
1838
|
+
var RelayMutationAdmission = class {
|
|
1839
|
+
draining = false;
|
|
1840
|
+
inFlight = 0;
|
|
1841
|
+
waiters = [];
|
|
1842
|
+
begin() {
|
|
1843
|
+
if (this.draining) return null;
|
|
1844
|
+
this.inFlight++;
|
|
1845
|
+
let released = false;
|
|
1846
|
+
return () => {
|
|
1847
|
+
if (released) return;
|
|
1848
|
+
released = true;
|
|
1849
|
+
this.inFlight--;
|
|
1850
|
+
if (this.inFlight === 0) {
|
|
1851
|
+
for (const wake of this.waiters.splice(0)) wake();
|
|
1852
|
+
}
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
async drain() {
|
|
1856
|
+
this.draining = true;
|
|
1857
|
+
if (this.inFlight === 0) return;
|
|
1858
|
+
await new Promise((resolve5) => this.waiters.push(resolve5));
|
|
1859
|
+
}
|
|
1860
|
+
resume() {
|
|
1861
|
+
this.draining = false;
|
|
1862
|
+
}
|
|
1863
|
+
};
|
|
1864
|
+
|
|
1719
1865
|
// src/commands/runner/relay.ts
|
|
1720
1866
|
var MEMBERSHIP_SYNC_PATH = "/pags/membership/sync";
|
|
1721
1867
|
var CLI_CONTROL_PATHS = /* @__PURE__ */ new Set([MEMBERSHIP_SYNC_PATH, RUNNER_UPDATE_PATH]);
|
|
@@ -1839,7 +1985,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1839
1985
|
};
|
|
1840
1986
|
let updateInstallInFlight = false;
|
|
1841
1987
|
let installedPendingRestart = null;
|
|
1842
|
-
|
|
1988
|
+
const relayMutationAdmission = new RelayMutationAdmission();
|
|
1843
1989
|
const installAndRestart = async (plan, automatic = false) => {
|
|
1844
1990
|
if (updateInstallInFlight) return false;
|
|
1845
1991
|
updateInstallInFlight = true;
|
|
@@ -1858,17 +2004,17 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1858
2004
|
}
|
|
1859
2005
|
}
|
|
1860
2006
|
if (automatic) {
|
|
1861
|
-
|
|
2007
|
+
await relayMutationAdmission.drain();
|
|
1862
2008
|
await new Promise((resolve5) => setTimeout(resolve5, 500));
|
|
1863
2009
|
if (!await refreshAutomaticPolicy() || !autoUpdatePolicy.autoUpdate) {
|
|
1864
|
-
|
|
2010
|
+
relayMutationAdmission.resume();
|
|
1865
2011
|
updateInstallInFlight = false;
|
|
1866
2012
|
return false;
|
|
1867
2013
|
}
|
|
1868
2014
|
}
|
|
1869
2015
|
const finalFacts = await updateFacts();
|
|
1870
2016
|
if (finalFacts.busy.length || automatic && !mayAutomaticallyRestart({ authoritative: autoUpdatePolicyAuthoritative, enabled: autoUpdatePolicy.autoUpdate, workObserved: !finalFacts.busy.includes("runner-work-observation-unavailable"), busy: finalFacts.busy })) {
|
|
1871
|
-
|
|
2017
|
+
relayMutationAdmission.resume();
|
|
1872
2018
|
updateInstallInFlight = false;
|
|
1873
2019
|
return false;
|
|
1874
2020
|
}
|
|
@@ -1985,6 +2131,17 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1985
2131
|
}, delay);
|
|
1986
2132
|
automaticTimer.unref();
|
|
1987
2133
|
};
|
|
2134
|
+
const automaticUpdateController = new AutomaticUpdateController({
|
|
2135
|
+
policy: () => ({ value: autoUpdatePolicy, authoritative: autoUpdatePolicyAuthoritative }),
|
|
2136
|
+
refreshPolicy: refreshAutomaticPolicy,
|
|
2137
|
+
facts: updateFacts,
|
|
2138
|
+
plan: planRunnerUpdate,
|
|
2139
|
+
// This bridge is deliberately the same function used by explicit runner_update. It owns npm,
|
|
2140
|
+
// a fresh authoritative policy, the mutation drain, final local-work observation and restart.
|
|
2141
|
+
installAndRestart,
|
|
2142
|
+
status: saveAutoUpdateStatus
|
|
2143
|
+
});
|
|
2144
|
+
scheduleAutomaticUpdate = (initial = false, cancel = false) => automaticUpdateController.onPolicy(cancel ? "cancel" : initial ? "start" : "keep");
|
|
1988
2145
|
if (autoUpdatePolicyAuthoritative && autoUpdatePolicy.autoUpdate) scheduleAutomaticUpdate(true);
|
|
1989
2146
|
const answerUpdate = async (body) => {
|
|
1990
2147
|
const dryRun = body?.dryRun === true;
|
|
@@ -1995,7 +2152,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1995
2152
|
return { status: 200, result: { ...plan, detail: "Restarts itself as soon as these engines finish their turns \u2014 no run is cut off." } };
|
|
1996
2153
|
}
|
|
1997
2154
|
try {
|
|
1998
|
-
if (!await
|
|
2155
|
+
if (!await automaticUpdateController.installManually(plan)) return { status: 409, result: { error: "A runner update is already installing or restarting." } };
|
|
1999
2156
|
} catch (e) {
|
|
2000
2157
|
return { status: 500, result: { error: `npm could not install ${plan.latest}: ${e instanceof Error ? e.message : String(e)}` } };
|
|
2001
2158
|
}
|
|
@@ -2064,7 +2221,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
2064
2221
|
runnerNode,
|
|
2065
2222
|
// The final automatic-update drain is an admission gate, not merely another poll:
|
|
2066
2223
|
// command work starts only when the old runner is still permitted to serve it.
|
|
2067
|
-
() =>
|
|
2224
|
+
() => relayMutationAdmission.begin()
|
|
2068
2225
|
)
|
|
2069
2226
|
);
|
|
2070
2227
|
if (label) writeLine(`Attached agent: ${label}`);
|
|
@@ -2208,7 +2365,7 @@ async function readSessionResources(localUrl, runnerToken) {
|
|
|
2208
2365
|
}
|
|
2209
2366
|
var RTT_PROBE_PREFIX = "rtt:";
|
|
2210
2367
|
var RTT_ECHO_PREFIX = "rtt-echo:";
|
|
2211
|
-
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen, onControl, runnerNode = hostname3(),
|
|
2368
|
+
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen, onControl, runnerNode = hostname3(), beginMutation = () => () => void 0) {
|
|
2212
2369
|
let backoffMs = 1e3;
|
|
2213
2370
|
let reconnecting = false;
|
|
2214
2371
|
let closed = false;
|
|
@@ -2276,7 +2433,8 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
2276
2433
|
return;
|
|
2277
2434
|
}
|
|
2278
2435
|
const method = (cmd.method || "POST").toUpperCase();
|
|
2279
|
-
|
|
2436
|
+
const releaseMutation = method !== "GET" && method !== "HEAD" ? beginMutation() : null;
|
|
2437
|
+
if (method !== "GET" && method !== "HEAD" && !releaseMutation) {
|
|
2280
2438
|
try {
|
|
2281
2439
|
ws.send(JSON.stringify({ id: cmd.id, status: 503, result: { error: "Runner is draining for a safe automatic update; retry shortly." } }));
|
|
2282
2440
|
} catch {
|
|
@@ -2310,6 +2468,8 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
2310
2468
|
ws.send(JSON.stringify({ id: cmd.id, status: 500, error: err instanceof Error ? err.message : String(err) }));
|
|
2311
2469
|
} catch {
|
|
2312
2470
|
}
|
|
2471
|
+
} finally {
|
|
2472
|
+
releaseMutation?.();
|
|
2313
2473
|
}
|
|
2314
2474
|
};
|
|
2315
2475
|
ws.onclose = (ev) => {
|