pi-agent-replicate 0.0.0-stage → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +27 -0
- package/CHANGELOG.md +152 -0
- package/CONTEXT.md +63 -0
- package/LICENSE +21 -0
- package/README.md +68 -2
- package/RELEASING.md +132 -0
- package/assets/banner.png +0 -0
- package/config.json.example +6 -0
- package/docs/README.md +23 -0
- package/docs/adr/0003-installable-role-packs.md +29 -0
- package/docs/adr/0008-adopt-pi-only-subagent-execution.md +22 -0
- package/docs/adr/0009-durable-recursive-subagent-ownership.md +23 -0
- package/docs/adr/0010-public-subagent-tree-registry.md +16 -0
- package/docs/configuration.md +14 -0
- package/docs/development.md +27 -0
- package/docs/public-subagent-tree-api.md +58 -0
- package/docs/retained-roles.md +71 -0
- package/docs/settled-lifecycle-contract.md +71 -0
- package/docs/worktree-subagents.md +58 -0
- package/examples/role-pack/extension.ts +18 -0
- package/examples/role-pack/package.json +16 -0
- package/examples/role-pack/roles/example-reviewer.md +12 -0
- package/index.d.ts +116 -0
- package/index.js +1064 -0
- package/index.ts +155 -0
- package/package.json +70 -4
- package/pi-extension/subagents/activity.js +575 -0
- package/pi-extension/subagents/activity.ts +980 -0
- package/pi-extension/subagents/completion.js +184 -0
- package/pi-extension/subagents/completion.ts +229 -0
- package/pi-extension/subagents/delivery-drain.js +15 -0
- package/pi-extension/subagents/delivery-drain.ts +20 -0
- package/pi-extension/subagents/delivery-files.js +86 -0
- package/pi-extension/subagents/delivery-files.ts +105 -0
- package/pi-extension/subagents/delivery-log.js +51 -0
- package/pi-extension/subagents/delivery-log.ts +51 -0
- package/pi-extension/subagents/delivery-payload.ts +55 -0
- package/pi-extension/subagents/delivery.ts +219 -0
- package/pi-extension/subagents/execution-owner.js +30 -0
- package/pi-extension/subagents/execution-owner.ts +38 -0
- package/pi-extension/subagents/herdr.js +645 -0
- package/pi-extension/subagents/herdr.ts +927 -0
- package/pi-extension/subagents/index.ts +3400 -0
- package/pi-extension/subagents/launch.js +774 -0
- package/pi-extension/subagents/launch.ts +1124 -0
- package/pi-extension/subagents/lifecycle.js +477 -0
- package/pi-extension/subagents/lifecycle.ts +582 -0
- package/pi-extension/subagents/lineage.js +806 -0
- package/pi-extension/subagents/lineage.ts +841 -0
- package/pi-extension/subagents/replica-background.js +32 -0
- package/pi-extension/subagents/replica-background.ts +38 -0
- package/pi-extension/subagents/replica-branch.ts +45 -0
- package/pi-extension/subagents/replica-directory-settings.ts +51 -0
- package/pi-extension/subagents/replica-history.js +22 -0
- package/pi-extension/subagents/replica-history.ts +21 -0
- package/pi-extension/subagents/replica-work.js +326 -0
- package/pi-extension/subagents/replica-work.ts +336 -0
- package/pi-extension/subagents/replica-worker.js +74 -0
- package/pi-extension/subagents/replica-worker.ts +71 -0
- package/pi-extension/subagents/replication.ts +99 -0
- package/pi-extension/subagents/result-delivery-queue.ts +33 -0
- package/pi-extension/subagents/role-catalog.js +123 -0
- package/pi-extension/subagents/role-catalog.ts +24 -0
- package/pi-extension/subagents/runtime-routing.js +154 -0
- package/pi-extension/subagents/runtime-routing.ts +239 -0
- package/pi-extension/subagents/session-binding.js +346 -0
- package/pi-extension/subagents/session-binding.ts +339 -0
- package/pi-extension/subagents/session-work.js +219 -0
- package/pi-extension/subagents/session-work.ts +167 -0
- package/pi-extension/subagents/session.js +318 -0
- package/pi-extension/subagents/session.ts +448 -0
- package/pi-extension/subagents/settled-contract.js +47 -0
- package/pi-extension/subagents/settled-contract.ts +175 -0
- package/pi-extension/subagents/source-state.js +180 -0
- package/pi-extension/subagents/source-state.ts +184 -0
- package/pi-extension/subagents/status.ts +493 -0
- package/pi-extension/subagents/subagent-done.js +805 -0
- package/pi-extension/subagents/subagent-done.ts +878 -0
- package/pi-extension/subagents/terminal.js +111 -0
- package/pi-extension/subagents/terminal.ts +208 -0
- package/pi-extension/subagents/watchdog.js +122 -0
- package/pi-extension/subagents/watchdog.ts +171 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { writeBindingFile } from "./session-binding.js";
|
|
6
|
+
function gitEnvironment(extra = {}) {
|
|
7
|
+
const env = { ...process.env, GIT_OPTIONAL_LOCKS: "0", ...extra };
|
|
8
|
+
// The explicit cwd, not a launcher's inherited Git routing, owns this operation.
|
|
9
|
+
delete env.GIT_DIR;
|
|
10
|
+
delete env.GIT_WORK_TREE;
|
|
11
|
+
delete env.GIT_OBJECT_DIRECTORY;
|
|
12
|
+
delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
|
|
13
|
+
if (!extra.GIT_INDEX_FILE)
|
|
14
|
+
delete env.GIT_INDEX_FILE;
|
|
15
|
+
return env;
|
|
16
|
+
}
|
|
17
|
+
function git(cwd, args, extra, input) {
|
|
18
|
+
return execFileSync("git", ["--literal-pathspecs", ...args], { cwd, env: gitEnvironment(extra), input, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
19
|
+
}
|
|
20
|
+
function indexHash(cwd) {
|
|
21
|
+
const path = git(cwd, ["rev-parse", "--path-format=absolute", "--git-path", "index"]).trim();
|
|
22
|
+
return createHash("sha256").update(existsSync(path) ? readFileSync(path) : "missing index").digest("hex");
|
|
23
|
+
}
|
|
24
|
+
function rejectUnsupportedIndex(cwd, extra) {
|
|
25
|
+
if (git(cwd, ["ls-files", "-v", "-z"], extra).split("\0").some((entry) => /^(?:S|[a-z]) /.test(entry)))
|
|
26
|
+
throw new Error("Replica snapshots do not support sparse or assume-unchanged index entries.");
|
|
27
|
+
const entries = git(cwd, ["ls-files", "--stage", "-z"], extra).split("\0");
|
|
28
|
+
if (entries.some((entry) => entry.startsWith("160000 ")))
|
|
29
|
+
throw new Error("Replica snapshots do not support submodules or nested Git repositories.");
|
|
30
|
+
if (git(cwd, ["ls-files", "--unmerged", "-z"], extra))
|
|
31
|
+
throw new Error("Replica snapshots require an index without unresolved conflicts.");
|
|
32
|
+
}
|
|
33
|
+
/** Herdr allocates linked worktrees through the repository's main checkout. */
|
|
34
|
+
export function sourceRepositoryAnchor(cwd) {
|
|
35
|
+
const rows = git(cwd, ["worktree", "list", "--porcelain", "-z"]).split("\0");
|
|
36
|
+
const first = rows.slice(0, rows.indexOf(""));
|
|
37
|
+
if (!first[0]?.startsWith("worktree ") || first.includes("bare"))
|
|
38
|
+
throw new Error("Replication requires a main non-bare checkout for Herdr allocation.");
|
|
39
|
+
const anchor = first[0].slice("worktree ".length);
|
|
40
|
+
if (realpathSync(git(anchor, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).trim()) !== realpathSync(git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).trim()))
|
|
41
|
+
throw new Error("Replica repository anchor mismatch.");
|
|
42
|
+
return anchor;
|
|
43
|
+
}
|
|
44
|
+
/** Capture tracked working content and nonignored new files through a private index. */
|
|
45
|
+
export function snapshotSource(cwd, storeDir) {
|
|
46
|
+
const repoDir = realpathSync(git(cwd, ["rev-parse", "--show-toplevel"]).trim());
|
|
47
|
+
const commonDir = realpathSync(git(repoDir, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).trim());
|
|
48
|
+
const relativeStore = relative(repoDir, resolve(storeDir));
|
|
49
|
+
if (!relativeStore)
|
|
50
|
+
throw new Error("Replica snapshot storage cannot be the source repository itself.");
|
|
51
|
+
if (relativeStore && relativeStore !== ".." && !relativeStore.startsWith(".." + sep) && !isAbsolute(relativeStore)) {
|
|
52
|
+
const ignored = spawnSync("git", ["check-ignore", "-q", "--", relativeStore], { cwd: repoDir, env: gitEnvironment() });
|
|
53
|
+
if (ignored.status !== 0)
|
|
54
|
+
throw new Error("Replica snapshot storage inside the repository must be Git-ignored.");
|
|
55
|
+
}
|
|
56
|
+
const head = git(repoDir, ["rev-parse", "--verify", "HEAD^{commit}"]).trim();
|
|
57
|
+
const originalIndex = indexHash(repoDir);
|
|
58
|
+
rejectUnsupportedIndex(repoDir);
|
|
59
|
+
mkdirSync(storeDir, { recursive: true });
|
|
60
|
+
const id = randomUUID(), privateIndex = join(resolve(storeDir), `snapshot-${id}.index`);
|
|
61
|
+
const env = { GIT_INDEX_FILE: privateIndex };
|
|
62
|
+
try {
|
|
63
|
+
git(repoDir, ["read-tree", head], env);
|
|
64
|
+
// Newly staged paths are tracked too, even when ignore rules match them.
|
|
65
|
+
git(repoDir, ["update-index", "-z", "--index-info"], env, git(repoDir, ["ls-files", "--stage", "-z"]));
|
|
66
|
+
git(repoDir, ["add", "-A", "--", "."], env);
|
|
67
|
+
rejectUnsupportedIndex(repoDir, env);
|
|
68
|
+
const tree = git(repoDir, ["write-tree"], env).trim();
|
|
69
|
+
// Detect a source edit racing the snapshot instead of launching from mixed reads.
|
|
70
|
+
git(repoDir, ["add", "-A", "--", "."], env);
|
|
71
|
+
if (git(repoDir, ["write-tree"], env).trim() !== tree || git(repoDir, ["rev-parse", "HEAD"]).trim() !== head || indexHash(repoDir) !== originalIndex) {
|
|
72
|
+
throw new Error("Source changed while taking the replica snapshot.");
|
|
73
|
+
}
|
|
74
|
+
const commit = git(repoDir, ["commit-tree", tree, "-p", head], {
|
|
75
|
+
GIT_AUTHOR_NAME: "Pi replica host", GIT_AUTHOR_EMAIL: "replica@localhost",
|
|
76
|
+
GIT_COMMITTER_NAME: "Pi replica host", GIT_COMMITTER_EMAIL: "replica@localhost",
|
|
77
|
+
}, "Replica source checkpoint\n").trim();
|
|
78
|
+
const ref = `refs/pi-replicas/snapshots/${id}`;
|
|
79
|
+
git(repoDir, ["update-ref", ref, commit]);
|
|
80
|
+
return { repoDir, commonDir, head, indexHash: originalIndex, commit, tree, ref };
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
rmSync(privateIndex, { force: true });
|
|
84
|
+
rmSync(`${privateIndex}.lock`, { force: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Compute child delta against the exact fork basis without changing any parent files. */
|
|
88
|
+
export function planSourceJoin(cwd, baseCommit, childCommit, storeDir) {
|
|
89
|
+
const before = snapshotSource(cwd, storeDir);
|
|
90
|
+
const result = spawnSync("git", ["merge-tree", "--write-tree", `--merge-base=${baseCommit}`, before.commit, childCommit], {
|
|
91
|
+
cwd: before.repoDir, env: gitEnvironment(), encoding: "utf8", maxBuffer: 64 * 1024 * 1024,
|
|
92
|
+
});
|
|
93
|
+
if (result.error)
|
|
94
|
+
throw result.error;
|
|
95
|
+
if (result.status === 1)
|
|
96
|
+
return { status: "conflict", details: result.stdout };
|
|
97
|
+
if (result.status !== 0)
|
|
98
|
+
throw new Error(`Git could not inspect the replica join: ${result.stderr || result.stdout}`);
|
|
99
|
+
const mergedTree = result.stdout.trim();
|
|
100
|
+
if (!/^[a-f0-9]{40,64}$/.test(mergedTree))
|
|
101
|
+
throw new Error("Git returned an invalid merged tree.");
|
|
102
|
+
const paths = git(before.repoDir, ["diff", "--no-renames", "--name-only", "-z", before.tree, mergedTree]).split("\0").filter(Boolean);
|
|
103
|
+
const id = randomUUID();
|
|
104
|
+
// A saved plan must keep its merged tree alive until crash recovery applies it.
|
|
105
|
+
git(before.repoDir, ["update-ref", `refs/pi-replicas/joins/${id}`, mergedTree]);
|
|
106
|
+
const plan = { status: "ready", before, mergedTree, paths, storeDir: resolve(storeDir), journalFile: join(resolve(storeDir), `join-${id}.json`) };
|
|
107
|
+
writeBindingFile(plan.journalFile, { ...plan, state: "prepared" });
|
|
108
|
+
return plan;
|
|
109
|
+
}
|
|
110
|
+
function treePaths(cwd, tree) {
|
|
111
|
+
const result = new Map();
|
|
112
|
+
for (const entry of git(cwd, ["ls-tree", "-r", "-z", tree]).split("\0").filter(Boolean)) {
|
|
113
|
+
const tab = entry.indexOf("\t");
|
|
114
|
+
result.set(entry.slice(tab + 1), entry.slice(0, tab));
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
/** Apply or recover owned files without touching staging; validate the receiver again before writing. */
|
|
119
|
+
export function applySourceJoin(plan, validateParent = () => { }) {
|
|
120
|
+
const journal = JSON.parse(readFileSync(plan.journalFile, "utf8"));
|
|
121
|
+
if (journal.before.commit !== plan.before.commit || journal.mergedTree !== plan.mergedTree)
|
|
122
|
+
throw new Error("Replica join journal does not match its plan.");
|
|
123
|
+
if (journal.state === "integrated")
|
|
124
|
+
return;
|
|
125
|
+
const current = snapshotSource(plan.before.repoDir, plan.storeDir);
|
|
126
|
+
if (current.commonDir !== plan.before.commonDir || current.head !== plan.before.head || current.indexHash !== plan.before.indexHash) {
|
|
127
|
+
throw new Error("Parent Git identity or staging changed since the join was prepared.");
|
|
128
|
+
}
|
|
129
|
+
if (journal.state === "prepared" && current.tree !== plan.before.tree)
|
|
130
|
+
throw new Error("Parent source changed since the join was prepared.");
|
|
131
|
+
if (journal.state === "applying") {
|
|
132
|
+
const old = treePaths(current.repoDir, plan.before.tree), after = treePaths(current.repoDir, plan.mergedTree), now = treePaths(current.repoDir, current.tree);
|
|
133
|
+
for (const path of plan.paths) {
|
|
134
|
+
if (now.get(path) !== old.get(path) && now.get(path) !== after.get(path))
|
|
135
|
+
throw new Error(`Parent source changed during interrupted join: ${path}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Limit recovery to owned paths; unrelated work written after application must survive.
|
|
139
|
+
const patch = plan.paths.length ? git(current.repoDir, ["diff", "--binary", "--full-index", "--no-ext-diff", "--no-textconv", "--no-renames", current.tree, plan.mergedTree, "--", ...plan.paths]) : "";
|
|
140
|
+
validateParent();
|
|
141
|
+
writeBindingFile(plan.journalFile, { ...plan, state: "applying" });
|
|
142
|
+
if (patch) {
|
|
143
|
+
git(current.repoDir, ["-c", "apply.ignoreWhitespace=no", "apply", "--check", "--binary", "--whitespace=nowarn", "-"], undefined, patch);
|
|
144
|
+
validateParent();
|
|
145
|
+
git(current.repoDir, ["-c", "apply.ignoreWhitespace=no", "apply", "--binary", "--whitespace=nowarn", "-"], undefined, patch);
|
|
146
|
+
}
|
|
147
|
+
const verified = snapshotSource(current.repoDir, plan.storeDir);
|
|
148
|
+
const expected = treePaths(current.repoDir, plan.mergedTree), actual = treePaths(current.repoDir, verified.tree);
|
|
149
|
+
if (plan.paths.some((path) => actual.get(path) !== expected.get(path)) || verified.indexHash !== plan.before.indexHash || verified.head !== plan.before.head) {
|
|
150
|
+
throw new Error("Replica join could not verify its applied files; saved work is retained.");
|
|
151
|
+
}
|
|
152
|
+
// Persist changed files and directory entries before publishing durable integration.
|
|
153
|
+
const directories = new Set([current.repoDir]);
|
|
154
|
+
for (const path of plan.paths) {
|
|
155
|
+
const absolute = join(current.repoDir, path);
|
|
156
|
+
if (existsSync(absolute) && lstatSync(absolute).isFile()) {
|
|
157
|
+
const fd = openSync(absolute, "r");
|
|
158
|
+
try {
|
|
159
|
+
fsyncSync(fd);
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
closeSync(fd);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (let directory = dirname(absolute); directory !== current.repoDir; directory = dirname(directory)) {
|
|
166
|
+
if (existsSync(directory))
|
|
167
|
+
directories.add(directory);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (const directory of directories) {
|
|
171
|
+
const fd = openSync(directory, "r");
|
|
172
|
+
try {
|
|
173
|
+
fsyncSync(fd);
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
closeSync(fd);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
writeBindingFile(plan.journalFile, { ...plan, state: "integrated" });
|
|
180
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { writeBindingFile } from "./session-binding.ts";
|
|
6
|
+
|
|
7
|
+
/** A GC-protected source checkpoint; it never advances the caller's branch. */
|
|
8
|
+
export interface SourceSnapshot {
|
|
9
|
+
repoDir: string;
|
|
10
|
+
commonDir: string;
|
|
11
|
+
head: string;
|
|
12
|
+
indexHash: string;
|
|
13
|
+
commit: string;
|
|
14
|
+
tree: string;
|
|
15
|
+
ref: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A clean three-way result, not yet applied to the parent's working files. */
|
|
19
|
+
export interface SourceJoinReady {
|
|
20
|
+
status: "ready";
|
|
21
|
+
before: SourceSnapshot;
|
|
22
|
+
mergedTree: string;
|
|
23
|
+
paths: string[];
|
|
24
|
+
storeDir: string;
|
|
25
|
+
journalFile: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Git conflicts are reported without checking conflict markers into the parent. */
|
|
29
|
+
export interface SourceJoinConflict {
|
|
30
|
+
status: "conflict";
|
|
31
|
+
details: string;
|
|
32
|
+
}
|
|
33
|
+
export type SourceJoin = SourceJoinReady | SourceJoinConflict;
|
|
34
|
+
|
|
35
|
+
type JoinJournal = SourceJoinReady & { state: "prepared" | "applying" | "integrated" };
|
|
36
|
+
|
|
37
|
+
function gitEnvironment(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
|
|
38
|
+
const env: NodeJS.ProcessEnv = { ...process.env, GIT_OPTIONAL_LOCKS: "0", ...extra };
|
|
39
|
+
// The explicit cwd, not a launcher's inherited Git routing, owns this operation.
|
|
40
|
+
delete env.GIT_DIR; delete env.GIT_WORK_TREE;
|
|
41
|
+
delete env.GIT_OBJECT_DIRECTORY; delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
|
|
42
|
+
if (!extra.GIT_INDEX_FILE) delete env.GIT_INDEX_FILE;
|
|
43
|
+
return env;
|
|
44
|
+
}
|
|
45
|
+
function git(cwd: string, args: string[], extra?: NodeJS.ProcessEnv, input?: string | Buffer): string {
|
|
46
|
+
return execFileSync("git", ["--literal-pathspecs", ...args], { cwd, env: gitEnvironment(extra), input, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
47
|
+
}
|
|
48
|
+
function indexHash(cwd: string): string {
|
|
49
|
+
const path = git(cwd, ["rev-parse", "--path-format=absolute", "--git-path", "index"]).trim();
|
|
50
|
+
return createHash("sha256").update(existsSync(path) ? readFileSync(path) : "missing index").digest("hex");
|
|
51
|
+
}
|
|
52
|
+
function rejectUnsupportedIndex(cwd: string, extra?: NodeJS.ProcessEnv): void {
|
|
53
|
+
if (git(cwd, ["ls-files", "-v", "-z"], extra).split("\0").some((entry) => /^(?:S|[a-z]) /.test(entry))) throw new Error("Replica snapshots do not support sparse or assume-unchanged index entries.");
|
|
54
|
+
const entries = git(cwd, ["ls-files", "--stage", "-z"], extra).split("\0");
|
|
55
|
+
if (entries.some((entry) => entry.startsWith("160000 "))) throw new Error("Replica snapshots do not support submodules or nested Git repositories.");
|
|
56
|
+
if (git(cwd, ["ls-files", "--unmerged", "-z"], extra)) throw new Error("Replica snapshots require an index without unresolved conflicts.");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Herdr allocates linked worktrees through the repository's main checkout. */
|
|
60
|
+
export function sourceRepositoryAnchor(cwd: string): string {
|
|
61
|
+
const rows = git(cwd, ["worktree", "list", "--porcelain", "-z"]).split("\0");
|
|
62
|
+
const first = rows.slice(0, rows.indexOf(""));
|
|
63
|
+
if (!first[0]?.startsWith("worktree ") || first.includes("bare")) throw new Error("Replication requires a main non-bare checkout for Herdr allocation.");
|
|
64
|
+
const anchor = first[0].slice("worktree ".length);
|
|
65
|
+
if (realpathSync(git(anchor, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).trim()) !== realpathSync(git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).trim())) throw new Error("Replica repository anchor mismatch.");
|
|
66
|
+
return anchor;
|
|
67
|
+
}
|
|
68
|
+
/** Capture tracked working content and nonignored new files through a private index. */
|
|
69
|
+
export function snapshotSource(cwd: string, storeDir: string): SourceSnapshot {
|
|
70
|
+
const repoDir = realpathSync(git(cwd, ["rev-parse", "--show-toplevel"]).trim());
|
|
71
|
+
const commonDir = realpathSync(git(repoDir, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).trim());
|
|
72
|
+
const relativeStore = relative(repoDir, resolve(storeDir));
|
|
73
|
+
if (!relativeStore) throw new Error("Replica snapshot storage cannot be the source repository itself.");
|
|
74
|
+
if (relativeStore && relativeStore !== ".." && !relativeStore.startsWith(".." + sep) && !isAbsolute(relativeStore)) {
|
|
75
|
+
const ignored = spawnSync("git", ["check-ignore", "-q", "--", relativeStore], { cwd: repoDir, env: gitEnvironment() });
|
|
76
|
+
if (ignored.status !== 0) throw new Error("Replica snapshot storage inside the repository must be Git-ignored.");
|
|
77
|
+
}
|
|
78
|
+
const head = git(repoDir, ["rev-parse", "--verify", "HEAD^{commit}"]).trim();
|
|
79
|
+
const originalIndex = indexHash(repoDir);
|
|
80
|
+
rejectUnsupportedIndex(repoDir);
|
|
81
|
+
mkdirSync(storeDir, { recursive: true });
|
|
82
|
+
const id = randomUUID(), privateIndex = join(resolve(storeDir), `snapshot-${id}.index`);
|
|
83
|
+
const env = { GIT_INDEX_FILE: privateIndex };
|
|
84
|
+
try {
|
|
85
|
+
git(repoDir, ["read-tree", head], env);
|
|
86
|
+
// Newly staged paths are tracked too, even when ignore rules match them.
|
|
87
|
+
git(repoDir, ["update-index", "-z", "--index-info"], env, git(repoDir, ["ls-files", "--stage", "-z"]));
|
|
88
|
+
git(repoDir, ["add", "-A", "--", "."], env);
|
|
89
|
+
rejectUnsupportedIndex(repoDir, env);
|
|
90
|
+
const tree = git(repoDir, ["write-tree"], env).trim();
|
|
91
|
+
// Detect a source edit racing the snapshot instead of launching from mixed reads.
|
|
92
|
+
git(repoDir, ["add", "-A", "--", "."], env);
|
|
93
|
+
if (git(repoDir, ["write-tree"], env).trim() !== tree || git(repoDir, ["rev-parse", "HEAD"]).trim() !== head || indexHash(repoDir) !== originalIndex) {
|
|
94
|
+
throw new Error("Source changed while taking the replica snapshot.");
|
|
95
|
+
}
|
|
96
|
+
const commit = git(repoDir, ["commit-tree", tree, "-p", head], {
|
|
97
|
+
GIT_AUTHOR_NAME: "Pi replica host", GIT_AUTHOR_EMAIL: "replica@localhost",
|
|
98
|
+
GIT_COMMITTER_NAME: "Pi replica host", GIT_COMMITTER_EMAIL: "replica@localhost",
|
|
99
|
+
}, "Replica source checkpoint\n").trim();
|
|
100
|
+
const ref = `refs/pi-replicas/snapshots/${id}`;
|
|
101
|
+
git(repoDir, ["update-ref", ref, commit]);
|
|
102
|
+
return { repoDir, commonDir, head, indexHash: originalIndex, commit, tree, ref };
|
|
103
|
+
} finally {
|
|
104
|
+
rmSync(privateIndex, { force: true }); rmSync(`${privateIndex}.lock`, { force: true });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Compute child delta against the exact fork basis without changing any parent files. */
|
|
109
|
+
export function planSourceJoin(cwd: string, baseCommit: string, childCommit: string, storeDir: string): SourceJoin {
|
|
110
|
+
const before = snapshotSource(cwd, storeDir);
|
|
111
|
+
const result = spawnSync("git", ["merge-tree", "--write-tree", `--merge-base=${baseCommit}`, before.commit, childCommit], {
|
|
112
|
+
cwd: before.repoDir, env: gitEnvironment(), encoding: "utf8", maxBuffer: 64 * 1024 * 1024,
|
|
113
|
+
});
|
|
114
|
+
if (result.error) throw result.error;
|
|
115
|
+
if (result.status === 1) return { status: "conflict", details: result.stdout };
|
|
116
|
+
if (result.status !== 0) throw new Error(`Git could not inspect the replica join: ${result.stderr || result.stdout}`);
|
|
117
|
+
const mergedTree = result.stdout.trim();
|
|
118
|
+
if (!/^[a-f0-9]{40,64}$/.test(mergedTree)) throw new Error("Git returned an invalid merged tree.");
|
|
119
|
+
const paths = git(before.repoDir, ["diff", "--no-renames", "--name-only", "-z", before.tree, mergedTree]).split("\0").filter(Boolean);
|
|
120
|
+
const id = randomUUID();
|
|
121
|
+
// A saved plan must keep its merged tree alive until crash recovery applies it.
|
|
122
|
+
git(before.repoDir, ["update-ref", `refs/pi-replicas/joins/${id}`, mergedTree]);
|
|
123
|
+
const plan: SourceJoinReady = { status: "ready", before, mergedTree, paths, storeDir: resolve(storeDir), journalFile: join(resolve(storeDir), `join-${id}.json`) };
|
|
124
|
+
writeBindingFile(plan.journalFile, { ...plan, state: "prepared" } satisfies JoinJournal);
|
|
125
|
+
return plan;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function treePaths(cwd: string, tree: string): Map<string, string> {
|
|
129
|
+
const result = new Map<string, string>();
|
|
130
|
+
for (const entry of git(cwd, ["ls-tree", "-r", "-z", tree]).split("\0").filter(Boolean)) {
|
|
131
|
+
const tab = entry.indexOf("\t");
|
|
132
|
+
result.set(entry.slice(tab + 1), entry.slice(0, tab));
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Apply or recover owned files without touching staging; validate the receiver again before writing. */
|
|
138
|
+
export function applySourceJoin(plan: SourceJoinReady, validateParent: () => void = () => {}): void {
|
|
139
|
+
const journal = JSON.parse(readFileSync(plan.journalFile, "utf8")) as JoinJournal;
|
|
140
|
+
if (journal.before.commit !== plan.before.commit || journal.mergedTree !== plan.mergedTree) throw new Error("Replica join journal does not match its plan.");
|
|
141
|
+
if (journal.state === "integrated") return;
|
|
142
|
+
const current = snapshotSource(plan.before.repoDir, plan.storeDir);
|
|
143
|
+
if (current.commonDir !== plan.before.commonDir || current.head !== plan.before.head || current.indexHash !== plan.before.indexHash) {
|
|
144
|
+
throw new Error("Parent Git identity or staging changed since the join was prepared.");
|
|
145
|
+
}
|
|
146
|
+
if (journal.state === "prepared" && current.tree !== plan.before.tree) throw new Error("Parent source changed since the join was prepared.");
|
|
147
|
+
if (journal.state === "applying") {
|
|
148
|
+
const old = treePaths(current.repoDir, plan.before.tree), after = treePaths(current.repoDir, plan.mergedTree), now = treePaths(current.repoDir, current.tree);
|
|
149
|
+
for (const path of plan.paths) {
|
|
150
|
+
if (now.get(path) !== old.get(path) && now.get(path) !== after.get(path)) throw new Error(`Parent source changed during interrupted join: ${path}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Limit recovery to owned paths; unrelated work written after application must survive.
|
|
154
|
+
const patch = plan.paths.length ? git(current.repoDir, ["diff", "--binary", "--full-index", "--no-ext-diff", "--no-textconv", "--no-renames", current.tree, plan.mergedTree, "--", ...plan.paths]) : "";
|
|
155
|
+
validateParent();
|
|
156
|
+
writeBindingFile(plan.journalFile, { ...plan, state: "applying" } satisfies JoinJournal);
|
|
157
|
+
if (patch) {
|
|
158
|
+
git(current.repoDir, ["-c", "apply.ignoreWhitespace=no", "apply", "--check", "--binary", "--whitespace=nowarn", "-"], undefined, patch);
|
|
159
|
+
validateParent();
|
|
160
|
+
git(current.repoDir, ["-c", "apply.ignoreWhitespace=no", "apply", "--binary", "--whitespace=nowarn", "-"], undefined, patch);
|
|
161
|
+
}
|
|
162
|
+
const verified = snapshotSource(current.repoDir, plan.storeDir);
|
|
163
|
+
const expected = treePaths(current.repoDir, plan.mergedTree), actual = treePaths(current.repoDir, verified.tree);
|
|
164
|
+
if (plan.paths.some((path) => actual.get(path) !== expected.get(path)) || verified.indexHash !== plan.before.indexHash || verified.head !== plan.before.head) {
|
|
165
|
+
throw new Error("Replica join could not verify its applied files; saved work is retained.");
|
|
166
|
+
}
|
|
167
|
+
// Persist changed files and directory entries before publishing durable integration.
|
|
168
|
+
const directories = new Set<string>([current.repoDir]);
|
|
169
|
+
for (const path of plan.paths) {
|
|
170
|
+
const absolute = join(current.repoDir, path);
|
|
171
|
+
if (existsSync(absolute) && lstatSync(absolute).isFile()) {
|
|
172
|
+
const fd = openSync(absolute, "r");
|
|
173
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
174
|
+
}
|
|
175
|
+
for (let directory = dirname(absolute); directory !== current.repoDir; directory = dirname(directory)) {
|
|
176
|
+
if (existsSync(directory)) directories.add(directory);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const directory of directories) {
|
|
180
|
+
const fd = openSync(directory, "r");
|
|
181
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
182
|
+
}
|
|
183
|
+
writeBindingFile(plan.journalFile, { ...plan, state: "integrated" } satisfies JoinJournal);
|
|
184
|
+
}
|