@evo-dev/core 0.0.1-alpha.13 → 0.0.1-alpha.15

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.
@@ -1,7 +1,7 @@
1
1
  import { lstat, readFile, readdir, realpath, stat } from "node:fs/promises";
2
2
  import { basename, dirname, isAbsolute, join, parse } from "node:path";
3
3
  import { resolveEvoDevPaths } from "../config/paths.ts";
4
- import { resolveProjectLogKey } from "../runtime-logs/index.ts";
4
+ import { resolvePathProjectLogKey, resolveProjectLogIdentity } from "../runtime-logs/index.ts";
5
5
  import {
6
6
  isFileExistsError,
7
7
  isNotFoundError,
@@ -16,9 +16,13 @@ export type ProjectDiscoveryTarget = "claude" | "codex";
16
16
 
17
17
  export interface ResolvedProjectWorkspace {
18
18
  projectKey: string;
19
+ workspaceKey: string;
19
20
  displayName: string;
21
+ projectRoot: string;
20
22
  workspaceRoot: string;
21
23
  workspaceKind: ProjectWorkspaceKind;
24
+ gitCommonDir: string | null;
25
+ linkedWorktree: boolean;
22
26
  cwd: string;
23
27
  }
24
28
 
@@ -51,6 +55,40 @@ export interface RegisteredProjectRecordV1 {
51
55
  sourceContentStored: false;
52
56
  }
53
57
 
58
+ export interface ProjectWorkspaceRecordV1 {
59
+ schemaVersion: 1;
60
+ kind: "project-workspace";
61
+ workspaceKey: string;
62
+ projectKey: string;
63
+ displayName: string;
64
+ workspaceRoot: string;
65
+ workspaceKind: ProjectWorkspaceKind;
66
+ gitCommonDir: string | null;
67
+ sourceTargets: ProjectDiscoveryTarget[];
68
+ firstSeenAt: string;
69
+ lastSeenAt: string;
70
+ localOnly: true;
71
+ sourceContentStored: false;
72
+ }
73
+
74
+ export interface ProjectWorkspaceSummary extends ProjectWorkspaceRecordV1 {
75
+ available: boolean;
76
+ }
77
+
78
+ export interface ProjectAliasRecordV1 {
79
+ schemaVersion: 1;
80
+ kind: "project-alias";
81
+ aliasProjectKey: string;
82
+ canonicalProjectKey: string;
83
+ workspaceRoot: string;
84
+ createdAt: string;
85
+ updatedAt: string;
86
+ localOnly: true;
87
+ sourceContentStored: false;
88
+ }
89
+
90
+ export type ProjectAliasMap = Record<string, string>;
91
+
54
92
  export class ProjectRegistrationError extends Error {
55
93
  readonly code: "invalid" | "not-found" | "conflict";
56
94
 
@@ -64,17 +102,27 @@ export class ProjectRegistrationError extends Error {
64
102
  export function resolveProjectRegistryPaths(homeDir: string): {
65
103
  discoveredDir: string;
66
104
  registeredDir: string;
105
+ workspaceDir: string;
106
+ aliasDir: string;
67
107
  discoveredPath: (projectKey: string) => string;
68
108
  registeredPath: (projectKey: string) => string;
109
+ workspacePath: (workspaceKey: string) => string;
110
+ aliasPath: (aliasProjectKey: string) => string;
69
111
  } {
70
112
  const paths = resolveEvoDevPaths(homeDir);
71
113
  const discoveredDir = join(paths.stateDir, "projects", "discovered");
72
114
  const registeredDir = join(paths.rootDir, "projects");
115
+ const workspaceDir = join(paths.stateDir, "projects", "workspaces");
116
+ const aliasDir = join(paths.stateDir, "projects", "aliases");
73
117
  return {
74
118
  discoveredDir,
75
119
  registeredDir,
120
+ workspaceDir,
121
+ aliasDir,
76
122
  discoveredPath: (projectKey) => join(discoveredDir, `${projectKey}.json`),
77
123
  registeredPath: (projectKey) => join(registeredDir, projectKey, "workspace.json"),
124
+ workspacePath: (workspaceKey) => join(workspaceDir, `${workspaceKey}.json`),
125
+ aliasPath: (aliasProjectKey) => join(aliasDir, `${aliasProjectKey}.json`),
78
126
  };
79
127
  }
80
128
 
@@ -96,11 +144,16 @@ export async function resolveProjectWorkspaceFromCwd(input: {
96
144
 
97
145
  const gitRoot = await findNearestGitRoot(cwd);
98
146
  const workspaceRoot = gitRoot ?? cwd;
147
+ const identity = resolveProjectLogIdentity(input.homeDir, workspaceRoot);
99
148
  return {
100
- projectKey: sanitizeStorageId(resolveProjectLogKey(input.homeDir, workspaceRoot), "project"),
101
- displayName: basename(workspaceRoot) || parse(workspaceRoot).root,
149
+ projectKey: sanitizeStorageId(identity.projectKey, "project"),
150
+ workspaceKey: sanitizeStorageId(identity.workspaceKey, "workspace"),
151
+ displayName: basename(identity.projectRoot) || parse(identity.projectRoot).root,
152
+ projectRoot: identity.projectRoot,
102
153
  workspaceRoot,
103
154
  workspaceKind: gitRoot === null ? "directory" : "git",
155
+ gitCommonDir: identity.gitCommonDir,
156
+ linkedWorktree: identity.linkedWorktree,
104
157
  cwd,
105
158
  };
106
159
  }
@@ -119,12 +172,27 @@ export async function recordDiscoveredProject(input: {
119
172
  const paths = resolveProjectRegistryPaths(input.homeDir);
120
173
  const path = paths.discoveredPath(workspace.projectKey);
121
174
  const existing = await readDiscoveredProject(path);
175
+ await writeProjectWorkspaceRecord({
176
+ paths,
177
+ workspace,
178
+ target: input.target,
179
+ now,
180
+ });
181
+ if (workspace.workspaceKey !== workspace.projectKey) {
182
+ await writeProjectAliasRecord({
183
+ paths,
184
+ aliasProjectKey: workspace.workspaceKey,
185
+ canonicalProjectKey: workspace.projectKey,
186
+ workspaceRoot: workspace.workspaceRoot,
187
+ now,
188
+ });
189
+ }
122
190
  const record: DiscoveredProjectRecordV1 = {
123
191
  schemaVersion: 1,
124
192
  kind: "discovered-project",
125
193
  projectKey: workspace.projectKey,
126
194
  displayName: workspace.displayName,
127
- workspaceRoot: workspace.workspaceRoot,
195
+ workspaceRoot: workspace.projectRoot,
128
196
  lastCwd: workspace.cwd,
129
197
  workspaceKind: workspace.workspaceKind,
130
198
  sourceTargets: mergeTargets(existing?.sourceTargets ?? [], [input.target]),
@@ -143,10 +211,16 @@ export async function listDiscoveredProjects(input: {
143
211
  }): Promise<DiscoveredProjectRecordV1[]> {
144
212
  const paths = resolveProjectRegistryPaths(input.homeDir);
145
213
  const records = new Map<string, DiscoveredProjectRecordV1>();
214
+ const aliases = await listProjectAliases(input);
146
215
 
147
216
  for (const path of await listJsonFilePaths(paths.discoveredDir)) {
148
217
  const record = await readDiscoveredProject(path);
149
- if (record !== null) mergeDiscoveredRecord(records, record);
218
+ if (record !== null) {
219
+ mergeDiscoveredRecord(
220
+ records,
221
+ await canonicalizeDiscoveredRecord(input.homeDir, record, aliases),
222
+ );
223
+ }
150
224
  }
151
225
 
152
226
  for (const binding of await listLegacyHookBindings(input.homeDir)) {
@@ -161,7 +235,7 @@ export async function listDiscoveredProjects(input: {
161
235
  kind: "discovered-project",
162
236
  projectKey: workspace.projectKey,
163
237
  displayName: workspace.displayName,
164
- workspaceRoot: workspace.workspaceRoot,
238
+ workspaceRoot: workspace.projectRoot,
165
239
  lastCwd: workspace.cwd,
166
240
  workspaceKind: workspace.workspaceKind,
167
241
  sourceTargets: [binding.target],
@@ -184,18 +258,125 @@ export async function listRegisteredProjects(input: {
184
258
  homeDir: string;
185
259
  }): Promise<RegisteredProjectRecordV1[]> {
186
260
  const { registeredDir } = resolveProjectRegistryPaths(input.homeDir);
187
- const records: RegisteredProjectRecordV1[] = [];
261
+ const aliases = await listProjectAliases(input);
262
+ const records = new Map<string, RegisteredProjectRecordV1>();
188
263
  for (const projectKey of await listDirectoryNames(registeredDir)) {
189
264
  const record = await readRegisteredProject(join(registeredDir, projectKey, "workspace.json"));
190
- if (record !== null && record.projectKey === projectKey) records.push(record);
265
+ if (record === null || record.projectKey !== projectKey) continue;
266
+ mergeRegisteredRecord(
267
+ records,
268
+ await canonicalizeRegisteredRecord(input.homeDir, record, aliases),
269
+ );
191
270
  }
192
- return records.sort(
271
+ return [...records.values()].sort(
193
272
  (left, right) =>
194
273
  right.lastSeenAt.localeCompare(left.lastSeenAt) ||
195
274
  left.projectKey.localeCompare(right.projectKey),
196
275
  );
197
276
  }
198
277
 
278
+ export async function listProjectAliases(input: {
279
+ homeDir: string;
280
+ }): Promise<ProjectAliasMap> {
281
+ const paths = resolveProjectRegistryPaths(input.homeDir);
282
+ const aliases: ProjectAliasMap = {};
283
+ for (const path of await listJsonFilePaths(paths.aliasDir)) {
284
+ const record = await readProjectAlias(path);
285
+ if (record !== null) aliases[record.aliasProjectKey] = record.canonicalProjectKey;
286
+ }
287
+
288
+ for (const path of await listJsonFilePaths(paths.discoveredDir)) {
289
+ const record = await readDiscoveredProject(path);
290
+ if (record !== null) {
291
+ await collectAvailableProjectAlias(input.homeDir, record, aliases);
292
+ }
293
+ }
294
+ for (const projectKey of await listDirectoryNames(paths.registeredDir)) {
295
+ const record = await readRegisteredProject(
296
+ join(paths.registeredDir, projectKey, "workspace.json"),
297
+ );
298
+ if (record !== null) {
299
+ await collectAvailableProjectAlias(input.homeDir, record, aliases);
300
+ }
301
+ }
302
+ return normalizeProjectAliases(aliases);
303
+ }
304
+
305
+ export function canonicalizeProjectKey(projectKey: string, aliases: ProjectAliasMap): string {
306
+ let current = projectKey;
307
+ const seen = new Set<string>();
308
+ while (!seen.has(current)) {
309
+ seen.add(current);
310
+ const next = aliases[current];
311
+ if (next === undefined || next === current) return current;
312
+ current = next;
313
+ }
314
+ return projectKey;
315
+ }
316
+
317
+ export async function listEquivalentProjectKeys(input: {
318
+ homeDir: string;
319
+ projectKey: string;
320
+ }): Promise<string[]> {
321
+ const aliases = await listProjectAliases({ homeDir: input.homeDir });
322
+ const canonical = canonicalizeProjectKey(input.projectKey, aliases);
323
+ return [
324
+ ...new Set([
325
+ canonical,
326
+ input.projectKey,
327
+ ...Object.keys(aliases).filter(
328
+ (alias) => canonicalizeProjectKey(alias, aliases) === canonical,
329
+ ),
330
+ ]),
331
+ ].sort();
332
+ }
333
+
334
+ export async function listProjectWorkspaces(input: {
335
+ homeDir: string;
336
+ }): Promise<ProjectWorkspaceSummary[]> {
337
+ const paths = resolveProjectRegistryPaths(input.homeDir);
338
+ const aliases = await listProjectAliases(input);
339
+ const records = new Map<string, ProjectWorkspaceSummary>();
340
+
341
+ for (const path of await listJsonFilePaths(paths.workspaceDir)) {
342
+ const record = await readProjectWorkspace(path);
343
+ if (record !== null) {
344
+ mergeProjectWorkspaceSummary(
345
+ records,
346
+ await summarizeProjectWorkspace(input.homeDir, record, aliases),
347
+ );
348
+ }
349
+ }
350
+ for (const path of await listJsonFilePaths(paths.discoveredDir)) {
351
+ const record = await readDiscoveredProject(path);
352
+ if (record !== null) {
353
+ mergeProjectWorkspaceSummary(
354
+ records,
355
+ await summarizeLegacyWorkspace(input.homeDir, record, aliases),
356
+ );
357
+ }
358
+ }
359
+ for (const projectKey of await listDirectoryNames(paths.registeredDir)) {
360
+ const record = await readRegisteredProject(
361
+ join(paths.registeredDir, projectKey, "workspace.json"),
362
+ );
363
+ if (record !== null) {
364
+ mergeProjectWorkspaceSummary(
365
+ records,
366
+ await summarizeLegacyWorkspace(input.homeDir, record, aliases),
367
+ );
368
+ }
369
+ }
370
+
371
+ return [...records.values()].sort(
372
+ (left, right) =>
373
+ left.projectKey.localeCompare(right.projectKey) ||
374
+ Number(right.available) - Number(left.available) ||
375
+ right.lastSeenAt.localeCompare(left.lastSeenAt) ||
376
+ left.workspaceKey.localeCompare(right.workspaceKey),
377
+ );
378
+ }
379
+
199
380
  export async function registerDiscoveredProject(input: {
200
381
  homeDir: string;
201
382
  projectKey: string;
@@ -240,6 +421,229 @@ export async function registerDiscoveredProject(input: {
240
421
  }
241
422
  }
242
423
 
424
+ async function writeProjectWorkspaceRecord(input: {
425
+ paths: ReturnType<typeof resolveProjectRegistryPaths>;
426
+ workspace: ResolvedProjectWorkspace;
427
+ target: ProjectDiscoveryTarget;
428
+ now: string;
429
+ }): Promise<void> {
430
+ const path = input.paths.workspacePath(input.workspace.workspaceKey);
431
+ const existing = await readProjectWorkspace(path);
432
+ const record: ProjectWorkspaceRecordV1 = {
433
+ schemaVersion: 1,
434
+ kind: "project-workspace",
435
+ workspaceKey: input.workspace.workspaceKey,
436
+ projectKey: input.workspace.projectKey,
437
+ displayName:
438
+ basename(input.workspace.workspaceRoot) || parse(input.workspace.workspaceRoot).root,
439
+ workspaceRoot: input.workspace.workspaceRoot,
440
+ workspaceKind: input.workspace.workspaceKind,
441
+ gitCommonDir: input.workspace.gitCommonDir,
442
+ sourceTargets: mergeTargets(existing?.sourceTargets ?? [], [input.target]),
443
+ firstSeenAt: existing?.firstSeenAt ?? input.now,
444
+ lastSeenAt: input.now,
445
+ localOnly: true,
446
+ sourceContentStored: false,
447
+ };
448
+ await writeJsonFile(path, record);
449
+ }
450
+
451
+ async function writeProjectAliasRecord(input: {
452
+ paths: ReturnType<typeof resolveProjectRegistryPaths>;
453
+ aliasProjectKey: string;
454
+ canonicalProjectKey: string;
455
+ workspaceRoot: string;
456
+ now: string;
457
+ }): Promise<void> {
458
+ const path = input.paths.aliasPath(input.aliasProjectKey);
459
+ const existing = await readProjectAlias(path);
460
+ const record: ProjectAliasRecordV1 = {
461
+ schemaVersion: 1,
462
+ kind: "project-alias",
463
+ aliasProjectKey: input.aliasProjectKey,
464
+ canonicalProjectKey: input.canonicalProjectKey,
465
+ workspaceRoot: input.workspaceRoot,
466
+ createdAt: existing?.createdAt ?? input.now,
467
+ updatedAt: input.now,
468
+ localOnly: true,
469
+ sourceContentStored: false,
470
+ };
471
+ await writeJsonFile(path, record);
472
+ }
473
+
474
+ async function canonicalizeDiscoveredRecord(
475
+ homeDir: string,
476
+ record: DiscoveredProjectRecordV1,
477
+ aliases: ProjectAliasMap,
478
+ ): Promise<DiscoveredProjectRecordV1> {
479
+ const workspace = await resolveProjectWorkspaceFromCwd({
480
+ homeDir,
481
+ cwd: record.workspaceRoot,
482
+ }).catch(() => null);
483
+ if (workspace === null) {
484
+ return {
485
+ ...record,
486
+ projectKey: canonicalizeProjectKey(record.projectKey, aliases),
487
+ };
488
+ }
489
+ return {
490
+ ...record,
491
+ projectKey: workspace.projectKey,
492
+ displayName: workspace.displayName,
493
+ workspaceRoot: workspace.projectRoot,
494
+ workspaceKind: workspace.workspaceKind,
495
+ };
496
+ }
497
+
498
+ async function canonicalizeRegisteredRecord(
499
+ homeDir: string,
500
+ record: RegisteredProjectRecordV1,
501
+ aliases: ProjectAliasMap,
502
+ ): Promise<RegisteredProjectRecordV1> {
503
+ const workspace = await resolveProjectWorkspaceFromCwd({
504
+ homeDir,
505
+ cwd: record.workspaceRoot,
506
+ }).catch(() => null);
507
+ if (workspace === null) {
508
+ return {
509
+ ...record,
510
+ projectKey: canonicalizeProjectKey(record.projectKey, aliases),
511
+ };
512
+ }
513
+ return {
514
+ ...record,
515
+ projectKey: workspace.projectKey,
516
+ displayName: workspace.displayName,
517
+ workspaceRoot: workspace.projectRoot,
518
+ workspaceKind: workspace.workspaceKind,
519
+ };
520
+ }
521
+
522
+ async function collectAvailableProjectAlias(
523
+ homeDir: string,
524
+ record: Pick<DiscoveredProjectRecordV1, "projectKey" | "workspaceRoot">,
525
+ aliases: ProjectAliasMap,
526
+ ): Promise<void> {
527
+ const workspace = await resolveProjectWorkspaceFromCwd({
528
+ homeDir,
529
+ cwd: record.workspaceRoot,
530
+ }).catch(() => null);
531
+ if (workspace === null) return;
532
+ if (record.projectKey !== workspace.projectKey) {
533
+ aliases[record.projectKey] = workspace.projectKey;
534
+ }
535
+ if (workspace.workspaceKey !== workspace.projectKey) {
536
+ aliases[workspace.workspaceKey] = workspace.projectKey;
537
+ }
538
+ }
539
+
540
+ function normalizeProjectAliases(aliases: ProjectAliasMap): ProjectAliasMap {
541
+ return Object.fromEntries(
542
+ Object.keys(aliases)
543
+ .sort()
544
+ .flatMap((alias) => {
545
+ const canonical = canonicalizeProjectKey(alias, aliases);
546
+ return canonical === alias ? [] : [[alias, canonical]];
547
+ }),
548
+ );
549
+ }
550
+
551
+ async function summarizeProjectWorkspace(
552
+ homeDir: string,
553
+ record: ProjectWorkspaceRecordV1,
554
+ aliases: ProjectAliasMap,
555
+ ): Promise<ProjectWorkspaceSummary> {
556
+ const workspace = await resolveProjectWorkspaceFromCwd({
557
+ homeDir,
558
+ cwd: record.workspaceRoot,
559
+ }).catch(() => null);
560
+ if (workspace === null) {
561
+ return {
562
+ ...record,
563
+ projectKey: canonicalizeProjectKey(record.projectKey, aliases),
564
+ available: false,
565
+ };
566
+ }
567
+ return {
568
+ ...record,
569
+ workspaceKey: workspace.workspaceKey,
570
+ projectKey: workspace.projectKey,
571
+ displayName: basename(workspace.workspaceRoot) || parse(workspace.workspaceRoot).root,
572
+ workspaceRoot: workspace.workspaceRoot,
573
+ workspaceKind: workspace.workspaceKind,
574
+ gitCommonDir: workspace.gitCommonDir,
575
+ available: true,
576
+ };
577
+ }
578
+
579
+ async function summarizeLegacyWorkspace(
580
+ homeDir: string,
581
+ record: DiscoveredProjectRecordV1 | RegisteredProjectRecordV1,
582
+ aliases: ProjectAliasMap,
583
+ ): Promise<ProjectWorkspaceSummary> {
584
+ const workspace = await resolveProjectWorkspaceFromCwd({
585
+ homeDir,
586
+ cwd: record.workspaceRoot,
587
+ }).catch(() => null);
588
+ const sourceTargets = record.kind === "discovered-project" ? record.sourceTargets : [];
589
+ const firstSeenAt =
590
+ record.kind === "discovered-project" ? record.firstSeenAt : record.registeredAt;
591
+ if (workspace === null) {
592
+ return {
593
+ schemaVersion: 1,
594
+ kind: "project-workspace",
595
+ workspaceKey: resolvePathProjectLogKey(homeDir, record.workspaceRoot),
596
+ projectKey: canonicalizeProjectKey(record.projectKey, aliases),
597
+ displayName: record.displayName,
598
+ workspaceRoot: record.workspaceRoot,
599
+ workspaceKind: record.workspaceKind,
600
+ gitCommonDir: null,
601
+ sourceTargets,
602
+ firstSeenAt,
603
+ lastSeenAt: record.lastSeenAt,
604
+ localOnly: true,
605
+ sourceContentStored: false,
606
+ available: false,
607
+ };
608
+ }
609
+ return {
610
+ schemaVersion: 1,
611
+ kind: "project-workspace",
612
+ workspaceKey: workspace.workspaceKey,
613
+ projectKey: workspace.projectKey,
614
+ displayName: basename(workspace.workspaceRoot) || parse(workspace.workspaceRoot).root,
615
+ workspaceRoot: workspace.workspaceRoot,
616
+ workspaceKind: workspace.workspaceKind,
617
+ gitCommonDir: workspace.gitCommonDir,
618
+ sourceTargets,
619
+ firstSeenAt,
620
+ lastSeenAt: record.lastSeenAt,
621
+ localOnly: true,
622
+ sourceContentStored: false,
623
+ available: true,
624
+ };
625
+ }
626
+
627
+ function mergeProjectWorkspaceSummary(
628
+ records: Map<string, ProjectWorkspaceSummary>,
629
+ next: ProjectWorkspaceSummary,
630
+ ): void {
631
+ const previous = records.get(next.workspaceKey);
632
+ if (previous === undefined) {
633
+ records.set(next.workspaceKey, next);
634
+ return;
635
+ }
636
+ const newer = next.lastSeenAt >= previous.lastSeenAt ? next : previous;
637
+ records.set(next.workspaceKey, {
638
+ ...newer,
639
+ projectKey: next.projectKey,
640
+ available: previous.available || next.available,
641
+ sourceTargets: mergeTargets(previous.sourceTargets, next.sourceTargets),
642
+ firstSeenAt: previous.firstSeenAt <= next.firstSeenAt ? previous.firstSeenAt : next.firstSeenAt,
643
+ lastSeenAt: previous.lastSeenAt >= next.lastSeenAt ? previous.lastSeenAt : next.lastSeenAt,
644
+ });
645
+ }
646
+
243
647
  async function findNearestGitRoot(cwd: string): Promise<string | null> {
244
648
  let current = cwd;
245
649
  for (;;) {
@@ -273,6 +677,24 @@ async function readRegisteredProject(path: string): Promise<RegisteredProjectRec
273
677
  }
274
678
  }
275
679
 
680
+ async function readProjectWorkspace(path: string): Promise<ProjectWorkspaceRecordV1 | null> {
681
+ try {
682
+ return parseProjectWorkspace(JSON.parse(await readFile(path, "utf8")) as unknown);
683
+ } catch (error) {
684
+ if (isNotFoundError(error) || error instanceof ProjectRegistrationError) return null;
685
+ throw error;
686
+ }
687
+ }
688
+
689
+ async function readProjectAlias(path: string): Promise<ProjectAliasRecordV1 | null> {
690
+ try {
691
+ return parseProjectAlias(JSON.parse(await readFile(path, "utf8")) as unknown);
692
+ } catch (error) {
693
+ if (isNotFoundError(error) || error instanceof ProjectRegistrationError) return null;
694
+ throw error;
695
+ }
696
+ }
697
+
276
698
  function parseDiscoveredProject(value: unknown): DiscoveredProjectRecordV1 {
277
699
  const record = requireRecord(value);
278
700
  if (
@@ -314,6 +736,47 @@ function parseRegisteredProject(value: unknown): RegisteredProjectRecordV1 {
314
736
  return record as unknown as RegisteredProjectRecordV1;
315
737
  }
316
738
 
739
+ function parseProjectWorkspace(value: unknown): ProjectWorkspaceRecordV1 {
740
+ const record = requireRecord(value);
741
+ if (
742
+ record.schemaVersion !== 1 ||
743
+ record.kind !== "project-workspace" ||
744
+ !isProjectKey(record.workspaceKey) ||
745
+ !isProjectKey(record.projectKey) ||
746
+ !isNonEmptyString(record.displayName) ||
747
+ !isAbsoluteString(record.workspaceRoot) ||
748
+ !isWorkspaceKind(record.workspaceKind) ||
749
+ !(record.gitCommonDir === null || isAbsoluteString(record.gitCommonDir)) ||
750
+ !isDiscoveryTargetArray(record.sourceTargets) ||
751
+ !isNonEmptyString(record.firstSeenAt) ||
752
+ !isNonEmptyString(record.lastSeenAt) ||
753
+ record.localOnly !== true ||
754
+ record.sourceContentStored !== false
755
+ ) {
756
+ throw new ProjectRegistrationError("invalid", "Project workspace record is invalid.");
757
+ }
758
+ return record as unknown as ProjectWorkspaceRecordV1;
759
+ }
760
+
761
+ function parseProjectAlias(value: unknown): ProjectAliasRecordV1 {
762
+ const record = requireRecord(value);
763
+ if (
764
+ record.schemaVersion !== 1 ||
765
+ record.kind !== "project-alias" ||
766
+ !isProjectKey(record.aliasProjectKey) ||
767
+ !isProjectKey(record.canonicalProjectKey) ||
768
+ record.aliasProjectKey === record.canonicalProjectKey ||
769
+ !isAbsoluteString(record.workspaceRoot) ||
770
+ !isNonEmptyString(record.createdAt) ||
771
+ !isNonEmptyString(record.updatedAt) ||
772
+ record.localOnly !== true ||
773
+ record.sourceContentStored !== false
774
+ ) {
775
+ throw new ProjectRegistrationError("invalid", "Project alias record is invalid.");
776
+ }
777
+ return record as unknown as ProjectAliasRecordV1;
778
+ }
779
+
317
780
  function mergeDiscoveredRecord(
318
781
  records: Map<string, DiscoveredProjectRecordV1>,
319
782
  next: DiscoveredProjectRecordV1,
@@ -332,6 +795,24 @@ function mergeDiscoveredRecord(
332
795
  });
333
796
  }
334
797
 
798
+ function mergeRegisteredRecord(
799
+ records: Map<string, RegisteredProjectRecordV1>,
800
+ next: RegisteredProjectRecordV1,
801
+ ): void {
802
+ const previous = records.get(next.projectKey);
803
+ if (previous === undefined) {
804
+ records.set(next.projectKey, next);
805
+ return;
806
+ }
807
+ const newer = next.lastSeenAt >= previous.lastSeenAt ? next : previous;
808
+ records.set(next.projectKey, {
809
+ ...newer,
810
+ registeredAt:
811
+ previous.registeredAt <= next.registeredAt ? previous.registeredAt : next.registeredAt,
812
+ lastSeenAt: previous.lastSeenAt >= next.lastSeenAt ? previous.lastSeenAt : next.lastSeenAt,
813
+ });
814
+ }
815
+
335
816
  function mergeTargets(
336
817
  left: ProjectDiscoveryTarget[],
337
818
  right: ProjectDiscoveryTarget[],