@akira-tl/forgerelay 0.8.0 → 0.8.2

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,
@@ -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";
@@ -71,6 +71,9 @@ export class WorkspaceRegistry {
71
71
  .filter((binding) => binding.conversationScopeId === openOptions.conversationScopeId)
72
72
  .map((binding) => binding.workspaceSessionId)
73
73
  : []);
74
+ const workspaceIdFilter = input.workspaceId
75
+ ? this.store?.getSession(input.workspaceId)?.id ?? input.workspaceId
76
+ : undefined;
74
77
  const rootKey = input.root
75
78
  ? await canonicalPath(assertAllowedPath(input.root, [...this.config.allowedRoots, this.config.worktreeRoot]))
76
79
  : undefined;
@@ -110,7 +113,7 @@ export class WorkspaceRegistry {
110
113
  const entry = entries[index];
111
114
  if (!session || !entry)
112
115
  continue;
113
- if (input.workspaceId && entry.workspaceId !== input.workspaceId)
116
+ if (workspaceIdFilter && entry.workspaceId !== workspaceIdFilter)
114
117
  continue;
115
118
  if (input.status && entry.status !== input.status)
116
119
  continue;
@@ -149,8 +152,11 @@ export class WorkspaceRegistry {
149
152
  };
150
153
  }
151
154
  async resumeWorkspace(workspaceId, conversationScopeId, bootstrapContext = "auto") {
152
- const workspace = this.getWorkspace(workspaceId);
153
- 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;
154
160
  if (!conversationScopeId || !this.store) {
155
161
  return {
156
162
  ...context,
@@ -209,22 +215,23 @@ export class WorkspaceRegistry {
209
215
  const workspace = this.getWorkspace(workspaceId);
210
216
  const canonicalWorkspaceId = workspace.id;
211
217
  if (workspace.mode === "worktree") {
212
- const aliases = this.activeSessions("worktree")
213
- .filter((session) => resolve(session.root) === resolve(workspace.root));
214
- if (aliases.length <= 1) {
215
- throw new Error(`Workspace ${canonicalWorkspaceId} is backed by a managed worktree. Use close_workspace with its managed-worktree finalize lifecycle instead of releasing the physical target as a separate handle.`);
216
- }
218
+ throw new Error(`Workspace ${canonicalWorkspaceId} is backed by a managed worktree. Use close_workspace with its managed-worktree finalize lifecycle.`);
217
219
  }
218
220
  if (this.store) {
219
- for (const binding of this.store.listConversationBindings()) {
220
- if (binding.workspaceSessionId === canonicalWorkspaceId) {
221
- this.store.deleteConversationBinding(binding.conversationScopeId, binding.targetKey);
222
- }
223
- }
224
- this.store.deleteSession(canonicalWorkspaceId);
221
+ this.deleteConversationBindingsForWorkspace(canonicalWorkspaceId);
222
+ this.store.setSessionStatus(canonicalWorkspaceId, "closed");
225
223
  }
226
224
  this.workspaces.delete(canonicalWorkspaceId);
227
225
  }
226
+ deleteWorkspace(workspaceId) {
227
+ const session = this.getWorkspaceSession(workspaceId);
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.`);
230
+ }
231
+ this.deleteConversationBindingsForWorkspace(session.id);
232
+ this.store?.deleteSession(session.id);
233
+ this.workspaces.delete(session.id);
234
+ }
228
235
  workspaceIdsForPhysicalWorkspace(workspace) {
229
236
  const root = resolve(workspace.root);
230
237
  return this.activeSessions(workspace.mode)
@@ -323,6 +330,7 @@ export class WorkspaceRegistry {
323
330
  },
324
331
  }));
325
332
  for (const aliasedWorkspaceId of aliasedWorkspaceIds) {
333
+ this.deleteConversationBindingsForWorkspace(aliasedWorkspaceId);
326
334
  this.store?.setSessionStatus(aliasedWorkspaceId, "closed");
327
335
  this.workspaces.delete(aliasedWorkspaceId);
328
336
  }
@@ -336,7 +344,7 @@ export class WorkspaceRegistry {
336
344
  if (boundContext)
337
345
  return boundContext;
338
346
  const context = await this.openOnce(targetKey, async () => {
339
- const reusableWorkspace = await this.findReusableWorkspaceByDirectory(projectKey, "checkout");
347
+ const reusableWorkspace = await this.findReusableWorkspaceByDirectory(projectKey, "checkout", true);
340
348
  if (!reusableWorkspace)
341
349
  return this.openCheckoutWorkspace(path);
342
350
  return this.reusedWorkspaceContext(reusableWorkspace);
@@ -382,10 +390,8 @@ export class WorkspaceRegistry {
382
390
  if (boundContext)
383
391
  return boundContext;
384
392
  const context = await this.openOnce(targetKey, async () => {
385
- const reusableWorkspace = await this.findReusableWorktreeBySource(sourceKey, resolvedBase.targetBranch);
386
- if (!reusableWorkspace)
387
- return this.openWorktreeWorkspace(path, input.baseRef);
388
- return this.reusedWorkspaceContext(reusableWorkspace);
393
+ const reusableContext = await this.findReusableWorktreeContextBySource(sourceKey, resolvedBase.targetBranch);
394
+ return reusableContext ?? this.openWorktreeWorkspace(path, input.baseRef);
389
395
  });
390
396
  return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
391
397
  }
@@ -547,27 +553,55 @@ export class WorkspaceRegistry {
547
553
  });
548
554
  }
549
555
  }
550
- async findReusableWorkspaceByDirectory(directoryKey, mode) {
551
- for (const session of this.activeSessions(mode)) {
556
+ async findReusableWorkspaceByDirectory(directoryKey, mode, reopenClosedCheckout = false) {
557
+ const sessions = this.store
558
+ ? this.store.listSessions({ mode })
559
+ : this.activeSessions(mode);
560
+ for (const session of sessions) {
561
+ const reusable = session.status === "active" ||
562
+ (reopenClosedCheckout && mode === "checkout" && session.status === "closed");
563
+ if (!reusable)
564
+ continue;
552
565
  const root = await this.validSessionRoot(session);
553
566
  if (!root)
554
567
  continue;
555
568
  if (await canonicalPath(root) !== directoryKey)
556
569
  continue;
570
+ if (session.status === "closed") {
571
+ this.store?.setSessionStatus(session.id, "active");
572
+ const reopened = this.store?.getSession(session.id);
573
+ if (!reopened)
574
+ continue;
575
+ return this.workspaceFromSession(reopened, false);
576
+ }
557
577
  return this.workspaceFromSession(session, false);
558
578
  }
559
579
  return undefined;
560
580
  }
561
- async findReusableWorktreeBySource(sourceKey, targetBranch) {
562
- 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) {
563
587
  if (!session.sourceRoot || session.targetBranch !== targetBranch)
564
588
  continue;
565
589
  if (await canonicalPath(session.sourceRoot) !== sourceKey)
566
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;
567
598
  const root = await this.validSessionRoot(session);
568
599
  if (!root)
569
600
  continue;
570
- 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]);
571
605
  }
572
606
  return undefined;
573
607
  }
@@ -636,6 +670,116 @@ export class WorkspaceRegistry {
636
670
  includeBootstrapContext: true,
637
671
  };
638
672
  }
673
+ async workspaceForOpen(workspaceId) {
674
+ const session = this.store?.getSession(workspaceId);
675
+ if (session?.status === "closed" && session.mode === "checkout") {
676
+ this.store?.setSessionStatus(session.id, "active");
677
+ const reopened = this.store?.getSession(session.id);
678
+ if (!reopened) {
679
+ throw new Error(`Unknown workspaceId: ${workspaceId}. Call open_workspace first.`);
680
+ }
681
+ return this.workspaceFromSession(reopened, true);
682
+ }
683
+ return this.getWorkspace(workspaceId);
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
+ }
751
+ getWorkspaceSession(workspaceId) {
752
+ const session = this.store?.getSession(workspaceId);
753
+ if (session)
754
+ return session;
755
+ const workspace = this.workspaces.get(workspaceId);
756
+ if (!workspace) {
757
+ throw new Error(`Unknown workspaceId: ${workspaceId}. Call open_workspace first.`);
758
+ }
759
+ return {
760
+ id: workspace.id,
761
+ root: workspace.root,
762
+ status: "active",
763
+ mode: workspace.mode,
764
+ sourceRoot: workspace.sourceRoot,
765
+ baseRef: workspace.worktree?.baseRef,
766
+ baseSha: workspace.worktree?.baseSha,
767
+ branch: workspace.worktree?.branch,
768
+ targetBranch: workspace.worktree?.targetBranch,
769
+ managed: workspace.worktree?.managed ?? false,
770
+ createdAt: "",
771
+ lastUsedAt: "",
772
+ };
773
+ }
774
+ deleteConversationBindingsForWorkspace(workspaceId) {
775
+ if (!this.store)
776
+ return;
777
+ for (const binding of this.store.listConversationBindings()) {
778
+ if (binding.workspaceSessionId === workspaceId) {
779
+ this.store.deleteConversationBinding(binding.conversationScopeId, binding.targetKey);
780
+ }
781
+ }
782
+ }
639
783
  getWorkspace(workspaceId) {
640
784
  const workspace = this.workspaces.get(workspaceId);
641
785
  if (workspace) {
@@ -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
 
@@ -288,11 +301,17 @@ normal open path may still include `staleWorkspaces` for an idle persistent
288
301
  Workspace; use `open_workspace(action="list")` for complete, filtered inventory
289
302
  when continuation or cleanup actually requires it.
290
303
 
291
- In v0.8.0, `close_workspace` is still the existing public close operation:
292
- checkout-backed Workspaces remove their ForgeRelay record without deleting project
293
- files, while managed-worktree-backed Workspaces require `commitMessage` and run the
294
- safe commit / fast-forward-only integration / cleanup lifecycle. Persistent
295
- closed/reopen semantics land in the following lifecycle stage.
304
+ For checkout-backed Workspaces, `close_workspace` defaults to `action="close"` and
305
+ preserves the Workspace identity for later reopen. A closed Workspace stays visible
306
+ through `open_workspace(action="list")`, while ordinary execution tools reject it
307
+ until `open_workspace` reactivates the same ID by path or by `workspaceId`.
308
+ `close_workspace(action="delete")` is the explicit permanent checkout cleanup path:
309
+ it removes ForgeRelay-owned Workspace state but never deletes or mutates project
310
+ files. Managed-worktree close still requires `commitMessage` and runs the safe commit /
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.
296
315
 
297
316
  Shell commands are allowed to modify ordinary project files when that is a
298
317
  natural part of the user's requested development task; ForgeRelay does not apply
@@ -339,15 +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
- In v0.8.0, `close_workspace` still removes a checkout-backed Workspace record without
343
- deleting checkout files; the persistent closed/reopen lifecycle is introduced by
344
- the next lifecycle stage. For a managed-worktree-backed Workspace, `close_workspace` requires
345
- `commitMessage` and runs the existing safe worktree finalize lifecycle: close Hooks,
346
- commit when needed, fast-forward-only integration, cleanup, and alias invalidation.
347
- For a Composite Workspace, the same tool means dissolve: it removes only the
348
- Composite identity, membership, and Composite-facing panel/lifecycle state. It does
349
- not close members, finalize member worktrees, interrupt member processes, delete
350
- files, or remove remote registrations and relayed Workspace routes.
342
+ For checkout-backed Workspaces, `close_workspace` defaults to `action="close"`:
343
+ it marks the persistent Workspace closed, removes current conversation bindings, and
344
+ keeps the same Workspace identity available for later `open_workspace` by path or ID.
345
+ Closed Workspaces remain visible in inventory but ordinary execution tools reject them
346
+ until reopened. `action="delete"` permanently removes ForgeRelay-owned checkout
347
+ identity/state while never deleting or mutating the user's checkout directory.
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.
351
368
 
352
369
  Hot workspace/session activity timestamps are coalesced in memory and flushed to the
353
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.0",
3
+ "version": "0.8.2",
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",