hammerkit 1.27.0-next.3 → 1.27.0-next.31

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.
Files changed (134) hide show
  1. package/build.schema.json +188 -0
  2. package/dist/cache/backends/local-cache-backend.js +6 -0
  3. package/dist/cache/backends/registry/registry-client.js +1 -0
  4. package/dist/cache/backends/registry-cache-backend.js +2 -2
  5. package/dist/cache/explain.js +17 -0
  6. package/dist/cli.js +2 -0
  7. package/dist/dashboard/assets/index-CjnKm5-3.css +2 -0
  8. package/dist/dashboard/assets/index-zPXa4klZ.js +13 -0
  9. package/dist/dashboard/favicon.svg +1 -0
  10. package/dist/dashboard/index.html +15 -0
  11. package/dist/docker/item-network.js +13 -5
  12. package/dist/executer/deadline.js +17 -3
  13. package/dist/executer/deferred-restore.js +36 -0
  14. package/dist/executer/docker-service.js +30 -8
  15. package/dist/executer/docker-task.js +21 -1
  16. package/dist/executer/event-cache.js +20 -2
  17. package/dist/executer/execute-work-service.js +2 -0
  18. package/dist/executer/execute-work-task.js +10 -3
  19. package/dist/executer/execution-steps.js +25 -4
  20. package/dist/executer/reset-work-tree.js +4 -0
  21. package/dist/executer/scheduler/enqueue-next.js +57 -6
  22. package/dist/executer/scheduler/state-key.js +42 -5
  23. package/dist/executer/watch-loop.js +4 -1
  24. package/dist/harness/connect.js +20 -6
  25. package/dist/harness/mcp-command.js +22 -6
  26. package/dist/harness/mcp.js +63 -114
  27. package/dist/harness/opencode.js +158 -50
  28. package/dist/harness/pi.js +29 -0
  29. package/dist/harness/sandbox-client.js +168 -17
  30. package/dist/harness/service-tools.js +141 -0
  31. package/dist/harness/task-tools.js +221 -0
  32. package/dist/kubernetes/apply.js +27 -16
  33. package/dist/kubernetes/await-running-state.js +59 -13
  34. package/dist/kubernetes/claim-layout.js +71 -0
  35. package/dist/kubernetes/cluster-state.js +245 -0
  36. package/dist/kubernetes/ensure-http-route.js +1 -1
  37. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +35 -12
  38. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +1 -1
  39. package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
  40. package/dist/kubernetes/ensure-persistent-data.js +30 -20
  41. package/dist/kubernetes/follow-pod-logs.js +182 -0
  42. package/dist/kubernetes/get-service-ip.js +2 -2
  43. package/dist/kubernetes/job-placement.js +36 -12
  44. package/dist/kubernetes/job-secrets.js +33 -19
  45. package/dist/kubernetes/kept-claims.js +22 -10
  46. package/dist/kubernetes/kubernetes-instance.js +1 -0
  47. package/dist/kubernetes/network-policy.js +85 -27
  48. package/dist/kubernetes/remove-persistent-data.js +3 -1
  49. package/dist/kubernetes/resources.js +25 -3
  50. package/dist/kubernetes/restore-kubernetes-data.js +9 -3
  51. package/dist/kubernetes/sandbox-runner.js +628 -0
  52. package/dist/kubernetes/secrets.js +7 -11
  53. package/dist/kubernetes/volumes.js +6 -3
  54. package/dist/main.js +2 -0
  55. package/dist/network/egress-proxy.js +45 -6
  56. package/dist/optimizer/calculate-checksum.js +3 -2
  57. package/dist/optimizer/check-key.js +94 -0
  58. package/dist/optimizer/check-keys.js +64 -0
  59. package/dist/optimizer/get-work-cache-stats.js +17 -3
  60. package/dist/optimizer/work-task-cache-description.js +51 -1
  61. package/dist/planner/utils/append-work-dependencies.js +10 -5
  62. package/dist/planner/utils/append-work-service.js +19 -1
  63. package/dist/planner/utils/append-work-task.js +18 -3
  64. package/dist/planner/utils/get-work-runtime.js +6 -1
  65. package/dist/planner/utils/normalize-path.js +19 -1
  66. package/dist/planner/utils/parse-work-mount.js +7 -1
  67. package/dist/planner/utils/parse-work-port.js +4 -1
  68. package/dist/planner/utils/task-arguments.js +3 -0
  69. package/dist/planner/validate.js +22 -0
  70. package/dist/planner/work-network.js +3 -1
  71. package/dist/planner/work-runtime-aggregate.js +32 -0
  72. package/dist/planner/work-runtime-docker.js +2 -1
  73. package/dist/planner/work-runtime-kubernetes.js +124 -48
  74. package/dist/planner/work-runtime-local.js +2 -1
  75. package/dist/planner/work-task.js +6 -1
  76. package/dist/program.js +31 -10
  77. package/dist/remote/device-login.js +7 -2
  78. package/dist/remote/host-config.js +69 -3
  79. package/dist/remote/remote-commands.js +33 -7
  80. package/dist/remote/remote-status.js +112 -0
  81. package/dist/remote/remote-work-commands.js +545 -45
  82. package/dist/remote/server-client.js +15 -2
  83. package/dist/remote/token-source.js +64 -26
  84. package/dist/run-program.js +9 -0
  85. package/dist/schema/build-file-base-task-schema.js +8 -1
  86. package/dist/schema/build-file-container-service-schema.js +12 -0
  87. package/dist/schema/build-file-container-task-schema.js +16 -1
  88. package/dist/schema/build-file-include-schema.js +18 -4
  89. package/dist/schema/build-file-kubernetes-service-schema.js +2 -0
  90. package/dist/schema/build-file-task-schema.js +5 -1
  91. package/dist/schema/capabilities-schema.js +56 -0
  92. package/dist/schema/reference-parser.js +120 -11
  93. package/dist/schema/schema-parser.js +92 -41
  94. package/dist/schema/work-scope-parser.js +33 -10
  95. package/dist/secrets/command-secret-provider.js +3 -1
  96. package/dist/secrets/github-app-token-command.js +3 -1
  97. package/dist/secrets/secret-catalog.js +43 -19
  98. package/dist/server/accounts-api.js +4 -1
  99. package/dist/server/approvals-api.js +1 -1
  100. package/dist/server/capacity-format.js +36 -0
  101. package/dist/server/capacity.js +300 -0
  102. package/dist/server/checks-api.js +83 -0
  103. package/dist/server/checks.js +116 -0
  104. package/dist/server/dashboard.js +196 -0
  105. package/dist/server/definitions-api.js +66 -3
  106. package/dist/server/endpoints.js +793 -0
  107. package/dist/server/executor.js +57 -5
  108. package/dist/server/kubernetes-backend.js +392 -65
  109. package/dist/server/kubernetes-sandbox-storage.js +48 -3
  110. package/dist/server/oauth.js +12 -1
  111. package/dist/server/overview.js +271 -0
  112. package/dist/server/plan.js +188 -103
  113. package/dist/server/policy.js +34 -2
  114. package/dist/server/proposals-api.js +17 -8
  115. package/dist/server/repo-access.js +5 -0
  116. package/dist/server/run-logs.js +4 -0
  117. package/dist/server/run-plan.js +50 -19
  118. package/dist/server/run-request.js +58 -3
  119. package/dist/server/run-worker.js +245 -12
  120. package/dist/server/runs.js +532 -35
  121. package/dist/server/sandbox-pod.js +28 -0
  122. package/dist/server/sandbox-storage.js +23 -0
  123. package/dist/server/sandboxes-api.js +10 -4
  124. package/dist/server/sandboxes.js +8 -0
  125. package/dist/server/server-command.js +39 -20
  126. package/dist/server/server-config.js +277 -14
  127. package/dist/server/server.js +402 -33
  128. package/dist/server/service-runs.js +82 -0
  129. package/dist/server/timings.js +132 -0
  130. package/dist/server/wait-reason.js +21 -0
  131. package/dist/utils/abort-on-signals.js +46 -6
  132. package/dist/utils/tar.js +36 -0
  133. package/dist/utils/terminal-prompt.js +53 -0
  134. package/package.json +8 -2
@@ -4,6 +4,7 @@ exports.hammerkitPiExtension = exports.createPiOperations = void 0;
4
4
  const path_1 = require("path");
5
5
  const connect_1 = require("./connect");
6
6
  const format_1 = require("./format");
7
+ const service_tools_1 = require("./service-tools");
7
8
  const sandbox_client_1 = require("./sandbox-client");
8
9
  const IMAGE_TYPES = {
9
10
  '.png': 'image/png',
@@ -178,6 +179,34 @@ function hammerkitPiExtension(library, settings = {}) {
178
179
  return { content: [{ type: 'text', text: `pushed ${pushed.commit} to ${pushed.branch}` }], details: pushed };
179
180
  },
180
181
  });
182
+ for (const tool of service_tools_1.SERVICE_TOOLS) {
183
+ register({
184
+ name: tool.name,
185
+ label: tool.name,
186
+ description: tool.description,
187
+ promptSnippet: tool.title,
188
+ parameters: (0, service_tools_1.serviceToolSchema)(tool),
189
+ execute: async (_id, params, signal) => {
190
+ const text = await tool.call(await client(), params, signal ?? new AbortController().signal);
191
+ return { content: [{ type: 'text', text }], details: undefined };
192
+ },
193
+ });
194
+ }
195
+ register({
196
+ name: 'check',
197
+ label: 'check',
198
+ description: sandbox_client_1.CHECK_DESCRIPTION,
199
+ promptSnippet: 'Run a task as a check that CI verifies after the push',
200
+ parameters: {
201
+ type: 'object',
202
+ properties: { task: { type: 'string', description: 'The name of the task' } },
203
+ required: ['task'],
204
+ },
205
+ execute: async (_id, params, signal) => {
206
+ const text = await (0, sandbox_client_1.runCheck)(await client(), params.task, signal);
207
+ return { content: [{ type: 'text', text }], details: { task: params.task } };
208
+ },
209
+ });
181
210
  pi.on('session_start', async (_event, ctx) => {
182
211
  try {
183
212
  const sandbox = await client();
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.quotePosix = exports.fetchFailure = exports.toSandboxPath = exports.checkSandboxPath = exports.exitCodeOf = exports.followRun = exports.submitRun = exports.FINAL_RUN_STATES = exports.oneAtATime = exports.SandboxClient = exports.SandboxClientError = void 0;
3
+ exports.quotePosix = exports.fetchFailure = exports.toSandboxPath = exports.checkSandboxPath = exports.exitCodeOf = exports.readRunLog = exports.getRun = exports.followRun = exports.submitRun = exports.FINAL_RUN_STATES = exports.oneAtATime = exports.SandboxClient = exports.listPublishedTasks = exports.runCheck = exports.CHECK_DESCRIPTION = exports.SandboxClientError = void 0;
4
4
  const path_1 = require("path");
5
5
  const token_source_1 = require("../remote/token-source");
6
6
  // a write carries up to 32 MiB
@@ -16,6 +16,48 @@ class SandboxClientError extends Error {
16
16
  }
17
17
  }
18
18
  exports.SandboxClientError = SandboxClientError;
19
+ // What the agent is told of a check, on each harness.
20
+ exports.CHECK_DESCRIPTION = 'Run a task as a check before you push, such as test or e2e: it runs on a copy of the files a push ' +
21
+ 'would commit (what .gitignore leaves out is not there, so dependencies are installed by the tasks ' +
22
+ 'the build file names), and when it passes, CI verifies it for that commit instead of running it. ' +
23
+ 'Push without changing files afterwards, or the check no longer counts for the files that changed.';
24
+ // A check, run to its end, in a few lines: how it went and the last lines of a failure.
25
+ async function runCheck(client, task, signal) {
26
+ const ran = await client.runTask(task, { check: true, signal });
27
+ const result = ran.result;
28
+ const lines = [
29
+ result?.status === 'succeeded' || result?.status === 'cached'
30
+ ? `check ${task} passed (run ${ran.run.id}${result.status === 'cached' ? ', from the cache' : ''}): CI can verify it for a push of these files`
31
+ : `check ${task} did not pass (run ${ran.run.id}: ${result?.status ?? ran.run.state}${result?.exitCode !== undefined ? `, exit code ${result.exitCode}` : ''})`,
32
+ ];
33
+ const error = result?.error ?? ran.run.error;
34
+ if (error) {
35
+ lines.push(`error: ${error}`);
36
+ }
37
+ if (result && result.status !== 'succeeded' && result.status !== 'cached' && result.lastLog.length > 0) {
38
+ lines.push('last lines:', ...result.lastLog);
39
+ }
40
+ return lines.join('\n');
41
+ }
42
+ exports.runCheck = runCheck;
43
+ /**
44
+ * The tasks of a published version, without its services, as `GET /v1/definitions/<hash>`
45
+ * answers them.
46
+ */
47
+ async function listPublishedTasks(connection, hash) {
48
+ const version = await json(await call(connection, 'GET', `/v1/definitions/${hash}`));
49
+ return version.tasks.map((name) => {
50
+ const plan = version.plan?.[name];
51
+ const args = version.arguments?.[name];
52
+ return {
53
+ name,
54
+ ...(plan?.description ? { description: plan.description } : {}),
55
+ ...(plan && plan.command !== undefined ? { command: plan.command } : {}),
56
+ ...(args !== undefined ? { args } : {}),
57
+ };
58
+ });
59
+ }
60
+ exports.listPublishedTasks = listPublishedTasks;
19
61
  /**
20
62
  * A caller's sandbox on a hammerkit server, over its HTTP API: the raw read, write and
21
63
  * stat that harnesses plug their own tools into, the typed endpoints for clients without
@@ -34,6 +76,10 @@ class SandboxClient {
34
76
  get id() {
35
77
  return this.sandbox.id;
36
78
  }
79
+ // the tasks of the published version that run on the sandbox
80
+ tasks() {
81
+ return listPublishedTasks(this.connection, this.build.hash);
82
+ }
37
83
  static async create(connection, repo, ref) {
38
84
  return json(await call(connection, 'POST', '/v1/sandboxes', { body: { repo, ref } }));
39
85
  }
@@ -115,12 +161,13 @@ class SandboxClient {
115
161
  runOptions.args = options.args;
116
162
  }
117
163
  if (options.timeoutSeconds) {
118
- runOptions.timeout = `${Math.max(1, Math.ceil(options.timeoutSeconds))}s`;
164
+ // a cap: it also shortens a shell task that sets a longer timeout of its own
165
+ runOptions.maxTimeout = `${Math.max(1, Math.ceil(options.timeoutSeconds))}s`;
119
166
  }
120
167
  const submitted = await submitRun(this.connection, {
121
168
  definition: this.build.hash,
122
169
  tasks: [task],
123
- source: { type: 'sandbox', id: this.id },
170
+ source: { type: 'sandbox', id: this.id, ...(options.check ? { check: true } : {}) },
124
171
  options: runOptions,
125
172
  });
126
173
  const followed = await followRun(this.connection, submitted, {
@@ -138,11 +185,68 @@ class SandboxClient {
138
185
  exitCode: exitCodeOf(result),
139
186
  };
140
187
  }
188
+ /**
189
+ * Starts services of the published build file on the sandbox, or on a branch of its
190
+ * repository, and follows them until they are up: the run then holds their endpoints
191
+ * until its ttl ends or it is stopped. A signal cancels the run.
192
+ */
193
+ async up(request, options = {}) {
194
+ if (options.signal?.aborted) {
195
+ throw new SandboxClientError('the run was cancelled');
196
+ }
197
+ const submitted = await submitRun(this.connection, {
198
+ definition: this.build.hash,
199
+ services: request.services,
200
+ endpoints: request.endpoints ?? [],
201
+ ...(request.ttl ? { ttl: request.ttl } : {}),
202
+ ...(request.sleepTtl ? { sleepTtl: request.sleepTtl } : {}),
203
+ ...(request.name ? { name: request.name } : {}),
204
+ source: request.branch
205
+ ? { type: 'ref', repo: this.sandbox.repo, ref: request.branch }
206
+ : { type: 'sandbox', id: this.id },
207
+ });
208
+ return followRun(this.connection, submitted, { signal: options.signal, untilUp: true });
209
+ }
210
+ // Stops a run: a service run takes its services and endpoints down.
211
+ async stop(id) {
212
+ return json(await call(this.connection, 'POST', `/v1/runs/${encodeURIComponent(id)}/cancel`));
213
+ }
214
+ /**
215
+ * A page of an endpoint of a service run, as the caller sees it: only at an address the
216
+ * server gave the run, so the token goes to the server and nowhere else.
217
+ */
218
+ async fetchEndpoint(id, path, service, signal) {
219
+ const run = await this.getRun(id);
220
+ const endpoint = run.up?.endpoints.find((candidate) => service === undefined || candidate.service === service);
221
+ if (!endpoint) {
222
+ throw new SandboxClientError(run.up
223
+ ? `${id} has no endpoint${service ? ` of ${service}` : ''}, it has ${run.up.endpoints.map((candidate) => `${candidate.service}:${candidate.port}`).join(', ') || 'none'}`
224
+ : `${id} is not a service run`);
225
+ }
226
+ const url = new URL(path.startsWith('/') ? path : `/${path}`, endpoint.url);
227
+ if (url.origin !== new URL(endpoint.url).origin) {
228
+ throw new SandboxClientError(`${path} is not a path of ${endpoint.url}`);
229
+ }
230
+ const response = await fetch(url, {
231
+ headers: { 'hammerkit-authorization': `Bearer ${await this.connection.token()}` },
232
+ redirect: 'manual',
233
+ signal: AbortSignal.any([AbortSignal.timeout(TIMEOUT_MS), ...(signal ? [signal] : [])]),
234
+ });
235
+ return {
236
+ url: url.toString(),
237
+ status: response.status,
238
+ type: response.headers.get('content-type') ?? '',
239
+ body: await response.text(),
240
+ };
241
+ }
141
242
  /**
142
243
  * A shell command on the sandbox: the shell task of the build file with the command as
143
244
  * its argument. `cwd` is a directory of the sandbox, `''` for its root.
144
245
  */
145
246
  async exec(command, cwd, options = {}) {
247
+ if (!this.build.shell) {
248
+ throw new SandboxClientError('there is no shell on this sandbox (HAMMERKIT_SHELL is empty), run a task instead');
249
+ }
146
250
  // on its own line: `cd dir && a; b` would run b in the root when dir is missing
147
251
  const script = cwd && cwd !== '.' ? `cd -- ${quotePosix(cwd)} || exit 1\n${command}` : command;
148
252
  // a shell task in a container without `mounts: [.]` runs in an empty directory: the
@@ -219,7 +323,9 @@ async function followRun(connection, submitted, options = {}) {
219
323
  const cancel = () => {
220
324
  cancelling ?? (cancelling = call(connection, 'POST', `${path}/cancel`).catch(() => undefined));
221
325
  };
222
- options.signal?.addEventListener('abort', cancel, { once: true });
326
+ if (!options.detach) {
327
+ options.signal?.addEventListener('abort', cancel, { once: true });
328
+ }
223
329
  const lines = [];
224
330
  let from = 1;
225
331
  let truncated = false;
@@ -244,6 +350,9 @@ async function followRun(connection, submitted, options = {}) {
244
350
  let run = submitted;
245
351
  let waitingSaid = false;
246
352
  for (;;) {
353
+ if (options.signal?.aborted && options.detach) {
354
+ break;
355
+ }
247
356
  if (options.signal?.aborted) {
248
357
  // the process may exit after this, so the cancel has to reach the server first
249
358
  cancel();
@@ -257,24 +366,13 @@ async function followRun(connection, submitted, options = {}) {
257
366
  options.onWaiting?.(run);
258
367
  }
259
368
  await readLog();
260
- if (exports.FINAL_RUN_STATES.includes(run.state)) {
369
+ if (exports.FINAL_RUN_STATES.includes(run.state) || (options.untilUp && run.state === 'up')) {
261
370
  break;
262
371
  }
263
372
  run = await json(await call(connection, 'GET', path, { query: { wait: POLL_WAIT } }));
264
373
  }
265
374
  if (truncated) {
266
- // the end of a long log is kept in the results
267
- const tasks = options.task !== undefined ? [options.task] : Object.keys(run.results ?? {});
268
- for (const task of tasks) {
269
- const result = run.results?.[task];
270
- if (!result) {
271
- continue;
272
- }
273
- const said = options.task !== undefined ? 'the last lines were' : `the last lines of ${task} were`;
274
- for (const text of [`… the server cut the log off at its limit, ${said}:`, ...result.lastLog]) {
275
- add({ line: 0, task, stream: 'stdout', text });
276
- }
277
- }
375
+ truncatedEnd(run, options.task).forEach(add);
278
376
  }
279
377
  return { run, lines, truncated };
280
378
  }
@@ -283,6 +381,59 @@ async function followRun(connection, submitted, options = {}) {
283
381
  }
284
382
  }
285
383
  exports.followRun = followRun;
384
+ // The end of a log the server cut off at its limit: the last lines of each task, which its
385
+ // results keep.
386
+ function truncatedEnd(run, only) {
387
+ const lines = [];
388
+ const tasks = only !== undefined ? [only] : Object.keys(run.results ?? {});
389
+ for (const task of tasks) {
390
+ const result = run.results?.[task];
391
+ if (!result) {
392
+ continue;
393
+ }
394
+ const said = only !== undefined ? 'the last lines were' : `the last lines of ${task} were`;
395
+ for (const text of [`… the server cut the log off at its limit, ${said}:`, ...result.lastLog]) {
396
+ lines.push({ line: 0, task, stream: 'stdout', text });
397
+ }
398
+ }
399
+ return lines;
400
+ }
401
+ // A run as the server keeps it, or null when it keeps none the caller may see.
402
+ async function getRun(connection, id) {
403
+ const response = await call(connection, 'GET', `/v1/runs/${encodeURIComponent(id)}`, { missing: true });
404
+ return response.status === 404 ? null : json(response);
405
+ }
406
+ exports.getRun = getRun;
407
+ /**
408
+ * Reads the log of a run as it is now, every line or the last `tail` of them, of one task or
409
+ * of all: also of a run that ended, as long as the server keeps it.
410
+ */
411
+ async function readRunLog(connection, run, query, onLine) {
412
+ const path = `/v1/runs/${encodeURIComponent(run.id)}/logs`;
413
+ if (query.tail !== undefined) {
414
+ const page = await json(await call(connection, 'GET', path, { query: { task: query.task, tail: query.tail } }));
415
+ page.lines.forEach(onLine);
416
+ return { truncated: page.truncated };
417
+ }
418
+ let from = 1;
419
+ let truncated = false;
420
+ for (;;) {
421
+ const page = await json(await call(connection, 'GET', path, { query: { task: query.task, from, limit: 1000 } }));
422
+ truncated = page.truncated;
423
+ for (const line of page.lines) {
424
+ onLine(line);
425
+ from = line.line + 1;
426
+ }
427
+ if (!page.next) {
428
+ break;
429
+ }
430
+ }
431
+ if (truncated) {
432
+ truncatedEnd(run, query.task).forEach(onLine);
433
+ }
434
+ return { truncated };
435
+ }
436
+ exports.readRunLog = readRunLog;
286
437
  function exitCodeOf(result) {
287
438
  if (!result) {
288
439
  return null;
@@ -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
@@ -0,0 +1,221 @@
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
+ const sandbox_client_1 = require("./sandbox-client");
5
+ // the most tasks a description lists, and of a task's description or command it shows
6
+ const MAX_LISTED = 40;
7
+ const MAX_DESCRIPTION_CHARS = 200;
8
+ const MAX_COMMAND_CHARS = 100;
9
+ // how long a client waits for the tasks of the build file when it starts, it goes on without them
10
+ exports.LIST_TASKS_MS = 15000;
11
+ // The tasks of the published version, or why they could not be read within `ms`.
12
+ async function readTaskListing(list, ms = exports.LIST_TASKS_MS) {
13
+ let timer;
14
+ const timeout = new Promise((_, reject) => {
15
+ timer = setTimeout(() => reject(new Error(`no answer within ${ms / 1000} s`)), ms);
16
+ timer.unref?.();
17
+ });
18
+ try {
19
+ return await Promise.race([list(), timeout]);
20
+ }
21
+ catch (e) {
22
+ return { error: e instanceof Error ? e.message : String(e) };
23
+ }
24
+ finally {
25
+ clearTimeout(timer);
26
+ }
27
+ }
28
+ exports.readTaskListing = readTaskListing;
29
+ // the hint at the end of a cut output or a result, for the model
30
+ function readLogHint(run, task) {
31
+ return `read more of the log with run_logs { "run": "${run}", "task": "${task}" }`;
32
+ }
33
+ exports.readLogHint = readLogHint;
34
+ // A run of a task in a few lines: how it went, why it ran, its last lines.
35
+ function describeRun(task, ran) {
36
+ const result = ran.result;
37
+ const lines = [`run ${ran.run.id}: ${ran.run.state}`];
38
+ if (result) {
39
+ lines.push(`task ${task}: ${result.status}` +
40
+ (result.exitCode !== undefined ? `, exit code ${result.exitCode}` : '') +
41
+ (result.durationMs !== undefined ? `, ${result.durationMs} ms` : '') +
42
+ (result.cache?.hit ? ', from the cache' : ''));
43
+ if (result.error) {
44
+ lines.push(`error: ${result.error}`);
45
+ }
46
+ if (result.cache?.causes?.length) {
47
+ lines.push(`ran because: ${result.cache.causes.join('; ')}`);
48
+ }
49
+ }
50
+ else if (ran.run.error) {
51
+ lines.push(`error: ${ran.run.error}`);
52
+ }
53
+ if (result && result.lastLog.length > 0) {
54
+ lines.push('last lines:', ...result.lastLog);
55
+ }
56
+ lines.push(readLogHint(ran.run.id, task));
57
+ return lines.join('\n');
58
+ }
59
+ exports.describeRun = describeRun;
60
+ /**
61
+ * The tasks for the model, one line each: what it is for (or its command, when the build
62
+ * file says nothing) and whether it takes arguments. `shell` is left out, the shell tool runs it.
63
+ */
64
+ function describeTasks(listing, shell) {
65
+ if (listing === undefined) {
66
+ return '';
67
+ }
68
+ if (!Array.isArray(listing)) {
69
+ return `The tasks of the build file could not be listed (${listing.error}); name one as the build file does.`;
70
+ }
71
+ const tasks = listing.filter((task) => !shell || task.name !== shell);
72
+ if (tasks.length === 0) {
73
+ return 'The published build file has no tasks to run.';
74
+ }
75
+ const lines = tasks.slice(0, MAX_LISTED).map((task) => {
76
+ const parts = [`- ${task.name}`];
77
+ if (task.description) {
78
+ parts.push(`: ${cut(oneLine(task.description), MAX_DESCRIPTION_CHARS)}`);
79
+ }
80
+ else if (task.command) {
81
+ parts.push(`: runs \`${cut(oneLine(task.command), MAX_COMMAND_CHARS)}\``);
82
+ }
83
+ if (task.args === null) {
84
+ parts.push(' (takes args)');
85
+ }
86
+ else if (task.args !== undefined || task.command === null) {
87
+ parts.push(' (takes no args)');
88
+ }
89
+ return parts.join('');
90
+ });
91
+ if (tasks.length > MAX_LISTED) {
92
+ lines.push(`- and ${tasks.length - MAX_LISTED} more`);
93
+ }
94
+ return ['The tasks of the published build file:', ...lines].join('\n');
95
+ }
96
+ exports.describeTasks = describeTasks;
97
+ function oneLine(text) {
98
+ return text.replace(/\s+/g, ' ').trim();
99
+ }
100
+ function cut(text, max) {
101
+ return text.length > max ? `${text.slice(0, max)}…` : text;
102
+ }
103
+ /**
104
+ * `run_task`, `run_logs` and `check`, with the tasks the description of `run_task` lists. Without a
105
+ * listing it names none, as the MCP tools before a version could be read.
106
+ */
107
+ function createTaskTools(listing, shell) {
108
+ const tasks = describeTasks(listing, shell);
109
+ return [
110
+ {
111
+ name: 'run_task',
112
+ title: 'Run a task',
113
+ description: 'Run a task of the published build file on the sandbox, such as test or build, and answer how it went: ' +
114
+ 'its status, exit code, why it was not taken from the cache, and its last lines. args are appended to ' +
115
+ 'its last command, for example a test file. It runs in the container, with the network and the ' +
116
+ 'options the build file gives the task.' +
117
+ (tasks ? `\n\n${tasks}` : ''),
118
+ parameters: {
119
+ task: { type: 'string', description: 'The name of the task' },
120
+ args: { type: 'string[]', description: 'Arguments for its last command' },
121
+ },
122
+ required: ['task'],
123
+ async call(client, args, signal) {
124
+ const ran = await client.runTask(args.task, { args: args.args?.length ? args.args : undefined, signal });
125
+ return describeRun(args.task, ran);
126
+ },
127
+ },
128
+ {
129
+ name: 'run_logs',
130
+ title: 'Read the log of a run',
131
+ description: 'Read the log of a run as numbered lines: one task, the last lines (tail), a range (from, limit) or ' +
132
+ 'the lines that contain a text (grep, a fixed string) with lines of context around them.',
133
+ parameters: {
134
+ run: { type: 'string', description: 'The id of the run' },
135
+ task: { type: 'string', description: 'Only the lines of this task' },
136
+ tail: {
137
+ type: 'integer',
138
+ description: 'The last lines, or with grep the last matches',
139
+ minimum: 1,
140
+ maximum: 1000,
141
+ },
142
+ from: { type: 'integer', description: 'The first line to read', minimum: 1 },
143
+ limit: { type: 'integer', description: 'How many lines to read', minimum: 1, maximum: 1000 },
144
+ grep: { type: 'string', description: 'Only lines that contain this text' },
145
+ ignore_case: { type: 'boolean', description: 'Ignore case in grep' },
146
+ context: { type: 'integer', description: 'Lines before and after each match', minimum: 0, maximum: 20 },
147
+ },
148
+ required: ['run'],
149
+ async call(client, args) {
150
+ const page = await client.logs(args.run, {
151
+ task: args.task || undefined,
152
+ tail: args.tail || undefined,
153
+ from: args.from || undefined,
154
+ limit: args.limit || undefined,
155
+ grep: args.grep || undefined,
156
+ i: args.ignore_case || undefined,
157
+ context: args.context || undefined,
158
+ });
159
+ const lines = page.lines.map((line) => `${line.line}${line.match === false ? '-' : ':'} ${args.task ? '' : `[${line.task}] `}${line.text}`);
160
+ 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' : ''})`);
161
+ return lines.join('\n');
162
+ },
163
+ },
164
+ {
165
+ name: 'check',
166
+ title: 'Run a check',
167
+ description: sandbox_client_1.CHECK_DESCRIPTION,
168
+ parameters: { task: { type: 'string', description: 'The name of the task' } },
169
+ required: ['task'],
170
+ call(client, args, signal) {
171
+ return (0, sandbox_client_1.runCheck)(client, args.task, signal);
172
+ },
173
+ },
174
+ ];
175
+ }
176
+ exports.createTaskTools = createTaskTools;
177
+ // The parameters as a JSON schema, for MCP and opencode 2.
178
+ function taskToolSchema(tool) {
179
+ return {
180
+ type: 'object',
181
+ properties: Object.fromEntries(Object.entries(tool.parameters).map(([name, parameter]) => {
182
+ if (parameter.type === 'string[]') {
183
+ return [name, { type: 'array', items: { type: 'string' }, description: parameter.description }];
184
+ }
185
+ if (parameter.type === 'integer') {
186
+ const { maximum } = parameter;
187
+ return [
188
+ name,
189
+ {
190
+ type: 'integer',
191
+ description: parameter.description,
192
+ minimum: parameter.minimum,
193
+ ...(maximum !== undefined ? { maximum } : {}),
194
+ },
195
+ ];
196
+ }
197
+ return [name, { type: parameter.type, description: parameter.description }];
198
+ })),
199
+ required: tool.required,
200
+ };
201
+ }
202
+ exports.taskToolSchema = taskToolSchema;
203
+ // The parameters with the zod of opencode 1.
204
+ function taskToolArgs(tool, z) {
205
+ return Object.fromEntries(Object.entries(tool.parameters).map(([name, parameter]) => {
206
+ let type;
207
+ if (parameter.type === 'string[]') {
208
+ type = z.array(z.string());
209
+ }
210
+ else if (parameter.type === 'integer') {
211
+ type = z.number().int().min(parameter.minimum);
212
+ type = parameter.maximum !== undefined ? type.max(parameter.maximum) : type;
213
+ }
214
+ else {
215
+ type = parameter.type === 'boolean' ? z.boolean() : z.string();
216
+ }
217
+ return [name, (tool.required.includes(name) ? type : type.optional()).describe(parameter.description)];
218
+ }));
219
+ }
220
+ exports.taskToolArgs = taskToolArgs;
221
+ //# sourceMappingURL=task-tools.js.map