hammerkit 1.27.0-next.3 → 1.27.0-next.30
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 +188 -0
- package/dist/cache/backends/local-cache-backend.js +6 -0
- package/dist/cache/backends/registry-cache-backend.js +2 -2
- package/dist/cache/explain.js +17 -0
- package/dist/cli.js +2 -0
- package/dist/docker/item-network.js +13 -5
- package/dist/executer/deadline.js +17 -3
- package/dist/executer/deferred-restore.js +36 -0
- package/dist/executer/docker-service.js +30 -8
- package/dist/executer/docker-task.js +21 -1
- package/dist/executer/event-cache.js +20 -2
- package/dist/executer/execute-work-service.js +2 -0
- package/dist/executer/execute-work-task.js +10 -3
- package/dist/executer/execution-steps.js +25 -4
- package/dist/executer/reset-work-tree.js +4 -0
- package/dist/executer/scheduler/enqueue-next.js +57 -6
- package/dist/executer/scheduler/state-key.js +42 -5
- package/dist/executer/watch-loop.js +4 -1
- package/dist/harness/connect.js +20 -6
- package/dist/harness/mcp-command.js +22 -6
- package/dist/harness/mcp.js +63 -114
- package/dist/harness/opencode.js +158 -50
- package/dist/harness/pi.js +29 -0
- package/dist/harness/sandbox-client.js +168 -17
- package/dist/harness/service-tools.js +141 -0
- package/dist/harness/task-tools.js +221 -0
- package/dist/kubernetes/apply.js +27 -16
- package/dist/kubernetes/await-running-state.js +59 -13
- package/dist/kubernetes/claim-layout.js +71 -0
- package/dist/kubernetes/cluster-state.js +245 -0
- package/dist/kubernetes/ensure-http-route.js +1 -1
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +35 -12
- package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +1 -1
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
- package/dist/kubernetes/ensure-persistent-data.js +30 -20
- package/dist/kubernetes/follow-pod-logs.js +182 -0
- package/dist/kubernetes/get-service-ip.js +2 -2
- package/dist/kubernetes/job-placement.js +36 -12
- package/dist/kubernetes/job-secrets.js +33 -19
- package/dist/kubernetes/kept-claims.js +22 -10
- package/dist/kubernetes/network-policy.js +85 -27
- package/dist/kubernetes/remove-persistent-data.js +3 -1
- package/dist/kubernetes/resources.js +25 -3
- package/dist/kubernetes/restore-kubernetes-data.js +9 -3
- package/dist/kubernetes/sandbox-runner.js +628 -0
- package/dist/kubernetes/secrets.js +7 -11
- package/dist/kubernetes/volumes.js +6 -3
- package/dist/main.js +2 -0
- package/dist/network/egress-proxy.js +45 -6
- package/dist/optimizer/calculate-checksum.js +3 -2
- package/dist/optimizer/check-key.js +94 -0
- package/dist/optimizer/check-keys.js +64 -0
- package/dist/optimizer/get-work-cache-stats.js +17 -3
- package/dist/optimizer/work-task-cache-description.js +51 -1
- package/dist/planner/utils/append-work-dependencies.js +10 -5
- package/dist/planner/utils/append-work-service.js +19 -1
- package/dist/planner/utils/append-work-task.js +18 -3
- package/dist/planner/utils/get-work-runtime.js +6 -1
- package/dist/planner/utils/normalize-path.js +19 -1
- package/dist/planner/utils/parse-work-mount.js +7 -1
- package/dist/planner/utils/parse-work-port.js +4 -1
- package/dist/planner/utils/task-arguments.js +3 -0
- package/dist/planner/validate.js +22 -0
- package/dist/planner/work-network.js +3 -1
- package/dist/planner/work-runtime-aggregate.js +32 -0
- package/dist/planner/work-runtime-docker.js +2 -1
- package/dist/planner/work-runtime-kubernetes.js +124 -48
- package/dist/planner/work-runtime-local.js +2 -1
- package/dist/planner/work-task.js +6 -1
- package/dist/program.js +31 -10
- package/dist/remote/device-login.js +7 -2
- package/dist/remote/host-config.js +69 -3
- package/dist/remote/remote-commands.js +30 -7
- package/dist/remote/remote-work-commands.js +426 -45
- package/dist/remote/server-client.js +15 -2
- package/dist/remote/token-source.js +64 -26
- package/dist/run-program.js +9 -0
- package/dist/schema/build-file-base-task-schema.js +8 -1
- package/dist/schema/build-file-container-service-schema.js +12 -0
- package/dist/schema/build-file-container-task-schema.js +16 -1
- package/dist/schema/build-file-include-schema.js +18 -4
- package/dist/schema/build-file-kubernetes-service-schema.js +2 -0
- package/dist/schema/build-file-task-schema.js +5 -1
- package/dist/schema/capabilities-schema.js +56 -0
- package/dist/schema/reference-parser.js +120 -11
- package/dist/schema/schema-parser.js +92 -41
- package/dist/schema/work-scope-parser.js +33 -10
- package/dist/secrets/command-secret-provider.js +3 -1
- package/dist/secrets/github-app-token-command.js +3 -1
- package/dist/secrets/secret-catalog.js +43 -19
- package/dist/server/accounts-api.js +3 -1
- package/dist/server/approvals-api.js +1 -1
- package/dist/server/checks-api.js +83 -0
- package/dist/server/checks.js +116 -0
- package/dist/server/definitions-api.js +66 -3
- package/dist/server/endpoints.js +739 -0
- package/dist/server/executor.js +57 -5
- package/dist/server/kubernetes-backend.js +367 -65
- package/dist/server/kubernetes-sandbox-storage.js +48 -3
- package/dist/server/oauth.js +12 -1
- package/dist/server/plan.js +188 -103
- package/dist/server/policy.js +32 -2
- package/dist/server/repo-access.js +5 -0
- package/dist/server/run-plan.js +50 -19
- package/dist/server/run-request.js +58 -3
- package/dist/server/run-worker.js +245 -12
- package/dist/server/runs.js +410 -27
- package/dist/server/sandbox-pod.js +28 -0
- package/dist/server/sandbox-storage.js +23 -0
- package/dist/server/sandboxes-api.js +10 -4
- package/dist/server/sandboxes.js +8 -0
- package/dist/server/server-command.js +39 -20
- package/dist/server/server-config.js +255 -14
- package/dist/server/server.js +269 -15
- package/dist/server/service-runs.js +82 -0
- package/dist/utils/abort-on-signals.js +46 -6
- package/dist/utils/tar.js +36 -0
- package/dist/utils/terminal-prompt.js +53 -0
- package/package.json +1 -1
package/dist/harness/opencode.js
CHANGED
|
@@ -9,35 +9,69 @@ const url_1 = require("url");
|
|
|
9
9
|
const connect_1 = require("./connect");
|
|
10
10
|
const format_1 = require("./format");
|
|
11
11
|
const sandbox_client_1 = require("./sandbox-client");
|
|
12
|
+
const task_tools_1 = require("./task-tools");
|
|
13
|
+
const service_tools_1 = require("./service-tools");
|
|
12
14
|
const DEFAULT_READ_LINES = 2000;
|
|
13
15
|
const LIST_LIMIT = 100;
|
|
14
16
|
const SEARCH_LIMIT = 100;
|
|
15
17
|
/**
|
|
16
18
|
* The tools of opencode on a hammerkit sandbox: `read`, `write`, `edit`, `apply_patch`,
|
|
17
19
|
* `bash`, `grep`, `glob` and `list` with the parameters of opencode's own, which a plugin replaces
|
|
18
|
-
* by registering tools of the same name, and `
|
|
19
|
-
* which
|
|
20
|
+
* by registering tools of the same name, `run_task` and `run_logs` for the tasks of the build
|
|
21
|
+
* file, and `push`, which takes the work out as a branch. opencode resolves paths against its
|
|
22
|
+
* directory, which stands for the root of the sandbox. `schema` is the zod of opencode
|
|
20
23
|
* (`tool.schema` of `@opencode-ai/plugin`), so it reads the parameters as its own.
|
|
21
24
|
*/
|
|
22
25
|
function hammerkitOpencodePlugin(schema, settings = {}) {
|
|
23
|
-
return async (input) =>
|
|
26
|
+
return async (input) => {
|
|
27
|
+
const harness = connectHarness(input.directory, settings);
|
|
28
|
+
const tasks = await harness.tasks();
|
|
29
|
+
return { tool: createOpencodeTools(schema, harness.client, { tasks, shell: harness.shell }) };
|
|
30
|
+
};
|
|
24
31
|
}
|
|
25
32
|
exports.hammerkitOpencodePlugin = hammerkitOpencodePlugin;
|
|
26
|
-
|
|
27
|
-
function lazySandbox(directory, settings) {
|
|
33
|
+
function connectHarness(directory, settings) {
|
|
28
34
|
const environment = settings.environment ?? (0, connect_1.getHarnessEnvironment)(directory);
|
|
29
35
|
const options = settings.options ?? (0, connect_1.harnessOptionsFromEnv)(environment.processEnvs);
|
|
30
36
|
let connecting;
|
|
31
|
-
|
|
37
|
+
const client = () => {
|
|
32
38
|
if (!connecting) {
|
|
33
39
|
connecting = settings.client ? settings.client() : (0, connect_1.connectSandbox)(environment, options);
|
|
34
40
|
connecting.catch(() => (connecting = undefined));
|
|
35
41
|
}
|
|
36
42
|
return connecting;
|
|
37
43
|
};
|
|
44
|
+
return {
|
|
45
|
+
client,
|
|
46
|
+
// the version is read without a sandbox, which the first call of a tool takes
|
|
47
|
+
tasks: () => (0, task_tools_1.readTaskListing)(async () => {
|
|
48
|
+
if (settings.client) {
|
|
49
|
+
return (await client()).tasks();
|
|
50
|
+
}
|
|
51
|
+
const server = await (0, connect_1.connectServer)(environment, options);
|
|
52
|
+
return (0, sandbox_client_1.listPublishedTasks)(server.connection, server.build);
|
|
53
|
+
}),
|
|
54
|
+
shell: options.shell ?? connect_1.DEFAULT_SHELL_TASK,
|
|
55
|
+
};
|
|
38
56
|
}
|
|
39
57
|
// the names of the tools of the plugin, which stand in for opencode's own
|
|
40
|
-
exports.OPENCODE_TOOLS = [
|
|
58
|
+
exports.OPENCODE_TOOLS = [
|
|
59
|
+
'read',
|
|
60
|
+
'write',
|
|
61
|
+
'edit',
|
|
62
|
+
'apply_patch',
|
|
63
|
+
'bash',
|
|
64
|
+
'grep',
|
|
65
|
+
'glob',
|
|
66
|
+
'run_task',
|
|
67
|
+
'run_logs',
|
|
68
|
+
'check',
|
|
69
|
+
'push',
|
|
70
|
+
'list',
|
|
71
|
+
'services_up',
|
|
72
|
+
'endpoint_get',
|
|
73
|
+
'services_stop',
|
|
74
|
+
];
|
|
41
75
|
/**
|
|
42
76
|
* The plugin as the file `harness/opencode.ts` exports it: it takes the zod of opencode from
|
|
43
77
|
* `@opencode-ai/plugin`, found next to hammerkit or else in the opencode directories of the
|
|
@@ -111,7 +145,22 @@ function packageEntry(manifest) {
|
|
|
111
145
|
}
|
|
112
146
|
}
|
|
113
147
|
// the names of the tools of the plugin on opencode 2, which stand in for its own
|
|
114
|
-
exports.OPENCODE_V2_TOOLS = [
|
|
148
|
+
exports.OPENCODE_V2_TOOLS = [
|
|
149
|
+
'read',
|
|
150
|
+
'write',
|
|
151
|
+
'edit',
|
|
152
|
+
'patch',
|
|
153
|
+
'shell',
|
|
154
|
+
'glob',
|
|
155
|
+
'grep',
|
|
156
|
+
'run_task',
|
|
157
|
+
'run_logs',
|
|
158
|
+
'check',
|
|
159
|
+
'push',
|
|
160
|
+
'services_up',
|
|
161
|
+
'endpoint_get',
|
|
162
|
+
'services_stop',
|
|
163
|
+
];
|
|
115
164
|
// the tools of opencode 2 that work on this machine and have no stand-in on a sandbox
|
|
116
165
|
exports.OPENCODE_V2_LOCAL_TOOLS = [
|
|
117
166
|
'opencode_worktree_create',
|
|
@@ -120,25 +169,31 @@ exports.OPENCODE_V2_LOCAL_TOOLS = [
|
|
|
120
169
|
];
|
|
121
170
|
/**
|
|
122
171
|
* The plugin for opencode 2: the tools of {@link createOpencodeV2Tools} in place of opencode's own,
|
|
123
|
-
* and none of the worktree tools, which would make worktrees of this machine
|
|
124
|
-
* plugin as an object with an id and a setup, it
|
|
172
|
+
* and none of the worktree tools, which would make worktrees of this machine, nor its shell
|
|
173
|
+
* when there is no shell task. opencode 2 takes a plugin as an object with an id and a setup, it
|
|
174
|
+
* needs no package of opencode.
|
|
125
175
|
*/
|
|
126
176
|
function hammerkitOpencodeV2Plugin(settings = {}) {
|
|
127
177
|
return {
|
|
128
178
|
id: 'hammerkit',
|
|
129
179
|
async setup(context) {
|
|
130
180
|
const directory = context.location.directory;
|
|
131
|
-
const
|
|
181
|
+
const harness = connectHarness(directory, settings);
|
|
182
|
+
const tasks = await harness.tasks();
|
|
183
|
+
const tools = createOpencodeV2Tools(harness.client, directory, { tasks, shell: harness.shell });
|
|
132
184
|
await context.tool.transform((editor) => {
|
|
133
185
|
for (const tool of tools) {
|
|
134
186
|
editor.add(tool);
|
|
135
187
|
}
|
|
136
|
-
for (const name of exports.OPENCODE_V2_LOCAL_TOOLS) {
|
|
188
|
+
for (const name of [...exports.OPENCODE_V2_LOCAL_TOOLS, ...(harness.shell ? [] : ['shell'])]) {
|
|
137
189
|
editor.remove(name);
|
|
138
190
|
}
|
|
139
191
|
});
|
|
140
192
|
// opencode describes the tool named shell anew for each request, as the shell of this machine
|
|
141
193
|
const shell = tools.find((tool) => tool.name === 'shell');
|
|
194
|
+
if (!shell) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
142
197
|
for (const name of ['context', 'compaction', 'generate']) {
|
|
143
198
|
await context.session.hook(name, (request) => {
|
|
144
199
|
if (request.tools.shell) {
|
|
@@ -163,8 +218,9 @@ exports.hammerkitOpencodeEntry = hammerkitOpencodeEntry;
|
|
|
163
218
|
* opencode 1 under their new names (`patch`, `shell`), paths as `path`, relative to `directory`
|
|
164
219
|
* or absolute below it. opencode 2 has no `list`, `read` lists a directory.
|
|
165
220
|
*/
|
|
166
|
-
function createOpencodeV2Tools(client, directory) {
|
|
167
|
-
const tools = opencodeTools(client);
|
|
221
|
+
function createOpencodeV2Tools(client, directory, options = {}) {
|
|
222
|
+
const tools = opencodeTools(client, options);
|
|
223
|
+
const shell = options.shell ?? connect_1.DEFAULT_SHELL_TASK;
|
|
168
224
|
const tool = (name, of, input, args, permission) => ({
|
|
169
225
|
name,
|
|
170
226
|
description: tools[of].description,
|
|
@@ -176,6 +232,7 @@ function createOpencodeV2Tools(client, directory) {
|
|
|
176
232
|
},
|
|
177
233
|
});
|
|
178
234
|
const same = (input) => input;
|
|
235
|
+
const taskTools = Object.fromEntries((0, task_tools_1.createTaskTools)().map((task) => [task.name, task]));
|
|
179
236
|
return [
|
|
180
237
|
tool('read', 'read', object({
|
|
181
238
|
path: text('The file or directory to read, relative to the project or absolute'),
|
|
@@ -193,11 +250,15 @@ function createOpencodeV2Tools(client, directory) {
|
|
|
193
250
|
replaceAll: flag('Replace all occurrences of oldString (default false)'),
|
|
194
251
|
}, ['path', 'oldString', 'newString']), ({ path, ...rest }) => ({ filePath: path, ...rest }), 'edit'),
|
|
195
252
|
tool('patch', 'apply_patch', object({ patchText: text('The full patch text that describes all changes to be made') }, ['patchText']), same, 'edit'),
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
253
|
+
...(shell
|
|
254
|
+
? [
|
|
255
|
+
tool('shell', 'bash', object({
|
|
256
|
+
command: text('The command to execute'),
|
|
257
|
+
workdir: text('The directory to run the command in, relative to the project. Use this instead of cd.'),
|
|
258
|
+
timeout: count('Timeout in milliseconds, 0 for the default of the shell task'),
|
|
259
|
+
}, ['command']), ({ timeout, ...rest }) => ({ ...rest, timeout: timeout || undefined })),
|
|
260
|
+
]
|
|
261
|
+
: []),
|
|
201
262
|
tool('glob', 'glob', object({
|
|
202
263
|
pattern: text('The glob pattern to match files against, like "**/*.ts"'),
|
|
203
264
|
path: text('The directory to search in, the project by default'),
|
|
@@ -211,10 +272,12 @@ function createOpencodeV2Tools(client, directory) {
|
|
|
211
272
|
caseSensitive: flag('Match case (default true)'),
|
|
212
273
|
limit: count(`The maximum number of matching lines (default ${SEARCH_LIMIT})`),
|
|
213
274
|
}, ['pattern']), same),
|
|
275
|
+
...['run_task', 'run_logs', 'check'].map((name) => tool(name, name, { ...(0, task_tools_1.taskToolSchema)(taskTools[name]), additionalProperties: false }, same)),
|
|
214
276
|
tool('push', 'push', object({
|
|
215
277
|
branch: text('The branch to push to, created when it does not exist'),
|
|
216
278
|
message: text('The commit message'),
|
|
217
279
|
}, ['branch', 'message']), same),
|
|
280
|
+
...service_tools_1.SERVICE_TOOLS.map((service) => tool(service.name, service.name, { ...(0, service_tools_1.serviceToolSchema)(service), additionalProperties: false }, same)),
|
|
218
281
|
];
|
|
219
282
|
}
|
|
220
283
|
exports.createOpencodeV2Tools = createOpencodeV2Tools;
|
|
@@ -227,11 +290,46 @@ const object = (properties, required) => ({
|
|
|
227
290
|
required,
|
|
228
291
|
additionalProperties: false,
|
|
229
292
|
});
|
|
230
|
-
function createOpencodeTools(z, client) {
|
|
231
|
-
return Object.fromEntries(Object.entries(opencodeTools(client)).map(([name, { args, ...tool }]) => [
|
|
293
|
+
function createOpencodeTools(z, client, options = {}) {
|
|
294
|
+
return Object.fromEntries(Object.entries(opencodeTools(client, options)).map(([name, { args, ...tool }]) => [
|
|
295
|
+
name,
|
|
296
|
+
{ ...tool, args: args(z) },
|
|
297
|
+
]));
|
|
232
298
|
}
|
|
233
299
|
exports.createOpencodeTools = createOpencodeTools;
|
|
234
|
-
|
|
300
|
+
// services_up, endpoint_get and services_stop, with their parameters for the zod of opencode 1
|
|
301
|
+
function opencodeServiceTools(client) {
|
|
302
|
+
return Object.fromEntries(service_tools_1.SERVICE_TOOLS.map((tool) => [
|
|
303
|
+
tool.name,
|
|
304
|
+
{
|
|
305
|
+
description: tool.description,
|
|
306
|
+
args: (z) => (0, service_tools_1.serviceToolArgs)(tool, z),
|
|
307
|
+
async execute(args, context) {
|
|
308
|
+
return { title: tool.title, output: await tool.call(await client(), args, context.abort) };
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
]));
|
|
312
|
+
}
|
|
313
|
+
function opencodeTools(client, options = {}) {
|
|
314
|
+
const shell = options.shell ?? connect_1.DEFAULT_SHELL_TASK;
|
|
315
|
+
const tasks = Object.fromEntries((0, task_tools_1.createTaskTools)(options.tasks, shell || undefined).map((task) => [
|
|
316
|
+
task.name,
|
|
317
|
+
{
|
|
318
|
+
description: task.description,
|
|
319
|
+
args: (z) => (0, task_tools_1.taskToolArgs)(task, z),
|
|
320
|
+
async execute(args, context) {
|
|
321
|
+
const output = await task.call(await client(), args, context.abort);
|
|
322
|
+
return {
|
|
323
|
+
title: task.name === 'run_logs'
|
|
324
|
+
? `log of ${args.run}`
|
|
325
|
+
: `${task.name === 'check' ? 'check' : 'run'} ${args.task}`,
|
|
326
|
+
output,
|
|
327
|
+
};
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
]));
|
|
331
|
+
// without a shell task bash still stands in for opencode's, which would run on this machine
|
|
332
|
+
const noShell = 'There is no shell on this hammerkit sandbox: run the tasks of the build file with run_task.';
|
|
235
333
|
const where = (context, path) => (0, sandbox_client_1.toSandboxPath)(context.directory, path ?? context.directory);
|
|
236
334
|
return inOrder({
|
|
237
335
|
read: {
|
|
@@ -337,35 +435,43 @@ function opencodeTools(client) {
|
|
|
337
435
|
].join('\n');
|
|
338
436
|
},
|
|
339
437
|
},
|
|
340
|
-
bash:
|
|
341
|
-
|
|
342
|
-
'
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
438
|
+
bash: shell
|
|
439
|
+
? {
|
|
440
|
+
description: 'Run a shell command on the hammerkit sandbox, as the shell task of the published build file, in its ' +
|
|
441
|
+
'container. Commands run one at a time and nothing is kept between them but the files. They start in ' +
|
|
442
|
+
'the root of the sandbox, where the paths of this machine do not exist: give them relative paths.',
|
|
443
|
+
args: (z) => ({
|
|
444
|
+
command: z.string().describe('The command to execute'),
|
|
445
|
+
timeout: z.number().int().min(1).optional().describe('Optional timeout in milliseconds'),
|
|
446
|
+
workdir: z
|
|
447
|
+
.string()
|
|
448
|
+
.optional()
|
|
449
|
+
.describe("The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands."),
|
|
450
|
+
}),
|
|
451
|
+
async execute(args, context) {
|
|
452
|
+
const sandbox = await client();
|
|
453
|
+
const ran = await sandbox.exec(args.command, where(context, args.workdir), {
|
|
454
|
+
signal: context.abort,
|
|
455
|
+
timeoutSeconds: args.timeout ? args.timeout / 1000 : undefined,
|
|
456
|
+
});
|
|
457
|
+
const lines = [(0, format_1.capOutput)(ran.output, (0, task_tools_1.readLogHint)(ran.run.id, shell))];
|
|
458
|
+
if (ran.exitCode === null) {
|
|
459
|
+
lines.push(`hammerkit: ${ran.result?.error ?? ran.run.error ?? `the run ended ${ran.run.state}`}`);
|
|
460
|
+
}
|
|
461
|
+
else if (ran.exitCode !== 0) {
|
|
462
|
+
lines.push(`Exit code: ${ran.exitCode}`);
|
|
463
|
+
}
|
|
464
|
+
context.metadata?.({ metadata: { exit: ran.exitCode, run: ran.run.id } });
|
|
465
|
+
return lines.filter((line) => line.length > 0).join('\n\n') || '(no output)';
|
|
466
|
+
},
|
|
467
|
+
}
|
|
468
|
+
: {
|
|
469
|
+
description: noShell,
|
|
470
|
+
args: (z) => ({ command: z.string().describe('The command to execute') }),
|
|
471
|
+
async execute() {
|
|
472
|
+
throw new Error(noShell);
|
|
473
|
+
},
|
|
367
474
|
},
|
|
368
|
-
},
|
|
369
475
|
grep: {
|
|
370
476
|
description: 'Search the files of the hammerkit sandbox with a regular expression (ripgrep). Returns the matching ' +
|
|
371
477
|
'lines with their file and line number.',
|
|
@@ -430,6 +536,7 @@ function opencodeTools(client) {
|
|
|
430
536
|
return lines.join('\n');
|
|
431
537
|
},
|
|
432
538
|
},
|
|
539
|
+
...tasks,
|
|
433
540
|
push: {
|
|
434
541
|
description: 'Commit the changes of the hammerkit sandbox and push them to a branch of its repository, for review. ' +
|
|
435
542
|
'The server pushes only to the branches its operator allows; the sandbox is at the pushed commit afterwards.',
|
|
@@ -474,6 +581,7 @@ function opencodeTools(client) {
|
|
|
474
581
|
return lines.join('\n');
|
|
475
582
|
},
|
|
476
583
|
},
|
|
584
|
+
...opencodeServiceTools(client),
|
|
477
585
|
});
|
|
478
586
|
}
|
|
479
587
|
// opencode runs the tool calls of a step in parallel, they reach the sandbox one at a time in their order
|
package/dist/harness/pi.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.hammerkitPiExtension = exports.createPiOperations = void 0;
|
|
|
4
4
|
const path_1 = require("path");
|
|
5
5
|
const connect_1 = require("./connect");
|
|
6
6
|
const format_1 = require("./format");
|
|
7
|
+
const service_tools_1 = require("./service-tools");
|
|
7
8
|
const sandbox_client_1 = require("./sandbox-client");
|
|
8
9
|
const IMAGE_TYPES = {
|
|
9
10
|
'.png': 'image/png',
|
|
@@ -178,6 +179,34 @@ function hammerkitPiExtension(library, settings = {}) {
|
|
|
178
179
|
return { content: [{ type: 'text', text: `pushed ${pushed.commit} to ${pushed.branch}` }], details: pushed };
|
|
179
180
|
},
|
|
180
181
|
});
|
|
182
|
+
for (const tool of service_tools_1.SERVICE_TOOLS) {
|
|
183
|
+
register({
|
|
184
|
+
name: tool.name,
|
|
185
|
+
label: tool.name,
|
|
186
|
+
description: tool.description,
|
|
187
|
+
promptSnippet: tool.title,
|
|
188
|
+
parameters: (0, service_tools_1.serviceToolSchema)(tool),
|
|
189
|
+
execute: async (_id, params, signal) => {
|
|
190
|
+
const text = await tool.call(await client(), params, signal ?? new AbortController().signal);
|
|
191
|
+
return { content: [{ type: 'text', text }], details: undefined };
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
register({
|
|
196
|
+
name: 'check',
|
|
197
|
+
label: 'check',
|
|
198
|
+
description: sandbox_client_1.CHECK_DESCRIPTION,
|
|
199
|
+
promptSnippet: 'Run a task as a check that CI verifies after the push',
|
|
200
|
+
parameters: {
|
|
201
|
+
type: 'object',
|
|
202
|
+
properties: { task: { type: 'string', description: 'The name of the task' } },
|
|
203
|
+
required: ['task'],
|
|
204
|
+
},
|
|
205
|
+
execute: async (_id, params, signal) => {
|
|
206
|
+
const text = await (0, sandbox_client_1.runCheck)(await client(), params.task, signal);
|
|
207
|
+
return { content: [{ type: 'text', text }], details: { task: params.task } };
|
|
208
|
+
},
|
|
209
|
+
});
|
|
181
210
|
pi.on('session_start', async (_event, ctx) => {
|
|
182
211
|
try {
|
|
183
212
|
const sandbox = await client();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.quotePosix = exports.fetchFailure = exports.toSandboxPath = exports.checkSandboxPath = exports.exitCodeOf = exports.followRun = exports.submitRun = exports.FINAL_RUN_STATES = exports.oneAtATime = exports.SandboxClient = exports.SandboxClientError = void 0;
|
|
3
|
+
exports.quotePosix = exports.fetchFailure = exports.toSandboxPath = exports.checkSandboxPath = exports.exitCodeOf = exports.readRunLog = exports.getRun = exports.followRun = exports.submitRun = exports.FINAL_RUN_STATES = exports.oneAtATime = exports.SandboxClient = exports.listPublishedTasks = exports.runCheck = exports.CHECK_DESCRIPTION = exports.SandboxClientError = void 0;
|
|
4
4
|
const path_1 = require("path");
|
|
5
5
|
const token_source_1 = require("../remote/token-source");
|
|
6
6
|
// a write carries up to 32 MiB
|
|
@@ -16,6 +16,48 @@ class SandboxClientError extends Error {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
exports.SandboxClientError = SandboxClientError;
|
|
19
|
+
// What the agent is told of a check, on each harness.
|
|
20
|
+
exports.CHECK_DESCRIPTION = 'Run a task as a check before you push, such as test or e2e: it runs on a copy of the files a push ' +
|
|
21
|
+
'would commit (what .gitignore leaves out is not there, so dependencies are installed by the tasks ' +
|
|
22
|
+
'the build file names), and when it passes, CI verifies it for that commit instead of running it. ' +
|
|
23
|
+
'Push without changing files afterwards, or the check no longer counts for the files that changed.';
|
|
24
|
+
// A check, run to its end, in a few lines: how it went and the last lines of a failure.
|
|
25
|
+
async function runCheck(client, task, signal) {
|
|
26
|
+
const ran = await client.runTask(task, { check: true, signal });
|
|
27
|
+
const result = ran.result;
|
|
28
|
+
const lines = [
|
|
29
|
+
result?.status === 'succeeded' || result?.status === 'cached'
|
|
30
|
+
? `check ${task} passed (run ${ran.run.id}${result.status === 'cached' ? ', from the cache' : ''}): CI can verify it for a push of these files`
|
|
31
|
+
: `check ${task} did not pass (run ${ran.run.id}: ${result?.status ?? ran.run.state}${result?.exitCode !== undefined ? `, exit code ${result.exitCode}` : ''})`,
|
|
32
|
+
];
|
|
33
|
+
const error = result?.error ?? ran.run.error;
|
|
34
|
+
if (error) {
|
|
35
|
+
lines.push(`error: ${error}`);
|
|
36
|
+
}
|
|
37
|
+
if (result && result.status !== 'succeeded' && result.status !== 'cached' && result.lastLog.length > 0) {
|
|
38
|
+
lines.push('last lines:', ...result.lastLog);
|
|
39
|
+
}
|
|
40
|
+
return lines.join('\n');
|
|
41
|
+
}
|
|
42
|
+
exports.runCheck = runCheck;
|
|
43
|
+
/**
|
|
44
|
+
* The tasks of a published version, without its services, as `GET /v1/definitions/<hash>`
|
|
45
|
+
* answers them.
|
|
46
|
+
*/
|
|
47
|
+
async function listPublishedTasks(connection, hash) {
|
|
48
|
+
const version = await json(await call(connection, 'GET', `/v1/definitions/${hash}`));
|
|
49
|
+
return version.tasks.map((name) => {
|
|
50
|
+
const plan = version.plan?.[name];
|
|
51
|
+
const args = version.arguments?.[name];
|
|
52
|
+
return {
|
|
53
|
+
name,
|
|
54
|
+
...(plan?.description ? { description: plan.description } : {}),
|
|
55
|
+
...(plan && plan.command !== undefined ? { command: plan.command } : {}),
|
|
56
|
+
...(args !== undefined ? { args } : {}),
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
exports.listPublishedTasks = listPublishedTasks;
|
|
19
61
|
/**
|
|
20
62
|
* A caller's sandbox on a hammerkit server, over its HTTP API: the raw read, write and
|
|
21
63
|
* stat that harnesses plug their own tools into, the typed endpoints for clients without
|
|
@@ -34,6 +76,10 @@ class SandboxClient {
|
|
|
34
76
|
get id() {
|
|
35
77
|
return this.sandbox.id;
|
|
36
78
|
}
|
|
79
|
+
// the tasks of the published version that run on the sandbox
|
|
80
|
+
tasks() {
|
|
81
|
+
return listPublishedTasks(this.connection, this.build.hash);
|
|
82
|
+
}
|
|
37
83
|
static async create(connection, repo, ref) {
|
|
38
84
|
return json(await call(connection, 'POST', '/v1/sandboxes', { body: { repo, ref } }));
|
|
39
85
|
}
|
|
@@ -115,12 +161,13 @@ class SandboxClient {
|
|
|
115
161
|
runOptions.args = options.args;
|
|
116
162
|
}
|
|
117
163
|
if (options.timeoutSeconds) {
|
|
118
|
-
|
|
164
|
+
// a cap: it also shortens a shell task that sets a longer timeout of its own
|
|
165
|
+
runOptions.maxTimeout = `${Math.max(1, Math.ceil(options.timeoutSeconds))}s`;
|
|
119
166
|
}
|
|
120
167
|
const submitted = await submitRun(this.connection, {
|
|
121
168
|
definition: this.build.hash,
|
|
122
169
|
tasks: [task],
|
|
123
|
-
source: { type: 'sandbox', id: this.id },
|
|
170
|
+
source: { type: 'sandbox', id: this.id, ...(options.check ? { check: true } : {}) },
|
|
124
171
|
options: runOptions,
|
|
125
172
|
});
|
|
126
173
|
const followed = await followRun(this.connection, submitted, {
|
|
@@ -138,11 +185,68 @@ class SandboxClient {
|
|
|
138
185
|
exitCode: exitCodeOf(result),
|
|
139
186
|
};
|
|
140
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Starts services of the published build file on the sandbox, or on a branch of its
|
|
190
|
+
* repository, and follows them until they are up: the run then holds their endpoints
|
|
191
|
+
* until its ttl ends or it is stopped. A signal cancels the run.
|
|
192
|
+
*/
|
|
193
|
+
async up(request, options = {}) {
|
|
194
|
+
if (options.signal?.aborted) {
|
|
195
|
+
throw new SandboxClientError('the run was cancelled');
|
|
196
|
+
}
|
|
197
|
+
const submitted = await submitRun(this.connection, {
|
|
198
|
+
definition: this.build.hash,
|
|
199
|
+
services: request.services,
|
|
200
|
+
endpoints: request.endpoints ?? [],
|
|
201
|
+
...(request.ttl ? { ttl: request.ttl } : {}),
|
|
202
|
+
...(request.sleepTtl ? { sleepTtl: request.sleepTtl } : {}),
|
|
203
|
+
...(request.name ? { name: request.name } : {}),
|
|
204
|
+
source: request.branch
|
|
205
|
+
? { type: 'ref', repo: this.sandbox.repo, ref: request.branch }
|
|
206
|
+
: { type: 'sandbox', id: this.id },
|
|
207
|
+
});
|
|
208
|
+
return followRun(this.connection, submitted, { signal: options.signal, untilUp: true });
|
|
209
|
+
}
|
|
210
|
+
// Stops a run: a service run takes its services and endpoints down.
|
|
211
|
+
async stop(id) {
|
|
212
|
+
return json(await call(this.connection, 'POST', `/v1/runs/${encodeURIComponent(id)}/cancel`));
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* A page of an endpoint of a service run, as the caller sees it: only at an address the
|
|
216
|
+
* server gave the run, so the token goes to the server and nowhere else.
|
|
217
|
+
*/
|
|
218
|
+
async fetchEndpoint(id, path, service, signal) {
|
|
219
|
+
const run = await this.getRun(id);
|
|
220
|
+
const endpoint = run.up?.endpoints.find((candidate) => service === undefined || candidate.service === service);
|
|
221
|
+
if (!endpoint) {
|
|
222
|
+
throw new SandboxClientError(run.up
|
|
223
|
+
? `${id} has no endpoint${service ? ` of ${service}` : ''}, it has ${run.up.endpoints.map((candidate) => `${candidate.service}:${candidate.port}`).join(', ') || 'none'}`
|
|
224
|
+
: `${id} is not a service run`);
|
|
225
|
+
}
|
|
226
|
+
const url = new URL(path.startsWith('/') ? path : `/${path}`, endpoint.url);
|
|
227
|
+
if (url.origin !== new URL(endpoint.url).origin) {
|
|
228
|
+
throw new SandboxClientError(`${path} is not a path of ${endpoint.url}`);
|
|
229
|
+
}
|
|
230
|
+
const response = await fetch(url, {
|
|
231
|
+
headers: { 'hammerkit-authorization': `Bearer ${await this.connection.token()}` },
|
|
232
|
+
redirect: 'manual',
|
|
233
|
+
signal: AbortSignal.any([AbortSignal.timeout(TIMEOUT_MS), ...(signal ? [signal] : [])]),
|
|
234
|
+
});
|
|
235
|
+
return {
|
|
236
|
+
url: url.toString(),
|
|
237
|
+
status: response.status,
|
|
238
|
+
type: response.headers.get('content-type') ?? '',
|
|
239
|
+
body: await response.text(),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
141
242
|
/**
|
|
142
243
|
* A shell command on the sandbox: the shell task of the build file with the command as
|
|
143
244
|
* its argument. `cwd` is a directory of the sandbox, `''` for its root.
|
|
144
245
|
*/
|
|
145
246
|
async exec(command, cwd, options = {}) {
|
|
247
|
+
if (!this.build.shell) {
|
|
248
|
+
throw new SandboxClientError('there is no shell on this sandbox (HAMMERKIT_SHELL is empty), run a task instead');
|
|
249
|
+
}
|
|
146
250
|
// on its own line: `cd dir && a; b` would run b in the root when dir is missing
|
|
147
251
|
const script = cwd && cwd !== '.' ? `cd -- ${quotePosix(cwd)} || exit 1\n${command}` : command;
|
|
148
252
|
// a shell task in a container without `mounts: [.]` runs in an empty directory: the
|
|
@@ -219,7 +323,9 @@ async function followRun(connection, submitted, options = {}) {
|
|
|
219
323
|
const cancel = () => {
|
|
220
324
|
cancelling ?? (cancelling = call(connection, 'POST', `${path}/cancel`).catch(() => undefined));
|
|
221
325
|
};
|
|
222
|
-
options.
|
|
326
|
+
if (!options.detach) {
|
|
327
|
+
options.signal?.addEventListener('abort', cancel, { once: true });
|
|
328
|
+
}
|
|
223
329
|
const lines = [];
|
|
224
330
|
let from = 1;
|
|
225
331
|
let truncated = false;
|
|
@@ -244,6 +350,9 @@ async function followRun(connection, submitted, options = {}) {
|
|
|
244
350
|
let run = submitted;
|
|
245
351
|
let waitingSaid = false;
|
|
246
352
|
for (;;) {
|
|
353
|
+
if (options.signal?.aborted && options.detach) {
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
247
356
|
if (options.signal?.aborted) {
|
|
248
357
|
// the process may exit after this, so the cancel has to reach the server first
|
|
249
358
|
cancel();
|
|
@@ -257,24 +366,13 @@ async function followRun(connection, submitted, options = {}) {
|
|
|
257
366
|
options.onWaiting?.(run);
|
|
258
367
|
}
|
|
259
368
|
await readLog();
|
|
260
|
-
if (exports.FINAL_RUN_STATES.includes(run.state)) {
|
|
369
|
+
if (exports.FINAL_RUN_STATES.includes(run.state) || (options.untilUp && run.state === 'up')) {
|
|
261
370
|
break;
|
|
262
371
|
}
|
|
263
372
|
run = await json(await call(connection, 'GET', path, { query: { wait: POLL_WAIT } }));
|
|
264
373
|
}
|
|
265
374
|
if (truncated) {
|
|
266
|
-
|
|
267
|
-
const tasks = options.task !== undefined ? [options.task] : Object.keys(run.results ?? {});
|
|
268
|
-
for (const task of tasks) {
|
|
269
|
-
const result = run.results?.[task];
|
|
270
|
-
if (!result) {
|
|
271
|
-
continue;
|
|
272
|
-
}
|
|
273
|
-
const said = options.task !== undefined ? 'the last lines were' : `the last lines of ${task} were`;
|
|
274
|
-
for (const text of [`… the server cut the log off at its limit, ${said}:`, ...result.lastLog]) {
|
|
275
|
-
add({ line: 0, task, stream: 'stdout', text });
|
|
276
|
-
}
|
|
277
|
-
}
|
|
375
|
+
truncatedEnd(run, options.task).forEach(add);
|
|
278
376
|
}
|
|
279
377
|
return { run, lines, truncated };
|
|
280
378
|
}
|
|
@@ -283,6 +381,59 @@ async function followRun(connection, submitted, options = {}) {
|
|
|
283
381
|
}
|
|
284
382
|
}
|
|
285
383
|
exports.followRun = followRun;
|
|
384
|
+
// The end of a log the server cut off at its limit: the last lines of each task, which its
|
|
385
|
+
// results keep.
|
|
386
|
+
function truncatedEnd(run, only) {
|
|
387
|
+
const lines = [];
|
|
388
|
+
const tasks = only !== undefined ? [only] : Object.keys(run.results ?? {});
|
|
389
|
+
for (const task of tasks) {
|
|
390
|
+
const result = run.results?.[task];
|
|
391
|
+
if (!result) {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
const said = only !== undefined ? 'the last lines were' : `the last lines of ${task} were`;
|
|
395
|
+
for (const text of [`… the server cut the log off at its limit, ${said}:`, ...result.lastLog]) {
|
|
396
|
+
lines.push({ line: 0, task, stream: 'stdout', text });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return lines;
|
|
400
|
+
}
|
|
401
|
+
// A run as the server keeps it, or null when it keeps none the caller may see.
|
|
402
|
+
async function getRun(connection, id) {
|
|
403
|
+
const response = await call(connection, 'GET', `/v1/runs/${encodeURIComponent(id)}`, { missing: true });
|
|
404
|
+
return response.status === 404 ? null : json(response);
|
|
405
|
+
}
|
|
406
|
+
exports.getRun = getRun;
|
|
407
|
+
/**
|
|
408
|
+
* Reads the log of a run as it is now, every line or the last `tail` of them, of one task or
|
|
409
|
+
* of all: also of a run that ended, as long as the server keeps it.
|
|
410
|
+
*/
|
|
411
|
+
async function readRunLog(connection, run, query, onLine) {
|
|
412
|
+
const path = `/v1/runs/${encodeURIComponent(run.id)}/logs`;
|
|
413
|
+
if (query.tail !== undefined) {
|
|
414
|
+
const page = await json(await call(connection, 'GET', path, { query: { task: query.task, tail: query.tail } }));
|
|
415
|
+
page.lines.forEach(onLine);
|
|
416
|
+
return { truncated: page.truncated };
|
|
417
|
+
}
|
|
418
|
+
let from = 1;
|
|
419
|
+
let truncated = false;
|
|
420
|
+
for (;;) {
|
|
421
|
+
const page = await json(await call(connection, 'GET', path, { query: { task: query.task, from, limit: 1000 } }));
|
|
422
|
+
truncated = page.truncated;
|
|
423
|
+
for (const line of page.lines) {
|
|
424
|
+
onLine(line);
|
|
425
|
+
from = line.line + 1;
|
|
426
|
+
}
|
|
427
|
+
if (!page.next) {
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (truncated) {
|
|
432
|
+
truncatedEnd(run, query.task).forEach(onLine);
|
|
433
|
+
}
|
|
434
|
+
return { truncated };
|
|
435
|
+
}
|
|
436
|
+
exports.readRunLog = readRunLog;
|
|
286
437
|
function exitCodeOf(result) {
|
|
287
438
|
if (!result) {
|
|
288
439
|
return null;
|