hammerkit 1.26.0 → 1.27.0-next.2

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 (118) hide show
  1. package/build.schema.json +53 -1
  2. package/dist/cache/backends/local-cache-backend.js +10 -2
  3. package/dist/cache/explain.js +13 -11
  4. package/dist/cli.js +3 -0
  5. package/dist/docker/item-network.js +471 -0
  6. package/dist/docker/pull.js +4 -2
  7. package/dist/docker/remove-orphaned-containers.js +13 -6
  8. package/dist/environment/replace-env-variables.js +8 -6
  9. package/dist/executer/await-completed-dependencies.js +2 -1
  10. package/dist/executer/check-readiness.js +2 -2
  11. package/dist/executer/docker-service.js +24 -13
  12. package/dist/executer/docker-task.js +18 -29
  13. package/dist/executer/event-cache.js +4 -0
  14. package/dist/executer/execute-command.js +18 -5
  15. package/dist/executer/execute-docker.js +4 -1
  16. package/dist/executer/execution-steps.js +33 -7
  17. package/dist/executer/get-docker-executor.js +14 -7
  18. package/dist/executer/scheduler/enqueue-next.js +17 -3
  19. package/dist/executer/watch-loop.js +2 -2
  20. package/dist/executer/watch-state-key.js +1 -1
  21. package/dist/file/within-root.js +42 -0
  22. package/dist/harness/connect.js +99 -0
  23. package/dist/harness/format.js +73 -0
  24. package/dist/harness/mcp-command.js +59 -0
  25. package/dist/harness/mcp.js +432 -0
  26. package/dist/harness/opencode.js +492 -0
  27. package/dist/harness/pi.js +217 -0
  28. package/dist/harness/sandbox-client.js +373 -0
  29. package/dist/index.js +33 -36
  30. package/dist/kubernetes/apply.js +31 -1
  31. package/dist/kubernetes/await-running-state.js +33 -1
  32. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +43 -21
  33. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
  34. package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
  35. package/dist/kubernetes/ensure-persistent-data.js +48 -7
  36. package/dist/kubernetes/get-service-ip.js +15 -1
  37. package/dist/kubernetes/job-placement.js +99 -0
  38. package/dist/kubernetes/job-secrets.js +306 -0
  39. package/dist/kubernetes/kept-claims.js +191 -0
  40. package/dist/kubernetes/kubernetes-instance.js +10 -2
  41. package/dist/kubernetes/network-gate.js +71 -0
  42. package/dist/kubernetes/network-policy.js +526 -0
  43. package/dist/kubernetes/resources.js +13 -2
  44. package/dist/kubernetes/restore-kubernetes-data.js +19 -4
  45. package/dist/kubernetes/secrets.js +2 -0
  46. package/dist/kubernetes/store-kubernetes-data.js +1 -1
  47. package/dist/kubernetes/volumes.js +100 -11
  48. package/dist/log.js +1 -1
  49. package/dist/main.js +41 -0
  50. package/dist/network/egress-proxy.js +368 -0
  51. package/dist/network/port-relay.js +35 -0
  52. package/dist/optimizer/get-work-cache-stats.js +16 -0
  53. package/dist/optimizer/work-task-cache-description.js +3 -2
  54. package/dist/planner/service-definition.js +5 -0
  55. package/dist/planner/utils/append-work-environment.js +2 -0
  56. package/dist/planner/utils/append-work-service.js +4 -0
  57. package/dist/planner/utils/append-work-task.js +17 -1
  58. package/dist/planner/utils/assert-item-paths.js +50 -0
  59. package/dist/planner/utils/hosted-volumes.js +35 -0
  60. package/dist/planner/work-network.js +193 -0
  61. package/dist/planner/work-runtime-docker.js +41 -0
  62. package/dist/planner/work-runtime-kubernetes.js +335 -102
  63. package/dist/program.js +23 -1
  64. package/dist/remote/annotations.js +103 -0
  65. package/dist/remote/bundle-text.js +24 -0
  66. package/dist/remote/bundle.js +9 -1
  67. package/dist/remote/remote-commands.js +4 -1
  68. package/dist/remote/remote-work-commands.js +182 -69
  69. package/dist/remote/server-client.js +18 -5
  70. package/dist/remote/source-manifest.js +15 -2
  71. package/dist/remote/token-source.js +67 -0
  72. package/dist/schema/build-file-base-task-schema.js +3 -0
  73. package/dist/schema/build-file-container-service-schema.js +6 -0
  74. package/dist/schema/build-file-container-task-schema.js +3 -0
  75. package/dist/schema/build-file-environment-schema.js +9 -0
  76. package/dist/schema/network-schema.js +78 -0
  77. package/dist/schema/reference-parser.js +1 -0
  78. package/dist/schema/schema-parser.js +16 -3
  79. package/dist/schema/work-scope-parser.js +5 -0
  80. package/dist/secrets/command-secret-provider.js +73 -10
  81. package/dist/secrets/github-app-token-command.js +26 -0
  82. package/dist/secrets/github-app-token.js +191 -0
  83. package/dist/server/accounts-api.js +9 -2
  84. package/dist/server/definitions-api.js +69 -47
  85. package/dist/server/definitions.js +76 -51
  86. package/dist/server/executor.js +156 -41
  87. package/dist/server/git-checkout.js +72 -8
  88. package/dist/server/kubernetes-backend.js +562 -0
  89. package/dist/server/kubernetes-sandbox-storage.js +996 -0
  90. package/dist/server/oauth.js +1 -0
  91. package/dist/server/plan.js +51 -1
  92. package/dist/server/policy.js +104 -15
  93. package/dist/server/proposals-api.js +24 -40
  94. package/dist/server/proposals.js +12 -25
  95. package/dist/server/repo-access.js +37 -6
  96. package/dist/server/run-logs.js +217 -0
  97. package/dist/server/run-plan.js +51 -2
  98. package/dist/server/run-request.js +23 -10
  99. package/dist/server/run-store.js +5 -0
  100. package/dist/server/run-worker-command.js +13 -1
  101. package/dist/server/run-worker.js +121 -24
  102. package/dist/server/runs.js +184 -10
  103. package/dist/server/sandbox-docker.js +62 -0
  104. package/dist/server/sandbox-files-api.js +269 -0
  105. package/dist/server/sandbox-files.js +1054 -0
  106. package/dist/server/sandbox-patch.js +481 -0
  107. package/dist/server/sandbox-pod-command.js +15 -0
  108. package/dist/server/sandbox-pod.js +146 -0
  109. package/dist/server/sandbox-storage.js +462 -0
  110. package/dist/server/sandboxes-api.js +210 -0
  111. package/dist/server/sandboxes.js +519 -0
  112. package/dist/server/server-config.js +412 -22
  113. package/dist/server/server.js +204 -39
  114. package/dist/utils/run-id.js +12 -0
  115. package/harness/opencode.ts +11 -0
  116. package/harness/pi.ts +7 -0
  117. package/package.json +8 -2
  118. package/dist/remote/text-diff.js +0 -89
@@ -13,7 +13,9 @@ const runs_1 = require("./runs");
13
13
  const git_checkout_1 = require("./git-checkout");
14
14
  const repo_access_1 = require("./repo-access");
15
15
  const executor_1 = require("./executor");
16
+ const kubernetes_backend_1 = require("./kubernetes-backend");
16
17
  const run_plan_1 = require("./run-plan");
18
+ const work_network_1 = require("../planner/work-network");
17
19
  const run_environment_1 = require("./run-environment");
18
20
  const accounts_api_1 = require("./accounts-api");
19
21
  const run_request_1 = require("./run-request");
@@ -25,7 +27,13 @@ const proposals_api_1 = require("./proposals-api");
25
27
  const proposals_1 = require("./proposals");
26
28
  const sources_1 = require("./sources");
27
29
  const sources_api_1 = require("./sources-api");
30
+ const sandboxes_1 = require("./sandboxes");
31
+ const sandbox_storage_1 = require("./sandbox-storage");
32
+ const kubernetes_sandbox_storage_1 = require("./kubernetes-sandbox-storage");
33
+ const sandbox_docker_1 = require("./sandbox-docker");
34
+ const sandboxes_api_1 = require("./sandboxes-api");
28
35
  const units_1 = require("../utils/units");
36
+ const run_logs_1 = require("./run-logs");
29
37
  const get_cache_directory_1 = require("../optimizer/get-cache-directory");
30
38
  const path_1 = require("path");
31
39
  var discovery_2 = require("./discovery");
@@ -42,11 +50,20 @@ function getDiscoveryDocument(config) {
42
50
  ...(i.clientId ? { clientId: i.clientId } : {}),
43
51
  scope: i.scope,
44
52
  })),
45
- backend: { type: config.backend.type, platform: config.backend.platform ?? (0, server_config_1.detectPlatform)() },
53
+ backend: config.backend.type === 'kubernetes'
54
+ ? {
55
+ type: 'kubernetes',
56
+ platform: config.backend.pools[0].platform,
57
+ pools: config.backend.pools.map((pool) => ({ name: pool.name, platform: pool.platform })),
58
+ }
59
+ : { type: 'host', platform: config.backend.platform ?? (0, server_config_1.detectPlatform)() },
46
60
  };
47
61
  }
48
62
  exports.getDiscoveryDocument = getDiscoveryDocument;
49
63
  const HEARTBEAT_MS = 15000;
64
+ // the longest `?wait=` of a run: below the 60 seconds after which a proxy in front of the
65
+ // server (nginx by default) closes a connection that says nothing
66
+ const MAX_WAIT_MS = 50000;
50
67
  // Server-sent events: `id` is the sequence number, so a client that lost the
51
68
  // connection continues with Last-Event-ID (or ?after=) and misses nothing.
52
69
  function streamEvents(registry, config, principal, id, req, res, url) {
@@ -79,8 +96,8 @@ function streamEvents(registry, config, principal, id, req, res, url) {
79
96
  watch.stop();
80
97
  });
81
98
  }
82
- async function handleRuns(config, registry, definitions, sources, planning, repos, log, principal, req, res, url) {
83
- const match = /^\/v1\/runs(?:\/([^/]+)(?:\/(cancel|events))?)?$/.exec(url.pathname);
99
+ async function handleRuns(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res, url) {
100
+ const match = /^\/v1\/runs(?:\/([^/]+)(?:\/(cancel|events|logs))?)?$/.exec(url.pathname);
84
101
  if (!match) {
85
102
  return false;
86
103
  }
@@ -88,10 +105,17 @@ async function handleRuns(config, registry, definitions, sources, planning, repo
88
105
  const method = req.method;
89
106
  if (!id) {
90
107
  if (method === 'GET') {
91
- (0, http_2.send)(res, 200, { runs: registry.list(principal).map(toRunResponse) });
108
+ // the results of each task are read one run at a time
109
+ (0, http_2.send)(res, 200, {
110
+ runs: registry.list(principal).map((run) => {
111
+ const { results, ...listed } = toRunResponse(run);
112
+ void results;
113
+ return listed;
114
+ }),
115
+ });
92
116
  }
93
117
  else if (method === 'POST') {
94
- await submitRun(config, registry, definitions, sources, planning, repos, log, principal, req, res);
118
+ await submitRun(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res);
95
119
  }
96
120
  else {
97
121
  (0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET, POST' });
@@ -116,9 +140,39 @@ async function handleRuns(config, registry, definitions, sources, planning, repo
116
140
  else if (action === 'events') {
117
141
  streamEvents(registry, config, principal, id, req, res, url);
118
142
  }
143
+ else if (action === 'logs') {
144
+ let query;
145
+ try {
146
+ query = (0, run_logs_1.parseLogQuery)(url.searchParams);
147
+ }
148
+ catch (e) {
149
+ (0, http_2.send)(res, 400, { error: e instanceof Error ? e.message : String(e) });
150
+ return true;
151
+ }
152
+ const logs = registry.logs(principal, id, accountsOf(config, principal));
153
+ logs
154
+ ? (0, http_2.send)(res, 200, (0, run_logs_1.readLogs)(logs.events, logs.index, query, logs.truncated))
155
+ : (0, http_2.send)(res, 404, { error: 'not found' });
156
+ }
119
157
  else {
120
- const run = registry.get(principal, id, accountsOf(config, principal));
121
- run ? (0, http_2.send)(res, 200, toRunResponse(run)) : (0, http_2.send)(res, 404, { error: 'not found' });
158
+ let wait = 0;
159
+ const waitParam = url.searchParams.get('wait');
160
+ if (waitParam !== null) {
161
+ try {
162
+ wait = Math.min((0, units_1.parseDuration)(waitParam), MAX_WAIT_MS);
163
+ }
164
+ catch {
165
+ (0, http_2.send)(res, 400, { error: `wait is a duration up to ${(0, units_1.formatDuration)(MAX_WAIT_MS)}, for example 30s` });
166
+ return true;
167
+ }
168
+ }
169
+ // a caller that hangs up stops waiting
170
+ const gone = new AbortController();
171
+ res.on('close', () => gone.abort());
172
+ const run = await registry.wait(principal, id, wait, accountsOf(config, principal), gone.signal);
173
+ if (!gone.signal.aborted) {
174
+ run ? (0, http_2.send)(res, 200, toRunResponse(run)) : (0, http_2.send)(res, 404, { error: 'not found' });
175
+ }
122
176
  }
123
177
  return true;
124
178
  }
@@ -130,9 +184,9 @@ function accountsOf(config, principal) {
130
184
  function toRunResponse(run) {
131
185
  const { owner, ...visible } = run;
132
186
  void owner;
133
- return visible;
187
+ return { ...visible, results: run.results ?? {} };
134
188
  }
135
- async function submitRun(config, registry, definitions, sources, planning, repos, log, principal, req, res) {
189
+ async function submitRun(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res) {
136
190
  const parsed = run_request_1.runRequestSchema.safeParse(await (0, http_2.readJson)(req));
137
191
  if (!parsed.success) {
138
192
  const issue = parsed.error.issues[0];
@@ -147,21 +201,26 @@ async function submitRun(config, registry, definitions, sources, planning, repos
147
201
  (0, http_2.send)(res, 403, { error: 'you are not entitled to run anything on this server' });
148
202
  return;
149
203
  }
150
- const version = definitions.find(request.definition.name, request.definition.hash);
204
+ const version = definitions.find(request.definition);
151
205
  if (!version) {
152
- (0, http_2.send)(res, 400, { error: `there is no build file ${request.definition.name} at ${request.definition.hash}` });
206
+ (0, http_2.send)(res, 400, { error: `there is no published build file ${request.definition}` });
153
207
  return;
154
208
  }
155
209
  if (version.yanked) {
156
210
  (0, http_2.send)(res, 409, {
157
- error: `${version.name} at ${version.hash} was yanked${version.yanked.reason ? `: ${version.yanked.reason}` : ''}`,
211
+ error: `${version.hash} was yanked${version.yanked.reason ? `: ${version.yanked.reason}` : ''}`,
158
212
  });
159
213
  return;
160
214
  }
161
215
  const known = [...version.tasks, ...version.services];
162
216
  const unknown = request.tasks.filter((task) => !known.includes(task));
163
217
  if (unknown.length > 0) {
164
- (0, http_2.send)(res, 400, { error: `${version.name} has no task ${unknown.map(oauth_1.quote).join(', ')}` });
218
+ (0, http_2.send)(res, 400, { error: `${version.hash} has no task ${unknown.map(oauth_1.quote).join(', ')}` });
219
+ return;
220
+ }
221
+ const argued = (request.options.args ?? []).length > 0 ? request.tasks[0] : undefined;
222
+ if (argued !== undefined && version.services.includes(argued)) {
223
+ (0, http_2.send)(res, 400, { error: `${argued} is a service, arguments go to a task` });
165
224
  return;
166
225
  }
167
226
  if (request.source.type === 'upload') {
@@ -180,29 +239,57 @@ async function submitRun(config, registry, definitions, sources, planning, repos
180
239
  return;
181
240
  }
182
241
  }
242
+ const sandbox = request.source.type === 'sandbox' ? sandboxes.registry.get(principal, request.source.id) : undefined;
243
+ if (request.source.type === 'sandbox') {
244
+ if (!sandbox) {
245
+ (0, http_2.send)(res, 400, { error: `there is no sandbox ${request.source.id}, create one with POST /v1/sandboxes` });
246
+ return;
247
+ }
248
+ if (sandboxes.policy.runtimes?.length === 0) {
249
+ (0, http_2.send)(res, 403, {
250
+ error: 'this server runs only local tasks, which it runs on no sandbox unless sandboxes.runtimes names local',
251
+ });
252
+ return;
253
+ }
254
+ await sandboxes.registry.measured(principal);
255
+ const over = sandboxes.registry.overLimit(principal);
256
+ if (over) {
257
+ (0, http_2.send)(res, 413, { error: over });
258
+ return;
259
+ }
260
+ }
183
261
  // the accounts of a run are read from the files it is pinned to, with the engine
184
262
  const bundle = version.plan ? await definitions.files(version) : undefined;
185
263
  if (!version.plan || !bundle) {
186
264
  (0, http_2.send)(res, 409, {
187
- error: `${version.name} at ${version.hash} was published before accounts were recorded, publish it again`,
265
+ error: `${version.hash} was published before accounts were recorded, publish it again`,
188
266
  });
189
267
  return;
190
268
  }
191
269
  let planned;
192
270
  try {
193
- planned = await (0, run_plan_1.planRun)(bundle, request, planning.environment, planning.policy);
271
+ // a run on a sandbox may only use the runtimes of `sandboxes.runtimes`
272
+ planned = await (0, run_plan_1.planRun)(bundle, request, planning.environment, sandbox ? sandboxes.policy : planning.policy);
194
273
  }
195
274
  catch (e) {
196
275
  (0, http_2.send)(res, 400, { error: `the run does not load: ${e instanceof Error ? e.message : String(e)}` });
197
276
  return;
198
277
  }
278
+ const refused = (0, run_plan_1.refuseArguments)(request, planned.plan);
279
+ if (refused) {
280
+ log(`refuse run iss=${principal.iss} sub=${principal.sub}: ${refused}`);
281
+ (0, http_2.send)(res, 400, { error: refused });
282
+ return;
283
+ }
199
284
  const decision = (0, policy_1.decideRun)({
200
285
  grant,
201
286
  accounts: planned.accounts,
202
287
  catalog: config.accounts,
203
288
  source: request.source.type === 'ref'
204
289
  ? { type: 'ref', repo: request.source.repo, ref: request.source.ref }
205
- : { type: 'upload' },
290
+ : sandbox
291
+ ? { type: 'sandbox', repo: sandbox.repo, branch: sandbox.ref }
292
+ : { type: 'upload' },
206
293
  approvable: (account) => config.entitlements.some((entitlement) => entitlement.accounts[account] === 'use'),
207
294
  readable: (repo) => repos.readable(repo),
208
295
  });
@@ -228,19 +315,34 @@ async function submitRun(config, registry, definitions, sources, planning, repos
228
315
  return;
229
316
  }
230
317
  }
231
- else {
318
+ else if (request.source.type === 'upload') {
232
319
  source = request.source;
233
320
  }
321
+ const submit = (runSource) => registry.submit(principal, {
322
+ definition: version.hash,
323
+ tasks: request.tasks,
324
+ accounts: planned.accounts,
325
+ plan: planned.plan,
326
+ source: runSource,
327
+ options: request.options,
328
+ }, approvals);
234
329
  try {
235
- const run = registry.submit(principal, {
236
- definition: { name: version.name, hash: version.hash },
237
- tasks: request.tasks,
238
- accounts: planned.accounts,
239
- plan: planned.plan,
240
- source,
241
- options: request.options,
242
- }, approvals);
243
- log(`run ${run.id} iss=${principal.iss} sub=${principal.sub} accounts=[${planned.accounts.join(',')}] source=${run.source.type} tasks=${run.tasks.length}${approvals.length > 0 ? ` approval=[${approvals.join(',')}]` : ''}`);
330
+ // a sandbox is read and the run submitted while nothing else changes it: the run
331
+ // records what the sandbox held, and holds it from now on
332
+ const run = source
333
+ ? submit(source)
334
+ : await sandboxes.registry.withSandbox(principal, sandbox.id, async (current) => {
335
+ const state = await sandboxes.registry.state(current.id);
336
+ return submit({
337
+ type: 'sandbox',
338
+ id: current.id,
339
+ repo: current.repo,
340
+ commit: state.commit,
341
+ tree: state.tree,
342
+ clean: state.clean,
343
+ });
344
+ });
345
+ log(`run ${run.id} iss=${principal.iss} sub=${principal.sub} accounts=[${planned.accounts.join(',')}] source=${run.source.type}${run.source.type === 'sandbox' ? ` sandbox=${run.source.id}` : ''} tasks=${run.tasks.length}${approvals.length > 0 ? ` approval=[${approvals.join(',')}]` : ''}`);
244
346
  (0, http_2.send)(res, 202, toRunResponse(run));
245
347
  }
246
348
  catch (e) {
@@ -248,6 +350,12 @@ async function submitRun(config, registry, definitions, sources, planning, repos
248
350
  (0, http_2.send)(res, e.status, { error: e.message });
249
351
  return;
250
352
  }
353
+ if (e instanceof sandboxes_1.SandboxError) {
354
+ (0, http_2.send)(res, e.status === 404 ? 400 : e.status, {
355
+ error: e.status === 404 ? `there is no sandbox ${sandbox.id}` : e.message,
356
+ });
357
+ return;
358
+ }
251
359
  throw e;
252
360
  }
253
361
  }
@@ -271,7 +379,7 @@ async function authenticate(verifier, log, req, res) {
271
379
  return null;
272
380
  }
273
381
  }
274
- async function handle(config, registry, definitions, proposals, sources, planning, repos, verifier, log, req, res) {
382
+ async function handle(config, registry, definitions, proposals, sources, sandboxes, planning, repos, verifier, log, req, res) {
275
383
  const url = new URL(req.url ?? '/', 'http://localhost');
276
384
  const path = url.pathname;
277
385
  if (path === discovery_1.DISCOVERY_PATH || path === '/healthz') {
@@ -295,7 +403,10 @@ async function handle(config, registry, definitions, proposals, sources, plannin
295
403
  if (await (0, sources_api_1.handleSources)(config, sources, log, principal, req, res, url)) {
296
404
  return;
297
405
  }
298
- if (await handleRuns(config, registry, definitions, sources, planning, repos, log, principal, req, res, url)) {
406
+ if (await (0, sandboxes_api_1.handleSandboxes)(config, sandboxes.registry, repos, log, principal, req, res, url)) {
407
+ return;
408
+ }
409
+ if (await handleRuns(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res, url)) {
299
410
  return;
300
411
  }
301
412
  if (await (0, approvals_api_1.handleApprovals)(config, registry, log, principal, req, res, url)) {
@@ -315,7 +426,7 @@ async function startServer(config, environment, options = {}) {
315
426
  const write = options.log ?? (() => undefined);
316
427
  // request urls and subjects come from callers: no control characters, so no forged log lines
317
428
  const log = (line) => write(line.replace(/\p{Cc}/gu, '?').slice(0, 1000));
318
- if (config.runtimes.includes('local')) {
429
+ if (config.runtimes.includes('local') && config.backend.type === 'host') {
319
430
  log('warning: the local runtime is allowed, local tasks run as the user of this server and read all it can read; ' +
320
431
  'an account is then an approval class, not a credential boundary');
321
432
  }
@@ -327,7 +438,13 @@ async function startServer(config, environment, options = {}) {
327
438
  processEnvs: (0, run_environment_1.runEnvironment)(config, environment.processEnvs),
328
439
  hosted: { catalog: (0, server_config_1.getSecretCatalog)(config) },
329
440
  },
330
- policy: { envs: config.runs.env.allow, reserved: (0, server_config_1.getSourceAccounts)(config), runtimes: config.runtimes },
441
+ policy: {
442
+ envs: config.runs.env.allow,
443
+ reserved: (0, server_config_1.getSourceAccounts)(config),
444
+ runtimes: config.runtimes,
445
+ backend: config.backend.type,
446
+ network: (0, work_network_1.parseWorkNetwork)(config.runs.network),
447
+ },
331
448
  };
332
449
  const storage = config.storage.directory ?? (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'server');
333
450
  const definitions = new definitions_1.DefinitionStore((0, path_1.join)(storage, 'definitions'), planning.environment, {
@@ -335,15 +452,59 @@ async function startServer(config, environment, options = {}) {
335
452
  });
336
453
  await definitions.load();
337
454
  const repos = new repo_access_1.RepoAccess(config, planning.environment, options.repos);
338
- const executor = options.executor ??
339
- new executor_1.HostExecutor({
340
- definitions,
341
- planning,
455
+ const sandboxRuntimes = (0, server_config_1.getSandboxRuntimes)(config);
456
+ const sandboxLimits = {
457
+ count: config.sandboxes.limits.count,
458
+ bytes: (0, units_1.parseSize)(config.sandboxes.limits.bytes),
459
+ total: (0, units_1.parseSize)(config.sandboxes.total),
460
+ };
461
+ const sandboxes = {
462
+ registry: new sandboxes_1.SandboxRegistry({
463
+ // on a cluster a claim per sandbox in the caller's namespace, else a directory
464
+ storage: config.backend.type === 'kubernetes'
465
+ ? new kubernetes_sandbox_storage_1.KubernetesSandboxStorage({ ...config, backend: config.backend }, sandboxLimits, planning.environment.processEnvs, (0, path_1.join)(storage, 'sandboxes', 'staging'))
466
+ : new sandbox_storage_1.HostSandboxStorage((0, path_1.join)(storage, 'sandboxes', 'files'), planning.environment.processEnvs,
467
+ // what containers of runs leave behind is given back and removed with a container
468
+ sandboxRuntimes.includes('docker')
469
+ ? new sandbox_docker_1.DockerSandboxContainers(planning.environment.processEnvs.DOCKER_HOST)
470
+ : undefined),
471
+ records: (0, path_1.join)(storage, 'sandboxes', 'records'),
472
+ limits: sandboxLimits,
342
473
  repos,
343
- directory: (0, path_1.join)(storage, 'runs'),
344
- cache: (0, path_1.join)(storage, 'cache'),
345
- worker: options.worker,
346
- });
474
+ log,
475
+ }),
476
+ // a sandbox holds code nobody reviewed: only the variables of `sandboxes.env`
477
+ policy: {
478
+ ...planning.policy,
479
+ envs: config.sandboxes.env.allow,
480
+ envsFrom: 'sandboxes.env.allow',
481
+ runtimes: sandboxRuntimes,
482
+ },
483
+ };
484
+ if (sandboxRuntimes.length === 0 && config.entitlements.some((entitlement) => entitlement.sandboxes?.length)) {
485
+ log('warning: entitlements keep sandboxes, but this server runs only local tasks, which it runs on no sandbox; ' +
486
+ 'runs on sandboxes are refused until sandboxes.runtimes names a runtime');
487
+ }
488
+ // on a cluster that lists the claims of sandboxes in the namespaces of callers, which a
489
+ // server without sandboxes need not be allowed to
490
+ if (config.backend.type === 'host' || config.entitlements.some((entitlement) => entitlement.sandboxes?.length)) {
491
+ await sandboxes.registry.load();
492
+ }
493
+ const executorOptions = {
494
+ definitions,
495
+ planning,
496
+ repos,
497
+ directory: (0, path_1.join)(storage, 'runs'),
498
+ cache: (0, path_1.join)(storage, 'cache'),
499
+ worker: options.worker,
500
+ sandboxes: { ...sandboxes, network: config.sandboxes.network },
501
+ log,
502
+ };
503
+ const backend = config.backend;
504
+ const executor = options.executor ??
505
+ (backend.type === 'kubernetes'
506
+ ? new kubernetes_backend_1.KubernetesExecutor({ ...config, backend }, environment.processEnvs, executorOptions)
507
+ : new executor_1.HostExecutor(executorOptions));
347
508
  const sources = new sources_1.SourceStore((0, path_1.join)(storage, 'sources'));
348
509
  const registry = new runs_1.RunRegistry({
349
510
  executor,
@@ -358,13 +519,17 @@ async function startServer(config, environment, options = {}) {
358
519
  log,
359
520
  });
360
521
  await registry.restore();
522
+ sandboxes.registry.useRuns({
523
+ holder: (id) => registry.holder(id),
524
+ stop: (id) => registry.stopSandbox(id, 'the sandbox was deleted'),
525
+ });
361
526
  const proposals = new proposals_1.ProposalRegistry({
362
527
  maxWaiting: config.approvals.maxWaiting,
363
528
  timeout: (0, units_1.parseDuration)(config.approvals.timeout),
364
529
  cooldown: (0, units_1.parseDuration)(config.approvals.cooldown),
365
530
  });
366
531
  const listener = (req, res) => {
367
- handle(config, registry, definitions, proposals, sources, planning, repos, verifier, log, req, res).catch((e) => {
532
+ handle(config, registry, definitions, proposals, sources, sandboxes, planning, repos, verifier, log, req, res).catch((e) => {
368
533
  if (e instanceof http_2.HttpError) {
369
534
  (0, http_2.send)(res, e.status, { error: e.message });
370
535
  return;
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getRunId = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const runId = (0, crypto_1.randomBytes)(4).toString('hex');
6
+ // Tells this process's run apart from a concurrent one of the same items, on
7
+ // this machine or another one using the same daemon or cluster.
8
+ function getRunId() {
9
+ return runId;
10
+ }
11
+ exports.getRunId = getRunId;
12
+ //# sourceMappingURL=run-id.js.map
@@ -0,0 +1,11 @@
1
+ // The hammerkit plugin for opencode 2, and for opencode 1 by its `server`, re-exported by a plugin
2
+ // file of the project: `export { default } from 'hammerkit/harness/opencode'`, see
3
+ // https://hammerkit.dev/docs/remote-compute/agents
4
+ import { hammerkitOpencodeEntry } from '../dist/harness/opencode'
5
+
6
+ const plugin = hammerkitOpencodeEntry((specifier) => import(specifier))
7
+
8
+ export default plugin
9
+
10
+ // the name plugin files of opencode 1 re-exported, the same plugin so opencode 1 loads it once
11
+ export const HammerkitPlugin = plugin
package/harness/pi.ts ADDED
@@ -0,0 +1,7 @@
1
+ // The hammerkit extension for pi: `pi install npm:hammerkit`. pi loads this file with its
2
+ // own module, whose tools hammerkit gives a sandbox as their backend, see
3
+ // https://hammerkit.dev/docs/remote-compute/agents
4
+ import * as pi from '@earendil-works/pi-coding-agent'
5
+ import { hammerkitPiExtension } from '../dist/harness/pi'
6
+
7
+ export default hammerkitPiExtension(pi)
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.26.0",
4
+ "version": "1.27.0-next.2",
5
5
  "keywords": [
6
6
  "build",
7
7
  "build-tool",
@@ -19,8 +19,14 @@
19
19
  "!dist/**/*.spec.*",
20
20
  "!dist/testing",
21
21
  "!dist/**/*.map",
22
- "build.schema.json"
22
+ "build.schema.json",
23
+ "harness"
23
24
  ],
25
+ "pi": {
26
+ "extensions": [
27
+ "./harness/pi.ts"
28
+ ]
29
+ },
24
30
  "bugs": {
25
31
  "url": "https://github.com/no0dles/hammerkit/issues"
26
32
  },
@@ -1,89 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.diffBundles = exports.diffLines = exports.visible = void 0;
4
- // Text from a proposal is shown to somebody who decides on it, so nothing in it may
5
- // rewrite the terminal or hide what it says: control and format characters (which
6
- // include the ones that reorder text) are shown as `?`. Long lines are not cut,
7
- // what is hidden behind a cut is what a reviewer needs to see.
8
- function visible(line) {
9
- return line.replace(/\t/g, ' ').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '?');
10
- }
11
- exports.visible = visible;
12
- // Beyond this many comparisons a file is shown whole instead of compared.
13
- const MAX_CELLS = 4000000;
14
- // The lines that differ, with `context` lines around each change.
15
- function diffLines(before, after, context = 2) {
16
- if ((before.length + 1) * (after.length + 1) > MAX_CELLS) {
17
- return undefined;
18
- }
19
- const n = before.length;
20
- const m = after.length;
21
- // lengths of the longest common suffixes
22
- const table = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
23
- for (let i = n - 1; i >= 0; i--) {
24
- for (let j = m - 1; j >= 0; j--) {
25
- table[i][j] = before[i] === after[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
26
- }
27
- }
28
- const all = [];
29
- let i = 0;
30
- let j = 0;
31
- while (i < n && j < m) {
32
- if (before[i] === after[j]) {
33
- all.push({ type: ' ', text: before[i] });
34
- i++;
35
- j++;
36
- }
37
- else if (table[i + 1][j] >= table[i][j + 1]) {
38
- all.push({ type: '-', text: before[i++] });
39
- }
40
- else {
41
- all.push({ type: '+', text: after[j++] });
42
- }
43
- }
44
- while (i < n)
45
- all.push({ type: '-', text: before[i++] });
46
- while (j < m)
47
- all.push({ type: '+', text: after[j++] });
48
- const keep = new Array(all.length).fill(false);
49
- all.forEach((line, index) => {
50
- if (line.type !== ' ') {
51
- for (let k = Math.max(0, index - context); k <= Math.min(all.length - 1, index + context); k++) {
52
- keep[k] = true;
53
- }
54
- }
55
- });
56
- return all.filter((_, index) => keep[index]);
57
- }
58
- exports.diffLines = diffLines;
59
- const lines = (text) => (text === '' ? [] : text.replace(/\n$/, '').split('\n'));
60
- // What a proposal changes against the version it follows, file by file.
61
- function diffBundles(base, proposed) {
62
- const out = [];
63
- const before = base?.files ?? {};
64
- const paths = [...new Set([...Object.keys(before), ...Object.keys(proposed.files)])].sort();
65
- for (const path of paths) {
66
- const old = before[path];
67
- const next = proposed.files[path];
68
- if (old === next) {
69
- continue;
70
- }
71
- const status = old === undefined ? 'added' : next === undefined ? 'removed' : 'changed';
72
- out.push(`--- ${visible(path)} (${status})`);
73
- const compared = old !== undefined && next !== undefined ? diffLines(lines(old), lines(next)) : undefined;
74
- if (compared) {
75
- compared.forEach((line) => out.push(`${line.type} ${visible(line.text)}`));
76
- }
77
- else {
78
- // added, removed or too large to compare: all of the old, then all of the new
79
- lines(old ?? '').forEach((line) => out.push(`- ${visible(line)}`));
80
- lines(next ?? '').forEach((line) => out.push(`+ ${visible(line)}`));
81
- }
82
- }
83
- if (base && base.entry !== proposed.entry) {
84
- out.unshift(`entry file: ${visible(base.entry)} -> ${visible(proposed.entry)}`);
85
- }
86
- return out;
87
- }
88
- exports.diffBundles = diffBundles;
89
- //# sourceMappingURL=text-diff.js.map