@akira-tl/forgerelay 0.8.1 → 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 +7 -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 +114 -55
- package/dist/workspace-store.js +25 -0
- package/dist/workspaces.js +95 -13
- package/docs/chatgpt-coding-workflow.md +17 -2
- package/docs/configuration.md +21 -6
- package/package.json +1 -1
- package/scripts/debug/accept.mjs +117 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,13 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.8.2] - 2026-08-31
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Managed-worktree Workspace close now preserves the same identity for later reopen with fresh backing; explicit delete safely finalizes active work before removing ForgeRelay-owned state.
|
|
12
|
+
- Composite Workspace close now preserves identity and member topology for later reopen; explicit delete dissolves only the Composite relationship and leaves member Workspaces untouched.
|
|
13
|
+
|
|
7
14
|
## [0.8.1] - 2026-08-30
|
|
8
15
|
|
|
9
16
|
### Added
|
package/README.md
CHANGED
|
@@ -136,8 +136,10 @@ ForgeRelay does not merge member filesystems, Git state, Hooks, Skills, processe
|
|
|
136
136
|
or audit facts, and it never infers a member from the tool type or purpose text.
|
|
137
137
|
The Composite Activity Panel presents member operations in one Host Turn while the
|
|
138
138
|
actual facts remain owned by the member Workspace. `close_workspace` on a Composite
|
|
139
|
-
Workspace
|
|
140
|
-
|
|
139
|
+
Workspace now preserves the Composite identity and member topology as `closed`; a
|
|
140
|
+
later `open_workspace` restores the same `cws_...` identity. `action="delete"` is the
|
|
141
|
+
explicit dissolve operation. Neither close nor delete closes member Workspaces,
|
|
142
|
+
finalizes their worktrees, stops their jobs, changes their files, or removes relay routes.
|
|
141
143
|
|
|
142
144
|
### Progressive MCP context
|
|
143
145
|
|
|
@@ -12,7 +12,7 @@ export class CompositeActivityCoordinator {
|
|
|
12
12
|
return this.composites.has(workspaceId);
|
|
13
13
|
}
|
|
14
14
|
beginPanel(workspaceId, conversationScopeId) {
|
|
15
|
-
this.composites.
|
|
15
|
+
this.composites.touchActive(workspaceId);
|
|
16
16
|
const snapshot = this.queries.beginTurn(conversationScopeId, workspaceId);
|
|
17
17
|
this.turns.set(snapshot.turnId, {
|
|
18
18
|
compositeWorkspaceId: workspaceId,
|
|
@@ -13,16 +13,20 @@ export class CompositeWorkspaceRegistry {
|
|
|
13
13
|
has(workspaceId) {
|
|
14
14
|
return this.records.has(workspaceId);
|
|
15
15
|
}
|
|
16
|
+
isActive(workspaceId) {
|
|
17
|
+
return this.records.get(workspaceId)?.status === "active";
|
|
18
|
+
}
|
|
16
19
|
create(name) {
|
|
17
20
|
const normalized = normalizeName(name);
|
|
18
21
|
const existing = [...this.records.values()].find((record) => record.name === normalized);
|
|
19
22
|
if (existing)
|
|
20
|
-
return this.
|
|
23
|
+
return this.open(existing.id);
|
|
21
24
|
const now = new Date().toISOString();
|
|
22
25
|
const record = {
|
|
23
26
|
id: `cws_${randomBytes(5).toString("hex")}`,
|
|
24
27
|
kind: "composite",
|
|
25
28
|
name: normalized,
|
|
29
|
+
status: "active",
|
|
26
30
|
members: [],
|
|
27
31
|
createdAt: now,
|
|
28
32
|
lastUsedAt: now,
|
|
@@ -38,7 +42,17 @@ export class CompositeWorkspaceRegistry {
|
|
|
38
42
|
return cloneRecord(record);
|
|
39
43
|
}
|
|
40
44
|
open(workspaceId) {
|
|
41
|
-
|
|
45
|
+
const record = this.requireRecord(workspaceId);
|
|
46
|
+
record.status = "active";
|
|
47
|
+
return this.touchRecord(record);
|
|
48
|
+
}
|
|
49
|
+
close(workspaceId) {
|
|
50
|
+
const record = this.requireActive(workspaceId);
|
|
51
|
+
record.status = "closed";
|
|
52
|
+
return this.touchRecord(record);
|
|
53
|
+
}
|
|
54
|
+
touchActive(workspaceId) {
|
|
55
|
+
return this.touchRecord(this.requireActive(workspaceId));
|
|
42
56
|
}
|
|
43
57
|
list() {
|
|
44
58
|
return [...this.records.values()]
|
|
@@ -46,7 +60,7 @@ export class CompositeWorkspaceRegistry {
|
|
|
46
60
|
.sort((left, right) => right.lastUsedAt.localeCompare(left.lastUsedAt));
|
|
47
61
|
}
|
|
48
62
|
addMember(workspaceId, input) {
|
|
49
|
-
const record = this.
|
|
63
|
+
const record = this.requireActive(workspaceId);
|
|
50
64
|
const name = normalizeMemberName(input.name);
|
|
51
65
|
const purpose = input.purpose.trim();
|
|
52
66
|
if (!purpose)
|
|
@@ -64,7 +78,7 @@ export class CompositeWorkspaceRegistry {
|
|
|
64
78
|
return cloneRecord(record);
|
|
65
79
|
}
|
|
66
80
|
updateMember(workspaceId, memberName, input) {
|
|
67
|
-
const record = this.
|
|
81
|
+
const record = this.requireActive(workspaceId);
|
|
68
82
|
const currentName = normalizeMemberName(memberName);
|
|
69
83
|
const index = record.members.findIndex((member) => member.name === currentName);
|
|
70
84
|
if (index < 0)
|
|
@@ -94,7 +108,7 @@ export class CompositeWorkspaceRegistry {
|
|
|
94
108
|
return cloneRecord(record);
|
|
95
109
|
}
|
|
96
110
|
removeMember(workspaceId, memberName) {
|
|
97
|
-
const record = this.
|
|
111
|
+
const record = this.requireActive(workspaceId);
|
|
98
112
|
const name = normalizeMemberName(memberName);
|
|
99
113
|
const index = record.members.findIndex((member) => member.name === name);
|
|
100
114
|
if (index < 0)
|
|
@@ -105,7 +119,7 @@ export class CompositeWorkspaceRegistry {
|
|
|
105
119
|
return cloneRecord(record);
|
|
106
120
|
}
|
|
107
121
|
member(workspaceId, memberName) {
|
|
108
|
-
const record = this.
|
|
122
|
+
const record = this.requireActive(workspaceId);
|
|
109
123
|
const name = normalizeMemberName(memberName);
|
|
110
124
|
const member = record.members.find((entry) => entry.name === name);
|
|
111
125
|
if (!member)
|
|
@@ -118,12 +132,18 @@ export class CompositeWorkspaceRegistry {
|
|
|
118
132
|
this.persist();
|
|
119
133
|
return cloneRecord(record);
|
|
120
134
|
}
|
|
121
|
-
|
|
122
|
-
const record = this.requireRecord(workspaceId);
|
|
135
|
+
touchRecord(record) {
|
|
123
136
|
record.lastUsedAt = new Date().toISOString();
|
|
124
137
|
this.persist();
|
|
125
138
|
return cloneRecord(record);
|
|
126
139
|
}
|
|
140
|
+
requireActive(workspaceId) {
|
|
141
|
+
const record = this.requireRecord(workspaceId);
|
|
142
|
+
if (record.status !== "active") {
|
|
143
|
+
throw new Error(`Composite Workspace ${workspaceId} is closed. Reopen it with open_workspace before use.`);
|
|
144
|
+
}
|
|
145
|
+
return record;
|
|
146
|
+
}
|
|
127
147
|
requireRecord(workspaceId) {
|
|
128
148
|
const record = this.records.get(workspaceId);
|
|
129
149
|
if (!record)
|
|
@@ -140,7 +160,7 @@ export class CompositeWorkspaceRegistry {
|
|
|
140
160
|
return;
|
|
141
161
|
throw new Error(`Failed to load Composite Workspace state: ${errorMessage(error)}`);
|
|
142
162
|
}
|
|
143
|
-
if (parsed?.version !== 1 || !Array.isArray(parsed.workspaces)) {
|
|
163
|
+
if ((parsed?.version !== 1 && parsed?.version !== 2) || !Array.isArray(parsed.workspaces)) {
|
|
144
164
|
throw new Error("Composite Workspace state has an unsupported format.");
|
|
145
165
|
}
|
|
146
166
|
for (const record of parsed.workspaces) {
|
|
@@ -148,6 +168,7 @@ export class CompositeWorkspaceRegistry {
|
|
|
148
168
|
continue;
|
|
149
169
|
this.records.set(record.id, {
|
|
150
170
|
...record,
|
|
171
|
+
status: record.status === "closed" ? "closed" : "active",
|
|
151
172
|
members: Array.isArray(record.members) ? record.members.map((member) => ({ ...member })) : [],
|
|
152
173
|
});
|
|
153
174
|
}
|
|
@@ -155,7 +176,7 @@ export class CompositeWorkspaceRegistry {
|
|
|
155
176
|
persist() {
|
|
156
177
|
mkdirSync(this.stateDir, { recursive: true });
|
|
157
178
|
const state = {
|
|
158
|
-
version:
|
|
179
|
+
version: 2,
|
|
159
180
|
workspaces: [...this.records.values()].map(cloneRecord),
|
|
160
181
|
};
|
|
161
182
|
const tempPath = `${this.statePath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
package/dist/git-worktrees.js
CHANGED
|
@@ -57,6 +57,28 @@ export async function createManagedWorktree(input) {
|
|
|
57
57
|
managed: true,
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
|
+
export async function discardFreshManagedWorktree(input) {
|
|
61
|
+
const sourceRoot = assertAllowedPath(input.worktree.sourceRoot, input.config.allowedRoots);
|
|
62
|
+
const worktreePath = assertAllowedPath(input.worktree.path, [input.config.worktreeRoot]);
|
|
63
|
+
const worktreeBranch = await currentBranch(worktreePath);
|
|
64
|
+
if (worktreeBranch !== input.worktree.branch) {
|
|
65
|
+
throw new GitWorktreeError("GIT_WORKTREE_CLOSE_FAILED", `Cannot roll back reopened worktree because it is on branch ${JSON.stringify(worktreeBranch)} instead of ${JSON.stringify(input.worktree.branch)}.`);
|
|
66
|
+
}
|
|
67
|
+
if ((await git(["status", "--porcelain=v1"], worktreePath)).trim().length > 0) {
|
|
68
|
+
throw new GitWorktreeError("GIT_WORKTREE_CLOSE_FAILED", "Cannot roll back reopened worktree because it acquired uncommitted changes during reopen.");
|
|
69
|
+
}
|
|
70
|
+
const worktreeHead = (await git(["rev-parse", "HEAD"], worktreePath)).trim();
|
|
71
|
+
if (worktreeHead !== input.worktree.baseSha) {
|
|
72
|
+
throw new GitWorktreeError("GIT_WORKTREE_CLOSE_FAILED", "Cannot roll back reopened worktree because its branch advanced during reopen.");
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
await git(["worktree", "remove", worktreePath], sourceRoot);
|
|
76
|
+
await git(["branch", "-D", input.worktree.branch], sourceRoot);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
throw new GitWorktreeError("GIT_WORKTREE_CLOSE_FAILED", `Git failed to remove the temporary managed worktree created for a failed reopen. ${error instanceof Error ? error.message : String(error)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
60
82
|
export async function closeManagedWorktree(input) {
|
|
61
83
|
const sourceRoot = assertAllowedPath(input.worktree.sourceRoot, input.config.allowedRoots);
|
|
62
84
|
const worktreePath = assertAllowedPath(input.worktree.path, [input.config.worktreeRoot]);
|
|
@@ -38,7 +38,7 @@ function capabilityContractInstructions(config) {
|
|
|
38
38
|
const staleWorkspacePolicy = config.toolMode === "codex"
|
|
39
39
|
? ""
|
|
40
40
|
: ` If ${toolNames.openWorkspace} reports stale workspaces, let the user choose resume or ${toolNames.closeWorkspace}; never auto-close.`;
|
|
41
|
-
const workspaceLifecycle = `
|
|
41
|
+
const workspaceLifecycle = `Default to the user's existing checkout. Reuse workspaceId from ${toolNames.openWorkspace}; change it only when asked.${staleWorkspacePolicy} Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. ${toolNames.closeWorkspace} preserves Workspace identity. Managed close finalizes backing and needs commitMessage. Composite close preserves members; delete removes only Composite state. Active worktree delete still finalizes safely; checkout files are never deleted.`;
|
|
42
42
|
const activityPanel = `Project-work order: ${toolNames.openWorkspace} if needed → activity_panel(workspaceId) once → work tools. activity_panel is the single ForgeRelay UI render tool: Workspace above Activity. A new workspaceId creates a new card. Never call activity_panel before needed ${toolNames.openWorkspace}.`;
|
|
43
43
|
const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Read an availableAgentsFiles path before working under it.`;
|
|
44
44
|
const capabilityGuides = `For optional capabilities from ${toolNames.openWorkspace}, use ${toolNames.capability}; if unfamiliar, describe first and read its advertised capability guide with ${toolNames.read}.`;
|
package/dist/server.js
CHANGED
|
@@ -2078,6 +2078,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2078
2078
|
memberAction: z.enum(["add", "update", "remove"]).optional(),
|
|
2079
2079
|
kind: z.enum(["workspace", "composite"]).optional(),
|
|
2080
2080
|
name: z.string().optional(),
|
|
2081
|
+
status: z.string().optional(),
|
|
2082
|
+
state: z.enum(["active", "stale", "invalid", "closed"]).optional(),
|
|
2081
2083
|
members: z.array(z.object({
|
|
2082
2084
|
name: z.string(),
|
|
2083
2085
|
purpose: z.string(),
|
|
@@ -2134,6 +2136,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2134
2136
|
workspaceId: z.string(),
|
|
2135
2137
|
kind: z.literal("composite"),
|
|
2136
2138
|
name: z.string(),
|
|
2139
|
+
status: z.enum(["active", "closed"]),
|
|
2140
|
+
state: z.enum(["active", "closed"]),
|
|
2137
2141
|
members: z.array(z.object({
|
|
2138
2142
|
name: z.string(),
|
|
2139
2143
|
purpose: z.string(),
|
|
@@ -2161,6 +2165,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2161
2165
|
if (!workspaceId || !compositeWorkspaces.has(workspaceId)) {
|
|
2162
2166
|
throw new Error("open_workspace action=member requires an existing Composite Workspace workspaceId.");
|
|
2163
2167
|
}
|
|
2168
|
+
if (!compositeWorkspaces.isActive(workspaceId)) {
|
|
2169
|
+
throw new Error(`Composite Workspace ${workspaceId} is closed. Reopen it with open_workspace before changing members.`);
|
|
2170
|
+
}
|
|
2164
2171
|
if (!memberAction || !member) {
|
|
2165
2172
|
throw new Error("open_workspace action=member requires memberAction and member.");
|
|
2166
2173
|
}
|
|
@@ -2294,25 +2301,30 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2294
2301
|
newWorkspace !== undefined || context !== undefined) {
|
|
2295
2302
|
throw new Error("open_workspace action=list does not accept path, relay, name, memberName, baseRef, newWorktree, newWorkspace, or context. Use kind/root/workspaceId/mode/status/state/staleOnly for inventory filters.");
|
|
2296
2303
|
}
|
|
2304
|
+
const compositeInventory = () => compositeWorkspaces.list()
|
|
2305
|
+
.filter((entry) => workspaceId === undefined || entry.id === workspaceId)
|
|
2306
|
+
.filter((entry) => status === undefined || entry.status === status)
|
|
2307
|
+
.filter((entry) => state === undefined || entry.status === state)
|
|
2308
|
+
.map((entry) => ({
|
|
2309
|
+
workspaceId: entry.id,
|
|
2310
|
+
kind: entry.kind,
|
|
2311
|
+
name: entry.name,
|
|
2312
|
+
status: entry.status,
|
|
2313
|
+
state: entry.status,
|
|
2314
|
+
members: entry.members,
|
|
2315
|
+
createdAt: entry.createdAt,
|
|
2316
|
+
lastUsedAt: entry.lastUsedAt,
|
|
2317
|
+
}));
|
|
2297
2318
|
if (kind === "composite") {
|
|
2298
|
-
if (root !== undefined || mode !== undefined ||
|
|
2299
|
-
|
|
2300
|
-
throw new Error("Composite Workspace inventory does not accept root/mode/
|
|
2319
|
+
if (root !== undefined || mode !== undefined || staleOnly !== undefined ||
|
|
2320
|
+
offset !== undefined || limit !== undefined) {
|
|
2321
|
+
throw new Error("Composite Workspace inventory does not accept root/mode/staleOnly/offset/limit filters; use workspaceId/status/state when selecting Composite Workspaces.");
|
|
2301
2322
|
}
|
|
2302
|
-
const composites =
|
|
2303
|
-
|
|
2304
|
-
.map((entry) => ({
|
|
2305
|
-
workspaceId: entry.id,
|
|
2306
|
-
kind: entry.kind,
|
|
2307
|
-
name: entry.name,
|
|
2308
|
-
members: entry.members,
|
|
2309
|
-
createdAt: entry.createdAt,
|
|
2310
|
-
lastUsedAt: entry.lastUsedAt,
|
|
2311
|
-
}));
|
|
2312
|
-
const instruction = "Resume a Composite Workspace with open_workspace(action=\"open\", workspaceId=...). Use close_workspace only when the user chooses to dissolve it.";
|
|
2323
|
+
const composites = compositeInventory();
|
|
2324
|
+
const instruction = "Open a Composite Workspace by workspaceId to resume or reopen it. close_workspace preserves its identity; action=delete permanently dissolves only Composite-owned state.";
|
|
2313
2325
|
const result = [
|
|
2314
2326
|
`Composite Workspace inventory: ${composites.length} matching record${composites.length === 1 ? "" : "s"}.`,
|
|
2315
|
-
...composites.map((entry) => `${entry.name} [${entry.workspaceId}] members=${entry.members.length} last-used=${entry.lastUsedAt}`),
|
|
2327
|
+
...composites.map((entry) => `${entry.name} [${entry.workspaceId}] state=${entry.state} members=${entry.members.length} last-used=${entry.lastUsedAt}`),
|
|
2316
2328
|
instruction,
|
|
2317
2329
|
].join("\n");
|
|
2318
2330
|
return {
|
|
@@ -2325,20 +2337,13 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2325
2337
|
};
|
|
2326
2338
|
}
|
|
2327
2339
|
const inventory = await workspaces.listWorkspaces({ workspaceId, mode, root, status, state, staleOnly, offset, limit }, { conversationScopeId, protectedWorkspaceIds });
|
|
2328
|
-
const composites = kind === "workspace"
|
|
2340
|
+
const composites = kind === "workspace" || root !== undefined || mode !== undefined || staleOnly
|
|
2329
2341
|
? []
|
|
2330
|
-
:
|
|
2331
|
-
workspaceId: entry.id,
|
|
2332
|
-
kind: entry.kind,
|
|
2333
|
-
name: entry.name,
|
|
2334
|
-
members: entry.members,
|
|
2335
|
-
createdAt: entry.createdAt,
|
|
2336
|
-
lastUsedAt: entry.lastUsedAt,
|
|
2337
|
-
}));
|
|
2342
|
+
: compositeInventory();
|
|
2338
2343
|
const nextOffset = inventory.page.offset + inventory.page.limit;
|
|
2339
2344
|
const instruction = [
|
|
2340
2345
|
"Resume a selected workspaceId with open_workspace(action=\"open\", workspaceId=...).",
|
|
2341
|
-
"Use close_workspace only after the user chooses cleanup
|
|
2346
|
+
"Use close_workspace only after the user chooses cleanup; Composite close preserves identity, while action=delete dissolves only Composite-owned state. Never close inventory entries automatically.",
|
|
2342
2347
|
inventory.page.hasMore
|
|
2343
2348
|
? `More matching workspaces are available; continue with offset=${nextOffset}.`
|
|
2344
2349
|
: undefined,
|
|
@@ -2356,7 +2361,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2356
2361
|
`root=${entry.root}`,
|
|
2357
2362
|
`last-used=${entry.lastUsedAt}`,
|
|
2358
2363
|
].filter(Boolean).join(" ")),
|
|
2359
|
-
...composites.map((entry) => `${entry.name} [${entry.workspaceId}] kind=composite members=${entry.members.length}`),
|
|
2364
|
+
...composites.map((entry) => `${entry.name} [${entry.workspaceId}] kind=composite state=${entry.state} members=${entry.members.length}`),
|
|
2360
2365
|
instruction,
|
|
2361
2366
|
].join("\n");
|
|
2362
2367
|
logToolCall(config, {
|
|
@@ -2406,7 +2411,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2406
2411
|
composite.members.length > 0
|
|
2407
2412
|
? "Before first work on a member, reopen this Composite Workspace with memberName=<member> and context=auto to receive that member's project bootstrap without creating an implicit current member."
|
|
2408
2413
|
: undefined,
|
|
2409
|
-
"Use
|
|
2414
|
+
"close_workspace preserves this Composite identity for later reopen. Use action=delete only when the user explicitly wants to dissolve the Composite relationship; neither operation closes or cleans up member Workspaces.",
|
|
2410
2415
|
].join("\n\n");
|
|
2411
2416
|
const response = {
|
|
2412
2417
|
content: [textBlock(instruction)],
|
|
@@ -2419,7 +2424,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2419
2424
|
path: composite.name,
|
|
2420
2425
|
members: composite.members,
|
|
2421
2426
|
instruction,
|
|
2422
|
-
summary: { members: composite.members.length },
|
|
2427
|
+
summary: { members: composite.members.length, status: composite.status },
|
|
2423
2428
|
},
|
|
2424
2429
|
},
|
|
2425
2430
|
structuredContent: {
|
|
@@ -2427,6 +2432,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2427
2432
|
workspaceId: composite.id,
|
|
2428
2433
|
kind: "composite",
|
|
2429
2434
|
name: composite.name,
|
|
2435
|
+
status: composite.status,
|
|
2436
|
+
state: composite.status,
|
|
2430
2437
|
members: composite.members,
|
|
2431
2438
|
...(memberContext ? { memberContext } : {}),
|
|
2432
2439
|
instruction,
|
|
@@ -2921,13 +2928,13 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2921
2928
|
});
|
|
2922
2929
|
registerAppTool(server, toolNames.closeWorkspace, {
|
|
2923
2930
|
title: "Close workspace",
|
|
2924
|
-
description: "Close or explicitly delete one Workspace after the user chooses cleanup. action=close (default) preserves checkout identity for later reopen. action=delete permanently removes ForgeRelay-owned
|
|
2931
|
+
description: "Close or explicitly delete one Workspace after the user chooses cleanup. action=close (default) preserves checkout, managed-worktree, and Composite identity for later reopen. action=delete permanently removes ForgeRelay-owned state. Managed-worktree-backed Workspaces still finalize safely when active and require commitMessage. Composite delete dissolves only Composite-owned state and never closes member Workspaces. Checkout project files are never deleted; relayed delete remains unavailable.",
|
|
2925
2932
|
inputSchema: {
|
|
2926
2933
|
workspaceId: z.string().describe("Workspace identifier to close or delete."),
|
|
2927
2934
|
action: z
|
|
2928
2935
|
.enum(["close", "delete"])
|
|
2929
2936
|
.optional()
|
|
2930
|
-
.describe("Defaults to close. close preserves checkout identity for later reopen; delete
|
|
2937
|
+
.describe("Defaults to close. close preserves checkout identity, managed-worktree identity, and Composite identity for later reopen; delete removes ForgeRelay-owned state. Composite delete dissolves only the Composite relationship. Active managed worktrees still require safe finalization and commitMessage; checkout project files are never deleted."),
|
|
2931
2938
|
commitMessage: z
|
|
2932
2939
|
.string()
|
|
2933
2940
|
.min(1)
|
|
@@ -2945,6 +2952,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2945
2952
|
purpose: z.string(),
|
|
2946
2953
|
workspaceId: z.string(),
|
|
2947
2954
|
})).optional(),
|
|
2955
|
+
status: z.enum(["active", "closed"]).optional(),
|
|
2948
2956
|
dissolved: z.boolean().optional(),
|
|
2949
2957
|
sourceRoot: z.string().optional(),
|
|
2950
2958
|
branch: z.string().optional(),
|
|
@@ -2958,21 +2966,22 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2958
2966
|
annotations: WRITE_TOOL_ANNOTATIONS,
|
|
2959
2967
|
}, async ({ workspaceId, action = "close", commitMessage }, extra) => {
|
|
2960
2968
|
if (compositeWorkspaces.has(workspaceId)) {
|
|
2961
|
-
if (action === "delete") {
|
|
2962
|
-
throw new Error("close_workspace action=delete is not available for Composite Workspaces until the Composite persistent lifecycle stage.");
|
|
2963
|
-
}
|
|
2964
2969
|
if (commitMessage !== undefined) {
|
|
2965
|
-
throw new Error("close_workspace commitMessage is not valid
|
|
2970
|
+
throw new Error("close_workspace commitMessage is not valid for a Composite Workspace.");
|
|
2966
2971
|
}
|
|
2967
|
-
const composite =
|
|
2972
|
+
const composite = action === "delete"
|
|
2973
|
+
? compositeWorkspaces.dissolve(workspaceId)
|
|
2974
|
+
: compositeWorkspaces.close(workspaceId);
|
|
2968
2975
|
compositeActivity.forgetComposite(workspaceId);
|
|
2969
2976
|
workspacePanelStates.delete(workspaceId);
|
|
2970
2977
|
const result = [
|
|
2971
|
-
|
|
2978
|
+
action === "delete"
|
|
2979
|
+
? `Deleted Composite Workspace ${composite.name} (${workspaceId}); its Composite relationship and ForgeRelay-owned Composite state were dissolved.`
|
|
2980
|
+
: `Closed Composite Workspace ${composite.name} (${workspaceId}); its identity and member topology were preserved for later reopen.`,
|
|
2972
2981
|
composite.members.length > 0
|
|
2973
2982
|
? `Preserved member Workspaces: ${composite.members.map((member) => `${member.name} [${member.workspaceId}]`).join(", ")}.`
|
|
2974
2983
|
: "The Composite Workspace had no members.",
|
|
2975
|
-
"Member Workspace handles, managed worktrees, processes, files, and Workspace Relay routes were not closed or
|
|
2984
|
+
"Member Workspace handles, managed worktrees, processes, files, and Workspace Relay routes were not closed, finalized, deleted, or otherwise mutated.",
|
|
2976
2985
|
].join("\n");
|
|
2977
2986
|
return {
|
|
2978
2987
|
content: [textBlock(result)],
|
|
@@ -2980,22 +2989,24 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2980
2989
|
tool: toolNames.closeWorkspace,
|
|
2981
2990
|
card: {
|
|
2982
2991
|
workspaceId,
|
|
2983
|
-
action
|
|
2992
|
+
action,
|
|
2984
2993
|
kind: "composite",
|
|
2985
2994
|
name: composite.name,
|
|
2986
2995
|
members: composite.members,
|
|
2987
|
-
|
|
2996
|
+
...(action === "close" ? { status: "closed" } : {}),
|
|
2997
|
+
dissolved: action === "delete",
|
|
2988
2998
|
payload: { content: [textBlock(result)] },
|
|
2989
2999
|
},
|
|
2990
3000
|
},
|
|
2991
3001
|
structuredContent: {
|
|
2992
3002
|
result,
|
|
2993
3003
|
workspaceId,
|
|
2994
|
-
action
|
|
3004
|
+
action,
|
|
2995
3005
|
kind: "composite",
|
|
2996
3006
|
name: composite.name,
|
|
2997
3007
|
members: composite.members,
|
|
2998
|
-
|
|
3008
|
+
...(action === "close" ? { status: "closed" } : {}),
|
|
3009
|
+
dissolved: action === "delete",
|
|
2999
3010
|
},
|
|
3000
3011
|
};
|
|
3001
3012
|
}
|
|
@@ -3007,14 +3018,11 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
3007
3018
|
workspacePanelStates.delete(workspaceId);
|
|
3008
3019
|
return response;
|
|
3009
3020
|
}
|
|
3010
|
-
|
|
3021
|
+
const session = workspaces.getWorkspaceSession(workspaceId);
|
|
3022
|
+
if (action === "delete" && session.mode === "checkout") {
|
|
3011
3023
|
if (commitMessage !== undefined) {
|
|
3012
3024
|
throw new Error("close_workspace commitMessage is not valid with action=delete for a checkout Workspace.");
|
|
3013
3025
|
}
|
|
3014
|
-
const session = workspaces.getWorkspaceSession(workspaceId);
|
|
3015
|
-
if (session.mode !== "checkout") {
|
|
3016
|
-
throw new Error("close_workspace action=delete is not available for managed-worktree-backed Workspaces until their persistent lifecycle stage.");
|
|
3017
|
-
}
|
|
3018
3026
|
if (processSessions.activeWorkspaceIds().has(session.id)) {
|
|
3019
3027
|
throw new Error(`Workspace ${session.id} still owns a running process. Poll, interrupt, or wait for it before deleting this Workspace.`);
|
|
3020
3028
|
}
|
|
@@ -3055,12 +3063,58 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
3055
3063
|
workspacePanelStates.delete(session.id);
|
|
3056
3064
|
return response;
|
|
3057
3065
|
}
|
|
3058
|
-
|
|
3066
|
+
if (action === "delete" && session.mode === "worktree" && session.status === "closed") {
|
|
3067
|
+
if (commitMessage !== undefined) {
|
|
3068
|
+
throw new Error("close_workspace commitMessage is not needed when deleting an already-closed managed-worktree Workspace.");
|
|
3069
|
+
}
|
|
3070
|
+
const hookRoot = session.sourceRoot ?? session.root;
|
|
3071
|
+
const response = await runToolWithHooks(hooks, {
|
|
3072
|
+
signal: extra.signal,
|
|
3073
|
+
tool: toolNames.closeWorkspace,
|
|
3074
|
+
invocation: {
|
|
3075
|
+
workspaceId: session.id,
|
|
3076
|
+
workspaceRoot: hookRoot,
|
|
3077
|
+
workspaceMode: session.mode,
|
|
3078
|
+
sourceRoot: session.sourceRoot,
|
|
3079
|
+
},
|
|
3080
|
+
payload: { workspaceId: session.id, action: "delete", mode: session.mode },
|
|
3081
|
+
operation: async () => {
|
|
3082
|
+
workspaces.deleteWorkspace(session.id);
|
|
3083
|
+
await reviewCheckpoints.releaseWorkspace(session.id);
|
|
3084
|
+
const result = `Deleted closed managed-worktree Workspace ${session.id}. Its already-removed worktree backing was not recreated.`;
|
|
3085
|
+
return {
|
|
3086
|
+
content: [textBlock(result)],
|
|
3087
|
+
_meta: {
|
|
3088
|
+
tool: toolNames.closeWorkspace,
|
|
3089
|
+
card: {
|
|
3090
|
+
workspaceId: session.id,
|
|
3091
|
+
action: "delete",
|
|
3092
|
+
mode: "worktree",
|
|
3093
|
+
sourceRoot: session.sourceRoot,
|
|
3094
|
+
targetBranch: session.targetBranch,
|
|
3095
|
+
payload: { content: [textBlock(result)] },
|
|
3096
|
+
},
|
|
3097
|
+
},
|
|
3098
|
+
structuredContent: {
|
|
3099
|
+
result,
|
|
3100
|
+
workspaceId: session.id,
|
|
3101
|
+
action: "delete",
|
|
3102
|
+
mode: "worktree",
|
|
3103
|
+
sourceRoot: session.sourceRoot,
|
|
3104
|
+
targetBranch: session.targetBranch,
|
|
3105
|
+
},
|
|
3106
|
+
};
|
|
3107
|
+
},
|
|
3108
|
+
});
|
|
3109
|
+
workspacePanelStates.delete(session.id);
|
|
3110
|
+
return response;
|
|
3111
|
+
}
|
|
3112
|
+
const workspace = workspaces.getWorkspace(session.id);
|
|
3059
3113
|
const response = await runToolWithHooks(hooks, {
|
|
3060
3114
|
signal: extra.signal,
|
|
3061
3115
|
tool: toolNames.closeWorkspace,
|
|
3062
3116
|
invocation: workspaceHookInvocation(workspace),
|
|
3063
|
-
payload: { workspaceId, action
|
|
3117
|
+
payload: { workspaceId: workspace.id, action, commitMessage, mode: workspace.mode },
|
|
3064
3118
|
afterCwd: (response) => "sourceRoot" in response.structuredContent &&
|
|
3065
3119
|
typeof response.structuredContent.sourceRoot === "string"
|
|
3066
3120
|
? response.structuredContent.sourceRoot
|
|
@@ -3068,7 +3122,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
3068
3122
|
operation: async () => {
|
|
3069
3123
|
if (workspace.mode === "worktree") {
|
|
3070
3124
|
if (!commitMessage) {
|
|
3071
|
-
throw new Error(`Managed-worktree-backed
|
|
3125
|
+
throw new Error(`Managed-worktree-backed Workspace ${workspace.id} requires commitMessage when ${action === "delete" ? "deleting active work" : "closing"}.`);
|
|
3072
3126
|
}
|
|
3073
3127
|
const physicalWorkspaceIds = workspaces.workspaceIdsForPhysicalWorkspace(workspace);
|
|
3074
3128
|
const busyWorkspaceIds = physicalWorkspaceIds
|
|
@@ -3080,14 +3134,19 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
3080
3134
|
const retirement = await codeIntelligence.retireWorkspaceRoot(workspace.root);
|
|
3081
3135
|
let closed;
|
|
3082
3136
|
try {
|
|
3083
|
-
closed = await workspaces.closeWorktree(
|
|
3137
|
+
closed = await workspaces.closeWorktree(workspace.id, commitMessage);
|
|
3084
3138
|
}
|
|
3085
3139
|
finally {
|
|
3086
3140
|
codeIntelligence.restoreWorkspaceRoot(retirement.root);
|
|
3087
3141
|
}
|
|
3088
3142
|
await Promise.all(physicalWorkspaceIds.map((id) => reviewCheckpoints.releaseWorkspace(id)));
|
|
3143
|
+
if (action === "delete") {
|
|
3144
|
+
workspaces.deleteWorkspace(workspace.id);
|
|
3145
|
+
}
|
|
3089
3146
|
const result = [
|
|
3090
|
-
|
|
3147
|
+
action === "delete"
|
|
3148
|
+
? `Safely finalized and deleted managed-worktree Workspace ${workspace.id}.`
|
|
3149
|
+
: `Closed managed-worktree-backed Workspace ${workspace.id}; its identity was preserved for later reopen.`,
|
|
3091
3150
|
`Merged ${closed.branch} into ${closed.targetBranch} by fast-forward.`,
|
|
3092
3151
|
`Source checkout: ${closed.sourceRoot}`,
|
|
3093
3152
|
`Commit: ${closed.commitSha}`,
|
|
@@ -3107,8 +3166,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
3107
3166
|
_meta: {
|
|
3108
3167
|
tool: toolNames.closeWorkspace,
|
|
3109
3168
|
card: {
|
|
3110
|
-
workspaceId,
|
|
3111
|
-
action
|
|
3169
|
+
workspaceId: workspace.id,
|
|
3170
|
+
action,
|
|
3112
3171
|
mode: "worktree",
|
|
3113
3172
|
sourceRoot: closed.sourceRoot,
|
|
3114
3173
|
branch: closed.branch,
|
|
@@ -3122,8 +3181,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
3122
3181
|
},
|
|
3123
3182
|
structuredContent: {
|
|
3124
3183
|
result,
|
|
3125
|
-
workspaceId,
|
|
3126
|
-
action
|
|
3184
|
+
workspaceId: workspace.id,
|
|
3185
|
+
action,
|
|
3127
3186
|
mode: "worktree",
|
|
3128
3187
|
sourceRoot: closed.sourceRoot,
|
|
3129
3188
|
branch: closed.branch,
|
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";
|
|
@@ -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
package/scripts/debug/accept.mjs
CHANGED
|
@@ -644,9 +644,124 @@ try {
|
|
|
644
644
|
readFileSync(join(gitProject, "feature.txt"), "utf8").replace(/\r\n/g, "\n"),
|
|
645
645
|
"debug worktree acceptance\n",
|
|
646
646
|
);
|
|
647
|
+
const closedWorktreeInventory = callTool(oauth.accessToken, sessionId, 110, "open_workspace", {
|
|
648
|
+
action: "list",
|
|
649
|
+
workspaceId: worktreeWorkspaceId,
|
|
650
|
+
});
|
|
651
|
+
assert.equal(closedWorktreeInventory.structuredContent.workspaces.length, 1);
|
|
652
|
+
assert.equal(closedWorktreeInventory.structuredContent.workspaces[0].state, "closed");
|
|
653
|
+
|
|
654
|
+
const reopenedWorktree = callTool(oauth.accessToken, sessionId, 111, "open_workspace", {
|
|
655
|
+
workspaceId: worktreeWorkspaceId,
|
|
656
|
+
context: "none",
|
|
657
|
+
});
|
|
658
|
+
assert.equal(reopenedWorktree.structuredContent.workspaceId, worktreeWorkspaceId);
|
|
659
|
+
const reopenedWorktreePath = reopenedWorktree.structuredContent.worktree.path;
|
|
660
|
+
assert.notEqual(reopenedWorktreePath, managedWorktreePath);
|
|
661
|
+
assert.ok(existsSync(reopenedWorktreePath));
|
|
662
|
+
|
|
663
|
+
callTool(oauth.accessToken, sessionId, 112, "write", {
|
|
664
|
+
workspaceId: worktreeWorkspaceId,
|
|
665
|
+
path: "delete-feature.txt",
|
|
666
|
+
content: "debug worktree delete acceptance\n",
|
|
667
|
+
});
|
|
668
|
+
const deletedWorktree = callTool(oauth.accessToken, sessionId, 113, "close_workspace", {
|
|
669
|
+
workspaceId: worktreeWorkspaceId,
|
|
670
|
+
action: "delete",
|
|
671
|
+
commitMessage: "test(debug): verify 7677 worktree delete lifecycle",
|
|
672
|
+
});
|
|
673
|
+
assert.equal(deletedWorktree.structuredContent.action, "delete");
|
|
674
|
+
assert.equal(existsSync(reopenedWorktreePath), false);
|
|
675
|
+
assert.equal(
|
|
676
|
+
readFileSync(join(gitProject, "delete-feature.txt"), "utf8").replace(/\r\n/g, "\n"),
|
|
677
|
+
"debug worktree delete acceptance\n",
|
|
678
|
+
);
|
|
679
|
+
const deletedWorktreeInventory = callTool(oauth.accessToken, sessionId, 114, "open_workspace", {
|
|
680
|
+
action: "list",
|
|
681
|
+
workspaceId: worktreeWorkspaceId,
|
|
682
|
+
});
|
|
683
|
+
assert.equal(deletedWorktreeInventory.structuredContent.workspaces.length, 0);
|
|
684
|
+
pass(
|
|
685
|
+
"managed worktree lifecycle",
|
|
686
|
+
`${worktreeWorkspaceId} close -> closed inventory -> same-id reopen with fresh backing -> safe delete`,
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
callTool(oauth.accessToken, sessionId, 115, "write", {
|
|
690
|
+
workspaceId,
|
|
691
|
+
path: "composite-sentinel.txt",
|
|
692
|
+
content: "debug composite member acceptance\n",
|
|
693
|
+
});
|
|
694
|
+
const compositeOpened = callTool(oauth.accessToken, sessionId, 116, "open_workspace", {
|
|
695
|
+
kind: "composite",
|
|
696
|
+
name: "debug-lifecycle-composite",
|
|
697
|
+
context: "none",
|
|
698
|
+
});
|
|
699
|
+
const compositeWorkspaceId = compositeOpened.structuredContent.workspaceId;
|
|
700
|
+
callTool(oauth.accessToken, sessionId, 117, "open_workspace", {
|
|
701
|
+
action: "member",
|
|
702
|
+
workspaceId: compositeWorkspaceId,
|
|
703
|
+
memberAction: "add",
|
|
704
|
+
member: {
|
|
705
|
+
name: "code",
|
|
706
|
+
purpose: "Debug lifecycle member",
|
|
707
|
+
workspaceId,
|
|
708
|
+
},
|
|
709
|
+
});
|
|
710
|
+
const closedComposite = callTool(oauth.accessToken, sessionId, 118, "close_workspace", {
|
|
711
|
+
workspaceId: compositeWorkspaceId,
|
|
712
|
+
});
|
|
713
|
+
assert.equal(closedComposite.structuredContent.action, "close");
|
|
714
|
+
assert.equal(closedComposite.structuredContent.status, "closed");
|
|
715
|
+
assert.equal(closedComposite.structuredContent.dissolved, false);
|
|
716
|
+
const closedCompositeInventory = callTool(oauth.accessToken, sessionId, 119, "open_workspace", {
|
|
717
|
+
action: "list",
|
|
718
|
+
kind: "composite",
|
|
719
|
+
workspaceId: compositeWorkspaceId,
|
|
720
|
+
status: "closed",
|
|
721
|
+
});
|
|
722
|
+
assert.equal(closedCompositeInventory.structuredContent.compositeWorkspaces.length, 1);
|
|
723
|
+
assert.equal(closedCompositeInventory.structuredContent.compositeWorkspaces[0].state, "closed");
|
|
724
|
+
const closedCompositeRead = callTool(oauth.accessToken, sessionId, 120, "read", {
|
|
725
|
+
workspaceId: compositeWorkspaceId,
|
|
726
|
+
member: "code",
|
|
727
|
+
path: "composite-sentinel.txt",
|
|
728
|
+
});
|
|
729
|
+
assert.equal(closedCompositeRead.isError, true);
|
|
730
|
+
|
|
731
|
+
const reopenedComposite = callTool(oauth.accessToken, sessionId, 121, "open_workspace", {
|
|
732
|
+
workspaceId: compositeWorkspaceId,
|
|
733
|
+
context: "none",
|
|
734
|
+
});
|
|
735
|
+
assert.equal(reopenedComposite.structuredContent.workspaceId, compositeWorkspaceId);
|
|
736
|
+
assert.equal(reopenedComposite.structuredContent.status, "active");
|
|
737
|
+
assert.equal(reopenedComposite.structuredContent.members[0].workspaceId, workspaceId);
|
|
738
|
+
const reopenedCompositeRead = callTool(oauth.accessToken, sessionId, 122, "read", {
|
|
739
|
+
workspaceId: compositeWorkspaceId,
|
|
740
|
+
member: "code",
|
|
741
|
+
path: "composite-sentinel.txt",
|
|
742
|
+
});
|
|
743
|
+
assert.match(reopenedCompositeRead.structuredContent.result, /debug composite member acceptance/);
|
|
744
|
+
|
|
745
|
+
const deletedComposite = callTool(oauth.accessToken, sessionId, 123, "close_workspace", {
|
|
746
|
+
workspaceId: compositeWorkspaceId,
|
|
747
|
+
action: "delete",
|
|
748
|
+
});
|
|
749
|
+
assert.equal(deletedComposite.structuredContent.action, "delete");
|
|
750
|
+
assert.equal(deletedComposite.structuredContent.dissolved, true);
|
|
751
|
+
const memberAfterCompositeDelete = callTool(oauth.accessToken, sessionId, 124, "read", {
|
|
752
|
+
workspaceId,
|
|
753
|
+
path: "composite-sentinel.txt",
|
|
754
|
+
});
|
|
755
|
+
assert.match(memberAfterCompositeDelete.structuredContent.result, /debug composite member acceptance/);
|
|
756
|
+
const deletedCompositeInventory = callTool(oauth.accessToken, sessionId, 125, "open_workspace", {
|
|
757
|
+
action: "list",
|
|
758
|
+
kind: "composite",
|
|
759
|
+
workspaceId: compositeWorkspaceId,
|
|
760
|
+
});
|
|
761
|
+
assert.equal(deletedCompositeInventory.structuredContent.compositeWorkspaces.length, 0);
|
|
647
762
|
pass(
|
|
648
|
-
"
|
|
649
|
-
`${
|
|
763
|
+
"Composite lifecycle",
|
|
764
|
+
`${compositeWorkspaceId} close -> closed/non-routable -> same-id reopen -> delete; member Workspace preserved`,
|
|
650
765
|
);
|
|
651
766
|
|
|
652
767
|
exerciseReleaseTagHooks(oauth.accessToken, sessionId);
|