hammerkit 1.27.0-next.13 → 1.27.0-next.14
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/harness/connect.js +19 -5
- package/dist/harness/mcp-command.js +22 -6
- package/dist/harness/mcp.js +52 -114
- package/dist/harness/opencode.js +129 -50
- package/dist/harness/sandbox-client.js +26 -1
- package/dist/harness/task-tools.js +210 -0
- package/dist/server/definitions-api.js +4 -2
- package/dist/server/plan.js +1 -0
- package/dist/server/run-plan.js +31 -1
- package/package.json +1 -1
package/dist/harness/connect.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.getHarnessEnvironment = exports.connectSandbox = exports.harnessOptionsFromEnv = exports.DEFAULT_SHELL_TASK = void 0;
|
|
3
|
+
exports.getHarnessEnvironment = exports.connectSandbox = exports.connectServer = exports.hasShell = exports.harnessOptionsFromEnv = exports.DEFAULT_SHELL_TASK = void 0;
|
|
4
4
|
const get_file_context_1 = require("../file/get-file-context");
|
|
5
5
|
const log_1 = require("../log");
|
|
6
6
|
const work_item_status_1 = require("../planner/work-item-status");
|
|
@@ -20,11 +20,21 @@ function harnessOptionsFromEnv(env) {
|
|
|
20
20
|
repo: value('HAMMERKIT_REPO'),
|
|
21
21
|
ref: value('HAMMERKIT_REF'),
|
|
22
22
|
build: value('HAMMERKIT_BUILD'),
|
|
23
|
-
shell
|
|
23
|
+
// set but empty is no shell, unset the default one
|
|
24
|
+
shell: env.HAMMERKIT_SHELL !== undefined ? env.HAMMERKIT_SHELL.trim() : undefined,
|
|
24
25
|
};
|
|
25
26
|
}
|
|
26
27
|
exports.harnessOptionsFromEnv = harnessOptionsFromEnv;
|
|
27
|
-
|
|
28
|
+
// whether the clients offer a shell tool
|
|
29
|
+
function hasShell(options) {
|
|
30
|
+
return options.shell !== '';
|
|
31
|
+
}
|
|
32
|
+
exports.hasShell = hasShell;
|
|
33
|
+
/**
|
|
34
|
+
* The registered server a harness works on and the published version it runs, checked:
|
|
35
|
+
* what the clients need to list the tasks of the version before they take a sandbox.
|
|
36
|
+
*/
|
|
37
|
+
async function connectServer(environment, options) {
|
|
28
38
|
const config = await (0, host_config_1.readHostConfig)(environment);
|
|
29
39
|
const name = options.server ?? config.run.on;
|
|
30
40
|
if (!name || !(0, host_config_1.hasServer)(config.servers, name)) {
|
|
@@ -40,7 +50,11 @@ async function connectSandbox(environment, options, log = () => undefined) {
|
|
|
40
50
|
throw new Error(`${options.build} is not the hash of a published version (sha256:...), name it as hammerkit remote publish printed it (HAMMERKIT_BUILD or --build)`);
|
|
41
51
|
}
|
|
42
52
|
const server = config.servers[name];
|
|
43
|
-
|
|
53
|
+
return { name, connection: { url: server.url, ...(0, token_source_1.getTokenSource)(environment, name, server) }, build: options.build };
|
|
54
|
+
}
|
|
55
|
+
exports.connectServer = connectServer;
|
|
56
|
+
async function connectSandbox(environment, options, log = () => undefined) {
|
|
57
|
+
const { name, connection, build } = await connectServer(environment, options);
|
|
44
58
|
let sandbox;
|
|
45
59
|
if (options.sandbox) {
|
|
46
60
|
sandbox = await sandbox_client_1.SandboxClient.get(connection, options.sandbox);
|
|
@@ -59,7 +73,7 @@ async function connectSandbox(environment, options, log = () => undefined) {
|
|
|
59
73
|
else {
|
|
60
74
|
throw new Error('name a sandbox (HAMMERKIT_SANDBOX or --sandbox), or a repository and ref to create one from (HAMMERKIT_REPO and HAMMERKIT_REF, or --repo and --ref)');
|
|
61
75
|
}
|
|
62
|
-
return new sandbox_client_1.SandboxClient(connection, sandbox, { hash:
|
|
76
|
+
return new sandbox_client_1.SandboxClient(connection, sandbox, { hash: build, shell: options.shell ?? exports.DEFAULT_SHELL_TASK });
|
|
63
77
|
}
|
|
64
78
|
exports.connectSandbox = connectSandbox;
|
|
65
79
|
// A client that starts with a repository and ref takes a sandbox of the caller's of the same
|
|
@@ -6,6 +6,15 @@ const log_1 = require("../log");
|
|
|
6
6
|
const version_1 = require("../version");
|
|
7
7
|
const connect_1 = require("./connect");
|
|
8
8
|
const mcp_1 = require("./mcp");
|
|
9
|
+
const task_tools_1 = require("./task-tools");
|
|
10
|
+
// the tasks run_task lists; a server that does not answer leaves them out, the tools still work
|
|
11
|
+
async function listTasks(sandbox, log) {
|
|
12
|
+
const listing = await (0, task_tools_1.readTaskListing)(() => sandbox.tasks());
|
|
13
|
+
if (!Array.isArray(listing)) {
|
|
14
|
+
log(`the tasks of ${sandbox.build.hash} could not be listed: ${listing.error}`);
|
|
15
|
+
}
|
|
16
|
+
return listing;
|
|
17
|
+
}
|
|
9
18
|
// `hammerkit remote mcp`: a Model Context Protocol server on stdio for one sandbox of a
|
|
10
19
|
// server. It lives under `remote`, which already is a command: a top-level `mcp` would hide a
|
|
11
20
|
// task named `mcp`, since `hammerkit <task>` runs a task. Like the other `remote` commands it
|
|
@@ -21,20 +30,27 @@ function registerMcpCommand(remote, environment, input = process.stdin) {
|
|
|
21
30
|
.addOption(new commander_1.Option('--repo <repo>', 'create a sandbox of this repository').env('HAMMERKIT_REPO'))
|
|
22
31
|
.addOption(new commander_1.Option('--ref <ref>', 'at this branch').env('HAMMERKIT_REF'))
|
|
23
32
|
.addOption(new commander_1.Option('--build <hash>', 'hash of the published version of the build file whose tasks run on it').env('HAMMERKIT_BUILD'))
|
|
24
|
-
.addOption(new commander_1.Option('--shell <task>',
|
|
33
|
+
.addOption(new commander_1.Option('--shell <task>', "its task whose last command is a shell, like sh -c; '' for no bash tool")
|
|
25
34
|
.env('HAMMERKIT_SHELL')
|
|
26
35
|
.default(connect_1.DEFAULT_SHELL_TASK))
|
|
27
36
|
.action(async (options) => {
|
|
28
37
|
try {
|
|
29
38
|
// stdout carries the protocol only, everything else goes to stderr
|
|
30
39
|
const log = (line) => environment.stderr.write(`hammerkit remote mcp: ${line}\n`);
|
|
31
|
-
|
|
40
|
+
// as the plugins read it: blank is no shell
|
|
41
|
+
const sandbox = await (0, connect_1.connectSandbox)(environment, { ...options, shell: options.shell.trim() }, log);
|
|
32
42
|
log(`sandbox ${sandbox.id} of ${sandbox.sandbox.repo} at ${sandbox.sandbox.commit}`);
|
|
43
|
+
const shell = sandbox.build.shell;
|
|
33
44
|
const instructions = `The tools work on hammerkit sandbox ${sandbox.id}, a checkout of ${sandbox.sandbox.repo} at ` +
|
|
34
|
-
`${sandbox.sandbox.commit} on a server. Paths are relative to its root.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
45
|
+
`${sandbox.sandbox.commit} on a server. Paths are relative to its root. ` +
|
|
46
|
+
(shell
|
|
47
|
+
? `bash runs a command as the task ${shell} of the published build file ${sandbox.build.hash}, ` +
|
|
48
|
+
'run_task runs its other tasks; only the files are kept between commands. '
|
|
49
|
+
: `run_task runs the tasks of the published build file ${sandbox.build.hash}; only the files are ` +
|
|
50
|
+
'kept between runs. ') +
|
|
51
|
+
'changes shows what changed since the fetch.';
|
|
52
|
+
const tasks = await listTasks(sandbox, log);
|
|
53
|
+
const served = (0, mcp_1.serveMcp)(input, environment.stdout, (0, mcp_1.createMcpTools)(async () => sandbox, { tasks, shell: shell || false }), {
|
|
38
54
|
name: 'hammerkit',
|
|
39
55
|
version: (0, version_1.getVersion)(),
|
|
40
56
|
instructions,
|
package/dist/harness/mcp.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.serveMcp = exports.createMcpTools = void 0;
|
|
|
4
4
|
const readline_1 = require("readline");
|
|
5
5
|
const format_1 = require("./format");
|
|
6
6
|
const sandbox_client_1 = require("./sandbox-client");
|
|
7
|
+
const task_tools_1 = require("./task-tools");
|
|
7
8
|
// the newest first; a client asking for an older one gets it
|
|
8
9
|
const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
9
10
|
const READ_ONLY = { readOnlyHint: true, openWorldHint: false };
|
|
@@ -25,40 +26,47 @@ const flag = (description) => ({ type: 'boolean', description });
|
|
|
25
26
|
function sandboxPath(path) {
|
|
26
27
|
return (path ?? '').replace(/\\/g, '/').replace(/^\/+/, '');
|
|
27
28
|
}
|
|
28
|
-
function readLog(run, task) {
|
|
29
|
-
return `read more of the log with run_logs { "run": "${run}", "task": "${task}" }`;
|
|
30
|
-
}
|
|
31
|
-
function describeRun(task, ran) {
|
|
32
|
-
const result = ran.result;
|
|
33
|
-
const lines = [`run ${ran.run.id}: ${ran.run.state}`];
|
|
34
|
-
if (result) {
|
|
35
|
-
lines.push(`task ${task}: ${result.status}` +
|
|
36
|
-
(result.exitCode !== undefined ? `, exit code ${result.exitCode}` : '') +
|
|
37
|
-
(result.durationMs !== undefined ? `, ${result.durationMs} ms` : '') +
|
|
38
|
-
(result.cache?.hit ? ', from the cache' : ''));
|
|
39
|
-
if (result.error) {
|
|
40
|
-
lines.push(`error: ${result.error}`);
|
|
41
|
-
}
|
|
42
|
-
if (result.cache?.causes?.length) {
|
|
43
|
-
lines.push(`ran because: ${result.cache.causes.join('; ')}`);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
else if (ran.run.error) {
|
|
47
|
-
lines.push(`error: ${ran.run.error}`);
|
|
48
|
-
}
|
|
49
|
-
if (result && result.lastLog.length > 0) {
|
|
50
|
-
lines.push('last lines:', ...result.lastLog);
|
|
51
|
-
}
|
|
52
|
-
lines.push(readLog(ran.run.id, task));
|
|
53
|
-
return lines.join('\n');
|
|
54
|
-
}
|
|
55
29
|
/**
|
|
56
30
|
* The tools of `hammerkit remote mcp`, for chat clients and agents without file and shell tools of
|
|
57
31
|
* their own. They take the shapes models are trained on: numbered reads with offset and
|
|
58
32
|
* limit, exact-string replace, `apply_patch` or a git diff, ripgrep's parameters, a shell
|
|
59
|
-
* command. Paths are relative to the root of the sandbox.
|
|
33
|
+
* command. Paths are relative to the root of the sandbox. `tasks` are the tasks the description
|
|
34
|
+
* of `run_task` lists; without `shell` there is no `bash`.
|
|
60
35
|
*/
|
|
61
|
-
function createMcpTools(client) {
|
|
36
|
+
function createMcpTools(client, options = {}) {
|
|
37
|
+
const [runTask, runLogs] = (0, task_tools_1.createTaskTools)(options.tasks, options.shell || undefined).map((tool) => fromTaskTool(tool, client));
|
|
38
|
+
const bash = {
|
|
39
|
+
name: 'bash',
|
|
40
|
+
description: 'Run a shell command on the sandbox and answer its output and exit code. It runs as the shell task of ' +
|
|
41
|
+
'the published build file, in its container and with its network; commands run one at a time, and ' +
|
|
42
|
+
'only the files are kept between them.',
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
command: text('The command'),
|
|
47
|
+
workdir: text('The directory to run it in, relative to the root of the sandbox'),
|
|
48
|
+
timeout: count('Stop the command after this many seconds', 1),
|
|
49
|
+
},
|
|
50
|
+
required: ['command'],
|
|
51
|
+
},
|
|
52
|
+
annotations: { title: 'Run a command' },
|
|
53
|
+
async call(args, signal) {
|
|
54
|
+
const sandbox = await client();
|
|
55
|
+
const ran = await sandbox.exec(args.command, sandboxPath(args.workdir), {
|
|
56
|
+
signal,
|
|
57
|
+
timeoutSeconds: args.timeout,
|
|
58
|
+
});
|
|
59
|
+
const output = (0, format_1.capOutput)(ran.output, (0, task_tools_1.readLogHint)(ran.run.id, sandbox.build.shell));
|
|
60
|
+
const lines = output ? [output, ''] : [];
|
|
61
|
+
if (ran.exitCode === null) {
|
|
62
|
+
lines.push(`hammerkit: ${ran.result?.error ?? ran.run.error ?? `the run ended ${ran.run.state}`}`);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
lines.push(`exit code ${ran.exitCode}`);
|
|
66
|
+
}
|
|
67
|
+
return lines.join('\n');
|
|
68
|
+
},
|
|
69
|
+
};
|
|
62
70
|
return inOrder([
|
|
63
71
|
{
|
|
64
72
|
name: 'read',
|
|
@@ -185,91 +193,9 @@ function createMcpTools(client) {
|
|
|
185
193
|
return (0, format_1.formatTree)(await (await client()).tree({ path, glob: args.pattern, limit: 500 }));
|
|
186
194
|
},
|
|
187
195
|
},
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
'the published build file, in its container and with its network; commands run one at a time, and ' +
|
|
192
|
-
'only the files are kept between them.',
|
|
193
|
-
inputSchema: {
|
|
194
|
-
type: 'object',
|
|
195
|
-
properties: {
|
|
196
|
-
command: text('The command'),
|
|
197
|
-
workdir: text('The directory to run it in, relative to the root of the sandbox'),
|
|
198
|
-
timeout: count('Stop the command after this many seconds', 1),
|
|
199
|
-
},
|
|
200
|
-
required: ['command'],
|
|
201
|
-
},
|
|
202
|
-
annotations: { title: 'Run a command' },
|
|
203
|
-
async call(args, signal) {
|
|
204
|
-
const sandbox = await client();
|
|
205
|
-
const ran = await sandbox.exec(args.command, sandboxPath(args.workdir), {
|
|
206
|
-
signal,
|
|
207
|
-
timeoutSeconds: args.timeout,
|
|
208
|
-
});
|
|
209
|
-
const output = (0, format_1.capOutput)(ran.output, readLog(ran.run.id, sandbox.build.shell));
|
|
210
|
-
const lines = output ? [output, ''] : [];
|
|
211
|
-
if (ran.exitCode === null) {
|
|
212
|
-
lines.push(`hammerkit: ${ran.result?.error ?? ran.run.error ?? `the run ended ${ran.run.state}`}`);
|
|
213
|
-
}
|
|
214
|
-
else {
|
|
215
|
-
lines.push(`exit code ${ran.exitCode}`);
|
|
216
|
-
}
|
|
217
|
-
return lines.join('\n');
|
|
218
|
-
},
|
|
219
|
-
},
|
|
220
|
-
{
|
|
221
|
-
name: 'run_task',
|
|
222
|
-
description: 'Run a task of the published build file on the sandbox, such as test or build, and answer how it went: ' +
|
|
223
|
-
'its status, exit code, why it was not taken from the cache, and its last lines. args are appended to ' +
|
|
224
|
-
'its last command, for example a test file.',
|
|
225
|
-
inputSchema: {
|
|
226
|
-
type: 'object',
|
|
227
|
-
properties: {
|
|
228
|
-
task: text('The name of the task'),
|
|
229
|
-
args: { type: 'array', items: { type: 'string' }, description: 'Arguments for its last command' },
|
|
230
|
-
},
|
|
231
|
-
required: ['task'],
|
|
232
|
-
},
|
|
233
|
-
annotations: { title: 'Run a task' },
|
|
234
|
-
async call(args, signal) {
|
|
235
|
-
const ran = await (await client()).runTask(args.task, { args: args.args, signal });
|
|
236
|
-
return describeRun(args.task, ran);
|
|
237
|
-
},
|
|
238
|
-
},
|
|
239
|
-
{
|
|
240
|
-
name: 'run_logs',
|
|
241
|
-
description: 'Read the log of a run as numbered lines: one task, the last lines (tail), a range (from, limit) or ' +
|
|
242
|
-
'the lines that contain a text (grep, a fixed string) with lines of context around them.',
|
|
243
|
-
inputSchema: {
|
|
244
|
-
type: 'object',
|
|
245
|
-
properties: {
|
|
246
|
-
run: text('The id of the run'),
|
|
247
|
-
task: text('Only the lines of this task'),
|
|
248
|
-
tail: count('The last lines, or with grep the last matches', 1, 1000),
|
|
249
|
-
from: count('The first line to read', 1),
|
|
250
|
-
limit: count('How many lines to read', 1, 1000),
|
|
251
|
-
grep: text('Only lines that contain this text'),
|
|
252
|
-
ignore_case: flag('Ignore case in grep'),
|
|
253
|
-
context: count('Lines before and after each match', 0, 20),
|
|
254
|
-
},
|
|
255
|
-
required: ['run'],
|
|
256
|
-
},
|
|
257
|
-
annotations: { title: 'Read the log of a run', ...READ_ONLY },
|
|
258
|
-
async call(args) {
|
|
259
|
-
const page = await (await client()).logs(args.run, {
|
|
260
|
-
task: args.task,
|
|
261
|
-
tail: args.tail,
|
|
262
|
-
from: args.from,
|
|
263
|
-
limit: args.limit,
|
|
264
|
-
grep: args.grep,
|
|
265
|
-
i: args.ignore_case,
|
|
266
|
-
context: args.context,
|
|
267
|
-
});
|
|
268
|
-
const lines = page.lines.map((line) => `${line.line}${line.match === false ? '-' : ':'} ${args.task ? '' : `[${line.task}] `}${line.text}`);
|
|
269
|
-
lines.push('', `(${page.total} lines${args.grep ? ` searched, ${page.lines.filter((line) => line.match !== false).length} match here` : ''}${page.next ? `, read on with from ${page.next}` : ''}${page.truncated ? ', the server cut the log off at its limit' : ''})`);
|
|
270
|
-
return lines.join('\n');
|
|
271
|
-
},
|
|
272
|
-
},
|
|
196
|
+
...(options.shell === false ? [] : [bash]),
|
|
197
|
+
runTask,
|
|
198
|
+
{ ...runLogs, annotations: { ...runLogs.annotations, ...READ_ONLY } },
|
|
273
199
|
{
|
|
274
200
|
name: 'changes',
|
|
275
201
|
description: 'What changed in the sandbox since the commit it was fetched at: a summary of the files, a diff, or a ' +
|
|
@@ -318,6 +244,18 @@ function createMcpTools(client) {
|
|
|
318
244
|
]);
|
|
319
245
|
}
|
|
320
246
|
exports.createMcpTools = createMcpTools;
|
|
247
|
+
// a tool shared with the other harnesses, as an MCP tool
|
|
248
|
+
function fromTaskTool(tool, client) {
|
|
249
|
+
return {
|
|
250
|
+
name: tool.name,
|
|
251
|
+
description: tool.description,
|
|
252
|
+
inputSchema: (0, task_tools_1.taskToolSchema)(tool),
|
|
253
|
+
annotations: { title: tool.title },
|
|
254
|
+
async call(args, signal) {
|
|
255
|
+
return tool.call(await client(), args, signal);
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
321
259
|
// the calls of a client go to the sandbox one at a time, in the order the client sent them
|
|
322
260
|
function inOrder(tools) {
|
|
323
261
|
const inTurn = (0, sandbox_client_1.oneAtATime)();
|
package/dist/harness/opencode.js
CHANGED
|
@@ -9,35 +9,64 @@ 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");
|
|
12
13
|
const DEFAULT_READ_LINES = 2000;
|
|
13
14
|
const LIST_LIMIT = 100;
|
|
14
15
|
const SEARCH_LIMIT = 100;
|
|
15
16
|
/**
|
|
16
17
|
* The tools of opencode on a hammerkit sandbox: `read`, `write`, `edit`, `apply_patch`,
|
|
17
18
|
* `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
|
|
19
|
+
* by registering tools of the same name, `run_task` and `run_logs` for the tasks of the build
|
|
20
|
+
* file, and `push`, which takes the work out as a branch. opencode resolves paths against its
|
|
21
|
+
* directory, which stands for the root of the sandbox. `schema` is the zod of opencode
|
|
20
22
|
* (`tool.schema` of `@opencode-ai/plugin`), so it reads the parameters as its own.
|
|
21
23
|
*/
|
|
22
24
|
function hammerkitOpencodePlugin(schema, settings = {}) {
|
|
23
|
-
return async (input) =>
|
|
25
|
+
return async (input) => {
|
|
26
|
+
const harness = connectHarness(input.directory, settings);
|
|
27
|
+
const tasks = await harness.tasks();
|
|
28
|
+
return { tool: createOpencodeTools(schema, harness.client, { tasks, shell: harness.shell }) };
|
|
29
|
+
};
|
|
24
30
|
}
|
|
25
31
|
exports.hammerkitOpencodePlugin = hammerkitOpencodePlugin;
|
|
26
|
-
|
|
27
|
-
function lazySandbox(directory, settings) {
|
|
32
|
+
function connectHarness(directory, settings) {
|
|
28
33
|
const environment = settings.environment ?? (0, connect_1.getHarnessEnvironment)(directory);
|
|
29
34
|
const options = settings.options ?? (0, connect_1.harnessOptionsFromEnv)(environment.processEnvs);
|
|
30
35
|
let connecting;
|
|
31
|
-
|
|
36
|
+
const client = () => {
|
|
32
37
|
if (!connecting) {
|
|
33
38
|
connecting = settings.client ? settings.client() : (0, connect_1.connectSandbox)(environment, options);
|
|
34
39
|
connecting.catch(() => (connecting = undefined));
|
|
35
40
|
}
|
|
36
41
|
return connecting;
|
|
37
42
|
};
|
|
43
|
+
return {
|
|
44
|
+
client,
|
|
45
|
+
// the version is read without a sandbox, which the first call of a tool takes
|
|
46
|
+
tasks: () => (0, task_tools_1.readTaskListing)(async () => {
|
|
47
|
+
if (settings.client) {
|
|
48
|
+
return (await client()).tasks();
|
|
49
|
+
}
|
|
50
|
+
const server = await (0, connect_1.connectServer)(environment, options);
|
|
51
|
+
return (0, sandbox_client_1.listPublishedTasks)(server.connection, server.build);
|
|
52
|
+
}),
|
|
53
|
+
shell: options.shell ?? connect_1.DEFAULT_SHELL_TASK,
|
|
54
|
+
};
|
|
38
55
|
}
|
|
39
56
|
// the names of the tools of the plugin, which stand in for opencode's own
|
|
40
|
-
exports.OPENCODE_TOOLS = [
|
|
57
|
+
exports.OPENCODE_TOOLS = [
|
|
58
|
+
'read',
|
|
59
|
+
'write',
|
|
60
|
+
'edit',
|
|
61
|
+
'apply_patch',
|
|
62
|
+
'bash',
|
|
63
|
+
'grep',
|
|
64
|
+
'glob',
|
|
65
|
+
'run_task',
|
|
66
|
+
'run_logs',
|
|
67
|
+
'push',
|
|
68
|
+
'list',
|
|
69
|
+
];
|
|
41
70
|
/**
|
|
42
71
|
* The plugin as the file `harness/opencode.ts` exports it: it takes the zod of opencode from
|
|
43
72
|
* `@opencode-ai/plugin`, found next to hammerkit or else in the opencode directories of the
|
|
@@ -111,7 +140,18 @@ function packageEntry(manifest) {
|
|
|
111
140
|
}
|
|
112
141
|
}
|
|
113
142
|
// the names of the tools of the plugin on opencode 2, which stand in for its own
|
|
114
|
-
exports.OPENCODE_V2_TOOLS = [
|
|
143
|
+
exports.OPENCODE_V2_TOOLS = [
|
|
144
|
+
'read',
|
|
145
|
+
'write',
|
|
146
|
+
'edit',
|
|
147
|
+
'patch',
|
|
148
|
+
'shell',
|
|
149
|
+
'glob',
|
|
150
|
+
'grep',
|
|
151
|
+
'run_task',
|
|
152
|
+
'run_logs',
|
|
153
|
+
'push',
|
|
154
|
+
];
|
|
115
155
|
// the tools of opencode 2 that work on this machine and have no stand-in on a sandbox
|
|
116
156
|
exports.OPENCODE_V2_LOCAL_TOOLS = [
|
|
117
157
|
'opencode_worktree_create',
|
|
@@ -120,25 +160,31 @@ exports.OPENCODE_V2_LOCAL_TOOLS = [
|
|
|
120
160
|
];
|
|
121
161
|
/**
|
|
122
162
|
* 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
|
|
163
|
+
* and none of the worktree tools, which would make worktrees of this machine, nor its shell
|
|
164
|
+
* when there is no shell task. opencode 2 takes a plugin as an object with an id and a setup, it
|
|
165
|
+
* needs no package of opencode.
|
|
125
166
|
*/
|
|
126
167
|
function hammerkitOpencodeV2Plugin(settings = {}) {
|
|
127
168
|
return {
|
|
128
169
|
id: 'hammerkit',
|
|
129
170
|
async setup(context) {
|
|
130
171
|
const directory = context.location.directory;
|
|
131
|
-
const
|
|
172
|
+
const harness = connectHarness(directory, settings);
|
|
173
|
+
const tasks = await harness.tasks();
|
|
174
|
+
const tools = createOpencodeV2Tools(harness.client, directory, { tasks, shell: harness.shell });
|
|
132
175
|
await context.tool.transform((editor) => {
|
|
133
176
|
for (const tool of tools) {
|
|
134
177
|
editor.add(tool);
|
|
135
178
|
}
|
|
136
|
-
for (const name of exports.OPENCODE_V2_LOCAL_TOOLS) {
|
|
179
|
+
for (const name of [...exports.OPENCODE_V2_LOCAL_TOOLS, ...(harness.shell ? [] : ['shell'])]) {
|
|
137
180
|
editor.remove(name);
|
|
138
181
|
}
|
|
139
182
|
});
|
|
140
183
|
// opencode describes the tool named shell anew for each request, as the shell of this machine
|
|
141
184
|
const shell = tools.find((tool) => tool.name === 'shell');
|
|
185
|
+
if (!shell) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
142
188
|
for (const name of ['context', 'compaction', 'generate']) {
|
|
143
189
|
await context.session.hook(name, (request) => {
|
|
144
190
|
if (request.tools.shell) {
|
|
@@ -163,8 +209,9 @@ exports.hammerkitOpencodeEntry = hammerkitOpencodeEntry;
|
|
|
163
209
|
* opencode 1 under their new names (`patch`, `shell`), paths as `path`, relative to `directory`
|
|
164
210
|
* or absolute below it. opencode 2 has no `list`, `read` lists a directory.
|
|
165
211
|
*/
|
|
166
|
-
function createOpencodeV2Tools(client, directory) {
|
|
167
|
-
const tools = opencodeTools(client);
|
|
212
|
+
function createOpencodeV2Tools(client, directory, options = {}) {
|
|
213
|
+
const tools = opencodeTools(client, options);
|
|
214
|
+
const shell = options.shell ?? connect_1.DEFAULT_SHELL_TASK;
|
|
168
215
|
const tool = (name, of, input, args, permission) => ({
|
|
169
216
|
name,
|
|
170
217
|
description: tools[of].description,
|
|
@@ -176,6 +223,7 @@ function createOpencodeV2Tools(client, directory) {
|
|
|
176
223
|
},
|
|
177
224
|
});
|
|
178
225
|
const same = (input) => input;
|
|
226
|
+
const taskTools = Object.fromEntries((0, task_tools_1.createTaskTools)().map((task) => [task.name, task]));
|
|
179
227
|
return [
|
|
180
228
|
tool('read', 'read', object({
|
|
181
229
|
path: text('The file or directory to read, relative to the project or absolute'),
|
|
@@ -193,11 +241,15 @@ function createOpencodeV2Tools(client, directory) {
|
|
|
193
241
|
replaceAll: flag('Replace all occurrences of oldString (default false)'),
|
|
194
242
|
}, ['path', 'oldString', 'newString']), ({ path, ...rest }) => ({ filePath: path, ...rest }), 'edit'),
|
|
195
243
|
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
|
-
|
|
244
|
+
...(shell
|
|
245
|
+
? [
|
|
246
|
+
tool('shell', 'bash', object({
|
|
247
|
+
command: text('The command to execute'),
|
|
248
|
+
workdir: text('The directory to run the command in, relative to the project. Use this instead of cd.'),
|
|
249
|
+
timeout: count('Timeout in milliseconds, 0 for the default of the shell task'),
|
|
250
|
+
}, ['command']), ({ timeout, ...rest }) => ({ ...rest, timeout: timeout || undefined })),
|
|
251
|
+
]
|
|
252
|
+
: []),
|
|
201
253
|
tool('glob', 'glob', object({
|
|
202
254
|
pattern: text('The glob pattern to match files against, like "**/*.ts"'),
|
|
203
255
|
path: text('The directory to search in, the project by default'),
|
|
@@ -211,6 +263,7 @@ function createOpencodeV2Tools(client, directory) {
|
|
|
211
263
|
caseSensitive: flag('Match case (default true)'),
|
|
212
264
|
limit: count(`The maximum number of matching lines (default ${SEARCH_LIMIT})`),
|
|
213
265
|
}, ['pattern']), same),
|
|
266
|
+
...['run_task', 'run_logs'].map((name) => tool(name, name, { ...(0, task_tools_1.taskToolSchema)(taskTools[name]), additionalProperties: false }, same)),
|
|
214
267
|
tool('push', 'push', object({
|
|
215
268
|
branch: text('The branch to push to, created when it does not exist'),
|
|
216
269
|
message: text('The commit message'),
|
|
@@ -227,11 +280,28 @@ const object = (properties, required) => ({
|
|
|
227
280
|
required,
|
|
228
281
|
additionalProperties: false,
|
|
229
282
|
});
|
|
230
|
-
function createOpencodeTools(z, client) {
|
|
231
|
-
return Object.fromEntries(Object.entries(opencodeTools(client)).map(([name, { args, ...tool }]) => [
|
|
283
|
+
function createOpencodeTools(z, client, options = {}) {
|
|
284
|
+
return Object.fromEntries(Object.entries(opencodeTools(client, options)).map(([name, { args, ...tool }]) => [
|
|
285
|
+
name,
|
|
286
|
+
{ ...tool, args: args(z) },
|
|
287
|
+
]));
|
|
232
288
|
}
|
|
233
289
|
exports.createOpencodeTools = createOpencodeTools;
|
|
234
|
-
function opencodeTools(client) {
|
|
290
|
+
function opencodeTools(client, options = {}) {
|
|
291
|
+
const shell = options.shell ?? connect_1.DEFAULT_SHELL_TASK;
|
|
292
|
+
const tasks = Object.fromEntries((0, task_tools_1.createTaskTools)(options.tasks, shell || undefined).map((task) => [
|
|
293
|
+
task.name,
|
|
294
|
+
{
|
|
295
|
+
description: task.description,
|
|
296
|
+
args: (z) => (0, task_tools_1.taskToolArgs)(task, z),
|
|
297
|
+
async execute(args, context) {
|
|
298
|
+
const output = await task.call(await client(), args, context.abort);
|
|
299
|
+
return { title: task.name === 'run_task' ? `run ${args.task}` : `log of ${args.run}`, output };
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
]));
|
|
303
|
+
// without a shell task bash still stands in for opencode's, which would run on this machine
|
|
304
|
+
const noShell = 'There is no shell on this hammerkit sandbox: run the tasks of the build file with run_task.';
|
|
235
305
|
const where = (context, path) => (0, sandbox_client_1.toSandboxPath)(context.directory, path ?? context.directory);
|
|
236
306
|
return inOrder({
|
|
237
307
|
read: {
|
|
@@ -337,35 +407,43 @@ function opencodeTools(client) {
|
|
|
337
407
|
].join('\n');
|
|
338
408
|
},
|
|
339
409
|
},
|
|
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
|
-
|
|
410
|
+
bash: shell
|
|
411
|
+
? {
|
|
412
|
+
description: 'Run a shell command on the hammerkit sandbox, as the shell task of the published build file, in its ' +
|
|
413
|
+
'container. Commands run one at a time and nothing is kept between them but the files. They start in ' +
|
|
414
|
+
'the root of the sandbox, where the paths of this machine do not exist: give them relative paths.',
|
|
415
|
+
args: (z) => ({
|
|
416
|
+
command: z.string().describe('The command to execute'),
|
|
417
|
+
timeout: z.number().int().min(1).optional().describe('Optional timeout in milliseconds'),
|
|
418
|
+
workdir: z
|
|
419
|
+
.string()
|
|
420
|
+
.optional()
|
|
421
|
+
.describe("The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands."),
|
|
422
|
+
}),
|
|
423
|
+
async execute(args, context) {
|
|
424
|
+
const sandbox = await client();
|
|
425
|
+
const ran = await sandbox.exec(args.command, where(context, args.workdir), {
|
|
426
|
+
signal: context.abort,
|
|
427
|
+
timeoutSeconds: args.timeout ? args.timeout / 1000 : undefined,
|
|
428
|
+
});
|
|
429
|
+
const lines = [(0, format_1.capOutput)(ran.output, (0, task_tools_1.readLogHint)(ran.run.id, shell))];
|
|
430
|
+
if (ran.exitCode === null) {
|
|
431
|
+
lines.push(`hammerkit: ${ran.result?.error ?? ran.run.error ?? `the run ended ${ran.run.state}`}`);
|
|
432
|
+
}
|
|
433
|
+
else if (ran.exitCode !== 0) {
|
|
434
|
+
lines.push(`Exit code: ${ran.exitCode}`);
|
|
435
|
+
}
|
|
436
|
+
context.metadata?.({ metadata: { exit: ran.exitCode, run: ran.run.id } });
|
|
437
|
+
return lines.filter((line) => line.length > 0).join('\n\n') || '(no output)';
|
|
438
|
+
},
|
|
439
|
+
}
|
|
440
|
+
: {
|
|
441
|
+
description: noShell,
|
|
442
|
+
args: (z) => ({ command: z.string().describe('The command to execute') }),
|
|
443
|
+
async execute() {
|
|
444
|
+
throw new Error(noShell);
|
|
445
|
+
},
|
|
367
446
|
},
|
|
368
|
-
},
|
|
369
447
|
grep: {
|
|
370
448
|
description: 'Search the files of the hammerkit sandbox with a regular expression (ripgrep). Returns the matching ' +
|
|
371
449
|
'lines with their file and line number.',
|
|
@@ -430,6 +508,7 @@ function opencodeTools(client) {
|
|
|
430
508
|
return lines.join('\n');
|
|
431
509
|
},
|
|
432
510
|
},
|
|
511
|
+
...tasks,
|
|
433
512
|
push: {
|
|
434
513
|
description: 'Commit the changes of the hammerkit sandbox and push them to a branch of its repository, for review. ' +
|
|
435
514
|
'The server pushes only to the branches its operator allows; the sandbox is at the pushed commit afterwards.',
|
|
@@ -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
|
}
|
|
@@ -144,6 +166,9 @@ class SandboxClient {
|
|
|
144
166
|
* its argument. `cwd` is a directory of the sandbox, `''` for its root.
|
|
145
167
|
*/
|
|
146
168
|
async exec(command, cwd, options = {}) {
|
|
169
|
+
if (!this.build.shell) {
|
|
170
|
+
throw new SandboxClientError('there is no shell on this sandbox (HAMMERKIT_SHELL is empty), run a task instead');
|
|
171
|
+
}
|
|
147
172
|
// on its own line: `cd dir && a; b` would run b in the root when dir is missing
|
|
148
173
|
const script = cwd && cwd !== '.' ? `cd -- ${quotePosix(cwd)} || exit 1\n${command}` : command;
|
|
149
174
|
// a shell task in a container without `mounts: [.]` runs in an empty directory: the
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.taskToolArgs = exports.taskToolSchema = exports.createTaskTools = exports.describeTasks = exports.describeRun = exports.readLogHint = exports.readTaskListing = exports.LIST_TASKS_MS = void 0;
|
|
4
|
+
// the most tasks a description lists, and of a task's description or command it shows
|
|
5
|
+
const MAX_LISTED = 40;
|
|
6
|
+
const MAX_DESCRIPTION_CHARS = 200;
|
|
7
|
+
const MAX_COMMAND_CHARS = 100;
|
|
8
|
+
// how long a client waits for the tasks of the build file when it starts, it goes on without them
|
|
9
|
+
exports.LIST_TASKS_MS = 15000;
|
|
10
|
+
// The tasks of the published version, or why they could not be read within `ms`.
|
|
11
|
+
async function readTaskListing(list, ms = exports.LIST_TASKS_MS) {
|
|
12
|
+
let timer;
|
|
13
|
+
const timeout = new Promise((_, reject) => {
|
|
14
|
+
timer = setTimeout(() => reject(new Error(`no answer within ${ms / 1000} s`)), ms);
|
|
15
|
+
timer.unref?.();
|
|
16
|
+
});
|
|
17
|
+
try {
|
|
18
|
+
return await Promise.race([list(), timeout]);
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
exports.readTaskListing = readTaskListing;
|
|
28
|
+
// the hint at the end of a cut output or a result, for the model
|
|
29
|
+
function readLogHint(run, task) {
|
|
30
|
+
return `read more of the log with run_logs { "run": "${run}", "task": "${task}" }`;
|
|
31
|
+
}
|
|
32
|
+
exports.readLogHint = readLogHint;
|
|
33
|
+
// A run of a task in a few lines: how it went, why it ran, its last lines.
|
|
34
|
+
function describeRun(task, ran) {
|
|
35
|
+
const result = ran.result;
|
|
36
|
+
const lines = [`run ${ran.run.id}: ${ran.run.state}`];
|
|
37
|
+
if (result) {
|
|
38
|
+
lines.push(`task ${task}: ${result.status}` +
|
|
39
|
+
(result.exitCode !== undefined ? `, exit code ${result.exitCode}` : '') +
|
|
40
|
+
(result.durationMs !== undefined ? `, ${result.durationMs} ms` : '') +
|
|
41
|
+
(result.cache?.hit ? ', from the cache' : ''));
|
|
42
|
+
if (result.error) {
|
|
43
|
+
lines.push(`error: ${result.error}`);
|
|
44
|
+
}
|
|
45
|
+
if (result.cache?.causes?.length) {
|
|
46
|
+
lines.push(`ran because: ${result.cache.causes.join('; ')}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else if (ran.run.error) {
|
|
50
|
+
lines.push(`error: ${ran.run.error}`);
|
|
51
|
+
}
|
|
52
|
+
if (result && result.lastLog.length > 0) {
|
|
53
|
+
lines.push('last lines:', ...result.lastLog);
|
|
54
|
+
}
|
|
55
|
+
lines.push(readLogHint(ran.run.id, task));
|
|
56
|
+
return lines.join('\n');
|
|
57
|
+
}
|
|
58
|
+
exports.describeRun = describeRun;
|
|
59
|
+
/**
|
|
60
|
+
* The tasks for the model, one line each: what it is for (or its command, when the build
|
|
61
|
+
* file says nothing) and whether it takes arguments. `shell` is left out, the shell tool runs it.
|
|
62
|
+
*/
|
|
63
|
+
function describeTasks(listing, shell) {
|
|
64
|
+
if (listing === undefined) {
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
if (!Array.isArray(listing)) {
|
|
68
|
+
return `The tasks of the build file could not be listed (${listing.error}); name one as the build file does.`;
|
|
69
|
+
}
|
|
70
|
+
const tasks = listing.filter((task) => !shell || task.name !== shell);
|
|
71
|
+
if (tasks.length === 0) {
|
|
72
|
+
return 'The published build file has no tasks to run.';
|
|
73
|
+
}
|
|
74
|
+
const lines = tasks.slice(0, MAX_LISTED).map((task) => {
|
|
75
|
+
const parts = [`- ${task.name}`];
|
|
76
|
+
if (task.description) {
|
|
77
|
+
parts.push(`: ${cut(oneLine(task.description), MAX_DESCRIPTION_CHARS)}`);
|
|
78
|
+
}
|
|
79
|
+
else if (task.command) {
|
|
80
|
+
parts.push(`: runs \`${cut(oneLine(task.command), MAX_COMMAND_CHARS)}\``);
|
|
81
|
+
}
|
|
82
|
+
if (task.args === null) {
|
|
83
|
+
parts.push(' (takes args)');
|
|
84
|
+
}
|
|
85
|
+
else if (task.args !== undefined || task.command === null) {
|
|
86
|
+
parts.push(' (takes no args)');
|
|
87
|
+
}
|
|
88
|
+
return parts.join('');
|
|
89
|
+
});
|
|
90
|
+
if (tasks.length > MAX_LISTED) {
|
|
91
|
+
lines.push(`- and ${tasks.length - MAX_LISTED} more`);
|
|
92
|
+
}
|
|
93
|
+
return ['The tasks of the published build file:', ...lines].join('\n');
|
|
94
|
+
}
|
|
95
|
+
exports.describeTasks = describeTasks;
|
|
96
|
+
function oneLine(text) {
|
|
97
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
98
|
+
}
|
|
99
|
+
function cut(text, max) {
|
|
100
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* `run_task` and `run_logs`, with the tasks the description of `run_task` lists. Without a
|
|
104
|
+
* listing it names none, as the MCP tools before a version could be read.
|
|
105
|
+
*/
|
|
106
|
+
function createTaskTools(listing, shell) {
|
|
107
|
+
const tasks = describeTasks(listing, shell);
|
|
108
|
+
return [
|
|
109
|
+
{
|
|
110
|
+
name: 'run_task',
|
|
111
|
+
title: 'Run a task',
|
|
112
|
+
description: 'Run a task of the published build file on the sandbox, such as test or build, and answer how it went: ' +
|
|
113
|
+
'its status, exit code, why it was not taken from the cache, and its last lines. args are appended to ' +
|
|
114
|
+
'its last command, for example a test file. It runs in the container, with the network and the ' +
|
|
115
|
+
'options the build file gives the task.' +
|
|
116
|
+
(tasks ? `\n\n${tasks}` : ''),
|
|
117
|
+
parameters: {
|
|
118
|
+
task: { type: 'string', description: 'The name of the task' },
|
|
119
|
+
args: { type: 'string[]', description: 'Arguments for its last command' },
|
|
120
|
+
},
|
|
121
|
+
required: ['task'],
|
|
122
|
+
async call(client, args, signal) {
|
|
123
|
+
const ran = await client.runTask(args.task, { args: args.args?.length ? args.args : undefined, signal });
|
|
124
|
+
return describeRun(args.task, ran);
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'run_logs',
|
|
129
|
+
title: 'Read the log of a run',
|
|
130
|
+
description: 'Read the log of a run as numbered lines: one task, the last lines (tail), a range (from, limit) or ' +
|
|
131
|
+
'the lines that contain a text (grep, a fixed string) with lines of context around them.',
|
|
132
|
+
parameters: {
|
|
133
|
+
run: { type: 'string', description: 'The id of the run' },
|
|
134
|
+
task: { type: 'string', description: 'Only the lines of this task' },
|
|
135
|
+
tail: {
|
|
136
|
+
type: 'integer',
|
|
137
|
+
description: 'The last lines, or with grep the last matches',
|
|
138
|
+
minimum: 1,
|
|
139
|
+
maximum: 1000,
|
|
140
|
+
},
|
|
141
|
+
from: { type: 'integer', description: 'The first line to read', minimum: 1 },
|
|
142
|
+
limit: { type: 'integer', description: 'How many lines to read', minimum: 1, maximum: 1000 },
|
|
143
|
+
grep: { type: 'string', description: 'Only lines that contain this text' },
|
|
144
|
+
ignore_case: { type: 'boolean', description: 'Ignore case in grep' },
|
|
145
|
+
context: { type: 'integer', description: 'Lines before and after each match', minimum: 0, maximum: 20 },
|
|
146
|
+
},
|
|
147
|
+
required: ['run'],
|
|
148
|
+
async call(client, args) {
|
|
149
|
+
const page = await client.logs(args.run, {
|
|
150
|
+
task: args.task || undefined,
|
|
151
|
+
tail: args.tail || undefined,
|
|
152
|
+
from: args.from || undefined,
|
|
153
|
+
limit: args.limit || undefined,
|
|
154
|
+
grep: args.grep || undefined,
|
|
155
|
+
i: args.ignore_case || undefined,
|
|
156
|
+
context: args.context || undefined,
|
|
157
|
+
});
|
|
158
|
+
const lines = page.lines.map((line) => `${line.line}${line.match === false ? '-' : ':'} ${args.task ? '' : `[${line.task}] `}${line.text}`);
|
|
159
|
+
lines.push('', `(${page.total} lines${args.grep ? ` searched, ${page.lines.filter((line) => line.match !== false).length} match here` : ''}${page.next ? `, read on with from ${page.next}` : ''}${page.truncated ? ', the server cut the log off at its limit' : ''})`);
|
|
160
|
+
return lines.join('\n');
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
exports.createTaskTools = createTaskTools;
|
|
166
|
+
// The parameters as a JSON schema, for MCP and opencode 2.
|
|
167
|
+
function taskToolSchema(tool) {
|
|
168
|
+
return {
|
|
169
|
+
type: 'object',
|
|
170
|
+
properties: Object.fromEntries(Object.entries(tool.parameters).map(([name, parameter]) => {
|
|
171
|
+
if (parameter.type === 'string[]') {
|
|
172
|
+
return [name, { type: 'array', items: { type: 'string' }, description: parameter.description }];
|
|
173
|
+
}
|
|
174
|
+
if (parameter.type === 'integer') {
|
|
175
|
+
const { maximum } = parameter;
|
|
176
|
+
return [
|
|
177
|
+
name,
|
|
178
|
+
{
|
|
179
|
+
type: 'integer',
|
|
180
|
+
description: parameter.description,
|
|
181
|
+
minimum: parameter.minimum,
|
|
182
|
+
...(maximum !== undefined ? { maximum } : {}),
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
return [name, { type: parameter.type, description: parameter.description }];
|
|
187
|
+
})),
|
|
188
|
+
required: tool.required,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
exports.taskToolSchema = taskToolSchema;
|
|
192
|
+
// The parameters with the zod of opencode 1.
|
|
193
|
+
function taskToolArgs(tool, z) {
|
|
194
|
+
return Object.fromEntries(Object.entries(tool.parameters).map(([name, parameter]) => {
|
|
195
|
+
let type;
|
|
196
|
+
if (parameter.type === 'string[]') {
|
|
197
|
+
type = z.array(z.string());
|
|
198
|
+
}
|
|
199
|
+
else if (parameter.type === 'integer') {
|
|
200
|
+
type = z.number().int().min(parameter.minimum);
|
|
201
|
+
type = parameter.maximum !== undefined ? type.max(parameter.maximum) : type;
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
type = parameter.type === 'boolean' ? z.boolean() : z.string();
|
|
205
|
+
}
|
|
206
|
+
return [name, (tool.required.includes(name) ? type : type.optional()).describe(parameter.description)];
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
exports.taskToolArgs = taskToolArgs;
|
|
210
|
+
//# sourceMappingURL=task-tools.js.map
|
|
@@ -5,6 +5,7 @@ const zod_1 = require("zod");
|
|
|
5
5
|
const bundle_1 = require("../remote/bundle");
|
|
6
6
|
const definitions_1 = require("./definitions");
|
|
7
7
|
const http_1 = require("./http");
|
|
8
|
+
const run_plan_1 = require("./run-plan");
|
|
8
9
|
const oauth_1 = require("./oauth");
|
|
9
10
|
const policy_1 = require("./policy");
|
|
10
11
|
const proposals_1 = require("./proposals");
|
|
@@ -19,7 +20,8 @@ const MAX_PUBLISH_BYTES = bundle_1.MAX_BUNDLE_BYTES * 2;
|
|
|
19
20
|
* `annotations`, which are not part of the hash
|
|
20
21
|
* GET /v1/definitions the versions, newest first; `?<key>=<value>`
|
|
21
22
|
* keeps the ones with those annotations
|
|
22
|
-
* GET /v1/definitions/<hash> one version
|
|
23
|
+
* GET /v1/definitions/<hash> one version, with `arguments`: per task null when
|
|
24
|
+
* a run of it may pass arguments, else why not
|
|
23
25
|
* GET /v1/definitions/<hash>/files its files
|
|
24
26
|
* POST /v1/definitions/<hash>/yank pull it back (body: optional {reason})
|
|
25
27
|
* POST /v1/definitions/<hash>/unyank let it go on
|
|
@@ -72,7 +74,7 @@ async function handleDefinitions(config, store, proposals, log, principal, req,
|
|
|
72
74
|
bundle ? (0, http_1.send)(res, 200, bundle) : (0, http_1.send)(res, 404, { error: 'not found' });
|
|
73
75
|
}
|
|
74
76
|
else {
|
|
75
|
-
(0, http_1.send)(res, 200, found);
|
|
77
|
+
(0, http_1.send)(res, 200, found.plan ? { ...found, arguments: (0, run_plan_1.describeArguments)(found.plan, config.runs) } : found);
|
|
76
78
|
}
|
|
77
79
|
return true;
|
|
78
80
|
}
|
package/dist/server/plan.js
CHANGED
|
@@ -284,6 +284,7 @@ function toPlan(root, item) {
|
|
|
284
284
|
const isTask = data.type === 'local-task' || data.type === 'container-task';
|
|
285
285
|
return {
|
|
286
286
|
kind: isTask ? 'task' : 'service',
|
|
287
|
+
...(data.description ? { description: data.description } : {}),
|
|
287
288
|
runtime: data.type === 'local-task' ? 'local' : 'docker',
|
|
288
289
|
...((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data) ? { image: data.image } : {}),
|
|
289
290
|
cmds: 'cmds' in data ? data.cmds.map((cmd) => cmd.cmd) : 'cmd' in data && data.cmd ? [data.cmd.cmd] : [],
|
package/dist/server/run-plan.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.refuseArguments = exports.planRun = exports.getRunScopes = void 0;
|
|
3
|
+
exports.describeArguments = exports.refuseArguments = exports.planRun = exports.getRunScopes = void 0;
|
|
4
4
|
const parse_label_arguments_1 = require("../parser/parse-label-arguments");
|
|
5
5
|
const plan_1 = require("./plan");
|
|
6
6
|
function labels(values) {
|
|
@@ -87,4 +87,34 @@ function refuseArguments(run, plan, policy = {}) {
|
|
|
87
87
|
return null;
|
|
88
88
|
}
|
|
89
89
|
exports.refuseArguments = refuseArguments;
|
|
90
|
+
/**
|
|
91
|
+
* For each task of a version, whether a run of it alone may pass arguments: null when it may,
|
|
92
|
+
* else why not, as {@link refuseArguments} answers a run. The plan is the one of the whole
|
|
93
|
+
* version: a task's run is the task with its dependencies and services.
|
|
94
|
+
*/
|
|
95
|
+
function describeArguments(plan, policy = {}) {
|
|
96
|
+
const described = {};
|
|
97
|
+
for (const [name, entry] of Object.entries(plan)) {
|
|
98
|
+
if (entry.kind !== 'task') {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const run = {};
|
|
102
|
+
const visit = (item) => {
|
|
103
|
+
if (run[item] || !plan[item]) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
run[item] = plan[item];
|
|
107
|
+
for (const next of [...plan[item].deps, ...plan[item].needs]) {
|
|
108
|
+
visit(next);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
visit(name);
|
|
112
|
+
described[name] =
|
|
113
|
+
entry.command === null
|
|
114
|
+
? `${name} has no command to take arguments`
|
|
115
|
+
: refuseArguments({ tasks: [name], options: { args: ['-'] } }, run, policy);
|
|
116
|
+
}
|
|
117
|
+
return described;
|
|
118
|
+
}
|
|
119
|
+
exports.describeArguments = describeArguments;
|
|
90
120
|
//# sourceMappingURL=run-plan.js.map
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hammerkit",
|
|
3
3
|
"description": "Containerized build tool with incremental caching that runs the same build file locally, in Docker and on Kubernetes.",
|
|
4
|
-
"version": "1.27.0-next.
|
|
4
|
+
"version": "1.27.0-next.14",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"build",
|
|
7
7
|
"build-tool",
|