@akira-tl/forgerelay 0.9.2 → 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 +6 -0
- package/capabilities/workspace/workspace-checkpoints/GUIDE.md +35 -4
- package/dist/mcp/server/core/capabilities/workspace-checkpoint.js +9 -0
- package/dist/mcp/server/core/capability-registry.js +1 -1
- package/dist/server.js +6 -0
- package/dist/workspaces/state/workspace-checkpoints.js +129 -13
- package/package.json +1 -1
- package/scripts/ci/architecture.mjs +9 -2
- package/scripts/release/release-gate.test.mjs +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ 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
|
+
|
|
7
13
|
## [0.9.2] - 2026-09-04
|
|
8
14
|
|
|
9
15
|
### Added
|
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
|
|
3
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
4
|
|
|
5
|
-
## v0.9.
|
|
5
|
+
## v0.9.3 surface
|
|
6
6
|
|
|
7
|
-
Supported operations are
|
|
7
|
+
Supported operations are:
|
|
8
8
|
|
|
9
9
|
- `create` — create a named immutable checkpoint of the current Git-visible working tree.
|
|
10
|
-
- `list` — return bounded checkpoint metadata,
|
|
10
|
+
- `list` — return bounded checkpoint metadata, preserving checkpoint creation order.
|
|
11
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.
|
|
12
14
|
- `delete` — explicitly delete one checkpoint and its ForgeRelay-owned Git ref.
|
|
13
15
|
|
|
14
|
-
Restore is
|
|
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.
|
|
15
17
|
|
|
16
18
|
## Create
|
|
17
19
|
|
|
@@ -53,6 +55,35 @@ Use `inspect` only after selecting an id:
|
|
|
53
55
|
|
|
54
56
|
`list` and `inspect` expose bounded metadata only. Checkpoints do not move when `review.changes` advances its independent last-shown baseline.
|
|
55
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
|
+
|
|
56
87
|
## Delete
|
|
57
88
|
|
|
58
89
|
Deletion is explicit:
|
|
@@ -13,6 +13,15 @@ export const workspaceCheckpointInputSchema = z.discriminatedUnion("operation",
|
|
|
13
13
|
operation: z.literal("inspect"),
|
|
14
14
|
checkpointId: z.string().regex(/^cp_[a-f0-9]{10}$/),
|
|
15
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(),
|
|
16
25
|
z.object({
|
|
17
26
|
operation: z.literal("delete"),
|
|
18
27
|
checkpointId: z.string().regex(/^cp_[a-f0-9]{10}$/),
|
|
@@ -308,7 +308,7 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
308
308
|
...(dependencies.workspaceCheckpoint
|
|
309
309
|
? [{
|
|
310
310
|
name: "workspace.checkpoint",
|
|
311
|
-
description: "Create, list, inspect, or delete immutable Git-backed checkpoints owned by the current persistent Workspace.",
|
|
311
|
+
description: "Create, list, inspect, safely restore, or delete immutable Git-backed checkpoints owned by the current persistent Workspace.",
|
|
312
312
|
guideName: "workspace-checkpoints",
|
|
313
313
|
readGuideBeforeFirstUse: true,
|
|
314
314
|
batchPolicy: "unsupported",
|
package/dist/server.js
CHANGED
|
@@ -156,6 +156,12 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
156
156
|
return { value: await workspaceCheckpoints.list(context.workspaceId, root, input) };
|
|
157
157
|
case "inspect":
|
|
158
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
|
+
};
|
|
159
165
|
case "delete":
|
|
160
166
|
return { value: await workspaceCheckpoints.delete(context.workspaceId, root, input.checkpointId) };
|
|
161
167
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { mkdirSync, readFileSync, renameSync, rmSync, rmdirSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { mkdtemp, realpath, rm } from "node:fs/promises";
|
|
3
|
+
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import * as z from "zod/v4";
|
|
@@ -117,6 +117,73 @@ export class WorkspaceCheckpointStore {
|
|
|
117
117
|
await assertCheckpointRef(state.gitCommonDir, id, checkpoint);
|
|
118
118
|
return { workspaceId: id, checkpoint: cloneCheckpoint(checkpoint), ignoredFilesIncluded: false };
|
|
119
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
|
+
}
|
|
120
187
|
async delete(workspaceId, workspaceRoot, checkpointId) {
|
|
121
188
|
const id = normalizeWorkspaceId(workspaceId);
|
|
122
189
|
const cpId = normalizeCheckpointId(checkpointId);
|
|
@@ -247,6 +314,21 @@ async function resolveRepository(workspaceRoot) {
|
|
|
247
314
|
return { gitRoot: eligibility.gitRoot, gitCommonDir: commonDir };
|
|
248
315
|
}
|
|
249
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) {
|
|
250
332
|
const tempDir = await mkdtemp(join(tmpdir(), "forgerelay-checkpoint-index-"));
|
|
251
333
|
const indexPath = join(tempDir, "index");
|
|
252
334
|
const env = checkpointEnv(indexPath);
|
|
@@ -255,18 +337,35 @@ async function createWorkingTreeSnapshot(gitRoot) {
|
|
|
255
337
|
await git(gitRoot, ["add", "-A", "--", "."], { env });
|
|
256
338
|
const tree = (await git(gitRoot, ["write-tree"], { env })).stdout.trim();
|
|
257
339
|
const baseHead = (await git(gitRoot, ["rev-parse", "--verify", "HEAD^{commit}"])).stdout.trim();
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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]);
|
|
270
369
|
}
|
|
271
370
|
finally {
|
|
272
371
|
await rm(tempDir, { recursive: true, force: true });
|
|
@@ -296,7 +395,12 @@ function parseStatNumber(value) {
|
|
|
296
395
|
}
|
|
297
396
|
function checkpointEnv(indexPath) {
|
|
298
397
|
return {
|
|
398
|
+
...checkpointIdentityEnv(),
|
|
299
399
|
GIT_INDEX_FILE: indexPath,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
function checkpointIdentityEnv() {
|
|
403
|
+
return {
|
|
300
404
|
GIT_AUTHOR_NAME: "ForgeRelay",
|
|
301
405
|
GIT_AUTHOR_EMAIL: "forgerelay@users.noreply.local",
|
|
302
406
|
GIT_COMMITTER_NAME: "ForgeRelay",
|
|
@@ -358,6 +462,18 @@ function normalizeCheckpointId(checkpointId) {
|
|
|
358
462
|
throw new Error(`Invalid checkpoint id ${checkpointId}.`);
|
|
359
463
|
return value;
|
|
360
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
|
+
}
|
|
361
477
|
function normalizeCheckpointName(name) {
|
|
362
478
|
const value = name.trim();
|
|
363
479
|
if (!value)
|
package/package.json
CHANGED
|
@@ -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");
|