@ricsam/r5d-worker 0.0.162 → 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 +9 -0
- package/dist/mjs/personal/runtime.mjs +47 -42
- package/dist/mjs/runtime/workspace/authority.mjs +509 -72
- package/dist/mjs/runtime/workspace/files.mjs +26 -5
- 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 +4 -0
- package/dist/types/personal/runtime.d.ts +3 -2
- package/dist/types/runtime/workspace/authority.d.ts +82 -4
- package/dist/types/runtime/workspace/contracts.d.ts +26 -0
- package/dist/types/runtime/workspace/files.d.ts +24 -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
|
@@ -17,6 +17,7 @@ const WORKBENCH_OPERATION_MARKERS = [
|
|
|
17
17
|
];
|
|
18
18
|
const WORKBENCH_CONFLICT_CODES = /* @__PURE__ */ new Set([
|
|
19
19
|
"conflict",
|
|
20
|
+
"sync_conflict",
|
|
20
21
|
"workbench_conflict",
|
|
21
22
|
"workbench_operation_in_progress"
|
|
22
23
|
]);
|
|
@@ -99,7 +100,23 @@ async function durableJson(file, value) {
|
|
|
99
100
|
await dir.close();
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
|
-
|
|
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 = {}) {
|
|
103
120
|
if (indexFile && path.dirname(indexFile) !== cwd) throw new WorkspaceError("unsafe_path", "Index override must be authority-owned");
|
|
104
121
|
return new Promise((resolve, reject) => {
|
|
105
122
|
const child = spawn(
|
|
@@ -142,7 +159,8 @@ function git(cwd, args, input, indexFile, transport) {
|
|
|
142
159
|
// Only the reviewed GitHub import/push boundary supplies this option.
|
|
143
160
|
// Tokens are process-private environment values, never argv, URLs or diagnostics.
|
|
144
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: "" } : {},
|
|
145
|
-
...indexFile ? { GIT_INDEX_FILE: indexFile } : {}
|
|
162
|
+
...indexFile ? { GIT_INDEX_FILE: indexFile } : {},
|
|
163
|
+
...options.env ?? {}
|
|
146
164
|
},
|
|
147
165
|
stdio: ["pipe", "pipe", "pipe"]
|
|
148
166
|
}
|
|
@@ -152,7 +170,7 @@ function git(cwd, args, input, indexFile, transport) {
|
|
|
152
170
|
const timer = setTimeout(() => {
|
|
153
171
|
failed = true;
|
|
154
172
|
child.kill("SIGKILL");
|
|
155
|
-
}, 3e4);
|
|
173
|
+
}, options.timeoutMs ?? 3e4);
|
|
156
174
|
child.stdout.on("data", (b) => {
|
|
157
175
|
bytes += b.length;
|
|
158
176
|
if (bytes > 32 * 1024 * 1024) {
|
|
@@ -173,9 +191,9 @@ function git(cwd, args, input, indexFile, transport) {
|
|
|
173
191
|
});
|
|
174
192
|
child.on("close", (code) => {
|
|
175
193
|
clearTimeout(timer);
|
|
176
|
-
if (failed || code
|
|
194
|
+
if (failed || code === null)
|
|
177
195
|
reject(new WorkspaceError("git_failed", "Bounded private Git operation failed; preserve state for inspection"));
|
|
178
|
-
else resolve(Buffer.concat(output));
|
|
196
|
+
else resolve({ code, stdout: Buffer.concat(output) });
|
|
179
197
|
});
|
|
180
198
|
child.stdin.on("error", () => {
|
|
181
199
|
});
|
|
@@ -420,11 +438,14 @@ async function snapshotTree(repo, cwd, canonicalHead, maxBytes = 128 * 1024 * 10
|
|
|
420
438
|
return (await git(repo, ["write-tree"])).toString().trim();
|
|
421
439
|
}
|
|
422
440
|
export {
|
|
441
|
+
PLATFORM_COMMIT_EMAIL,
|
|
442
|
+
PROJECT_COMMIT_IDENTITY,
|
|
423
443
|
WORKBENCH_CONFLICT_CODES,
|
|
424
444
|
WORKBENCH_OPERATION_MARKERS,
|
|
425
445
|
durableJson,
|
|
426
446
|
ensureAuthorityGitRepositoryLayout,
|
|
427
447
|
git,
|
|
448
|
+
gitResult,
|
|
428
449
|
materializeTree,
|
|
429
450
|
noSymlinkAncestors,
|
|
430
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"),
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* command before it counts as evidence of a stuck checkout. */
|
|
8
8
|
/** Leaves the checkout holding unpublished conflict state. */
|
|
9
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>;
|
|
10
14
|
/** Refused because the source candidate itself is unsafe or oversized. */
|
|
11
15
|
export declare const BLOCKED_PUBLICATION_CODES: ReadonlySet<string>;
|
|
12
16
|
/** Consecutive identical observations required before electing an incident.
|
|
@@ -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;
|
|
@@ -191,6 +199,7 @@ export declare class WorkspaceAuthority {
|
|
|
191
199
|
* run, so publication defers to it instead of electing an incident over it. */
|
|
192
200
|
workbenchHasLiveRun(identity: WorkspaceIdentity): Promise<boolean>;
|
|
193
201
|
status(identity: WorkspaceIdentity): Promise<{
|
|
202
|
+
outer?: OuterState | undefined;
|
|
194
203
|
files: string[] | null;
|
|
195
204
|
initialized: boolean;
|
|
196
205
|
head: string | null;
|
|
@@ -204,6 +213,7 @@ export declare class WorkspaceAuthority {
|
|
|
204
213
|
operationId?: string;
|
|
205
214
|
commit?: string;
|
|
206
215
|
};
|
|
216
|
+
mirroredHead?: string | null;
|
|
207
217
|
runs: Record<string, {
|
|
208
218
|
sessionId?: string;
|
|
209
219
|
artifactEnvironment?: boolean;
|
|
@@ -235,10 +245,11 @@ export declare class WorkspaceAuthority {
|
|
|
235
245
|
/** No-argument hydration is initial-only. Explicit expectedBase permits a
|
|
236
246
|
* clean workbench refresh; process/PTY liveness never gates synchronization. */
|
|
237
247
|
hydrate(identity: WorkspaceIdentity, expectedBase?: string): Promise<{
|
|
238
|
-
head: string;
|
|
248
|
+
head: string | null;
|
|
249
|
+
unchanged: boolean;
|
|
250
|
+
integrated?: string;
|
|
239
251
|
} | {
|
|
240
252
|
head: string;
|
|
241
|
-
unchanged: boolean;
|
|
242
253
|
}>;
|
|
243
254
|
private hydrateIdle;
|
|
244
255
|
private assertFreshAccountWorkbench;
|
|
@@ -250,6 +261,11 @@ export declare class WorkspaceAuthority {
|
|
|
250
261
|
unchanged?: boolean;
|
|
251
262
|
}>;
|
|
252
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;
|
|
253
269
|
/** Scoped receipt lookup never repeats a file effect; completion can clear its own crash marker. */
|
|
254
270
|
fileWriteResult(identity: WorkspaceIdentity, input: {
|
|
255
271
|
id: string;
|
|
@@ -309,7 +325,7 @@ export declare class WorkspaceAuthority {
|
|
|
309
325
|
expectedHead?: string | null;
|
|
310
326
|
};
|
|
311
327
|
}): Promise<{
|
|
312
|
-
head: string
|
|
328
|
+
head: string;
|
|
313
329
|
pushed: boolean;
|
|
314
330
|
}>;
|
|
315
331
|
archiveBranch(identity: WorkspaceIdentity, input: {
|
|
@@ -327,10 +343,72 @@ export declare class WorkspaceAuthority {
|
|
|
327
343
|
retained: boolean;
|
|
328
344
|
head: string;
|
|
329
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;
|
|
330
404
|
publish(identity: WorkspaceIdentity, options?: {
|
|
331
405
|
allowLargeDiff?: boolean;
|
|
332
406
|
allowBlockedConflict?: boolean;
|
|
333
407
|
}): Promise<{
|
|
408
|
+
head: string | null;
|
|
409
|
+
unchanged: boolean;
|
|
410
|
+
integrated?: string;
|
|
411
|
+
} | {
|
|
334
412
|
head: string;
|
|
335
413
|
unchanged?: boolean;
|
|
336
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
|
+
};
|
|
@@ -19,9 +19,32 @@ export declare function durableJson(file: string, value: unknown): Promise<void>
|
|
|
19
19
|
/** All Git operates in authority-created repos; NEVER workbench .git/config/index.
|
|
20
20
|
* hash-object --no-filters and update-index --index-info replace git add/checkout.
|
|
21
21
|
*/
|
|
22
|
+
export type GitOptions = {
|
|
23
|
+
/** One project's publication fits the default; a whole-workspace walk does not. */
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
/** Commits that belong to the project's own history carry its identity, not
|
|
26
|
+
* the platform's: the platform identity marks snapshots that hydration peels. */
|
|
27
|
+
env?: Record<string, string>;
|
|
28
|
+
};
|
|
29
|
+
/** Committer of platform-authored snapshot commits, recognised by hydration. */
|
|
30
|
+
export declare const PLATFORM_COMMIT_EMAIL = "workspace@invalid";
|
|
31
|
+
/** Identity of commits made in a project's own history on the user's behalf. */
|
|
32
|
+
export declare const PROJECT_COMMIT_IDENTITY: {
|
|
33
|
+
readonly GIT_AUTHOR_NAME: "r5d";
|
|
34
|
+
readonly GIT_AUTHOR_EMAIL: "workspace@r5d.dev";
|
|
35
|
+
readonly GIT_COMMITTER_NAME: "r5d";
|
|
36
|
+
readonly GIT_COMMITTER_EMAIL: "workspace@r5d.dev";
|
|
37
|
+
};
|
|
22
38
|
export declare function git(cwd: string, args: string[], input?: Buffer | string, indexFile?: string, transport?: {
|
|
23
39
|
token: string;
|
|
24
|
-
}): Promise<Buffer>;
|
|
40
|
+
}, options?: GitOptions): Promise<Buffer>;
|
|
41
|
+
/** Some Git commands answer with their exit code (`merge-base --is-ancestor`,
|
|
42
|
+
* `merge-tree`, `rev-parse --verify --quiet`). Only a killed or overflowing
|
|
43
|
+
* process is a failure here; the caller interprets the code. */
|
|
44
|
+
export declare function gitResult(cwd: string, args: string[], input?: Buffer | string, options?: GitOptions): Promise<{
|
|
45
|
+
code: number;
|
|
46
|
+
stdout: Buffer;
|
|
47
|
+
}>;
|
|
25
48
|
export declare function sourcePath(file: string): void;
|
|
26
49
|
export declare function sourceBytes(bytes: Buffer, file?: string): void;
|
|
27
50
|
export type TreeEntry = {
|