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
@@ -30,6 +30,8 @@ exports.SANDBOX_ID_PATTERN = /^sbx_[A-Za-z0-9_-]{16}$/;
30
30
  function onSandbox(run, sandbox) {
31
31
  return run.source.type === 'sandbox' && run.source.id === sandbox;
32
32
  }
33
+ // the tasks of a run whose check keys are kept, a build file has fewer
34
+ const MAX_KEYED_TASKS = 1000;
33
35
  // how much of the end of a task's output its result carries
34
36
  const TAIL_LINES = 20;
35
37
  const TAIL_LINE_LENGTH = 1000;
@@ -100,6 +102,7 @@ class RunRegistry {
100
102
  bytes: 0,
101
103
  tails: new Map(Object.entries(run.results ?? {}).map(([task, result]) => [task, result.lastLog])),
102
104
  saving: Promise.resolve(),
105
+ keys: {},
103
106
  };
104
107
  this.entries.set(run.id, entry);
105
108
  if (run.state === 'awaiting-approval') {
@@ -112,9 +115,22 @@ class RunRegistry {
112
115
  this.finish(entry, 'rejected', 'nobody approved it in time');
113
116
  }
114
117
  }
118
+ else if (this.maySleep(entry) && ['queued', 'running', 'up', 'asleep'].includes(run.state)) {
119
+ // the services went with the process that held them, the next request wakes them
120
+ if (run.state === 'queued' || run.state === 'running' || run.state === 'up') {
121
+ this.sleep(entry);
122
+ }
123
+ else {
124
+ this.expireLater(entry);
125
+ }
126
+ }
115
127
  else if (run.state === 'queued' || run.state === 'running') {
116
128
  this.finish(entry, 'failed', `the server stopped while the run was ${run.state}, submit it again`);
117
129
  }
130
+ else if (run.state === 'up') {
131
+ // the services went with the process that held them
132
+ this.finish(entry, 'failed', 'the server stopped while the services were up, start them again');
133
+ }
118
134
  }
119
135
  this.evict();
120
136
  }
@@ -129,10 +145,18 @@ class RunRegistry {
129
145
  throw new RunError('too many runs are waiting for an approval');
130
146
  }
131
147
  this.checkLimits(owner, waiting);
148
+ if (spec.up) {
149
+ this.checkUpLimits(owner);
150
+ this.checkHosts(owner, spec.up);
151
+ }
132
152
  const run = {
133
153
  ...spec,
134
154
  id: `run_${(0, crypto_1.randomBytes)(12).toString('base64url')}`,
135
- owner: { iss: owner.iss, sub: owner.sub },
155
+ owner: {
156
+ iss: owner.iss,
157
+ sub: owner.sub,
158
+ ...(spec.up?.sleepTtl ? { groups: [...(owner.groups ?? [])], claims: { ...(owner.claims ?? {}) } } : {}),
159
+ },
136
160
  state: waiting ? 'awaiting-approval' : 'queued',
137
161
  createdAt: this.timestamp(),
138
162
  };
@@ -145,6 +169,7 @@ class RunRegistry {
145
169
  bytes: 0,
146
170
  tails: new Map(),
147
171
  saving: Promise.resolve(),
172
+ keys: {},
148
173
  };
149
174
  this.entries.set(run.id, entry);
150
175
  if (waiting) {
@@ -179,30 +204,79 @@ class RunRegistry {
179
204
  .map((entry) => this.copy(entry.run))
180
205
  .reverse();
181
206
  }
182
- // A queued run is cancelled at once; a running one when its executor stops.
207
+ // A queued run is cancelled at once; a running one when its executor stops. A service run
208
+ // that is up stops its services, and says who stopped it.
183
209
  cancel(owner, id, canUse) {
184
210
  const entry = this.find(owner, id, canUse);
185
211
  if (!entry) {
186
212
  return undefined;
187
213
  }
188
- this.stop(entry);
214
+ this.stop(entry, undefined, { iss: owner.iss, sub: owner.sub });
189
215
  return this.copy(entry.run);
190
216
  }
217
+ /**
218
+ * Moves the end of a service run that is up (or asleep) to `ttl` (ms) from now, at most
219
+ * the server's maxTtl, and never sooner than it was. Its owner may, and whoever `mayOpen`
220
+ * says may open its endpoints.
221
+ */
222
+ extend(caller, id, ttl, mayOpen) {
223
+ const entry = this.entries.get(id);
224
+ if (!entry || !(this.owns(caller, entry) || (entry.run.up && mayOpen?.(this.copy(entry.run))))) {
225
+ return { status: 'not-found' };
226
+ }
227
+ // up, asleep, or woken and starting again
228
+ if (!entry.run.upAt || isFinal(entry.run.state)) {
229
+ return { status: 'not-up', state: entry.run.state };
230
+ }
231
+ // never sooner than it was: who wants it gone stops it
232
+ const ms = Math.min(ttl, this.options.up?.maxTtl ?? ttl);
233
+ const expiresAt = Math.max(Date.parse(entry.run.expiresAt ?? '') || 0, this.now() + ms);
234
+ entry.run.expiresAt = new Date(expiresAt).toISOString();
235
+ this.record(entry, { type: 'extended', by: { iss: caller.iss, sub: caller.sub }, expiresAt: entry.run.expiresAt });
236
+ this.expireLater(entry);
237
+ return { status: 'ok', run: this.copy(entry.run) };
238
+ }
239
+ /**
240
+ * The service run an endpoint host belongs to: the newest that is up, so one that replaces
241
+ * a run of the same name takes its hosts over as soon as it is up; else the newest that
242
+ * had it, for a page that says how it stands. With the address of the endpoint when up.
243
+ */
244
+ endpoint(host) {
245
+ let found;
246
+ for (const entry of this.entries.values()) {
247
+ const endpoint = entry.run.up?.endpoints.find((candidate) => candidate.host === host);
248
+ if (endpoint && (entry.run.state === 'up' || found?.entry.run.state !== 'up')) {
249
+ found = { entry, endpoint };
250
+ }
251
+ }
252
+ if (!found) {
253
+ return undefined;
254
+ }
255
+ const { entry, endpoint } = found;
256
+ const target = entry.run.state === 'up'
257
+ ? entry.targets?.find((candidate) => candidate.service === endpoint.service && candidate.port === endpoint.port)
258
+ : undefined;
259
+ return { run: this.copy(entry.run), endpoint: { ...endpoint }, ...(target ? { target } : {}) };
260
+ }
191
261
  // The run that holds a sandbox: the oldest on it that is not over. It holds it from
192
- // when it is submitted, so the files are not moved under what an approver saw.
262
+ // when it is submitted, so the files are not moved under what an approver saw. A service
263
+ // run that is up works on a copy of the files, and holds it no longer.
193
264
  holder(sandbox) {
194
- return [...this.entries.values()].find((entry) => onSandbox(entry.run, sandbox) && !isFinal(entry.run.state))?.run
195
- .id;
265
+ return [...this.entries.values()].find((entry) => onSandbox(entry.run, sandbox) &&
266
+ !isFinal(entry.run.state) &&
267
+ entry.run.state !== 'up' &&
268
+ entry.run.state !== 'asleep')?.run.id;
196
269
  }
197
270
  // Cancels every run on a sandbox and resolves when none of them runs anymore.
198
271
  async stopSandbox(sandbox, reason) {
199
272
  const on = () => [...this.entries.values()].filter((entry) => onSandbox(entry.run, sandbox));
200
273
  on().forEach((entry) => this.stop(entry, reason));
201
- while (on().some((entry) => entry.run.state === 'running')) {
274
+ while (on().some((entry) => entry.run.state === 'running' || entry.run.state === 'up')) {
202
275
  await new Promise((resolve) => setTimeout(resolve, 20));
203
276
  }
204
277
  }
205
- stop(entry, reason) {
278
+ stop(entry, reason, by) {
279
+ var _a, _b;
206
280
  if (entry.run.state === 'queued') {
207
281
  this.queue.splice(this.queue.indexOf(entry), 1);
208
282
  this.finish(entry, 'cancelled', reason);
@@ -213,6 +287,15 @@ class RunRegistry {
213
287
  else if (entry.run.state === 'running') {
214
288
  entry.abort.abort();
215
289
  }
290
+ else if (entry.run.state === 'up') {
291
+ (_a = entry.run).stoppedBy ?? (_a.stoppedBy = by ?? 'replaced');
292
+ entry.sleeping = false;
293
+ entry.abort.abort();
294
+ }
295
+ else if (entry.run.state === 'asleep') {
296
+ (_b = entry.run).stoppedBy ?? (_b.stoppedBy = by ?? 'replaced');
297
+ this.finish(entry, 'succeeded');
298
+ }
216
299
  }
217
300
  // The runs that wait for an approval, of anybody, oldest first. Who may see
218
301
  // them is for the caller to decide.
@@ -289,18 +372,20 @@ class RunRegistry {
289
372
  return { events, finished, stop: () => entry.listeners.delete(listener) };
290
373
  }
291
374
  /**
292
- * The run once it is over, or as it is after `ms` or when `signal` aborts, for clients
293
- * that can not hold a stream of events open (a tool call of an agent).
375
+ * The run once it is over (a service run: once it is up), or as it is after `ms` or when
376
+ * `signal` aborts, for clients that can not hold a stream of events open (a tool call of
377
+ * an agent).
294
378
  */
295
379
  async wait(owner, id, ms, canUse, signal) {
296
380
  const entry = this.find(owner, id, canUse);
297
381
  if (!entry) {
298
382
  return undefined;
299
383
  }
300
- if (!isFinal(entry.run.state) && ms > 0 && !this.closed && !signal?.aborted) {
384
+ if (!isFinal(entry.run.state) && entry.run.state !== 'up' && ms > 0 && !this.closed && !signal?.aborted) {
301
385
  await new Promise((resolve) => {
302
386
  const listener = (event) => {
303
- if (event.type === 'state' && isFinal(event.state)) {
387
+ // a service run is there once it is up
388
+ if (event.type === 'state' && (isFinal(event.state) || event.state === 'up')) {
304
389
  done();
305
390
  }
306
391
  };
@@ -335,10 +420,19 @@ class RunRegistry {
335
420
  async close() {
336
421
  this.closed = true;
337
422
  for (const entry of this.queue.splice(0)) {
338
- this.finish(entry, 'cancelled');
423
+ if (this.maySleep(entry) && this.options.store) {
424
+ // a woken service run sleeps again, the next start keeps it
425
+ this.sleep(entry);
426
+ }
427
+ else {
428
+ this.finish(entry, 'cancelled');
429
+ }
339
430
  }
340
431
  for (const entry of this.entries.values()) {
341
- if (entry.run.state === 'awaiting-approval') {
432
+ if (entry.run.state === 'asleep') {
433
+ this.clearTimer(entry);
434
+ }
435
+ else if (entry.run.state === 'awaiting-approval') {
342
436
  if (this.options.store) {
343
437
  // the record keeps it waiting, the next start takes it back
344
438
  this.clearTimer(entry);
@@ -353,11 +447,15 @@ class RunRegistry {
353
447
  done();
354
448
  }
355
449
  for (const entry of this.entries.values()) {
356
- if (entry.run.state === 'running') {
450
+ if (entry.run.state === 'running' || entry.run.state === 'up') {
451
+ // a service run that may sleep is asleep in its record, the next request after the
452
+ // start wakes it
453
+ entry.sleeping = this.maySleep(entry) && !!this.options.store;
357
454
  entry.abort.abort();
358
455
  }
359
456
  }
360
- while (this.running > 0) {
457
+ while (this.running > 0 ||
458
+ [...this.entries.values()].some((entry) => entry.run.state === 'running' || entry.run.state === 'up')) {
361
459
  await new Promise((resolve) => setTimeout(resolve, 10));
362
460
  }
363
461
  // the runs that were stopped are written down before the server is gone
@@ -388,6 +486,8 @@ class RunRegistry {
388
486
  copy(run) {
389
487
  return {
390
488
  ...run,
489
+ ...(run.up ? { up: JSON.parse(JSON.stringify(run.up)) } : {}),
490
+ ...(run.stoppedBy && typeof run.stoppedBy === 'object' ? { stoppedBy: { ...run.stoppedBy } } : {}),
391
491
  tasks: [...run.tasks],
392
492
  source: { ...run.source },
393
493
  options: JSON.parse(JSON.stringify(run.options)),
@@ -415,6 +515,37 @@ class RunRegistry {
415
515
  throw new QuotaError(`you have ${limits.queued} runs in the queue, wait for one to start or cancel one`);
416
516
  }
417
517
  }
518
+ // Service runs that are not over, of the caller and of everybody, against what may be:
519
+ // one that is asleep counts, it wakes without asking for a place.
520
+ checkUpLimits(owner) {
521
+ const limits = this.options.up;
522
+ if (!limits) {
523
+ return;
524
+ }
525
+ const open = [...this.entries.values()].filter((entry) => entry.run.up && !isFinal(entry.run.state));
526
+ if (open.filter((entry) => this.owns(owner, entry)).length >= limits.perCaller) {
527
+ throw new QuotaError(`you have ${limits.perCaller} service runs starting, up or asleep, stop one or wait for its end`);
528
+ }
529
+ if (open.length >= limits.total) {
530
+ throw new RunError(`the server has ${limits.total} service runs starting, up or asleep, try again later`);
531
+ }
532
+ }
533
+ // A host is one run's, or a run's that replaces it (same owner and name) once it is up:
534
+ // two labels that come out the same are refused, they would reach each other's services.
535
+ checkHosts(owner, up) {
536
+ const hosts = new Set(up.endpoints.map((endpoint) => endpoint.host));
537
+ for (const entry of this.entries.values()) {
538
+ if (isFinal(entry.run.state) || !entry.run.up) {
539
+ continue;
540
+ }
541
+ const taken = entry.run.up.endpoints.find((endpoint) => hosts.has(endpoint.host));
542
+ if (taken && !(this.owns(owner, entry) && entry.run.up.name === up.name)) {
543
+ const error = new RunError(`the address ${taken.host} is another service run's, pick another name`);
544
+ error.status = 409;
545
+ throw error;
546
+ }
547
+ }
548
+ }
418
549
  runningOf(owner) {
419
550
  return [...this.entries.values()].filter((entry) => entry.run.state === 'running' && this.owns(owner, entry)).length;
420
551
  }
@@ -424,20 +555,30 @@ class RunRegistry {
424
555
  return (source.type === 'sandbox' &&
425
556
  [...this.entries.values()].some((entry) => entry.run.state === 'running' && onSandbox(entry.run, source.id)));
426
557
  }
558
+ // Whether a run that executes holds what the run would (RunExecutor.exclusive)
559
+ held(run) {
560
+ const exclusive = this.options.executor.exclusive?.bind(this.options.executor);
561
+ const key = exclusive?.(run);
562
+ return (key !== undefined &&
563
+ [...this.entries.values()].some((entry) => entry.run.state === 'running' && exclusive?.(entry.run) === key));
564
+ }
427
565
  // The first run in the queue whose caller has a place left: one caller with many runs
428
566
  // does not keep the others from the machine. A run on a sandbox another run works on
429
- // waits for it.
567
+ // waits for it, as does one that needs what an executing run holds.
430
568
  dispatch() {
431
569
  while (this.running < this.maxConcurrent) {
432
- const perCaller = Math.min(this.options.limits?.running ?? Infinity, this.options.executor.runningPerCaller ?? Infinity);
433
- const index = this.queue.findIndex((entry) => this.runningOf(entry.run.owner) < perCaller && !this.sandboxBusy(entry.run));
570
+ const perCaller = this.options.limits?.running ?? Infinity;
571
+ const index = this.queue.findIndex((entry) => this.runningOf(entry.run.owner) < perCaller && !this.sandboxBusy(entry.run) && !this.held(entry.run));
434
572
  if (index < 0) {
435
573
  return;
436
574
  }
437
575
  const [entry] = this.queue.splice(index, 1);
438
576
  this.running++;
439
577
  this.execute(entry).finally(() => {
440
- this.running--;
578
+ // a service run that is up gave its place back already
579
+ if (!entry.released) {
580
+ this.running--;
581
+ }
441
582
  this.dispatch();
442
583
  });
443
584
  }
@@ -446,27 +587,190 @@ class RunRegistry {
446
587
  entry.run.state = 'running';
447
588
  entry.run.startedAt = this.timestamp();
448
589
  this.record(entry, { type: 'state', state: 'running' });
590
+ // a service run gets this long to be up, then its ttl counts
449
591
  const limit = this.options.maxDuration;
450
- const timer = limit
592
+ entry.durationTimer = limit
451
593
  ? setTimeout(() => {
452
594
  entry.timedOut = true;
453
595
  entry.abort.abort();
454
596
  }, limit)
455
597
  : undefined;
456
- timer?.unref();
598
+ entry.durationTimer?.unref();
457
599
  try {
458
600
  await this.options.executor.execute(this.copy(entry.run), {
459
601
  emit: (event) => this.record(entry, event),
460
602
  signal: entry.abort.signal,
603
+ ...(entry.run.up ? { up: (targets) => this.markUp(entry, targets) } : {}),
461
604
  });
462
- this.finish(entry, entry.timedOut ? 'failed' : entry.abort.signal.aborted ? 'cancelled' : 'succeeded', entry.timedOut ? this.tooLong() : undefined);
605
+ if (entry.sleeping) {
606
+ this.sleep(entry);
607
+ }
608
+ else if (entry.run.upAt) {
609
+ // services stay until they are stopped: ones that stopped on their own failed
610
+ this.endServices(entry, 'the services stopped on their own');
611
+ }
612
+ else {
613
+ this.finish(entry, entry.timedOut ? 'failed' : entry.abort.signal.aborted ? 'cancelled' : 'succeeded', entry.timedOut ? this.tooLong() : undefined);
614
+ }
463
615
  }
464
616
  catch (e) {
465
- const cancelled = entry.abort.signal.aborted && !entry.timedOut;
466
- this.finish(entry, cancelled ? 'cancelled' : 'failed', entry.timedOut ? this.tooLong() : cancelled ? undefined : e instanceof Error ? e.message : String(e));
617
+ const message = e instanceof Error ? e.message : String(e);
618
+ if (entry.sleeping) {
619
+ this.sleep(entry);
620
+ }
621
+ else if (entry.run.upAt) {
622
+ this.endServices(entry, message);
623
+ }
624
+ else {
625
+ const cancelled = entry.abort.signal.aborted && !entry.timedOut;
626
+ this.finish(entry, cancelled ? 'cancelled' : 'failed', entry.timedOut ? this.tooLong() : cancelled ? undefined : message);
627
+ }
467
628
  }
468
629
  finally {
469
- clearTimeout(timer);
630
+ clearTimeout(entry.durationTimer);
631
+ }
632
+ }
633
+ // The services of a service run are ready: its ttl counts from now, its place of
634
+ // maxConcurrent goes to the next run, and an older run of its owner with the same name
635
+ // stops, so its hosts are this run's.
636
+ markUp(entry, targets) {
637
+ const spec = entry.run.up;
638
+ if (!spec || entry.run.state !== 'running' || entry.abort.signal.aborted) {
639
+ return;
640
+ }
641
+ clearTimeout(entry.durationTimer);
642
+ entry.durationTimer = undefined;
643
+ entry.targets = targets.map((target) => ({ ...target, address: { ...target.address } }));
644
+ entry.run.state = 'up';
645
+ // one reading of the clock, so the ttl is exactly expiresAt - upAt
646
+ const now = this.now();
647
+ // the ttl counts from the first time, not from a wake
648
+ if (!entry.run.upAt) {
649
+ entry.run.upAt = new Date(now).toISOString();
650
+ entry.run.expiresAt = new Date(now + spec.ttl).toISOString();
651
+ }
652
+ if (spec.sleepTtl) {
653
+ entry.run.sleepsAt = new Date(now + spec.sleepTtl).toISOString();
654
+ }
655
+ this.record(entry, { type: 'state', state: 'up' });
656
+ this.expireLater(entry);
657
+ entry.released = true;
658
+ this.running--;
659
+ const entries = [...this.entries.values()];
660
+ for (const other of entries.slice(0, entries.indexOf(entry))) {
661
+ if (other.run.up?.name === spec.name &&
662
+ this.owns(entry.run.owner, other) &&
663
+ !isFinal(other.run.state) &&
664
+ other.run.state !== 'awaiting-approval') {
665
+ other.run.stoppedBy = 'replaced';
666
+ this.stop(other, `replaced by ${entry.run.id}`);
667
+ }
668
+ }
669
+ this.dispatch();
670
+ }
671
+ // Stops a service run at its expiry, and puts one that is up to sleep at its sleepsAt. A
672
+ // timer can't wait longer than about 24 days, so a longer one looks again then.
673
+ expireLater(entry) {
674
+ this.clearTimer(entry);
675
+ const expiresAt = Date.parse(entry.run.expiresAt ?? '');
676
+ const sleepsAt = entry.run.state === 'up' ? Date.parse(entry.run.sleepsAt ?? '') : NaN;
677
+ const next = Number.isFinite(sleepsAt) ? Math.min(expiresAt, sleepsAt) : expiresAt;
678
+ entry.timer = setTimeout(() => {
679
+ var _a;
680
+ if (isFinal(entry.run.state)) {
681
+ return;
682
+ }
683
+ if (Date.parse(entry.run.expiresAt ?? '') <= this.now()) {
684
+ (_a = entry.run).stoppedBy ?? (_a.stoppedBy = 'ttl');
685
+ entry.sleeping = false;
686
+ if (entry.run.state === 'asleep') {
687
+ this.finish(entry, 'succeeded');
688
+ }
689
+ else {
690
+ this.stop(entry);
691
+ }
692
+ return;
693
+ }
694
+ if (entry.run.state === 'up' && Date.parse(entry.run.sleepsAt ?? '') <= this.now()) {
695
+ entry.sleeping = true;
696
+ entry.abort.abort();
697
+ return;
698
+ }
699
+ this.expireLater(entry);
700
+ }, Math.max(0, Math.min(next - this.now(), 2 ** 31 - 1)));
701
+ entry.timer.unref();
702
+ }
703
+ // Whether a service run sleeps instead of ending: it has a sleepTtl, its ttl has not
704
+ // ended, and it starts from a commit (the files of a sandbox change under it).
705
+ maySleep(entry) {
706
+ return (!!entry.run.up?.sleepTtl &&
707
+ !!entry.run.upAt &&
708
+ entry.run.source.type === 'ref' &&
709
+ Date.parse(entry.run.expiresAt ?? '') > this.now());
710
+ }
711
+ // The services stopped, the run waits for a request to wake it, or for its end.
712
+ sleep(entry) {
713
+ entry.sleeping = false;
714
+ entry.targets = undefined;
715
+ entry.run.state = 'asleep';
716
+ delete entry.run.sleepsAt;
717
+ this.record(entry, { type: 'state', state: 'asleep' });
718
+ this.expireLater(entry);
719
+ }
720
+ /**
721
+ * A request to an endpoint of a service run: one that is up stays up for its sleepTtl from
722
+ * now, one that is asleep is started again (it holds its place of runs.up.limits while it
723
+ * sleeps), if `mayWake` finds its owner may still run it: else it ends. Answers why it was
724
+ * not woken, or undefined.
725
+ */
726
+ visit(id, mayWake) {
727
+ const entry = this.entries.get(id);
728
+ if (!entry?.run.up) {
729
+ return undefined;
730
+ }
731
+ const sleepTtl = entry.run.up.sleepTtl;
732
+ if (entry.run.state === 'up' && sleepTtl) {
733
+ // only kept in memory: a server that stops puts the run to sleep anyway
734
+ entry.run.sleepsAt = new Date(this.now() + sleepTtl).toISOString();
735
+ return undefined;
736
+ }
737
+ if (entry.run.state !== 'asleep' || this.closed) {
738
+ return undefined;
739
+ }
740
+ if (!this.maySleep(entry)) {
741
+ this.expireLater(entry);
742
+ return 'its time ran out';
743
+ }
744
+ const refused = mayWake?.(this.copy(entry.run));
745
+ if (refused) {
746
+ this.finish(entry, 'failed', refused);
747
+ return refused;
748
+ }
749
+ this.clearTimer(entry);
750
+ entry.abort = new AbortController();
751
+ entry.released = false;
752
+ entry.timedOut = false;
753
+ entry.run.wakes = (entry.run.wakes ?? 0) + 1;
754
+ entry.run.state = 'queued';
755
+ this.record(entry, { type: 'state', state: 'queued' });
756
+ this.queue.push(entry);
757
+ this.dispatch();
758
+ return undefined;
759
+ }
760
+ // The end of a run that was up: stopped by somebody, its ttl or a newer run it succeeded,
761
+ // stopped by the server going away it failed, as its record says after a restart.
762
+ endServices(entry, failure) {
763
+ if (entry.timedOut) {
764
+ this.finish(entry, 'failed', this.tooLong());
765
+ }
766
+ else if (!entry.abort.signal.aborted) {
767
+ this.finish(entry, 'failed', failure);
768
+ }
769
+ else if (entry.run.stoppedBy) {
770
+ this.finish(entry, 'succeeded');
771
+ }
772
+ else {
773
+ this.finish(entry, 'failed', 'the server stopped while the services were up, start them again');
470
774
  }
471
775
  }
472
776
  tooLong() {
@@ -474,6 +778,8 @@ class RunRegistry {
474
778
  }
475
779
  finish(entry, state, error) {
476
780
  this.clearTimer(entry);
781
+ clearTimeout(entry.durationTimer);
782
+ entry.targets = undefined;
477
783
  entry.run.state = state;
478
784
  entry.run.finishedAt = this.timestamp();
479
785
  if (error) {
@@ -488,9 +794,23 @@ class RunRegistry {
488
794
  }
489
795
  this.record(entry, { type: 'state', state, ...(error ? { error } : {}) }, true);
490
796
  entry.listeners.clear();
797
+ try {
798
+ this.options.onFinished?.(this.copy(entry.run), { ...entry.keys });
799
+ }
800
+ catch (e) {
801
+ this.options.log?.(`run ${entry.run.id} ended, but what it passed was not kept: ${e instanceof Error ? e.message : e}`);
802
+ }
491
803
  this.evict();
492
804
  }
493
805
  record(entry, recorded, force = false) {
806
+ if (recorded.type === 'key') {
807
+ // a task name is the executor's; the first key it reports for a task is the one
808
+ // computed before anything of the run had run
809
+ if (!(recorded.task in entry.keys) && Object.keys(entry.keys).length < MAX_KEYED_TASKS) {
810
+ entry.keys[recorded.task] = recorded.key;
811
+ }
812
+ return;
813
+ }
494
814
  const event = recorded.type === 'task' ? bounded(recorded) : recorded;
495
815
  if (event.type === 'log') {
496
816
  this.keepTail(entry, event.task, event.text);
@@ -572,7 +892,7 @@ class RunRegistry {
572
892
  }
573
893
  // what a restart must not lose: the runs that wait, and how a run ended. A running
574
894
  // run is not kept up to date, it fails with the server.
575
- if (event.type === 'state' || event.type === 'approval') {
895
+ if (event.type === 'state' || event.type === 'approval' || event.type === 'extended') {
576
896
  this.persist(entry);
577
897
  }
578
898
  }
@@ -1,12 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.sandboxPodMain = exports.decodePodError = exports.encodeEditOps = exports.INCOMING_REF = exports.INCOMING = void 0;
4
+ const crypto_1 = require("crypto");
4
5
  const promises_1 = require("fs/promises");
5
6
  const path_1 = require("path");
6
7
  const url_1 = require("url");
7
8
  const git_checkout_1 = require("./git-checkout");
8
9
  const sandbox_files_1 = require("./sandbox-files");
9
10
  const sandbox_storage_1 = require("./sandbox-storage");
11
+ const tar_1 = require("../utils/tar");
10
12
  // On a cluster a sandbox is a claim, and what the server does with its files runs in a
11
13
  // short pod that mounts it (see KubernetesSandboxStorage): `hammerkit server-sandbox
12
14
  // <directory>` reads one request on its standard input, does it with the code of a host
@@ -91,6 +93,32 @@ async function answer(storage, directory, request, out) {
91
93
  }
92
94
  return send({ files: plan.files, bytes: plan.bytes });
93
95
  }
96
+ case 'archive': {
97
+ // written beside the sandbox on the claim, sent, and removed
98
+ const into = (0, path_1.join)(directory, `${request.id}-archive-${(0, crypto_1.randomBytes)(6).toString('hex')}`);
99
+ try {
100
+ await storage.exportTree(key, request.tree, into, AbortSignal.timeout(10 * 60000));
101
+ await write(out, JSON.stringify({ ok: true, result: null, stream: true }) + '\n');
102
+ const pack = (0, tar_1.packArchive)({ cwd: into, portable: true }, ['.']);
103
+ try {
104
+ for await (const chunk of pack) {
105
+ await write(out, chunk);
106
+ }
107
+ }
108
+ catch (e) {
109
+ throw new AnswerStarted(e instanceof Error ? e.message : String(e));
110
+ }
111
+ finally {
112
+ // a reader that went away (over its size limit) leaves the pack reading files
113
+ // that are removed next: what fails then is no answer of anybody's
114
+ pack.on('error', () => undefined);
115
+ }
116
+ return;
117
+ }
118
+ finally {
119
+ await (0, promises_1.rm)(into, { recursive: true, force: true });
120
+ }
121
+ }
94
122
  case 'raw': {
95
123
  const raw = await files().raw(request.path);
96
124
  const header = {
@@ -276,6 +276,29 @@ class HostSandboxStorage {
276
276
  // files change, and what it wrote to the git data is pruned again. Files a task made
277
277
  // unreadable are made readable first: git would leave them out and call the sandbox
278
278
  // clean.
279
+ async exportTree({ id }, tree, into, signal) {
280
+ if (!/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(tree)) {
281
+ throw new Error(`${JSON.stringify(tree.slice(0, 80))} is not a git tree`);
282
+ }
283
+ return this.withIndex(id, signal, async (run) => {
284
+ const now = (await run(['write-tree'], 'read')).trim();
285
+ if (now !== tree) {
286
+ throw new Error(`the files of the sandbox ${id} are ${now} now, not ${tree}`);
287
+ }
288
+ // an index of its own holds the tree alone, whatever the sandbox's index says of its
289
+ // entries; the files are written as git has them, through no filter of the server's
290
+ const index = (0, path_1.join)(this.root(id), `export-${(0, crypto_1.randomBytes)(6).toString('hex')}`);
291
+ await (0, promises_1.mkdir)(into, { recursive: true });
292
+ try {
293
+ const env = this.readEnv({ GIT_INDEX_FILE: index });
294
+ await this.git(id, ['read-tree', tree], env, signal, 'read');
295
+ await this.git(id, ['checkout-index', '--all', '--force', `--prefix=${(0, path_1.join)(into, '/')}`], env, signal, 'read');
296
+ }
297
+ finally {
298
+ await (0, promises_1.rm)(index, { force: true });
299
+ }
300
+ });
301
+ }
279
302
  async state({ id }, signal = AbortSignal.timeout(5 * 60 * 1000)) {
280
303
  return this.withIndex(id, signal, async (run) => {
281
304
  const tree = (await run(['write-tree'], 'read')).trim();
@@ -175,7 +175,8 @@ function parse(schema, body) {
175
175
  /**
176
176
  * Who the commit of a push is by: the caller, as their token names them (`name` and
177
177
  * `email`, unless the issuer says the address is not verified), else by their subject at the
178
- * host of their issuer. Never what the request says, so a commit can not claim to be
178
+ * host of their issuer. Without a name, the local part of the address names them, then the
179
+ * subject. Never what the request says, so a commit can not claim to be
179
180
  * somebody else's.
180
181
  */
181
182
  function author(principal) {
@@ -183,11 +184,16 @@ function author(principal) {
183
184
  .replace(/[<>\0\r\n]/g, '')
184
185
  .trim()
185
186
  .slice(0, 200);
186
- const name = clean(principal.claims.name) || clean(principal.claims.preferred_username) || clean(principal.sub);
187
187
  // an address the issuer says it did not verify is whatever the caller typed in
188
188
  const claimed = principal.emailVerified === false ? '' : clean(principal.claims.email);
189
- if (/^[^\s@]+@[^\s@]+$/.test(claimed)) {
190
- return { name, email: claimed };
189
+ const email = /^[^\s@]+@[^\s@]+$/.test(claimed) ? claimed : '';
190
+ // a Google service account's token has an address but no name: its local part reads better than the number in sub
191
+ const name = clean(principal.claims.name) ||
192
+ clean(principal.claims.preferred_username) ||
193
+ email.slice(0, email.indexOf('@')) ||
194
+ clean(principal.sub);
195
+ if (email) {
196
+ return { name, email };
191
197
  }
192
198
  let host = 'hammerkit';
193
199
  try {