@davideasden/pi-undo 0.1.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/src/model.ts ADDED
@@ -0,0 +1,160 @@
1
+ export type RootState = "active" | "uninitialized" | "broken";
2
+
3
+ export type ManifestId = string & {
4
+ readonly __manifestId: unique symbol;
5
+ };
6
+
7
+ export type TopologyFingerprint = string;
8
+ export type WorkspaceFingerprint = string;
9
+
10
+ export type ResultCode =
11
+ | "ok"
12
+ | "noop"
13
+ | "busy"
14
+ | "idle_timeout"
15
+ | "capture_failed"
16
+ | "restore_failed_safe"
17
+ | "partial_restore"
18
+ | "recovery_required"
19
+ | "history_paused"
20
+ | "refill_skipped"
21
+ | "refill_failed";
22
+
23
+ export interface RootTopologyIdentity {
24
+ readonly relativeRoot: string;
25
+ readonly parentRoot: string | null;
26
+ readonly state: RootState;
27
+ readonly sourceIdentity: string;
28
+ readonly privateRepositoryId: string;
29
+ readonly gitlinkOid?: string;
30
+ }
31
+
32
+ export interface DiscoveryRoot extends RootTopologyIdentity {
33
+ readonly treeId: string | null;
34
+ readonly gitBacked: boolean;
35
+ }
36
+
37
+ export interface SnapshotRoot extends RootTopologyIdentity {
38
+ readonly treeId: string | null;
39
+ readonly coverage: string;
40
+ readonly ignorePolicy: string;
41
+ readonly ignoredPresentPaths: readonly string[];
42
+ readonly ignoreClosure: string;
43
+ readonly objectClosure: string;
44
+ }
45
+
46
+ export interface SnapshotManifest {
47
+ readonly schemaVersion: 1;
48
+ readonly manifestId: ManifestId;
49
+ readonly workspaceIdentity: WorkspaceFingerprint;
50
+ readonly topologyFingerprint: TopologyFingerprint;
51
+ readonly coverage: string;
52
+ readonly roots: readonly SnapshotRoot[];
53
+ readonly createdAt: string;
54
+ }
55
+
56
+ export interface CheckpointRecord {
57
+ readonly schemaVersion: 1;
58
+ readonly checkpointId: string;
59
+ readonly runId: string;
60
+ readonly sessionIdentity: SessionFileIdentity;
61
+ readonly startEntryId: string;
62
+ readonly userEntryId: string;
63
+ readonly endLeafId: string;
64
+ readonly rawPrompt: string;
65
+ readonly beforeManifestId: ManifestId;
66
+ readonly afterManifestId: ManifestId;
67
+ readonly changedPaths: readonly string[];
68
+ readonly checksum: string;
69
+ }
70
+
71
+ export interface SessionFileIdentity {
72
+ readonly path: string;
73
+ readonly headerChecksum: string;
74
+ }
75
+
76
+ export interface CursorState {
77
+ readonly schemaVersion: 1;
78
+ readonly opId: string;
79
+ readonly action: "undo" | "redo" | "tree";
80
+ readonly sessionIdentity: SessionFileIdentity;
81
+ readonly fromLogicalLeaf: string | null;
82
+ readonly toLogicalLeaf: string | null;
83
+ readonly targetManifestId: ManifestId;
84
+ readonly rollbackManifestId: ManifestId;
85
+ readonly undoHead: string | null;
86
+ readonly redoStack: readonly string[];
87
+ readonly descriptorChecksum: string;
88
+ readonly checksum: string;
89
+ }
90
+
91
+ export type JournalPhase =
92
+ | "PREPARING"
93
+ | "PREPARED"
94
+ | "SESSION_MOVED"
95
+ | "APPLYING"
96
+ | "FILES_VERIFIED"
97
+ | "CURSOR_COMMITTED"
98
+ | "COMMITTED"
99
+ | "ABORTING"
100
+ | "ABORTED"
101
+ | "RECOVERY_REQUIRED";
102
+
103
+ export interface OperationDescriptor {
104
+ readonly schemaVersion: 1;
105
+ readonly opId: string;
106
+ readonly sessionIdentity: SessionFileIdentity;
107
+ readonly workspaceIdentity: WorkspaceFingerprint;
108
+ readonly action: "undo" | "redo" | "tree";
109
+ readonly fromLogicalLeaf: string | null;
110
+ readonly toLogicalLeaf: string | null;
111
+ readonly targetManifestId: ManifestId;
112
+ readonly rollbackManifestId: ManifestId;
113
+ readonly coverage: string;
114
+ readonly scopePaths: readonly string[];
115
+ readonly planDigest: string;
116
+ readonly checksum: string;
117
+ }
118
+
119
+ export interface JournalState {
120
+ readonly schemaVersion: 1;
121
+ readonly opId: string;
122
+ readonly phase: JournalPhase;
123
+ readonly revision: number;
124
+ readonly descriptorChecksum: string;
125
+ readonly observedLogicalLeaf?: string | null;
126
+ readonly checksum: string;
127
+ }
128
+
129
+ export type MutationState =
130
+ | "INTENT"
131
+ | "SOURCE_QUARANTINED"
132
+ | "SOURCE_VERIFIED"
133
+ | "TARGET_INSTALLED"
134
+ | "TARGET_VERIFIED"
135
+ | "CLEANED";
136
+
137
+ export interface MutationRecord {
138
+ readonly schemaVersion: 1;
139
+ readonly opId: string;
140
+ readonly ordinal: number;
141
+ readonly state: MutationState;
142
+ readonly kind: "write" | "delete" | "symlink";
143
+ readonly path: string;
144
+ readonly sourceArtifact: string;
145
+ readonly targetArtifact: string | null;
146
+ readonly sourceFingerprint: string;
147
+ readonly targetFingerprint: string;
148
+ readonly previousChecksum: string | null;
149
+ readonly checksum: string;
150
+ }
151
+
152
+ export interface RestorePath {
153
+ readonly relativePath: string;
154
+ readonly kind: "file" | "directory" | "symlink";
155
+ readonly mode: number;
156
+ readonly blobId: string | null;
157
+ readonly size: number;
158
+ readonly rootHash: string;
159
+ readonly linkText?: string;
160
+ }
@@ -0,0 +1,229 @@
1
+ import { open, readFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+
4
+ import { fsyncDirectory } from "./atomic-fs.ts";
5
+ import { assertMutationRecord, assertOperationId, canonicalJson, checksum } from "./encoding.ts";
6
+ import type { MutationRecord, MutationState } from "./model.ts";
7
+
8
+ export interface MutationIntent {
9
+ readonly kind: MutationRecord["kind"];
10
+ readonly path: string;
11
+ readonly sourceArtifact: string;
12
+ readonly targetArtifact: string | null;
13
+ readonly sourceFingerprint: string;
14
+ readonly targetFingerprint: string;
15
+ }
16
+
17
+ const stateOrder: readonly MutationState[] = [
18
+ "INTENT",
19
+ "SOURCE_QUARANTINED",
20
+ "SOURCE_VERIFIED",
21
+ "TARGET_INSTALLED",
22
+ "TARGET_VERIFIED",
23
+ "CLEANED",
24
+ ];
25
+
26
+ export class MutationJournal {
27
+ private readonly path: string;
28
+ private readonly opId: string;
29
+ private mutationQueue: Promise<void> = Promise.resolve();
30
+
31
+ constructor(path: string, opId: string) {
32
+ this.path = path;
33
+ this.opId = assertOperationId(opId);
34
+ }
35
+
36
+ get operationId(): string {
37
+ return this.opId;
38
+ }
39
+
40
+ get storagePath(): string {
41
+ return this.path;
42
+ }
43
+
44
+ async load(): Promise<readonly MutationRecord[]> {
45
+ return (await this.readRecords()).latest;
46
+ }
47
+
48
+ begin(intent: MutationIntent): Promise<MutationRecord> {
49
+ return this.enqueueMutation(() => this.beginMutation(intent));
50
+ }
51
+
52
+ private async beginMutation(intent: MutationIntent): Promise<MutationRecord> {
53
+ const current = await this.readRecords();
54
+ const content = {
55
+ schemaVersion: 1 as const,
56
+ opId: this.opId,
57
+ ordinal: current.latest.length + 1,
58
+ state: "INTENT" as const,
59
+ kind: intent.kind,
60
+ path: intent.path,
61
+ sourceArtifact: intent.sourceArtifact,
62
+ targetArtifact: intent.targetArtifact,
63
+ sourceFingerprint: intent.sourceFingerprint,
64
+ targetFingerprint: intent.targetFingerprint,
65
+ previousChecksum: current.tail?.checksum ?? null,
66
+ };
67
+ const record = assertMutationRecord({ ...content, checksum: checksum(canonicalJson(content)) });
68
+ await this.append(record, current.durableEnd, current.hasNonDurableTail);
69
+ return record;
70
+ }
71
+
72
+ advance(ordinal: number, state: MutationState): Promise<MutationRecord> {
73
+ return this.enqueueMutation(() => this.advanceMutation(ordinal, state));
74
+ }
75
+
76
+ markRollbackCleaned(ordinal: number): Promise<MutationRecord> {
77
+ return this.enqueueMutation(() => this.markRollbackCleanedMutation(ordinal));
78
+ }
79
+
80
+ private async markRollbackCleanedMutation(ordinal: number): Promise<MutationRecord> {
81
+ const current = await this.readRecords();
82
+ const previous = current.latest[ordinal - 1];
83
+ if (previous === undefined || previous.state === "CLEANED") {
84
+ throw new Error(`mutation rollback 终结状态无效:${previous?.state ?? "missing"}`);
85
+ }
86
+ return this.appendState(current, previous, "CLEANED");
87
+ }
88
+
89
+ private async advanceMutation(ordinal: number, state: MutationState): Promise<MutationRecord> {
90
+ const current = await this.readRecords();
91
+ const previous = current.latest[ordinal - 1];
92
+ if (previous === undefined || stateOrder.indexOf(state) !== stateOrder.indexOf(previous.state) + 1) {
93
+ throw new Error(`mutation state 必须严格推进:${previous?.state ?? "missing"} -> ${state}`);
94
+ }
95
+ return this.appendState(current, previous, state);
96
+ }
97
+
98
+ private async appendState(
99
+ current: Awaited<ReturnType<MutationJournal["readRecords"]>>,
100
+ previous: MutationRecord,
101
+ state: MutationState,
102
+ ): Promise<MutationRecord> {
103
+ const content = {
104
+ schemaVersion: previous.schemaVersion,
105
+ opId: previous.opId,
106
+ ordinal: previous.ordinal,
107
+ state,
108
+ kind: previous.kind,
109
+ path: previous.path,
110
+ sourceArtifact: previous.sourceArtifact,
111
+ targetArtifact: previous.targetArtifact,
112
+ sourceFingerprint: previous.sourceFingerprint,
113
+ targetFingerprint: previous.targetFingerprint,
114
+ previousChecksum: current.tail?.checksum ?? null,
115
+ };
116
+ const record = assertMutationRecord({ ...content, checksum: checksum(canonicalJson(content)) });
117
+ await this.append(record, current.durableEnd, current.hasNonDurableTail);
118
+ return record;
119
+ }
120
+
121
+ private enqueueMutation<T>(operation: () => Promise<T>): Promise<T> {
122
+ const result = this.mutationQueue.then(operation);
123
+ this.mutationQueue = result.then(() => undefined, () => undefined);
124
+ return result;
125
+ }
126
+
127
+ async activeArtifacts(): Promise<ReadonlySet<string>> {
128
+ const artifacts = new Set<string>();
129
+ for (const record of await this.load()) {
130
+ if (record.state === "CLEANED") continue;
131
+ artifacts.add(record.sourceArtifact);
132
+ if (record.targetArtifact !== null) artifacts.add(record.targetArtifact);
133
+ }
134
+ return artifacts;
135
+ }
136
+
137
+ async assertCleaned(): Promise<void> {
138
+ if ((await this.load()).some((record) => record.state !== "CLEANED")) {
139
+ throw new Error("mutation journal 仍有未清理 artifact");
140
+ }
141
+ }
142
+
143
+ private async readRecords(): Promise<{
144
+ readonly latest: readonly MutationRecord[];
145
+ readonly tail: MutationRecord | undefined;
146
+ readonly durableEnd: number;
147
+ readonly hasNonDurableTail: boolean;
148
+ }> {
149
+ let bytes: Buffer;
150
+ try {
151
+ bytes = await readFile(this.path);
152
+ } catch (error) {
153
+ if (hasErrorCode(error, "ENOENT")) {
154
+ return { latest: [], tail: undefined, durableEnd: 0, hasNonDurableTail: false };
155
+ }
156
+ throw error;
157
+ }
158
+
159
+ const durableEnd = bytes.at(-1) === 0x0a ? bytes.length : bytes.lastIndexOf(0x0a) + 1;
160
+ const durable = bytes.subarray(0, durableEnd).toString("utf8");
161
+ const lines = durable === "" ? [] : durable.slice(0, -1).split("\n");
162
+ const latest: MutationRecord[] = [];
163
+ let tail: MutationRecord | undefined;
164
+
165
+ for (const line of lines) {
166
+ const record = assertMutationRecord(JSON.parse(line));
167
+ if (record.opId !== this.opId) throw new Error("mutation record opId 与 journal 不匹配");
168
+ if (record.previousChecksum !== (tail?.checksum ?? null)) {
169
+ throw new Error("mutation journal hash chain 断裂");
170
+ }
171
+
172
+ const previous = latest[record.ordinal - 1];
173
+ if (previous === undefined) {
174
+ if (record.ordinal !== latest.length + 1) throw new Error("mutation ordinal 不连续");
175
+ if (record.state !== "INTENT") throw new Error("mutation ordinal 必须从 INTENT 开始");
176
+ } else {
177
+ if (immutablePayload(previous) !== immutablePayload(record)) {
178
+ throw new Error("同一 mutation ordinal 的 immutable payload 冲突");
179
+ }
180
+ if (!isValidDurableTransition(previous.state, record.state)) {
181
+ throw new Error("mutation state 未严格推进");
182
+ }
183
+ }
184
+ latest[record.ordinal - 1] = record;
185
+ tail = record;
186
+ }
187
+
188
+ return { latest, tail, durableEnd, hasNonDurableTail: durableEnd !== bytes.length };
189
+ }
190
+
191
+ private async append(record: MutationRecord, durableEnd: number, hasNonDurableTail: boolean): Promise<void> {
192
+ const directory = dirname(this.path);
193
+ const handle = await open(this.path, "a+", 0o600);
194
+ try {
195
+ if (hasNonDurableTail) {
196
+ await handle.truncate(durableEnd);
197
+ await handle.sync();
198
+ await fsyncDirectory(directory);
199
+ }
200
+ await handle.writeFile(`${canonicalJson(record)}\n`);
201
+ await handle.sync();
202
+ } finally {
203
+ await handle.close();
204
+ }
205
+ await fsyncDirectory(directory);
206
+ }
207
+ }
208
+
209
+ function isValidDurableTransition(previous: MutationState, next: MutationState): boolean {
210
+ return stateOrder.indexOf(next) === stateOrder.indexOf(previous) + 1 ||
211
+ (next === "CLEANED" && previous !== "CLEANED");
212
+ }
213
+
214
+ function immutablePayload(record: MutationRecord): string {
215
+ return canonicalJson({
216
+ opId: record.opId,
217
+ ordinal: record.ordinal,
218
+ kind: record.kind,
219
+ path: record.path,
220
+ sourceArtifact: record.sourceArtifact,
221
+ targetArtifact: record.targetArtifact,
222
+ sourceFingerprint: record.sourceFingerprint,
223
+ targetFingerprint: record.targetFingerprint,
224
+ });
225
+ }
226
+
227
+ function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
228
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
229
+ }
@@ -0,0 +1,121 @@
1
+ import { lstat } from "node:fs/promises";
2
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
+
4
+ export type PathSafetyErrorCode = "unsafe_path" | "symlink_escape";
5
+
6
+ export class PathSafetyError extends Error {
7
+ readonly code: PathSafetyErrorCode;
8
+
9
+ constructor(code: PathSafetyErrorCode, message: string) {
10
+ super(message);
11
+ this.name = "PathSafetyError";
12
+ this.code = code;
13
+ }
14
+ }
15
+
16
+ export function relativeSafePath(root: string, candidate: string): string {
17
+ if (typeof root !== "string" || root.length === 0 || root.includes("\0")) {
18
+ fail("unsafe_path", "root 路径无效");
19
+ }
20
+ assertRelativeCandidate(candidate);
21
+ if (candidate === ".") {
22
+ return candidate;
23
+ }
24
+
25
+ const canonicalRoot = resolve(root);
26
+ const absoluteCandidate = resolve(canonicalRoot, candidate);
27
+ const canonicalRelative = relative(canonicalRoot, absoluteCandidate);
28
+ if (
29
+ canonicalRelative === ".." ||
30
+ canonicalRelative.startsWith(`..${sep}`) ||
31
+ isAbsolute(canonicalRelative)
32
+ ) {
33
+ fail("unsafe_path", "candidate 不在 root 内或不是规范路径");
34
+ }
35
+ return candidate;
36
+ }
37
+
38
+ export async function assertNoSymlinkEscape(root: string, relativePath: string): Promise<void> {
39
+ const safePath = relativeSafePath(root, relativePath);
40
+ if (safePath === ".") {
41
+ return;
42
+ }
43
+
44
+ let current = resolve(root);
45
+ const parts = safePath.split("/");
46
+ for (let index = 0; index < parts.length; index += 1) {
47
+ current = join(current, parts[index]);
48
+ try {
49
+ const metadata = await lstat(current);
50
+ if (index < parts.length - 1) {
51
+ if (metadata.isSymbolicLink()) {
52
+ fail("symlink_escape", "中间路径组件不能是 symlink");
53
+ }
54
+ if (!metadata.isDirectory()) {
55
+ fail("unsafe_path", "中间路径组件不是目录");
56
+ }
57
+ }
58
+ } catch (error) {
59
+ if (hasErrorCode(error, "ENOENT")) {
60
+ return;
61
+ }
62
+ throw error;
63
+ }
64
+ }
65
+ }
66
+
67
+ export function sortDeletePaths(paths: readonly string[]): string[] {
68
+ return sortPaths(paths, -1);
69
+ }
70
+
71
+ export function sortWritePaths(paths: readonly string[]): string[] {
72
+ return sortPaths(paths, 1);
73
+ }
74
+
75
+ function sortPaths(paths: readonly string[], direction: -1 | 1): string[] {
76
+ for (const path of paths) {
77
+ assertRelativeCandidate(path);
78
+ }
79
+ return [...paths].sort((left, right) => {
80
+ const depthDifference = pathDepth(left) - pathDepth(right);
81
+ if (depthDifference !== 0) {
82
+ return depthDifference * direction;
83
+ }
84
+ return comparePaths(left, right);
85
+ });
86
+ }
87
+
88
+ function assertRelativeCandidate(candidate: string): void {
89
+ if (
90
+ typeof candidate !== "string" ||
91
+ candidate.length === 0 ||
92
+ candidate.includes("\0") ||
93
+ candidate.includes("\\") ||
94
+ isAbsolute(candidate) ||
95
+ /^[A-Za-z]:/.test(candidate)
96
+ ) {
97
+ fail("unsafe_path", "candidate 必须是相对 POSIX 路径");
98
+ }
99
+ if (candidate === ".") {
100
+ return;
101
+ }
102
+ if (candidate.split("/").some((part) => part.length === 0 || part === "." || part === "..")) {
103
+ fail("unsafe_path", "candidate 包含不安全路径组件");
104
+ }
105
+ }
106
+
107
+ function pathDepth(path: string): number {
108
+ return path === "." ? 0 : path.split("/").length;
109
+ }
110
+
111
+ function comparePaths(left: string, right: string): number {
112
+ return left < right ? -1 : left > right ? 1 : 0;
113
+ }
114
+
115
+ function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
116
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
117
+ }
118
+
119
+ function fail(code: PathSafetyErrorCode, message: string): never {
120
+ throw new PathSafetyError(code, message);
121
+ }