@isparling/engram-cli 0.1.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,1139 @@
1
+ // Portable manifests name logical space content; machine-local bindings supply
2
+ // paths and policy; the registry selects one binding for one host-session
3
+ // identity. Serialization remains schema_version 0.
4
+
5
+ import { link, mkdir, readFile, realpath, stat, unlink, writeFile } from "node:fs/promises";
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import { hostname } from "node:os";
8
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
9
+ import { atomicWriteFile } from "./atomicWrite.ts";
10
+ import { isDefaultQmdCacheHome, isDefaultQmdConfigDir } from "./qmdConfigGuard.ts";
11
+ import type { SpaceBinding } from "./spaceBinding.ts";
12
+ import { err, ok, type EnvLike, type Result } from "./types.ts";
13
+
14
+ const ID_PATTERN = /^[a-z][a-z0-9-]*$/;
15
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
16
+ const SUPPORTED_KNOWLEDGE_SCHEMA_VERSIONS = new Set(["0"]);
17
+
18
+ type PackVersion = {
19
+ id: string;
20
+ version: string;
21
+ /** Module specifier for the externally installed pack. */
22
+ from?: string;
23
+ /** Whether this pack is the designated extraction pack. At most one per binding. */
24
+ extract?: boolean;
25
+ };
26
+
27
+ type SpaceManifest = {
28
+ schemaVersion: 0;
29
+ spaceId: string;
30
+ knowledgeSchemaVersion: string;
31
+ recordsDir: string;
32
+ requiredPacks: PackVersion[];
33
+ };
34
+
35
+ type ProviderPolicy = {
36
+ allowedModels: string[];
37
+ credentialEnv: string[];
38
+ };
39
+
40
+ type LocalBinding = {
41
+ manifestPath: string;
42
+ qmdConfigDir: string;
43
+ qmdCacheHome: string;
44
+ qmdCollectionName: string;
45
+ sessionsDir: string;
46
+ // Registration validates these roots. Runtime protected-root enforcement is
47
+ // intentionally deferred; current record operations stay
48
+ // confined to the validated recordsRoot.
49
+ readRoots: string[];
50
+ writeRoots: string[];
51
+ providerPolicy: ProviderPolicy;
52
+ installedPacks: PackVersion[];
53
+ };
54
+
55
+ export type ActiveSpace = SpaceBinding & {
56
+ spaceId: string;
57
+ spaceRoot: string;
58
+ manifestPath: string;
59
+ bindingPath: string;
60
+ sessionsDir: string;
61
+ readRoots: string[];
62
+ writeRoots: string[];
63
+ allowedModels: string[];
64
+ credentialEnv: string[];
65
+ knowledgeSchemaVersion: string;
66
+ packs: PackVersion[];
67
+ };
68
+
69
+ type RegisteredBoundary = {
70
+ space_root: string;
71
+ records_root: string;
72
+ qmd_config_dir: string;
73
+ qmd_cache_home: string;
74
+ qmd_collection_name: string;
75
+ sessions_dir: string;
76
+ };
77
+
78
+ type RegistryEntry = {
79
+ space_id: string;
80
+ binding_path: string;
81
+ binding_hash: string;
82
+ boundary: RegisteredBoundary;
83
+ };
84
+
85
+ type SpaceState = {
86
+ qmd_freshness: "unknown" | "fresh" | "index-stale";
87
+ };
88
+
89
+ type RegistryLockOwner = {
90
+ schema_version: 0;
91
+ pid: number;
92
+ hostname: string;
93
+ token: string;
94
+ purpose?: "recovery";
95
+ };
96
+
97
+ type RegistryDocument = {
98
+ schema_version: 0;
99
+ spaces: RegistryEntry[];
100
+ active: Record<string, string>;
101
+ state: Record<string, SpaceState>;
102
+ last_boundary_error: string | null;
103
+ };
104
+
105
+ export type ActiveSpaceStatus = {
106
+ space_id: string;
107
+ space_root: string;
108
+ records_root: string;
109
+ qmd: {
110
+ collection: string;
111
+ config_dir: string;
112
+ cache_home: string;
113
+ };
114
+ sessions_dir: string;
115
+ read_roots: string[];
116
+ write_roots: string[];
117
+ allowed_models: string[];
118
+ knowledge_schema_version: string;
119
+ packs: PackVersion[];
120
+ compatibility: "compatible";
121
+ qmd_freshness: SpaceState["qmd_freshness"];
122
+ session_boundary: "validated-not-enforced";
123
+ };
124
+
125
+ export type SpaceRegistryStatus = {
126
+ schema_version: 0;
127
+ registered_spaces: string[];
128
+ active_spaces: Record<string, ActiveSpaceStatus>;
129
+ last_boundary_error: string | null;
130
+ };
131
+
132
+ function isObject(value: unknown): value is Record<string, unknown> {
133
+ return typeof value === "object" && value !== null && !Array.isArray(value);
134
+ }
135
+
136
+ function unknownKeys(value: Record<string, unknown>, allowed: readonly string[]): string[] {
137
+ const allowedSet = new Set(allowed);
138
+ return Object.keys(value).filter((key) => !allowedSet.has(key));
139
+ }
140
+
141
+ function requiredString(value: unknown, field: string, errors: string[]): string | undefined {
142
+ if (typeof value !== "string" || value.trim().length === 0) {
143
+ errors.push(`${field} must be a non-empty string`);
144
+ return undefined;
145
+ }
146
+ if (value.includes("\n") || value.includes("\r")) {
147
+ errors.push(`${field} must be a single line`);
148
+ return undefined;
149
+ }
150
+ return value;
151
+ }
152
+
153
+ function idValue(value: unknown, field: string, errors: string[]): string | undefined {
154
+ const parsed = requiredString(value, field, errors);
155
+ if (parsed !== undefined && !ID_PATTERN.test(parsed)) {
156
+ errors.push(`${field} must be a kebab-case identifier`);
157
+ return undefined;
158
+ }
159
+ return parsed;
160
+ }
161
+
162
+ function stringArray(value: unknown, field: string, errors: string[]): string[] | undefined {
163
+ if (!Array.isArray(value) || value.length === 0) {
164
+ errors.push(`${field} must be a non-empty array`);
165
+ return undefined;
166
+ }
167
+ const parsed: string[] = [];
168
+ for (let index = 0; index < value.length; index++) {
169
+ const item = requiredString(value[index], `${field}[${index}]`, errors);
170
+ if (item !== undefined) parsed.push(item);
171
+ }
172
+ return parsed.length === value.length ? parsed : undefined;
173
+ }
174
+
175
+ function packVersions(value: unknown, field: string, errors: string[]): PackVersion[] | undefined {
176
+ if (!Array.isArray(value) || value.length === 0) {
177
+ errors.push(`${field} must be a non-empty array`);
178
+ return undefined;
179
+ }
180
+
181
+ const packs: PackVersion[] = [];
182
+ const seen = new Set<string>();
183
+ let extractCount = 0;
184
+ for (let index = 0; index < value.length; index++) {
185
+ const raw = value[index];
186
+ if (!isObject(raw)) {
187
+ errors.push(`${field}[${index}] must be an object`);
188
+ continue;
189
+ }
190
+ for (const key of unknownKeys(raw, ["id", "version", "from", "extract"])) {
191
+ errors.push(`${field}[${index}] contains unknown field ${key}`);
192
+ }
193
+ const id = idValue(raw.id, `${field}[${index}].id`, errors);
194
+ const version = requiredString(raw.version, `${field}[${index}].version`, errors);
195
+ const from = raw.from !== undefined ? requiredString(raw.from, `${field}[${index}].from`, errors) : undefined;
196
+ let extract: boolean | undefined;
197
+ if (raw.extract !== undefined) {
198
+ if (typeof raw.extract !== "boolean") {
199
+ errors.push(`${field}[${index}].extract must be a boolean`);
200
+ } else {
201
+ extract = raw.extract;
202
+ if (extract) extractCount++;
203
+ }
204
+ }
205
+ if (id === undefined || version === undefined) continue;
206
+ if (seen.has(id)) {
207
+ errors.push(`${field} contains duplicate pack ${id}`);
208
+ continue;
209
+ }
210
+ seen.add(id);
211
+ const pack: PackVersion = { id, version };
212
+ if (from !== undefined) pack.from = from;
213
+ if (extract !== undefined) pack.extract = extract;
214
+ packs.push(pack);
215
+ }
216
+ if (extractCount > 1) {
217
+ errors.push(`${field} has more than one pack with extract: true`);
218
+ }
219
+ return packs.length === value.length ? packs : undefined;
220
+ }
221
+
222
+ function parseManifest(value: unknown): Result<SpaceManifest> {
223
+ if (!isObject(value)) return err(["space manifest must be an object"]);
224
+ const errors: string[] = [];
225
+ for (const key of unknownKeys(value, [
226
+ "schema_version",
227
+ "space_id",
228
+ "knowledge_schema_version",
229
+ "records_dir",
230
+ "required_packs",
231
+ ])) {
232
+ errors.push(`space manifest contains unknown field ${key}`);
233
+ }
234
+ if (value.schema_version !== 0) errors.push("space manifest schema_version must be 0");
235
+ const spaceId = idValue(value.space_id, "space manifest space_id", errors);
236
+ const knowledgeSchemaVersion = requiredString(
237
+ value.knowledge_schema_version,
238
+ "space manifest knowledge_schema_version",
239
+ errors,
240
+ );
241
+ const recordsDir = requiredString(value.records_dir, "space manifest records_dir", errors);
242
+ if (recordsDir !== undefined) {
243
+ const segments = recordsDir.split(/[\\/]/);
244
+ const isWindowsPath = recordsDir.includes("\\") || /^[A-Za-z]:\//.test(recordsDir);
245
+ if (
246
+ isAbsolute(recordsDir) ||
247
+ isWindowsPath ||
248
+ recordsDir === "." ||
249
+ segments.some((segment) => segment === ".." || segment === "")
250
+ ) {
251
+ errors.push("space manifest records_dir must be a relative path without parent or empty segments");
252
+ }
253
+ }
254
+ const requiredPacks = packVersions(value.required_packs, "space manifest required_packs", errors);
255
+ if (errors.length > 0 || spaceId === undefined || knowledgeSchemaVersion === undefined || recordsDir === undefined || requiredPacks === undefined) {
256
+ return err(errors);
257
+ }
258
+ return ok({
259
+ schemaVersion: 0,
260
+ spaceId,
261
+ knowledgeSchemaVersion,
262
+ recordsDir,
263
+ requiredPacks,
264
+ });
265
+ }
266
+
267
+ function parseProviderPolicy(value: unknown, errors: string[]): ProviderPolicy | undefined {
268
+ if (!isObject(value)) {
269
+ errors.push("provider_policy must be an object");
270
+ return undefined;
271
+ }
272
+ for (const key of unknownKeys(value, ["allowed_models", "credential_env"])) {
273
+ errors.push(`provider_policy contains unknown field ${key}`);
274
+ }
275
+ const allowedModels = stringArray(value.allowed_models, "provider_policy.allowed_models", errors);
276
+ const credentialEnv = stringArray(value.credential_env, "provider_policy.credential_env", errors);
277
+ if (allowedModels === undefined || credentialEnv === undefined) return undefined;
278
+ if (credentialEnv.some((name) => !/^[A-Z_][A-Z0-9_]*$/.test(name))) {
279
+ errors.push("provider_policy.credential_env entries must be environment variable names");
280
+ return undefined;
281
+ }
282
+ return { allowedModels, credentialEnv };
283
+ }
284
+
285
+ function parseLocalBinding(value: unknown): Result<LocalBinding> {
286
+ if (!isObject(value)) return err(["local binding must be an object"]);
287
+ const errors: string[] = [];
288
+ for (const key of unknownKeys(value, [
289
+ "schema_version",
290
+ "manifest_path",
291
+ "qmd_config_dir",
292
+ "qmd_cache_home",
293
+ "qmd_collection_name",
294
+ "sessions_dir",
295
+ "read_roots",
296
+ "write_roots",
297
+ "provider_policy",
298
+ "installed_packs",
299
+ ])) {
300
+ errors.push(`local binding contains unknown field ${key}`);
301
+ }
302
+ if (value.schema_version !== 0) errors.push("local binding schema_version must be 0");
303
+
304
+ const manifestPath = requiredString(value.manifest_path, "manifest_path", errors);
305
+ const qmdConfigDir = requiredString(value.qmd_config_dir, "qmd_config_dir", errors);
306
+ const qmdCacheHome = requiredString(value.qmd_cache_home, "qmd_cache_home", errors);
307
+ const qmdCollectionName = idValue(value.qmd_collection_name, "qmd_collection_name", errors);
308
+ const sessionsDir = requiredString(value.sessions_dir, "sessions_dir", errors);
309
+ const readRoots = stringArray(value.read_roots, "read_roots", errors);
310
+ const writeRoots = stringArray(value.write_roots, "write_roots", errors);
311
+ const providerPolicy = parseProviderPolicy(value.provider_policy, errors);
312
+ const installedPacks = packVersions(value.installed_packs, "installed_packs", errors);
313
+
314
+ for (const [field, pathValue] of [
315
+ ["manifest_path", manifestPath],
316
+ ["qmd_config_dir", qmdConfigDir],
317
+ ["qmd_cache_home", qmdCacheHome],
318
+ ["sessions_dir", sessionsDir],
319
+ ] as const) {
320
+ if (pathValue !== undefined && !isAbsolute(pathValue)) errors.push(`${field} must be an absolute path`);
321
+ }
322
+ for (const [field, roots] of [["read_roots", readRoots], ["write_roots", writeRoots]] as const) {
323
+ if (roots !== undefined) {
324
+ for (const root of roots) if (!isAbsolute(root)) errors.push(`${field} entries must be absolute paths`);
325
+ }
326
+ }
327
+
328
+ if (
329
+ errors.length > 0 ||
330
+ manifestPath === undefined ||
331
+ qmdConfigDir === undefined ||
332
+ qmdCacheHome === undefined ||
333
+ qmdCollectionName === undefined ||
334
+ sessionsDir === undefined ||
335
+ readRoots === undefined ||
336
+ writeRoots === undefined ||
337
+ providerPolicy === undefined ||
338
+ installedPacks === undefined
339
+ ) {
340
+ return err(errors);
341
+ }
342
+ return ok({
343
+ manifestPath,
344
+ qmdConfigDir,
345
+ qmdCacheHome,
346
+ qmdCollectionName,
347
+ sessionsDir,
348
+ readRoots,
349
+ writeRoots,
350
+ providerPolicy,
351
+ installedPacks,
352
+ });
353
+ }
354
+
355
+ async function readJson(path: string, label: string): Promise<Result<unknown>> {
356
+ try {
357
+ return ok(JSON.parse(await readFile(path, "utf8")));
358
+ } catch {
359
+ return err([`${label} could not be read as JSON`]);
360
+ }
361
+ }
362
+
363
+ async function canonicalDirectory(path: string, field: string, errors: string[]): Promise<string | undefined> {
364
+ try {
365
+ const canonical = await realpath(path);
366
+ if (!(await stat(canonical)).isDirectory()) {
367
+ errors.push(`${field} must name a directory`);
368
+ return undefined;
369
+ }
370
+ return canonical;
371
+ } catch {
372
+ errors.push(`${field} must name an existing directory`);
373
+ return undefined;
374
+ }
375
+ }
376
+
377
+ function containsPath(root: string, candidate: string): boolean {
378
+ const pathFromRoot = relative(root, candidate);
379
+ return pathFromRoot === "" || (pathFromRoot !== ".." && !pathFromRoot.startsWith(".." + sep) && !isAbsolute(pathFromRoot));
380
+ }
381
+
382
+ function pathsOverlap(left: string, right: string): boolean {
383
+ return containsPath(left, right) || containsPath(right, left);
384
+ }
385
+
386
+ async function loadEffectiveBinding(bindingPath: string): Promise<Result<ActiveSpace>> {
387
+ const bindingJson = await readJson(bindingPath, "local binding");
388
+ if (!bindingJson.ok) return bindingJson;
389
+ const bindingResult = parseLocalBinding(bindingJson.value);
390
+ if (!bindingResult.ok) return bindingResult;
391
+ const binding = bindingResult.value;
392
+
393
+ const manifestJson = await readJson(binding.manifestPath, "space manifest");
394
+ if (!manifestJson.ok) return manifestJson;
395
+ const manifestResult = parseManifest(manifestJson.value);
396
+ if (!manifestResult.ok) return manifestResult;
397
+ const manifest = manifestResult.value;
398
+
399
+ const errors: string[] = [];
400
+ let canonicalBindingPath: string | undefined;
401
+ try {
402
+ canonicalBindingPath = await realpath(bindingPath);
403
+ if (!(await stat(canonicalBindingPath)).isFile()) errors.push("binding path must name a file");
404
+ } catch {
405
+ errors.push("binding path must name an existing file");
406
+ }
407
+ let manifestPath: string | undefined;
408
+ try {
409
+ manifestPath = await realpath(binding.manifestPath);
410
+ if (!(await stat(manifestPath)).isFile()) errors.push("manifest_path must name a file");
411
+ } catch {
412
+ errors.push("manifest_path must name an existing file");
413
+ }
414
+
415
+ const spaceRoot = manifestPath === undefined ? undefined : await canonicalDirectory(dirname(manifestPath), "space root", errors);
416
+ const recordsRoot = spaceRoot === undefined
417
+ ? undefined
418
+ : await canonicalDirectory(resolve(spaceRoot, manifest.recordsDir), "records_dir", errors);
419
+ const qmdConfigDir = await canonicalDirectory(binding.qmdConfigDir, "qmd_config_dir", errors);
420
+ const qmdCacheHome = await canonicalDirectory(binding.qmdCacheHome, "qmd_cache_home", errors);
421
+ const sessionsDir = await canonicalDirectory(binding.sessionsDir, "sessions_dir", errors);
422
+
423
+ const readRoots: string[] = [];
424
+ for (const [index, readRoot] of binding.readRoots.entries()) {
425
+ const root = await canonicalDirectory(readRoot, `read_roots[${index}]`, errors);
426
+ if (root !== undefined) readRoots.push(root);
427
+ }
428
+ const writeRoots: string[] = [];
429
+ for (const [index, writeRoot] of binding.writeRoots.entries()) {
430
+ const root = await canonicalDirectory(writeRoot, `write_roots[${index}]`, errors);
431
+ if (root !== undefined) writeRoots.push(root);
432
+ }
433
+
434
+ if (qmdConfigDir !== undefined && await isDefaultQmdConfigDir(qmdConfigDir)) {
435
+ errors.push("qmd_config_dir must not be the user's default qmd configuration directory");
436
+ }
437
+ if (qmdCacheHome !== undefined && await isDefaultQmdCacheHome(qmdCacheHome)) {
438
+ errors.push("qmd_cache_home must not be the user's default qmd cache home");
439
+ }
440
+ if (recordsRoot !== undefined && !readRoots.some((root) => containsPath(root, recordsRoot))) {
441
+ errors.push("read_roots must authorize records_dir");
442
+ }
443
+ if (recordsRoot !== undefined && !writeRoots.some((root) => containsPath(root, recordsRoot))) {
444
+ errors.push("write_roots must authorize records_dir");
445
+ }
446
+ if (spaceRoot !== undefined && readRoots.some((root) => !containsPath(spaceRoot, root))) {
447
+ errors.push("read_roots must stay within the portable space root");
448
+ }
449
+ if (spaceRoot !== undefined && writeRoots.some((root) => !containsPath(spaceRoot, root))) {
450
+ errors.push("write_roots must stay within the portable space root");
451
+ }
452
+ if (!SUPPORTED_KNOWLEDGE_SCHEMA_VERSIONS.has(manifest.knowledgeSchemaVersion)) {
453
+ errors.push(`unsupported knowledge schema version ${manifest.knowledgeSchemaVersion}`);
454
+ }
455
+
456
+ const installedById = new Map(binding.installedPacks.map((pack) => [pack.id, pack.version]));
457
+ for (const required of manifest.requiredPacks) {
458
+ const installedVersion = installedById.get(required.id);
459
+ if (installedVersion !== required.version) {
460
+ errors.push(`required pack ${required.id} version ${required.version} is not installed at that version`);
461
+ }
462
+ }
463
+
464
+ if (
465
+ errors.length > 0 ||
466
+ canonicalBindingPath === undefined ||
467
+ manifestPath === undefined ||
468
+ spaceRoot === undefined ||
469
+ recordsRoot === undefined ||
470
+ qmdConfigDir === undefined ||
471
+ qmdCacheHome === undefined ||
472
+ sessionsDir === undefined
473
+ ) {
474
+ return err(errors);
475
+ }
476
+
477
+ const bindingPackIndex = new Map<string, PackVersion>();
478
+ for (const bp of binding.installedPacks) bindingPackIndex.set(bp.id, bp);
479
+ const packs: PackVersion[] = manifest.requiredPacks.map((pack) => {
480
+ const bp = bindingPackIndex.get(pack.id);
481
+ if (bp === undefined) return { ...pack };
482
+ const merged: PackVersion = { id: pack.id, version: pack.version };
483
+ if (bp.from !== undefined) merged.from = bp.from;
484
+ if (bp.extract !== undefined) merged.extract = bp.extract;
485
+ return merged;
486
+ });
487
+
488
+ return ok({
489
+ spaceId: manifest.spaceId,
490
+ spaceRoot,
491
+ bindingPath: canonicalBindingPath,
492
+ manifestPath,
493
+ recordsRoot,
494
+ qmdConfigDir,
495
+ qmdCacheHome,
496
+ qmdCollectionName: binding.qmdCollectionName,
497
+ sessionsDir,
498
+ readRoots,
499
+ writeRoots,
500
+ allowedModels: [...binding.providerPolicy.allowedModels],
501
+ credentialEnv: [...binding.providerPolicy.credentialEnv],
502
+ knowledgeSchemaVersion: manifest.knowledgeSchemaVersion,
503
+ packs,
504
+ });
505
+ }
506
+
507
+ function emptyRegistry(): RegistryDocument {
508
+ return { schema_version: 0, spaces: [], active: {}, state: {}, last_boundary_error: null };
509
+ }
510
+
511
+ function parseRegisteredBoundary(
512
+ value: unknown,
513
+ field: string,
514
+ errors: string[],
515
+ ): RegisteredBoundary | undefined {
516
+ if (!isObject(value)) {
517
+ errors.push(`${field} must be an object`);
518
+ return undefined;
519
+ }
520
+ for (const key of unknownKeys(value, [
521
+ "space_root",
522
+ "records_root",
523
+ "qmd_config_dir",
524
+ "qmd_cache_home",
525
+ "qmd_collection_name",
526
+ "sessions_dir",
527
+ ])) {
528
+ errors.push(`${field} contains unknown field ${key}`);
529
+ }
530
+ const spaceRoot = requiredString(value.space_root, `${field}.space_root`, errors);
531
+ const recordsRoot = requiredString(value.records_root, `${field}.records_root`, errors);
532
+ const qmdConfigDir = requiredString(value.qmd_config_dir, `${field}.qmd_config_dir`, errors);
533
+ const qmdCacheHome = requiredString(value.qmd_cache_home, `${field}.qmd_cache_home`, errors);
534
+ const qmdCollectionName = idValue(value.qmd_collection_name, `${field}.qmd_collection_name`, errors);
535
+ const sessionsDir = requiredString(value.sessions_dir, `${field}.sessions_dir`, errors);
536
+
537
+ for (const [pathField, pathValue] of [
538
+ ["space_root", spaceRoot],
539
+ ["records_root", recordsRoot],
540
+ ["qmd_config_dir", qmdConfigDir],
541
+ ["qmd_cache_home", qmdCacheHome],
542
+ ["sessions_dir", sessionsDir],
543
+ ] as const) {
544
+ if (pathValue !== undefined && !isAbsolute(pathValue)) errors.push(`${field}.${pathField} must be absolute`);
545
+ }
546
+ if (
547
+ spaceRoot === undefined ||
548
+ recordsRoot === undefined ||
549
+ qmdConfigDir === undefined ||
550
+ qmdCacheHome === undefined ||
551
+ qmdCollectionName === undefined ||
552
+ sessionsDir === undefined ||
553
+ !isAbsolute(spaceRoot) ||
554
+ !isAbsolute(recordsRoot) ||
555
+ !isAbsolute(qmdConfigDir) ||
556
+ !isAbsolute(qmdCacheHome) ||
557
+ !isAbsolute(sessionsDir)
558
+ ) {
559
+ return undefined;
560
+ }
561
+ return {
562
+ space_root: spaceRoot,
563
+ records_root: recordsRoot,
564
+ qmd_config_dir: qmdConfigDir,
565
+ qmd_cache_home: qmdCacheHome,
566
+ qmd_collection_name: qmdCollectionName,
567
+ sessions_dir: sessionsDir,
568
+ };
569
+ }
570
+
571
+ function parseRegistry(value: unknown): Result<RegistryDocument> {
572
+ if (!isObject(value)) return err(["space registry must be an object"]);
573
+ const errors: string[] = [];
574
+ for (const key of unknownKeys(value, ["schema_version", "spaces", "active", "state", "last_boundary_error"])) {
575
+ errors.push(`space registry contains unknown field ${key}`);
576
+ }
577
+ if (value.schema_version !== 0) errors.push("space registry schema_version must be 0");
578
+
579
+ const spaces: RegistryEntry[] = [];
580
+ if (!Array.isArray(value.spaces)) {
581
+ errors.push("space registry spaces must be an array");
582
+ } else {
583
+ for (let index = 0; index < value.spaces.length; index++) {
584
+ const item = value.spaces[index];
585
+ if (!isObject(item)) {
586
+ errors.push(`space registry spaces[${index}] must be an object`);
587
+ continue;
588
+ }
589
+ for (const key of unknownKeys(item, ["space_id", "binding_path", "binding_hash", "boundary"])) {
590
+ errors.push(`space registry spaces[${index}] contains unknown field ${key}`);
591
+ }
592
+ const spaceId = idValue(item.space_id, `space registry spaces[${index}].space_id`, errors);
593
+ const bindingPath = requiredString(item.binding_path, `space registry spaces[${index}].binding_path`, errors);
594
+ const bindingHash = requiredString(item.binding_hash, `space registry spaces[${index}].binding_hash`, errors);
595
+ const boundary = parseRegisteredBoundary(item.boundary, `space registry spaces[${index}].boundary`, errors);
596
+ if (bindingPath !== undefined && !isAbsolute(bindingPath)) {
597
+ errors.push(`space registry spaces[${index}].binding_path must be absolute`);
598
+ }
599
+ if (bindingHash !== undefined && !/^[0-9a-f]{64}$/.test(bindingHash)) {
600
+ errors.push(`space registry spaces[${index}].binding_hash must be a SHA-256 value`);
601
+ }
602
+ if (
603
+ spaceId !== undefined &&
604
+ bindingPath !== undefined &&
605
+ isAbsolute(bindingPath) &&
606
+ bindingHash !== undefined &&
607
+ boundary !== undefined &&
608
+ /^[0-9a-f]{64}$/.test(bindingHash)
609
+ ) {
610
+ spaces.push({ space_id: spaceId, binding_path: bindingPath, binding_hash: bindingHash, boundary });
611
+ }
612
+ }
613
+ }
614
+
615
+ const active: Record<string, string> = {};
616
+ if (!isObject(value.active)) {
617
+ errors.push("space registry active must be an object mapping host sessions to spaces");
618
+ } else {
619
+ for (const [hostSessionId, rawSpaceId] of Object.entries(value.active)) {
620
+ if (!SESSION_ID_PATTERN.test(hostSessionId)) {
621
+ errors.push(`space registry active contains invalid host session id ${hostSessionId}`);
622
+ continue;
623
+ }
624
+ const spaceId = idValue(rawSpaceId, `space registry active.${hostSessionId}`, errors);
625
+ if (spaceId !== undefined) active[hostSessionId] = spaceId;
626
+ }
627
+ }
628
+
629
+ const state: Record<string, SpaceState> = {};
630
+ if (!isObject(value.state)) {
631
+ errors.push("space registry state must be an object");
632
+ } else {
633
+ for (const [spaceId, rawState] of Object.entries(value.state)) {
634
+ if (!ID_PATTERN.test(spaceId) || !isObject(rawState)) {
635
+ errors.push("space registry contains invalid state entry");
636
+ continue;
637
+ }
638
+ for (const key of unknownKeys(rawState, ["qmd_freshness"])) {
639
+ errors.push(`space registry state for ${spaceId} contains unknown field ${key}`);
640
+ }
641
+ const freshness = rawState.qmd_freshness;
642
+ if (freshness !== "unknown" && freshness !== "fresh" && freshness !== "index-stale") {
643
+ errors.push(`space registry state for ${spaceId} has invalid qmd_freshness`);
644
+ } else {
645
+ state[spaceId] = { qmd_freshness: freshness };
646
+ }
647
+ }
648
+ }
649
+
650
+ let lastBoundaryError: string | null = null;
651
+ if (value.last_boundary_error !== null) {
652
+ lastBoundaryError = requiredString(value.last_boundary_error, "space registry last_boundary_error", errors) ?? null;
653
+ }
654
+ if (errors.length > 0) return err(errors);
655
+ return ok({ schema_version: 0, spaces, active, state, last_boundary_error: lastBoundaryError });
656
+ }
657
+
658
+ async function loadRegistry(registryPath: string, missingIsEmpty: boolean): Promise<Result<RegistryDocument>> {
659
+ try {
660
+ const parsed = JSON.parse(await readFile(registryPath, "utf8"));
661
+ return parseRegistry(parsed);
662
+ } catch (error) {
663
+ if (missingIsEmpty && isObject(error) && error.code === "ENOENT") return ok(emptyRegistry());
664
+ return err(["space registry could not be read as JSON"]);
665
+ }
666
+ }
667
+
668
+ async function saveRegistry(registryPath: string, registry: RegistryDocument): Promise<void> {
669
+ await mkdir(dirname(registryPath), { recursive: true });
670
+ await atomicWriteFile(registryPath, JSON.stringify(registry, null, 2) + "\n");
671
+ }
672
+
673
+ function errorCode(error: unknown): string | undefined {
674
+ return isObject(error) && typeof error.code === "string" ? error.code : undefined;
675
+ }
676
+
677
+ function parseRegistryLockOwner(value: unknown): Result<RegistryLockOwner> {
678
+ if (!isObject(value)) return err(["registry lock owner metadata must be an object"]);
679
+ const errors: string[] = [];
680
+ for (const key of unknownKeys(value, ["schema_version", "pid", "hostname", "token", "purpose"])) {
681
+ errors.push(`registry lock owner metadata contains unknown field ${key}`);
682
+ }
683
+ if (value.schema_version !== 0) errors.push("registry lock owner schema_version must be 0");
684
+ if (!Number.isSafeInteger(value.pid) || typeof value.pid !== "number" || value.pid <= 0) {
685
+ errors.push("registry lock owner pid must be a positive integer");
686
+ }
687
+ const ownerHostname = requiredString(value.hostname, "registry lock owner hostname", errors);
688
+ const token = requiredString(value.token, "registry lock owner token", errors);
689
+ let purpose: "recovery" | undefined;
690
+ if (value.purpose !== undefined) {
691
+ if (value.purpose !== "recovery") errors.push("registry lock owner purpose must be recovery when present");
692
+ else purpose = "recovery";
693
+ }
694
+ if (
695
+ errors.length > 0 ||
696
+ typeof value.pid !== "number" ||
697
+ !Number.isSafeInteger(value.pid) ||
698
+ value.pid <= 0 ||
699
+ ownerHostname === undefined ||
700
+ token === undefined
701
+ ) {
702
+ return err(errors);
703
+ }
704
+ if (purpose === "recovery") {
705
+ return ok({ schema_version: 0, pid: value.pid, hostname: ownerHostname, token, purpose });
706
+ }
707
+ return ok({ schema_version: 0, pid: value.pid, hostname: ownerHostname, token });
708
+ }
709
+
710
+ async function readRegistryLockOwner(lockPath: string): Promise<Result<RegistryLockOwner>> {
711
+ try {
712
+ return parseRegistryLockOwner(JSON.parse(await readFile(lockPath, "utf8")));
713
+ } catch {
714
+ return err(["registry lock is held but its owner metadata cannot be validated"]);
715
+ }
716
+ }
717
+
718
+ function processState(pid: number): "live" | "absent" | "unknown" {
719
+ try {
720
+ process.kill(pid, 0);
721
+ return "live";
722
+ } catch (error) {
723
+ return errorCode(error) === "ESRCH" ? "absent" : "unknown";
724
+ }
725
+ }
726
+
727
+ async function clearStaleRecoveryMarker(recoveryPath: string): Promise<Result<void>> {
728
+ try {
729
+ await stat(recoveryPath);
730
+ } catch (error) {
731
+ if (errorCode(error) === "ENOENT") return ok(undefined);
732
+ return err(["registry lock recovery marker could not be inspected"]);
733
+ }
734
+
735
+ const existing = await readRegistryLockOwner(recoveryPath);
736
+ if (!existing.ok || existing.value.purpose !== "recovery") {
737
+ return err(["registry lock recovery owner metadata cannot be validated"]);
738
+ }
739
+ if (existing.value.hostname !== hostname()) {
740
+ return err([`registry lock recovery is held by pid ${existing.value.pid} on a host whose liveness cannot be validated`]);
741
+ }
742
+ const state = processState(existing.value.pid);
743
+ if (state === "live") return err([`registry lock recovery is held by live pid ${existing.value.pid}`]);
744
+ if (state === "unknown") {
745
+ return err([`registry lock recovery is held by pid ${existing.value.pid} whose liveness cannot be validated`]);
746
+ }
747
+ try {
748
+ await unlink(recoveryPath);
749
+ return ok(undefined);
750
+ } catch (error) {
751
+ if (errorCode(error) === "ENOENT") return ok(undefined);
752
+ return err(["registry lock recovery marker changed while stale-owner recovery was being validated"]);
753
+ }
754
+ }
755
+
756
+ async function installExclusiveOwnerMetadata(
757
+ ownerPath: string,
758
+ owner: RegistryLockOwner,
759
+ ): Promise<Result<boolean>> {
760
+ const candidatePath = `${ownerPath}.candidate-${owner.pid}-${owner.token}`;
761
+ try {
762
+ await writeFile(candidatePath, JSON.stringify(owner), { encoding: "utf8", flag: "wx", mode: 0o600 });
763
+ try {
764
+ await link(candidatePath, ownerPath);
765
+ return ok(true);
766
+ } catch (error) {
767
+ if (errorCode(error) === "EEXIST") return ok(false);
768
+ return err(["registry lock owner metadata could not be installed"]);
769
+ }
770
+ } catch (error) {
771
+ return err(["registry lock owner metadata could not be prepared"]);
772
+ } finally {
773
+ await unlink(candidatePath).catch(() => {});
774
+ }
775
+ }
776
+
777
+ async function recoverProvenStaleLock(
778
+ lockPath: string,
779
+ recoveryPath: string,
780
+ expectedOwner: RegistryLockOwner,
781
+ ): Promise<Result<void>> {
782
+ const recoveryOwner: RegistryLockOwner = {
783
+ schema_version: 0,
784
+ pid: process.pid,
785
+ hostname: hostname(),
786
+ token: randomUUID(),
787
+ purpose: "recovery",
788
+ };
789
+ const installed = await installExclusiveOwnerMetadata(recoveryPath, recoveryOwner);
790
+ if (!installed.ok) return installed;
791
+ if (!installed.value) return err(["registry lock recovery is already in progress"]);
792
+
793
+ try {
794
+ const current = await readRegistryLockOwner(lockPath);
795
+ if (!current.ok) return current;
796
+ if (
797
+ current.value.pid !== expectedOwner.pid ||
798
+ current.value.hostname !== expectedOwner.hostname ||
799
+ current.value.token !== expectedOwner.token
800
+ ) {
801
+ return err(["registry lock changed while stale-owner recovery was being validated"]);
802
+ }
803
+ const state = processState(current.value.pid);
804
+ if (state === "live") return err([`registry lock is held by live pid ${current.value.pid}`]);
805
+ if (state === "unknown") {
806
+ return err([`registry lock is held by pid ${current.value.pid} whose liveness cannot be validated`]);
807
+ }
808
+ await unlink(lockPath);
809
+ return ok(undefined);
810
+ } catch {
811
+ return err(["registry lock changed while stale-owner recovery was being validated"]);
812
+ } finally {
813
+ await unlink(recoveryPath).catch(() => {});
814
+ }
815
+ }
816
+
817
+ async function acquireRegistryLock(registryPath: string): Promise<Result<RegistryLockOwner>> {
818
+ const lockPath = `${registryPath}.lock`;
819
+ const recoveryPath = `${lockPath}.recovery`;
820
+ await mkdir(dirname(registryPath), { recursive: true });
821
+
822
+ for (let attempt = 0; attempt < 2; attempt++) {
823
+ const clearedRecovery = await clearStaleRecoveryMarker(recoveryPath);
824
+ if (!clearedRecovery.ok) return clearedRecovery;
825
+ const owner: RegistryLockOwner = {
826
+ schema_version: 0,
827
+ pid: process.pid,
828
+ hostname: hostname(),
829
+ token: randomUUID(),
830
+ };
831
+ const candidatePath = `${lockPath}.candidate-${owner.pid}-${owner.token}`;
832
+ try {
833
+ await writeFile(candidatePath, JSON.stringify(owner), { encoding: "utf8", flag: "wx", mode: 0o600 });
834
+ await link(candidatePath, lockPath);
835
+ return ok(owner);
836
+ } catch (error) {
837
+ if (errorCode(error) !== "EEXIST") {
838
+ return err(["registry lock could not be acquired"]);
839
+ }
840
+ } finally {
841
+ await unlink(candidatePath).catch(() => {});
842
+ }
843
+
844
+ const existing = await readRegistryLockOwner(lockPath);
845
+ if (!existing.ok) return existing;
846
+ if (existing.value.hostname !== hostname()) {
847
+ return err([`registry lock is held by pid ${existing.value.pid} on a host whose liveness cannot be validated`]);
848
+ }
849
+ const state = processState(existing.value.pid);
850
+ if (state === "live") return err([`registry lock is held by live pid ${existing.value.pid}`]);
851
+ if (state === "unknown") {
852
+ return err([`registry lock is held by pid ${existing.value.pid} whose liveness cannot be validated`]);
853
+ }
854
+ const recovered = await recoverProvenStaleLock(lockPath, recoveryPath, existing.value);
855
+ if (!recovered.ok) return recovered;
856
+ }
857
+ return err(["registry lock could not be acquired after recovering its proven-absent owner"]);
858
+ }
859
+
860
+ async function releaseRegistryLock(registryPath: string, owner: RegistryLockOwner): Promise<Result<void>> {
861
+ const lockPath = `${registryPath}.lock`;
862
+ const current = await readRegistryLockOwner(lockPath);
863
+ if (!current.ok) return current;
864
+ if (current.value.token !== owner.token) {
865
+ return err(["registry lock ownership changed before release"]);
866
+ }
867
+ try {
868
+ await unlink(lockPath);
869
+ return ok(undefined);
870
+ } catch {
871
+ return err(["registry lock could not be released"]);
872
+ }
873
+ }
874
+
875
+ async function withRegistryLock<T>(
876
+ registryPath: string,
877
+ operation: () => Promise<Result<T>>,
878
+ ): Promise<Result<T>> {
879
+ const acquired = await acquireRegistryLock(registryPath);
880
+ if (!acquired.ok) return acquired;
881
+ let outcome: Result<T>;
882
+ try {
883
+ outcome = await operation();
884
+ } catch {
885
+ outcome = err(["registry mutation failed while holding the registry lock"]);
886
+ }
887
+ const released = await releaseRegistryLock(registryPath, acquired.value);
888
+ if (!released.ok) return released;
889
+ return outcome;
890
+ }
891
+
892
+ function bindingHash(space: ActiveSpace): string {
893
+ // Integrity covers the effective boundary and provider policy, not JSON
894
+ // formatting or required-pack metadata. Compatibility is revalidated on
895
+ // every load, so a compatible pack upgrade can evolve without weakening
896
+ // redirect detection.
897
+ const integrityUnit = {
898
+ space_id: space.spaceId,
899
+ space_root: space.spaceRoot,
900
+ manifest_path: space.manifestPath,
901
+ records_root: space.recordsRoot,
902
+ qmd_config_dir: space.qmdConfigDir,
903
+ qmd_cache_home: space.qmdCacheHome,
904
+ qmd_collection_name: space.qmdCollectionName,
905
+ sessions_dir: space.sessionsDir,
906
+ read_roots: [...space.readRoots].sort(),
907
+ write_roots: [...space.writeRoots].sort(),
908
+ allowed_models: [...space.allowedModels].sort(),
909
+ credential_env: [...space.credentialEnv].sort(),
910
+ knowledge_schema_version: space.knowledgeSchemaVersion,
911
+ };
912
+ return createHash("sha256").update(JSON.stringify(integrityUnit)).digest("hex");
913
+ }
914
+
915
+ function registeredBoundary(space: ActiveSpace): RegisteredBoundary {
916
+ return {
917
+ space_root: space.spaceRoot,
918
+ records_root: space.recordsRoot,
919
+ qmd_config_dir: space.qmdConfigDir,
920
+ qmd_cache_home: space.qmdCacheHome,
921
+ qmd_collection_name: space.qmdCollectionName,
922
+ sessions_dir: space.sessionsDir,
923
+ };
924
+ }
925
+
926
+ async function loadRegisteredSpace(entry: RegistryEntry): Promise<Result<ActiveSpace>> {
927
+ const effective = await loadEffectiveBinding(entry.binding_path);
928
+ if (!effective.ok) return effective;
929
+ if (effective.value.spaceId !== entry.space_id) {
930
+ return err([`registered binding identity changed since registration for ${entry.space_id}`]);
931
+ }
932
+ const currentHash = bindingHash(effective.value);
933
+ if (currentHash !== entry.binding_hash) {
934
+ return err([`registered binding or manifest changed since registration for ${entry.space_id}`]);
935
+ }
936
+ return effective;
937
+ }
938
+
939
+ function registrationCollision(candidate: ActiveSpace, existing: RegisteredBoundary): string | undefined {
940
+ if (candidate.spaceRoot === existing.space_root) return "space root is already registered";
941
+ if (pathsOverlap(candidate.spaceRoot, existing.space_root)) return "space root overlaps another registered space root";
942
+ if (candidate.recordsRoot === existing.records_root) return "records_dir is already registered";
943
+ if (candidate.qmdConfigDir === existing.qmd_config_dir) return "qmd_config_dir is already used by another space";
944
+ if (pathsOverlap(candidate.qmdConfigDir, existing.qmd_config_dir)) return "qmd_config_dir overlaps another space";
945
+ if (candidate.qmdCacheHome === existing.qmd_cache_home) return "qmd_cache_home is already used by another space";
946
+ if (pathsOverlap(candidate.qmdCacheHome, existing.qmd_cache_home)) return "qmd_cache_home overlaps another space";
947
+ if (candidate.qmdCollectionName === existing.qmd_collection_name) return "qmd_collection_name is already used by another space";
948
+ if (candidate.sessionsDir === existing.sessions_dir) return "sessions_dir is already used by another space";
949
+ if (pathsOverlap(candidate.sessionsDir, existing.sessions_dir)) return "sessions_dir overlaps another space";
950
+ return undefined;
951
+ }
952
+
953
+ export async function registerSpace(registryPath: string, bindingPath: string): Promise<Result<ActiveSpaceStatus>> {
954
+ if (!isAbsolute(registryPath)) return err(["space registry path must be absolute"]);
955
+ if (!isAbsolute(bindingPath)) return err(["local binding path must be absolute"]);
956
+
957
+ const candidate = await loadEffectiveBinding(bindingPath);
958
+ if (!candidate.ok) return candidate;
959
+ return withRegistryLock(registryPath, async () => {
960
+ const registryResult = await loadRegistry(registryPath, true);
961
+ if (!registryResult.ok) return registryResult;
962
+ const registry = registryResult.value;
963
+
964
+ for (const entry of registry.spaces) {
965
+ if (entry.space_id === candidate.value.spaceId) continue;
966
+ const collision = registrationCollision(candidate.value, entry.boundary);
967
+ if (collision !== undefined) return err([collision]);
968
+ }
969
+
970
+ let canonicalBindingPath: string;
971
+ try {
972
+ canonicalBindingPath = await realpath(bindingPath);
973
+ } catch {
974
+ return err(["local binding path must name an existing file"]);
975
+ }
976
+ const nextEntry: RegistryEntry = {
977
+ space_id: candidate.value.spaceId,
978
+ binding_path: canonicalBindingPath,
979
+ binding_hash: bindingHash(candidate.value),
980
+ boundary: registeredBoundary(candidate.value),
981
+ };
982
+ registry.spaces = registry.spaces.filter((entry) => entry.space_id !== candidate.value.spaceId);
983
+ registry.spaces.push(nextEntry);
984
+ registry.spaces.sort((left, right) => left.space_id.localeCompare(right.space_id));
985
+ registry.state[candidate.value.spaceId] = { qmd_freshness: "unknown" };
986
+ registry.last_boundary_error = null;
987
+ await saveRegistry(registryPath, registry);
988
+ return ok(statusFor(candidate.value, "unknown"));
989
+ });
990
+ }
991
+
992
+ function validSessionId(value: string): boolean {
993
+ return SESSION_ID_PATTERN.test(value);
994
+ }
995
+
996
+ async function recordBoundaryError(registryPath: string, message: string): Promise<Result<void>> {
997
+ return withRegistryLock(registryPath, async () => {
998
+ const current = await loadRegistry(registryPath, false);
999
+ if (!current.ok) return current;
1000
+ current.value.last_boundary_error = message;
1001
+ await saveRegistry(registryPath, current.value);
1002
+ return ok(undefined);
1003
+ });
1004
+ }
1005
+
1006
+ export async function selectSpace(
1007
+ registryPath: string,
1008
+ spaceId: string,
1009
+ hostSessionId: string,
1010
+ ): Promise<Result<ActiveSpaceStatus>> {
1011
+ if (!validSessionId(hostSessionId)) return err(["host session id must use only letters, digits, dot, underscore, or hyphen"]);
1012
+ return withRegistryLock(registryPath, async () => {
1013
+ const registryResult = await loadRegistry(registryPath, false);
1014
+ if (!registryResult.ok) return registryResult;
1015
+ const registry = registryResult.value;
1016
+ const entry = registry.spaces.find((candidate) => candidate.space_id === spaceId);
1017
+ if (entry === undefined) return err([`space ${spaceId} is not registered`]);
1018
+
1019
+ const selectedSpaceId = registry.active[hostSessionId];
1020
+ if (selectedSpaceId !== undefined && selectedSpaceId !== spaceId) {
1021
+ registry.last_boundary_error = "changing primary spaces requires a fresh host session identifier";
1022
+ await saveRegistry(registryPath, registry);
1023
+ return err([registry.last_boundary_error]);
1024
+ }
1025
+
1026
+ const effective = await loadRegisteredSpace(entry);
1027
+ if (!effective.ok) {
1028
+ registry.last_boundary_error = "selected space binding failed validation";
1029
+ await saveRegistry(registryPath, registry);
1030
+ return effective;
1031
+ }
1032
+ const sessionPath = resolve(effective.value.sessionsDir, hostSessionId);
1033
+ const isNewSelection = selectedSpaceId === undefined;
1034
+ if (isNewSelection) {
1035
+ try {
1036
+ await mkdir(sessionPath);
1037
+ } catch (error) {
1038
+ const code = errorCode(error);
1039
+ const message = code === "EEXIST"
1040
+ ? "selecting a new primary space requires an unused host session identifier"
1041
+ : "the selected space's session directory could not be created";
1042
+ registry.last_boundary_error = message;
1043
+ await saveRegistry(registryPath, registry);
1044
+ return err([message]);
1045
+ }
1046
+ } else {
1047
+ await mkdir(sessionPath, { recursive: true });
1048
+ }
1049
+ registry.active[hostSessionId] = spaceId;
1050
+ registry.last_boundary_error = null;
1051
+ await saveRegistry(registryPath, registry);
1052
+ const freshness = registry.state[spaceId]?.qmd_freshness ?? "unknown";
1053
+ return ok(statusFor(effective.value, freshness));
1054
+ });
1055
+ }
1056
+
1057
+ export async function resolveActiveSpace(env: EnvLike): Promise<Result<ActiveSpace>> {
1058
+ const registryPath = env.ENGRAM_BINDING_REGISTRY;
1059
+ const hostSessionId = env.ENGRAM_HOST_SESSION_ID;
1060
+ const errors: string[] = [];
1061
+ if (registryPath === undefined || registryPath.length === 0) errors.push("missing ENGRAM_BINDING_REGISTRY");
1062
+ else if (!isAbsolute(registryPath)) errors.push("ENGRAM_BINDING_REGISTRY must be an absolute path");
1063
+ if (hostSessionId === undefined || !validSessionId(hostSessionId)) errors.push("missing or invalid ENGRAM_HOST_SESSION_ID");
1064
+ if (errors.length > 0 || registryPath === undefined || hostSessionId === undefined) return err(errors);
1065
+
1066
+ const registryResult = await loadRegistry(registryPath, false);
1067
+ if (!registryResult.ok) return registryResult;
1068
+ const registry = registryResult.value;
1069
+ const activeSpaceId = registry.active[hostSessionId];
1070
+ if (activeSpaceId === undefined) {
1071
+ const message = "no active space is selected for this host session";
1072
+ const recorded = await recordBoundaryError(registryPath, message);
1073
+ if (!recorded.ok) return recorded;
1074
+ return err([message]);
1075
+ }
1076
+ const entry = registry.spaces.find((candidate) => candidate.space_id === activeSpaceId);
1077
+ if (entry === undefined) return err(["active space is not registered"]);
1078
+ return loadRegisteredSpace(entry);
1079
+ }
1080
+
1081
+ function statusFor(space: ActiveSpace, freshness: SpaceState["qmd_freshness"]): ActiveSpaceStatus {
1082
+ return {
1083
+ space_id: space.spaceId,
1084
+ space_root: space.spaceRoot,
1085
+ records_root: space.recordsRoot,
1086
+ qmd: {
1087
+ collection: space.qmdCollectionName,
1088
+ config_dir: space.qmdConfigDir,
1089
+ cache_home: space.qmdCacheHome,
1090
+ },
1091
+ sessions_dir: space.sessionsDir,
1092
+ read_roots: [...space.readRoots],
1093
+ write_roots: [...space.writeRoots],
1094
+ allowed_models: [...space.allowedModels],
1095
+ knowledge_schema_version: space.knowledgeSchemaVersion,
1096
+ packs: space.packs.map((pack) => ({ ...pack })),
1097
+ compatibility: "compatible",
1098
+ qmd_freshness: freshness,
1099
+ session_boundary: "validated-not-enforced",
1100
+ };
1101
+ }
1102
+
1103
+ export async function inspectSpaceRegistry(registryPath: string): Promise<Result<SpaceRegistryStatus>> {
1104
+ const registryResult = await loadRegistry(registryPath, false);
1105
+ if (!registryResult.ok) return registryResult;
1106
+ const registry = registryResult.value;
1107
+ const activeSpaces: Record<string, ActiveSpaceStatus> = {};
1108
+ for (const [hostSessionId, spaceId] of Object.entries(registry.active)) {
1109
+ const entry = registry.spaces.find((candidate) => candidate.space_id === spaceId);
1110
+ if (entry === undefined) return err(["active space is not registered"]);
1111
+ const effective = await loadRegisteredSpace(entry);
1112
+ if (!effective.ok) return effective;
1113
+ const freshness = registry.state[entry.space_id]?.qmd_freshness ?? "unknown";
1114
+ activeSpaces[hostSessionId] = statusFor(effective.value, freshness);
1115
+ }
1116
+ return ok({
1117
+ schema_version: 0,
1118
+ registered_spaces: registry.spaces.map((entry) => entry.space_id),
1119
+ active_spaces: activeSpaces,
1120
+ last_boundary_error: registry.last_boundary_error,
1121
+ });
1122
+ }
1123
+
1124
+ export async function recordQmdFreshness(
1125
+ registryPath: string,
1126
+ spaceId: string,
1127
+ freshness: "fresh" | "index-stale",
1128
+ ): Promise<Result<void>> {
1129
+ return withRegistryLock(registryPath, async () => {
1130
+ const registryResult = await loadRegistry(registryPath, false);
1131
+ if (!registryResult.ok) return registryResult;
1132
+ if (!registryResult.value.spaces.some((entry) => entry.space_id === spaceId)) {
1133
+ return err([`space ${spaceId} is not registered`]);
1134
+ }
1135
+ registryResult.value.state[spaceId] = { qmd_freshness: freshness };
1136
+ await saveRegistry(registryPath, registryResult.value);
1137
+ return ok(undefined);
1138
+ });
1139
+ }