@akira-tl/forgerelay 0.8.1 → 0.8.3
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 +13 -0
- package/README.md +4 -2
- package/capabilities/workspace-tasks/GUIDE.md +29 -0
- package/dist/capabilities.js +6 -0
- package/dist/capability-registry.js +82 -21
- package/dist/composite-activity.js +1 -1
- package/dist/composite-workspaces.js +31 -10
- package/dist/git-worktrees.js +22 -0
- package/dist/mcp/server-instructions.js +1 -1
- package/dist/server.js +290 -60
- package/dist/subagents/sessions/capability.js +3 -0
- package/dist/workspace-store.js +25 -0
- package/dist/workspace-tasks.js +351 -0
- package/dist/workspaces.js +95 -13
- package/docs/chatgpt-coding-workflow.md +17 -2
- package/docs/configuration.md +21 -6
- package/package.json +2 -2
- package/scripts/debug/accept.mjs +255 -2
package/dist/workspace-store.js
CHANGED
|
@@ -90,6 +90,31 @@ export class SqliteWorkspaceStore {
|
|
|
90
90
|
.where(eq(workspaceSessions.id, sessionId))
|
|
91
91
|
.run();
|
|
92
92
|
}
|
|
93
|
+
replaceWorktreeBacking(input) {
|
|
94
|
+
const sessionId = this.resolveSessionId(input.id);
|
|
95
|
+
if (!sessionId)
|
|
96
|
+
throw new Error(`Unknown workspace session: ${input.id}`);
|
|
97
|
+
this.pendingSessionTouches.delete(sessionId);
|
|
98
|
+
const row = this.database.db
|
|
99
|
+
.update(workspaceSessions)
|
|
100
|
+
.set({
|
|
101
|
+
root: input.root,
|
|
102
|
+
status: "active",
|
|
103
|
+
sourceRoot: input.sourceRoot,
|
|
104
|
+
baseRef: input.baseRef,
|
|
105
|
+
baseSha: input.baseSha,
|
|
106
|
+
branch: input.branch,
|
|
107
|
+
targetBranch: input.targetBranch,
|
|
108
|
+
managed: "true",
|
|
109
|
+
lastUsedAt: this.now().toISOString(),
|
|
110
|
+
})
|
|
111
|
+
.where(eq(workspaceSessions.id, sessionId))
|
|
112
|
+
.returning()
|
|
113
|
+
.get();
|
|
114
|
+
if (!row)
|
|
115
|
+
throw new Error(`Unknown workspace session: ${input.id}`);
|
|
116
|
+
return rowToWorkspaceSession(row);
|
|
117
|
+
}
|
|
93
118
|
listSessions(input = {}) {
|
|
94
119
|
const conditions = [
|
|
95
120
|
input.status ? eq(workspaceSessions.status, input.status) : undefined,
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, rmdirSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import * as z from "zod/v4";
|
|
5
|
+
const TASK_STATE_VERSION = 1;
|
|
6
|
+
const MAX_TASK_STATE_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
const MAX_TASK_LISTS = 100;
|
|
8
|
+
const MAX_TASKS_PER_LIST = 500;
|
|
9
|
+
const MAX_LIST_NAME_LENGTH = 120;
|
|
10
|
+
const MAX_TASK_SUBJECT_LENGTH = 240;
|
|
11
|
+
const MAX_TASK_CONTENT_LENGTH = 64 * 1024;
|
|
12
|
+
const workspaceTaskSchema = z.object({
|
|
13
|
+
id: z.string().regex(/^tsk_[a-f0-9]{10}$/),
|
|
14
|
+
status: z.enum(["pending", "in_progress", "completed"]),
|
|
15
|
+
subject: z.string().min(1).max(MAX_TASK_SUBJECT_LENGTH),
|
|
16
|
+
content: z.string().max(MAX_TASK_CONTENT_LENGTH),
|
|
17
|
+
}).strict();
|
|
18
|
+
const workspaceTaskListSchema = z.object({
|
|
19
|
+
id: z.string().regex(/^tl_[a-f0-9]{10}$/),
|
|
20
|
+
name: z.string().min(1).max(MAX_LIST_NAME_LENGTH),
|
|
21
|
+
state: z.enum(["active", "archived"]),
|
|
22
|
+
revision: z.number().int().positive(),
|
|
23
|
+
tasks: z.array(workspaceTaskSchema).max(MAX_TASKS_PER_LIST),
|
|
24
|
+
}).strict();
|
|
25
|
+
const workspaceTaskStateSchema = z.object({
|
|
26
|
+
version: z.literal(TASK_STATE_VERSION),
|
|
27
|
+
revision: z.number().int().nonnegative(),
|
|
28
|
+
lists: z.array(workspaceTaskListSchema).max(MAX_TASK_LISTS),
|
|
29
|
+
}).strict().superRefine((state, context) => {
|
|
30
|
+
const listIds = new Set();
|
|
31
|
+
const taskIds = new Set();
|
|
32
|
+
state.lists.forEach((list, listIndex) => {
|
|
33
|
+
if (listIds.has(list.id)) {
|
|
34
|
+
context.addIssue({
|
|
35
|
+
code: "custom",
|
|
36
|
+
path: ["lists", listIndex, "id"],
|
|
37
|
+
message: `Duplicate Task List id ${list.id}.`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
listIds.add(list.id);
|
|
41
|
+
list.tasks.forEach((task, taskIndex) => {
|
|
42
|
+
if (taskIds.has(task.id)) {
|
|
43
|
+
context.addIssue({
|
|
44
|
+
code: "custom",
|
|
45
|
+
path: ["lists", listIndex, "tasks", taskIndex, "id"],
|
|
46
|
+
message: `Duplicate Task id ${task.id}.`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
taskIds.add(task.id);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
export class WorkspaceTaskStore {
|
|
54
|
+
stateDir;
|
|
55
|
+
constructor(stateDir) {
|
|
56
|
+
this.stateDir = stateDir;
|
|
57
|
+
}
|
|
58
|
+
ensureWorkspace(workspaceId) {
|
|
59
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
60
|
+
const loaded = this.tryReadState(id);
|
|
61
|
+
if (loaded)
|
|
62
|
+
return snapshot(loaded.state, loaded.fingerprint);
|
|
63
|
+
return this.writeState(id, emptyState());
|
|
64
|
+
}
|
|
65
|
+
initializeWorkspace(workspaceId) {
|
|
66
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
67
|
+
const directory = this.workspaceStateDir(id);
|
|
68
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
69
|
+
try {
|
|
70
|
+
writeFileSync(this.statePath(id), `${JSON.stringify(emptyState(), null, 2)}\n`, {
|
|
71
|
+
mode: 0o600,
|
|
72
|
+
flag: "wx",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (!isErrno(error, "EEXIST"))
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
read(workspaceId) {
|
|
81
|
+
return this.ensureWorkspace(workspaceId);
|
|
82
|
+
}
|
|
83
|
+
createList(workspaceId, input) {
|
|
84
|
+
return this.mutate(workspaceId, (state) => {
|
|
85
|
+
if (state.lists.length >= MAX_TASK_LISTS) {
|
|
86
|
+
throw new Error(`Workspace Task List limit is ${MAX_TASK_LISTS}.`);
|
|
87
|
+
}
|
|
88
|
+
const list = {
|
|
89
|
+
id: `tl_${randomBytes(5).toString("hex")}`,
|
|
90
|
+
name: normalizeListName(input.name),
|
|
91
|
+
state: "active",
|
|
92
|
+
revision: 1,
|
|
93
|
+
tasks: [],
|
|
94
|
+
};
|
|
95
|
+
const position = normalizeInsertPosition(input.position, state.lists.length, "Task List");
|
|
96
|
+
state.lists.splice(position, 0, list);
|
|
97
|
+
return true;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
updateList(workspaceId, listId, input) {
|
|
101
|
+
return this.mutate(workspaceId, (state) => {
|
|
102
|
+
const index = requireListIndex(state, listId);
|
|
103
|
+
const list = state.lists[index];
|
|
104
|
+
const nextName = input.name === undefined ? list.name : normalizeListName(input.name);
|
|
105
|
+
const nextState = input.state ?? list.state;
|
|
106
|
+
const nextPosition = input.position === undefined
|
|
107
|
+
? index
|
|
108
|
+
: normalizeMovePosition(input.position, state.lists.length, "Task List");
|
|
109
|
+
const metadataChanged = nextName !== list.name || nextState !== list.state;
|
|
110
|
+
const positionChanged = nextPosition !== index;
|
|
111
|
+
if (!metadataChanged && !positionChanged)
|
|
112
|
+
return false;
|
|
113
|
+
list.name = nextName;
|
|
114
|
+
list.state = nextState;
|
|
115
|
+
list.revision += 1;
|
|
116
|
+
if (positionChanged)
|
|
117
|
+
moveArrayEntry(state.lists, index, nextPosition);
|
|
118
|
+
return true;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
deleteList(workspaceId, listId) {
|
|
122
|
+
return this.mutate(workspaceId, (state) => {
|
|
123
|
+
state.lists.splice(requireListIndex(state, listId), 1);
|
|
124
|
+
return true;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
createTask(workspaceId, listId, input) {
|
|
128
|
+
return this.mutate(workspaceId, (state) => {
|
|
129
|
+
const list = requireList(state, listId);
|
|
130
|
+
if (list.tasks.length >= MAX_TASKS_PER_LIST) {
|
|
131
|
+
throw new Error(`Task limit per Task List is ${MAX_TASKS_PER_LIST}.`);
|
|
132
|
+
}
|
|
133
|
+
const task = {
|
|
134
|
+
id: `tsk_${randomBytes(5).toString("hex")}`,
|
|
135
|
+
status: input.status ?? "pending",
|
|
136
|
+
subject: normalizeTaskSubject(input.subject),
|
|
137
|
+
content: normalizeTaskContent(input.content ?? ""),
|
|
138
|
+
};
|
|
139
|
+
const position = normalizeInsertPosition(input.position, list.tasks.length, "Task");
|
|
140
|
+
list.tasks.splice(position, 0, task);
|
|
141
|
+
list.revision += 1;
|
|
142
|
+
return true;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
updateTask(workspaceId, listId, taskId, input) {
|
|
146
|
+
return this.mutate(workspaceId, (state) => {
|
|
147
|
+
const list = requireList(state, listId);
|
|
148
|
+
const index = requireTaskIndex(list, taskId);
|
|
149
|
+
const task = list.tasks[index];
|
|
150
|
+
const nextStatus = input.status ?? task.status;
|
|
151
|
+
const nextSubject = input.subject === undefined ? task.subject : normalizeTaskSubject(input.subject);
|
|
152
|
+
const nextContent = input.content === undefined ? task.content : normalizeTaskContent(input.content);
|
|
153
|
+
const nextPosition = input.position === undefined
|
|
154
|
+
? index
|
|
155
|
+
: normalizeMovePosition(input.position, list.tasks.length, "Task");
|
|
156
|
+
const fieldsChanged = nextStatus !== task.status || nextSubject !== task.subject || nextContent !== task.content;
|
|
157
|
+
const positionChanged = nextPosition !== index;
|
|
158
|
+
if (!fieldsChanged && !positionChanged)
|
|
159
|
+
return false;
|
|
160
|
+
task.status = nextStatus;
|
|
161
|
+
task.subject = nextSubject;
|
|
162
|
+
task.content = nextContent;
|
|
163
|
+
if (positionChanged)
|
|
164
|
+
moveArrayEntry(list.tasks, index, nextPosition);
|
|
165
|
+
list.revision += 1;
|
|
166
|
+
return true;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
deleteTask(workspaceId, listId, taskId) {
|
|
170
|
+
return this.mutate(workspaceId, (state) => {
|
|
171
|
+
const list = requireList(state, listId);
|
|
172
|
+
list.tasks.splice(requireTaskIndex(list, taskId), 1);
|
|
173
|
+
list.revision += 1;
|
|
174
|
+
return true;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
deleteWorkspace(workspaceId) {
|
|
178
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
179
|
+
rmSync(this.statePath(id), { force: true });
|
|
180
|
+
try {
|
|
181
|
+
rmdirSync(this.workspaceStateDir(id));
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (!isErrno(error, "ENOENT") && !isErrno(error, "ENOTEMPTY") && !isErrno(error, "EEXIST")) {
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
mutate(workspaceId, mutateState) {
|
|
190
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
191
|
+
const loaded = this.tryReadState(id);
|
|
192
|
+
const state = loaded ? cloneState(loaded.state) : emptyState();
|
|
193
|
+
if (!mutateState(state)) {
|
|
194
|
+
return loaded
|
|
195
|
+
? snapshot(loaded.state, loaded.fingerprint)
|
|
196
|
+
: this.writeState(id, state);
|
|
197
|
+
}
|
|
198
|
+
state.revision += 1;
|
|
199
|
+
return this.writeState(id, state);
|
|
200
|
+
}
|
|
201
|
+
tryReadState(workspaceId) {
|
|
202
|
+
const path = this.statePath(workspaceId);
|
|
203
|
+
let raw;
|
|
204
|
+
try {
|
|
205
|
+
raw = readFileSync(path);
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (isErrno(error, "ENOENT"))
|
|
209
|
+
return undefined;
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
if (raw.byteLength > MAX_TASK_STATE_BYTES) {
|
|
213
|
+
throw new Error(`Workspace Task state exceeds ${MAX_TASK_STATE_BYTES} bytes.`);
|
|
214
|
+
}
|
|
215
|
+
let parsed;
|
|
216
|
+
try {
|
|
217
|
+
parsed = JSON.parse(raw.toString("utf8"));
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
throw new Error(`Workspace Task state is not valid JSON: ${errorMessage(error)}`);
|
|
221
|
+
}
|
|
222
|
+
const validated = workspaceTaskStateSchema.safeParse(parsed);
|
|
223
|
+
if (!validated.success) {
|
|
224
|
+
const details = validated.error.issues
|
|
225
|
+
.map((issue) => `${issue.path.length > 0 ? issue.path.join(".") : "state"}: ${issue.message}`)
|
|
226
|
+
.join("; ");
|
|
227
|
+
throw new Error(`Workspace Task state has an unsupported or invalid format: ${details}`);
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
state: cloneState(validated.data),
|
|
231
|
+
fingerprint: fingerprint(raw),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
writeState(workspaceId, state) {
|
|
235
|
+
const workspaceDir = this.workspaceStateDir(workspaceId);
|
|
236
|
+
mkdirSync(workspaceDir, { recursive: true, mode: 0o700 });
|
|
237
|
+
const validated = workspaceTaskStateSchema.parse(state);
|
|
238
|
+
const serialized = `${JSON.stringify(validated, null, 2)}\n`;
|
|
239
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_TASK_STATE_BYTES) {
|
|
240
|
+
throw new Error(`Workspace Task state exceeds ${MAX_TASK_STATE_BYTES} bytes.`);
|
|
241
|
+
}
|
|
242
|
+
const statePath = this.statePath(workspaceId);
|
|
243
|
+
const tempPath = `${statePath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
244
|
+
try {
|
|
245
|
+
writeFileSync(tempPath, serialized, { mode: 0o600 });
|
|
246
|
+
renameSync(tempPath, statePath);
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
rmSync(tempPath, { force: true });
|
|
250
|
+
}
|
|
251
|
+
return snapshot(validated, fingerprint(Buffer.from(serialized, "utf8")));
|
|
252
|
+
}
|
|
253
|
+
workspaceStateDir(workspaceId) {
|
|
254
|
+
return join(this.stateDir, "workspaces", workspaceId);
|
|
255
|
+
}
|
|
256
|
+
statePath(workspaceId) {
|
|
257
|
+
return join(this.workspaceStateDir(workspaceId), "tasks.json");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function emptyState() {
|
|
261
|
+
return { version: TASK_STATE_VERSION, revision: 0, lists: [] };
|
|
262
|
+
}
|
|
263
|
+
function snapshot(state, stateFingerprint) {
|
|
264
|
+
return {
|
|
265
|
+
...cloneState(state),
|
|
266
|
+
fingerprint: stateFingerprint,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function cloneState(state) {
|
|
270
|
+
return {
|
|
271
|
+
version: state.version,
|
|
272
|
+
revision: state.revision,
|
|
273
|
+
lists: state.lists.map((list) => ({
|
|
274
|
+
...list,
|
|
275
|
+
tasks: list.tasks.map((task) => ({ ...task })),
|
|
276
|
+
})),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function requireList(state, listId) {
|
|
280
|
+
return state.lists[requireListIndex(state, listId)];
|
|
281
|
+
}
|
|
282
|
+
function requireListIndex(state, listId) {
|
|
283
|
+
const index = state.lists.findIndex((list) => list.id === listId);
|
|
284
|
+
if (index < 0)
|
|
285
|
+
throw new Error(`Unknown Task List ${listId}.`);
|
|
286
|
+
return index;
|
|
287
|
+
}
|
|
288
|
+
function requireTaskIndex(list, taskId) {
|
|
289
|
+
const index = list.tasks.findIndex((task) => task.id === taskId);
|
|
290
|
+
if (index < 0)
|
|
291
|
+
throw new Error(`Task List ${list.id} has no Task ${taskId}.`);
|
|
292
|
+
return index;
|
|
293
|
+
}
|
|
294
|
+
function normalizeWorkspaceId(workspaceId) {
|
|
295
|
+
const value = workspaceId.trim();
|
|
296
|
+
if (!/^[a-z][a-z0-9_-]{1,127}$/.test(value)) {
|
|
297
|
+
throw new Error("Workspace ID is not valid for Workspace Task state.");
|
|
298
|
+
}
|
|
299
|
+
return value;
|
|
300
|
+
}
|
|
301
|
+
function normalizeListName(name) {
|
|
302
|
+
const value = name.trim();
|
|
303
|
+
if (!value)
|
|
304
|
+
throw new Error("Task List name must not be empty.");
|
|
305
|
+
if (value.length > MAX_LIST_NAME_LENGTH) {
|
|
306
|
+
throw new Error(`Task List name must be at most ${MAX_LIST_NAME_LENGTH} characters.`);
|
|
307
|
+
}
|
|
308
|
+
return value;
|
|
309
|
+
}
|
|
310
|
+
function normalizeTaskSubject(subject) {
|
|
311
|
+
const value = subject.trim();
|
|
312
|
+
if (!value)
|
|
313
|
+
throw new Error("Task subject must not be empty.");
|
|
314
|
+
if (value.length > MAX_TASK_SUBJECT_LENGTH) {
|
|
315
|
+
throw new Error(`Task subject must be at most ${MAX_TASK_SUBJECT_LENGTH} characters.`);
|
|
316
|
+
}
|
|
317
|
+
return value;
|
|
318
|
+
}
|
|
319
|
+
function normalizeTaskContent(content) {
|
|
320
|
+
if (content.length > MAX_TASK_CONTENT_LENGTH) {
|
|
321
|
+
throw new Error(`Task content must be at most ${MAX_TASK_CONTENT_LENGTH} characters.`);
|
|
322
|
+
}
|
|
323
|
+
return content;
|
|
324
|
+
}
|
|
325
|
+
function normalizeInsertPosition(position, length, label) {
|
|
326
|
+
if (position === undefined)
|
|
327
|
+
return length;
|
|
328
|
+
if (!Number.isInteger(position) || position < 0 || position > length) {
|
|
329
|
+
throw new Error(`${label} position must be an integer between 0 and ${length}.`);
|
|
330
|
+
}
|
|
331
|
+
return position;
|
|
332
|
+
}
|
|
333
|
+
function normalizeMovePosition(position, length, label) {
|
|
334
|
+
if (!Number.isInteger(position) || position < 0 || position >= length) {
|
|
335
|
+
throw new Error(`${label} position must be an integer between 0 and ${Math.max(0, length - 1)}.`);
|
|
336
|
+
}
|
|
337
|
+
return position;
|
|
338
|
+
}
|
|
339
|
+
function moveArrayEntry(values, from, to) {
|
|
340
|
+
const [value] = values.splice(from, 1);
|
|
341
|
+
values.splice(to, 0, value);
|
|
342
|
+
}
|
|
343
|
+
function fingerprint(content) {
|
|
344
|
+
return createHash("sha256").update(content).digest("hex");
|
|
345
|
+
}
|
|
346
|
+
function isErrno(error, code) {
|
|
347
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
348
|
+
}
|
|
349
|
+
function errorMessage(error) {
|
|
350
|
+
return error instanceof Error ? error.message : String(error);
|
|
351
|
+
}
|
package/dist/workspaces.js
CHANGED
|
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
|
|
|
5
5
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { loadCapabilityGuides, markCapabilityGuideActivated, resolveCapabilityGuideReadPath, } from "./capabilities.js";
|
|
7
7
|
import { HookRunner } from "./hooks.js";
|
|
8
|
-
import { closeManagedWorktree, createManagedWorktree, resolveManagedWorktreeBase, } from "./git-worktrees.js";
|
|
8
|
+
import { closeManagedWorktree, createManagedWorktree, discardFreshManagedWorktree, resolveManagedWorktreeBase, } from "./git-worktrees.js";
|
|
9
9
|
import { AccessDeniedError, assertAllowedPath, isPathInsideRoot, resolveAllowedPath, } from "./roots.js";
|
|
10
10
|
import { loadWorkspaceSkills, markSkillActivated, resolveSkillReadPath, } from "./skills.js";
|
|
11
11
|
import { loadSubagentProfiles, } from "./subagents/profiles.js";
|
|
@@ -152,8 +152,11 @@ export class WorkspaceRegistry {
|
|
|
152
152
|
};
|
|
153
153
|
}
|
|
154
154
|
async resumeWorkspace(workspaceId, conversationScopeId, bootstrapContext = "auto") {
|
|
155
|
-
const
|
|
156
|
-
const context =
|
|
155
|
+
const session = this.store?.getSession(workspaceId);
|
|
156
|
+
const context = session?.status === "closed" && session.mode === "worktree"
|
|
157
|
+
? await this.reopenClosedManagedWorktreeContext(session)
|
|
158
|
+
: await this.reusedWorkspaceContext(await this.workspaceForOpen(workspaceId));
|
|
159
|
+
const workspace = context.workspace;
|
|
157
160
|
if (!conversationScopeId || !this.store) {
|
|
158
161
|
return {
|
|
159
162
|
...context,
|
|
@@ -222,8 +225,8 @@ export class WorkspaceRegistry {
|
|
|
222
225
|
}
|
|
223
226
|
deleteWorkspace(workspaceId) {
|
|
224
227
|
const session = this.getWorkspaceSession(workspaceId);
|
|
225
|
-
if (session.mode
|
|
226
|
-
throw new Error(`Workspace ${session.id} is
|
|
228
|
+
if (session.mode === "worktree" && session.status === "active") {
|
|
229
|
+
throw new Error(`Workspace ${session.id} is an active managed-worktree Workspace. Finalize it safely before deleting its persistent identity.`);
|
|
227
230
|
}
|
|
228
231
|
this.deleteConversationBindingsForWorkspace(session.id);
|
|
229
232
|
this.store?.deleteSession(session.id);
|
|
@@ -327,6 +330,7 @@ export class WorkspaceRegistry {
|
|
|
327
330
|
},
|
|
328
331
|
}));
|
|
329
332
|
for (const aliasedWorkspaceId of aliasedWorkspaceIds) {
|
|
333
|
+
this.deleteConversationBindingsForWorkspace(aliasedWorkspaceId);
|
|
330
334
|
this.store?.setSessionStatus(aliasedWorkspaceId, "closed");
|
|
331
335
|
this.workspaces.delete(aliasedWorkspaceId);
|
|
332
336
|
}
|
|
@@ -386,10 +390,8 @@ export class WorkspaceRegistry {
|
|
|
386
390
|
if (boundContext)
|
|
387
391
|
return boundContext;
|
|
388
392
|
const context = await this.openOnce(targetKey, async () => {
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
return this.openWorktreeWorkspace(path, input.baseRef);
|
|
392
|
-
return this.reusedWorkspaceContext(reusableWorkspace);
|
|
393
|
+
const reusableContext = await this.findReusableWorktreeContextBySource(sourceKey, resolvedBase.targetBranch);
|
|
394
|
+
return reusableContext ?? this.openWorktreeWorkspace(path, input.baseRef);
|
|
393
395
|
});
|
|
394
396
|
return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
|
|
395
397
|
}
|
|
@@ -576,16 +578,30 @@ export class WorkspaceRegistry {
|
|
|
576
578
|
}
|
|
577
579
|
return undefined;
|
|
578
580
|
}
|
|
579
|
-
async
|
|
580
|
-
|
|
581
|
+
async findReusableWorktreeContextBySource(sourceKey, targetBranch) {
|
|
582
|
+
const sessions = this.store
|
|
583
|
+
? this.store.listSessions({ mode: "worktree" })
|
|
584
|
+
: this.activeSessions("worktree");
|
|
585
|
+
const closedMatches = [];
|
|
586
|
+
for (const session of sessions) {
|
|
581
587
|
if (!session.sourceRoot || session.targetBranch !== targetBranch)
|
|
582
588
|
continue;
|
|
583
589
|
if (await canonicalPath(session.sourceRoot) !== sourceKey)
|
|
584
590
|
continue;
|
|
591
|
+
if (session.status === "closed") {
|
|
592
|
+
if (session.managed)
|
|
593
|
+
closedMatches.push(session);
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (session.status !== "active")
|
|
597
|
+
continue;
|
|
585
598
|
const root = await this.validSessionRoot(session);
|
|
586
599
|
if (!root)
|
|
587
600
|
continue;
|
|
588
|
-
return this.workspaceFromSession(session, false);
|
|
601
|
+
return this.reusedWorkspaceContext(this.workspaceFromSession(session, false));
|
|
602
|
+
}
|
|
603
|
+
if (closedMatches.length === 1) {
|
|
604
|
+
return this.reopenClosedManagedWorktreeContext(closedMatches[0]);
|
|
589
605
|
}
|
|
590
606
|
return undefined;
|
|
591
607
|
}
|
|
@@ -654,7 +670,7 @@ export class WorkspaceRegistry {
|
|
|
654
670
|
includeBootstrapContext: true,
|
|
655
671
|
};
|
|
656
672
|
}
|
|
657
|
-
workspaceForOpen(workspaceId) {
|
|
673
|
+
async workspaceForOpen(workspaceId) {
|
|
658
674
|
const session = this.store?.getSession(workspaceId);
|
|
659
675
|
if (session?.status === "closed" && session.mode === "checkout") {
|
|
660
676
|
this.store?.setSessionStatus(session.id, "active");
|
|
@@ -666,6 +682,72 @@ export class WorkspaceRegistry {
|
|
|
666
682
|
}
|
|
667
683
|
return this.getWorkspace(workspaceId);
|
|
668
684
|
}
|
|
685
|
+
async reopenClosedManagedWorktreeContext(session) {
|
|
686
|
+
const operationKey = JSON.stringify(["worktree-reopen", session.id]);
|
|
687
|
+
return this.openOnce(operationKey, async () => {
|
|
688
|
+
const current = this.store?.getSession(session.id);
|
|
689
|
+
if (!current) {
|
|
690
|
+
throw new Error(`Unknown workspaceId: ${session.id}. Call open_workspace first.`);
|
|
691
|
+
}
|
|
692
|
+
if (current.status === "active") {
|
|
693
|
+
return this.reusedWorkspaceContext(this.getWorkspace(current.id));
|
|
694
|
+
}
|
|
695
|
+
if (current.status !== "closed" || current.mode !== "worktree") {
|
|
696
|
+
throw new Error(`Workspace ${current.id} is not a closed managed-worktree Workspace.`);
|
|
697
|
+
}
|
|
698
|
+
return this.reopenClosedManagedWorktreeContextUnlocked(current);
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
async reopenClosedManagedWorktreeContextUnlocked(session) {
|
|
702
|
+
if (!this.store) {
|
|
703
|
+
throw new Error(`Workspace ${session.id} cannot be reopened without persistent Workspace state.`);
|
|
704
|
+
}
|
|
705
|
+
if (!session.managed || !session.sourceRoot || !session.targetBranch) {
|
|
706
|
+
throw new Error(`Workspace ${session.id} does not have enough managed-worktree metadata to recreate its execution backing.`);
|
|
707
|
+
}
|
|
708
|
+
const worktree = await createManagedWorktree({
|
|
709
|
+
sourcePath: session.sourceRoot,
|
|
710
|
+
baseRef: session.targetBranch,
|
|
711
|
+
config: this.config,
|
|
712
|
+
});
|
|
713
|
+
const candidateSession = {
|
|
714
|
+
...session,
|
|
715
|
+
root: worktree.path,
|
|
716
|
+
status: "active",
|
|
717
|
+
sourceRoot: worktree.sourceRoot,
|
|
718
|
+
baseRef: worktree.baseRef,
|
|
719
|
+
baseSha: worktree.baseSha,
|
|
720
|
+
branch: worktree.branch,
|
|
721
|
+
targetBranch: worktree.targetBranch,
|
|
722
|
+
managed: true,
|
|
723
|
+
};
|
|
724
|
+
const workspace = this.workspaceFromSession(candidateSession, false);
|
|
725
|
+
try {
|
|
726
|
+
const context = await this.reusedWorkspaceContext(workspace);
|
|
727
|
+
this.store.replaceWorktreeBacking({
|
|
728
|
+
id: session.id,
|
|
729
|
+
root: worktree.path,
|
|
730
|
+
sourceRoot: worktree.sourceRoot,
|
|
731
|
+
baseRef: worktree.baseRef,
|
|
732
|
+
baseSha: worktree.baseSha,
|
|
733
|
+
branch: worktree.branch,
|
|
734
|
+
targetBranch: worktree.targetBranch,
|
|
735
|
+
});
|
|
736
|
+
return context;
|
|
737
|
+
}
|
|
738
|
+
catch (error) {
|
|
739
|
+
this.workspaces.delete(session.id);
|
|
740
|
+
try {
|
|
741
|
+
await discardFreshManagedWorktree({ worktree, config: this.config });
|
|
742
|
+
}
|
|
743
|
+
catch (cleanupError) {
|
|
744
|
+
const original = error instanceof Error ? error.message : String(error);
|
|
745
|
+
const cleanup = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
|
746
|
+
throw new Error(`${original} Reopen rollback also failed: ${cleanup}`);
|
|
747
|
+
}
|
|
748
|
+
throw error;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
669
751
|
getWorkspaceSession(workspaceId) {
|
|
670
752
|
const session = this.store?.getSession(workspaceId);
|
|
671
753
|
if (session)
|
|
@@ -139,6 +139,19 @@ If histories diverge, close is refused and the worktree is preserved. Rebase and
|
|
|
139
139
|
verify inside the worktree, then retry. The source checkout is not intentionally
|
|
140
140
|
placed into a merge-conflict state.
|
|
141
141
|
|
|
142
|
+
A successful managed close now preserves the Workspace identity as `closed` even
|
|
143
|
+
though the physical worktree and managed branch are removed. Reopen that Workspace
|
|
144
|
+
with `open_workspace(workspaceId="ws_...")`; ForgeRelay creates fresh worktree
|
|
145
|
+
backing from the recorded source/target relationship and returns the same Workspace
|
|
146
|
+
ID. If the source checkout or target branch can no longer provide valid backing, the
|
|
147
|
+
open fails and the durable Workspace record remains closed.
|
|
148
|
+
|
|
149
|
+
`close_workspace(action="delete")` is never an implicit discard for active isolated
|
|
150
|
+
work. An active managed worktree still requires `commitMessage` and completes the
|
|
151
|
+
same safe finalize/integrate/cleanup lifecycle before ForgeRelay deletes its identity.
|
|
152
|
+
For an already-closed managed-worktree Workspace, delete removes only ForgeRelay-owned
|
|
153
|
+
state and does not recreate the physical backing.
|
|
154
|
+
|
|
142
155
|
Legacy `devspace/*` managed branches remain closable when they are already stored
|
|
143
156
|
in workspace metadata; only new managed branches use `forgerelay/*`.
|
|
144
157
|
|
|
@@ -295,8 +308,10 @@ until `open_workspace` reactivates the same ID by path or by `workspaceId`.
|
|
|
295
308
|
`close_workspace(action="delete")` is the explicit permanent checkout cleanup path:
|
|
296
309
|
it removes ForgeRelay-owned Workspace state but never deletes or mutates project
|
|
297
310
|
files. Managed-worktree close still requires `commitMessage` and runs the safe commit /
|
|
298
|
-
fast-forward-only integration / cleanup lifecycle
|
|
299
|
-
|
|
311
|
+
fast-forward-only integration / cleanup lifecycle. Composite close preserves the same
|
|
312
|
+
`cws_...` identity and member topology; Composite `action="delete"` dissolves only
|
|
313
|
+
Composite-owned state without touching member Workspaces. Relayed delete remains a later
|
|
314
|
+
lifecycle stage.
|
|
300
315
|
|
|
301
316
|
Shell commands are allowed to modify ordinary project files when that is a
|
|
302
317
|
natural part of the user's requested development task; ForgeRelay does not apply
|
package/docs/configuration.md
CHANGED
|
@@ -339,17 +339,32 @@ or externally removed managed-worktree root can therefore remain diagnostically
|
|
|
339
339
|
ordinary same-target opens no longer accumulate duplicate inventory rows;
|
|
340
340
|
`action="list"` remains the formal on-demand inventory path.
|
|
341
341
|
|
|
342
|
-
For checkout-backed Workspaces, `close_workspace`
|
|
342
|
+
For checkout-backed Workspaces, `close_workspace` defaults to `action="close"`:
|
|
343
343
|
it marks the persistent Workspace closed, removes current conversation bindings, and
|
|
344
344
|
keeps the same Workspace identity available for later `open_workspace` by path or ID.
|
|
345
345
|
Closed Workspaces remain visible in inventory but ordinary execution tools reject them
|
|
346
346
|
until reopened. `action="delete"` permanently removes ForgeRelay-owned checkout
|
|
347
347
|
identity/state while never deleting or mutating the user's checkout directory.
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
348
|
+
|
|
349
|
+
Managed-worktree close also preserves the Workspace identity. It still requires
|
|
350
|
+
`commitMessage` and runs the existing BeforeWorktreeClose / commit / fast-forward-only
|
|
351
|
+
integration / physical cleanup / AfterWorktreeClose lifecycle, then leaves the
|
|
352
|
+
Workspace closed after its old worktree path and managed branch are removed. Reopening
|
|
353
|
+
the closed Workspace by ID recreates fresh managed-worktree backing from its recorded
|
|
354
|
+
source/target branch relationship while keeping the same `workspaceId`; an
|
|
355
|
+
unambiguous repeated source/target open can reuse the same closed identity as well.
|
|
356
|
+
If backing recreation fails, the record remains closed and unchanged.
|
|
357
|
+
|
|
358
|
+
`action="delete"` on an active managed-worktree Workspace is not a discard operation:
|
|
359
|
+
it requires `commitMessage`, completes the same safe finalize lifecycle, and only then
|
|
360
|
+
removes the persistent ForgeRelay identity. Deleting an already-closed worktree
|
|
361
|
+
Workspace removes only ForgeRelay-owned state and does not recreate backing.
|
|
362
|
+
Composite close now marks only the Composite record closed while preserving its identity,
|
|
363
|
+
name, members, and coordination metadata. Closed Composites remain inspectable and reject
|
|
364
|
+
member routing or mutation until reopened with the same `cws_...` ID. Composite
|
|
365
|
+
`action="delete"` permanently dissolves only Composite-owned state; it never closes,
|
|
366
|
+
finalizes, deletes, or otherwise mutates member Workspaces. Relayed delete remains
|
|
367
|
+
deferred to Workspace Relay lifecycle parity.
|
|
353
368
|
|
|
354
369
|
Hot workspace/session activity timestamps are coalesced in memory and flushed to the
|
|
355
370
|
SQLite state database in a transaction at most every five minutes; normal shutdown
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akira-tl/forgerelay",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "Local development control plane for MCP coding agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://github.com/Akira-TL/forgerelay#readme",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"release:publish": "node scripts/release/publish.mjs",
|
|
48
48
|
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
|
|
49
49
|
"start": "node dist/cli.js serve",
|
|
50
|
-
"test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/release-version.test.mjs && tsx src/oauth/router.test.ts && tsx src/remote-auth-cli.test.ts && tsx src/remote-ssh-auth-cli.test.ts && tsx src/remote-workspace-relay.test.ts && tsx src/remote-workspace-relay-process.test.ts && tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/workspace-lifecycle-app.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/operations/edit-preflight.test.ts && tsx src/skills.test.ts && tsx src/db/migrations.test.ts && tsx src/workspace-store.test.ts && tsx src/activity/audit-store.test.ts && tsx src/activity/bash-output-store.test.ts && tsx src/activity/lifecycle.test.ts && tsx src/activity/query-service.test.ts && tsx src/operations/core-operation-executor.test.ts && tsx src/operations/bulk-mutation.test.ts && tsx src/operations/batch/scheduler.test.ts && tsx src/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
|
|
50
|
+
"test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/release-version.test.mjs && tsx src/oauth/router.test.ts && tsx src/remote-auth-cli.test.ts && tsx src/remote-ssh-auth-cli.test.ts && tsx src/remote-workspace-relay.test.ts && tsx src/remote-workspace-relay-process.test.ts && tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/workspace-lifecycle-app.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/operations/edit-preflight.test.ts && tsx src/skills.test.ts && tsx src/db/migrations.test.ts && tsx src/workspace-store.test.ts && tsx src/workspace-tasks.test.ts && tsx src/activity/audit-store.test.ts && tsx src/activity/bash-output-store.test.ts && tsx src/activity/lifecycle.test.ts && tsx src/activity/query-service.test.ts && tsx src/operations/core-operation-executor.test.ts && tsx src/operations/bulk-mutation.test.ts && tsx src/operations/batch/scheduler.test.ts && tsx src/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
|
|
51
51
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
52
52
|
"release:check": "node scripts/release-version.mjs check",
|
|
53
53
|
"release:tag-check": "node scripts/release-version.mjs tag",
|