@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.
- package/CHANGELOG.md +17 -0
- package/README.md +4 -2
- 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 +181 -52
- package/dist/workspace-store.js +25 -0
- package/dist/workspaces.js +169 -25
- package/docs/chatgpt-coding-workflow.md +24 -5
- package/docs/configuration.md +26 -9
- package/package.json +1 -1
- package/scripts/debug/accept.mjs +190 -33
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,
|
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";
|
|
@@ -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 (
|
|
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
|
|
153
|
-
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;
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
|
386
|
-
|
|
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
|
-
|
|
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
|
|
562
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
-
|
|
343
|
-
|
|
344
|
-
the
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|