hammerkit 1.27.0-next.13 → 1.27.0-next.15
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/docker/item-network.js +13 -5
- package/dist/executer/docker-service.js +30 -8
- package/dist/harness/connect.js +19 -5
- package/dist/harness/mcp-command.js +22 -6
- package/dist/harness/mcp.js +62 -114
- package/dist/harness/opencode.js +151 -50
- package/dist/harness/pi.js +14 -0
- package/dist/harness/sandbox-client.js +81 -2
- package/dist/harness/service-tools.js +141 -0
- package/dist/harness/task-tools.js +210 -0
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +2 -0
- package/dist/planner/utils/parse-work-port.js +4 -1
- package/dist/remote/remote-work-commands.js +98 -12
- package/dist/schema/work-scope-parser.js +6 -0
- package/dist/server/accounts-api.js +2 -1
- package/dist/server/approvals-api.js +1 -1
- package/dist/server/definitions-api.js +4 -2
- package/dist/server/endpoints.js +612 -0
- package/dist/server/executor.js +14 -1
- package/dist/server/kubernetes-backend.js +200 -33
- package/dist/server/plan.js +1 -0
- package/dist/server/policy.js +24 -2
- package/dist/server/repo-access.js +5 -0
- package/dist/server/run-plan.js +38 -4
- package/dist/server/run-request.js +44 -2
- package/dist/server/run-worker.js +130 -9
- package/dist/server/runs.js +317 -24
- package/dist/server/server-config.js +160 -9
- package/dist/server/server.js +210 -14
- package/dist/server/service-runs.js +82 -0
- package/package.json +1 -1
package/dist/harness/opencode.js
CHANGED
|
@@ -9,35 +9,68 @@ 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
|
+
'push',
|
|
69
|
+
'list',
|
|
70
|
+
'services_up',
|
|
71
|
+
'endpoint_get',
|
|
72
|
+
'services_stop',
|
|
73
|
+
];
|
|
41
74
|
/**
|
|
42
75
|
* The plugin as the file `harness/opencode.ts` exports it: it takes the zod of opencode from
|
|
43
76
|
* `@opencode-ai/plugin`, found next to hammerkit or else in the opencode directories of the
|
|
@@ -111,7 +144,21 @@ function packageEntry(manifest) {
|
|
|
111
144
|
}
|
|
112
145
|
}
|
|
113
146
|
// the names of the tools of the plugin on opencode 2, which stand in for its own
|
|
114
|
-
exports.OPENCODE_V2_TOOLS = [
|
|
147
|
+
exports.OPENCODE_V2_TOOLS = [
|
|
148
|
+
'read',
|
|
149
|
+
'write',
|
|
150
|
+
'edit',
|
|
151
|
+
'patch',
|
|
152
|
+
'shell',
|
|
153
|
+
'glob',
|
|
154
|
+
'grep',
|
|
155
|
+
'run_task',
|
|
156
|
+
'run_logs',
|
|
157
|
+
'push',
|
|
158
|
+
'services_up',
|
|
159
|
+
'endpoint_get',
|
|
160
|
+
'services_stop',
|
|
161
|
+
];
|
|
115
162
|
// the tools of opencode 2 that work on this machine and have no stand-in on a sandbox
|
|
116
163
|
exports.OPENCODE_V2_LOCAL_TOOLS = [
|
|
117
164
|
'opencode_worktree_create',
|
|
@@ -120,25 +167,31 @@ exports.OPENCODE_V2_LOCAL_TOOLS = [
|
|
|
120
167
|
];
|
|
121
168
|
/**
|
|
122
169
|
* 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
|
|
170
|
+
* and none of the worktree tools, which would make worktrees of this machine, nor its shell
|
|
171
|
+
* when there is no shell task. opencode 2 takes a plugin as an object with an id and a setup, it
|
|
172
|
+
* needs no package of opencode.
|
|
125
173
|
*/
|
|
126
174
|
function hammerkitOpencodeV2Plugin(settings = {}) {
|
|
127
175
|
return {
|
|
128
176
|
id: 'hammerkit',
|
|
129
177
|
async setup(context) {
|
|
130
178
|
const directory = context.location.directory;
|
|
131
|
-
const
|
|
179
|
+
const harness = connectHarness(directory, settings);
|
|
180
|
+
const tasks = await harness.tasks();
|
|
181
|
+
const tools = createOpencodeV2Tools(harness.client, directory, { tasks, shell: harness.shell });
|
|
132
182
|
await context.tool.transform((editor) => {
|
|
133
183
|
for (const tool of tools) {
|
|
134
184
|
editor.add(tool);
|
|
135
185
|
}
|
|
136
|
-
for (const name of exports.OPENCODE_V2_LOCAL_TOOLS) {
|
|
186
|
+
for (const name of [...exports.OPENCODE_V2_LOCAL_TOOLS, ...(harness.shell ? [] : ['shell'])]) {
|
|
137
187
|
editor.remove(name);
|
|
138
188
|
}
|
|
139
189
|
});
|
|
140
190
|
// opencode describes the tool named shell anew for each request, as the shell of this machine
|
|
141
191
|
const shell = tools.find((tool) => tool.name === 'shell');
|
|
192
|
+
if (!shell) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
142
195
|
for (const name of ['context', 'compaction', 'generate']) {
|
|
143
196
|
await context.session.hook(name, (request) => {
|
|
144
197
|
if (request.tools.shell) {
|
|
@@ -163,8 +216,9 @@ exports.hammerkitOpencodeEntry = hammerkitOpencodeEntry;
|
|
|
163
216
|
* opencode 1 under their new names (`patch`, `shell`), paths as `path`, relative to `directory`
|
|
164
217
|
* or absolute below it. opencode 2 has no `list`, `read` lists a directory.
|
|
165
218
|
*/
|
|
166
|
-
function createOpencodeV2Tools(client, directory) {
|
|
167
|
-
const tools = opencodeTools(client);
|
|
219
|
+
function createOpencodeV2Tools(client, directory, options = {}) {
|
|
220
|
+
const tools = opencodeTools(client, options);
|
|
221
|
+
const shell = options.shell ?? connect_1.DEFAULT_SHELL_TASK;
|
|
168
222
|
const tool = (name, of, input, args, permission) => ({
|
|
169
223
|
name,
|
|
170
224
|
description: tools[of].description,
|
|
@@ -176,6 +230,7 @@ function createOpencodeV2Tools(client, directory) {
|
|
|
176
230
|
},
|
|
177
231
|
});
|
|
178
232
|
const same = (input) => input;
|
|
233
|
+
const taskTools = Object.fromEntries((0, task_tools_1.createTaskTools)().map((task) => [task.name, task]));
|
|
179
234
|
return [
|
|
180
235
|
tool('read', 'read', object({
|
|
181
236
|
path: text('The file or directory to read, relative to the project or absolute'),
|
|
@@ -193,11 +248,15 @@ function createOpencodeV2Tools(client, directory) {
|
|
|
193
248
|
replaceAll: flag('Replace all occurrences of oldString (default false)'),
|
|
194
249
|
}, ['path', 'oldString', 'newString']), ({ path, ...rest }) => ({ filePath: path, ...rest }), 'edit'),
|
|
195
250
|
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
|
-
|
|
251
|
+
...(shell
|
|
252
|
+
? [
|
|
253
|
+
tool('shell', 'bash', object({
|
|
254
|
+
command: text('The command to execute'),
|
|
255
|
+
workdir: text('The directory to run the command in, relative to the project. Use this instead of cd.'),
|
|
256
|
+
timeout: count('Timeout in milliseconds, 0 for the default of the shell task'),
|
|
257
|
+
}, ['command']), ({ timeout, ...rest }) => ({ ...rest, timeout: timeout || undefined })),
|
|
258
|
+
]
|
|
259
|
+
: []),
|
|
201
260
|
tool('glob', 'glob', object({
|
|
202
261
|
pattern: text('The glob pattern to match files against, like "**/*.ts"'),
|
|
203
262
|
path: text('The directory to search in, the project by default'),
|
|
@@ -211,10 +270,12 @@ function createOpencodeV2Tools(client, directory) {
|
|
|
211
270
|
caseSensitive: flag('Match case (default true)'),
|
|
212
271
|
limit: count(`The maximum number of matching lines (default ${SEARCH_LIMIT})`),
|
|
213
272
|
}, ['pattern']), same),
|
|
273
|
+
...['run_task', 'run_logs'].map((name) => tool(name, name, { ...(0, task_tools_1.taskToolSchema)(taskTools[name]), additionalProperties: false }, same)),
|
|
214
274
|
tool('push', 'push', object({
|
|
215
275
|
branch: text('The branch to push to, created when it does not exist'),
|
|
216
276
|
message: text('The commit message'),
|
|
217
277
|
}, ['branch', 'message']), same),
|
|
278
|
+
...service_tools_1.SERVICE_TOOLS.map((service) => tool(service.name, service.name, { ...(0, service_tools_1.serviceToolSchema)(service), additionalProperties: false }, same)),
|
|
218
279
|
];
|
|
219
280
|
}
|
|
220
281
|
exports.createOpencodeV2Tools = createOpencodeV2Tools;
|
|
@@ -227,11 +288,41 @@ const object = (properties, required) => ({
|
|
|
227
288
|
required,
|
|
228
289
|
additionalProperties: false,
|
|
229
290
|
});
|
|
230
|
-
function createOpencodeTools(z, client) {
|
|
231
|
-
return Object.fromEntries(Object.entries(opencodeTools(client)).map(([name, { args, ...tool }]) => [
|
|
291
|
+
function createOpencodeTools(z, client, options = {}) {
|
|
292
|
+
return Object.fromEntries(Object.entries(opencodeTools(client, options)).map(([name, { args, ...tool }]) => [
|
|
293
|
+
name,
|
|
294
|
+
{ ...tool, args: args(z) },
|
|
295
|
+
]));
|
|
232
296
|
}
|
|
233
297
|
exports.createOpencodeTools = createOpencodeTools;
|
|
234
|
-
|
|
298
|
+
// services_up, endpoint_get and services_stop, with their parameters for the zod of opencode 1
|
|
299
|
+
function opencodeServiceTools(client) {
|
|
300
|
+
return Object.fromEntries(service_tools_1.SERVICE_TOOLS.map((tool) => [
|
|
301
|
+
tool.name,
|
|
302
|
+
{
|
|
303
|
+
description: tool.description,
|
|
304
|
+
args: (z) => (0, service_tools_1.serviceToolArgs)(tool, z),
|
|
305
|
+
async execute(args, context) {
|
|
306
|
+
return { title: tool.title, output: await tool.call(await client(), args, context.abort) };
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
]));
|
|
310
|
+
}
|
|
311
|
+
function opencodeTools(client, options = {}) {
|
|
312
|
+
const shell = options.shell ?? connect_1.DEFAULT_SHELL_TASK;
|
|
313
|
+
const tasks = Object.fromEntries((0, task_tools_1.createTaskTools)(options.tasks, shell || undefined).map((task) => [
|
|
314
|
+
task.name,
|
|
315
|
+
{
|
|
316
|
+
description: task.description,
|
|
317
|
+
args: (z) => (0, task_tools_1.taskToolArgs)(task, z),
|
|
318
|
+
async execute(args, context) {
|
|
319
|
+
const output = await task.call(await client(), args, context.abort);
|
|
320
|
+
return { title: task.name === 'run_task' ? `run ${args.task}` : `log of ${args.run}`, output };
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
]));
|
|
324
|
+
// without a shell task bash still stands in for opencode's, which would run on this machine
|
|
325
|
+
const noShell = 'There is no shell on this hammerkit sandbox: run the tasks of the build file with run_task.';
|
|
235
326
|
const where = (context, path) => (0, sandbox_client_1.toSandboxPath)(context.directory, path ?? context.directory);
|
|
236
327
|
return inOrder({
|
|
237
328
|
read: {
|
|
@@ -337,35 +428,43 @@ function opencodeTools(client) {
|
|
|
337
428
|
].join('\n');
|
|
338
429
|
},
|
|
339
430
|
},
|
|
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
|
-
|
|
431
|
+
bash: shell
|
|
432
|
+
? {
|
|
433
|
+
description: 'Run a shell command on the hammerkit sandbox, as the shell task of the published build file, in its ' +
|
|
434
|
+
'container. Commands run one at a time and nothing is kept between them but the files. They start in ' +
|
|
435
|
+
'the root of the sandbox, where the paths of this machine do not exist: give them relative paths.',
|
|
436
|
+
args: (z) => ({
|
|
437
|
+
command: z.string().describe('The command to execute'),
|
|
438
|
+
timeout: z.number().int().min(1).optional().describe('Optional timeout in milliseconds'),
|
|
439
|
+
workdir: z
|
|
440
|
+
.string()
|
|
441
|
+
.optional()
|
|
442
|
+
.describe("The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands."),
|
|
443
|
+
}),
|
|
444
|
+
async execute(args, context) {
|
|
445
|
+
const sandbox = await client();
|
|
446
|
+
const ran = await sandbox.exec(args.command, where(context, args.workdir), {
|
|
447
|
+
signal: context.abort,
|
|
448
|
+
timeoutSeconds: args.timeout ? args.timeout / 1000 : undefined,
|
|
449
|
+
});
|
|
450
|
+
const lines = [(0, format_1.capOutput)(ran.output, (0, task_tools_1.readLogHint)(ran.run.id, shell))];
|
|
451
|
+
if (ran.exitCode === null) {
|
|
452
|
+
lines.push(`hammerkit: ${ran.result?.error ?? ran.run.error ?? `the run ended ${ran.run.state}`}`);
|
|
453
|
+
}
|
|
454
|
+
else if (ran.exitCode !== 0) {
|
|
455
|
+
lines.push(`Exit code: ${ran.exitCode}`);
|
|
456
|
+
}
|
|
457
|
+
context.metadata?.({ metadata: { exit: ran.exitCode, run: ran.run.id } });
|
|
458
|
+
return lines.filter((line) => line.length > 0).join('\n\n') || '(no output)';
|
|
459
|
+
},
|
|
460
|
+
}
|
|
461
|
+
: {
|
|
462
|
+
description: noShell,
|
|
463
|
+
args: (z) => ({ command: z.string().describe('The command to execute') }),
|
|
464
|
+
async execute() {
|
|
465
|
+
throw new Error(noShell);
|
|
466
|
+
},
|
|
367
467
|
},
|
|
368
|
-
},
|
|
369
468
|
grep: {
|
|
370
469
|
description: 'Search the files of the hammerkit sandbox with a regular expression (ripgrep). Returns the matching ' +
|
|
371
470
|
'lines with their file and line number.',
|
|
@@ -430,6 +529,7 @@ function opencodeTools(client) {
|
|
|
430
529
|
return lines.join('\n');
|
|
431
530
|
},
|
|
432
531
|
},
|
|
532
|
+
...tasks,
|
|
433
533
|
push: {
|
|
434
534
|
description: 'Commit the changes of the hammerkit sandbox and push them to a branch of its repository, for review. ' +
|
|
435
535
|
'The server pushes only to the branches its operator allows; the sandbox is at the pushed commit afterwards.',
|
|
@@ -474,6 +574,7 @@ function opencodeTools(client) {
|
|
|
474
574
|
return lines.join('\n');
|
|
475
575
|
},
|
|
476
576
|
},
|
|
577
|
+
...opencodeServiceTools(client),
|
|
477
578
|
});
|
|
478
579
|
}
|
|
479
580
|
// 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,19 @@ 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
|
+
}
|
|
181
195
|
pi.on('session_start', async (_event, ctx) => {
|
|
182
196
|
try {
|
|
183
197
|
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.followRun = exports.submitRun = exports.FINAL_RUN_STATES = exports.oneAtATime = exports.SandboxClient = exports.listPublishedTasks = 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,24 @@ class SandboxClientError extends Error {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
exports.SandboxClientError = SandboxClientError;
|
|
19
|
+
/**
|
|
20
|
+
* The tasks of a published version, without its services, as `GET /v1/definitions/<hash>`
|
|
21
|
+
* answers them.
|
|
22
|
+
*/
|
|
23
|
+
async function listPublishedTasks(connection, hash) {
|
|
24
|
+
const version = await json(await call(connection, 'GET', `/v1/definitions/${hash}`));
|
|
25
|
+
return version.tasks.map((name) => {
|
|
26
|
+
const plan = version.plan?.[name];
|
|
27
|
+
const args = version.arguments?.[name];
|
|
28
|
+
return {
|
|
29
|
+
name,
|
|
30
|
+
...(plan?.description ? { description: plan.description } : {}),
|
|
31
|
+
...(plan && plan.command !== undefined ? { command: plan.command } : {}),
|
|
32
|
+
...(args !== undefined ? { args } : {}),
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
exports.listPublishedTasks = listPublishedTasks;
|
|
19
37
|
/**
|
|
20
38
|
* A caller's sandbox on a hammerkit server, over its HTTP API: the raw read, write and
|
|
21
39
|
* stat that harnesses plug their own tools into, the typed endpoints for clients without
|
|
@@ -34,6 +52,10 @@ class SandboxClient {
|
|
|
34
52
|
get id() {
|
|
35
53
|
return this.sandbox.id;
|
|
36
54
|
}
|
|
55
|
+
// the tasks of the published version that run on the sandbox
|
|
56
|
+
tasks() {
|
|
57
|
+
return listPublishedTasks(this.connection, this.build.hash);
|
|
58
|
+
}
|
|
37
59
|
static async create(connection, repo, ref) {
|
|
38
60
|
return json(await call(connection, 'POST', '/v1/sandboxes', { body: { repo, ref } }));
|
|
39
61
|
}
|
|
@@ -139,11 +161,68 @@ class SandboxClient {
|
|
|
139
161
|
exitCode: exitCodeOf(result),
|
|
140
162
|
};
|
|
141
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Starts services of the published build file on the sandbox, or on a branch of its
|
|
166
|
+
* repository, and follows them until they are up: the run then holds their endpoints
|
|
167
|
+
* until its ttl ends or it is stopped. A signal cancels the run.
|
|
168
|
+
*/
|
|
169
|
+
async up(request, options = {}) {
|
|
170
|
+
if (options.signal?.aborted) {
|
|
171
|
+
throw new SandboxClientError('the run was cancelled');
|
|
172
|
+
}
|
|
173
|
+
const submitted = await submitRun(this.connection, {
|
|
174
|
+
definition: this.build.hash,
|
|
175
|
+
services: request.services,
|
|
176
|
+
endpoints: request.endpoints ?? [],
|
|
177
|
+
...(request.ttl ? { ttl: request.ttl } : {}),
|
|
178
|
+
...(request.sleepTtl ? { sleepTtl: request.sleepTtl } : {}),
|
|
179
|
+
...(request.name ? { name: request.name } : {}),
|
|
180
|
+
source: request.branch
|
|
181
|
+
? { type: 'ref', repo: this.sandbox.repo, ref: request.branch }
|
|
182
|
+
: { type: 'sandbox', id: this.id },
|
|
183
|
+
});
|
|
184
|
+
return followRun(this.connection, submitted, { signal: options.signal, untilUp: true });
|
|
185
|
+
}
|
|
186
|
+
// Stops a run: a service run takes its services and endpoints down.
|
|
187
|
+
async stop(id) {
|
|
188
|
+
return json(await call(this.connection, 'POST', `/v1/runs/${encodeURIComponent(id)}/cancel`));
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* A page of an endpoint of a service run, as the caller sees it: only at an address the
|
|
192
|
+
* server gave the run, so the token goes to the server and nowhere else.
|
|
193
|
+
*/
|
|
194
|
+
async fetchEndpoint(id, path, service, signal) {
|
|
195
|
+
const run = await this.getRun(id);
|
|
196
|
+
const endpoint = run.up?.endpoints.find((candidate) => service === undefined || candidate.service === service);
|
|
197
|
+
if (!endpoint) {
|
|
198
|
+
throw new SandboxClientError(run.up
|
|
199
|
+
? `${id} has no endpoint${service ? ` of ${service}` : ''}, it has ${run.up.endpoints.map((candidate) => `${candidate.service}:${candidate.port}`).join(', ') || 'none'}`
|
|
200
|
+
: `${id} is not a service run`);
|
|
201
|
+
}
|
|
202
|
+
const url = new URL(path.startsWith('/') ? path : `/${path}`, endpoint.url);
|
|
203
|
+
if (url.origin !== new URL(endpoint.url).origin) {
|
|
204
|
+
throw new SandboxClientError(`${path} is not a path of ${endpoint.url}`);
|
|
205
|
+
}
|
|
206
|
+
const response = await fetch(url, {
|
|
207
|
+
headers: { 'hammerkit-authorization': `Bearer ${await this.connection.token()}` },
|
|
208
|
+
redirect: 'manual',
|
|
209
|
+
signal: AbortSignal.any([AbortSignal.timeout(TIMEOUT_MS), ...(signal ? [signal] : [])]),
|
|
210
|
+
});
|
|
211
|
+
return {
|
|
212
|
+
url: url.toString(),
|
|
213
|
+
status: response.status,
|
|
214
|
+
type: response.headers.get('content-type') ?? '',
|
|
215
|
+
body: await response.text(),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
142
218
|
/**
|
|
143
219
|
* A shell command on the sandbox: the shell task of the build file with the command as
|
|
144
220
|
* its argument. `cwd` is a directory of the sandbox, `''` for its root.
|
|
145
221
|
*/
|
|
146
222
|
async exec(command, cwd, options = {}) {
|
|
223
|
+
if (!this.build.shell) {
|
|
224
|
+
throw new SandboxClientError('there is no shell on this sandbox (HAMMERKIT_SHELL is empty), run a task instead');
|
|
225
|
+
}
|
|
147
226
|
// on its own line: `cd dir && a; b` would run b in the root when dir is missing
|
|
148
227
|
const script = cwd && cwd !== '.' ? `cd -- ${quotePosix(cwd)} || exit 1\n${command}` : command;
|
|
149
228
|
// a shell task in a container without `mounts: [.]` runs in an empty directory: the
|
|
@@ -258,7 +337,7 @@ async function followRun(connection, submitted, options = {}) {
|
|
|
258
337
|
options.onWaiting?.(run);
|
|
259
338
|
}
|
|
260
339
|
await readLog();
|
|
261
|
-
if (exports.FINAL_RUN_STATES.includes(run.state)) {
|
|
340
|
+
if (exports.FINAL_RUN_STATES.includes(run.state) || (options.untilUp && run.state === 'up')) {
|
|
262
341
|
break;
|
|
263
342
|
}
|
|
264
343
|
run = await json(await call(connection, 'GET', path, { query: { wait: POLL_WAIT } }));
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SERVICE_TOOLS = exports.describeServiceRun = exports.parseEndpoint = exports.serviceToolArgs = exports.serviceToolSchema = void 0;
|
|
4
|
+
// the most of a page an agent gets back
|
|
5
|
+
const MAX_PAGE_CHARS = 20000;
|
|
6
|
+
// The parameters as a JSON schema, for MCP, pi and opencode 2.
|
|
7
|
+
function serviceToolSchema(tool) {
|
|
8
|
+
return {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: Object.fromEntries(Object.entries(tool.parameters).map(([name, parameter]) => [
|
|
11
|
+
name,
|
|
12
|
+
parameter.type === 'string'
|
|
13
|
+
? { type: 'string', description: parameter.description }
|
|
14
|
+
: { type: 'array', items: { type: 'string' }, description: parameter.description },
|
|
15
|
+
])),
|
|
16
|
+
required: tool.required,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
exports.serviceToolSchema = serviceToolSchema;
|
|
20
|
+
// The parameters with the zod of opencode 1.
|
|
21
|
+
function serviceToolArgs(tool, z) {
|
|
22
|
+
return Object.fromEntries(Object.entries(tool.parameters).map(([name, parameter]) => {
|
|
23
|
+
const type = parameter.type === 'string' ? z.string() : z.array(z.string());
|
|
24
|
+
return [name, (tool.required.includes(name) ? type : type.optional()).describe(parameter.description)];
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
exports.serviceToolArgs = serviceToolArgs;
|
|
28
|
+
// `web:8080` as the server takes an endpoint
|
|
29
|
+
function parseEndpoint(value) {
|
|
30
|
+
const index = value.lastIndexOf(':');
|
|
31
|
+
const port = Number(value.slice(index + 1));
|
|
32
|
+
if (index <= 0 || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
33
|
+
throw new Error(`${value} is not an endpoint, write it as service:port, such as web:8080`);
|
|
34
|
+
}
|
|
35
|
+
return { service: value.slice(0, index), port };
|
|
36
|
+
}
|
|
37
|
+
exports.parseEndpoint = parseEndpoint;
|
|
38
|
+
// How a service run stands, for an agent: where its endpoints are and until when.
|
|
39
|
+
function describeServiceRun(run) {
|
|
40
|
+
const lines = [`run ${run.id}: ${run.state}${run.up ? ` (${run.up.name})` : ''}`];
|
|
41
|
+
if (run.state === 'up') {
|
|
42
|
+
lines.push(`up until ${run.expiresAt}, stop it with services_stop when it is no longer needed`);
|
|
43
|
+
if (run.up?.sleepTtl) {
|
|
44
|
+
lines.push('without a request for a while the services sleep, the next request wakes them');
|
|
45
|
+
}
|
|
46
|
+
for (const endpoint of run.up?.endpoints ?? []) {
|
|
47
|
+
lines.push(`${endpoint.service}:${endpoint.port} -> ${endpoint.url}`);
|
|
48
|
+
}
|
|
49
|
+
if ((run.up?.endpoints ?? []).length > 0) {
|
|
50
|
+
lines.push('a person opens these addresses in a browser and signs in; read a page yourself with endpoint_get');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else if (run.error) {
|
|
54
|
+
lines.push(`error: ${run.error}`);
|
|
55
|
+
}
|
|
56
|
+
for (const [task, result] of Object.entries(run.results ?? {})) {
|
|
57
|
+
if (result.status === 'failed') {
|
|
58
|
+
lines.push(`${task} failed${result.error ? `: ${result.error}` : ''}`, ...result.lastLog);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (run.state !== 'up') {
|
|
62
|
+
lines.push(`read the log with run_logs { "run": "${run.id}" }`);
|
|
63
|
+
}
|
|
64
|
+
return lines.join('\n');
|
|
65
|
+
}
|
|
66
|
+
exports.describeServiceRun = describeServiceRun;
|
|
67
|
+
exports.SERVICE_TOOLS = [
|
|
68
|
+
{
|
|
69
|
+
name: 'services_up',
|
|
70
|
+
title: 'Start services',
|
|
71
|
+
description: 'Start services of the published build file on the hammerkit server, with the files of the sandbox (or of ' +
|
|
72
|
+
'a branch), and keep them up for a person to look at: each endpoint (service:port) gets an address of its ' +
|
|
73
|
+
'own behind the sign-in of the server. Answers once they are up, with the addresses and until when they ' +
|
|
74
|
+
'stay. A second start with the same name replaces the first, so its addresses stay the same. A server on ' +
|
|
75
|
+
'Kubernetes starts services from a branch only: push the sandbox and pass branch.',
|
|
76
|
+
parameters: {
|
|
77
|
+
services: { type: 'string[]', description: 'The services to start, as the build file names them' },
|
|
78
|
+
endpoints: { type: 'string[]', description: 'The ports to publish, as service:port, such as web:8080' },
|
|
79
|
+
ttl: {
|
|
80
|
+
type: 'string',
|
|
81
|
+
description: 'How long they stay once ready, such as 2h, then they are removed; the server default when left out',
|
|
82
|
+
},
|
|
83
|
+
sleepTtl: {
|
|
84
|
+
type: 'string',
|
|
85
|
+
description: 'How long they stay up without a request, such as 30m, then they sleep until the next request wakes ' +
|
|
86
|
+
'them (from a branch only); the server default when left out',
|
|
87
|
+
},
|
|
88
|
+
name: {
|
|
89
|
+
type: 'string',
|
|
90
|
+
description: 'Lower case letters, digits and -; the addresses carry it. The branch when left out',
|
|
91
|
+
},
|
|
92
|
+
branch: {
|
|
93
|
+
type: 'string',
|
|
94
|
+
description: 'Start from this pushed branch of the repository instead of the files of the sandbox',
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
required: ['services'],
|
|
98
|
+
async call(client, args, signal) {
|
|
99
|
+
const followed = await client.up({
|
|
100
|
+
services: args.services,
|
|
101
|
+
endpoints: (args.endpoints ?? []).map(parseEndpoint),
|
|
102
|
+
ttl: args.ttl || undefined,
|
|
103
|
+
sleepTtl: args.sleepTtl || undefined,
|
|
104
|
+
name: args.name || undefined,
|
|
105
|
+
branch: args.branch || undefined,
|
|
106
|
+
}, { signal });
|
|
107
|
+
return describeServiceRun(followed.run);
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'endpoint_get',
|
|
112
|
+
title: 'Read a page of an endpoint',
|
|
113
|
+
description: 'GET a path of an endpoint of a service run started with services_up, signed in as you, and answer its ' +
|
|
114
|
+
'status and body: to check that the app answers before a person opens it.',
|
|
115
|
+
parameters: {
|
|
116
|
+
run: { type: 'string', description: 'The id of the service run' },
|
|
117
|
+
path: { type: 'string', description: 'The path, such as / or /api/health' },
|
|
118
|
+
service: { type: 'string', description: 'The service of the endpoint, the first endpoint when left out' },
|
|
119
|
+
},
|
|
120
|
+
required: ['run', 'path'],
|
|
121
|
+
async call(client, args, signal) {
|
|
122
|
+
const page = await client.fetchEndpoint(args.run, args.path, args.service || undefined, signal);
|
|
123
|
+
const body = page.body.length > MAX_PAGE_CHARS
|
|
124
|
+
? `${page.body.slice(0, MAX_PAGE_CHARS)}\n… (${page.body.length} characters, the first ${MAX_PAGE_CHARS} shown)`
|
|
125
|
+
: page.body;
|
|
126
|
+
return [`GET ${page.url}: ${page.status}${page.type ? ` (${page.type})` : ''}`, '', body].join('\n');
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'services_stop',
|
|
131
|
+
title: 'Stop services',
|
|
132
|
+
description: 'Stop a service run started with services_up: its services and endpoints go.',
|
|
133
|
+
parameters: { run: { type: 'string', description: 'The id of the service run' } },
|
|
134
|
+
required: ['run'],
|
|
135
|
+
async call(client, args) {
|
|
136
|
+
const run = await client.stop(args.run);
|
|
137
|
+
return `run ${run.id}: ${run.state === 'up' ? 'stopping' : run.state}`;
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
//# sourceMappingURL=service-tools.js.map
|