@1yefuwang1/dsh-worktrees 0.0.0-stage → 0.2.13
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/CHANGELOG.md +218 -0
- package/LICENSE +21 -0
- package/README.md +471 -2
- package/client.js +1398 -0
- package/cordis.patch.yml +6 -0
- package/dist/context.d.ts +20 -0
- package/dist/context.js +70 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +30 -0
- package/dist/first-message.d.ts +19 -0
- package/dist/first-message.js +121 -0
- package/dist/git.d.ts +45 -0
- package/dist/git.js +806 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +183 -0
- package/dist/naming.d.ts +25 -0
- package/dist/naming.js +151 -0
- package/dist/project-context.d.ts +20 -0
- package/dist/project-context.js +82 -0
- package/dist/project-rpc.d.ts +10 -0
- package/dist/project-rpc.js +103 -0
- package/dist/project-store.d.ts +121 -0
- package/dist/project-store.js +32 -0
- package/dist/projects.d.ts +149 -0
- package/dist/projects.js +603 -0
- package/dist/quiet-rpc.d.ts +7 -0
- package/dist/quiet-rpc.js +125 -0
- package/dist/rpc.d.ts +10 -0
- package/dist/rpc.js +418 -0
- package/dist/runtime.d.ts +22 -0
- package/dist/runtime.js +123 -0
- package/dist/schema.d.ts +50 -0
- package/dist/schema.js +86 -0
- package/dist/service.d.ts +98 -0
- package/dist/service.js +943 -0
- package/dist/sessions.d.ts +31 -0
- package/dist/sessions.js +130 -0
- package/dist/store.d.ts +114 -0
- package/dist/store.js +44 -0
- package/dist/types.d.ts +262 -0
- package/dist/types.js +1 -0
- package/icon.svg +1 -0
- package/locale/en.json +6 -0
- package/package.json +106 -3
package/dist/projects.js
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import { basename, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
6
|
+
import { abortIfRequested, WorktreeError } from './errors.js';
|
|
7
|
+
import { localProjectPath, planBlocksMutation, policyOf, required } from './runtime.js';
|
|
8
|
+
import { SessionBootstrap } from './sessions.js';
|
|
9
|
+
import { projectBindingSchema, projectRecordSchema } from './project-store.js';
|
|
10
|
+
/** Legacy records select their first folder; an explicit main ID must name a member. */
|
|
11
|
+
export function resolveMainFolder(project) {
|
|
12
|
+
const folder = project.mainFolderId === undefined ? project.folders[0] : project.folders.find(value => value.id === project.mainFolderId);
|
|
13
|
+
if (folder === undefined)
|
|
14
|
+
throw new WorktreeError('PROJECT_FOLDER_NOT_FOUND', 'The project main folder is not registered.');
|
|
15
|
+
return folder;
|
|
16
|
+
}
|
|
17
|
+
const clone = (value) => structuredClone(value);
|
|
18
|
+
const text = z.string().min(1).max(256).regex(/^[^\u0000-\u001f\u007f]+$/u);
|
|
19
|
+
const pathSchema = z.string().min(1).max(4096).refine(value => isAbsolute(value) && !/[\u0000-\u001f\u007f]/u.test(value));
|
|
20
|
+
const titleSchema = z.string().trim().min(1).max(120).regex(/^[^\u0000-\u001f\u007f]+$/u);
|
|
21
|
+
const foldersSchema = z.array(pathSchema).min(1).max(32);
|
|
22
|
+
const requestSchema = z.discriminatedUnion('action', [
|
|
23
|
+
z.object({ action: z.literal('list'), projectId: z.string().uuid().optional() }).strict(),
|
|
24
|
+
z.object({ action: z.literal('create'), id: z.string().uuid(), title: titleSchema, folders: foldersSchema, mainFolder: pathSchema.optional() }).strict(),
|
|
25
|
+
z.object({ action: z.literal('update'), projectId: z.string().uuid(), title: titleSchema.optional(), folders: foldersSchema.optional(), mainFolder: pathSchema.optional() }).strict().refine(value => value.title !== undefined || value.folders !== undefined || value.mainFolder !== undefined),
|
|
26
|
+
z.object({ action: z.literal('remove'), projectId: z.string().uuid() }).strict(),
|
|
27
|
+
z.object({ action: z.literal('bind'), projectId: z.string().uuid(), folderId: text, sessionId: text }).strict(),
|
|
28
|
+
z.object({ action: z.literal('start'), operationId: z.string().uuid(), projectId: z.string().uuid(), folderId: text.optional() }).strict(),
|
|
29
|
+
]);
|
|
30
|
+
/** Lossless, bounded plain JSON validation shared by the tool parser and quiet RPC. */
|
|
31
|
+
export function projectJsonSnapshot(value, code = 'INVALID_REQUEST') {
|
|
32
|
+
const limit = 1048576;
|
|
33
|
+
let bytes = 0;
|
|
34
|
+
let nodes = 0;
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
const invalid = () => { throw new WorktreeError(code, 'Bounded, lossless plain JSON is required.'); };
|
|
37
|
+
const charge = (count) => { if ((bytes += count) > limit)
|
|
38
|
+
invalid(); };
|
|
39
|
+
const visit = (input, depth) => {
|
|
40
|
+
if (++nodes > 65536 || depth > 16)
|
|
41
|
+
invalid();
|
|
42
|
+
if (input === null || typeof input === 'boolean') {
|
|
43
|
+
charge(input === false ? 5 : 4);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (typeof input === 'string') {
|
|
47
|
+
if (Buffer.byteLength(input) > limit)
|
|
48
|
+
invalid();
|
|
49
|
+
charge(Buffer.byteLength(JSON.stringify(input)));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (typeof input === 'number') {
|
|
53
|
+
if (!Number.isFinite(input) || Object.is(input, -0) || (Number.isInteger(input) && !Number.isSafeInteger(input)))
|
|
54
|
+
invalid();
|
|
55
|
+
charge(String(input).length);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (typeof input !== 'object' || input === null || seen.has(input))
|
|
59
|
+
invalid();
|
|
60
|
+
const object = input;
|
|
61
|
+
const array = Array.isArray(object);
|
|
62
|
+
const prototype = Object.getPrototypeOf(object);
|
|
63
|
+
if (array ? prototype !== Array.prototype : prototype !== Object.prototype && prototype !== null)
|
|
64
|
+
invalid();
|
|
65
|
+
seen.add(object);
|
|
66
|
+
charge(2);
|
|
67
|
+
const keys = Reflect.ownKeys(object);
|
|
68
|
+
if (keys.length > 65536)
|
|
69
|
+
invalid();
|
|
70
|
+
let elements = 0;
|
|
71
|
+
for (const key of keys) {
|
|
72
|
+
if (array && key === 'length')
|
|
73
|
+
continue;
|
|
74
|
+
if (typeof key !== 'string')
|
|
75
|
+
invalid();
|
|
76
|
+
const name = key;
|
|
77
|
+
const descriptor = Object.getOwnPropertyDescriptor(object, name);
|
|
78
|
+
if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor))
|
|
79
|
+
invalid();
|
|
80
|
+
if (array && name !== String(elements))
|
|
81
|
+
invalid();
|
|
82
|
+
if (elements++ > 0)
|
|
83
|
+
charge(1);
|
|
84
|
+
if (!array)
|
|
85
|
+
charge(Buffer.byteLength(JSON.stringify(name)) + 1);
|
|
86
|
+
visit(descriptor.value, depth + 1);
|
|
87
|
+
}
|
|
88
|
+
if (array && elements !== object.length)
|
|
89
|
+
invalid();
|
|
90
|
+
seen.delete(object);
|
|
91
|
+
};
|
|
92
|
+
visit(value, 0);
|
|
93
|
+
return JSON.parse(JSON.stringify(value));
|
|
94
|
+
}
|
|
95
|
+
export function parseProjectRequest(input) {
|
|
96
|
+
const parsed = requestSchema.safeParse(projectJsonSnapshot(input));
|
|
97
|
+
if (!parsed.success)
|
|
98
|
+
throw new WorktreeError('INVALID_REQUEST', 'Invalid project action or fields. Use absolute existing folder paths.');
|
|
99
|
+
return parsed.data;
|
|
100
|
+
}
|
|
101
|
+
const uuidParameter = { type: 'string', format: 'uuid' };
|
|
102
|
+
const idParameter = { type: 'string', minLength: 1, maxLength: 256, pattern: '^[^\\u0000-\\u001f\\u007f]+$' };
|
|
103
|
+
const folderParameter = { type: 'array', minItems: 1, maxItems: 32, items: { type: 'string', minLength: 1, maxLength: 4096, description: 'Absolute path to an existing local directory. Canonical duplicates are rejected.' } };
|
|
104
|
+
const titleParameter = { type: 'string', minLength: 1, maxLength: 120 };
|
|
105
|
+
const mainFolderParameter = { type: 'string', minLength: 1, maxLength: 4096 };
|
|
106
|
+
const variant = (action, properties, requiredFields) => ({ type: 'object', additionalProperties: false, properties: { action: { const: action, type: 'string' }, ...properties }, required: ['action', ...requiredFields] });
|
|
107
|
+
/** Plain JSON schema, not a Zod object. Folder updates replace the complete folder array. */
|
|
108
|
+
export const parameterSchema = { type: 'object', oneOf: [
|
|
109
|
+
variant('list', { projectId: uuidParameter }, []), variant('create', { id: uuidParameter, title: titleParameter, folders: folderParameter, mainFolder: { ...mainFolderParameter, description: 'Absolute existing source directory in folders (canonical match); omitted uses the first folder.' } }, ['id', 'title', 'folders']),
|
|
110
|
+
{ ...variant('update', { projectId: uuidParameter, title: titleParameter, folders: folderParameter, mainFolder: { ...mainFolderParameter, description: 'Absolute existing source directory in resulting folders (canonical match). Omitted retains the main, or uses the sole remaining folder; supply a replacement to remove the main when multiple folders remain.' } }, ['projectId']), anyOf: [{ required: ['title'] }, { required: ['folders'] }, { required: ['mainFolder'] }] },
|
|
111
|
+
variant('remove', { projectId: { ...uuidParameter, description: 'Remove only project metadata and membership. Folders, files, conversations and worktrees remain; use a fresh UUID to re-add.' } }, ['projectId']),
|
|
112
|
+
variant('bind', { projectId: uuidParameter, folderId: idParameter, sessionId: idParameter }, ['projectId', 'folderId', 'sessionId']),
|
|
113
|
+
variant('start', { operationId: uuidParameter, projectId: uuidParameter, folderId: { ...idParameter, description: 'Override the main folder for this new start. Omitted uses the main; replay reuses the original receipt folder and session.' } }, ['operationId', 'projectId']),
|
|
114
|
+
] };
|
|
115
|
+
export const projectParameterSchema = parameterSchema;
|
|
116
|
+
function contained(root, path) { const part = relative(root, path); return part === '' || (!part.startsWith(`..${sep}`) && part !== '..' && !isAbsolute(part)); }
|
|
117
|
+
/** Durable project membership never changes native workspace paths, session cwd, files or history. */
|
|
118
|
+
export class ProjectController {
|
|
119
|
+
owner;
|
|
120
|
+
store;
|
|
121
|
+
worktrees;
|
|
122
|
+
dependencies;
|
|
123
|
+
bootstrap;
|
|
124
|
+
shutdown = new AbortController();
|
|
125
|
+
flights = new Set();
|
|
126
|
+
headers = new Map();
|
|
127
|
+
queue = Promise.resolve();
|
|
128
|
+
closed = false;
|
|
129
|
+
closing;
|
|
130
|
+
constructor(owner, store, worktrees, dependencies = {}) {
|
|
131
|
+
this.owner = owner;
|
|
132
|
+
this.store = store;
|
|
133
|
+
this.worktrees = worktrees;
|
|
134
|
+
this.dependencies = dependencies;
|
|
135
|
+
this.bootstrap = dependencies.bootstrap ?? new SessionBootstrap(owner);
|
|
136
|
+
}
|
|
137
|
+
records() { return [...this.store.projects.entries()].filter(([id]) => this.store.removals.get(id) === undefined).map(([, record]) => clone(record)); }
|
|
138
|
+
/** Internal read-only selection from a known managed row, never from a fabricated session ID. */
|
|
139
|
+
selectionForWorktree(worktreeId) {
|
|
140
|
+
const records = this.worktrees.records();
|
|
141
|
+
const record = records.find(value => value.id === worktreeId);
|
|
142
|
+
return record === undefined ? undefined : this.worktreeFolder(record, records);
|
|
143
|
+
}
|
|
144
|
+
resolveFolder(projectId, folderId) {
|
|
145
|
+
const project = this.store.removals.get(projectId) === undefined ? this.store.projects.get(projectId) : undefined;
|
|
146
|
+
const folder = project === undefined ? undefined : folderId === undefined ? resolveMainFolder(project) : project.folders.find(value => value.id === folderId);
|
|
147
|
+
if (folder === undefined)
|
|
148
|
+
throw new WorktreeError('PROJECT_FOLDER_NOT_FOUND', 'The selected project folder is not registered.');
|
|
149
|
+
return clone(folder);
|
|
150
|
+
}
|
|
151
|
+
startFolder(request) {
|
|
152
|
+
const receipt = this.store.starts.get(request.operationId);
|
|
153
|
+
// A default replay is pinned to its receipt, never to a subsequently changed main.
|
|
154
|
+
const folderId = request.folderId ?? (receipt?.projectId === request.projectId ? receipt.folderId : undefined);
|
|
155
|
+
return this.resolveFolder(request.projectId, folderId);
|
|
156
|
+
}
|
|
157
|
+
folderForPath(path) {
|
|
158
|
+
for (const record of this.records()) {
|
|
159
|
+
const folder = record.folders.find(value => value.path === path);
|
|
160
|
+
if (folder !== undefined)
|
|
161
|
+
return { projectId: record.id, folderId: folder.id, path: folder.path };
|
|
162
|
+
}
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
bindingFor(sessionId, cwd) {
|
|
166
|
+
const header = this.headers.get(sessionId);
|
|
167
|
+
if (header?.origin === 'subagent')
|
|
168
|
+
return undefined;
|
|
169
|
+
const path = cwd ?? header?.cwd;
|
|
170
|
+
// A known or supplied cwd always wins over an old receipt, especially a Local handoff.
|
|
171
|
+
if (path !== undefined)
|
|
172
|
+
return this.infer(sessionId, path);
|
|
173
|
+
const receipt = this.store.bindings.get(sessionId);
|
|
174
|
+
return receipt === undefined ? undefined : this.infer(sessionId, receipt.effectiveCwd);
|
|
175
|
+
}
|
|
176
|
+
execute(request, invocation) {
|
|
177
|
+
const signal = AbortSignal.any([invocation.signal, this.shutdown.signal, AbortSignal.timeout(this.dependencies.operationTimeoutMs ?? 120000)]);
|
|
178
|
+
const input = { ...invocation, signal };
|
|
179
|
+
return this.serial(async () => {
|
|
180
|
+
const parsed = parseProjectRequest(request);
|
|
181
|
+
const startFolder = this.guard(parsed, input);
|
|
182
|
+
if (parsed.action === 'remove')
|
|
183
|
+
return this.removeProject(parsed, input);
|
|
184
|
+
await this.synchronizeNow(signal);
|
|
185
|
+
this.guard(parsed, input, startFolder);
|
|
186
|
+
if (parsed.action === 'list')
|
|
187
|
+
return this.snapshot(parsed.projectId);
|
|
188
|
+
if (parsed.action === 'create' || parsed.action === 'update')
|
|
189
|
+
return this.changeProject(parsed, input);
|
|
190
|
+
if (parsed.action === 'bind') {
|
|
191
|
+
const folder = this.resolveFolder(parsed.projectId, parsed.folderId);
|
|
192
|
+
const header = await this.inspect(parsed.sessionId, signal);
|
|
193
|
+
const binding = header.cwd === undefined || header.origin === 'subagent' ? undefined : this.infer(parsed.sessionId, header.cwd);
|
|
194
|
+
if (binding === undefined || binding.projectId !== parsed.projectId || binding.folderId !== folder.id)
|
|
195
|
+
throw new WorktreeError('PROJECT_BINDING_MISMATCH', 'The ordinary session cwd does not belong to this exact project folder.');
|
|
196
|
+
this.guard(parsed, input);
|
|
197
|
+
await this.store.bindings.put(binding.sessionId, binding);
|
|
198
|
+
return { binding: clone(binding) };
|
|
199
|
+
}
|
|
200
|
+
return this.start(parsed, input, startFolder);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
synchronize() { return this.serial(() => this.synchronizeNow(this.shutdown.signal)); }
|
|
204
|
+
/** Internal metadata-only import after admission; removed sources remain independent of projects. */
|
|
205
|
+
ensureFolder(path, signal) {
|
|
206
|
+
return this.serial(async () => {
|
|
207
|
+
const lifetime = signal === undefined ? this.shutdown.signal : AbortSignal.any([signal, this.shutdown.signal]);
|
|
208
|
+
abortIfRequested(lifetime);
|
|
209
|
+
if (!isAbsolute(path) || /[\u0000-\u001f\u007f]/u.test(path))
|
|
210
|
+
throw new WorktreeError('INVALID_PROJECT_PATH', 'An absolute existing source folder is required.');
|
|
211
|
+
const canonical = (await this.canonicalFolders([path]))[0];
|
|
212
|
+
const existing = this.folderForPath(canonical);
|
|
213
|
+
if (existing !== undefined)
|
|
214
|
+
return existing;
|
|
215
|
+
abortIfRequested(lifetime);
|
|
216
|
+
const registry = required(this.owner, 'workspaceRegistry');
|
|
217
|
+
if (this.suppressedFolder(canonical, registry.list().find(workspace => workspace.path === canonical)?.id))
|
|
218
|
+
return undefined;
|
|
219
|
+
await registry.create(canonical);
|
|
220
|
+
await this.synchronizeNow(lifetime);
|
|
221
|
+
const imported = this.folderForPath(canonical);
|
|
222
|
+
if (imported === undefined)
|
|
223
|
+
throw new WorktreeError('PROJECT_FOLDER_NOT_FOUND', 'The source folder could not be imported.');
|
|
224
|
+
return imported;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
recordThread(binding) {
|
|
228
|
+
return this.serial(async () => {
|
|
229
|
+
const parsed = projectBindingSchema.safeParse(projectJsonSnapshot(binding));
|
|
230
|
+
if (!parsed.success)
|
|
231
|
+
throw new WorktreeError('PROJECT_BINDING_MISMATCH', 'Invalid internal thread receipt.');
|
|
232
|
+
const header = await this.inspect(binding.sessionId);
|
|
233
|
+
const actual = header.origin === 'subagent' || header.cwd === undefined ? undefined : this.infer(binding.sessionId, header.cwd);
|
|
234
|
+
if (actual === undefined || actual.sessionId !== parsed.data.sessionId || actual.projectId !== parsed.data.projectId || actual.folderId !== parsed.data.folderId || actual.mode !== parsed.data.mode || actual.effectiveCwd !== parsed.data.effectiveCwd || actual.worktreeId !== parsed.data.worktreeId)
|
|
235
|
+
throw new WorktreeError('PROJECT_BINDING_MISMATCH', 'The receipt does not match the exact ordinary session cwd and backing checkout.');
|
|
236
|
+
await this.store.bindings.put(actual.sessionId, clone(actual));
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
close() {
|
|
240
|
+
return this.closing ??= (async () => { this.closed = true; this.shutdown.abort(); await Promise.allSettled([...this.flights]); await this.bootstrap.close(); this.headers.clear(); })();
|
|
241
|
+
}
|
|
242
|
+
serial(operation) {
|
|
243
|
+
if (this.closed)
|
|
244
|
+
return Promise.reject(new WorktreeError('CLOSED', 'The project controller is closing.'));
|
|
245
|
+
const task = this.queue.then(operation);
|
|
246
|
+
this.queue = task.catch(() => undefined);
|
|
247
|
+
this.flights.add(task);
|
|
248
|
+
void task.then(() => this.flights.delete(task), () => this.flights.delete(task));
|
|
249
|
+
return task;
|
|
250
|
+
}
|
|
251
|
+
guard(request, invocation, startFolder) {
|
|
252
|
+
abortIfRequested(invocation.signal);
|
|
253
|
+
const agent = invocation.agent;
|
|
254
|
+
if (agent === undefined) {
|
|
255
|
+
if (invocation.origin !== 'ui' || request.action === 'bind')
|
|
256
|
+
throw new WorktreeError('NO_CALLER', 'A genuine invoking ordinary session is required.');
|
|
257
|
+
return request.action === 'start' ? startFolder ?? this.startFolder(request) : undefined;
|
|
258
|
+
}
|
|
259
|
+
if (!this.bootstrap.isLive(agent) || this.bootstrap.actor(agent.id) !== agent)
|
|
260
|
+
throw new WorktreeError('SESSION_CHANGED', 'The invoking session is no longer the exact ordinary live session.');
|
|
261
|
+
if (invocation.origin === 'tool' && request.action !== 'list' && planBlocksMutation(agent))
|
|
262
|
+
throw new WorktreeError('PLAN_MODE', 'Project mutations are unavailable while plan mode is active or pending enabled.');
|
|
263
|
+
if (request.action === 'start') {
|
|
264
|
+
const folder = startFolder ?? this.startFolder(request);
|
|
265
|
+
const policy = policyOf(agent);
|
|
266
|
+
if (!isAbsolute(policy.workspaceRoot) || (!contained(policy.workspaceRoot, folder.path) && policy.mode !== 'danger-full-access'))
|
|
267
|
+
throw new WorktreeError('FULL_ACCESS_REQUIRED', 'Starting a conversation outside the caller workspace requires existing full access; metadata never grants permissions.');
|
|
268
|
+
return folder;
|
|
269
|
+
}
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
infer(sessionId, cwd) {
|
|
273
|
+
const local = this.folderForPath(cwd);
|
|
274
|
+
if (local !== undefined)
|
|
275
|
+
return { sessionId, projectId: local.projectId, folderId: local.folderId, mode: 'local', effectiveCwd: cwd };
|
|
276
|
+
const records = this.worktrees.records();
|
|
277
|
+
for (const record of records) {
|
|
278
|
+
if (record.effectiveCwd !== cwd)
|
|
279
|
+
continue;
|
|
280
|
+
const source = this.worktreeFolder(record, records);
|
|
281
|
+
if (source !== undefined)
|
|
282
|
+
return { sessionId, projectId: source.projectId, folderId: source.folderId, mode: 'worktree', effectiveCwd: cwd, worktreeId: record.id };
|
|
283
|
+
}
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
worktreeFolder(record, records) {
|
|
287
|
+
const hint = (row) => {
|
|
288
|
+
const ownership = row;
|
|
289
|
+
if (ownership.folderId === undefined)
|
|
290
|
+
return undefined;
|
|
291
|
+
const projects = this.records();
|
|
292
|
+
const exact = projects.find(project => project.id === ownership.projectId)?.folders.find(folder => folder.id === ownership.folderId);
|
|
293
|
+
if (exact !== undefined)
|
|
294
|
+
return { projectId: ownership.projectId, folderId: exact.id, path: exact.path };
|
|
295
|
+
// Imported-project adoption keeps the native folder ID stable while replacing its logical owner.
|
|
296
|
+
for (const project of projects) {
|
|
297
|
+
const folder = project.folders.find(value => value.id === ownership.folderId);
|
|
298
|
+
if (folder !== undefined)
|
|
299
|
+
return { projectId: project.id, folderId: folder.id, path: folder.path };
|
|
300
|
+
}
|
|
301
|
+
return undefined;
|
|
302
|
+
};
|
|
303
|
+
const captured = hint(record);
|
|
304
|
+
if (captured !== undefined)
|
|
305
|
+
return captured;
|
|
306
|
+
let root = record.repoRoot;
|
|
307
|
+
let sourcePath = resolve(root, record.projectSubdir);
|
|
308
|
+
const seen = new Set([record.id]);
|
|
309
|
+
// Older rows can point to another managed checkout as their Git source. Map only known,
|
|
310
|
+
// unambiguous roots of the same repository; no filesystem/Git lookup occurs on a context read.
|
|
311
|
+
for (let depth = 0; depth < 64; depth++) {
|
|
312
|
+
const source = this.folderForPath(sourcePath);
|
|
313
|
+
if (source !== undefined)
|
|
314
|
+
return source;
|
|
315
|
+
if (typeof record.commonDir !== 'string' || record.commonDir.length === 0 || !contained(root, sourcePath))
|
|
316
|
+
return undefined;
|
|
317
|
+
const parents = records.filter(row => row.checkoutRoot === root && row.commonDir === record.commonDir);
|
|
318
|
+
if (parents.length !== 1)
|
|
319
|
+
return undefined;
|
|
320
|
+
const parent = parents[0];
|
|
321
|
+
if (seen.has(parent.id))
|
|
322
|
+
return undefined;
|
|
323
|
+
seen.add(parent.id);
|
|
324
|
+
// A parent ownership hint applies only to its exact selected project subdirectory.
|
|
325
|
+
if (sourcePath === parent.effectiveCwd) {
|
|
326
|
+
const inherited = hint(parent);
|
|
327
|
+
if (inherited !== undefined)
|
|
328
|
+
return inherited;
|
|
329
|
+
}
|
|
330
|
+
const subdir = relative(parent.checkoutRoot, sourcePath);
|
|
331
|
+
root = parent.repoRoot;
|
|
332
|
+
sourcePath = resolve(root, subdir);
|
|
333
|
+
}
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
async inspect(id, signal, requireBlank = false) {
|
|
337
|
+
const observation = await required(this.owner, 'sessionQuery').observeSession(SessionId(id), { signal, projectionMode: 'none' });
|
|
338
|
+
let header;
|
|
339
|
+
try {
|
|
340
|
+
if (observation.header.id !== id)
|
|
341
|
+
throw new WorktreeError('SESSION_CHANGED', 'The inspected session identity changed.');
|
|
342
|
+
if (requireBlank && (observation.header.isSeeded === true || observation.events.some(event => event.type === 'turn/start' || event.type === 'user/message')))
|
|
343
|
+
throw new WorktreeError('SESSION_NOT_BLANK', 'The requested new thread already has conversation history; this start will not adopt or duplicate it.');
|
|
344
|
+
header = clone(observation.header);
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
observation[Symbol.dispose]();
|
|
348
|
+
}
|
|
349
|
+
if (header.cwd !== undefined) {
|
|
350
|
+
const native = required(this.owner, 'workspaceRegistry').list().find(workspace => workspace.sessionIds.includes(id));
|
|
351
|
+
if (native !== undefined)
|
|
352
|
+
header.cwd = native.path;
|
|
353
|
+
else {
|
|
354
|
+
try {
|
|
355
|
+
header.cwd = await realpath(header.cwd);
|
|
356
|
+
}
|
|
357
|
+
catch { /* Missing execution directories cannot invent another membership. */ }
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
this.headers.set(id, header);
|
|
361
|
+
return header;
|
|
362
|
+
}
|
|
363
|
+
snapshot(projectId) {
|
|
364
|
+
const projects = this.records().filter(record => projectId === undefined || record.id === projectId);
|
|
365
|
+
const bindings = [];
|
|
366
|
+
for (const [id] of this.store.bindings.entries()) {
|
|
367
|
+
const binding = this.bindingFor(id);
|
|
368
|
+
if (binding !== undefined && (projectId === undefined || binding.projectId === projectId))
|
|
369
|
+
bindings.push(binding);
|
|
370
|
+
}
|
|
371
|
+
const allRecords = this.worktrees.records();
|
|
372
|
+
const records = allRecords.filter(record => projectId === undefined || this.worktreeFolder(record, allRecords)?.projectId === projectId);
|
|
373
|
+
return projectJsonSnapshot({ projects, bindings, records }, 'RESPONSE_LIMIT');
|
|
374
|
+
}
|
|
375
|
+
async synchronizeNow(signal) {
|
|
376
|
+
abortIfRequested(signal);
|
|
377
|
+
const registry = required(this.owner, 'workspaceRegistry');
|
|
378
|
+
// Receipt-first removal repair must precede adoption, imports and binding inference.
|
|
379
|
+
for (const [id] of this.store.removals.entries()) {
|
|
380
|
+
abortIfRequested(signal);
|
|
381
|
+
await this.finishRemoval(id);
|
|
382
|
+
}
|
|
383
|
+
const worktrees = this.worktrees.records();
|
|
384
|
+
// Repair interrupted imported-folder adoption. Custom projects are the only permitted winners.
|
|
385
|
+
const customs = this.records().filter(record => record.imported !== true);
|
|
386
|
+
for (const imported of this.records().filter(record => record.imported === true)) {
|
|
387
|
+
const retained = imported.folders.filter(folder => !customs.some(project => project.folders.some(value => value.path === folder.path)));
|
|
388
|
+
if (retained.length === imported.folders.length)
|
|
389
|
+
continue;
|
|
390
|
+
if (retained.length === 0)
|
|
391
|
+
await this.store.projects.delete(imported.id);
|
|
392
|
+
else
|
|
393
|
+
await this.store.projects.put(imported.id, { ...imported, folders: retained, mainFolderId: retained.find(folder => folder.id === imported.mainFolderId)?.id ?? retained[0].id, updatedAt: Date.now() });
|
|
394
|
+
}
|
|
395
|
+
// Additive v1 repair: metadata only, with stable folder ordering and no session changes.
|
|
396
|
+
for (const project of this.records()) {
|
|
397
|
+
abortIfRequested(signal);
|
|
398
|
+
if (project.mainFolderId === undefined)
|
|
399
|
+
await this.store.projects.put(project.id, { ...project, mainFolderId: resolveMainFolder(project).id });
|
|
400
|
+
}
|
|
401
|
+
for (const workspace of registry.list()) {
|
|
402
|
+
abortIfRequested(signal);
|
|
403
|
+
if (this.folderForPath(workspace.path) !== undefined || this.suppressedFolder(workspace.path, workspace.id) || worktrees.some(record => contained(record.checkoutRoot, workspace.path)))
|
|
404
|
+
continue;
|
|
405
|
+
const now = Date.now();
|
|
406
|
+
const project = { id: randomUUID(), title: workspace.title.trim().slice(0, 120) || basename(workspace.path) || 'Project', folders: [{ id: workspace.id, path: workspace.path, title: workspace.title }], mainFolderId: workspace.id, createdAt: now, updatedAt: now, imported: true };
|
|
407
|
+
await this.store.projects.put(project.id, project);
|
|
408
|
+
}
|
|
409
|
+
const nativePaths = new Map(registry.list().flatMap(workspace => workspace.sessionIds.map(id => [id, workspace.path])));
|
|
410
|
+
const sessions = await required(this.owner, 'sessionQuery').listSessions(signal);
|
|
411
|
+
abortIfRequested(signal);
|
|
412
|
+
this.headers.clear();
|
|
413
|
+
for (const { header: sourceHeader } of sessions) {
|
|
414
|
+
const header = clone(sourceHeader);
|
|
415
|
+
const indexedCwd = nativePaths.get(header.id);
|
|
416
|
+
if (header.cwd !== undefined && indexedCwd !== undefined)
|
|
417
|
+
header.cwd = indexedCwd;
|
|
418
|
+
this.headers.set(header.id, header);
|
|
419
|
+
const binding = header.origin === 'subagent' || header.cwd === undefined ? undefined : this.infer(header.id, header.cwd);
|
|
420
|
+
const stored = this.store.bindings.get(header.id);
|
|
421
|
+
if (binding === undefined) {
|
|
422
|
+
if (stored !== undefined)
|
|
423
|
+
await this.store.bindings.delete(header.id);
|
|
424
|
+
}
|
|
425
|
+
else if (JSON.stringify(stored) !== JSON.stringify(binding))
|
|
426
|
+
await this.store.bindings.put(header.id, binding);
|
|
427
|
+
}
|
|
428
|
+
// The registry's exact canonical-cwd index is authoritative even for old cold sessions.
|
|
429
|
+
for (const [id, path] of nativePaths) {
|
|
430
|
+
if (this.headers.get(id)?.origin === 'subagent')
|
|
431
|
+
continue;
|
|
432
|
+
const header = this.headers.get(id);
|
|
433
|
+
this.headers.set(id, { ...header, id, cwd: path });
|
|
434
|
+
const binding = this.bindingFor(id, path);
|
|
435
|
+
if (binding !== undefined && JSON.stringify(this.store.bindings.get(id)) !== JSON.stringify(binding))
|
|
436
|
+
await this.store.bindings.put(id, binding);
|
|
437
|
+
}
|
|
438
|
+
for (const [id] of this.store.bindings.entries())
|
|
439
|
+
if (!this.headers.has(id))
|
|
440
|
+
await this.store.bindings.delete(id);
|
|
441
|
+
}
|
|
442
|
+
suppressedFolder(path, folderId) {
|
|
443
|
+
return [...this.store.removals.entries()].some(([, receipt]) => receipt.folders.some(folder => folder.path === path || folder.id === folderId));
|
|
444
|
+
}
|
|
445
|
+
/** Cleanup is metadata-only and may finish after cancellation once the receipt commits. */
|
|
446
|
+
async finishRemoval(projectId) {
|
|
447
|
+
await this.store.projects.delete(projectId);
|
|
448
|
+
for (const [id, binding] of this.store.bindings.entries())
|
|
449
|
+
if (binding.projectId === projectId)
|
|
450
|
+
await this.store.bindings.delete(id);
|
|
451
|
+
}
|
|
452
|
+
async removeProject(request, invocation) {
|
|
453
|
+
const id = request.projectId;
|
|
454
|
+
if (this.store.removals.get(id) === undefined) {
|
|
455
|
+
const project = this.store.projects.get(id);
|
|
456
|
+
if (project === undefined)
|
|
457
|
+
throw new WorktreeError('PROJECT_NOT_FOUND', 'This project is not registered.');
|
|
458
|
+
this.guard(request, invocation);
|
|
459
|
+
// Publish the durable decision before deleting anything. Readers hide it immediately;
|
|
460
|
+
// retry/restart repairs interrupted cleanup without reimporting retained native folders.
|
|
461
|
+
await this.store.removals.put(id, { id, folders: project.folders.map(({ id, path }) => ({ id, path })), removedAt: Date.now() });
|
|
462
|
+
}
|
|
463
|
+
await this.finishRemoval(id);
|
|
464
|
+
this.guard(request, invocation);
|
|
465
|
+
return { removed: true, projectId: id, scope: 'project-metadata' };
|
|
466
|
+
}
|
|
467
|
+
async canonicalFolders(paths) {
|
|
468
|
+
const result = [];
|
|
469
|
+
for (const path of paths) {
|
|
470
|
+
let canonical;
|
|
471
|
+
try {
|
|
472
|
+
canonical = await realpath(path);
|
|
473
|
+
if (!(await stat(canonical)).isDirectory())
|
|
474
|
+
throw Error('not directory');
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
throw new WorktreeError('INVALID_PROJECT_PATH', 'Each project folder must be an existing absolute local directory.');
|
|
478
|
+
}
|
|
479
|
+
if (result.includes(canonical))
|
|
480
|
+
throw new WorktreeError('DUPLICATE_PROJECT_FOLDER', 'Canonical folder paths must be distinct.');
|
|
481
|
+
if (this.worktrees.records().some(record => contained(record.checkoutRoot, canonical)))
|
|
482
|
+
throw new WorktreeError('MANAGED_PROJECT_FOLDER', 'Managed worktree checkouts belong to their source project, not a new Local folder.');
|
|
483
|
+
result.push(canonical);
|
|
484
|
+
}
|
|
485
|
+
return result;
|
|
486
|
+
}
|
|
487
|
+
async changeProject(request, invocation) {
|
|
488
|
+
const id = request.action === 'create' ? request.id : request.projectId;
|
|
489
|
+
const existing = this.store.projects.get(id);
|
|
490
|
+
if (request.action === 'create' && [...this.store.removals.entries()].some(([removedId]) => removedId.toLowerCase() === id.toLowerCase()))
|
|
491
|
+
throw new WorktreeError('PROJECT_REMOVED', 'This project id was removed. Use a fresh UUID to re-add folders.');
|
|
492
|
+
if (request.action === 'create' && existing !== undefined)
|
|
493
|
+
throw new WorktreeError('PROJECT_EXISTS', 'This project id is already registered.');
|
|
494
|
+
if (request.action === 'update' && existing === undefined)
|
|
495
|
+
throw new WorktreeError('PROJECT_NOT_FOUND', 'This project is not registered.');
|
|
496
|
+
const paths = request.folders === undefined ? existing.folders.map(folder => folder.path) : await this.canonicalFolders(request.folders);
|
|
497
|
+
const explicitMain = request.mainFolder === undefined ? undefined : (await this.canonicalFolders([request.mainFolder]))[0];
|
|
498
|
+
if (explicitMain !== undefined && !paths.includes(explicitMain))
|
|
499
|
+
throw new WorktreeError('PROJECT_MAIN_FOLDER_NOT_FOUND', 'The main folder must be a canonical member of the resulting project folders.');
|
|
500
|
+
for (const path of paths) {
|
|
501
|
+
const owner = this.folderForPath(path);
|
|
502
|
+
const project = owner === undefined ? undefined : this.store.projects.get(owner.projectId);
|
|
503
|
+
if (project !== undefined && project.id !== id && project.imported !== true)
|
|
504
|
+
throw new WorktreeError('PROJECT_FOLDER_CONFLICT', 'This canonical folder belongs to another explicit project.');
|
|
505
|
+
}
|
|
506
|
+
if (existing !== undefined)
|
|
507
|
+
for (const folder of existing.folders) {
|
|
508
|
+
if (paths.includes(folder.path))
|
|
509
|
+
continue;
|
|
510
|
+
const bound = [...this.store.bindings.entries()].some(([, binding]) => binding.projectId === id && binding.folderId === folder.id);
|
|
511
|
+
const pending = [...this.store.starts.entries()].some(([, start]) => start.projectId === id && start.folderId === folder.id);
|
|
512
|
+
const records = this.worktrees.records();
|
|
513
|
+
const managed = records.some(record => this.worktreeFolder(record, records)?.folderId === folder.id || resolve(record.repoRoot, record.projectSubdir) === folder.path);
|
|
514
|
+
if (bound || pending || managed)
|
|
515
|
+
throw new WorktreeError('PROJECT_FOLDER_IN_USE', 'Retain folders with existing threads, managed worktrees or start receipts.');
|
|
516
|
+
}
|
|
517
|
+
const priorMain = existing === undefined ? undefined : resolveMainFolder(existing).path;
|
|
518
|
+
const mainPath = explicitMain ?? (priorMain !== undefined && paths.includes(priorMain) ? priorMain : paths.length === 1 || existing === undefined ? paths[0] : undefined);
|
|
519
|
+
if (mainPath === undefined)
|
|
520
|
+
throw new WorktreeError('PROJECT_MAIN_FOLDER_REQUIRED', 'Supply a replacement main folder when removing the prior main from a multi-folder project.');
|
|
521
|
+
const registry = required(this.owner, 'workspaceRegistry');
|
|
522
|
+
const folders = [];
|
|
523
|
+
for (const path of paths) {
|
|
524
|
+
this.guard(request, invocation);
|
|
525
|
+
const workspace = await registry.create(path);
|
|
526
|
+
if (workspace.path !== path)
|
|
527
|
+
throw new WorktreeError('PROJECT_PATH_CHANGED', 'The canonical folder path changed during registration.');
|
|
528
|
+
folders.push({ id: workspace.id, path: workspace.path, title: workspace.title });
|
|
529
|
+
}
|
|
530
|
+
this.guard(request, invocation);
|
|
531
|
+
const now = Date.now();
|
|
532
|
+
const project = projectRecordSchema.parse({ id, title: request.title ?? existing.title, folders, mainFolderId: folders.find(folder => folder.path === mainPath).id, createdAt: existing?.createdAt ?? now, updatedAt: now });
|
|
533
|
+
// Publish the explicit winner first. If storage fails afterwards, synchronize repairs adoption.
|
|
534
|
+
await this.store.projects.put(id, project);
|
|
535
|
+
await this.synchronizeNow();
|
|
536
|
+
return { project: clone(project) };
|
|
537
|
+
}
|
|
538
|
+
async start(request, invocation, folder) {
|
|
539
|
+
const current = this.resolveFolder(request.projectId, folder.id);
|
|
540
|
+
if (current.path !== folder.path)
|
|
541
|
+
throw new WorktreeError('PROJECT_PATH_CHANGED', 'The captured project folder changed during synchronization.');
|
|
542
|
+
const actorId = invocation.agent?.id ?? null;
|
|
543
|
+
const guard = () => this.guard(request, invocation, folder);
|
|
544
|
+
const previous = this.store.starts.get(request.operationId);
|
|
545
|
+
if (previous !== undefined) {
|
|
546
|
+
if (previous.projectId !== request.projectId || previous.folderId !== folder.id || previous.actorId !== actorId || previous.effectiveCwd !== folder.path)
|
|
547
|
+
throw new WorktreeError('OPERATION_REUSED', 'This start operation id belongs to another project folder or caller.');
|
|
548
|
+
if (previous.phase !== 'ready' || previous.sessionId === null || previous.workspaceId === null)
|
|
549
|
+
throw new WorktreeError('RECOVERY_REQUIRED', 'This start may have committed a session. Do not duplicate it; inspect or open the recorded thread.');
|
|
550
|
+
const binding = this.bindingFor(previous.sessionId);
|
|
551
|
+
if (binding === undefined || binding.projectId !== request.projectId || binding.folderId !== folder.id)
|
|
552
|
+
throw new WorktreeError('RECOVERY_REQUIRED', 'The recorded session no longer has the expected exact project cwd.');
|
|
553
|
+
return { sessionId: previous.sessionId, workspaceId: previous.workspaceId, binding };
|
|
554
|
+
}
|
|
555
|
+
if (invocation.agent === undefined && this.headers.has(request.operationId))
|
|
556
|
+
throw new WorktreeError('OPERATION_REUSED', 'The requested session identity already exists independently of this start.');
|
|
557
|
+
const canonical = await this.canonicalFolders([folder.path]);
|
|
558
|
+
if (canonical[0] !== folder.path)
|
|
559
|
+
throw new WorktreeError('PROJECT_PATH_CHANGED', 'The selected folder no longer has its registered canonical path.');
|
|
560
|
+
if (invocation.agent !== undefined && await (this.dependencies.localPath ?? localProjectPath)(invocation.agent, folder.path, invocation.signal) !== folder.path)
|
|
561
|
+
throw new WorktreeError('PROJECT_PATH_CHANGED', 'The caller filesystem does not map to this exact native folder.');
|
|
562
|
+
const settings = invocation.agent === undefined ? undefined : await this.bootstrap.settings(invocation.agent, invocation.signal);
|
|
563
|
+
guard();
|
|
564
|
+
const now = Date.now();
|
|
565
|
+
let operation = { id: request.operationId, projectId: request.projectId, folderId: folder.id, actorId, effectiveCwd: folder.path, requestedSessionId: request.operationId, phase: 'planned', sessionId: null, workspaceId: null, createdAt: now, updatedAt: now };
|
|
566
|
+
const receipt = async (phase, extra = {}) => { operation = { ...operation, ...extra, phase, updatedAt: Date.now() }; await this.store.starts.put(operation.id, clone(operation)); };
|
|
567
|
+
try {
|
|
568
|
+
await receipt('planned');
|
|
569
|
+
guard();
|
|
570
|
+
if (invocation.agent !== undefined && (await this.bootstrap.settings(invocation.agent, invocation.signal)).hash !== settings.hash)
|
|
571
|
+
throw new WorktreeError('SETTINGS_CHANGED', 'The invoking settings changed before session creation.');
|
|
572
|
+
guard();
|
|
573
|
+
await receipt('creating');
|
|
574
|
+
guard();
|
|
575
|
+
let result;
|
|
576
|
+
if (invocation.agent !== undefined) {
|
|
577
|
+
const created = await this.bootstrap.create({ cwd: folder.path, title: folder.title, source: invocation.agent, settings: settings, mode: 'new', signal: invocation.signal,
|
|
578
|
+
onCreated: async (identity) => { await receipt('session-created', { sessionId: identity.sessionId, workspaceId: identity.workspaceId }); }, });
|
|
579
|
+
result = { sessionId: created.sessionId, workspaceId: created.workspaceId };
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
const created = await required(this.owner, 'sessionController').create({ workspaceId: folder.id, cwd: folder.path, sessionId: SessionId(operation.requestedSessionId) });
|
|
583
|
+
result = { sessionId: created.sessionId, workspaceId: folder.id };
|
|
584
|
+
await receipt('session-created', result);
|
|
585
|
+
}
|
|
586
|
+
if (result.workspaceId !== folder.id || (invocation.agent === undefined && result.sessionId !== operation.requestedSessionId))
|
|
587
|
+
throw new WorktreeError('PROJECT_BINDING_MISMATCH', 'The created session did not match its requested native workspace identity.');
|
|
588
|
+
const header = await this.inspect(result.sessionId, undefined, true);
|
|
589
|
+
if (header.origin === 'subagent' || header.cwd !== folder.path)
|
|
590
|
+
throw new WorktreeError('PROJECT_BINDING_MISMATCH', 'The created ordinary session cwd does not match the selected folder.');
|
|
591
|
+
const binding = { sessionId: result.sessionId, projectId: request.projectId, folderId: folder.id, mode: 'local', effectiveCwd: folder.path };
|
|
592
|
+
await this.store.bindings.put(binding.sessionId, binding);
|
|
593
|
+
await receipt('ready', result);
|
|
594
|
+
guard();
|
|
595
|
+
return { ...result, binding: clone(binding) };
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
if (operation.phase !== 'ready')
|
|
599
|
+
await receipt('recovery-required');
|
|
600
|
+
throw error;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import { type ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection';
|
|
3
|
+
/**
|
|
4
|
+
* A feature owns exact POST routes, never the singleton /api interceptor owned
|
|
5
|
+
* by the API gateway. Keep Connection's public envelope and admitted operator.
|
|
6
|
+
*/
|
|
7
|
+
export declare function registerQuietRpcRoute(owner: Context, endpoint: string, handler: ConnectionRpcHandler): () => Promise<void>;
|