@akira-tl/forgerelay 0.7.4 → 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 +21 -0
- package/dist/db/migrations.js +19 -0
- package/dist/db/schema.js +8 -0
- package/dist/mcp/server-instructions.js +1 -1
- package/dist/server.js +111 -20
- package/dist/workspace-store.js +77 -8
- package/dist/workspaces.js +153 -87
- package/docs/chatgpt-coding-workflow.md +41 -34
- package/docs/configuration.md +38 -32
- package/docs/roadmap.md +39 -11
- package/docs/security.md +4 -4
- package/package.json +1 -1
- package/scripts/debug/accept.mjs +73 -31
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ 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
|
+
|
|
17
|
+
## [0.8.0] - 2026-08-30
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Workspace identity is now canonical per physical checkout or managed worktree, so different Host conversations reuse one persistent `workspaceId`; legacy duplicate IDs remain compatible aliases, and `newWorkspace` no longer creates same-target duplicates.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Composite member bootstrap now preserves capability guides and subagent provider/profile context exposed by the underlying Workspace.
|
|
26
|
+
- Composite member `context="none"` now truthfully reports suppressed bootstrap instead of claiming it was already delivered.
|
|
27
|
+
|
|
7
28
|
## [0.7.4] - 2026-08-30
|
|
8
29
|
|
|
9
30
|
### Added
|
package/dist/db/migrations.js
CHANGED
|
@@ -74,6 +74,11 @@ const migrations = [
|
|
|
74
74
|
name: "subagent-run-ownership",
|
|
75
75
|
up: migrateSubagentRunOwnership,
|
|
76
76
|
},
|
|
77
|
+
{
|
|
78
|
+
version: 16,
|
|
79
|
+
name: "workspace-session-aliases",
|
|
80
|
+
up: migrateWorkspaceSessionAliases,
|
|
81
|
+
},
|
|
77
82
|
];
|
|
78
83
|
export function migrateDatabase(sqlite) {
|
|
79
84
|
const migrate = sqlite.transaction(() => {
|
|
@@ -368,6 +373,20 @@ function migrateSubagentRunOwnership(sqlite) {
|
|
|
368
373
|
addColumnIfMissing(sqlite, "local_agent_sessions", "active_owner_id", "text");
|
|
369
374
|
addColumnIfMissing(sqlite, "local_agent_sessions", "active_owner_pid", "integer");
|
|
370
375
|
}
|
|
376
|
+
function migrateWorkspaceSessionAliases(sqlite) {
|
|
377
|
+
sqlite.exec(`
|
|
378
|
+
create table if not exists workspace_session_aliases (
|
|
379
|
+
alias_id text primary key,
|
|
380
|
+
workspace_session_id text not null,
|
|
381
|
+
foreign key (workspace_session_id)
|
|
382
|
+
references workspace_sessions(id)
|
|
383
|
+
on delete cascade
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
create index if not exists workspace_session_aliases_workspace_idx
|
|
387
|
+
on workspace_session_aliases(workspace_session_id);
|
|
388
|
+
`);
|
|
389
|
+
}
|
|
371
390
|
function migrateActivityHostTurnWorkspace(sqlite) {
|
|
372
391
|
migrateActivityHostTurns(sqlite);
|
|
373
392
|
addColumnIfMissing(sqlite, "activity_host_turns", "workspace_id", "text");
|
package/dist/db/schema.js
CHANGED
|
@@ -16,6 +16,14 @@ export const workspaceSessions = sqliteTable("workspace_sessions", {
|
|
|
16
16
|
index("workspace_sessions_root_idx").on(table.root, table.lastUsedAt),
|
|
17
17
|
index("workspace_sessions_status_idx").on(table.status, table.lastUsedAt),
|
|
18
18
|
]);
|
|
19
|
+
export const workspaceSessionAliases = sqliteTable("workspace_session_aliases", {
|
|
20
|
+
aliasId: text("alias_id").primaryKey(),
|
|
21
|
+
workspaceSessionId: text("workspace_session_id")
|
|
22
|
+
.notNull()
|
|
23
|
+
.references(() => workspaceSessions.id, { onDelete: "cascade" }),
|
|
24
|
+
}, (table) => [
|
|
25
|
+
index("workspace_session_aliases_workspace_idx").on(table.workspaceSessionId),
|
|
26
|
+
]);
|
|
19
27
|
export const loadedAgentFiles = sqliteTable("loaded_agent_files", {
|
|
20
28
|
workspaceSessionId: text("workspace_session_id")
|
|
21
29
|
.notNull()
|
|
@@ -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
|
@@ -1362,6 +1362,22 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1362
1362
|
const skills = workspace.skills
|
|
1363
1363
|
.filter((skill) => !skill.disableModelInvocation)
|
|
1364
1364
|
.map((skill) => ({ name: skill.name, description: skill.description }));
|
|
1365
|
+
const capabilityGuides = workspace.capabilityGuides.map((guide) => ({
|
|
1366
|
+
name: guide.name,
|
|
1367
|
+
description: guide.description,
|
|
1368
|
+
whenToRead: guide.whenToRead,
|
|
1369
|
+
path: formatPathForPrompt(guide.filePath),
|
|
1370
|
+
}));
|
|
1371
|
+
const agentProviders = config.subagents ? subagentProviders : [];
|
|
1372
|
+
const agents = workspace.agentProfiles.map((profile) => {
|
|
1373
|
+
const summary = summarizeSubagentProfile(profile);
|
|
1374
|
+
const availability = agentProviders.find((provider) => provider.name === summary.provider);
|
|
1375
|
+
return {
|
|
1376
|
+
...summary,
|
|
1377
|
+
providerAvailable: availability?.available,
|
|
1378
|
+
providerUnavailableReason: availability?.reason,
|
|
1379
|
+
};
|
|
1380
|
+
});
|
|
1365
1381
|
return {
|
|
1366
1382
|
member: memberName,
|
|
1367
1383
|
workspaceId: compositeWorkspaceId,
|
|
@@ -1373,15 +1389,20 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1373
1389
|
includeBootstrapContext: opened.includeBootstrapContext,
|
|
1374
1390
|
...(opened.includeBootstrapContext
|
|
1375
1391
|
? {
|
|
1392
|
+
capabilityGuides,
|
|
1376
1393
|
agentsFiles,
|
|
1377
1394
|
availableAgentsFiles,
|
|
1378
1395
|
skills,
|
|
1396
|
+
agentProviders,
|
|
1397
|
+
agents,
|
|
1379
1398
|
skillDiagnostics: redactSkillDiagnosticPaths(workspace.skillDiagnostics),
|
|
1380
1399
|
}
|
|
1381
1400
|
: {}),
|
|
1382
1401
|
instruction: opened.includeBootstrapContext
|
|
1383
1402
|
? `Bootstrap context for Composite member ${memberName}. Keep using Composite workspaceId ${compositeWorkspaceId} and pass member=${memberName} for work operations.`
|
|
1384
|
-
:
|
|
1403
|
+
: contextPolicy === "none"
|
|
1404
|
+
? `Bootstrap context for Composite member ${memberName} was intentionally suppressed by context=none. Keep using Composite workspaceId ${compositeWorkspaceId} with member=${memberName}; request context=auto or context=full when member bootstrap is needed.`
|
|
1405
|
+
: `Composite member ${memberName} context was already delivered for this Host context; keep using Composite workspaceId ${compositeWorkspaceId} with member=${memberName}.`,
|
|
1385
1406
|
};
|
|
1386
1407
|
};
|
|
1387
1408
|
const coreOperations = createCoreOperationExecutor({
|
|
@@ -1960,7 +1981,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1960
1981
|
action: z
|
|
1961
1982
|
.enum(["open", "list", "member"])
|
|
1962
1983
|
.optional()
|
|
1963
|
-
.describe("Defaults to open. Use list to inspect
|
|
1984
|
+
.describe("Defaults to open. Use list to inspect known Workspaces. Use member to add/remove a named execution member on an existing Composite Workspace."),
|
|
1964
1985
|
memberAction: z
|
|
1965
1986
|
.enum(["add", "update", "remove"])
|
|
1966
1987
|
.optional()
|
|
@@ -2000,7 +2021,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2000
2021
|
workspaceId: z
|
|
2001
2022
|
.string()
|
|
2002
2023
|
.optional()
|
|
2003
|
-
.describe("For action=open, an existing
|
|
2024
|
+
.describe("For action=open, an existing Workspace ID to resume or reuse. Historical duplicate IDs from earlier ForgeRelay versions may resolve to the canonical Workspace ID. For action=list, filters inventory to one Workspace ID."),
|
|
2004
2025
|
mode: z
|
|
2005
2026
|
.enum(["checkout", "worktree"])
|
|
2006
2027
|
.optional()
|
|
@@ -2016,7 +2037,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2016
2037
|
newWorkspace: z
|
|
2017
2038
|
.boolean()
|
|
2018
2039
|
.optional()
|
|
2019
|
-
.describe("
|
|
2040
|
+
.describe("Deprecated compatibility flag. It no longer creates another Workspace identity for the same physical checkout or managed worktree; ForgeRelay reuses that target's canonical Workspace. Use newWorktree=true when the user explicitly needs separate Git isolation."),
|
|
2020
2041
|
context: z
|
|
2021
2042
|
.enum(["auto", "full", "none"])
|
|
2022
2043
|
.optional()
|
|
@@ -2036,7 +2057,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2036
2057
|
staleOnly: z
|
|
2037
2058
|
.boolean()
|
|
2038
2059
|
.optional()
|
|
2039
|
-
.describe("For action=list, return only active
|
|
2060
|
+
.describe("For action=list, return only active Workspaces idle for more than two days."),
|
|
2040
2061
|
offset: z
|
|
2041
2062
|
.number()
|
|
2042
2063
|
.int()
|
|
@@ -2570,7 +2591,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2570
2591
|
const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : [];
|
|
2571
2592
|
const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : [];
|
|
2572
2593
|
const workspaceContextInstruction = "For later open_workspace calls, context=\"auto\" avoids repeating unchanged bootstrap context; use context=\"none\" when only the workspace handle/metadata is needed, or context=\"full\" to force a refresh.";
|
|
2573
|
-
const workspaceManagementInstruction = "When you need to continue
|
|
2594
|
+
const workspaceManagementInstruction = "When you need to inspect known Workspaces, continue earlier work, or organize Workspace state, use open_workspace(action=\"list\") to inspect candidates, then resume a selected workspaceId or ask the user before close_workspace cleanup.";
|
|
2574
2595
|
const cardInstruction = config.skillsEnabled
|
|
2575
2596
|
? `Use this workspaceId in all subsequent tool calls for this project. Follow loaded agentsFiles instructions. Read an availableAgentsFiles path before working under it. When a task matches an available skill, load it with read(path=\"skills://<name>\") before proceeding. When a task matches a capability guide, read its advertised path before proceeding. ${workspaceContextInstruction} ${workspaceManagementInstruction}`
|
|
2576
2597
|
: `Use this workspaceId in all subsequent tool calls for this project. Follow loaded agentsFiles instructions. Read an availableAgentsFiles path before working under it. When a task matches a capability guide, read its advertised path before proceeding. ${workspaceContextInstruction} ${workspaceManagementInstruction}`;
|
|
@@ -2632,7 +2653,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2632
2653
|
? `Known worktrees: ${knownWorktrees.map((worktree) => `${worktree.path} [${worktree.workspaceId}]${worktree.branch ? ` branch=${worktree.branch}` : ""}${worktree.targetBranch ? ` target=${worktree.targetBranch}` : ""}${worktree.current ? " (current)" : ""}`).join(", ")}`
|
|
2633
2654
|
: undefined,
|
|
2634
2655
|
staleWorkspaces.length > 0
|
|
2635
|
-
? `
|
|
2656
|
+
? `This Workspace has been idle for more than 2 days: ${staleWorkspaces.map((stale) => `${stale.workspaceId} last-used=${stale.lastUsedAt}`).join(", ")}. It remains available to resume or explicitly close; do not clean it up automatically.`
|
|
2636
2657
|
: undefined,
|
|
2637
2658
|
`ForgeRelay ${capabilityFingerprint.version} capabilities: ${capabilityFingerprint.capabilities.join(", ")}`,
|
|
2638
2659
|
instruction,
|
|
@@ -2900,9 +2921,13 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2900
2921
|
});
|
|
2901
2922
|
registerAppTool(server, toolNames.closeWorkspace, {
|
|
2902
2923
|
title: "Close workspace",
|
|
2903
|
-
description: "Close one
|
|
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.",
|
|
2904
2925
|
inputSchema: {
|
|
2905
|
-
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."),
|
|
2906
2931
|
commitMessage: z
|
|
2907
2932
|
.string()
|
|
2908
2933
|
.min(1)
|
|
@@ -2911,6 +2936,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2911
2936
|
},
|
|
2912
2937
|
outputSchema: resultOutputSchema({
|
|
2913
2938
|
workspaceId: z.string(),
|
|
2939
|
+
action: z.enum(["close", "delete"]).optional(),
|
|
2914
2940
|
kind: z.enum(["workspace", "composite"]).optional(),
|
|
2915
2941
|
mode: z.enum(["checkout", "worktree"]).optional(),
|
|
2916
2942
|
name: z.string().optional(),
|
|
@@ -2930,8 +2956,11 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2930
2956
|
}),
|
|
2931
2957
|
_meta: {},
|
|
2932
2958
|
annotations: WRITE_TOOL_ANNOTATIONS,
|
|
2933
|
-
}, async ({ workspaceId, commitMessage }, extra) => {
|
|
2959
|
+
}, async ({ workspaceId, action = "close", commitMessage }, extra) => {
|
|
2934
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
|
+
}
|
|
2935
2964
|
if (commitMessage !== undefined) {
|
|
2936
2965
|
throw new Error("close_workspace commitMessage is not valid when dissolving a Composite Workspace.");
|
|
2937
2966
|
}
|
|
@@ -2951,6 +2980,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2951
2980
|
tool: toolNames.closeWorkspace,
|
|
2952
2981
|
card: {
|
|
2953
2982
|
workspaceId,
|
|
2983
|
+
action: "close",
|
|
2954
2984
|
kind: "composite",
|
|
2955
2985
|
name: composite.name,
|
|
2956
2986
|
members: composite.members,
|
|
@@ -2961,6 +2991,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2961
2991
|
structuredContent: {
|
|
2962
2992
|
result,
|
|
2963
2993
|
workspaceId,
|
|
2994
|
+
action: "close",
|
|
2964
2995
|
kind: "composite",
|
|
2965
2996
|
name: composite.name,
|
|
2966
2997
|
members: composite.members,
|
|
@@ -2969,16 +3000,67 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2969
3000
|
};
|
|
2970
3001
|
}
|
|
2971
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
|
+
}
|
|
2972
3006
|
const response = await remoteWorkspaces.closeWorkspace(workspaceId, commitMessage, hostScopeIdFor(extra._meta, extra.sessionId));
|
|
2973
3007
|
workspacePanelStates.delete(workspaceId);
|
|
2974
3008
|
return response;
|
|
2975
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
|
+
}
|
|
2976
3058
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
2977
3059
|
const response = await runToolWithHooks(hooks, {
|
|
2978
3060
|
signal: extra.signal,
|
|
2979
3061
|
tool: toolNames.closeWorkspace,
|
|
2980
3062
|
invocation: workspaceHookInvocation(workspace),
|
|
2981
|
-
payload: { workspaceId, commitMessage, mode: workspace.mode },
|
|
3063
|
+
payload: { workspaceId, action: "close", commitMessage, mode: workspace.mode },
|
|
2982
3064
|
afterCwd: (response) => "sourceRoot" in response.structuredContent &&
|
|
2983
3065
|
typeof response.structuredContent.sourceRoot === "string"
|
|
2984
3066
|
? response.structuredContent.sourceRoot
|
|
@@ -2992,7 +3074,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2992
3074
|
const busyWorkspaceIds = physicalWorkspaceIds
|
|
2993
3075
|
.filter((id) => processSessions.activeWorkspaceIds().has(id));
|
|
2994
3076
|
if (busyWorkspaceIds.length > 0) {
|
|
2995
|
-
throw new Error(`Cannot close this worktree-backed
|
|
3077
|
+
throw new Error(`Cannot close this worktree-backed Workspace while Workspace processes are still running: ${busyWorkspaceIds.join(", ")}.`);
|
|
2996
3078
|
}
|
|
2997
3079
|
const startedAt = performance.now();
|
|
2998
3080
|
const retirement = await codeIntelligence.retireWorkspaceRoot(workspace.root);
|
|
@@ -3026,6 +3108,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
3026
3108
|
tool: toolNames.closeWorkspace,
|
|
3027
3109
|
card: {
|
|
3028
3110
|
workspaceId,
|
|
3111
|
+
action: "close",
|
|
3029
3112
|
mode: "worktree",
|
|
3030
3113
|
sourceRoot: closed.sourceRoot,
|
|
3031
3114
|
branch: closed.branch,
|
|
@@ -3040,6 +3123,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
3040
3123
|
structuredContent: {
|
|
3041
3124
|
result,
|
|
3042
3125
|
workspaceId,
|
|
3126
|
+
action: "close",
|
|
3043
3127
|
mode: "worktree",
|
|
3044
3128
|
sourceRoot: closed.sourceRoot,
|
|
3045
3129
|
branch: closed.branch,
|
|
@@ -3054,27 +3138,34 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
3054
3138
|
if (commitMessage !== undefined) {
|
|
3055
3139
|
throw new Error("close_workspace commitMessage is only valid for managed-worktree-backed workspaces.");
|
|
3056
3140
|
}
|
|
3057
|
-
|
|
3058
|
-
|
|
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.`);
|
|
3059
3144
|
}
|
|
3060
|
-
workspaces.closeWorkspace(
|
|
3061
|
-
await reviewCheckpoints.releaseWorkspace(
|
|
3062
|
-
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.`;
|
|
3063
3148
|
return {
|
|
3064
3149
|
content: [textBlock(result)],
|
|
3065
3150
|
_meta: {
|
|
3066
3151
|
tool: toolNames.closeWorkspace,
|
|
3067
3152
|
card: {
|
|
3068
|
-
workspaceId,
|
|
3153
|
+
workspaceId: checkoutWorkspaceId,
|
|
3154
|
+
action: "close",
|
|
3069
3155
|
mode: "checkout",
|
|
3070
3156
|
payload: { content: [textBlock(result)] },
|
|
3071
3157
|
},
|
|
3072
3158
|
},
|
|
3073
|
-
structuredContent: {
|
|
3159
|
+
structuredContent: {
|
|
3160
|
+
result,
|
|
3161
|
+
workspaceId: checkoutWorkspaceId,
|
|
3162
|
+
action: "close",
|
|
3163
|
+
mode: "checkout",
|
|
3164
|
+
},
|
|
3074
3165
|
};
|
|
3075
3166
|
},
|
|
3076
3167
|
});
|
|
3077
|
-
workspacePanelStates.delete(
|
|
3168
|
+
workspacePanelStates.delete(workspace.id);
|
|
3078
3169
|
return response;
|
|
3079
3170
|
});
|
|
3080
3171
|
registerAppTool(server, toolNames.read, {
|
package/dist/workspace-store.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { and, desc, eq } from "drizzle-orm";
|
|
2
2
|
import { openDatabase } from "./db/client.js";
|
|
3
|
-
import { workspaceContextDeliveries, workspaceConversationBindings, workspaceSessions, } from "./db/schema.js";
|
|
3
|
+
import { workspaceContextDeliveries, workspaceConversationBindings, workspaceSessionAliases, workspaceSessions, } from "./db/schema.js";
|
|
4
4
|
const DEFAULT_TOUCH_FLUSH_INTERVAL_MS = 5 * 60 * 1_000;
|
|
5
5
|
export class SqliteWorkspaceStore {
|
|
6
6
|
database;
|
|
@@ -61,24 +61,33 @@ export class SqliteWorkspaceStore {
|
|
|
61
61
|
return session;
|
|
62
62
|
}
|
|
63
63
|
getSession(id) {
|
|
64
|
+
const sessionId = this.resolveSessionId(id);
|
|
65
|
+
if (!sessionId)
|
|
66
|
+
return undefined;
|
|
64
67
|
const row = this.database.db
|
|
65
68
|
.select()
|
|
66
69
|
.from(workspaceSessions)
|
|
67
|
-
.where(eq(workspaceSessions.id,
|
|
70
|
+
.where(eq(workspaceSessions.id, sessionId))
|
|
68
71
|
.get();
|
|
69
72
|
if (!row)
|
|
70
73
|
return undefined;
|
|
71
|
-
return applySessionTouch(rowToWorkspaceSession(row), this.pendingSessionTouches.get(
|
|
74
|
+
return applySessionTouch(rowToWorkspaceSession(row), this.pendingSessionTouches.get(sessionId));
|
|
72
75
|
}
|
|
73
76
|
touchSession(id) {
|
|
74
|
-
this.
|
|
77
|
+
const sessionId = this.resolveSessionId(id);
|
|
78
|
+
if (!sessionId)
|
|
79
|
+
return;
|
|
80
|
+
this.pendingSessionTouches.set(sessionId, this.now().toISOString());
|
|
75
81
|
}
|
|
76
82
|
setSessionStatus(id, status) {
|
|
77
|
-
this.
|
|
83
|
+
const sessionId = this.resolveSessionId(id);
|
|
84
|
+
if (!sessionId)
|
|
85
|
+
return;
|
|
86
|
+
this.pendingSessionTouches.delete(sessionId);
|
|
78
87
|
this.database.db
|
|
79
88
|
.update(workspaceSessions)
|
|
80
89
|
.set({ status, lastUsedAt: this.now().toISOString() })
|
|
81
|
-
.where(eq(workspaceSessions.id,
|
|
90
|
+
.where(eq(workspaceSessions.id, sessionId))
|
|
82
91
|
.run();
|
|
83
92
|
}
|
|
84
93
|
listSessions(input = {}) {
|
|
@@ -100,11 +109,56 @@ export class SqliteWorkspaceStore {
|
|
|
100
109
|
.map((session) => applySessionTouch(session, this.pendingSessionTouches.get(session.id)))
|
|
101
110
|
.sort((left, right) => right.lastUsedAt.localeCompare(left.lastUsedAt));
|
|
102
111
|
}
|
|
112
|
+
foldSessions(input) {
|
|
113
|
+
const aliases = input.aliasIds.filter((id) => id !== input.canonicalId);
|
|
114
|
+
if (aliases.length === 0)
|
|
115
|
+
return;
|
|
116
|
+
const fold = this.database.sqlite.transaction(() => {
|
|
117
|
+
this.database.sqlite.prepare(`
|
|
118
|
+
update workspace_sessions
|
|
119
|
+
set created_at = ?, last_used_at = ?, status = ?
|
|
120
|
+
where id = ?
|
|
121
|
+
`).run(input.createdAt, input.lastUsedAt, input.status, input.canonicalId);
|
|
122
|
+
const rebindConversation = this.database.sqlite.prepare(`
|
|
123
|
+
update workspace_conversation_bindings
|
|
124
|
+
set workspace_session_id = ?
|
|
125
|
+
where workspace_session_id = ?
|
|
126
|
+
`);
|
|
127
|
+
const rebindSubagents = this.database.sqlite.prepare(`
|
|
128
|
+
update local_agent_sessions
|
|
129
|
+
set workspace_id = ?
|
|
130
|
+
where workspace_id = ?
|
|
131
|
+
`);
|
|
132
|
+
const rebindAliases = this.database.sqlite.prepare(`
|
|
133
|
+
update workspace_session_aliases
|
|
134
|
+
set workspace_session_id = ?
|
|
135
|
+
where workspace_session_id = ?
|
|
136
|
+
`);
|
|
137
|
+
const deleteSession = this.database.sqlite.prepare("delete from workspace_sessions where id = ?");
|
|
138
|
+
const rememberAlias = this.database.sqlite.prepare(`
|
|
139
|
+
insert into workspace_session_aliases (alias_id, workspace_session_id)
|
|
140
|
+
values (?, ?)
|
|
141
|
+
on conflict(alias_id) do update set workspace_session_id = excluded.workspace_session_id
|
|
142
|
+
`);
|
|
143
|
+
for (const aliasId of aliases) {
|
|
144
|
+
rebindConversation.run(input.canonicalId, aliasId);
|
|
145
|
+
rebindSubagents.run(input.canonicalId, aliasId);
|
|
146
|
+
rebindAliases.run(input.canonicalId, aliasId);
|
|
147
|
+
deleteSession.run(aliasId);
|
|
148
|
+
rememberAlias.run(aliasId, input.canonicalId);
|
|
149
|
+
this.pendingSessionTouches.delete(aliasId);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
fold.immediate();
|
|
153
|
+
}
|
|
103
154
|
deleteSession(id) {
|
|
104
|
-
this.
|
|
155
|
+
const sessionId = this.resolveSessionId(id);
|
|
156
|
+
if (!sessionId)
|
|
157
|
+
return;
|
|
158
|
+
this.pendingSessionTouches.delete(sessionId);
|
|
105
159
|
this.database.db
|
|
106
160
|
.delete(workspaceSessions)
|
|
107
|
-
.where(eq(workspaceSessions.id,
|
|
161
|
+
.where(eq(workspaceSessions.id, sessionId))
|
|
108
162
|
.run();
|
|
109
163
|
}
|
|
110
164
|
listConversationBindings() {
|
|
@@ -212,6 +266,21 @@ export class SqliteWorkspaceStore {
|
|
|
212
266
|
.where(and(eq(workspaceContextDeliveries.conversationScopeId, conversationScopeId), eq(workspaceContextDeliveries.targetKey, targetKey)))
|
|
213
267
|
.run();
|
|
214
268
|
}
|
|
269
|
+
resolveSessionId(id) {
|
|
270
|
+
const direct = this.database.db
|
|
271
|
+
.select({ id: workspaceSessions.id })
|
|
272
|
+
.from(workspaceSessions)
|
|
273
|
+
.where(eq(workspaceSessions.id, id))
|
|
274
|
+
.get();
|
|
275
|
+
if (direct)
|
|
276
|
+
return direct.id;
|
|
277
|
+
return this.database.db
|
|
278
|
+
.select({ workspaceSessionId: workspaceSessionAliases.workspaceSessionId })
|
|
279
|
+
.from(workspaceSessionAliases)
|
|
280
|
+
.where(eq(workspaceSessionAliases.aliasId, id))
|
|
281
|
+
.get()
|
|
282
|
+
?.workspaceSessionId;
|
|
283
|
+
}
|
|
215
284
|
get pendingTouchCount() {
|
|
216
285
|
return this.pendingSessionTouches.size + this.pendingConversationTouches.size;
|
|
217
286
|
}
|