hammerkit 1.27.0-next.2 → 1.27.0-next.21

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 (103) hide show
  1. package/build.schema.json +27 -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 +25 -4
  11. package/dist/executer/reset-work-tree.js +4 -0
  12. package/dist/executer/scheduler/enqueue-next.js +7 -0
  13. package/dist/executer/scheduler/state-key.js +42 -5
  14. package/dist/harness/connect.js +20 -6
  15. package/dist/harness/mcp-command.js +22 -6
  16. package/dist/harness/mcp.js +63 -114
  17. package/dist/harness/opencode.js +158 -50
  18. package/dist/harness/pi.js +29 -0
  19. package/dist/harness/sandbox-client.js +207 -65
  20. package/dist/harness/service-tools.js +141 -0
  21. package/dist/harness/task-tools.js +221 -0
  22. package/dist/kubernetes/apply.js +27 -16
  23. package/dist/kubernetes/claim-layout.js +71 -0
  24. package/dist/kubernetes/ensure-http-route.js +1 -1
  25. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +5 -9
  26. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +1 -1
  27. package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
  28. package/dist/kubernetes/ensure-persistent-data.js +30 -20
  29. package/dist/kubernetes/get-service-ip.js +2 -2
  30. package/dist/kubernetes/job-secrets.js +33 -19
  31. package/dist/kubernetes/kept-claims.js +22 -10
  32. package/dist/kubernetes/network-policy.js +85 -27
  33. package/dist/kubernetes/remove-persistent-data.js +3 -1
  34. package/dist/kubernetes/resources.js +25 -3
  35. package/dist/kubernetes/restore-kubernetes-data.js +9 -3
  36. package/dist/kubernetes/sandbox-runner.js +626 -0
  37. package/dist/kubernetes/secrets.js +7 -11
  38. package/dist/kubernetes/volumes.js +6 -3
  39. package/dist/network/egress-proxy.js +45 -6
  40. package/dist/optimizer/calculate-checksum.js +3 -2
  41. package/dist/optimizer/check-key.js +94 -0
  42. package/dist/optimizer/check-keys.js +64 -0
  43. package/dist/optimizer/get-work-cache-stats.js +17 -3
  44. package/dist/optimizer/work-task-cache-description.js +51 -1
  45. package/dist/planner/utils/append-work-dependencies.js +10 -5
  46. package/dist/planner/utils/append-work-service.js +1 -0
  47. package/dist/planner/utils/append-work-task.js +15 -2
  48. package/dist/planner/utils/get-work-runtime.js +6 -1
  49. package/dist/planner/utils/normalize-path.js +19 -1
  50. package/dist/planner/utils/parse-work-mount.js +7 -1
  51. package/dist/planner/utils/parse-work-port.js +4 -1
  52. package/dist/planner/utils/task-arguments.js +3 -0
  53. package/dist/planner/validate.js +22 -0
  54. package/dist/planner/work-network.js +3 -1
  55. package/dist/planner/work-runtime-aggregate.js +32 -0
  56. package/dist/planner/work-runtime-docker.js +2 -1
  57. package/dist/planner/work-runtime-kubernetes.js +97 -48
  58. package/dist/planner/work-runtime-local.js +2 -1
  59. package/dist/planner/work-task.js +6 -1
  60. package/dist/program.js +35 -13
  61. package/dist/remote/annotations.js +13 -1
  62. package/dist/remote/device-login.js +7 -2
  63. package/dist/remote/host-config.js +64 -3
  64. package/dist/remote/remote-commands.js +41 -9
  65. package/dist/remote/remote-work-commands.js +246 -1
  66. package/dist/remote/server-client.js +15 -2
  67. package/dist/remote/token-source.js +64 -26
  68. package/dist/run-program.js +9 -0
  69. package/dist/schema/build-file-base-task-schema.js +5 -1
  70. package/dist/schema/build-file-container-service-schema.js +2 -0
  71. package/dist/schema/build-file-include-schema.js +10 -4
  72. package/dist/schema/reference-parser.js +9 -1
  73. package/dist/schema/schema-parser.js +36 -4
  74. package/dist/schema/work-scope-parser.js +33 -10
  75. package/dist/secrets/github-app-token-command.js +3 -1
  76. package/dist/server/accounts-api.js +3 -1
  77. package/dist/server/approvals-api.js +1 -1
  78. package/dist/server/checks-api.js +83 -0
  79. package/dist/server/checks.js +116 -0
  80. package/dist/server/definitions-api.js +4 -2
  81. package/dist/server/endpoints.js +612 -0
  82. package/dist/server/executor.js +56 -5
  83. package/dist/server/kubernetes-backend.js +303 -64
  84. package/dist/server/kubernetes-sandbox-storage.js +48 -3
  85. package/dist/server/oauth.js +12 -1
  86. package/dist/server/plan.js +126 -62
  87. package/dist/server/policy.js +32 -2
  88. package/dist/server/repo-access.js +5 -0
  89. package/dist/server/run-plan.js +50 -19
  90. package/dist/server/run-request.js +64 -3
  91. package/dist/server/run-worker.js +146 -10
  92. package/dist/server/runs.js +347 -27
  93. package/dist/server/sandbox-pod.js +28 -0
  94. package/dist/server/sandbox-storage.js +23 -0
  95. package/dist/server/sandboxes-api.js +10 -4
  96. package/dist/server/sandboxes.js +8 -0
  97. package/dist/server/server-command.js +39 -20
  98. package/dist/server/server-config.js +213 -14
  99. package/dist/server/server.js +268 -15
  100. package/dist/server/service-runs.js +82 -0
  101. package/dist/utils/abort-on-signals.js +46 -6
  102. package/dist/utils/tar.js +36 -0
  103. package/package.json +1 -1
@@ -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
@@ -75,24 +82,78 @@ exports.runRequestSchema = zod_1.z
75
82
  return repo;
76
83
  }),
77
84
  ref: name,
85
+ // the commit the ref has to be at, such as the one a CI job checked out: a ref that
86
+ // moved since is refused, so the run executes the code that was asked for
87
+ commit: zod_1.z
88
+ .string()
89
+ .regex(/^([0-9a-f]{40}|[0-9a-f]{64})$/, 'expected a full commit hash, 40 or 64 hex digits')
90
+ .optional(),
78
91
  })
79
92
  .strict(),
80
93
  // the name of a tree announced with POST /v1/sources
81
94
  zod_1.z.object({ type: zod_1.z.literal('upload'), snapshot: zod_1.z.string().regex(bundle_1.HASH_PATTERN) }).strict(),
82
95
  // a sandbox of the caller's, see POST /v1/sandboxes
83
96
  zod_1.z
84
- .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
+ })
85
104
  .strict(),
86
105
  ]),
87
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([]),
88
127
  })
89
128
  .strict()
90
- .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, {
91
130
  message: 'name at least one task, or filter by labels',
92
131
  path: ['tasks'],
93
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'] })
94
150
  .refine((request) => (request.options.args ?? []).length === 0 || request.tasks.length === 1, {
95
151
  message: 'arguments go to one task, name exactly one in tasks',
96
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'],
97
158
  });
98
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.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,8 @@ 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 check_keys_1 = require("../optimizer/check-keys");
24
26
  // one log line of a task is cut here, the registry caps how many events a run keeps
25
27
  const MAX_LOG_TEXT = 8 * 1024;
26
28
  /**
@@ -59,6 +61,17 @@ exports.workerInputSchema = zod_1.z
59
61
  .string()
60
62
  .regex(/^[a-z]+-[0-9a-f]{24}$/)
61
63
  .optional(),
64
+ // a service run: the services it keeps up instead of tasks, and the ports the server
65
+ // reaches as endpoints
66
+ up: zod_1.z
67
+ .object({
68
+ services: zod_1.z.array(zod_1.z.string()).min(1),
69
+ endpoints: zod_1.z.array(zod_1.z.object({ service: zod_1.z.string(), port: zod_1.z.number().int() }).strict()),
70
+ })
71
+ .strict()
72
+ .optional(),
73
+ // the run's results count as checks: report each task's check key before it runs
74
+ check: zod_1.z.boolean().optional(),
62
75
  })
63
76
  .strict();
64
77
  // The ceiling of the run, combined here and not in the server: the lower of two allow lists
@@ -68,6 +81,60 @@ function getRunNetwork(input) {
68
81
  return input.sandboxNetwork ? (0, work_network_1.limitNetwork)((0, work_network_1.parseWorkNetwork)(input.sandboxNetwork), runs) : runs;
69
82
  }
70
83
  exports.getRunNetwork = getRunNetwork;
84
+ /**
85
+ * The ports of the services of a service run as the server publishes them: on a host the
86
+ * ports of endpoints go to a free port of the loopback interface, where only the server
87
+ * reaches them, and no other port goes to the host, whatever the build file says (`up`
88
+ * would publish them on every interface, where two runs collide). On a cluster nothing is
89
+ * published, the server reaches the Service of each.
90
+ */
91
+ function publishEndpoints(workTree, endpoints, cluster) {
92
+ for (const service of Object.values(workTree.services)) {
93
+ if (!('ports' in service.data)) {
94
+ continue;
95
+ }
96
+ service.data.ports = service.data.ports.map((port) => !cluster &&
97
+ endpoints.some((endpoint) => endpoint.service === service.name && endpoint.port === port.containerPort)
98
+ ? { containerPort: port.containerPort, hostPort: 0, hostIp: '127.0.0.1' }
99
+ : { containerPort: port.containerPort, hostPort: null });
100
+ }
101
+ }
102
+ exports.publishEndpoints = publishEndpoints;
103
+ // Where the server reaches each endpoint, once every service the run keeps up is ready;
104
+ // null until then.
105
+ function getEndpointTargets(workTree, up, kubernetes) {
106
+ const services = Object.values(workTree.services).filter((service) => up.services.includes(service.name));
107
+ if (services.length < up.services.length || services.some((service) => service.state.current.type !== 'running')) {
108
+ return null;
109
+ }
110
+ const targets = [];
111
+ for (const endpoint of up.endpoints) {
112
+ const service = services.find((candidate) => candidate.name === endpoint.service);
113
+ const state = service?.state.current;
114
+ if (!service || state?.type !== 'running') {
115
+ return null;
116
+ }
117
+ if (kubernetes) {
118
+ targets.push({
119
+ service: endpoint.service,
120
+ port: endpoint.port,
121
+ address: { host: `${(0, resources_1.getResourceName)(service, kubernetes)}.${kubernetes.namespace}.svc`, port: endpoint.port },
122
+ });
123
+ continue;
124
+ }
125
+ const published = state.published?.find((candidate) => candidate.containerPort === endpoint.port);
126
+ if (!published) {
127
+ throw new Error(`${endpoint.service} is up, but its port ${endpoint.port} was not published`);
128
+ }
129
+ targets.push({
130
+ service: endpoint.service,
131
+ port: endpoint.port,
132
+ address: { host: published.host, port: published.port },
133
+ });
134
+ }
135
+ return targets;
136
+ }
137
+ exports.getEndpointTargets = getEndpointTargets;
71
138
  /**
72
139
  * Executes the tasks of a run with the engine of `hammerkit run`. The caller says which
73
140
  * process this is in: it reports what happens through `emit` and stops when `abortCtrl`
@@ -75,7 +142,9 @@ exports.getRunNetwork = getRunNetwork;
75
142
  */
76
143
  async function runTasks(input, cwd, processEnvs, abortCtrl,
77
144
  // whether the run was stopped from outside; the engine aborts too when a task fails
78
- cancelled, emit) {
145
+ cancelled, emit,
146
+ // a service run: its services are ready, at these addresses
147
+ up) {
79
148
  const secrets = (0, redact_1.createSecretRegistry)();
80
149
  const status = (0, work_item_status_1.statusConsole)((0, empty_writable_1.emptyWritable)(), secrets);
81
150
  // the process starts in a directory of the server, the tasks work on the run's files
@@ -117,15 +186,27 @@ cancelled, emit) {
117
186
  const options = input.options;
118
187
  const execOptions = {
119
188
  logMode: 'live',
120
- cacheDefault: options.cache ?? 'checksum',
189
+ // a check takes a cached result only for the same files, never for the same dates
190
+ cacheDefault: input.check && options.cache === 'modify-date' ? 'checksum' : options.cache ?? 'checksum',
121
191
  cacheReadOnly: options.cacheReadOnly ?? false,
122
192
  timeout: options.timeout ? (0, units_1.parseDuration)(options.timeout) : null,
193
+ maxTimeout: options.maxTimeout ? (0, units_1.parseDuration)(options.maxTimeout) : null,
123
194
  skipDeps: options.skipDeps !== false,
124
195
  network: getRunNetwork(input),
125
196
  // why a task was not taken from the cache goes into its result
126
197
  explain: true,
127
198
  };
128
- for (const { name, scope } of (0, run_plan_1.getRunScopes)(input.tasks, options)) {
199
+ const scopes = (0, run_plan_1.getRunScopes)(input.tasks, options, input.up?.services);
200
+ if (input.check) {
201
+ // the files as they are before any task wrote to them, a second task's too: a first
202
+ // one that formats or generates into src would key it by files no commit holds
203
+ for (const { scope } of scopes) {
204
+ for (const [task, { key }] of Object.entries(await (0, check_keys_1.getCheckKeys)(input.entry, environment, scope))) {
205
+ emit({ type: 'key', task, key });
206
+ }
207
+ }
208
+ }
209
+ for (const { name, scope } of scopes) {
129
210
  const { ctx, scope: parsed } = await (0, schema_parser_1.createParseContext)(input.entry, environment, {
130
211
  directory: input.directory,
131
212
  });
@@ -134,17 +215,44 @@ cancelled, emit) {
134
215
  if (input.volumeScope && !input.kubernetes) {
135
216
  (0, hosted_volumes_1.scopeServiceVolumes)(workTree, input.volumeScope);
136
217
  }
218
+ if (input.up) {
219
+ publishEndpoints(workTree, input.up.endpoints, !!input.kubernetes);
220
+ }
137
221
  const cli = (0, cli_1.getCli)(workTree, environment);
138
- if (cli.tasks().length === 0) {
222
+ if (cli.tasks().length === 0 && (!input.up || cli.services().length === 0)) {
139
223
  throw new Error(`no tasks found for ${name}`);
140
224
  }
141
- const execution = cli.exec({ ...execOptions, workers: cli.defaultWorkers() });
225
+ const execution = input.up
226
+ ? cli.up({ ...execOptions, workers: cli.defaultWorkers(), daemon: false, wait: 'ready' })
227
+ : cli.exec({ ...execOptions, workers: cli.defaultWorkers() });
142
228
  const seen = new Map();
143
- const report = () => reportTasks(emit, execution.state.current, seen, (text) => secrets.redact(text));
229
+ let ready = false;
230
+ const report = () => {
231
+ reportTasks(emit, execution.state.current, seen, (text) => secrets.redact(text));
232
+ if (input.up && up && !ready) {
233
+ const targets = getEndpointTargets(execution.state.current, input.up, input.kubernetes);
234
+ if (targets) {
235
+ ready = true;
236
+ up(targets);
237
+ }
238
+ }
239
+ };
144
240
  const watch = execution.state.on('run-events', report);
145
241
  try {
146
242
  const result = await execution.start();
147
243
  report();
244
+ // on a cluster the services stay in their Deployments once they are ready, until
245
+ // the namespace of the run goes
246
+ if (input.up && input.kubernetes && ready && result.success && !abortCtrl.signal.aborted) {
247
+ await new Promise((resolve) => abortCtrl.signal.addEventListener('abort', () => resolve(), { once: true }));
248
+ return;
249
+ }
250
+ if (input.up && ready) {
251
+ if (!abortCtrl.signal.aborted) {
252
+ throw new Error('the services stopped on their own');
253
+ }
254
+ return;
255
+ }
148
256
  if (!result.success) {
149
257
  throw new Error(cancelled() ? 'the run was cancelled' : `${name} failed`);
150
258
  }
@@ -217,9 +325,13 @@ redact) {
217
325
  // The run is the first line of the standard input. The server keeps it open and writes
218
326
  // `cancel` on it to stop the run: that works the same on every platform, where a signal
219
327
  // on Windows ends the process without letting it stop its tasks.
328
+ // `onEnd` is told when the input ends before the run does: the server is gone.
220
329
  function readInput(stdin, cancel) {
221
330
  const lines = (0, readline_1.createInterface)({ input: stdin, crlfDelay: Infinity });
222
331
  let first;
332
+ let closing = false;
333
+ let ended = false;
334
+ let onEnd;
223
335
  const input = new Promise((resolve) => {
224
336
  lines.on('line', (line) => {
225
337
  if (first === undefined) {
@@ -230,10 +342,29 @@ function readInput(stdin, cancel) {
230
342
  cancel();
231
343
  }
232
344
  });
233
- lines.on('close', () => resolve(first ?? ''));
345
+ lines.on('close', () => {
346
+ resolve(first ?? '');
347
+ if (!closing) {
348
+ ended = true;
349
+ onEnd?.();
350
+ }
351
+ });
234
352
  });
235
- return { input, close: () => lines.close() };
353
+ return {
354
+ input,
355
+ close: () => {
356
+ closing = true;
357
+ lines.close();
358
+ },
359
+ onEnd: (listener) => {
360
+ onEnd = listener;
361
+ if (ended) {
362
+ listener();
363
+ }
364
+ },
365
+ };
236
366
  }
367
+ exports.readInput = readInput;
237
368
  /**
238
369
  * The process of one run: reads its input, executes the tasks in the current directory
239
370
  * and reports as lines of JSON. It exits 0 when it could say how the run ended, and the
@@ -260,8 +391,13 @@ async function workerMain(stdin, stdout, processEnvs, cwd, abortCtrl) {
260
391
  say({ kind: 'result', ok: false, error: `the run could not be read: ${e instanceof Error ? e.message : e}` });
261
392
  return;
262
393
  }
394
+ if (input.up) {
395
+ // services stay until they are stopped: without the server that would stop them, at
396
+ // their ttl or on a cancel, nothing would
397
+ reader.onEnd(stop);
398
+ }
263
399
  try {
264
- await runTasks(input, cwd, processEnvs, abortCtrl, () => cancelled, (event) => say({ kind: 'event', event }));
400
+ await runTasks(input, cwd, processEnvs, abortCtrl, () => cancelled, (event) => say({ kind: 'event', event }), (targets) => say({ kind: 'up', targets }));
265
401
  say({ kind: 'result', ok: true });
266
402
  }
267
403
  catch (e) {