@ontrails/topography 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,486 @@
1
+ /**
2
+ * Config-owned, app-partitioned workspace graph derivation.
3
+ *
4
+ * A workspace view reads the app-local locks named by Config. Filesystem lock
5
+ * discovery contributes observation evidence only; it never adds an app to the
6
+ * canonical view.
7
+ */
8
+
9
+ import { posix } from 'node:path';
10
+
11
+ import { ValidationError } from '@ontrails/core';
12
+ import { trailsLockFileName } from '@ontrails/config';
13
+ import type { ResolvedTrailsWorkspaceApp } from '@ontrails/config';
14
+
15
+ import { deriveStableHash, deriveTopoGraphHash } from './hash.js';
16
+ import { isTopoArtifactRegenerationError, readTrailsLock } from './io.js';
17
+ import type {
18
+ LockManifestSummary,
19
+ TopoGraph,
20
+ TopoGraphEntry,
21
+ } from './types.js';
22
+ import {
23
+ collectWorkspaceLockCensus,
24
+ workspaceCollectionSkipForApp,
25
+ } from './workspace-lock-census.js';
26
+ import { WORKSPACE_VIEW_SCHEMA_VERSION } from './workspace-view-types.js';
27
+ import type {
28
+ DeriveWorkspaceViewOptions,
29
+ WorkspaceAppLockFreshness,
30
+ WorkspaceAppLockObservation,
31
+ WorkspaceView,
32
+ WorkspaceViewApp,
33
+ WorkspaceViewCollectionSkip,
34
+ WorkspaceViewCollision,
35
+ WorkspaceViewContent,
36
+ } from './workspace-view-types.js';
37
+
38
+ export { WORKSPACE_VIEW_SCHEMA_VERSION } from './workspace-view-types.js';
39
+ export type {
40
+ DeriveWorkspaceViewOptions,
41
+ UnownedWorkspaceLockObservation,
42
+ WorkspaceAppLockBinding,
43
+ WorkspaceAppLockFreshness,
44
+ WorkspaceAppLockObservation,
45
+ WorkspaceAppLockStatus,
46
+ WorkspaceView,
47
+ WorkspaceViewApp,
48
+ WorkspaceViewCollectionSkip,
49
+ WorkspaceViewCollision,
50
+ WorkspaceViewContent,
51
+ WorkspaceViewEvidence,
52
+ } from './workspace-view-types.js';
53
+
54
+ const compareStrings = (left: string, right: string): number => {
55
+ if (left < right) {
56
+ return -1;
57
+ }
58
+ return left > right ? 1 : 0;
59
+ };
60
+
61
+ const compareCollisions = (
62
+ left: WorkspaceViewCollision,
63
+ right: WorkspaceViewCollision
64
+ ): number =>
65
+ compareStrings(left.kind, right.kind) || compareStrings(left.id, right.id);
66
+
67
+ const lockPathForApp = (app: ResolvedTrailsWorkspaceApp): string =>
68
+ app.root === '.'
69
+ ? trailsLockFileName
70
+ : posix.join(app.root, trailsLockFileName);
71
+
72
+ const normalizedTopoGraph = (topoGraph: TopoGraph): TopoGraph => {
73
+ const { generatedAt: _generatedAt, ...canonical } = topoGraph;
74
+ return canonical;
75
+ };
76
+
77
+ const summarizeTopoGraph = (
78
+ topoGraph: TopoGraph
79
+ ): Readonly<Record<TopoGraphEntry['kind'], number>> => ({
80
+ entity: topoGraph.entries.filter((entry) => entry.kind === 'entity').length,
81
+ resource: topoGraph.entries.filter((entry) => entry.kind === 'resource')
82
+ .length,
83
+ signal: topoGraph.entries.filter((entry) => entry.kind === 'signal').length,
84
+ trail: topoGraph.entries.filter((entry) => entry.kind === 'trail').length,
85
+ });
86
+
87
+ const summaryMatches = (
88
+ summary: LockManifestSummary,
89
+ topoGraph: TopoGraph
90
+ ): boolean => {
91
+ const actual = summarizeTopoGraph(topoGraph);
92
+ return (
93
+ summary.entities === actual.entity &&
94
+ summary.resources === actual.resource &&
95
+ summary.signals === actual.signal &&
96
+ summary.trails === actual.trail
97
+ );
98
+ };
99
+
100
+ const deriveCollisions = (
101
+ apps: readonly WorkspaceViewApp[]
102
+ ): readonly WorkspaceViewCollision[] => {
103
+ const owners = new Map<string, Set<string>>();
104
+ for (const app of apps) {
105
+ for (const entry of app.topoGraph.entries) {
106
+ const key = `${entry.kind}\0${entry.id}`;
107
+ const appIds = owners.get(key) ?? new Set<string>();
108
+ appIds.add(app.id);
109
+ owners.set(key, appIds);
110
+ }
111
+ }
112
+
113
+ const collisions: WorkspaceViewCollision[] = [];
114
+ for (const [key, appIds] of owners) {
115
+ if (appIds.size < 2) {
116
+ continue;
117
+ }
118
+ const separator = key.indexOf('\0');
119
+ collisions.push({
120
+ appIds: [...appIds].toSorted(compareStrings),
121
+ id: key.slice(separator + 1),
122
+ kind: key.slice(0, separator) as TopoGraphEntry['kind'],
123
+ });
124
+ }
125
+ return collisions.toSorted(compareCollisions);
126
+ };
127
+
128
+ const deriveWorkspaceViewHash = (content: WorkspaceViewContent): string =>
129
+ deriveStableHash({
130
+ apps: content.apps.map((app) => ({
131
+ id: app.id,
132
+ root: app.root,
133
+ topoGraphHash: app.topoGraphHash,
134
+ })),
135
+ collisions: content.collisions,
136
+ workspaceViewSchemaVersion: content.workspaceViewSchemaVersion,
137
+ });
138
+
139
+ const errorDetail = (error: unknown): string =>
140
+ error instanceof Error ? error.message : String(error);
141
+
142
+ const freshnessFor = (
143
+ currentHash: string | undefined,
144
+ savedHash: string
145
+ ): WorkspaceAppLockFreshness => {
146
+ if (currentHash === undefined) {
147
+ return 'unknown';
148
+ }
149
+ return currentHash === savedHash ? 'fresh' : 'stale';
150
+ };
151
+
152
+ const validateSelection = (
153
+ configuredAppIds: readonly string[],
154
+ requested: readonly string[] | undefined
155
+ ): readonly string[] => {
156
+ const selectedAppIds = [...(requested ?? configuredAppIds)].toSorted(
157
+ compareStrings
158
+ );
159
+ if (selectedAppIds.length === 0) {
160
+ throw new ValidationError(
161
+ 'Workspace app selection must contain at least one configured app ID.',
162
+ { context: { configuredAppIds, selectedAppIds } }
163
+ );
164
+ }
165
+ const unique = new Set(selectedAppIds);
166
+ if (unique.size !== selectedAppIds.length) {
167
+ throw new ValidationError(
168
+ 'Workspace app selection contains duplicate IDs.',
169
+ {
170
+ context: { selectedAppIds },
171
+ }
172
+ );
173
+ }
174
+ const configured = new Set(configuredAppIds);
175
+ const unknown = selectedAppIds.filter((id) => !configured.has(id));
176
+ if (unknown.length > 0) {
177
+ throw new ValidationError(
178
+ `Workspace app selection contains unconfigured IDs: ${unknown.join(', ')}.`,
179
+ { context: { configuredAppIds, selectedAppIds, unknownAppIds: unknown } }
180
+ );
181
+ }
182
+ return selectedAppIds;
183
+ };
184
+
185
+ const validateCurrentAppGraphHashes = (
186
+ configuredAppIds: readonly string[],
187
+ currentAppGraphHashes: Readonly<Record<string, string>> | undefined
188
+ ): void => {
189
+ if (currentAppGraphHashes === undefined) {
190
+ return;
191
+ }
192
+ for (const appId of configuredAppIds) {
193
+ if (!Object.hasOwn(currentAppGraphHashes, appId)) {
194
+ continue;
195
+ }
196
+ const currentHash = currentAppGraphHashes[appId];
197
+ if (currentHash !== undefined && !/^[0-9a-f]{64}$/u.test(currentHash)) {
198
+ throw new ValidationError(
199
+ `Current graph hash for app ${appId} must be a lowercase SHA-256 digest.`,
200
+ { context: { appId, currentHash } }
201
+ );
202
+ }
203
+ }
204
+ };
205
+
206
+ interface AppReadResult {
207
+ readonly app?: WorkspaceViewApp | undefined;
208
+ readonly observation: WorkspaceAppLockObservation;
209
+ }
210
+
211
+ const failedAppRead = (
212
+ app: ResolvedTrailsWorkspaceApp,
213
+ selected: ReadonlySet<string>,
214
+ error: unknown
215
+ ): AppReadResult => {
216
+ const lockPath = lockPathForApp(app);
217
+ const invalid = isTopoArtifactRegenerationError(error);
218
+ return {
219
+ observation: {
220
+ binding: 'unavailable',
221
+ coaching: invalid
222
+ ? `Regenerate ${lockPath} by compiling configured app ${app.id}.`
223
+ : `Restore access to ${lockPath}, then inspect configured app ${app.id} again.`,
224
+ detail: errorDetail(error),
225
+ freshness: 'unavailable',
226
+ id: app.id,
227
+ lockPath,
228
+ provenance: 'configured-app-lock',
229
+ root: app.root,
230
+ selected: selected.has(app.id),
231
+ status: invalid ? 'invalid' : 'unavailable',
232
+ },
233
+ };
234
+ };
235
+
236
+ const unavailableAppRead = (
237
+ app: ResolvedTrailsWorkspaceApp,
238
+ selected: ReadonlySet<string>,
239
+ collectionSkip: WorkspaceViewCollectionSkip
240
+ ): AppReadResult => {
241
+ const lockPath = lockPathForApp(app);
242
+ const scopeExcluded = collectionSkip.reason === 'scope-excluded';
243
+ return {
244
+ observation: {
245
+ binding: 'unavailable',
246
+ coaching: scopeExcluded
247
+ ? `Include ${lockPath} in lockScope to observe configured app ${app.id}.`
248
+ : `App ${app.id} extends beyond the ${collectionSkip.reason} collection edge at ${collectionSkip.path}. ` +
249
+ 'Invoke that project as its own collection root or move the app inside this workspace working tree.',
250
+ detail: scopeExcluded
251
+ ? `Configured app lock ${lockPath} is outside the active lock census scope.`
252
+ : `Configured app root ${app.root} is not observable from this collection.`,
253
+ freshness: 'unavailable',
254
+ id: app.id,
255
+ lockPath,
256
+ provenance: 'configured-app-lock',
257
+ root: app.root,
258
+ selected: selected.has(app.id),
259
+ status: 'unavailable',
260
+ },
261
+ };
262
+ };
263
+
264
+ const readConfiguredApp = async (
265
+ app: ResolvedTrailsWorkspaceApp,
266
+ selected: ReadonlySet<string>,
267
+ currentAppGraphHashes: Readonly<Record<string, string>> | undefined,
268
+ collectionSkip: WorkspaceViewCollectionSkip | undefined,
269
+ lockRootDirectory: string = app.rootDir
270
+ ): Promise<AppReadResult> => {
271
+ const lockPath = lockPathForApp(app);
272
+ const base = {
273
+ id: app.id,
274
+ lockPath,
275
+ provenance: 'configured-app-lock' as const,
276
+ root: app.root,
277
+ selected: selected.has(app.id),
278
+ };
279
+
280
+ if (collectionSkip !== undefined) {
281
+ return unavailableAppRead(app, selected, collectionSkip);
282
+ }
283
+
284
+ let lock: Awaited<ReturnType<typeof readTrailsLock>>;
285
+ try {
286
+ lock = await readTrailsLock({ dir: lockRootDirectory });
287
+ } catch (error) {
288
+ return failedAppRead(app, selected, error);
289
+ }
290
+ if (lock === null) {
291
+ return {
292
+ observation: {
293
+ ...base,
294
+ binding: 'unavailable',
295
+ coaching: `Create ${lockPath} by compiling configured app ${app.id}.`,
296
+ freshness: 'unavailable',
297
+ status: 'missing',
298
+ },
299
+ };
300
+ }
301
+
302
+ const actualAppId = lock.scope['app'];
303
+ const libraryAppId = lock.topoGraph.library?.app;
304
+ if (
305
+ actualAppId !== app.id ||
306
+ (libraryAppId !== undefined && libraryAppId !== app.id)
307
+ ) {
308
+ return {
309
+ observation: {
310
+ ...base,
311
+ ...(actualAppId === undefined ? {} : { actualAppId }),
312
+ binding: 'mismatched',
313
+ coaching: `Align workspace.apps.${app.id}, the topo name, and ${lockPath}, then recompile the selected app.`,
314
+ detail:
315
+ libraryAppId !== undefined && libraryAppId !== app.id
316
+ ? `Lock scope app is ${JSON.stringify(actualAppId)} and library app is ${JSON.stringify(libraryAppId)}; expected ${JSON.stringify(app.id)}.`
317
+ : `Lock scope app is ${JSON.stringify(actualAppId)}; expected ${JSON.stringify(app.id)}.`,
318
+ freshness: 'unavailable',
319
+ status: 'available',
320
+ },
321
+ };
322
+ }
323
+ if (lock.topoGraph.workspace !== undefined) {
324
+ return {
325
+ observation: {
326
+ ...base,
327
+ actualAppId,
328
+ binding: 'matched',
329
+ coaching: `Regenerate ${lockPath} as an app-local lock without legacy workspace metadata.`,
330
+ detail:
331
+ 'App-local locks cannot carry legacy aggregate workspace metadata.',
332
+ freshness: 'unavailable',
333
+ status: 'invalid',
334
+ },
335
+ };
336
+ }
337
+
338
+ const topoGraph = normalizedTopoGraph(lock.topoGraph as TopoGraph);
339
+ const actualHash = deriveTopoGraphHash(topoGraph);
340
+ if (actualHash !== lock.topoGraphHash) {
341
+ return {
342
+ observation: {
343
+ ...base,
344
+ actualAppId,
345
+ binding: 'matched',
346
+ coaching: `Regenerate invalid ${lockPath} by compiling configured app ${app.id}.`,
347
+ detail: `Stored topoGraphHash ${lock.topoGraphHash} does not match graph content ${actualHash}.`,
348
+ freshness: 'unavailable',
349
+ status: 'invalid',
350
+ },
351
+ };
352
+ }
353
+ if (!summaryMatches(lock.summary, topoGraph)) {
354
+ return {
355
+ observation: {
356
+ ...base,
357
+ actualAppId,
358
+ binding: 'matched',
359
+ coaching: `Regenerate invalid ${lockPath} by compiling configured app ${app.id}.`,
360
+ detail: `Stored lock summary does not match graph content: ${JSON.stringify(lock.summary)}.`,
361
+ freshness: 'unavailable',
362
+ status: 'invalid',
363
+ },
364
+ };
365
+ }
366
+
367
+ const hasCurrentHash =
368
+ currentAppGraphHashes !== undefined &&
369
+ Object.hasOwn(currentAppGraphHashes, app.id);
370
+ const currentHash = hasCurrentHash
371
+ ? currentAppGraphHashes?.[app.id]
372
+ : undefined;
373
+ const freshness = freshnessFor(currentHash, actualHash);
374
+ return {
375
+ app: {
376
+ id: app.id,
377
+ root: app.root,
378
+ topoGraph,
379
+ topoGraphHash: actualHash,
380
+ },
381
+ observation: {
382
+ ...base,
383
+ actualAppId,
384
+ binding: 'matched',
385
+ ...(freshness === 'stale'
386
+ ? {
387
+ coaching: `Regenerate stale ${lockPath} by compiling configured app ${app.id}.`,
388
+ detail: `Current graph hash ${currentHash} does not match saved graph hash ${actualHash}.`,
389
+ }
390
+ : {}),
391
+ freshness,
392
+ status: 'available',
393
+ },
394
+ };
395
+ };
396
+
397
+ /**
398
+ * Derive the workspace's canonical app-partitioned graph view from Config app
399
+ * identity and app-local locks.
400
+ *
401
+ * The function never imports app source and never writes or refreshes a lock.
402
+ * Missing, invalid, stale, contradictory, and unowned artifacts remain typed
403
+ * observation evidence. Only a complete bound app set receives a canonical
404
+ * `workspaceViewHash`.
405
+ *
406
+ * @example
407
+ * ```ts
408
+ * const identity = await readTrailsProjectIdentity({
409
+ * boundaryDir: projectRoot,
410
+ * startDir: process.cwd(),
411
+ * });
412
+ * const view = await deriveWorkspaceView({ identity });
413
+ * ```
414
+ */
415
+ export const deriveWorkspaceView = async (
416
+ options: DeriveWorkspaceViewOptions
417
+ ): Promise<WorkspaceView> => {
418
+ const configuredApps = [...options.identity.apps].toSorted((left, right) =>
419
+ compareStrings(left.id, right.id)
420
+ );
421
+ if (configuredApps.length === 0) {
422
+ throw new ValidationError(
423
+ 'A workspace view requires Config-owned workspace.apps identity.'
424
+ );
425
+ }
426
+ const configuredAppIds = configuredApps.map((app) => app.id);
427
+ const selectedAppIds = validateSelection(
428
+ configuredAppIds,
429
+ options.selectedAppIds
430
+ );
431
+ validateCurrentAppGraphHashes(
432
+ configuredAppIds,
433
+ options.currentAppGraphHashes
434
+ );
435
+ const selected = new Set(selectedAppIds);
436
+ const expectedLockPaths = new Set(configuredApps.map(lockPathForApp));
437
+ const census = collectWorkspaceLockCensus(
438
+ options.identity,
439
+ expectedLockPaths,
440
+ options.lockScope
441
+ );
442
+ const reads = await Promise.all(
443
+ configuredApps.map((app) =>
444
+ readConfiguredApp(
445
+ app,
446
+ selected,
447
+ options.currentAppGraphHashes,
448
+ workspaceCollectionSkipForApp(app, census.collectionSkips),
449
+ census.configuredAppRootDirectories.get(lockPathForApp(app))
450
+ )
451
+ )
452
+ );
453
+ const apps = reads
454
+ .flatMap((result) => (result.app === undefined ? [] : [result.app]))
455
+ .toSorted((left, right) => compareStrings(left.id, right.id));
456
+ const collisions = deriveCollisions(apps);
457
+ const content: WorkspaceViewContent = {
458
+ apps,
459
+ collisions,
460
+ workspaceViewSchemaVersion: WORKSPACE_VIEW_SCHEMA_VERSION,
461
+ };
462
+ const completeAppIds = new Set(apps.map((app) => app.id));
463
+ const configuredCompleteness =
464
+ completeAppIds.size === configuredAppIds.length ? 'complete' : 'partial';
465
+ const selectedCompleteness = selectedAppIds.every((id) =>
466
+ completeAppIds.has(id)
467
+ )
468
+ ? 'complete'
469
+ : 'partial';
470
+ return {
471
+ content,
472
+ evidence: {
473
+ apps: reads.map((result) => result.observation),
474
+ collectionSkips: census.collectionSkips,
475
+ configuredAppIds,
476
+ configuredCompleteness,
477
+ selectedAppIds,
478
+ selectedCompleteness,
479
+ unownedLocks: census.unownedLocks,
480
+ },
481
+ workspaceViewHash:
482
+ configuredCompleteness === 'complete'
483
+ ? deriveWorkspaceViewHash(content)
484
+ : null,
485
+ };
486
+ };