hammerkit 1.27.0-next.2 → 1.27.0-next.20
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 +9 -0
- package/dist/cache/backends/registry-cache-backend.js +2 -2
- package/dist/cache/explain.js +17 -0
- package/dist/cli.js +1 -0
- package/dist/docker/item-network.js +13 -5
- package/dist/executer/deadline.js +17 -3
- package/dist/executer/docker-service.js +30 -8
- package/dist/executer/event-cache.js +20 -2
- package/dist/executer/execute-work-task.js +5 -3
- package/dist/executer/execution-steps.js +5 -2
- package/dist/executer/scheduler/enqueue-next.js +7 -0
- package/dist/executer/scheduler/state-key.js +42 -5
- 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 +207 -65
- 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/claim-layout.js +71 -0
- package/dist/kubernetes/ensure-http-route.js +1 -1
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +5 -9
- 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/get-service-ip.js +2 -2
- 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 +626 -0
- package/dist/kubernetes/secrets.js +7 -11
- package/dist/kubernetes/volumes.js +6 -3
- 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 +1 -0
- package/dist/planner/utils/append-work-task.js +15 -2
- 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 +97 -48
- package/dist/planner/work-runtime-local.js +2 -1
- package/dist/planner/work-task.js +6 -1
- package/dist/program.js +14 -4
- package/dist/remote/annotations.js +13 -1
- package/dist/remote/device-login.js +7 -2
- package/dist/remote/host-config.js +64 -3
- package/dist/remote/remote-commands.js +41 -9
- package/dist/remote/remote-work-commands.js +246 -1
- 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 +5 -1
- package/dist/schema/build-file-container-service-schema.js +2 -0
- package/dist/schema/reference-parser.js +9 -1
- package/dist/schema/work-scope-parser.js +6 -0
- package/dist/secrets/github-app-token-command.js +3 -1
- 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 +4 -2
- package/dist/server/endpoints.js +612 -0
- package/dist/server/executor.js +56 -5
- package/dist/server/kubernetes-backend.js +303 -64
- package/dist/server/kubernetes-sandbox-storage.js +48 -3
- package/dist/server/oauth.js +12 -1
- package/dist/server/plan.js +126 -62
- 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 +64 -3
- package/dist/server/run-worker.js +146 -10
- package/dist/server/runs.js +347 -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 +213 -14
- package/dist/server/server.js +268 -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/package.json +1 -1
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.quotePosix = exports.fetchFailure = exports.toSandboxPath = exports.checkSandboxPath = exports.exitCodeOf = 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.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
|
|
7
7
|
const TIMEOUT_MS = 60000;
|
|
8
8
|
// a run is asked for this long at a time, the log is read in between
|
|
9
9
|
const POLL_WAIT = '1s';
|
|
10
|
+
// a cancelled run waits this long for the server to take the cancel, before it gives up
|
|
11
|
+
const CANCEL_WAIT_MS = 10000;
|
|
10
12
|
class SandboxClientError extends Error {
|
|
11
13
|
constructor(message, status) {
|
|
12
14
|
super(message);
|
|
@@ -14,6 +16,48 @@ class SandboxClientError extends Error {
|
|
|
14
16
|
}
|
|
15
17
|
}
|
|
16
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;
|
|
17
61
|
/**
|
|
18
62
|
* A caller's sandbox on a hammerkit server, over its HTTP API: the raw read, write and
|
|
19
63
|
* stat that harnesses plug their own tools into, the typed endpoints for clients without
|
|
@@ -32,6 +76,10 @@ class SandboxClient {
|
|
|
32
76
|
get id() {
|
|
33
77
|
return this.sandbox.id;
|
|
34
78
|
}
|
|
79
|
+
// the tasks of the published version that run on the sandbox
|
|
80
|
+
tasks() {
|
|
81
|
+
return listPublishedTasks(this.connection, this.build.hash);
|
|
82
|
+
}
|
|
35
83
|
static async create(connection, repo, ref) {
|
|
36
84
|
return json(await call(connection, 'POST', '/v1/sandboxes', { body: { repo, ref } }));
|
|
37
85
|
}
|
|
@@ -113,80 +161,92 @@ class SandboxClient {
|
|
|
113
161
|
runOptions.args = options.args;
|
|
114
162
|
}
|
|
115
163
|
if (options.timeoutSeconds) {
|
|
116
|
-
|
|
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`;
|
|
117
166
|
}
|
|
118
|
-
const submitted = await
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
truncated = page.truncated;
|
|
138
|
-
for (const line of page.lines) {
|
|
139
|
-
lines.push(line.text);
|
|
140
|
-
options.onLine?.(line.text);
|
|
141
|
-
from = line.line + 1;
|
|
142
|
-
}
|
|
143
|
-
if (!page.next) {
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
167
|
+
const submitted = await submitRun(this.connection, {
|
|
168
|
+
definition: this.build.hash,
|
|
169
|
+
tasks: [task],
|
|
170
|
+
source: { type: 'sandbox', id: this.id, ...(options.check ? { check: true } : {}) },
|
|
171
|
+
options: runOptions,
|
|
172
|
+
});
|
|
173
|
+
const followed = await followRun(this.connection, submitted, {
|
|
174
|
+
task,
|
|
175
|
+
signal: options.signal,
|
|
176
|
+
onLine: options.onLine ? (line) => options.onLine?.(line.text) : undefined,
|
|
177
|
+
onWaiting: options.onWaiting,
|
|
178
|
+
});
|
|
179
|
+
const result = followed.run.results?.[task];
|
|
180
|
+
return {
|
|
181
|
+
run: followed.run,
|
|
182
|
+
result,
|
|
183
|
+
output: followed.lines.map((line) => line.text).join('\n'),
|
|
184
|
+
truncated: followed.truncated,
|
|
185
|
+
exitCode: exitCodeOf(result),
|
|
147
186
|
};
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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`);
|
|
180
225
|
}
|
|
181
|
-
|
|
182
|
-
|
|
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}`);
|
|
183
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
|
+
};
|
|
184
241
|
}
|
|
185
242
|
/**
|
|
186
243
|
* A shell command on the sandbox: the shell task of the build file with the command as
|
|
187
244
|
* its argument. `cwd` is a directory of the sandbox, `''` for its root.
|
|
188
245
|
*/
|
|
189
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
|
+
}
|
|
190
250
|
// on its own line: `cd dir && a; b` would run b in the root when dir is missing
|
|
191
251
|
const script = cwd && cwd !== '.' ? `cd -- ${quotePosix(cwd)} || exit 1\n${command}` : command;
|
|
192
252
|
// a shell task in a container without `mounts: [.]` runs in an empty directory: the
|
|
@@ -245,6 +305,88 @@ function oneAtATime() {
|
|
|
245
305
|
};
|
|
246
306
|
}
|
|
247
307
|
exports.oneAtATime = oneAtATime;
|
|
308
|
+
// a run that is over, its state does not change any more
|
|
309
|
+
exports.FINAL_RUN_STATES = ['succeeded', 'failed', 'cancelled', 'rejected'];
|
|
310
|
+
// Submits a run, as `POST /v1/runs` takes it, and answers it as the server queued it or
|
|
311
|
+
// holds it for an approval.
|
|
312
|
+
async function submitRun(connection, request) {
|
|
313
|
+
return json(await call(connection, 'POST', '/v1/runs', { body: request }));
|
|
314
|
+
}
|
|
315
|
+
exports.submitRun = submitRun;
|
|
316
|
+
/**
|
|
317
|
+
* Follows a run to its end: its log as it grows, and its state. A signal cancels the run
|
|
318
|
+
* on the server and fails the follow.
|
|
319
|
+
*/
|
|
320
|
+
async function followRun(connection, submitted, options = {}) {
|
|
321
|
+
const path = `/v1/runs/${encodeURIComponent(submitted.id)}`;
|
|
322
|
+
let cancelling;
|
|
323
|
+
const cancel = () => {
|
|
324
|
+
cancelling ?? (cancelling = call(connection, 'POST', `${path}/cancel`).catch(() => undefined));
|
|
325
|
+
};
|
|
326
|
+
options.signal?.addEventListener('abort', cancel, { once: true });
|
|
327
|
+
const lines = [];
|
|
328
|
+
let from = 1;
|
|
329
|
+
let truncated = false;
|
|
330
|
+
const add = (line) => {
|
|
331
|
+
lines.push(line);
|
|
332
|
+
options.onLine?.(line);
|
|
333
|
+
};
|
|
334
|
+
const readLog = async () => {
|
|
335
|
+
for (;;) {
|
|
336
|
+
const page = await json(await call(connection, 'GET', `${path}/logs`, { query: { task: options.task, from, limit: 1000 } }));
|
|
337
|
+
truncated = page.truncated;
|
|
338
|
+
for (const line of page.lines) {
|
|
339
|
+
add(line);
|
|
340
|
+
from = line.line + 1;
|
|
341
|
+
}
|
|
342
|
+
if (!page.next) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
try {
|
|
348
|
+
let run = submitted;
|
|
349
|
+
let waitingSaid = false;
|
|
350
|
+
for (;;) {
|
|
351
|
+
if (options.signal?.aborted) {
|
|
352
|
+
// the process may exit after this, so the cancel has to reach the server first
|
|
353
|
+
cancel();
|
|
354
|
+
let timer;
|
|
355
|
+
await Promise.race([cancelling, new Promise((resolve) => (timer = setTimeout(resolve, CANCEL_WAIT_MS)))]);
|
|
356
|
+
clearTimeout(timer);
|
|
357
|
+
throw new SandboxClientError('the run was cancelled');
|
|
358
|
+
}
|
|
359
|
+
if (run.state === 'awaiting-approval' && !waitingSaid) {
|
|
360
|
+
waitingSaid = true;
|
|
361
|
+
options.onWaiting?.(run);
|
|
362
|
+
}
|
|
363
|
+
await readLog();
|
|
364
|
+
if (exports.FINAL_RUN_STATES.includes(run.state) || (options.untilUp && run.state === 'up')) {
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
run = await json(await call(connection, 'GET', path, { query: { wait: POLL_WAIT } }));
|
|
368
|
+
}
|
|
369
|
+
if (truncated) {
|
|
370
|
+
// the end of a long log is kept in the results
|
|
371
|
+
const tasks = options.task !== undefined ? [options.task] : Object.keys(run.results ?? {});
|
|
372
|
+
for (const task of tasks) {
|
|
373
|
+
const result = run.results?.[task];
|
|
374
|
+
if (!result) {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const said = options.task !== undefined ? 'the last lines were' : `the last lines of ${task} were`;
|
|
378
|
+
for (const text of [`… the server cut the log off at its limit, ${said}:`, ...result.lastLog]) {
|
|
379
|
+
add({ line: 0, task, stream: 'stdout', text });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return { run, lines, truncated };
|
|
384
|
+
}
|
|
385
|
+
finally {
|
|
386
|
+
options.signal?.removeEventListener('abort', cancel);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
exports.followRun = followRun;
|
|
248
390
|
function exitCodeOf(result) {
|
|
249
391
|
if (!result) {
|
|
250
392
|
return null;
|
|
@@ -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
|