@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
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace entity registry (`ctx.workspaceRegistry`): durable workspace records,
|
|
3
|
+
* stable registry order, and header-validated session membership over the
|
|
4
|
+
* domain data form.
|
|
5
|
+
* @module @prettier-ai/dsh-workspace
|
|
6
|
+
*/
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { stat } from 'node:fs/promises';
|
|
9
|
+
import { basename } from 'node:path';
|
|
10
|
+
import { Service } from '@prettier-ai/cordis';
|
|
11
|
+
import { WorkspaceEntity } from "./entity.js";
|
|
12
|
+
export { WorkspaceMoveInvalidError } from "./entity.js";
|
|
13
|
+
import { realpathNormalize } from "./paths.js";
|
|
14
|
+
import { workspaceDomainSpec } from "./spec.js";
|
|
15
|
+
export { workspaceDomainState, workspaceRecord, workspaceDomainSpec } from "./spec.js";
|
|
16
|
+
export { realpathNormalize } from "./paths.js";
|
|
17
|
+
/**
|
|
18
|
+
* Brand a string as a {@link WorkspaceId}.
|
|
19
|
+
* @param id - Raw workspace id string.
|
|
20
|
+
* @returns the same string, branded at compile time.
|
|
21
|
+
*/
|
|
22
|
+
export function WorkspaceId(id) {
|
|
23
|
+
return id;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* An archiveSession request named a session neither live nor in session
|
|
27
|
+
* persistence — a definite miss only; storage faults propagate as themselves.
|
|
28
|
+
*/
|
|
29
|
+
export class WorkspaceUnknownSessionError extends Error {
|
|
30
|
+
sessionId;
|
|
31
|
+
/**
|
|
32
|
+
* @param sessionId - The unknown session id.
|
|
33
|
+
*/
|
|
34
|
+
constructor(sessionId) {
|
|
35
|
+
super(`cannot archive session '${sessionId}': live sessions and session persistence hold no such session`);
|
|
36
|
+
this.sessionId = sessionId;
|
|
37
|
+
this.name = 'WorkspaceUnknownSessionError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** A workspace reorder named a source or anchor absent from the durable registry order. */
|
|
41
|
+
export class WorkspaceOrderInvalidError extends Error {
|
|
42
|
+
workspaceId;
|
|
43
|
+
/**
|
|
44
|
+
* @param workspaceId - Missing source or anchor id.
|
|
45
|
+
*/
|
|
46
|
+
constructor(workspaceId) {
|
|
47
|
+
super(`cannot reorder unknown workspace '${workspaceId}'`);
|
|
48
|
+
this.workspaceId = workspaceId;
|
|
49
|
+
this.name = 'WorkspaceOrderInvalidError';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const sameIds = (left, right) => left.length === right.length && left.every((id, index) => id === right[index]);
|
|
53
|
+
const compareHeaders = (left, right) => right.createdAt - left.createdAt || String(left.id).localeCompare(String(right.id));
|
|
54
|
+
/**
|
|
55
|
+
* Durable workspace registry. Startup waits for `sessionPersistence`, builds
|
|
56
|
+
* one canonical-cwd header index, and completes the one-time history
|
|
57
|
+
* bootstrap before the service becomes active. The persistence dependency is
|
|
58
|
+
* mandatory so an unavailable peer can never be mistaken for an empty
|
|
59
|
+
* history and commit the initialized marker.
|
|
60
|
+
*/
|
|
61
|
+
export class WorkspaceRegistry extends Service {
|
|
62
|
+
static inject = ['storageDomain', 'sessionPersistence'];
|
|
63
|
+
table;
|
|
64
|
+
global;
|
|
65
|
+
state;
|
|
66
|
+
entities = new Map();
|
|
67
|
+
headers = new Map();
|
|
68
|
+
sessionPaths = new Map();
|
|
69
|
+
invalidSessionPaths = new Map();
|
|
70
|
+
operationTail = Promise.resolve();
|
|
71
|
+
host = {
|
|
72
|
+
table: () => this.requireTable(),
|
|
73
|
+
sessionPath: id => this.sessionPaths.get(id),
|
|
74
|
+
readSessionHeader: id => this.readSessionHeader(id),
|
|
75
|
+
rememberSessionPath: (id, path) => {
|
|
76
|
+
this.sessionPaths.set(id, path);
|
|
77
|
+
this.invalidSessionPaths.delete(id);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
constructor(ctx) {
|
|
81
|
+
super(ctx, 'workspaceRegistry');
|
|
82
|
+
}
|
|
83
|
+
/** Open the domain, finish bootstrap when required, and rebuild the ordered cache. */
|
|
84
|
+
async [Service.init]() {
|
|
85
|
+
const domain = await this.ctx.storageDomain.open(workspaceDomainSpec);
|
|
86
|
+
this.ctx.effect(() => () => domain.close(), 'workspace.domainClose');
|
|
87
|
+
this.table = domain.table('workspaces');
|
|
88
|
+
this.global = domain.global;
|
|
89
|
+
this.state = domain.global.get();
|
|
90
|
+
await this.recoverPendingMutation();
|
|
91
|
+
this.validateStoredState(this.state);
|
|
92
|
+
if (!this.state.initialized) {
|
|
93
|
+
const headers = await this.ctx.sessionPersistence.list();
|
|
94
|
+
await this.replaceHeaderIndex(headers);
|
|
95
|
+
await this.bootstrap(headers);
|
|
96
|
+
}
|
|
97
|
+
else if (this.table.size > 0) {
|
|
98
|
+
await this.replaceHeaderIndex(await this.ctx.sessionPersistence.list());
|
|
99
|
+
}
|
|
100
|
+
await this.indexLiveSessions();
|
|
101
|
+
this.validateStoredState(this.requireState());
|
|
102
|
+
this.rebuildEntities();
|
|
103
|
+
this.reportFilteredCandidates();
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Create or reuse a workspace for an existing directory. The path is
|
|
107
|
+
* canonicalized through `fs.realpath`; a nonexistent path rejects with the
|
|
108
|
+
* original error and a non-directory rejects. Repeated calls for the same
|
|
109
|
+
* canonical path return the existing entity without changing its title.
|
|
110
|
+
* A newly created workspace is prepended to the durable registry order.
|
|
111
|
+
* Different canonical paths may share a display title.
|
|
112
|
+
* @param path - Existing directory to own, in any path spelling.
|
|
113
|
+
* @param title - Display title used only when a new record is created.
|
|
114
|
+
* @returns the existing or newly durable workspace.
|
|
115
|
+
*/
|
|
116
|
+
// TODO: `title` lost its last production caller when the gateway's
|
|
117
|
+
// create-by-name branch was deleted
|
|
118
|
+
// (.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md);
|
|
119
|
+
// drop the parameter with its @param clause and the `create(path, title?)`
|
|
120
|
+
// lines in this package's README pair.
|
|
121
|
+
async create(path, title) {
|
|
122
|
+
const canonical = await realpathNormalize(path);
|
|
123
|
+
if (!(await stat(canonical)).isDirectory()) {
|
|
124
|
+
throw new Error(`cannot create a workspace at '${canonical}': path is not a directory`);
|
|
125
|
+
}
|
|
126
|
+
return await this.enqueueOperation(() => this.createCanonical(canonical, title));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Look up a workspace by id.
|
|
130
|
+
* @param id - Workspace id.
|
|
131
|
+
* @returns the workspace, or `undefined` when unknown.
|
|
132
|
+
*/
|
|
133
|
+
get(id) {
|
|
134
|
+
return this.entities.get(id);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Synchronous workspace projection in durable registry order. Every
|
|
138
|
+
* entity's `sessionIds` getter is already filtered by the startup/live
|
|
139
|
+
* canonical-cwd header index; this method performs no persistence reads.
|
|
140
|
+
* @returns a fresh ordered array of workspace entities.
|
|
141
|
+
*/
|
|
142
|
+
list() {
|
|
143
|
+
return this.requireState().workspaceIds.map((id) => {
|
|
144
|
+
const entity = this.entities.get(id);
|
|
145
|
+
if (entity === undefined) {
|
|
146
|
+
throw new Error(`workspace registry order references missing workspace '${id}'`);
|
|
147
|
+
}
|
|
148
|
+
return entity;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Delete one workspace registration while retaining its directory and every
|
|
153
|
+
* session log. The durable order is updated before the table deletion; a
|
|
154
|
+
* failed table write restores the prior order and keeps the entity
|
|
155
|
+
* published. Unknown ids are an idempotent no-op for domain callers.
|
|
156
|
+
* @param id - Workspace registration to remove.
|
|
157
|
+
* @returns `true` when a record was deleted, `false` when it was unknown.
|
|
158
|
+
*/
|
|
159
|
+
delete(id) {
|
|
160
|
+
return this.enqueueOperation(() => this.deleteKnown(id));
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Move one workspace within the durable display order, DOM-insertBefore-like.
|
|
164
|
+
* With an anchor it lands before that workspace; without one it appends.
|
|
165
|
+
* @param id - Workspace to move.
|
|
166
|
+
* @param beforeId - Workspace anchor; omitted appends.
|
|
167
|
+
* @returns the complete committed workspace order.
|
|
168
|
+
*/
|
|
169
|
+
insertBefore(id, beforeId) {
|
|
170
|
+
return this.enqueueOperation(async () => {
|
|
171
|
+
const state = this.requireState();
|
|
172
|
+
if (!state.workspaceIds.includes(id))
|
|
173
|
+
throw new WorkspaceOrderInvalidError(id);
|
|
174
|
+
if (beforeId !== undefined && !state.workspaceIds.includes(beforeId)) {
|
|
175
|
+
throw new WorkspaceOrderInvalidError(beforeId);
|
|
176
|
+
}
|
|
177
|
+
if (beforeId === id)
|
|
178
|
+
return state.workspaceIds;
|
|
179
|
+
const without = state.workspaceIds.filter(workspaceId => workspaceId !== id);
|
|
180
|
+
const at = beforeId === undefined ? without.length : without.indexOf(beforeId);
|
|
181
|
+
const workspaceIds = [...without.slice(0, at), id, ...without.slice(at)];
|
|
182
|
+
if (sameIds(workspaceIds, state.workspaceIds))
|
|
183
|
+
return state.workspaceIds;
|
|
184
|
+
await this.setState({ ...state, workspaceIds });
|
|
185
|
+
return workspaceIds;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The registry-global archive set: sessions hidden from every grouping
|
|
190
|
+
* surface. Archiving never touches workspace accounting — an archived
|
|
191
|
+
* session keeps its `sessionIds` slot so unarchiving restores its position.
|
|
192
|
+
* @returns the archived session ids in archive order.
|
|
193
|
+
*/
|
|
194
|
+
get archivedSessionIds() {
|
|
195
|
+
return this.requireState().archivedSessionIds;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Archive one session durably. The session must exist (live or in session
|
|
199
|
+
* persistence); its workspace accounting — or lack of one — is irrelevant.
|
|
200
|
+
* An already archived id resolves without writing.
|
|
201
|
+
* @param sessionId - The session to archive.
|
|
202
|
+
* @returns resolution after durability.
|
|
203
|
+
*/
|
|
204
|
+
archiveSession(sessionId) {
|
|
205
|
+
return this.enqueueOperation(async () => {
|
|
206
|
+
// The chain slot serializes against every other registry write, so this
|
|
207
|
+
// check-then-write pair cannot interleave with another archive.
|
|
208
|
+
if (this.requireState().archivedSessionIds.includes(sessionId))
|
|
209
|
+
return;
|
|
210
|
+
if (!(await this.sessionKnown(sessionId))) {
|
|
211
|
+
throw new WorkspaceUnknownSessionError(sessionId);
|
|
212
|
+
}
|
|
213
|
+
const state = this.requireState();
|
|
214
|
+
await this.setState({ ...state, archivedSessionIds: [...state.archivedSessionIds, sessionId] });
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Whether a session is live, header-indexed, or present in a fresh
|
|
219
|
+
* persistence listing. Only a definite miss returns false — a failing
|
|
220
|
+
* `sessionPersistence.list()` propagates so storage faults never
|
|
221
|
+
* masquerade as an unknown session.
|
|
222
|
+
*/
|
|
223
|
+
async sessionKnown(id) {
|
|
224
|
+
if (this.ctx.get('sessions')?.get(id) !== undefined)
|
|
225
|
+
return true;
|
|
226
|
+
if (this.headers.has(id))
|
|
227
|
+
return true;
|
|
228
|
+
await this.indexHeaders(await this.ctx.sessionPersistence.list());
|
|
229
|
+
return this.headers.has(id);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Resolve by canonical directory path without creating or mutating a
|
|
233
|
+
* workspace. A missing path rejects during `realpath`; an existing unowned
|
|
234
|
+
* directory returns `undefined`.
|
|
235
|
+
* @param path - Existing directory path in any spelling.
|
|
236
|
+
* @returns the workspace owning the canonical path, when one exists.
|
|
237
|
+
*/
|
|
238
|
+
async resolveByPath(path) {
|
|
239
|
+
const canonical = await realpathNormalize(path);
|
|
240
|
+
for (const entity of this.entities.values()) {
|
|
241
|
+
if (entity.path === canonical)
|
|
242
|
+
return entity;
|
|
243
|
+
}
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
async createCanonical(canonical, title) {
|
|
247
|
+
for (const entity of this.entities.values()) {
|
|
248
|
+
if (entity.path === canonical)
|
|
249
|
+
return entity;
|
|
250
|
+
}
|
|
251
|
+
const workspaceName = title ?? basename(canonical);
|
|
252
|
+
const table = this.requireTable();
|
|
253
|
+
const state = this.requireState();
|
|
254
|
+
const id = WorkspaceId(randomUUID());
|
|
255
|
+
const now = new Date().toISOString();
|
|
256
|
+
const record = {
|
|
257
|
+
path: canonical,
|
|
258
|
+
title: workspaceName,
|
|
259
|
+
sessionIds: [],
|
|
260
|
+
createdAt: now,
|
|
261
|
+
updatedAt: now,
|
|
262
|
+
};
|
|
263
|
+
const entity = new WorkspaceEntity(this.host, id, record);
|
|
264
|
+
this.entities.set(id, entity);
|
|
265
|
+
const pendingState = {
|
|
266
|
+
...state,
|
|
267
|
+
pendingMutation: { operation: 'create', workspaceId: id },
|
|
268
|
+
};
|
|
269
|
+
try {
|
|
270
|
+
await this.setState(pendingState);
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
this.entities.delete(id);
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
await table.put(id, record);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
this.entities.delete(id);
|
|
281
|
+
try {
|
|
282
|
+
await this.setState(state);
|
|
283
|
+
}
|
|
284
|
+
catch (rollbackError) {
|
|
285
|
+
throw new AggregateError([error, rollbackError], `workspace '${id}' record write and pending-marker rollback both failed`);
|
|
286
|
+
}
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
await this.setState({
|
|
291
|
+
initialized: true,
|
|
292
|
+
workspaceIds: [id, ...state.workspaceIds],
|
|
293
|
+
archivedSessionIds: state.archivedSessionIds,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
this.entities.delete(id);
|
|
298
|
+
try {
|
|
299
|
+
await table.delete(id);
|
|
300
|
+
}
|
|
301
|
+
catch (rollbackError) {
|
|
302
|
+
throw new AggregateError([error, rollbackError], `workspace '${id}' order write and record rollback both failed; the pending marker remains recoverable`);
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
await this.setState(state);
|
|
306
|
+
}
|
|
307
|
+
catch (rollbackError) {
|
|
308
|
+
throw new AggregateError([error, rollbackError], `workspace '${id}' order write and pending-marker rollback both failed`);
|
|
309
|
+
}
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
return entity;
|
|
313
|
+
}
|
|
314
|
+
async deleteKnown(id) {
|
|
315
|
+
const entity = this.entities.get(id);
|
|
316
|
+
if (entity === undefined)
|
|
317
|
+
return false;
|
|
318
|
+
const state = this.requireState();
|
|
319
|
+
const nextState = {
|
|
320
|
+
initialized: true,
|
|
321
|
+
workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id),
|
|
322
|
+
archivedSessionIds: state.archivedSessionIds,
|
|
323
|
+
};
|
|
324
|
+
await this.setState({
|
|
325
|
+
...nextState,
|
|
326
|
+
pendingMutation: { operation: 'delete', workspaceId: id },
|
|
327
|
+
});
|
|
328
|
+
this.entities.delete(id);
|
|
329
|
+
try {
|
|
330
|
+
await this.requireTable().delete(id);
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
this.entities.set(id, entity);
|
|
334
|
+
try {
|
|
335
|
+
await this.setState(state);
|
|
336
|
+
}
|
|
337
|
+
catch (rollbackError) {
|
|
338
|
+
// The durable marker still says to finish deletion, so the cache must
|
|
339
|
+
// agree with that recoverable direction rather than republish a row
|
|
340
|
+
// absent from the persisted order.
|
|
341
|
+
this.entities.delete(id);
|
|
342
|
+
throw new AggregateError([error, rollbackError], `workspace '${id}' record deletion and registry-order rollback both failed`);
|
|
343
|
+
}
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
await this.setState(nextState);
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
// The deletion committed at the table write and was already published
|
|
351
|
+
// to Host streams. Keep the durable marker for startup recovery rather
|
|
352
|
+
// than reporting failure after the requested state became true.
|
|
353
|
+
this.ctx.logger.warn(`workspace '${id}' was deleted but its pending marker could not be cleared: ${String(error)}`);
|
|
354
|
+
}
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Complete the one mutation explicitly named by durable state. Unexplained
|
|
359
|
+
* order/table divergence still reaches {@link validateStoredState} and
|
|
360
|
+
* fails loud; this path never guesses which operation created a row from its shape alone.
|
|
361
|
+
*/
|
|
362
|
+
async recoverPendingMutation() {
|
|
363
|
+
const state = this.requireState();
|
|
364
|
+
const pending = state.pendingMutation;
|
|
365
|
+
if (pending === undefined)
|
|
366
|
+
return;
|
|
367
|
+
if (state.workspaceIds.includes(pending.workspaceId)) {
|
|
368
|
+
throw new Error(`workspace domain is inconsistent: pending ${pending.operation} workspace `
|
|
369
|
+
+ `'${pending.workspaceId}' is still present in registry order`);
|
|
370
|
+
}
|
|
371
|
+
await this.requireTable().delete(pending.workspaceId);
|
|
372
|
+
await this.setState({
|
|
373
|
+
initialized: state.initialized,
|
|
374
|
+
workspaceIds: state.workspaceIds,
|
|
375
|
+
archivedSessionIds: state.archivedSessionIds,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
async bootstrap(headers) {
|
|
379
|
+
const table = this.requireTable();
|
|
380
|
+
const state = this.requireState();
|
|
381
|
+
const groupsByPath = new Map();
|
|
382
|
+
for (const header of headers) {
|
|
383
|
+
const path = this.sessionPaths.get(header.id);
|
|
384
|
+
if (path === undefined)
|
|
385
|
+
continue;
|
|
386
|
+
const group = groupsByPath.get(path);
|
|
387
|
+
if (group === undefined)
|
|
388
|
+
groupsByPath.set(path, [header]);
|
|
389
|
+
else
|
|
390
|
+
group.push(header);
|
|
391
|
+
}
|
|
392
|
+
const groups = [...groupsByPath].map(([path, groupHeaders]) => {
|
|
393
|
+
groupHeaders.sort(compareHeaders);
|
|
394
|
+
const newest = groupHeaders[0];
|
|
395
|
+
return { path, headers: groupHeaders, newestAt: newest.createdAt };
|
|
396
|
+
}).sort((left, right) => right.newestAt - left.newestAt || left.path.localeCompare(right.path));
|
|
397
|
+
const byPath = new Map();
|
|
398
|
+
const accounted = new Map();
|
|
399
|
+
for (const [id, record] of table.entries()) {
|
|
400
|
+
byPath.set(record.path, id);
|
|
401
|
+
for (const sessionId of record.sessionIds)
|
|
402
|
+
accounted.set(sessionId, id);
|
|
403
|
+
}
|
|
404
|
+
for (const group of groups) {
|
|
405
|
+
let id = byPath.get(group.path);
|
|
406
|
+
if (id === undefined) {
|
|
407
|
+
const sessionIds = group.headers
|
|
408
|
+
.map(header => header.id)
|
|
409
|
+
.filter(sessionId => !accounted.has(sessionId));
|
|
410
|
+
if (sessionIds.length === 0)
|
|
411
|
+
continue;
|
|
412
|
+
id = WorkspaceId(randomUUID());
|
|
413
|
+
const createdAt = new Date(group.newestAt).toISOString();
|
|
414
|
+
const record = {
|
|
415
|
+
path: group.path,
|
|
416
|
+
title: basename(group.path),
|
|
417
|
+
sessionIds,
|
|
418
|
+
createdAt,
|
|
419
|
+
updatedAt: createdAt,
|
|
420
|
+
};
|
|
421
|
+
await table.put(id, record);
|
|
422
|
+
byPath.set(group.path, id);
|
|
423
|
+
for (const sessionId of sessionIds)
|
|
424
|
+
accounted.set(sessionId, id);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const current = table.get(id);
|
|
428
|
+
const historical = group.headers
|
|
429
|
+
.map(header => header.id)
|
|
430
|
+
.filter(sessionId => accounted.get(sessionId) === undefined || accounted.get(sessionId) === id);
|
|
431
|
+
const historicalSet = new Set(historical);
|
|
432
|
+
const sessionIds = [
|
|
433
|
+
...historical,
|
|
434
|
+
...current.sessionIds.filter(sessionId => !historicalSet.has(sessionId)),
|
|
435
|
+
];
|
|
436
|
+
if (sameSessionIds(current.sessionIds, sessionIds))
|
|
437
|
+
continue;
|
|
438
|
+
await table.update(id, record => ({
|
|
439
|
+
...record,
|
|
440
|
+
sessionIds,
|
|
441
|
+
updatedAt: new Date().toISOString(),
|
|
442
|
+
}));
|
|
443
|
+
for (const sessionId of historical)
|
|
444
|
+
accounted.set(sessionId, id);
|
|
445
|
+
}
|
|
446
|
+
const groupRank = new Map(groups.map(group => [group.path, group.newestAt]));
|
|
447
|
+
const priorRank = new Map(state.workspaceIds.map((id, index) => [id, index]));
|
|
448
|
+
const workspaceIds = [...table.entries()]
|
|
449
|
+
.sort(([leftId, left], [rightId, right]) => {
|
|
450
|
+
const leftTime = groupRank.get(left.path) ?? Date.parse(left.createdAt);
|
|
451
|
+
const rightTime = groupRank.get(right.path) ?? Date.parse(right.createdAt);
|
|
452
|
+
return rightTime - leftTime
|
|
453
|
+
|| (priorRank.get(leftId) ?? Number.MAX_SAFE_INTEGER)
|
|
454
|
+
- (priorRank.get(rightId) ?? Number.MAX_SAFE_INTEGER)
|
|
455
|
+
|| String(leftId).localeCompare(String(rightId));
|
|
456
|
+
})
|
|
457
|
+
.map(([id]) => id);
|
|
458
|
+
if (!sameIds(state.workspaceIds, workspaceIds)) {
|
|
459
|
+
await this.setState({ initialized: false, workspaceIds, archivedSessionIds: state.archivedSessionIds });
|
|
460
|
+
}
|
|
461
|
+
await this.setState({ initialized: true, workspaceIds, archivedSessionIds: state.archivedSessionIds });
|
|
462
|
+
}
|
|
463
|
+
validateStoredState(state) {
|
|
464
|
+
const table = this.requireTable();
|
|
465
|
+
const order = new Set();
|
|
466
|
+
for (const id of state.workspaceIds) {
|
|
467
|
+
if (order.has(id)) {
|
|
468
|
+
throw new Error(`workspace domain is inconsistent: registry order repeats workspace '${id}'`);
|
|
469
|
+
}
|
|
470
|
+
if (table.get(id) === undefined) {
|
|
471
|
+
throw new Error(`workspace domain is inconsistent: registry order references missing workspace '${id}'`);
|
|
472
|
+
}
|
|
473
|
+
order.add(id);
|
|
474
|
+
}
|
|
475
|
+
if (state.initialized && order.size !== table.size) {
|
|
476
|
+
const orphan = [...table.keys()].find(id => !order.has(id));
|
|
477
|
+
throw new Error(`workspace domain is inconsistent: workspace '${orphan}' is absent from registry order`);
|
|
478
|
+
}
|
|
479
|
+
const paths = new Map();
|
|
480
|
+
const accounted = new Map();
|
|
481
|
+
for (const [id, record] of table.entries()) {
|
|
482
|
+
const pathHolder = paths.get(record.path);
|
|
483
|
+
if (pathHolder !== undefined) {
|
|
484
|
+
throw new Error(`workspace domain is inconsistent: path '${record.path}' is claimed `
|
|
485
|
+
+ `by both workspace '${pathHolder}' and workspace '${id}'`);
|
|
486
|
+
}
|
|
487
|
+
paths.set(record.path, id);
|
|
488
|
+
for (const sessionId of record.sessionIds) {
|
|
489
|
+
const holder = accounted.get(sessionId);
|
|
490
|
+
if (holder !== undefined) {
|
|
491
|
+
throw new Error(`workspace domain is inconsistent: session '${sessionId}' is accounted `
|
|
492
|
+
+ `by both workspace '${holder}' and workspace '${id}'`);
|
|
493
|
+
}
|
|
494
|
+
accounted.set(sessionId, id);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
rebuildEntities() {
|
|
499
|
+
this.entities.clear();
|
|
500
|
+
for (const id of this.requireState().workspaceIds) {
|
|
501
|
+
const record = this.requireTable().get(id);
|
|
502
|
+
this.entities.set(id, new WorkspaceEntity(this.host, id, record));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
async replaceHeaderIndex(headers) {
|
|
506
|
+
this.headers.clear();
|
|
507
|
+
this.sessionPaths.clear();
|
|
508
|
+
this.invalidSessionPaths.clear();
|
|
509
|
+
await this.indexHeaders(headers);
|
|
510
|
+
}
|
|
511
|
+
async indexHeaders(headers) {
|
|
512
|
+
for (const header of headers)
|
|
513
|
+
await this.indexHeader(header);
|
|
514
|
+
}
|
|
515
|
+
async indexHeader(header) {
|
|
516
|
+
this.headers.set(header.id, header);
|
|
517
|
+
this.sessionPaths.delete(header.id);
|
|
518
|
+
if (header.cwd === undefined) {
|
|
519
|
+
this.invalidSessionPaths.set(header.id, 'header has no cwd');
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
try {
|
|
523
|
+
const path = await realpathNormalize(header.cwd);
|
|
524
|
+
if (!(await stat(path)).isDirectory()) {
|
|
525
|
+
this.invalidSessionPaths.set(header.id, `cwd '${header.cwd}' is not a directory`);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
this.sessionPaths.set(header.id, path);
|
|
529
|
+
this.invalidSessionPaths.delete(header.id);
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
this.invalidSessionPaths.set(header.id, `cwd '${header.cwd}' does not resolve`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
async indexLiveSessions() {
|
|
536
|
+
const sessions = this.ctx.get('sessions');
|
|
537
|
+
if (sessions === undefined)
|
|
538
|
+
return;
|
|
539
|
+
await this.indexHeaders(sessions.list().map(session => session.header));
|
|
540
|
+
}
|
|
541
|
+
reportFilteredCandidates() {
|
|
542
|
+
for (const entity of this.entities.values()) {
|
|
543
|
+
const record = this.requireTable().get(entity.id);
|
|
544
|
+
for (const sessionId of record.sessionIds) {
|
|
545
|
+
const path = this.sessionPaths.get(sessionId);
|
|
546
|
+
if (path === record.path)
|
|
547
|
+
continue;
|
|
548
|
+
const reason = this.invalidSessionPaths.get(sessionId)
|
|
549
|
+
?? (this.headers.has(sessionId)
|
|
550
|
+
? `canonical cwd '${path}' differs from workspace path '${record.path}'`
|
|
551
|
+
: 'session header is missing');
|
|
552
|
+
this.ctx.logger.warn(`workspace '${entity.id}' filtered session '${sessionId}' from membership: ${reason}`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
async readSessionHeader(id) {
|
|
557
|
+
const live = this.ctx.get('sessions')?.get(id);
|
|
558
|
+
if (live !== undefined) {
|
|
559
|
+
this.headers.set(id, live.header);
|
|
560
|
+
return live.header;
|
|
561
|
+
}
|
|
562
|
+
const cached = this.headers.get(id);
|
|
563
|
+
if (cached !== undefined)
|
|
564
|
+
return cached;
|
|
565
|
+
const headers = await this.ctx.sessionPersistence.list();
|
|
566
|
+
await this.indexHeaders(headers);
|
|
567
|
+
const header = this.headers.get(id);
|
|
568
|
+
if (header === undefined) {
|
|
569
|
+
throw new Error(`cannot validate session '${id}': session persistence holds no such session`);
|
|
570
|
+
}
|
|
571
|
+
return header;
|
|
572
|
+
}
|
|
573
|
+
requireTable() {
|
|
574
|
+
if (this.table === undefined)
|
|
575
|
+
throw new Error('workspace registry is not started yet');
|
|
576
|
+
return this.table;
|
|
577
|
+
}
|
|
578
|
+
requireState() {
|
|
579
|
+
if (this.state === undefined)
|
|
580
|
+
throw new Error('workspace registry is not started yet');
|
|
581
|
+
return this.state;
|
|
582
|
+
}
|
|
583
|
+
async setState(state) {
|
|
584
|
+
await this.global.set(state);
|
|
585
|
+
this.state = state;
|
|
586
|
+
}
|
|
587
|
+
enqueueOperation(operation) {
|
|
588
|
+
const result = this.operationTail.then(async () => {
|
|
589
|
+
// A committed delete may leave only its marker cleanup pending. Retry
|
|
590
|
+
// recovery before another create/delete can overwrite that pending operation record.
|
|
591
|
+
await this.recoverPendingMutation();
|
|
592
|
+
return await operation();
|
|
593
|
+
});
|
|
594
|
+
this.operationTail = result.then(() => { }, () => { });
|
|
595
|
+
return result;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const sameSessionIds = (left, right) => left.length === right.length && left.every((id, index) => id === right[index]);
|
|
599
|
+
export default WorkspaceRegistry;
|
|
600
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@prettier-ai/dsh-workspace`.
|
|
3
|
+
* @module @prettier-ai/dsh-workspace/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@prettier-ai/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "workspace-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@prettier-ai/dsh-workspace`.
|
|
3
|
+
* @module @prettier-ai/dsh-workspace/invariant
|
|
4
|
+
*/
|
|
5
|
+
import { WorkspaceId } from '@prettier-ai/dsh-workspace';
|
|
6
|
+
const PACKAGE_NAME = '@prettier-ai/dsh-workspace';
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
export const name = 'workspace-invariant';
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
export const inject = ['invariants'];
|
|
11
|
+
/**
|
|
12
|
+
* Owned relationship: the registry's entity cache mirrors the workspace
|
|
13
|
+
* domain's durable table. Every `domain/changed` for the `workspaces` table
|
|
14
|
+
* must name a record the cache already holds an entity for (the registry
|
|
15
|
+
* caches before the durable put and mutates only through cached entities).
|
|
16
|
+
* A delete is valid only after the registry has removed the entity from its
|
|
17
|
+
* cache, whether for create rollback or an explicit registration deletion;
|
|
18
|
+
* deleting while the cache still publishes the entity proves a bypass.
|
|
19
|
+
*/
|
|
20
|
+
const install = Object.assign((ctx, fail) => {
|
|
21
|
+
ctx.on('domain/changed', (change) => {
|
|
22
|
+
if (change.domain !== 'workspace' || change.table !== 'workspaces')
|
|
23
|
+
return;
|
|
24
|
+
if (change.operation === 'deleted') {
|
|
25
|
+
if (ctx.workspaceRegistry.get(WorkspaceId(change.key)) !== undefined) {
|
|
26
|
+
fail(`workspace record '${change.key}' was deleted while the registry cache still `
|
|
27
|
+
+ 'publishes it — some write path bypassed ctx.workspaceRegistry');
|
|
28
|
+
}
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (ctx.workspaceRegistry.get(WorkspaceId(change.key)) === undefined) {
|
|
32
|
+
fail(`workspace record '${change.key}' landed durably but the registry cache holds `
|
|
33
|
+
+ 'no entity for it — the cache and the domain table have diverged');
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}, { inject: ['workspaceRegistry'] });
|
|
37
|
+
/**
|
|
38
|
+
* Register this package's invariant companion.
|
|
39
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
40
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
41
|
+
*/
|
|
42
|
+
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
43
|
+
//# sourceMappingURL=invariant.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path canonicalization for workspace identity.
|
|
3
|
+
* @module @prettier-ai/dsh-workspace/src/paths
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Canonicalize a directory path via `fs.realpath`: trailing slashes, `..`
|
|
7
|
+
* segments, and symlinks are all resolved. This is the ONE uniqueness canon of
|
|
8
|
+
* the package — workspace paths are stored canonicalized, uniqueness is
|
|
9
|
+
* string equality of canonicalized paths (a symlink to an existing
|
|
10
|
+
* workspace's directory collides), and attach-time session `cwd` checks go
|
|
11
|
+
* through the same canon. A path that does not exist rejects with the
|
|
12
|
+
* original `ENOENT` — this is `create`'s reject path (a workspace must point
|
|
13
|
+
* at an existing directory).
|
|
14
|
+
* @param path - The path to canonicalize.
|
|
15
|
+
* @returns the canonical absolute path.
|
|
16
|
+
*/
|
|
17
|
+
export declare function realpathNormalize(path: string): Promise<string>;
|
|
18
|
+
//# sourceMappingURL=paths.d.ts.map
|