@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/service.js
ADDED
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, realpath, readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { SessionSeq } from '@deepseek-ai/dsh-session';
|
|
5
|
+
import { GitOperations } from './git.js';
|
|
6
|
+
import { WorktreeError, abortIfRequested, failureOf } from './errors.js';
|
|
7
|
+
import { capability, required, createExecutor, localProjectPath, planBlocksMutation, requireFullAccess } from './runtime.js';
|
|
8
|
+
import { fullAccessActions, mutationActions, hashValue, parseRequest } from './schema.js';
|
|
9
|
+
import { SessionBootstrap } from './sessions.js';
|
|
10
|
+
import { fallbackName, generateWorktreeName, NAMING_DEFAULTS, NAMING_INPUT_CHARS, namingConfig } from './naming.js';
|
|
11
|
+
/** Validate before any filesystem/Git/naming effects; legacy plain configs may be relative. */
|
|
12
|
+
export function resolveWorktreeRoot(value, allowRelative = false) {
|
|
13
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 4096 || value !== value.trim() || /[\u0000-\u001f]/u.test(value) || (!allowRelative && !isAbsolute(value))) {
|
|
14
|
+
throw new WorktreeError('INVALID_ROOT', 'The worktree root must be a nonempty absolute Host path of at most 4096 characters, without control characters or surrounding whitespace.');
|
|
15
|
+
}
|
|
16
|
+
const root = resolve(value);
|
|
17
|
+
if (dirname(root) === root)
|
|
18
|
+
throw new WorktreeError('UNSAFE_ROOT', 'The filesystem root cannot be the managed worktree root.');
|
|
19
|
+
return root;
|
|
20
|
+
}
|
|
21
|
+
function contained(root, path) {
|
|
22
|
+
const part = relative(root, path);
|
|
23
|
+
return part === '' || (!part.startsWith(`..${sep}`) && part !== '..' && !isAbsolute(part));
|
|
24
|
+
}
|
|
25
|
+
function requiredText(value, field) {
|
|
26
|
+
if (value === undefined)
|
|
27
|
+
throw new WorktreeError('INVALID_REQUEST', `Missing ${field}.`);
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
function clone(value) { return structuredClone(value); }
|
|
31
|
+
async function canonicalFuture(path) {
|
|
32
|
+
try {
|
|
33
|
+
return await realpath(path);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT')
|
|
37
|
+
throw error;
|
|
38
|
+
const parent = dirname(path);
|
|
39
|
+
if (parent === path)
|
|
40
|
+
throw error;
|
|
41
|
+
return resolve(await canonicalFuture(parent), basename(path));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function waitFor(prior, signal) {
|
|
45
|
+
abortIfRequested(signal);
|
|
46
|
+
return new Promise((fulfill, reject) => {
|
|
47
|
+
const abort = () => { cleanup(); reject(new WorktreeError('CANCELLED', 'Cancelled while waiting for the repository queue.')); };
|
|
48
|
+
const cleanup = () => signal.removeEventListener('abort', abort);
|
|
49
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
50
|
+
prior.then(() => { cleanup(); fulfill(); }, error => { cleanup(); reject(error); });
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/** Caller-authorized operations; shared Git metadata is serialized per canonical common directory. */
|
|
54
|
+
export class WorktreeController {
|
|
55
|
+
owner;
|
|
56
|
+
config;
|
|
57
|
+
store;
|
|
58
|
+
dependencies;
|
|
59
|
+
bootstrap;
|
|
60
|
+
shutdown = new AbortController();
|
|
61
|
+
queues = new Map();
|
|
62
|
+
flights = new Set();
|
|
63
|
+
statuses = new Map();
|
|
64
|
+
previews = new Map();
|
|
65
|
+
namingPreparations = new Map();
|
|
66
|
+
generation;
|
|
67
|
+
cursors = new Map();
|
|
68
|
+
closed = false;
|
|
69
|
+
constructor(owner, config, store, dependencies = {}) {
|
|
70
|
+
this.owner = owner;
|
|
71
|
+
this.config = config;
|
|
72
|
+
this.store = store;
|
|
73
|
+
this.dependencies = dependencies;
|
|
74
|
+
this.bootstrap = dependencies.bootstrap ?? new SessionBootstrap(owner);
|
|
75
|
+
}
|
|
76
|
+
records() { return [...this.store.worktrees.entries()].map(([, record]) => clone(record)); }
|
|
77
|
+
cachedStatus(id) { const state = this.statuses.get(id); return state === undefined ? undefined : clone(state); }
|
|
78
|
+
execute(request, invocation) {
|
|
79
|
+
if (this.closed)
|
|
80
|
+
return Promise.reject(new WorktreeError('CLOSED', 'The worktree controller is closing.'));
|
|
81
|
+
const signal = AbortSignal.any([invocation.signal, this.shutdown.signal, AbortSignal.timeout(this.config.operationTimeoutMs)]);
|
|
82
|
+
let parsed;
|
|
83
|
+
const input = { ...invocation, signal, capturedRoot: undefined };
|
|
84
|
+
try {
|
|
85
|
+
parsed = parseRequest(request);
|
|
86
|
+
if (parsed.action === 'create') {
|
|
87
|
+
this.guard(parsed, input);
|
|
88
|
+
let root;
|
|
89
|
+
try {
|
|
90
|
+
root = this.dependencies.worktreeRoot === undefined ? this.config.root : this.dependencies.worktreeRoot();
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new WorktreeError('INVALID_ROOT', 'The live worktree root setting could not be read.');
|
|
94
|
+
}
|
|
95
|
+
// Read exactly once, synchronously at admission, before bootstrap or queues.
|
|
96
|
+
input.capturedRoot = resolveWorktreeRoot(root, this.dependencies.worktreeRoot === undefined);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
return Promise.reject(error);
|
|
101
|
+
}
|
|
102
|
+
const task = Promise.resolve().then(() => this.run(parsed, input));
|
|
103
|
+
this.flights.add(task);
|
|
104
|
+
void task.then(() => this.flights.delete(task), () => this.flights.delete(task));
|
|
105
|
+
return task;
|
|
106
|
+
}
|
|
107
|
+
namingTask(signal, task) {
|
|
108
|
+
if (this.closed)
|
|
109
|
+
return Promise.reject(new WorktreeError('CLOSED', 'The worktree controller is closing.'));
|
|
110
|
+
const lifetime = AbortSignal.any([signal, this.shutdown.signal, AbortSignal.timeout(this.config.operationTimeoutMs)]);
|
|
111
|
+
const result = Promise.resolve().then(() => task(lifetime));
|
|
112
|
+
this.flights.add(result);
|
|
113
|
+
void result.then(() => this.flights.delete(result), () => this.flights.delete(result));
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
async skipFirstMessageName(id, reason) {
|
|
117
|
+
const record = this.record(id);
|
|
118
|
+
await this.queued(record.commonDir, this.shutdown.signal, async () => {
|
|
119
|
+
const current = this.record(id);
|
|
120
|
+
const naming = current.firstMessageNaming;
|
|
121
|
+
if (naming === undefined || naming.phase === 'complete' || naming.phase === 'skipped')
|
|
122
|
+
return;
|
|
123
|
+
await this.store.worktrees.put(id, { ...current, firstMessageNaming: { ...naming, phase: 'skipped', reason } });
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
prepareFirstMessageName(id, agent, event, signal) {
|
|
127
|
+
const active = this.namingPreparations.get(id);
|
|
128
|
+
if (active?.agent === agent)
|
|
129
|
+
return active.task;
|
|
130
|
+
const task = this.namingTask(signal, lifetime => this.prepareName(id, agent, event, lifetime));
|
|
131
|
+
this.namingPreparations.set(id, { agent, task });
|
|
132
|
+
const cleanup = () => { if (this.namingPreparations.get(id)?.task === task)
|
|
133
|
+
this.namingPreparations.delete(id); };
|
|
134
|
+
void task.then(cleanup, cleanup);
|
|
135
|
+
return task;
|
|
136
|
+
}
|
|
137
|
+
namingInvocation(id, agent, signal) {
|
|
138
|
+
const request = { action: 'branch', id };
|
|
139
|
+
const invocation = { agent, origin: 'tool', signal };
|
|
140
|
+
this.guard(request, invocation);
|
|
141
|
+
const record = this.record(id);
|
|
142
|
+
if (record.firstMessageNaming?.sessionId !== agent.id || !record.sessionIds.includes(agent.id) || agent.session.header.cwd !== record.effectiveCwd)
|
|
143
|
+
throw new WorktreeError('SESSION_CHANGED', 'First-message naming requires the exact receiving ordinary session and execution directory.');
|
|
144
|
+
this.usable(record, false);
|
|
145
|
+
return { request, invocation };
|
|
146
|
+
}
|
|
147
|
+
messageForNaming(id, agent, supplied) {
|
|
148
|
+
const naming = this.record(id).firstMessageNaming;
|
|
149
|
+
const event = naming?.messageSeq === undefined ? supplied : agent.session.eventAt(SessionSeq(naming.messageSeq));
|
|
150
|
+
if (event?.type !== 'user/message' || event.surfaceOp !== 'append' || event.sourceEventSeqs?.length || !agent.session.isOwnSeq(event.seq) || event.data.role !== 'user' || event.data.source.kind !== 'user')
|
|
151
|
+
throw new WorktreeError('MESSAGE_CHANGED', 'The original authored message is unavailable; automatic naming will not use replacements.');
|
|
152
|
+
if (agent.session.eventAt(event.seq) !== event)
|
|
153
|
+
throw new WorktreeError('MESSAGE_CHANGED', 'The message is not the receiving session committed event.');
|
|
154
|
+
if (naming?.messageSeq !== undefined && (event.data.id !== naming.messageId || hashValue(event.data) !== naming.messageHash))
|
|
155
|
+
throw new WorktreeError('MESSAGE_CHANGED', 'The original first-message identity changed.');
|
|
156
|
+
return event;
|
|
157
|
+
}
|
|
158
|
+
async prepareName(id, agent, supplied, signal) {
|
|
159
|
+
const record = this.record(id);
|
|
160
|
+
const prior = record.firstMessageNaming;
|
|
161
|
+
if (prior === undefined || prior.phase === 'complete' || prior.phase === 'skipped')
|
|
162
|
+
return;
|
|
163
|
+
try {
|
|
164
|
+
const { request, invocation } = this.namingInvocation(id, agent, signal);
|
|
165
|
+
const event = this.messageForNaming(id, agent, supplied);
|
|
166
|
+
const flushed = await required(this.owner, 'sessions').flush(agent.session);
|
|
167
|
+
if (!flushed)
|
|
168
|
+
throw new WorktreeError('MISSING_DURABILITY_BARRIER', 'First-message naming requires a successful session durability checkpoint.');
|
|
169
|
+
this.namingInvocation(id, agent, signal);
|
|
170
|
+
let infer = false;
|
|
171
|
+
await this.queued(record.commonDir, signal, async () => {
|
|
172
|
+
this.namingInvocation(id, agent, signal);
|
|
173
|
+
const current = this.record(id);
|
|
174
|
+
const naming = current.firstMessageNaming;
|
|
175
|
+
if (naming.phase !== 'waiting')
|
|
176
|
+
return;
|
|
177
|
+
// Durable intent is written before auxiliary inference. Recovery of an
|
|
178
|
+
// interrupted intent uses fallback rather than paying for another call.
|
|
179
|
+
await this.putRecord({ ...current, firstMessageNaming: { ...naming, phase: 'generating', messageSeq: event.seq, messageId: event.data.id, messageHash: hashValue(event.data) } }, request, invocation);
|
|
180
|
+
infer = true;
|
|
181
|
+
});
|
|
182
|
+
const pending = this.record(id).firstMessageNaming;
|
|
183
|
+
if (pending.phase !== 'generating')
|
|
184
|
+
return;
|
|
185
|
+
let text = '';
|
|
186
|
+
for (const block of event.data.content) {
|
|
187
|
+
if (block.type !== 'text')
|
|
188
|
+
continue;
|
|
189
|
+
text += `${text ? '\n' : ''}${block.text.slice(0, NAMING_INPUT_CHARS - text.length)}`;
|
|
190
|
+
text = text.slice(0, NAMING_INPUT_CHARS);
|
|
191
|
+
if (text.length === NAMING_INPUT_CHARS)
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
const generatedName = infer
|
|
195
|
+
? await generateWorktreeName({ source: agent, caller: agent, firstPrompt: text, id, config: this.config, signal, guard: () => { this.namingInvocation(id, agent, signal); } })
|
|
196
|
+
: fallbackName(text, id, 'provider-failure');
|
|
197
|
+
await this.queued(record.commonDir, signal, async () => {
|
|
198
|
+
this.namingInvocation(id, agent, signal);
|
|
199
|
+
const current = this.record(id);
|
|
200
|
+
const naming = current.firstMessageNaming;
|
|
201
|
+
if (naming.phase !== 'generating')
|
|
202
|
+
return;
|
|
203
|
+
await this.putRecord({ ...current, firstMessageNaming: { ...naming, phase: 'generated', generatedName } }, request, invocation);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (!signal.aborted && error instanceof WorktreeError && ['PROTECTED', 'ARCHIVED', 'MESSAGE_CHANGED', 'SESSION_CHANGED', 'SESSION_NOT_LIVE', 'FULL_ACCESS_REQUIRED', 'PLAN_MODE'].includes(error.code))
|
|
208
|
+
await this.skipFirstMessageName(id, error.code);
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
finishFirstMessageName(id, agent, signal) {
|
|
213
|
+
return this.namingTask(signal, lifetime => this.finishName(id, agent, lifetime));
|
|
214
|
+
}
|
|
215
|
+
namingMaintenance(agent, task) {
|
|
216
|
+
try {
|
|
217
|
+
return agent.runMaintenance(task);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return Promise.reject(new WorktreeError('BUSY', 'The receiving agent became active before the idle naming claim.'));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async finishName(id, agent, signal) {
|
|
224
|
+
const record = this.record(id);
|
|
225
|
+
try {
|
|
226
|
+
await this.queued(record.commonDir, signal, async () => {
|
|
227
|
+
const current = this.record(id);
|
|
228
|
+
const naming = current.firstMessageNaming;
|
|
229
|
+
if (naming === undefined || !['generated', 'renaming'].includes(naming.phase) || naming.generatedName === undefined)
|
|
230
|
+
return;
|
|
231
|
+
const { request, invocation } = this.namingInvocation(id, agent, signal);
|
|
232
|
+
this.messageForNaming(id, agent);
|
|
233
|
+
// Acquiring true idle inside the repository queue closes the race with
|
|
234
|
+
// waking input, without blocking the original prompt or emitting chat rows.
|
|
235
|
+
await this.namingMaintenance(agent, async (maintenance) => {
|
|
236
|
+
const scopedSignal = AbortSignal.any([signal, maintenance]);
|
|
237
|
+
const scoped = { ...invocation, signal: scopedSignal };
|
|
238
|
+
const guard = () => { this.namingInvocation(id, agent, scopedSignal); this.messageForNaming(id, agent); };
|
|
239
|
+
guard();
|
|
240
|
+
const flushed = await required(this.owner, 'sessions').flush(agent.session);
|
|
241
|
+
if (!flushed)
|
|
242
|
+
throw new WorktreeError('MISSING_DURABILITY_BARRIER', 'First-message log durability was not established before branch mutation.');
|
|
243
|
+
guard();
|
|
244
|
+
const git = this.git(request, scoped);
|
|
245
|
+
const state = await git.verify(current, scopedSignal);
|
|
246
|
+
const generatedName = naming.generatedName;
|
|
247
|
+
if (state.branch !== naming.initialBranch && !(naming.phase === 'renaming' && state.branch === generatedName.branch))
|
|
248
|
+
throw new WorktreeError('BRANCH_CHANGED', 'The user changed the initial branch; automatic naming is skipped.');
|
|
249
|
+
let next = current;
|
|
250
|
+
if (state.branch === naming.initialBranch) {
|
|
251
|
+
next = { ...current, firstMessageNaming: { ...naming, phase: 'renaming' } };
|
|
252
|
+
await this.putRecord(next, request, scoped);
|
|
253
|
+
guard();
|
|
254
|
+
const renamed = await git.renameBranch(next, naming.initialBranch, generatedName.branch, scopedSignal);
|
|
255
|
+
this.statuses.set(id, renamed);
|
|
256
|
+
}
|
|
257
|
+
// Receipt of committed Git effect survives cancellation/permission changes.
|
|
258
|
+
next = { ...this.record(id), branch: generatedName.branch, naming: generatedName, displayName: generatedName.title, firstMessageNaming: { ...naming, phase: 'renaming' } };
|
|
259
|
+
await this.store.worktrees.put(id, clone(next));
|
|
260
|
+
guard();
|
|
261
|
+
const workspace = next.workspaceId === null ? undefined : capability(this.owner, 'workspaceRegistry')?.get(next.workspaceId);
|
|
262
|
+
if (workspace !== undefined) {
|
|
263
|
+
if (workspace.path !== next.effectiveCwd)
|
|
264
|
+
throw new WorktreeError('SESSION_CHANGED', 'The workspace no longer identifies the receiving checkout.');
|
|
265
|
+
// The no-prompt flow always creates this default title. Publish the
|
|
266
|
+
// usual structural change only while it is still plugin-owned; a
|
|
267
|
+
// manually edited native Workspace title takes precedence.
|
|
268
|
+
if (workspace.title === `${basename(next.repoRoot)} · ${next.remoteBranch}`)
|
|
269
|
+
await workspace.setTitle(generatedName.title);
|
|
270
|
+
}
|
|
271
|
+
guard();
|
|
272
|
+
await this.putRecord({ ...next, firstMessageNaming: { ...naming, phase: 'complete' } }, request, scoped);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
if (!signal.aborted && error instanceof WorktreeError && ['BRANCH_CHANGED', 'BRANCH_EXISTS', 'PROTECTED', 'ARCHIVED', 'MESSAGE_CHANGED', 'SESSION_CHANGED', 'SESSION_NOT_LIVE', 'FULL_ACCESS_REQUIRED', 'PLAN_MODE'].includes(error.code))
|
|
278
|
+
await this.skipFirstMessageName(id, error.code);
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async close() {
|
|
283
|
+
if (this.closed)
|
|
284
|
+
return;
|
|
285
|
+
this.closed = true;
|
|
286
|
+
this.shutdown.abort();
|
|
287
|
+
await Promise.allSettled([...this.flights]);
|
|
288
|
+
await this.bootstrap.close();
|
|
289
|
+
this.previews.clear();
|
|
290
|
+
this.cursors.clear();
|
|
291
|
+
this.statuses.clear();
|
|
292
|
+
}
|
|
293
|
+
guard(request, invocation) {
|
|
294
|
+
abortIfRequested(invocation.signal);
|
|
295
|
+
if (!this.bootstrap.isLive(invocation.agent) || this.bootstrap.actor(invocation.agent.id) !== invocation.agent)
|
|
296
|
+
throw new WorktreeError('SESSION_CHANGED', 'The invoking session is no longer the exact live ordinary session.');
|
|
297
|
+
if (fullAccessActions.has(request.action))
|
|
298
|
+
requireFullAccess(invocation.agent);
|
|
299
|
+
if (invocation.origin === 'tool' && mutationActions.has(request.action) && planBlocksMutation(invocation.agent))
|
|
300
|
+
throw new WorktreeError('PLAN_MODE', 'This mutation is unavailable while plan mode is active or pending enabled.');
|
|
301
|
+
}
|
|
302
|
+
git(request, invocation) {
|
|
303
|
+
const guard = () => this.guard(request, invocation);
|
|
304
|
+
if (this.dependencies.git !== undefined)
|
|
305
|
+
return this.dependencies.git(invocation.agent, invocation.signal, guard);
|
|
306
|
+
const executor = createExecutor(invocation.agent, this.config, this.shutdown.signal);
|
|
307
|
+
return new GitOperations(async (spec) => { guard(); return executor(spec); }, this.config, guard);
|
|
308
|
+
}
|
|
309
|
+
local(actor, path, signal) { return (this.dependencies.localPath ?? localProjectPath)(actor, path, signal); }
|
|
310
|
+
projectSelection(path, request) {
|
|
311
|
+
const projects = this.dependencies.projects;
|
|
312
|
+
if (request?.projectId !== undefined && request.folderId !== undefined) {
|
|
313
|
+
if (projects === undefined)
|
|
314
|
+
throw new WorktreeError('MISSING_PROJECTS', 'The project capability is unavailable.');
|
|
315
|
+
const folder = projects.resolveFolder(request.projectId, request.folderId);
|
|
316
|
+
if (folder.path !== path)
|
|
317
|
+
throw new WorktreeError('PROJECT_PATH_MISMATCH', 'The selected project folder no longer matches the creation directory.');
|
|
318
|
+
return { projectId: request.projectId, folderId: folder.id, path: folder.path };
|
|
319
|
+
}
|
|
320
|
+
return projects?.folderForPath(path);
|
|
321
|
+
}
|
|
322
|
+
assertProject(selection) {
|
|
323
|
+
if (selection === undefined)
|
|
324
|
+
return;
|
|
325
|
+
const folder = this.dependencies.projects?.resolveFolder(selection.projectId, selection.folderId);
|
|
326
|
+
if (folder?.path !== selection.path)
|
|
327
|
+
throw new WorktreeError('PROJECT_CHANGED', 'Project folder membership changed while preparing the thread.');
|
|
328
|
+
}
|
|
329
|
+
async bindThread(sessionId, cwd, selection, worktreeId) {
|
|
330
|
+
if (selection === undefined)
|
|
331
|
+
return;
|
|
332
|
+
await this.dependencies.projects?.recordThread({ sessionId, projectId: selection.projectId, folderId: selection.folderId, effectiveCwd: cwd, mode: worktreeId === undefined ? 'local' : 'worktree', ...(worktreeId === undefined ? {} : { worktreeId }) });
|
|
333
|
+
}
|
|
334
|
+
async queued(key, signal, operation) {
|
|
335
|
+
const prior = this.queues.get(key) ?? Promise.resolve();
|
|
336
|
+
let release;
|
|
337
|
+
const gate = new Promise(fulfill => { release = fulfill; });
|
|
338
|
+
const tail = prior.then(() => gate);
|
|
339
|
+
this.queues.set(key, tail);
|
|
340
|
+
try {
|
|
341
|
+
await waitFor(prior, signal);
|
|
342
|
+
abortIfRequested(signal);
|
|
343
|
+
return await operation();
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
release();
|
|
347
|
+
void tail.then(() => { if (this.queues.get(key) === tail)
|
|
348
|
+
this.queues.delete(key); });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
record(id) {
|
|
352
|
+
const record = this.store.worktrees.get(requiredText(id, 'id'));
|
|
353
|
+
if (record === undefined)
|
|
354
|
+
throw new WorktreeError('NOT_FOUND', 'This worktree is not recorded by the plugin.');
|
|
355
|
+
return clone(record);
|
|
356
|
+
}
|
|
357
|
+
usable(record, allowProtected = true) {
|
|
358
|
+
if (record.archived)
|
|
359
|
+
throw new WorktreeError('ARCHIVED', 'Unarchive the worktree before using it.');
|
|
360
|
+
if (record.state !== 'ready')
|
|
361
|
+
throw new WorktreeError('RECOVERY_REQUIRED', 'The worktree is not ready. Inspect its operation status before proceeding.');
|
|
362
|
+
if (!allowProtected && record.protected)
|
|
363
|
+
throw new WorktreeError('PROTECTED', 'Unprotect the worktree before this operation.');
|
|
364
|
+
}
|
|
365
|
+
async putRecord(record, request, invocation) { this.guard(request, invocation); await this.store.worktrees.put(record.id, clone(record)); }
|
|
366
|
+
async putOperation(operation, request, invocation) { this.guard(request, invocation); await this.store.operations.put(operation.id, clone(operation)); }
|
|
367
|
+
progress(invocation, operation, stage) {
|
|
368
|
+
// Immutable identity only; no request/settings/provider data or authorization.
|
|
369
|
+
try {
|
|
370
|
+
void Promise.resolve(invocation.onProgress?.(Object.freeze({ stage, operationId: operation.id, worktreeId: operation.worktreeId }))).catch(() => undefined);
|
|
371
|
+
}
|
|
372
|
+
catch { /* observer only */ }
|
|
373
|
+
}
|
|
374
|
+
operationResult(operation) {
|
|
375
|
+
if (operation.sessionId === null || operation.workspaceId === null)
|
|
376
|
+
throw new WorktreeError('RECOVERY_REQUIRED', 'The operation has no durably recorded conversation identity.');
|
|
377
|
+
return { worktree: this.record(operation.worktreeId), sessionId: operation.sessionId, workspaceId: operation.workspaceId, settingsHash: operation.settingsHash, operation: clone(operation) };
|
|
378
|
+
}
|
|
379
|
+
cursor(key, supplied) {
|
|
380
|
+
if (supplied === undefined)
|
|
381
|
+
return 0;
|
|
382
|
+
const cursor = this.cursors.get(supplied);
|
|
383
|
+
if (cursor === undefined || cursor.key !== key)
|
|
384
|
+
throw new WorktreeError('STALE_CURSOR', 'The advertised generation or list changed. Reload from its first page.');
|
|
385
|
+
return cursor.offset;
|
|
386
|
+
}
|
|
387
|
+
nextCursor(key, offset) {
|
|
388
|
+
if (this.cursors.size >= 512)
|
|
389
|
+
this.cursors.delete(this.cursors.keys().next().value ?? '');
|
|
390
|
+
const token = randomUUID();
|
|
391
|
+
this.cursors.set(token, { key, offset });
|
|
392
|
+
return token;
|
|
393
|
+
}
|
|
394
|
+
async run(request, invocation) {
|
|
395
|
+
this.guard(request, invocation);
|
|
396
|
+
if (request.action === 'start' || request.action === 'branch' || request.action === 'export' || (request.action === 'status' && request.id !== undefined))
|
|
397
|
+
await this.local(invocation.agent, undefined, invocation.signal);
|
|
398
|
+
const git = this.git(request, invocation);
|
|
399
|
+
if (request.action === 'create' || request.action === 'handoff')
|
|
400
|
+
return this.queued(`operation:${requiredText(request.operationId, 'operationId')}`, invocation.signal, () => request.action === 'create' ? this.create(request, invocation, git) : this.handoff(request, invocation, git));
|
|
401
|
+
if (request.action === 'status')
|
|
402
|
+
return this.status(request, invocation, git);
|
|
403
|
+
if (request.action === 'list' || request.action === 'branches') {
|
|
404
|
+
const path = await this.local(invocation.agent, request.repoPath, invocation.signal);
|
|
405
|
+
const repository = await git.discover(path, invocation.signal);
|
|
406
|
+
if (request.action === 'list') {
|
|
407
|
+
const items = this.records().filter(r => r.commonDir === repository.commonDir && (request.includeArchived === true || !r.archived)).sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
|
|
408
|
+
const key = hashValue({ repository: repository.commonDir, includeArchived: request.includeArchived === true, records: items.map(r => [r.id, r.createdAt, r.archived]) });
|
|
409
|
+
const offset = this.cursor(key, request.cursor);
|
|
410
|
+
const limit = request.limit ?? 50;
|
|
411
|
+
return { items: items.slice(offset, offset + limit), ...(offset + limit < items.length ? { nextCursor: this.nextCursor(key, offset + limit) } : {}) };
|
|
412
|
+
}
|
|
413
|
+
return this.branches(request, invocation, git, repository);
|
|
414
|
+
}
|
|
415
|
+
if (request.action === 'export')
|
|
416
|
+
return this.exportPatch(request, invocation);
|
|
417
|
+
const record = this.record(request.id);
|
|
418
|
+
return this.queued(record.commonDir, invocation.signal, async () => {
|
|
419
|
+
this.guard(request, invocation);
|
|
420
|
+
const current = this.record(record.id);
|
|
421
|
+
if (request.action === 'protect') {
|
|
422
|
+
const next = { ...current, protected: request.protected === true };
|
|
423
|
+
await this.putRecord(next, request, invocation);
|
|
424
|
+
return { worktree: next };
|
|
425
|
+
}
|
|
426
|
+
if (request.action === 'archive') {
|
|
427
|
+
if (current.protected && request.archived !== false)
|
|
428
|
+
throw new WorktreeError('PROTECTED', 'Unprotect the worktree before archiving it.');
|
|
429
|
+
const next = { ...current, archived: request.archived !== false };
|
|
430
|
+
await this.putRecord(next, request, invocation);
|
|
431
|
+
return { worktree: next, scope: 'plugin-record', retained: ['checkout', 'base-ref', 'sessions', 'logs'] };
|
|
432
|
+
}
|
|
433
|
+
this.usable(current);
|
|
434
|
+
this.statuses.set(current.id, await git.verify(current, invocation.signal));
|
|
435
|
+
if (request.action === 'branch') {
|
|
436
|
+
this.usable(current, false);
|
|
437
|
+
this.guard(request, invocation);
|
|
438
|
+
const state = await git.createBranch(current, requiredText(request.name, 'name'), invocation.signal);
|
|
439
|
+
const next = { ...current, branch: state.branch };
|
|
440
|
+
this.statuses.set(next.id, state);
|
|
441
|
+
await this.putRecord(next, request, invocation);
|
|
442
|
+
return { worktree: next, status: state };
|
|
443
|
+
}
|
|
444
|
+
if (request.action === 'start')
|
|
445
|
+
return this.start(request, invocation, current);
|
|
446
|
+
if (request.action === 'preview')
|
|
447
|
+
return this.preview(request, invocation, git, current);
|
|
448
|
+
throw new WorktreeError('INVALID_REQUEST', 'Unsupported worktree action.');
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
async status(request, invocation, git) {
|
|
452
|
+
const settings = await this.bootstrap.settings(invocation.agent, invocation.signal);
|
|
453
|
+
if (request.operationId !== undefined) {
|
|
454
|
+
const operation = this.store.operations.get(request.operationId);
|
|
455
|
+
if (operation === undefined)
|
|
456
|
+
throw new WorktreeError('NOT_FOUND', 'This operation is not recorded.');
|
|
457
|
+
const worktree = this.store.worktrees.get(operation.worktreeId);
|
|
458
|
+
return { operation: clone(operation), ...(worktree === undefined ? {} : { worktree: clone(worktree) }), ...(operation.sessionId === null ? {} : { sessionId: operation.sessionId }), settingsHash: settings.hash };
|
|
459
|
+
}
|
|
460
|
+
if (request.id !== undefined) {
|
|
461
|
+
const record = this.record(request.id);
|
|
462
|
+
let state = null;
|
|
463
|
+
try {
|
|
464
|
+
state = await git.verify(record, invocation.signal);
|
|
465
|
+
this.statuses.set(record.id, state);
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
abortIfRequested(invocation.signal);
|
|
469
|
+
if (!(error instanceof WorktreeError))
|
|
470
|
+
throw error;
|
|
471
|
+
this.statuses.delete(record.id);
|
|
472
|
+
}
|
|
473
|
+
return { worktree: record, status: state, settingsHash: settings.hash };
|
|
474
|
+
}
|
|
475
|
+
const path = await this.local(invocation.agent, request.repoPath, invocation.signal);
|
|
476
|
+
const repository = await git.discover(path, invocation.signal);
|
|
477
|
+
const projectBinding = this.dependencies.projects?.bindingFor(invocation.agent.id) ?? this.dependencies.projects?.bindingFor(invocation.agent.id, path);
|
|
478
|
+
return { repository, projectPath: path, ...(projectBinding === undefined ? {} : { projectBinding }), remotes: await git.remotes(repository.root, invocation.signal), settingsHash: settings.hash, defaults: { remote: this.config.defaultRemote, branch: this.config.defaultBranch }, namingConfig: namingConfig(this.config) };
|
|
479
|
+
}
|
|
480
|
+
async branches(request, invocation, git, repository) {
|
|
481
|
+
const remote = requiredText(request.remote, 'remote');
|
|
482
|
+
const identity = (await git.remotes(repository.root, invocation.signal)).find(r => r.name === remote)?.identity;
|
|
483
|
+
if (identity === undefined)
|
|
484
|
+
throw new WorktreeError('REMOTE_NOT_FOUND', 'The selected remote is not configured.');
|
|
485
|
+
if (request.remoteIdentity !== undefined && request.remoteIdentity !== identity)
|
|
486
|
+
throw new WorktreeError('REMOTE_CHANGED', 'The remote configuration changed. Reload its branch list.');
|
|
487
|
+
let generation = this.generation;
|
|
488
|
+
if (request.cursor === undefined) {
|
|
489
|
+
this.generation = undefined;
|
|
490
|
+
const value = await git.branches(repository.root, remote, identity, invocation.signal);
|
|
491
|
+
generation = { id: randomUUID(), repository: repository.commonDir, value: { ...value, branches: [...value.branches].sort((a, b) => a.name.localeCompare(b.name)) } };
|
|
492
|
+
this.generation = generation;
|
|
493
|
+
}
|
|
494
|
+
if (generation === undefined || generation.repository !== repository.commonDir || generation.value.remote !== remote || generation.value.remoteIdentity !== identity)
|
|
495
|
+
throw new WorktreeError('STALE_CURSOR', 'The branch generation expired. Reload its first page.');
|
|
496
|
+
const query = request.query ?? '';
|
|
497
|
+
const items = generation.value.branches.filter(branch => branch.name.toLocaleLowerCase().includes(query.toLocaleLowerCase()));
|
|
498
|
+
const key = hashValue({ generation: generation.id, query });
|
|
499
|
+
const offset = this.cursor(key, request.cursor);
|
|
500
|
+
const limit = request.limit ?? 50;
|
|
501
|
+
return { remote, remoteIdentity: identity, defaultBranch: generation.value.defaultBranch, items: items.slice(offset, offset + limit), observedAt: generation.value.observedAt, ...(offset + limit < items.length ? { nextCursor: this.nextCursor(key, offset + limit) } : {}) };
|
|
502
|
+
}
|
|
503
|
+
normalizedCreate(request, actor, path) {
|
|
504
|
+
return { ...request, repoPath: path, sourceSessionId: request.sourceSessionId ?? actor.id, sessionMode: request.sessionMode ?? 'new', requireBlankSource: request.requireBlankSource ?? false };
|
|
505
|
+
}
|
|
506
|
+
async sameSettings(source, settings, request, invocation) {
|
|
507
|
+
this.guard(request, invocation);
|
|
508
|
+
if ((await this.bootstrap.settings(source, invocation.signal)).hash !== settings.hash)
|
|
509
|
+
throw new WorktreeError('SETTINGS_CHANGED', 'Source settings changed. Prepare a new operation with the current selection.');
|
|
510
|
+
if (request.requireBlankSource === true)
|
|
511
|
+
await this.bootstrap.assertBlank(source);
|
|
512
|
+
}
|
|
513
|
+
newOperation(id, kind, requestHash, actor, record, source, settings, mode, targetRoot = null, previewId = null) {
|
|
514
|
+
const now = Date.now();
|
|
515
|
+
return { id, kind, requestHash, actorId: actor.id, worktreeId: record.id, phase: 'planned', createdAt: now, updatedAt: now, sessionId: null, workspaceId: null, error: null, settingsHash: settings.hash, settings: clone(settings), sourceSessionId: source.id, sessionMode: mode, targetRoot, previewId, appliedHead: null, appliedFingerprint: null };
|
|
516
|
+
}
|
|
517
|
+
async phase(operation, phase, request, invocation, extra = {}) {
|
|
518
|
+
const next = { ...operation, ...extra, phase, updatedAt: Date.now() };
|
|
519
|
+
await this.putOperation(next, request, invocation);
|
|
520
|
+
return next;
|
|
521
|
+
}
|
|
522
|
+
async receipt(operation, phase, extra = {}) {
|
|
523
|
+
// Recording an already committed effect must survive cancellation or permission changes.
|
|
524
|
+
// This is storage-only recovery bookkeeping, never admission for another Git/session effect.
|
|
525
|
+
const next = { ...operation, ...extra, phase, updatedAt: Date.now() };
|
|
526
|
+
await this.store.operations.put(next.id, clone(next));
|
|
527
|
+
return next;
|
|
528
|
+
}
|
|
529
|
+
async journalFailure(id, error, effects) {
|
|
530
|
+
const current = this.store.operations.get(id);
|
|
531
|
+
if (current === undefined || current.phase === 'ready')
|
|
532
|
+
return;
|
|
533
|
+
const failure = failureOf(error);
|
|
534
|
+
// Cleanup journaling may outlive cancellation; it never performs Git or session effects.
|
|
535
|
+
const phase = current.phase === 'code-applied' ? 'code-applied' : !effects && (failure.code === 'CANCELLED' || failure.code === 'TIMEOUT') ? 'cancelled-before-effects' : 'recovery-required';
|
|
536
|
+
await this.store.operations.put(id, { ...current, phase, updatedAt: Date.now(), error: failure });
|
|
537
|
+
const record = this.store.worktrees.get(current.worktreeId);
|
|
538
|
+
if (record !== undefined && record.state === 'creating')
|
|
539
|
+
await this.store.worktrees.put(record.id, { ...record, state: 'recovery-required', error: failure.message });
|
|
540
|
+
}
|
|
541
|
+
async create(request, invocation, git) {
|
|
542
|
+
const path = await this.local(invocation.agent, request.repoPath, invocation.signal);
|
|
543
|
+
const operationId = requiredText(request.operationId, 'operationId');
|
|
544
|
+
const requestHash = hashValue({ actor: invocation.agent.id, request: this.normalizedCreate(request, invocation.agent, path) });
|
|
545
|
+
const existing = this.store.operations.get(operationId);
|
|
546
|
+
if (existing !== undefined) {
|
|
547
|
+
if (existing.kind !== 'create' || existing.actorId !== invocation.agent.id || existing.requestHash !== requestHash)
|
|
548
|
+
throw new WorktreeError('OPERATION_REUSED', 'The operation id is already bound to another request or caller.');
|
|
549
|
+
if (existing.phase === 'ready') {
|
|
550
|
+
const result = this.operationResult(existing);
|
|
551
|
+
this.progress(invocation, existing, 'ready');
|
|
552
|
+
return result;
|
|
553
|
+
}
|
|
554
|
+
throw new WorktreeError('RECOVERY_REQUIRED', 'This operation may have committed effects. Inspect its status; do not repeat checkout creation.');
|
|
555
|
+
}
|
|
556
|
+
const source = this.bootstrap.actor(request.sourceSessionId ?? invocation.agent.id);
|
|
557
|
+
const settings = await this.bootstrap.settings(source, invocation.signal);
|
|
558
|
+
if (request.settingsHash !== undefined && request.settingsHash !== settings.hash)
|
|
559
|
+
throw new WorktreeError('SETTINGS_CHANGED', 'Source settings changed before preparation.');
|
|
560
|
+
if (request.requireBlankSource === true)
|
|
561
|
+
await this.bootstrap.assertBlank(source);
|
|
562
|
+
let repository = await git.discover(path, invocation.signal);
|
|
563
|
+
let project = this.projectSelection(path, request);
|
|
564
|
+
if (project === undefined && this.dependencies.projects !== undefined) {
|
|
565
|
+
// An explicit agent repoPath may not have had a native Workspace yet.
|
|
566
|
+
// Import only metadata after the actual caller admitted that source path.
|
|
567
|
+
const sourceRecord = this.records().find(record => record.effectiveCwd === path);
|
|
568
|
+
const owner = sourceRecord === undefined ? undefined : this.dependencies.projects.selectionForWorktree(sourceRecord.id);
|
|
569
|
+
if (owner !== undefined)
|
|
570
|
+
project = owner;
|
|
571
|
+
else {
|
|
572
|
+
const binding = this.dependencies.projects.bindingFor(invocation.agent.id, path);
|
|
573
|
+
if (binding !== undefined) {
|
|
574
|
+
const folder = this.dependencies.projects.resolveFolder(binding.projectId, binding.folderId);
|
|
575
|
+
project = { projectId: binding.projectId, folderId: folder.id, path: folder.path };
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
const localFolder = sourceRecord === undefined ? path : resolve(sourceRecord.repoRoot, sourceRecord.projectSubdir);
|
|
579
|
+
// Removed/independent nested sources may name another managed checkout.
|
|
580
|
+
// Optional metadata must not reimport that checkout or block independent use.
|
|
581
|
+
if (!this.records().some(record => contained(record.checkoutRoot, localFolder)))
|
|
582
|
+
project = await this.dependencies.projects.ensureFolder(localFolder, invocation.signal);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (project !== undefined && project.path !== path) {
|
|
587
|
+
// A thread may originate in another managed checkout. Its logical Local
|
|
588
|
+
// folder remains the original project folder, not a new project or handoff target.
|
|
589
|
+
const originalPath = await this.local(invocation.agent, project.path, invocation.signal);
|
|
590
|
+
const original = await git.discover(originalPath, invocation.signal);
|
|
591
|
+
if (originalPath !== project.path || original.commonDir !== repository.commonDir || original.projectSubdir !== repository.projectSubdir)
|
|
592
|
+
throw new WorktreeError('PROJECT_REPOSITORY_MISMATCH', 'The source checkout does not map to the selected original project folder.');
|
|
593
|
+
repository = original;
|
|
594
|
+
}
|
|
595
|
+
return this.queued(repository.commonDir, invocation.signal, async () => {
|
|
596
|
+
this.assertProject(project);
|
|
597
|
+
await this.sameSettings(source, settings, request, invocation);
|
|
598
|
+
const remote = requiredText(request.remote, 'remote');
|
|
599
|
+
const remoteBranch = requiredText(request.remoteBranch, 'remoteBranch');
|
|
600
|
+
const identity = (await git.remotes(repository.root, invocation.signal)).find(r => r.name === remote)?.identity;
|
|
601
|
+
if (identity === undefined)
|
|
602
|
+
throw new WorktreeError('REMOTE_NOT_FOUND', 'The selected remote is not configured.');
|
|
603
|
+
if (request.remoteIdentity !== undefined && request.remoteIdentity !== identity)
|
|
604
|
+
throw new WorktreeError('REMOTE_CHANGED', 'The remote configuration changed. Reload its branch list.');
|
|
605
|
+
const root = resolveWorktreeRoot(await canonicalFuture(resolveWorktreeRoot(invocation.capturedRoot)));
|
|
606
|
+
if (contained(repository.root, root))
|
|
607
|
+
throw new WorktreeError('UNSAFE_ROOT', 'The worktree root must be outside the source checkout.');
|
|
608
|
+
const id = randomUUID();
|
|
609
|
+
const directoryRoot = resolve(root, hashValue(repository.commonDir).slice(0, 24), id);
|
|
610
|
+
const initialBranch = `worktree/${id}`;
|
|
611
|
+
const destination = resolve(directoryRoot, `worktree-${id}`);
|
|
612
|
+
const record = { id, operationId, repoRoot: repository.root, commonDir: repository.commonDir, projectSubdir: repository.projectSubdir, checkoutRoot: destination, effectiveCwd: resolve(destination, repository.projectSubdir), remote, remoteIdentity: identity, remoteBranch, baseOid: '', baseRef: `refs/dsh-worktrees/${id}/base`, fetchedAt: 0, createdAt: Date.now(), sessionIds: [], workspaceId: null, branch: null, ...(project === undefined ? {} : { projectId: project.projectId, folderId: project.folderId }), protected: false, archived: false, state: 'creating', error: null };
|
|
613
|
+
let operation = this.newOperation(operationId, 'create', requestHash, invocation.agent, record, source, settings, request.sessionMode ?? 'new');
|
|
614
|
+
let effects = false;
|
|
615
|
+
try {
|
|
616
|
+
await this.putOperation(operation, request, invocation);
|
|
617
|
+
await this.putRecord(record, request, invocation);
|
|
618
|
+
await this.sameSettings(source, settings, request, invocation);
|
|
619
|
+
operation = await this.phase(operation, 'fetching', request, invocation);
|
|
620
|
+
effects = true;
|
|
621
|
+
this.guard(request, invocation);
|
|
622
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
623
|
+
if (await canonicalFuture(destination) !== destination)
|
|
624
|
+
throw new WorktreeError('PATH_CHANGED', 'The destination ancestry changed during preparation.');
|
|
625
|
+
this.guard(request, invocation);
|
|
626
|
+
const progressOperation = Object.freeze({ id: operationId, worktreeId: id });
|
|
627
|
+
const checkout = await git.create({ id, repository, destination, remote, remoteIdentity: identity, remoteBranch, baseRef: record.baseRef, signal: invocation.signal,
|
|
628
|
+
onProgress: stage => this.progress(invocation, progressOperation, stage),
|
|
629
|
+
});
|
|
630
|
+
let created = { ...record, ...checkout };
|
|
631
|
+
await this.store.worktrees.put(created.id, clone(created));
|
|
632
|
+
operation = await this.receipt(operation, 'checkout-created');
|
|
633
|
+
await this.sameSettings(source, settings, request, invocation);
|
|
634
|
+
if (created.checkoutRoot !== destination || created.effectiveCwd !== record.effectiveCwd || !contained(destination, created.effectiveCwd))
|
|
635
|
+
throw new WorktreeError('UNSAFE_PATH', 'The created checkout does not match its planned directory mapping.');
|
|
636
|
+
{
|
|
637
|
+
this.guard(request, invocation);
|
|
638
|
+
// Keep one stable random checkout and initial branch in both flows.
|
|
639
|
+
const state = await git.createBranch(created, initialBranch, invocation.signal);
|
|
640
|
+
created = { ...created, branch: state.branch };
|
|
641
|
+
this.statuses.set(created.id, state);
|
|
642
|
+
await this.store.worktrees.put(created.id, clone(created));
|
|
643
|
+
await this.sameSettings(source, settings, request, invocation);
|
|
644
|
+
}
|
|
645
|
+
if (request.firstPrompt !== undefined) {
|
|
646
|
+
// Durable intent precedes the one bounded auxiliary call. Interrupted
|
|
647
|
+
// creates remain fail-closed, retaining checkout/name instead of paying again.
|
|
648
|
+
operation = await this.phase(operation, 'checkout-created', request, invocation, { namingStarted: true });
|
|
649
|
+
this.progress(invocation, progressOperation, 'naming');
|
|
650
|
+
this.guard(request, invocation);
|
|
651
|
+
// Foreground setup never waits beyond 15s, even if legacy background
|
|
652
|
+
// naming has a longer configured limit; shorter configured limits win.
|
|
653
|
+
const config = { ...this.config, namingTimeoutMs: Math.min(namingConfig(this.config).namingTimeoutMs, NAMING_DEFAULTS.namingTimeoutMs) };
|
|
654
|
+
const generatedName = await generateWorktreeName({ source, caller: invocation.agent, firstPrompt: request.firstPrompt, id, config, signal: invocation.signal, guard: () => this.guard(request, invocation) });
|
|
655
|
+
operation = await this.receipt(operation, 'checkout-created', { generatedName });
|
|
656
|
+
created = { ...created, displayName: generatedName.title, naming: generatedName };
|
|
657
|
+
await this.store.worktrees.put(created.id, clone(created));
|
|
658
|
+
await this.sameSettings(source, settings, request, invocation);
|
|
659
|
+
this.assertProject(project);
|
|
660
|
+
const renamed = await git.renameBranch(created, initialBranch, generatedName.branch, invocation.signal);
|
|
661
|
+
// This receipt is storage-only even if cancellation follows Git's commit.
|
|
662
|
+
created = { ...created, branch: renamed.branch };
|
|
663
|
+
this.statuses.set(created.id, renamed);
|
|
664
|
+
await this.store.worktrees.put(created.id, clone(created));
|
|
665
|
+
await this.sameSettings(source, settings, request, invocation);
|
|
666
|
+
}
|
|
667
|
+
this.assertProject(project);
|
|
668
|
+
this.progress(invocation, progressOperation, 'opening');
|
|
669
|
+
this.guard(request, invocation);
|
|
670
|
+
const result = await this.bootstrap.create({ cwd: checkout.effectiveCwd, title: created.displayName ?? `${basename(repository.root)} · ${remoteBranch}`, source, settings, mode: operation.sessionMode, signal: invocation.signal,
|
|
671
|
+
onCreated: async (identity) => {
|
|
672
|
+
operation = await this.receipt(operation, 'session-created', { sessionId: identity.sessionId, workspaceId: identity.workspaceId });
|
|
673
|
+
if (request.firstPrompt === undefined)
|
|
674
|
+
created = { ...created, firstMessageNaming: { sessionId: identity.sessionId, initialBranch, phase: 'waiting' } };
|
|
675
|
+
await this.store.worktrees.put(created.id, { ...created, sessionIds: [identity.sessionId], workspaceId: identity.workspaceId });
|
|
676
|
+
await this.bindThread(identity.sessionId, checkout.effectiveCwd, project, created.id);
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
const next = { ...created, sessionIds: [result.sessionId], workspaceId: result.workspaceId };
|
|
680
|
+
await this.sameSettings(source, settings, request, invocation);
|
|
681
|
+
await this.putRecord({ ...next, state: 'ready' }, request, invocation);
|
|
682
|
+
operation = await this.phase(operation, 'ready', request, invocation);
|
|
683
|
+
this.progress(invocation, progressOperation, 'ready');
|
|
684
|
+
if (request.firstPrompt === undefined)
|
|
685
|
+
this.dependencies.onNamingEligible?.(result.agent);
|
|
686
|
+
return this.operationResult(operation);
|
|
687
|
+
}
|
|
688
|
+
catch (error) {
|
|
689
|
+
await this.journalFailure(operation.id, error, effects);
|
|
690
|
+
throw error;
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
async start(request, invocation, record) {
|
|
695
|
+
const source = this.bootstrap.actor(request.sourceSessionId ?? invocation.agent.id);
|
|
696
|
+
const settings = await this.bootstrap.settings(source, invocation.signal);
|
|
697
|
+
const project = this.dependencies.projects?.selectionForWorktree(record.id) ?? this.projectSelection(resolve(record.repoRoot, record.projectSubdir));
|
|
698
|
+
let operation = this.newOperation(randomUUID(), 'create', hashValue({ request, actor: invocation.agent.id }), invocation.agent, record, source, settings, request.sessionMode ?? 'new');
|
|
699
|
+
try {
|
|
700
|
+
await this.putOperation(operation, request, invocation);
|
|
701
|
+
this.assertProject(project);
|
|
702
|
+
await this.sameSettings(source, settings, request, invocation);
|
|
703
|
+
await this.bootstrap.create({ cwd: record.effectiveCwd, title: record.displayName ?? `${basename(record.repoRoot)} · ${record.remoteBranch}`, source, settings, mode: operation.sessionMode, signal: invocation.signal,
|
|
704
|
+
onCreated: async (identity) => {
|
|
705
|
+
operation = await this.receipt(operation, 'session-created', { sessionId: identity.sessionId, workspaceId: identity.workspaceId });
|
|
706
|
+
await this.store.worktrees.put(record.id, { ...record, sessionIds: [...record.sessionIds, identity.sessionId], workspaceId: identity.workspaceId });
|
|
707
|
+
await this.bindThread(identity.sessionId, record.effectiveCwd, project, record.id);
|
|
708
|
+
},
|
|
709
|
+
});
|
|
710
|
+
await this.sameSettings(source, settings, request, invocation);
|
|
711
|
+
operation = await this.phase(operation, 'ready', request, invocation);
|
|
712
|
+
return this.operationResult(operation);
|
|
713
|
+
}
|
|
714
|
+
catch (error) {
|
|
715
|
+
await this.journalFailure(operation.id, error, true);
|
|
716
|
+
throw error;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
async candidates(roots, signal) {
|
|
720
|
+
const agents = required(this.owner, 'agents').list();
|
|
721
|
+
const candidates = [];
|
|
722
|
+
for (const agent of agents) {
|
|
723
|
+
abortIfRequested(signal);
|
|
724
|
+
const cwd = agent.session.header.cwd;
|
|
725
|
+
if (cwd === undefined)
|
|
726
|
+
continue;
|
|
727
|
+
let canonical;
|
|
728
|
+
try {
|
|
729
|
+
canonical = await realpath(cwd);
|
|
730
|
+
}
|
|
731
|
+
catch {
|
|
732
|
+
throw new WorktreeError('BUSY_UNKNOWN', 'A live session working directory could not be inspected safely.');
|
|
733
|
+
}
|
|
734
|
+
if (roots.some(root => contained(root, canonical)))
|
|
735
|
+
candidates.push(agent);
|
|
736
|
+
}
|
|
737
|
+
return candidates.sort((a, b) => a.id.localeCompare(b.id));
|
|
738
|
+
}
|
|
739
|
+
checkIdle(agents, invocation) {
|
|
740
|
+
for (const agent of agents) {
|
|
741
|
+
if (!this.bootstrap.isLive(agent) || agent.status !== 'idle' || agent.inbox.nextStep.length > 0 || agent.inbox.nextTurn.length > 0)
|
|
742
|
+
throw new WorktreeError('BUSY', 'A known source or target session has active or pending work. Finish it before handoff; the invoking tool never waits for its own turn.');
|
|
743
|
+
try {
|
|
744
|
+
if (capability(this.owner, 'jobs')?.list(agent.id).some(job => job.status === 'running' || job.status === 'stopping'))
|
|
745
|
+
throw new WorktreeError('BUSY', 'Known background jobs are still running or stopping.');
|
|
746
|
+
if (capability(this.owner, 'terminals')?.hasOwnerActivity(agent))
|
|
747
|
+
throw new WorktreeError('BUSY', 'A known terminal still owns activity in the checkout.');
|
|
748
|
+
if (capability(this.owner, 'terminalController')?.list(agent.id).some(terminal => terminal.state === 'running'))
|
|
749
|
+
throw new WorktreeError('BUSY', 'A known user terminal is running in the checkout.');
|
|
750
|
+
}
|
|
751
|
+
catch (error) {
|
|
752
|
+
if (error instanceof WorktreeError)
|
|
753
|
+
throw error;
|
|
754
|
+
throw new WorktreeError('BUSY_UNKNOWN', 'Background activity diagnostics could not be read.');
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
// Unowned jobs cannot be associated reliably with any checkout; conservatively deny.
|
|
758
|
+
try {
|
|
759
|
+
if (capability(this.owner, 'jobs')?.list().some(job => job.status === 'running' || job.status === 'stopping'))
|
|
760
|
+
throw new WorktreeError('BUSY', 'An unowned background job is still running or stopping.');
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
if (error instanceof WorktreeError)
|
|
764
|
+
throw error;
|
|
765
|
+
throw new WorktreeError('BUSY_UNKNOWN', 'Unowned job diagnostics could not be read.');
|
|
766
|
+
}
|
|
767
|
+
abortIfRequested(invocation.signal);
|
|
768
|
+
}
|
|
769
|
+
async checkClaimed(roots, claimed, invocation) {
|
|
770
|
+
const current = await this.candidates(roots, invocation.signal);
|
|
771
|
+
if (current.length !== claimed.length || current.some(agent => !claimed.includes(agent)))
|
|
772
|
+
throw new WorktreeError('BUSY', 'The known source or target session set changed while acquiring maintenance claims. Retry with a new preview.');
|
|
773
|
+
this.checkIdle(current, invocation);
|
|
774
|
+
}
|
|
775
|
+
async claims(agents, invocation, task, index = 0, signals = []) {
|
|
776
|
+
const agent = agents[index];
|
|
777
|
+
if (agent === undefined)
|
|
778
|
+
return task(AbortSignal.any([invocation.signal, ...signals]));
|
|
779
|
+
this.checkIdle([agent], invocation);
|
|
780
|
+
let claim;
|
|
781
|
+
try {
|
|
782
|
+
claim = agent.runMaintenance(signal => this.claims(agents, invocation, task, index + 1, [...signals, signal]));
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
throw new WorktreeError('BUSY', 'An idle maintenance claim could not be acquired. Finish active work before retrying.');
|
|
786
|
+
}
|
|
787
|
+
return claim;
|
|
788
|
+
}
|
|
789
|
+
async sourceIn(record, requested, actor, signal) {
|
|
790
|
+
let source;
|
|
791
|
+
if (requested !== undefined)
|
|
792
|
+
source = this.bootstrap.actor(requested);
|
|
793
|
+
else {
|
|
794
|
+
const cwd = actor.session.header.cwd;
|
|
795
|
+
const actorInCheckout = cwd !== undefined && contained(record.checkoutRoot, await realpath(cwd));
|
|
796
|
+
source = actorInCheckout ? actor : this.bootstrap.actor(requiredText(record.sessionIds[0], 'primary worktree session'));
|
|
797
|
+
}
|
|
798
|
+
const cwd = await this.local(source, undefined, signal);
|
|
799
|
+
if (!contained(record.checkoutRoot, cwd))
|
|
800
|
+
throw new WorktreeError('SOURCE_OUTSIDE_WORKTREE', 'The source session must execute inside this validated worktree.');
|
|
801
|
+
return source;
|
|
802
|
+
}
|
|
803
|
+
async preview(request, invocation, git, record) {
|
|
804
|
+
const source = await this.sourceIn(record, request.sourceSessionId, invocation.agent, invocation.signal);
|
|
805
|
+
const originalFolder = this.dependencies.projects?.selectionForWorktree(record.id)?.path;
|
|
806
|
+
const targetPath = await this.local(invocation.agent, request.targetPath ?? originalFolder ?? record.repoRoot, invocation.signal);
|
|
807
|
+
const target = await git.discover(targetPath, invocation.signal);
|
|
808
|
+
if (target.commonDir !== record.commonDir || target.root === record.checkoutRoot || this.records().some(item => item.checkoutRoot === target.root))
|
|
809
|
+
throw new WorktreeError('INVALID_TARGET', 'Choose a Local checkout of this repository, not a managed worktree.');
|
|
810
|
+
const roots = [record.checkoutRoot, target.root];
|
|
811
|
+
const agents = await this.candidates(roots, invocation.signal);
|
|
812
|
+
this.checkIdle(agents, invocation);
|
|
813
|
+
return this.claims(agents, invocation, async (signal) => {
|
|
814
|
+
const scoped = { ...invocation, signal };
|
|
815
|
+
this.guard(request, scoped);
|
|
816
|
+
await this.checkClaimed(roots, agents, scoped);
|
|
817
|
+
const snapshotRoot = resolve(await canonicalFuture(resolve(this.config.root)), '.snapshots');
|
|
818
|
+
if (roots.some(root => contained(root, snapshotRoot)))
|
|
819
|
+
throw new WorktreeError('UNSAFE_ROOT', 'Private snapshots must remain outside both checkouts.');
|
|
820
|
+
this.guard(request, scoped);
|
|
821
|
+
await mkdir(snapshotRoot, { recursive: true, mode: 0o700 });
|
|
822
|
+
if (await realpath(snapshotRoot) !== snapshotRoot)
|
|
823
|
+
throw new WorktreeError('PATH_CHANGED', 'The snapshot directory ancestry changed.');
|
|
824
|
+
this.guard(request, scoped);
|
|
825
|
+
const preview = await git.snapshot({ worktreeId: record.id, sourceSessionId: source.id, repositoryRoot: record.repoRoot, checkoutRoot: record.checkoutRoot, targetRoot: target.root, baseOid: record.baseOid, snapshotRoot, signal });
|
|
826
|
+
if (this.previews.size >= 128)
|
|
827
|
+
this.previews.delete(this.previews.keys().next().value ?? '');
|
|
828
|
+
this.previews.set(preview.id, clone(preview));
|
|
829
|
+
return clone(preview);
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
previewById(id) {
|
|
833
|
+
const preview = this.previews.get(requiredText(id, 'previewId'));
|
|
834
|
+
if (preview === undefined)
|
|
835
|
+
throw new WorktreeError('PREVIEW_EXPIRED', 'The preview expired or belongs to an earlier plugin lifetime. Generate a fresh preview.');
|
|
836
|
+
return clone(preview);
|
|
837
|
+
}
|
|
838
|
+
async exportPatch(request, invocation) {
|
|
839
|
+
const preview = this.previewById(request.previewId);
|
|
840
|
+
this.guard(request, invocation);
|
|
841
|
+
const root = await realpath(resolve(this.config.root, '.snapshots'));
|
|
842
|
+
const path = await realpath(preview.patchPath);
|
|
843
|
+
const info = await stat(path);
|
|
844
|
+
if (!contained(root, path) || !info.isFile())
|
|
845
|
+
throw new WorktreeError('INVALID_PATCH', 'The patch is not an existing plugin-owned snapshot file.');
|
|
846
|
+
if (info.size !== preview.bytes || info.size > this.config.maxSnapshotBytes)
|
|
847
|
+
throw new WorktreeError('PATCH_CHANGED', 'The exported patch exceeds its immutable preview size or configured bound.');
|
|
848
|
+
this.guard(request, invocation);
|
|
849
|
+
const bytes = await readFile(path);
|
|
850
|
+
if (bytes.length !== preview.bytes || createHash('sha256').update(bytes).digest('hex') !== preview.patchHash)
|
|
851
|
+
throw new WorktreeError('PATCH_CHANGED', 'The exported patch no longer matches its immutable preview.');
|
|
852
|
+
return { path, bytes: preview.bytes, patchHash: preview.patchHash, ...(bytes.length <= 65536 ? { patch: bytes.toString('utf8') } : {}) };
|
|
853
|
+
}
|
|
854
|
+
async handoff(request, invocation, git) {
|
|
855
|
+
const id = requiredText(request.operationId, 'operationId');
|
|
856
|
+
const requestHash = hashValue({ request, actor: invocation.agent.id });
|
|
857
|
+
const existing = this.store.operations.get(id);
|
|
858
|
+
if (existing !== undefined && (existing.kind !== 'handoff' || existing.actorId !== invocation.agent.id || existing.requestHash !== requestHash))
|
|
859
|
+
throw new WorktreeError('OPERATION_REUSED', 'The operation id is already bound to another request or caller.');
|
|
860
|
+
if (existing?.phase === 'ready')
|
|
861
|
+
return this.operationResult(existing);
|
|
862
|
+
if (existing !== undefined && existing.phase !== 'code-applied')
|
|
863
|
+
throw new WorktreeError('RECOVERY_REQUIRED', 'The application result is uncertain. Inspect actual checkout state; the patch is never automatically repeated.');
|
|
864
|
+
const record = this.record(request.id);
|
|
865
|
+
this.usable(record, false);
|
|
866
|
+
const preview = existing === undefined ? this.previewById(request.previewId) : undefined;
|
|
867
|
+
if (preview !== undefined && preview.worktreeId !== record.id)
|
|
868
|
+
throw new WorktreeError('PREVIEW_MISMATCH', 'This preview belongs to another worktree.');
|
|
869
|
+
const source = await this.sourceIn(record, existing?.sourceSessionId ?? preview?.sourceSessionId, invocation.agent, invocation.signal);
|
|
870
|
+
const settings = existing?.settings ?? await this.bootstrap.settings(source, invocation.signal);
|
|
871
|
+
const targetRoot = existing?.targetRoot ?? preview?.targetRoot;
|
|
872
|
+
if (targetRoot === undefined || targetRoot === null)
|
|
873
|
+
throw new WorktreeError('RECOVERY_REQUIRED', 'The durable target checkout identity is unavailable.');
|
|
874
|
+
const target = await git.discover(await this.local(invocation.agent, targetRoot, invocation.signal), invocation.signal);
|
|
875
|
+
if (target.root !== targetRoot || target.commonDir !== record.commonDir)
|
|
876
|
+
throw new WorktreeError('INVALID_TARGET', 'The target checkout identity changed.');
|
|
877
|
+
const targetWorktree = this.records().find(item => item.checkoutRoot === targetRoot);
|
|
878
|
+
if (targetWorktree !== undefined && existing === undefined)
|
|
879
|
+
throw new WorktreeError('INVALID_TARGET', 'Choose a Local checkout rather than another managed worktree.');
|
|
880
|
+
return this.queued(record.commonDir, invocation.signal, async () => {
|
|
881
|
+
this.guard(request, invocation);
|
|
882
|
+
this.statuses.set(record.id, await git.verify(record, invocation.signal));
|
|
883
|
+
const roots = [record.checkoutRoot, targetRoot];
|
|
884
|
+
const agents = await this.candidates(roots, invocation.signal);
|
|
885
|
+
this.checkIdle(agents, invocation);
|
|
886
|
+
return this.claims(agents, invocation, async (signal) => {
|
|
887
|
+
const scoped = { ...invocation, signal };
|
|
888
|
+
this.guard(request, scoped);
|
|
889
|
+
await this.checkClaimed(roots, agents, scoped);
|
|
890
|
+
await this.sameSettings(source, settings, request, scoped);
|
|
891
|
+
const cwd = await realpath(resolve(targetRoot, record.projectSubdir));
|
|
892
|
+
if (!contained(targetRoot, cwd) || !(await stat(cwd)).isDirectory())
|
|
893
|
+
throw new WorktreeError('MISSING_PROJECT_DIRECTORY', 'The target project directory is missing or escapes the target checkout.');
|
|
894
|
+
const targetProject = this.dependencies.projects === undefined ? undefined : targetWorktree === undefined
|
|
895
|
+
? await this.dependencies.projects.ensureFolder(cwd, signal)
|
|
896
|
+
: this.dependencies.projects.selectionForWorktree(targetWorktree.id);
|
|
897
|
+
this.assertProject(targetProject);
|
|
898
|
+
this.guard(request, scoped);
|
|
899
|
+
let operation = existing ?? this.newOperation(id, 'handoff', requestHash, invocation.agent, record, source, settings, 'continue', targetRoot, preview?.id ?? null);
|
|
900
|
+
try {
|
|
901
|
+
if (existing === undefined) {
|
|
902
|
+
if (preview === undefined)
|
|
903
|
+
throw new WorktreeError('PREVIEW_EXPIRED', 'Generate a new preview.');
|
|
904
|
+
await this.putOperation(operation, request, scoped);
|
|
905
|
+
operation = await this.phase(operation, 'applying', request, scoped);
|
|
906
|
+
this.guard(request, scoped);
|
|
907
|
+
await this.checkClaimed(roots, agents, scoped);
|
|
908
|
+
const applied = await git.apply(preview, record, signal);
|
|
909
|
+
operation = await this.receipt(operation, 'code-applied', { appliedHead: applied.head });
|
|
910
|
+
const appliedFingerprint = await git.fingerprint(targetRoot, signal);
|
|
911
|
+
operation = await this.receipt(operation, 'code-applied', { appliedFingerprint });
|
|
912
|
+
}
|
|
913
|
+
else {
|
|
914
|
+
const state = await git.status(targetRoot, signal);
|
|
915
|
+
const fingerprint = await git.fingerprint(targetRoot, signal);
|
|
916
|
+
if (state.head !== operation.appliedHead || fingerprint !== operation.appliedFingerprint)
|
|
917
|
+
throw new WorktreeError('RECOVERY_REQUIRED', 'The target checkout changed after application. Inspect actual state before creating the continuation.');
|
|
918
|
+
}
|
|
919
|
+
if (await realpath(resolve(targetRoot, record.projectSubdir)) !== cwd || !(await stat(cwd)).isDirectory())
|
|
920
|
+
throw new WorktreeError('MISSING_PROJECT_DIRECTORY', 'The Local project directory changed during application.');
|
|
921
|
+
this.assertProject(targetProject);
|
|
922
|
+
this.guard(request, scoped);
|
|
923
|
+
await this.sameSettings(source, settings, request, scoped);
|
|
924
|
+
await this.bootstrap.create({ cwd, title: `${basename(record.repoRoot)} · local continuation`, source, settings, mode: 'continue', signal,
|
|
925
|
+
onCreated: async (identity) => {
|
|
926
|
+
operation = await this.receipt(operation, 'session-created', { sessionId: identity.sessionId, workspaceId: identity.workspaceId });
|
|
927
|
+
// Normal handoff is Local. Older already-applied receipts targeting
|
|
928
|
+
// a managed checkout retain truthful target backing during recovery.
|
|
929
|
+
await this.bindThread(identity.sessionId, cwd, targetProject, targetWorktree?.id);
|
|
930
|
+
},
|
|
931
|
+
});
|
|
932
|
+
await this.sameSettings(source, settings, request, scoped);
|
|
933
|
+
operation = await this.phase(operation, 'ready', request, scoped);
|
|
934
|
+
return this.operationResult(operation);
|
|
935
|
+
}
|
|
936
|
+
catch (error) {
|
|
937
|
+
await this.journalFailure(operation.id, error, true);
|
|
938
|
+
throw error;
|
|
939
|
+
}
|
|
940
|
+
});
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
}
|