@akira-tl/forgerelay 0.8.10 → 0.9.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/README.md +13 -10
- package/capabilities/workspace/managed-worktrees/GUIDE.md +23 -1
- package/dist/mcp/server/core/capabilities.js +1 -0
- package/dist/mcp/server/core/capability-registry.js +28 -0
- package/dist/mcp/server/core/capability-support.js +17 -0
- package/dist/mcp/server/core/schemas.js +23 -0
- package/dist/mcp/server/operations/runtime/operation-runtime.js +54 -1
- package/dist/server.js +6 -0
- package/dist/workspaces/git/git-worktrees.js +1 -1
- package/dist/workspaces/git/worktree-recovery.js +518 -0
- package/dist/workspaces/inventory.js +4 -1
- package/dist/workspaces/relay/result-support.js +36 -0
- package/dist/workspaces/relay/workspace-relay.js +4 -1
- package/dist/workspaces/sessions.js +93 -0
- package/dist/workspaces.js +3 -0
- package/docs/configuration.md +19 -12
- package/docs/roadmap.md +45 -35
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.9.1] - 2026-09-04
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added the low-frequency `workspace.recovery` Capability for ForgeRelay-managed worktrees. `status` reports the existing recovery model; `repair` can recreate missing active execution backing only from the surviving ForgeRelay managed branch; and Workspace-scoped `cleanup` removes only proven stale worktree registration or an already-integrated closed-Workspace managed branch. Workspace identity and Task state are preserved, active/unique/ambiguous Git state is never force-deleted, Relay executes recovery on the owning Execution ForgeRelay, and Composite use remains explicit-member scoped.
|
|
12
|
+
|
|
13
|
+
## [0.9.0] - 2026-09-04
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Added bounded read-only managed-worktree recovery diagnostics to `open_workspace` inventory and inspection, distinguishing healthy, recoverable, and manual-intervention states across missing backing/source/branches, stale Git worktree registration, and branch mismatches. Relayed inspection carries the sanitized diagnostic facts from the Execution ForgeRelay without mutating Git state.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Aligned current roadmap and product documentation with already-shipped first-class Subagent delegation, Hook-backed managed-worktree close verification, and explicitly authorized live managed TypeScript/Pyright installation. The 0.9 line is now defined as recovery diagnostics/repair, Workspace checkpoints/restore, and owner-facing retention maintenance.
|
|
22
|
+
- Added managed-worktree recovery/inventory tests to the regular release test suite so the diagnostic contract is exercised on every cloud platform.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Canonicalized managed-worktree registration paths through their real filesystem location, preventing macOS `/var/...` and `/private/var/...` aliases (and equivalent symlink aliases) from being misreported as missing Git worktree registration.
|
|
27
|
+
|
|
7
28
|
## [0.8.10] - 2026-09-03
|
|
8
29
|
|
|
9
30
|
### Added
|
package/README.md
CHANGED
|
@@ -165,10 +165,13 @@ symbols, workspace symbols, and diagnostics. Results use ForgeRelay-owned normal
|
|
|
165
165
|
locations, ranges, symbols, hover content, and diagnostic shapes rather than raw LSP
|
|
166
166
|
wire unions.
|
|
167
167
|
|
|
168
|
-
Language servers
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
168
|
+
Language servers are never installed automatically or without user authorization.
|
|
169
|
+
ForgeRelay can discover configured or `PATH`-available servers, and `forgerelay init`
|
|
170
|
+
can optionally enable Agent-managed installation of TypeScript/JavaScript and Pyright
|
|
171
|
+
inside ForgeRelay's private config directory. That permission is disabled by default;
|
|
172
|
+
a successful managed install becomes available to the same running ForgeRelay process
|
|
173
|
+
on the next semantic request without a restart. `rust-analyzer`, `gopls`, and `clangd`
|
|
174
|
+
remain external toolchain/system installations. See
|
|
172
175
|
[Configuration Reference](docs/configuration.md#lsp-code-intelligence) and
|
|
173
176
|
[`examples/language-servers.json`](examples/language-servers.json).
|
|
174
177
|
|
|
@@ -297,13 +300,13 @@ forgerelay doctor
|
|
|
297
300
|
|
|
298
301
|
## Where ForgeRelay is going
|
|
299
302
|
|
|
300
|
-
With
|
|
301
|
-
|
|
302
|
-
|
|
303
|
+
With persistent Workspace lifecycle, first-class Subagent delegation, managed-LSP
|
|
304
|
+
support, and worktree-close verification already shipped, the 0.9 line focuses on
|
|
305
|
+
Workspace recovery and history without expanding the canonical Core tool surface:
|
|
303
306
|
|
|
304
|
-
1.
|
|
305
|
-
2.
|
|
306
|
-
3.
|
|
307
|
+
1. read-only managed-worktree recovery diagnostics, followed by safe repair/cleanup;
|
|
308
|
+
2. persistent Workspace checkpoints and concurrency-safe restore;
|
|
309
|
+
3. owner-facing retention inspection and explicitly authorized maintenance.
|
|
307
310
|
|
|
308
311
|
ForgeRelay does not plan to add its own shell sandbox, long-term memory system,
|
|
309
312
|
or plugin marketplace. Conversation, planning, web access, and other host-native
|
|
@@ -45,4 +45,26 @@ Managed worktree finalize:
|
|
|
45
45
|
|
|
46
46
|
source checkout 可能被用户或其他 Agent 修改。关闭前不要假设 target branch 仍停留在创建 worktree 时的状态;让 ForgeRelay 的 close 检查决定是否可以安全集成。遇到 diverged target、dirty source、忙碌 workspace 或 cleanup warning 时,保留现有 worktree 状态并向用户说明真实失败点,不要自行强制 reset、merge 或删除 worktree。
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
如果 active managed-worktree Workspace 的物理 backing 丢失,先用 `open_workspace action="inspect"` 或 `workspace.recovery` 的 `status` 查看恢复分类。只有 Capability 返回 `recoverable` 时,才考虑 `workspace.recovery { operation: "repair" }`。
|
|
49
|
+
|
|
50
|
+
`workspace.recovery` 的 repair 规则:
|
|
51
|
+
|
|
52
|
+
- 只恢复当前 persistent `workspaceId`,不会创建新的 Workspace identity;
|
|
53
|
+
- 必须从持久化记录中的原 `forgerelay/*` managed branch 恢复,绝不从 target branch 重新创建替代工作;
|
|
54
|
+
- managed branch、target branch、source repository identity、worktree ownership 任一无法证明时返回 `manual-intervention`,不猜测;
|
|
55
|
+
- 若同一 managed branch 已出现另一个 worktree candidate,拒绝自动恢复;
|
|
56
|
+
- recovery backing 创建后必须重新验证 branch、HEAD、Git registration 和工作树健康状态,验证失败时仅回滚 ForgeRelay 本次创建的临时 backing;
|
|
57
|
+
- repair 不 merge、不 rebase、不 reset、不删除 managed branch,也不移动 target branch。
|
|
58
|
+
|
|
59
|
+
`workspace.recovery { operation: "cleanup" }` 只清理当前 persistent Workspace 能证明归属的 Git administrative residue:
|
|
60
|
+
|
|
61
|
+
- backing 已不存在且 stale registration 仍精确对应持久化 managed branch 时,可只移除该 registration;不执行 repository-wide `git worktree prune`;
|
|
62
|
+
- active Workspace 的 managed branch 始终保留,即使 stale registration 已被清掉,避免 cleanup 把仍需恢复的 Workspace 变成不可恢复状态;
|
|
63
|
+
- closed Workspace 的残留 `forgerelay/*` branch 只有在没有任何 worktree registration 或其他 persistent Workspace identity 持有它、source checkout 仍位于持久化 target branch、且 Git 证明该 branch 已完整并入 target 时才允许删除;
|
|
64
|
+
- unique/unmerged commits、branch ownership mismatch、active backing、缺失 target、source/target identity 不可证明等情况返回 `manual-intervention`,不强制删除;
|
|
65
|
+
- cleanup 返回 `cleaned`、`nothing-to-clean` 或 `manual-intervention` 分类;对 active Workspace 成功清理 registration 后同时返回更新后的 recovery projection;
|
|
66
|
+
- cleanup 不 reopen、close、delete Workspace,也不改变 persistent Workspace lifecycle state。
|
|
67
|
+
|
|
68
|
+
Relay Workspace 的 recovery 在 Execution ForgeRelay 上执行;Composite Workspace 必须显式指定实际拥有该 worktree 的 member。不要在 Gateway 或 Composite identity 上自行解释/修改成员 Git 状态。
|
|
69
|
+
|
|
70
|
+
如果需要继续工作,复用原 `workspaceId`;不要因为 backing 丢失、一次关闭失败或一次恢复失败就创建新的隔离副本来绕过原状态。
|
|
@@ -190,6 +190,11 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
190
190
|
taskId: z.string().min(1),
|
|
191
191
|
}).strict(),
|
|
192
192
|
]);
|
|
193
|
+
const workspaceRecoveryInput = z.discriminatedUnion("operation", [
|
|
194
|
+
z.object({ operation: z.literal("status") }).strict(),
|
|
195
|
+
z.object({ operation: z.literal("repair") }).strict(),
|
|
196
|
+
z.object({ operation: z.literal("cleanup") }).strict(),
|
|
197
|
+
]);
|
|
193
198
|
const subagentSessionInput = z.discriminatedUnion("operation", [
|
|
194
199
|
z.object({
|
|
195
200
|
operation: z.literal("start"),
|
|
@@ -287,6 +292,18 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
287
292
|
run: async (input, context, options) => dependencies.codeIntelligence.run(input, context, options),
|
|
288
293
|
}]
|
|
289
294
|
: []),
|
|
295
|
+
...(dependencies.workspaceRecovery
|
|
296
|
+
? [{
|
|
297
|
+
name: "workspace.recovery",
|
|
298
|
+
description: "Inspect or safely repair the current managed-worktree Workspace when ForgeRelay can prove the surviving managed branch and execution backing ownership.",
|
|
299
|
+
guideName: "managed-worktrees",
|
|
300
|
+
readGuideBeforeFirstUse: true,
|
|
301
|
+
batchPolicy: "unsupported",
|
|
302
|
+
inputSchema: workspaceRecoveryInput,
|
|
303
|
+
availability: (context) => managedWorktreeAvailability(context, dependencies.workspaceRecovery?.available ?? false, dependencies.workspaceRecovery?.unavailableReason),
|
|
304
|
+
run: async (input, context, options) => dependencies.workspaceRecovery.run(input, context, options),
|
|
305
|
+
}]
|
|
306
|
+
: []),
|
|
290
307
|
...(dependencies.workspaceTasks
|
|
291
308
|
? [{
|
|
292
309
|
name: "workspace.tasks",
|
|
@@ -360,6 +377,17 @@ function filesystemWorkspaceAvailability(context, available = true, reason) {
|
|
|
360
377
|
}
|
|
361
378
|
return { available, ...(reason ? { reason } : {}) };
|
|
362
379
|
}
|
|
380
|
+
function managedWorktreeAvailability(context, available = true, reason) {
|
|
381
|
+
if (context.workspaceKind !== "workspace" ||
|
|
382
|
+
context.workspaceMode !== "worktree" ||
|
|
383
|
+
context.workspaceManaged !== true) {
|
|
384
|
+
return {
|
|
385
|
+
available: false,
|
|
386
|
+
reason: "This capability is available only for a ForgeRelay-managed worktree Workspace.",
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
return { available, ...(reason ? { reason } : {}) };
|
|
390
|
+
}
|
|
363
391
|
function requireWorkspaceRoot(context) {
|
|
364
392
|
if (context.workspaceKind !== "workspace" || !context.workspaceRoot) {
|
|
365
393
|
throw new CapabilityError("capability_unavailable", "This capability requires a filesystem-backed Workspace.");
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { loadCapabilityGuides } from "./capabilities.js";
|
|
1
2
|
import { CapabilityError } from "./capability-registry.js";
|
|
2
3
|
import { formatPathForPrompt } from "../../../workspaces/resources/skills.js";
|
|
3
4
|
export function workspaceHookInvocation(workspace) {
|
|
@@ -13,6 +14,8 @@ export function capabilityContextFor(workspace) {
|
|
|
13
14
|
workspaceId: workspace.id,
|
|
14
15
|
workspaceKind: "workspace",
|
|
15
16
|
workspaceRoot: workspace.root,
|
|
17
|
+
workspaceMode: workspace.mode,
|
|
18
|
+
workspaceManaged: workspace.worktree?.managed ?? false,
|
|
16
19
|
guides: workspace.capabilityGuides.map((guide) => ({
|
|
17
20
|
name: guide.name,
|
|
18
21
|
description: guide.description,
|
|
@@ -21,6 +24,20 @@ export function capabilityContextFor(workspace) {
|
|
|
21
24
|
})),
|
|
22
25
|
};
|
|
23
26
|
}
|
|
27
|
+
export function managedWorktreeRecoveryCapabilityContext(session, config) {
|
|
28
|
+
return {
|
|
29
|
+
workspaceId: session.id,
|
|
30
|
+
workspaceKind: "workspace",
|
|
31
|
+
workspaceMode: session.mode,
|
|
32
|
+
workspaceManaged: session.managed,
|
|
33
|
+
guides: loadCapabilityGuides(config).map((guide) => ({
|
|
34
|
+
name: guide.name,
|
|
35
|
+
description: guide.description,
|
|
36
|
+
whenToRead: guide.whenToRead,
|
|
37
|
+
path: formatPathForPrompt(guide.filePath),
|
|
38
|
+
})),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
24
41
|
export function compositeCapabilityContext(workspaceId, guides) {
|
|
25
42
|
return {
|
|
26
43
|
workspaceId,
|
|
@@ -94,6 +94,26 @@ export const workspaceSubagentProviderOutputSchema = z.object({
|
|
|
94
94
|
export const workspaceAvailableAgentsFileOutputSchema = z.object({
|
|
95
95
|
path: z.string(),
|
|
96
96
|
});
|
|
97
|
+
export const managedWorktreeRecoveryOutputSchema = z.object({
|
|
98
|
+
classification: z.enum(["healthy", "recoverable", "manual-intervention"]),
|
|
99
|
+
conditions: z.array(z.enum([
|
|
100
|
+
"backing-missing",
|
|
101
|
+
"managed-branch-missing",
|
|
102
|
+
"git-registration-stale",
|
|
103
|
+
"git-registration-missing",
|
|
104
|
+
"git-registration-unavailable",
|
|
105
|
+
"branch-mismatch",
|
|
106
|
+
"source-missing",
|
|
107
|
+
"source-unavailable",
|
|
108
|
+
"target-branch-missing",
|
|
109
|
+
])),
|
|
110
|
+
backing: z.enum(["present", "missing"]),
|
|
111
|
+
source: z.enum(["available", "missing", "unavailable"]),
|
|
112
|
+
gitRegistration: z.enum(["registered", "stale", "missing", "unavailable"]),
|
|
113
|
+
managedBranch: z.enum(["present", "missing", "unknown"]),
|
|
114
|
+
targetBranch: z.enum(["present", "missing", "unknown"]),
|
|
115
|
+
backingBranch: z.enum(["matching", "mismatched", "unavailable"]),
|
|
116
|
+
});
|
|
97
117
|
export const workspaceInventoryEntryOutputSchema = z.object({
|
|
98
118
|
label: z.string(),
|
|
99
119
|
workspaceId: z.string(),
|
|
@@ -109,6 +129,7 @@ export const workspaceInventoryEntryOutputSchema = z.object({
|
|
|
109
129
|
lastUsedAt: z.string(),
|
|
110
130
|
idleMs: z.number().nonnegative(),
|
|
111
131
|
rootValid: z.boolean(),
|
|
132
|
+
recovery: managedWorktreeRecoveryOutputSchema.optional(),
|
|
112
133
|
current: z.boolean(),
|
|
113
134
|
});
|
|
114
135
|
export const workspaceInventorySummaryOutputSchema = z.object({
|
|
@@ -167,6 +188,7 @@ export const workspaceInspectionOutputSchema = z.union([
|
|
|
167
188
|
lastUsedAt: z.string(),
|
|
168
189
|
idleMs: z.number().nonnegative(),
|
|
169
190
|
rootValid: z.boolean(),
|
|
191
|
+
recovery: managedWorktreeRecoveryOutputSchema.optional(),
|
|
170
192
|
taskSummary: workspaceTaskInspectionSummaryOutputSchema.optional(),
|
|
171
193
|
}),
|
|
172
194
|
z.object({
|
|
@@ -186,6 +208,7 @@ export const workspaceInspectionOutputSchema = z.union([
|
|
|
186
208
|
lastUsedAt: z.string().optional(),
|
|
187
209
|
idleMs: z.number().nonnegative().optional(),
|
|
188
210
|
rootValid: z.boolean().optional(),
|
|
211
|
+
recovery: managedWorktreeRecoveryOutputSchema.optional(),
|
|
189
212
|
taskSummary: workspaceTaskInspectionSummaryOutputSchema.optional(),
|
|
190
213
|
relay: z.string(),
|
|
191
214
|
executionLocation: z.string(),
|
|
@@ -10,7 +10,7 @@ import { formatAgentsPath } from "../../../../workspaces.js";
|
|
|
10
10
|
import { markReturnedOutput, processActivityOutcome, processToolResponse, } from "../../../process/runtime.js";
|
|
11
11
|
import { capabilityActivityAuditRequest, capabilityActivityAuditResult } from "../../../../subagents/sessions/mcp/audit.js";
|
|
12
12
|
import { activityRelationFor, activityRequestFor, runActivityTool, runActivityToolWithHooks, standardActivityOutcome, } from "../../core/activity-support.js";
|
|
13
|
-
import { capabilityContextFor, workspaceHookInvocation } from "../../core/capability-support.js";
|
|
13
|
+
import { capabilityContextFor, managedWorktreeRecoveryCapabilityContext, workspaceHookInvocation, } from "../../core/capability-support.js";
|
|
14
14
|
import { assertWorkspaceInstructionsLoadedBeforeSideEffect, contentLineCount, contentText, countDiffStats, formatDiscoveredWorkspaceInstructions, logFailedToolResponse, logToolCall, newFilePatch, textBlock, textSummary, toolResultContent, toolResultIsError, toolResultText, workspaceLogContext, } from "../../core/tool-support.js";
|
|
15
15
|
import { appendAutomaticMutationDiagnostics } from "./mutation-diagnostics.js";
|
|
16
16
|
export function createOperationRuntime(options) {
|
|
@@ -429,6 +429,58 @@ export function createOperationRuntime(options) {
|
|
|
429
429
|
},
|
|
430
430
|
capabilityRun: async (input, context) => {
|
|
431
431
|
const { workspaceId, name, arguments: capabilityArguments, file } = input;
|
|
432
|
+
if (name === "workspace.recovery") {
|
|
433
|
+
const session = workspaces.getWorkspaceSession(workspaceId);
|
|
434
|
+
if (session.status === "closed") {
|
|
435
|
+
const startedAt = performance.now();
|
|
436
|
+
try {
|
|
437
|
+
const execution = await capabilityRegistry.run(name, capabilityArguments ?? {}, managedWorktreeRecoveryCapabilityContext(session, config), {
|
|
438
|
+
nativeFile: file,
|
|
439
|
+
signal: context.signal,
|
|
440
|
+
requestMeta: context.requestMeta,
|
|
441
|
+
sessionId: context.sessionId,
|
|
442
|
+
batch: context.batch,
|
|
443
|
+
});
|
|
444
|
+
const result = {
|
|
445
|
+
content: [textBlock(`Capability ${name} completed.\n${JSON.stringify(execution.value, null, 2)}`)],
|
|
446
|
+
structuredContent: { name, action: "run", result: execution.value },
|
|
447
|
+
};
|
|
448
|
+
logToolCall(config, {
|
|
449
|
+
tool: toolNames.capability,
|
|
450
|
+
workspaceId: session.id,
|
|
451
|
+
capability: name,
|
|
452
|
+
action: "run",
|
|
453
|
+
success: true,
|
|
454
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
455
|
+
});
|
|
456
|
+
return result;
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
const capabilityError = error instanceof CapabilityError
|
|
460
|
+
? error
|
|
461
|
+
: new CapabilityError("execution_failed", error instanceof Error ? error.message : String(error));
|
|
462
|
+
const result = {
|
|
463
|
+
content: [textBlock(`${capabilityError.code}: ${capabilityError.message}`)],
|
|
464
|
+
structuredContent: {
|
|
465
|
+
name,
|
|
466
|
+
action: "run",
|
|
467
|
+
error: { code: capabilityError.code, message: capabilityError.message },
|
|
468
|
+
},
|
|
469
|
+
isError: true,
|
|
470
|
+
};
|
|
471
|
+
logToolCall(config, {
|
|
472
|
+
tool: toolNames.capability,
|
|
473
|
+
workspaceId: session.id,
|
|
474
|
+
capability: name,
|
|
475
|
+
action: "run",
|
|
476
|
+
success: false,
|
|
477
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
478
|
+
error: capabilityError.message,
|
|
479
|
+
});
|
|
480
|
+
return result;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
432
484
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
433
485
|
let changedPaths = [];
|
|
434
486
|
return runActivityTool(activityLifecycle, workspace, hostScopeIdFor(context.requestMeta, context.sessionId), toolNames.capability, activityRequestFor(capabilityActivityAuditRequest(input), context), (activityContext) => runToolWithHooks(hooks, {
|
|
@@ -438,6 +490,7 @@ export function createOperationRuntime(options) {
|
|
|
438
490
|
payload: { name, action: "run" },
|
|
439
491
|
isFailure: toolResultIsError,
|
|
440
492
|
changedPaths: () => changedPaths,
|
|
493
|
+
afterCwd: () => workspaces.getWorkspaceSession(workspaceId).root,
|
|
441
494
|
operation: async () => {
|
|
442
495
|
const startedAt = performance.now();
|
|
443
496
|
try {
|
package/dist/server.js
CHANGED
|
@@ -131,6 +131,12 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
131
131
|
const capabilityRegistry = createCapabilityRegistry({
|
|
132
132
|
inspectHooks: (workspaceRoot) => checkHookConfiguration(workspaceRoot, config.hooks),
|
|
133
133
|
...subagentMcp.registryDependencies,
|
|
134
|
+
workspaceRecovery: {
|
|
135
|
+
available: true,
|
|
136
|
+
run: async (input, context) => ({
|
|
137
|
+
value: await workspaces.runManagedWorktreeRecovery(context.workspaceId, input.operation),
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
134
140
|
workspaceTasks: {
|
|
135
141
|
available: true,
|
|
136
142
|
run: async (input, context) => {
|
|
@@ -228,7 +228,7 @@ async function isAncestor(ancestor, descendant, cwd) {
|
|
|
228
228
|
return false;
|
|
229
229
|
}
|
|
230
230
|
}
|
|
231
|
-
function managedWorktreePath(input) {
|
|
231
|
+
export function managedWorktreePath(input) {
|
|
232
232
|
const repoName = sanitizePathSegment(basename(input.repoRoot)) || "repo";
|
|
233
233
|
return join(input.worktreeRoot, `${repoName}-${input.worktreeId}`);
|
|
234
234
|
}
|
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, realpath, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { platform } from "node:os";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { assertAllowedPath } from "../../mcp/filesystem/roots.js";
|
|
6
|
+
import { managedWorktreePath } from "./git-worktrees.js";
|
|
7
|
+
import { git } from "./git.js";
|
|
8
|
+
export async function inspectManagedWorktreeRecovery(session, config) {
|
|
9
|
+
if (session.status !== "active" ||
|
|
10
|
+
session.mode !== "worktree" ||
|
|
11
|
+
!session.managed)
|
|
12
|
+
return undefined;
|
|
13
|
+
const backing = await directoryState(session.root, [config.worktreeRoot]);
|
|
14
|
+
const source = await sourceState(session.sourceRoot, config.allowedRoots);
|
|
15
|
+
const conditions = [];
|
|
16
|
+
if (source === "missing")
|
|
17
|
+
conditions.push("source-missing");
|
|
18
|
+
else if (source === "unavailable")
|
|
19
|
+
conditions.push("source-unavailable");
|
|
20
|
+
if (backing === "missing")
|
|
21
|
+
conditions.push("backing-missing");
|
|
22
|
+
if (source !== "available" || !session.sourceRoot) {
|
|
23
|
+
return {
|
|
24
|
+
classification: "manual-intervention",
|
|
25
|
+
conditions,
|
|
26
|
+
backing,
|
|
27
|
+
source,
|
|
28
|
+
gitRegistration: "unavailable",
|
|
29
|
+
managedBranch: "unknown",
|
|
30
|
+
targetBranch: "unknown",
|
|
31
|
+
backingBranch: "unavailable",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const sourceRoot = assertAllowedPath(session.sourceRoot, config.allowedRoots);
|
|
35
|
+
const [managedBranch, targetBranch, registration] = await Promise.all([
|
|
36
|
+
branchState(sourceRoot, session.branch),
|
|
37
|
+
branchState(sourceRoot, session.targetBranch),
|
|
38
|
+
registrationState(sourceRoot, session.root),
|
|
39
|
+
]);
|
|
40
|
+
if (managedBranch === "missing")
|
|
41
|
+
conditions.push("managed-branch-missing");
|
|
42
|
+
if (targetBranch === "missing")
|
|
43
|
+
conditions.push("target-branch-missing");
|
|
44
|
+
if (registration === "stale")
|
|
45
|
+
conditions.push("git-registration-stale");
|
|
46
|
+
else if (registration === "missing")
|
|
47
|
+
conditions.push("git-registration-missing");
|
|
48
|
+
else if (registration === "unavailable")
|
|
49
|
+
conditions.push("git-registration-unavailable");
|
|
50
|
+
const backingBranch = backing === "present"
|
|
51
|
+
? await backingBranchState(session.root, session.branch)
|
|
52
|
+
: "unavailable";
|
|
53
|
+
if (backingBranch === "mismatched")
|
|
54
|
+
conditions.push("branch-mismatch");
|
|
55
|
+
const recoverable = backing === "missing" &&
|
|
56
|
+
managedBranch === "present" &&
|
|
57
|
+
targetBranch === "present" &&
|
|
58
|
+
(registration === "stale" || registration === "missing") &&
|
|
59
|
+
conditions.every((condition) => condition === "backing-missing" ||
|
|
60
|
+
condition === "git-registration-stale" ||
|
|
61
|
+
condition === "git-registration-missing");
|
|
62
|
+
return {
|
|
63
|
+
classification: conditions.length === 0
|
|
64
|
+
? "healthy"
|
|
65
|
+
: recoverable
|
|
66
|
+
? "recoverable"
|
|
67
|
+
: "manual-intervention",
|
|
68
|
+
conditions,
|
|
69
|
+
backing,
|
|
70
|
+
source,
|
|
71
|
+
gitRegistration: registration,
|
|
72
|
+
managedBranch,
|
|
73
|
+
targetBranch,
|
|
74
|
+
backingBranch,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export async function prepareManagedWorktreeRepair(session, config) {
|
|
78
|
+
const recovery = await inspectManagedWorktreeRecovery(session, config);
|
|
79
|
+
if (!recovery) {
|
|
80
|
+
throw new Error(`Workspace ${session.id} is not an active managed-worktree Workspace.`);
|
|
81
|
+
}
|
|
82
|
+
if (recovery.classification !== "recoverable") {
|
|
83
|
+
return {
|
|
84
|
+
prepared: false,
|
|
85
|
+
recovery,
|
|
86
|
+
reason: "The Workspace is not in a provably recoverable managed-worktree state.",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (!session.sourceRoot ||
|
|
90
|
+
!session.branch ||
|
|
91
|
+
!session.targetBranch ||
|
|
92
|
+
!session.baseRef ||
|
|
93
|
+
!session.baseSha ||
|
|
94
|
+
!session.branch.startsWith("forgerelay/")) {
|
|
95
|
+
return {
|
|
96
|
+
prepared: false,
|
|
97
|
+
recovery: manualRecovery(recovery),
|
|
98
|
+
reason: "Persisted managed-worktree ownership metadata is incomplete or cannot prove ForgeRelay branch ownership.",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const sourceRoot = assertAllowedPath(session.sourceRoot, config.allowedRoots);
|
|
102
|
+
const head = (await git(sourceRoot, ["rev-parse", `refs/heads/${session.branch}`])).stdout.trim();
|
|
103
|
+
const beforeRegistrations = await worktreeRegistrations(sourceRoot);
|
|
104
|
+
const persistedRegistrations = await matchingPathRegistrations(beforeRegistrations, session.root);
|
|
105
|
+
if (recovery.gitRegistration === "stale" &&
|
|
106
|
+
(persistedRegistrations.length !== 1 ||
|
|
107
|
+
persistedRegistrations[0]?.prunable !== true ||
|
|
108
|
+
persistedRegistrations[0]?.branch !== `refs/heads/${session.branch}` ||
|
|
109
|
+
(persistedRegistrations[0]?.head !== undefined && persistedRegistrations[0].head !== head))) {
|
|
110
|
+
return {
|
|
111
|
+
prepared: false,
|
|
112
|
+
recovery: manualRecovery(recovery),
|
|
113
|
+
reason: "The stale Git worktree registration does not prove ownership of the persisted ForgeRelay managed branch.",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const beforeConflicts = await conflictingBranchRegistrations(beforeRegistrations, session.branch, session.root);
|
|
117
|
+
if (beforeConflicts.length > 0) {
|
|
118
|
+
return {
|
|
119
|
+
prepared: false,
|
|
120
|
+
recovery: manualRecovery(recovery),
|
|
121
|
+
reason: `Managed branch ${session.branch} is already associated with another worktree candidate.`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const root = await allocateManagedWorktreeRecoveryPath(sourceRoot, config);
|
|
125
|
+
try {
|
|
126
|
+
await git(sourceRoot, ["worktree", "add", "--force", root, session.branch]);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
await rm(root, { recursive: true, force: true });
|
|
130
|
+
const afterFailureConflicts = await conflictingBranchRegistrations(await worktreeRegistrations(sourceRoot), session.branch, session.root);
|
|
131
|
+
if (afterFailureConflicts.length > 0) {
|
|
132
|
+
return {
|
|
133
|
+
prepared: false,
|
|
134
|
+
recovery: manualRecovery(recovery),
|
|
135
|
+
reason: `Managed branch ${session.branch} acquired another worktree candidate while repair was starting.`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`Git could not recreate managed-worktree backing from ${session.branch}: ${error instanceof Error ? error.message : String(error)}`);
|
|
139
|
+
}
|
|
140
|
+
const preparation = {
|
|
141
|
+
prepared: true,
|
|
142
|
+
previousRoot: session.root,
|
|
143
|
+
root,
|
|
144
|
+
sourceRoot,
|
|
145
|
+
branch: session.branch,
|
|
146
|
+
targetBranch: session.targetBranch,
|
|
147
|
+
baseRef: session.baseRef,
|
|
148
|
+
baseSha: session.baseSha,
|
|
149
|
+
head,
|
|
150
|
+
recovery,
|
|
151
|
+
};
|
|
152
|
+
try {
|
|
153
|
+
const afterRegistrations = await worktreeRegistrations(sourceRoot);
|
|
154
|
+
const afterConflicts = await conflictingBranchRegistrations(afterRegistrations, session.branch, session.root, root);
|
|
155
|
+
if (afterConflicts.length > 0) {
|
|
156
|
+
await rollbackManagedWorktreeRepair(preparation, config);
|
|
157
|
+
return {
|
|
158
|
+
prepared: false,
|
|
159
|
+
recovery: manualRecovery(recovery),
|
|
160
|
+
reason: `Managed branch ${session.branch} has ambiguous worktree ownership after repair preflight.`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const rootKey = await registrationPathKey(root);
|
|
164
|
+
const repairedRegistration = await firstMatchingRegistration(afterRegistrations, `refs/heads/${session.branch}`, rootKey);
|
|
165
|
+
if (!repairedRegistration || repairedRegistration.prunable) {
|
|
166
|
+
throw new Error("Git did not register the newly created recovery backing as an active worktree.");
|
|
167
|
+
}
|
|
168
|
+
const actualBranch = (await git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).stdout.trim();
|
|
169
|
+
if (actualBranch !== session.branch) {
|
|
170
|
+
throw new Error(`Recovery backing opened branch ${actualBranch} instead of ${session.branch}.`);
|
|
171
|
+
}
|
|
172
|
+
const repairedHead = (await git(root, ["rev-parse", "HEAD"])).stdout.trim();
|
|
173
|
+
const managedHead = (await git(sourceRoot, ["rev-parse", `refs/heads/${session.branch}`])).stdout.trim();
|
|
174
|
+
if (repairedHead !== managedHead) {
|
|
175
|
+
throw new Error("Recovery backing HEAD does not match the surviving managed branch.");
|
|
176
|
+
}
|
|
177
|
+
if ((await git(root, ["status", "--porcelain=v1"])).stdout.trim().length > 0) {
|
|
178
|
+
throw new Error("Recovery backing is not clean immediately after reconstruction.");
|
|
179
|
+
}
|
|
180
|
+
const candidateRecovery = await inspectManagedWorktreeRecovery({ ...session, root }, config);
|
|
181
|
+
if (!candidateRecovery || candidateRecovery.classification !== "healthy") {
|
|
182
|
+
await rollbackManagedWorktreeRepair({ ...preparation, head: repairedHead }, config);
|
|
183
|
+
return {
|
|
184
|
+
prepared: false,
|
|
185
|
+
recovery: candidateRecovery ? manualRecovery(candidateRecovery) : manualRecovery(recovery),
|
|
186
|
+
reason: "The reconstructed backing did not pass the managed-worktree recovery health check.",
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
return { ...preparation, head: repairedHead, recovery: candidateRecovery };
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
try {
|
|
193
|
+
await rollbackManagedWorktreeRepair(preparation, config);
|
|
194
|
+
}
|
|
195
|
+
catch (rollbackError) {
|
|
196
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)} Recovery rollback also failed; the temporary backing was preserved for inspection: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
|
|
197
|
+
}
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
export async function rollbackManagedWorktreeRepair(preparation, config) {
|
|
202
|
+
const sourceRoot = assertAllowedPath(preparation.sourceRoot, config.allowedRoots);
|
|
203
|
+
const root = assertAllowedPath(preparation.root, [config.worktreeRoot]);
|
|
204
|
+
const actualBranch = (await git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).stdout.trim();
|
|
205
|
+
if (actualBranch !== preparation.branch) {
|
|
206
|
+
throw new Error(`Cannot roll back recovery backing because it is on ${actualBranch} instead of ${preparation.branch}.`);
|
|
207
|
+
}
|
|
208
|
+
if ((await git(root, ["status", "--porcelain=v1"])).stdout.trim().length > 0) {
|
|
209
|
+
throw new Error("Cannot roll back recovery backing because it acquired working-tree changes.");
|
|
210
|
+
}
|
|
211
|
+
const head = (await git(root, ["rev-parse", "HEAD"])).stdout.trim();
|
|
212
|
+
if (head !== preparation.head) {
|
|
213
|
+
throw new Error("Cannot roll back recovery backing because its managed branch advanced after reconstruction.");
|
|
214
|
+
}
|
|
215
|
+
await git(sourceRoot, ["worktree", "remove", root]);
|
|
216
|
+
}
|
|
217
|
+
export async function cleanupManagedWorktreeState(session, config, options = {}) {
|
|
218
|
+
if (session.mode !== "worktree" || !session.managed || (session.status !== "active" && session.status !== "closed")) {
|
|
219
|
+
throw new Error(`Workspace ${session.id} is not an active or closed ForgeRelay-managed worktree Workspace.`);
|
|
220
|
+
}
|
|
221
|
+
let recoveryBefore;
|
|
222
|
+
try {
|
|
223
|
+
recoveryBefore = await inspectManagedWorktreeRecovery(session, config);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
recoveryBefore = undefined;
|
|
227
|
+
}
|
|
228
|
+
if (!session.sourceRoot || !session.branch || !session.targetBranch || !session.branch.startsWith("forgerelay/")) {
|
|
229
|
+
return cleanupResult(session, recoveryBefore, false, false, "manual-intervention", "Persisted managed-worktree ownership metadata is incomplete or cannot prove ForgeRelay branch ownership.");
|
|
230
|
+
}
|
|
231
|
+
let backing;
|
|
232
|
+
try {
|
|
233
|
+
backing = await directoryState(session.root, [config.worktreeRoot]);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return cleanupResult(session, recoveryBefore, false, false, "manual-intervention", "The persisted managed-worktree backing path is outside the trusted ForgeRelay worktree root or otherwise unavailable.");
|
|
237
|
+
}
|
|
238
|
+
if (backing === "present") {
|
|
239
|
+
return cleanupResult(session, recoveryBefore, false, false, "nothing-to-clean", "The managed worktree backing is still present; cleanup will not mutate an active or residual physical backing.");
|
|
240
|
+
}
|
|
241
|
+
const source = await sourceState(session.sourceRoot, config.allowedRoots);
|
|
242
|
+
if (source !== "available") {
|
|
243
|
+
return cleanupResult(session, recoveryBefore, false, false, "manual-intervention", "The persisted source repository is unavailable, so cleanup cannot prove Git ownership safely.");
|
|
244
|
+
}
|
|
245
|
+
const sourceRoot = assertAllowedPath(session.sourceRoot, config.allowedRoots);
|
|
246
|
+
const branchRef = `refs/heads/${session.branch}`;
|
|
247
|
+
let registrations = await worktreeRegistrations(sourceRoot);
|
|
248
|
+
const persistedRegistrations = await matchingPathRegistrations(registrations, session.root);
|
|
249
|
+
if (persistedRegistrations.length > 1) {
|
|
250
|
+
return cleanupResult(session, recoveryBefore, false, false, "manual-intervention", "Multiple Git worktree registrations match the persisted backing; cleanup requires manual intervention.");
|
|
251
|
+
}
|
|
252
|
+
let registrationRemoved = false;
|
|
253
|
+
const persistedRegistration = persistedRegistrations[0];
|
|
254
|
+
if (persistedRegistration) {
|
|
255
|
+
if (!persistedRegistration.prunable) {
|
|
256
|
+
return cleanupResult(session, recoveryBefore, false, false, "manual-intervention", "The persisted backing still has an active Git worktree registration; cleanup will not remove it.");
|
|
257
|
+
}
|
|
258
|
+
if (persistedRegistration.branch !== branchRef) {
|
|
259
|
+
return cleanupResult(session, recoveryBefore, false, false, "manual-intervention", "The stale Git worktree registration no longer proves ownership of the persisted ForgeRelay managed branch.");
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
await git(sourceRoot, ["worktree", "remove", persistedRegistration.path]);
|
|
263
|
+
registrationRemoved = true;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return cleanupResult(session, recoveryBefore, false, false, "manual-intervention", "Git did not confirm safe removal of the stale worktree registration; no cleanup mutation was forced.");
|
|
267
|
+
}
|
|
268
|
+
registrations = await worktreeRegistrations(sourceRoot);
|
|
269
|
+
}
|
|
270
|
+
if (session.status === "active") {
|
|
271
|
+
const recovery = await inspectManagedWorktreeRecovery(session, config);
|
|
272
|
+
return cleanupResult(session, recovery, registrationRemoved, false, registrationRemoved ? "cleaned" : "nothing-to-clean", registrationRemoved
|
|
273
|
+
? "Stale Git worktree registration removed; the managed branch is preserved because the Workspace remains active."
|
|
274
|
+
: "No stale registration is safe to remove; the managed branch is preserved because the Workspace remains active.");
|
|
275
|
+
}
|
|
276
|
+
const managedBranch = await branchState(sourceRoot, session.branch);
|
|
277
|
+
if (managedBranch === "missing") {
|
|
278
|
+
return cleanupResult(session, undefined, registrationRemoved, false, registrationRemoved ? "cleaned" : "nothing-to-clean", registrationRemoved ? undefined : "No stale registration or managed branch remains to clean.");
|
|
279
|
+
}
|
|
280
|
+
if (registrations.some((registration) => registration.branch === branchRef)) {
|
|
281
|
+
return cleanupResult(session, undefined, registrationRemoved, false, "manual-intervention", "The managed branch is still associated with a Git worktree registration and was preserved.");
|
|
282
|
+
}
|
|
283
|
+
if (options.managedBranchOwnedByOtherWorkspace) {
|
|
284
|
+
return cleanupResult(session, undefined, registrationRemoved, false, "manual-intervention", "Another persistent Workspace still references the managed branch, so it was preserved.");
|
|
285
|
+
}
|
|
286
|
+
if (await branchState(sourceRoot, session.targetBranch) !== "present") {
|
|
287
|
+
return cleanupResult(session, undefined, registrationRemoved, false, "manual-intervention", "The intended target branch is missing, so the managed branch was preserved.");
|
|
288
|
+
}
|
|
289
|
+
const sourceBranch = await currentBranchName(sourceRoot);
|
|
290
|
+
if (sourceBranch !== session.targetBranch) {
|
|
291
|
+
return cleanupResult(session, undefined, registrationRemoved, false, "manual-intervention", "The source checkout is not on the persisted target branch, so the managed branch was preserved.");
|
|
292
|
+
}
|
|
293
|
+
if (!await branchIsAncestorOfTarget(sourceRoot, session.branch, session.targetBranch)) {
|
|
294
|
+
return cleanupResult(session, undefined, registrationRemoved, false, "manual-intervention", "The managed branch contains commits not integrated into the intended target branch and was preserved.");
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
await git(sourceRoot, ["branch", "-d", session.branch]);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return cleanupResult(session, undefined, registrationRemoved, false, "manual-intervention", "Git did not confirm safe managed-branch deletion at cleanup time; the branch was preserved.");
|
|
301
|
+
}
|
|
302
|
+
return cleanupResult(session, undefined, registrationRemoved, true, "cleaned");
|
|
303
|
+
}
|
|
304
|
+
async function directoryState(path, allowedRoots) {
|
|
305
|
+
const allowedPath = assertAllowedPath(path, allowedRoots);
|
|
306
|
+
try {
|
|
307
|
+
return (await stat(allowedPath)).isDirectory() ? "present" : "missing";
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
if (isMissingPath(error))
|
|
311
|
+
return "missing";
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
async function sourceState(sourceRoot, allowedRoots) {
|
|
316
|
+
if (!sourceRoot)
|
|
317
|
+
return "unavailable";
|
|
318
|
+
let sourcePath;
|
|
319
|
+
try {
|
|
320
|
+
sourcePath = assertAllowedPath(sourceRoot, allowedRoots);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return "unavailable";
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
if (!(await stat(sourcePath)).isDirectory())
|
|
327
|
+
return "missing";
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
return isMissingPath(error) ? "missing" : "unavailable";
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
const gitRoot = (await git(sourcePath, ["rev-parse", "--show-toplevel"])).stdout.trim();
|
|
334
|
+
const [canonicalGitRoot, canonicalSource] = await Promise.all([realpath(gitRoot), realpath(sourcePath)]);
|
|
335
|
+
return pathKey(canonicalGitRoot) === pathKey(canonicalSource) ? "available" : "unavailable";
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return "unavailable";
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async function branchState(sourceRoot, branch) {
|
|
342
|
+
if (!branch)
|
|
343
|
+
return "missing";
|
|
344
|
+
try {
|
|
345
|
+
await git(sourceRoot, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
346
|
+
return "present";
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return "missing";
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async function registrationState(sourceRoot, worktreePath) {
|
|
353
|
+
let registrations;
|
|
354
|
+
try {
|
|
355
|
+
registrations = parseWorktreeRegistrations((await git(sourceRoot, ["worktree", "list", "--porcelain"])).stdout);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return "unavailable";
|
|
359
|
+
}
|
|
360
|
+
const worktreeKey = await registrationPathKey(worktreePath);
|
|
361
|
+
for (const registration of registrations) {
|
|
362
|
+
if (await registrationPathKey(registration.path) !== worktreeKey)
|
|
363
|
+
continue;
|
|
364
|
+
return registration.prunable ? "stale" : "registered";
|
|
365
|
+
}
|
|
366
|
+
return "missing";
|
|
367
|
+
}
|
|
368
|
+
async function backingBranchState(worktreeRoot, expectedBranch) {
|
|
369
|
+
if (!expectedBranch)
|
|
370
|
+
return "mismatched";
|
|
371
|
+
try {
|
|
372
|
+
const actual = (await git(worktreeRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"])).stdout.trim();
|
|
373
|
+
return actual === expectedBranch ? "matching" : "mismatched";
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return "mismatched";
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
function parseWorktreeRegistrations(output) {
|
|
380
|
+
return output
|
|
381
|
+
.trim()
|
|
382
|
+
.split(/\n\n+/)
|
|
383
|
+
.map((block) => {
|
|
384
|
+
const lines = block.split("\n");
|
|
385
|
+
const path = lines.find((line) => line.startsWith("worktree "))?.slice("worktree ".length);
|
|
386
|
+
if (!path)
|
|
387
|
+
return undefined;
|
|
388
|
+
const branch = lines.find((line) => line.startsWith("branch "))?.slice("branch ".length);
|
|
389
|
+
const head = lines.find((line) => line.startsWith("HEAD "))?.slice("HEAD ".length);
|
|
390
|
+
return {
|
|
391
|
+
path,
|
|
392
|
+
prunable: lines.some((line) => line.startsWith("prunable ")),
|
|
393
|
+
...(branch ? { branch } : {}),
|
|
394
|
+
...(head ? { head } : {}),
|
|
395
|
+
};
|
|
396
|
+
})
|
|
397
|
+
.filter((entry) => entry !== undefined);
|
|
398
|
+
}
|
|
399
|
+
async function worktreeRegistrations(sourceRoot) {
|
|
400
|
+
return parseWorktreeRegistrations((await git(sourceRoot, ["worktree", "list", "--porcelain"])).stdout);
|
|
401
|
+
}
|
|
402
|
+
async function conflictingBranchRegistrations(registrations, branch, previousRoot, repairedRoot) {
|
|
403
|
+
const branchRef = `refs/heads/${branch}`;
|
|
404
|
+
const previousKey = await registrationPathKey(previousRoot);
|
|
405
|
+
const repairedKey = repairedRoot ? await registrationPathKey(repairedRoot) : undefined;
|
|
406
|
+
const conflicts = [];
|
|
407
|
+
for (const registration of registrations) {
|
|
408
|
+
if (registration.branch !== branchRef)
|
|
409
|
+
continue;
|
|
410
|
+
const key = await registrationPathKey(registration.path);
|
|
411
|
+
if (key === previousKey && registration.prunable)
|
|
412
|
+
continue;
|
|
413
|
+
if (repairedKey !== undefined && key === repairedKey && !registration.prunable)
|
|
414
|
+
continue;
|
|
415
|
+
conflicts.push(registration);
|
|
416
|
+
}
|
|
417
|
+
return conflicts;
|
|
418
|
+
}
|
|
419
|
+
async function matchingPathRegistrations(registrations, path) {
|
|
420
|
+
const expectedKey = await registrationPathKey(path);
|
|
421
|
+
const matches = [];
|
|
422
|
+
for (const registration of registrations) {
|
|
423
|
+
if (await registrationPathKey(registration.path) === expectedKey)
|
|
424
|
+
matches.push(registration);
|
|
425
|
+
}
|
|
426
|
+
return matches;
|
|
427
|
+
}
|
|
428
|
+
async function firstMatchingRegistration(registrations, branchRef, pathKeyValue) {
|
|
429
|
+
for (const registration of registrations) {
|
|
430
|
+
if (registration.branch !== branchRef)
|
|
431
|
+
continue;
|
|
432
|
+
if (await registrationPathKey(registration.path) === pathKeyValue)
|
|
433
|
+
return registration;
|
|
434
|
+
}
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
437
|
+
async function allocateManagedWorktreeRecoveryPath(sourceRoot, config) {
|
|
438
|
+
await mkdir(config.worktreeRoot, { recursive: true });
|
|
439
|
+
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
440
|
+
const path = managedWorktreePath({
|
|
441
|
+
worktreeRoot: config.worktreeRoot,
|
|
442
|
+
repoRoot: sourceRoot,
|
|
443
|
+
worktreeId: randomBytes(4).toString("hex"),
|
|
444
|
+
});
|
|
445
|
+
assertAllowedPath(path, [config.worktreeRoot]);
|
|
446
|
+
try {
|
|
447
|
+
await stat(path);
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
if (isMissingPath(error))
|
|
451
|
+
return path;
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
throw new Error("Could not allocate a unique managed-worktree recovery path.");
|
|
456
|
+
}
|
|
457
|
+
function manualRecovery(recovery) {
|
|
458
|
+
return recovery.classification === "manual-intervention"
|
|
459
|
+
? recovery
|
|
460
|
+
: { ...recovery, classification: "manual-intervention" };
|
|
461
|
+
}
|
|
462
|
+
function cleanupResult(session, recovery, registrationRemoved, managedBranchRemoved, classification, reason) {
|
|
463
|
+
return {
|
|
464
|
+
classification,
|
|
465
|
+
cleaned: registrationRemoved || managedBranchRemoved,
|
|
466
|
+
registrationRemoved,
|
|
467
|
+
managedBranchRemoved,
|
|
468
|
+
status: session.status,
|
|
469
|
+
...(recovery ? { recovery } : {}),
|
|
470
|
+
...(reason ? { reason } : {}),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
async function currentBranchName(cwd) {
|
|
474
|
+
try {
|
|
475
|
+
return (await git(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"])).stdout.trim() || undefined;
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
return undefined;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
async function branchIsAncestorOfTarget(sourceRoot, branch, targetBranch) {
|
|
482
|
+
try {
|
|
483
|
+
await git(sourceRoot, [
|
|
484
|
+
"merge-base",
|
|
485
|
+
"--is-ancestor",
|
|
486
|
+
`refs/heads/${branch}`,
|
|
487
|
+
`refs/heads/${targetBranch}`,
|
|
488
|
+
]);
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
async function registrationPathKey(path) {
|
|
496
|
+
const resolved = resolve(path);
|
|
497
|
+
try {
|
|
498
|
+
return pathKey(await realpath(resolved));
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
if (!isMissingPath(error))
|
|
502
|
+
return pathKey(resolved);
|
|
503
|
+
try {
|
|
504
|
+
return pathKey(join(await realpath(dirname(resolved)), basename(resolved)));
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
return pathKey(resolved);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
function pathKey(path) {
|
|
512
|
+
const resolved = resolve(path);
|
|
513
|
+
return platform() === "win32" ? resolved.toLowerCase() : resolved;
|
|
514
|
+
}
|
|
515
|
+
function isMissingPath(error) {
|
|
516
|
+
return error instanceof Error && "code" in error &&
|
|
517
|
+
(error.code === "ENOENT" || error.code === "ENOTDIR");
|
|
518
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { basename, resolve } from "node:path";
|
|
2
2
|
import { assertAllowedPath } from "../mcp/filesystem/roots.js";
|
|
3
3
|
import { canonicalPath } from "./paths.js";
|
|
4
|
+
import { inspectManagedWorktreeRecovery } from "./git/worktree-recovery.js";
|
|
4
5
|
const WORKSPACE_STALE_REMINDER_MS = 2 * 24 * 60 * 60 * 1_000;
|
|
5
6
|
/** Build bounded read-only projections over persistent Workspace sessions. */
|
|
6
7
|
export class WorkspaceInventoryService {
|
|
@@ -84,11 +85,12 @@ export class WorkspaceInventoryService {
|
|
|
84
85
|
}
|
|
85
86
|
async inventoryEntryForSession(session, now, current) {
|
|
86
87
|
const rootValid = await this.sessions.validSessionRoot(session) !== undefined;
|
|
88
|
+
const recovery = await inspectManagedWorktreeRecovery(session, this.config);
|
|
87
89
|
const lastUsedAt = Date.parse(session.lastUsedAt);
|
|
88
90
|
const idleMs = Number.isFinite(lastUsedAt) ? Math.max(0, now - lastUsedAt) : 0;
|
|
89
91
|
const state = session.status !== "active"
|
|
90
92
|
? "closed"
|
|
91
|
-
: !rootValid
|
|
93
|
+
: !rootValid || (recovery !== undefined && recovery.classification !== "healthy")
|
|
92
94
|
? "invalid"
|
|
93
95
|
: idleMs >= WORKSPACE_STALE_REMINDER_MS
|
|
94
96
|
? "stale"
|
|
@@ -109,6 +111,7 @@ export class WorkspaceInventoryService {
|
|
|
109
111
|
lastUsedAt: session.lastUsedAt,
|
|
110
112
|
idleMs,
|
|
111
113
|
rootValid,
|
|
114
|
+
...(recovery ? { recovery } : {}),
|
|
112
115
|
current,
|
|
113
116
|
};
|
|
114
117
|
}
|
|
@@ -25,6 +25,42 @@ export function copyNumberField(source, target, field) {
|
|
|
25
25
|
if (typeof value === "number" && Number.isFinite(value) && value >= 0)
|
|
26
26
|
target[field] = value;
|
|
27
27
|
}
|
|
28
|
+
export function safeManagedWorktreeRecovery(value) {
|
|
29
|
+
if (!value || typeof value !== "object")
|
|
30
|
+
return undefined;
|
|
31
|
+
const recovery = value;
|
|
32
|
+
const classifications = new Set(["healthy", "recoverable", "manual-intervention"]);
|
|
33
|
+
const conditions = new Set([
|
|
34
|
+
"backing-missing",
|
|
35
|
+
"managed-branch-missing",
|
|
36
|
+
"git-registration-stale",
|
|
37
|
+
"git-registration-missing",
|
|
38
|
+
"git-registration-unavailable",
|
|
39
|
+
"branch-mismatch",
|
|
40
|
+
"source-missing",
|
|
41
|
+
"source-unavailable",
|
|
42
|
+
"target-branch-missing",
|
|
43
|
+
]);
|
|
44
|
+
if (typeof recovery.classification !== "string" || !classifications.has(recovery.classification) ||
|
|
45
|
+
!Array.isArray(recovery.conditions) || recovery.conditions.some((condition) => typeof condition !== "string" || !conditions.has(condition)) ||
|
|
46
|
+
(recovery.backing !== "present" && recovery.backing !== "missing") ||
|
|
47
|
+
(recovery.source !== "available" && recovery.source !== "missing" && recovery.source !== "unavailable") ||
|
|
48
|
+
(recovery.gitRegistration !== "registered" && recovery.gitRegistration !== "stale" && recovery.gitRegistration !== "missing" && recovery.gitRegistration !== "unavailable") ||
|
|
49
|
+
(recovery.managedBranch !== "present" && recovery.managedBranch !== "missing" && recovery.managedBranch !== "unknown") ||
|
|
50
|
+
(recovery.targetBranch !== "present" && recovery.targetBranch !== "missing" && recovery.targetBranch !== "unknown") ||
|
|
51
|
+
(recovery.backingBranch !== "matching" && recovery.backingBranch !== "mismatched" && recovery.backingBranch !== "unavailable"))
|
|
52
|
+
return undefined;
|
|
53
|
+
return {
|
|
54
|
+
classification: recovery.classification,
|
|
55
|
+
conditions: [...recovery.conditions],
|
|
56
|
+
backing: recovery.backing,
|
|
57
|
+
source: recovery.source,
|
|
58
|
+
gitRegistration: recovery.gitRegistration,
|
|
59
|
+
managedBranch: recovery.managedBranch,
|
|
60
|
+
targetBranch: recovery.targetBranch,
|
|
61
|
+
backingBranch: recovery.backingBranch,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
28
64
|
export function safeTaskSummary(value) {
|
|
29
65
|
if (!value || typeof value !== "object")
|
|
30
66
|
return undefined;
|
|
@@ -7,7 +7,7 @@ import { RemoteMcpConnectionPool } from "./transport/remote-mcp-connection-pool.
|
|
|
7
7
|
import { withFileLock } from "../../runtime/state/lock/file-lock.js";
|
|
8
8
|
import { withRemoteServiceEndpoint } from "./transport/remote-transport.js";
|
|
9
9
|
import { loadForgeRelayFiles, writeForgeRelayRemote, } from "../../runtime/config/user-config.js";
|
|
10
|
-
import { assertRemoteToolSucceeded,
|
|
10
|
+
import { assertRemoteToolSucceeded, copyBooleanField, copyNumberField, copyStringField, errorMessage, remapToolResultWorkspaceId, replaceExactWorkspaceId, safeManagedWorktreeRecovery, safeTaskSummary, sanitizedRemoteError, stringField, toolResultText, } from "./result-support.js";
|
|
11
11
|
export class RemoteWorkspaceRelay {
|
|
12
12
|
routes = new Map();
|
|
13
13
|
turnRoutes = new Map();
|
|
@@ -77,6 +77,9 @@ export class RemoteWorkspaceRelay {
|
|
|
77
77
|
copyStringField(remoteInspection, projection, "lastUsedAt");
|
|
78
78
|
copyNumberField(remoteInspection, projection, "idleMs");
|
|
79
79
|
copyBooleanField(remoteInspection, projection, "rootValid");
|
|
80
|
+
const recovery = safeManagedWorktreeRecovery(remoteInspection.recovery);
|
|
81
|
+
if (recovery)
|
|
82
|
+
projection.recovery = recovery;
|
|
80
83
|
const taskSummary = safeTaskSummary(remoteInspection.taskSummary);
|
|
81
84
|
if (taskSummary)
|
|
82
85
|
projection.taskSummary = taskSummary;
|
|
@@ -2,6 +2,7 @@ import { stat } from "node:fs/promises";
|
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { loadCapabilityGuides } from "../mcp/server/core/capabilities.js";
|
|
4
4
|
import { createManagedWorktree, discardFreshManagedWorktree, } from "./git/git-worktrees.js";
|
|
5
|
+
import { cleanupManagedWorktreeState, inspectManagedWorktreeRecovery, prepareManagedWorktreeRepair, rollbackManagedWorktreeRepair, } from "./git/worktree-recovery.js";
|
|
5
6
|
import { AccessDeniedError, assertAllowedPath } from "../mcp/filesystem/roots.js";
|
|
6
7
|
import { loadSubagentProfiles } from "../subagents/profiles.js";
|
|
7
8
|
import { BOOTSTRAP_CONTEXT_COMPONENTS, bootstrapContextFingerprints, resolveBootstrapContextComponents, } from "./bootstrap.js";
|
|
@@ -371,6 +372,98 @@ export class WorkspaceSessionService {
|
|
|
371
372
|
throw error;
|
|
372
373
|
}
|
|
373
374
|
}
|
|
375
|
+
async runManagedWorktreeRecovery(workspaceId, operation) {
|
|
376
|
+
const store = this.store;
|
|
377
|
+
if (!store) {
|
|
378
|
+
throw new Error(`Workspace ${workspaceId} cannot use managed-worktree recovery without persistent Workspace state.`);
|
|
379
|
+
}
|
|
380
|
+
const session = store.getSession(workspaceId);
|
|
381
|
+
if (!session)
|
|
382
|
+
throw new Error(`Unknown workspaceId: ${workspaceId}. Call open_workspace first.`);
|
|
383
|
+
if (operation === "cleanup") {
|
|
384
|
+
let managedBranchOwnedByOtherWorkspace = false;
|
|
385
|
+
if (session.sourceRoot && session.branch) {
|
|
386
|
+
const sourceKey = canonicalPersistedWorkspacePath(session.sourceRoot);
|
|
387
|
+
for (const candidate of store.listSessions({ mode: "worktree" })) {
|
|
388
|
+
if (candidate.id === session.id ||
|
|
389
|
+
!candidate.managed ||
|
|
390
|
+
candidate.branch !== session.branch ||
|
|
391
|
+
!candidate.sourceRoot)
|
|
392
|
+
continue;
|
|
393
|
+
if (canonicalPersistedWorkspacePath(candidate.sourceRoot) === sourceKey) {
|
|
394
|
+
managedBranchOwnedByOtherWorkspace = true;
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
workspaceId: session.id,
|
|
401
|
+
...await cleanupManagedWorktreeState(session, this.config, { managedBranchOwnedByOtherWorkspace }),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
const recovery = await inspectManagedWorktreeRecovery(session, this.config);
|
|
405
|
+
if (!recovery) {
|
|
406
|
+
throw new Error(`Workspace ${session.id} is not an active managed-worktree Workspace.`);
|
|
407
|
+
}
|
|
408
|
+
if (operation === "status") {
|
|
409
|
+
return {
|
|
410
|
+
workspaceId: session.id,
|
|
411
|
+
repaired: false,
|
|
412
|
+
recovery,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
const prepared = await prepareManagedWorktreeRepair(session, this.config);
|
|
416
|
+
if (!prepared.prepared) {
|
|
417
|
+
return {
|
|
418
|
+
workspaceId: session.id,
|
|
419
|
+
repaired: false,
|
|
420
|
+
recovery: prepared.recovery,
|
|
421
|
+
reason: prepared.reason,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
const cachedWorkspace = this.workspaces.get(session.id);
|
|
425
|
+
try {
|
|
426
|
+
this.context.forgetWorkspaceResources(session.id);
|
|
427
|
+
this.workspaces.delete(session.id);
|
|
428
|
+
const candidateSession = {
|
|
429
|
+
...session,
|
|
430
|
+
root: prepared.root,
|
|
431
|
+
};
|
|
432
|
+
const candidateWorkspace = this.workspaceFromSession(candidateSession, false);
|
|
433
|
+
await this.reusedWorkspaceContext(candidateWorkspace);
|
|
434
|
+
store.replaceWorktreeBacking({
|
|
435
|
+
id: session.id,
|
|
436
|
+
root: prepared.root,
|
|
437
|
+
sourceRoot: prepared.sourceRoot,
|
|
438
|
+
baseRef: prepared.baseRef,
|
|
439
|
+
baseSha: prepared.baseSha,
|
|
440
|
+
branch: prepared.branch,
|
|
441
|
+
targetBranch: prepared.targetBranch,
|
|
442
|
+
});
|
|
443
|
+
return {
|
|
444
|
+
workspaceId: session.id,
|
|
445
|
+
repaired: true,
|
|
446
|
+
previousRoot: prepared.previousRoot,
|
|
447
|
+
root: prepared.root,
|
|
448
|
+
branch: prepared.branch,
|
|
449
|
+
targetBranch: prepared.targetBranch,
|
|
450
|
+
recovery: prepared.recovery,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
this.context.forgetWorkspaceResources(session.id);
|
|
455
|
+
this.workspaces.delete(session.id);
|
|
456
|
+
if (cachedWorkspace)
|
|
457
|
+
this.workspaces.set(session.id, cachedWorkspace);
|
|
458
|
+
try {
|
|
459
|
+
await rollbackManagedWorktreeRepair(prepared, this.config);
|
|
460
|
+
}
|
|
461
|
+
catch (rollbackError) {
|
|
462
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)} Recovery rollback also failed; the temporary backing was preserved for inspection: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
|
|
463
|
+
}
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
374
467
|
getWorkspaceSession(workspaceId) {
|
|
375
468
|
const session = this.store?.getSession(workspaceId);
|
|
376
469
|
if (session)
|
package/dist/workspaces.js
CHANGED
|
@@ -316,6 +316,9 @@ export class WorkspaceRegistry {
|
|
|
316
316
|
getWorkspace(workspaceId) {
|
|
317
317
|
return this.sessions.getWorkspace(workspaceId);
|
|
318
318
|
}
|
|
319
|
+
runManagedWorktreeRecovery(workspaceId, operation) {
|
|
320
|
+
return this.sessions.runManagedWorktreeRecovery(workspaceId, operation);
|
|
321
|
+
}
|
|
319
322
|
fileToolRoots(workspace) {
|
|
320
323
|
return this.context.fileToolRoots(workspace);
|
|
321
324
|
}
|
package/docs/configuration.md
CHANGED
|
@@ -338,23 +338,30 @@ inventory is paginated (50 records by default, at most 100) and can filter by Wo
|
|
|
338
338
|
ID, persisted status, derived state, mode, canonical root/source root, or stale-only
|
|
339
339
|
state. Reading inventory does not refresh `lastUsedAt`. Persisted `status="active"`
|
|
340
340
|
means the record has not been explicitly closed; the derived `state` distinguishes
|
|
341
|
-
`active`, `stale`, `invalid`, and `closed`.
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
341
|
+
`active`, `stale`, `invalid`, and `closed`. Active managed-worktree entries also expose
|
|
342
|
+
a bounded `recovery` projection: ForgeRelay observes backing/source availability, the
|
|
343
|
+
recorded managed and target branches, Git worktree registration, and the backing's
|
|
344
|
+
current branch, then classifies the result as `healthy`, `recoverable`, or
|
|
345
|
+
`manual-intervention`. A missing checkout or externally damaged managed worktree can
|
|
346
|
+
therefore remain diagnostically `status="active"` while appearing as `state="invalid"`.
|
|
347
|
+
This projection is observation only: inventory never runs `git worktree prune`, creates
|
|
348
|
+
or removes worktrees/branches, or otherwise repairs Git state. Canonical identity means
|
|
349
|
+
ordinary same-target opens no longer accumulate duplicate inventory rows;
|
|
350
|
+
`action="list"` remains the formal on-demand inventory path.
|
|
346
351
|
|
|
347
352
|
`open_workspace(action="inspect", workspaceId="...")` is the bounded read-only detail
|
|
348
353
|
path for one known Workspace. It uses an explicit allowlist and never opens/resumes the
|
|
349
354
|
target, changes conversation bindings or bootstrap-delivery records, refreshes
|
|
350
355
|
`lastUsedAt`, or grants file/process/Git/Capability authority. Safe projections include
|
|
351
|
-
ordinary/worktree lifecycle metadata,
|
|
352
|
-
alias/execution-location presentation
|
|
353
|
-
summary. Inspection never returns
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
356
|
+
ordinary/worktree lifecycle metadata, managed-worktree recovery observations,
|
|
357
|
+
Composite member availability summaries, Relay alias/execution-location presentation
|
|
358
|
+
metadata, and an already-existing Task List summary. Inspection never returns
|
|
359
|
+
AGENTS/CLAUDE contents, Skills, Capability-guide paths or contents, Subagent
|
|
360
|
+
bodies/sessions, files, Git diffs, process/Activity output, Hook/review artifacts,
|
|
361
|
+
credentials, network/SSH routes, or Task bodies. For Relay Workspaces the Gateway asks
|
|
362
|
+
the Execution ForgeRelay for this same bounded inspection and forwards sanitized
|
|
363
|
+
lifecycle/recovery facts under the Gateway Workspace identity; the Gateway does not
|
|
364
|
+
inspect or mutate the remote Git repository itself.
|
|
358
365
|
|
|
359
366
|
For checkout-backed Workspaces, `close_workspace` defaults to `action="close"`:
|
|
360
367
|
it marks the persistent Workspace closed, removes current conversation bindings, and
|
package/docs/roadmap.md
CHANGED
|
@@ -38,7 +38,7 @@ ForgeRelay does not plan to add:
|
|
|
38
38
|
- a plugin marketplace/runtime;
|
|
39
39
|
- a second conversation/session runtime;
|
|
40
40
|
- host-owned commands such as plan mode, context inspection, or model selection;
|
|
41
|
-
- automatic installation and
|
|
41
|
+
- automatic or unapproved language-server installation. Agent-managed TypeScript/JavaScript and Pyright installs require explicit user opt-in and remain disabled by default; Rust Analyzer, `gopls`, and `clangd` stay external toolchain/system dependencies.
|
|
42
42
|
|
|
43
43
|
Shell execution remains a trusted local-user capability. Workspace filesystem
|
|
44
44
|
containment must not be described as a shell sandbox.
|
|
@@ -197,11 +197,12 @@ capability
|
|
|
197
197
|
|
|
198
198
|
## 0.4 — LSP code intelligence v1
|
|
199
199
|
|
|
200
|
-
LSP
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
200
|
+
LSP v1 was designed around language servers already available on the user's machine
|
|
201
|
+
or explicitly configured by the user/project. ForgeRelay later added a narrow managed
|
|
202
|
+
installation path for TypeScript/JavaScript and Pyright: it is disabled by default,
|
|
203
|
+
requires explicit user authorization during setup, installs only into ForgeRelay's
|
|
204
|
+
private config directory, and refreshes live without a server restart. This does not
|
|
205
|
+
turn arbitrary language-server installation into an implicit Agent capability.
|
|
205
206
|
|
|
206
207
|
During 0.4 development, MCP App UI hardening can land alongside the LSP work when
|
|
207
208
|
it does not distort the code-intelligence scope. In particular, evaluate a more
|
|
@@ -245,9 +246,10 @@ and verify publication before work begins on the next boundary:
|
|
|
245
246
|
The shipping 0.4 LSP v1 contract keeps the canonical nine Core MCP tools unchanged
|
|
246
247
|
and exposes semantic operations only through `code.intelligence`. Deterministic
|
|
247
248
|
fake-LSP coverage remains the primary cross-platform protocol/lifecycle gate, while
|
|
248
|
-
`npm run lsp:interop` exercises
|
|
249
|
-
|
|
250
|
-
|
|
249
|
+
`npm run lsp:interop` exercises detected servers only when executables are already
|
|
250
|
+
present and never downloads them. The later managed TypeScript/Pyright installation
|
|
251
|
+
path is a separate explicitly authorized runtime capability and does not change that
|
|
252
|
+
interop-test contract.
|
|
251
253
|
|
|
252
254
|
## 0.5 — Durable Activity and batch execution
|
|
253
255
|
|
|
@@ -315,32 +317,40 @@ milestone: the next stage remains blocked until the previous version's tag-trigg
|
|
|
315
317
|
release workflow has completed successfully. Runtime acceptance uses only the
|
|
316
318
|
reserved 7677/7678 debug instances and never touches the normal 7676 installation.
|
|
317
319
|
|
|
318
|
-
##
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
-
|
|
337
|
-
|
|
338
|
-
-
|
|
339
|
-
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
320
|
+
## 0.9 — Workspace Recovery & History
|
|
321
|
+
|
|
322
|
+
0.9 builds on persistent Workspace identity, first-class `subagent.session`, and the
|
|
323
|
+
existing Hook-backed managed-worktree finalize lifecycle. It does not add another Core
|
|
324
|
+
MCP tool. Each stage is release-gated: the following stage begins only after the prior
|
|
325
|
+
version's tag-triggered Linux/macOS/Windows verification and publication succeed.
|
|
326
|
+
|
|
327
|
+
- **0.9.0** — add bounded read-only managed-worktree recovery observations to
|
|
328
|
+
`open_workspace(action="list"|"inspect")`, distinguishing healthy, recoverable,
|
|
329
|
+
and manual-intervention states without repairing or pruning Git state; align current
|
|
330
|
+
Roadmap/product documentation with already-shipped Subagent, worktree verification,
|
|
331
|
+
and explicitly authorized managed-LSP behavior;
|
|
332
|
+
- **0.9.1** — add Workspace-scoped safe managed-worktree repair and cleanup through
|
|
333
|
+
the Capability Gateway. Recovery must preserve surviving managed-branch work and
|
|
334
|
+
refuse ambiguous ownership rather than guessing;
|
|
335
|
+
- **0.9.2** — add persistent Workspace checkpoints with explicit create/list/inspect/
|
|
336
|
+
delete lifecycle, stored outside normal project history and surviving Workspace
|
|
337
|
+
close/reopen;
|
|
338
|
+
- **0.9.3** — add checkpoint restore with optimistic-concurrency preflight so external
|
|
339
|
+
or user edits made after inspection cannot be silently overwritten. Restore changes
|
|
340
|
+
working-tree content without moving branch HEAD or using `git reset --hard`;
|
|
341
|
+
- **0.9.4** — add owner-facing retention inspection and explicitly authorized prune
|
|
342
|
+
through the CLI. Audit retention remains unlimited by default, persistent Workspace
|
|
343
|
+
identity/Tasks are not ordinary GC targets, and destructive global maintenance is
|
|
344
|
+
not exposed as an ordinary Agent MCP Capability.
|
|
345
|
+
|
|
346
|
+
Workspace Relay continues to treat the Execution ForgeRelay as the owner of Git,
|
|
347
|
+
recovery, checkpoint, and retention facts; the Gateway only routes and presents bounded
|
|
348
|
+
results. Development acceptance remains on isolated 7677/7678 instances and never uses
|
|
349
|
+
the normal 7676 installation.
|
|
350
|
+
|
|
351
|
+
Later refinements may add `.worktreeinclude`-style explicit copying of selected
|
|
352
|
+
Git-ignored files. Native PowerShell/`cmd.exe` shell execution is also a separate
|
|
353
|
+
compatibility decision rather than part of the 0.9 recovery/history line.
|
|
344
354
|
|
|
345
355
|
## Workspace Task Lists
|
|
346
356
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akira-tl/forgerelay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Local development control plane for MCP coding agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://github.com/Akira-TL/forgerelay#readme",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"release:push-ready": "node scripts/release/push-ready.mjs",
|
|
53
53
|
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
|
|
54
54
|
"start": "node dist/cli.js serve",
|
|
55
|
-
"test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/push-ready.test.mjs scripts/release/release-version.test.mjs && tsx src/mcp/oauth/router.test.ts && tsx src/workspaces/relay/auth/remote-auth-cli.test.ts && tsx src/workspaces/relay/auth/remote-ssh-auth-cli.test.ts && tsx src/workspaces/relay/tests/lifecycle.test.ts && tsx src/workspaces/relay/tests/routing.test.ts && tsx src/workspaces/relay/tests/ssh.test.ts && tsx src/workspaces/relay/tests/process.test.ts && tsx src/runtime/config/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/runtime/managed-language-servers.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/runtime/logging/logger.test.ts && tsx src/runtime/logging/proxy-trust.test.ts && tsx src/runtime/state/lock/file-lock.test.ts && tsx src/mcp/panel/mcp-app-template.test.ts && tsx src/mcp/hooks/hooks.test.ts && tsx src/mcp/server/core/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/mcp/request-meta.test.ts && tsx src/mcp/artifacts/incoming-artifacts.test.ts && tsx src/mcp/artifacts/artifact-download.test.ts && tsx src/ui/core/card-types.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/activity/detail-card.test.ts && tsx src/ui/review/patch-display.test.ts && tsx src/ui/core/tool-display.test.ts && tsx src/mcp/filesystem/apply-patch.test.ts && tsx src/mcp/process/process-platform.test.ts && tsx src/mcp/process/process-sessions.test.ts && tsx src/mcp/server/transport/mcp-sessions.test.ts && tsx src/mcp/server/transport/server-shutdown.test.ts && tsx src/mcp/server/operations/mutation-diagnostics.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/adapters/pi.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/mcp/filesystem/roots.test.ts && tsx src/mcp/filesystem/file-mutations.test.ts && tsx src/mcp/operations/edit-preflight.test.ts && tsx src/workspaces/resources/skills.test.ts && tsx src/runtime/state/db/migrations.test.ts && tsx src/workspaces/state/workspace-store.test.ts && tsx src/workspaces/tasks/workspace-tasks.test.ts && tsx src/workspaces/tasks/workspace-task-reminders.test.ts && tsx src/activity/history/audit-store.test.ts && tsx src/activity/history/bash-output-store.test.ts && tsx src/activity/runtime/lifecycle.test.ts && tsx src/activity/history/query-service.test.ts && tsx src/mcp/operations/core-operation-executor.test.ts && tsx src/mcp/operations/bulk-mutation.test.ts && tsx src/mcp/operations/batch/scheduler.test.ts && tsx src/mcp/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspaces/conversation-checkout.test.ts && tsx src/workspaces/conversation-worktree.test.ts && tsx src/workspaces/review/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/mcp/process/server.test.ts && tsx src/mcp/panel/server.test.ts && tsx src/mcp/server/server.test.ts && tsx src/mcp/oauth/oauth-store.test.ts && tsx src/cli/cli.test.ts",
|
|
55
|
+
"test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/push-ready.test.mjs scripts/release/release-version.test.mjs && tsx src/mcp/oauth/router.test.ts && tsx src/workspaces/relay/auth/remote-auth-cli.test.ts && tsx src/workspaces/relay/auth/remote-ssh-auth-cli.test.ts && tsx src/workspaces/relay/tests/lifecycle.test.ts && tsx src/workspaces/relay/tests/routing.test.ts && tsx src/workspaces/relay/tests/ssh.test.ts && tsx src/workspaces/relay/tests/process.test.ts && tsx src/workspaces/relay/tests/recovery.test.ts && tsx src/runtime/config/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/runtime/managed-language-servers.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/runtime/logging/logger.test.ts && tsx src/runtime/logging/proxy-trust.test.ts && tsx src/runtime/state/lock/file-lock.test.ts && tsx src/mcp/panel/mcp-app-template.test.ts && tsx src/mcp/hooks/hooks.test.ts && tsx src/mcp/server/core/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/mcp/request-meta.test.ts && tsx src/mcp/artifacts/incoming-artifacts.test.ts && tsx src/mcp/artifacts/artifact-download.test.ts && tsx src/ui/core/card-types.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/activity/detail-card.test.ts && tsx src/ui/review/patch-display.test.ts && tsx src/ui/core/tool-display.test.ts && tsx src/mcp/filesystem/apply-patch.test.ts && tsx src/mcp/process/process-platform.test.ts && tsx src/mcp/process/process-sessions.test.ts && tsx src/mcp/server/transport/mcp-sessions.test.ts && tsx src/mcp/server/transport/server-shutdown.test.ts && tsx src/mcp/server/operations/mutation-diagnostics.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/adapters/pi.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/mcp/filesystem/roots.test.ts && tsx src/mcp/filesystem/file-mutations.test.ts && tsx src/mcp/operations/edit-preflight.test.ts && tsx src/workspaces/resources/skills.test.ts && tsx src/runtime/state/db/migrations.test.ts && tsx src/workspaces/state/workspace-store.test.ts && tsx src/workspaces/tasks/workspace-tasks.test.ts && tsx src/workspaces/tasks/workspace-task-reminders.test.ts && tsx src/activity/history/audit-store.test.ts && tsx src/activity/history/bash-output-store.test.ts && tsx src/activity/runtime/lifecycle.test.ts && tsx src/activity/history/query-service.test.ts && tsx src/mcp/operations/core-operation-executor.test.ts && tsx src/mcp/operations/bulk-mutation.test.ts && tsx src/mcp/operations/batch/scheduler.test.ts && tsx src/mcp/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspaces/conversation-checkout.test.ts && tsx src/workspaces/conversation-worktree.test.ts && tsx src/workspaces/git/worktree-recovery.test.ts && tsx src/mcp/server/workspace/workspace-inventory.test.ts && tsx src/mcp/server/workspace/workspace-recovery.test.ts && tsx src/workspaces/review/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/mcp/process/server.test.ts && tsx src/mcp/panel/server.test.ts && tsx src/mcp/server/server.test.ts && tsx src/mcp/oauth/oauth-store.test.ts && tsx src/cli/cli.test.ts",
|
|
56
56
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
57
57
|
"release:check": "node scripts/release-version.mjs check",
|
|
58
58
|
"release:tag-check": "node scripts/release-version.mjs tag",
|