@prettier-ai/dsh-workspace 0.1.2-alpha.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/lib/index.js ADDED
@@ -0,0 +1,757 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { realpath, stat } from "node:fs/promises";
3
+ import { basename } from "node:path";
4
+ import { Service } from "@prettier-ai/cordis";
5
+ import { z } from "zod";
6
+ import { SessionId } from "@prettier-ai/dsh-session";
7
+ import { defineDomain, domainTable } from "@prettier-ai/dsh-storage-domain";
8
+ //#region lib/types/paths.js
9
+ /**
10
+ * Path canonicalization for workspace identity.
11
+ * @module @prettier-ai/dsh-workspace/src/paths
12
+ */
13
+ /**
14
+ * Canonicalize a directory path via `fs.realpath`: trailing slashes, `..`
15
+ * segments, and symlinks are all resolved. This is the ONE uniqueness canon of
16
+ * the package — workspace paths are stored canonicalized, uniqueness is
17
+ * string equality of canonicalized paths (a symlink to an existing
18
+ * workspace's directory collides), and attach-time session `cwd` checks go
19
+ * through the same canon. A path that does not exist rejects with the
20
+ * original `ENOENT` — this is `create`'s reject path (a workspace must point
21
+ * at an existing directory).
22
+ * @param path - The path to canonicalize.
23
+ * @returns the canonical absolute path.
24
+ */
25
+ async function realpathNormalize(path) {
26
+ return await realpath(path);
27
+ }
28
+ //#endregion
29
+ //#region lib/types/entity.js
30
+ /**
31
+ * Package-private workspace entity: the single {@link Workspace}
32
+ * implementation. Holds a record snapshot that is swapped in place after each
33
+ * durable mutation; every write funnels through the private `mutate` so
34
+ * `updatedAt` stamping and invalid-account pruning happen exactly once.
35
+ * Not re-exported from the package entrypoint — consumers see only the
36
+ * `Workspace` interface.
37
+ * @module @prettier-ai/dsh-workspace/src/entity
38
+ */
39
+ /** An insertSessionBefore request named a session or anchor not on the account (storage failures stay plain errors). */
40
+ var WorkspaceMoveInvalidError = class extends Error {
41
+ /**
42
+ * @param message - Which id was unaccounted and where.
43
+ */
44
+ constructor(message) {
45
+ super(message);
46
+ this.name = "WorkspaceMoveInvalidError";
47
+ }
48
+ };
49
+ /** Chain-slot abort sentinel thrown by the update fn when the record needs no change; only `mutate` observes it. */
50
+ const unchangedSentinel = /* @__PURE__ */ new Error("workspace record unchanged (internal sentinel)");
51
+ /** The single {@link Workspace} implementation; constructed only by the registry. */
52
+ var WorkspaceEntity = class {
53
+ host;
54
+ id;
55
+ record;
56
+ /**
57
+ * @param host - Registry-owned table, session-path index, and header reads.
58
+ * @param id - The record's stable id.
59
+ * @param record - The validated record snapshot loaded or just written.
60
+ */
61
+ constructor(host, id, record) {
62
+ this.host = host;
63
+ this.id = id;
64
+ this.record = record;
65
+ }
66
+ get path() {
67
+ return this.record.path;
68
+ }
69
+ get title() {
70
+ return this.record.title;
71
+ }
72
+ get createdAt() {
73
+ return this.record.createdAt;
74
+ }
75
+ get updatedAt() {
76
+ return this.record.updatedAt;
77
+ }
78
+ get sessionIds() {
79
+ return this.record.sessionIds.filter((id) => this.host.sessionPath(id) === this.record.path);
80
+ }
81
+ async setTitle(title) {
82
+ await this.mutate((record) => ({
83
+ ...record,
84
+ title
85
+ }));
86
+ }
87
+ async attachSession(sessionId) {
88
+ if (!this.record.sessionIds.includes(sessionId)) {
89
+ const header = await this.host.readSessionHeader(sessionId);
90
+ if (header.cwd === void 0) throw new Error(`cannot attach session '${sessionId}' to workspace '${this.record.path}': its stored header carries no cwd to validate against`);
91
+ let cwd;
92
+ try {
93
+ cwd = await realpathNormalize(header.cwd);
94
+ } catch (error) {
95
+ throw new Error(`cannot attach session '${sessionId}' to workspace '${this.record.path}': its cwd '${header.cwd}' does not resolve, so it cannot be validated`, { cause: error });
96
+ }
97
+ if (!(await stat(cwd)).isDirectory()) throw new Error(`cannot attach session '${sessionId}' to workspace '${this.record.path}': its cwd '${header.cwd}' is not a directory`);
98
+ if (cwd !== this.record.path) throw new Error(`cannot attach session '${sessionId}' to workspace '${this.record.path}': its cwd resolves to '${cwd}'`);
99
+ this.host.rememberSessionPath(sessionId, cwd);
100
+ }
101
+ await this.mutate((record) => record.sessionIds.includes(sessionId) ? record : {
102
+ ...record,
103
+ sessionIds: [sessionId, ...record.sessionIds]
104
+ });
105
+ }
106
+ async insertSessionBefore(sessionId, beforeSessionId) {
107
+ await this.mutate((record) => {
108
+ if (!record.sessionIds.includes(sessionId)) throw new WorkspaceMoveInvalidError(`cannot move session '${sessionId}' in workspace '${record.path}': the session is not accounted`);
109
+ if (beforeSessionId !== void 0 && !record.sessionIds.includes(beforeSessionId)) throw new WorkspaceMoveInvalidError(`cannot move session '${sessionId}' before '${beforeSessionId}' in workspace '${record.path}': the anchor session is not accounted`);
110
+ if (beforeSessionId === sessionId) return record;
111
+ const without = record.sessionIds.filter((id) => id !== sessionId);
112
+ const at = beforeSessionId === void 0 ? without.length : without.indexOf(beforeSessionId);
113
+ const sessionIds = [
114
+ ...without.slice(0, at),
115
+ sessionId,
116
+ ...without.slice(at)
117
+ ];
118
+ return sessionIds.every((id, index) => id === record.sessionIds[index]) ? record : {
119
+ ...record,
120
+ sessionIds
121
+ };
122
+ });
123
+ }
124
+ async detachSession(sessionId) {
125
+ await this.mutate((record) => record.sessionIds.includes(sessionId) ? {
126
+ ...record,
127
+ sessionIds: record.sessionIds.filter((id) => id !== sessionId)
128
+ } : record);
129
+ }
130
+ async status() {
131
+ try {
132
+ return (await stat(this.record.path)).isDirectory() ? "ok" : "missing-dir";
133
+ } catch {
134
+ return "missing-dir";
135
+ }
136
+ }
137
+ /**
138
+ * The single write path: run `fn` on the domain write chain via
139
+ * `table.update`, stamping `updatedAt` and pruning candidates that no
140
+ * longer pass the id-plus-canonical-cwd membership check, then swap the
141
+ * snapshot.
142
+ *
143
+ * `fn` sees the value current at its chain slot, so membership decisions
144
+ * (attach/detach idempotence) are race-free against queued writes; a fn
145
+ * signalling no change by returning `current` verbatim aborts the slot
146
+ * through the sentinel when pruning also finds nothing, so a no-op neither
147
+ * rewrites the medium nor emits a change event.
148
+ */
149
+ async mutate(fn) {
150
+ let next;
151
+ try {
152
+ next = await this.host.table().update(this.id, (current) => {
153
+ const changed = fn(current);
154
+ const sessionIds = changed.sessionIds.filter((id) => this.host.sessionPath(id) === changed.path);
155
+ if (changed === current && sessionIds.length === current.sessionIds.length) throw unchangedSentinel;
156
+ return {
157
+ ...changed,
158
+ sessionIds,
159
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
160
+ };
161
+ });
162
+ } catch (error) {
163
+ if (error === unchangedSentinel) return;
164
+ throw error;
165
+ }
166
+ this.record = next;
167
+ }
168
+ };
169
+ //#endregion
170
+ //#region lib/types/spec.js
171
+ /**
172
+ * The workspace domain declaration: record schema and the `defineDomain` spec
173
+ * the registry opens. The zod schema validates the shipped format at the
174
+ * durability boundary and is the direct source of a future RPC wire projection.
175
+ * @module @prettier-ai/dsh-workspace/src/spec
176
+ */
177
+ /** Workspace id schema at the durable boundary; branding has no runtime representation. */
178
+ const workspaceId = z.string().transform((value) => value);
179
+ /**
180
+ * Durable shape of one workspace record. `path` is the `fs.realpath` canon
181
+ * stamped at create; `sessionIds` is the ordered ownership account (array
182
+ * order is display order); timestamps are ISO-8601 strings.
183
+ */
184
+ const workspaceRecord = z.object({
185
+ path: z.string(),
186
+ title: z.string(),
187
+ sessionIds: z.array(z.string().transform(SessionId)),
188
+ createdAt: z.string(),
189
+ updatedAt: z.string()
190
+ });
191
+ /**
192
+ * Recoverable two-write mutation marker. The marker is persisted before the
193
+ * record/order pair can diverge, so startup can distinguish an interrupted
194
+ * registry operation from unexplained medium corruption.
195
+ */
196
+ const workspacePendingMutation = z.discriminatedUnion("operation", [z.object({
197
+ operation: z.literal("create"),
198
+ workspaceId
199
+ }), z.object({
200
+ operation: z.literal("delete"),
201
+ workspaceId
202
+ })]);
203
+ /**
204
+ * Durable registry state. `initialized` distinguishes a valid empty registry
205
+ * from one that still needs the header-only history bootstrap;
206
+ * `workspaceIds` is the authoritative display order. `archivedSessionIds` is
207
+ * the registry-global archive set layered over workspace accounting: an
208
+ * archived session keeps its `sessionIds` slot (unarchiving must restore the
209
+ * position), so the set never participates in the one-owner accounting
210
+ * invariant. Defaulted so records written before the field parse unchanged.
211
+ */
212
+ const workspaceDomainState = z.object({
213
+ initialized: z.boolean(),
214
+ workspaceIds: z.array(workspaceId),
215
+ archivedSessionIds: z.array(z.string().transform(SessionId)).default([]),
216
+ pendingMutation: workspacePendingMutation.optional()
217
+ });
218
+ /**
219
+ * The workspace domain spec: one `workspaces` table keyed by
220
+ * {@link WorkspaceId} plus the bootstrap/order singleton. The registry opens
221
+ * this through `ctx.storage.domain`; the spec object is the single source of
222
+ * the domain's identity, version, and schemas.
223
+ */
224
+ const workspaceDomainSpec = defineDomain({
225
+ name: "workspace",
226
+ version: 2,
227
+ global: {
228
+ schema: workspaceDomainState,
229
+ initial: {
230
+ initialized: false,
231
+ workspaceIds: [],
232
+ archivedSessionIds: []
233
+ }
234
+ },
235
+ tables: { workspaces: domainTable(workspaceRecord) }
236
+ });
237
+ //#endregion
238
+ //#region lib/types/index.js
239
+ /**
240
+ * Workspace entity registry (`ctx.workspaceRegistry`): durable workspace records,
241
+ * stable registry order, and header-validated session membership over the
242
+ * domain data form.
243
+ * @module @prettier-ai/dsh-workspace
244
+ */
245
+ /**
246
+ * Brand a string as a {@link WorkspaceId}.
247
+ * @param id - Raw workspace id string.
248
+ * @returns the same string, branded at compile time.
249
+ */
250
+ function WorkspaceId(id) {
251
+ return id;
252
+ }
253
+ /**
254
+ * An archiveSession request named a session neither live nor in session
255
+ * persistence — a definite miss only; storage faults propagate as themselves.
256
+ */
257
+ var WorkspaceUnknownSessionError = class extends Error {
258
+ sessionId;
259
+ /**
260
+ * @param sessionId - The unknown session id.
261
+ */
262
+ constructor(sessionId) {
263
+ super(`cannot archive session '${sessionId}': live sessions and session persistence hold no such session`);
264
+ this.sessionId = sessionId;
265
+ this.name = "WorkspaceUnknownSessionError";
266
+ }
267
+ };
268
+ /** A workspace reorder named a source or anchor absent from the durable registry order. */
269
+ var WorkspaceOrderInvalidError = class extends Error {
270
+ workspaceId;
271
+ /**
272
+ * @param workspaceId - Missing source or anchor id.
273
+ */
274
+ constructor(workspaceId) {
275
+ super(`cannot reorder unknown workspace '${workspaceId}'`);
276
+ this.workspaceId = workspaceId;
277
+ this.name = "WorkspaceOrderInvalidError";
278
+ }
279
+ };
280
+ const sameIds = (left, right) => left.length === right.length && left.every((id, index) => id === right[index]);
281
+ const compareHeaders = (left, right) => right.createdAt - left.createdAt || String(left.id).localeCompare(String(right.id));
282
+ /**
283
+ * Durable workspace registry. Startup waits for `sessionPersistence`, builds
284
+ * one canonical-cwd header index, and completes the one-time history
285
+ * bootstrap before the service becomes active. The persistence dependency is
286
+ * mandatory so an unavailable peer can never be mistaken for an empty
287
+ * history and commit the initialized marker.
288
+ */
289
+ var WorkspaceRegistry = class extends Service {
290
+ static inject = ["storageDomain", "sessionPersistence"];
291
+ table;
292
+ global;
293
+ state;
294
+ entities = /* @__PURE__ */ new Map();
295
+ headers = /* @__PURE__ */ new Map();
296
+ sessionPaths = /* @__PURE__ */ new Map();
297
+ invalidSessionPaths = /* @__PURE__ */ new Map();
298
+ operationTail = Promise.resolve();
299
+ host = {
300
+ table: () => this.requireTable(),
301
+ sessionPath: (id) => this.sessionPaths.get(id),
302
+ readSessionHeader: (id) => this.readSessionHeader(id),
303
+ rememberSessionPath: (id, path) => {
304
+ this.sessionPaths.set(id, path);
305
+ this.invalidSessionPaths.delete(id);
306
+ }
307
+ };
308
+ constructor(ctx) {
309
+ super(ctx, "workspaceRegistry");
310
+ }
311
+ /** Open the domain, finish bootstrap when required, and rebuild the ordered cache. */
312
+ async [Service.init]() {
313
+ const domain = await this.ctx.storageDomain.open(workspaceDomainSpec);
314
+ this.ctx.effect(() => () => domain.close(), "workspace.domainClose");
315
+ this.table = domain.table("workspaces");
316
+ this.global = domain.global;
317
+ this.state = domain.global.get();
318
+ await this.recoverPendingMutation();
319
+ this.validateStoredState(this.state);
320
+ if (!this.state.initialized) {
321
+ const headers = await this.ctx.sessionPersistence.list();
322
+ await this.replaceHeaderIndex(headers);
323
+ await this.bootstrap(headers);
324
+ } else if (this.table.size > 0) await this.replaceHeaderIndex(await this.ctx.sessionPersistence.list());
325
+ await this.indexLiveSessions();
326
+ this.validateStoredState(this.requireState());
327
+ this.rebuildEntities();
328
+ this.reportFilteredCandidates();
329
+ }
330
+ /**
331
+ * Create or reuse a workspace for an existing directory. The path is
332
+ * canonicalized through `fs.realpath`; a nonexistent path rejects with the
333
+ * original error and a non-directory rejects. Repeated calls for the same
334
+ * canonical path return the existing entity without changing its title.
335
+ * A newly created workspace is prepended to the durable registry order.
336
+ * Different canonical paths may share a display title.
337
+ * @param path - Existing directory to own, in any path spelling.
338
+ * @param title - Display title used only when a new record is created.
339
+ * @returns the existing or newly durable workspace.
340
+ */
341
+ async create(path, title) {
342
+ const canonical = await realpathNormalize(path);
343
+ if (!(await stat(canonical)).isDirectory()) throw new Error(`cannot create a workspace at '${canonical}': path is not a directory`);
344
+ return await this.enqueueOperation(() => this.createCanonical(canonical, title));
345
+ }
346
+ /**
347
+ * Look up a workspace by id.
348
+ * @param id - Workspace id.
349
+ * @returns the workspace, or `undefined` when unknown.
350
+ */
351
+ get(id) {
352
+ return this.entities.get(id);
353
+ }
354
+ /**
355
+ * Synchronous workspace projection in durable registry order. Every
356
+ * entity's `sessionIds` getter is already filtered by the startup/live
357
+ * canonical-cwd header index; this method performs no persistence reads.
358
+ * @returns a fresh ordered array of workspace entities.
359
+ */
360
+ list() {
361
+ return this.requireState().workspaceIds.map((id) => {
362
+ const entity = this.entities.get(id);
363
+ if (entity === void 0) throw new Error(`workspace registry order references missing workspace '${id}'`);
364
+ return entity;
365
+ });
366
+ }
367
+ /**
368
+ * Delete one workspace registration while retaining its directory and every
369
+ * session log. The durable order is updated before the table deletion; a
370
+ * failed table write restores the prior order and keeps the entity
371
+ * published. Unknown ids are an idempotent no-op for domain callers.
372
+ * @param id - Workspace registration to remove.
373
+ * @returns `true` when a record was deleted, `false` when it was unknown.
374
+ */
375
+ delete(id) {
376
+ return this.enqueueOperation(() => this.deleteKnown(id));
377
+ }
378
+ /**
379
+ * Move one workspace within the durable display order, DOM-insertBefore-like.
380
+ * With an anchor it lands before that workspace; without one it appends.
381
+ * @param id - Workspace to move.
382
+ * @param beforeId - Workspace anchor; omitted appends.
383
+ * @returns the complete committed workspace order.
384
+ */
385
+ insertBefore(id, beforeId) {
386
+ return this.enqueueOperation(async () => {
387
+ const state = this.requireState();
388
+ if (!state.workspaceIds.includes(id)) throw new WorkspaceOrderInvalidError(id);
389
+ if (beforeId !== void 0 && !state.workspaceIds.includes(beforeId)) throw new WorkspaceOrderInvalidError(beforeId);
390
+ if (beforeId === id) return state.workspaceIds;
391
+ const without = state.workspaceIds.filter((workspaceId) => workspaceId !== id);
392
+ const at = beforeId === void 0 ? without.length : without.indexOf(beforeId);
393
+ const workspaceIds = [
394
+ ...without.slice(0, at),
395
+ id,
396
+ ...without.slice(at)
397
+ ];
398
+ if (sameIds(workspaceIds, state.workspaceIds)) return state.workspaceIds;
399
+ await this.setState({
400
+ ...state,
401
+ workspaceIds
402
+ });
403
+ return workspaceIds;
404
+ });
405
+ }
406
+ /**
407
+ * The registry-global archive set: sessions hidden from every grouping
408
+ * surface. Archiving never touches workspace accounting — an archived
409
+ * session keeps its `sessionIds` slot so unarchiving restores its position.
410
+ * @returns the archived session ids in archive order.
411
+ */
412
+ get archivedSessionIds() {
413
+ return this.requireState().archivedSessionIds;
414
+ }
415
+ /**
416
+ * Archive one session durably. The session must exist (live or in session
417
+ * persistence); its workspace accounting — or lack of one — is irrelevant.
418
+ * An already archived id resolves without writing.
419
+ * @param sessionId - The session to archive.
420
+ * @returns resolution after durability.
421
+ */
422
+ archiveSession(sessionId) {
423
+ return this.enqueueOperation(async () => {
424
+ if (this.requireState().archivedSessionIds.includes(sessionId)) return;
425
+ if (!await this.sessionKnown(sessionId)) throw new WorkspaceUnknownSessionError(sessionId);
426
+ const state = this.requireState();
427
+ await this.setState({
428
+ ...state,
429
+ archivedSessionIds: [...state.archivedSessionIds, sessionId]
430
+ });
431
+ });
432
+ }
433
+ /**
434
+ * Whether a session is live, header-indexed, or present in a fresh
435
+ * persistence listing. Only a definite miss returns false — a failing
436
+ * `sessionPersistence.list()` propagates so storage faults never
437
+ * masquerade as an unknown session.
438
+ */
439
+ async sessionKnown(id) {
440
+ if (this.ctx.get("sessions")?.get(id) !== void 0) return true;
441
+ if (this.headers.has(id)) return true;
442
+ await this.indexHeaders(await this.ctx.sessionPersistence.list());
443
+ return this.headers.has(id);
444
+ }
445
+ /**
446
+ * Resolve by canonical directory path without creating or mutating a
447
+ * workspace. A missing path rejects during `realpath`; an existing unowned
448
+ * directory returns `undefined`.
449
+ * @param path - Existing directory path in any spelling.
450
+ * @returns the workspace owning the canonical path, when one exists.
451
+ */
452
+ async resolveByPath(path) {
453
+ const canonical = await realpathNormalize(path);
454
+ for (const entity of this.entities.values()) if (entity.path === canonical) return entity;
455
+ }
456
+ async createCanonical(canonical, title) {
457
+ for (const entity of this.entities.values()) if (entity.path === canonical) return entity;
458
+ const workspaceName = title ?? basename(canonical);
459
+ const table = this.requireTable();
460
+ const state = this.requireState();
461
+ const id = WorkspaceId(randomUUID());
462
+ const now = (/* @__PURE__ */ new Date()).toISOString();
463
+ const record = {
464
+ path: canonical,
465
+ title: workspaceName,
466
+ sessionIds: [],
467
+ createdAt: now,
468
+ updatedAt: now
469
+ };
470
+ const entity = new WorkspaceEntity(this.host, id, record);
471
+ this.entities.set(id, entity);
472
+ const pendingState = {
473
+ ...state,
474
+ pendingMutation: {
475
+ operation: "create",
476
+ workspaceId: id
477
+ }
478
+ };
479
+ try {
480
+ await this.setState(pendingState);
481
+ } catch (error) {
482
+ this.entities.delete(id);
483
+ throw error;
484
+ }
485
+ try {
486
+ await table.put(id, record);
487
+ } catch (error) {
488
+ this.entities.delete(id);
489
+ try {
490
+ await this.setState(state);
491
+ } catch (rollbackError) {
492
+ throw new AggregateError([error, rollbackError], `workspace '${id}' record write and pending-marker rollback both failed`);
493
+ }
494
+ throw error;
495
+ }
496
+ try {
497
+ await this.setState({
498
+ initialized: true,
499
+ workspaceIds: [id, ...state.workspaceIds],
500
+ archivedSessionIds: state.archivedSessionIds
501
+ });
502
+ } catch (error) {
503
+ this.entities.delete(id);
504
+ try {
505
+ await table.delete(id);
506
+ } catch (rollbackError) {
507
+ throw new AggregateError([error, rollbackError], `workspace '${id}' order write and record rollback both failed; the pending marker remains recoverable`);
508
+ }
509
+ try {
510
+ await this.setState(state);
511
+ } catch (rollbackError) {
512
+ throw new AggregateError([error, rollbackError], `workspace '${id}' order write and pending-marker rollback both failed`);
513
+ }
514
+ throw error;
515
+ }
516
+ return entity;
517
+ }
518
+ async deleteKnown(id) {
519
+ const entity = this.entities.get(id);
520
+ if (entity === void 0) return false;
521
+ const state = this.requireState();
522
+ const nextState = {
523
+ initialized: true,
524
+ workspaceIds: state.workspaceIds.filter((workspaceId) => workspaceId !== id),
525
+ archivedSessionIds: state.archivedSessionIds
526
+ };
527
+ await this.setState({
528
+ ...nextState,
529
+ pendingMutation: {
530
+ operation: "delete",
531
+ workspaceId: id
532
+ }
533
+ });
534
+ this.entities.delete(id);
535
+ try {
536
+ await this.requireTable().delete(id);
537
+ } catch (error) {
538
+ this.entities.set(id, entity);
539
+ try {
540
+ await this.setState(state);
541
+ } catch (rollbackError) {
542
+ this.entities.delete(id);
543
+ throw new AggregateError([error, rollbackError], `workspace '${id}' record deletion and registry-order rollback both failed`);
544
+ }
545
+ throw error;
546
+ }
547
+ try {
548
+ await this.setState(nextState);
549
+ } catch (error) {
550
+ this.ctx.logger.warn(`workspace '${id}' was deleted but its pending marker could not be cleared: ${String(error)}`);
551
+ }
552
+ return true;
553
+ }
554
+ /**
555
+ * Complete the one mutation explicitly named by durable state. Unexplained
556
+ * order/table divergence still reaches {@link validateStoredState} and
557
+ * fails loud; this path never guesses which operation created a row from its shape alone.
558
+ */
559
+ async recoverPendingMutation() {
560
+ const state = this.requireState();
561
+ const pending = state.pendingMutation;
562
+ if (pending === void 0) return;
563
+ if (state.workspaceIds.includes(pending.workspaceId)) throw new Error(`workspace domain is inconsistent: pending ${pending.operation} workspace '${pending.workspaceId}' is still present in registry order`);
564
+ await this.requireTable().delete(pending.workspaceId);
565
+ await this.setState({
566
+ initialized: state.initialized,
567
+ workspaceIds: state.workspaceIds,
568
+ archivedSessionIds: state.archivedSessionIds
569
+ });
570
+ }
571
+ async bootstrap(headers) {
572
+ const table = this.requireTable();
573
+ const state = this.requireState();
574
+ const groupsByPath = /* @__PURE__ */ new Map();
575
+ for (const header of headers) {
576
+ const path = this.sessionPaths.get(header.id);
577
+ if (path === void 0) continue;
578
+ const group = groupsByPath.get(path);
579
+ if (group === void 0) groupsByPath.set(path, [header]);
580
+ else group.push(header);
581
+ }
582
+ const groups = [...groupsByPath].map(([path, groupHeaders]) => {
583
+ groupHeaders.sort(compareHeaders);
584
+ return {
585
+ path,
586
+ headers: groupHeaders,
587
+ newestAt: groupHeaders[0].createdAt
588
+ };
589
+ }).sort((left, right) => right.newestAt - left.newestAt || left.path.localeCompare(right.path));
590
+ const byPath = /* @__PURE__ */ new Map();
591
+ const accounted = /* @__PURE__ */ new Map();
592
+ for (const [id, record] of table.entries()) {
593
+ byPath.set(record.path, id);
594
+ for (const sessionId of record.sessionIds) accounted.set(sessionId, id);
595
+ }
596
+ for (const group of groups) {
597
+ let id = byPath.get(group.path);
598
+ if (id === void 0) {
599
+ const sessionIds = group.headers.map((header) => header.id).filter((sessionId) => !accounted.has(sessionId));
600
+ if (sessionIds.length === 0) continue;
601
+ id = WorkspaceId(randomUUID());
602
+ const createdAt = new Date(group.newestAt).toISOString();
603
+ const record = {
604
+ path: group.path,
605
+ title: basename(group.path),
606
+ sessionIds,
607
+ createdAt,
608
+ updatedAt: createdAt
609
+ };
610
+ await table.put(id, record);
611
+ byPath.set(group.path, id);
612
+ for (const sessionId of sessionIds) accounted.set(sessionId, id);
613
+ continue;
614
+ }
615
+ const current = table.get(id);
616
+ const historical = group.headers.map((header) => header.id).filter((sessionId) => accounted.get(sessionId) === void 0 || accounted.get(sessionId) === id);
617
+ const historicalSet = new Set(historical);
618
+ const sessionIds = [...historical, ...current.sessionIds.filter((sessionId) => !historicalSet.has(sessionId))];
619
+ if (sameSessionIds(current.sessionIds, sessionIds)) continue;
620
+ await table.update(id, (record) => ({
621
+ ...record,
622
+ sessionIds,
623
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
624
+ }));
625
+ for (const sessionId of historical) accounted.set(sessionId, id);
626
+ }
627
+ const groupRank = new Map(groups.map((group) => [group.path, group.newestAt]));
628
+ const priorRank = new Map(state.workspaceIds.map((id, index) => [id, index]));
629
+ const workspaceIds = [...table.entries()].sort(([leftId, left], [rightId, right]) => {
630
+ const leftTime = groupRank.get(left.path) ?? Date.parse(left.createdAt);
631
+ return (groupRank.get(right.path) ?? Date.parse(right.createdAt)) - leftTime || (priorRank.get(leftId) ?? Number.MAX_SAFE_INTEGER) - (priorRank.get(rightId) ?? Number.MAX_SAFE_INTEGER) || String(leftId).localeCompare(String(rightId));
632
+ }).map(([id]) => id);
633
+ if (!sameIds(state.workspaceIds, workspaceIds)) await this.setState({
634
+ initialized: false,
635
+ workspaceIds,
636
+ archivedSessionIds: state.archivedSessionIds
637
+ });
638
+ await this.setState({
639
+ initialized: true,
640
+ workspaceIds,
641
+ archivedSessionIds: state.archivedSessionIds
642
+ });
643
+ }
644
+ validateStoredState(state) {
645
+ const table = this.requireTable();
646
+ const order = /* @__PURE__ */ new Set();
647
+ for (const id of state.workspaceIds) {
648
+ if (order.has(id)) throw new Error(`workspace domain is inconsistent: registry order repeats workspace '${id}'`);
649
+ if (table.get(id) === void 0) throw new Error(`workspace domain is inconsistent: registry order references missing workspace '${id}'`);
650
+ order.add(id);
651
+ }
652
+ if (state.initialized && order.size !== table.size) {
653
+ const orphan = [...table.keys()].find((id) => !order.has(id));
654
+ throw new Error(`workspace domain is inconsistent: workspace '${orphan}' is absent from registry order`);
655
+ }
656
+ const paths = /* @__PURE__ */ new Map();
657
+ const accounted = /* @__PURE__ */ new Map();
658
+ for (const [id, record] of table.entries()) {
659
+ const pathHolder = paths.get(record.path);
660
+ if (pathHolder !== void 0) throw new Error(`workspace domain is inconsistent: path '${record.path}' is claimed by both workspace '${pathHolder}' and workspace '${id}'`);
661
+ paths.set(record.path, id);
662
+ for (const sessionId of record.sessionIds) {
663
+ const holder = accounted.get(sessionId);
664
+ if (holder !== void 0) throw new Error(`workspace domain is inconsistent: session '${sessionId}' is accounted by both workspace '${holder}' and workspace '${id}'`);
665
+ accounted.set(sessionId, id);
666
+ }
667
+ }
668
+ }
669
+ rebuildEntities() {
670
+ this.entities.clear();
671
+ for (const id of this.requireState().workspaceIds) {
672
+ const record = this.requireTable().get(id);
673
+ this.entities.set(id, new WorkspaceEntity(this.host, id, record));
674
+ }
675
+ }
676
+ async replaceHeaderIndex(headers) {
677
+ this.headers.clear();
678
+ this.sessionPaths.clear();
679
+ this.invalidSessionPaths.clear();
680
+ await this.indexHeaders(headers);
681
+ }
682
+ async indexHeaders(headers) {
683
+ for (const header of headers) await this.indexHeader(header);
684
+ }
685
+ async indexHeader(header) {
686
+ this.headers.set(header.id, header);
687
+ this.sessionPaths.delete(header.id);
688
+ if (header.cwd === void 0) {
689
+ this.invalidSessionPaths.set(header.id, "header has no cwd");
690
+ return;
691
+ }
692
+ try {
693
+ const path = await realpathNormalize(header.cwd);
694
+ if (!(await stat(path)).isDirectory()) {
695
+ this.invalidSessionPaths.set(header.id, `cwd '${header.cwd}' is not a directory`);
696
+ return;
697
+ }
698
+ this.sessionPaths.set(header.id, path);
699
+ this.invalidSessionPaths.delete(header.id);
700
+ } catch {
701
+ this.invalidSessionPaths.set(header.id, `cwd '${header.cwd}' does not resolve`);
702
+ }
703
+ }
704
+ async indexLiveSessions() {
705
+ const sessions = this.ctx.get("sessions");
706
+ if (sessions === void 0) return;
707
+ await this.indexHeaders(sessions.list().map((session) => session.header));
708
+ }
709
+ reportFilteredCandidates() {
710
+ for (const entity of this.entities.values()) {
711
+ const record = this.requireTable().get(entity.id);
712
+ for (const sessionId of record.sessionIds) {
713
+ const path = this.sessionPaths.get(sessionId);
714
+ if (path === record.path) continue;
715
+ const reason = this.invalidSessionPaths.get(sessionId) ?? (this.headers.has(sessionId) ? `canonical cwd '${path}' differs from workspace path '${record.path}'` : "session header is missing");
716
+ this.ctx.logger.warn(`workspace '${entity.id}' filtered session '${sessionId}' from membership: ${reason}`);
717
+ }
718
+ }
719
+ }
720
+ async readSessionHeader(id) {
721
+ const live = this.ctx.get("sessions")?.get(id);
722
+ if (live !== void 0) {
723
+ this.headers.set(id, live.header);
724
+ return live.header;
725
+ }
726
+ const cached = this.headers.get(id);
727
+ if (cached !== void 0) return cached;
728
+ const headers = await this.ctx.sessionPersistence.list();
729
+ await this.indexHeaders(headers);
730
+ const header = this.headers.get(id);
731
+ if (header === void 0) throw new Error(`cannot validate session '${id}': session persistence holds no such session`);
732
+ return header;
733
+ }
734
+ requireTable() {
735
+ if (this.table === void 0) throw new Error("workspace registry is not started yet");
736
+ return this.table;
737
+ }
738
+ requireState() {
739
+ if (this.state === void 0) throw new Error("workspace registry is not started yet");
740
+ return this.state;
741
+ }
742
+ async setState(state) {
743
+ await this.global.set(state);
744
+ this.state = state;
745
+ }
746
+ enqueueOperation(operation) {
747
+ const result = this.operationTail.then(async () => {
748
+ await this.recoverPendingMutation();
749
+ return await operation();
750
+ });
751
+ this.operationTail = result.then(() => {}, () => {});
752
+ return result;
753
+ }
754
+ };
755
+ const sameSessionIds = (left, right) => left.length === right.length && left.every((id, index) => id === right[index]);
756
+ //#endregion
757
+ export { WorkspaceId, WorkspaceMoveInvalidError, WorkspaceOrderInvalidError, WorkspaceRegistry, WorkspaceRegistry as default, WorkspaceUnknownSessionError, realpathNormalize, workspaceDomainSpec, workspaceDomainState, workspaceRecord };