@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/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +178 -0
- package/README.zh.md +178 -0
- package/lib/index.js +757 -0
- package/lib/invariant.js +114 -0
- package/lib/types/entity.d.ts +88 -0
- package/lib/types/entity.js +156 -0
- package/lib/types/index.d.ts +163 -0
- package/lib/types/index.js +600 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +43 -0
- package/lib/types/paths.d.ts +18 -0
- package/lib/types/paths.js +21 -0
- package/lib/types/spec.d.ts +85 -0
- package/lib/types/spec.js +63 -0
- package/lib/types/types.d.ts +92 -0
- package/lib/types/types.js +8 -0
- package/package.json +60 -0
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 };
|