harnery 0.44.0 → 0.45.0
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/dist/commander.js +1 -0
- package/dist/commands/files.d.ts +2 -2
- package/dist/commands/files.d.ts.map +1 -1
- package/dist/commands/files.js +7 -4
- package/dist/commands/servers.d.ts.map +1 -1
- package/dist/commands/servers.js +11 -17
- package/dist/commands/tunnel.d.ts +15 -0
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +453 -153
- package/dist/commands/turn-rollback.d.ts +5 -0
- package/dist/commands/turn-rollback.d.ts.map +1 -0
- package/dist/commands/turn-rollback.js +76 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +44 -0
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +18 -0
- package/dist/core/agents/state/proc-start.d.ts +5 -6
- package/dist/core/agents/state/proc-start.d.ts.map +1 -1
- package/dist/core/agents/state/proc-start.js +13 -9
- package/dist/core/agents/state/process-handle.d.ts +26 -0
- package/dist/core/agents/state/process-handle.d.ts.map +1 -0
- package/dist/core/agents/state/process-handle.js +121 -0
- package/dist/core/artifacts/delivery-card.d.ts.map +1 -1
- package/dist/core/artifacts/delivery-card.js +2 -1
- package/dist/core/resources/service.d.ts +2 -16
- package/dist/core/resources/service.d.ts.map +1 -1
- package/dist/core/resources/service.js +1 -78
- package/dist/core/semantic/service.d.ts.map +1 -1
- package/dist/core/semantic/service.js +2 -9
- package/dist/core/servers/index.d.ts +12 -11
- package/dist/core/servers/index.d.ts.map +1 -1
- package/dist/core/servers/index.js +194 -81
- package/dist/core/servers/tunnels.d.ts +5 -3
- package/dist/core/servers/tunnels.d.ts.map +1 -1
- package/dist/core/servers/tunnels.js +45 -2
- package/dist/core/supervisor/service.d.ts +2 -4
- package/dist/core/supervisor/service.d.ts.map +1 -1
- package/dist/core/turn-rollback/content.d.ts +53 -0
- package/dist/core/turn-rollback/content.d.ts.map +1 -0
- package/dist/core/turn-rollback/content.js +396 -0
- package/dist/core/turn-rollback/index.d.ts +72 -0
- package/dist/core/turn-rollback/index.d.ts.map +1 -0
- package/dist/core/turn-rollback/index.js +443 -0
- package/dist/core/workflow/index.d.ts +4 -2
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/index.js +2 -1
- package/dist/core/workflow/recovery-audit.d.ts +30 -0
- package/dist/core/workflow/recovery-audit.d.ts.map +1 -0
- package/dist/core/workflow/recovery-audit.js +116 -0
- package/dist/core/workflow/run-state.d.ts +19 -2
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +113 -8
- package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.js +70 -14
- package/dist/core/workflow/workspaces/reclaim.d.ts +6 -1
- package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/recovery.d.ts +2 -0
- package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/recovery.js +24 -0
- package/dist/lib/tunnel/state.d.ts +13 -6
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +58 -12
- package/package.json +6 -1
- package/src/commander.ts +10 -0
- package/src/commands/files.ts +13 -5
- package/src/commands/servers.ts +12 -23
- package/src/commands/tunnel.ts +533 -166
- package/src/commands/turn-rollback.ts +111 -0
- package/src/commands/workflow.ts +59 -0
- package/src/core/agents/coord-client.ts +20 -0
- package/src/core/agents/state/proc-start.ts +14 -11
- package/src/core/agents/state/process-handle.ts +165 -0
- package/src/core/artifacts/delivery-card.ts +7 -1
- package/src/core/resources/service.ts +3 -88
- package/src/core/semantic/service.ts +2 -8
- package/src/core/servers/index.ts +234 -85
- package/src/core/servers/tunnels.ts +55 -3
- package/src/core/supervisor/service.ts +2 -3
- package/src/core/turn-rollback/content.ts +499 -0
- package/src/core/turn-rollback/index.ts +580 -0
- package/src/core/workflow/index.ts +12 -1
- package/src/core/workflow/recovery-audit.ts +181 -0
- package/src/core/workflow/run-state.ts +137 -8
- package/src/core/workflow/workspaces/local-git.ts +86 -15
- package/src/core/workflow/workspaces/reclaim.ts +4 -0
- package/src/core/workflow/workspaces/recovery.ts +23 -0
- package/src/lib/tunnel/state.ts +70 -15
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { WorkspaceBinding } from "../workflow/workspaces/types.js";
|
|
2
|
+
export declare const SNAPSHOT_LIMIT: number;
|
|
3
|
+
export interface BlobEntry {
|
|
4
|
+
oid: string;
|
|
5
|
+
mode: "100644" | "100755";
|
|
6
|
+
}
|
|
7
|
+
export interface ContentEntry {
|
|
8
|
+
path: string;
|
|
9
|
+
index: BlobEntry | null;
|
|
10
|
+
worktree: BlobEntry | null;
|
|
11
|
+
untracked: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface ContentSnapshot {
|
|
14
|
+
head: string;
|
|
15
|
+
index_tree: string;
|
|
16
|
+
worktree_tree: string;
|
|
17
|
+
entries: ContentEntry[];
|
|
18
|
+
total_bytes: number;
|
|
19
|
+
sha256: string;
|
|
20
|
+
}
|
|
21
|
+
interface ObservedContent {
|
|
22
|
+
entries: ContentEntry[];
|
|
23
|
+
blobs: Map<string, Buffer>;
|
|
24
|
+
total: number;
|
|
25
|
+
head: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function git(root: string, args: string[], options?: {
|
|
28
|
+
input?: Buffer | string;
|
|
29
|
+
index?: string;
|
|
30
|
+
bare?: boolean;
|
|
31
|
+
}): Buffer;
|
|
32
|
+
export declare function textGit(root: string, args: string[], options?: Parameters<typeof git>[2]): string;
|
|
33
|
+
export declare function safePaths(binding: WorkspaceBinding, paths: readonly string[]): string[];
|
|
34
|
+
export declare function openParent(binding: WorkspaceBinding, path: string): {
|
|
35
|
+
path: string;
|
|
36
|
+
close(): void;
|
|
37
|
+
};
|
|
38
|
+
export declare function observe(binding: WorkspaceBinding, store: string, paths: string[]): ObservedContent;
|
|
39
|
+
export declare function persistSnapshot(store: string, observed: ObservedContent, ref: string): ContentSnapshot;
|
|
40
|
+
export declare function verifySnapshot(store: string, snapshot: ContentSnapshot): void;
|
|
41
|
+
export declare function currentMatches(binding: WorkspaceBinding, store: string, snapshot: ContentSnapshot): boolean;
|
|
42
|
+
export interface IndexLock {
|
|
43
|
+
path: string;
|
|
44
|
+
privatePath: string;
|
|
45
|
+
publish(): void;
|
|
46
|
+
close(): void;
|
|
47
|
+
}
|
|
48
|
+
export declare function lockIndex(binding: WorkspaceBinding, state: string): IndexLock;
|
|
49
|
+
export declare function stageBefore(binding: WorkspaceBinding, store: string, before: ContentSnapshot, index: IndexLock): void;
|
|
50
|
+
export declare function restoreFile(binding: WorkspaceBinding, store: string, entry: ContentEntry): void;
|
|
51
|
+
export declare function ensureDirectory(path: string): void;
|
|
52
|
+
export {};
|
|
53
|
+
//# sourceMappingURL=content.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"content.d.ts","sourceRoot":"","sources":["../../../src/core/turn-rollback/content.ts"],"names":[],"mappings":"AA4BA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AAExE,eAAO,MAAM,cAAc,QAAkB,CAAC;AAC9C,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,QAAQ,GAAG,QAAQ,CAAC;CAC3B;AACD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,SAAS,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,SAAS,GAAG,IAAI,CAAC;IAC3B,SAAS,EAAE,OAAO,CAAC;CACpB;AACD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;CAChB;AACD,UAAU,eAAe;IACvB,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wBAAgB,GAAG,CACjB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CAAO,GACxE,MAAM,CAsBR;AACD,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,CAAC,EAAE,UAAU,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,CAEjG;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,gBAAgB,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CA2BvF;AAED,wBAAgB,UAAU,CACxB,OAAO,EAAE,gBAAgB,EACzB,IAAI,EAAE,MAAM,GACX;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,IAAI,IAAI,CAAA;CAAE,CA0CjC;AAmFD,wBAAgB,OAAO,CACrB,OAAO,EAAE,gBAAgB,EACzB,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EAAE,GACd,eAAe,CAkCjB;AAqBD,wBAAgB,eAAe,CAC7B,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,eAAe,EACzB,GAAG,EAAE,MAAM,GACV,eAAe,CAajB;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,eAAe,GAAG,IAAI,CAgC7E;AAED,wBAAgB,cAAc,CAC5B,OAAO,EAAE,gBAAgB,EACzB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,eAAe,GACxB,OAAO,CAUT;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,IAAI,IAAI,CAAC;IAChB,KAAK,IAAI,IAAI,CAAC;CACf;AACD,wBAAgB,SAAS,CAAC,OAAO,EAAE,gBAAgB,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,CA8C7E;AAED,wBAAgB,WAAW,CACzB,OAAO,EAAE,gBAAgB,EACzB,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,eAAe,EACvB,KAAK,EAAE,SAAS,GACf,IAAI,CAiBN;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,gBAAgB,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY,GAAG,IAAI,CA2B/F;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAmBlD"}
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, constants, existsSync, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
5
|
+
import { stateDirMode, stateFileMode } from "../storage/modes.js";
|
|
6
|
+
import { fsyncParentDirectory, stableDigest } from "../workflow/durable-record.js";
|
|
7
|
+
import { assertSafeRelativeSegment, containsPath, descriptorPath, filesystemIdentity, sameFilesystemIdentity, } from "../workflow/workspaces/paths.js";
|
|
8
|
+
export const SNAPSHOT_LIMIT = 8 * 1024 * 1024;
|
|
9
|
+
export function git(root, args, options = {}) {
|
|
10
|
+
const env = { ...process.env };
|
|
11
|
+
for (const key of Object.keys(env))
|
|
12
|
+
if (key.startsWith("GIT_"))
|
|
13
|
+
delete env[key];
|
|
14
|
+
Object.assign(env, {
|
|
15
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
16
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
17
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
18
|
+
GIT_LITERAL_PATHSPECS: "1",
|
|
19
|
+
});
|
|
20
|
+
if (args[0] === "check-ignore")
|
|
21
|
+
delete env.GIT_LITERAL_PATHSPECS;
|
|
22
|
+
if (options.index)
|
|
23
|
+
env.GIT_INDEX_FILE = options.index;
|
|
24
|
+
return execFileSync("git", [options.bare ? `--git-dir=${root}` : "-C", ...(options.bare ? [] : [root]), ...args], {
|
|
25
|
+
env,
|
|
26
|
+
input: options.input,
|
|
27
|
+
maxBuffer: SNAPSHOT_LIMIT + 1024 * 1024,
|
|
28
|
+
timeout: 10_000,
|
|
29
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export function textGit(root, args, options) {
|
|
33
|
+
return git(root, args, options).toString("utf8").trim();
|
|
34
|
+
}
|
|
35
|
+
export function safePaths(binding, paths) {
|
|
36
|
+
if (!Array.isArray(paths) || paths.length === 0 || paths.length > 100)
|
|
37
|
+
throw new Error("path count limit is 1 to 100");
|
|
38
|
+
const normalized = paths
|
|
39
|
+
.map((path) => {
|
|
40
|
+
if (typeof path !== "string" || path.length > 2000)
|
|
41
|
+
throw new Error("invalid content path");
|
|
42
|
+
for (const part of path.split("/")) {
|
|
43
|
+
assertSafeRelativeSegment(part, "content path");
|
|
44
|
+
if ([".git", ".harnery"].includes(part.toLowerCase()))
|
|
45
|
+
throw new Error("administrative content path is forbidden");
|
|
46
|
+
}
|
|
47
|
+
if (relative(binding.workspace_root, resolve(binding.workspace_root, path)).replaceAll("\\", "/") !== path)
|
|
48
|
+
throw new Error("content path escapes workspace");
|
|
49
|
+
return path;
|
|
50
|
+
})
|
|
51
|
+
.sort();
|
|
52
|
+
if (new Set(normalized).size !== normalized.length)
|
|
53
|
+
throw new Error("duplicate content paths");
|
|
54
|
+
for (const path of normalized) {
|
|
55
|
+
const opened = openParent(binding, path);
|
|
56
|
+
opened.close();
|
|
57
|
+
}
|
|
58
|
+
return normalized;
|
|
59
|
+
}
|
|
60
|
+
export function openParent(binding, path) {
|
|
61
|
+
const fds = [];
|
|
62
|
+
try {
|
|
63
|
+
const root = binding.workspace_root;
|
|
64
|
+
let fd = openSync(root, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
65
|
+
fds.push(fd);
|
|
66
|
+
if (!sameFilesystemIdentity(filesystemIdentity(descriptorPath(fd)), binding.workspace_root_identity))
|
|
67
|
+
throw new Error("workspace root identity changed");
|
|
68
|
+
for (const segment of path.split("/").slice(0, -1)) {
|
|
69
|
+
fd = openSync(join(descriptorPath(fd), segment), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
70
|
+
fds.push(fd);
|
|
71
|
+
if (existsSync(join(descriptorPath(fd), ".git")))
|
|
72
|
+
throw new Error("nested repository boundary is forbidden");
|
|
73
|
+
}
|
|
74
|
+
const parent = descriptorPath(fd);
|
|
75
|
+
const real = realpathSync(parent);
|
|
76
|
+
if (!containsPath(root, real))
|
|
77
|
+
throw new Error("content parent escaped workspace");
|
|
78
|
+
for (const reserved of [
|
|
79
|
+
binding.repository?.gitdir.realpath,
|
|
80
|
+
binding.repository?.common_dir.realpath,
|
|
81
|
+
]) {
|
|
82
|
+
if (reserved && containsPath(reserved, real))
|
|
83
|
+
throw new Error("Git administrative content is forbidden");
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
path: join(parent, path.split("/").at(-1)),
|
|
87
|
+
close: () => {
|
|
88
|
+
for (const handle of fds.reverse())
|
|
89
|
+
closeSync(handle);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
for (const fd of fds.reverse())
|
|
95
|
+
closeSync(fd);
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function bytesAt(binding, path) {
|
|
100
|
+
const parent = openParent(binding, path);
|
|
101
|
+
try {
|
|
102
|
+
let stat;
|
|
103
|
+
try {
|
|
104
|
+
stat = lstatSync(parent.path);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (error.code === "ENOENT")
|
|
108
|
+
return null;
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
if (!stat.isFile() || stat.nlink !== 1)
|
|
112
|
+
throw new Error("content must be an unlinked regular file");
|
|
113
|
+
if (stat.size > SNAPSHOT_LIMIT)
|
|
114
|
+
throw new Error("snapshot byte limit exceeded");
|
|
115
|
+
const fd = openSync(parent.path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
116
|
+
try {
|
|
117
|
+
const current = fstatSync(fd);
|
|
118
|
+
if (!current.isFile() ||
|
|
119
|
+
current.nlink !== 1 ||
|
|
120
|
+
current.ino !== stat.ino ||
|
|
121
|
+
current.dev !== stat.dev)
|
|
122
|
+
throw new Error("content identity changed");
|
|
123
|
+
const bytes = readFileSync(fd);
|
|
124
|
+
const after = fstatSync(fd);
|
|
125
|
+
if (after.size !== current.size ||
|
|
126
|
+
after.mtimeMs !== current.mtimeMs ||
|
|
127
|
+
after.ctimeMs !== current.ctimeMs)
|
|
128
|
+
throw new Error("content changed during capture");
|
|
129
|
+
return { bytes, mode: current.mode & 0o111 ? "100755" : "100644" };
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
closeSync(fd);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
parent.close();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function indexEntries(binding, paths) {
|
|
140
|
+
const root = binding.workspace_root;
|
|
141
|
+
if (textGit(root, ["rev-parse", "--shared-index-path"]))
|
|
142
|
+
throw new Error("split index is unsupported");
|
|
143
|
+
try {
|
|
144
|
+
if (textGit(root, ["config", "--bool", "index.sparse"]) === "true")
|
|
145
|
+
throw new Error("sparse index is unsupported");
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (!(error && typeof error === "object" && "status" in error && error.status === 1))
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
const flags = git(root, ["ls-files", "-v", "-z", "--", ...paths])
|
|
152
|
+
.toString("utf8")
|
|
153
|
+
.split("\0")
|
|
154
|
+
.filter(Boolean);
|
|
155
|
+
if (flags.some((flag) => !flag.startsWith("H ")))
|
|
156
|
+
throw new Error("selected index flags are unsupported");
|
|
157
|
+
const entries = new Map();
|
|
158
|
+
const debugFlags = git(root, ["ls-files", "--debug", "-z", "--", ...paths]).toString("utf8");
|
|
159
|
+
for (const match of debugFlags.matchAll(/flags: ([a-f0-9]+)\n/g)) {
|
|
160
|
+
if ((Number.parseInt(match[1], 16) & 0x20000000) !== 0)
|
|
161
|
+
throw new Error("intent-to-add index entries are unsupported");
|
|
162
|
+
}
|
|
163
|
+
for (const line of git(root, ["ls-files", "--stage", "-z", "--", ...paths])
|
|
164
|
+
.toString("utf8")
|
|
165
|
+
.split("\0")
|
|
166
|
+
.filter(Boolean)) {
|
|
167
|
+
const match = /^(100644|100755) ([a-f0-9]{40,64}) 0\t(.+)$/.exec(line);
|
|
168
|
+
if (!match || /^0+$/.test(match[2]) || !paths.includes(match[3]))
|
|
169
|
+
throw new Error("unsupported index entry");
|
|
170
|
+
entries.set(match[3], { mode: match[1], source: match[2] });
|
|
171
|
+
}
|
|
172
|
+
return entries;
|
|
173
|
+
}
|
|
174
|
+
export function observe(binding, store, paths) {
|
|
175
|
+
const staged = indexEntries(binding, paths);
|
|
176
|
+
const blobs = new Map();
|
|
177
|
+
let total = 0;
|
|
178
|
+
const blob = (bytes, mode) => {
|
|
179
|
+
total += bytes.length;
|
|
180
|
+
if (total > SNAPSHOT_LIMIT)
|
|
181
|
+
throw new Error("snapshot byte limit exceeded");
|
|
182
|
+
const oid = textGit(store, ["hash-object", "--stdin"], { bare: true, input: bytes });
|
|
183
|
+
blobs.set(oid, bytes);
|
|
184
|
+
return { oid, mode };
|
|
185
|
+
};
|
|
186
|
+
const entries = paths.map((path) => {
|
|
187
|
+
const index = staged.get(path);
|
|
188
|
+
const worktree = bytesAt(binding, path);
|
|
189
|
+
// check-ignore without --no-index only reports untracked ignored paths.
|
|
190
|
+
if (!index) {
|
|
191
|
+
try {
|
|
192
|
+
git(binding.workspace_root, ["check-ignore", "-q", "--", path]);
|
|
193
|
+
throw new Error("ignored content path is forbidden");
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (!(error && typeof error === "object" && "status" in error && error.status === 1))
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
path,
|
|
202
|
+
index: index
|
|
203
|
+
? blob(git(binding.workspace_root, ["cat-file", "blob", index.source]), index.mode)
|
|
204
|
+
: null,
|
|
205
|
+
worktree: worktree ? blob(worktree.bytes, worktree.mode) : null,
|
|
206
|
+
untracked: !index && worktree !== null,
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
return { entries, blobs, total, head: textGit(binding.workspace_root, ["rev-parse", "HEAD"]) };
|
|
210
|
+
}
|
|
211
|
+
function tree(store, entries, side) {
|
|
212
|
+
const temporary = join(dirname(store), `tree-${randomUUID()}.index`);
|
|
213
|
+
try {
|
|
214
|
+
git(store, ["read-tree", "--empty"], { bare: true, index: temporary });
|
|
215
|
+
for (const entry of entries) {
|
|
216
|
+
const blob = entry[side];
|
|
217
|
+
if (blob)
|
|
218
|
+
git(store, ["update-index", "--add", "--cacheinfo", `${blob.mode},${blob.oid},${entry.path}`], { bare: true, index: temporary });
|
|
219
|
+
}
|
|
220
|
+
return textGit(store, ["write-tree"], { bare: true, index: temporary });
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
if (existsSync(temporary))
|
|
224
|
+
unlinkSync(temporary);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
export function persistSnapshot(store, observed, ref) {
|
|
228
|
+
for (const bytes of observed.blobs.values())
|
|
229
|
+
git(store, ["hash-object", "-w", "--stdin"], { bare: true, input: bytes });
|
|
230
|
+
const basis = {
|
|
231
|
+
head: observed.head,
|
|
232
|
+
entries: observed.entries,
|
|
233
|
+
total_bytes: observed.total,
|
|
234
|
+
index_tree: tree(store, observed.entries, "index"),
|
|
235
|
+
worktree_tree: tree(store, observed.entries, "worktree"),
|
|
236
|
+
};
|
|
237
|
+
for (const side of ["index", "worktree"])
|
|
238
|
+
git(store, ["update-ref", `refs/turns/${ref}/${side}`, basis[`${side}_tree`]], { bare: true });
|
|
239
|
+
return { ...basis, sha256: stableDigest(basis) };
|
|
240
|
+
}
|
|
241
|
+
export function verifySnapshot(store, snapshot) {
|
|
242
|
+
const { sha256, ...basis } = snapshot;
|
|
243
|
+
if (sha256 !== stableDigest(basis) ||
|
|
244
|
+
!Number.isSafeInteger(snapshot.total_bytes) ||
|
|
245
|
+
snapshot.total_bytes < 0 ||
|
|
246
|
+
snapshot.total_bytes > SNAPSHOT_LIMIT)
|
|
247
|
+
throw new Error("snapshot digest or byte limit changed");
|
|
248
|
+
let total = 0;
|
|
249
|
+
for (const side of ["index", "worktree"]) {
|
|
250
|
+
const actual = git(store, ["ls-tree", "-r", "-z", snapshot[`${side}_tree`]], { bare: true })
|
|
251
|
+
.toString("utf8")
|
|
252
|
+
.split("\0")
|
|
253
|
+
.filter(Boolean)
|
|
254
|
+
.sort();
|
|
255
|
+
const expected = snapshot.entries
|
|
256
|
+
.filter((entry) => entry[side])
|
|
257
|
+
.map((entry) => `${entry[side].mode} blob ${entry[side].oid}\t${entry.path}`)
|
|
258
|
+
.sort();
|
|
259
|
+
if (stableDigest(actual) !== stableDigest(expected))
|
|
260
|
+
throw new Error("snapshot tree changed");
|
|
261
|
+
for (const entry of snapshot.entries) {
|
|
262
|
+
const blob = entry[side];
|
|
263
|
+
if (blob) {
|
|
264
|
+
const bytes = git(store, ["cat-file", "blob", blob.oid], { bare: true });
|
|
265
|
+
if (textGit(store, ["hash-object", "--stdin"], { bare: true, input: bytes }) !== blob.oid)
|
|
266
|
+
throw new Error("snapshot blob digest changed");
|
|
267
|
+
total += bytes.length;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (total !== snapshot.total_bytes)
|
|
272
|
+
throw new Error("snapshot byte count changed");
|
|
273
|
+
}
|
|
274
|
+
export function currentMatches(binding, store, snapshot) {
|
|
275
|
+
const current = observe(binding, store, snapshot.entries.map((entry) => entry.path));
|
|
276
|
+
return (current.head === snapshot.head &&
|
|
277
|
+
stableDigest(current.entries) === stableDigest(snapshot.entries));
|
|
278
|
+
}
|
|
279
|
+
export function lockIndex(binding, state) {
|
|
280
|
+
const path = join(binding.repository.gitdir.realpath, "index");
|
|
281
|
+
const lock = `${path}.lock`;
|
|
282
|
+
const fd = openSync(lock, "wx", stateFileMode());
|
|
283
|
+
const identity = fstatSync(fd, { bigint: true });
|
|
284
|
+
const privatePath = join(state, `live-${randomUUID()}.index`);
|
|
285
|
+
let published = false;
|
|
286
|
+
const exact = () => {
|
|
287
|
+
const current = lstatSync(lock, { bigint: true });
|
|
288
|
+
if (!current.isFile() || current.dev !== identity.dev || current.ino !== identity.ino)
|
|
289
|
+
throw new Error("Git index lock changed");
|
|
290
|
+
};
|
|
291
|
+
try {
|
|
292
|
+
writeFileSync(privatePath, readFileSync(path), { flag: "wx", mode: stateFileMode() });
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
closeSync(fd);
|
|
296
|
+
exact();
|
|
297
|
+
unlinkSync(lock);
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
path,
|
|
302
|
+
privatePath,
|
|
303
|
+
publish() {
|
|
304
|
+
exact();
|
|
305
|
+
writeFileSync(fd, readFileSync(privatePath));
|
|
306
|
+
fsyncSync(fd);
|
|
307
|
+
exact();
|
|
308
|
+
renameSync(lock, path);
|
|
309
|
+
published = true;
|
|
310
|
+
const dir = openSync(dirname(path), "r");
|
|
311
|
+
try {
|
|
312
|
+
fsyncSync(dir);
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
closeSync(dir);
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
close() {
|
|
319
|
+
closeSync(fd);
|
|
320
|
+
if (!published) {
|
|
321
|
+
exact();
|
|
322
|
+
unlinkSync(lock);
|
|
323
|
+
}
|
|
324
|
+
if (existsSync(privatePath))
|
|
325
|
+
unlinkSync(privatePath);
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
export function stageBefore(binding, store, before, index) {
|
|
330
|
+
for (const entry of before.entries) {
|
|
331
|
+
if (entry.index) {
|
|
332
|
+
const bytes = git(store, ["cat-file", "blob", entry.index.oid], { bare: true });
|
|
333
|
+
const sourceOid = textGit(binding.workspace_root, ["hash-object", "-w", "--stdin"], {
|
|
334
|
+
input: bytes,
|
|
335
|
+
});
|
|
336
|
+
git(binding.workspace_root, ["update-index", "--add", "--cacheinfo", `${entry.index.mode},${sourceOid},${entry.path}`], { index: index.privatePath });
|
|
337
|
+
}
|
|
338
|
+
else
|
|
339
|
+
git(binding.workspace_root, ["update-index", "--force-remove", "--", entry.path], {
|
|
340
|
+
index: index.privatePath,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
export function restoreFile(binding, store, entry) {
|
|
345
|
+
const parent = openParent(binding, entry.path);
|
|
346
|
+
const temporary = `${parent.path}.restore-${randomUUID()}`;
|
|
347
|
+
try {
|
|
348
|
+
if (!entry.worktree) {
|
|
349
|
+
if (existsSync(parent.path))
|
|
350
|
+
unlinkSync(parent.path);
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
const fd = openSync(temporary, "wx", entry.worktree.mode === "100755" ? 0o755 : 0o644);
|
|
354
|
+
try {
|
|
355
|
+
fchmodSync(fd, entry.worktree.mode === "100755" ? 0o755 : 0o644);
|
|
356
|
+
writeFileSync(fd, git(store, ["cat-file", "blob", entry.worktree.oid], { bare: true }));
|
|
357
|
+
fsyncSync(fd);
|
|
358
|
+
}
|
|
359
|
+
finally {
|
|
360
|
+
closeSync(fd);
|
|
361
|
+
}
|
|
362
|
+
renameSync(temporary, parent.path);
|
|
363
|
+
}
|
|
364
|
+
const fd = openSync(dirname(parent.path), "r");
|
|
365
|
+
try {
|
|
366
|
+
fsyncSync(fd);
|
|
367
|
+
}
|
|
368
|
+
finally {
|
|
369
|
+
closeSync(fd);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
if (existsSync(temporary))
|
|
374
|
+
unlinkSync(temporary);
|
|
375
|
+
parent.close();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
export function ensureDirectory(path) {
|
|
379
|
+
if (!existsSync(path)) {
|
|
380
|
+
ensureDirectory(dirname(path));
|
|
381
|
+
mkdirSync(path, { mode: stateDirMode() });
|
|
382
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
383
|
+
try {
|
|
384
|
+
fchmodSync(fd, stateDirMode());
|
|
385
|
+
fsyncSync(fd);
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
closeSync(fd);
|
|
389
|
+
}
|
|
390
|
+
fsyncParentDirectory(path);
|
|
391
|
+
}
|
|
392
|
+
if (!lstatSync(path).isDirectory() ||
|
|
393
|
+
lstatSync(path).isSymbolicLink() ||
|
|
394
|
+
realpathSync(path) !== resolve(path))
|
|
395
|
+
throw new Error("rollback state path is unsafe");
|
|
396
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { WorkspaceBinding } from "../workflow/workspaces/types.js";
|
|
2
|
+
import { type ContentEntry, type ContentSnapshot } from "./content.js";
|
|
3
|
+
export type { ContentEntry, ContentSnapshot } from "./content.js";
|
|
4
|
+
export type TurnIdentity = {
|
|
5
|
+
turn_id: string;
|
|
6
|
+
generation: number;
|
|
7
|
+
};
|
|
8
|
+
export interface TurnCapture {
|
|
9
|
+
schema_version: 1;
|
|
10
|
+
capture_id: string;
|
|
11
|
+
binding_sha256: string;
|
|
12
|
+
binding_generation: number;
|
|
13
|
+
turn: TurnIdentity;
|
|
14
|
+
paths: string[];
|
|
15
|
+
before: ContentSnapshot;
|
|
16
|
+
sha256: string;
|
|
17
|
+
end?: ContentSnapshot;
|
|
18
|
+
}
|
|
19
|
+
export interface TurnRollbackPlan {
|
|
20
|
+
schema_version: 1;
|
|
21
|
+
capture_id: string;
|
|
22
|
+
binding_sha256: string;
|
|
23
|
+
turn: TurnIdentity;
|
|
24
|
+
before_sha256: string;
|
|
25
|
+
end_sha256: string;
|
|
26
|
+
paths: Array<{
|
|
27
|
+
path: string;
|
|
28
|
+
before: ContentEntry;
|
|
29
|
+
end: ContentEntry;
|
|
30
|
+
}>;
|
|
31
|
+
plan_sha256: string;
|
|
32
|
+
}
|
|
33
|
+
export interface TurnRollbackResult {
|
|
34
|
+
schema_version: 1;
|
|
35
|
+
status: "preview" | "applied" | "already_applied" | "unknown";
|
|
36
|
+
capture_id: string;
|
|
37
|
+
plan_sha256: string;
|
|
38
|
+
reason?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface TurnRollbackStatus {
|
|
41
|
+
status: "missing" | "open" | "sealed" | "applied" | "unknown";
|
|
42
|
+
capture_id: string;
|
|
43
|
+
}
|
|
44
|
+
type CaptureInput = {
|
|
45
|
+
binding: WorkspaceBinding;
|
|
46
|
+
capture_id: string;
|
|
47
|
+
};
|
|
48
|
+
/** Capture the exact named files before a turn, without staging anything. */
|
|
49
|
+
export declare function beginTurnRollback(coordRoot: string, input: {
|
|
50
|
+
binding: WorkspaceBinding;
|
|
51
|
+
turn: TurnIdentity;
|
|
52
|
+
paths: readonly string[];
|
|
53
|
+
onSnapshotStep?: (step: "content_persisted", captureId: string) => void;
|
|
54
|
+
}): Promise<TurnCapture>;
|
|
55
|
+
/** Seal the end boundary of the latest captured turn. */
|
|
56
|
+
export declare function sealTurnRollback(coordRoot: string, input: CaptureInput & {
|
|
57
|
+
onSnapshotStep?: (step: "content_persisted", captureId: string) => void;
|
|
58
|
+
}): Promise<TurnCapture>;
|
|
59
|
+
/** Produce a dry-run exact-path proposal bound to both captured boundaries. */
|
|
60
|
+
export declare function prepareTurnRollback(coordRoot: string, input: CaptureInput): Promise<TurnRollbackPlan>;
|
|
61
|
+
export declare function applyTurnRollback(coordRoot: string, input: {
|
|
62
|
+
binding: WorkspaceBinding;
|
|
63
|
+
plan: TurnRollbackPlan;
|
|
64
|
+
yes?: boolean;
|
|
65
|
+
actor?: string;
|
|
66
|
+
reason?: string;
|
|
67
|
+
/** Observation/fault boundary; does not replace any authority or content guard. */
|
|
68
|
+
onStep?: (step: "started" | "worktree_published" | "index_published") => void;
|
|
69
|
+
}): Promise<TurnRollbackResult>;
|
|
70
|
+
/** Read recovery evidence without attempting to continue an interrupted restore. */
|
|
71
|
+
export declare function inspectTurnRollback(coordRoot: string, input: CaptureInput): TurnRollbackStatus;
|
|
72
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/core/turn-rollback/index.ts"],"names":[],"mappings":"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"}
|