@ericsanchezok/synergy-workbench 0.0.0-stage → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/AGENTS.md +30 -0
  2. package/README.md +16 -2
  3. package/dist/agents.js +11 -0
  4. package/dist/cli-adapter.js +8 -0
  5. package/dist/component.js +65 -0
  6. package/dist/config-schema.js +265 -0
  7. package/dist/execution/activity.js +78 -0
  8. package/dist/execution/content.js +184 -0
  9. package/dist/execution/json-sections.js +231 -0
  10. package/dist/execution/presentation.js +79 -0
  11. package/dist/execution/preview.js +13 -0
  12. package/dist/execution/process.js +125 -0
  13. package/dist/execution/projection.js +327 -0
  14. package/dist/execution/query.js +18 -0
  15. package/dist/execution/routes.js +189 -0
  16. package/dist/execution/schema.js +322 -0
  17. package/dist/execution/service.js +1993 -0
  18. package/dist/features.js +22 -0
  19. package/dist/http.js +65 -0
  20. package/dist/performance/analysis.js +330 -0
  21. package/dist/performance/catalog.js +391 -0
  22. package/dist/performance/dashboard.js +483 -0
  23. package/dist/performance/error.js +15 -0
  24. package/dist/performance/index.js +6 -0
  25. package/dist/performance/inflight.js +43 -0
  26. package/dist/performance/projection.js +138 -0
  27. package/dist/performance/prompt/base.txt +38 -0
  28. package/dist/performance/prompt/builder.js +21 -0
  29. package/dist/performance/routes/performance-route.js +439 -0
  30. package/dist/performance/schema.js +628 -0
  31. package/dist/performance/timeline.js +98 -0
  32. package/dist/performance/trace-detail.js +91 -0
  33. package/dist/presentation/service.js +270 -0
  34. package/dist/presentation/tools/workbench-view.js +65 -0
  35. package/dist/presentation/tools.js +10 -0
  36. package/dist/project/directories.js +363 -0
  37. package/dist/project/git-health.js +498 -0
  38. package/dist/project/ide.js +66 -0
  39. package/dist/project/migration.js +22 -0
  40. package/dist/project/routes/directories.js +89 -0
  41. package/dist/project/routes/git.js +30 -0
  42. package/dist/project/routes/task-defaults.js +42 -0
  43. package/dist/project/session-health.js +9 -0
  44. package/dist/project/startup.js +28 -0
  45. package/dist/project/task-defaults.js +68 -0
  46. package/dist/project/tools/session-control.js +664 -0
  47. package/dist/project/tools/session-control.txt +60 -0
  48. package/dist/project/tools/worktree-archive.js +76 -0
  49. package/dist/project/tools/worktree-enter.js +175 -0
  50. package/dist/project/tools/worktree-leave.js +119 -0
  51. package/dist/project/tools/worktree-list.js +52 -0
  52. package/dist/project/tools.js +26 -0
  53. package/dist/project/vcs-branch-watcher.js +76 -0
  54. package/dist/project/vcs.js +114 -0
  55. package/dist/project/worktree-janitor.js +140 -0
  56. package/dist/project/worktrees.js +98 -0
  57. package/dist/push/bridge.js +171 -0
  58. package/dist/push/index.js +4 -0
  59. package/dist/push/routes/push.js +224 -0
  60. package/dist/push/service.js +93 -0
  61. package/dist/push/store.js +121 -0
  62. package/dist/push/types.js +58 -0
  63. package/dist/review/git.js +226 -0
  64. package/dist/review/routes.js +45 -0
  65. package/dist/review/schema.js +32 -0
  66. package/dist/review/state.js +79 -0
  67. package/dist/stats/aggregator.js +252 -0
  68. package/dist/stats/canonical.js +89 -0
  69. package/dist/stats/cli/stats.js +302 -0
  70. package/dist/stats/engine.js +176 -0
  71. package/dist/stats/index.js +5 -0
  72. package/dist/stats/rollup.js +460 -0
  73. package/dist/stats/routes/stats.js +94 -0
  74. package/dist/stats/routes/usage.js +83 -0
  75. package/dist/stats/storage.js +86 -0
  76. package/dist/stats/types.js +267 -0
  77. package/dist/worker.js +6 -0
  78. package/package.json +232 -4
  79. package/src/agents.ts +15 -0
  80. package/src/cli-adapter.ts +11 -0
  81. package/src/component.ts +73 -0
  82. package/src/config-schema.ts +289 -0
  83. package/src/execution/activity.ts +83 -0
  84. package/src/execution/content.ts +189 -0
  85. package/src/execution/json-sections.ts +230 -0
  86. package/src/execution/presentation.ts +91 -0
  87. package/src/execution/preview.ts +12 -0
  88. package/src/execution/process.ts +118 -0
  89. package/src/execution/projection.ts +330 -0
  90. package/src/execution/query.ts +19 -0
  91. package/src/execution/routes.ts +324 -0
  92. package/src/execution/schema.ts +336 -0
  93. package/src/execution/service.ts +2149 -0
  94. package/src/features.ts +22 -0
  95. package/src/http.ts +75 -0
  96. package/src/performance/analysis.ts +356 -0
  97. package/src/performance/catalog.ts +902 -0
  98. package/src/performance/dashboard.ts +521 -0
  99. package/src/performance/error.ts +32 -0
  100. package/src/performance/index.ts +6 -0
  101. package/src/performance/inflight.ts +46 -0
  102. package/src/performance/projection.ts +143 -0
  103. package/src/performance/prompt/base.txt +38 -0
  104. package/src/performance/prompt/builder.ts +24 -0
  105. package/src/performance/routes/performance-route.ts +541 -0
  106. package/src/performance/schema.ts +695 -0
  107. package/src/performance/timeline.ts +100 -0
  108. package/src/performance/trace-detail.ts +97 -0
  109. package/src/presentation/service.ts +303 -0
  110. package/src/presentation/tools/workbench-view.ts +75 -0
  111. package/src/presentation/tools.ts +11 -0
  112. package/src/project/directories.ts +391 -0
  113. package/src/project/git-health.ts +577 -0
  114. package/src/project/ide.ts +80 -0
  115. package/src/project/migration.ts +24 -0
  116. package/src/project/routes/directories.ts +124 -0
  117. package/src/project/routes/git.ts +42 -0
  118. package/src/project/routes/task-defaults.ts +50 -0
  119. package/src/project/session-health.ts +10 -0
  120. package/src/project/startup.ts +28 -0
  121. package/src/project/task-defaults.ts +70 -0
  122. package/src/project/tools/session-control.ts +725 -0
  123. package/src/project/tools/session-control.txt +60 -0
  124. package/src/project/tools/worktree-archive.ts +92 -0
  125. package/src/project/tools/worktree-enter.ts +227 -0
  126. package/src/project/tools/worktree-leave.ts +140 -0
  127. package/src/project/tools/worktree-list.ts +68 -0
  128. package/src/project/tools.ts +29 -0
  129. package/src/project/vcs-branch-watcher.ts +81 -0
  130. package/src/project/vcs.ts +124 -0
  131. package/src/project/worktree-janitor.ts +153 -0
  132. package/src/project/worktrees.ts +110 -0
  133. package/src/push/bridge.ts +182 -0
  134. package/src/push/index.ts +4 -0
  135. package/src/push/routes/push.ts +267 -0
  136. package/src/push/service.ts +108 -0
  137. package/src/push/store.ts +120 -0
  138. package/src/push/types.ts +67 -0
  139. package/src/review/git.ts +221 -0
  140. package/src/review/routes.ts +65 -0
  141. package/src/review/schema.ts +34 -0
  142. package/src/review/state.ts +77 -0
  143. package/src/stats/aggregator.ts +277 -0
  144. package/src/stats/canonical.ts +93 -0
  145. package/src/stats/cli/stats.ts +348 -0
  146. package/src/stats/engine.ts +197 -0
  147. package/src/stats/index.ts +5 -0
  148. package/src/stats/rollup.ts +522 -0
  149. package/src/stats/routes/stats.ts +107 -0
  150. package/src/stats/routes/usage.ts +103 -0
  151. package/src/stats/storage.ts +78 -0
  152. package/src/stats/types.ts +351 -0
  153. package/src/worker.ts +7 -0
@@ -0,0 +1,391 @@
1
+ import path from "node:path"
2
+ import fs from "node:fs/promises"
3
+ import { createHash } from "node:crypto"
4
+ import { $ } from "bun"
5
+ import { z } from "zod"
6
+ import { NamedError } from "@ericsanchezok/synergy-util/error"
7
+ import { Config } from "@ericsanchezok/synergy-harness/config/config"
8
+ import { Scope } from "@ericsanchezok/synergy-harness/scope"
9
+ import { Storage } from "@ericsanchezok/synergy-harness/storage/storage"
10
+ import { StoragePath } from "@ericsanchezok/synergy-harness/storage/path"
11
+ import { WorkspaceCatalog, WorkspaceBinding, WorkspaceLocation } from "@ericsanchezok/synergy-harness/workspace"
12
+ import { WorkspaceAccess } from "@ericsanchezok/synergy-harness/workspace/access"
13
+
14
+ export namespace ProjectDirectories {
15
+ export const Record = z
16
+ .object({
17
+ version: z.literal(1),
18
+ scopeID: z.string(),
19
+ revision: z.number().int().nonnegative(),
20
+ mainWorkspaceID: z.string().nullable(),
21
+ additionalWorkspaceIDs: z.array(z.string()),
22
+ })
23
+ .meta({ ref: "ProjectDirectoryConfiguration" })
24
+ export type Record = z.infer<typeof Record>
25
+ export const Folder = z
26
+ .object({
27
+ workspaceID: z.string(),
28
+ generation: z.number().int(),
29
+ path: z.string(),
30
+ available: z.boolean(),
31
+ git: z.boolean(),
32
+ unavailable: WorkspaceCatalog.Unavailable.Schema.optional(),
33
+ })
34
+ .meta({ ref: "ProjectFolder" })
35
+ export const Result = Record.extend({ folders: z.array(Folder) }).meta({ ref: "ProjectDirectories" })
36
+ export type Result = z.infer<typeof Result>
37
+ const Selection = z.object({
38
+ directories: z.array(z.string().min(1)).min(1).max(32),
39
+ mainDirectory: z.string().min(1),
40
+ })
41
+ export const CreateInput = Selection.extend({ name: z.string().trim().min(1).max(120) }).meta({
42
+ ref: "ProjectCreateInput",
43
+ })
44
+ export const UpdateInput = Selection.extend({ revision: z.number().int().nonnegative() }).meta({
45
+ ref: "ProjectDirectoriesUpdate",
46
+ })
47
+ export const Created = z
48
+ .object({ scope: Scope.Info, directories: Result, existing: z.boolean() })
49
+ .meta({ ref: "ProjectCreated" })
50
+ export const Conflict = NamedError.create("ProjectDirectoriesConflict", z.object({ message: z.string() }))
51
+ export const Invalid = NamedError.create("ProjectDirectoriesInvalid", z.object({ message: z.string() }))
52
+
53
+ export async function isGit(directory: string) {
54
+ try {
55
+ const result = await $`git rev-parse --show-toplevel`.cwd(directory).quiet().nothrow()
56
+ return result.exitCode === 0 && path.resolve(result.text().trim()) === directory
57
+ } catch {
58
+ return false
59
+ }
60
+ }
61
+
62
+ async function validate(input: z.infer<typeof Selection>) {
63
+ const source = WorkspaceLocation.source()
64
+ const locations = await Promise.all(
65
+ input.directories.map(async (directory) => {
66
+ if (!path.isAbsolute(directory)) throw new Invalid({ message: "Choose an absolute folder path." })
67
+ try {
68
+ const location = await source.identify(directory)
69
+ if (!(await fs.stat(location.path)).isDirectory()) throw new Invalid({ message: "Choose a folder." })
70
+ return location
71
+ } catch (error) {
72
+ if (error instanceof Invalid) throw error
73
+ throw new Invalid({ message: `Cannot access folder: ${directory}. Check that it exists and is readable.` })
74
+ }
75
+ }),
76
+ )
77
+ const unique = [...new Map(locations.map((item) => [item.path, item])).values()]
78
+ if (!path.isAbsolute(input.mainDirectory)) throw new Invalid({ message: "Choose an absolute main folder path." })
79
+ const main = await source.identify(input.mainDirectory).catch(() => {
80
+ throw new Invalid({ message: "The main folder is unavailable. Choose a project folder again." })
81
+ })
82
+ if (!unique.some((item) => item.path === main.path))
83
+ throw new Invalid({ message: "The main folder must be in the project." })
84
+ return { locations: unique, main: main.path, hostID: await source.hostID() }
85
+ }
86
+
87
+ export async function create(input: z.input<typeof CreateInput>): Promise<z.infer<typeof Created>> {
88
+ const parsed = CreateInput.parse(input)
89
+ const selection = await validate(parsed)
90
+ const owner = await findMainOwner(selection.main)
91
+ if (owner) return { scope: owner, directories: await get(owner.id), existing: true }
92
+ const discovered = (await Scope.fromDirectory(selection.main, { persist: false })).scope
93
+ if (discovered.type !== "project") throw new Invalid({ message: "A project folder is required." })
94
+ const existing = await Scope.fromID(discovered.id)
95
+ if (existing?.type === "project") return { scope: existing, directories: await get(existing.id), existing: true }
96
+ const created = await WorkspaceAccess.exclusive(
97
+ selection.locations.map((item) => item.path),
98
+ async () => {
99
+ await revalidate(selection)
100
+ const mainOwner = await findMainOwner(selection.main)
101
+ if (mainOwner)
102
+ throw new Conflict({ message: "This folder was just assigned to another project. Open that project." })
103
+ const concurrent = await Scope.fromID(discovered.id)
104
+ if (concurrent?.type === "project")
105
+ throw new Conflict({ message: "This project was just created. Open the existing project." })
106
+ await Config.domainUpdate(
107
+ "general",
108
+ { defaultSessionWorkspace: "main" },
109
+ { root: path.join(discovered.local!.directory, ".synergy") },
110
+ )
111
+ return Storage.transaction(async () => {
112
+ const mainOwner = await findMainOwner(selection.main)
113
+ if (mainOwner)
114
+ throw new Conflict({ message: "This folder was just assigned to another project. Open that project." })
115
+ const concurrent = await Scope.fromID(discovered.id)
116
+ if (concurrent?.type === "project")
117
+ throw new Conflict({ message: "This project was just created. Open the existing project." })
118
+ const scope = await Scope.registerProject({ ...discovered, name: parsed.name })
119
+ const record = await save(scope.id, selection, 0)
120
+ return { scope, record }
121
+ })
122
+ },
123
+ )
124
+ return { scope: created.scope, directories: await describe(created.record), existing: false }
125
+ }
126
+
127
+ async function findMainOwner(directory: string) {
128
+ const scopes = await Scope.list()
129
+ const configs = await Storage.readMany<Record>(scopes.map((scope) => StoragePath.projectDirectories(scope.id)))
130
+ for (const [index, config] of configs.entries()) {
131
+ if (!config?.mainWorkspaceID) continue
132
+ const [workspace] = await WorkspaceCatalog.readMany([config.mainWorkspaceID])
133
+ if (workspace?.binding.path === directory) return scopes[index]
134
+ }
135
+ }
136
+
137
+ export async function get(scopeID: string): Promise<Result> {
138
+ const [stored] = await Storage.readMany<unknown>([StoragePath.projectDirectories(scopeID)])
139
+ const record = stored ? Record.parse(stored) : await migrateProject(await Scope.resolve({ scopeID }))
140
+ return describe(record)
141
+ }
142
+
143
+ export async function update(scopeID: string, input: z.input<typeof UpdateInput>): Promise<Result> {
144
+ const parsed = UpdateInput.parse(input)
145
+ const current = await get(scopeID)
146
+ if (current.revision !== parsed.revision)
147
+ throw new Conflict({ message: "Project folders changed. Reload before saving." })
148
+ const selection = await validate(parsed)
149
+ const all = await WorkspaceCatalog.list(scopeID)
150
+ const currentIDs = new Set(current.folders.map((item) => item.workspaceID))
151
+ const roots = [
152
+ ...new Set([
153
+ ...selection.locations.map((item) => item.path),
154
+ ...all
155
+ .filter(
156
+ (item) =>
157
+ isActiveOnHost(item, selection.hostID) && (item.type === "git_worktree" || currentIDs.has(item.id)),
158
+ )
159
+ .flatMap((item) => (item.binding.path ? [item.binding.path] : [])),
160
+ ]),
161
+ ]
162
+ const saved = await WorkspaceAccess.exclusive(roots, async () => {
163
+ await revalidate(selection)
164
+ return Storage.transaction(async () => {
165
+ const [latest] = await Storage.readMany<Record>([StoragePath.projectDirectories(scopeID)])
166
+ if (latest?.revision !== parsed.revision)
167
+ throw new Conflict({ message: "Project folders changed. Reload before saving." })
168
+ const record = await save(scopeID, selection, parsed.revision, current)
169
+ return record
170
+ })
171
+ })
172
+ return describe(saved)
173
+ }
174
+
175
+ async function revalidate(selection: Awaited<ReturnType<typeof validate>>) {
176
+ for (const location of selection.locations) {
177
+ const current = await WorkspaceLocation.source().identify(location.path)
178
+ if (current.path !== location.path || current.physicalID !== location.physicalID)
179
+ throw new Conflict({ message: "A selected folder changed. Choose it again before saving." })
180
+ }
181
+ }
182
+
183
+ function isActiveOnHost(item: WorkspaceCatalog.Info, hostID: string) {
184
+ return item.lifecycle === "active" && item.binding.state === "bound" && item.binding.hostID === hostID
185
+ }
186
+
187
+ async function save(
188
+ scopeID: string,
189
+ selection: Awaited<ReturnType<typeof validate>>,
190
+ revision: number,
191
+ previous?: Result,
192
+ ) {
193
+ const folders = await Promise.all(
194
+ selection.locations.map((location) =>
195
+ WorkspaceCatalog.register({
196
+ scopeID,
197
+ type: "directory",
198
+ hostID: selection.hostID,
199
+ ...location,
200
+ }),
201
+ ),
202
+ )
203
+ const main = folders.find((item) => item.binding.path === selection.main)!
204
+ const record: Record = {
205
+ version: 1,
206
+ scopeID,
207
+ revision: revision + 1,
208
+ mainWorkspaceID: main.id,
209
+ additionalWorkspaceIDs: folders.filter((item) => item.id !== main.id).map((item) => item.id),
210
+ }
211
+ const oldIDs = new Set(previous?.folders.map((item) => item.workspaceID) ?? [])
212
+ const all = await WorkspaceCatalog.list(scopeID)
213
+ const selected = new Set(folders.map((item) => item.id))
214
+ for (const item of all) {
215
+ if (!isActiveOnHost(item, selection.hostID)) continue
216
+ const origin = typeof item.metadata.originalCheckout === "string" ? item.metadata.originalCheckout : undefined
217
+ if (!selected.has(item.id) && !oldIDs.has(item.id) && !(item.type === "git_worktree" && origin)) continue
218
+ const grants =
219
+ item.type === "git_worktree"
220
+ ? folders
221
+ .filter((folder) => folder.binding.path !== origin && folder.id !== item.id)
222
+ .map((folder) => folder.id)
223
+ : selected.has(item.id)
224
+ ? folders.filter((folder) => folder.id !== item.id).map((folder) => folder.id)
225
+ : []
226
+ if (JSON.stringify(item.sharedWritableWorkspaceIDs) === JSON.stringify(grants)) continue
227
+ const changed = await WorkspaceCatalog.setSharing(item.id, {
228
+ scopeID,
229
+ expectedRevision: item.revision,
230
+ workspaceIDs: grants,
231
+ })
232
+ Storage.afterCommit(() => WorkspaceCatalog.publishUpdated(changed))
233
+ }
234
+ for (const folder of await WorkspaceCatalog.readMany(folders.map((item) => item.id)))
235
+ if (folder) Storage.afterCommit(() => WorkspaceCatalog.publishUpdated(folder))
236
+ await Storage.write(StoragePath.projectDirectories(scopeID), record)
237
+ return record
238
+ }
239
+
240
+ async function describe(record: Record): Promise<Result> {
241
+ const ids = [...(record.mainWorkspaceID ? [record.mainWorkspaceID] : []), ...record.additionalWorkspaceIDs]
242
+ const records = await WorkspaceCatalog.readMany(ids)
243
+ const folders = await Promise.all(
244
+ records.map(async (item, index) => {
245
+ if (!item)
246
+ return {
247
+ workspaceID: ids[index]!,
248
+ generation: 1,
249
+ path: "",
250
+ available: false,
251
+ git: false,
252
+ unavailable: new WorkspaceCatalog.Unavailable({
253
+ workspaceID: ids[index]!,
254
+ message: "The project folder binding is unavailable",
255
+ reason: "binding_unavailable",
256
+ }).toObject(),
257
+ }
258
+ const directory = item.binding.path ?? ""
259
+ let unavailable: z.infer<typeof WorkspaceCatalog.Unavailable.Schema> | undefined
260
+ const available = await WorkspaceBinding.validate(item.id, record.scopeID).then(
261
+ () => true,
262
+ (error: unknown) => {
263
+ const parsed = WorkspaceCatalog.Unavailable.Schema.safeParse(error)
264
+ if (!parsed.success) throw error
265
+ unavailable = parsed.data
266
+ return false
267
+ },
268
+ )
269
+ return {
270
+ workspaceID: item.id,
271
+ generation: item.binding.generation,
272
+ path: directory,
273
+ available,
274
+ git: available && (await isGit(directory)),
275
+ ...(unavailable ? { unavailable } : {}),
276
+ }
277
+ }),
278
+ )
279
+ return { ...record, folders }
280
+ }
281
+
282
+ export async function migrateProject(scope: Scope): Promise<Record> {
283
+ const [existing] = await Storage.readMany<Record>([StoragePath.projectDirectories(scope.id)])
284
+ if (existing) return Record.parse(existing)
285
+ const hostID = await WorkspaceLocation.source().hostID()
286
+ const paths = scope.local ? [...new Set([scope.local.worktree, ...scope.local.sandboxes])] : []
287
+ const worktrees = new Set<string>()
288
+ if (scope.local) {
289
+ try {
290
+ const git = await $`git worktree list --porcelain -z`.cwd(scope.local.worktree).quiet().nothrow()
291
+ if (git.exitCode === 0)
292
+ for (const line of git.text().split("\0"))
293
+ if (line.startsWith("worktree ")) worktrees.add(path.resolve(line.slice(9)))
294
+ } catch {}
295
+ }
296
+ if (scope.local) worktrees.delete(path.resolve(scope.local.worktree))
297
+ const catalog = await WorkspaceCatalog.list(scope.id)
298
+ const retainedTrees = new Set(
299
+ catalog.flatMap((record) => (record.type === "git_worktree" && record.binding.path ? [record.binding.path] : [])),
300
+ )
301
+ for (const directory of retainedTrees) worktrees.add(directory)
302
+ const locate = (directory: string) => {
303
+ const requested = path.resolve(directory)
304
+ const retained =
305
+ catalog.find(
306
+ (record) =>
307
+ record.binding.path === requested && record.binding.hostID === hostID && record.binding.state === "bound",
308
+ ) ?? catalog.find((record) => record.type === "git_worktree" && record.binding.path === requested)
309
+ return retained
310
+ ? { path: requested, hostID: retained.binding.hostID }
311
+ : WorkspaceBinding.locateHistory(scope.id, directory)
312
+ }
313
+ const locations = await Promise.all(paths.map(locate))
314
+ if (locations[0]) worktrees.delete(locations[0].path)
315
+ const candidates = [
316
+ ...new Map(
317
+ locations.filter((location) => !worktrees.has(location.path)).map((location) => [location.path, location]),
318
+ ).values(),
319
+ ]
320
+ const historical = await Promise.all(
321
+ [...worktrees].filter((directory) => !retainedTrees.has(directory)).map(locate),
322
+ )
323
+ return Storage.transaction(async () => {
324
+ const [concurrent] = await Storage.readMany<Record>([StoragePath.projectDirectories(scope.id)])
325
+ if (concurrent) return Record.parse(concurrent)
326
+ const retain = async (input: WorkspaceCatalog.RegisterInput) => {
327
+ const existing = await WorkspaceCatalog.findByLocation(input)
328
+ if (existing) return existing
329
+ const retained = catalog.find((record) => record.type === "git_worktree" && record.binding.path === input.path)
330
+ return retained ? WorkspaceCatalog.get(retained.id, scope.id) : WorkspaceCatalog.register(input)
331
+ }
332
+ const records = await Promise.all(
333
+ candidates.map((location) => retain({ scopeID: scope.id, type: "directory", ...location })),
334
+ )
335
+ for (const location of historical) {
336
+ await retain({
337
+ scopeID: scope.id,
338
+ type: "git_worktree",
339
+ ...location,
340
+ metadata: {
341
+ originalCheckout: scope.local?.worktree,
342
+ sourceWorkspaceID: records[0]?.id,
343
+ worktreeID: "wt_" + createHash("sha256").update(location.path).digest("hex").slice(0, 16),
344
+ name: path.basename(location.path),
345
+ },
346
+ })
347
+ }
348
+ const byID = new Map((await WorkspaceCatalog.list(scope.id)).map((record) => [record.id, record]))
349
+ const migratedTrees = [...byID.values()].filter((item) => item.type === "git_worktree")
350
+ const folders = new Map([...records, ...migratedTrees].map((record) => [record.id, record]))
351
+ for (const folder of folders.values()) {
352
+ if (folder.lifecycle !== "active" || folder.binding.state !== "bound") continue
353
+ const canShare = (other: WorkspaceCatalog.Info) =>
354
+ other.lifecycle === "active" &&
355
+ other.binding.state === "bound" &&
356
+ other.binding.hostID === folder.binding.hostID
357
+ const grants = records
358
+ .filter(
359
+ (other) =>
360
+ other.id !== folder.id && other.binding.path !== folder.metadata.originalCheckout && canShare(other),
361
+ )
362
+ .map((other) => other.id)
363
+ const retainedGrants = folder.sharedWritableWorkspaceIDs.filter((id) => {
364
+ const target = byID.get(id)
365
+ return !target || canShare(target)
366
+ })
367
+ const workspaceIDs = [...new Set([...retainedGrants, ...grants])]
368
+ if (
369
+ workspaceIDs.length === folder.sharedWritableWorkspaceIDs.length &&
370
+ workspaceIDs.every((id, index) => id === folder.sharedWritableWorkspaceIDs[index])
371
+ )
372
+ continue
373
+ const updated = await WorkspaceCatalog.setSharing(folder.id, {
374
+ scopeID: scope.id,
375
+ expectedRevision: folder.revision,
376
+ workspaceIDs,
377
+ })
378
+ Storage.afterCommit(() => WorkspaceCatalog.publishUpdated(updated))
379
+ }
380
+ const record: Record = {
381
+ version: 1,
382
+ scopeID: scope.id,
383
+ revision: 1,
384
+ mainWorkspaceID: records[0]?.id ?? null,
385
+ additionalWorkspaceIDs: records.slice(1).map((item) => item.id),
386
+ }
387
+ await Storage.write(StoragePath.projectDirectories(scope.id), record)
388
+ return record
389
+ })
390
+ }
391
+ }