hammerkit 1.27.0-next.9 → 2.0.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 (122) hide show
  1. package/build.schema.json +176 -6
  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/cache-sync.js +1 -1
  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/archive-task-entry.js +4 -2
  13. package/dist/executer/deadline.js +17 -3
  14. package/dist/executer/deferred-restore.js +36 -0
  15. package/dist/executer/docker-service.js +30 -8
  16. package/dist/executer/docker-task.js +21 -1
  17. package/dist/executer/execute-work-service.js +2 -0
  18. package/dist/executer/execute-work-task.js +11 -4
  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 +6 -3
  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 +194 -23
  30. package/dist/harness/service-tools.js +141 -0
  31. package/dist/harness/task-tools.js +221 -0
  32. package/dist/kubernetes/apply.js +49 -1
  33. package/dist/kubernetes/await-running-state.js +172 -13
  34. package/dist/kubernetes/cluster-state.js +245 -0
  35. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +133 -9
  36. package/dist/kubernetes/ensure-kubernetes-service-exists.js +2 -0
  37. package/dist/kubernetes/ensure-persistent-data.js +211 -6
  38. package/dist/kubernetes/follow-pod-logs.js +182 -0
  39. package/dist/kubernetes/job-placement.js +36 -12
  40. package/dist/kubernetes/kubernetes-instance.js +1 -0
  41. package/dist/kubernetes/network-policy.js +2 -1
  42. package/dist/kubernetes/remove-stale-items.js +46 -0
  43. package/dist/kubernetes/restore-kubernetes-data.js +9 -5
  44. package/dist/kubernetes/sandbox-runner.js +628 -0
  45. package/dist/kubernetes/service-rollouts.js +74 -0
  46. package/dist/kubernetes/store-kubernetes-data.js +5 -2
  47. package/dist/kubernetes/volumes.js +94 -9
  48. package/dist/main.js +2 -0
  49. package/dist/optimizer/calculate-checksum.js +3 -2
  50. package/dist/optimizer/check-key.js +94 -0
  51. package/dist/optimizer/check-keys.js +64 -0
  52. package/dist/optimizer/get-work-cache-stats.js +17 -3
  53. package/dist/optimizer/work-task-cache-description.js +49 -1
  54. package/dist/planner/service-definition.js +9 -4
  55. package/dist/planner/utils/append-work-dependencies.js +5 -3
  56. package/dist/planner/utils/append-work-service.js +18 -1
  57. package/dist/planner/utils/append-work-task.js +13 -3
  58. package/dist/planner/utils/get-work-runtime.js +6 -1
  59. package/dist/planner/utils/normalize-path.js +19 -1
  60. package/dist/planner/utils/parse-work-mount.js +7 -1
  61. package/dist/planner/utils/parse-work-port.js +4 -1
  62. package/dist/planner/validate.js +22 -0
  63. package/dist/planner/work-network.js +3 -1
  64. package/dist/planner/work-runtime-aggregate.js +32 -0
  65. package/dist/planner/work-runtime-kubernetes.js +136 -13
  66. package/dist/planner/work-task.js +6 -1
  67. package/dist/program.js +27 -9
  68. package/dist/remote/device-login.js +7 -2
  69. package/dist/remote/host-config.js +69 -3
  70. package/dist/remote/remote-commands.js +33 -7
  71. package/dist/remote/remote-status.js +112 -0
  72. package/dist/remote/remote-work-commands.js +562 -45
  73. package/dist/remote/server-client.js +15 -2
  74. package/dist/remote/token-source.js +64 -26
  75. package/dist/run-program.js +9 -0
  76. package/dist/schema/build-file-base-task-schema.js +3 -3
  77. package/dist/schema/build-file-container-service-schema.js +10 -0
  78. package/dist/schema/build-file-container-task-schema.js +16 -1
  79. package/dist/schema/build-file-include-schema.js +18 -4
  80. package/dist/schema/build-file-kubernetes-service-schema.js +2 -0
  81. package/dist/schema/build-file-task-schema.js +5 -1
  82. package/dist/schema/capabilities-schema.js +56 -0
  83. package/dist/schema/reference-parser.js +119 -11
  84. package/dist/schema/removed-properties.js +20 -0
  85. package/dist/schema/schema-parser.js +94 -41
  86. package/dist/schema/work-scope-parser.js +33 -10
  87. package/dist/secrets/command-secret-provider.js +3 -1
  88. package/dist/secrets/github-app-token-command.js +3 -1
  89. package/dist/secrets/secret-catalog.js +43 -19
  90. package/dist/server/accounts-api.js +4 -1
  91. package/dist/server/approvals-api.js +1 -1
  92. package/dist/server/capacity-format.js +36 -0
  93. package/dist/server/capacity.js +300 -0
  94. package/dist/server/checks-api.js +83 -0
  95. package/dist/server/checks.js +116 -0
  96. package/dist/server/dashboard.js +196 -0
  97. package/dist/server/definitions-api.js +66 -3
  98. package/dist/server/endpoints.js +794 -0
  99. package/dist/server/executor.js +164 -9
  100. package/dist/server/kubernetes-backend.js +415 -48
  101. package/dist/server/kubernetes-sandbox-storage.js +46 -2
  102. package/dist/server/overview.js +271 -0
  103. package/dist/server/plan.js +170 -121
  104. package/dist/server/policy.js +34 -2
  105. package/dist/server/proposals-api.js +17 -8
  106. package/dist/server/repo-access.js +5 -0
  107. package/dist/server/run-logs.js +4 -0
  108. package/dist/server/run-plan.js +38 -4
  109. package/dist/server/run-request.js +58 -3
  110. package/dist/server/run-worker.js +262 -13
  111. package/dist/server/runs.js +962 -36
  112. package/dist/server/sandbox-pod.js +28 -0
  113. package/dist/server/sandbox-storage.js +23 -0
  114. package/dist/server/sandboxes.js +8 -0
  115. package/dist/server/server-config.js +246 -10
  116. package/dist/server/server.js +429 -32
  117. package/dist/server/service-runs.js +82 -0
  118. package/dist/server/timings.js +132 -0
  119. package/dist/server/wait-reason.js +21 -0
  120. package/dist/utils/tar.js +28 -1
  121. package/dist/utils/terminal-prompt.js +53 -0
  122. package/package.json +8 -2
@@ -1,12 +1,17 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.runRequestSchema = exports.runOptionsSchema = exports.MAX_ARGS_BYTES = exports.MAX_ARGS = void 0;
3
+ exports.runRequestSchema = exports.runOptionsSchema = exports.MAX_ARGS_BYTES = exports.MAX_ARGS = exports.MAX_ENDPOINTS = exports.SERVICE_RUN_NAME_PATTERN = void 0;
4
4
  const zod_1 = require("zod");
5
5
  const units_1 = require("../utils/units");
6
6
  const bundle_1 = require("../remote/bundle");
7
7
  const repo_1 = require("./repo");
8
8
  const runs_1 = require("./runs");
9
9
  const name = zod_1.z.string().min(1).max(200);
10
+ // The name of a service run, one DNS label: the hosts of its endpoints carry it, and a run of
11
+ // the same caller with the same name replaces it
12
+ exports.SERVICE_RUN_NAME_PATTERN = /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/;
13
+ // endpoints one service run may publish
14
+ exports.MAX_ENDPOINTS = 5;
10
15
  // what one run may pass after `--`
11
16
  exports.MAX_ARGS = 100;
12
17
  exports.MAX_ARGS_BYTES = 16 * 1024;
@@ -38,6 +43,8 @@ exports.runOptionsSchema = zod_1.z
38
43
  cacheReadOnly: zod_1.z.boolean().optional(),
39
44
  // as --timeout
40
45
  timeout: duration.optional(),
46
+ // as --max-timeout: caps every task, also one with a longer timeout of its own
47
+ maxTimeout: duration.optional(),
41
48
  // as --no-skip-deps when false
42
49
  skipDeps: zod_1.z.boolean().optional(),
43
50
  // as the text after `--`: appended to the last command of the one task the run
@@ -87,18 +94,66 @@ exports.runRequestSchema = zod_1.z
87
94
  zod_1.z.object({ type: zod_1.z.literal('upload'), snapshot: zod_1.z.string().regex(bundle_1.HASH_PATTERN) }).strict(),
88
95
  // a sandbox of the caller's, see POST /v1/sandboxes
89
96
  zod_1.z
90
- .object({ type: zod_1.z.literal('sandbox'), id: zod_1.z.string().regex(runs_1.SANDBOX_ID_PATTERN, 'expected a sandbox id') })
97
+ .object({
98
+ type: zod_1.z.literal('sandbox'),
99
+ id: zod_1.z.string().regex(runs_1.SANDBOX_ID_PATTERN, 'expected a sandbox id'),
100
+ // a check: run on a copy of the files a push of the sandbox would commit, so what
101
+ // passes counts for that commit (remote verify)
102
+ check: zod_1.z.literal(true).optional(),
103
+ })
91
104
  .strict(),
92
105
  ]),
93
106
  options: exports.runOptionsSchema.default({}),
107
+ // a service run: these services of the build file, started as `hammerkit up` starts
108
+ // them and kept up until `ttl` ends or the run is cancelled, instead of tasks
109
+ services: zod_1.z.array(name).max(20).default([]),
110
+ // how long the services stay once they are ready, whether they sleep in between or not;
111
+ // `runs.up.ttl` when left out. At its end they are removed.
112
+ ttl: duration.optional(),
113
+ // how long the services stay up without a request to one of the endpoints, then they
114
+ // sleep until the next request wakes them; `runs.up.sleepTtl` when left out
115
+ sleepTtl: duration.optional(),
116
+ // a second service run of the caller with the same name replaces the first; the branch
117
+ // of the source when left out
118
+ name: zod_1.z
119
+ .string()
120
+ .regex(exports.SERVICE_RUN_NAME_PATTERN, 'expected lower case letters, digits and -, at most 40, as a DNS label')
121
+ .optional(),
122
+ // ports of the run's services published at hosts of their own, behind the server's sign-in
123
+ endpoints: zod_1.z
124
+ .array(zod_1.z.object({ service: name, port: zod_1.z.number().int().min(1).max(65535) }).strict())
125
+ .max(exports.MAX_ENDPOINTS)
126
+ .default([]),
94
127
  })
95
128
  .strict()
96
- .refine((request) => request.tasks.length > 0 || (request.options.filter ?? []).length > 0, {
129
+ .refine((request) => request.services.length > 0 || request.tasks.length > 0 || (request.options.filter ?? []).length > 0, {
97
130
  message: 'name at least one task, or filter by labels',
98
131
  path: ['tasks'],
99
132
  })
133
+ .refine((request) => request.services.length === 0 || request.tasks.length === 0, {
134
+ message: 'a run names tasks or services, not both',
135
+ path: ['services'],
136
+ })
137
+ .refine((request) => request.services.length === 0 ||
138
+ ((request.options.filter ?? []).length === 0 && (request.options.exclude ?? []).length === 0), { message: 'a service run names its services, labels do not select them', path: ['options', 'filter'] })
139
+ .refine((request) => request.services.length === 0 || (request.options.args ?? []).length === 0, {
140
+ message: 'a service run takes no arguments',
141
+ path: ['options', 'args'],
142
+ })
143
+ .refine((request) => request.services.length > 0 ||
144
+ (request.ttl === undefined &&
145
+ request.sleepTtl === undefined &&
146
+ request.name === undefined &&
147
+ request.endpoints.length === 0), { message: 'ttl, sleepTtl, name and endpoints are for a run of services', path: ['services'] })
148
+ .refine((request) => new Set(request.endpoints.map((endpoint) => `${endpoint.service}:${endpoint.port}`)).size ===
149
+ request.endpoints.length, { message: 'an endpoint is named twice', path: ['endpoints'] })
100
150
  .refine((request) => (request.options.args ?? []).length === 0 || request.tasks.length === 1, {
101
151
  message: 'arguments go to one task, name exactly one in tasks',
102
152
  path: ['options', 'args'],
153
+ })
154
+ .refine((request) => request.source.type !== 'sandbox' || !request.source.check || (request.options.args ?? []).length === 0, { message: 'a check runs the task as the build file says, it takes no arguments', path: ['options', 'args'] })
155
+ .refine((request) => request.source.type !== 'sandbox' || !request.source.check || request.services.length === 0, {
156
+ message: 'a check runs tasks, a run of services keeps them up and passes nothing',
157
+ path: ['services'],
103
158
  });
104
159
  //# sourceMappingURL=run-request.js.map
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.workerMain = exports.taskCauses = exports.runTasks = exports.getRunNetwork = exports.workerInputSchema = void 0;
3
+ exports.workerMain = exports.readInput = exports.taskCauses = exports.describeFailures = exports.runTasks = exports.getEndpointTargets = exports.publishEndpoints = exports.getRunNetwork = exports.workerInputSchema = void 0;
4
4
  const readline_1 = require("readline");
5
5
  const zod_1 = require("zod");
6
6
  const cli_1 = require("../cli");
@@ -21,6 +21,12 @@ const network_schema_1 = require("../schema/network-schema");
21
21
  const work_network_1 = require("../planner/work-network");
22
22
  const run_plan_1 = require("./run-plan");
23
23
  const runs_1 = require("./runs");
24
+ const resources_1 = require("../kubernetes/resources");
25
+ const remove_stale_items_1 = require("../kubernetes/remove-stale-items");
26
+ const service_rollouts_1 = require("../kubernetes/service-rollouts");
27
+ const cluster_state_1 = require("../kubernetes/cluster-state");
28
+ const kubernetes_instance_1 = require("../kubernetes/kubernetes-instance");
29
+ const check_keys_1 = require("../optimizer/check-keys");
24
30
  // one log line of a task is cut here, the registry caps how many events a run keeps
25
31
  const MAX_LOG_TEXT = 8 * 1024;
26
32
  /**
@@ -49,6 +55,9 @@ exports.workerInputSchema = zod_1.z
49
55
  kubernetes: zod_1.z
50
56
  .custom((value) => typeof value === 'object' && value !== null && value.type === 'kubernetes')
51
57
  .optional(),
58
+ // on the server's docker host, the runtime a container that adds capabilities runs in
59
+ // (`backend.capabilities.runtime`)
60
+ capabilityRuntime: zod_1.z.string().min(1).optional(),
52
61
  // the most any task or service reaches besides its needs, as `--network`: `runs.network`
53
62
  network: network_schema_1.networkSchema.optional(),
54
63
  // for a run on a sandbox, `sandboxes.network`: the run gets the lower of the two
@@ -57,8 +66,19 @@ exports.workerInputSchema = zod_1.z
57
66
  // volumes (see scopeServiceVolumes)
58
67
  volumeScope: zod_1.z
59
68
  .string()
60
- .regex(/^[a-z]+-[0-9a-f]{24}$/)
69
+ .regex(/^(?:[a-z]+|up-[0-9a-f]{8})-[0-9a-f]{24}$/)
61
70
  .optional(),
71
+ // a service run: the services it keeps up instead of tasks, and the ports the server
72
+ // reaches as endpoints
73
+ up: zod_1.z
74
+ .object({
75
+ services: zod_1.z.array(zod_1.z.string()).min(1),
76
+ endpoints: zod_1.z.array(zod_1.z.object({ service: zod_1.z.string(), port: zod_1.z.number().int() }).strict()),
77
+ })
78
+ .strict()
79
+ .optional(),
80
+ // the run's results count as checks: report each task's check key before it runs
81
+ check: zod_1.z.boolean().optional(),
62
82
  })
63
83
  .strict();
64
84
  // The ceiling of the run, combined here and not in the server: the lower of two allow lists
@@ -68,6 +88,60 @@ function getRunNetwork(input) {
68
88
  return input.sandboxNetwork ? (0, work_network_1.limitNetwork)((0, work_network_1.parseWorkNetwork)(input.sandboxNetwork), runs) : runs;
69
89
  }
70
90
  exports.getRunNetwork = getRunNetwork;
91
+ /**
92
+ * The ports of the services of a service run as the server publishes them: on a host the
93
+ * ports of endpoints go to a free port of the loopback interface, where only the server
94
+ * reaches them, and no other port goes to the host, whatever the build file says (`up`
95
+ * would publish them on every interface, where two runs collide). On a cluster nothing is
96
+ * published, the server reaches the Service of each.
97
+ */
98
+ function publishEndpoints(workTree, endpoints, cluster) {
99
+ for (const service of Object.values(workTree.services)) {
100
+ if (!('ports' in service.data)) {
101
+ continue;
102
+ }
103
+ service.data.ports = service.data.ports.map((port) => !cluster &&
104
+ endpoints.some((endpoint) => endpoint.service === service.name && endpoint.port === port.containerPort)
105
+ ? { containerPort: port.containerPort, hostPort: 0, hostIp: '127.0.0.1' }
106
+ : { containerPort: port.containerPort, hostPort: null });
107
+ }
108
+ }
109
+ exports.publishEndpoints = publishEndpoints;
110
+ // Where the server reaches each endpoint, once every service the run keeps up is ready;
111
+ // null until then.
112
+ function getEndpointTargets(workTree, up, kubernetes) {
113
+ const services = Object.values(workTree.services).filter((service) => up.services.includes(service.name));
114
+ if (services.length < up.services.length || services.some((service) => service.state.current.type !== 'running')) {
115
+ return null;
116
+ }
117
+ const targets = [];
118
+ for (const endpoint of up.endpoints) {
119
+ const service = services.find((candidate) => candidate.name === endpoint.service);
120
+ const state = service?.state.current;
121
+ if (!service || state?.type !== 'running') {
122
+ return null;
123
+ }
124
+ if (kubernetes) {
125
+ targets.push({
126
+ service: endpoint.service,
127
+ port: endpoint.port,
128
+ address: { host: `${(0, resources_1.getResourceName)(service, kubernetes)}.${kubernetes.namespace}.svc`, port: endpoint.port },
129
+ });
130
+ continue;
131
+ }
132
+ const published = state.published?.find((candidate) => candidate.containerPort === endpoint.port);
133
+ if (!published) {
134
+ throw new Error(`${endpoint.service} is up, but its port ${endpoint.port} was not published`);
135
+ }
136
+ targets.push({
137
+ service: endpoint.service,
138
+ port: endpoint.port,
139
+ address: { host: published.host, port: published.port },
140
+ });
141
+ }
142
+ return targets;
143
+ }
144
+ exports.getEndpointTargets = getEndpointTargets;
71
145
  /**
72
146
  * Executes the tasks of a run with the engine of `hammerkit run`. The caller says which
73
147
  * process this is in: it reports what happens through `emit` and stops when `abortCtrl`
@@ -75,7 +149,9 @@ exports.getRunNetwork = getRunNetwork;
75
149
  */
76
150
  async function runTasks(input, cwd, processEnvs, abortCtrl,
77
151
  // whether the run was stopped from outside; the engine aborts too when a task fails
78
- cancelled, emit) {
152
+ cancelled, emit,
153
+ // a service run: its services are ready, at these addresses
154
+ up) {
79
155
  const secrets = (0, redact_1.createSecretRegistry)();
80
156
  const status = (0, work_item_status_1.statusConsole)((0, empty_writable_1.emptyWritable)(), secrets);
81
157
  // the process starts in a directory of the server, the tasks work on the run's files
@@ -84,7 +160,12 @@ cancelled, emit) {
84
160
  // is what is streamed: it is redacted with the secrets of this run
85
161
  const environment = {
86
162
  processEnvs,
87
- hosted: { catalog: input.catalog, accounts: input.accounts, kubernetes: input.kubernetes },
163
+ hosted: {
164
+ catalog: input.catalog,
165
+ accounts: input.accounts,
166
+ kubernetes: input.kubernetes,
167
+ capabilityRuntime: input.capabilityRuntime,
168
+ },
88
169
  abortCtrl,
89
170
  cwd: work,
90
171
  file: (0, get_file_context_1.getFileContext)(work),
@@ -117,15 +198,27 @@ cancelled, emit) {
117
198
  const options = input.options;
118
199
  const execOptions = {
119
200
  logMode: 'live',
120
- cacheDefault: options.cache ?? 'checksum',
201
+ // a check takes a cached result only for the same files, never for the same dates
202
+ cacheDefault: input.check && options.cache === 'modify-date' ? 'checksum' : options.cache ?? 'checksum',
121
203
  cacheReadOnly: options.cacheReadOnly ?? false,
122
204
  timeout: options.timeout ? (0, units_1.parseDuration)(options.timeout) : null,
205
+ maxTimeout: options.maxTimeout ? (0, units_1.parseDuration)(options.maxTimeout) : null,
123
206
  skipDeps: options.skipDeps !== false,
124
207
  network: getRunNetwork(input),
125
208
  // why a task was not taken from the cache goes into its result
126
209
  explain: true,
127
210
  };
128
- for (const { name, scope } of (0, run_plan_1.getRunScopes)(input.tasks, options)) {
211
+ const scopes = (0, run_plan_1.getRunScopes)(input.tasks, options, input.up?.services);
212
+ if (input.check) {
213
+ // the files as they are before any task wrote to them, a second task's too: a first
214
+ // one that formats or generates into src would key it by files no commit holds
215
+ for (const { scope } of scopes) {
216
+ for (const [task, { key }] of Object.entries(await (0, check_keys_1.getCheckKeys)(input.entry, environment, scope))) {
217
+ emit({ type: 'key', task, key });
218
+ }
219
+ }
220
+ }
221
+ for (const { name, scope } of scopes) {
129
222
  const { ctx, scope: parsed } = await (0, schema_parser_1.createParseContext)(input.entry, environment, {
130
223
  directory: input.directory,
131
224
  });
@@ -134,23 +227,75 @@ cancelled, emit) {
134
227
  if (input.volumeScope && !input.kubernetes) {
135
228
  (0, hosted_volumes_1.scopeServiceVolumes)(workTree, input.volumeScope);
136
229
  }
230
+ if (input.up) {
231
+ publishEndpoints(workTree, input.up.endpoints, !!input.kubernetes);
232
+ }
137
233
  const cli = (0, cli_1.getCli)(workTree, environment);
138
- if (cli.tasks().length === 0) {
234
+ if (cli.tasks().length === 0 && (!input.up || cli.services().length === 0)) {
139
235
  throw new Error(`no tasks found for ${name}`);
140
236
  }
141
- const execution = cli.exec({ ...execOptions, workers: cli.defaultWorkers() });
237
+ const execution = input.up
238
+ ? cli.up({ ...execOptions, workers: cli.defaultWorkers(), daemon: false, wait: 'ready' })
239
+ : cli.exec({ ...execOptions, workers: cli.defaultWorkers() });
142
240
  const seen = new Map();
143
- const report = () => reportTasks(emit, execution.state.current, seen, (text) => secrets.redact(text));
241
+ const failedServices = new Set();
242
+ let ready = false;
243
+ const report = () => {
244
+ reportTasks(emit, execution.state.current, seen, (text) => secrets.redact(text));
245
+ reportServiceFailures(emit, execution.state.current, failedServices, (text) => secrets.redact(text));
246
+ if (input.up && up && !ready) {
247
+ const targets = getEndpointTargets(execution.state.current, input.up, input.kubernetes);
248
+ if (targets) {
249
+ ready = true;
250
+ up(targets);
251
+ }
252
+ }
253
+ };
144
254
  const watch = execution.state.on('run-events', report);
145
255
  try {
146
256
  const result = await execution.start();
147
257
  report();
258
+ // on a cluster the services stay in their Deployments once they are ready, until
259
+ // the namespace of the run goes
260
+ if (input.up && input.kubernetes && ready && result.success && !abortCtrl.signal.aborted) {
261
+ if (input.kubernetes.server?.serviceRun) {
262
+ // a run that moved here from another commit: what it made for items it has no more
263
+ const ids = [...Object.values(workTree.tasks), ...Object.values(workTree.services)].map((item) => item.id());
264
+ await (0, remove_stale_items_1.removeStaleItems)((0, kubernetes_instance_1.createKubernetesInstances)(input.kubernetes), input.kubernetes, ids).catch((e) => emit({
265
+ type: 'log',
266
+ task: 'hammerkit',
267
+ stream: 'stderr',
268
+ text: `what the run made at its previous commit could not be removed: ${(0, log_1.getErrorMessage)(e)}`,
269
+ }));
270
+ }
271
+ await new Promise((resolve) => abortCtrl.signal.addEventListener('abort', () => resolve(), { once: true }));
272
+ return;
273
+ }
274
+ if (input.up && ready) {
275
+ if (!abortCtrl.signal.aborted) {
276
+ throw new Error('the services stopped on their own');
277
+ }
278
+ return;
279
+ }
148
280
  if (!result.success) {
149
- throw new Error(cancelled() ? 'the run was cancelled' : `${name} failed`);
281
+ if (cancelled()) {
282
+ throw new Error('the run was cancelled');
283
+ }
284
+ if (input.kubernetes) {
285
+ // what the cluster said, before the server removes what the run made
286
+ await reportClusterState(input.kubernetes, execution.state.current, !!input.up, emit, (text) => secrets.redact(text));
287
+ }
288
+ // a service run names what failed and why: its services have no results of their own
289
+ const failures = input.up ? describeFailures(execution.state.current, (text) => secrets.redact(text)) : [];
290
+ throw new Error(failures.length > 0 ? failures.join('; ').slice(0, runs_1.MAX_TASK_ERROR) : `${name} failed`);
150
291
  }
151
292
  }
152
293
  finally {
153
294
  watch.close();
295
+ if (input.up && input.kubernetes?.server?.serviceRun && !ready && service_rollouts_1.serviceRollouts.size > 0) {
296
+ // a move (or a start) whose services did not all get up: the run stays where it was
297
+ await service_rollouts_1.serviceRollouts.undo((0, kubernetes_instance_1.createKubernetesInstances)(input.kubernetes), (text) => emit({ type: 'log', task: 'hammerkit', stream: 'stderr', text }));
298
+ }
154
299
  }
155
300
  }
156
301
  }
@@ -159,6 +304,82 @@ cancelled, emit) {
159
304
  }
160
305
  }
161
306
  exports.runTasks = runTasks;
307
+ // What the cluster said of a run that failed, into its log: the server removes its pods
308
+ // once it ended.
309
+ async function reportClusterState(kubernetes, workTree, ownNamespace, emit, redact) {
310
+ let lines;
311
+ try {
312
+ lines = await (0, cluster_state_1.readClusterState)((0, kubernetes_instance_1.createKubernetesInstances)(kubernetes), kubernetes, workTree, ownNamespace);
313
+ }
314
+ catch (e) {
315
+ lines = [`it could not be read: ${(0, log_1.getErrorMessage)(e)}`];
316
+ }
317
+ if (lines.length === 0) {
318
+ return;
319
+ }
320
+ for (const text of [`what the cluster said in ${kubernetes.namespace}:`, ...lines.map((line) => ` ${line}`)]) {
321
+ emit({ type: 'log', task: 'hammerkit', stream: 'stderr', text: redact(text).slice(0, MAX_LOG_TEXT) });
322
+ }
323
+ }
324
+ // A service has no result of its own in the run: why it failed goes into the log, under
325
+ // its name, once.
326
+ function reportServiceFailures(emit, workTree, said, redact) {
327
+ for (const service of (0, plan_work_tasks_1.iterateWorkServices)(workTree)) {
328
+ const state = service.state.current;
329
+ if (state.type === 'error' && !said.has(service.name)) {
330
+ said.add(service.name);
331
+ if (lastError(service) === state.errorMessage) {
332
+ // the runtime said it already
333
+ continue;
334
+ }
335
+ emit({
336
+ type: 'log',
337
+ task: service.name,
338
+ stream: 'stderr',
339
+ text: redact(state.errorMessage).slice(0, MAX_LOG_TEXT),
340
+ });
341
+ }
342
+ }
343
+ }
344
+ // The last error the runtime of an item wrote to its status.
345
+ function lastError(item) {
346
+ let last;
347
+ for (const message of item.status.read()) {
348
+ if (message.level === 'error') {
349
+ last = message.message;
350
+ }
351
+ }
352
+ return last;
353
+ }
354
+ /**
355
+ * What failed in a run, one line each, for its error: the services and tasks that failed,
356
+ * with their reason. Those that only stopped because another failed are left out.
357
+ */
358
+ function describeFailures(workTree, redact) {
359
+ const failures = [];
360
+ for (const service of (0, plan_work_tasks_1.iterateWorkServices)(workTree)) {
361
+ const state = service.state.current;
362
+ if (state.type === 'error') {
363
+ failures.push(`${service.name}: ${redact(state.errorMessage)}`);
364
+ }
365
+ else if (state.type === 'end' && state.reason === 'crash') {
366
+ // on Docker a service that does not get ready ends, and its runtime says why
367
+ const why = lastError(service);
368
+ failures.push(`${service.name}: ${why ? redact(why) : 'it stopped on its own'}`);
369
+ }
370
+ }
371
+ for (const task of (0, plan_work_tasks_1.iterateWorkTasks)(workTree)) {
372
+ const state = task.state.current;
373
+ if (state.type === 'error') {
374
+ failures.push(`${task.name}: ${redact(state.errorMessage)}`);
375
+ }
376
+ else if (state.type === 'crash') {
377
+ failures.push(`${task.name}: exit code ${state.exitCode}`);
378
+ }
379
+ }
380
+ return failures;
381
+ }
382
+ exports.describeFailures = describeFailures;
162
383
  // Why a task ran, as a task event carries it: the first causes, masked and cut short.
163
384
  function taskCauses(causes, redact) {
164
385
  return causes?.length
@@ -217,9 +438,13 @@ redact) {
217
438
  // The run is the first line of the standard input. The server keeps it open and writes
218
439
  // `cancel` on it to stop the run: that works the same on every platform, where a signal
219
440
  // on Windows ends the process without letting it stop its tasks.
441
+ // `onEnd` is told when the input ends before the run does: the server is gone.
220
442
  function readInput(stdin, cancel) {
221
443
  const lines = (0, readline_1.createInterface)({ input: stdin, crlfDelay: Infinity });
222
444
  let first;
445
+ let closing = false;
446
+ let ended = false;
447
+ let onEnd;
223
448
  const input = new Promise((resolve) => {
224
449
  lines.on('line', (line) => {
225
450
  if (first === undefined) {
@@ -230,10 +455,29 @@ function readInput(stdin, cancel) {
230
455
  cancel();
231
456
  }
232
457
  });
233
- lines.on('close', () => resolve(first ?? ''));
458
+ lines.on('close', () => {
459
+ resolve(first ?? '');
460
+ if (!closing) {
461
+ ended = true;
462
+ onEnd?.();
463
+ }
464
+ });
234
465
  });
235
- return { input, close: () => lines.close() };
466
+ return {
467
+ input,
468
+ close: () => {
469
+ closing = true;
470
+ lines.close();
471
+ },
472
+ onEnd: (listener) => {
473
+ onEnd = listener;
474
+ if (ended) {
475
+ listener();
476
+ }
477
+ },
478
+ };
236
479
  }
480
+ exports.readInput = readInput;
237
481
  /**
238
482
  * The process of one run: reads its input, executes the tasks in the current directory
239
483
  * and reports as lines of JSON. It exits 0 when it could say how the run ended, and the
@@ -260,8 +504,13 @@ async function workerMain(stdin, stdout, processEnvs, cwd, abortCtrl) {
260
504
  say({ kind: 'result', ok: false, error: `the run could not be read: ${e instanceof Error ? e.message : e}` });
261
505
  return;
262
506
  }
507
+ if (input.up) {
508
+ // services stay until they are stopped: without the server that would stop them, at
509
+ // their ttl or on a cancel, nothing would
510
+ reader.onEnd(stop);
511
+ }
263
512
  try {
264
- await runTasks(input, cwd, processEnvs, abortCtrl, () => cancelled, (event) => say({ kind: 'event', event }));
513
+ await runTasks(input, cwd, processEnvs, abortCtrl, () => cancelled, (event) => say({ kind: 'event', event }), (targets) => say({ kind: 'up', targets }));
265
514
  say({ kind: 'result', ok: true });
266
515
  }
267
516
  catch (e) {