@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.
- package/CHANGELOG.md +866 -0
- package/README.md +228 -0
- package/package.json +40 -0
- package/src/activation-report.ts +385 -0
- package/src/backend-support.ts +11 -0
- package/src/derive.ts +690 -0
- package/src/diff.ts +1088 -0
- package/src/forces.ts +125 -0
- package/src/hash.ts +55 -0
- package/src/index.ts +273 -0
- package/src/internal/topo-snapshots.ts +682 -0
- package/src/internal/topo-store-read.ts +1102 -0
- package/src/internal/topo-store.ts +1651 -0
- package/src/io.ts +280 -0
- package/src/library-derivation.ts +131 -0
- package/src/overlays.ts +155 -0
- package/src/permit.ts +19 -0
- package/src/source-fingerprint.ts +103 -0
- package/src/surface-bindings.ts +101 -0
- package/src/topo-store.ts +716 -0
- package/src/types.ts +749 -0
- package/src/versioning.ts +277 -0
- package/src/wayfind/error-facts.ts +363 -0
- package/src/wayfind/filters.ts +430 -0
- package/src/wayfind/loader.ts +443 -0
- package/src/wayfind/navigation.ts +265 -0
- package/src/wayfind/provenance.ts +152 -0
- package/src/wayfind/queries.ts +1656 -0
- package/src/wayfind/relations.ts +344 -0
- package/src/workspace-configured-aliases.ts +221 -0
- package/src/workspace-lock-census.ts +306 -0
- package/src/workspace-view-types.ts +104 -0
- package/src/workspace-view.ts +486 -0
|
@@ -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
|
+
};
|