@ricsam/r5d-worker 0.0.161 → 0.0.163
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/cjs/package.json +1 -1
- package/dist/mjs/internal-r5dctl.cjs +1 -1
- package/dist/mjs/main.mjs +2 -2
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/personal/client.mjs +2 -2
- package/dist/mjs/personal/publication-refusal.mjs +47 -0
- package/dist/mjs/personal/runtime.mjs +47 -46
- package/dist/mjs/runtime/workspace/authority.mjs +521 -76
- package/dist/mjs/runtime/workspace/files.mjs +44 -7
- package/dist/mjs/runtime/workspace/outer.mjs +286 -0
- package/dist/mjs/runtime/workspace/storage-wire.mjs +5 -2
- package/dist/types/personal/publication-refusal.d.ts +35 -0
- package/dist/types/personal/runtime.d.ts +3 -2
- package/dist/types/runtime/workspace/authority.d.ts +86 -4
- package/dist/types/runtime/workspace/contracts.d.ts +26 -0
- package/dist/types/runtime/workspace/files.d.ts +30 -1
- package/dist/types/runtime/workspace/outer.d.ts +105 -0
- package/dist/types/runtime/workspace/storage-wire.d.ts +2 -1
- package/package.json +2 -2
|
@@ -6,6 +6,21 @@ import { createHash } from "node:crypto";
|
|
|
6
6
|
import { safeTreePath, STORAGE_LIMITS } from "./storage-wire.mjs";
|
|
7
7
|
import { WorkspaceError } from "./contracts.mjs";
|
|
8
8
|
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
9
|
+
const WORKBENCH_OPERATION_MARKERS = [
|
|
10
|
+
"MERGE_HEAD",
|
|
11
|
+
"CHERRY_PICK_HEAD",
|
|
12
|
+
"REVERT_HEAD",
|
|
13
|
+
"REBASE_HEAD",
|
|
14
|
+
"rebase-merge",
|
|
15
|
+
"rebase-apply",
|
|
16
|
+
"index.lock"
|
|
17
|
+
];
|
|
18
|
+
const WORKBENCH_CONFLICT_CODES = /* @__PURE__ */ new Set([
|
|
19
|
+
"conflict",
|
|
20
|
+
"sync_conflict",
|
|
21
|
+
"workbench_conflict",
|
|
22
|
+
"workbench_operation_in_progress"
|
|
23
|
+
]);
|
|
9
24
|
async function privateRoot(root, installationId) {
|
|
10
25
|
if (!path.isAbsolute(root) || path.normalize(root) !== root || path.basename(root) !== installationId || root.split(path.sep).some((p) => [".r5d", "r5d-dev", "legacy", "app-data"].includes(p.toLowerCase())))
|
|
11
26
|
throw new WorkspaceError("unsafe_root", "Use an independently allocated private new-installation root, never a legacy worktree");
|
|
@@ -85,7 +100,23 @@ async function durableJson(file, value) {
|
|
|
85
100
|
await dir.close();
|
|
86
101
|
}
|
|
87
102
|
}
|
|
88
|
-
|
|
103
|
+
const PLATFORM_COMMIT_EMAIL = "workspace@invalid";
|
|
104
|
+
const PROJECT_COMMIT_IDENTITY = {
|
|
105
|
+
GIT_AUTHOR_NAME: "r5d",
|
|
106
|
+
GIT_AUTHOR_EMAIL: "workspace@r5d.dev",
|
|
107
|
+
GIT_COMMITTER_NAME: "r5d",
|
|
108
|
+
GIT_COMMITTER_EMAIL: "workspace@r5d.dev"
|
|
109
|
+
};
|
|
110
|
+
function git(cwd, args, input, indexFile, transport, options = {}) {
|
|
111
|
+
return runGit(cwd, args, input, indexFile, transport, options).then(({ code, stdout }) => {
|
|
112
|
+
if (code !== 0) throw new WorkspaceError("git_failed", "Bounded private Git operation failed; preserve state for inspection");
|
|
113
|
+
return stdout;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function gitResult(cwd, args, input, options = {}) {
|
|
117
|
+
return runGit(cwd, args, input, void 0, void 0, options);
|
|
118
|
+
}
|
|
119
|
+
function runGit(cwd, args, input, indexFile, transport, options = {}) {
|
|
89
120
|
if (indexFile && path.dirname(indexFile) !== cwd) throw new WorkspaceError("unsafe_path", "Index override must be authority-owned");
|
|
90
121
|
return new Promise((resolve, reject) => {
|
|
91
122
|
const child = spawn(
|
|
@@ -128,7 +159,8 @@ function git(cwd, args, input, indexFile, transport) {
|
|
|
128
159
|
// Only the reviewed GitHub import/push boundary supplies this option.
|
|
129
160
|
// Tokens are process-private environment values, never argv, URLs or diagnostics.
|
|
130
161
|
...transport ? { GIT_CONFIG_COUNT: "3", GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader", GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${transport.token}`).toString("base64")}`, GIT_CONFIG_KEY_1: "http.followRedirects", GIT_CONFIG_VALUE_1: "false", GIT_CONFIG_KEY_2: "credential.helper", GIT_CONFIG_VALUE_2: "" } : {},
|
|
131
|
-
...indexFile ? { GIT_INDEX_FILE: indexFile } : {}
|
|
162
|
+
...indexFile ? { GIT_INDEX_FILE: indexFile } : {},
|
|
163
|
+
...options.env ?? {}
|
|
132
164
|
},
|
|
133
165
|
stdio: ["pipe", "pipe", "pipe"]
|
|
134
166
|
}
|
|
@@ -138,7 +170,7 @@ function git(cwd, args, input, indexFile, transport) {
|
|
|
138
170
|
const timer = setTimeout(() => {
|
|
139
171
|
failed = true;
|
|
140
172
|
child.kill("SIGKILL");
|
|
141
|
-
}, 3e4);
|
|
173
|
+
}, options.timeoutMs ?? 3e4);
|
|
142
174
|
child.stdout.on("data", (b) => {
|
|
143
175
|
bytes += b.length;
|
|
144
176
|
if (bytes > 32 * 1024 * 1024) {
|
|
@@ -159,9 +191,9 @@ function git(cwd, args, input, indexFile, transport) {
|
|
|
159
191
|
});
|
|
160
192
|
child.on("close", (code) => {
|
|
161
193
|
clearTimeout(timer);
|
|
162
|
-
if (failed || code
|
|
194
|
+
if (failed || code === null)
|
|
163
195
|
reject(new WorkspaceError("git_failed", "Bounded private Git operation failed; preserve state for inspection"));
|
|
164
|
-
else resolve(Buffer.concat(output));
|
|
196
|
+
else resolve({ code, stdout: Buffer.concat(output) });
|
|
165
197
|
});
|
|
166
198
|
child.stdin.on("error", () => {
|
|
167
199
|
});
|
|
@@ -287,7 +319,7 @@ async function snapshotTree(repo, cwd, canonicalHead, maxBytes = 128 * 1024 * 10
|
|
|
287
319
|
if (!gitDirectory.startsWith(worktrees)) throw new WorkspaceError("unsafe_git", "Linked worktree belongs to another repository");
|
|
288
320
|
await noSymlinkAncestors(gitDirectory);
|
|
289
321
|
} else if (!metadataStat.isDirectory()) throw new WorkspaceError("unsafe_git", "Invalid worktree metadata");
|
|
290
|
-
for (const name of
|
|
322
|
+
for (const name of WORKBENCH_OPERATION_MARKERS) {
|
|
291
323
|
if (await fs.lstat(path.join(gitDirectory, name)).then(
|
|
292
324
|
() => true,
|
|
293
325
|
(e) => {
|
|
@@ -296,7 +328,7 @@ async function snapshotTree(repo, cwd, canonicalHead, maxBytes = 128 * 1024 * 10
|
|
|
296
328
|
}
|
|
297
329
|
))
|
|
298
330
|
throw new WorkspaceError(
|
|
299
|
-
"
|
|
331
|
+
"workbench_operation_in_progress",
|
|
300
332
|
"Git merge/rebase/index operation remains in progress; preserve and resolve it before publication"
|
|
301
333
|
);
|
|
302
334
|
}
|
|
@@ -406,9 +438,14 @@ async function snapshotTree(repo, cwd, canonicalHead, maxBytes = 128 * 1024 * 10
|
|
|
406
438
|
return (await git(repo, ["write-tree"])).toString().trim();
|
|
407
439
|
}
|
|
408
440
|
export {
|
|
441
|
+
PLATFORM_COMMIT_EMAIL,
|
|
442
|
+
PROJECT_COMMIT_IDENTITY,
|
|
443
|
+
WORKBENCH_CONFLICT_CODES,
|
|
444
|
+
WORKBENCH_OPERATION_MARKERS,
|
|
409
445
|
durableJson,
|
|
410
446
|
ensureAuthorityGitRepositoryLayout,
|
|
411
447
|
git,
|
|
448
|
+
gitResult,
|
|
412
449
|
materializeTree,
|
|
413
450
|
noSymlinkAncestors,
|
|
414
451
|
privateRoot,
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { GitOid, STORAGE_LIMITS, safeTreePath } from "./storage-wire.mjs";
|
|
5
|
+
import { WorkspaceError } from "./contracts.mjs";
|
|
6
|
+
import { git, gitResult, readRegular, sourceBytes } from "./files.mjs";
|
|
7
|
+
const OUTER_BRANCH = "main";
|
|
8
|
+
const OUTER_REF = `refs/heads/${OUTER_BRANCH}`;
|
|
9
|
+
const OUTER_SNAPSHOT_LIMIT_BYTES = 5 * 1024 * 1024;
|
|
10
|
+
const OUTER_GIT_TIMEOUT_MS = 10 * 6e4;
|
|
11
|
+
const OUTER_CONFLICT_CODE = "sync_conflict";
|
|
12
|
+
const LEGACY_ACCOUNT_README = "# Account workspace\n";
|
|
13
|
+
const OUTER_EXCLUDES = [
|
|
14
|
+
"/artifacts/",
|
|
15
|
+
"/.incoming/",
|
|
16
|
+
".r5d/",
|
|
17
|
+
".r5d-next/",
|
|
18
|
+
".env",
|
|
19
|
+
".env.*",
|
|
20
|
+
"!.env.example",
|
|
21
|
+
"*.pem",
|
|
22
|
+
"*.key",
|
|
23
|
+
"*.p12",
|
|
24
|
+
"*.pfx",
|
|
25
|
+
"id_rsa",
|
|
26
|
+
"id_ed25519",
|
|
27
|
+
"kubeconfig",
|
|
28
|
+
".ssh/",
|
|
29
|
+
".aws/",
|
|
30
|
+
".kube/",
|
|
31
|
+
".git-credentials",
|
|
32
|
+
".netrc",
|
|
33
|
+
".pypirc"
|
|
34
|
+
];
|
|
35
|
+
class OuterSnapshotRefusal extends WorkspaceError {
|
|
36
|
+
constructor(code, message, paths) {
|
|
37
|
+
super(code, message);
|
|
38
|
+
this.paths = paths;
|
|
39
|
+
this.name = "OuterSnapshotRefusal";
|
|
40
|
+
}
|
|
41
|
+
paths;
|
|
42
|
+
}
|
|
43
|
+
const nulSplit = (output) => {
|
|
44
|
+
if (!Buffer.from(output.toString("utf8")).equals(output)) throw new WorkspaceError("unsafe_path", "Non-UTF8 names unsupported");
|
|
45
|
+
return output.toString("utf8").split("\0").filter(Boolean);
|
|
46
|
+
};
|
|
47
|
+
const withinRoot = (file, root) => file === root || file.startsWith(`${root}/`);
|
|
48
|
+
const MAX_REPORTED_PATHS = 50;
|
|
49
|
+
class OuterRepository {
|
|
50
|
+
constructor(gitDir, workTree) {
|
|
51
|
+
this.gitDir = gitDir;
|
|
52
|
+
this.workTree = workTree;
|
|
53
|
+
}
|
|
54
|
+
gitDir;
|
|
55
|
+
workTree;
|
|
56
|
+
static async open(gitDir, workTree) {
|
|
57
|
+
const exists = await fs.lstat(gitDir).then(
|
|
58
|
+
(stat) => stat.isDirectory(),
|
|
59
|
+
(error) => {
|
|
60
|
+
if (error.code === "ENOENT") return false;
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
if (!exists) {
|
|
65
|
+
await fs.mkdir(gitDir, { recursive: true, mode: 448 });
|
|
66
|
+
await git(gitDir, ["init", "--bare", "--template=", "--object-format=sha1", "."]);
|
|
67
|
+
}
|
|
68
|
+
const repository = new OuterRepository(gitDir, workTree);
|
|
69
|
+
await fs.mkdir(path.join(gitDir, "info"), { recursive: true, mode: 448 });
|
|
70
|
+
await fs.writeFile(path.join(gitDir, "info", "exclude"), `${OUTER_EXCLUDES.join("\n")}
|
|
71
|
+
`, { mode: 384 });
|
|
72
|
+
await repository.run(["config", "core.bare", "false"]);
|
|
73
|
+
return repository;
|
|
74
|
+
}
|
|
75
|
+
run(args, input, options = {}) {
|
|
76
|
+
return git(this.gitDir, ["--git-dir", this.gitDir, "--work-tree", this.workTree, ...args], input, void 0, void 0, {
|
|
77
|
+
timeoutMs: options.timeoutMs ?? OUTER_GIT_TIMEOUT_MS
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
result(args, input) {
|
|
81
|
+
return gitResult(this.gitDir, ["--git-dir", this.gitDir, "--work-tree", this.workTree, ...args], input, {
|
|
82
|
+
timeoutMs: OUTER_GIT_TIMEOUT_MS
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
async head() {
|
|
86
|
+
const { code, stdout } = await this.result(["rev-parse", "--verify", "--quiet", OUTER_REF]);
|
|
87
|
+
if (code === 1) return null;
|
|
88
|
+
if (code !== 0) throw new WorkspaceError("git_failed", "Outer repository head is unreadable");
|
|
89
|
+
return GitOid.parse(stdout.toString("utf8").trim());
|
|
90
|
+
}
|
|
91
|
+
async setHead(commit) {
|
|
92
|
+
await this.run(["update-ref", OUTER_REF, GitOid.parse(commit)]);
|
|
93
|
+
}
|
|
94
|
+
async has(object) {
|
|
95
|
+
return (await this.result(["cat-file", "-e", `${GitOid.parse(object)}^{commit}`])).code === 0;
|
|
96
|
+
}
|
|
97
|
+
async treeOf(commit) {
|
|
98
|
+
return GitOid.parse((await this.run(["rev-parse", `${GitOid.parse(commit)}^{tree}`])).toString("utf8").trim());
|
|
99
|
+
}
|
|
100
|
+
async emptyTree() {
|
|
101
|
+
return GitOid.parse((await this.run(["hash-object", "-t", "tree", "--stdin"], "")).toString("utf8").trim());
|
|
102
|
+
}
|
|
103
|
+
async isAncestor(ancestor, descendant) {
|
|
104
|
+
const { code } = await this.result(["merge-base", "--is-ancestor", GitOid.parse(ancestor), GitOid.parse(descendant)]);
|
|
105
|
+
if (code !== 0 && code !== 1) throw new WorkspaceError("git_failed", "Outer repository ancestry is unreadable");
|
|
106
|
+
return code === 0;
|
|
107
|
+
}
|
|
108
|
+
async listTree(tree) {
|
|
109
|
+
const records = nulSplit(await this.run(["ls-tree", "-r", "-z", "--full-tree", tree]));
|
|
110
|
+
if (records.length > STORAGE_LIMITS.treeEntries) throw new WorkspaceError("too_large", "Too many workspace entries");
|
|
111
|
+
return records.map((record) => {
|
|
112
|
+
const match = /^(\d{6}) (blob|commit|tree) ([0-9a-f]{40})\t(.+)$/.exec(record);
|
|
113
|
+
if (!match) throw new WorkspaceError("unsafe_tree", "Malformed workspace tree entry");
|
|
114
|
+
safeTreePath(match[4]);
|
|
115
|
+
return { mode: match[1], oid: match[3], file: match[4], ...match[2] === "blob" ? {} : { type: match[2] } };
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/** Git refuses to walk into a nested repository until the outer index holds
|
|
119
|
+
* at least one path beneath it. That entry is written directly, bypassing the
|
|
120
|
+
* walk; afterwards the checkout is an ordinary directory to every command. A
|
|
121
|
+
* checkout whose tracked count drops to zero silently disappears again, so
|
|
122
|
+
* this runs before every snapshot rather than once. */
|
|
123
|
+
async seed(inners) {
|
|
124
|
+
for (const inner of inners) {
|
|
125
|
+
if ((await this.run(["ls-files", "-z", "--", `${inner.root}/`])).length) continue;
|
|
126
|
+
const seed = await this.seedCandidate(inner);
|
|
127
|
+
if (seed) await this.run(["update-index", "--add", "--", seed]);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async seedCandidate(inner) {
|
|
131
|
+
const directory = path.join(this.workTree, inner.root);
|
|
132
|
+
const present = async (file) => fs.lstat(path.join(directory, file)).then((stat) => stat.isFile() && !stat.isSymbolicLink(), () => false);
|
|
133
|
+
for (const file of [...await inner.tracked()].sort()) if (await present(file)) return `${inner.root}/${file}`;
|
|
134
|
+
const entries = await fs.readdir(directory, { withFileTypes: true }).catch((error) => {
|
|
135
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR") return [];
|
|
136
|
+
throw error;
|
|
137
|
+
});
|
|
138
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name)))
|
|
139
|
+
if (entry.isFile() && !entry.isSymbolicLink() && entry.name.toLowerCase() !== ".git") return `${inner.root}/${entry.name}`;
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
/** Stage the whole workspace and measure what no inner repository explains.
|
|
143
|
+
* Inside a checkout, a path its HEAD tracks was asked for by whoever imported
|
|
144
|
+
* or committed it and costs nothing; a path it ignores never reaches the walk.
|
|
145
|
+
* Everything else, and everything outside every checkout, counts in full. */
|
|
146
|
+
async snapshot(inners, options = {}) {
|
|
147
|
+
const limit = options.limitBytes ?? OUTER_SNAPSHOT_LIMIT_BYTES;
|
|
148
|
+
await this.seed(inners);
|
|
149
|
+
const dependencies = nulSplit(
|
|
150
|
+
await this.run(["ls-files", "--others", "--exclude-standard", "--directory", "-z", "--", ":(glob)**/node_modules/**"])
|
|
151
|
+
);
|
|
152
|
+
if (dependencies.length && !options.allowLargeDiff)
|
|
153
|
+
throw new OuterSnapshotRefusal("too_large", "An unignored dependency tree exceeds the automatic publication limit", dependencies.slice(0, MAX_REPORTED_PATHS));
|
|
154
|
+
const others = nulSplit(await this.run(["ls-files", "--others", "--exclude-standard", "-z"]));
|
|
155
|
+
const foreign = others.filter((file) => file.endsWith("/")).map((file) => file.slice(0, -1));
|
|
156
|
+
const tracked = /* @__PURE__ */ new Map();
|
|
157
|
+
let unexplainedBytes = 0;
|
|
158
|
+
const unexplainedPaths = [];
|
|
159
|
+
for (const file of others) {
|
|
160
|
+
if (file.endsWith("/")) continue;
|
|
161
|
+
const inner = inners.find((candidate) => withinRoot(file, candidate.root));
|
|
162
|
+
if (inner) {
|
|
163
|
+
let set = tracked.get(inner.root);
|
|
164
|
+
if (!set) tracked.set(inner.root, set = await inner.tracked());
|
|
165
|
+
if (set.has(file.slice(inner.root.length + 1))) continue;
|
|
166
|
+
}
|
|
167
|
+
const stat = await fs.lstat(path.join(this.workTree, file)).catch((error) => {
|
|
168
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR") return null;
|
|
169
|
+
throw error;
|
|
170
|
+
});
|
|
171
|
+
if (!stat?.isFile()) continue;
|
|
172
|
+
unexplainedBytes += stat.size;
|
|
173
|
+
unexplainedPaths.push(file);
|
|
174
|
+
if (unexplainedBytes > limit && !options.allowLargeDiff)
|
|
175
|
+
throw new OuterSnapshotRefusal(
|
|
176
|
+
"too_large",
|
|
177
|
+
`Unexplained workspace content exceeds the ${limit}-byte automatic publication limit`,
|
|
178
|
+
unexplainedPaths.slice(0, MAX_REPORTED_PATHS)
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
for (const file of unexplainedPaths) {
|
|
182
|
+
const bytes = await readRegular(path.join(this.workTree, file)).catch((error) => {
|
|
183
|
+
if (error instanceof WorkspaceError) throw new OuterSnapshotRefusal(error.code, error.message, [file]);
|
|
184
|
+
throw error;
|
|
185
|
+
});
|
|
186
|
+
try {
|
|
187
|
+
sourceBytes(bytes, file);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (error instanceof WorkspaceError) throw new OuterSnapshotRefusal(error.code, error.message, [file]);
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
await this.run(["add", "-A", "--", ".", ...foreign.map((directory) => `:(exclude,literal)${directory}`)]);
|
|
194
|
+
await this.stripUnsupported();
|
|
195
|
+
const tree = GitOid.parse((await this.run(["write-tree"])).toString("utf8").trim());
|
|
196
|
+
return { tree, unexplainedBytes, unexplainedPaths };
|
|
197
|
+
}
|
|
198
|
+
/** Staging records a symlink as a link entry, and a nested repository that
|
|
199
|
+
* appeared between the walk and the add as a gitlink. Neither can be
|
|
200
|
+
* materialized on another host; both are dropped from the index without
|
|
201
|
+
* touching the working tree. */
|
|
202
|
+
async stripUnsupported() {
|
|
203
|
+
const unsupported = nulSplit(await this.run(["ls-files", "--stage", "-z"])).map((record) => /^(\d{6}) [0-9a-f]{40} \d\t(.+)$/.exec(record)).filter((match) => !!match && (match[1] === "160000" || match[1] === "120000")).map((match) => match[2]);
|
|
204
|
+
if (unsupported.length) await this.run(["update-index", "--force-remove", "-z", "--stdin"], `${unsupported.join("\0")}\0`);
|
|
205
|
+
}
|
|
206
|
+
async commit(tree, parents, message) {
|
|
207
|
+
const args = ["commit-tree", GitOid.parse(tree)];
|
|
208
|
+
for (const parent of parents) args.push("-p", GitOid.parse(parent));
|
|
209
|
+
return GitOid.parse((await this.run(args, `${message}
|
|
210
|
+
`)).toString("utf8").trim());
|
|
211
|
+
}
|
|
212
|
+
/** A three-way merge computed entirely in the object store. The result tree
|
|
213
|
+
* of a conflicted merge carries ordinary conflict markers in the files named. */
|
|
214
|
+
async merge(ours, theirs) {
|
|
215
|
+
const { code, stdout } = await this.result(["merge-tree", "--write-tree", "-z", "--name-only", "--no-messages", GitOid.parse(ours), GitOid.parse(theirs)]);
|
|
216
|
+
if (code !== 0 && code !== 1) throw new WorkspaceError("git_failed", "Outer repository merge failed");
|
|
217
|
+
const tokens = stdout.toString("utf8").split("\0");
|
|
218
|
+
const tree = GitOid.parse(tokens[0].trim());
|
|
219
|
+
const conflicts = [];
|
|
220
|
+
if (code === 1) for (const token of tokens.slice(1)) {
|
|
221
|
+
if (!token) break;
|
|
222
|
+
safeTreePath(token);
|
|
223
|
+
conflicts.push(token);
|
|
224
|
+
}
|
|
225
|
+
return { tree, conflicts };
|
|
226
|
+
}
|
|
227
|
+
/** Move the working tree from one tree to another without clobbering anything
|
|
228
|
+
* edited since the index last saw it. Git refuses the whole update when any
|
|
229
|
+
* path it would change is locally modified, so a refusal is transient by
|
|
230
|
+
* construction: the next snapshot picks that edit up and the merge recurs. */
|
|
231
|
+
async apply(fromTree, toTree) {
|
|
232
|
+
const { code } = await this.result(["read-tree", "-m", "-u", GitOid.parse(fromTree), GitOid.parse(toTree)]);
|
|
233
|
+
if (code !== 0) throw new WorkspaceError("apply_refused", "Concurrent edits kept the workspace from taking the merged tree; retried next cycle");
|
|
234
|
+
}
|
|
235
|
+
/** Materialize a commit into a working tree that holds none of its paths yet. */
|
|
236
|
+
async checkout(commit) {
|
|
237
|
+
await this.run(["read-tree", GitOid.parse(commit)]);
|
|
238
|
+
await this.run(["checkout-index", "-a"]);
|
|
239
|
+
}
|
|
240
|
+
/** Reset index and working tree to a commit, deleting tracked paths it lacks.
|
|
241
|
+
* Never `clean`: the second `-f` that would reach nested repositories is the
|
|
242
|
+
* one switch this repository must never pass. */
|
|
243
|
+
async reset(commit) {
|
|
244
|
+
await this.run(["reset", "--hard", GitOid.parse(commit)]);
|
|
245
|
+
}
|
|
246
|
+
async diffPaths(fromTree, toTree) {
|
|
247
|
+
return nulSplit(await this.run(["diff-tree", "-r", "--name-only", "-z", "--no-renames", GitOid.parse(fromTree), GitOid.parse(toTree)]));
|
|
248
|
+
}
|
|
249
|
+
/** Objects reachable from the head that the mirror lacks. The bundle names the
|
|
250
|
+
* branch, which the storage side verifies before it accepts the head. */
|
|
251
|
+
async bundle(exclude) {
|
|
252
|
+
const file = path.join(this.gitDir, `${randomUUID()}.bundle`);
|
|
253
|
+
try {
|
|
254
|
+
await this.run(["bundle", "create", file, OUTER_REF, ...exclude ? [`^${GitOid.parse(exclude)}`] : []]);
|
|
255
|
+
return await readRegular(file, STORAGE_LIMITS.blobBytes);
|
|
256
|
+
} finally {
|
|
257
|
+
await fs.rm(file, { force: true });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
async unbundle(bytes) {
|
|
261
|
+
const file = path.join(this.gitDir, `${randomUUID()}.bundle`);
|
|
262
|
+
try {
|
|
263
|
+
await fs.writeFile(file, bytes, { mode: 384, flag: "wx" });
|
|
264
|
+
await this.run(["bundle", "unbundle", file]);
|
|
265
|
+
} finally {
|
|
266
|
+
await fs.rm(file, { force: true });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/** Whether a commit's tree is exactly the legacy account bootstrap. */
|
|
270
|
+
async isLegacyBootstrap(commit) {
|
|
271
|
+
const entries = await this.listTree(await this.treeOf(commit));
|
|
272
|
+
if (entries.length !== 1 || entries[0].file !== "README.md" || entries[0].mode !== "100644") return false;
|
|
273
|
+
return (await this.run(["cat-file", "blob", entries[0].oid])).toString("utf8") === LEGACY_ACCOUNT_README;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
export {
|
|
277
|
+
LEGACY_ACCOUNT_README,
|
|
278
|
+
OUTER_BRANCH,
|
|
279
|
+
OUTER_CONFLICT_CODE,
|
|
280
|
+
OUTER_EXCLUDES,
|
|
281
|
+
OUTER_GIT_TIMEOUT_MS,
|
|
282
|
+
OUTER_REF,
|
|
283
|
+
OUTER_SNAPSHOT_LIMIT_BYTES,
|
|
284
|
+
OuterRepository,
|
|
285
|
+
OuterSnapshotRefusal
|
|
286
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { OwnershipFence, RuntimeId } from "@ricsam/r5d-api/runtime-protocol";
|
|
3
|
-
const STORAGE_LIMITS = { chunkBytes: 64 * 1024, blobBytes: 128 * 1024 * 1024, requestBytes: 96 * 1024, treeEntries:
|
|
3
|
+
const STORAGE_LIMITS = { chunkBytes: 64 * 1024, blobBytes: 128 * 1024 * 1024, requestBytes: 96 * 1024, treeEntries: 2e5 };
|
|
4
4
|
const StorageId = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,99}$/);
|
|
5
5
|
const GitOid = z.string().regex(/^[0-9a-f]{40}$/);
|
|
6
6
|
const BranchName = z.string().min(1).max(150).refine((value) => value.split("/").every((part) => /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(part)));
|
|
@@ -27,7 +27,10 @@ const StorageRequest = z.discriminatedUnion("method", [
|
|
|
27
27
|
branch: BranchName,
|
|
28
28
|
expectedHead: GitOid.nullable(),
|
|
29
29
|
commit: GitOid,
|
|
30
|
-
bundleId: StorageId
|
|
30
|
+
bundleId: StorageId,
|
|
31
|
+
/** A mirror publishes whatever head the checkout has, rewinds included;
|
|
32
|
+
* the head compare-and-set still applies. */
|
|
33
|
+
force: z.boolean().optional()
|
|
31
34
|
}).strict(),
|
|
32
35
|
z.object({
|
|
33
36
|
method: z.literal("blob.append"),
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Publication refusals, and how long one must persist before it becomes a
|
|
2
|
+
* durable remediation incident.
|
|
3
|
+
*
|
|
4
|
+
* Refusing to publish is cheap and immediate. Electing an incident is not: it
|
|
5
|
+
* interrupts whoever owns the checkout with an automatic remediation session.
|
|
6
|
+
* So a condition an ordinary command produces in passing has to outlive that
|
|
7
|
+
* command before it counts as evidence of a stuck checkout. */
|
|
8
|
+
/** Leaves the checkout holding unpublished conflict state. */
|
|
9
|
+
export declare const CONFLICT_PUBLICATION_CODES: ReadonlySet<string>;
|
|
10
|
+
/** Refusals that clear themselves: a concurrent edit kept a merged tree from
|
|
11
|
+
* landing, or the mirror moved between read and publish. The next cycle
|
|
12
|
+
* repeats the work; nothing durable happened and nothing is reported. */
|
|
13
|
+
export declare const TRANSIENT_PUBLICATION_CODES: ReadonlySet<string>;
|
|
14
|
+
/** Refused because the source candidate itself is unsafe or oversized. */
|
|
15
|
+
export declare const BLOCKED_PUBLICATION_CODES: ReadonlySet<string>;
|
|
16
|
+
/** Consecutive identical observations required before electing an incident.
|
|
17
|
+
* `unsafe_file`: a package manager or Git command can atomically replace a file
|
|
18
|
+
* between directory enumeration and the bounded single-link read. */
|
|
19
|
+
export declare const PUBLICATION_OBSERVATION_THRESHOLDS: ReadonlyMap<string, number>;
|
|
20
|
+
/** A merge, rebase, revert, cherry-pick or index lock belongs to whatever is
|
|
21
|
+
* running in that checkout. Its duration is the agent's, not ours: resolving a
|
|
22
|
+
* conflicted cherry-pick legitimately takes minutes, so no fixed number of
|
|
23
|
+
* samples is a safe bound. Publication defers while a run is live and elects
|
|
24
|
+
* only once the marker has outlived it. */
|
|
25
|
+
export declare const OPERATION_IN_PROGRESS_CODE = "workbench_operation_in_progress";
|
|
26
|
+
/** Observations required once no run is live — the marker has been abandoned. */
|
|
27
|
+
export declare const ABANDONED_OPERATION_OBSERVATIONS = 2;
|
|
28
|
+
/** Backstop for a run that never reaches a terminal state while holding a
|
|
29
|
+
* marker. At the one-minute publication interval this is roughly half an hour. */
|
|
30
|
+
export declare const STUCK_OPERATION_OBSERVATIONS = 30;
|
|
31
|
+
export declare function publicationRefusalOutcome(code: string): "conflict_blocked" | "large_diff_blocked" | "failed";
|
|
32
|
+
/** True once this refusal has proven durable enough to elect an incident. */
|
|
33
|
+
export declare function publicationRefusalElects(code: string, observations: number, context?: {
|
|
34
|
+
workbenchHasLiveRun?: boolean;
|
|
35
|
+
}): boolean;
|
|
@@ -104,7 +104,8 @@ export type PersonalWorkerRuntime = Awaited<ReturnType<typeof openPersonalWorker
|
|
|
104
104
|
export declare function openPersonalWorkerRuntime(options: {
|
|
105
105
|
root: string;
|
|
106
106
|
grant: z.infer<typeof PersonalWorkerGrant>;
|
|
107
|
-
|
|
107
|
+
/** A null session is the account scope: the outer repository is bound to no conversation. */
|
|
108
|
+
storage: (sessionId: string | null) => StorageTransport;
|
|
108
109
|
cliEntrypoint?: string;
|
|
109
110
|
/** Internal test/embedding override. Personal workers publish every minute. */
|
|
110
111
|
publicationIntervalMs?: number;
|
|
@@ -144,7 +145,7 @@ export declare function openPersonalWorkerRuntime(options: {
|
|
|
144
145
|
authority: WorkspaceAuthority;
|
|
145
146
|
synchronize: () => Promise<{
|
|
146
147
|
workbenchId: string;
|
|
147
|
-
head?: string;
|
|
148
|
+
head?: string | null;
|
|
148
149
|
unchanged?: boolean;
|
|
149
150
|
error?: string;
|
|
150
151
|
}[]>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { OperationEnvelope } from "@ricsam/r5d-api/runtime-protocol";
|
|
2
2
|
import { type PollResult, type RunIdentity } from "../protocol";
|
|
3
|
-
import { WorkspaceConfig, type ExecutorRoute, type WorkspaceIdentity, type ApprovedWorkbench as ApprovedWorkbenchInput } from "./contracts";
|
|
3
|
+
import { WorkspaceConfig, type ExecutorRoute, type WorkspaceIdentity, type OuterState, type ApprovedWorkbench as ApprovedWorkbenchInput } from "./contracts";
|
|
4
4
|
import { SessionArtifactChunk } from "./artifacts";
|
|
5
5
|
import { WorkspaceFileWrite } from "./file-write";
|
|
6
6
|
import { WorkspaceStorageClient } from "./storage-client";
|
|
@@ -10,6 +10,12 @@ export interface WorkspaceAuthorityOptions {
|
|
|
10
10
|
executor: (identity: WorkspaceIdentity) => Promise<ExecutorRoute>;
|
|
11
11
|
/** Trusted catalog authorization; never a client-supplied filesystem path. */
|
|
12
12
|
resolveWorkbench?: (identity: WorkspaceIdentity) => Promise<ApprovedWorkbenchInput | null>;
|
|
13
|
+
/** Storage for the account's outer repository, bound to no session. Its
|
|
14
|
+
* presence selects the outer-repository model: the workspace root is one
|
|
15
|
+
* repository whose files synchronize as a unit and whose project checkouts
|
|
16
|
+
* are mirrored, not published, per branch. Without it linked checkouts keep
|
|
17
|
+
* publishing per project, which the server workspace service relies on. */
|
|
18
|
+
accountStorage?: (userId: string) => Promise<WorkspaceStorageClient>;
|
|
13
19
|
}
|
|
14
20
|
/** Independent resource owner. Run receipts and workbench never live in replaceable adapters/gateways.
|
|
15
21
|
* Private root is exclusively owned; a crash leaves lock+intent for explicit maintenance.
|
|
@@ -25,6 +31,7 @@ export declare class WorkspaceAuthority {
|
|
|
25
31
|
private closeTask?;
|
|
26
32
|
private readonly actions;
|
|
27
33
|
private readonly repositoryInitializations;
|
|
34
|
+
private outerRepository?;
|
|
28
35
|
private pending;
|
|
29
36
|
private constructor();
|
|
30
37
|
private expectedCwd;
|
|
@@ -178,6 +185,7 @@ export declare class WorkspaceAuthority {
|
|
|
178
185
|
private save;
|
|
179
186
|
private owned;
|
|
180
187
|
private serial;
|
|
188
|
+
private serialKey;
|
|
181
189
|
private route;
|
|
182
190
|
private storage;
|
|
183
191
|
private refresh;
|
|
@@ -186,7 +194,12 @@ export declare class WorkspaceAuthority {
|
|
|
186
194
|
* agents remain ordinary concurrent filesystem writers whose later changes
|
|
187
195
|
* are observed by a subsequent publication cycle. */
|
|
188
196
|
private assertAvailable;
|
|
197
|
+
/** Whether any run on this physical workbench is still live, across every
|
|
198
|
+
* session sharing it. An in-progress Git operation marker belongs to such a
|
|
199
|
+
* run, so publication defers to it instead of electing an incident over it. */
|
|
200
|
+
workbenchHasLiveRun(identity: WorkspaceIdentity): Promise<boolean>;
|
|
189
201
|
status(identity: WorkspaceIdentity): Promise<{
|
|
202
|
+
outer?: OuterState | undefined;
|
|
190
203
|
files: string[] | null;
|
|
191
204
|
initialized: boolean;
|
|
192
205
|
head: string | null;
|
|
@@ -200,6 +213,7 @@ export declare class WorkspaceAuthority {
|
|
|
200
213
|
operationId?: string;
|
|
201
214
|
commit?: string;
|
|
202
215
|
};
|
|
216
|
+
mirroredHead?: string | null;
|
|
203
217
|
runs: Record<string, {
|
|
204
218
|
sessionId?: string;
|
|
205
219
|
artifactEnvironment?: boolean;
|
|
@@ -231,10 +245,11 @@ export declare class WorkspaceAuthority {
|
|
|
231
245
|
/** No-argument hydration is initial-only. Explicit expectedBase permits a
|
|
232
246
|
* clean workbench refresh; process/PTY liveness never gates synchronization. */
|
|
233
247
|
hydrate(identity: WorkspaceIdentity, expectedBase?: string): Promise<{
|
|
234
|
-
head: string;
|
|
248
|
+
head: string | null;
|
|
249
|
+
unchanged: boolean;
|
|
250
|
+
integrated?: string;
|
|
235
251
|
} | {
|
|
236
252
|
head: string;
|
|
237
|
-
unchanged: boolean;
|
|
238
253
|
}>;
|
|
239
254
|
private hydrateIdle;
|
|
240
255
|
private assertFreshAccountWorkbench;
|
|
@@ -246,6 +261,11 @@ export declare class WorkspaceAuthority {
|
|
|
246
261
|
unchanged?: boolean;
|
|
247
262
|
}>;
|
|
248
263
|
private installGitPolicy;
|
|
264
|
+
/** This repository is also the common Git directory for the visible project
|
|
265
|
+
* worktrees. Keep their ordinary Git topology pointed at GitHub; mirroring is
|
|
266
|
+
* performed by WorkspaceAuthority directly and must never be exposed as the
|
|
267
|
+
* checkout's origin. */
|
|
268
|
+
private configureLinkedRemote;
|
|
249
269
|
/** Scoped receipt lookup never repeats a file effect; completion can clear its own crash marker. */
|
|
250
270
|
fileWriteResult(identity: WorkspaceIdentity, input: {
|
|
251
271
|
id: string;
|
|
@@ -305,7 +325,7 @@ export declare class WorkspaceAuthority {
|
|
|
305
325
|
expectedHead?: string | null;
|
|
306
326
|
};
|
|
307
327
|
}): Promise<{
|
|
308
|
-
head: string
|
|
328
|
+
head: string;
|
|
309
329
|
pushed: boolean;
|
|
310
330
|
}>;
|
|
311
331
|
archiveBranch(identity: WorkspaceIdentity, input: {
|
|
@@ -323,10 +343,72 @@ export declare class WorkspaceAuthority {
|
|
|
323
343
|
retained: boolean;
|
|
324
344
|
head: string;
|
|
325
345
|
}>;
|
|
346
|
+
private outerEnabled;
|
|
347
|
+
private outerStateFile;
|
|
348
|
+
private outer;
|
|
349
|
+
private saveOuter;
|
|
350
|
+
outerStatus(userId: string): Promise<OuterState>;
|
|
351
|
+
private accountRepositoryId;
|
|
352
|
+
private provisionId;
|
|
353
|
+
private hasCommit;
|
|
354
|
+
private innerHead;
|
|
355
|
+
private headMatches;
|
|
356
|
+
private innerTracked;
|
|
357
|
+
/** Platform snapshot commits from the per-project model sit on top of the
|
|
358
|
+
* GitHub tip. Hydration peels them so a checkout's history is its own. */
|
|
359
|
+
private platformSnapshot;
|
|
360
|
+
private firstParent;
|
|
361
|
+
/** Every initialized project checkout beneath the workspace root, for the
|
|
362
|
+
* outer walk and the measure. */
|
|
363
|
+
private innerCheckouts;
|
|
364
|
+
/** The mirror's head for a branch, or null when it has none. Only a caller
|
|
365
|
+
* that owns the repository's existence provisions it: a project repository
|
|
366
|
+
* is provisioned by its seed or import under that action's own identity. */
|
|
367
|
+
private remoteHead;
|
|
368
|
+
private downloadBundle;
|
|
369
|
+
private uploadBundle;
|
|
370
|
+
private unbundleInto;
|
|
371
|
+
private forcePublishSupported;
|
|
372
|
+
/** One synchronization cycle of the outer repository: snapshot the workspace,
|
|
373
|
+
* integrate what the mirror gained, publish what changed. Serialized on its
|
|
374
|
+
* own queue; project checkouts stay ordinary concurrent writers whose later
|
|
375
|
+
* edits the next cycle observes. */
|
|
376
|
+
synchronizeOuter(userId: string, options?: {
|
|
377
|
+
allowLargeDiff?: boolean;
|
|
378
|
+
resolveConflict?: boolean;
|
|
379
|
+
requireHead?: string;
|
|
380
|
+
bootstrapOnly?: boolean;
|
|
381
|
+
}): Promise<{
|
|
382
|
+
head: string | null;
|
|
383
|
+
unchanged: boolean;
|
|
384
|
+
integrated?: string;
|
|
385
|
+
}>;
|
|
386
|
+
private synchronizeOuterIdle;
|
|
387
|
+
/** Push a project checkout's own HEAD to its mirror. Last push wins: no merge
|
|
388
|
+
* is attempted, and a rewind or rewrite replaces the mirror as readily as a
|
|
389
|
+
* fast-forward. A lost acknowledgement needs no reconciliation because the
|
|
390
|
+
* next cycle simply observes the mirror already holds the head. */
|
|
391
|
+
private mirrorInnerIdle;
|
|
392
|
+
/** Mirror one project checkout now, outside the periodic cycle. */
|
|
393
|
+
mirror(identity: WorkspaceIdentity): Promise<{
|
|
394
|
+
head: string | null;
|
|
395
|
+
unchanged: boolean;
|
|
396
|
+
}>;
|
|
397
|
+
/** Commit the checkout's working tree to its own history on the user's behalf. */
|
|
398
|
+
private commitInnerIdle;
|
|
399
|
+
/** Install a project checkout from its mirror. Files may already be present
|
|
400
|
+
* because the outer repository delivered them first; the worktree is then
|
|
401
|
+
* created around them and they show as ordinary dirty content. */
|
|
402
|
+
private hydrateLinkedIdle;
|
|
403
|
+
private installLinkedWorktree;
|
|
326
404
|
publish(identity: WorkspaceIdentity, options?: {
|
|
327
405
|
allowLargeDiff?: boolean;
|
|
328
406
|
allowBlockedConflict?: boolean;
|
|
329
407
|
}): Promise<{
|
|
408
|
+
head: string | null;
|
|
409
|
+
unchanged: boolean;
|
|
410
|
+
integrated?: string;
|
|
411
|
+
} | {
|
|
330
412
|
head: string;
|
|
331
413
|
unchanged?: boolean;
|
|
332
414
|
}>;
|
|
@@ -74,6 +74,8 @@ export type WorkspaceState = {
|
|
|
74
74
|
operationId?: string;
|
|
75
75
|
commit?: string;
|
|
76
76
|
};
|
|
77
|
+
/** Inner HEAD the mirror is known to hold, for linked project checkouts. */
|
|
78
|
+
mirroredHead?: string | null;
|
|
77
79
|
runs: Record<string, {
|
|
78
80
|
sessionId?: string;
|
|
79
81
|
artifactEnvironment?: boolean;
|
|
@@ -87,3 +89,27 @@ export type WorkspaceState = {
|
|
|
87
89
|
completedAt?: string;
|
|
88
90
|
}>;
|
|
89
91
|
};
|
|
92
|
+
/** Durable state of the account's outer repository on this worker. */
|
|
93
|
+
export type OuterState = {
|
|
94
|
+
userId: string;
|
|
95
|
+
initialized: boolean;
|
|
96
|
+
/** Head the mirror is known to hold; the parent of the next snapshot. */
|
|
97
|
+
publishedHead: string | null;
|
|
98
|
+
/** publishedHead, or the one unpublished snapshot or merge on top of it. */
|
|
99
|
+
head: string | null;
|
|
100
|
+
blocked: null | {
|
|
101
|
+
code: string;
|
|
102
|
+
message: string;
|
|
103
|
+
paths?: string[];
|
|
104
|
+
operationId?: string;
|
|
105
|
+
commit?: string;
|
|
106
|
+
};
|
|
107
|
+
/** A merge that conflicted: the markers are in the working tree and the next
|
|
108
|
+
* snapshot is the resolution, committed with both sides as parents. */
|
|
109
|
+
conflict: null | {
|
|
110
|
+
ours: string;
|
|
111
|
+
theirs: string;
|
|
112
|
+
tree: string;
|
|
113
|
+
paths: string[];
|
|
114
|
+
};
|
|
115
|
+
};
|