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.
Files changed (100) hide show
  1. package/build.schema.json +9 -0
  2. package/dist/cache/backends/registry-cache-backend.js +2 -2
  3. package/dist/cache/explain.js +17 -0
  4. package/dist/cli.js +1 -0
  5. package/dist/docker/item-network.js +13 -5
  6. package/dist/executer/deadline.js +17 -3
  7. package/dist/executer/docker-service.js +30 -8
  8. package/dist/executer/event-cache.js +20 -2
  9. package/dist/executer/execute-work-task.js +5 -3
  10. package/dist/executer/execution-steps.js +5 -2
  11. package/dist/executer/scheduler/enqueue-next.js +7 -0
  12. package/dist/executer/scheduler/state-key.js +42 -5
  13. package/dist/harness/connect.js +20 -6
  14. package/dist/harness/mcp-command.js +22 -6
  15. package/dist/harness/mcp.js +63 -114
  16. package/dist/harness/opencode.js +158 -50
  17. package/dist/harness/pi.js +29 -0
  18. package/dist/harness/sandbox-client.js +207 -65
  19. package/dist/harness/service-tools.js +141 -0
  20. package/dist/harness/task-tools.js +221 -0
  21. package/dist/kubernetes/apply.js +27 -16
  22. package/dist/kubernetes/claim-layout.js +71 -0
  23. package/dist/kubernetes/ensure-http-route.js +1 -1
  24. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +5 -9
  25. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +1 -1
  26. package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
  27. package/dist/kubernetes/ensure-persistent-data.js +30 -20
  28. package/dist/kubernetes/get-service-ip.js +2 -2
  29. package/dist/kubernetes/job-secrets.js +33 -19
  30. package/dist/kubernetes/kept-claims.js +22 -10
  31. package/dist/kubernetes/network-policy.js +85 -27
  32. package/dist/kubernetes/remove-persistent-data.js +3 -1
  33. package/dist/kubernetes/resources.js +25 -3
  34. package/dist/kubernetes/restore-kubernetes-data.js +9 -3
  35. package/dist/kubernetes/sandbox-runner.js +626 -0
  36. package/dist/kubernetes/secrets.js +7 -11
  37. package/dist/kubernetes/volumes.js +6 -3
  38. package/dist/network/egress-proxy.js +45 -6
  39. package/dist/optimizer/calculate-checksum.js +3 -2
  40. package/dist/optimizer/check-key.js +94 -0
  41. package/dist/optimizer/check-keys.js +64 -0
  42. package/dist/optimizer/get-work-cache-stats.js +17 -3
  43. package/dist/optimizer/work-task-cache-description.js +51 -1
  44. package/dist/planner/utils/append-work-dependencies.js +10 -5
  45. package/dist/planner/utils/append-work-service.js +1 -0
  46. package/dist/planner/utils/append-work-task.js +15 -2
  47. package/dist/planner/utils/get-work-runtime.js +6 -1
  48. package/dist/planner/utils/normalize-path.js +19 -1
  49. package/dist/planner/utils/parse-work-mount.js +7 -1
  50. package/dist/planner/utils/parse-work-port.js +4 -1
  51. package/dist/planner/utils/task-arguments.js +3 -0
  52. package/dist/planner/validate.js +22 -0
  53. package/dist/planner/work-network.js +3 -1
  54. package/dist/planner/work-runtime-aggregate.js +32 -0
  55. package/dist/planner/work-runtime-docker.js +2 -1
  56. package/dist/planner/work-runtime-kubernetes.js +97 -48
  57. package/dist/planner/work-runtime-local.js +2 -1
  58. package/dist/planner/work-task.js +6 -1
  59. package/dist/program.js +14 -4
  60. package/dist/remote/annotations.js +13 -1
  61. package/dist/remote/device-login.js +7 -2
  62. package/dist/remote/host-config.js +64 -3
  63. package/dist/remote/remote-commands.js +41 -9
  64. package/dist/remote/remote-work-commands.js +246 -1
  65. package/dist/remote/server-client.js +15 -2
  66. package/dist/remote/token-source.js +64 -26
  67. package/dist/run-program.js +9 -0
  68. package/dist/schema/build-file-base-task-schema.js +5 -1
  69. package/dist/schema/build-file-container-service-schema.js +2 -0
  70. package/dist/schema/reference-parser.js +9 -1
  71. package/dist/schema/work-scope-parser.js +6 -0
  72. package/dist/secrets/github-app-token-command.js +3 -1
  73. package/dist/server/accounts-api.js +3 -1
  74. package/dist/server/approvals-api.js +1 -1
  75. package/dist/server/checks-api.js +83 -0
  76. package/dist/server/checks.js +116 -0
  77. package/dist/server/definitions-api.js +4 -2
  78. package/dist/server/endpoints.js +612 -0
  79. package/dist/server/executor.js +56 -5
  80. package/dist/server/kubernetes-backend.js +303 -64
  81. package/dist/server/kubernetes-sandbox-storage.js +48 -3
  82. package/dist/server/oauth.js +12 -1
  83. package/dist/server/plan.js +126 -62
  84. package/dist/server/policy.js +32 -2
  85. package/dist/server/repo-access.js +5 -0
  86. package/dist/server/run-plan.js +50 -19
  87. package/dist/server/run-request.js +64 -3
  88. package/dist/server/run-worker.js +146 -10
  89. package/dist/server/runs.js +347 -27
  90. package/dist/server/sandbox-pod.js +28 -0
  91. package/dist/server/sandbox-storage.js +23 -0
  92. package/dist/server/sandboxes-api.js +10 -4
  93. package/dist/server/sandboxes.js +8 -0
  94. package/dist/server/server-command.js +39 -20
  95. package/dist/server/server-config.js +213 -14
  96. package/dist/server/server.js +268 -15
  97. package/dist/server/service-runs.js +82 -0
  98. package/dist/utils/abort-on-signals.js +46 -6
  99. package/dist/utils/tar.js +36 -0
  100. package/package.json +1 -1
package/build.schema.json CHANGED
@@ -289,6 +289,9 @@
289
289
  ],
290
290
  "description": "network the task or service reaches besides its needs: open (default), none or { allow: [hosts or host:port] }"
291
291
  },
292
+ "args": {
293
+ "type": "boolean"
294
+ },
292
295
  "runner": {
293
296
  "type": "string",
294
297
  "minLength": 1
@@ -370,6 +373,9 @@
370
373
  "network": {
371
374
  "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/network"
372
375
  },
376
+ "args": {
377
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/args"
378
+ },
373
379
  "runner": {
374
380
  "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/runner"
375
381
  },
@@ -511,6 +517,9 @@
511
517
  "network": {
512
518
  "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/network",
513
519
  "description": "network the task or service reaches besides its needs: open (default), none or { allow: [hosts or host:port] }"
520
+ },
521
+ "args": {
522
+ "type": "boolean"
514
523
  }
515
524
  },
516
525
  "required": [
@@ -5,6 +5,7 @@ const crypto_1 = require("crypto");
5
5
  const os_1 = require("os");
6
6
  const path_1 = require("path");
7
7
  const tar_1 = require("tar");
8
+ const tar_2 = require("../../utils/tar");
8
9
  const registry_reference_1 = require("./registry/registry-reference");
9
10
  const registry_client_1 = require("./registry/registry-client");
10
11
  const MAX_TAG_LENGTH = 128;
@@ -74,8 +75,7 @@ function createRegistryCacheBackend(spec) {
74
75
  const layerFile = (0, path_1.join)(dir, 'layer.tar');
75
76
  const files = (await environment.file.listFiles(from)).sort();
76
77
  // portable + mtime-free so identical entries produce identical digests
77
- // `noMtime` is supported by tar 6 but missing from its type definitions
78
- await (0, tar_1.create)({ file: layerFile, cwd: from, portable: true, noMtime: true }, files);
78
+ await (0, tar_2.packArchive)({ file: layerFile, cwd: from, portable: true, noMtime: true }, files);
79
79
  const layer = await (0, registry_client_1.digestFile)(layerFile);
80
80
  const config = Buffer.from(JSON.stringify({
81
81
  architecture: 'amd64',
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.explainWorkTree = exports.explainTask = exports.describeCause = void 0;
4
+ const work_task_1 = require("../planner/work-task");
4
5
  const state_key_1 = require("../executer/scheduler/state-key");
5
6
  const work_task_cache_description_1 = require("../optimizer/work-task-cache-description");
6
7
  const get_cache_directory_1 = require("../optimizer/get-cache-directory");
@@ -157,6 +158,22 @@ async function explainTask(item, defaultCacheMethod, environment, memo = new Map
157
158
  visiting.delete(item.id());
158
159
  return explanation;
159
160
  }
161
+ // Mirrors executeWorkTask: an aggregate is never looked up itself, and is reported
162
+ // cached when all its dependencies were. What changed is in its dependencies.
163
+ if ((0, work_task_1.isAggregateTask)(item.data)) {
164
+ const causes = [];
165
+ for (const dep of item.deps) {
166
+ const depExplanation = await explainTask(dep, defaultCacheMethod, environment, memo, visiting);
167
+ if (depExplanation.status !== 'hit') {
168
+ causes.push({ kind: 'dependency-changed', identifier: dep.name });
169
+ }
170
+ }
171
+ const status = causes.length === 0 ? 'hit' : 'miss';
172
+ const explanation = { taskId: item.id(), taskName: item.name, status, causes };
173
+ memo.set(item.id(), explanation);
174
+ visiting.delete(item.id());
175
+ return explanation;
176
+ }
160
177
  const runtimeStateKey = await safeCurrentStateKey(item, environment);
161
178
  const hasEntry = await safeHas(item, stateKey, environment);
162
179
  const predictedHit = runtimeStateKey === stateKey || hasEntry;
package/dist/cli.js CHANGED
@@ -79,6 +79,7 @@ class Cli {
79
79
  explain: options?.explain ?? false,
80
80
  cacheReadOnly: options?.cacheReadOnly ?? false,
81
81
  timeout: options?.timeout ?? null,
82
+ maxTimeout: options?.maxTimeout ?? null,
82
83
  skipDeps: options?.skipDeps ?? true,
83
84
  wait: options?.wait ?? 'ready',
84
85
  resourceCheck: options?.resourceCheck ?? true,
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.removeOrphanedNetworks = exports.removeItemNetworks = exports.getNetworkHostConfig = exports.removeItemNetwork = exports.prepareItemNetwork = exports.checkNetworkLimitSupport = exports.getProxyNetworkName = exports.getRunNetworkName = exports.getItemNetworkName = void 0;
3
+ exports.removeOrphanedNetworks = exports.removeItemNetworks = exports.getNetworkHostConfig = exports.getPortBinding = exports.removeItemNetwork = exports.prepareItemNetwork = exports.checkNetworkLimitSupport = exports.getProxyNetworkName = exports.getRunNetworkName = exports.getItemNetworkName = void 0;
4
4
  const readline_1 = require("readline");
5
5
  const stream_1 = require("stream");
6
6
  const work_service_1 = require("../planner/work-service");
@@ -258,7 +258,7 @@ options) {
258
258
  await removeItemNetwork(docker, name);
259
259
  throw e;
260
260
  }
261
- const publish = (options.publish ?? []).filter((port) => !!port.hostPort);
261
+ const publish = (options.publish ?? []).filter((port) => port.hostPort !== null);
262
262
  return {
263
263
  options: {
264
264
  mode: name,
@@ -272,8 +272,9 @@ options) {
272
272
  await docker.getNetwork(getProxyNetworkName(name)).connect({ Container: container.id });
273
273
  }
274
274
  if (publish.length > 0) {
275
- await startPortRelay(docker, item, environment, name, container, publish, options);
275
+ return await startPortRelay(docker, item, environment, name, container, publish, options);
276
276
  }
277
+ return undefined;
277
278
  }
278
279
  catch (e) {
279
280
  await removeItemNetwork(docker, name);
@@ -374,12 +375,13 @@ async function startPortRelay(docker, item, environment, networkName, service, p
374
375
  ExposedPorts: Object.fromEntries(ports.map((port) => [`${port.containerPort}/tcp`, {}])),
375
376
  HostConfig: {
376
377
  ...HELPER_HOST_CONFIG,
377
- PortBindings: Object.fromEntries(ports.map((port) => [`${port.containerPort}/tcp`, [{ HostPort: `${port.hostPort}` }]])),
378
+ PortBindings: Object.fromEntries(ports.map((port) => [`${port.containerPort}/tcp`, [getPortBinding(port)]])),
378
379
  },
379
380
  });
380
381
  await docker.getNetwork(networkName).connect({ Container: container.id });
381
382
  await container.start();
382
- item.status.write('debug', `ports ${ports.map((port) => port.hostPort).join(', ')} published by a relay`);
383
+ item.status.write('debug', `ports ${ports.map((port) => port.hostPort || 'any').join(', ')} published by a relay`);
384
+ return container;
383
385
  }
384
386
  // The network, its proxy network, its proxy and relay, and every endpoint on
385
387
  // them: what an earlier run left behind, or what the item used.
@@ -429,6 +431,12 @@ async function removeNetwork(docker, name) {
429
431
  }
430
432
  }
431
433
  }
434
+ // A port of the host for a port of a container: a free one for 0, on one address of it
435
+ // when the port names one.
436
+ function getPortBinding(port) {
437
+ return { HostPort: port.hostPort ? `${port.hostPort}` : '', ...(port.hostIp ? { HostIp: port.hostIp } : {}) };
438
+ }
439
+ exports.getPortBinding = getPortBinding;
432
440
  function getNetworkHostConfig(network) {
433
441
  return {
434
442
  ...(network.mode ? { NetworkMode: network.mode } : {}),
@@ -1,14 +1,28 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.withDeadline = void 0;
3
+ exports.withDeadline = exports.getTaskTimeout = void 0;
4
4
  const abort_event_1 = require("../utils/abort-event");
5
+ // How long a task may run: its own `timeout`, else the run's default (`--timeout`), and
6
+ // never longer than the run's cap (`--max-timeout`), which also covers tasks with a
7
+ // longer timeout of their own. A caller can shorten a task this way, never extend it.
8
+ function getTaskTimeout(own, run) {
9
+ const timeout = own ?? run.timeout;
10
+ if (run.maxTimeout === null) {
11
+ return timeout;
12
+ }
13
+ return timeout === null ? run.maxTimeout : Math.min(timeout, run.maxTimeout);
14
+ }
15
+ exports.getTaskTimeout = getTaskTimeout;
16
+ // the longest delay of setTimeout: Node fires a longer one after 1 ms
17
+ const MAX_TIMER_MS = 2 ** 31 - 1;
5
18
  // An abort signal that follows `parent` and additionally fires after `timeout`
6
- // ms, so a task deadline reuses the runtimes' existing abort/cleanup path.
19
+ // ms, so a task deadline reuses the runtimes' existing abort/cleanup path. A
20
+ // timeout beyond what a timer can wait (about 24.8 days) sets no deadline.
7
21
  function withDeadline(parent, timeout) {
8
22
  const controller = new AbortController();
9
23
  let expired = false;
10
24
  const parentListener = (0, abort_event_1.listenOnAbort)(parent, () => controller.abort());
11
- const timer = timeout === null
25
+ const timer = timeout === null || timeout > MAX_TIMER_MS
12
26
  ? null
13
27
  : setTimeout(() => {
14
28
  expired = true;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.dockerService = exports.buildServiceCreateOptions = void 0;
3
+ exports.dockerService = exports.buildServiceCreateOptions = exports.readPublishedPorts = void 0;
4
4
  const work_service_1 = require("../planner/work-service");
5
5
  const run_labels_1 = require("../docker/run-labels");
6
6
  const service_hostname_1 = require("../planner/utils/service-hostname");
@@ -22,6 +22,28 @@ const work_healthcheck_1 = require("../planner/work-healthcheck");
22
22
  const docker_resources_1 = require("./docker-resources");
23
23
  const egress_proxy_1 = require("../network/egress-proxy");
24
24
  const HEALTHCHECK_INTERVAL_MS = 1000;
25
+ // Where docker published the ports of a container (or of its relay), for those that asked
26
+ // for a free one.
27
+ async function readPublishedPorts(container, ports) {
28
+ const wanted = ports.filter((port) => port.hostPort === 0);
29
+ if (wanted.length === 0) {
30
+ return [];
31
+ }
32
+ const bound = (await container.inspect()).NetworkSettings.Ports ?? {};
33
+ return wanted.flatMap((port) => {
34
+ const binding = bound[`${port.containerPort}/tcp`]?.find((candidate) => candidate.HostPort);
35
+ return binding
36
+ ? [
37
+ {
38
+ containerPort: port.containerPort,
39
+ host: port.hostIp ?? (binding.HostIp && binding.HostIp !== '0.0.0.0' ? binding.HostIp : '127.0.0.1'),
40
+ port: Number(binding.HostPort),
41
+ },
42
+ ]
43
+ : [];
44
+ });
45
+ }
46
+ exports.readPublishedPorts = readPublishedPorts;
25
47
  function buildServiceCreateOptions(item, options, network, secrets, resources = {}) {
26
48
  const envs = (0, replace_env_variables_1.getEnvironmentVariables)(item.data.envs);
27
49
  const command = (0, work_service_1.getServiceCommand)(item.data);
@@ -58,12 +80,9 @@ function buildServiceCreateOptions(item, options, network, secrets, resources =
58
80
  // collide with another run on the same host. A limited service's are
59
81
  // published by its relay.
60
82
  PortBindings: options.publishPorts && !network.relayPorts
61
- ? item.data.ports
62
- .filter((p) => !!p.hostPort)
63
- .reduce((map, port) => {
64
- map[`${port.containerPort}/tcp`] = [{ HostPort: `${port.hostPort}` }];
65
- return map;
66
- }, {})
83
+ ? Object.fromEntries(item.data.ports
84
+ .filter((p) => p.hostPort !== null)
85
+ .map((port) => [`${port.containerPort}/tcp`, [(0, item_network_1.getPortBinding)(port)]]))
67
86
  : {},
68
87
  ...resources,
69
88
  },
@@ -90,16 +109,18 @@ async function dockerService(docker, item, options, environment) {
90
109
  const resources = await (0, docker_resources_1.resolveDockerResources)(docker, item.data.resources, item.status);
91
110
  await (0, execution_steps_1.refuseHostLinks)(item, environment);
92
111
  container = await docker.createContainer(buildServiceCreateOptions(item, options, network.options, secrets, resources));
93
- await network.connect(container);
112
+ const relay = await network.connect(container);
94
113
  const stream = await container.attach({ stream: true, stdout: true, stderr: true });
95
114
  (0, stream_1.logStream)(item.status, stream);
96
115
  await container.start();
116
+ const published = options.publishPorts ? await readPublishedPorts(relay ?? container, item.data.ports) : [];
97
117
  if (!item.data.healthcheck || !options.waitForReady) {
98
118
  options.state.set({
99
119
  type: 'running',
100
120
  dns: { containerId: container.id },
101
121
  stateKey: options.stateKey,
102
122
  remote: null,
123
+ ...(published.length > 0 ? { published } : {}),
103
124
  });
104
125
  }
105
126
  else {
@@ -125,6 +146,7 @@ async function dockerService(docker, item, options, environment) {
125
146
  dns: { containerId: container.id },
126
147
  stateKey: options.stateKey,
127
148
  remote: null,
149
+ ...(published.length > 0 ? { published } : {}),
128
150
  });
129
151
  }
130
152
  }
@@ -8,6 +8,8 @@ const execute_docker_1 = require("./execute-docker");
8
8
  const plan_work_tasks_1 = require("../planner/utils/plan-work-tasks");
9
9
  const get_cache_directory_1 = require("../optimizer/get-cache-directory");
10
10
  const path_1 = require("path");
11
+ const crypto_1 = require("crypto");
12
+ const claim_layout_1 = require("../kubernetes/claim-layout");
11
13
  const move_files_1 = require("../file/move-files");
12
14
  const work_instance_id_1 = require("../planner/work-instance-id");
13
15
  async function restoreCache(environment, path, workTree) {
@@ -43,8 +45,24 @@ function* getArchivePaths(task, path) {
43
45
  }
44
46
  }
45
47
  else {
46
- for (const generate of task.generates.filter((g) => !g.inherited)) {
47
- const generateId = (0, path_1.relative)(task.cwd, generate.path).split(path_1.sep).join('-');
48
+ // `a-b` and `a/b` flatten alike: of those, the least deep (then the first in sorted
49
+ // order) keeps the name, the others' names carry a hash of their relative path, so a
50
+ // store and a restore in another directory or order find the same archive
51
+ const generates = task.generates
52
+ .filter((g) => !g.inherited)
53
+ .map((generate) => ({ generate, rel: (0, path_1.relative)(task.cwd, generate.path).split(path_1.sep).join(path_1.posix.sep) }));
54
+ const holders = new Map();
55
+ for (const { rel } of [...generates].sort((a, b) => (0, claim_layout_1.byDepth)(a.rel, b.rel))) {
56
+ const id = rel.split(path_1.posix.sep).join('-');
57
+ if (!holders.has(id)) {
58
+ holders.set(id, rel);
59
+ }
60
+ }
61
+ for (const { generate, rel } of generates) {
62
+ let generateId = rel.split(path_1.posix.sep).join('-');
63
+ if (holders.get(generateId) !== rel) {
64
+ generateId += '-' + (0, crypto_1.createHash)('sha256').update(rel).digest('hex').slice(0, 12);
65
+ }
48
66
  yield {
49
67
  filename: (0, path_1.join)(path, generateId + '-generates.tgz'),
50
68
  path: generate.path,
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.executeWorkTask = void 0;
4
+ const work_task_1 = require("../planner/work-task");
4
5
  const states_1 = require("./states");
5
6
  const abort_1 = require("./abort");
6
7
  const log_1 = require("../log");
@@ -12,7 +13,8 @@ const archive_task_entry_1 = require("./archive-task-entry");
12
13
  const units_1 = require("../utils/units");
13
14
  async function pushToBackend(work, environment, cacheState, options) {
14
15
  const { resolved, stateKey, provable } = cacheState;
15
- if (resolved.method === 'none' || !provable) {
16
+ // an aggregate has nothing to store, see checkCacheState
17
+ if (resolved.method === 'none' || !provable || (0, work_task_1.isAggregateTask)(work.data)) {
16
18
  return;
17
19
  }
18
20
  if (options.cacheReadOnly) {
@@ -29,7 +31,7 @@ async function pushToBackend(work, environment, cacheState, options) {
29
31
  }
30
32
  }
31
33
  // A task without cmds only groups its dependencies (`ci: { deps: [test, build] }`).
32
- // Having no src of its own it is never a cache hit, so its dependencies are
34
+ // It is never a cache hit itself (see checkCacheState), so its dependencies are
33
35
  // always checked and their outputs restored. When every one of them was a hit,
34
36
  // nothing ran: report the group as cached too. Reporting only, the cache
35
37
  // decision is untouched.
@@ -94,7 +96,7 @@ dependents = null) {
94
96
  stateKey: cacheState.stateKey,
95
97
  started,
96
98
  });
97
- const timeout = work.data.timeout ?? options.timeout;
99
+ const timeout = (0, deadline_1.getTaskTimeout)(work.data.timeout, options);
98
100
  const deadline = (0, deadline_1.withDeadline)(abort, timeout);
99
101
  try {
100
102
  await work.runtime.execute(environment, {
@@ -89,8 +89,11 @@ exports.prepareMounts = prepareMounts;
89
89
  async function setUserPermissions(item, container, environment) {
90
90
  if (item.data.user) {
91
91
  // On a server only the volumes: a chown as the container's root on a path of the host
92
- // would follow a link a task put there after the binds were checked
93
- const binds = (0, get_container_binds_1.getContainerBinds)(item).filter((bind) => !environment.hosted || !(0, path_1.isAbsolute)(bind.localPath));
92
+ // would follow a link a task put there after the binds were checked. Never a mount: it
93
+ // is a path of the host the build file names, such as /var/run/docker.sock, whose owner
94
+ // is not hammerkit's to change
95
+ const mounted = new Set(item.data.mounts.map((mount) => mount.containerPath));
96
+ const binds = (0, get_container_binds_1.getContainerBinds)(item).filter((bind) => !mounted.has(bind.containerPath) && (!environment.hosted || !(0, path_1.isAbsolute)(bind.localPath)));
94
97
  if (!environment.hosted) {
95
98
  await (0, set_user_permission_1.setUserPermission)(item.data.cwd, item.status, environment, container, item.data.user);
96
99
  }
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.checkCacheState = exports.UNCACHED_STATE_KEY = void 0;
4
4
  const work_item_1 = require("../../planner/work-item");
5
+ const work_task_1 = require("../../planner/work-task");
5
6
  const get_cache_directory_1 = require("../../optimizer/get-cache-directory");
6
7
  const cache_metadata_1 = require("../cache-metadata");
7
8
  const work_task_cache_description_1 = require("../../optimizer/work-task-cache-description");
@@ -35,6 +36,12 @@ options = {}) {
35
36
  item.status.write('debug', `${item.name} always runs, it or one of its dependencies has no src files`);
36
37
  return { cached: false, stateKey, resolved, provable };
37
38
  }
39
+ // An aggregate runs nothing, so running it costs nothing, and it is never a hit itself:
40
+ // asked for, it still has its dependencies checked and their outputs restored. Its key
41
+ // counts for what depends on it, and it is reported cached when they all were.
42
+ if ((0, work_item_1.isWorkTaskItem)(item) && (0, work_task_1.isAggregateTask)(item.data)) {
43
+ return { cached: false, stateKey, resolved, provable };
44
+ }
38
45
  if ((0, work_item_1.isWorkTaskItem)(item)) {
39
46
  const runtimeStateKey = await item.runtime.currentStateKey(environment);
40
47
  if (runtimeStateKey === stateKey) {
@@ -1,8 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.computeStateKey = exports.combineStateKeys = exports.resolveEffective = void 0;
4
- const crypto_1 = require("crypto");
3
+ exports.computeStateKey = exports.combineStateKeys = exports.isOwnProvable = exports.resolveEffective = void 0;
5
4
  const get_work_cache_stats_1 = require("../../optimizer/get-work-cache-stats");
5
+ const work_task_1 = require("../../planner/work-task");
6
6
  // The state-key engine is shared by the scheduler's hit/miss decision
7
7
  // (`checkCacheState`) and the read-only cache-explain engine, so a prediction
8
8
  // can never diverge from the decision it predicts — there is one implementation.
@@ -21,9 +21,14 @@ exports.resolveEffective = resolveEffective;
21
21
  // whose `src` entries all match no file (a typo, a moved path). Services are not
22
22
  // affected.
23
23
  function isOwnProvable(item, unmatched) {
24
+ if ((0, work_task_1.isAggregateTask)(item.data)) {
25
+ // it runs nothing and reads nothing: what its dependencies prove is all there is
26
+ return true;
27
+ }
24
28
  // unmatched ⊆ declared, so this is also false when nothing is declared
25
29
  return unmatched.length < item.data.src.filter((src) => !src.inherited).length;
26
30
  }
31
+ exports.isOwnProvable = isOwnProvable;
27
32
  // Fold a task's own state key together with the state keys of its dependencies.
28
33
  // A change anywhere in the dependency subtree must change the resulting key, so
29
34
  // that a downstream task is invalidated when an upstream source changes — even
@@ -34,9 +39,7 @@ function combineStateKeys(ownKey, depKeys) {
34
39
  }
35
40
  // Sort so the key is independent of dependency declaration order.
36
41
  const sorted = [...depKeys].sort();
37
- return (0, crypto_1.createHash)('md5')
38
- .update([ownKey, ...sorted].join(','))
39
- .digest('hex');
42
+ return (0, get_work_cache_stats_1.stateKeyDigest)([ownKey, ...sorted].join(','));
40
43
  }
41
44
  exports.combineStateKeys = combineStateKeys;
42
45
  // `none` only disables the cache *check* for a task; its sources still influence
@@ -72,7 +75,41 @@ async function computeStateKey(item, defaultCacheMethod, environment, memo = new
72
75
  depKeys.push(effective.stateKey);
73
76
  provable = provable && effective.provable;
74
77
  }
78
+ // what a task that needs a service passed against: the sources of the service and of the
79
+ // tasks it depends on (its definition is in the task id, getWorkServiceCacheIdentity)
80
+ for (const need of item.needs) {
81
+ const effective = await resolveNeedStateKey(need.service, defaultCacheMethod, environment, memo);
82
+ depKeys.push(`${need.name}=${effective.stateKey}`);
83
+ }
75
84
  return { stateKey: combineStateKeys(ownKey, depKeys), stats, resolved, provable, unmatched };
76
85
  }
77
86
  exports.computeStateKey = computeStateKey;
87
+ // A needed service's sources folded with the state keys of its dependencies and needs. It
88
+ // does not make the task that needs it unprovable: a dependency without `src` (resetting the
89
+ // service's data, say) is skipped with the service when the task is a cache hit.
90
+ async function resolveNeedStateKey(service, defaultCacheMethod, environment, memo) {
91
+ const memoKey = `need\0${service.name}`;
92
+ const existing = memo.get(memoKey);
93
+ if (existing !== undefined) {
94
+ return existing;
95
+ }
96
+ // services that need each other in a loop fail to plan (checkForLoop): this only has to end
97
+ memo.set(memoKey, { stateKey: 'loop', provable: true });
98
+ const resolved = resolveEffective(service, defaultCacheMethod);
99
+ const { stats } = await (0, get_work_cache_stats_1.getWorkItemCacheStats)(service, environment);
100
+ const keys = [];
101
+ for (const dep of service.deps) {
102
+ keys.push((await resolveEffectiveStateKey(dep, defaultCacheMethod, environment, memo)).stateKey);
103
+ }
104
+ for (const need of service.needs) {
105
+ const effective = await resolveNeedStateKey(need.service, defaultCacheMethod, environment, memo);
106
+ keys.push(`${need.name}=${effective.stateKey}`);
107
+ }
108
+ const effective = {
109
+ stateKey: combineStateKeys((0, get_work_cache_stats_1.getStateKey)(stats, concreteMethod(resolved, defaultCacheMethod)), keys),
110
+ provable: true,
111
+ };
112
+ memo.set(memoKey, effective);
113
+ return effective;
114
+ }
78
115
  //# sourceMappingURL=state-key.js.map
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getHarnessEnvironment = exports.connectSandbox = exports.harnessOptionsFromEnv = exports.DEFAULT_SHELL_TASK = void 0;
3
+ exports.getHarnessEnvironment = exports.connectSandbox = exports.connectServer = exports.hasShell = exports.harnessOptionsFromEnv = exports.DEFAULT_SHELL_TASK = void 0;
4
4
  const get_file_context_1 = require("../file/get-file-context");
5
5
  const log_1 = require("../log");
6
6
  const work_item_status_1 = require("../planner/work-item-status");
@@ -20,11 +20,21 @@ function harnessOptionsFromEnv(env) {
20
20
  repo: value('HAMMERKIT_REPO'),
21
21
  ref: value('HAMMERKIT_REF'),
22
22
  build: value('HAMMERKIT_BUILD'),
23
- shell: value('HAMMERKIT_SHELL'),
23
+ // set but empty is no shell, unset the default one
24
+ shell: env.HAMMERKIT_SHELL !== undefined ? env.HAMMERKIT_SHELL.trim() : undefined,
24
25
  };
25
26
  }
26
27
  exports.harnessOptionsFromEnv = harnessOptionsFromEnv;
27
- async function connectSandbox(environment, options, log = () => undefined) {
28
+ // whether the clients offer a shell tool
29
+ function hasShell(options) {
30
+ return options.shell !== '';
31
+ }
32
+ exports.hasShell = hasShell;
33
+ /**
34
+ * The registered server a harness works on and the published version it runs, checked:
35
+ * what the clients need to list the tasks of the version before they take a sandbox.
36
+ */
37
+ async function connectServer(environment, options) {
28
38
  const config = await (0, host_config_1.readHostConfig)(environment);
29
39
  const name = options.server ?? config.run.on;
30
40
  if (!name || !(0, host_config_1.hasServer)(config.servers, name)) {
@@ -39,8 +49,12 @@ async function connectSandbox(environment, options, log = () => undefined) {
39
49
  if (!bundle_1.HASH_PATTERN.test(options.build)) {
40
50
  throw new Error(`${options.build} is not the hash of a published version (sha256:...), name it as hammerkit remote publish printed it (HAMMERKIT_BUILD or --build)`);
41
51
  }
42
- const server = config.servers[name];
43
- const connection = { url: server.url, ...(0, token_source_1.getTokenSource)(environment, name, server) };
52
+ const server = (0, host_config_1.getRegisteredServer)(config, name);
53
+ return { name, connection: { url: server.url, ...(0, token_source_1.getTokenSource)(environment, name, server) }, build: options.build };
54
+ }
55
+ exports.connectServer = connectServer;
56
+ async function connectSandbox(environment, options, log = () => undefined) {
57
+ const { name, connection, build } = await connectServer(environment, options);
44
58
  let sandbox;
45
59
  if (options.sandbox) {
46
60
  sandbox = await sandbox_client_1.SandboxClient.get(connection, options.sandbox);
@@ -59,7 +73,7 @@ async function connectSandbox(environment, options, log = () => undefined) {
59
73
  else {
60
74
  throw new Error('name a sandbox (HAMMERKIT_SANDBOX or --sandbox), or a repository and ref to create one from (HAMMERKIT_REPO and HAMMERKIT_REF, or --repo and --ref)');
61
75
  }
62
- return new sandbox_client_1.SandboxClient(connection, sandbox, { hash: options.build, shell: options.shell ?? exports.DEFAULT_SHELL_TASK });
76
+ return new sandbox_client_1.SandboxClient(connection, sandbox, { hash: build, shell: options.shell ?? exports.DEFAULT_SHELL_TASK });
63
77
  }
64
78
  exports.connectSandbox = connectSandbox;
65
79
  // A client that starts with a repository and ref takes a sandbox of the caller's of the same
@@ -6,6 +6,15 @@ const log_1 = require("../log");
6
6
  const version_1 = require("../version");
7
7
  const connect_1 = require("./connect");
8
8
  const mcp_1 = require("./mcp");
9
+ const task_tools_1 = require("./task-tools");
10
+ // the tasks run_task lists; a server that does not answer leaves them out, the tools still work
11
+ async function listTasks(sandbox, log) {
12
+ const listing = await (0, task_tools_1.readTaskListing)(() => sandbox.tasks());
13
+ if (!Array.isArray(listing)) {
14
+ log(`the tasks of ${sandbox.build.hash} could not be listed: ${listing.error}`);
15
+ }
16
+ return listing;
17
+ }
9
18
  // `hammerkit remote mcp`: a Model Context Protocol server on stdio for one sandbox of a
10
19
  // server. It lives under `remote`, which already is a command: a top-level `mcp` would hide a
11
20
  // task named `mcp`, since `hammerkit <task>` runs a task. Like the other `remote` commands it
@@ -21,20 +30,27 @@ function registerMcpCommand(remote, environment, input = process.stdin) {
21
30
  .addOption(new commander_1.Option('--repo <repo>', 'create a sandbox of this repository').env('HAMMERKIT_REPO'))
22
31
  .addOption(new commander_1.Option('--ref <ref>', 'at this branch').env('HAMMERKIT_REF'))
23
32
  .addOption(new commander_1.Option('--build <hash>', 'hash of the published version of the build file whose tasks run on it').env('HAMMERKIT_BUILD'))
24
- .addOption(new commander_1.Option('--shell <task>', 'its task whose last command is a shell, like sh -c')
33
+ .addOption(new commander_1.Option('--shell <task>', "its task whose last command is a shell, like sh -c; '' for no bash tool")
25
34
  .env('HAMMERKIT_SHELL')
26
35
  .default(connect_1.DEFAULT_SHELL_TASK))
27
36
  .action(async (options) => {
28
37
  try {
29
38
  // stdout carries the protocol only, everything else goes to stderr
30
39
  const log = (line) => environment.stderr.write(`hammerkit remote mcp: ${line}\n`);
31
- const sandbox = await (0, connect_1.connectSandbox)(environment, options, log);
40
+ // as the plugins read it: blank is no shell
41
+ const sandbox = await (0, connect_1.connectSandbox)(environment, { ...options, shell: options.shell.trim() }, log);
32
42
  log(`sandbox ${sandbox.id} of ${sandbox.sandbox.repo} at ${sandbox.sandbox.commit}`);
43
+ const shell = sandbox.build.shell;
33
44
  const instructions = `The tools work on hammerkit sandbox ${sandbox.id}, a checkout of ${sandbox.sandbox.repo} at ` +
34
- `${sandbox.sandbox.commit} on a server. Paths are relative to its root. bash runs a command as the ` +
35
- `task ${sandbox.build.shell} of the published build file ${sandbox.build.hash}, run_task runs its other ` +
36
- 'tasks; only the files are kept between commands. changes shows what changed since the fetch.';
37
- const served = (0, mcp_1.serveMcp)(input, environment.stdout, (0, mcp_1.createMcpTools)(async () => sandbox), {
45
+ `${sandbox.sandbox.commit} on a server. Paths are relative to its root. ` +
46
+ (shell
47
+ ? `bash runs a command as the task ${shell} of the published build file ${sandbox.build.hash}, ` +
48
+ 'run_task runs its other tasks; only the files are kept between commands. '
49
+ : `run_task runs the tasks of the published build file ${sandbox.build.hash}; only the files are ` +
50
+ 'kept between runs. ') +
51
+ 'changes shows what changed since the fetch.';
52
+ const tasks = await listTasks(sandbox, log);
53
+ const served = (0, mcp_1.serveMcp)(input, environment.stdout, (0, mcp_1.createMcpTools)(async () => sandbox, { tasks, shell: shell || false }), {
38
54
  name: 'hammerkit',
39
55
  version: (0, version_1.getVersion)(),
40
56
  instructions,