@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.
- package/AGENTS.md +30 -0
- package/README.md +16 -2
- package/dist/agents.js +11 -0
- package/dist/cli-adapter.js +8 -0
- package/dist/component.js +65 -0
- package/dist/config-schema.js +265 -0
- package/dist/execution/activity.js +78 -0
- package/dist/execution/content.js +184 -0
- package/dist/execution/json-sections.js +231 -0
- package/dist/execution/presentation.js +79 -0
- package/dist/execution/preview.js +13 -0
- package/dist/execution/process.js +125 -0
- package/dist/execution/projection.js +327 -0
- package/dist/execution/query.js +18 -0
- package/dist/execution/routes.js +189 -0
- package/dist/execution/schema.js +322 -0
- package/dist/execution/service.js +1993 -0
- package/dist/features.js +22 -0
- package/dist/http.js +65 -0
- package/dist/performance/analysis.js +330 -0
- package/dist/performance/catalog.js +391 -0
- package/dist/performance/dashboard.js +483 -0
- package/dist/performance/error.js +15 -0
- package/dist/performance/index.js +6 -0
- package/dist/performance/inflight.js +43 -0
- package/dist/performance/projection.js +138 -0
- package/dist/performance/prompt/base.txt +38 -0
- package/dist/performance/prompt/builder.js +21 -0
- package/dist/performance/routes/performance-route.js +439 -0
- package/dist/performance/schema.js +628 -0
- package/dist/performance/timeline.js +98 -0
- package/dist/performance/trace-detail.js +91 -0
- package/dist/presentation/service.js +270 -0
- package/dist/presentation/tools/workbench-view.js +65 -0
- package/dist/presentation/tools.js +10 -0
- package/dist/project/directories.js +363 -0
- package/dist/project/git-health.js +498 -0
- package/dist/project/ide.js +66 -0
- package/dist/project/migration.js +22 -0
- package/dist/project/routes/directories.js +89 -0
- package/dist/project/routes/git.js +30 -0
- package/dist/project/routes/task-defaults.js +42 -0
- package/dist/project/session-health.js +9 -0
- package/dist/project/startup.js +28 -0
- package/dist/project/task-defaults.js +68 -0
- package/dist/project/tools/session-control.js +664 -0
- package/dist/project/tools/session-control.txt +60 -0
- package/dist/project/tools/worktree-archive.js +76 -0
- package/dist/project/tools/worktree-enter.js +175 -0
- package/dist/project/tools/worktree-leave.js +119 -0
- package/dist/project/tools/worktree-list.js +52 -0
- package/dist/project/tools.js +26 -0
- package/dist/project/vcs-branch-watcher.js +76 -0
- package/dist/project/vcs.js +114 -0
- package/dist/project/worktree-janitor.js +140 -0
- package/dist/project/worktrees.js +98 -0
- package/dist/push/bridge.js +171 -0
- package/dist/push/index.js +4 -0
- package/dist/push/routes/push.js +224 -0
- package/dist/push/service.js +93 -0
- package/dist/push/store.js +121 -0
- package/dist/push/types.js +58 -0
- package/dist/review/git.js +226 -0
- package/dist/review/routes.js +45 -0
- package/dist/review/schema.js +32 -0
- package/dist/review/state.js +79 -0
- package/dist/stats/aggregator.js +252 -0
- package/dist/stats/canonical.js +89 -0
- package/dist/stats/cli/stats.js +302 -0
- package/dist/stats/engine.js +176 -0
- package/dist/stats/index.js +5 -0
- package/dist/stats/rollup.js +460 -0
- package/dist/stats/routes/stats.js +94 -0
- package/dist/stats/routes/usage.js +83 -0
- package/dist/stats/storage.js +86 -0
- package/dist/stats/types.js +267 -0
- package/dist/worker.js +6 -0
- package/package.json +232 -4
- package/src/agents.ts +15 -0
- package/src/cli-adapter.ts +11 -0
- package/src/component.ts +73 -0
- package/src/config-schema.ts +289 -0
- package/src/execution/activity.ts +83 -0
- package/src/execution/content.ts +189 -0
- package/src/execution/json-sections.ts +230 -0
- package/src/execution/presentation.ts +91 -0
- package/src/execution/preview.ts +12 -0
- package/src/execution/process.ts +118 -0
- package/src/execution/projection.ts +330 -0
- package/src/execution/query.ts +19 -0
- package/src/execution/routes.ts +324 -0
- package/src/execution/schema.ts +336 -0
- package/src/execution/service.ts +2149 -0
- package/src/features.ts +22 -0
- package/src/http.ts +75 -0
- package/src/performance/analysis.ts +356 -0
- package/src/performance/catalog.ts +902 -0
- package/src/performance/dashboard.ts +521 -0
- package/src/performance/error.ts +32 -0
- package/src/performance/index.ts +6 -0
- package/src/performance/inflight.ts +46 -0
- package/src/performance/projection.ts +143 -0
- package/src/performance/prompt/base.txt +38 -0
- package/src/performance/prompt/builder.ts +24 -0
- package/src/performance/routes/performance-route.ts +541 -0
- package/src/performance/schema.ts +695 -0
- package/src/performance/timeline.ts +100 -0
- package/src/performance/trace-detail.ts +97 -0
- package/src/presentation/service.ts +303 -0
- package/src/presentation/tools/workbench-view.ts +75 -0
- package/src/presentation/tools.ts +11 -0
- package/src/project/directories.ts +391 -0
- package/src/project/git-health.ts +577 -0
- package/src/project/ide.ts +80 -0
- package/src/project/migration.ts +24 -0
- package/src/project/routes/directories.ts +124 -0
- package/src/project/routes/git.ts +42 -0
- package/src/project/routes/task-defaults.ts +50 -0
- package/src/project/session-health.ts +10 -0
- package/src/project/startup.ts +28 -0
- package/src/project/task-defaults.ts +70 -0
- package/src/project/tools/session-control.ts +725 -0
- package/src/project/tools/session-control.txt +60 -0
- package/src/project/tools/worktree-archive.ts +92 -0
- package/src/project/tools/worktree-enter.ts +227 -0
- package/src/project/tools/worktree-leave.ts +140 -0
- package/src/project/tools/worktree-list.ts +68 -0
- package/src/project/tools.ts +29 -0
- package/src/project/vcs-branch-watcher.ts +81 -0
- package/src/project/vcs.ts +124 -0
- package/src/project/worktree-janitor.ts +153 -0
- package/src/project/worktrees.ts +110 -0
- package/src/push/bridge.ts +182 -0
- package/src/push/index.ts +4 -0
- package/src/push/routes/push.ts +267 -0
- package/src/push/service.ts +108 -0
- package/src/push/store.ts +120 -0
- package/src/push/types.ts +67 -0
- package/src/review/git.ts +221 -0
- package/src/review/routes.ts +65 -0
- package/src/review/schema.ts +34 -0
- package/src/review/state.ts +77 -0
- package/src/stats/aggregator.ts +277 -0
- package/src/stats/canonical.ts +93 -0
- package/src/stats/cli/stats.ts +348 -0
- package/src/stats/engine.ts +197 -0
- package/src/stats/index.ts +5 -0
- package/src/stats/rollup.ts +522 -0
- package/src/stats/routes/stats.ts +107 -0
- package/src/stats/routes/usage.ts +103 -0
- package/src/stats/storage.ts +78 -0
- package/src/stats/types.ts +351 -0
- 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
|
+
}
|