@akira-tl/forgerelay 0.9.1 → 0.9.3
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 +12 -0
- package/capabilities/workspace/workspace-checkpoints/GUIDE.md +106 -0
- package/dist/mcp/server/core/capabilities/workspace-checkpoint.js +29 -0
- package/dist/mcp/server/core/capabilities.js +7 -0
- package/dist/mcp/server/core/capability-registry.js +13 -0
- package/dist/mcp/server/workspace/runtime/workspace-tools.js +4 -1
- package/dist/server.js +31 -1
- package/dist/workspaces/state/workspace-checkpoints.js +524 -0
- package/package.json +2 -2
- package/scripts/ci/architecture.mjs +9 -2
- package/scripts/release/release-gate.test.mjs +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.9.3] - 2026-09-04
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Extended `workspace.checkpoint` with `restore.preflight` and optimistic-concurrency-protected `restore`. Restore uses deterministic Git-visible working-tree snapshot identities, refuses stale preflight tokens before mutation, restores content without moving branch HEAD or rewriting history, leaves ignored files outside the restore model, does not reconstruct staged-versus-unstaged state, remains independent from managed-worktree recovery, and routes through the owning Execution ForgeRelay / explicit Composite member.
|
|
12
|
+
|
|
13
|
+
## [0.9.2] - 2026-09-04
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Added persistent Workspace-owned `workspace.checkpoint` create/list/inspect/delete operations backed by immutable Git-visible working-tree snapshots. Checkpoints preserve stable Workspace identity across restart, close/reopen, managed-worktree backing recreation, and ordinary idle GC; ignored files stay excluded, Relay execution remains owned by the Execution ForgeRelay, Composite use remains explicit-member scoped, and no Core MCP tool was added.
|
|
18
|
+
|
|
7
19
|
## [0.9.1] - 2026-09-04
|
|
8
20
|
|
|
9
21
|
### Added
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Workspace Checkpoints
|
|
2
|
+
|
|
3
|
+
`workspace.checkpoint` provides low-frequency, persistent checkpoints owned by the current filesystem Workspace. Checkpoints are immutable Git-backed snapshots intended for deliberate recovery/history workflows.
|
|
4
|
+
|
|
5
|
+
## v0.9.3 surface
|
|
6
|
+
|
|
7
|
+
Supported operations are:
|
|
8
|
+
|
|
9
|
+
- `create` — create a named immutable checkpoint of the current Git-visible working tree.
|
|
10
|
+
- `list` — return bounded checkpoint metadata, preserving checkpoint creation order.
|
|
11
|
+
- `inspect` — return bounded metadata for one checkpoint.
|
|
12
|
+
- `restore.preflight` — identify the selected checkpoint content snapshot and the current Git-visible working snapshot without mutating files.
|
|
13
|
+
- `restore` — restore checkpoint content only when the caller supplies the still-current snapshot identity returned by preflight.
|
|
14
|
+
- `delete` — explicitly delete one checkpoint and its ForgeRelay-owned Git ref.
|
|
15
|
+
|
|
16
|
+
Restore is deliberately a two-step optimistic-concurrency operation. Do not replace it with checkout/reset, hidden merge/rebase behavior, or branch-history rewrites.
|
|
17
|
+
|
|
18
|
+
## Create
|
|
19
|
+
|
|
20
|
+
Use:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"operation": "create",
|
|
25
|
+
"name": "before parser refactor"
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
A checkpoint snapshots the current Git-visible working-tree content using a private temporary Git index. Creation does not move branch `HEAD`, write project history, modify files, or mutate the real staging index.
|
|
30
|
+
|
|
31
|
+
The snapshot includes tracked files and non-ignored untracked files that Git would normally admit. Git-ignored files are excluded. Consequently Workspace checkpoints are **not a backup mechanism for secrets, ignored configuration, credentials, build caches, or other ignored files**.
|
|
32
|
+
|
|
33
|
+
Checkpoint metadata includes a stable checkpoint id, user-provided name, creation timestamp, immutable snapshot commit id, base `HEAD`, and bounded file/addition/removal counts. It does not return patches or full file contents by default.
|
|
34
|
+
|
|
35
|
+
## List and inspect
|
|
36
|
+
|
|
37
|
+
Use `list` for discovery:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"operation": "list",
|
|
42
|
+
"offset": 0,
|
|
43
|
+
"limit": 50
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Use `inspect` only after selecting an id:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"operation": "inspect",
|
|
52
|
+
"checkpointId": "cp_0123456789"
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`list` and `inspect` expose bounded metadata only. Checkpoints do not move when `review.changes` advances its independent last-shown baseline.
|
|
57
|
+
|
|
58
|
+
## Restore preflight and restore
|
|
59
|
+
|
|
60
|
+
First preflight the selected checkpoint:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"operation": "restore.preflight",
|
|
65
|
+
"checkpointId": "cp_0123456789"
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The result includes `checkpointSnapshot`, the immutable Git tree identity selected for restore, plus `currentSnapshot`, the deterministic Git tree identity of the current Git-visible working content. It also includes a bounded `restoreSummary`. Preflight does not mutate the Workspace.
|
|
70
|
+
|
|
71
|
+
Then pass that exact `currentSnapshot` back as `expectedCurrentSnapshot`:
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"operation": "restore",
|
|
76
|
+
"checkpointId": "cp_0123456789",
|
|
77
|
+
"expectedCurrentSnapshot": "0123456789abcdef0123456789abcdef01234567"
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Immediately before applying content changes ForgeRelay recomputes the current Git-visible working snapshot. If it no longer matches `expectedCurrentSnapshot`, restore fails before mutation and the intervening edits remain untouched. Run preflight again before deciding whether to retry.
|
|
82
|
+
|
|
83
|
+
A successful restore writes only ordinary working-tree content needed to reproduce the checkpoint's Git-visible tree. It does **not** move branch `HEAD`, rewrite commit history, merge/rebase, auto-commit, or use `git reset --hard` semantics. Ignored files remain outside the checkpoint/restore content model and are not intentionally changed.
|
|
84
|
+
|
|
85
|
+
The 0.9 restore contract is **content-state only**. ForgeRelay does not reconstruct or promise the historical staged-versus-unstaged partition. Restore does not rewrite the real Git index; existing staging state may therefore differ from the restored working-tree content and should be inspected normally with Git afterward. Results expose `stagingStateRestored: false` to make this explicit.
|
|
86
|
+
|
|
87
|
+
## Delete
|
|
88
|
+
|
|
89
|
+
Deletion is explicit:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"operation": "delete",
|
|
94
|
+
"checkpointId": "cp_0123456789"
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
ForgeRelay verifies that the Workspace-owned Git ref still points at the checkpoint's recorded immutable commit before deleting it. A mismatched or missing ref is treated as inconsistent state rather than silently deleting something else.
|
|
99
|
+
|
|
100
|
+
Ordinary Workspace close, process restart, idle Workspace cache GC, and managed-worktree backing replacement preserve checkpoints. Permanent `close_workspace { action: "delete" }` removes checkpoints owned by that Workspace identity.
|
|
101
|
+
|
|
102
|
+
## Relay and Composite Workspaces
|
|
103
|
+
|
|
104
|
+
Checkpoint ownership belongs to the Execution Workspace. Through Workspace Relay, checkpoint operations execute on the remote Execution ForgeRelay and persist there.
|
|
105
|
+
|
|
106
|
+
A Composite Workspace does not own filesystem checkpoints itself. Pass an explicit `member` so the operation targets that member's persistent Workspace identity.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const workspaceCheckpointInputSchema = z.discriminatedUnion("operation", [
|
|
3
|
+
z.object({
|
|
4
|
+
operation: z.literal("create"),
|
|
5
|
+
name: z.string().trim().min(1).max(120),
|
|
6
|
+
}).strict(),
|
|
7
|
+
z.object({
|
|
8
|
+
operation: z.literal("list"),
|
|
9
|
+
offset: z.number().int().min(0).optional(),
|
|
10
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
11
|
+
}).strict(),
|
|
12
|
+
z.object({
|
|
13
|
+
operation: z.literal("inspect"),
|
|
14
|
+
checkpointId: z.string().regex(/^cp_[a-f0-9]{10}$/),
|
|
15
|
+
}).strict(),
|
|
16
|
+
z.object({
|
|
17
|
+
operation: z.literal("restore.preflight"),
|
|
18
|
+
checkpointId: z.string().regex(/^cp_[a-f0-9]{10}$/),
|
|
19
|
+
}).strict(),
|
|
20
|
+
z.object({
|
|
21
|
+
operation: z.literal("restore"),
|
|
22
|
+
checkpointId: z.string().regex(/^cp_[a-f0-9]{10}$/),
|
|
23
|
+
expectedCurrentSnapshot: z.string().regex(/^[a-f0-9]{40,64}$/),
|
|
24
|
+
}).strict(),
|
|
25
|
+
z.object({
|
|
26
|
+
operation: z.literal("delete"),
|
|
27
|
+
checkpointId: z.string().regex(/^cp_[a-f0-9]{10}$/),
|
|
28
|
+
}).strict(),
|
|
29
|
+
]);
|
|
@@ -46,6 +46,12 @@ const CAPABILITY_GUIDE_DEFINITIONS = [
|
|
|
46
46
|
description: "Persistent Task Lists owned by the current Workspace.",
|
|
47
47
|
whenToRead: "Read before creating or maintaining Workspace Tasks.",
|
|
48
48
|
},
|
|
49
|
+
{
|
|
50
|
+
name: "workspace-checkpoints",
|
|
51
|
+
directory: "workspace/workspace-checkpoints",
|
|
52
|
+
description: "Immutable persistent Git-backed checkpoints owned by the current Workspace.",
|
|
53
|
+
whenToRead: "Read before creating, listing, inspecting, or deleting Workspace checkpoints.",
|
|
54
|
+
},
|
|
49
55
|
{
|
|
50
56
|
name: "batch-execution",
|
|
51
57
|
description: "One-call execution of multiple independent ForgeRelay core operations.",
|
|
@@ -96,6 +102,7 @@ export function buildCapabilityFingerprint(config, version, context = {}) {
|
|
|
96
102
|
"code.intelligence",
|
|
97
103
|
"workspace.tasks",
|
|
98
104
|
"workspace.recovery",
|
|
105
|
+
"workspace.checkpoint",
|
|
99
106
|
];
|
|
100
107
|
if (config.toolMode !== "codex") {
|
|
101
108
|
capabilities.push("batch.execute");
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { MAX_CODE_INTELLIGENCE_RESULT_LIMIT, } from "../../../lsp/code-intelligence-types.js";
|
|
3
3
|
import { batchExecuteInputSchema, } from "../../operations/batch/types.js";
|
|
4
|
+
import { workspaceCheckpointInputSchema, } from "./capabilities/workspace-checkpoint.js";
|
|
4
5
|
export class CapabilityError extends Error {
|
|
5
6
|
code;
|
|
6
7
|
constructor(code, message) {
|
|
@@ -304,6 +305,18 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
304
305
|
run: async (input, context, options) => dependencies.workspaceRecovery.run(input, context, options),
|
|
305
306
|
}]
|
|
306
307
|
: []),
|
|
308
|
+
...(dependencies.workspaceCheckpoint
|
|
309
|
+
? [{
|
|
310
|
+
name: "workspace.checkpoint",
|
|
311
|
+
description: "Create, list, inspect, safely restore, or delete immutable Git-backed checkpoints owned by the current persistent Workspace.",
|
|
312
|
+
guideName: "workspace-checkpoints",
|
|
313
|
+
readGuideBeforeFirstUse: true,
|
|
314
|
+
batchPolicy: "unsupported",
|
|
315
|
+
inputSchema: workspaceCheckpointInputSchema,
|
|
316
|
+
availability: (context) => filesystemWorkspaceAvailability(context, dependencies.workspaceCheckpoint?.available ?? false, dependencies.workspaceCheckpoint?.unavailableReason),
|
|
317
|
+
run: async (input, context, options) => dependencies.workspaceCheckpoint.run(input, context, options),
|
|
318
|
+
}]
|
|
319
|
+
: []),
|
|
307
320
|
...(dependencies.workspaceTasks
|
|
308
321
|
? [{
|
|
309
322
|
name: "workspace.tasks",
|
|
@@ -15,7 +15,7 @@ const WRITE_TOOL_ANNOTATIONS = {
|
|
|
15
15
|
openWorldHint: false,
|
|
16
16
|
};
|
|
17
17
|
export function registerWorkspaceAuxiliaryTools(options) {
|
|
18
|
-
const { server, config, workspaces, remoteWorkspaces, compositeWorkspaces, compositeTaskGuides, capabilityRegistry, coreOperations, activityLifecycle, hooks, workspaceTasks, taskReminders, activityQueries, compositeActivity, workspacePanelStates, processSessions, reviewCheckpoints, codeIntelligence, resolveExecutionTarget, prepareExecutionContext, hostScopeIdFor, presentExecutionResult, presentSemanticWorkResult, } = options;
|
|
18
|
+
const { server, config, workspaces, remoteWorkspaces, compositeWorkspaces, compositeTaskGuides, capabilityRegistry, coreOperations, activityLifecycle, hooks, workspaceTasks, workspaceCheckpoints, taskReminders, activityQueries, compositeActivity, workspacePanelStates, processSessions, reviewCheckpoints, codeIntelligence, resolveExecutionTarget, prepareExecutionContext, hostScopeIdFor, presentExecutionResult, presentSemanticWorkResult, } = options;
|
|
19
19
|
registerAppTool(server, "workspace_instruction", {
|
|
20
20
|
title: "Read Workspace instruction",
|
|
21
21
|
description: "App-only lazy data source for one instruction file already advertised by the Workspace presentation. Reading through this UI source does not activate nested instructions or create an Activity.",
|
|
@@ -399,6 +399,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
|
|
|
399
399
|
},
|
|
400
400
|
payload: { workspaceId: session.id, action: "delete", mode: session.mode },
|
|
401
401
|
operation: async () => {
|
|
402
|
+
await workspaceCheckpoints.deleteWorkspace(session.id);
|
|
402
403
|
workspaces.deleteWorkspace(session.id);
|
|
403
404
|
workspaceTasks.deleteWorkspace(session.id);
|
|
404
405
|
taskReminders.forget(session.id);
|
|
@@ -444,6 +445,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
|
|
|
444
445
|
},
|
|
445
446
|
payload: { workspaceId: session.id, action: "delete", mode: session.mode },
|
|
446
447
|
operation: async () => {
|
|
448
|
+
await workspaceCheckpoints.deleteWorkspace(session.id);
|
|
447
449
|
workspaces.deleteWorkspace(session.id);
|
|
448
450
|
workspaceTasks.deleteWorkspace(session.id);
|
|
449
451
|
taskReminders.forget(session.id);
|
|
@@ -509,6 +511,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
|
|
|
509
511
|
}
|
|
510
512
|
await Promise.all(physicalWorkspaceIds.map((id) => reviewCheckpoints.releaseWorkspace(id)));
|
|
511
513
|
if (action === "delete") {
|
|
514
|
+
await workspaceCheckpoints.deleteWorkspace(workspace.id);
|
|
512
515
|
workspaces.deleteWorkspace(workspace.id);
|
|
513
516
|
workspaceTasks.deleteWorkspace(workspace.id);
|
|
514
517
|
taskReminders.forget(workspace.id);
|
package/dist/server.js
CHANGED
|
@@ -26,6 +26,7 @@ import { shutdownHttpServer } from "./mcp/server/transport/server-shutdown.js";
|
|
|
26
26
|
import { formatPathForPrompt } from "./workspaces/resources/skills.js";
|
|
27
27
|
import { WorkspaceTaskReminderTracker } from "./workspaces/tasks/workspace-task-reminders.js";
|
|
28
28
|
import { WorkspaceTaskStore } from "./workspaces/tasks/workspace-tasks.js";
|
|
29
|
+
import { WorkspaceCheckpointStore } from "./workspaces/state/workspace-checkpoints.js";
|
|
29
30
|
import { compactWorkspacePresentation } from "./workspaces/presentation/workspace-presentation.js";
|
|
30
31
|
import { formatAgentsPath } from "./workspaces.js";
|
|
31
32
|
import { summarizeSubagentProfile } from "./subagents/profiles.js";
|
|
@@ -49,6 +50,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
49
50
|
const compositeWorkspaces = options.compositeWorkspaces
|
|
50
51
|
?? new CompositeWorkspaceRegistry(config.stateDir);
|
|
51
52
|
const workspaceTasks = new WorkspaceTaskStore(config.stateDir);
|
|
53
|
+
const workspaceCheckpoints = new WorkspaceCheckpointStore(config.stateDir);
|
|
52
54
|
const taskReminders = options.taskReminders
|
|
53
55
|
?? new WorkspaceTaskReminderTracker(config.taskReminderInterval, workspaceTasks);
|
|
54
56
|
const compositeTaskGuides = loadCapabilityGuides(config).filter((guide) => guide.name === "workspace-tasks");
|
|
@@ -137,6 +139,34 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
137
139
|
value: await workspaces.runManagedWorktreeRecovery(context.workspaceId, input.operation),
|
|
138
140
|
}),
|
|
139
141
|
},
|
|
142
|
+
workspaceCheckpoint: {
|
|
143
|
+
available: true,
|
|
144
|
+
run: async (input, context) => {
|
|
145
|
+
const root = requireCapabilityWorkspaceRoot(context);
|
|
146
|
+
switch (input.operation) {
|
|
147
|
+
case "create":
|
|
148
|
+
return {
|
|
149
|
+
value: {
|
|
150
|
+
workspaceId: context.workspaceId,
|
|
151
|
+
checkpoint: await workspaceCheckpoints.create(context.workspaceId, root, input.name),
|
|
152
|
+
ignoredFilesIncluded: false,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
case "list":
|
|
156
|
+
return { value: await workspaceCheckpoints.list(context.workspaceId, root, input) };
|
|
157
|
+
case "inspect":
|
|
158
|
+
return { value: await workspaceCheckpoints.inspect(context.workspaceId, root, input.checkpointId) };
|
|
159
|
+
case "restore.preflight":
|
|
160
|
+
return { value: await workspaceCheckpoints.preflightRestore(context.workspaceId, root, input.checkpointId) };
|
|
161
|
+
case "restore":
|
|
162
|
+
return {
|
|
163
|
+
value: await workspaceCheckpoints.restore(context.workspaceId, root, input.checkpointId, input.expectedCurrentSnapshot),
|
|
164
|
+
};
|
|
165
|
+
case "delete":
|
|
166
|
+
return { value: await workspaceCheckpoints.delete(context.workspaceId, root, input.checkpointId) };
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
},
|
|
140
170
|
workspaceTasks: {
|
|
141
171
|
available: true,
|
|
142
172
|
run: async (input, context) => {
|
|
@@ -451,7 +481,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
451
481
|
});
|
|
452
482
|
registerWorkspaceAuxiliaryTools({
|
|
453
483
|
server, config, workspaces, remoteWorkspaces, compositeWorkspaces, compositeTaskGuides, capabilityRegistry,
|
|
454
|
-
coreOperations, activityLifecycle, hooks, workspaceTasks, taskReminders, activityQueries, compositeActivity,
|
|
484
|
+
coreOperations, activityLifecycle, hooks, workspaceTasks, workspaceCheckpoints, taskReminders, activityQueries, compositeActivity,
|
|
455
485
|
workspacePanelStates, processSessions, reviewCheckpoints, codeIntelligence, resolveExecutionTarget,
|
|
456
486
|
prepareExecutionContext, hostScopeIdFor, presentExecutionResult, presentSemanticWorkResult,
|
|
457
487
|
});
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, rmdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import * as z from "zod/v4";
|
|
7
|
+
import { getGitEligibility, git, safeWorkspaceRefSegment } from "../git/git.js";
|
|
8
|
+
const CHECKPOINT_STATE_VERSION = 1;
|
|
9
|
+
const CHECKPOINT_REF_PREFIX = "refs/forgerelay/checkpoints";
|
|
10
|
+
const MAX_CHECKPOINTS = 500;
|
|
11
|
+
const MAX_CHECKPOINT_NAME_LENGTH = 120;
|
|
12
|
+
const MAX_CHECKPOINT_STATE_BYTES = 1024 * 1024;
|
|
13
|
+
const checkpointSchema = z.object({
|
|
14
|
+
id: z.string().regex(/^cp_[a-f0-9]{10}$/),
|
|
15
|
+
name: z.string().min(1).max(MAX_CHECKPOINT_NAME_LENGTH),
|
|
16
|
+
createdAt: z.string().min(1),
|
|
17
|
+
commit: z.string().regex(/^[a-f0-9]{40,64}$/),
|
|
18
|
+
baseHead: z.string().regex(/^[a-f0-9]{40,64}$/),
|
|
19
|
+
summary: z.object({
|
|
20
|
+
files: z.number().int().nonnegative(),
|
|
21
|
+
additions: z.number().int().nonnegative(),
|
|
22
|
+
removals: z.number().int().nonnegative(),
|
|
23
|
+
}).strict(),
|
|
24
|
+
}).strict();
|
|
25
|
+
const checkpointStateSchema = z.object({
|
|
26
|
+
version: z.literal(CHECKPOINT_STATE_VERSION),
|
|
27
|
+
revision: z.number().int().nonnegative(),
|
|
28
|
+
gitCommonDir: z.string().min(1),
|
|
29
|
+
checkpoints: z.array(checkpointSchema).max(MAX_CHECKPOINTS),
|
|
30
|
+
}).strict().superRefine((state, context) => {
|
|
31
|
+
const ids = new Set();
|
|
32
|
+
for (const [index, checkpoint] of state.checkpoints.entries()) {
|
|
33
|
+
if (ids.has(checkpoint.id)) {
|
|
34
|
+
context.addIssue({
|
|
35
|
+
code: "custom",
|
|
36
|
+
path: ["checkpoints", index, "id"],
|
|
37
|
+
message: `Duplicate checkpoint id ${checkpoint.id}.`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
ids.add(checkpoint.id);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
export class WorkspaceCheckpointStore {
|
|
44
|
+
stateDir;
|
|
45
|
+
now;
|
|
46
|
+
mutationChains = new Map();
|
|
47
|
+
constructor(stateDir, now = () => new Date()) {
|
|
48
|
+
this.stateDir = stateDir;
|
|
49
|
+
this.now = now;
|
|
50
|
+
}
|
|
51
|
+
async create(workspaceId, workspaceRoot, name) {
|
|
52
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
53
|
+
const checkpointName = normalizeCheckpointName(name);
|
|
54
|
+
return this.runMutation(id, async () => {
|
|
55
|
+
const repository = await resolveRepository(workspaceRoot);
|
|
56
|
+
const loaded = this.tryReadState(id);
|
|
57
|
+
if (loaded)
|
|
58
|
+
await assertSameRepository(loaded.gitCommonDir, repository.gitCommonDir, id);
|
|
59
|
+
if ((loaded?.checkpoints.length ?? 0) >= MAX_CHECKPOINTS) {
|
|
60
|
+
throw new Error(`Workspace checkpoint limit is ${MAX_CHECKPOINTS}. Delete an older checkpoint first.`);
|
|
61
|
+
}
|
|
62
|
+
const checkpointId = `cp_${randomBytes(5).toString("hex")}`;
|
|
63
|
+
const ref = checkpointRef(id, checkpointId);
|
|
64
|
+
const snapshot = await createWorkingTreeSnapshot(repository.gitRoot);
|
|
65
|
+
const checkpoint = {
|
|
66
|
+
id: checkpointId,
|
|
67
|
+
name: checkpointName,
|
|
68
|
+
createdAt: this.now().toISOString(),
|
|
69
|
+
commit: snapshot.commit,
|
|
70
|
+
baseHead: snapshot.baseHead,
|
|
71
|
+
summary: snapshot.summary,
|
|
72
|
+
};
|
|
73
|
+
await updateRef(repository.gitCommonDir, ref, checkpoint.commit, zeroOid(checkpoint.commit.length));
|
|
74
|
+
try {
|
|
75
|
+
this.writeState(id, {
|
|
76
|
+
version: CHECKPOINT_STATE_VERSION,
|
|
77
|
+
revision: (loaded?.revision ?? 0) + 1,
|
|
78
|
+
gitCommonDir: repository.gitCommonDir,
|
|
79
|
+
checkpoints: [...(loaded?.checkpoints ?? []), checkpoint],
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
await deleteRef(repository.gitCommonDir, ref, checkpoint.commit).catch(() => undefined);
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
return { ...checkpoint, summary: { ...checkpoint.summary } };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
async list(workspaceId, workspaceRoot, input = {}) {
|
|
90
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
91
|
+
const repository = await resolveRepository(workspaceRoot);
|
|
92
|
+
const state = this.tryReadState(id);
|
|
93
|
+
if (state)
|
|
94
|
+
await assertSameRepository(state.gitCommonDir, repository.gitCommonDir, id);
|
|
95
|
+
const offset = normalizeOffset(input.offset);
|
|
96
|
+
const limit = normalizeLimit(input.limit);
|
|
97
|
+
const checkpoints = state?.checkpoints ?? [];
|
|
98
|
+
return {
|
|
99
|
+
workspaceId: id,
|
|
100
|
+
checkpoints: checkpoints.slice(offset, offset + limit).map(cloneCheckpoint),
|
|
101
|
+
page: {
|
|
102
|
+
offset,
|
|
103
|
+
limit,
|
|
104
|
+
total: checkpoints.length,
|
|
105
|
+
hasMore: offset + limit < checkpoints.length,
|
|
106
|
+
},
|
|
107
|
+
ignoredFilesIncluded: false,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
async inspect(workspaceId, workspaceRoot, checkpointId) {
|
|
111
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
112
|
+
const cpId = normalizeCheckpointId(checkpointId);
|
|
113
|
+
const repository = await resolveRepository(workspaceRoot);
|
|
114
|
+
const state = this.requireState(id);
|
|
115
|
+
await assertSameRepository(state.gitCommonDir, repository.gitCommonDir, id);
|
|
116
|
+
const checkpoint = requireCheckpoint(state, cpId);
|
|
117
|
+
await assertCheckpointRef(state.gitCommonDir, id, checkpoint);
|
|
118
|
+
return { workspaceId: id, checkpoint: cloneCheckpoint(checkpoint), ignoredFilesIncluded: false };
|
|
119
|
+
}
|
|
120
|
+
async preflightRestore(workspaceId, workspaceRoot, checkpointId) {
|
|
121
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
122
|
+
const cpId = normalizeCheckpointId(checkpointId);
|
|
123
|
+
const repository = await resolveRepository(workspaceRoot);
|
|
124
|
+
const state = this.requireState(id);
|
|
125
|
+
await assertSameRepository(state.gitCommonDir, repository.gitCommonDir, id);
|
|
126
|
+
const checkpoint = requireCheckpoint(state, cpId);
|
|
127
|
+
await assertCheckpointRef(state.gitCommonDir, id, checkpoint);
|
|
128
|
+
const [checkpointSnapshot, current] = await Promise.all([
|
|
129
|
+
checkpointTree(repository.gitRoot, checkpoint.commit),
|
|
130
|
+
snapshotWorkingTree(repository.gitRoot),
|
|
131
|
+
]);
|
|
132
|
+
const restoreSummary = summarizeNumstat((await git(repository.gitRoot, [
|
|
133
|
+
"diff",
|
|
134
|
+
"--numstat",
|
|
135
|
+
"-z",
|
|
136
|
+
"--no-renames",
|
|
137
|
+
current.tree,
|
|
138
|
+
checkpointSnapshot,
|
|
139
|
+
"--",
|
|
140
|
+
".",
|
|
141
|
+
], { maxBuffer: 50 * 1024 * 1024 })).stdout);
|
|
142
|
+
return {
|
|
143
|
+
workspaceId: id,
|
|
144
|
+
checkpoint: cloneCheckpoint(checkpoint),
|
|
145
|
+
checkpointSnapshot,
|
|
146
|
+
currentSnapshot: current.tree,
|
|
147
|
+
restoreSummary,
|
|
148
|
+
ignoredFilesIncluded: false,
|
|
149
|
+
stagingStateRestored: false,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
async restore(workspaceId, workspaceRoot, checkpointId, expectedCurrentSnapshot) {
|
|
153
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
154
|
+
const cpId = normalizeCheckpointId(checkpointId);
|
|
155
|
+
const expected = normalizeSnapshotId(expectedCurrentSnapshot);
|
|
156
|
+
return this.runMutation(id, async () => {
|
|
157
|
+
const repository = await resolveRepository(workspaceRoot);
|
|
158
|
+
const state = this.requireState(id);
|
|
159
|
+
await assertSameRepository(state.gitCommonDir, repository.gitCommonDir, id);
|
|
160
|
+
const checkpoint = requireCheckpoint(state, cpId);
|
|
161
|
+
await assertCheckpointRef(state.gitCommonDir, id, checkpoint);
|
|
162
|
+
const checkpointSnapshot = await checkpointTree(repository.gitRoot, checkpoint.commit);
|
|
163
|
+
const current = await snapshotWorkingTree(repository.gitRoot);
|
|
164
|
+
assertExpectedCurrentSnapshot(expected, current.tree);
|
|
165
|
+
if (current.tree !== checkpointSnapshot) {
|
|
166
|
+
await applyTreeRestore(repository.gitRoot, current.tree, checkpointSnapshot, async () => {
|
|
167
|
+
const immediate = await snapshotWorkingTree(repository.gitRoot);
|
|
168
|
+
assertExpectedCurrentSnapshot(expected, immediate.tree);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const restored = await snapshotWorkingTree(repository.gitRoot);
|
|
172
|
+
if (restored.tree !== checkpointSnapshot) {
|
|
173
|
+
throw new Error(`Workspace checkpoint restore did not reproduce checkpoint snapshot ${checkpointSnapshot}; current snapshot is ${restored.tree}.`);
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
workspaceId: id,
|
|
177
|
+
checkpointId: cpId,
|
|
178
|
+
restored: true,
|
|
179
|
+
checkpointSnapshot,
|
|
180
|
+
previousSnapshot: current.tree,
|
|
181
|
+
currentSnapshot: restored.tree,
|
|
182
|
+
ignoredFilesIncluded: false,
|
|
183
|
+
stagingStateRestored: false,
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
async delete(workspaceId, workspaceRoot, checkpointId) {
|
|
188
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
189
|
+
const cpId = normalizeCheckpointId(checkpointId);
|
|
190
|
+
return this.runMutation(id, async () => {
|
|
191
|
+
const repository = await resolveRepository(workspaceRoot);
|
|
192
|
+
const state = this.requireState(id);
|
|
193
|
+
await assertSameRepository(state.gitCommonDir, repository.gitCommonDir, id);
|
|
194
|
+
const checkpoint = requireCheckpoint(state, cpId);
|
|
195
|
+
const ref = checkpointRef(id, cpId);
|
|
196
|
+
await deleteRef(state.gitCommonDir, ref, checkpoint.commit);
|
|
197
|
+
try {
|
|
198
|
+
this.writeState(id, {
|
|
199
|
+
...state,
|
|
200
|
+
revision: state.revision + 1,
|
|
201
|
+
checkpoints: state.checkpoints.filter((candidate) => candidate.id !== cpId),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
await updateRef(state.gitCommonDir, ref, checkpoint.commit, zeroOid(checkpoint.commit.length)).catch(() => undefined);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
return { workspaceId: id, checkpointId: cpId, deleted: true };
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
async deleteWorkspace(workspaceId) {
|
|
212
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
213
|
+
await this.runMutation(id, async () => {
|
|
214
|
+
const state = this.tryReadState(id);
|
|
215
|
+
if (!state)
|
|
216
|
+
return;
|
|
217
|
+
for (const checkpoint of state.checkpoints) {
|
|
218
|
+
await deleteRef(state.gitCommonDir, checkpointRef(id, checkpoint.id), checkpoint.commit);
|
|
219
|
+
}
|
|
220
|
+
rmSync(this.statePath(id), { force: true });
|
|
221
|
+
try {
|
|
222
|
+
rmdirSync(this.workspaceStateDir(id));
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
if (!isErrno(error, "ENOENT") && !isErrno(error, "ENOTEMPTY") && !isErrno(error, "EEXIST")) {
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
async runMutation(workspaceId, operation) {
|
|
232
|
+
const previous = this.mutationChains.get(workspaceId) ?? Promise.resolve();
|
|
233
|
+
const current = previous.catch(() => undefined).then(operation);
|
|
234
|
+
this.mutationChains.set(workspaceId, current);
|
|
235
|
+
try {
|
|
236
|
+
return await current;
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
if (this.mutationChains.get(workspaceId) === current)
|
|
240
|
+
this.mutationChains.delete(workspaceId);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
requireState(workspaceId) {
|
|
244
|
+
const state = this.tryReadState(workspaceId);
|
|
245
|
+
if (!state)
|
|
246
|
+
throw new Error(`Workspace ${workspaceId} has no checkpoints.`);
|
|
247
|
+
return state;
|
|
248
|
+
}
|
|
249
|
+
tryReadState(workspaceId) {
|
|
250
|
+
let raw;
|
|
251
|
+
try {
|
|
252
|
+
raw = readFileSync(this.statePath(workspaceId));
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (isErrno(error, "ENOENT"))
|
|
256
|
+
return undefined;
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
if (raw.byteLength > MAX_CHECKPOINT_STATE_BYTES) {
|
|
260
|
+
throw new Error(`Workspace checkpoint state exceeds ${MAX_CHECKPOINT_STATE_BYTES} bytes.`);
|
|
261
|
+
}
|
|
262
|
+
let parsed;
|
|
263
|
+
try {
|
|
264
|
+
parsed = JSON.parse(raw.toString("utf8"));
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
throw new Error(`Workspace checkpoint state is not valid JSON: ${errorMessage(error)}`);
|
|
268
|
+
}
|
|
269
|
+
const validated = checkpointStateSchema.safeParse(parsed);
|
|
270
|
+
if (!validated.success) {
|
|
271
|
+
const details = validated.error.issues
|
|
272
|
+
.map((issue) => `${issue.path.length > 0 ? issue.path.join(".") : "state"}: ${issue.message}`)
|
|
273
|
+
.join("; ");
|
|
274
|
+
throw new Error(`Workspace checkpoint state has an unsupported or invalid format: ${details}`);
|
|
275
|
+
}
|
|
276
|
+
return cloneState(validated.data);
|
|
277
|
+
}
|
|
278
|
+
writeState(workspaceId, state) {
|
|
279
|
+
const validated = checkpointStateSchema.parse(state);
|
|
280
|
+
const serialized = `${JSON.stringify(validated, null, 2)}\n`;
|
|
281
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_CHECKPOINT_STATE_BYTES) {
|
|
282
|
+
throw new Error(`Workspace checkpoint state exceeds ${MAX_CHECKPOINT_STATE_BYTES} bytes.`);
|
|
283
|
+
}
|
|
284
|
+
const workspaceDir = this.workspaceStateDir(workspaceId);
|
|
285
|
+
mkdirSync(workspaceDir, { recursive: true, mode: 0o700 });
|
|
286
|
+
const statePath = this.statePath(workspaceId);
|
|
287
|
+
const tempPath = `${statePath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
288
|
+
try {
|
|
289
|
+
writeFileSync(tempPath, serialized, { mode: 0o600 });
|
|
290
|
+
renameSync(tempPath, statePath);
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
rmSync(tempPath, { force: true });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
workspaceStateDir(workspaceId) {
|
|
297
|
+
return join(this.stateDir, "workspaces", workspaceId);
|
|
298
|
+
}
|
|
299
|
+
statePath(workspaceId) {
|
|
300
|
+
return join(this.workspaceStateDir(workspaceId), "checkpoints.json");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async function resolveRepository(workspaceRoot) {
|
|
304
|
+
const eligibility = await getGitEligibility(workspaceRoot);
|
|
305
|
+
if (!eligibility.ok || !eligibility.gitRoot) {
|
|
306
|
+
throw new Error(eligibility.message ?? "workspace.checkpoint requires a Git workspace with a HEAD commit.");
|
|
307
|
+
}
|
|
308
|
+
const commonDirRaw = (await git(eligibility.gitRoot, [
|
|
309
|
+
"rev-parse",
|
|
310
|
+
"--path-format=absolute",
|
|
311
|
+
"--git-common-dir",
|
|
312
|
+
])).stdout.trim();
|
|
313
|
+
const commonDir = await canonicalExistingPath(commonDirRaw);
|
|
314
|
+
return { gitRoot: eligibility.gitRoot, gitCommonDir: commonDir };
|
|
315
|
+
}
|
|
316
|
+
async function createWorkingTreeSnapshot(gitRoot) {
|
|
317
|
+
const snapshot = await snapshotWorkingTree(gitRoot);
|
|
318
|
+
const commit = (await git(gitRoot, [
|
|
319
|
+
"commit-tree",
|
|
320
|
+
snapshot.tree,
|
|
321
|
+
"-p",
|
|
322
|
+
snapshot.baseHead,
|
|
323
|
+
"-m",
|
|
324
|
+
"ForgeRelay persistent workspace checkpoint",
|
|
325
|
+
], { env: checkpointIdentityEnv() })).stdout.trim();
|
|
326
|
+
const numstat = (await git(gitRoot, ["diff", "--numstat", "-z", snapshot.baseHead, commit], {
|
|
327
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
328
|
+
})).stdout;
|
|
329
|
+
return { commit, baseHead: snapshot.baseHead, summary: summarizeNumstat(numstat) };
|
|
330
|
+
}
|
|
331
|
+
async function snapshotWorkingTree(gitRoot) {
|
|
332
|
+
const tempDir = await mkdtemp(join(tmpdir(), "forgerelay-checkpoint-index-"));
|
|
333
|
+
const indexPath = join(tempDir, "index");
|
|
334
|
+
const env = checkpointEnv(indexPath);
|
|
335
|
+
try {
|
|
336
|
+
await git(gitRoot, ["read-tree", "HEAD"], { env });
|
|
337
|
+
await git(gitRoot, ["add", "-A", "--", "."], { env });
|
|
338
|
+
const tree = (await git(gitRoot, ["write-tree"], { env })).stdout.trim();
|
|
339
|
+
const baseHead = (await git(gitRoot, ["rev-parse", "--verify", "HEAD^{commit}"])).stdout.trim();
|
|
340
|
+
return { tree, baseHead };
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async function checkpointTree(gitRoot, checkpointCommit) {
|
|
347
|
+
return (await git(gitRoot, ["rev-parse", "--verify", `${checkpointCommit}^{tree}`])).stdout.trim();
|
|
348
|
+
}
|
|
349
|
+
async function applyTreeRestore(gitRoot, currentTree, checkpointTreeId, verifyImmediatelyBeforeApply) {
|
|
350
|
+
const tempDir = await mkdtemp(join(tmpdir(), "forgerelay-checkpoint-restore-"));
|
|
351
|
+
const patchPath = join(tempDir, "restore.patch");
|
|
352
|
+
try {
|
|
353
|
+
const patch = (await git(gitRoot, [
|
|
354
|
+
"diff",
|
|
355
|
+
"--binary",
|
|
356
|
+
"--full-index",
|
|
357
|
+
"--no-renames",
|
|
358
|
+
"--no-ext-diff",
|
|
359
|
+
"--no-textconv",
|
|
360
|
+
currentTree,
|
|
361
|
+
checkpointTreeId,
|
|
362
|
+
"--",
|
|
363
|
+
".",
|
|
364
|
+
], { maxBuffer: 100 * 1024 * 1024 })).stdout;
|
|
365
|
+
await writeFile(patchPath, patch, { encoding: "utf8", mode: 0o600 });
|
|
366
|
+
await git(gitRoot, ["apply", "--check", "--binary", "--whitespace=nowarn", patchPath]);
|
|
367
|
+
await verifyImmediatelyBeforeApply();
|
|
368
|
+
await git(gitRoot, ["apply", "--binary", "--whitespace=nowarn", patchPath]);
|
|
369
|
+
}
|
|
370
|
+
finally {
|
|
371
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
function summarizeNumstat(output) {
|
|
375
|
+
const fields = output.split("\0").filter((field) => field.length > 0);
|
|
376
|
+
let files = 0;
|
|
377
|
+
let additions = 0;
|
|
378
|
+
let removals = 0;
|
|
379
|
+
for (let index = 0; index < fields.length;) {
|
|
380
|
+
const header = fields[index++] ?? "";
|
|
381
|
+
const parts = header.split("\t");
|
|
382
|
+
additions += parseStatNumber(parts[0]);
|
|
383
|
+
removals += parseStatNumber(parts[1]);
|
|
384
|
+
files += 1;
|
|
385
|
+
if (parts.length < 3)
|
|
386
|
+
index += 2;
|
|
387
|
+
}
|
|
388
|
+
return { files, additions, removals };
|
|
389
|
+
}
|
|
390
|
+
function parseStatNumber(value) {
|
|
391
|
+
if (!value || value === "-")
|
|
392
|
+
return 0;
|
|
393
|
+
const parsed = Number(value);
|
|
394
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
395
|
+
}
|
|
396
|
+
function checkpointEnv(indexPath) {
|
|
397
|
+
return {
|
|
398
|
+
...checkpointIdentityEnv(),
|
|
399
|
+
GIT_INDEX_FILE: indexPath,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
function checkpointIdentityEnv() {
|
|
403
|
+
return {
|
|
404
|
+
GIT_AUTHOR_NAME: "ForgeRelay",
|
|
405
|
+
GIT_AUTHOR_EMAIL: "forgerelay@users.noreply.local",
|
|
406
|
+
GIT_COMMITTER_NAME: "ForgeRelay",
|
|
407
|
+
GIT_COMMITTER_EMAIL: "forgerelay@users.noreply.local",
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
async function updateRef(commonDir, ref, commit, oldValue) {
|
|
411
|
+
await git(commonDir, ["--git-dir", commonDir, "update-ref", ref, commit, oldValue]);
|
|
412
|
+
}
|
|
413
|
+
async function deleteRef(commonDir, ref, expectedCommit) {
|
|
414
|
+
await assertCheckpointRefCommit(commonDir, ref, expectedCommit);
|
|
415
|
+
await git(commonDir, ["--git-dir", commonDir, "update-ref", "-d", ref, expectedCommit]);
|
|
416
|
+
}
|
|
417
|
+
async function assertCheckpointRef(commonDir, workspaceId, checkpoint) {
|
|
418
|
+
await assertCheckpointRefCommit(commonDir, checkpointRef(workspaceId, checkpoint.id), checkpoint.commit);
|
|
419
|
+
}
|
|
420
|
+
async function assertCheckpointRefCommit(commonDir, ref, expectedCommit) {
|
|
421
|
+
let actual;
|
|
422
|
+
try {
|
|
423
|
+
actual = (await git(commonDir, ["--git-dir", commonDir, "rev-parse", "--verify", `${ref}^{commit}`])).stdout.trim();
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
throw new Error(`Checkpoint Git ref ${ref} is missing; refusing to mutate inconsistent checkpoint state.`);
|
|
427
|
+
}
|
|
428
|
+
if (actual !== expectedCommit) {
|
|
429
|
+
throw new Error(`Checkpoint Git ref ${ref} no longer matches its immutable checkpoint commit.`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
function checkpointRef(workspaceId, checkpointId) {
|
|
433
|
+
return `${CHECKPOINT_REF_PREFIX}/${safeWorkspaceRefSegment(workspaceId)}/${checkpointId}`;
|
|
434
|
+
}
|
|
435
|
+
async function assertSameRepository(stored, current, workspaceId) {
|
|
436
|
+
const [storedCanonical, currentCanonical] = await Promise.all([
|
|
437
|
+
canonicalExistingPath(stored),
|
|
438
|
+
canonicalExistingPath(current),
|
|
439
|
+
]);
|
|
440
|
+
if (storedCanonical !== currentCanonical) {
|
|
441
|
+
throw new Error(`Workspace checkpoint repository mismatch for ${workspaceId}.`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
async function canonicalExistingPath(path) {
|
|
445
|
+
try {
|
|
446
|
+
return await realpath(path);
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
return resolve(path);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
function normalizeWorkspaceId(workspaceId) {
|
|
453
|
+
const value = workspaceId.trim();
|
|
454
|
+
if (!/^[a-z][a-z0-9_-]{1,127}$/.test(value)) {
|
|
455
|
+
throw new Error("Workspace ID is not valid for checkpoint state.");
|
|
456
|
+
}
|
|
457
|
+
return value;
|
|
458
|
+
}
|
|
459
|
+
function normalizeCheckpointId(checkpointId) {
|
|
460
|
+
const value = checkpointId.trim();
|
|
461
|
+
if (!/^cp_[a-f0-9]{10}$/.test(value))
|
|
462
|
+
throw new Error(`Invalid checkpoint id ${checkpointId}.`);
|
|
463
|
+
return value;
|
|
464
|
+
}
|
|
465
|
+
function normalizeSnapshotId(snapshotId) {
|
|
466
|
+
const value = snapshotId.trim();
|
|
467
|
+
if (!/^[a-f0-9]{40,64}$/.test(value)) {
|
|
468
|
+
throw new Error("Workspace checkpoint snapshot identity must be a Git object id.");
|
|
469
|
+
}
|
|
470
|
+
return value;
|
|
471
|
+
}
|
|
472
|
+
function assertExpectedCurrentSnapshot(expected, actual) {
|
|
473
|
+
if (actual !== expected) {
|
|
474
|
+
throw new Error(`Workspace checkpoint restore refused because the current working snapshot changed: expected ${expected}, found ${actual}. Run restore.preflight again before retrying.`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
function normalizeCheckpointName(name) {
|
|
478
|
+
const value = name.trim();
|
|
479
|
+
if (!value)
|
|
480
|
+
throw new Error("Checkpoint name must not be empty.");
|
|
481
|
+
if (value.length > MAX_CHECKPOINT_NAME_LENGTH) {
|
|
482
|
+
throw new Error(`Checkpoint name must be at most ${MAX_CHECKPOINT_NAME_LENGTH} characters.`);
|
|
483
|
+
}
|
|
484
|
+
return value;
|
|
485
|
+
}
|
|
486
|
+
function normalizeOffset(offset) {
|
|
487
|
+
if (offset === undefined)
|
|
488
|
+
return 0;
|
|
489
|
+
if (!Number.isInteger(offset) || offset < 0)
|
|
490
|
+
throw new Error("Checkpoint list offset must be a non-negative integer.");
|
|
491
|
+
return offset;
|
|
492
|
+
}
|
|
493
|
+
function normalizeLimit(limit) {
|
|
494
|
+
if (limit === undefined)
|
|
495
|
+
return 50;
|
|
496
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
497
|
+
throw new Error("Checkpoint list limit must be an integer between 1 and 100.");
|
|
498
|
+
}
|
|
499
|
+
return limit;
|
|
500
|
+
}
|
|
501
|
+
function requireCheckpoint(state, checkpointId) {
|
|
502
|
+
const checkpoint = state.checkpoints.find((candidate) => candidate.id === checkpointId);
|
|
503
|
+
if (!checkpoint)
|
|
504
|
+
throw new Error(`Unknown Workspace checkpoint ${checkpointId}.`);
|
|
505
|
+
return checkpoint;
|
|
506
|
+
}
|
|
507
|
+
function cloneCheckpoint(checkpoint) {
|
|
508
|
+
return { ...checkpoint, summary: { ...checkpoint.summary } };
|
|
509
|
+
}
|
|
510
|
+
function cloneState(state) {
|
|
511
|
+
return {
|
|
512
|
+
...state,
|
|
513
|
+
checkpoints: state.checkpoints.map(cloneCheckpoint),
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
function zeroOid(length) {
|
|
517
|
+
return "0".repeat(length);
|
|
518
|
+
}
|
|
519
|
+
function isErrno(error, code) {
|
|
520
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
521
|
+
}
|
|
522
|
+
function errorMessage(error) {
|
|
523
|
+
return error instanceof Error ? error.message : String(error);
|
|
524
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akira-tl/forgerelay",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
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/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",
|
|
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/workspaces/relay/tests/checkpoint.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/mcp/server/workspace/workspace-checkpoint.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",
|
|
@@ -9,6 +9,13 @@ const MAX_DIRECT_FILES = 8;
|
|
|
9
9
|
const MAX_DIRECT_DIRS = 8;
|
|
10
10
|
const LINE_LIMIT_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".mjs", ".cjs", ".css"]);
|
|
11
11
|
|
|
12
|
+
// Append-only versioned archives grow by design and must keep their canonical
|
|
13
|
+
// flat paths for release tooling and stable links. They remain subject to all
|
|
14
|
+
// other architecture checks, including code line limits where applicable.
|
|
15
|
+
const DIRECT_FILE_LIMIT_EXEMPT_DIRS = new Set([
|
|
16
|
+
"docs/releases",
|
|
17
|
+
]);
|
|
18
|
+
|
|
12
19
|
// Repository-root protocol files are intentionally discoverable by Git, npm,
|
|
13
20
|
// Node, Vite, contributors, and agents. Keep that conventional surface explicit
|
|
14
21
|
// instead of hiding required/default-discovery files merely to satisfy a count.
|
|
@@ -67,7 +74,7 @@ const directories = new Set([...directFiles.keys(), ...directDirs.keys()]);
|
|
|
67
74
|
for (const directory of [...directories].sort()) {
|
|
68
75
|
const files = directFiles.get(directory) ?? 0;
|
|
69
76
|
const dirs = directDirs.get(directory)?.size ?? 0;
|
|
70
|
-
if (directory !== "." && files > MAX_DIRECT_FILES) {
|
|
77
|
+
if (directory !== "." && !DIRECT_FILE_LIMIT_EXEMPT_DIRS.has(directory) && files > MAX_DIRECT_FILES) {
|
|
71
78
|
violations.push(`${directory}: ${files} direct files > ${MAX_DIRECT_FILES}`);
|
|
72
79
|
}
|
|
73
80
|
if (dirs > MAX_DIRECT_DIRS) {
|
|
@@ -83,7 +90,7 @@ if (violations.length > 0) {
|
|
|
83
90
|
|
|
84
91
|
console.log(
|
|
85
92
|
`Architecture check passed: ${tracked.length} tracked files; ` +
|
|
86
|
-
`code <= ${MAX_LINES} lines; non-root directories <= ${MAX_DIRECT_FILES} files / ${MAX_DIRECT_DIRS} directories.`,
|
|
93
|
+
`code <= ${MAX_LINES} lines; bounded non-root directories <= ${MAX_DIRECT_FILES} files / ${MAX_DIRECT_DIRS} directories.`,
|
|
87
94
|
);
|
|
88
95
|
|
|
89
96
|
function gitTrackedFiles() {
|
|
@@ -60,6 +60,12 @@ test("cross-platform cloud CI delegates to one shell-free verification entrypoin
|
|
|
60
60
|
}
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
test("architecture gate treats the append-only release-note archive as an explicit flat-path exception", async () => {
|
|
64
|
+
const architecture = await readFile(resolve(repoRoot, "scripts/ci/architecture.mjs"), "utf8");
|
|
65
|
+
assert.match(architecture, /DIRECT_FILE_LIMIT_EXEMPT_DIRS/);
|
|
66
|
+
assert.match(architecture, /"docs\/releases"/);
|
|
67
|
+
});
|
|
68
|
+
|
|
63
69
|
test("release runtime and local parity share the checked-in Node contract", async () => {
|
|
64
70
|
const nodeVersion = (await readFile(resolve(repoRoot, ".nvmrc"), "utf8")).trim();
|
|
65
71
|
assert.equal(nodeVersion, "22.19.0");
|