@deepseek-ai/dsh-app-boot 0.2.1-rc.5 → 0.2.2-alpha.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/lib/index.js CHANGED
@@ -2,65 +2,509 @@ import { createRequire, isBuiltin } from "node:module";
2
2
  import { fileURLToPath, pathToFileURL } from "node:url";
3
3
  import fs, { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { inspect, parseEnv } from "node:util";
5
+ import { Context, Service } from "@deepseek-ai/cordis";
5
6
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
6
7
  import * as yaml from "js-yaml";
7
8
  import { load } from "js-yaml";
8
- import { Context, Service } from "@deepseek-ai/cordis";
9
9
  import Loader, { EntryGroup, EntryTree, ModuleLoader, isJsExpr } from "@deepseek-ai/cordis-plugin-loader";
10
- import { access, constants, mkdir, readFile, realpath, rename, writeFile } from "node:fs/promises";
10
+ import { access, constants, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
11
11
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
12
12
  import Group from "@deepseek-ai/cordis-plugin-group";
13
13
  import { dshHomePath, resolveDshHome } from "@deepseek-ai/dsh-home-paths";
14
14
  import { createLaunchEnvironmentSnapshot } from "@deepseek-ai/dsh-launch-environment";
15
+ import { INSTALLATION_OWNED_PROFILE_TUPLES, PROFILE_TEMPLATES, RETIRED_BUNDLES } from "@deepseek-ai/dsh-official-bundles";
15
16
  import semver, { parse } from "semver";
16
17
  import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
17
18
  import { getEnvironmentData, setEnvironmentData } from "node:worker_threads";
18
19
  import { imports } from "resolve.exports";
19
- //#region ../../../vendor/include/src/index.ts
20
- const JsExpr = new yaml.Type("tag:yaml.org,2002:js", {
21
- kind: "scalar",
22
- resolve: (data) => typeof data === "string",
23
- construct: (data) => ({ __jsExpr: data }),
24
- predicate: isJsExpr,
25
- represent: (data) => data["__jsExpr"]
26
- });
20
+ //#region lib/types/startup-diagnostics.js
21
+ /** Configuration-free snapshots captured before a failed startup disposes its plugins. */
22
+ function stateName(state) {
23
+ return {
24
+ 0: "pending",
25
+ 1: "loading",
26
+ 2: "active",
27
+ 3: "failed",
28
+ 4: "disposed",
29
+ 5: "unloading"
30
+ }[state];
31
+ }
32
+ function pluginState(ctx, entry) {
33
+ let state;
34
+ try {
35
+ state = entry.disabled ? "disabled" : entry.fiber === void 0 ? "failed" : stateName(entry.fiber.state);
36
+ } catch (_error) {
37
+ state = "failed";
38
+ }
39
+ let version = "unknown";
40
+ const source = entry.parent.tree.ctx.baseUrl;
41
+ try {
42
+ if (source !== void 0) version = ctx.get("pluginPackages")?.packageOf(entry.options.name, source)?.version ?? "unknown";
43
+ } catch (_error) {}
44
+ const fiber = entry.fiber;
45
+ return {
46
+ id: entry.id,
47
+ module: entry.options.name,
48
+ version,
49
+ state,
50
+ source: source ?? "unknown",
51
+ missing: state === "pending" && fiber !== void 0 ? Object.keys(fiber.inject).filter((name) => fiber.ctx.get(name) === void 0) : []
52
+ };
53
+ }
27
54
  /**
28
- * The entry-list YAML dialect: `!!js` scalars round-trip as expression nodes
29
- * the Loader evaluates at entry activation. Exported so config tooling
30
- * (`dsh --dump-config`) parses and prints exactly the dialect this include
31
- * mounts.
55
+ * Capture Loader state and registered service ownership before teardown.
56
+ * Providers that never registered cannot be inferred from their package names.
57
+ * @param ctx - Live application context with its Loader still mounted.
58
+ * @returns At most 256 plugin rows and 256 missing-service observations, with omission counts.
32
59
  */
33
- const entryListSchema = yaml.JSON_SCHEMA.extend(JsExpr);
34
- const schema = entryListSchema;
35
- const writable = {
36
- ".json": "application/json",
37
- ".yaml": "application/yaml",
38
- ".yml": "application/yaml"
39
- };
40
- const supported = new Set(Object.keys(writable));
41
- const WRITE_RETRY_LIMIT = 10;
42
- const WRITE_RETRY_DELAY_MS = 50;
43
- function retryableWriteError(error) {
44
- const code = error?.code;
45
- return code === "EACCES" || code === "EBUSY" || code === "EPERM";
60
+ function captureStartupComposition(ctx) {
61
+ const rows = [...ctx.loader.entries()].map((entry) => ({
62
+ entry,
63
+ state: pluginState(ctx, entry)
64
+ }));
65
+ rows.sort((a, b) => Number(a.state.state === "active") - Number(b.state.state === "active"));
66
+ const services = [];
67
+ let omittedServices = 0;
68
+ const store = ctx.reflect.store;
69
+ for (const { entry, state } of rows) {
70
+ const fiber = entry.fiber;
71
+ if (fiber === void 0) continue;
72
+ for (const service of state.missing) {
73
+ if (services.length === 256) {
74
+ omittedServices++;
75
+ continue;
76
+ }
77
+ const scope = fiber.ctx[Context.isolate][service];
78
+ const providers = [];
79
+ for (const key of Object.getOwnPropertySymbols(store)) {
80
+ const impl = store[key];
81
+ if (impl?.name !== service) continue;
82
+ providers.push({
83
+ entry: impl.ctx.fiber.entry?.id ?? "unknown",
84
+ state: stateName(impl.ctx.fiber.state),
85
+ visibility: key === scope ? "same-scope" : "other-scope"
86
+ });
87
+ }
88
+ services.push({
89
+ consumer: state.id,
90
+ service,
91
+ providers
92
+ });
93
+ }
94
+ }
95
+ return {
96
+ plugins: rows.slice(0, 256).map((row) => row.state),
97
+ services,
98
+ omittedPlugins: Math.max(0, rows.length - 256),
99
+ omittedServices
100
+ };
101
+ }
102
+ //#endregion
103
+ //#region ../../../vendor/cosmokit/src/misc.ts
104
+ /** Return true when a value is `null` or `undefined`. */
105
+ function isNullable(value) {
106
+ return value === null || value === void 0;
107
+ }
108
+ /** Omit selected keys from a shallow object copy. */
109
+ function omit(source, keys) {
110
+ if (!keys) return { ...source };
111
+ const result = { ...source };
112
+ for (const key of keys) Reflect.deleteProperty(result, key);
113
+ return result;
114
+ }
115
+ //#endregion
116
+ //#region ../../../vendor/cosmokit/src/volatile.ts
117
+ /** Shared config references used by schema validators and plugin runtimes. */
118
+ const write = Symbol.for("cosmokit.volatile.write");
119
+ /**
120
+ * Identify references across ESM/CJS copies of the shared library.
121
+ * @param value - a parsed config value.
122
+ * @returns whether the value implements the shared reference protocol.
123
+ */
124
+ function isVolatile(value) {
125
+ return typeof value === "object" && value !== null && write in value;
126
+ }
127
+ //#endregion
128
+ //#region ../../../vendor/cosmokit/src/types.ts
129
+ /** Test values using `instanceof` with a `toStringTag` fallback. */
130
+ function is(type, value) {
131
+ if (arguments.length === 1) return (value) => is(type, value);
132
+ return type in globalThis && value instanceof globalThis[type] || Object.prototype.toString.call(value).slice(8, -1) === type;
133
+ }
134
+ function isArrayBufferLike(value) {
135
+ return is("ArrayBuffer", value) || is("SharedArrayBuffer", value);
136
+ }
137
+ function isArrayBufferSource(value) {
138
+ return isArrayBufferLike(value) || ArrayBuffer.isView(value);
139
+ }
140
+ let Binary;
141
+ (function(_Binary) {
142
+ _Binary.is = isArrayBufferLike;
143
+ _Binary.isSource = isArrayBufferSource;
144
+ function fromSource(source) {
145
+ if (ArrayBuffer.isView(source)) return source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength);
146
+ else return source;
147
+ }
148
+ _Binary.fromSource = fromSource;
149
+ function toBase64(source) {
150
+ source = fromSource(source);
151
+ if (typeof Buffer !== "undefined") return Buffer.from(source).toString("base64");
152
+ let binary = "";
153
+ const bytes = new Uint8Array(source);
154
+ for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
155
+ return btoa(binary);
156
+ }
157
+ _Binary.toBase64 = toBase64;
158
+ function fromBase64(source) {
159
+ if (typeof Buffer !== "undefined") return fromSource(Buffer.from(source, "base64"));
160
+ return Uint8Array.from(atob(source), (c) => c.charCodeAt(0));
161
+ }
162
+ _Binary.fromBase64 = fromBase64;
163
+ function toHex(source) {
164
+ source = fromSource(source);
165
+ if (typeof Buffer !== "undefined") return Buffer.from(source).toString("hex");
166
+ return Array.from(new Uint8Array(source), (byte) => byte.toString(16).padStart(2, "0")).join("");
167
+ }
168
+ _Binary.toHex = toHex;
169
+ function fromHex(source) {
170
+ if (typeof Buffer !== "undefined") return fromSource(Buffer.from(source, "hex"));
171
+ const hex = source.length % 2 === 0 ? source : source.slice(0, source.length - 1);
172
+ const buffer = [];
173
+ for (let i = 0; i < hex.length; i += 2) buffer.push(parseInt(`${hex[i]}${hex[i + 1]}`, 16));
174
+ return Uint8Array.from(buffer).buffer;
175
+ }
176
+ _Binary.fromHex = fromHex;
177
+ })(Binary || (Binary = {}));
178
+ Binary.fromBase64;
179
+ Binary.toBase64;
180
+ Binary.fromHex;
181
+ Binary.toHex;
182
+ /**
183
+ * Compare values recursively, treating two volatile references as equal regardless of value.
184
+ * Strict comparison distinguishes null/undefined, treats opaque objects by identity,
185
+ * compares URLs by normalized href, treats array holes as undefined, and considers distinct cyclic structures unequal.
186
+ * @param a - first value.
187
+ * @param b - second value.
188
+ * @param strict - whether to require strict data equality outside volatile references.
189
+ * @returns whether the values compare equal.
190
+ */
191
+ function deepEqual(a, b, strict) {
192
+ const ancestors = /* @__PURE__ */ new Set();
193
+ function compare(a, b) {
194
+ if (a === b) return true;
195
+ if (isVolatile(a) || isVolatile(b)) return isVolatile(a) && isVolatile(b);
196
+ if (!strict && isNullable(a) && isNullable(b)) return true;
197
+ if (typeof a !== typeof b || typeof a !== "object" || !a || !b) return false;
198
+ if (ancestors.has(a)) return false;
199
+ function check(test, then) {
200
+ return test(a) ? test(b) ? then(a, b) : false : test(b) ? false : void 0;
201
+ }
202
+ ancestors.add(a);
203
+ try {
204
+ return check(Array.isArray, (a, b) => {
205
+ if (a.length !== b.length) return false;
206
+ for (let index = 0; index < a.length; index++) if (!compare(a[index], b[index])) return false;
207
+ return true;
208
+ }) ?? check(is("Date"), (a, b) => a.valueOf() === b.valueOf()) ?? check(is("URL"), (a, b) => a.href === b.href) ?? check(is("RegExp"), (a, b) => a.source === b.source && a.flags === b.flags) ?? check(isArrayBufferLike, (a, b) => {
209
+ if (a.byteLength !== b.byteLength) return false;
210
+ const viewA = new Uint8Array(a);
211
+ const viewB = new Uint8Array(b);
212
+ for (let i = 0; i < viewA.length; i++) if (viewA[i] !== viewB[i]) return false;
213
+ return true;
214
+ }) ?? ((!strict || [a, b].every((value) => Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) && Object.keys({
215
+ ...a,
216
+ ...b
217
+ }).every((key) => compare(a[key], b[key])));
218
+ } finally {
219
+ ancestors.delete(a);
220
+ }
221
+ }
222
+ return compare(a, b);
223
+ }
224
+ //#endregion
225
+ //#region ../../../vendor/cosmokit/src/time.ts
226
+ let Time;
227
+ (function(_Time) {
228
+ _Time.millisecond = 1;
229
+ const second = _Time.second = 1e3;
230
+ const minute = _Time.minute = second * 60;
231
+ const hour = _Time.hour = minute * 60;
232
+ const day = _Time.day = hour * 24;
233
+ const week = _Time.week = day * 7;
234
+ let timezoneOffset = (/* @__PURE__ */ new Date()).getTimezoneOffset();
235
+ function setTimezoneOffset(offset) {
236
+ timezoneOffset = offset;
237
+ }
238
+ _Time.setTimezoneOffset = setTimezoneOffset;
239
+ function getTimezoneOffset() {
240
+ return timezoneOffset;
241
+ }
242
+ _Time.getTimezoneOffset = getTimezoneOffset;
243
+ function getDateNumber(date = /* @__PURE__ */ new Date(), offset) {
244
+ if (typeof date === "number") date = new Date(date);
245
+ if (offset === void 0) offset = timezoneOffset;
246
+ return Math.floor((date.valueOf() / minute - offset) / 1440);
247
+ }
248
+ _Time.getDateNumber = getDateNumber;
249
+ function fromDateNumber(value, offset) {
250
+ const date = new Date(value * day);
251
+ if (offset === void 0) offset = timezoneOffset;
252
+ return new Date(+date + offset * minute);
253
+ }
254
+ _Time.fromDateNumber = fromDateNumber;
255
+ const numeric = /\d+(?:\.\d+)?/.source;
256
+ const timeRegExp = new RegExp(`^${[
257
+ "w(?:eek(?:s)?)?",
258
+ "d(?:ay(?:s)?)?",
259
+ "h(?:our(?:s)?)?",
260
+ "m(?:in(?:ute)?(?:s)?)?",
261
+ "s(?:ec(?:ond)?(?:s)?)?"
262
+ ].map((unit) => `(${numeric}${unit})?`).join("")}$`);
263
+ function parseTime(source) {
264
+ const capture = timeRegExp.exec(source);
265
+ if (!capture) return 0;
266
+ return (parseFloat(capture[1]) * week || 0) + (parseFloat(capture[2]) * day || 0) + (parseFloat(capture[3]) * hour || 0) + (parseFloat(capture[4]) * minute || 0) + (parseFloat(capture[5]) * second || 0);
267
+ }
268
+ _Time.parseTime = parseTime;
269
+ function parseDate(date) {
270
+ const parsed = parseTime(date);
271
+ if (parsed) date = Date.now() + parsed;
272
+ else if (/^\d{1,2}(:\d{1,2}){1,2}$/.test(date)) date = `${(/* @__PURE__ */ new Date()).toLocaleDateString()}-${date}`;
273
+ else if (/^\d{1,2}-\d{1,2}-\d{1,2}(:\d{1,2}){1,2}$/.test(date)) date = `${(/* @__PURE__ */ new Date()).getFullYear()}-${date}`;
274
+ return date ? new Date(date) : /* @__PURE__ */ new Date();
275
+ }
276
+ _Time.parseDate = parseDate;
277
+ function format(ms) {
278
+ const abs = Math.abs(ms);
279
+ if (abs >= day - hour / 2) return Math.round(ms / day) + "d";
280
+ else if (abs >= hour - minute / 2) return Math.round(ms / hour) + "h";
281
+ else if (abs >= minute - second / 2) return Math.round(ms / minute) + "m";
282
+ else if (abs >= second) return Math.round(ms / second) + "s";
283
+ return ms + "ms";
284
+ }
285
+ _Time.format = format;
286
+ function toDigits(source, length = 2) {
287
+ return source.toString().padStart(length, "0");
288
+ }
289
+ _Time.toDigits = toDigits;
290
+ function template(template, time = /* @__PURE__ */ new Date()) {
291
+ return template.replace("yyyy", time.getFullYear().toString()).replace("yy", time.getFullYear().toString().slice(2)).replace("MM", toDigits(time.getMonth() + 1)).replace("dd", toDigits(time.getDate())).replace("hh", toDigits(time.getHours())).replace("mm", toDigits(time.getMinutes())).replace("ss", toDigits(time.getSeconds())).replace("SSS", toDigits(time.getMilliseconds(), 3));
292
+ }
293
+ _Time.template = template;
294
+ })(Time || (Time = {}));
295
+ //#endregion
296
+ //#region ../../../vendor/include/src/journal.ts
297
+ function diffOptions(legacy, options) {
298
+ const changes = {};
299
+ for (const key of new Set([...Object.keys(legacy), ...Object.keys(options)])) {
300
+ if (key === "id") continue;
301
+ if (deepEqual(legacy[key], options[key])) continue;
302
+ changes[key] = options[key];
303
+ }
304
+ return changes;
305
+ }
306
+ /** Fold a later record into an earlier one for the same entry. */
307
+ function mergeRecords(older, newer) {
308
+ if (!older) return newer;
309
+ if (newer.kind === "remove") return older.kind === "upsert" && older.created ? void 0 : newer;
310
+ if (older.kind === "remove") return {
311
+ ...newer,
312
+ created: false
313
+ };
314
+ const relocated = newer.parent !== void 0;
315
+ return {
316
+ kind: "upsert",
317
+ created: older.created,
318
+ parent: relocated ? newer.parent : older.parent,
319
+ position: relocated ? newer.position : older.position,
320
+ changes: {
321
+ ...older.changes,
322
+ ...newer.changes
323
+ }
324
+ };
325
+ }
326
+ function merge(journal, id, record) {
327
+ const merged = mergeRecords(journal.get(id), record);
328
+ if (merged) journal.set(id, merged);
329
+ else journal.delete(id);
330
+ }
331
+ function record$1(journal, change, parent) {
332
+ const { id, options, legacy } = change;
333
+ if (!options) return merge(journal, id, { kind: "remove" });
334
+ const position = change.group.data.indexOf(options);
335
+ if (!legacy) return merge(journal, id, {
336
+ kind: "upsert",
337
+ created: true,
338
+ parent,
339
+ position,
340
+ changes: omit(options, ["id"])
341
+ });
342
+ merge(journal, id, {
343
+ kind: "upsert",
344
+ created: false,
345
+ parent: change.from ? parent : void 0,
346
+ position,
347
+ changes: diffOptions(legacy, options)
348
+ });
349
+ }
350
+ /** Index every entry (including nested group members) by id. */
351
+ function flatten(data, parent = null, result = /* @__PURE__ */ new Map()) {
352
+ data.forEach((options, position) => {
353
+ if (options.id) result.set(options.id, {
354
+ parent,
355
+ position,
356
+ options,
357
+ list: data
358
+ });
359
+ if (options.group && Array.isArray(options.config)) flatten(options.config, options.id ?? null, result);
360
+ });
361
+ return result;
362
+ }
363
+ function applyChanges(options, changes, filter) {
364
+ for (const [key, value] of Object.entries(changes)) {
365
+ if (filter && !filter(key)) continue;
366
+ if (value === void 0) delete options[key];
367
+ else options[key] = value;
368
+ }
369
+ }
370
+ function detach(data, id) {
371
+ const flat = flatten(data).get(id);
372
+ if (!flat) return;
373
+ flat.list.splice(flat.list.indexOf(flat.options), 1);
374
+ return flat.options;
375
+ }
376
+ /**
377
+ * Insert `options` under `parent` (a group id, or `null` for the root) at
378
+ * `position`, clamped to the group's length. Returns false when the parent
379
+ * group does not exist.
380
+ */
381
+ function place(data, options, parent, position) {
382
+ let list = data;
383
+ if (parent !== null) {
384
+ const group = flatten(data).get(parent);
385
+ if (!group?.options.group) return false;
386
+ list = group.options.config ??= [];
387
+ }
388
+ list.splice(Math.min(position, list.length), 0, options);
389
+ return true;
390
+ }
391
+ /**
392
+ * Overlay a journal onto `data` in place. `filter(id, key)` restricts which
393
+ * changes apply; `key` is `null` when asking about the entry as a whole
394
+ * (creation, removal, relocation).
395
+ */
396
+ function applyJournal(data, journal, filter = () => true, warn = () => {}) {
397
+ for (const [id, record] of journal) {
398
+ if (!filter(id, null)) continue;
399
+ if (record.kind === "remove") {
400
+ detach(data, id);
401
+ continue;
402
+ }
403
+ const flat = flatten(data).get(id);
404
+ if (!flat) {
405
+ const options = { id };
406
+ applyChanges(options, record.changes, (key) => filter(id, key));
407
+ const parent = record.parent ?? null;
408
+ if (!place(data, options, parent, record.position)) warn("cannot place entry %C: group %C not found", id, parent);
409
+ continue;
410
+ }
411
+ applyChanges(flat.options, record.changes, (key) => filter(id, key));
412
+ if (record.parent !== void 0 && flat.parent !== record.parent) {
413
+ detach(data, id);
414
+ if (!place(data, flat.options, record.parent, record.position)) {
415
+ warn("cannot move entry %C: group %C not found", id, record.parent);
416
+ place(data, flat.options, flat.parent, flat.position);
417
+ }
418
+ }
419
+ }
420
+ return data;
421
+ }
422
+ /**
423
+ * Three-way reconciliation of a journal against a new file state. The file
424
+ * wins every conflict: conflicting keys are dropped from the journal so that
425
+ * the tree follows the file, and each drop is reported.
426
+ *
427
+ * `fileOwned(id, key)` scopes the check to what the file can actually express
428
+ * (patch-owned keys and patch-inserted entries live elsewhere).
429
+ */
430
+ function reconcile(journal, base, theirs, fileOwned) {
431
+ const conflicts = [];
432
+ for (const [id, record] of journal) {
433
+ if (!fileOwned(id, null)) continue;
434
+ const b = base.get(id);
435
+ const t = theirs.get(id);
436
+ if (record.kind === "remove") {
437
+ if (!t) journal.delete(id);
438
+ else if (!b || !deepEqual(t.options, b.options)) {
439
+ conflicts.push({
440
+ id,
441
+ reason: b ? "modified in file, removed at runtime" : "added in file, removed at runtime"
442
+ });
443
+ journal.delete(id);
444
+ }
445
+ continue;
446
+ }
447
+ if (!t) {
448
+ if (b) {
449
+ conflicts.push({
450
+ id,
451
+ reason: "removed in file, modified at runtime"
452
+ });
453
+ journal.delete(id);
454
+ }
455
+ continue;
456
+ }
457
+ if (record.created) record.created = false;
458
+ for (const key of Object.keys(record.changes)) {
459
+ if (!fileOwned(id, key)) continue;
460
+ const bv = b?.options[key];
461
+ const tv = t.options[key];
462
+ if (deepEqual(bv, tv) || deepEqual(tv, record.changes[key])) continue;
463
+ conflicts.push({
464
+ id,
465
+ reason: `key "${key}" modified both in file and at runtime`
466
+ });
467
+ delete record.changes[key];
468
+ }
469
+ if (record.parent !== void 0 && b && t.parent !== b.parent && t.parent !== record.parent) {
470
+ conflicts.push({
471
+ id,
472
+ reason: "moved both in file and at runtime"
473
+ });
474
+ record.parent = void 0;
475
+ }
476
+ const moved = record.parent !== void 0 && record.parent !== t.parent;
477
+ if (!Object.keys(record.changes).length && !moved) journal.delete(id);
478
+ }
479
+ return conflicts;
480
+ }
481
+ //#endregion
482
+ //#region ../../../vendor/include/src/patch.ts
483
+ function randomId(used) {
484
+ let id;
485
+ do
486
+ id = Math.random().toString(16).slice(2, 10);
487
+ while (used(id));
488
+ return id;
489
+ }
490
+ /** Give every entry (nested groups included) an id, in place. */
491
+ function ensureIds(entries, used) {
492
+ for (const entry of entries) {
493
+ if (!entry.id) entry.id = randomId(used);
494
+ if (entry.group && Array.isArray(entry.config)) ensureIds(entry.config, used);
495
+ }
496
+ }
497
+ /** Runtime changes are routed back to an inserted entry by its id, so every insert needs one. */
498
+ function ensureInsertIds(patches, used) {
499
+ for (const patch of patches ?? []) if (patch.insert) ensureIds(patch.insert, used);
46
500
  }
47
501
  /**
48
- * Apply patch lists to an entry list — THE patch semantics of this include,
49
- * shared by mounting (`applyPatches`) and offline config tooling
50
- * (`dsh --dump-config`) so a dump can never drift from what boots. The input
51
- * is never mutated: patching shared entry objects would bake earlier patch
52
- * values into the cached parse, so repeated application (config hot-reloads)
53
- * could never revert a removed or changed patch. Inserted entries are indexed
54
- * as they are added, so a later patch in the same list can target a row an
55
- * earlier patch inserted. A patch that matches nothing warns and is skipped.
56
- * @param data - the parsed entry list (JSON-safe plain data).
57
- * @param patches - the patch list to apply, in order.
58
- * @param warn - sink for skipped-patch diagnostics (printf-style, `%C` = code).
59
- * @returns a detached entry list with every applicable patch applied.
502
+ * Overlay `patches` onto a fresh clone of `data`. Inserted entries are cloned
503
+ * too, so the tree owns every object it mounts.
60
504
  */
61
- function applyEntryPatches(data, patches, warn) {
62
- if (!patches?.length) return [...data];
505
+ function applyPatches(data, patches, warn) {
63
506
  data = structuredClone(data);
507
+ if (!patches?.length) return data;
64
508
  const entryMap = /* @__PURE__ */ new Map();
65
509
  const buildMap = (entries) => {
66
510
  for (const entry of entries) {
@@ -72,6 +516,7 @@ function applyEntryPatches(data, patches, warn) {
72
516
  for (const patch of patches) {
73
517
  const { id, insert, name, ...overrides } = patch;
74
518
  if (insert) {
519
+ const clone = structuredClone(insert);
75
520
  if (id) {
76
521
  const target = entryMap.get(id);
77
522
  if (!target) {
@@ -83,9 +528,9 @@ function applyEntryPatches(data, patches, warn) {
83
528
  continue;
84
529
  }
85
530
  if (!Array.isArray(target.config)) target.config = [];
86
- target.config.push(...insert);
87
- } else data.push(...insert);
88
- buildMap(insert);
531
+ target.config.push(...clone);
532
+ } else data.push(...clone);
533
+ buildMap(clone);
89
534
  continue;
90
535
  }
91
536
  if (!id) {
@@ -108,145 +553,518 @@ function applyEntryPatches(data, patches, warn) {
108
553
  }
109
554
  return data;
110
555
  }
111
- /** Loader entry tree backed by a YAML or JSON file. */
556
+ var PatchIndex = class {
557
+ patches;
558
+ keys = /* @__PURE__ */ new Map();
559
+ inserts = /* @__PURE__ */ new Map();
560
+ constructor(patches) {
561
+ this.patches = patches;
562
+ patches.forEach((patch, index) => {
563
+ const { id, insert } = patch;
564
+ if (insert) {
565
+ const walk = (list, parent) => {
566
+ for (const options of list) {
567
+ this.inserts.set(options.id, {
568
+ type: "insert",
569
+ index,
570
+ list,
571
+ options,
572
+ parent
573
+ });
574
+ if (options.group && Array.isArray(options.config)) walk(options.config, options.id);
575
+ }
576
+ };
577
+ walk(insert, id ?? null);
578
+ return;
579
+ }
580
+ if (!id) return;
581
+ const keys = this.keys.get(id) ?? /* @__PURE__ */ new Map();
582
+ for (const key of Object.keys(omit(patch, [
583
+ "id",
584
+ "insert",
585
+ "name"
586
+ ]))) keys.set(key, index);
587
+ this.keys.set(id, keys);
588
+ });
589
+ }
590
+ /** Owner of the entry as a whole. */
591
+ entry(id) {
592
+ if (id === null) return { type: "file" };
593
+ return this.inserts.get(id) ?? { type: "file" };
594
+ }
595
+ /** Owner of one key of an entry. */
596
+ key(id, key) {
597
+ const insert = this.inserts.get(id);
598
+ if (insert) return insert;
599
+ const index = this.keys.get(id)?.get(key);
600
+ return index === void 0 ? { type: "file" } : {
601
+ type: "patch",
602
+ index
603
+ };
604
+ }
605
+ fileOwned(id, key) {
606
+ return (key === null ? this.entry(id) : this.key(id, key)).type === "file";
607
+ }
608
+ };
609
+ /**
610
+ * Route a journal to its owners: file-owned changes are applied to `data`,
611
+ * patch-owned ones to `patches`. Both are mutated in place, so pass clones.
612
+ * Returns whether any patch changed.
613
+ */
614
+ function routeJournal(journal, data, patches, warn) {
615
+ const index = new PatchIndex(patches);
616
+ let patched = false;
617
+ const insertList = (owner) => owner.options.config ??= [];
618
+ const placeAt = (options, parent, position) => {
619
+ const owner = index.entry(parent);
620
+ if (owner.type === "insert") {
621
+ const list = insertList(owner);
622
+ list.splice(Math.min(position, list.length), 0, options);
623
+ patched = true;
624
+ } else if (!place(data, options, parent, position)) warn("cannot place entry %C: group %C not found", options.id, parent);
625
+ };
626
+ for (const [id, record] of journal) {
627
+ const owner = index.entry(id);
628
+ if (record.kind === "remove") {
629
+ if (owner.type === "insert") {
630
+ owner.list.splice(owner.list.indexOf(owner.options), 1);
631
+ patched = true;
632
+ } else detach(data, id);
633
+ continue;
634
+ }
635
+ const current = owner.type === "insert" ? owner : flatten(data).get(id);
636
+ if (!current) {
637
+ const options = { id };
638
+ applyChanges(options, record.changes);
639
+ placeAt(options, record.parent ?? null, record.position);
640
+ continue;
641
+ }
642
+ if (record.parent !== void 0 && current.parent !== record.parent) {
643
+ if (owner.type === "insert") {
644
+ owner.list.splice(owner.list.indexOf(owner.options), 1);
645
+ patched = true;
646
+ } else detach(data, id);
647
+ applyChanges(current.options, record.changes);
648
+ placeAt(current.options, record.parent, record.position);
649
+ continue;
650
+ }
651
+ if (owner.type === "insert") {
652
+ applyChanges(current.options, record.changes);
653
+ patched = true;
654
+ continue;
655
+ }
656
+ const patchChanges = {};
657
+ applyChanges(current.options, record.changes, (key) => {
658
+ const keyOwner = index.key(id, key);
659
+ if (keyOwner.type !== "patch") return true;
660
+ (patchChanges[keyOwner.index] ??= {})[key] = record.changes[key];
661
+ return false;
662
+ });
663
+ for (const [i, changes] of Object.entries(patchChanges)) {
664
+ applyChanges(patches[i], changes);
665
+ patched = true;
666
+ }
667
+ }
668
+ return patched;
669
+ }
670
+ //#endregion
671
+ //#region ../../../vendor/include/src/index.ts
672
+ const JsExpr = new yaml.Type("tag:yaml.org,2002:js", {
673
+ kind: "scalar",
674
+ resolve: (data) => typeof data === "string",
675
+ construct: (data) => ({ __jsExpr: data }),
676
+ predicate: isJsExpr,
677
+ represent: (data) => data["__jsExpr"]
678
+ });
679
+ /** YAML dialect preserving child-owned JavaScript expression nodes. */
680
+ const entryListSchema = yaml.JSON_SCHEMA.extend(JsExpr);
681
+ const schema = entryListSchema;
682
+ const types = {
683
+ ".json": "application/json",
684
+ ".yaml": "application/yaml",
685
+ ".yml": "application/yaml"
686
+ };
687
+ var ConfigFileError = class extends Error {
688
+ stage;
689
+ name = "ConfigFileError";
690
+ constructor(stage, path, cause) {
691
+ super(`failed to ${stage} config file ${path}`, { cause });
692
+ this.stage = stage;
693
+ }
694
+ };
695
+ /** The file changed under us between the pre-check and the rename. */
696
+ var StaleWriteError = class extends Error {
697
+ name = "StaleWriteError";
698
+ };
699
+ function isENOENT(error) {
700
+ return error?.code === "ENOENT";
701
+ }
702
+ const RENAME_RETRIES = 10;
703
+ const RENAME_BACKOFF = 20;
704
+ /**
705
+ * On Windows a rename over a file that another process holds open (typically
706
+ * an antivirus scanning what we just wrote) fails with one of these; the lock
707
+ * is normally gone within milliseconds. A bounded retry is cheap enough to
708
+ * apply on every platform.
709
+ */
710
+ function isTransientRenameError(error) {
711
+ const code = error?.code;
712
+ return code === "EACCES" || code === "EPERM" || code === "EBUSY";
713
+ }
112
714
  var Include = class extends EntryTree {
113
715
  config;
114
716
  static inject = ["loader"];
115
717
  static [EntryGroup.key] = true;
116
718
  filename;
117
719
  type;
118
- readonly;
119
- content;
120
- data;
121
- writeTask;
122
- pendingWrite;
123
- writeQueue = Promise.resolve();
720
+ /** The last file state we read or wrote. */
721
+ cache;
722
+ /** Tree-side changes that have not reached the file yet. */
723
+ journal = /* @__PURE__ */ new Map();
724
+ /** Ids we assigned to entries the file leaves anonymous. */
725
+ anonymous = /* @__PURE__ */ new Map();
726
+ dirtyRead = false;
727
+ dirtyWrite = false;
728
+ _draining;
729
+ /** Only the latest intended tree is ever applied; older ones are skipped. */
730
+ _pendingTree;
731
+ _applyTask;
732
+ _disposed = false;
733
+ _seq = 0;
734
+ persistenceError;
124
735
  constructor(ctx, config) {
125
736
  super(ctx);
126
737
  this.config = config;
127
738
  this.enableLogs = config.enableLogs ?? ctx.fiber.entry?.parent.tree.enableLogs ?? false;
128
739
  this.filename = fileURLToPath(new URL(this.config.path, this.ctx.baseUrl));
129
740
  const ext = extname(this.filename);
130
- if (!supported.has(ext)) throw new Error(`extension "${ext}" not supported`);
131
- this.type = writable[ext];
132
- this.readonly = !this.type;
741
+ if (!types[ext]) throw new Error(`extension "${ext}" not supported`);
742
+ this.type = types[ext];
133
743
  this.ctx.baseUrl = new URL(".", pathToFileURL(this.filename)).href;
134
- ctx.on("internal/update", (config, _, next) => {
744
+ ensureInsertIds(config.patches, (id) => !!this.store[id]);
745
+ ctx.on("internal/update", (config, next) => {
135
746
  if (config.path !== this.config.path) return next();
136
747
  this.config = config;
137
- this.root.update(this.applyPatches(this.data, config.patches)).catch((error) => {
138
- this.ctx.logger.warn("config update at %C failed", this.filename);
139
- this.ctx.logger.warn(error);
140
- });
141
- });
142
- }
143
- async checkAccess() {
144
- if (!this.type) return;
145
- try {
146
- await access(this.filename, constants.W_OK);
147
- } catch {
148
- this.readonly = true;
149
- }
150
- }
151
- async read(forced = false) {
152
- const content = await readFile(this.filename, "utf8");
153
- if (!forced && this.content === content) return false;
154
- let data;
155
- if (this.type === "application/yaml") data = yaml.load(content, { schema: entryListSchema });
156
- else if (this.type === "application/json") data = JSON.parse(content);
157
- else {
158
- const module = await import(
159
- /* @vite-ignore */
160
- this.filename
161
- );
162
- data = module.default || module;
163
- }
164
- if (!Array.isArray(data)) throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`);
165
- this.content = content;
166
- this.data = data;
167
- await this.checkAccess();
168
- return true;
169
- }
170
- applyPatches(data, patches = this.config.patches) {
171
- return applyEntryPatches(data, patches, (message, ...args) => {
172
- this.ctx.root.logger?.("loader").warn(message, ...args);
748
+ ensureInsertIds(config.patches, (id) => !!this.store[id]);
749
+ this._scheduleApply();
750
+ return this._applyTask;
173
751
  });
174
752
  }
753
+ warn = (format, ...args) => {
754
+ this.ctx.root.logger?.("loader").warn(format, ...args);
755
+ };
175
756
  async *[Service.init]() {
176
757
  try {
177
- await this.read();
758
+ await this._read();
178
759
  } catch (error) {
179
- if (error?.code !== "ENOENT") throw error;
180
- if (this.config.initial) {
181
- await this._writeFile(this.config.initial);
182
- await this.read(true);
183
- } else throw new Error(`config file not found: ${this.filename}`);
760
+ if (!isENOENT(error instanceof ConfigFileError && error.stage === "read" ? error.cause : void 0)) throw error;
761
+ if (!this.config.initial) throw new Error(`config file not found: ${this.filename}`);
762
+ const initial = structuredClone(this.config.initial);
763
+ ensureIds(initial, (id) => !!this.store[id]);
764
+ await this._writeText(this.dump(initial));
765
+ await this._read();
184
766
  }
185
767
  yield () => this.stop();
186
- await this.root.update(this.applyPatches(this.data));
768
+ await this._applyTask;
187
769
  }
188
770
  async stop() {
771
+ this._disposed = true;
772
+ this._pendingTree = void 0;
773
+ if (this.journal.size) {
774
+ this.dirtyWrite = true;
775
+ this._drain();
776
+ }
189
777
  try {
190
- await this.flushWrite();
778
+ await this.flush();
191
779
  } finally {
192
780
  this.root.stop();
193
- await this.flushWrite();
194
781
  }
195
782
  }
196
783
  /**
197
- * Re-read the file and refresh child entries when content changed. An
198
- * unreadable or unparsable file logs a warning and keeps the last good
199
- * tree: a hot-reload of a live app must never take the process down.
784
+ * Resolves once every pending read and write has been processed and the
785
+ * tree reflects the outcome. Retries pending writes and rejects while changes remain unsaved.
200
786
  */
201
- async refresh() {
787
+ async flush() {
788
+ if (this.journal.size && !this._draining) {
789
+ this.dirtyWrite = true;
790
+ this._drain();
791
+ }
792
+ while (this._draining || this._applyTask) {
793
+ await this._draining;
794
+ await this._applyTask;
795
+ }
796
+ if (this.journal.size) throw this.persistenceError ?? /* @__PURE__ */ new Error(`config file ${this.filename} has ${this.journal.size} unsaved change(s)`);
797
+ this.persistenceError = void 0;
798
+ }
799
+ /** Re-read the file and reconcile the tree with it. */
800
+ refresh() {
801
+ this.dirtyRead = true;
802
+ this._drain();
803
+ return this.flush();
804
+ }
805
+ /** Called synchronously by the loader once a tree-side change is in place. */
806
+ commit(change) {
807
+ this.context.emit("loader/config-update");
808
+ const parent = change.group === this.root ? null : change.group.ctx.fiber.entry.options.id;
809
+ record$1(this.journal, change, parent);
810
+ this.dirtyWrite = true;
811
+ this._drain();
812
+ }
813
+ _drain() {
814
+ if (this._draining) return;
815
+ this._draining = this._loop().finally(() => {
816
+ this._draining = void 0;
817
+ });
818
+ }
819
+ async _loop() {
820
+ while (this.dirtyRead || this.dirtyWrite) {
821
+ if (this.dirtyRead) {
822
+ this.dirtyRead = false;
823
+ try {
824
+ await this._read();
825
+ } catch (error) {
826
+ this.persistenceError = error;
827
+ this.ctx.logger.warn(error);
828
+ if (this.dirtyRead) continue;
829
+ return;
830
+ }
831
+ }
832
+ if (this.dirtyWrite) {
833
+ this.dirtyWrite = false;
834
+ try {
835
+ await this._write();
836
+ } catch (error) {
837
+ this.persistenceError = error;
838
+ this.ctx.logger.error(error);
839
+ }
840
+ }
841
+ }
842
+ }
843
+ async _read() {
844
+ let content;
845
+ try {
846
+ content = await readFile(this.filename, "utf8");
847
+ } catch (error) {
848
+ throw new ConfigFileError("read", this.filename, error);
849
+ }
850
+ if (content === this.cache?.content) return;
851
+ let data;
202
852
  try {
203
- if (!await this.read()) return;
204
- await this.root.update(this.applyPatches(this.data));
853
+ data = this.type === "application/yaml" ? yaml.load(content, { schema }) : JSON.parse(content);
205
854
  } catch (error) {
206
- this.ctx.logger.warn("config reload at %C failed; keeping the running tree", this.filename);
207
- this.ctx.logger.warn(error);
855
+ throw new ConfigFileError("parse", this.filename, error);
208
856
  }
857
+ if (!Array.isArray(data)) throw new ConfigFileError("validate", this.filename, /* @__PURE__ */ new TypeError("config file must be a top-level array"));
858
+ this._assignIds(data);
859
+ if (this.cache) {
860
+ const index = new PatchIndex(this.config.patches ?? []);
861
+ const conflicts = reconcile(this.journal, flatten(this.cache.data), flatten(data), (id, key) => index.fileOwned(id, key));
862
+ for (const { id, reason } of conflicts) this.ctx.logger.error("config conflict in %C: entry %C %s; file wins", this.filename, id, reason);
863
+ }
864
+ this.cache = {
865
+ content,
866
+ data
867
+ };
868
+ this._scheduleApply();
869
+ if (this.journal.size) this.dirtyWrite = true;
209
870
  }
210
- async _writeFile(config) {
211
- if (this.readonly) throw new Error(`cannot overwrite readonly config`);
212
- if (this.type === "application/yaml") this.content = yaml.dump(config, { schema });
213
- else if (this.type === "application/json") this.content = JSON.stringify(config, null, 2);
214
- await writeFile(this.filename + ".tmp", this.content);
215
- for (let retry = 0;; retry++) try {
216
- await rename(this.filename + ".tmp", this.filename);
871
+ async _write() {
872
+ if (!this.cache || !this.journal.size) return;
873
+ const current = await readFile(this.filename, "utf8").catch((error) => {
874
+ if (isENOENT(error)) return void 0;
875
+ throw error;
876
+ });
877
+ if (current === void 0) {
878
+ this.ctx.logger.warn("config file %C is missing, %d pending change(s) kept in memory", this.filename, this.journal.size);
217
879
  return;
880
+ }
881
+ if (current !== this.cache.content) {
882
+ this.dirtyRead = true;
883
+ this.dirtyWrite = true;
884
+ return;
885
+ }
886
+ const batch = this.journal;
887
+ this.journal = /* @__PURE__ */ new Map();
888
+ const restore = () => {
889
+ for (const [id, record] of this.journal) merge(batch, id, record);
890
+ this.journal = batch;
891
+ };
892
+ const data = structuredClone(this.cache.data);
893
+ const patches = structuredClone(this.config.patches ?? []);
894
+ if (routeJournal(batch, data, patches, this.warn)) {
895
+ this.config = {
896
+ ...this.config,
897
+ patches
898
+ };
899
+ if (this.ctx.fiber.entry) try {
900
+ Promise.resolve(this.ctx.fiber.update(this.config)).catch((error) => {
901
+ this.ctx.logger.error(error);
902
+ });
903
+ } catch (error) {
904
+ this.ctx.logger.error(error);
905
+ }
906
+ else this.ctx.logger.warn("patches of %C changed at runtime but cannot be persisted", this.filename);
907
+ }
908
+ if (deepEqual(data, this.cache.data)) {
909
+ this._scheduleApply();
910
+ return;
911
+ }
912
+ const text = this.dump(data);
913
+ try {
914
+ await access(this.filename, constants.W_OK);
915
+ } catch {
916
+ restore();
917
+ this.ctx.logger.warn("config file %C is read-only, %d pending change(s) kept in memory", this.filename, this.journal.size);
918
+ return;
919
+ }
920
+ try {
921
+ await this._writeText(text, this.cache.content);
218
922
  } catch (error) {
219
- if (!retryableWriteError(error) || retry >= WRITE_RETRY_LIMIT) throw error;
220
- await setTimeout$1((retry + 1) * WRITE_RETRY_DELAY_MS);
923
+ restore();
924
+ if (error instanceof StaleWriteError) {
925
+ this.dirtyRead = true;
926
+ this.dirtyWrite = true;
927
+ return;
928
+ }
929
+ this.ctx.logger.warn("failed to write config file %C, %d pending change(s) kept in memory", this.filename, this.journal.size);
930
+ throw error;
221
931
  }
932
+ this.cache = {
933
+ content: text,
934
+ data
935
+ };
936
+ this._scheduleApply();
222
937
  }
223
- writeFile(config) {
224
- clearTimeout(this.writeTask);
225
- this.pendingWrite = config;
226
- this.writeTask = setTimeout(() => {
227
- this.flushWrite();
228
- }, 0);
229
- }
230
- flushWrite() {
231
- clearTimeout(this.writeTask);
232
- this.writeTask = void 0;
233
- const config = this.pendingWrite;
234
- this.pendingWrite = void 0;
235
- if (config === void 0) return this.writeQueue;
236
- const run = this.writeQueue.then(() => this._writeFile(config), () => this._writeFile(config));
237
- this.writeQueue = run;
238
- run.catch((error) => {
239
- this.ctx.root.logger?.("loader").warn("failed to write config file %C", this.filename);
240
- this.ctx.root.logger?.("loader").warn(error);
241
- });
242
- return run;
938
+ /**
939
+ * Write through a temp file and a rename, so that no reader ever sees a
940
+ * truncated file. When `expected` is given, the rename (and every retry of
941
+ * it) only goes ahead while the file still holds that content.
942
+ */
943
+ async _writeText(text, expected) {
944
+ const tmp = join(dirname(this.filename), `.${basename(this.filename)}.${process.pid}.${this._seq++}.tmp`);
945
+ try {
946
+ await writeFile(tmp, text);
947
+ for (let attempt = 0;; attempt++) {
948
+ if (expected !== void 0) {
949
+ if (await readFile(this.filename, "utf8").catch((error) => {
950
+ if (isENOENT(error)) return void 0;
951
+ throw error;
952
+ }) !== expected) throw new StaleWriteError();
953
+ }
954
+ try {
955
+ await rename(tmp, this.filename);
956
+ return;
957
+ } catch (error) {
958
+ if (attempt >= RENAME_RETRIES || !isTransientRenameError(error)) throw error;
959
+ }
960
+ await setTimeout$1(RENAME_BACKOFF * (attempt + 1));
961
+ }
962
+ } catch (error) {
963
+ await rm(tmp, { force: true }).catch(() => {});
964
+ throw error;
965
+ }
243
966
  }
244
- /** Schedule a write of the current root entry data. */
245
- write() {
246
- this.context.emit("loader/config-update");
247
- return this.writeFile(this.root.data);
967
+ dump(data) {
968
+ return this.type === "application/yaml" ? yaml.dump(data, { schema }) : JSON.stringify(data, null, 2);
969
+ }
970
+ /**
971
+ * Entries the file leaves anonymous get an id here, before the loader sees
972
+ * them. One that looks exactly like an entry we named on the previous read
973
+ * keeps that id, so editing a neighbor does not restart it; anything else
974
+ * gets a fresh id. Assigned ids stay in memory until the next write.
975
+ */
976
+ _assignIds(data) {
977
+ const used = /* @__PURE__ */ new Set();
978
+ const pending = [];
979
+ const walk = (entries, parent) => {
980
+ for (const options of entries) {
981
+ if (options.id) used.add(options.id);
982
+ else pending.push({
983
+ options,
984
+ parent
985
+ });
986
+ if (options.group && Array.isArray(options.config)) walk(options.config, options.id ?? null);
987
+ }
988
+ };
989
+ walk(data, null);
990
+ const previous = this.anonymous;
991
+ this.anonymous = /* @__PURE__ */ new Map();
992
+ for (const { options, parent } of pending) {
993
+ let id;
994
+ for (const [candidate, info] of previous) {
995
+ if (info.parent !== parent || !deepEqual(info.options, options)) continue;
996
+ id = candidate;
997
+ previous.delete(candidate);
998
+ break;
999
+ }
1000
+ if (!id) do
1001
+ id = Math.random().toString(16).slice(2, 10);
1002
+ while (used.has(id) || this.store[id]);
1003
+ used.add(id);
1004
+ this.anonymous.set(id, {
1005
+ parent,
1006
+ options: structuredClone(options)
1007
+ });
1008
+ const rest = { ...options };
1009
+ for (const key of Object.keys(options)) delete options[key];
1010
+ Object.assign(options, { id }, rest);
1011
+ }
1012
+ }
1013
+ _scheduleApply() {
1014
+ if (this._disposed || !this.cache) return;
1015
+ const tree = applyPatches(this.cache.data, this.config.patches, this.warn);
1016
+ this._pendingTree = applyJournal(tree, this.journal, void 0, this.warn);
1017
+ this._applyTask ??= (async () => {
1018
+ try {
1019
+ while (this._pendingTree) {
1020
+ const tree = this._pendingTree;
1021
+ this._pendingTree = void 0;
1022
+ await this.root.update(tree);
1023
+ }
1024
+ } finally {
1025
+ this._applyTask = void 0;
1026
+ }
1027
+ })();
1028
+ }
1029
+ };
1030
+ //#endregion
1031
+ //#region lib/types/bundle-platform.js
1032
+ /** Host operating-system admission for optional bundle layers. */
1033
+ const NODE_PLATFORMS = Object.freeze([
1034
+ "aix",
1035
+ "android",
1036
+ "darwin",
1037
+ "freebsd",
1038
+ "haiku",
1039
+ "linux",
1040
+ "openbsd",
1041
+ "sunos",
1042
+ "win32",
1043
+ "cygwin",
1044
+ "netbsd"
1045
+ ]);
1046
+ /** A valid bundle declaration excludes the operating system running DSH. */
1047
+ var UnsupportedBundlePlatformError = class extends Error {
1048
+ /**
1049
+ * @param platforms Node platform names supported by the bundle.
1050
+ * @param platform Operating system of the DSH Host.
1051
+ */
1052
+ constructor(platforms, platform) {
1053
+ super(`Bundle supports Host platforms ${platforms.join(", ")}; the DSH Host is running ${platform}`);
1054
+ this.name = "UnsupportedBundlePlatformError";
248
1055
  }
249
1056
  };
1057
+ /**
1058
+ * Validate package metadata and reject an unsupported Host before selecting or composing a bundle.
1059
+ * @param platforms The optional `dsh.bundle.platforms` field read from package.json.
1060
+ * @param platform Operating system of the DSH Host, from Node's `process.platform`.
1061
+ * @throws When the declaration is malformed or excludes the Host; omission allows every platform.
1062
+ */
1063
+ function assertBundlePlatform(platforms, platform) {
1064
+ if (platforms === void 0) return;
1065
+ if (!Array.isArray(platforms) || platforms.length === 0 || !platforms.every((value) => typeof value === "string" && NODE_PLATFORMS.includes(value))) throw new Error("dsh.bundle.platforms must be a nonempty list of Node platform names");
1066
+ if (!platforms.includes(platform)) throw new UnsupportedBundlePlatformError(platforms, platform);
1067
+ }
250
1068
  //#endregion
251
1069
  //#region lib/types/plugin-compatibility.js
252
1070
  /** Evaluate plugin dsh requirements (`engines.dsh`, else dsh peers) without importing plugin code. */
@@ -547,49 +1365,6 @@ function resolveProfileDir(name, home = resolveDshHome()) {
547
1365
  if (name === "" || name.includes("/") || name.includes("\\") || name === "." || name === ".." || name === "node_modules") throw new Error(`dsh: invalid profile name ${JSON.stringify(name)}`);
548
1366
  return join(home, PROFILES_DIR, name);
549
1367
  }
550
- /** The shipped profile templates auto-initialized on first use, by name. */
551
- const PROFILE_TEMPLATES = {
552
- acp: { bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-acp-app"] },
553
- web: { bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"] },
554
- headless: { bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-headless"] },
555
- sdk: { bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-sdk-app"] },
556
- "sdk-minimal": { bundles: ["@deepseek-ai/dsh-sdk-minimal"] }
557
- };
558
- /** Installation-owned bundle tuples normalized to the shipped template. */
559
- const INSTALLATION_OWNED_PROFILE_TUPLES = { headless: [
560
- "@deepseek-ai/dsh-base",
561
- "@deepseek-ai/dsh-web-app",
562
- "@deepseek-ai/dsh-headless"
563
- ] };
564
- /**
565
- * Bundles an earlier release shipped and the installation no longer carries;
566
- * {@link loadProfileDirectory} removes them from the profile's bundle list.
567
- */
568
- const RETIRED_BUNDLES = new Set(["@deepseek-ai/dsh-experimental-schedule-bundle"]);
569
- /** The bundle list a `dsh plugin` init uses for a name with no shipped template. */
570
- const DEFAULT_PROFILE_BUNDLES = ["@deepseek-ai/dsh-base"];
571
- /**
572
- * The bundles the dsh installation ships for a person to switch on: each a
573
- * runtime dependency of the installation that declares `dsh.bundle.patch`,
574
- * an `icon`, and `./locale/*.json` display metadata, selected by no shipped
575
- * template, and offered switched off by the plugin manager
576
- * ([rationale](../../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md),
577
- * [admission](../../../../.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md)).
578
- */
579
- const OPTIONAL_BUNDLES = [
580
- "@deepseek-ai/dsh-experimental-session-search",
581
- "@deepseek-ai/dsh-experimental-ralph-bundle",
582
- "@deepseek-ai/dsh-experimental-terminal-bundle",
583
- "@deepseek-ai/dsh-experimental-badge-skill-bundle",
584
- "@deepseek-ai/dsh-experimental-session-titles-bundle",
585
- "@deepseek-ai/dsh-experimental-agent-team-profile",
586
- "@deepseek-ai/dsh-experimental-voice-input-bundle",
587
- "@deepseek-ai/dsh-experimental-cot-translation-bundle",
588
- "@deepseek-ai/dsh-experimental-auto-review",
589
- "@deepseek-ai/dsh-experimental-inspector-profile",
590
- "@deepseek-ai/dsh-experimental-plugin-market-bundle",
591
- "@deepseek-ai/dsh-experimental-tool-worktree"
592
- ];
593
1368
  const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer:
594
1369
  # a top-level YAML array of loader patch entries (id-targeted config
595
1370
  # overrides, disables, and insert lists; \`!!js\` expressions allowed).
@@ -710,7 +1485,11 @@ function readPackageManifest(anchor) {
710
1485
  }
711
1486
  /** Return dependency names that may be imported by a loader-visible plugin. */
712
1487
  function profileDependencyNames(manifest) {
713
- return [...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {})];
1488
+ return [
1489
+ ...Object.keys(manifest.dependencies ?? {}),
1490
+ ...Object.keys(manifest.peerDependencies ?? {}),
1491
+ ...Object.keys(manifest.optionalDependencies ?? {})
1492
+ ];
714
1493
  }
715
1494
  /** Resolve the installation packages that the runtime resolver supplies to every profile. */
716
1495
  function collectInstallationScopePackages(installAnchor, skippedBundles) {
@@ -1004,7 +1783,8 @@ function resolveBundleDir(binName, packageName, installAnchor, profileDir) {
1004
1783
  * the shared Harness home. This is used by application-owned profiles whose
1005
1784
  * package project and lifecycle belong to that application.
1006
1785
  * Retired bundles are removed from the stored bundle list first, rewriting the
1007
- * manifest when it listed one. Unreadable bundles, and bundles whose own dsh requirements the profile does not exempt, are skipped
1786
+ * manifest when it listed one. Unreadable bundles, unsupported Host platforms, and bundles whose own dsh requirements
1787
+ * the profile does not exempt are skipped
1008
1788
  * without changing the manifest and listed in `skippedBundles`; nothing is printed.
1009
1789
  * @param binName - the diagnostic prefix on thrown errors.
1010
1790
  * @param dir - absolute profile package directory.
@@ -1022,6 +1802,7 @@ function loadProfileDirectory(binName, dir, installAnchor, options = {}) {
1022
1802
  const bundleManifest = readProfileManifest(binName, packageDir);
1023
1803
  const bundle = bundleManifest.dsh?.bundle;
1024
1804
  if (bundle === void 0) throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`);
1805
+ assertBundlePlatform(bundle.platforms, process.platform);
1025
1806
  const issue = evaluatePluginCompatibility(bundleManifest, exemptions);
1026
1807
  if (issue !== void 0 && !issue.exempted) throw new Error(pluginCompatibilityWarning(issue));
1027
1808
  const patchPaths = bundlePatchPaths(packageDir, bundle);
@@ -1051,8 +1832,8 @@ function loadProfileDirectory(binName, dir, installAnchor, options = {}) {
1051
1832
  }
1052
1833
  /**
1053
1834
  * Load a profile: resolve every `dsh.profile.bundles` entry to its patch
1054
- * layer and parse the profile's own patch file. Unreadable or incompatible bundles
1055
- * are skipped and listed in `skippedBundles`; profile manifest and user patch errors still throw.
1835
+ * layer and parse the profile's own patch file. Unreadable bundles, unsupported Host platforms,
1836
+ * and incompatible bundles are skipped and listed in `skippedBundles`; profile manifest and user patch errors still throw.
1056
1837
  * @param binName - the diagnostic prefix on thrown errors.
1057
1838
  * @param name - the profile name.
1058
1839
  * @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor).
@@ -1082,7 +1863,7 @@ function loadProfile(binName, name, installAnchor, home = resolveDshHome(), opti
1082
1863
  * @returns the composed entry list.
1083
1864
  */
1084
1865
  function composeEntries(layers, warn = () => {}) {
1085
- return applyEntryPatches([], structuredClone(layers.flat()), (message, ...args) => {
1866
+ return applyPatches([], structuredClone(layers.flat()), (message, ...args) => {
1086
1867
  let index = 0;
1087
1868
  warn(message.replace(/%C/g, () => JSON.stringify(args[index++])));
1088
1869
  });
@@ -1205,6 +1986,8 @@ function writeProfileBundles(profileDir, manifest, bundles) {
1205
1986
  * template entries retain their order and duplicates. New bundle dependencies activate automatically.
1206
1987
  * @param options - location, inventory captured before pnpm, and whether explicitly disabled bundles remain disabled.
1207
1988
  * @returns updated inventory and newly added ordinary dependencies for caller-owned warnings.
1989
+ * @throws If a new selection has invalid platform metadata or excludes the Host. Installed dependencies
1990
+ * remain; no bundle selection changes are written.
1208
1991
  */
1209
1992
  function reconcileProfilePlugins(options) {
1210
1993
  const after = readProfilePlugins(options);
@@ -1214,7 +1997,10 @@ function reconcileProfilePlugins(options) {
1214
1997
  const disabled = new Set(options.preserveDisabled ? options.before.dependencies.filter((dependency) => dependency.bundle && !dependency.enabled).map((dependency) => dependency.name) : []);
1215
1998
  const previous = after.manifest.dsh?.profile?.bundles ?? [];
1216
1999
  const bundles = previous.filter((name) => !(beforeNames.has(name) || afterNames.has(name)) || bundleNames.has(name));
1217
- for (const dependency of after.dependencies) if (dependency.bundle && !disabled.has(dependency.name) && !bundles.includes(dependency.name)) bundles.push(dependency.name);
2000
+ for (const dependency of after.dependencies) if (dependency.bundle && !disabled.has(dependency.name) && !bundles.includes(dependency.name)) {
2001
+ assertBundlePlatform(bundleManifest(options, dependency.name)?.dsh?.bundle?.platforms, process.platform);
2002
+ bundles.push(dependency.name);
2003
+ }
1218
2004
  return {
1219
2005
  plugins: {
1220
2006
  manifest: bundles.length !== previous.length || bundles.some((name, index) => name !== previous[index]) ? writeProfileBundles(options.profileDir, after.manifest, bundles) : after.manifest,
@@ -1785,7 +2571,7 @@ function internalModules() {
1785
2571
  const esmResolve = addon.requireBuiltin("internal/modules/esm/resolve");
1786
2572
  const esm = esmModule.getOrInitializeCascadedLoader();
1787
2573
  const modern = "getOrCreateModuleJob" in esm;
1788
- /* v8 ignore start -- the supported Node 22/24/26 matrix validates each available Internal interface */
2574
+ /* v8 ignore start -- the supported Node 24/26 matrix validates each available Internal interface */
1789
2575
  if (typeof esm.resolveSync !== "function" || typeof Reflect.get(esm, modern ? "getOrCreateModuleJob" : "getModuleJobForImport") !== "function" || !modern && typeof Reflect.get(esm, "resolve") !== "function" || typeof cjsModule.Module._resolveFilename !== "function" || typeof cjsHelpers.getCjsConditions !== "function" || typeof esmUtils.getDefaultConditions !== "function" || typeof esmResolve.defaultResolve !== "function") throw new Error("profile resolution: unsupported Node module loader");
1790
2576
  /* v8 ignore stop */
1791
2577
  return {
@@ -1861,7 +2647,7 @@ function installRuntimeInterception(resolution) {
1861
2647
  let expected;
1862
2648
  try {
1863
2649
  expected = adapted(target.specifier, target.parentURL, attributes);
1864
- /* v8 ignore next -- Node 24+ resolves synchronously; the Node 22 matrix covers its Promise result */
2650
+ /* v8 ignore next -- the current loader resolves synchronously; the private interface also permits Promise results */
1865
2651
  if (expected instanceof Promise) expected = expected.catch(restoreImporter);
1866
2652
  } catch (error) {
1867
2653
  return restoreImporter(error);
@@ -1890,7 +2676,7 @@ function installRuntimeInterception(resolution) {
1890
2676
  } catch (error) {
1891
2677
  return restoreImporter(error);
1892
2678
  }
1893
- /* v8 ignore next -- Node 24+ resolves synchronously; the Node 22 matrix covers its Promise result */
2679
+ /* v8 ignore next -- the current loader resolves synchronously; the private interface also permits Promise results */
1894
2680
  if (result instanceof Promise) return result.catch(restoreImporter);
1895
2681
  if (cacheable) state.esm = result;
1896
2682
  return result;
@@ -1912,12 +2698,12 @@ function installRuntimeInterception(resolution) {
1912
2698
  };
1913
2699
  return (request, parent, attributes) => {
1914
2700
  const result = adapted(request, parent, attributes);
1915
- /* v8 ignore next -- Node 24+ resolves synchronously; the Node 22 matrix covers its Promise result */
2701
+ /* v8 ignore next -- the current loader resolves synchronously; the private interface also permits Promise results */
1916
2702
  return result instanceof Promise ? result.then((resolved) => relocate(resolved, parent)) : relocate(result, parent);
1917
2703
  };
1918
2704
  };
1919
2705
  let restoreEsm;
1920
- /* v8 ignore else -- CI coverage runs Node 24 v2; the Node 22 matrix exercises the v1 adapter */
2706
+ /* v8 ignore else -- CI coverage runs Node 24 v2; the Node 24.0 matrix exercises the v1 adapter */
1921
2707
  if (modern) {
1922
2708
  const loader = esm;
1923
2709
  const original = Reflect.get(loader, "resolveSync");
@@ -2376,7 +3162,7 @@ function preflight(ctx, entries, parentURL, report) {
2376
3162
  data = config.initial;
2377
3163
  }
2378
3164
  if (!Array.isArray(data)) return void 0;
2379
- return check(applyEntryPatches(data, config.patches, patchWarning(ctx)), pathToFileURL(filename).href) ? `its included file ${filename} reaches an incompatible plugin, and that file is never rewritten` : void 0;
3165
+ return check(applyPatches(data, config.patches, patchWarning(ctx)), pathToFileURL(filename).href) ? `its included file ${filename} reaches an incompatible plugin, and that file is never rewritten` : void 0;
2380
3166
  } finally {
2381
3167
  includes.delete(filename);
2382
3168
  }
@@ -2397,46 +3183,10 @@ function preflight(ctx, entries, parentURL, report) {
2397
3183
  */
2398
3184
  function prepareProfilePatches(ctx, patches, parentURL, binName = "dsh") {
2399
3185
  if (ctx.get("profileContext") === void 0) return patches;
2400
- const rows = prepareProfileEntries(ctx, applyEntryPatches([], patches, patchWarning(ctx)), parentURL, binName);
3186
+ const rows = prepareProfileEntries(ctx, applyPatches([], patches, patchWarning(ctx)), parentURL, binName);
2401
3187
  return rows.length === 0 ? [] : [{ insert: rows }];
2402
3188
  }
2403
3189
  //#endregion
2404
- //#region lib/types/official-bundle-packages.js
2405
- /** Package admission shared by offline catalog generation and release ordering. */
2406
- /** Public DSH release members available through the ordinary on-demand bundle installer. */
2407
- const ON_DEMAND_BUNDLES = ["@deepseek-ai/dsh-subagent-claude-code", "@deepseek-ai/dsh-subagent-codex"];
2408
- //#endregion
2409
- //#region lib/types/official-bundles.generated.js
2410
- /** Generated offline Official bundle metadata. Run pnpm gen-official-bundle-catalog; do not edit. */
2411
- /** Official on-demand entries, independent of installed packages and registry availability. */
2412
- const OFFICIAL_ON_DEMAND_CATALOG = [{
2413
- packageName: "@deepseek-ai/dsh-subagent-claude-code",
2414
- meta: {
2415
- title: {
2416
- "en": "Claude Code subagent",
2417
- "zh": "Claude Code 子智能体"
2418
- },
2419
- description: {
2420
- "en": "Delegate one task to Claude Code using its native authentication and settings.",
2421
- "zh": "使用 Claude Code 原生认证和设置委派单个任务。"
2422
- },
2423
- icon: "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzYiIGhlaWdodD0iMzYiIHZpZXdCb3g9IjAgMCAzNiAzNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB4PSIxNC45ODkzIiB5PSIxNS4xODc3IiB3aWR0aD0iMTIuMjM2NSIgaGVpZ2h0PSIxMi4yMzY1IiByeD0iMiIgZmlsbD0idXJsKCNkc2hfcGx1Z2luX2FydF9SMGEpIiBmaWxsLW9wYWNpdHk9IjAuOCI+PC9yZWN0PjxyZWN0IHg9IjguODcxMDkiIHk9IjkuMDY5NyIgd2lkdGg9IjEyLjIzNjUiIGhlaWdodD0iMTIuMjM2NSIgcng9IjIiIGZpbGw9InVybCgjZHNoX3BsdWdpbl9hcnRfUjBiKSIgZmlsbC1vcGFjaXR5PSIwLjgiPjwvcmVjdD48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImRzaF9wbHVnaW5fYXJ0X1IwYSIgeDE9IjIxLjEwNzUiIHkxPSIxNS4xODc3IiB4Mj0iMjEuMTA3NSIgeTI9IjI3LjQyNDMiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBzdG9wLWNvbG9yPSIjNDVFN0E0Ij48L3N0b3A+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMDU5MDlEIj48L3N0b3A+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9ImRzaF9wbHVnaW5fYXJ0X1IwYiIgeDE9IjE0Ljk4OTQiIHkxPSI5LjA2OTciIHgyPSIxNC45ODk0IiB5Mj0iMjEuMzA2MyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiM2OUI5RkYiPjwvc3RvcD48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMzMjRERTIiPjwvc3RvcD48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48L3N2Zz4K"
2424
- }
2425
- }, {
2426
- packageName: "@deepseek-ai/dsh-subagent-codex",
2427
- meta: {
2428
- title: {
2429
- "en": "Codex subagent",
2430
- "zh": "Codex 子智能体"
2431
- },
2432
- description: {
2433
- "en": "Delegate one task to Codex using its native authentication and settings.",
2434
- "zh": "使用 Codex 原生认证和设置委派单个任务。"
2435
- },
2436
- icon: "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzYiIGhlaWdodD0iMzYiIHZpZXdCb3g9IjAgMCAzNiAzNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB4PSIxNC45ODkzIiB5PSIxNS4xODc3IiB3aWR0aD0iMTIuMjM2NSIgaGVpZ2h0PSIxMi4yMzY1IiByeD0iMiIgZmlsbD0idXJsKCNkc2hfcGx1Z2luX2FydF9SMGEpIiBmaWxsLW9wYWNpdHk9IjAuOCI+PC9yZWN0PjxyZWN0IHg9IjguODcxMDkiIHk9IjkuMDY5NyIgd2lkdGg9IjEyLjIzNjUiIGhlaWdodD0iMTIuMjM2NSIgcng9IjIiIGZpbGw9InVybCgjZHNoX3BsdWdpbl9hcnRfUjBiKSIgZmlsbC1vcGFjaXR5PSIwLjgiPjwvcmVjdD48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImRzaF9wbHVnaW5fYXJ0X1IwYSIgeDE9IjIxLjEwNzUiIHkxPSIxNS4xODc3IiB4Mj0iMjEuMTA3NSIgeTI9IjI3LjQyNDMiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBzdG9wLWNvbG9yPSIjNDVFN0E0Ij48L3N0b3A+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMDU5MDlEIj48L3N0b3A+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9ImRzaF9wbHVnaW5fYXJ0X1IwYiIgeDE9IjE0Ljk4OTQiIHkxPSI5LjA2OTciIHgyPSIxNC45ODk0IiB5Mj0iMjEuMzA2MyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiM2OUI5RkYiPjwvc3RvcD48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMzMjRERTIiPjwvc3RvcD48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48L3N2Zz4K"
2437
- }
2438
- }];
2439
- //#endregion
2440
3190
  //#region lib/types/config-schema/native.js
2441
3191
  /** Identity checks at the plugin-export and lazy-builder boundaries. */
2442
3192
  /**
@@ -3359,7 +4109,7 @@ async function collectConfigSchemas(profile, entries, resolution, diagnostics =
3359
4109
  const rows = entryList(source);
3360
4110
  let children;
3361
4111
  try {
3362
- children = applyEntryPatches(rows, patches, warn(path));
4112
+ children = applyPatches(rows, patches, warn(path));
3363
4113
  } catch (error) {
3364
4114
  throw new Error(`include patches could not be composed for ${filename}; child declarations are unavailable`, { cause: error });
3365
4115
  }
@@ -3807,10 +4557,7 @@ async function recompose(ctx, patches, binName, options, withResolution) {
3807
4557
  options: JSON.stringify(failure.entry.options),
3808
4558
  diagnostic: failure.entry.fiber?.uid ? reloadDiagnostic(failure) : void 0
3809
4559
  }));
3810
- const previousFibers = [...ctx.loader.entries()].flatMap((row) => row.fiber === void 0 ? [] : [{
3811
- fiber: row.fiber,
3812
- failed: row.fiber.state === FIBER_FAILED || row.fiber.state === FIBER_DISPOSED
3813
- }]);
4560
+ const previousFibers = [...ctx.loader.entries()].flatMap((row) => row.fiber === void 0 ? [] : [row.fiber]);
3814
4561
  const { patches: previous = [], ...includeConfig } = entry.options.config;
3815
4562
  const current = withResolution ? ctx.get("pluginPackages")?.resolution : void 0;
3816
4563
  const successor = current instanceof ProfileRuntimeResolution ? await current.computeLatestResolution() : void 0;
@@ -3849,15 +4596,16 @@ async function recompose(ctx, patches, binName, options, withResolution) {
3849
4596
  const prepared = prepareProfilePatches(ctx, patches, parentURL, binName);
3850
4597
  applying = true;
3851
4598
  const retried = [...new Set([...running, ...withResolution ? [...ctx.loader.entries()].filter(importFailed) : []])];
4599
+ const priorData = new Map(retried.map((row) => [row, row.parent.data]));
3852
4600
  try {
3853
4601
  await entry.update({ config: {
3854
4602
  ...includeConfig,
3855
4603
  patches: prepared
3856
4604
  } });
3857
4605
  } finally {
3858
- await resume(ctx, retried);
4606
+ await resume(ctx, retried.filter((row) => row.parent.data === priorData.get(row)));
3859
4607
  }
3860
- const results = await Promise.allSettled(previousFibers.map(({ fiber }) => fiber.await()));
4608
+ await Promise.allSettled(previousFibers.map((fiber) => fiber.await()));
3861
4609
  await ctx.loader.await();
3862
4610
  const failures = await inactiveEntries(ctx);
3863
4611
  const introduced = failures.filter((failure) => {
@@ -3867,7 +4615,6 @@ async function recompose(ctx, patches, binName, options, withResolution) {
3867
4615
  return !previousFailures.some((previous) => sameFailure(previous, failure));
3868
4616
  });
3869
4617
  if (introduced.length > 0) throw new Error(activationDiagnostic(binName, introduced, false).trimEnd());
3870
- for (const [index, result] of results.entries()) if (result.status === "rejected" && !previousFibers[index]?.failed) throw result.reason;
3871
4618
  ctx.emit("app-boot/config-reload");
3872
4619
  return { warnings: failures.map(reloadDiagnostic) };
3873
4620
  } finally {
@@ -3897,7 +4644,7 @@ function rowIds(patches) {
3897
4644
  if ((row.group === true || row.name === "cordis:group") && Array.isArray(row.config)) visit(row.config);
3898
4645
  }
3899
4646
  };
3900
- visit(applyEntryPatches([], patches, () => {}));
4647
+ visit(applyPatches([], patches, () => {}));
3901
4648
  return ids;
3902
4649
  }
3903
4650
  /** Publish a generation unless it maps exactly what is published, which would only drop the package caches. */
@@ -4052,7 +4799,7 @@ function renderConfigDump(binName, absoluteConfigPath, layers, warn = (line) =>
4052
4799
  const baseLabel = basename(absoluteConfigPath);
4053
4800
  const base = parsed;
4054
4801
  const snapshot = (count, warnings) => {
4055
- return applyEntryPatches(base, structuredClone(layers.slice(0, count).flatMap((layer) => layer.patches)), (message, ...args) => {
4802
+ return applyPatches(base, structuredClone(layers.slice(0, count).flatMap((layer) => layer.patches)), (message, ...args) => {
4056
4803
  let index = 0;
4057
4804
  warnings.push(message.replace(/%C/g, () => JSON.stringify(args[index++])));
4058
4805
  });
@@ -4269,6 +5016,8 @@ const FIBER_DISPOSED = 4;
4269
5016
  */
4270
5017
  const requiredStartupEntryIds = new Set([
4271
5018
  "agent-loop",
5019
+ "tool-executor-native",
5020
+ "tool-executor-ptc",
4272
5021
  "webserver",
4273
5022
  "web-runtime",
4274
5023
  "modules",
@@ -4298,6 +5047,8 @@ function formatActivationError(error, stacks = true) {
4298
5047
  /** Startup audit failure with non-enumerable metadata and original failures as its cause. */
4299
5048
  var StartupError = class extends Error {
4300
5049
  entries;
5050
+ /** Plugin states captured before disposal, excluding configuration values. */
5051
+ composition;
4301
5052
  /** Root configuration and startup logs, attached by boot after disposal. */
4302
5053
  startup;
4303
5054
  /**
@@ -4310,7 +5061,8 @@ var StartupError = class extends Error {
4310
5061
  this.entries = entries;
4311
5062
  Object.defineProperties(this, {
4312
5063
  entries: { enumerable: false },
4313
- startup: { enumerable: false }
5064
+ startup: { enumerable: false },
5065
+ composition: { enumerable: false }
4314
5066
  });
4315
5067
  }
4316
5068
  };
@@ -4524,14 +5276,6 @@ async function boot(binName, absoluteConfigPath, patches, prepare, bareModuleBas
4524
5276
  try {
4525
5277
  ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + "/";
4526
5278
  ctx.provide("dshHomePath", dshHomePath);
4527
- ctx.on("internal/update", (_config, _noSave, next) => {
4528
- Promise.resolve(next()).catch((error) => {
4529
- ctx.logger.error(error);
4530
- });
4531
- }, {
4532
- global: true,
4533
- prepend: true
4534
- });
4535
5279
  await ctx.plugin(Loader);
4536
5280
  await prepare?.(ctx);
4537
5281
  stage = "plugin tree failed to load";
@@ -4541,6 +5285,11 @@ async function boot(binName, absoluteConfigPath, patches, prepare, bareModuleBas
4541
5285
  await auditStartupEntries(ctx, binName);
4542
5286
  return ctx;
4543
5287
  } catch (cause) {
5288
+ if (cause instanceof StartupError) try {
5289
+ cause.composition = captureStartupComposition(ctx);
5290
+ } catch (error) {
5291
+ ctx.logger.warn("Startup composition diagnostic unavailable", error);
5292
+ }
4544
5293
  await ctx.fiber.dispose();
4545
5294
  if (cause instanceof StartupError) {
4546
5295
  cause.startup = {
@@ -4563,4 +5312,4 @@ async function boot(binName, absoluteConfigPath, patches, prepare, bareModuleBas
4563
5312
  }
4564
5313
  }
4565
5314
  //#endregion
4566
- export { DEFAULT_PROFILE_BUNDLES, FAIL_LOUD_RELEASE_TIMEOUT_MS, LOADER_EXPRESSION_SCHEMA, OFFICIAL_ON_DEMAND_CATALOG, ON_DEMAND_BUNDLES, OPTIONAL_BUNDLES, PROFILES_DIR, PROFILE_COMPATIBILITY_FILENAME, PROFILE_PATCH_FILENAME, PROFILE_TEMPLATES, PluginPackages, ProfileRuntimeResolution, ResolutionRestartRequired, StartupError, auditStartupEntries, boot, bundlePatchFiles, bundlePatchPaths, composeEntries, createConfigProjector, createRuntimeResolution, evaluatePluginCompatibility, generateConfigSchema, getDshRuntimeVersion, initProfile, installFailLoud, isNativeConfigSchema, loadEnv, loadLayeredEnv, loadOptionalPatches, loadOverlayPatches, loadProfile, loadProfileDirectory, mountRootInclude, pluginCompatibilityWarning, prepareProfileEntries, prepareProfilePatches, readBundlePatches, readPluginMeta, readProfileCompatibility, readProfileManifest, readProfilePatches, readProfilePlugins, readProfileVersionExemptions, recomposeProfile, reconcileProfilePatches, reconcileProfilePlugins, removeLinkProjections, renderConfigDump, reportSkippedBundles, resolveBundleDir, resolveConfigPath, resolvePluginResource, resolveProfileDir, resolveTelemetryPatch, sanitizeProfile, setProfileVersionExemption, writeProfileBundles, writeProfileManifest };
5315
+ export { FAIL_LOUD_RELEASE_TIMEOUT_MS, LOADER_EXPRESSION_SCHEMA, PROFILES_DIR, PROFILE_COMPATIBILITY_FILENAME, PROFILE_PATCH_FILENAME, PluginPackages, ProfileRuntimeResolution, ResolutionRestartRequired, StartupError, UnsupportedBundlePlatformError, assertBundlePlatform, auditStartupEntries, boot, bundlePatchFiles, bundlePatchPaths, composeEntries, createConfigProjector, createRuntimeResolution, evaluatePluginCompatibility, generateConfigSchema, getDshRuntimeVersion, initProfile, installFailLoud, isNativeConfigSchema, loadEnv, loadLayeredEnv, loadOptionalPatches, loadOverlayPatches, loadProfile, loadProfileDirectory, mountRootInclude, pluginCompatibilityWarning, prepareProfileEntries, prepareProfilePatches, readBundlePatches, readPluginMeta, readProfileCompatibility, readProfileManifest, readProfilePatches, readProfilePlugins, readProfileVersionExemptions, recomposeProfile, reconcileProfilePatches, reconcileProfilePlugins, removeLinkProjections, renderConfigDump, reportSkippedBundles, resolveBundleDir, resolveConfigPath, resolvePluginResource, resolveProfileDir, resolveTelemetryPatch, sanitizeProfile, setProfileVersionExemption, validatePluginVersionExemption, writeProfileBundles, writeProfileManifest };