@wendoo/bridge-app 0.2.2

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.
Files changed (77) hide show
  1. package/README.md +44 -0
  2. package/dist/app-bridge.d.ts +77 -0
  3. package/dist/app-bridge.d.ts.map +1 -0
  4. package/dist/app-bridge.js +205 -0
  5. package/dist/app-environment-host.d.ts +524 -0
  6. package/dist/app-environment-host.d.ts.map +1 -0
  7. package/dist/app-environment-host.js +1560 -0
  8. package/dist/brain-diagnostics.d.ts +47 -0
  9. package/dist/brain-diagnostics.d.ts.map +1 -0
  10. package/dist/brain-diagnostics.js +100 -0
  11. package/dist/bridge-project.d.ts +32 -0
  12. package/dist/bridge-project.d.ts.map +1 -0
  13. package/dist/bridge-project.js +69 -0
  14. package/dist/compilation.d.ts +145 -0
  15. package/dist/compilation.d.ts.map +1 -0
  16. package/dist/compilation.js +404 -0
  17. package/dist/core-extension.d.ts +10 -0
  18. package/dist/core-extension.d.ts.map +1 -0
  19. package/dist/core-extension.js +9 -0
  20. package/dist/embedded-extension-id-gate.d.ts +38 -0
  21. package/dist/embedded-extension-id-gate.d.ts.map +1 -0
  22. package/dist/embedded-extension-id-gate.js +55 -0
  23. package/dist/embedded-extension-loader.d.ts +33 -0
  24. package/dist/embedded-extension-loader.d.ts.map +1 -0
  25. package/dist/embedded-extension-loader.js +90 -0
  26. package/dist/embedded-extension-vite-plugin.d.ts +34 -0
  27. package/dist/embedded-extension-vite-plugin.d.ts.map +1 -0
  28. package/dist/embedded-extension-vite-plugin.js +39 -0
  29. package/dist/embedded-extensions.d.ts +293 -0
  30. package/dist/embedded-extensions.d.ts.map +1 -0
  31. package/dist/embedded-extensions.js +526 -0
  32. package/dist/extension-catalog.d.ts +243 -0
  33. package/dist/extension-catalog.d.ts.map +1 -0
  34. package/dist/extension-catalog.js +408 -0
  35. package/dist/extension-install-log.d.ts +54 -0
  36. package/dist/extension-install-log.d.ts.map +1 -0
  37. package/dist/extension-install-log.js +22 -0
  38. package/dist/extension-install.d.ts +162 -0
  39. package/dist/extension-install.d.ts.map +1 -0
  40. package/dist/extension-install.js +412 -0
  41. package/dist/extension-report-presenter.d.ts +40 -0
  42. package/dist/extension-report-presenter.d.ts.map +1 -0
  43. package/dist/extension-report-presenter.js +36 -0
  44. package/dist/fetched-extension-snapshots.d.ts +66 -0
  45. package/dist/fetched-extension-snapshots.d.ts.map +1 -0
  46. package/dist/fetched-extension-snapshots.js +137 -0
  47. package/dist/folder-host-session.d.ts +85 -0
  48. package/dist/folder-host-session.d.ts.map +1 -0
  49. package/dist/folder-host-session.js +210 -0
  50. package/dist/index.d.ts +38 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +19 -0
  53. package/dist/library-offer.d.ts +60 -0
  54. package/dist/library-offer.d.ts.map +1 -0
  55. package/dist/library-offer.js +47 -0
  56. package/dist/library-uninstall-guard.d.ts +66 -0
  57. package/dist/library-uninstall-guard.d.ts.map +1 -0
  58. package/dist/library-uninstall-guard.js +103 -0
  59. package/dist/manifest-files.d.ts +53 -0
  60. package/dist/manifest-files.d.ts.map +1 -0
  61. package/dist/manifest-files.js +242 -0
  62. package/dist/node.d.ts +4 -0
  63. package/dist/node.d.ts.map +1 -0
  64. package/dist/node.js +2 -0
  65. package/dist/project-file-bridge.d.ts +10 -0
  66. package/dist/project-file-bridge.d.ts.map +1 -0
  67. package/dist/project-file-bridge.js +87 -0
  68. package/dist/user-tile-registration.d.ts +89 -0
  69. package/dist/user-tile-registration.d.ts.map +1 -0
  70. package/dist/user-tile-registration.js +100 -0
  71. package/dist/vfs-asset-url-provider.d.ts +22 -0
  72. package/dist/vfs-asset-url-provider.d.ts.map +1 -0
  73. package/dist/vfs-asset-url-provider.js +52 -0
  74. package/dist/workspace-folder-project-store.d.ts +125 -0
  75. package/dist/workspace-folder-project-store.d.ts.map +1 -0
  76. package/dist/workspace-folder-project-store.js +400 -0
  77. package/package.json +103 -0
@@ -0,0 +1,1560 @@
1
+ import { AppHostError, AppHostErrorCode, appHostError, applyCatalogMove, checkExtensionReferenceUpdate, collectUnstableDependencies, diffWendooJsonToManifest, ExtensionFetchErrorCode, fetchExtensionSnapshot, fileContentText, highestListedRelease, parseCatalogMoveReference, parseExtensionAddInput, parseProjectContentManifest, resolveExtensionAddInput, syncManifestToWendooJson, WENDOO_JSON_PATH, } from "@wendoo/app-host";
2
+ import { createWendooEnvironment, Dict, encodePersistedBrainJson, logger, renameBrainNamespaces, } from "@wendoo/core/app";
3
+ import { augmentProjectFileSystem, createBridgeProject, createProjectCompiler } from "./compilation.js";
4
+ import { CatalogMoveWarningCode, createCatalogMoveVersionLookup, ExtensionResolutionCycleError, resolveProjectExtensions, } from "./embedded-extensions.js";
5
+ import { collectExtensionFetchClosure, diffProjectDiagnostics, floatingPinsFromSnapshots, movedClosureHasMissingContent, typecheckBrainProblems, } from "./extension-install.js";
6
+ import { appendExtensionInstallLog, EXTENSION_INSTALL_LOG_APP_DATA_KEY, parseExtensionInstallLog, } from "./extension-install-log.js";
7
+ import { decodeInstalledSnapshotFiles, fetchedContentFromSnapshots, INSTALLED_EXTENSIONS_APP_DATA_KEY, installedExtensionMetadataFromSnapshots, parseInstalledExtensionSnapshots, serializeInstalledExtensionSnapshots, } from "./fetched-extension-snapshots.js";
8
+ import { applyCompiledUserTiles, collectTileSourceCompileErrors } from "./user-tile-registration.js";
9
+ // Project app-data keys.
10
+ const BRAINS_APP_DATA_KEY = "brains";
11
+ /**
12
+ * Build the `BRAIN_RECORD_UNREADABLE` error for a stored brain record that
13
+ * could not be read or parsed, carrying `cause`'s text as detail.
14
+ */
15
+ function brainRecordUnreadable(cause) {
16
+ const detail = cause instanceof Error ? cause.message : String(cause);
17
+ return appHostError(AppHostErrorCode.BRAIN_RECORD_UNREADABLE, `Stored brains could not be read: ${detail}`);
18
+ }
19
+ /**
20
+ * Build the `EXTENSION_RECORD_UNREADABLE` error for a stored installed-extension
21
+ * record that could not be read, carrying `cause`'s text as detail.
22
+ */
23
+ function extensionRecordUnreadable(cause) {
24
+ const detail = cause instanceof Error ? cause.message : String(cause);
25
+ return appHostError(AppHostErrorCode.EXTENSION_RECORD_UNREADABLE, `Stored installed libraries could not be read: ${detail}`);
26
+ }
27
+ const NO_COMPILE_DIAGNOSTICS = [];
28
+ // ---------------------------------------------------------------------------
29
+ // AppEnvironmentHost
30
+ // ---------------------------------------------------------------------------
31
+ /**
32
+ * Glue layer that wires a {@link ProjectManager}, a {@link WendooEnvironment},
33
+ * the project compiler, user-tile registration, and (optionally) the bridge
34
+ * into a single host an app UI can drive.
35
+ */
36
+ export class AppEnvironmentHost {
37
+ env;
38
+ projectManager;
39
+ mounts;
40
+ embeddedExtensions;
41
+ extensionFetchTransport;
42
+ catalogMoves;
43
+ approvedCatalogEntry;
44
+ onDidCompileCallback;
45
+ // -- Installed fetched-extension snapshots (persisted in the project store) --
46
+ _installedSnapshots = {};
47
+ _installedContent = new Map();
48
+ // -- Latest resolved extension closure of the active project --
49
+ _lastResolution;
50
+ _resolutionWarningsListeners = new Set();
51
+ // -- Last recorded fetch failure per reference (seeded from the install log) --
52
+ _fetchFailures = new Map();
53
+ // -- Latest workspace compile result --
54
+ _lastCompileResult;
55
+ // -- Per-tile error compile diagnostics and source path, keyed by ActionKey; rebuilt from the latest compile --
56
+ _tileCompileDiagnostics = new Map();
57
+ // -- Brain cache --
58
+ _brainCache = new Map();
59
+ _defaultBrainCache = new Map();
60
+ // -- First stored-record read failure of the active project's load --
61
+ _projectRecordFailure;
62
+ // -- Brain rebuild coordination --
63
+ _pendingBrainRebuild = false;
64
+ // -- Doc / VFS revision counters (useSyncExternalStore pattern) --
65
+ _docRevision = 0;
66
+ _vfsRevision = 0;
67
+ _docRevisionListeners = new Set();
68
+ _vfsRevisionListeners = new Set();
69
+ // -- Brain-diagnostics revision counter (useSyncExternalStore pattern) --
70
+ _brainDiagnosticsRevision = 0;
71
+ _brainDiagnosticsListeners = new Set();
72
+ // -- Latest workspace-compile diagnostics (useSyncExternalStore pattern) --
73
+ _compileDiagnostics = NO_COMPILE_DIAGNOSTICS;
74
+ _compileDiagnosticsListeners = new Set();
75
+ // -- Project lifecycle --
76
+ _projectUnloadingListeners = new Set();
77
+ _projectLoadedListeners = new Set();
78
+ // -- Bridge --
79
+ _bridge;
80
+ _bridgeUrl;
81
+ _bridgeStatus = "disconnected";
82
+ _bridgeJoinCode;
83
+ _bridgeStatusListeners = new Set();
84
+ _bridgeJoinCodeListeners = new Set();
85
+ _bridgeStateUnsub;
86
+ _remoteChangeUnsub;
87
+ _loadBindingToken;
88
+ _saveBindingToken;
89
+ // -- User tile metadata (last known) --
90
+ _lastUserTileMetadata;
91
+ // -- Compilation --
92
+ _compiler;
93
+ // -- Served file system (raw project files plus compiler-controlled files) --
94
+ _servedFileSystem;
95
+ // -- Compiler-controlled file set changes --
96
+ _compilerControlledFilesListeners = new Set();
97
+ constructor(options) {
98
+ this.projectManager = options.projectManager;
99
+ this.mounts = options.mounts;
100
+ this.embeddedExtensions = options.embeddedExtensions ?? [];
101
+ this.extensionFetchTransport = options.extensionFetchTransport;
102
+ this.catalogMoves = options.catalogMoves ?? {};
103
+ this.approvedCatalogEntry = options.approvedCatalogEntry;
104
+ this.onDidCompileCallback = options.onDidCompile;
105
+ this._bridgeUrl = options.bridgeUrl;
106
+ this._loadBindingToken = options.loadBindingToken ?? (() => undefined);
107
+ this._saveBindingToken = options.saveBindingToken ?? (() => { });
108
+ this.env = createWendooEnvironment({
109
+ modules: [...options.modules],
110
+ rng: options.rng,
111
+ numerics: options.numerics,
112
+ localizer: options.localizer,
113
+ });
114
+ this.env.onBrainsInvalidated((event) => {
115
+ if (event.invalidatedBrains.length > 0) {
116
+ this._pendingBrainRebuild = true;
117
+ }
118
+ });
119
+ }
120
+ // ---------------------------------------------------------------------------
121
+ // Project file system
122
+ // ---------------------------------------------------------------------------
123
+ get projectFileSystem() {
124
+ return this.projectManager.activeProject.filesystem;
125
+ }
126
+ /**
127
+ * The file system whose exported snapshot carries both the raw project files
128
+ * and the compiler-controlled files (ambient declarations, `tsconfig.json`,
129
+ * and the installed-extensions tree), including extension-owned assets such
130
+ * as tile icons. Each `exportSnapshot()` reads the live compiler, so
131
+ * installing or uninstalling an extension is reflected without a rebuild.
132
+ * Falls back to the raw project file system until the compiler is wired.
133
+ */
134
+ get servedProjectFileSystem() {
135
+ return this._servedFileSystem ?? this.projectFileSystem;
136
+ }
137
+ get activeProjectManifest() {
138
+ return this.projectManager.activeProject?.manifest;
139
+ }
140
+ /** Release bridge and project-manager resources owned by this host. */
141
+ dispose() {
142
+ this.teardownBridge();
143
+ this.projectManager.dispose();
144
+ }
145
+ // ---------------------------------------------------------------------------
146
+ // Initialize
147
+ // ---------------------------------------------------------------------------
148
+ async initialize(defaultProjectName) {
149
+ await this.projectManager.init();
150
+ const state = await this.projectManager.getProjectCollectionState();
151
+ if (state.access === "locked") {
152
+ return;
153
+ }
154
+ await this.projectManager.ensureDefaultProject(defaultProjectName);
155
+ await this.initCompiler();
156
+ this.absorbCatalogMovesOutcome(await this.applyCatalogMoves());
157
+ await this.loadBrainsFromProject();
158
+ }
159
+ // ---------------------------------------------------------------------------
160
+ // Compilation (always available, independent of bridge)
161
+ // ---------------------------------------------------------------------------
162
+ /**
163
+ * Resolve the active project's extension dependency graph against the host's
164
+ * embed record and the project's stored fetched snapshots. Logs any conflict
165
+ * warnings. On this load path a dependency cycle is logged and resolution
166
+ * falls back to no extension dependencies, so the project still loads and
167
+ * unresolved imports surface as ordinary compiler diagnostics; an install
168
+ * transaction refuses on a cycle instead.
169
+ */
170
+ resolveExtensions() {
171
+ try {
172
+ const resolved = resolveProjectExtensions(this.projectManager.activeProject.manifest.extensions, {
173
+ embedded: this.embeddedExtensions,
174
+ fetched: this._installedContent,
175
+ moves: this.catalogMoves,
176
+ floatingPins: floatingPinsFromSnapshots(this._installedSnapshots),
177
+ }, this.projectManager.activeProject.manifest.targets);
178
+ for (const warning of resolved.warnings) {
179
+ logger.warn(`[extension-resolution] ${warning.message}`);
180
+ }
181
+ return resolved;
182
+ }
183
+ catch (err) {
184
+ if (err instanceof ExtensionResolutionCycleError) {
185
+ logger.warn(`[extension-resolution] ${err.message}`);
186
+ return { dependencies: [], dependencyMounts: [], origins: [], warnings: [] };
187
+ }
188
+ throw err;
189
+ }
190
+ }
191
+ /** Replace the project's installed snapshot records, in memory and in the project store. */
192
+ async persistInstalledSnapshots(snapshots) {
193
+ this._installedSnapshots = snapshots;
194
+ this._installedContent = fetchedContentFromSnapshots(snapshots);
195
+ await this.projectManager.saveAppData(INSTALLED_EXTENSIONS_APP_DATA_KEY, serializeInstalledExtensionSnapshots(snapshots));
196
+ }
197
+ /**
198
+ * Read the active project's stored extension records: the installed
199
+ * snapshots and the install log. Both are empty when the project has stored
200
+ * neither. Returns undefined when the store could not serve them, recording
201
+ * the failure on {@link AppEnvironmentHost.projectRecordFailure}. On
202
+ * undefined the caller must leave the stored records untouched.
203
+ */
204
+ async readStoredExtensionRecordsForLoad() {
205
+ try {
206
+ const snapshots = parseInstalledExtensionSnapshots(await this.projectManager.loadAppData(INSTALLED_EXTENSIONS_APP_DATA_KEY));
207
+ const log = parseExtensionInstallLog(await this.projectManager.loadAppData(EXTENSION_INSTALL_LOG_APP_DATA_KEY));
208
+ return { snapshots, log };
209
+ }
210
+ catch (err) {
211
+ const failure = extensionRecordUnreadable(err);
212
+ logger.warn("Failed to load installed-library records:", failure);
213
+ this._projectRecordFailure ??= failure;
214
+ return undefined;
215
+ }
216
+ }
217
+ async initCompiler() {
218
+ // The installed-extensions tree regenerates from the stored snapshots;
219
+ // loading a project never reaches the network.
220
+ const stored = await this.readStoredExtensionRecordsForLoad();
221
+ this._installedSnapshots = stored?.snapshots ?? {};
222
+ this._installedContent = fetchedContentFromSnapshots(this._installedSnapshots);
223
+ this._fetchFailures = new Map();
224
+ for (const event of stored?.log ?? []) {
225
+ if (event.kind === "fetch-refusal") {
226
+ this._fetchFailures.set(event.reference, { code: event.code, message: event.message });
227
+ }
228
+ }
229
+ const resolution = this.resolveExtensions();
230
+ this.setLastResolution(resolution);
231
+ const { dependencies, dependencyMounts } = resolution;
232
+ this._compiler = createProjectCompiler({
233
+ environment: this.env,
234
+ filesystem: this.projectFileSystem,
235
+ projectNamespace: this.projectManager.activeProject.manifest.id,
236
+ mounts: this.mounts,
237
+ dependencies,
238
+ dependencyMounts,
239
+ onDidCompile: (result) => {
240
+ this._lastCompileResult = result;
241
+ this.setCompileDiagnostics(result);
242
+ this.persistMintedActionIds(result.projectResult.sourceRewrites);
243
+ logWorkspaceCompile(result);
244
+ const tileResult = applyCompiledUserTiles(this.env, result);
245
+ if (tileResult) {
246
+ this._lastUserTileMetadata = tileResult.metadata;
247
+ this.bumpDocRevision();
248
+ if (tileResult.changedActionKeys.length > 0) {
249
+ // A changed action bundle can make a previously unbuildable brain
250
+ // buildable, including one born invalidated because its action was
251
+ // missing at creation. Schedule the rebuild flush to retry the
252
+ // invalidated set even when this compile invalidated no live brain.
253
+ this._pendingBrainRebuild = true;
254
+ }
255
+ }
256
+ this.onDidCompileCallback?.(result, tileResult);
257
+ // A compile can change a tile's broken/clean status, which the per-brain
258
+ // diagnostics feed reads through the tile-compile map. Refresh the feed's
259
+ // subscribers so the brain-list badge tracks the newest compile.
260
+ this.bumpBrainDiagnosticsRevision();
261
+ },
262
+ });
263
+ this._servedFileSystem = augmentProjectFileSystem(this.projectFileSystem, this._compiler.compiler, {
264
+ onCompilerControlledFilesChanged: (files) => {
265
+ for (const listener of this._compilerControlledFilesListeners) {
266
+ listener(files);
267
+ }
268
+ },
269
+ });
270
+ syncManifestToWendooJson(this.projectFileSystem, this.projectManager.activeProject.manifest);
271
+ this._compiler.initialize();
272
+ }
273
+ /**
274
+ * Persist source files whose user-action declaration had a stable `id` minted
275
+ * during compilation. Writes the updated text to the project file system and
276
+ * to the compiler's in-memory view.
277
+ */
278
+ persistMintedActionIds(sourceRewrites) {
279
+ if (sourceRewrites.size === 0) {
280
+ return;
281
+ }
282
+ for (const [path, content] of sourceRewrites) {
283
+ const newEtag = `idgen-${Date.now()}`;
284
+ this.projectFileSystem.applyLocalChange({ action: "write", path, content, newEtag });
285
+ this._compiler?.compiler.applyWorkspaceChange({ action: "write", path, content, newEtag });
286
+ }
287
+ }
288
+ // ---------------------------------------------------------------------------
289
+ // Brain persistence (keyed by app-defined string)
290
+ // ---------------------------------------------------------------------------
291
+ async saveBrainForKey(key, brainDef) {
292
+ this._brainCache.set(key, brainDef);
293
+ const record = await this.loadBrainRecord();
294
+ record[key] = this.serializeBrainForStorage(brainDef);
295
+ await this.projectManager.saveAppData(BRAINS_APP_DATA_KEY, JSON.stringify(record));
296
+ this.bumpBrainDiagnosticsRevision();
297
+ }
298
+ /**
299
+ * Serialize a brain into its persisted form: identifiers qualified by the
300
+ * active project's namespace are stored with the namespace absent.
301
+ */
302
+ serializeBrainForStorage(brainDef) {
303
+ return encodePersistedBrainJson(brainDef, this.projectManager.activeProject.manifest.id);
304
+ }
305
+ async removeBrain(key) {
306
+ this._brainCache.delete(key);
307
+ const record = await this.loadBrainRecord();
308
+ delete record[key];
309
+ await this.projectManager.saveAppData(BRAINS_APP_DATA_KEY, JSON.stringify(record));
310
+ }
311
+ async loadBrainFromProject(key) {
312
+ const record = await this.loadBrainRecord();
313
+ const json = record[key];
314
+ if (!json)
315
+ return undefined;
316
+ return this.deserializeBrainForKey(key, json);
317
+ }
318
+ /**
319
+ * Returns the active project's in-memory brain for `key`, or undefined when none is cached. The
320
+ * cache is loaded on project load and updated by `saveBrainForKey` and `removeBrain`.
321
+ */
322
+ getCachedBrain(key) {
323
+ return this._brainCache.get(key);
324
+ }
325
+ /** Returns the keys of the active project's cached brains, in cache order. */
326
+ getCachedBrainKeys() {
327
+ return [...this._brainCache.keys()];
328
+ }
329
+ setDefaultBrain(key, brainDef) {
330
+ this._defaultBrainCache.set(key, brainDef);
331
+ }
332
+ getDefaultBrain(key) {
333
+ return this._defaultBrainCache.get(key);
334
+ }
335
+ async saveAllBrains() {
336
+ // Overlay the cache onto the stored record: an entry that failed to
337
+ // deserialize on load has no cache slot, and its stored bytes must survive.
338
+ const record = await this.loadBrainRecord();
339
+ for (const [key, def] of this._brainCache) {
340
+ record[key] = this.serializeBrainForStorage(def);
341
+ }
342
+ await this.projectManager.saveAppData(BRAINS_APP_DATA_KEY, JSON.stringify(record));
343
+ }
344
+ /**
345
+ * Read the active project's stored brain record together with the raw stored
346
+ * text it was parsed from. Both are empty -- `raw` undefined, `record` `{}` --
347
+ * when the project has never stored a record. Throws {@link AppHostError} with
348
+ * code `BRAIN_RECORD_UNREADABLE` -- and no other error -- when a stored record
349
+ * exists but cannot be read or parsed.
350
+ */
351
+ async readStoredBrains() {
352
+ let raw;
353
+ try {
354
+ raw = await this.projectManager.loadAppData(BRAINS_APP_DATA_KEY);
355
+ }
356
+ catch (err) {
357
+ throw brainRecordUnreadable(err);
358
+ }
359
+ if (!raw)
360
+ return { raw, record: {} };
361
+ try {
362
+ return { raw, record: JSON.parse(raw) };
363
+ }
364
+ catch (err) {
365
+ throw brainRecordUnreadable(err);
366
+ }
367
+ }
368
+ /**
369
+ * Read the active project's stored brain record. Returns an empty record when
370
+ * the project has never stored one. Throws {@link AppHostError} with code
371
+ * `BRAIN_RECORD_UNREADABLE` -- and no other error -- when a stored record
372
+ * exists but cannot be read or parsed.
373
+ */
374
+ async loadBrainRecord() {
375
+ return (await this.readStoredBrains()).record;
376
+ }
377
+ /**
378
+ * Read the stored brains on a project-load path. Returns the record and the
379
+ * raw stored text, or undefined when they could not be read, recording the
380
+ * failure on {@link AppEnvironmentHost.projectRecordFailure}. On undefined the
381
+ * caller must leave the stored brains untouched.
382
+ */
383
+ async readStoredBrainsForLoad() {
384
+ try {
385
+ return await this.readStoredBrains();
386
+ }
387
+ catch (err) {
388
+ if (!(err instanceof AppHostError)) {
389
+ throw err;
390
+ }
391
+ logger.warn("Failed to load brain record:", err);
392
+ this._projectRecordFailure ??= err;
393
+ return undefined;
394
+ }
395
+ }
396
+ /**
397
+ * The first failure that stopped one of the active project's stored records
398
+ * from loading, or undefined when they all loaded. Its `code` names the
399
+ * record: `BRAIN_RECORD_UNREADABLE` for the saved brains,
400
+ * `EXTENSION_RECORD_UNREADABLE` for the installed-library snapshots and the
401
+ * install log. While it is set the brain cache is empty because a record
402
+ * could not be read, not because the project has no brains, every brain
403
+ * write refuses, and no write replaces a record that could not be read.
404
+ * Cleared when the project unloads.
405
+ */
406
+ get projectRecordFailure() {
407
+ return this._projectRecordFailure;
408
+ }
409
+ /** True while the active project's stored extension records could not be read. */
410
+ get extensionRecordUnreadable() {
411
+ return this._projectRecordFailure?.code === AppHostErrorCode.EXTENSION_RECORD_UNREADABLE;
412
+ }
413
+ /**
414
+ * Deserialize the project's stored brains into the cache. Call after the
415
+ * load's catalog moves have applied: deserialization resolves saved tile
416
+ * references against the current action bundle. Caches nothing when the load
417
+ * already recorded a stored-record failure: the extension closure is then
418
+ * unknown, so no saved tile reference resolves.
419
+ */
420
+ async loadBrainsFromProject() {
421
+ if (this._projectRecordFailure) {
422
+ return;
423
+ }
424
+ const stored = await this.readStoredBrainsForLoad();
425
+ if (!stored) {
426
+ return;
427
+ }
428
+ for (const [key, json] of Object.entries(stored.record)) {
429
+ const def = this.deserializeBrainForKey(key, json);
430
+ if (def) {
431
+ this._brainCache.set(key, def);
432
+ }
433
+ }
434
+ }
435
+ /**
436
+ * Reconcile the in-memory brain cache against the project store's current
437
+ * brain record: cached brains absent from the record are dropped, and
438
+ * record entries whose stored form differs from the cached brain are
439
+ * deserialized into the cache. Returns the changed and removed brain keys.
440
+ */
441
+ async reconcileBrainsFromStore() {
442
+ const record = await this.loadBrainRecord();
443
+ const changed = [];
444
+ const removed = [];
445
+ for (const key of [...this._brainCache.keys()]) {
446
+ if (!(key in record)) {
447
+ this._brainCache.delete(key);
448
+ removed.push(key);
449
+ }
450
+ }
451
+ for (const [key, json] of Object.entries(record)) {
452
+ const cached = this._brainCache.get(key);
453
+ if (cached && JSON.stringify(json) === JSON.stringify(this.serializeBrainForStorage(cached))) {
454
+ continue;
455
+ }
456
+ const def = this.deserializeBrainForKey(key, json);
457
+ if (!def) {
458
+ continue;
459
+ }
460
+ this._brainCache.set(key, def);
461
+ changed.push(key);
462
+ }
463
+ return { changed, removed };
464
+ }
465
+ deserializeBrainForKey(key, json) {
466
+ try {
467
+ const brainDef = this.env.deserializeBrainJsonFromPlain(json, this.projectManager.activeProject.manifest.id);
468
+ if (brainDef.pages().size() === 0) {
469
+ brainDef.appendNewPage();
470
+ }
471
+ // Establish the stored typecheck state consumers of the loaded brain
472
+ // read (badges, diagnostics baselines).
473
+ typecheckBrainProblems(brainDef);
474
+ return brainDef;
475
+ }
476
+ catch (err) {
477
+ logger.warn(`Failed to load brain "${key}":`, err);
478
+ return undefined;
479
+ }
480
+ }
481
+ /**
482
+ * Re-resolve every cached brain's tile references against the current
483
+ * catalogs by round-tripping it through its persisted form -- the same
484
+ * resolution a project load performs, so a tile whose library left the
485
+ * closure becomes a placeholder and a placeholder whose library returned
486
+ * becomes the real tile again. A brain whose round-trip fails keeps its
487
+ * current definition.
488
+ */
489
+ refreshBrainCache() {
490
+ for (const [key, brainDef] of [...this._brainCache]) {
491
+ const refreshed = this.deserializeBrainForKey(key, this.serializeBrainForStorage(brainDef));
492
+ if (refreshed) {
493
+ this._brainCache.set(key, refreshed);
494
+ }
495
+ }
496
+ }
497
+ // ---------------------------------------------------------------------------
498
+ // Project metadata
499
+ // ---------------------------------------------------------------------------
500
+ async updateProjectMetadata(updates) {
501
+ await this.projectManager.updateActive(updates);
502
+ syncManifestToWendooJson(this.projectFileSystem, this.projectManager.activeProject.manifest);
503
+ }
504
+ /** The installed fetched-extension content available to the active project, keyed by reference. */
505
+ get installedExtensionContent() {
506
+ return this._installedContent;
507
+ }
508
+ /**
509
+ * The compiler-controlled file set of the active project's workspace: the
510
+ * generated `tsconfig.json`, ambient declarations, and the materialized
511
+ * installed-extensions tree. Undefined until the compiler is wired.
512
+ */
513
+ getCompilerControlledFiles() {
514
+ return this._compiler?.compiler.getCompilerControlledFiles();
515
+ }
516
+ /**
517
+ * Subscribe to compiler-controlled file set changes. The listener receives
518
+ * the full new set after each compile that changed it. Returns an
519
+ * unsubscribe function.
520
+ */
521
+ onCompilerControlledFilesChanged(listener) {
522
+ this._compilerControlledFilesListeners.add(listener);
523
+ return () => {
524
+ this._compilerControlledFilesListeners.delete(listener);
525
+ };
526
+ }
527
+ /** Install provenance of every installed fetched dependency, keyed by `<owner>/<repo>` origin. */
528
+ getInstalledExtensionMetadata() {
529
+ return installedExtensionMetadataFromSnapshots(this._installedSnapshots);
530
+ }
531
+ /**
532
+ * The installed libraries of the active project's resolved extension
533
+ * closure: one record per resolved origin, carrying the coordinate a
534
+ * compiled tile's identity namespace names and the display name the
535
+ * library's own `wendoo.json` declares. Empty until the compiler is
536
+ * wired.
537
+ */
538
+ get installedLibraries() {
539
+ const origins = this._lastResolution?.origins ?? [];
540
+ return origins.map((origin) => ({ coordinate: origin.origin, name: origin.name }));
541
+ }
542
+ /** The last recorded fetch failure per reference, keyed by the reference string as written. */
543
+ get extensionFetchFailures() {
544
+ return this._fetchFailures;
545
+ }
546
+ /**
547
+ * Non-fatal warnings of the active project's latest extension resolution,
548
+ * including the stable-coded catalog-move findings the load's move
549
+ * application recorded. Empty until the compiler is wired.
550
+ */
551
+ get resolutionWarnings() {
552
+ return this._lastResolution?.warnings ?? NO_RESOLUTION_WARNINGS;
553
+ }
554
+ /**
555
+ * Subscribe to resolution-warning changes for `useSyncExternalStore`. The
556
+ * listener fires whenever the active project's latest resolution is
557
+ * replaced: on project load and switch, and after each install transaction.
558
+ * Returns an unsubscribe function.
559
+ */
560
+ subscribeToResolutionWarnings = (listener) => {
561
+ this._resolutionWarningsListeners.add(listener);
562
+ return () => this._resolutionWarningsListeners.delete(listener);
563
+ };
564
+ /** Snapshot of {@link resolutionWarnings} for `useSyncExternalStore`. */
565
+ getResolutionWarningsSnapshot = () => {
566
+ return this.resolutionWarnings;
567
+ };
568
+ /** Record the latest resolution and notify resolution-warning subscribers. */
569
+ setLastResolution(resolution) {
570
+ this._lastResolution = resolution;
571
+ for (const listener of this._resolutionWarningsListeners) {
572
+ listener();
573
+ }
574
+ }
575
+ /** Merge a load's catalog-move findings into the latest resolution's warnings, skipping duplicates. */
576
+ absorbCatalogMovesOutcome(outcome) {
577
+ if (outcome === undefined || outcome.warnings.length === 0 || this._lastResolution === undefined) {
578
+ return;
579
+ }
580
+ const seen = new Set(this._lastResolution.warnings.map(resolutionWarningKey));
581
+ const added = outcome.warnings.filter((warning) => !seen.has(resolutionWarningKey(warning)));
582
+ if (added.length === 0) {
583
+ return;
584
+ }
585
+ this.setLastResolution({
586
+ ...this._lastResolution,
587
+ warnings: [...this._lastResolution.warnings, ...added],
588
+ });
589
+ }
590
+ /** Fetch one `gh:` reference's snapshot through the host's transport. */
591
+ fetchSnapshot(reference) {
592
+ if (!this.extensionFetchTransport) {
593
+ return Promise.resolve({
594
+ ok: false,
595
+ error: {
596
+ code: ExtensionFetchErrorCode.UNREACHABLE,
597
+ reference,
598
+ message: "The host application provides no extension fetch transport.",
599
+ },
600
+ });
601
+ }
602
+ return fetchExtensionSnapshot(reference, this.extensionFetchTransport);
603
+ }
604
+ /** Materialize a resolution: dependencies, `wendoo.json`, and a recompile of the whole workspace. */
605
+ applyResolution(resolution) {
606
+ if (!this._compiler) {
607
+ return;
608
+ }
609
+ this.setLastResolution(resolution);
610
+ this._compiler.compiler.setDependencies(resolution.dependencies, resolution.dependencyMounts);
611
+ syncManifestToWendooJson(this.projectFileSystem, this.projectManager.activeProject.manifest);
612
+ this._compiler.replaceProjectFiles();
613
+ }
614
+ /** Rewrite `wendoo.json` from the store manifest, restoring the file after a refused transaction. */
615
+ resyncManifestFile() {
616
+ const active = this.projectManager.activeProject;
617
+ if (active) {
618
+ syncManifestToWendooJson(this.projectFileSystem, active.manifest);
619
+ }
620
+ }
621
+ /**
622
+ * The active project's diagnostic state: the latest workspace compile's
623
+ * per-file diagnostics plus a fresh typecheck of every cached brain.
624
+ */
625
+ captureProjectDiagnostics() {
626
+ const files = this._lastCompileResult?.files ?? new Map();
627
+ const brains = new Map();
628
+ for (const [key, brain] of this._brainCache) {
629
+ brains.set(key, typecheckBrainProblems(brain));
630
+ }
631
+ return { files, brains };
632
+ }
633
+ /**
634
+ * Append the transaction's events to the project's install log. Appends
635
+ * nothing while the stored extension records are unreadable: the log's
636
+ * existing entries are unknown, so a write would replace them with these
637
+ * events alone.
638
+ */
639
+ async appendInstallLogEvents(events) {
640
+ if (events.length === 0 || this.extensionRecordUnreadable) {
641
+ return;
642
+ }
643
+ const raw = await this.projectManager.loadAppData(EXTENSION_INSTALL_LOG_APP_DATA_KEY);
644
+ await this.projectManager.saveAppData(EXTENSION_INSTALL_LOG_APP_DATA_KEY, appendExtensionInstallLog(raw, events));
645
+ }
646
+ /**
647
+ * The install and remove events between two resolved closures, plus the new
648
+ * resolution's warnings. An origin present in both closures records an
649
+ * install event when its winning reference or installed specifier changed
650
+ * (an update).
651
+ */
652
+ installLogEventsForChange(previous, next, previousSnapshots, nextSnapshots) {
653
+ const at = Date.now();
654
+ const previousByOrigin = new Map(previous.origins.map((origin) => [origin.origin, origin]));
655
+ const nextOrigins = new Set(next.origins.map((origin) => origin.origin));
656
+ const events = [];
657
+ for (const origin of next.origins) {
658
+ const specifier = nextSnapshots[origin.origin]?.specifier;
659
+ const before = previousByOrigin.get(origin.origin);
660
+ if (before !== undefined &&
661
+ before.reference === origin.reference &&
662
+ previousSnapshots[origin.origin]?.specifier === specifier) {
663
+ continue;
664
+ }
665
+ events.push({
666
+ kind: "install",
667
+ at,
668
+ origin: origin.origin,
669
+ reference: origin.reference,
670
+ ...(specifier !== undefined ? { specifier } : {}),
671
+ });
672
+ }
673
+ for (const origin of previous.origins) {
674
+ if (!nextOrigins.has(origin.origin)) {
675
+ events.push({ kind: "remove", at, origin: origin.origin });
676
+ }
677
+ }
678
+ for (const warning of next.warnings) {
679
+ events.push({ kind: "resolution-warning", at, warning });
680
+ }
681
+ return events;
682
+ }
683
+ /**
684
+ * Apply an extensions-map change to the active project as one transaction:
685
+ * fetch content for every newly reachable `gh:` reference, resolve the
686
+ * transitive dependency graph, then commit -- persist the manifest entries
687
+ * and the fetched snapshots, re-materialize the installed-extensions tree,
688
+ * recompile the workspace, and re-typecheck the project's brains -- and
689
+ * report the diagnostic difference against the pre-change baseline.
690
+ *
691
+ * Improved, unchanged, and worsened outcomes all commit. The transaction
692
+ * refuses outright only on mechanics failures -- an unreachable source, a
693
+ * missing or unparseable manifest, a missing listed file, a dependency
694
+ * cycle, or stored extension records the store could not serve -- leaving
695
+ * the project unchanged. Every commit appends its install, remove, and
696
+ * resolution-warning events to the project's install log.
697
+ *
698
+ * @param extensions - The active project's next extensions map, keyed by coordinate.
699
+ * @param options.refetchReferences - References fetched fresh even when a stored snapshot matches.
700
+ */
701
+ async updateProjectExtensions(extensions, options) {
702
+ if (this.extensionRecordUnreadable) {
703
+ const failure = this._projectRecordFailure;
704
+ return { committed: false, refusal: { kind: "store", code: failure.code, message: failure.message } };
705
+ }
706
+ const previousSnapshots = this._installedSnapshots;
707
+ const previousResolution = this.resolveExtensions();
708
+ const baseline = this.captureProjectDiagnostics();
709
+ const closure = await collectExtensionFetchClosure({
710
+ extensions,
711
+ embedded: this.embeddedExtensions,
712
+ stored: previousSnapshots,
713
+ refetch: options?.refetchReferences,
714
+ moves: this.catalogMoves,
715
+ fetchSnapshot: (reference) => this.fetchSnapshot(reference),
716
+ ...(this.extensionFetchTransport !== undefined
717
+ ? {
718
+ listVersions: (owner, repo) => this.extensionFetchTransport.listVersionTags(owner, repo),
719
+ }
720
+ : {}),
721
+ });
722
+ if (!closure.ok) {
723
+ this.resyncManifestFile();
724
+ this._fetchFailures.set(closure.error.reference, {
725
+ code: closure.error.code,
726
+ message: closure.error.message,
727
+ });
728
+ await this.appendInstallLogEvents([
729
+ {
730
+ kind: "fetch-refusal",
731
+ at: Date.now(),
732
+ reference: closure.error.reference,
733
+ code: closure.error.code,
734
+ message: closure.error.message,
735
+ },
736
+ ]);
737
+ return { committed: false, refusal: { kind: "fetch", error: closure.error } };
738
+ }
739
+ const fetchedContent = new Map();
740
+ for (const [reference, record] of closure.snapshotsByReference) {
741
+ fetchedContent.set(reference, decodeInstalledSnapshotFiles(record));
742
+ }
743
+ let resolution;
744
+ try {
745
+ resolution = resolveProjectExtensions(extensions, {
746
+ embedded: this.embeddedExtensions,
747
+ fetched: fetchedContent,
748
+ moves: this.catalogMoves,
749
+ floatingPins: closure.floatingPins,
750
+ }, this.projectManager.activeProject.manifest.targets);
751
+ }
752
+ catch (err) {
753
+ if (err instanceof ExtensionResolutionCycleError) {
754
+ this.resyncManifestFile();
755
+ return { committed: false, refusal: { kind: "cycle", cycle: err.cycle, message: err.message } };
756
+ }
757
+ throw err;
758
+ }
759
+ // Commit. Snapshot records persist for exactly the fetched origins in the
760
+ // resolved closure; records for origins the change dropped are pruned.
761
+ // Invariant: the installed content swaps in before the manifest commit,
762
+ // and the commit's active-project notification observes the new closure's
763
+ // content. A manifest commit failure restores the previous snapshots.
764
+ const nextSnapshots = {};
765
+ for (const origin of resolution.origins) {
766
+ const record = closure.snapshotsByReference.get(origin.reference);
767
+ if (record) {
768
+ nextSnapshots[origin.origin] = record;
769
+ }
770
+ }
771
+ await this.persistInstalledSnapshots(nextSnapshots);
772
+ try {
773
+ await this.projectManager.updateActive({ extensions });
774
+ }
775
+ catch (err) {
776
+ await this.persistInstalledSnapshots(previousSnapshots);
777
+ throw err;
778
+ }
779
+ for (const reference of closure.snapshotsByReference.keys()) {
780
+ this._fetchFailures.delete(reference);
781
+ }
782
+ this.applyResolution(resolution);
783
+ this.refreshBrainCache();
784
+ const outcome = diffProjectDiagnostics(baseline, this.captureProjectDiagnostics());
785
+ this.bumpBrainDiagnosticsRevision();
786
+ await this.appendInstallLogEvents(this.installLogEventsForChange(previousResolution, resolution, previousSnapshots, nextSnapshots));
787
+ for (const warning of closure.warnings) {
788
+ logger.warn(`[catalog-moves] ${warning.kind === "catalog-move-failed" ? warning.code : warning.kind}: ${warning.message}`);
789
+ }
790
+ const warnings = [...resolution.warnings, ...closure.warnings];
791
+ return { committed: true, outcome, warnings };
792
+ }
793
+ /** Version lookup over the embed record and the project's persisted snapshot content. */
794
+ moveVersionLookup() {
795
+ return createCatalogMoveVersionLookup({
796
+ embedded: this.embeddedExtensions,
797
+ contentForReference: (reference) => this._installedContent.get(reference),
798
+ });
799
+ }
800
+ /**
801
+ * Resolve a floating catalog-move destination for a coordinate to a pinned
802
+ * `gh:` reference: the coordinate's persisted snapshot pin when one exists,
803
+ * otherwise the highest stable published version listed by the host's
804
+ * transport. Returns undefined when no pin can be produced.
805
+ */
806
+ async resolveFloatingMoveReference(coordinate) {
807
+ const known = floatingPinsFromSnapshots(this._installedSnapshots).get(coordinate.toLowerCase());
808
+ if (known !== undefined) {
809
+ return known;
810
+ }
811
+ if (!this.extensionFetchTransport) {
812
+ return undefined;
813
+ }
814
+ const slash = coordinate.indexOf("/");
815
+ const listed = await this.extensionFetchTransport.listVersionTags(coordinate.slice(0, slash), coordinate.slice(slash + 1));
816
+ const version = listed.ok ? highestListedRelease(listed.versions) : undefined;
817
+ return version === undefined ? undefined : `gh:${coordinate}@${version}`;
818
+ }
819
+ /**
820
+ * Apply the host's curated catalog moves to the active project. Each
821
+ * captured top-level manifest entry is one move-application unit: its
822
+ * manifest rewrite, its saved-brain namespace rewrite (when the entry's
823
+ * final coordinate differs), and the fetches its chain requires run as one
824
+ * install transaction, rolled back together when the transaction refuses. A
825
+ * failed unit writes nothing, surfaces a stable-coded warning, and is
826
+ * retried on the next load, while other units proceed independently.
827
+ *
828
+ * Application interleaves apply, fetch, and re-apply: after each committed
829
+ * transaction the entries are re-applied with the versions the new content
830
+ * determines, and a transitive moved dependency whose content the project
831
+ * never persisted is healed by a fetch transaction, until nothing changes.
832
+ * A floating destination is resolved to a pin before its transaction runs;
833
+ * the pin is what the manifest receives. Returns undefined when the host
834
+ * declares no moves or the project is a true no-op; otherwise the
835
+ * transactions' reports and the load's stable-coded move warnings. Returns
836
+ * undefined without applying anything while the stored extension records are
837
+ * unreadable, so no move writes over a record the store could not serve.
838
+ */
839
+ async applyCatalogMoves() {
840
+ if (Object.keys(this.catalogMoves).length === 0 || this.extensionRecordUnreadable) {
841
+ return undefined;
842
+ }
843
+ const reports = [];
844
+ const warnings = [];
845
+ const warned = new Set();
846
+ const warn = (reference, code, message) => {
847
+ const key = `${reference} ${code}`;
848
+ if (warned.has(key)) {
849
+ return;
850
+ }
851
+ warned.add(key);
852
+ warnings.push({
853
+ kind: "catalog-move-failed",
854
+ origin: parseCatalogMoveReference(reference)?.coordinate ?? reference,
855
+ reference,
856
+ code,
857
+ message,
858
+ });
859
+ };
860
+ // Entries whose unit failed this load; they are not retried until the next load.
861
+ const failedEntries = new Set();
862
+ let progressed = true;
863
+ while (progressed) {
864
+ progressed = false;
865
+ // Top-level entries: each captured entry is its own transaction.
866
+ const lookup = this.moveVersionLookup();
867
+ const current = this.projectManager.activeProject?.manifest.extensions ?? {};
868
+ for (const [coordinate, reference] of Object.entries(current)) {
869
+ if (failedEntries.has(coordinate)) {
870
+ continue;
871
+ }
872
+ const applied = applyCatalogMove(reference, this.catalogMoves, lookup);
873
+ if (!applied.ok) {
874
+ warn(reference, applied.code, applied.message);
875
+ failedEntries.add(coordinate);
876
+ continue;
877
+ }
878
+ if (!applied.moved) {
879
+ continue;
880
+ }
881
+ let finalReference = applied.reference;
882
+ const parts = parseCatalogMoveReference(finalReference);
883
+ if (parts === undefined) {
884
+ continue;
885
+ }
886
+ if (parts.floating) {
887
+ const pin = await this.resolveFloatingMoveReference(parts.coordinate);
888
+ if (pin === undefined) {
889
+ warn(reference, CatalogMoveWarningCode.FLOATING_UNRESOLVED, `Catalog move for "${reference}" resolves to the floating "${finalReference}" and no stable ` +
890
+ "published version could be pinned; the move is skipped this load.");
891
+ failedEntries.add(coordinate);
892
+ continue;
893
+ }
894
+ finalReference = pin;
895
+ }
896
+ const finalCoordinate = parts.coordinate;
897
+ const next = {};
898
+ for (const [key, value] of Object.entries(current)) {
899
+ if (key !== coordinate) {
900
+ next[key] = value;
901
+ }
902
+ }
903
+ next[finalCoordinate] = finalReference;
904
+ // A rename rewrites saved-brain namespaces in the same unit, restored
905
+ // together when the transaction refuses. A brain record the store
906
+ // cannot serve fails the unit before any write.
907
+ let brainsBeforeRename;
908
+ let brainsRewritten = false;
909
+ if (finalCoordinate !== coordinate) {
910
+ const stored = await this.readStoredBrainsForLoad();
911
+ if (stored === undefined) {
912
+ warn(reference, CatalogMoveWarningCode.BRAINS_UNREADABLE, `Catalog move for "${reference}" renames the coordinate to "${finalCoordinate}", and the project's ` +
913
+ "stored brains could not be read to rewrite their references; the move is skipped this load.");
914
+ failedEntries.add(coordinate);
915
+ continue;
916
+ }
917
+ brainsBeforeRename = stored.raw;
918
+ brainsRewritten = await this.renameBrainNamespaces(stored.record, [
919
+ { from: coordinate, to: finalCoordinate },
920
+ ]);
921
+ }
922
+ const report = await this.updateProjectExtensions(next);
923
+ reports.push(report);
924
+ if (!report.committed) {
925
+ if (brainsRewritten) {
926
+ await this.restoreBrains(brainsBeforeRename);
927
+ }
928
+ const detail = report.refusal.kind === "fetch"
929
+ ? `${report.refusal.error.code}: ${report.refusal.error.message}`
930
+ : report.refusal.message;
931
+ warn(reference, CatalogMoveWarningCode.FETCH_FAILED, `Catalog move for "${reference}" to "${finalReference}" was refused (${detail}); the move is ` +
932
+ "skipped this load.");
933
+ failedEntries.add(coordinate);
934
+ continue;
935
+ }
936
+ progressed = true;
937
+ break;
938
+ }
939
+ if (progressed) {
940
+ continue;
941
+ }
942
+ // Transitive heal: a moved dependency at any depth may lack content on
943
+ // this load -- a dependency installed while the moved library was
944
+ // bundled has no persisted snapshot, and a floating destination may not
945
+ // be pinned yet. The fetch transaction fetches and persists exactly the
946
+ // missing content; content it learns can enable further captures.
947
+ const extensions = this.projectManager.activeProject?.manifest.extensions ?? {};
948
+ const needsHeal = await movedClosureHasMissingContent({
949
+ extensions,
950
+ embedded: this.embeddedExtensions,
951
+ stored: this._installedSnapshots,
952
+ moves: this.catalogMoves,
953
+ });
954
+ if (needsHeal) {
955
+ const before = new Set(Object.values(this._installedSnapshots).map((record) => record.reference));
956
+ const report = await this.updateProjectExtensions(extensions);
957
+ reports.push(report);
958
+ if (report.committed) {
959
+ if (Object.values(this._installedSnapshots).some((record) => !before.has(record.reference))) {
960
+ progressed = true;
961
+ }
962
+ }
963
+ else if (report.refusal.kind === "fetch") {
964
+ warn(report.refusal.error.reference, CatalogMoveWarningCode.FETCH_FAILED, report.refusal.error.message);
965
+ }
966
+ }
967
+ }
968
+ // A version-scoped capture still unevaluable at the end of the loop is a
969
+ // loud skip, retried on the next load.
970
+ const lookup = this.moveVersionLookup();
971
+ for (const reference of Object.values(this.projectManager.activeProject?.manifest.extensions ?? {})) {
972
+ const applied = applyCatalogMove(reference, this.catalogMoves, lookup);
973
+ if (applied.ok && applied.pendingVersion) {
974
+ warn(reference, CatalogMoveWarningCode.VERSION_UNKNOWN, `The version of "${reference}" could not be determined, so its version-scoped catalog move cannot be ` +
975
+ "evaluated; the move is skipped this load.");
976
+ }
977
+ }
978
+ for (const warning of warnings) {
979
+ if (warning.kind === "catalog-move-failed") {
980
+ logger.warn(`[catalog-moves] ${warning.code}: ${warning.message}`);
981
+ }
982
+ }
983
+ if (reports.length === 0 && warnings.length === 0) {
984
+ return undefined;
985
+ }
986
+ return { reports, warnings };
987
+ }
988
+ /**
989
+ * Rewrite every saved brain's foreign-namespace references from each rename's
990
+ * source coordinate to its target, persisting the rewritten brains record and
991
+ * re-deserializing the changed brains into the cache. Returns true when at
992
+ * least one brain changed.
993
+ *
994
+ * @param record - The stored brain record to rewrite, read by the caller; mutated in place.
995
+ * @param renames - The coordinate renames to apply, each a source and its target.
996
+ */
997
+ async renameBrainNamespaces(record, renames) {
998
+ const rewrite = (namespace) => {
999
+ for (const rename of renames) {
1000
+ if (rename.from === namespace) {
1001
+ return rename.to;
1002
+ }
1003
+ }
1004
+ return namespace;
1005
+ };
1006
+ const changedKeys = [];
1007
+ for (const [key, json] of Object.entries(record)) {
1008
+ const result = renameBrainNamespaces(json, rewrite);
1009
+ if (result.changed) {
1010
+ record[key] = result.brain;
1011
+ changedKeys.push(key);
1012
+ }
1013
+ }
1014
+ if (changedKeys.length === 0) {
1015
+ return false;
1016
+ }
1017
+ await this.projectManager.saveAppData(BRAINS_APP_DATA_KEY, JSON.stringify(record));
1018
+ for (const key of changedKeys) {
1019
+ const def = this.deserializeBrainForKey(key, record[key]);
1020
+ if (def) {
1021
+ this._brainCache.set(key, def);
1022
+ }
1023
+ }
1024
+ return true;
1025
+ }
1026
+ /** Restore the brains record and cache to a pre-rewrite snapshot after a refused migration. */
1027
+ async restoreBrains(raw) {
1028
+ if (raw === undefined) {
1029
+ return;
1030
+ }
1031
+ await this.projectManager.saveAppData(BRAINS_APP_DATA_KEY, raw);
1032
+ const record = JSON.parse(raw);
1033
+ for (const [key, json] of Object.entries(record)) {
1034
+ const def = this.deserializeBrainForKey(key, json);
1035
+ if (def) {
1036
+ this._brainCache.set(key, def);
1037
+ }
1038
+ }
1039
+ }
1040
+ /**
1041
+ * Normalize add-by-reference input to an installable extension reference: a
1042
+ * complete reference, an `<owner>/<repo>` coordinate, or a GitHub repository
1043
+ * URL. Input naming a repository without a version resolves to the
1044
+ * repository's latest published version through the host's transport. Fails
1045
+ * with a stable code when the input is unrecognized, no version can be
1046
+ * resolved, or the host has no transport to resolve one through.
1047
+ *
1048
+ * @param input - The pasted add-field text.
1049
+ */
1050
+ async resolveExtensionInstallInput(input) {
1051
+ if (this.extensionFetchTransport) {
1052
+ return resolveExtensionAddInput(input, this.extensionFetchTransport);
1053
+ }
1054
+ const parsed = parseExtensionAddInput(input);
1055
+ switch (parsed.kind) {
1056
+ case "reference":
1057
+ return { ok: true, reference: parsed.reference };
1058
+ case "coordinate":
1059
+ return {
1060
+ ok: false,
1061
+ code: ExtensionFetchErrorCode.UNREACHABLE,
1062
+ message: "The host application provides no extension fetch transport.",
1063
+ };
1064
+ case "invalid":
1065
+ return { ok: false, code: parsed.code, message: parsed.message };
1066
+ }
1067
+ }
1068
+ /**
1069
+ * Check one installed fetched dependency of the active project for newer
1070
+ * content: a `@<pin>` reference against the host catalog's approved pin when
1071
+ * the catalog lists the coordinate and against the source's published
1072
+ * versions when it does not, a `#<branch>` reference against the branch's
1073
+ * head commit. The check runs on request, reaches the source through the
1074
+ * host's transport, and changes nothing.
1075
+ *
1076
+ * @param coordinate - The dependency's `<owner>/<repo>` coordinate in the project's extensions map.
1077
+ */
1078
+ async checkExtensionUpdate(coordinate) {
1079
+ const reference = this.projectManager.activeProject.manifest.extensions?.[coordinate];
1080
+ const record = this._installedSnapshots[coordinate];
1081
+ if (reference === undefined || record === undefined || record.reference !== reference) {
1082
+ return {
1083
+ ok: false,
1084
+ error: {
1085
+ code: ExtensionFetchErrorCode.INVALID_REFERENCE,
1086
+ reference: reference ?? coordinate,
1087
+ message: `"${coordinate}" is not an installed fetched dependency of the active project.`,
1088
+ },
1089
+ };
1090
+ }
1091
+ if (!this.extensionFetchTransport) {
1092
+ return {
1093
+ ok: false,
1094
+ error: {
1095
+ code: ExtensionFetchErrorCode.UNREACHABLE,
1096
+ reference,
1097
+ message: "The host application provides no extension fetch transport.",
1098
+ },
1099
+ };
1100
+ }
1101
+ const manifestEntry = decodeInstalledSnapshotFiles(record).get(`/${WENDOO_JSON_PATH}`);
1102
+ const manifestContent = manifestEntry === undefined ? undefined : fileContentText(manifestEntry);
1103
+ const parsed = manifestContent !== undefined ? parseProjectContentManifest(manifestContent) : undefined;
1104
+ return checkExtensionReferenceUpdate({
1105
+ reference,
1106
+ installedSpecifier: record.specifier,
1107
+ installedVersion: parsed?.ok ? parsed.manifest.version : "0.0.0",
1108
+ transport: this.extensionFetchTransport,
1109
+ ...(this.approvedCatalogEntry !== undefined ? { approvedEntry: this.approvedCatalogEntry } : {}),
1110
+ });
1111
+ }
1112
+ /**
1113
+ * Apply one or more dependency updates to the active project as a single
1114
+ * install transaction with one outcome: each update's coordinate takes its
1115
+ * new reference in the extensions map, and each updated reference is fetched
1116
+ * fresh, never satisfied from its stored snapshot. The report and the
1117
+ * install log are those of {@link updateProjectExtensions}.
1118
+ *
1119
+ * @param updates - The updates to apply, as returned by update checks.
1120
+ */
1121
+ async applyExtensionUpdates(updates) {
1122
+ const next = { ...(this.projectManager.activeProject.manifest.extensions ?? {}) };
1123
+ const refetchReferences = new Set();
1124
+ for (const update of updates) {
1125
+ next[update.coordinate] = update.reference;
1126
+ refetchReferences.add(update.reference);
1127
+ }
1128
+ return this.updateProjectExtensions(next, { refetchReferences });
1129
+ }
1130
+ /**
1131
+ * Collect the active project's dependencies that are not stable for
1132
+ * consumers: branch references, and pinned references whose content the
1133
+ * project has no installed snapshot for. Exporting or publishing such a
1134
+ * project should ask for confirmation first.
1135
+ */
1136
+ async collectUnstableProjectDependencies() {
1137
+ const extensions = this.projectManager.activeProject.manifest.extensions ?? {};
1138
+ return collectUnstableDependencies(extensions, async (owner, repo, pin) => this._installedContent.has(`gh:${owner}/${repo}@${pin}`));
1139
+ }
1140
+ // ---------------------------------------------------------------------------
1141
+ // Project lifecycle events
1142
+ // ---------------------------------------------------------------------------
1143
+ onProjectUnloading(listener) {
1144
+ this._projectUnloadingListeners.add(listener);
1145
+ return () => this._projectUnloadingListeners.delete(listener);
1146
+ }
1147
+ onProjectLoaded(listener) {
1148
+ this._projectLoadedListeners.add(listener);
1149
+ return () => this._projectLoadedListeners.delete(listener);
1150
+ }
1151
+ // ---------------------------------------------------------------------------
1152
+ // Project switching / creation
1153
+ // ---------------------------------------------------------------------------
1154
+ async createProject(name) {
1155
+ await this.prepareProjectTransition();
1156
+ const manifest = await this.projectManager.create(name, {
1157
+ beforeActiveProjectChange: () => this.notifyProjectUnloading(),
1158
+ });
1159
+ await this.completeProjectTransition();
1160
+ return manifest;
1161
+ }
1162
+ async switchProject(id) {
1163
+ if (this.projectManager.activeProject?.manifest.id === id) {
1164
+ return;
1165
+ }
1166
+ await this.prepareProjectTransition();
1167
+ await this.projectManager.open(id, {
1168
+ beforeActiveProjectChange: () => this.notifyProjectUnloading(),
1169
+ });
1170
+ await this.completeProjectTransition();
1171
+ }
1172
+ async switchProjectCollectionAndOpenProject(projectCollectionId, projectId) {
1173
+ if (this.projectManager.activeProjectCollection?.projectCollectionId === projectCollectionId &&
1174
+ this.projectManager.activeProject?.manifest.id === projectId) {
1175
+ return {
1176
+ collection: this.projectManager.activeProjectCollection,
1177
+ project: this.projectManager.activeProject.manifest,
1178
+ access: "ready",
1179
+ };
1180
+ }
1181
+ await this.prepareProjectTransition();
1182
+ const result = await this.projectManager.switchProjectCollectionAndOpenProject(projectCollectionId, projectId, {
1183
+ beforeActiveProjectChange: () => this.notifyProjectUnloading(),
1184
+ });
1185
+ await this.completeProjectTransition();
1186
+ return result;
1187
+ }
1188
+ async switchProjectCollectionAndCreateProject(projectCollectionId, name) {
1189
+ await this.prepareProjectTransition();
1190
+ const result = await this.projectManager.switchProjectCollectionAndCreateProject(projectCollectionId, name, {
1191
+ beforeActiveProjectChange: () => this.notifyProjectUnloading(),
1192
+ });
1193
+ await this.completeProjectTransition();
1194
+ return result;
1195
+ }
1196
+ /**
1197
+ * Unlock a project collection and initialize the project environment when
1198
+ * unlocking restores the active project.
1199
+ *
1200
+ * @param projectCollectionId - Project collection to unlock.
1201
+ * @param pin - User-entered PIN or phrase.
1202
+ * @returns The unlocked project collection and its ready access status.
1203
+ */
1204
+ async unlockProjectCollection(projectCollectionId, pin) {
1205
+ const result = await this.projectManager.unlockProjectCollection(projectCollectionId, pin);
1206
+ if (this.projectManager.activeProjectCollection?.projectCollectionId === projectCollectionId &&
1207
+ this.projectManager.activeProject) {
1208
+ await this.completeProjectTransition();
1209
+ }
1210
+ return result;
1211
+ }
1212
+ /**
1213
+ * Lock a project collection after flushing app-owned project state.
1214
+ *
1215
+ * @param projectCollectionId - Project collection to lock.
1216
+ */
1217
+ async lockProjectCollection(projectCollectionId) {
1218
+ const locksActiveProject = this.projectManager.activeProjectCollection?.projectCollectionId === projectCollectionId &&
1219
+ this.projectManager.activeProject !== undefined;
1220
+ if (locksActiveProject) {
1221
+ await this.prepareProjectTransition();
1222
+ }
1223
+ await this.projectManager.lockProjectCollection(projectCollectionId, {
1224
+ beforeActiveProjectChange: () => this.notifyProjectUnloading(),
1225
+ });
1226
+ if (locksActiveProject) {
1227
+ this.completeProjectUnload();
1228
+ }
1229
+ }
1230
+ async prepareProjectTransition() {
1231
+ // A project whose load hit an unreadable record has an empty brain cache,
1232
+ // and flushing it would write over brains the store still holds.
1233
+ if (this.projectManager.activeProject && !this._projectRecordFailure) {
1234
+ await this.saveAllBrains();
1235
+ }
1236
+ }
1237
+ notifyProjectUnloading() {
1238
+ for (const listener of this._projectUnloadingListeners) {
1239
+ listener();
1240
+ }
1241
+ }
1242
+ completeProjectUnload() {
1243
+ this._brainCache.clear();
1244
+ this._projectRecordFailure = undefined;
1245
+ this._pendingBrainRebuild = false;
1246
+ this.env.replaceActionBundle({ revision: "", tiles: [], actions: Dict.empty(), roots: [] });
1247
+ // Compiles invalidate types per project namespace, so the outgoing
1248
+ // project's registrations must be cleared here or they outlive it.
1249
+ this.env.brainServices.runtime.types.removeUserTypes();
1250
+ this._lastUserTileMetadata = undefined;
1251
+ this._installedSnapshots = {};
1252
+ this._installedContent = new Map();
1253
+ this._fetchFailures = new Map();
1254
+ this._lastCompileResult = undefined;
1255
+ this.setCompileDiagnostics(undefined);
1256
+ this.setLastResolution(undefined);
1257
+ this.bumpDocRevision();
1258
+ this.teardownBridge();
1259
+ }
1260
+ async completeProjectTransition() {
1261
+ this.completeProjectUnload();
1262
+ await this.initCompiler();
1263
+ this.absorbCatalogMovesOutcome(await this.applyCatalogMoves());
1264
+ await this.loadBrainsFromProject();
1265
+ for (const listener of this._projectLoadedListeners) {
1266
+ listener();
1267
+ }
1268
+ }
1269
+ // ---------------------------------------------------------------------------
1270
+ // Brain rebuild flush
1271
+ // ---------------------------------------------------------------------------
1272
+ flushPendingBrainRebuilds() {
1273
+ if (!this._pendingBrainRebuild) {
1274
+ return;
1275
+ }
1276
+ this._pendingBrainRebuild = false;
1277
+ try {
1278
+ this.env.rebuildInvalidatedBrains();
1279
+ }
1280
+ catch (err) {
1281
+ logger.warn("Failed to rebuild invalidated brains:", err);
1282
+ }
1283
+ }
1284
+ // ---------------------------------------------------------------------------
1285
+ // User tile metadata
1286
+ // ---------------------------------------------------------------------------
1287
+ get lastUserTileMetadata() {
1288
+ return this._lastUserTileMetadata;
1289
+ }
1290
+ /**
1291
+ * Error compile diagnostics of the user tile registered under `key` together
1292
+ * with the defining source file's path, from the latest workspace compile.
1293
+ * Undefined when the tile compiled cleanly, produced only warnings or infos,
1294
+ * or is absent from the latest compile. Each stored diagnostic is the
1295
+ * compiler's verbatim object, exposing `message`, `severity`, and
1296
+ * `line`/`column`.
1297
+ */
1298
+ getTileCompileDiagnostics(key) {
1299
+ return this._tileCompileDiagnostics.get(key);
1300
+ }
1301
+ // ---------------------------------------------------------------------------
1302
+ // Doc / VFS revision (useSyncExternalStore pattern)
1303
+ // ---------------------------------------------------------------------------
1304
+ get docRevision() {
1305
+ return this._docRevision;
1306
+ }
1307
+ bumpDocRevision() {
1308
+ this._docRevision++;
1309
+ for (const listener of this._docRevisionListeners) {
1310
+ listener();
1311
+ }
1312
+ }
1313
+ bumpVfsRevision() {
1314
+ this._vfsRevision++;
1315
+ for (const listener of this._vfsRevisionListeners) {
1316
+ listener();
1317
+ }
1318
+ }
1319
+ subscribeToDocRevision = (listener) => {
1320
+ this._docRevisionListeners.add(listener);
1321
+ return () => this._docRevisionListeners.delete(listener);
1322
+ };
1323
+ /**
1324
+ * Subscribe to brain-diagnostics revision changes for
1325
+ * `useSyncExternalStore`. The revision bumps whenever the diagnostics a brain
1326
+ * surfaces may have changed: after each extension transaction, after each brain
1327
+ * save, and after each workspace compile (which can change a used tile's
1328
+ * broken/clean status). Returns an unsubscribe function.
1329
+ */
1330
+ subscribeToBrainDiagnostics = (listener) => {
1331
+ this._brainDiagnosticsListeners.add(listener);
1332
+ return () => this._brainDiagnosticsListeners.delete(listener);
1333
+ };
1334
+ /** Snapshot of the current brain-diagnostics revision for `useSyncExternalStore`. */
1335
+ getBrainDiagnosticsRevision = () => {
1336
+ return this._brainDiagnosticsRevision;
1337
+ };
1338
+ bumpBrainDiagnosticsRevision() {
1339
+ this._brainDiagnosticsRevision++;
1340
+ for (const listener of this._brainDiagnosticsListeners) {
1341
+ listener();
1342
+ }
1343
+ }
1344
+ /**
1345
+ * Subscribe to workspace-compile diagnostic changes for
1346
+ * `useSyncExternalStore`. The listener fires after every workspace compile
1347
+ * and on project unload. Returns an unsubscribe function.
1348
+ */
1349
+ subscribeToCompileDiagnostics = (listener) => {
1350
+ this._compileDiagnosticsListeners.add(listener);
1351
+ return () => this._compileDiagnosticsListeners.delete(listener);
1352
+ };
1353
+ /** Snapshot of the latest workspace compile's diagnostics for `useSyncExternalStore`; empty when clean. */
1354
+ getCompileDiagnosticsSnapshot = () => {
1355
+ return this._compileDiagnostics;
1356
+ };
1357
+ /** Record a compile's per-file diagnostics as one flat located list and notify subscribers. */
1358
+ setCompileDiagnostics(result) {
1359
+ // Rebuilt from the latest compile so a fixed tile drops out and a newly
1360
+ // broken tile appears; cleared to empty when a project unloads.
1361
+ this._tileCompileDiagnostics = result ? collectTileSourceCompileErrors(result) : new Map();
1362
+ const diagnostics = [];
1363
+ for (const [path, entries] of result?.files ?? []) {
1364
+ for (const entry of entries) {
1365
+ diagnostics.push({ ...entry, path });
1366
+ }
1367
+ }
1368
+ if (diagnostics.length === 0 && this._compileDiagnostics.length === 0) {
1369
+ return;
1370
+ }
1371
+ this._compileDiagnostics = diagnostics.length === 0 ? NO_COMPILE_DIAGNOSTICS : diagnostics;
1372
+ for (const listener of this._compileDiagnosticsListeners) {
1373
+ listener();
1374
+ }
1375
+ }
1376
+ getDocRevisionSnapshot = () => {
1377
+ return this._docRevision;
1378
+ };
1379
+ subscribeToVfsRevision = (listener) => {
1380
+ this._vfsRevisionListeners.add(listener);
1381
+ return () => this._vfsRevisionListeners.delete(listener);
1382
+ };
1383
+ getVfsRevisionSnapshot = () => {
1384
+ return this._vfsRevision;
1385
+ };
1386
+ // ---------------------------------------------------------------------------
1387
+ // Bridge (optional -- only available if bridgeUrl was provided)
1388
+ // ---------------------------------------------------------------------------
1389
+ initBridge() {
1390
+ if (!this._bridgeUrl || !this._compiler) {
1391
+ return;
1392
+ }
1393
+ this.teardownBridge();
1394
+ this._bridge = createBridgeProject({
1395
+ projectCompiler: this._compiler,
1396
+ servedFileSystem: this.servedProjectFileSystem,
1397
+ bridgeUrl: this._bridgeUrl,
1398
+ bindingToken: this._loadBindingToken(),
1399
+ onBindingTokenChange: (token) => {
1400
+ this._saveBindingToken(token);
1401
+ },
1402
+ });
1403
+ this.wireBridgeState(this._bridge.bridge);
1404
+ }
1405
+ connectBridge() {
1406
+ if (!this._bridge) {
1407
+ this.initBridge();
1408
+ }
1409
+ if (!this._bridge || this._bridge.bridge.snapshot().status !== "disconnected") {
1410
+ return;
1411
+ }
1412
+ this._bridge.bridge.start();
1413
+ }
1414
+ disconnectBridge() {
1415
+ this._bridge?.bridge.stop();
1416
+ }
1417
+ teardownBridge() {
1418
+ this._bridgeStateUnsub?.();
1419
+ this._bridgeStateUnsub = undefined;
1420
+ this._remoteChangeUnsub?.();
1421
+ this._remoteChangeUnsub = undefined;
1422
+ this._bridge?.bridge.stop();
1423
+ this._bridge = undefined;
1424
+ if (this._bridgeStatus !== "disconnected") {
1425
+ this._bridgeStatus = "disconnected";
1426
+ for (const listener of this._bridgeStatusListeners) {
1427
+ listener();
1428
+ }
1429
+ }
1430
+ if (this._bridgeJoinCode !== undefined) {
1431
+ this._bridgeJoinCode = undefined;
1432
+ for (const listener of this._bridgeJoinCodeListeners) {
1433
+ listener();
1434
+ }
1435
+ }
1436
+ }
1437
+ updateBridgeUrl(bridgeUrl) {
1438
+ this._bridgeUrl = bridgeUrl;
1439
+ if (!this._bridge) {
1440
+ return;
1441
+ }
1442
+ const shouldStart = this._bridgeStatus !== "disconnected";
1443
+ this._bridgeStateUnsub?.();
1444
+ this._bridgeStateUnsub = undefined;
1445
+ this._bridge.recreateBridge(bridgeUrl);
1446
+ this.wireBridgeState(this._bridge.bridge);
1447
+ if (shouldStart) {
1448
+ this._bridge.bridge.start();
1449
+ }
1450
+ }
1451
+ subscribeToBridgeStatus = (listener) => {
1452
+ this._bridgeStatusListeners.add(listener);
1453
+ return () => this._bridgeStatusListeners.delete(listener);
1454
+ };
1455
+ getBridgeStatusSnapshot = () => {
1456
+ return this._bridgeStatus;
1457
+ };
1458
+ subscribeToBridgeJoinCode = (listener) => {
1459
+ this._bridgeJoinCodeListeners.add(listener);
1460
+ return () => this._bridgeJoinCodeListeners.delete(listener);
1461
+ };
1462
+ getBridgeJoinCodeSnapshot = () => {
1463
+ return this._bridgeJoinCode;
1464
+ };
1465
+ /**
1466
+ * Apply a project file change observed outside the app (a folder-session
1467
+ * external edit): applies it to the project file system and the workspace
1468
+ * compiler, recompiles, and absorbs any `wendoo.json` manifest change.
1469
+ */
1470
+ applyExternalProjectFileChange(change) {
1471
+ this.projectFileSystem.applyRemoteChange(change);
1472
+ if (this._compiler) {
1473
+ this._compiler.compiler.applyWorkspaceChange(change);
1474
+ this._compiler.compiler.compile();
1475
+ }
1476
+ this.handleRemoteProjectFileChange(change);
1477
+ }
1478
+ /**
1479
+ * Absorb a project file change made by a remote peer: bumps the VFS
1480
+ * revision, and a `wendoo.json` write is diffed against the active
1481
+ * manifest -- an extensions change runs through the install transaction and
1482
+ * the remaining synced fields patch the manifest. The change itself must
1483
+ * already be applied to the project file system.
1484
+ */
1485
+ handleRemoteProjectFileChange(change) {
1486
+ this.bumpVfsRevision();
1487
+ if (change.action === "write" && change.path === WENDOO_JSON_PATH && this.projectManager.activeProject) {
1488
+ const manifestText = fileContentText(change.content);
1489
+ const patch = manifestText === undefined
1490
+ ? undefined
1491
+ : diffWendooJsonToManifest(manifestText, this.projectManager.activeProject.manifest);
1492
+ if (patch) {
1493
+ // An extensions change flows through the install transaction, the
1494
+ // same pipeline the extension browser uses; the remaining synced
1495
+ // fields patch the manifest directly.
1496
+ const { extensions, ...rest } = patch;
1497
+ if (extensions !== undefined) {
1498
+ void this.updateProjectExtensions(extensions);
1499
+ }
1500
+ if (Object.keys(rest).length > 0) {
1501
+ void this.projectManager.updateActive(rest);
1502
+ }
1503
+ }
1504
+ }
1505
+ }
1506
+ wireBridgeState(bridge) {
1507
+ this._bridgeStateUnsub?.();
1508
+ this._remoteChangeUnsub?.();
1509
+ this._bridgeStateUnsub = bridge.onStateChange(() => {
1510
+ this.applyBridgeSnapshot(bridge);
1511
+ });
1512
+ this._remoteChangeUnsub = bridge.onRemoteChange((change) => {
1513
+ this.handleRemoteProjectFileChange(change);
1514
+ });
1515
+ this.applyBridgeSnapshot(bridge);
1516
+ }
1517
+ applyBridgeSnapshot(bridge) {
1518
+ const snapshot = bridge.snapshot();
1519
+ if (snapshot.status !== this._bridgeStatus) {
1520
+ this._bridgeStatus = snapshot.status;
1521
+ for (const listener of this._bridgeStatusListeners) {
1522
+ listener();
1523
+ }
1524
+ }
1525
+ if (snapshot.joinCode !== this._bridgeJoinCode) {
1526
+ this._bridgeJoinCode = snapshot.joinCode;
1527
+ for (const listener of this._bridgeJoinCodeListeners) {
1528
+ listener();
1529
+ }
1530
+ }
1531
+ }
1532
+ }
1533
+ // ---------------------------------------------------------------------------
1534
+ // Helpers
1535
+ // ---------------------------------------------------------------------------
1536
+ /** Stable empty warnings array, the snapshot value while no resolution is recorded. */
1537
+ const NO_RESOLUTION_WARNINGS = [];
1538
+ /** Identity key of a resolution warning: the fields that name the same finding across sources. */
1539
+ function resolutionWarningKey(warning) {
1540
+ const reference = "reference" in warning ? warning.reference : "";
1541
+ const code = warning.kind === "catalog-move-failed" ? warning.code : "";
1542
+ return `${warning.kind} ${warning.origin} ${reference} ${code}`;
1543
+ }
1544
+ function logWorkspaceCompile(result) {
1545
+ const resultsByPath = result.projectResult.results;
1546
+ for (const [path, diagnostics] of result.files) {
1547
+ if (diagnostics.length > 0) {
1548
+ logger.warn(`[user-tile-compiler] ${path}: ${diagnostics.length} diagnostic(s)`);
1549
+ for (const diagnostic of diagnostics) {
1550
+ const range = diagnostic.range;
1551
+ logger.warn(` ${path}:${range.startLine}:${range.startColumn} - ${diagnostic.message}`);
1552
+ }
1553
+ continue;
1554
+ }
1555
+ const program = resultsByPath.get(path)?.program;
1556
+ if (program) {
1557
+ logger.debug(`[user-tile-compiler] ${path}: compiled ${program.kind} "${program.name}"`);
1558
+ }
1559
+ }
1560
+ }