@akira-tl/forgerelay 0.8.0 → 0.8.1
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 +10 -0
- package/dist/mcp/server-instructions.js +1 -1
- package/dist/server.js +82 -12
- package/dist/workspaces.js +78 -16
- package/docs/chatgpt-coding-workflow.md +9 -5
- package/docs/configuration.md +11 -9
- package/package.json +1 -1
- package/scripts/debug/accept.mjs +73 -31
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.8.1] - 2026-08-30
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `close_workspace(action="delete")` explicitly removes ForgeRelay-owned checkout Workspace state without deleting or mutating project files.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Checkout Workspace close is now reversible: it preserves the canonical identity for reopen by path or ID, closed Workspaces remain listable but non-executable, legacy aliases resolve canonically, and idle GC no longer deletes persistent identity.
|
|
16
|
+
|
|
7
17
|
## [0.8.0] - 2026-08-30
|
|
8
18
|
|
|
9
19
|
### Changed
|
|
@@ -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 = `Use ForgeRelay as a local coding workspace. 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}
|
|
41
|
+
const workspaceLifecycle = `Use ForgeRelay as a local coding workspace. 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} defaults to closing checkout Workspaces for later reopen; action=delete removes only ForgeRelay-owned checkout state, never project files. Managed close finalizes the worktree and requires commitMessage.`;
|
|
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
|
@@ -2921,9 +2921,13 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2921
2921
|
});
|
|
2922
2922
|
registerAppTool(server, toolNames.closeWorkspace, {
|
|
2923
2923
|
title: "Close workspace",
|
|
2924
|
-
description: "Close one Workspace after the user chooses cleanup.
|
|
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 checkout state but never project files. Managed-worktree-backed Workspaces still use the safe finalize lifecycle and require commitMessage; Composite and relayed delete semantics are not available in this stage.",
|
|
2925
2925
|
inputSchema: {
|
|
2926
|
-
workspaceId: z.string().describe("Workspace identifier to close."),
|
|
2926
|
+
workspaceId: z.string().describe("Workspace identifier to close or delete."),
|
|
2927
|
+
action: z
|
|
2928
|
+
.enum(["close", "delete"])
|
|
2929
|
+
.optional()
|
|
2930
|
+
.describe("Defaults to close. close preserves checkout identity for later reopen; delete permanently removes ForgeRelay-owned checkout state without deleting project files."),
|
|
2927
2931
|
commitMessage: z
|
|
2928
2932
|
.string()
|
|
2929
2933
|
.min(1)
|
|
@@ -2932,6 +2936,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2932
2936
|
},
|
|
2933
2937
|
outputSchema: resultOutputSchema({
|
|
2934
2938
|
workspaceId: z.string(),
|
|
2939
|
+
action: z.enum(["close", "delete"]).optional(),
|
|
2935
2940
|
kind: z.enum(["workspace", "composite"]).optional(),
|
|
2936
2941
|
mode: z.enum(["checkout", "worktree"]).optional(),
|
|
2937
2942
|
name: z.string().optional(),
|
|
@@ -2951,8 +2956,11 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2951
2956
|
}),
|
|
2952
2957
|
_meta: {},
|
|
2953
2958
|
annotations: WRITE_TOOL_ANNOTATIONS,
|
|
2954
|
-
}, async ({ workspaceId, commitMessage }, extra) => {
|
|
2959
|
+
}, async ({ workspaceId, action = "close", commitMessage }, extra) => {
|
|
2955
2960
|
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
|
+
}
|
|
2956
2964
|
if (commitMessage !== undefined) {
|
|
2957
2965
|
throw new Error("close_workspace commitMessage is not valid when dissolving a Composite Workspace.");
|
|
2958
2966
|
}
|
|
@@ -2972,6 +2980,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2972
2980
|
tool: toolNames.closeWorkspace,
|
|
2973
2981
|
card: {
|
|
2974
2982
|
workspaceId,
|
|
2983
|
+
action: "close",
|
|
2975
2984
|
kind: "composite",
|
|
2976
2985
|
name: composite.name,
|
|
2977
2986
|
members: composite.members,
|
|
@@ -2982,6 +2991,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2982
2991
|
structuredContent: {
|
|
2983
2992
|
result,
|
|
2984
2993
|
workspaceId,
|
|
2994
|
+
action: "close",
|
|
2985
2995
|
kind: "composite",
|
|
2986
2996
|
name: composite.name,
|
|
2987
2997
|
members: composite.members,
|
|
@@ -2990,16 +3000,67 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2990
3000
|
};
|
|
2991
3001
|
}
|
|
2992
3002
|
if (remoteWorkspaces.has(workspaceId)) {
|
|
3003
|
+
if (action === "delete") {
|
|
3004
|
+
throw new Error("close_workspace action=delete is not available for relayed Workspaces until Workspace Relay lifecycle parity is implemented.");
|
|
3005
|
+
}
|
|
2993
3006
|
const response = await remoteWorkspaces.closeWorkspace(workspaceId, commitMessage, hostScopeIdFor(extra._meta, extra.sessionId));
|
|
2994
3007
|
workspacePanelStates.delete(workspaceId);
|
|
2995
3008
|
return response;
|
|
2996
3009
|
}
|
|
3010
|
+
if (action === "delete") {
|
|
3011
|
+
if (commitMessage !== undefined) {
|
|
3012
|
+
throw new Error("close_workspace commitMessage is not valid with action=delete for a checkout Workspace.");
|
|
3013
|
+
}
|
|
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
|
+
if (processSessions.activeWorkspaceIds().has(session.id)) {
|
|
3019
|
+
throw new Error(`Workspace ${session.id} still owns a running process. Poll, interrupt, or wait for it before deleting this Workspace.`);
|
|
3020
|
+
}
|
|
3021
|
+
const response = await runToolWithHooks(hooks, {
|
|
3022
|
+
signal: extra.signal,
|
|
3023
|
+
tool: toolNames.closeWorkspace,
|
|
3024
|
+
invocation: {
|
|
3025
|
+
workspaceId: session.id,
|
|
3026
|
+
workspaceRoot: session.root,
|
|
3027
|
+
workspaceMode: session.mode,
|
|
3028
|
+
sourceRoot: session.sourceRoot,
|
|
3029
|
+
},
|
|
3030
|
+
payload: { workspaceId: session.id, action: "delete", mode: session.mode },
|
|
3031
|
+
operation: async () => {
|
|
3032
|
+
workspaces.deleteWorkspace(session.id);
|
|
3033
|
+
await reviewCheckpoints.releaseWorkspace(session.id);
|
|
3034
|
+
const result = `Deleted ForgeRelay Workspace ${session.id}. Physical project files were not removed.`;
|
|
3035
|
+
return {
|
|
3036
|
+
content: [textBlock(result)],
|
|
3037
|
+
_meta: {
|
|
3038
|
+
tool: toolNames.closeWorkspace,
|
|
3039
|
+
card: {
|
|
3040
|
+
workspaceId: session.id,
|
|
3041
|
+
action: "delete",
|
|
3042
|
+
mode: "checkout",
|
|
3043
|
+
payload: { content: [textBlock(result)] },
|
|
3044
|
+
},
|
|
3045
|
+
},
|
|
3046
|
+
structuredContent: {
|
|
3047
|
+
result,
|
|
3048
|
+
workspaceId: session.id,
|
|
3049
|
+
action: "delete",
|
|
3050
|
+
mode: "checkout",
|
|
3051
|
+
},
|
|
3052
|
+
};
|
|
3053
|
+
},
|
|
3054
|
+
});
|
|
3055
|
+
workspacePanelStates.delete(session.id);
|
|
3056
|
+
return response;
|
|
3057
|
+
}
|
|
2997
3058
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
2998
3059
|
const response = await runToolWithHooks(hooks, {
|
|
2999
3060
|
signal: extra.signal,
|
|
3000
3061
|
tool: toolNames.closeWorkspace,
|
|
3001
3062
|
invocation: workspaceHookInvocation(workspace),
|
|
3002
|
-
payload: { workspaceId, commitMessage, mode: workspace.mode },
|
|
3063
|
+
payload: { workspaceId, action: "close", commitMessage, mode: workspace.mode },
|
|
3003
3064
|
afterCwd: (response) => "sourceRoot" in response.structuredContent &&
|
|
3004
3065
|
typeof response.structuredContent.sourceRoot === "string"
|
|
3005
3066
|
? response.structuredContent.sourceRoot
|
|
@@ -3047,6 +3108,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
3047
3108
|
tool: toolNames.closeWorkspace,
|
|
3048
3109
|
card: {
|
|
3049
3110
|
workspaceId,
|
|
3111
|
+
action: "close",
|
|
3050
3112
|
mode: "worktree",
|
|
3051
3113
|
sourceRoot: closed.sourceRoot,
|
|
3052
3114
|
branch: closed.branch,
|
|
@@ -3061,6 +3123,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
3061
3123
|
structuredContent: {
|
|
3062
3124
|
result,
|
|
3063
3125
|
workspaceId,
|
|
3126
|
+
action: "close",
|
|
3064
3127
|
mode: "worktree",
|
|
3065
3128
|
sourceRoot: closed.sourceRoot,
|
|
3066
3129
|
branch: closed.branch,
|
|
@@ -3075,27 +3138,34 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
3075
3138
|
if (commitMessage !== undefined) {
|
|
3076
3139
|
throw new Error("close_workspace commitMessage is only valid for managed-worktree-backed workspaces.");
|
|
3077
3140
|
}
|
|
3078
|
-
|
|
3079
|
-
|
|
3141
|
+
const checkoutWorkspaceId = workspace.id;
|
|
3142
|
+
if (processSessions.activeWorkspaceIds().has(checkoutWorkspaceId)) {
|
|
3143
|
+
throw new Error(`Workspace ${checkoutWorkspaceId} still owns a running process. Poll, interrupt, or wait for it before closing this workspace.`);
|
|
3080
3144
|
}
|
|
3081
|
-
workspaces.closeWorkspace(
|
|
3082
|
-
await reviewCheckpoints.releaseWorkspace(
|
|
3083
|
-
const result = `Closed checkout-backed
|
|
3145
|
+
workspaces.closeWorkspace(checkoutWorkspaceId);
|
|
3146
|
+
await reviewCheckpoints.releaseWorkspace(checkoutWorkspaceId);
|
|
3147
|
+
const result = `Closed checkout-backed Workspace ${checkoutWorkspaceId}; its ForgeRelay identity was preserved for later reopen. Physical project files were not removed.`;
|
|
3084
3148
|
return {
|
|
3085
3149
|
content: [textBlock(result)],
|
|
3086
3150
|
_meta: {
|
|
3087
3151
|
tool: toolNames.closeWorkspace,
|
|
3088
3152
|
card: {
|
|
3089
|
-
workspaceId,
|
|
3153
|
+
workspaceId: checkoutWorkspaceId,
|
|
3154
|
+
action: "close",
|
|
3090
3155
|
mode: "checkout",
|
|
3091
3156
|
payload: { content: [textBlock(result)] },
|
|
3092
3157
|
},
|
|
3093
3158
|
},
|
|
3094
|
-
structuredContent: {
|
|
3159
|
+
structuredContent: {
|
|
3160
|
+
result,
|
|
3161
|
+
workspaceId: checkoutWorkspaceId,
|
|
3162
|
+
action: "close",
|
|
3163
|
+
mode: "checkout",
|
|
3164
|
+
},
|
|
3095
3165
|
};
|
|
3096
3166
|
},
|
|
3097
3167
|
});
|
|
3098
|
-
workspacePanelStates.delete(
|
|
3168
|
+
workspacePanelStates.delete(workspace.id);
|
|
3099
3169
|
return response;
|
|
3100
3170
|
});
|
|
3101
3171
|
registerAppTool(server, toolNames.read, {
|
package/dist/workspaces.js
CHANGED
|
@@ -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,7 +152,7 @@ export class WorkspaceRegistry {
|
|
|
149
152
|
};
|
|
150
153
|
}
|
|
151
154
|
async resumeWorkspace(workspaceId, conversationScopeId, bootstrapContext = "auto") {
|
|
152
|
-
const workspace = this.
|
|
155
|
+
const workspace = this.workspaceForOpen(workspaceId);
|
|
153
156
|
const context = await this.reusedWorkspaceContext(workspace);
|
|
154
157
|
if (!conversationScopeId || !this.store) {
|
|
155
158
|
return {
|
|
@@ -209,22 +212,23 @@ export class WorkspaceRegistry {
|
|
|
209
212
|
const workspace = this.getWorkspace(workspaceId);
|
|
210
213
|
const canonicalWorkspaceId = workspace.id;
|
|
211
214
|
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
|
-
}
|
|
215
|
+
throw new Error(`Workspace ${canonicalWorkspaceId} is backed by a managed worktree. Use close_workspace with its managed-worktree finalize lifecycle.`);
|
|
217
216
|
}
|
|
218
217
|
if (this.store) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
this.store.deleteConversationBinding(binding.conversationScopeId, binding.targetKey);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
this.store.deleteSession(canonicalWorkspaceId);
|
|
218
|
+
this.deleteConversationBindingsForWorkspace(canonicalWorkspaceId);
|
|
219
|
+
this.store.setSessionStatus(canonicalWorkspaceId, "closed");
|
|
225
220
|
}
|
|
226
221
|
this.workspaces.delete(canonicalWorkspaceId);
|
|
227
222
|
}
|
|
223
|
+
deleteWorkspace(workspaceId) {
|
|
224
|
+
const session = this.getWorkspaceSession(workspaceId);
|
|
225
|
+
if (session.mode !== "checkout") {
|
|
226
|
+
throw new Error(`Workspace ${session.id} is not a checkout Workspace. Delete semantics for ${session.mode} Workspaces are handled by their own lifecycle.`);
|
|
227
|
+
}
|
|
228
|
+
this.deleteConversationBindingsForWorkspace(session.id);
|
|
229
|
+
this.store?.deleteSession(session.id);
|
|
230
|
+
this.workspaces.delete(session.id);
|
|
231
|
+
}
|
|
228
232
|
workspaceIdsForPhysicalWorkspace(workspace) {
|
|
229
233
|
const root = resolve(workspace.root);
|
|
230
234
|
return this.activeSessions(workspace.mode)
|
|
@@ -336,7 +340,7 @@ export class WorkspaceRegistry {
|
|
|
336
340
|
if (boundContext)
|
|
337
341
|
return boundContext;
|
|
338
342
|
const context = await this.openOnce(targetKey, async () => {
|
|
339
|
-
const reusableWorkspace = await this.findReusableWorkspaceByDirectory(projectKey, "checkout");
|
|
343
|
+
const reusableWorkspace = await this.findReusableWorkspaceByDirectory(projectKey, "checkout", true);
|
|
340
344
|
if (!reusableWorkspace)
|
|
341
345
|
return this.openCheckoutWorkspace(path);
|
|
342
346
|
return this.reusedWorkspaceContext(reusableWorkspace);
|
|
@@ -547,13 +551,27 @@ export class WorkspaceRegistry {
|
|
|
547
551
|
});
|
|
548
552
|
}
|
|
549
553
|
}
|
|
550
|
-
async findReusableWorkspaceByDirectory(directoryKey, mode) {
|
|
551
|
-
|
|
554
|
+
async findReusableWorkspaceByDirectory(directoryKey, mode, reopenClosedCheckout = false) {
|
|
555
|
+
const sessions = this.store
|
|
556
|
+
? this.store.listSessions({ mode })
|
|
557
|
+
: this.activeSessions(mode);
|
|
558
|
+
for (const session of sessions) {
|
|
559
|
+
const reusable = session.status === "active" ||
|
|
560
|
+
(reopenClosedCheckout && mode === "checkout" && session.status === "closed");
|
|
561
|
+
if (!reusable)
|
|
562
|
+
continue;
|
|
552
563
|
const root = await this.validSessionRoot(session);
|
|
553
564
|
if (!root)
|
|
554
565
|
continue;
|
|
555
566
|
if (await canonicalPath(root) !== directoryKey)
|
|
556
567
|
continue;
|
|
568
|
+
if (session.status === "closed") {
|
|
569
|
+
this.store?.setSessionStatus(session.id, "active");
|
|
570
|
+
const reopened = this.store?.getSession(session.id);
|
|
571
|
+
if (!reopened)
|
|
572
|
+
continue;
|
|
573
|
+
return this.workspaceFromSession(reopened, false);
|
|
574
|
+
}
|
|
557
575
|
return this.workspaceFromSession(session, false);
|
|
558
576
|
}
|
|
559
577
|
return undefined;
|
|
@@ -636,6 +654,50 @@ export class WorkspaceRegistry {
|
|
|
636
654
|
includeBootstrapContext: true,
|
|
637
655
|
};
|
|
638
656
|
}
|
|
657
|
+
workspaceForOpen(workspaceId) {
|
|
658
|
+
const session = this.store?.getSession(workspaceId);
|
|
659
|
+
if (session?.status === "closed" && session.mode === "checkout") {
|
|
660
|
+
this.store?.setSessionStatus(session.id, "active");
|
|
661
|
+
const reopened = this.store?.getSession(session.id);
|
|
662
|
+
if (!reopened) {
|
|
663
|
+
throw new Error(`Unknown workspaceId: ${workspaceId}. Call open_workspace first.`);
|
|
664
|
+
}
|
|
665
|
+
return this.workspaceFromSession(reopened, true);
|
|
666
|
+
}
|
|
667
|
+
return this.getWorkspace(workspaceId);
|
|
668
|
+
}
|
|
669
|
+
getWorkspaceSession(workspaceId) {
|
|
670
|
+
const session = this.store?.getSession(workspaceId);
|
|
671
|
+
if (session)
|
|
672
|
+
return session;
|
|
673
|
+
const workspace = this.workspaces.get(workspaceId);
|
|
674
|
+
if (!workspace) {
|
|
675
|
+
throw new Error(`Unknown workspaceId: ${workspaceId}. Call open_workspace first.`);
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
id: workspace.id,
|
|
679
|
+
root: workspace.root,
|
|
680
|
+
status: "active",
|
|
681
|
+
mode: workspace.mode,
|
|
682
|
+
sourceRoot: workspace.sourceRoot,
|
|
683
|
+
baseRef: workspace.worktree?.baseRef,
|
|
684
|
+
baseSha: workspace.worktree?.baseSha,
|
|
685
|
+
branch: workspace.worktree?.branch,
|
|
686
|
+
targetBranch: workspace.worktree?.targetBranch,
|
|
687
|
+
managed: workspace.worktree?.managed ?? false,
|
|
688
|
+
createdAt: "",
|
|
689
|
+
lastUsedAt: "",
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
deleteConversationBindingsForWorkspace(workspaceId) {
|
|
693
|
+
if (!this.store)
|
|
694
|
+
return;
|
|
695
|
+
for (const binding of this.store.listConversationBindings()) {
|
|
696
|
+
if (binding.workspaceSessionId === workspaceId) {
|
|
697
|
+
this.store.deleteConversationBinding(binding.conversationScopeId, binding.targetKey);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
639
701
|
getWorkspace(workspaceId) {
|
|
640
702
|
const workspace = this.workspaces.get(workspaceId);
|
|
641
703
|
if (workspace) {
|
|
@@ -288,11 +288,15 @@ normal open path may still include `staleWorkspaces` for an idle persistent
|
|
|
288
288
|
Workspace; use `open_workspace(action="list")` for complete, filtered inventory
|
|
289
289
|
when continuation or cleanup actually requires it.
|
|
290
290
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
291
|
+
For checkout-backed Workspaces, `close_workspace` defaults to `action="close"` and
|
|
292
|
+
preserves the Workspace identity for later reopen. A closed Workspace stays visible
|
|
293
|
+
through `open_workspace(action="list")`, while ordinary execution tools reject it
|
|
294
|
+
until `open_workspace` reactivates the same ID by path or by `workspaceId`.
|
|
295
|
+
`close_workspace(action="delete")` is the explicit permanent checkout cleanup path:
|
|
296
|
+
it removes ForgeRelay-owned Workspace state but never deletes or mutates project
|
|
297
|
+
files. Managed-worktree close still requires `commitMessage` and runs the safe commit /
|
|
298
|
+
fast-forward-only integration / cleanup lifecycle; managed-worktree, Composite, and
|
|
299
|
+
relayed delete semantics are completed by their later lifecycle stages.
|
|
296
300
|
|
|
297
301
|
Shell commands are allowed to modify ordinary project files when that is a
|
|
298
302
|
natural part of the user's requested development task; ForgeRelay does not apply
|
package/docs/configuration.md
CHANGED
|
@@ -339,15 +339,17 @@ 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` now 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
|
+
Managed-worktree close still requires `commitMessage` and runs the existing safe
|
|
349
|
+
finalize lifecycle; managed-worktree delete semantics land in the next lifecycle
|
|
350
|
+
stage. Composite close still dissolves in this stage, and Composite delete remains
|
|
351
|
+
unavailable until its persistent lifecycle stage. Relayed delete is likewise deferred
|
|
352
|
+
to Workspace Relay lifecycle parity.
|
|
351
353
|
|
|
352
354
|
Hot workspace/session activity timestamps are coalesced in memory and flushed to the
|
|
353
355
|
SQLite state database in a transaction at most every five minutes; normal shutdown
|
package/package.json
CHANGED
package/scripts/debug/accept.mjs
CHANGED
|
@@ -32,6 +32,7 @@ const stateDir = resolve(acceptanceRoot, "state");
|
|
|
32
32
|
const worktreeRoot = resolve(acceptanceRoot, "worktrees");
|
|
33
33
|
const hookLog = resolve(acceptanceRoot, "hooks.jsonl");
|
|
34
34
|
const checkoutWorkspace = resolve(acceptanceRoot, "workspace");
|
|
35
|
+
const lifecycleDeleteWorkspace = resolve(acceptanceRoot, "delete-workspace");
|
|
35
36
|
const codeIntelligenceLog = resolve(acceptanceRoot, "code-intelligence-lsp.jsonl");
|
|
36
37
|
const fakeLanguageServer = resolve(repoRoot, "src", "lsp", "test-fixtures", "fake-lsp-server.mjs");
|
|
37
38
|
const gitProject = resolve(acceptanceRoot, "git-project");
|
|
@@ -45,6 +46,8 @@ await assertDebugPortFree();
|
|
|
45
46
|
rmSync(acceptanceRoot, { recursive: true, force: true });
|
|
46
47
|
mkdirSync(acceptanceRoot, { recursive: true });
|
|
47
48
|
setupGitProject(checkoutWorkspace);
|
|
49
|
+
setupGitProject(lifecycleDeleteWorkspace);
|
|
50
|
+
writeFileSync(join(lifecycleDeleteWorkspace, "keep.txt"), "keep checkout files\n");
|
|
48
51
|
setupCodeIntelligenceAcceptanceProject({
|
|
49
52
|
root: checkoutWorkspace,
|
|
50
53
|
fakeLanguageServer,
|
|
@@ -279,42 +282,52 @@ try {
|
|
|
279
282
|
assert.equal(capabilityCatalog[0].available, true);
|
|
280
283
|
assert.equal(capabilityCatalog[0].guide.name, "lifecycle-hooks");
|
|
281
284
|
|
|
282
|
-
const
|
|
285
|
+
const repeatedOpen = callTool(oauth.accessToken, sessionId, 84, "open_workspace", {
|
|
283
286
|
path: checkoutWorkspace,
|
|
284
287
|
newWorkspace: true,
|
|
285
288
|
}, workspaceConversationMeta);
|
|
286
|
-
|
|
287
|
-
assert.
|
|
288
|
-
assert.equal(freshLogical.structuredContent.action, "open");
|
|
289
|
+
assert.equal(repeatedOpen.structuredContent.workspaceId, workspaceId);
|
|
290
|
+
assert.equal(repeatedOpen.structuredContent.action, "open");
|
|
289
291
|
assert.equal(
|
|
290
|
-
|
|
292
|
+
repeatedOpen.structuredContent.contextFingerprint,
|
|
291
293
|
opened.structuredContent.contextFingerprint,
|
|
292
294
|
);
|
|
293
|
-
assert.equal(
|
|
294
|
-
assert.equal(
|
|
295
|
+
assert.equal(repeatedOpen.structuredContent.agentsFiles, undefined);
|
|
296
|
+
assert.equal(repeatedOpen.structuredContent.capabilityGuides, undefined);
|
|
295
297
|
|
|
296
298
|
const workspaceInventory = callTool(oauth.accessToken, sessionId, 85, "open_workspace", {
|
|
297
299
|
action: "list",
|
|
298
300
|
root: checkoutWorkspace,
|
|
299
301
|
}, workspaceConversationMeta);
|
|
300
302
|
assert.equal(workspaceInventory.structuredContent.action, "list");
|
|
301
|
-
assert.equal(workspaceInventory.structuredContent.summary.matching,
|
|
303
|
+
assert.equal(workspaceInventory.structuredContent.summary.matching, 1);
|
|
302
304
|
const inventoryEntries = workspaceInventory.structuredContent.workspaces;
|
|
303
|
-
assert.equal(inventoryEntries.length,
|
|
304
|
-
assert.equal(
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
);
|
|
305
|
+
assert.equal(inventoryEntries.length, 1);
|
|
306
|
+
assert.equal(inventoryEntries[0].workspaceId, workspaceId);
|
|
307
|
+
assert.equal(inventoryEntries[0].current, true);
|
|
308
|
+
|
|
309
|
+
const closedWorkspace = callTool(oauth.accessToken, sessionId, 86, "close_workspace", {
|
|
310
|
+
workspaceId,
|
|
311
|
+
});
|
|
312
|
+
assert.equal(closedWorkspace.isError, undefined);
|
|
313
|
+
assert.equal(closedWorkspace.structuredContent.workspaceId, workspaceId);
|
|
314
|
+
assert.equal(closedWorkspace.structuredContent.action, "close");
|
|
315
|
+
|
|
316
|
+
const closedInventory = callTool(oauth.accessToken, sessionId, 87, "open_workspace", {
|
|
317
|
+
action: "list",
|
|
318
|
+
workspaceId,
|
|
319
|
+
}, workspaceConversationMeta);
|
|
320
|
+
assert.equal(closedInventory.structuredContent.workspaces.length, 1);
|
|
321
|
+
assert.equal(closedInventory.structuredContent.workspaces[0].state, "closed");
|
|
322
|
+
assert.equal(closedInventory.structuredContent.workspaces[0].current, false);
|
|
312
323
|
|
|
313
|
-
const
|
|
314
|
-
workspaceId
|
|
324
|
+
const closedRead = callTool(oauth.accessToken, sessionId, 89, "read", {
|
|
325
|
+
workspaceId,
|
|
326
|
+
path: "AGENTS.md",
|
|
315
327
|
});
|
|
316
|
-
assert.equal(
|
|
317
|
-
|
|
328
|
+
assert.equal(closedRead.isError, true);
|
|
329
|
+
|
|
330
|
+
const resumedOriginal = callTool(oauth.accessToken, sessionId, 90, "open_workspace", {
|
|
318
331
|
workspaceId,
|
|
319
332
|
}, workspaceConversationMeta);
|
|
320
333
|
assert.equal(resumedOriginal.structuredContent.workspaceId, workspaceId);
|
|
@@ -323,9 +336,33 @@ try {
|
|
|
323
336
|
resumedOriginal.structuredContent.contextFingerprint,
|
|
324
337
|
opened.structuredContent.contextFingerprint,
|
|
325
338
|
);
|
|
339
|
+
|
|
340
|
+
const deleteOpened = callTool(oauth.accessToken, sessionId, 91, "open_workspace", {
|
|
341
|
+
path: lifecycleDeleteWorkspace,
|
|
342
|
+
context: "none",
|
|
343
|
+
}, { "openai/session": "acceptance-workspace-delete" });
|
|
344
|
+
const deleteWorkspaceId = deleteOpened.structuredContent.workspaceId;
|
|
345
|
+
const deleteClosed = callTool(oauth.accessToken, sessionId, 92, "close_workspace", {
|
|
346
|
+
workspaceId: deleteWorkspaceId,
|
|
347
|
+
});
|
|
348
|
+
assert.equal(deleteClosed.structuredContent.action, "close");
|
|
349
|
+
const deletedWorkspace = callTool(oauth.accessToken, sessionId, 93, "close_workspace", {
|
|
350
|
+
workspaceId: deleteWorkspaceId,
|
|
351
|
+
action: "delete",
|
|
352
|
+
});
|
|
353
|
+
assert.equal(deletedWorkspace.isError, undefined);
|
|
354
|
+
assert.equal(deletedWorkspace.structuredContent.workspaceId, deleteWorkspaceId);
|
|
355
|
+
assert.equal(deletedWorkspace.structuredContent.action, "delete");
|
|
356
|
+
assert.equal(readFileSync(join(lifecycleDeleteWorkspace, "keep.txt"), "utf8"), "keep checkout files\n");
|
|
357
|
+
const deletedInventory = callTool(oauth.accessToken, sessionId, 94, "open_workspace", {
|
|
358
|
+
action: "list",
|
|
359
|
+
workspaceId: deleteWorkspaceId,
|
|
360
|
+
});
|
|
361
|
+
assert.equal(deletedInventory.structuredContent.workspaces.length, 0);
|
|
362
|
+
|
|
326
363
|
pass(
|
|
327
|
-
"workspace
|
|
328
|
-
`${workspaceId} ->
|
|
364
|
+
"workspace lifecycle + inventory",
|
|
365
|
+
`${workspaceId} canonical reuse -> close -> list -> reopen; explicit delete preserves checkout files`,
|
|
329
366
|
);
|
|
330
367
|
|
|
331
368
|
const directCapability = callTool(oauth.accessToken, sessionId, 79, "capability", {
|
|
@@ -972,13 +1009,14 @@ function exerciseReleaseTagHooks(accessToken, sessionId) {
|
|
|
972
1009
|
|
|
973
1010
|
function exerciseSubagentHooks(runtimeEnv, debugStateDir, workspaceId, workspaceRoot) {
|
|
974
1011
|
const seedScript = [
|
|
975
|
-
'import {
|
|
976
|
-
'const store = new
|
|
1012
|
+
'import { SubagentSessionStore } from "./src/subagents/sessions/store.js";',
|
|
1013
|
+
'const store = new SubagentSessionStore(process.env.FORGERELAY_STATE_DIR);',
|
|
977
1014
|
'const record = store.create({',
|
|
978
1015
|
` workspaceId: ${JSON.stringify(workspaceId)},`,
|
|
979
1016
|
` workspaceRoot: ${JSON.stringify(workspaceRoot)},`,
|
|
980
1017
|
' profileName: "debug-missing-profile",',
|
|
981
1018
|
' provider: "codex",',
|
|
1019
|
+
' activeRun: { id: "run_debug_acceptance", startedAt: new Date().toISOString() },',
|
|
982
1020
|
'});',
|
|
983
1021
|
'store.close();',
|
|
984
1022
|
'process.stdout.write(record.id);',
|
|
@@ -1006,11 +1044,13 @@ function exerciseSubagentHooks(runtimeEnv, debugStateDir, workspaceId, workspace
|
|
|
1006
1044
|
}
|
|
1007
1045
|
|
|
1008
1046
|
const inspectScript = [
|
|
1009
|
-
'import {
|
|
1010
|
-
'
|
|
1047
|
+
'import { SubagentSessionStore } from "./src/subagents/sessions/store.js";',
|
|
1048
|
+
'import { SubagentDeliveryMailbox } from "./src/subagents/sessions/delivery-mailbox.js";',
|
|
1049
|
+
'const store = new SubagentSessionStore(process.env.FORGERELAY_STATE_DIR);',
|
|
1011
1050
|
`const record = store.get(${JSON.stringify(agentId)});`,
|
|
1051
|
+
`const deliveries = new SubagentDeliveryMailbox(process.env.FORGERELAY_STATE_DIR).claimSession(${JSON.stringify(workspaceId)}, ${JSON.stringify(agentId)});`,
|
|
1012
1052
|
'store.close();',
|
|
1013
|
-
'process.stdout.write(JSON.stringify({ status: record?.status,
|
|
1053
|
+
'process.stdout.write(JSON.stringify({ status: record?.status, latestRun: record?.latestRun, delivery: deliveries[0] }));',
|
|
1014
1054
|
].join("\n");
|
|
1015
1055
|
const inspected = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", inspectScript], {
|
|
1016
1056
|
cwd: repoRoot,
|
|
@@ -1021,9 +1061,11 @@ function exerciseSubagentHooks(runtimeEnv, debugStateDir, workspaceId, workspace
|
|
|
1021
1061
|
throw new Error(`unable to inspect debug subagent: ${inspected.stderr.trim()}`);
|
|
1022
1062
|
}
|
|
1023
1063
|
const record = JSON.parse(inspected.stdout);
|
|
1024
|
-
assert.equal(record.status, "
|
|
1025
|
-
assert.
|
|
1026
|
-
|
|
1064
|
+
assert.equal(record.status, "idle");
|
|
1065
|
+
assert.equal(record.latestRun?.status, "failed");
|
|
1066
|
+
assert.equal(record.delivery?.outcome, "failed");
|
|
1067
|
+
assert.match(record.delivery?.error ?? "", /Subagent profile not found/);
|
|
1068
|
+
pass("subagent hook path", `${agentId} stopped in deterministic failed Run without calling a provider`);
|
|
1027
1069
|
}
|
|
1028
1070
|
|
|
1029
1071
|
function toolText(result) {
|