dsh-managed-workspaces 0.0.0-stage → 1.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.
package/store.js ADDED
@@ -0,0 +1,1085 @@
1
+ // dsh-managed-workspaces: state + filesystem core.
2
+ //
3
+ // A managed workspace is a directory DSH creates under a configurable base
4
+ // directory. It is registered in the harness workspace registry (the registry
5
+ // itself lives in the host plugin), and additional folders are associated with
6
+ // it as directory links under `<workspace>/_roots/<alias>`.
7
+ //
8
+ // This module has no framework imports: it is usable from the host plugin and
9
+ // from `node --test` directly.
10
+
11
+ import { execFile } from 'node:child_process'
12
+ import { randomUUID } from 'node:crypto'
13
+ import { cp, lstat, mkdir, readFile, readdir, readlink, realpath, rename, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises'
14
+ import { homedir } from 'node:os'
15
+ import { dirname, isAbsolute, join, basename, parse, resolve, sep } from 'node:path'
16
+ import { promisify } from 'node:util'
17
+
18
+ const IS_WIN = process.platform === 'win32'
19
+ const execFileAsync = promisify(execFile)
20
+ const MANIFEST_VERSION = 1
21
+ const LINKS_DIRECTORY = '_roots'
22
+ const MAX_SEGMENT = 64
23
+ const RESERVED_SEGMENTS = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i
24
+ const INVALID_SEGMENT_CHARS = /[<>:"/\\|?*\u0000-\u001f]/
25
+
26
+ /** An error carrying a stable code that routes and tools can report. */
27
+ export class ManagedWorkspaceError extends Error {
28
+ constructor(code, message, details) {
29
+ super(message)
30
+ this.name = 'ManagedWorkspaceError'
31
+ this.code = code
32
+ if (details !== undefined) this.details = details
33
+ }
34
+ }
35
+
36
+ function fail(code, message, details) {
37
+ throw new ManagedWorkspaceError(code, message, details)
38
+ }
39
+
40
+ /** Normalize one absolute-or-relative spelling into an absolute, separator-stable path. */
41
+ export function normalizePath(value) {
42
+ let out = resolve(value)
43
+ while (out.length > 1 && out.endsWith(sep)) out = out.slice(0, -1)
44
+ return out
45
+ }
46
+
47
+ /** Compare two path spellings the way the host filesystem does. */
48
+ export function samePath(left, right) {
49
+ const a = normalizePath(left)
50
+ const b = normalizePath(right)
51
+ return IS_WIN ? a.toLowerCase() === b.toLowerCase() : a === b
52
+ }
53
+
54
+ /** Whether `child` equals `parent` or lies beneath it (lexical, host case rules). */
55
+ export function isUnder(child, parent) {
56
+ const target = IS_WIN ? normalizePath(child).toLowerCase() : normalizePath(child)
57
+ const root = IS_WIN ? normalizePath(parent).toLowerCase() : normalizePath(parent)
58
+ if (target === root) return true
59
+ const prefix = root.endsWith(sep) ? root : root + sep
60
+ return target.startsWith(prefix)
61
+ }
62
+
63
+ /** Expand `~`, `~/…`, and `~\…` against the OS home directory. */
64
+ export function expandHomePath(value) {
65
+ if (value === '~') return homedir()
66
+ if (value.startsWith('~/') || value.startsWith('~\\')) return join(homedir(), value.slice(2))
67
+ return value
68
+ }
69
+
70
+ /** Validate one directory name used as a workspace folder or a link alias. */
71
+ export function validateSegment(value, field = 'name') {
72
+ if (typeof value !== 'string') fail('invalid-name', `${field} must be a string`)
73
+ const name = value.trim()
74
+ if (name.length === 0) fail('invalid-name', `${field} must not be blank`)
75
+ if (name.length > MAX_SEGMENT) fail('invalid-name', `${field} must be at most ${MAX_SEGMENT} characters`)
76
+ if (name === '.' || name === '..') fail('invalid-name', `${field} must not be "." or ".."`)
77
+ if (INVALID_SEGMENT_CHARS.test(name)) fail('invalid-name', `${field} contains a character that is not allowed in a folder name: ${JSON.stringify(value)}`)
78
+ if (/[. ]$/.test(name)) fail('invalid-name', `${field} must not end with a dot or a space`)
79
+ if (RESERVED_SEGMENTS.test(name)) fail('invalid-name', `${field} is a reserved Windows device name: ${JSON.stringify(name)}`)
80
+ return name
81
+ }
82
+
83
+ /** Derive a safe alias from a folder's final path segment. */
84
+ export function defaultAlias(target) {
85
+ const base = normalizePath(target).split(sep).filter(Boolean).pop() ?? 'folder'
86
+ const cleaned = base.replace(INVALID_SEGMENT_CHARS, '-').replace(/[. ]+$/g, '').trim().slice(0, MAX_SEGMENT)
87
+ return cleaned.length > 0 && cleaned !== '.' && cleaned !== '..' && !RESERVED_SEGMENTS.test(cleaned) ? cleaned : 'folder'
88
+ }
89
+
90
+ /** Pick the first alias not already used in the workspace. */
91
+ export function uniqueAlias(used, preferred) {
92
+ const taken = new Set(used)
93
+ if (!taken.has(preferred)) return preferred
94
+ for (let index = 2; index < 1000; index += 1) {
95
+ const candidate = `${preferred}-${index}`
96
+ if (!taken.has(candidate)) return candidate
97
+ }
98
+ return `${preferred}-${randomUUID().slice(0, 8)}`
99
+ }
100
+
101
+ /** Resolve the harness home used for the plugin manifest. */
102
+ export function resolveDshHome() {
103
+ const fromEnv = process.env.DSH_HOME?.trim()
104
+ return fromEnv && fromEnv.length > 0 ? normalizePath(fromEnv) : join(homedir(), '.dsh')
105
+ }
106
+
107
+ /** Default base directory for new managed workspaces. */
108
+ export function defaultBaseDirectory() {
109
+ return join(homedir(), 'DSH-Workspaces')
110
+ }
111
+
112
+ async function lstatOrNull(path) {
113
+ try {
114
+ return await lstat(path)
115
+ } catch (error) {
116
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null
117
+ throw error
118
+ }
119
+ }
120
+
121
+ async function statOrNull(path) {
122
+ try {
123
+ return await stat(path)
124
+ } catch (error) {
125
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null
126
+ throw error
127
+ }
128
+ }
129
+
130
+ async function isDirectory(path) {
131
+ const info = await statOrNull(path)
132
+ return info !== null && info.isDirectory()
133
+ }
134
+
135
+ async function exists(path) {
136
+ return (await lstatOrNull(path)) !== null
137
+ }
138
+
139
+ async function resolveLink(linkPath) {
140
+ const raw = await readlink(linkPath)
141
+ return normalizePath(isAbsolute(raw) ? raw : resolve(dirname(linkPath), raw))
142
+ }
143
+
144
+ function rejectUnsupportedTarget(target) {
145
+ if (IS_WIN && (target.startsWith('\\\\') || target.startsWith('//'))) {
146
+ fail('unsupported-target', `a network (UNC) folder cannot be linked as a junction: ${target}`)
147
+ }
148
+ }
149
+
150
+ async function createDirectoryLink(target, linkPath) {
151
+ if (IS_WIN) {
152
+ try {
153
+ await symlink(target, linkPath, 'junction')
154
+ return
155
+ } catch (error) {
156
+ if (error.code !== 'EPERM' && error.code !== 'UNKNOWN' && error.code !== 'EACCES') throw error
157
+ await execFileAsync('cmd.exe', ['/d', '/s', '/c', 'mklink', '/J', linkPath, target], { windowsHide: true })
158
+ return
159
+ }
160
+ }
161
+ await symlink(target, linkPath, 'dir')
162
+ }
163
+
164
+ /** Remove one directory link, refusing to touch a real directory or a link to another target. */
165
+ async function detachLinkNode(linkPath, expectedTarget) {
166
+ const node = await lstatOrNull(linkPath)
167
+ if (node === null) return 'missing'
168
+ if (!node.isSymbolicLink()) fail('conflict', `refusing to remove "${linkPath}": it is a real directory or file, not a link`)
169
+ const actual = await resolveLink(linkPath)
170
+ if (!samePath(actual, expectedTarget)) {
171
+ fail('conflict', `refusing to remove "${linkPath}": it points to ${actual}, not ${expectedTarget}`)
172
+ }
173
+ await unlink(linkPath)
174
+ return 'removed'
175
+ }
176
+
177
+ /** Ensure a directory link exists at `linkPath`, adopting an equivalent one and refusing conflicts. */
178
+ async function ensureLinkNode(linkPath, target) {
179
+ const node = await lstatOrNull(linkPath)
180
+ if (node === null) {
181
+ await createDirectoryLink(target, linkPath)
182
+ return
183
+ }
184
+ if (!node.isSymbolicLink()) fail('conflict', `path already exists and is not a link: ${linkPath}`)
185
+ const actual = await resolveLink(linkPath)
186
+ if (!samePath(actual, target)) fail('conflict', `link points to ${actual}, not ${target}`)
187
+ }
188
+
189
+ /** Count files, directories, links, and bytes below one directory for copy verification. */
190
+ async function treeSummary(root) {
191
+ let files = 0
192
+ let directories = 0
193
+ let symlinks = 0
194
+ let bytes = 0
195
+ const stack = [root]
196
+ while (stack.length > 0) {
197
+ const directory = stack.pop()
198
+ const entries = await readdir(directory, { withFileTypes: true })
199
+ for (const entry of entries) {
200
+ const full = join(directory, entry.name)
201
+ if (entry.isSymbolicLink()) {
202
+ symlinks += 1
203
+ } else if (entry.isDirectory()) {
204
+ directories += 1
205
+ stack.push(full)
206
+ } else {
207
+ files += 1
208
+ bytes += (await stat(full)).size
209
+ }
210
+ }
211
+ }
212
+ return { files, directories, symlinks, bytes }
213
+ }
214
+
215
+ /**
216
+ * Move a directory: rename when both paths share a volume, otherwise copy,
217
+ * verify the copy against the source, and only then remove the source.
218
+ * @param {string} source - existing directory.
219
+ * @param {string} destination - path that must not exist yet.
220
+ * @returns {Promise<'rename' | 'copy'>} which mechanism moved the tree.
221
+ */
222
+ async function moveDirectory(source, destination) {
223
+ try {
224
+ await rename(source, destination)
225
+ return 'rename'
226
+ } catch (error) {
227
+ if (error.code !== 'EXDEV') throw error
228
+ }
229
+ await cp(source, destination, {
230
+ recursive: true,
231
+ errorOnExist: true,
232
+ force: false,
233
+ preserveTimestamps: true,
234
+ })
235
+ const left = await treeSummary(source)
236
+ const right = await treeSummary(destination)
237
+ const matches =
238
+ left.files === right.files &&
239
+ left.directories === right.directories &&
240
+ left.symlinks === right.symlinks &&
241
+ left.bytes === right.bytes
242
+ if (!matches) {
243
+ await rm(destination, { recursive: true, force: true })
244
+ fail(
245
+ 'copy-mismatch',
246
+ `copied tree does not match the source (${JSON.stringify(left)} vs ${JSON.stringify(right)}); the source was kept`,
247
+ )
248
+ }
249
+ await rm(source, { recursive: true, force: true })
250
+ return 'copy'
251
+ }
252
+
253
+ function freshManifest() {
254
+ return {
255
+ schemaVersion: MANIFEST_VERSION,
256
+ baseDirectory: defaultBaseDirectory(),
257
+ linksDirectoryName: LINKS_DIRECTORY,
258
+ workspaces: [],
259
+ }
260
+ }
261
+
262
+ function normalizeManifest(raw, warnings) {
263
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
264
+ warnings.push('manifest root was not an object')
265
+ return freshManifest()
266
+ }
267
+ const manifest = freshManifest()
268
+ if (typeof raw.baseDirectory === 'string' && raw.baseDirectory.trim().length > 0) {
269
+ const expanded = expandHomePath(raw.baseDirectory.trim())
270
+ if (isAbsolute(expanded)) manifest.baseDirectory = normalizePath(expanded)
271
+ else warnings.push(`ignored a non-absolute baseDirectory: ${JSON.stringify(raw.baseDirectory)}`)
272
+ }
273
+ if (Array.isArray(raw.workspaces)) {
274
+ for (const entry of raw.workspaces) {
275
+ if (entry === null || typeof entry !== 'object') {
276
+ warnings.push('ignored a malformed workspace record')
277
+ continue
278
+ }
279
+ if (typeof entry.id !== 'string' || typeof entry.name !== 'string' || typeof entry.path !== 'string') {
280
+ warnings.push('ignored a workspace record without id/name/path')
281
+ continue
282
+ }
283
+ if (!isAbsolute(entry.path)) {
284
+ warnings.push(`ignored a workspace with a non-absolute path: ${JSON.stringify(entry.path)}`)
285
+ continue
286
+ }
287
+ const links = []
288
+ if (Array.isArray(entry.links)) {
289
+ for (const link of entry.links) {
290
+ if (link === null || typeof link !== 'object') continue
291
+ if (typeof link.alias !== 'string' || typeof link.target !== 'string') continue
292
+ if (!isAbsolute(link.target)) continue
293
+ links.push({
294
+ alias: link.alias,
295
+ target: normalizePath(link.target),
296
+ addedAt: typeof link.addedAt === 'string' ? link.addedAt : new Date(0).toISOString(),
297
+ })
298
+ }
299
+ }
300
+ manifest.workspaces.push({
301
+ id: entry.id,
302
+ name: entry.name,
303
+ path: normalizePath(entry.path),
304
+ ...(typeof entry.relocatedTo === 'string' && isAbsolute(entry.relocatedTo)
305
+ ? { relocatedTo: normalizePath(entry.relocatedTo) }
306
+ : {}),
307
+ createdAt: typeof entry.createdAt === 'string' ? entry.createdAt : new Date(0).toISOString(),
308
+ origin: entry.origin === 'adopted' ? 'adopted' : 'created',
309
+ links,
310
+ })
311
+ }
312
+ }
313
+ return manifest
314
+ }
315
+
316
+ /**
317
+ * Owns the managed-workspace manifest, the workspace folder layout, and the
318
+ * folder links. All mutations run on one promise chain and persist atomically.
319
+ */
320
+ export class ManagedWorkspaceStore {
321
+ constructor(options = {}) {
322
+ this.home = normalizePath(options.home ?? resolveDshHome())
323
+ this.manifestPath = join(this.home, 'managed-workspaces', 'manifest.json')
324
+ this.now = options.now ?? (() => new Date().toISOString())
325
+ this.logger = options.logger ?? (() => {})
326
+ this.registry = null
327
+ this.manifest = null
328
+ this.warnings = []
329
+ this.queue = Promise.resolve()
330
+ this.loadPromise = null
331
+ }
332
+
333
+ /** Attach the host workspace registry (optional; the filesystem stays usable without it). */
334
+ setRegistry(registry) {
335
+ this.registry = registry ?? null
336
+ }
337
+
338
+ /** Dispose transient state; files and manifest stay on disk. */
339
+ dispose() {
340
+ this.registry = null
341
+ }
342
+
343
+ /** Load the manifest once. Repeated calls share the same promise. */
344
+ load() {
345
+ if (this.loadPromise === null) this.loadPromise = this.loadOnce()
346
+ return this.loadPromise
347
+ }
348
+
349
+ async loadOnce() {
350
+ let raw = null
351
+ try {
352
+ raw = await readFile(this.manifestPath, 'utf8')
353
+ } catch (error) {
354
+ if (error.code !== 'ENOENT') throw error
355
+ }
356
+ if (raw === null) {
357
+ this.manifest = freshManifest()
358
+ return this.manifest
359
+ }
360
+ try {
361
+ const parsed = JSON.parse(raw)
362
+ const warnings = []
363
+ this.manifest = normalizeManifest(parsed, warnings)
364
+ this.warnings.push(...warnings)
365
+ } catch (error) {
366
+ const quarantine = `${this.manifestPath}.corrupt-${Date.now()}`
367
+ let moved = false
368
+ try {
369
+ await rename(this.manifestPath, quarantine)
370
+ moved = true
371
+ } catch (moveError) {
372
+ this.warnings.push(`manifest was unreadable and could not be moved aside: ${errorMessage(moveError)}`)
373
+ }
374
+ this.warnings.push(
375
+ moved
376
+ ? `manifest was unreadable and was moved to ${quarantine}: ${errorMessage(error)}`
377
+ : `manifest was unreadable: ${errorMessage(error)}`,
378
+ )
379
+ this.manifest = freshManifest()
380
+ }
381
+ return this.manifest
382
+ }
383
+
384
+ /** Run one mutation after the previous one settled. */
385
+ run(operation) {
386
+ const result = this.queue.then(() => operation())
387
+ this.queue = result.then(
388
+ () => undefined,
389
+ () => undefined,
390
+ )
391
+ return result
392
+ }
393
+
394
+ async persist() {
395
+ const directory = dirname(this.manifestPath)
396
+ await mkdir(directory, { recursive: true })
397
+ const temporary = `${this.manifestPath}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`
398
+ await writeFile(temporary, `${JSON.stringify(this.manifest, null, 2)}\n`, 'utf8')
399
+ await rename(temporary, this.manifestPath)
400
+ }
401
+
402
+ requireManifest() {
403
+ if (this.manifest === null) {
404
+ fail('not-ready', 'managed workspace state has not been loaded yet')
405
+ }
406
+ return this.manifest
407
+ }
408
+
409
+ mustFind(id) {
410
+ const manifest = this.requireManifest()
411
+ const record = manifest.workspaces.find((entry) => entry.id === id)
412
+ if (record === undefined) fail('not-found', `unknown managed workspace: ${JSON.stringify(id)}`)
413
+ return record
414
+ }
415
+
416
+ linksDirectory(record) {
417
+ return join(record.path, this.requireManifest().linksDirectoryName ?? LINKS_DIRECTORY)
418
+ }
419
+
420
+ linksDirectoryAt(base) {
421
+ return join(base, this.requireManifest().linksDirectoryName ?? LINKS_DIRECTORY)
422
+ }
423
+
424
+ /** The registered workspace entity for a record (anchor or migrated location), or null. */
425
+ registryEntity(record) {
426
+ if (this.registry === null) return null
427
+ const match = this.registry.list().find(
428
+ (workspace) =>
429
+ samePath(workspace.path, record.path) ||
430
+ (typeof record.relocatedTo === 'string' && samePath(workspace.path, record.relocatedTo)),
431
+ )
432
+ return match ?? null
433
+ }
434
+
435
+ /** The registered workspace id for a record, or null. */
436
+ registryWorkspaceId(record) {
437
+ const entity = this.registryEntity(record)
438
+ return entity === null ? null : String(entity.id)
439
+ }
440
+
441
+ /** How many sessions the registry accounts to this workspace. */
442
+ sessionCountOf(record) {
443
+ const entity = this.registryEntity(record)
444
+ const ids = entity?.sessionIds
445
+ return Array.isArray(ids) ? ids.length : 0
446
+ }
447
+
448
+ /** The physical directory holding the workspace content (anchor resolved). */
449
+ async realPathOf(record) {
450
+ return normalizePath(await realpath(record.path))
451
+ }
452
+
453
+ /** All managed workspace records (loaded state only). */
454
+ listSync() {
455
+ return this.manifest === null ? [] : [...this.manifest.workspaces]
456
+ }
457
+
458
+ /** Whether a path is a filesystem root, which cannot become a workspace. */
459
+ isFilesystemRoot(path) {
460
+ const root = parse(normalizePath(path)).root
461
+ return root.length > 0 && samePath(root, path)
462
+ }
463
+
464
+ /** The display title the workspace registry holds for a path, when registered. */
465
+ registryTitle(path) {
466
+ if (this.registry === null) return null
467
+ const match = this.registry.list().find((workspace) => samePath(workspace.path, path))
468
+ if (match === undefined) return null
469
+ const title = typeof match.title === 'string' ? match.title.trim() : ''
470
+ return title.length > 0 ? title : null
471
+ }
472
+
473
+ /** A display name not yet used by another managed workspace. */
474
+ uniqueName(preferred) {
475
+ const taken = new Set(this.listSync().map((record) => record.name.toLowerCase()))
476
+ if (!taken.has(preferred.toLowerCase())) return preferred
477
+ for (let index = 2; index < 1000; index += 1) {
478
+ const candidate = `${preferred} (${index})`
479
+ if (!taken.has(candidate.toLowerCase())) return candidate
480
+ }
481
+ return `${preferred} (${randomUUID().slice(0, 4)})`
482
+ }
483
+
484
+ /** Registry workspaces that are not managed by this plugin yet. */
485
+ candidates() {
486
+ if (this.registry === null || this.manifest === null) return []
487
+ const out = []
488
+ for (const workspace of this.registry.list()) {
489
+ if (typeof workspace.path !== 'string') continue
490
+ if (this.isFilesystemRoot(workspace.path)) continue
491
+ if (this.manifest.workspaces.some((record) => samePath(record.path, workspace.path))) continue
492
+ if (
493
+ this.manifest.workspaces.some(
494
+ (record) => typeof record.relocatedTo === 'string' && samePath(record.relocatedTo, workspace.path),
495
+ )
496
+ ) {
497
+ continue
498
+ }
499
+ const title = typeof workspace.title === 'string' && workspace.title.trim().length > 0 ? workspace.title.trim() : basename(workspace.path)
500
+ out.push({ path: normalizePath(workspace.path), title, workspaceId: String(workspace.id) })
501
+ }
502
+ return out
503
+ }
504
+
505
+ /** Resolve the managed workspace that owns an absolute path. */
506
+ findWorkspaceForPath(path) {
507
+ if (this.manifest === null || typeof path !== 'string') return null
508
+ for (const workspace of this.manifest.workspaces) {
509
+ if (isUnder(path, workspace.path)) return { workspace, via: 'home' }
510
+ if (typeof workspace.relocatedTo === 'string' && isUnder(path, workspace.relocatedTo)) {
511
+ return { workspace, via: 'relocated' }
512
+ }
513
+ }
514
+ for (const workspace of this.manifest.workspaces) {
515
+ for (const link of workspace.links) {
516
+ if (isUnder(path, link.target)) return { workspace, via: 'link', alias: link.alias, link }
517
+ }
518
+ }
519
+ return null
520
+ }
521
+
522
+ async linkView(record, link) {
523
+ const linkPath = join(this.linksDirectory(record), link.alias)
524
+ const node = await lstatOrNull(linkPath)
525
+ let health
526
+ if (node === null) {
527
+ health = 'link-missing'
528
+ } else if (!node.isSymbolicLink()) {
529
+ health = 'conflict'
530
+ } else {
531
+ const actual = await resolveLink(linkPath)
532
+ if (!samePath(actual, link.target)) health = 'conflict'
533
+ else health = (await isDirectory(link.target)) ? 'ok' : 'target-missing'
534
+ }
535
+ return { alias: link.alias, target: link.target, linkPath, health }
536
+ }
537
+
538
+ async view(record) {
539
+ const links = []
540
+ for (const link of record.links) links.push(await this.linkView(record, link))
541
+ const workspaceId = this.registryWorkspaceId(record)
542
+ return {
543
+ id: record.id,
544
+ name: record.name,
545
+ path: record.path,
546
+ relocatedTo: typeof record.relocatedTo === 'string' ? record.relocatedTo : null,
547
+ createdAt: record.createdAt,
548
+ origin: record.origin === 'adopted' ? 'adopted' : 'created',
549
+ directory: (await isDirectory(record.path)) ? 'ok' : 'missing',
550
+ registered: workspaceId !== null,
551
+ workspaceId,
552
+ sessionCount: this.sessionCountOf(record),
553
+ links,
554
+ }
555
+ }
556
+
557
+ async buildState() {
558
+ const manifest = this.requireManifest()
559
+ const workspaces = []
560
+ for (const record of manifest.workspaces) workspaces.push(await this.view(record))
561
+ return {
562
+ baseDirectory: manifest.baseDirectory,
563
+ linksDirectoryName: manifest.linksDirectoryName ?? LINKS_DIRECTORY,
564
+ manifestPath: this.manifestPath,
565
+ warnings: [...this.warnings],
566
+ workspaces,
567
+ candidates: this.candidates(),
568
+ }
569
+ }
570
+
571
+ async state() {
572
+ await this.load()
573
+ return this.run(() => this.buildState())
574
+ }
575
+
576
+ async ensureBase() {
577
+ const manifest = this.requireManifest()
578
+ const base = manifest.baseDirectory
579
+ await mkdir(base, { recursive: true })
580
+ return base
581
+ }
582
+
583
+ async setBaseDirectory(value) {
584
+ await this.load()
585
+ return this.run(async () => {
586
+ if (typeof value !== 'string' || value.trim().length === 0) {
587
+ fail('invalid-base', 'baseDirectory must be a non-blank absolute path')
588
+ }
589
+ const expanded = expandHomePath(value.trim())
590
+ if (!isAbsolute(expanded)) fail('invalid-base', `baseDirectory must be absolute: ${JSON.stringify(value)}`)
591
+ const normalized = normalizePath(expanded)
592
+ const info = await statOrNull(normalized)
593
+ if (info !== null && !info.isDirectory()) fail('invalid-base', `baseDirectory is a file: ${normalized}`)
594
+ this.requireManifest().baseDirectory = normalized
595
+ await this.persist()
596
+ return { baseDirectory: normalized }
597
+ })
598
+ }
599
+
600
+ async registerRecord(record) {
601
+ if (this.registry === null) fail('registry-unavailable', 'the harness workspace registry is not available')
602
+ const workspace = await this.registry.create(record.path, record.name)
603
+ return String(workspace.id)
604
+ }
605
+
606
+ async create(name) {
607
+ await this.load()
608
+ return this.run(async () => {
609
+ const value = validateSegment(name)
610
+ const base = await this.ensureBase()
611
+ const directory = join(base, value)
612
+ if (await exists(directory)) fail('exists', `workspace folder already exists: ${directory}`)
613
+ await mkdir(join(directory, this.requireManifest().linksDirectoryName ?? LINKS_DIRECTORY), { recursive: true })
614
+ const canonical = normalizePath(await realpath(directory))
615
+ const record = {
616
+ id: `mws-${randomUUID()}`,
617
+ name: value,
618
+ path: canonical,
619
+ createdAt: this.now(),
620
+ origin: 'created',
621
+ links: [],
622
+ }
623
+ this.requireManifest().workspaces.push(record)
624
+ await this.persist()
625
+ let registered = false
626
+ let warning
627
+ try {
628
+ await this.registerRecord(record)
629
+ registered = true
630
+ } catch (error) {
631
+ warning = errorMessage(error)
632
+ this.logger(`workspace "${record.name}" was created but not registered: ${warning}`)
633
+ }
634
+ return { workspace: await this.view(record), registered, ...(warning === undefined ? {} : { warning }) }
635
+ })
636
+ }
637
+
638
+ /**
639
+ * Adopt an existing folder as a managed workspace without creating or moving
640
+ * anything inside it. `_roots` appears only when the first link is attached.
641
+ */
642
+ async adopt(folder) {
643
+ await this.load()
644
+ return this.run(async () => this.adoptOne(folder))
645
+ }
646
+
647
+ /** Adopt every registered workspace that this plugin does not manage yet. */
648
+ async adoptAll() {
649
+ await this.load()
650
+ return this.run(async () => {
651
+ const adopted = []
652
+ const skipped = []
653
+ const failed = []
654
+ for (const candidate of this.candidates()) {
655
+ try {
656
+ const result = await this.adoptOne(candidate.path)
657
+ if (result.adopted) adopted.push(result.workspace.name)
658
+ else skipped.push(result.workspace.name)
659
+ } catch (error) {
660
+ failed.push(`${candidate.path}: ${errorMessage(error)}`)
661
+ }
662
+ }
663
+ return { adopted, skipped, failed, state: await this.buildState() }
664
+ })
665
+ }
666
+
667
+ async adoptOne(folder) {
668
+ if (typeof folder !== 'string' || folder.trim().length === 0) {
669
+ fail('invalid-target', 'folder must be a non-blank absolute path')
670
+ }
671
+ const expanded = expandHomePath(folder.trim())
672
+ if (!isAbsolute(expanded)) fail('invalid-target', `folder must be absolute: ${JSON.stringify(folder)}`)
673
+ const info = await statOrNull(expanded)
674
+ if (info === null) fail('target-missing', `folder does not exist: ${expanded}`)
675
+ if (!info.isDirectory()) fail('invalid-target', `not a directory: ${expanded}`)
676
+ const target = normalizePath(await realpath(expanded))
677
+ if (this.isFilesystemRoot(target)) fail('invalid-target', `a filesystem root cannot become a workspace: ${target}`)
678
+ if (samePath(target, this.requireManifest().baseDirectory)) {
679
+ fail('invalid-target', 'the workspace base directory cannot itself be a workspace')
680
+ }
681
+ const existing = this.requireManifest().workspaces.find((record) => samePath(record.path, target))
682
+ if (existing !== undefined) {
683
+ return { workspace: await this.view(existing), adopted: false }
684
+ }
685
+ const relocated = this.requireManifest().workspaces.find(
686
+ (record) => typeof record.relocatedTo === 'string' && samePath(record.relocatedTo, target),
687
+ )
688
+ if (relocated !== undefined) {
689
+ fail('already-managed', `this folder is already the migrated location of workspace "${relocated.name}"`)
690
+ }
691
+ const preferred = this.registryTitle(target) ?? basename(target)
692
+ const record = {
693
+ id: `mws-${randomUUID()}`,
694
+ name: this.uniqueName(preferred),
695
+ path: target,
696
+ createdAt: this.now(),
697
+ origin: 'adopted',
698
+ links: [],
699
+ }
700
+ this.requireManifest().workspaces.push(record)
701
+ await this.persist()
702
+ let registered = false
703
+ let warning
704
+ try {
705
+ await this.registerRecord(record)
706
+ registered = true
707
+ } catch (error) {
708
+ warning = errorMessage(error)
709
+ this.logger(`workspace "${record.name}" was adopted but not registered: ${warning}`)
710
+ }
711
+ return {
712
+ workspace: await this.view(record),
713
+ adopted: true,
714
+ registered,
715
+ ...(warning === undefined ? {} : { warning }),
716
+ }
717
+ }
718
+
719
+ /** Recreate a set of recorded links under one base directory (best effort). */
720
+ async restoreLinks(links, base) {
721
+ const errors = []
722
+ if (links.length === 0) return errors
723
+ const roots = this.linksDirectoryAt(base)
724
+ await mkdir(roots, { recursive: true })
725
+ for (const link of links) {
726
+ try {
727
+ await ensureLinkNode(join(roots, link.alias), link.target)
728
+ } catch (error) {
729
+ errors.push(`${link.alias}: ${errorMessage(error)}`)
730
+ }
731
+ }
732
+ return errors
733
+ }
734
+
735
+ /** Remove every recorded link node below a base directory, returning the removed ones. */
736
+ async detachRecordLinks(record, base) {
737
+ const roots = this.linksDirectoryAt(base)
738
+ const detached = []
739
+ for (const link of record.links) {
740
+ const result = await detachLinkNode(join(roots, link.alias), link.target)
741
+ if (result === 'removed') detached.push(link)
742
+ }
743
+ return detached
744
+ }
745
+
746
+ /** Point the stable anchor path at the migrated directory, replacing a stale link. */
747
+ async ensureAnchorLink(record, destination, previous) {
748
+ const anchor = record.path
749
+ const node = await lstatOrNull(anchor)
750
+ if (node === null) {
751
+ await mkdir(dirname(anchor), { recursive: true })
752
+ await createDirectoryLink(destination, anchor)
753
+ return
754
+ }
755
+ if (!node.isSymbolicLink()) {
756
+ fail('conflict', `refusing to replace "${anchor}": it is a real directory or file`)
757
+ }
758
+ const actual = await resolveLink(anchor)
759
+ if (samePath(actual, destination)) return
760
+ if (!samePath(actual, previous) && !isUnder(actual, previous)) {
761
+ fail('conflict', `refusing to replace "${anchor}": it points to ${actual}`)
762
+ }
763
+ await unlink(anchor)
764
+ await createDirectoryLink(destination, anchor)
765
+ }
766
+
767
+ /**
768
+ * Move a workspace's directory to a new location.
769
+ *
770
+ * A session records its original working directory immutably, and the
771
+ * workspace registry groups sessions by that recorded path. Moving the
772
+ * folder would strand every conversation, so a workspace that still has
773
+ * sessions keeps its original path as a directory link to the new location
774
+ * (`anchor` mode). A workspace without sessions is re-registered at the new
775
+ * path instead (`clean` mode), leaving no link behind.
776
+ */
777
+ async migrate(id, destination, options = {}) {
778
+ await this.load()
779
+ return this.run(async () => {
780
+ const record = this.mustFind(id)
781
+ const raw = typeof destination === 'string' ? destination.trim() : ''
782
+ if (raw.length === 0) fail('invalid-target', 'destination must be a non-blank absolute path')
783
+ const expanded = expandHomePath(raw)
784
+ if (!isAbsolute(expanded)) fail('invalid-target', `destination must be absolute: ${JSON.stringify(destination)}`)
785
+ const target = normalizePath(expanded)
786
+ if (this.isFilesystemRoot(target)) fail('invalid-target', `a filesystem root cannot be a workspace: ${target}`)
787
+ if (samePath(target, this.requireManifest().baseDirectory)) {
788
+ fail('invalid-target', 'the workspace base directory cannot itself be a workspace')
789
+ }
790
+ if ((await lstatOrNull(target)) !== null) fail('exists', `destination already exists: ${target}`)
791
+
792
+ const source = await this.realPathOf(record)
793
+ if (samePath(target, source)) fail('invalid-target', 'destination is the current workspace location')
794
+ if (isUnder(target, source)) fail('invalid-target', 'cannot move a workspace into itself')
795
+ if (isUnder(source, target)) fail('invalid-target', 'cannot move a workspace onto its own parent')
796
+ for (const link of record.links) {
797
+ if (isUnder(target, link.target)) fail('invalid-target', 'cannot move a workspace into one of its linked folders')
798
+ }
799
+ if (this.registry !== null) {
800
+ const clash = this.registry
801
+ .list()
802
+ .find(
803
+ (workspace) =>
804
+ samePath(workspace.path, target) &&
805
+ !samePath(workspace.path, record.path) &&
806
+ !(typeof record.relocatedTo === 'string' && samePath(workspace.path, record.relocatedTo)),
807
+ )
808
+ if (clash !== undefined) fail('exists', `another registered workspace already uses ${target}`)
809
+ }
810
+ if (
811
+ this.listSync().some(
812
+ (other) =>
813
+ other.id !== record.id &&
814
+ (samePath(other.path, target) ||
815
+ (typeof other.relocatedTo === 'string' && samePath(other.relocatedTo, target))),
816
+ )
817
+ ) {
818
+ fail('exists', `another managed workspace already uses ${target}`)
819
+ }
820
+
821
+ const sessionCount = this.sessionCountOf(record)
822
+ const requested = options.mode === undefined ? 'auto' : String(options.mode)
823
+ if (!['auto', 'anchor', 'clean'].includes(requested)) fail('invalid-mode', `unknown migration mode: ${requested}`)
824
+ const mode = requested === 'auto' ? (sessionCount === 0 ? 'clean' : 'anchor') : requested
825
+ if (mode === 'clean' && sessionCount > 0) {
826
+ fail(
827
+ 'sessions-present',
828
+ `this workspace still has ${sessionCount} session(s); a clean migration would detach them. Use the anchor mode instead.`,
829
+ )
830
+ }
831
+
832
+ let detached = []
833
+ try {
834
+ detached = await this.detachRecordLinks(record, source)
835
+ } catch (error) {
836
+ const errors = await this.restoreLinks(detached, source)
837
+ fail(
838
+ 'conflict',
839
+ `cannot prepare links for the move: ${errorMessage(error)}${errors.length > 0 ? `; restoring them also failed: ${errors.join('; ')}` : ''}`,
840
+ )
841
+ }
842
+
843
+ let movedBy
844
+ try {
845
+ await mkdir(dirname(target), { recursive: true })
846
+ movedBy = await moveDirectory(source, target)
847
+ } catch (error) {
848
+ const errors = await this.restoreLinks(detached, source)
849
+ fail(
850
+ 'move-failed',
851
+ `${errorMessage(error)}${errors.length > 0 ? `; restoring links also failed: ${errors.join('; ')}` : ''}`,
852
+ )
853
+ }
854
+
855
+ const linksDirName = this.requireManifest().linksDirectoryName ?? LINKS_DIRECTORY
856
+ const linkErrors = record.links.length > 0 ? await this.restoreLinks(record.links, target) : []
857
+ if (!(await isDirectory(join(target, linksDirName))) && record.origin !== 'adopted') {
858
+ await mkdir(join(target, linksDirName), { recursive: true })
859
+ }
860
+
861
+ if (mode === 'clean') {
862
+ try {
863
+ const entity = this.registryEntity(record)
864
+ if (entity !== null) await this.registry.delete(String(entity.id))
865
+ if (this.registry !== null) await this.registry.create(target, record.name)
866
+ } catch (error) {
867
+ record.relocatedTo = target
868
+ await this.persist()
869
+ fail(
870
+ 'registration-failed',
871
+ `the directory moved to ${target}, but re-registering it failed: ${errorMessage(error)}. Run repair to re-anchor the original path.`,
872
+ )
873
+ }
874
+ record.path = target
875
+ delete record.relocatedTo
876
+ await this.persist()
877
+ return { workspace: await this.view(record), mode, source, destination: target, sessionCount, linkErrors, movedBy }
878
+ }
879
+
880
+ try {
881
+ await this.ensureAnchorLink(record, target, source)
882
+ } catch (anchorError) {
883
+ try {
884
+ await moveDirectory(target, source)
885
+ await this.restoreLinks(record.links, source)
886
+ if (typeof record.relocatedTo === 'string') await this.ensureAnchorLink(record, record.relocatedTo, source)
887
+ } catch (rollbackError) {
888
+ record.relocatedTo = target
889
+ await this.persist()
890
+ fail(
891
+ 'migration-incomplete',
892
+ `moved to ${target}, but the original path ${record.path} is unusable (${errorMessage(anchorError)}) and rolling back failed (${errorMessage(rollbackError)}). Create a link from ${record.path} to ${target} manually, then run repair.`,
893
+ )
894
+ }
895
+ fail(
896
+ 'migration-rollback',
897
+ `could not keep the original path usable (${errorMessage(anchorError)}); the directory was moved back to ${source}`,
898
+ )
899
+ }
900
+ record.relocatedTo = target
901
+ await this.persist()
902
+ return { workspace: await this.view(record), mode, source, destination: target, sessionCount, linkErrors, movedBy }
903
+ })
904
+ }
905
+
906
+ async attach(id, folder, alias) {
907
+ await this.load()
908
+ return this.run(async () => {
909
+ const record = this.mustFind(id)
910
+ if (typeof folder !== 'string' || folder.trim().length === 0) fail('invalid-target', 'folder must be a non-blank absolute path')
911
+ const expanded = expandHomePath(folder.trim())
912
+ if (!isAbsolute(expanded)) fail('invalid-target', `folder must be absolute: ${JSON.stringify(folder)}`)
913
+ const info = await statOrNull(expanded)
914
+ if (info === null) fail('target-missing', `folder does not exist: ${expanded}`)
915
+ if (!info.isDirectory()) fail('invalid-target', `not a directory: ${expanded}`)
916
+ const target = normalizePath(await realpath(expanded))
917
+ rejectUnsupportedTarget(target)
918
+ if (isUnder(target, record.path)) fail('invalid-link', 'cannot link a folder that lies inside the workspace')
919
+ if (isUnder(record.path, target)) fail('invalid-link', 'cannot link a folder that contains the workspace')
920
+
921
+ const existing = record.links.find((link) => samePath(link.target, target))
922
+ if (existing !== undefined) {
923
+ const requested = typeof alias === 'string' && alias.trim().length > 0 ? validateSegment(alias, 'alias') : existing.alias
924
+ if (requested !== existing.alias) {
925
+ fail('already-linked', `folder is already linked as "${existing.alias}"`, { alias: existing.alias })
926
+ }
927
+ return { workspace: await this.view(record), link: await this.linkView(record, existing), created: false }
928
+ }
929
+
930
+ const preferred = typeof alias === 'string' && alias.trim().length > 0 ? validateSegment(alias, 'alias') : defaultAlias(target)
931
+ const name = uniqueAlias(record.links.map((link) => link.alias), preferred)
932
+ const roots = this.linksDirectory(record)
933
+ await mkdir(roots, { recursive: true })
934
+ const linkPath = join(roots, name)
935
+ const node = await lstatOrNull(linkPath)
936
+ if (node !== null) {
937
+ if (node.isSymbolicLink() && samePath(await resolveLink(linkPath), target)) {
938
+ const link = { alias: name, target, addedAt: this.now() }
939
+ record.links.push(link)
940
+ await this.persist()
941
+ return { workspace: await this.view(record), link: await this.linkView(record, link), created: false }
942
+ }
943
+ fail('conflict', `path already exists and is not a link to this folder: ${linkPath}`)
944
+ }
945
+ await createDirectoryLink(target, linkPath)
946
+ const link = { alias: name, target, addedAt: this.now() }
947
+ record.links.push(link)
948
+ await this.persist()
949
+ return { workspace: await this.view(record), link: await this.linkView(record, link), created: true }
950
+ })
951
+ }
952
+
953
+ async detach(id, alias) {
954
+ await this.load()
955
+ return this.run(async () => {
956
+ const record = this.mustFind(id)
957
+ const name = validateSegment(alias, 'alias')
958
+ const index = record.links.findIndex((link) => link.alias === name)
959
+ if (index < 0) fail('not-found', `workspace "${record.name}" has no link named "${name}"`)
960
+ const link = record.links[index]
961
+ const linkPath = join(this.linksDirectory(record), name)
962
+ const node = await lstatOrNull(linkPath)
963
+ if (node !== null) {
964
+ if (!node.isSymbolicLink()) {
965
+ fail('conflict', `refusing to remove "${linkPath}": it is a real directory or file, not a link`)
966
+ }
967
+ const actual = await resolveLink(linkPath)
968
+ if (!samePath(actual, link.target)) {
969
+ fail('conflict', `refusing to remove "${linkPath}": it points to ${actual}, not ${link.target}`)
970
+ }
971
+ await unlink(linkPath)
972
+ }
973
+ record.links.splice(index, 1)
974
+ await this.persist()
975
+ return { workspace: await this.view(record), removed: name }
976
+ })
977
+ }
978
+
979
+ async register(id) {
980
+ await this.load()
981
+ return this.run(async () => {
982
+ const record = this.mustFind(id)
983
+ if (!(await isDirectory(record.path))) {
984
+ fail('missing', `workspace folder is missing; run repair first: ${record.path}`)
985
+ }
986
+ await this.registerRecord(record)
987
+ return { workspace: await this.view(record), registered: true }
988
+ })
989
+ }
990
+
991
+ async forget(id) {
992
+ await this.load()
993
+ return this.run(async () => {
994
+ const record = this.mustFind(id)
995
+ // An adopted workspace existed before the plugin: stop managing it but
996
+ // leave its sidebar registration alone, because removing it would hide a
997
+ // project the user created outside this plugin.
998
+ const keepRegistration = record.origin === 'adopted'
999
+ let unregistered = false
1000
+ if (!keepRegistration) {
1001
+ const workspaceId = this.registryWorkspaceId(record)
1002
+ if (workspaceId !== null) {
1003
+ await this.registry.delete(workspaceId)
1004
+ unregistered = true
1005
+ }
1006
+ }
1007
+ const index = this.requireManifest().workspaces.findIndex((entry) => entry.id === record.id)
1008
+ if (index >= 0) this.requireManifest().workspaces.splice(index, 1)
1009
+ await this.persist()
1010
+ return { removed: record.name, unregistered, keptRegistration: keepRegistration, path: record.path }
1011
+ })
1012
+ }
1013
+
1014
+ async repair(id) {
1015
+ await this.load()
1016
+ return this.run(async () => {
1017
+ const record = this.mustFind(id)
1018
+ const repaired = []
1019
+ const conflicts = []
1020
+ const relocated = typeof record.relocatedTo === 'string' ? record.relocatedTo : null
1021
+
1022
+ if (relocated !== null) {
1023
+ if (!(await isDirectory(relocated))) {
1024
+ fail('missing', `the migrated workspace folder is missing and will not be recreated: ${relocated}`)
1025
+ }
1026
+ // The original path is the anchor every recorded session still uses.
1027
+ try {
1028
+ await this.ensureAnchorLink(record, relocated, relocated)
1029
+ if (!(await isDirectory(record.path))) {
1030
+ conflicts.push(`anchor ${record.path} does not resolve to ${relocated}`)
1031
+ } else if (await samePath(await this.realPathOf(record), relocated)) {
1032
+ if (repaired.length === 0) repaired.push('anchor')
1033
+ }
1034
+ } catch (error) {
1035
+ conflicts.push(`anchor: ${errorMessage(error)}`)
1036
+ }
1037
+ } else if (!(await isDirectory(record.path))) {
1038
+ if (record.origin === 'adopted') {
1039
+ fail('missing', `the adopted workspace folder is missing and will not be recreated: ${record.path}`)
1040
+ }
1041
+ await mkdir(record.path, { recursive: true })
1042
+ repaired.push('workspace folder')
1043
+ }
1044
+
1045
+ const base = relocated ?? record.path
1046
+ const roots = this.linksDirectoryAt(base)
1047
+ // A created workspace always owns its _roots; an adopted one gets it only
1048
+ // when a linked folder actually needs it, so adoption leaves no trace.
1049
+ if (!(await isDirectory(roots)) && (record.links.length > 0 || record.origin !== 'adopted')) {
1050
+ await mkdir(roots, { recursive: true })
1051
+ repaired.push(roots)
1052
+ }
1053
+ for (const link of record.links) {
1054
+ const linkPath = join(roots, link.alias)
1055
+ const node = await lstatOrNull(linkPath)
1056
+ if (node === null) {
1057
+ try {
1058
+ await createDirectoryLink(link.target, linkPath)
1059
+ repaired.push(link.alias)
1060
+ } catch (error) {
1061
+ conflicts.push(`${link.alias}: could not create the link (${errorMessage(error)})`)
1062
+ }
1063
+ continue
1064
+ }
1065
+ if (!node.isSymbolicLink()) {
1066
+ conflicts.push(`${link.alias}: path is occupied by a real directory or file`)
1067
+ continue
1068
+ }
1069
+ const actual = await resolveLink(linkPath)
1070
+ if (!samePath(actual, link.target)) {
1071
+ conflicts.push(`${link.alias}: link points elsewhere (${actual})`)
1072
+ continue
1073
+ }
1074
+ if (!(await isDirectory(link.target))) conflicts.push(`${link.alias}: target folder is missing (${link.target})`)
1075
+ }
1076
+ return { repaired, conflicts, workspace: await this.view(record) }
1077
+ })
1078
+ }
1079
+ }
1080
+
1081
+ /** Render an error as one message string. */
1082
+ export function errorMessage(error) {
1083
+ if (error instanceof Error) return error.message
1084
+ return String(error)
1085
+ }