dsh-plugin-update 0.2.0 → 0.3.0

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.
@@ -0,0 +1,778 @@
1
+ // AUTO-GENERATED by node build.mjs — DO NOT EDIT. Source: src/host-batch.ts
2
+ import { homedir } from "node:os";
3
+ import {
4
+ batchEntryOf,
5
+ batchProgress,
6
+ batchRequestId,
7
+ createBatchSession,
8
+ emptyBatchSession,
9
+ isBatchFinished,
10
+ markBatchEntry,
11
+ orderTargets,
12
+ resumeBatchSession
13
+ } from "./batch.js";
14
+ import {
15
+ runBatch
16
+ } from "./batch-run.js";
17
+ import { assertPluginId, assertPrefix, resolveUpdateConfig } from "./config.js";
18
+ import { createHostUpdate } from "./host.js";
19
+ import { defaultHomeDir } from "./reader.js";
20
+ import { compareReleaseVersions } from "./service.js";
21
+ import {
22
+ createBatchDiskPorts,
23
+ createSkipDiskPorts,
24
+ createUpdateQueuePorts,
25
+ isVersionSkipped
26
+ } from "./store.js";
27
+ const DEFAULT_DRAIN_INTERVAL_MS = 5e3;
28
+ const MIN_DRAIN_INTERVAL_MS = 1e3;
29
+ const DEFAULT_INSTALL_POLL_MS = 500;
30
+ const REQUEST_ID_MAX_CHARS = 128;
31
+ const KNOWN_ERROR_CODES = [
32
+ "check-failed",
33
+ "invalid-release",
34
+ "check-expired",
35
+ "update-busy",
36
+ "install-failed",
37
+ "unknown-profile",
38
+ "source-install",
39
+ "invalid-installation",
40
+ "installation-changed",
41
+ "pending-restart",
42
+ "incompatible-node",
43
+ "registry-conflict",
44
+ "recovery-required",
45
+ "cross-scope"
46
+ ];
47
+ const JOB_FAILURE_CODES = [
48
+ "install-failed",
49
+ "installation-changed",
50
+ "registry-conflict",
51
+ "source-install",
52
+ "unknown-profile",
53
+ "invalid-installation",
54
+ "incompatible-node",
55
+ "pending-restart",
56
+ "recovery-required",
57
+ "check-expired",
58
+ "update-busy",
59
+ "check-failed",
60
+ "invalid-release"
61
+ ];
62
+ function batchFail(code) {
63
+ return Object.assign(new Error(code), { code });
64
+ }
65
+ function asRecord(value) {
66
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
67
+ }
68
+ function firstText(...values) {
69
+ for (const value of values) {
70
+ if (typeof value === "string" && value) return value;
71
+ }
72
+ return null;
73
+ }
74
+ function hash8(text) {
75
+ try {
76
+ const t = String(text || "");
77
+ let h = 5381;
78
+ for (let i = 0; i < t.length; i += 1) h = (h << 5) + h + t.charCodeAt(i) >>> 0;
79
+ return ("0000000" + h.toString(16)).slice(-8);
80
+ } catch {
81
+ return "00000000";
82
+ }
83
+ }
84
+ function phoneRequestIdOf(batchRequestId2) {
85
+ const raw = typeof batchRequestId2 === "string" ? batchRequestId2 : "";
86
+ const opaque = raw.length <= REQUEST_ID_MAX_CHARS && /^[A-Za-z0-9._~-]+$/.test(raw) && !/^(npm_|gh[pousr]_|github_pat_|sk-|bearer)/i.test(raw);
87
+ if (opaque) return raw;
88
+ const safe = raw.replace(/[^A-Za-z0-9._~-]/g, "-").replace(/^[.-]+/, "").slice(0, REQUEST_ID_MAX_CHARS - 10);
89
+ return (safe || "batch") + "-" + hash8(raw);
90
+ }
91
+ function errorPayloadOf(error) {
92
+ const code = error && typeof error.code === "string" ? String(error.code) : "";
93
+ if (KNOWN_ERROR_CODES.includes(code)) return { error: code, errorKind: code };
94
+ return { error: "check-failed", errorKind: "internal" };
95
+ }
96
+ function codeOfReply(reply) {
97
+ const code = firstText(reply["error"]) ?? "";
98
+ return KNOWN_ERROR_CODES.includes(code) ? code : "check-failed";
99
+ }
100
+ function jobFailureCode(job) {
101
+ const message = job && typeof job["message"] === "string" ? job["message"] : "";
102
+ const head = message.split(":")[0].trim();
103
+ return JOB_FAILURE_CODES.includes(head) ? head : "install-failed";
104
+ }
105
+ function snapshotOf(reply) {
106
+ return asRecord(reply["snapshot"]);
107
+ }
108
+ function jobOf(snapshot) {
109
+ const job = snapshot["job"];
110
+ return job && typeof job === "object" && !Array.isArray(job) ? job : null;
111
+ }
112
+ function sleep(ms) {
113
+ return new Promise((settle) => {
114
+ setTimeout(() => settle(), ms > 0 ? ms : 0);
115
+ });
116
+ }
117
+ const DRIVE_STEP_GUARD = 1e4;
118
+ function mergeInto(base, extra) {
119
+ if (!extra) return base;
120
+ const baseKeys = new Set(base.entries.map((entry) => entry.key));
121
+ const missing = extra.entries.filter((entry) => !baseKeys.has(entry.key));
122
+ if (missing.length === 0) return base;
123
+ const merged = new Map(base.entries.map((entry) => [entry.key, entry]));
124
+ for (const entry of missing) merged.set(entry.key, entry);
125
+ const order = [];
126
+ const seen = /* @__PURE__ */ new Set();
127
+ for (const key of [...extra.order, ...base.order]) {
128
+ if (seen.has(key) || !merged.has(key)) continue;
129
+ seen.add(key);
130
+ order.push(key);
131
+ }
132
+ for (const key of merged.keys()) {
133
+ if (seen.has(key)) continue;
134
+ seen.add(key);
135
+ order.push(key);
136
+ }
137
+ return {
138
+ ...base,
139
+ entries: order.map((key) => merged.get(key)),
140
+ order,
141
+ updatedAt: Math.max(base.updatedAt, extra.updatedAt)
142
+ };
143
+ }
144
+ function addKeys(session, keys, at) {
145
+ const have = new Set(session.entries.map((entry) => entry.key));
146
+ const added = [];
147
+ for (const key of keys) {
148
+ if (have.has(key)) continue;
149
+ have.add(key);
150
+ added.push({
151
+ key,
152
+ phase: "pending",
153
+ requestId: batchRequestId(session.id, key),
154
+ targetVersion: null,
155
+ restartRequired: false,
156
+ error: null,
157
+ updatedAt: at
158
+ });
159
+ }
160
+ if (added.length === 0) return session;
161
+ return {
162
+ ...session,
163
+ entries: [...session.entries, ...added],
164
+ order: orderTargets([...session.order, ...added.map((entry) => entry.key)], session.selfKey),
165
+ updatedAt: at
166
+ };
167
+ }
168
+ function validateTargets(raw) {
169
+ if (!Array.isArray(raw) || raw.length === 0) {
170
+ throw new Error("[dsh-plugin-update] \u6279\u91CF\u76EE\u6807\u6E05\u5355 targets \u5FC5\u586B\uFF1A\u81F3\u5C11\u4E00\u4E2A\u76EE\u6807");
171
+ }
172
+ const out = [];
173
+ const seenKeys = /* @__PURE__ */ new Set();
174
+ const seenPrefixes = /* @__PURE__ */ new Set();
175
+ for (const item of raw) {
176
+ const spec = asRecord(item);
177
+ const key = firstText(spec.key);
178
+ if (!key) throw new Error("[dsh-plugin-update] \u6279\u91CF\u76EE\u6807\u7F3A\u5C11\u7A33\u5B9A\u952E key");
179
+ if (seenKeys.has(key)) throw new Error("[dsh-plugin-update] \u6279\u91CF\u76EE\u6807\u952E\u91CD\u590D\uFF1A" + key);
180
+ seenKeys.add(key);
181
+ const prefix = assertPrefix(spec.prefix, "\u76EE\u6807 " + key + " \u7684\u5355\u63D2\u4EF6\u7535\u8BDD\u524D\u7F00 prefix");
182
+ if (seenPrefixes.has(prefix)) throw new Error("[dsh-plugin-update] \u6279\u91CF\u76EE\u6807\u7535\u8BDD\u524D\u7F00\u91CD\u590D\uFF1A" + prefix);
183
+ seenPrefixes.add(prefix);
184
+ if (!firstText(spec.packageName)) throw new Error("[dsh-plugin-update] \u6279\u91CF\u76EE\u6807\u7F3A\u5C11\u76EE\u6807\u5305\u540D packageName\uFF1A" + key);
185
+ out.push(spec);
186
+ }
187
+ return out;
188
+ }
189
+ function pluginIdOf(spec, batchPrefix) {
190
+ if (typeof spec.pluginId === "string" && spec.pluginId) return assertPluginId(spec.pluginId);
191
+ const derived = batchPrefix + "-" + spec.key;
192
+ try {
193
+ return assertPluginId(derived);
194
+ } catch {
195
+ return assertPluginId(derived.replace(/[^A-Za-z0-9._~-]+/g, "-"));
196
+ }
197
+ }
198
+ const EMPTY_CACHE = {
199
+ snapshot: null,
200
+ manual: null,
201
+ queue: null,
202
+ profileName: null,
203
+ error: null,
204
+ diag: null
205
+ };
206
+ function defaultTimers() {
207
+ return {
208
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
209
+ clearTimeout: (handle) => clearTimeout(handle)
210
+ };
211
+ }
212
+ async function skippedVersionsOf(rt) {
213
+ if (!rt.skipPorts) return [];
214
+ try {
215
+ return await rt.skipPorts.readSkipped();
216
+ } catch {
217
+ return [];
218
+ }
219
+ }
220
+ async function callPhone(rt, name, args) {
221
+ const handler = rt.host.handlers[name];
222
+ if (typeof handler !== "function") return { ok: false, error: "check-failed", errorKind: "internal" };
223
+ let reply;
224
+ try {
225
+ reply = await handler(args);
226
+ } catch (error) {
227
+ return { ok: false, ...errorPayloadOf(error) };
228
+ }
229
+ const value = asRecord(reply);
230
+ const next = { ...rt.cache };
231
+ if (value["ok"] === true) {
232
+ if ("snapshot" in value) next.snapshot = value["snapshot"] ?? null;
233
+ if ("manual" in value) next.manual = firstText(value["manual"]);
234
+ if ("queue" in value) next.queue = value["queue"] ?? null;
235
+ if ("env" in value) next.profileName = firstText(asRecord(value["env"])["profileName"]);
236
+ next.error = null;
237
+ next.diag = null;
238
+ } else {
239
+ next.diag = "diag" in value ? value["diag"] ?? null : null;
240
+ }
241
+ rt.cache = next;
242
+ return value;
243
+ }
244
+ async function defaultCheck(rt) {
245
+ const reply = await callPhone(rt, rt.host.phoneNames.updateCheck, { includeEnv: true, includeQueue: true });
246
+ if (reply["ok"] !== true) return { kind: "failed", error: codeOfReply(reply) };
247
+ const snapshot = snapshotOf(reply);
248
+ const latest = firstText(snapshot["latestVersion"]);
249
+ if (!latest) return { kind: "current" };
250
+ if (isVersionSkipped(await skippedVersionsOf(rt), latest)) return { kind: "skipped", version: latest };
251
+ const base = firstText(snapshot["installedVersion"], snapshot["runningVersion"]);
252
+ if (base) {
253
+ let newer = true;
254
+ try {
255
+ newer = compareReleaseVersions(latest, base) === 1;
256
+ } catch {
257
+ newer = true;
258
+ }
259
+ if (!newer) return { kind: "current" };
260
+ }
261
+ if (snapshot["canInstall"] !== true) {
262
+ const blocked = firstText(snapshot["blockedReason"]);
263
+ return { kind: "failed", error: blocked && KNOWN_ERROR_CODES.includes(blocked) ? blocked : "check-failed" };
264
+ }
265
+ const checkId = firstText(asRecord(reply["receipt"])["checkId"]);
266
+ if (!checkId) return { kind: "failed", error: "check-failed" };
267
+ rt.receipt = { checkId, version: latest };
268
+ return { kind: "update", version: latest };
269
+ }
270
+ function installOutcomeOf(job) {
271
+ const state = job && typeof job["state"] === "string" ? job["state"] : "";
272
+ if (state === "restart-required") return { kind: "done", restartRequired: true };
273
+ if (state === "completed") return { kind: "done", restartRequired: false };
274
+ if (state === "failed" || state === "interrupted") return { kind: "failed", error: jobFailureCode(job) };
275
+ return null;
276
+ }
277
+ async function settleInstall(rt, firstReply) {
278
+ const early = installOutcomeOf(jobOf(snapshotOf(firstReply)));
279
+ if (early) return early;
280
+ const deadline = rt.now() + rt.resolved.installTimeoutMs;
281
+ while (true) {
282
+ if (rt.now() >= deadline) return { kind: "failed", error: "install-failed" };
283
+ await sleep(rt.pollMs);
284
+ const status = await callPhone(rt, rt.host.phoneNames.updateStatus, {});
285
+ if (status["ok"] !== true) return { kind: "failed", error: codeOfReply(status) };
286
+ const outcome = installOutcomeOf(jobOf(snapshotOf(status)));
287
+ if (outcome) return outcome;
288
+ }
289
+ }
290
+ async function queuePositionFor(rt, requestId) {
291
+ try {
292
+ const args = { includeQueue: true };
293
+ if (requestId) args["requestId"] = requestId;
294
+ const reply = await callPhone(rt, rt.host.phoneNames.updateStatus, args);
295
+ if (reply["ok"] !== true) return null;
296
+ const position = asRecord(reply["queue"])["position"];
297
+ return typeof position === "number" && Number.isFinite(position) ? position : null;
298
+ } catch {
299
+ return null;
300
+ }
301
+ }
302
+ async function defaultInstall(rt, key, spec, requestId) {
303
+ let receipt = rt.receipt;
304
+ if (!receipt) {
305
+ const again = await rt.check(key, spec);
306
+ if (again.kind !== "update") return { kind: "failed", error: again.kind === "failed" ? again.error : "check-expired" };
307
+ receipt = rt.receipt;
308
+ if (!receipt) return { kind: "failed", error: "check-expired" };
309
+ }
310
+ const phoneRequestId = phoneRequestIdOf(requestId);
311
+ const reply = await callPhone(rt, rt.host.phoneNames.updateInstall, {
312
+ checkId: receipt.checkId,
313
+ requestId: phoneRequestId
314
+ });
315
+ rt.receipt = null;
316
+ if (reply["ok"] === true) return await settleInstall(rt, reply);
317
+ const code = codeOfReply(reply);
318
+ if (code === "update-busy") {
319
+ return { kind: "queued", position: await queuePositionFor(rt, phoneRequestId) };
320
+ }
321
+ return { kind: "failed", error: code };
322
+ }
323
+ function createMultiHostUpdate(deps = {}, options) {
324
+ const input = deps ?? {};
325
+ const prefix = assertPrefix(options && options.prefix, "\u6279\u91CF\u7535\u8BDD\u524D\u7F00 prefix");
326
+ const specs = validateTargets(options && options.targets);
327
+ const byKey = new Map(specs.map((spec) => [spec.key, spec]));
328
+ const orderedSpecs = orderTargets(specs.map((spec) => spec.key), options.selfKey ?? null).map(
329
+ (key) => byKey.get(key)
330
+ );
331
+ const now = typeof input.now === "function" ? input.now : Date.now;
332
+ const timers = input.timers ?? defaultTimers();
333
+ const randomId = typeof input.randomId === "function" ? input.randomId : () => Math.random().toString(36).slice(2, 12);
334
+ const pollMs = typeof input.installPollMs === "number" && Number.isFinite(input.installPollMs) && input.installPollMs >= 0 ? Math.floor(input.installPollMs) : DEFAULT_INSTALL_POLL_MS;
335
+ const env = input.readerOverrides && input.readerOverrides.env || process.env;
336
+ const batchHome = firstText(input.scope?.homeDir, input.readerOverrides?.homeDir, input.config?.homeDir) ?? defaultHomeDir(env, homedir());
337
+ const batchProfile = firstText(input.scope?.profileDir, input.readerOverrides?.profileDir);
338
+ const perTargetOverrides = (spec) => (input.readerOverridesFor ? input.readerOverridesFor(spec) : void 0) ?? {};
339
+ const scopeOf = (spec) => {
340
+ const per = perTargetOverrides(spec);
341
+ return {
342
+ homeDir: firstText(spec.homeDir, per.homeDir) ?? batchHome,
343
+ profileDir: firstText(spec.profileDir, per.profileDir, batchProfile)
344
+ };
345
+ };
346
+ const scopes = new Map(specs.map((spec) => [spec.key, scopeOf(spec)]));
347
+ const distinctScopes = /* @__PURE__ */ new Set();
348
+ for (const scope of scopes.values()) distinctScopes.add(scope.homeDir + "\0" + (scope.profileDir ?? "\0"));
349
+ const scopeError = distinctScopes.size > 1 ? "cross-scope" : null;
350
+ const batchScope = scopeError ? null : scopes.get(specs[0].key);
351
+ const queue = batchScope && batchScope.profileDir ? createUpdateQueuePorts(batchScope.homeDir, batchScope.profileDir) : null;
352
+ const baseOverrides = { ...input.readerOverrides ?? {} };
353
+ if (queue && !(baseOverrides.readQueue && baseOverrides.writeQueue)) {
354
+ baseOverrides.readQueue = () => queue.readQueue();
355
+ baseOverrides.writeQueue = (state) => queue.writeQueue(state);
356
+ if (typeof baseOverrides.tryAcquireGlobalLock !== "function") {
357
+ baseOverrides.tryAcquireGlobalLock = (lockId, pluginId, opts) => queue.tryAcquireGlobalLock(lockId, pluginId, opts ?? {});
358
+ }
359
+ if (typeof baseOverrides.releaseGlobalLock !== "function") {
360
+ baseOverrides.releaseGlobalLock = (lockId) => queue.releaseGlobalLock(lockId);
361
+ }
362
+ }
363
+ const targetOverrides = (spec) => {
364
+ const per = input.readerOverridesFor ? input.readerOverridesFor(spec) : void 0;
365
+ return per ? { ...baseOverrides, ...per } : baseOverrides;
366
+ };
367
+ const injectedCheck = input.transport ? input.transport.check : void 0;
368
+ const injectedInstall = input.transport ? input.transport.install : void 0;
369
+ const configBase = input.config ?? {};
370
+ const runtimes = orderedSpecs.map((spec) => {
371
+ const config = {
372
+ ...configBase,
373
+ pluginId: pluginIdOf(spec, prefix),
374
+ prefix: spec.prefix,
375
+ targetPackageName: spec.packageName
376
+ };
377
+ const resolved = resolveUpdateConfig(config);
378
+ const scope = scopes.get(spec.key);
379
+ let runtime;
380
+ runtime = {
381
+ spec,
382
+ config,
383
+ resolved,
384
+ host: createHostUpdate(
385
+ {
386
+ ctx: input.ctx,
387
+ logCtx: input.logCtx ?? null,
388
+ desktopPnpm: input.desktopPnpm,
389
+ pluginManager: input.pluginManager,
390
+ readerOverrides: targetOverrides(spec)
391
+ },
392
+ config
393
+ ),
394
+ pluginId: resolved.pluginId,
395
+ homeDir: scope.homeDir,
396
+ profileDir: scope.profileDir,
397
+ check: injectedCheck ?? (() => defaultCheck(runtime)),
398
+ install: injectedInstall ?? ((key, one, requestId) => defaultInstall(runtime, key, one, requestId)),
399
+ skipPorts: scope.profileDir ? createSkipDiskPorts(scope.homeDir, resolved.pluginId, scope.profileDir) : null,
400
+ cache: { ...EMPTY_CACHE },
401
+ receipt: null,
402
+ now,
403
+ pollMs
404
+ };
405
+ return runtime;
406
+ });
407
+ const runtimeByKey = new Map(runtimes.map((rt) => [rt.spec.key, rt]));
408
+ const batchPorts = batchScope && batchScope.profileDir ? createBatchDiskPorts(batchScope.homeDir, batchScope.profileDir) : null;
409
+ let memSession = null;
410
+ let driveActive = false;
411
+ let liveSession = null;
412
+ let lastRun = null;
413
+ let drainTimer = null;
414
+ let disposed = false;
415
+ async function readSession() {
416
+ if (batchPorts) return await batchPorts.readBatch();
417
+ return memSession ?? emptyBatchSession();
418
+ }
419
+ async function saveSession(session) {
420
+ if (batchPorts) {
421
+ await batchPorts.writeBatch(session);
422
+ return;
423
+ }
424
+ memSession = session;
425
+ }
426
+ async function clearSession() {
427
+ if (batchPorts) {
428
+ await batchPorts.clearBatch();
429
+ return;
430
+ }
431
+ memSession = emptyBatchSession();
432
+ }
433
+ function newSessionId() {
434
+ const suffix = String(randomId() ?? "").replace(/[^A-Za-z0-9._~-]/g, "").slice(0, 24);
435
+ return now().toString(36) + "-" + (suffix || hash8(String(now())));
436
+ }
437
+ function progressOf(session) {
438
+ if (session.entries.length > 0) return batchProgress(session);
439
+ const total = runtimes.length;
440
+ return { total, done: 0, failed: 0, skipped: 0, current: 0, pending: total, finished: false };
441
+ }
442
+ async function reconcileFailed(rt, entry) {
443
+ if (entry.phase !== "failed" || !entry.targetVersion) return null;
444
+ const snapshot = asRecord(rt.cache.snapshot);
445
+ const installed = firstText(snapshot["installedVersion"]);
446
+ if (!installed || installed !== entry.targetVersion) return null;
447
+ try {
448
+ const latest = await readSession();
449
+ const current = batchEntryOf(latest, entry.key);
450
+ if (!current || current.phase !== "failed") return null;
451
+ const marked = markBatchEntry(
452
+ latest,
453
+ entry.key,
454
+ { phase: "done", restartRequired: snapshot["blockedReason"] === "pending-restart" },
455
+ now()
456
+ );
457
+ if (!marked.changed) return null;
458
+ await saveSession(marked.session);
459
+ const corrected = batchEntryOf(marked.session, entry.key);
460
+ return corrected ? { session: marked.session, entry: corrected } : null;
461
+ } catch {
462
+ return null;
463
+ }
464
+ }
465
+ async function buildRows(session, refresh) {
466
+ let current = session;
467
+ const rows = [];
468
+ for (const rt of runtimes) {
469
+ let entry = batchEntryOf(current, rt.spec.key);
470
+ if (refresh && !driveActive) {
471
+ await callPhone(rt, rt.host.phoneNames.updateStatus, { includeEnv: true, includeQueue: true });
472
+ if (entry) {
473
+ const corrected = await reconcileFailed(rt, entry);
474
+ if (corrected) {
475
+ current = corrected.session;
476
+ entry = corrected.entry;
477
+ }
478
+ }
479
+ }
480
+ const phase = entry ? entry.phase : "pending";
481
+ rows.push({
482
+ key: rt.spec.key,
483
+ title: rt.spec.title ?? rt.spec.key,
484
+ phase,
485
+ targetVersion: entry ? entry.targetVersion : null,
486
+ restartRequired: entry ? entry.restartRequired === true : false,
487
+ error: entry ? entry.error : rt.cache.error,
488
+ snapshot: rt.cache.snapshot,
489
+ manual: rt.cache.manual,
490
+ queue: rt.cache.queue,
491
+ profileName: rt.cache.profileName,
492
+ phoneNames: rt.host.phoneNames,
493
+ // 新增两格(#25 解法三,只增不改):该目标的宿主插件标识,与最近一次失败回包的诊断。
494
+ pluginId: rt.pluginId,
495
+ diag: rt.cache.diag
496
+ });
497
+ }
498
+ return { rows, session: current };
499
+ }
500
+ async function table(session, refresh = true) {
501
+ const built = await buildRows(session, refresh);
502
+ return { ok: true, session: built.session, rows: built.rows, progress: progressOf(built.session) };
503
+ }
504
+ function denied() {
505
+ if (!scopeError) return null;
506
+ return { ok: false, error: scopeError, errorKind: scopeError };
507
+ }
508
+ function runtimeOf(key) {
509
+ const rt = runtimeByKey.get(key);
510
+ if (!rt) throw batchFail("check-failed");
511
+ return rt;
512
+ }
513
+ async function checkByKey(key) {
514
+ const rt = runtimeOf(key);
515
+ return await rt.check(key, rt.spec);
516
+ }
517
+ async function installByKey(key, requestId, version) {
518
+ const rt = runtimeOf(key);
519
+ const outcome = await rt.install(key, rt.spec, requestId, version);
520
+ if (outcome.kind === "queued") {
521
+ const position = typeof outcome.position === "number" && Number.isFinite(outcome.position) ? outcome.position : null;
522
+ if (position !== null) rt.cache = { ...rt.cache, queue: { ...asRecord(rt.cache.queue), position } };
523
+ }
524
+ return outcome;
525
+ }
526
+ function resetForRetry(session, selected, at) {
527
+ let changed = false;
528
+ const entries = session.entries.map((entry) => {
529
+ const picked = selected === null || selected.has(entry.key);
530
+ if (!picked) return entry;
531
+ const retryable = entry.phase === "failed" || selected !== null && entry.phase === "skipped";
532
+ if (!retryable) return entry;
533
+ changed = true;
534
+ return { ...entry, phase: "pending", error: null, updatedAt: at };
535
+ });
536
+ return changed ? { ...session, entries, updatedAt: at } : session;
537
+ }
538
+ async function driveLocked(session, selected, budget) {
539
+ let full = mergeInto(session, liveSession);
540
+ liveSession = full;
541
+ const steps = [];
542
+ let remaining = budget;
543
+ let guard = 0;
544
+ try {
545
+ while (true) {
546
+ full = mergeInto(full, liveSession);
547
+ liveSession = full;
548
+ const view = selected === null ? full : {
549
+ ...full,
550
+ order: full.order.filter((key) => selected.has(key)),
551
+ entries: full.entries.filter((entry) => selected.has(entry.key))
552
+ };
553
+ const result = await runBatch(
554
+ view,
555
+ {
556
+ check: (key) => checkByKey(key),
557
+ install: (key, requestId, version) => installByKey(key, requestId, version),
558
+ save: async (next) => {
559
+ const merged = mergeInto(next, liveSession);
560
+ liveSession = merged;
561
+ full = merged;
562
+ await saveSession(merged);
563
+ },
564
+ now: () => now()
565
+ },
566
+ { maxSteps: 1 }
567
+ );
568
+ for (const step of result.steps) steps.push(step);
569
+ remaining -= 1;
570
+ guard += 1;
571
+ if (result.stoppedBecause !== "max-steps") {
572
+ lastRun = { stoppedBecause: result.stoppedBecause, steps };
573
+ return full;
574
+ }
575
+ if (remaining <= 0 || guard >= DRIVE_STEP_GUARD) {
576
+ lastRun = { stoppedBecause: "max-steps", steps };
577
+ return full;
578
+ }
579
+ }
580
+ } finally {
581
+ liveSession = null;
582
+ }
583
+ }
584
+ async function drive(session, selected, budget) {
585
+ if (driveActive) throw batchFail("update-busy");
586
+ driveActive = true;
587
+ try {
588
+ return await driveLocked(session, selected, budget);
589
+ } finally {
590
+ driveActive = false;
591
+ }
592
+ }
593
+ function selectedKeysOf(args) {
594
+ const raw = args["keys"];
595
+ if (!Array.isArray(raw)) return null;
596
+ const selected = /* @__PURE__ */ new Set();
597
+ for (const item of raw) {
598
+ if (typeof item === "string" && runtimeByKey.has(item)) selected.add(item);
599
+ }
600
+ return selected;
601
+ }
602
+ async function statusPhone() {
603
+ const refuse = denied();
604
+ if (refuse) return refuse;
605
+ return await table(await readSession());
606
+ }
607
+ async function checkPhone() {
608
+ const refuse = denied();
609
+ if (refuse) return refuse;
610
+ const session = await readSession();
611
+ if (!driveActive) {
612
+ for (const rt of runtimes) {
613
+ try {
614
+ const outcome = await rt.check(rt.spec.key, rt.spec);
615
+ rt.cache = { ...rt.cache, error: outcome.kind === "failed" ? outcome.error : null };
616
+ } catch (error) {
617
+ rt.cache = { ...rt.cache, error: errorPayloadOf(error).error };
618
+ }
619
+ }
620
+ }
621
+ return await table(session, false);
622
+ }
623
+ async function joinWhileBusy(selected) {
624
+ let session = liveSession ?? await readSession();
625
+ if (session.entries.length === 0) {
626
+ session = createBatchSession({
627
+ id: newSessionId(),
628
+ keys: runtimes.map((rt) => rt.spec.key),
629
+ selfKey: options.selfKey ?? null,
630
+ stopOnFailure: options.stopOnFailure === true,
631
+ now: now()
632
+ });
633
+ liveSession = session;
634
+ await saveSession(session);
635
+ } else {
636
+ let next = selected && selected.size > 0 ? addKeys(session, selected, now()) : session;
637
+ next = resetForRetry(next, selected, now());
638
+ if (next !== session) {
639
+ session = next;
640
+ liveSession = session;
641
+ await saveSession(session);
642
+ }
643
+ }
644
+ return await table(session, false);
645
+ }
646
+ async function installPhone(args) {
647
+ const refuse = denied();
648
+ if (refuse) return refuse;
649
+ const selected = selectedKeysOf(args);
650
+ if (driveActive) return await joinWhileBusy(selected);
651
+ if (selected && selected.size === 0) return await table(await readSession());
652
+ driveActive = true;
653
+ let driven;
654
+ try {
655
+ let session = await readSession();
656
+ const reuse = session.entries.length > 0 && (selected !== null || !isBatchFinished(session));
657
+ if (!reuse) {
658
+ session = createBatchSession({
659
+ id: newSessionId(),
660
+ keys: runtimes.map((rt) => rt.spec.key),
661
+ selfKey: options.selfKey ?? null,
662
+ stopOnFailure: options.stopOnFailure === true,
663
+ now: now()
664
+ });
665
+ await saveSession(session);
666
+ }
667
+ const retried = resetForRetry(session, selected, now());
668
+ if (retried !== session) {
669
+ session = retried;
670
+ await saveSession(session);
671
+ }
672
+ driven = await driveLocked(session, selected, Number.POSITIVE_INFINITY);
673
+ } catch (error) {
674
+ return { ok: false, ...errorPayloadOf(error) };
675
+ } finally {
676
+ driveActive = false;
677
+ }
678
+ return await table(driven);
679
+ }
680
+ async function resumePhone() {
681
+ const refuse = denied();
682
+ if (refuse) return refuse;
683
+ if (driveActive) return { ok: false, error: "update-busy", errorKind: "update-busy" };
684
+ const disk = await readSession();
685
+ if (disk.entries.length === 0) return await table(disk);
686
+ const resumed = resumeBatchSession(disk, now());
687
+ if (resumed !== disk) await saveSession(resumed);
688
+ try {
689
+ return await table(await drive(resumed, null, Number.POSITIVE_INFINITY));
690
+ } catch (error) {
691
+ return { ok: false, ...errorPayloadOf(error) };
692
+ }
693
+ }
694
+ async function cancelPhone() {
695
+ const refuse = denied();
696
+ if (refuse) return refuse;
697
+ if (driveActive) return { ok: false, error: "update-busy", errorKind: "update-busy" };
698
+ await clearSession();
699
+ return await table(emptyBatchSession());
700
+ }
701
+ const drainEnabled = options.drain === true;
702
+ const drainIntervalMs = Math.max(
703
+ MIN_DRAIN_INTERVAL_MS,
704
+ typeof options.drainIntervalMs === "number" && Number.isFinite(options.drainIntervalMs) && options.drainIntervalMs > 0 ? Math.floor(options.drainIntervalMs) : DEFAULT_DRAIN_INTERVAL_MS
705
+ );
706
+ async function drainOnce() {
707
+ if (disposed || driveActive || scopeError) return;
708
+ const disk = await readSession();
709
+ if (disk.entries.length === 0 || isBatchFinished(disk)) return;
710
+ try {
711
+ await drive(disk, null, 1);
712
+ } catch {
713
+ }
714
+ }
715
+ async function tickDrain() {
716
+ drainTimer = null;
717
+ if (disposed) return;
718
+ try {
719
+ await drainOnce();
720
+ } catch {
721
+ }
722
+ armDrain();
723
+ }
724
+ function armDrain() {
725
+ if (disposed || !drainEnabled) return;
726
+ drainTimer = timers.setTimeout(() => {
727
+ void tickDrain();
728
+ }, drainIntervalMs);
729
+ }
730
+ const phoneNames = {
731
+ status: prefix + ".batchStatus",
732
+ check: prefix + ".batchCheck",
733
+ install: prefix + ".batchInstall",
734
+ resume: prefix + ".batchResume",
735
+ cancel: prefix + ".batchCancel"
736
+ };
737
+ function phone(fn) {
738
+ return async (args) => {
739
+ try {
740
+ return await fn(asRecord(args));
741
+ } catch (error) {
742
+ return { ok: false, ...errorPayloadOf(error) };
743
+ }
744
+ };
745
+ }
746
+ const handlers = {};
747
+ handlers[phoneNames.status] = phone(() => statusPhone());
748
+ handlers[phoneNames.check] = phone(() => checkPhone());
749
+ handlers[phoneNames.install] = phone((args) => installPhone(args));
750
+ handlers[phoneNames.resume] = phone(() => resumePhone());
751
+ handlers[phoneNames.cancel] = phone(() => cancelPhone());
752
+ for (const rt of runtimes) {
753
+ for (const name of Object.keys(rt.host.handlers)) {
754
+ const handler = rt.host.handlers[name];
755
+ handlers[name] = (args) => handler(asRecord(args));
756
+ }
757
+ }
758
+ armDrain();
759
+ return {
760
+ handlers,
761
+ phoneNames,
762
+ targets: orderedSpecs,
763
+ get lastRun() {
764
+ return lastRun;
765
+ },
766
+ dispose() {
767
+ disposed = true;
768
+ if (drainTimer !== null) {
769
+ timers.clearTimeout(drainTimer);
770
+ drainTimer = null;
771
+ }
772
+ }
773
+ };
774
+ }
775
+ export {
776
+ createMultiHostUpdate,
777
+ phoneRequestIdOf
778
+ };