hammerkit 1.26.0 → 1.27.0-next.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/build.schema.json +53 -1
- package/dist/cache/backends/local-cache-backend.js +10 -2
- package/dist/cache/explain.js +13 -11
- package/dist/cli.js +3 -0
- package/dist/docker/item-network.js +471 -0
- package/dist/docker/pull.js +4 -2
- package/dist/docker/remove-orphaned-containers.js +13 -6
- package/dist/environment/replace-env-variables.js +8 -6
- package/dist/executer/await-completed-dependencies.js +2 -1
- package/dist/executer/check-readiness.js +2 -2
- package/dist/executer/docker-service.js +24 -13
- package/dist/executer/docker-task.js +18 -29
- package/dist/executer/event-cache.js +4 -0
- package/dist/executer/execute-command.js +18 -5
- package/dist/executer/execute-docker.js +4 -1
- package/dist/executer/execution-steps.js +33 -7
- package/dist/executer/get-docker-executor.js +14 -7
- package/dist/executer/scheduler/enqueue-next.js +17 -3
- package/dist/executer/watch-loop.js +2 -2
- package/dist/executer/watch-state-key.js +1 -1
- package/dist/file/within-root.js +42 -0
- package/dist/harness/connect.js +99 -0
- package/dist/harness/format.js +73 -0
- package/dist/harness/mcp-command.js +59 -0
- package/dist/harness/mcp.js +432 -0
- package/dist/harness/opencode.js +492 -0
- package/dist/harness/pi.js +217 -0
- package/dist/harness/sandbox-client.js +373 -0
- package/dist/index.js +33 -36
- package/dist/kubernetes/apply.js +31 -1
- package/dist/kubernetes/await-running-state.js +33 -1
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +43 -21
- package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
- package/dist/kubernetes/ensure-persistent-data.js +48 -7
- package/dist/kubernetes/get-service-ip.js +15 -1
- package/dist/kubernetes/job-placement.js +99 -0
- package/dist/kubernetes/job-secrets.js +306 -0
- package/dist/kubernetes/kept-claims.js +191 -0
- package/dist/kubernetes/kubernetes-instance.js +10 -2
- package/dist/kubernetes/network-gate.js +71 -0
- package/dist/kubernetes/network-policy.js +526 -0
- package/dist/kubernetes/resources.js +13 -2
- package/dist/kubernetes/restore-kubernetes-data.js +19 -4
- package/dist/kubernetes/secrets.js +2 -0
- package/dist/kubernetes/store-kubernetes-data.js +1 -1
- package/dist/kubernetes/volumes.js +100 -11
- package/dist/main.js +41 -0
- package/dist/network/egress-proxy.js +368 -0
- package/dist/network/port-relay.js +35 -0
- package/dist/optimizer/get-work-cache-stats.js +16 -0
- package/dist/optimizer/work-task-cache-description.js +3 -2
- package/dist/planner/service-definition.js +5 -0
- package/dist/planner/utils/append-work-environment.js +2 -0
- package/dist/planner/utils/append-work-service.js +4 -0
- package/dist/planner/utils/append-work-task.js +17 -1
- package/dist/planner/utils/assert-item-paths.js +50 -0
- package/dist/planner/utils/hosted-volumes.js +35 -0
- package/dist/planner/work-network.js +193 -0
- package/dist/planner/work-runtime-docker.js +41 -0
- package/dist/planner/work-runtime-kubernetes.js +335 -102
- package/dist/program.js +21 -0
- package/dist/remote/annotations.js +103 -0
- package/dist/remote/bundle-text.js +24 -0
- package/dist/remote/bundle.js +9 -1
- package/dist/remote/remote-commands.js +4 -1
- package/dist/remote/remote-work-commands.js +182 -69
- package/dist/remote/server-client.js +18 -5
- package/dist/remote/source-manifest.js +15 -2
- package/dist/remote/token-source.js +67 -0
- package/dist/schema/build-file-base-task-schema.js +3 -0
- package/dist/schema/build-file-container-service-schema.js +6 -0
- package/dist/schema/build-file-container-task-schema.js +3 -0
- package/dist/schema/build-file-environment-schema.js +9 -0
- package/dist/schema/network-schema.js +78 -0
- package/dist/schema/reference-parser.js +1 -0
- package/dist/schema/schema-parser.js +16 -3
- package/dist/schema/work-scope-parser.js +5 -0
- package/dist/secrets/command-secret-provider.js +73 -10
- package/dist/secrets/github-app-token-command.js +23 -0
- package/dist/secrets/github-app-token.js +191 -0
- package/dist/server/accounts-api.js +9 -2
- package/dist/server/definitions-api.js +69 -47
- package/dist/server/definitions.js +76 -51
- package/dist/server/executor.js +156 -41
- package/dist/server/git-checkout.js +72 -8
- package/dist/server/kubernetes-backend.js +554 -0
- package/dist/server/kubernetes-sandbox-storage.js +996 -0
- package/dist/server/oauth.js +1 -0
- package/dist/server/plan.js +51 -1
- package/dist/server/policy.js +104 -15
- package/dist/server/proposals-api.js +24 -40
- package/dist/server/proposals.js +12 -25
- package/dist/server/repo-access.js +37 -6
- package/dist/server/run-logs.js +217 -0
- package/dist/server/run-plan.js +51 -2
- package/dist/server/run-request.js +23 -10
- package/dist/server/run-store.js +5 -0
- package/dist/server/run-worker-command.js +13 -1
- package/dist/server/run-worker.js +121 -24
- package/dist/server/runs.js +184 -10
- package/dist/server/sandbox-docker.js +62 -0
- package/dist/server/sandbox-files-api.js +269 -0
- package/dist/server/sandbox-files.js +1054 -0
- package/dist/server/sandbox-patch.js +481 -0
- package/dist/server/sandbox-pod-command.js +15 -0
- package/dist/server/sandbox-pod.js +146 -0
- package/dist/server/sandbox-storage.js +462 -0
- package/dist/server/sandboxes-api.js +210 -0
- package/dist/server/sandboxes.js +519 -0
- package/dist/server/server-config.js +412 -22
- package/dist/server/server.js +204 -39
- package/dist/utils/run-id.js +12 -0
- package/harness/opencode.ts +11 -0
- package/harness/pi.ts +7 -0
- package/package.json +8 -2
- package/dist/remote/text-diff.js +0 -89
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Patches for the files of a sandbox, in the two formats models are trained to write: a
|
|
4
|
+
* unified diff as git writes it (`git diff`, `git format-patch`, `diff -u`), and the body of
|
|
5
|
+
* OpenAI's `apply_patch` tool (`*** Begin Patch`). A patch is parsed into what it does per
|
|
6
|
+
* file, and each file is changed in memory, so a batch of edits applies fully or not at all.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.MAX_COMPARED_LINES = exports.parsePatch = exports.PatchError = void 0;
|
|
10
|
+
class PatchError extends Error {
|
|
11
|
+
}
|
|
12
|
+
exports.PatchError = PatchError;
|
|
13
|
+
function parsePatch(text) {
|
|
14
|
+
const files = /^\s*\*\*\* Begin Patch/.test(text) ? parseApplyPatch(text) : parseUnifiedDiff(text);
|
|
15
|
+
if (files.length === 0) {
|
|
16
|
+
throw new PatchError('the patch changes no file: send a unified diff or an apply_patch body (*** Begin Patch)');
|
|
17
|
+
}
|
|
18
|
+
return files;
|
|
19
|
+
}
|
|
20
|
+
exports.parsePatch = parsePatch;
|
|
21
|
+
function splitLines(content) {
|
|
22
|
+
if (content === '') {
|
|
23
|
+
return { lines: [], finalNewline: true, crlf: false };
|
|
24
|
+
}
|
|
25
|
+
const lines = content.split('\n');
|
|
26
|
+
const finalNewline = lines[lines.length - 1] === '';
|
|
27
|
+
if (finalNewline) {
|
|
28
|
+
lines.pop();
|
|
29
|
+
}
|
|
30
|
+
const crlf = lines.filter((line) => line.endsWith('\r')).length * 2 > lines.length;
|
|
31
|
+
return { lines, finalNewline, crlf };
|
|
32
|
+
}
|
|
33
|
+
function joinLines({ lines, finalNewline }) {
|
|
34
|
+
return lines.length === 0 ? '' : lines.join('\n') + (finalNewline ? '\n' : '');
|
|
35
|
+
}
|
|
36
|
+
const stripCr = (line) => (line.endsWith('\r') ? line.slice(0, -1) : line);
|
|
37
|
+
// Replaces `length` lines at `at` with the new side of `block`: context keeps the file's
|
|
38
|
+
// own line, added lines take the file's line ending.
|
|
39
|
+
function splice(file, at, block) {
|
|
40
|
+
const replacement = [];
|
|
41
|
+
let index = at;
|
|
42
|
+
for (const line of block) {
|
|
43
|
+
if (line.kind === ' ') {
|
|
44
|
+
replacement.push(file.lines[index++]);
|
|
45
|
+
}
|
|
46
|
+
else if (line.kind === '-') {
|
|
47
|
+
index++;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const text = stripCr(line.text);
|
|
51
|
+
replacement.push(file.crlf ? `${text}\r` : text);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
file.lines.splice(at, index - at, ...replacement);
|
|
55
|
+
return at + replacement.length;
|
|
56
|
+
}
|
|
57
|
+
function oldSide(block) {
|
|
58
|
+
return block.filter((line) => line.kind !== '+').map((line) => line.text);
|
|
59
|
+
}
|
|
60
|
+
// Lines one file's patch may compare to place its hunks. A hunk that is nowhere in a large
|
|
61
|
+
// file would otherwise be compared with every place in it, its whole length at each.
|
|
62
|
+
exports.MAX_COMPARED_LINES = 20000000;
|
|
63
|
+
let compared = 0;
|
|
64
|
+
function startComparing() {
|
|
65
|
+
compared = 0;
|
|
66
|
+
}
|
|
67
|
+
function compare(path) {
|
|
68
|
+
if (++compared > exports.MAX_COMPARED_LINES) {
|
|
69
|
+
throw new PatchError(`${path}: the patch's lines were compared with the file ${exports.MAX_COMPARED_LINES} times without finding their place; give its hunks more lines of context or the line numbers of the file as it is`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function matchesAt(path, lines, at, wanted, same = (a, b) => stripCr(a) === stripCr(b)) {
|
|
73
|
+
if (at < 0 || at + wanted.length > lines.length) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
return wanted.every((line, offset) => {
|
|
77
|
+
compare(path);
|
|
78
|
+
return same(lines[at + offset], line);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
82
|
+
function parseUnifiedDiff(text) {
|
|
83
|
+
const lines = text.split('\n');
|
|
84
|
+
const files = [];
|
|
85
|
+
let i = 0;
|
|
86
|
+
while (i < lines.length) {
|
|
87
|
+
const line = stripCr(lines[i]);
|
|
88
|
+
if (line.startsWith('diff --git ')) {
|
|
89
|
+
i = parseGitFile(lines, i, files);
|
|
90
|
+
}
|
|
91
|
+
else if (line.startsWith('--- ') && stripCr(lines[i + 1] ?? '').startsWith('+++ ')) {
|
|
92
|
+
i = parsePlainFile(lines, i, files);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
// a commit message of format-patch, `index` lines of other tools, a signature
|
|
96
|
+
i++;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return files;
|
|
100
|
+
}
|
|
101
|
+
function parseGitFile(lines, start, files) {
|
|
102
|
+
const names = parseGitNames(stripCr(lines[start]).slice('diff --git '.length));
|
|
103
|
+
const header = { from: names?.[0], to: names?.[1] };
|
|
104
|
+
let created = false;
|
|
105
|
+
let deleted = false;
|
|
106
|
+
let i = start + 1;
|
|
107
|
+
for (; i < lines.length; i++) {
|
|
108
|
+
const line = stripCr(lines[i]);
|
|
109
|
+
if (line.startsWith('diff --git ') || line.startsWith('--- ') || line.startsWith('@@')) {
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
if (line.startsWith('GIT binary patch') || /^Binary files .* differ$/.test(line)) {
|
|
113
|
+
throw new PatchError(`${header.to ?? header.from ?? 'a file'}: binary changes can not be applied, write the file with PUT`);
|
|
114
|
+
}
|
|
115
|
+
const mode = /^(old mode|new mode|deleted file mode|new file mode) (\d+)$/.exec(line);
|
|
116
|
+
if (mode) {
|
|
117
|
+
const exec = parseMode(mode[2], header.to ?? header.from);
|
|
118
|
+
if (mode[1] !== 'old mode' && mode[1] !== 'deleted file mode') {
|
|
119
|
+
header.exec = exec;
|
|
120
|
+
}
|
|
121
|
+
created || (created = mode[1] === 'new file mode');
|
|
122
|
+
deleted || (deleted = mode[1] === 'deleted file mode');
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const named = /^(rename from|rename to|copy from|copy to) (.+)$/.exec(line);
|
|
126
|
+
if (named) {
|
|
127
|
+
const path = unquote(named[2]);
|
|
128
|
+
if (named[1].endsWith('from')) {
|
|
129
|
+
header.from = path;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
header.to = path;
|
|
133
|
+
}
|
|
134
|
+
header.copy = named[1].startsWith('copy');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (i < lines.length && stripCr(lines[i]).startsWith('--- ')) {
|
|
138
|
+
const from = parseFileName(stripCr(lines[i]).slice(4), 'a/');
|
|
139
|
+
const to = parseFileName(stripCr(lines[i + 1] ?? '').slice(4), 'b/');
|
|
140
|
+
header.from = from ?? undefined;
|
|
141
|
+
header.to = to ?? undefined;
|
|
142
|
+
created || (created = from === null);
|
|
143
|
+
deleted || (deleted = to === null);
|
|
144
|
+
i += 2;
|
|
145
|
+
}
|
|
146
|
+
const [hunks, next] = parseHunks(lines, i, header.to ?? header.from ?? 'a file');
|
|
147
|
+
if (created) {
|
|
148
|
+
header.from = undefined;
|
|
149
|
+
}
|
|
150
|
+
if (deleted) {
|
|
151
|
+
header.to = undefined;
|
|
152
|
+
}
|
|
153
|
+
if (!header.from && !header.to) {
|
|
154
|
+
throw new PatchError(`the file of "${stripCr(lines[start])}" can not be read from the patch`);
|
|
155
|
+
}
|
|
156
|
+
files.push(filePatch(header, hunks));
|
|
157
|
+
return next;
|
|
158
|
+
}
|
|
159
|
+
function parsePlainFile(lines, start, files) {
|
|
160
|
+
const from = parseFileName(stripCr(lines[start]).slice(4), 'a/');
|
|
161
|
+
const to = parseFileName(stripCr(lines[start + 1]).slice(4), 'b/');
|
|
162
|
+
const [hunks, next] = parseHunks(lines, start + 2, to ?? from ?? 'a file');
|
|
163
|
+
if (from === null && to === null) {
|
|
164
|
+
throw new PatchError('a patch from /dev/null to /dev/null changes nothing');
|
|
165
|
+
}
|
|
166
|
+
// a plain diff names the same file twice, maybe in two directories (`diff -u old/x new/x`)
|
|
167
|
+
files.push(filePatch({ from: from ?? undefined, to: to === null ? undefined : from ?? to }, hunks));
|
|
168
|
+
return next;
|
|
169
|
+
}
|
|
170
|
+
function parseHunks(lines, start, path) {
|
|
171
|
+
const hunks = [];
|
|
172
|
+
let i = start;
|
|
173
|
+
while (i < lines.length) {
|
|
174
|
+
const header = HUNK_HEADER.exec(stripCr(lines[i]));
|
|
175
|
+
if (!header) {
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
const hunk = {
|
|
179
|
+
oldStart: Number(header[1]),
|
|
180
|
+
oldLength: header[2] === undefined ? 1 : Number(header[2]),
|
|
181
|
+
lines: [],
|
|
182
|
+
oldNoNewline: false,
|
|
183
|
+
newNoNewline: false,
|
|
184
|
+
};
|
|
185
|
+
let oldLeft = hunk.oldLength;
|
|
186
|
+
let newLeft = header[4] === undefined ? 1 : Number(header[4]);
|
|
187
|
+
i++;
|
|
188
|
+
while (oldLeft > 0 || newLeft > 0) {
|
|
189
|
+
if (i >= lines.length) {
|
|
190
|
+
throw new PatchError(`${path}: hunk ${hunks.length + 1} ends before its last line`);
|
|
191
|
+
}
|
|
192
|
+
const line = lines[i];
|
|
193
|
+
// editors drop the space of an empty context line
|
|
194
|
+
const kind = line === '' || line === '\r' ? ' ' : line[0];
|
|
195
|
+
if (kind !== ' ' && kind !== '-' && kind !== '+') {
|
|
196
|
+
if (kind === '\\') {
|
|
197
|
+
i++;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
throw new PatchError(`${path}: hunk ${hunks.length + 1} ends before its last line, at "${stripCr(line).slice(0, 80)}"`);
|
|
201
|
+
}
|
|
202
|
+
hunk.lines.push({ kind, text: line.slice(1) });
|
|
203
|
+
if (kind !== '+') {
|
|
204
|
+
oldLeft--;
|
|
205
|
+
}
|
|
206
|
+
if (kind !== '-') {
|
|
207
|
+
newLeft--;
|
|
208
|
+
}
|
|
209
|
+
if (oldLeft < 0 || newLeft < 0) {
|
|
210
|
+
throw new PatchError(`${path}: hunk ${hunks.length + 1} has more lines than its header says`);
|
|
211
|
+
}
|
|
212
|
+
i++;
|
|
213
|
+
if (lines[i]?.startsWith('\\')) {
|
|
214
|
+
hunk.oldNoNewline || (hunk.oldNoNewline = kind !== '+');
|
|
215
|
+
hunk.newNoNewline || (hunk.newNoNewline = kind !== '-');
|
|
216
|
+
i++;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
hunks.push(hunk);
|
|
220
|
+
}
|
|
221
|
+
return [hunks, i];
|
|
222
|
+
}
|
|
223
|
+
function filePatch(header, hunks) {
|
|
224
|
+
const path = header.to ?? header.from ?? '';
|
|
225
|
+
return {
|
|
226
|
+
...header,
|
|
227
|
+
apply(content) {
|
|
228
|
+
startComparing();
|
|
229
|
+
const file = splitLines(content);
|
|
230
|
+
let delta = 0;
|
|
231
|
+
let cursor = 0;
|
|
232
|
+
hunks.forEach((hunk, index) => {
|
|
233
|
+
const old = oldSide(hunk.lines);
|
|
234
|
+
// where the hunk says it is, moved by what the hunks before it added or removed
|
|
235
|
+
const expected = Math.max(cursor, nominal(hunk) + delta);
|
|
236
|
+
const at = findNear(path, file.lines, old, expected, cursor);
|
|
237
|
+
if (at < 0) {
|
|
238
|
+
throw new PatchError(`${path}: hunk ${index + 1} (@@ -${hunk.oldStart},${hunk.oldLength}) does not apply, its lines are not in the file at line ${hunk.oldStart} or near it; read the file again and make the patch from what it holds`);
|
|
239
|
+
}
|
|
240
|
+
const end = splice(file, at, hunk.lines);
|
|
241
|
+
delta = at - nominal(hunk) + (end - at - old.length);
|
|
242
|
+
cursor = end;
|
|
243
|
+
// a hunk that reaches the end of the file says whether its last line has a line feed
|
|
244
|
+
if (end === file.lines.length && hunk.lines.length > 0) {
|
|
245
|
+
file.finalNewline = !hunk.newNoNewline;
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
return joinLines(file);
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
// The line a hunk starts at: `-5,0` inserts after line 5, `-5,2` replaces lines 5 and 6.
|
|
253
|
+
function nominal(hunk) {
|
|
254
|
+
return hunk.oldLength === 0 ? hunk.oldStart : hunk.oldStart - 1;
|
|
255
|
+
}
|
|
256
|
+
// The match closest to `expected` at or after `from`, the way git apply moves a hunk
|
|
257
|
+
// whose lines moved.
|
|
258
|
+
function findNear(path, lines, wanted, expected, from) {
|
|
259
|
+
const last = lines.length - wanted.length;
|
|
260
|
+
for (let distance = 0; expected - distance >= from || expected + distance <= last; distance++) {
|
|
261
|
+
if (expected - distance >= from && matchesAt(path, lines, expected - distance, wanted)) {
|
|
262
|
+
return expected - distance;
|
|
263
|
+
}
|
|
264
|
+
if (distance > 0 && matchesAt(path, lines, expected + distance, wanted)) {
|
|
265
|
+
return expected + distance;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return -1;
|
|
269
|
+
}
|
|
270
|
+
function parseMode(mode, path) {
|
|
271
|
+
if (mode === '100755') {
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
if (mode === '100644') {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
throw new PatchError(`${path ?? 'a file'}: mode ${mode} is a symbolic link or a submodule, a patch may only change files`);
|
|
278
|
+
}
|
|
279
|
+
// `a/x b/x` of a `diff --git` line. With spaces in the name the two halves are found by
|
|
280
|
+
// being the same path; renames name their paths in `rename from` / `rename to`.
|
|
281
|
+
function parseGitNames(rest) {
|
|
282
|
+
if (rest.startsWith('"')) {
|
|
283
|
+
const end = quotedEnd(rest);
|
|
284
|
+
const from = unquote(rest.slice(0, end + 1));
|
|
285
|
+
const to = unquote(rest.slice(end + 2));
|
|
286
|
+
return [stripPrefix(from, 'a/'), stripPrefix(to, 'b/')];
|
|
287
|
+
}
|
|
288
|
+
if (rest.endsWith('"')) {
|
|
289
|
+
const start = rest.lastIndexOf(' "');
|
|
290
|
+
return [stripPrefix(rest.slice(0, start), 'a/'), stripPrefix(unquote(rest.slice(start + 1)), 'b/')];
|
|
291
|
+
}
|
|
292
|
+
const half = (rest.length - 1) / 2;
|
|
293
|
+
if (Number.isInteger(half) && rest[half] === ' ') {
|
|
294
|
+
const from = stripPrefix(rest.slice(0, half), 'a/');
|
|
295
|
+
const to = stripPrefix(rest.slice(half + 1), 'b/');
|
|
296
|
+
if (from === to) {
|
|
297
|
+
return [from, to];
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
function quotedEnd(value) {
|
|
303
|
+
for (let i = 1; i < value.length; i++) {
|
|
304
|
+
if (value[i] === '\\') {
|
|
305
|
+
i++;
|
|
306
|
+
}
|
|
307
|
+
else if (value[i] === '"') {
|
|
308
|
+
return i;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
throw new PatchError(`the quoted path ${value.slice(0, 80)} has no end`);
|
|
312
|
+
}
|
|
313
|
+
// `--- a/path\t2026-10-12 ...`, or /dev/null for a file that does not exist on that side
|
|
314
|
+
function parseFileName(value, prefix) {
|
|
315
|
+
const name = value.startsWith('"') ? unquote(value.slice(0, quotedEnd(value) + 1)) : value.split('\t')[0].trimEnd();
|
|
316
|
+
if (name === '/dev/null') {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
return stripPrefix(name, prefix);
|
|
320
|
+
}
|
|
321
|
+
function stripPrefix(path, prefix) {
|
|
322
|
+
return path.startsWith(prefix) ? path.slice(prefix.length) : path;
|
|
323
|
+
}
|
|
324
|
+
// A path git quoted: C escapes, and octal escapes for the bytes of UTF-8.
|
|
325
|
+
function unquote(value) {
|
|
326
|
+
if (!value.startsWith('"')) {
|
|
327
|
+
return value;
|
|
328
|
+
}
|
|
329
|
+
const bytes = [];
|
|
330
|
+
const escapes = { n: 10, t: 9, r: 13, '"': 34, '\\': 92, a: 7, b: 8, f: 12, v: 11 };
|
|
331
|
+
for (let i = 1; i < value.length - 1; i++) {
|
|
332
|
+
const char = value[i];
|
|
333
|
+
if (char !== '\\') {
|
|
334
|
+
bytes.push(...Buffer.from(char, 'utf8'));
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const next = value[++i];
|
|
338
|
+
if (/[0-7]/.test(next)) {
|
|
339
|
+
bytes.push(parseInt(value.slice(i, i + 3), 8));
|
|
340
|
+
i += 2;
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
bytes.push(escapes[next] ?? next.charCodeAt(0));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return Buffer.from(bytes).toString('utf8');
|
|
347
|
+
}
|
|
348
|
+
function parseApplyPatch(text) {
|
|
349
|
+
const lines = text.split('\n').map(stripCr);
|
|
350
|
+
let i = lines.findIndex((line) => line.trim() === '*** Begin Patch') + 1;
|
|
351
|
+
const files = [];
|
|
352
|
+
let ended = false;
|
|
353
|
+
while (i < lines.length) {
|
|
354
|
+
const line = lines[i];
|
|
355
|
+
if (line.trim() === '*** End Patch') {
|
|
356
|
+
ended = true;
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
if (line.trim() === '') {
|
|
360
|
+
i++;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
let match;
|
|
364
|
+
if ((match = /^\*\*\* Add File: (.+)$/.exec(line))) {
|
|
365
|
+
const content = [];
|
|
366
|
+
i++;
|
|
367
|
+
while (i < lines.length && !lines[i].startsWith('***')) {
|
|
368
|
+
if (!lines[i].startsWith('+')) {
|
|
369
|
+
throw new PatchError(`${match[1]}: every line of an added file starts with +, not "${lines[i].slice(0, 80)}"`);
|
|
370
|
+
}
|
|
371
|
+
content.push(lines[i].slice(1));
|
|
372
|
+
i++;
|
|
373
|
+
}
|
|
374
|
+
const added = content.length === 0 ? '' : `${content.join('\n')}\n`;
|
|
375
|
+
files.push({ to: match[1].trim(), apply: () => added });
|
|
376
|
+
}
|
|
377
|
+
else if ((match = /^\*\*\* Delete File: (.+)$/.exec(line))) {
|
|
378
|
+
files.push({ from: match[1].trim(), apply: () => '' });
|
|
379
|
+
i++;
|
|
380
|
+
}
|
|
381
|
+
else if ((match = /^\*\*\* Update File: (.+)$/.exec(line))) {
|
|
382
|
+
const path = match[1].trim();
|
|
383
|
+
let to = path;
|
|
384
|
+
i++;
|
|
385
|
+
const moved = /^\*\*\* Move to: (.+)$/.exec(lines[i] ?? '');
|
|
386
|
+
if (moved) {
|
|
387
|
+
to = moved[1].trim();
|
|
388
|
+
i++;
|
|
389
|
+
}
|
|
390
|
+
const chunks = [];
|
|
391
|
+
for (; i < lines.length; i++) {
|
|
392
|
+
const current = lines[i];
|
|
393
|
+
if (current === '*** End of File') {
|
|
394
|
+
if (chunks.length === 0) {
|
|
395
|
+
throw new PatchError(`${path}: *** End of File before any change`);
|
|
396
|
+
}
|
|
397
|
+
chunks[chunks.length - 1].endOfFile = true;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (current.startsWith('***')) {
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
if (current.startsWith('@@')) {
|
|
404
|
+
const anchor = current.slice(2).trim();
|
|
405
|
+
chunks.push({ anchor: anchor || undefined, lines: [], endOfFile: false });
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (chunks.length === 0) {
|
|
409
|
+
chunks.push({ lines: [], endOfFile: false });
|
|
410
|
+
}
|
|
411
|
+
const kind = current === '' ? ' ' : current[0];
|
|
412
|
+
if (kind !== ' ' && kind !== '-' && kind !== '+') {
|
|
413
|
+
throw new PatchError(`${path}: "${current.slice(0, 80)}" is not a change, each line starts with a space, - or +`);
|
|
414
|
+
}
|
|
415
|
+
chunks[chunks.length - 1].lines.push({ kind, text: current.slice(1) });
|
|
416
|
+
}
|
|
417
|
+
files.push({ from: path, to, apply: (content) => applyChunks(path, content, chunks) });
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
throw new PatchError(`"${line.slice(0, 80)}" is not an apply_patch line: expected *** Add File:, *** Update File:, *** Delete File: or *** End Patch`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (!ended) {
|
|
424
|
+
throw new PatchError('the patch has no *** End Patch line');
|
|
425
|
+
}
|
|
426
|
+
return files;
|
|
427
|
+
}
|
|
428
|
+
const exact = (a, b) => stripCr(a) === stripCr(b);
|
|
429
|
+
const trailing = (a, b) => a.trimEnd() === b.trimEnd();
|
|
430
|
+
const trimmed = (a, b) => a.trim() === b.trim();
|
|
431
|
+
// The way apply_patch finds its lines: after the chunk before it, exactly, then with
|
|
432
|
+
// trailing and then all surrounding whitespace ignored.
|
|
433
|
+
function applyChunks(path, content, chunks) {
|
|
434
|
+
startComparing();
|
|
435
|
+
const file = splitLines(content);
|
|
436
|
+
let cursor = 0;
|
|
437
|
+
chunks.forEach((chunk, index) => {
|
|
438
|
+
if (chunk.anchor !== undefined) {
|
|
439
|
+
const anchor = chunk.anchor;
|
|
440
|
+
const found = [exact, trimmed]
|
|
441
|
+
.map((same) => file.lines.findIndex((line, at) => {
|
|
442
|
+
if (at < cursor) {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
compare(path);
|
|
446
|
+
return same(line, anchor);
|
|
447
|
+
}))
|
|
448
|
+
.find((at) => at >= 0);
|
|
449
|
+
if (found === undefined) {
|
|
450
|
+
throw new PatchError(`${path}: chunk ${index + 1} follows "${anchor.slice(0, 80)}", which is not in the file after the chunk before it`);
|
|
451
|
+
}
|
|
452
|
+
cursor = found + 1;
|
|
453
|
+
}
|
|
454
|
+
if (chunk.lines.length === 0) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const old = oldSide(chunk.lines);
|
|
458
|
+
let at = -1;
|
|
459
|
+
for (const same of [exact, trailing, trimmed]) {
|
|
460
|
+
if (chunk.endOfFile && matchesAt(path, file.lines, file.lines.length - old.length, old, same)) {
|
|
461
|
+
at = file.lines.length - old.length;
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
at =
|
|
465
|
+
old.length === 0
|
|
466
|
+
? chunk.endOfFile
|
|
467
|
+
? file.lines.length
|
|
468
|
+
: cursor
|
|
469
|
+
: file.lines.findIndex((_, start) => start >= cursor && matchesAt(path, file.lines, start, old, same));
|
|
470
|
+
if (at >= 0) {
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (at < 0) {
|
|
475
|
+
throw new PatchError(`${path}: the lines of chunk ${index + 1} are not in the file${old.length > 0 ? ` (it starts with "${old[0].slice(0, 80)}")` : ''}; read the file again and make the patch from what it holds`);
|
|
476
|
+
}
|
|
477
|
+
cursor = splice(file, at, chunk.lines);
|
|
478
|
+
});
|
|
479
|
+
return joinLines(file);
|
|
480
|
+
}
|
|
481
|
+
//# sourceMappingURL=sandbox-patch.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runSandboxPodCommand = void 0;
|
|
4
|
+
const sandbox_pod_1 = require("./sandbox-pod");
|
|
5
|
+
// `hammerkit server-sandbox <directory>`: what a server runs in the pod of a sandbox's
|
|
6
|
+
// files on a cluster. It reads one request on its standard input and answers on its
|
|
7
|
+
// standard output. Not for people. It imports no more than it needs (see index.ts).
|
|
8
|
+
async function runSandboxPodCommand(directory, stdin, stdout) {
|
|
9
|
+
// tasks run as their image's user, which is not the pod's: what the pod writes, any
|
|
10
|
+
// of them may change, as in a directory a host shares with its containers
|
|
11
|
+
process.umask(0);
|
|
12
|
+
await (0, sandbox_pod_1.sandboxPodMain)(directory, stdin, stdout);
|
|
13
|
+
}
|
|
14
|
+
exports.runSandboxPodCommand = runSandboxPodCommand;
|
|
15
|
+
//# sourceMappingURL=sandbox-pod-command.js.map
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sandboxPodMain = exports.decodePodError = exports.encodeEditOps = exports.INCOMING_REF = exports.INCOMING = void 0;
|
|
4
|
+
const promises_1 = require("fs/promises");
|
|
5
|
+
const path_1 = require("path");
|
|
6
|
+
const url_1 = require("url");
|
|
7
|
+
const git_checkout_1 = require("./git-checkout");
|
|
8
|
+
const sandbox_files_1 = require("./sandbox-files");
|
|
9
|
+
const sandbox_storage_1 = require("./sandbox-storage");
|
|
10
|
+
// On a cluster a sandbox is a claim, and what the server does with its files runs in a
|
|
11
|
+
// short pod that mounts it (see KubernetesSandboxStorage): `hammerkit server-sandbox
|
|
12
|
+
// <directory>` reads one request on its standard input, does it with the code of a host
|
|
13
|
+
// (HostSandboxStorage, with the claim as the directory of the sandbox), and answers on
|
|
14
|
+
// its standard output.
|
|
15
|
+
// The repository the server fetched a commit into, below the directory, which create and
|
|
16
|
+
// fetch take it from; it goes afterwards
|
|
17
|
+
exports.INCOMING = 'incoming';
|
|
18
|
+
exports.INCOMING_REF = 'hammerkit-fetched';
|
|
19
|
+
// An edit crosses as JSON: bytes as base64.
|
|
20
|
+
function encodeEditOps(ops) {
|
|
21
|
+
return ops.map((op) => op.op === 'write' && Buffer.isBuffer(op.content)
|
|
22
|
+
? { ...op, content: op.content.toString('base64'), encoding: 'base64' }
|
|
23
|
+
: op);
|
|
24
|
+
}
|
|
25
|
+
exports.encodeEditOps = encodeEditOps;
|
|
26
|
+
// The error of an answer, as the API would have thrown it on a host.
|
|
27
|
+
function decodePodError(error) {
|
|
28
|
+
if (error.status) {
|
|
29
|
+
return new sandbox_files_1.SandboxFileError(error.status, error.message, error.details ?? {});
|
|
30
|
+
}
|
|
31
|
+
if (error.kind === 'too-large') {
|
|
32
|
+
return new git_checkout_1.GitOutputTooLarge(error.message);
|
|
33
|
+
}
|
|
34
|
+
return new Error(error.message);
|
|
35
|
+
}
|
|
36
|
+
exports.decodePodError = decodePodError;
|
|
37
|
+
function write(out, chunk) {
|
|
38
|
+
return new Promise((resolve, reject) => out.write(chunk, (e) => (e ? reject(e) : resolve())));
|
|
39
|
+
}
|
|
40
|
+
// A failure once the bytes of a raw read began: the answer can not say so any more, the
|
|
41
|
+
// command fails instead.
|
|
42
|
+
class AnswerStarted extends Error {
|
|
43
|
+
}
|
|
44
|
+
async function answer(storage, directory, request, out) {
|
|
45
|
+
// the owner is the server's to check; in the pod there is only this sandbox
|
|
46
|
+
const key = { id: request.id, owner: { iss: '', sub: '' } };
|
|
47
|
+
const files = () => storage.files(key);
|
|
48
|
+
const send = (result) => write(out, JSON.stringify({ ok: true, result }) + '\n');
|
|
49
|
+
switch (request.op) {
|
|
50
|
+
case 'create':
|
|
51
|
+
case 'fetch': {
|
|
52
|
+
const incoming = (0, path_1.join)(directory, exports.INCOMING);
|
|
53
|
+
try {
|
|
54
|
+
const fetch = {
|
|
55
|
+
url: (0, url_1.pathToFileURL)(incoming).href,
|
|
56
|
+
ref: exports.INCOMING_REF,
|
|
57
|
+
env: {},
|
|
58
|
+
signal: AbortSignal.timeout(10 * 60000),
|
|
59
|
+
};
|
|
60
|
+
return await send(request.op === 'create' ? await storage.create(key, fetch) : await storage.fetch(key, fetch));
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await (0, promises_1.rm)(incoming, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
case 'state':
|
|
67
|
+
return send(await storage.state(key));
|
|
68
|
+
case 'usage':
|
|
69
|
+
return send(await storage.usage(key));
|
|
70
|
+
case 'stat':
|
|
71
|
+
return send((await files().stat(request.path)) ?? null);
|
|
72
|
+
case 'tree':
|
|
73
|
+
return send(await files().tree(request.query));
|
|
74
|
+
case 'read':
|
|
75
|
+
return send(await files().read(request.path, request.query));
|
|
76
|
+
case 'search':
|
|
77
|
+
return send(await files().search(request.query));
|
|
78
|
+
case 'changes':
|
|
79
|
+
return send(await files().changes());
|
|
80
|
+
case 'diff':
|
|
81
|
+
return send(await files().diff(request.format, request.author));
|
|
82
|
+
case 'commit':
|
|
83
|
+
return send((await storage.commit(key, request.author, AbortSignal.timeout(10 * 60000))) ?? null);
|
|
84
|
+
case 'move':
|
|
85
|
+
await storage.moveTo(key, request.commit);
|
|
86
|
+
return send(null);
|
|
87
|
+
case 'edit': {
|
|
88
|
+
const plan = await files().plan(request.ops);
|
|
89
|
+
if (request.commit) {
|
|
90
|
+
await plan.commit();
|
|
91
|
+
}
|
|
92
|
+
return send({ files: plan.files, bytes: plan.bytes });
|
|
93
|
+
}
|
|
94
|
+
case 'raw': {
|
|
95
|
+
const raw = await files().raw(request.path);
|
|
96
|
+
const header = {
|
|
97
|
+
ok: true,
|
|
98
|
+
result: { stat: raw.stat, sha256: raw.sha256 },
|
|
99
|
+
...(raw.stream ? { stream: true } : {}),
|
|
100
|
+
};
|
|
101
|
+
await write(out, JSON.stringify(header) + '\n');
|
|
102
|
+
try {
|
|
103
|
+
if (raw.content) {
|
|
104
|
+
return await write(out, raw.content);
|
|
105
|
+
}
|
|
106
|
+
const stream = raw.stream;
|
|
107
|
+
for await (const chunk of stream) {
|
|
108
|
+
await write(out, chunk);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
throw new AnswerStarted(e instanceof Error ? e.message : String(e));
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Answers one request about the sandbox below `directory`. A failure of the request is an
|
|
120
|
+
* answer too, with the status the API gives it; the process only fails when it can not
|
|
121
|
+
* answer at all, or a raw read fails after its first bytes.
|
|
122
|
+
*/
|
|
123
|
+
async function sandboxPodMain(directory, input, out) {
|
|
124
|
+
const chunks = [];
|
|
125
|
+
for await (const chunk of input) {
|
|
126
|
+
chunks.push(chunk);
|
|
127
|
+
}
|
|
128
|
+
const request = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
129
|
+
const storage = new sandbox_storage_1.HostSandboxStorage(directory, process.env);
|
|
130
|
+
try {
|
|
131
|
+
await answer(storage, directory, request, out);
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
if (e instanceof AnswerStarted) {
|
|
135
|
+
throw e;
|
|
136
|
+
}
|
|
137
|
+
const error = e instanceof sandbox_files_1.SandboxFileError
|
|
138
|
+
? { status: e.status, message: e.message, details: e.details }
|
|
139
|
+
: e instanceof git_checkout_1.GitOutputTooLarge
|
|
140
|
+
? { kind: 'too-large', message: e.message }
|
|
141
|
+
: { message: e instanceof Error ? e.message : String(e) };
|
|
142
|
+
await write(out, JSON.stringify({ ok: false, error }) + '\n');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
exports.sandboxPodMain = sandboxPodMain;
|
|
146
|
+
//# sourceMappingURL=sandbox-pod.js.map
|