@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.
@@ -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
+ }
@@ -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 workspace = this.workspaceForOpen(workspaceId);
156
- const context = await this.reusedWorkspaceContext(workspace);
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 !== "checkout") {
226
- throw new Error(`Workspace ${session.id} is not a checkout Workspace. Delete semantics for ${session.mode} Workspaces are handled by their own lifecycle.`);
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 reusableWorkspace = await this.findReusableWorktreeBySource(sourceKey, resolvedBase.targetBranch);
390
- if (!reusableWorkspace)
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 findReusableWorktreeBySource(sourceKey, targetBranch) {
580
- for (const session of this.activeSessions("worktree")) {
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; managed-worktree, Composite, and
299
- relayed delete semantics are completed by their later lifecycle stages.
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
@@ -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` now defaults to `action="close"`:
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
- Managed-worktree close still requires `commitMessage` and runs the existing safe
349
- finalize lifecycle; managed-worktree delete semantics land in the next lifecycle
350
- stage. Composite close still dissolves in this stage, and Composite delete remains
351
- unavailable until its persistent lifecycle stage. Relayed delete is likewise deferred
352
- to Workspace Relay lifecycle parity.
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.1",
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",