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
@@ -27,9 +27,31 @@ class QuotaError extends RunError {
27
27
  exports.QuotaError = QuotaError;
28
28
  exports.RUN_ID_PATTERN = /^run_[A-Za-z0-9_-]{16}$/;
29
29
  exports.SANDBOX_ID_PATTERN = /^sbx_[A-Za-z0-9_-]{16}$/;
30
+ // A service run that is starting, up or asleep is listed as open.
31
+ function isOpenService(run) {
32
+ return !!run.up && !isFinal(run.state);
33
+ }
34
+ // Whether a run executes, for the limit of its caller: a move beside its services counts too
35
+ function executes(entry) {
36
+ return entry.run.state === 'running' || !!entry.mover?.counted;
37
+ }
38
+ // The places of runs.up.limits that service runs take: one per caller and name of the runs
39
+ // that are starting or up, an asleep one takes none (as checkUpLimits counts). Per place the
40
+ // newest run, which is the one that stays.
41
+ function servicePlaces(runs, asleep = false) {
42
+ const places = new Map();
43
+ for (const run of runs) {
44
+ if (run.up && !isFinal(run.state) && (run.state === 'asleep') === asleep) {
45
+ places.set(JSON.stringify([run.owner.iss, run.owner.sub, run.up.name]), run);
46
+ }
47
+ }
48
+ return [...places.values()];
49
+ }
30
50
  function onSandbox(run, sandbox) {
31
51
  return run.source.type === 'sandbox' && run.source.id === sandbox;
32
52
  }
53
+ // the tasks of a run whose check keys are kept, a build file has fewer
54
+ const MAX_KEYED_TASKS = 1000;
33
55
  // how much of the end of a task's output its result carries
34
56
  const TAIL_LINES = 20;
35
57
  const TAIL_LINE_LENGTH = 1000;
@@ -70,6 +92,8 @@ class RunRegistry {
70
92
  this.closed = false;
71
93
  // callers that wait for a run to finish, let go when the server stops
72
94
  this.waiters = new Set();
95
+ // the moves of service runs that execute beside their services
96
+ this.moving = new Set();
73
97
  this.maxConcurrent = options.maxConcurrent ?? 4;
74
98
  this.keep = options.keep ?? 200;
75
99
  this.maxEvents = options.maxEvents ?? 5000;
@@ -86,11 +110,15 @@ class RunRegistry {
86
110
  async restore() {
87
111
  const store = this.options.store;
88
112
  if (!store) {
113
+ // what a server before this one kept for its runs is nobody's now
114
+ this.options.executor.restored?.([]);
89
115
  return;
90
116
  }
91
117
  const { records, skipped } = await store.load();
92
118
  skipped.forEach((name) => this.options.log?.(`skip run record ${name}: it can not be read`));
93
119
  for (const { run, events } of records) {
120
+ // a move that executed went with the server
121
+ delete run.moving;
94
122
  const entry = {
95
123
  run,
96
124
  events,
@@ -100,6 +128,7 @@ class RunRegistry {
100
128
  bytes: 0,
101
129
  tails: new Map(Object.entries(run.results ?? {}).map(([task, result]) => [task, result.lastLog])),
102
130
  saving: Promise.resolve(),
131
+ keys: {},
103
132
  };
104
133
  this.entries.set(run.id, entry);
105
134
  if (run.state === 'awaiting-approval') {
@@ -112,11 +141,41 @@ class RunRegistry {
112
141
  this.finish(entry, 'rejected', 'nobody approved it in time');
113
142
  }
114
143
  }
144
+ else if (this.maySleep(entry) && ['queued', 'running', 'up', 'asleep'].includes(run.state)) {
145
+ // the services went with the process that held them, the next request wakes them
146
+ if (run.state === 'queued' || run.state === 'running' || run.state === 'up') {
147
+ // a server that stopped without putting the run to sleep left its volumes where they
148
+ // were, for the wake to mount
149
+ if (this.keepsVolumes()) {
150
+ run.volumesKept = true;
151
+ }
152
+ this.sleep(entry);
153
+ }
154
+ else {
155
+ this.expireLater(entry);
156
+ }
157
+ if (!this.keepsVolumes()) {
158
+ // the backend no longer keeps them, the sweep below removes them
159
+ delete run.volumesKept;
160
+ }
161
+ }
162
+ else if (run.state === 'asleep') {
163
+ // its time ran out while the server was down: it ends now, and gives its place back
164
+ run.stoppedBy ?? (run.stoppedBy = 'ttl');
165
+ this.finish(entry, 'succeeded');
166
+ }
115
167
  else if (run.state === 'queued' || run.state === 'running') {
116
168
  this.finish(entry, 'failed', `the server stopped while the run was ${run.state}, submit it again`);
117
169
  }
170
+ else if (run.state === 'up') {
171
+ // the services went with the process that held them
172
+ this.finish(entry, 'failed', 'the server stopped while the services were up, start them again');
173
+ }
118
174
  }
119
175
  this.evict();
176
+ this.options.executor.restored?.([...this.entries.values()]
177
+ .filter((entry) => entry.run.volumesKept && !isFinal(entry.run.state))
178
+ .map((entry) => this.copy(entry.run)));
120
179
  }
121
180
  // A run that needs approvals (for the accounts named) waits for them, the others
122
181
  // are queued at once.
@@ -129,13 +188,24 @@ class RunRegistry {
129
188
  throw new RunError('too many runs are waiting for an approval');
130
189
  }
131
190
  this.checkLimits(owner, waiting);
191
+ if (spec.up) {
192
+ this.checkUpLimits(owner, spec.up.name);
193
+ this.checkHosts(owner, spec.up);
194
+ }
132
195
  const run = {
133
196
  ...spec,
134
197
  id: `run_${(0, crypto_1.randomBytes)(12).toString('base64url')}`,
135
- owner: { iss: owner.iss, sub: owner.sub },
198
+ owner: {
199
+ iss: owner.iss,
200
+ sub: owner.sub,
201
+ ...(spec.up?.sleepTtl ? { groups: [...(owner.groups ?? [])], claims: { ...(owner.claims ?? {}) } } : {}),
202
+ },
136
203
  state: waiting ? 'awaiting-approval' : 'queued',
137
204
  createdAt: this.timestamp(),
138
205
  };
206
+ if (!waiting) {
207
+ run.queuedAt = run.createdAt;
208
+ }
139
209
  const entry = {
140
210
  run,
141
211
  events: [],
@@ -145,6 +215,7 @@ class RunRegistry {
145
215
  bytes: 0,
146
216
  tails: new Map(),
147
217
  saving: Promise.resolve(),
218
+ keys: {},
148
219
  };
149
220
  this.entries.set(run.id, entry);
150
221
  if (waiting) {
@@ -179,30 +250,133 @@ class RunRegistry {
179
250
  .map((entry) => this.copy(entry.run))
180
251
  .reverse();
181
252
  }
182
- // A queued run is cancelled at once; a running one when its executor stops.
253
+ // one run of any caller: who may see it is for the caller to decide
254
+ peek(id) {
255
+ const entry = this.entries.get(id);
256
+ return entry ? this.copy(entry.run) : undefined;
257
+ }
258
+ // every run it keeps, of every caller, newest first: who may see which is for the caller
259
+ all() {
260
+ return [...this.entries.values()].map((entry) => this.copy(entry.run)).reverse();
261
+ }
262
+ // What the caller has against each of their limits.
263
+ usage(owner) {
264
+ const mine = [...this.entries.values()].filter((entry) => this.owns(owner, entry));
265
+ const ids = (keep) => mine.filter(keep).map((entry) => entry.run.id);
266
+ return {
267
+ waiting: ids((entry) => entry.run.state === 'awaiting-approval'),
268
+ queued: ids((entry) => entry.run.state === 'queued'),
269
+ running: ids(executes),
270
+ services: servicePlaces(mine.map((entry) => entry.run)).map((run) => run.id),
271
+ asleep: servicePlaces(mine.map((entry) => entry.run), true).map((run) => run.id),
272
+ };
273
+ }
274
+ snapshot() {
275
+ const all = [...this.entries.values()];
276
+ const up = this.options.up;
277
+ const executing = new Map();
278
+ const key = (owner) => `${owner.iss}\0${owner.sub}`;
279
+ for (const entry of all) {
280
+ if (executes(entry)) {
281
+ executing.set(key(entry.run.owner), (executing.get(key(entry.run.owner)) ?? 0) + 1);
282
+ }
283
+ }
284
+ const runningOf = (owner) => executing.get(key(owner)) ?? 0;
285
+ return {
286
+ places: { max: this.maxConcurrent, used: this.running },
287
+ running: all.filter((entry) => entry.run.state === 'running').map((entry) => this.copy(entry.run)),
288
+ queue: this.queue.map((entry) => ({ run: this.copy(entry.run), waitsFor: this.waitReason(entry, runningOf) })),
289
+ awaiting: {
290
+ runs: all.filter((entry) => entry.run.state === 'awaiting-approval').map((entry) => this.copy(entry.run)),
291
+ max: this.maxWaiting,
292
+ },
293
+ services: {
294
+ runs: all.filter((entry) => isOpenService(entry.run)).map((entry) => this.copy(entry.run)),
295
+ taken: servicePlaces(all.map((entry) => entry.run)).length,
296
+ ...(up ? { max: up.total, perCaller: up.perCaller } : {}),
297
+ ...(up?.asleep !== undefined ? { asleepPerCaller: up.asleep } : {}),
298
+ },
299
+ ...(this.options.limits ? { limits: { ...this.options.limits } } : {}),
300
+ };
301
+ }
302
+ // Where a queued run stands in the queue (from 1), and why it waits.
303
+ queued(id) {
304
+ const index = this.queue.findIndex((entry) => entry.run.id === id);
305
+ return index < 0 ? undefined : { position: index + 1, waitsFor: this.waitReason(this.queue[index]) };
306
+ }
307
+ // A queued run is cancelled at once; a running one when its executor stops. A service run
308
+ // that is up stops its services, and says who stopped it.
183
309
  cancel(owner, id, canUse) {
184
310
  const entry = this.find(owner, id, canUse);
185
311
  if (!entry) {
186
312
  return undefined;
187
313
  }
188
- this.stop(entry);
314
+ this.stop(entry, undefined, { iss: owner.iss, sub: owner.sub });
189
315
  return this.copy(entry.run);
190
316
  }
317
+ /**
318
+ * Moves the end of a service run that is up (or asleep) to `ttl` (ms) from now, at most
319
+ * the server's maxTtl, and never sooner than it was. Its owner may, and whoever `mayOpen`
320
+ * says may open its endpoints.
321
+ */
322
+ extend(caller, id, ttl, mayOpen) {
323
+ const entry = this.entries.get(id);
324
+ if (!entry || !(this.owns(caller, entry) || (entry.run.up && mayOpen?.(this.copy(entry.run))))) {
325
+ return { status: 'not-found' };
326
+ }
327
+ // up, asleep, or woken and starting again
328
+ if (!entry.run.upAt || isFinal(entry.run.state)) {
329
+ return { status: 'not-up', state: entry.run.state };
330
+ }
331
+ // never sooner than it was: who wants it gone stops it
332
+ const ms = Math.min(ttl, this.options.up?.maxTtl ?? ttl);
333
+ const expiresAt = Math.max(Date.parse(entry.run.expiresAt ?? '') || 0, this.now() + ms);
334
+ entry.run.expiresAt = new Date(expiresAt).toISOString();
335
+ this.record(entry, { type: 'extended', by: { iss: caller.iss, sub: caller.sub }, expiresAt: entry.run.expiresAt });
336
+ this.expireLater(entry);
337
+ return { status: 'ok', run: this.copy(entry.run) };
338
+ }
339
+ /**
340
+ * The service run an endpoint host belongs to: the newest that is up, so one that replaces
341
+ * a run of the same name takes its hosts over as soon as it is up; else the newest that
342
+ * had it, for a page that says how it stands. With the address of the endpoint when up.
343
+ */
344
+ endpoint(host) {
345
+ let found;
346
+ for (const entry of this.entries.values()) {
347
+ const endpoint = entry.run.up?.endpoints.find((candidate) => candidate.host === host);
348
+ if (endpoint && (entry.run.state === 'up' || found?.entry.run.state !== 'up')) {
349
+ found = { entry, endpoint };
350
+ }
351
+ }
352
+ if (!found) {
353
+ return undefined;
354
+ }
355
+ const { entry, endpoint } = found;
356
+ const target = entry.run.state === 'up'
357
+ ? entry.targets?.find((candidate) => candidate.service === endpoint.service && candidate.port === endpoint.port)
358
+ : undefined;
359
+ return { run: this.copy(entry.run), endpoint: { ...endpoint }, ...(target ? { target } : {}) };
360
+ }
191
361
  // 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.
362
+ // when it is submitted, so the files are not moved under what an approver saw. A service
363
+ // run that is up works on a copy of the files, and holds it no longer.
193
364
  holder(sandbox) {
194
- return [...this.entries.values()].find((entry) => onSandbox(entry.run, sandbox) && !isFinal(entry.run.state))?.run
195
- .id;
365
+ return [...this.entries.values()].find((entry) => onSandbox(entry.run, sandbox) &&
366
+ !isFinal(entry.run.state) &&
367
+ entry.run.state !== 'up' &&
368
+ entry.run.state !== 'asleep')?.run.id;
196
369
  }
197
370
  // Cancels every run on a sandbox and resolves when none of them runs anymore.
198
371
  async stopSandbox(sandbox, reason) {
199
372
  const on = () => [...this.entries.values()].filter((entry) => onSandbox(entry.run, sandbox));
200
373
  on().forEach((entry) => this.stop(entry, reason));
201
- while (on().some((entry) => entry.run.state === 'running')) {
374
+ while (on().some((entry) => entry.run.state === 'running' || entry.run.state === 'up')) {
202
375
  await new Promise((resolve) => setTimeout(resolve, 20));
203
376
  }
204
377
  }
205
- stop(entry, reason) {
378
+ stop(entry, reason, by) {
379
+ var _a, _b;
206
380
  if (entry.run.state === 'queued') {
207
381
  this.queue.splice(this.queue.indexOf(entry), 1);
208
382
  this.finish(entry, 'cancelled', reason);
@@ -213,6 +387,17 @@ class RunRegistry {
213
387
  else if (entry.run.state === 'running') {
214
388
  entry.abort.abort();
215
389
  }
390
+ else if (entry.run.state === 'up') {
391
+ (_a = entry.run).stoppedBy ?? (_a.stoppedBy = by ?? 'replaced');
392
+ entry.sleeping = false;
393
+ entry.restartTo = undefined;
394
+ entry.mover?.abort.abort();
395
+ entry.abort.abort();
396
+ }
397
+ else if (entry.run.state === 'asleep') {
398
+ (_b = entry.run).stoppedBy ?? (_b.stoppedBy = by ?? 'replaced');
399
+ this.finish(entry, 'succeeded');
400
+ }
216
401
  }
217
402
  // The runs that wait for an approval, of anybody, oldest first. Who may see
218
403
  // them is for the caller to decide.
@@ -268,6 +453,7 @@ class RunRegistry {
268
453
  }
269
454
  this.clearTimer(entry);
270
455
  entry.run.state = 'queued';
456
+ entry.run.queuedAt = this.timestamp();
271
457
  this.record(entry, { type: 'state', state: 'queued' });
272
458
  this.queue.push(entry);
273
459
  const approved = this.copy(entry.run);
@@ -289,18 +475,21 @@ class RunRegistry {
289
475
  return { events, finished, stop: () => entry.listeners.delete(listener) };
290
476
  }
291
477
  /**
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).
478
+ * The run once it is over (a service run: once it is up), or as it is after `ms` or when
479
+ * `signal` aborts, for clients that can not hold a stream of events open (a tool call of
480
+ * an agent).
294
481
  */
295
482
  async wait(owner, id, ms, canUse, signal) {
296
483
  const entry = this.find(owner, id, canUse);
297
484
  if (!entry) {
298
485
  return undefined;
299
486
  }
300
- if (!isFinal(entry.run.state) && ms > 0 && !this.closed && !signal?.aborted) {
487
+ // a service run is there once it is up, and at the commit it moves to
488
+ const there = () => isFinal(entry.run.state) || (entry.run.state === 'up' && !entry.run.moving);
489
+ if (!there() && ms > 0 && !this.closed && !signal?.aborted) {
301
490
  await new Promise((resolve) => {
302
491
  const listener = (event) => {
303
- if (event.type === 'state' && isFinal(event.state)) {
492
+ if ((event.type === 'state' || event.type === 'moved') && there()) {
304
493
  done();
305
494
  }
306
495
  };
@@ -331,14 +520,34 @@ class RunRegistry {
331
520
  entry.index.update(entry.events);
332
521
  return { events: entry.events, index: entry.index, truncated: entry.truncated };
333
522
  }
523
+ // The log of any run, for a caller whose right to read it was checked already (an
524
+ // entitlement with `observe`).
525
+ logsOf(id) {
526
+ const entry = this.entries.get(id);
527
+ if (!entry) {
528
+ return undefined;
529
+ }
530
+ entry.index ?? (entry.index = new run_logs_1.LogIndex());
531
+ entry.index.update(entry.events);
532
+ return { events: entry.events, index: entry.index, truncated: entry.truncated };
533
+ }
334
534
  // Stops accepting runs and aborts the ones in flight.
335
535
  async close() {
336
536
  this.closed = true;
337
537
  for (const entry of this.queue.splice(0)) {
338
- this.finish(entry, 'cancelled');
538
+ if (this.maySleep(entry) && this.options.store) {
539
+ // a woken service run sleeps again, the next start keeps it
540
+ this.sleep(entry);
541
+ }
542
+ else {
543
+ this.finish(entry, 'cancelled');
544
+ }
339
545
  }
340
546
  for (const entry of this.entries.values()) {
341
- if (entry.run.state === 'awaiting-approval') {
547
+ if (entry.run.state === 'asleep') {
548
+ this.clearTimer(entry);
549
+ }
550
+ else if (entry.run.state === 'awaiting-approval') {
342
551
  if (this.options.store) {
343
552
  // the record keeps it waiting, the next start takes it back
344
553
  this.clearTimer(entry);
@@ -353,11 +562,17 @@ class RunRegistry {
353
562
  done();
354
563
  }
355
564
  for (const entry of this.entries.values()) {
356
- if (entry.run.state === 'running') {
565
+ if (entry.run.state === 'running' || entry.run.state === 'up') {
566
+ // a service run that may sleep is asleep in its record, the next request after the
567
+ // start wakes it
568
+ entry.sleeping = this.maySleep(entry) && !!this.options.store;
569
+ entry.mover?.abort.abort();
357
570
  entry.abort.abort();
358
571
  }
359
572
  }
360
- while (this.running > 0) {
573
+ while (this.running > 0 ||
574
+ this.moving.size > 0 ||
575
+ [...this.entries.values()].some((entry) => entry.run.state === 'running' || entry.run.state === 'up')) {
361
576
  await new Promise((resolve) => setTimeout(resolve, 10));
362
577
  }
363
578
  // the runs that were stopped are written down before the server is gone
@@ -388,6 +603,8 @@ class RunRegistry {
388
603
  copy(run) {
389
604
  return {
390
605
  ...run,
606
+ ...(run.up ? { up: JSON.parse(JSON.stringify(run.up)) } : {}),
607
+ ...(run.stoppedBy && typeof run.stoppedBy === 'object' ? { stoppedBy: { ...run.stoppedBy } } : {}),
391
608
  tasks: [...run.tasks],
392
609
  source: { ...run.source },
393
610
  options: JSON.parse(JSON.stringify(run.options)),
@@ -395,6 +612,8 @@ class RunRegistry {
395
612
  accounts: [...run.accounts],
396
613
  ...(run.approvals ? { approvals: JSON.parse(JSON.stringify(run.approvals)) } : {}),
397
614
  ...(run.results ? { results: JSON.parse(JSON.stringify(run.results)) } : {}),
615
+ ...(run.moves ? { moves: run.moves.map((move) => ({ ...move })) } : {}),
616
+ ...(run.moving ? { moving: { ...run.moving } } : {}),
398
617
  };
399
618
  }
400
619
  timestamp() {
@@ -406,30 +625,128 @@ class RunRegistry {
406
625
  if (!limits) {
407
626
  return;
408
627
  }
409
- const mine = [...this.entries.values()].filter((entry) => this.owns(owner, entry));
410
- const count = (state) => mine.filter((entry) => entry.run.state === state).length;
411
- if (waiting && count('awaiting-approval') >= limits.waiting) {
628
+ const usage = this.usage(owner);
629
+ if (waiting && usage.waiting.length >= limits.waiting) {
412
630
  throw new QuotaError(`you have ${limits.waiting} runs waiting for an approval, wait for a decision or cancel one`);
413
631
  }
414
- if (!waiting && count('queued') >= limits.queued) {
632
+ if (!waiting && usage.queued.length >= limits.queued) {
415
633
  throw new QuotaError(`you have ${limits.queued} runs in the queue, wait for one to start or cancel one`);
416
634
  }
417
635
  }
636
+ // Service runs that are starting or up, of the caller and of everybody, against what may
637
+ // be. One that is asleep does not count: it asks for a place when it wakes (visit). The runs
638
+ // of a caller with one name take one place: the newest replaces the others once it is up,
639
+ // and an older one that gets up after it goes (markUp), so a new run of a name the caller
640
+ // has does not need another. Several of one name that are still starting share it too:
641
+ // what they take while they start is held by runs.limits.running and maxConcurrent.
642
+ checkUpLimits(owner, name) {
643
+ // what a caller keeps asleep is held by a limit of its own: a run that sleeps may keep the
644
+ // volumes of its services. A run of a name the caller has a run of (asleep, up or
645
+ // starting) replaces it, it is not one more.
646
+ const asleep = this.options.up?.asleep;
647
+ if (asleep !== undefined) {
648
+ const open = [...this.entries.values()].filter((entry) => entry.run.up && !isFinal(entry.run.state) && this.owns(owner, entry));
649
+ const sleeping = new Set(open.filter((entry) => entry.run.state === 'asleep').map((entry) => entry.run.up?.name));
650
+ if (!open.some((entry) => entry.run.up?.name === name) && sleeping.size >= asleep) {
651
+ throw new QuotaError(`you have ${asleep} service runs asleep, stop one or wait for its end`);
652
+ }
653
+ }
654
+ const full = this.upLimitReached(owner, name);
655
+ if (full === 'caller') {
656
+ throw new QuotaError(`you have ${this.options.up?.perCaller} service runs starting or up, stop one or wait for its end`);
657
+ }
658
+ if (full === 'server') {
659
+ throw new RunError(`the server has ${this.options.up?.total} service runs starting or up, try again later`);
660
+ }
661
+ }
662
+ // Whose limit a service run of the caller with this name would go over, if any
663
+ upLimitReached(owner, name) {
664
+ const limits = this.options.up;
665
+ if (!limits) {
666
+ return undefined;
667
+ }
668
+ const places = new Set();
669
+ const mine = new Set();
670
+ for (const entry of this.entries.values()) {
671
+ if (!entry.run.up || isFinal(entry.run.state) || entry.run.state === 'asleep') {
672
+ continue;
673
+ }
674
+ places.add(JSON.stringify([entry.run.owner.iss, entry.run.owner.sub, entry.run.up.name]));
675
+ if (this.owns(owner, entry)) {
676
+ mine.add(entry.run.up.name);
677
+ }
678
+ }
679
+ if (mine.has(name)) {
680
+ return undefined;
681
+ }
682
+ if (mine.size >= limits.perCaller) {
683
+ return 'caller';
684
+ }
685
+ if (places.size >= limits.total) {
686
+ return 'server';
687
+ }
688
+ return undefined;
689
+ }
690
+ // A host is one run's, or a run's that replaces it (same owner and name) once it is up:
691
+ // two labels that come out the same are refused, they would reach each other's services.
692
+ checkHosts(owner, up) {
693
+ const hosts = new Set(up.endpoints.map((endpoint) => endpoint.host));
694
+ for (const entry of this.entries.values()) {
695
+ if (isFinal(entry.run.state) || !entry.run.up) {
696
+ continue;
697
+ }
698
+ const taken = entry.run.up.endpoints.find((endpoint) => hosts.has(endpoint.host));
699
+ if (taken && !(this.owns(owner, entry) && entry.run.up.name === up.name)) {
700
+ const error = new RunError(`the address ${taken.host} is another service run's, pick another name`);
701
+ error.status = 409;
702
+ throw error;
703
+ }
704
+ }
705
+ }
418
706
  runningOf(owner) {
419
- return [...this.entries.values()].filter((entry) => entry.run.state === 'running' && this.owns(owner, entry)).length;
707
+ return this.usage(owner).running.length;
420
708
  }
421
- // Whether a run on the same sandbox executes: a sandbox runs one at a time.
709
+ // The run on the same sandbox that executes: a sandbox runs one at a time.
422
710
  sandboxBusy(run) {
423
711
  const source = run.source;
424
- return (source.type === 'sandbox' &&
425
- [...this.entries.values()].some((entry) => entry.run.state === 'running' && onSandbox(entry.run, source.id)));
712
+ if (source.type !== 'sandbox') {
713
+ return undefined;
714
+ }
715
+ return [...this.entries.values()].find((entry) => entry.run.state === 'running' && onSandbox(entry.run, source.id))
716
+ ?.run.id;
426
717
  }
427
- // Whether a run that executes holds what the run would (RunExecutor.exclusive)
718
+ // The run that executes and holds what the run would (RunExecutor.exclusive)
428
719
  held(run) {
429
720
  const exclusive = this.options.executor.exclusive?.bind(this.options.executor);
430
721
  const key = exclusive?.(run);
431
- return (key !== undefined &&
432
- [...this.entries.values()].some((entry) => entry.run.state === 'running' && exclusive?.(entry.run) === key));
722
+ if (key === undefined) {
723
+ return undefined;
724
+ }
725
+ return [...this.entries.values()].find((entry) => entry.run.state === 'running' && exclusive?.(entry.run) === key)
726
+ ?.run.id;
727
+ }
728
+ // What keeps a queued run from starting, checked as dispatch checks it: what holds it
729
+ // even when a place frees up is named first.
730
+ // `runningOf` counts the runs of the owner that execute; a snapshot passes one that
731
+ // counted all owners once, so a look at the queue costs the runs once and not per entry
732
+ waitReason(entry, runningOf = (owner) => this.runningOf(owner)) {
733
+ const limit = this.options.limits?.running ?? Infinity;
734
+ const running = runningOf(entry.run.owner);
735
+ if (running >= limit) {
736
+ return { type: 'own-limit', running, limit };
737
+ }
738
+ const sandbox = this.sandboxBusy(entry.run);
739
+ if (sandbox) {
740
+ return { type: 'sandbox', run: sandbox };
741
+ }
742
+ const holder = this.held(entry.run);
743
+ if (holder) {
744
+ return { type: 'held', run: holder };
745
+ }
746
+ if (this.running >= this.maxConcurrent) {
747
+ return { type: 'place', used: this.running, max: this.maxConcurrent };
748
+ }
749
+ return { type: 'starting' };
433
750
  }
434
751
  // The first run in the queue whose caller has a place left: one caller with many runs
435
752
  // does not keep the others from the machine. A run on a sandbox another run works on
@@ -439,12 +756,21 @@ class RunRegistry {
439
756
  const perCaller = this.options.limits?.running ?? Infinity;
440
757
  const index = this.queue.findIndex((entry) => this.runningOf(entry.run.owner) < perCaller && !this.sandboxBusy(entry.run) && !this.held(entry.run));
441
758
  if (index < 0) {
759
+ // the moves that wait for a place
760
+ for (const entry of this.entries.values()) {
761
+ if (entry.pendingMove && entry.run.state === 'up' && !entry.mover) {
762
+ this.beginMove(entry);
763
+ }
764
+ }
442
765
  return;
443
766
  }
444
767
  const [entry] = this.queue.splice(index, 1);
445
768
  this.running++;
446
769
  this.execute(entry).finally(() => {
447
- this.running--;
770
+ // a service run that is up gave its place back already
771
+ if (!entry.released) {
772
+ this.running--;
773
+ }
448
774
  this.dispatch();
449
775
  });
450
776
  }
@@ -453,27 +779,597 @@ class RunRegistry {
453
779
  entry.run.state = 'running';
454
780
  entry.run.startedAt = this.timestamp();
455
781
  this.record(entry, { type: 'state', state: 'running' });
782
+ // a service run gets this long to be up, then its ttl counts
456
783
  const limit = this.options.maxDuration;
457
- const timer = limit
784
+ entry.durationTimer = limit
458
785
  ? setTimeout(() => {
459
786
  entry.timedOut = true;
460
787
  entry.abort.abort();
461
788
  }, limit)
462
789
  : undefined;
463
- timer?.unref();
790
+ entry.durationTimer?.unref();
791
+ const token = {};
792
+ entry.holder = token;
793
+ // the commit of a move whose services are not up there yet
794
+ const landing = entry.landing && !entry.landing.recorded ? entry.landing.change : undefined;
795
+ const run = landing
796
+ ? { ...this.copy(entry.run), source: { ...landing.source }, ...(landing.plan ? { plan: landing.plan } : {}) }
797
+ : this.copy(entry.run);
464
798
  try {
465
- await this.options.executor.execute(this.copy(entry.run), {
799
+ await this.options.executor.execute(run, {
466
800
  emit: (event) => this.record(entry, event),
467
801
  signal: entry.abort.signal,
802
+ ...(entry.run.up
803
+ ? {
804
+ up: (targets) => this.markUp(entry, targets),
805
+ keep: () => (entry.holder === token ? this.keepOf(entry) : 'everything'),
806
+ }
807
+ : {}),
468
808
  });
469
- this.finish(entry, entry.timedOut ? 'failed' : entry.abort.signal.aborted ? 'cancelled' : 'succeeded', entry.timedOut ? this.tooLong() : undefined);
809
+ if (entry.holder !== token) {
810
+ // a move took the services over
811
+ return;
812
+ }
813
+ if (this.fallBack(entry, entry.timedOut ? this.tooLong() : 'its services stopped before they were up')) {
814
+ return;
815
+ }
816
+ if (entry.restartTo || entry.sleeping) {
817
+ this.stopped(entry);
818
+ }
819
+ else if (entry.run.upAt) {
820
+ // services stay until they are stopped: ones that stopped on their own failed
821
+ this.endServices(entry, 'the services stopped on their own');
822
+ }
823
+ else {
824
+ this.finish(entry, entry.timedOut ? 'failed' : entry.abort.signal.aborted ? 'cancelled' : 'succeeded', entry.timedOut ? this.tooLong() : undefined);
825
+ }
470
826
  }
471
827
  catch (e) {
472
- const cancelled = entry.abort.signal.aborted && !entry.timedOut;
473
- this.finish(entry, cancelled ? 'cancelled' : 'failed', entry.timedOut ? this.tooLong() : cancelled ? undefined : e instanceof Error ? e.message : String(e));
828
+ const message = e instanceof Error ? e.message : String(e);
829
+ if (entry.holder !== token) {
830
+ return;
831
+ }
832
+ if (this.fallBack(entry, entry.timedOut ? this.tooLong() : message)) {
833
+ return;
834
+ }
835
+ if (entry.restartTo || entry.sleeping) {
836
+ this.stopped(entry);
837
+ }
838
+ else if (entry.run.upAt) {
839
+ this.endServices(entry, message);
840
+ }
841
+ else {
842
+ const cancelled = entry.abort.signal.aborted && !entry.timedOut;
843
+ this.finish(entry, cancelled ? 'cancelled' : 'failed', entry.timedOut ? this.tooLong() : cancelled ? undefined : message);
844
+ }
474
845
  }
475
846
  finally {
476
- clearTimeout(timer);
847
+ clearTimeout(entry.durationTimer);
848
+ }
849
+ }
850
+ // The services of a service run are ready: its ttl counts from now, its place of
851
+ // maxConcurrent goes to the next run, and an older run of its owner with the same name
852
+ // stops, so its hosts are this run's.
853
+ markUp(entry, targets) {
854
+ const spec = entry.run.up;
855
+ if (!spec || entry.run.state !== 'running' || entry.abort.signal.aborted) {
856
+ return;
857
+ }
858
+ clearTimeout(entry.durationTimer);
859
+ entry.durationTimer = undefined;
860
+ entry.targets = targets.map((target) => ({ ...target, address: { ...target.address } }));
861
+ // the services got up at the commit of a move: the run is there now
862
+ const landing = entry.landing;
863
+ entry.landing = undefined;
864
+ if (landing && !landing.recorded) {
865
+ this.moveTo(entry, landing.change);
866
+ }
867
+ entry.run.state = 'up';
868
+ // one reading of the clock, so the ttl is exactly expiresAt - upAt
869
+ const now = this.now();
870
+ // the ttl counts from the first time, not from a wake
871
+ if (!entry.run.upAt) {
872
+ entry.run.upAt = new Date(now).toISOString();
873
+ entry.run.expiresAt = new Date(now + spec.ttl).toISOString();
874
+ }
875
+ if (spec.sleepTtl) {
876
+ entry.run.sleepsAt = new Date(now + spec.sleepTtl).toISOString();
877
+ }
878
+ this.record(entry, { type: 'state', state: 'up' });
879
+ this.expireLater(entry);
880
+ entry.released = true;
881
+ this.running--;
882
+ const entries = [...this.entries.values()];
883
+ for (const other of entries.slice(0, entries.indexOf(entry))) {
884
+ if (other.run.up?.name === spec.name &&
885
+ this.owns(entry.run.owner, other) &&
886
+ !isFinal(other.run.state) &&
887
+ other.run.state !== 'awaiting-approval') {
888
+ other.run.stoppedBy = 'replaced';
889
+ this.stop(other, `replaced by ${entry.run.id}`);
890
+ }
891
+ }
892
+ // A newer run of the name that is up or asleep already (this one waited for an approval
893
+ // while it got up) is what the owner started last: this one goes, so the runs of a name,
894
+ // which take one place of runs.up.limits, never hold more than one set of services.
895
+ const newer = entries
896
+ .slice(entries.indexOf(entry) + 1)
897
+ .find((other) => other.run.up?.name === spec.name &&
898
+ this.owns(entry.run.owner, other) &&
899
+ (other.run.state === 'up' || other.run.state === 'asleep'));
900
+ if (newer) {
901
+ entry.run.stoppedBy = 'replaced';
902
+ this.stop(entry, `replaced by ${newer.run.id}`);
903
+ }
904
+ this.dispatch();
905
+ if (!newer) {
906
+ // a move that came while it started or woke
907
+ this.beginMove(entry);
908
+ }
909
+ }
910
+ /**
911
+ * The service run of the caller of this name that a run of theirs would replace: the newest
912
+ * that is not over and waits for no approval. RunRegistry.move moves it instead, when it
913
+ * runs the same.
914
+ */
915
+ named(owner, name) {
916
+ const entry = [...this.entries.values()]
917
+ .reverse()
918
+ .find((candidate) => candidate.run.up?.name === name &&
919
+ this.owns(owner, candidate) &&
920
+ !isFinal(candidate.run.state) &&
921
+ candidate.run.state !== 'awaiting-approval');
922
+ return entry ? this.copy(entry.run) : undefined;
923
+ }
924
+ /**
925
+ * Moves a service run of the caller to another commit of its repository, in place of a new
926
+ * run that replaces it: it keeps its id, its addresses, its volumes and its place. Queued or
927
+ * asleep, it starts the new commit when it starts; up, it moves there now (see
928
+ * RunExecutor.movesBeside), starting or waking, once it is up. A later move takes the place
929
+ * of one that did not begin yet, and stops one that executes. `ttl` moves its end to that
930
+ * long from now, if that is later. Undefined when the run is not the caller's to move.
931
+ */
932
+ move(owner, id, change) {
933
+ const entry = this.entries.get(id);
934
+ const state = entry?.run.state;
935
+ if (!entry ||
936
+ !this.owns(owner, entry) ||
937
+ !entry.run.up ||
938
+ entry.run.source.type !== 'ref' ||
939
+ !state ||
940
+ isFinal(state) ||
941
+ state === 'awaiting-approval' ||
942
+ this.closed) {
943
+ return undefined;
944
+ }
945
+ const by = { iss: owner.iss, sub: owner.sub };
946
+ if (change.owner) {
947
+ entry.run.owner = { ...change.owner };
948
+ }
949
+ if (change.ttl !== undefined) {
950
+ if (entry.run.upAt) {
951
+ const ms = Math.min(change.ttl, this.options.up?.maxTtl ?? change.ttl);
952
+ const expiresAt = Math.max(Date.parse(entry.run.expiresAt ?? '') || 0, this.now() + ms);
953
+ if (new Date(expiresAt).toISOString() !== entry.run.expiresAt) {
954
+ entry.run.expiresAt = new Date(expiresAt).toISOString();
955
+ this.record(entry, { type: 'extended', by, expiresAt: entry.run.expiresAt });
956
+ }
957
+ }
958
+ else {
959
+ entry.run.up.ttl = Math.max(entry.run.up.ttl, change.ttl);
960
+ }
961
+ }
962
+ if (state === 'queued' && entry.landing && !entry.landing.recorded) {
963
+ // it starts again at the commit of a move: at this one instead
964
+ entry.landing.change = change;
965
+ entry.run.moving = { commit: change.source.commit, ref: change.source.ref, since: this.timestamp() };
966
+ return this.copy(entry.run);
967
+ }
968
+ if (state === 'queued' || state === 'asleep') {
969
+ if (entry.run.upAt) {
970
+ // it was up at the commit it is at: a wake that fails at the new one goes back there
971
+ entry.landing = {
972
+ change,
973
+ from: entry.landing?.from ?? this.landingFrom(entry),
974
+ recorded: true,
975
+ };
976
+ }
977
+ this.moveTo(entry, change);
978
+ if (state === 'asleep') {
979
+ this.expireLater(entry);
980
+ }
981
+ return this.copy(entry.run);
982
+ }
983
+ // running or up: once it is up, beside its services
984
+ entry.pendingMove = change;
985
+ entry.run.moving = { commit: change.source.commit, ref: change.source.ref, since: this.timestamp() };
986
+ // one that executes goes, the newer commit is what is wanted
987
+ entry.mover?.abort.abort();
988
+ if (state === 'up') {
989
+ // a move counts as a request: the run does not sleep under it
990
+ this.keepAwake(entry);
991
+ this.beginMove(entry);
992
+ }
993
+ return this.copy(entry.run);
994
+ }
995
+ // The run is at the commit of `change` now
996
+ moveTo(entry, change, error) {
997
+ const move = {
998
+ commit: change.source.commit,
999
+ ref: change.source.ref,
1000
+ at: this.timestamp(),
1001
+ ...(error ? { error } : {}),
1002
+ };
1003
+ if (!error) {
1004
+ entry.run.source = { ...change.source };
1005
+ if (change.plan) {
1006
+ entry.run.plan = change.plan;
1007
+ }
1008
+ }
1009
+ entry.run.moves = [...(entry.run.moves ?? []), move];
1010
+ if (!entry.pendingMove && !entry.mover) {
1011
+ delete entry.run.moving;
1012
+ }
1013
+ this.record(entry, {
1014
+ type: 'moved',
1015
+ commit: move.commit,
1016
+ ref: move.ref,
1017
+ ...(error ? { error } : {}),
1018
+ });
1019
+ }
1020
+ keepAwake(entry) {
1021
+ const sleepTtl = entry.run.up?.sleepTtl;
1022
+ if (sleepTtl && entry.run.state === 'up') {
1023
+ entry.run.sleepsAt = new Date(this.now() + sleepTtl).toISOString();
1024
+ this.expireLater(entry);
1025
+ }
1026
+ }
1027
+ // The move that waits, once the run is up and no other move executes
1028
+ beginMove(entry) {
1029
+ const change = entry.pendingMove;
1030
+ if (!change || entry.run.state !== 'up' || entry.mover || entry.restartTo || entry.abort.signal.aborted) {
1031
+ return;
1032
+ }
1033
+ const beside = !!this.options.executor.movesBeside?.(this.copy(entry.run));
1034
+ if (beside &&
1035
+ (this.running >= this.maxConcurrent ||
1036
+ this.runningOf(entry.run.owner) >= (this.options.limits?.running ?? Infinity))) {
1037
+ // it executes as a run does: it waits for a place (dispatch)
1038
+ return;
1039
+ }
1040
+ entry.pendingMove = undefined;
1041
+ entry.run.moving = { commit: change.source.commit, ref: change.source.ref, since: this.timestamp() };
1042
+ this.record(entry, {
1043
+ type: 'log',
1044
+ task: 'hammerkit',
1045
+ stream: 'stdout',
1046
+ text: `moving ${entry.run.source.type === 'ref' ? `from ${entry.run.source.commit} ` : ''}to ${change.source.commit}`,
1047
+ });
1048
+ if (!beside) {
1049
+ // its services stop, their volumes kept, and it starts again at the commit
1050
+ entry.restartTo = change;
1051
+ entry.abort.abort();
1052
+ return;
1053
+ }
1054
+ const token = {};
1055
+ const abort = new AbortController();
1056
+ const mover = { token, abort, change, counted: true };
1057
+ this.running++;
1058
+ // it gets as long as a run gets to be up
1059
+ const limit = this.options.maxDuration;
1060
+ if (limit) {
1061
+ mover.timer = setTimeout(() => {
1062
+ mover.timedOut = true;
1063
+ abort.abort();
1064
+ }, limit);
1065
+ mover.timer.unref();
1066
+ }
1067
+ entry.mover = mover;
1068
+ const run = {
1069
+ ...this.copy(entry.run),
1070
+ source: { ...change.source },
1071
+ ...(change.plan ? { plan: change.plan } : {}),
1072
+ };
1073
+ const execution = this.executeMove(entry, token, abort, run);
1074
+ this.moving.add(execution);
1075
+ void execution.finally(() => this.moving.delete(execution));
1076
+ }
1077
+ // A move beside the services of a run that is up: once its services are ready it holds them
1078
+ // (moved), and its end is the run's. One that ends before leaves the run where it was.
1079
+ async executeMove(entry, token, abort, run) {
1080
+ let failure;
1081
+ try {
1082
+ await this.options.executor.execute(run, {
1083
+ emit: (event) => this.record(entry, event),
1084
+ signal: abort.signal,
1085
+ up: (targets) => this.moved(entry, token, targets),
1086
+ keep: () => (entry.holder === token ? this.keepOf(entry) : 'everything'),
1087
+ });
1088
+ failure = abort.signal.aborted ? 'the move was stopped' : 'its services stopped before they were up';
1089
+ }
1090
+ catch (e) {
1091
+ failure = e instanceof Error ? e.message : String(e);
1092
+ }
1093
+ const mover = entry.mover?.token === token ? entry.mover : undefined;
1094
+ if (mover?.timedOut) {
1095
+ failure = `the move took longer than ${(0, units_1.formatDuration)(this.options.maxDuration)} and was stopped`;
1096
+ }
1097
+ this.releaseMover(mover);
1098
+ if (entry.holder === token) {
1099
+ // the run's services were this execution's
1100
+ if (entry.restartTo || entry.sleeping) {
1101
+ this.stopped(entry);
1102
+ }
1103
+ else {
1104
+ this.endServices(entry, failure === 'the move was stopped' ? 'the services stopped on their own' : failure);
1105
+ }
1106
+ return;
1107
+ }
1108
+ if (entry.mover?.token !== token) {
1109
+ return;
1110
+ }
1111
+ entry.mover = undefined;
1112
+ if (isFinal(entry.run.state)) {
1113
+ return;
1114
+ }
1115
+ this.moveTo(entry, { source: run.source }, failure);
1116
+ this.beginMove(entry);
1117
+ this.dispatch();
1118
+ }
1119
+ // A move that took the services over or ended gives its place back
1120
+ releaseMover(mover) {
1121
+ if (!mover?.counted) {
1122
+ return;
1123
+ }
1124
+ mover.counted = false;
1125
+ clearTimeout(mover.timer);
1126
+ this.running--;
1127
+ }
1128
+ // The services of a move are ready: they are the run's from now, at its commit, and the
1129
+ // execution that held the services until now leaves them to it.
1130
+ moved(entry, token, targets) {
1131
+ const mover = entry.mover;
1132
+ if (!mover || mover.token !== token || mover.abort.signal.aborted || entry.run.state !== 'up') {
1133
+ return;
1134
+ }
1135
+ entry.mover = undefined;
1136
+ this.releaseMover(mover);
1137
+ const previous = entry.abort;
1138
+ entry.abort = mover.abort;
1139
+ entry.holder = token;
1140
+ entry.targets = targets.map((target) => ({ ...target, address: { ...target.address } }));
1141
+ this.moveTo(entry, mover.change);
1142
+ this.keepAwake(entry);
1143
+ previous.abort();
1144
+ this.beginMove(entry);
1145
+ this.dispatch();
1146
+ }
1147
+ // The services of a run stopped to sleep, or to start again at the commit of a move
1148
+ stopped(entry) {
1149
+ if (!entry.restartTo) {
1150
+ if (entry.run.stoppedBy) {
1151
+ // a stop came while its services stopped to sleep: it ends
1152
+ this.finish(entry, 'succeeded');
1153
+ return;
1154
+ }
1155
+ this.sleep(entry);
1156
+ return;
1157
+ }
1158
+ // the newest move, one that came while the services stopped
1159
+ const restart = entry.pendingMove ?? entry.restartTo;
1160
+ entry.restartTo = undefined;
1161
+ entry.pendingMove = undefined;
1162
+ if (this.closed) {
1163
+ // the record keeps it asleep, at the commit it was up at
1164
+ this.moveTo(entry, restart, 'the server stopped before the services were up at the commit');
1165
+ if (this.maySleep(entry) && this.options.store) {
1166
+ this.sleep(entry);
1167
+ }
1168
+ else {
1169
+ this.finish(entry, 'failed', 'the server stopped while the services were up, start them again');
1170
+ }
1171
+ return;
1172
+ }
1173
+ // up at its commit, the run is there (markUp); else it goes back (fallBack)
1174
+ entry.landing = {
1175
+ change: restart,
1176
+ from: this.landingFrom(entry),
1177
+ recorded: false,
1178
+ };
1179
+ entry.run.moving = { commit: restart.source.commit, ref: restart.source.ref, since: this.timestamp() };
1180
+ this.requeue(entry);
1181
+ }
1182
+ /**
1183
+ * The execution that started the services at the commit of a move ended before they were
1184
+ * up: the run goes back to the commit it was up at, with its volumes, and starts there
1185
+ * again. The move says why it did not get there. False when no move was landing, or the
1186
+ * run was stopped.
1187
+ */
1188
+ fallBack(entry, error) {
1189
+ const landing = entry.landing;
1190
+ if (!landing || entry.run.stoppedBy) {
1191
+ return false;
1192
+ }
1193
+ entry.landing = undefined;
1194
+ const from = landing.from.source;
1195
+ entry.run.source = { ...from };
1196
+ if (landing.from.plan) {
1197
+ entry.run.plan = landing.from.plan;
1198
+ }
1199
+ this.moveTo(entry, landing.change, error);
1200
+ this.record(entry, {
1201
+ type: 'log',
1202
+ task: 'hammerkit',
1203
+ stream: 'stderr',
1204
+ text: `did not get up at ${landing.change.source.commit}, starting again at ${from.commit}: ${error}`,
1205
+ });
1206
+ if (this.closed) {
1207
+ if (this.maySleep(entry) && this.options.store) {
1208
+ this.sleep(entry);
1209
+ }
1210
+ else {
1211
+ this.finish(entry, 'failed', 'the server stopped while the services were up, start them again');
1212
+ }
1213
+ return true;
1214
+ }
1215
+ this.requeue(entry);
1216
+ return true;
1217
+ }
1218
+ // where a run that moves goes back to: the commit it is at (service runs start from a ref)
1219
+ landingFrom(entry) {
1220
+ return {
1221
+ source: { ...entry.run.source },
1222
+ ...(entry.run.plan ? { plan: entry.run.plan } : {}),
1223
+ };
1224
+ }
1225
+ // The run starts again, its volumes kept
1226
+ requeue(entry) {
1227
+ entry.sleeping = false;
1228
+ entry.targets = undefined;
1229
+ entry.abort = new AbortController();
1230
+ entry.released = false;
1231
+ entry.timedOut = false;
1232
+ entry.run.state = 'queued';
1233
+ this.record(entry, { type: 'state', state: 'queued' });
1234
+ this.queue.push(entry);
1235
+ this.dispatch();
1236
+ }
1237
+ // Stops a service run at its expiry, and puts one that is up to sleep at its sleepsAt. A
1238
+ // timer can't wait longer than about 24 days, so a longer one looks again then.
1239
+ expireLater(entry) {
1240
+ this.clearTimer(entry);
1241
+ const expiresAt = Date.parse(entry.run.expiresAt ?? '');
1242
+ const sleepsAt = entry.run.state === 'up' ? Date.parse(entry.run.sleepsAt ?? '') : NaN;
1243
+ const next = Number.isFinite(sleepsAt) ? Math.min(expiresAt, sleepsAt) : expiresAt;
1244
+ entry.timer = setTimeout(() => {
1245
+ var _a;
1246
+ if (isFinal(entry.run.state)) {
1247
+ return;
1248
+ }
1249
+ if (Date.parse(entry.run.expiresAt ?? '') <= this.now()) {
1250
+ (_a = entry.run).stoppedBy ?? (_a.stoppedBy = 'ttl');
1251
+ entry.sleeping = false;
1252
+ if (entry.run.state === 'asleep') {
1253
+ this.finish(entry, 'succeeded');
1254
+ }
1255
+ else {
1256
+ this.stop(entry);
1257
+ }
1258
+ return;
1259
+ }
1260
+ if (entry.run.state === 'up' && Date.parse(entry.run.sleepsAt ?? '') <= this.now()) {
1261
+ if (entry.run.stoppedBy || entry.abort.signal.aborted) {
1262
+ // it stops already: it ends, it does not sleep
1263
+ return;
1264
+ }
1265
+ if (entry.mover || entry.pendingMove || entry.restartTo) {
1266
+ // it moves: it sleeps once its services are at the new commit
1267
+ this.keepAwake(entry);
1268
+ return;
1269
+ }
1270
+ entry.sleeping = true;
1271
+ entry.abort.abort();
1272
+ return;
1273
+ }
1274
+ this.expireLater(entry);
1275
+ }, Math.max(0, Math.min(next - this.now(), 2 ** 31 - 1)));
1276
+ entry.timer.unref();
1277
+ }
1278
+ keepsVolumes() {
1279
+ return this.options.up?.keepVolumes !== false;
1280
+ }
1281
+ // What the executor of a service run keeps of its services once they stopped, as it asks:
1282
+ // the volumes of a run that sleeps, on a server that keeps them. What it was told stays in
1283
+ // the record, so the volumes are removed (RunExecutor.forget) when the run ends asleep, or
1284
+ // when a stop came after the sleep began.
1285
+ keepOf(entry) {
1286
+ // a run that starts again at another commit keeps them on any server
1287
+ if (entry.restartTo || entry.landing || (entry.sleeping && this.keepsVolumes())) {
1288
+ entry.run.volumesKept = true;
1289
+ return 'volumes';
1290
+ }
1291
+ delete entry.run.volumesKept;
1292
+ return undefined;
1293
+ }
1294
+ // Whether a service run sleeps instead of ending: it has a sleepTtl, its ttl has not
1295
+ // ended, and it starts from a commit (the files of a sandbox change under it).
1296
+ maySleep(entry) {
1297
+ return (!!entry.run.up?.sleepTtl &&
1298
+ !!entry.run.upAt &&
1299
+ entry.run.source.type === 'ref' &&
1300
+ Date.parse(entry.run.expiresAt ?? '') > this.now());
1301
+ }
1302
+ // The services stopped, the run waits for a request to wake it, or for its end.
1303
+ sleep(entry) {
1304
+ entry.sleeping = false;
1305
+ entry.targets = undefined;
1306
+ entry.run.state = 'asleep';
1307
+ delete entry.run.sleepsAt;
1308
+ this.record(entry, { type: 'state', state: 'asleep' });
1309
+ this.expireLater(entry);
1310
+ }
1311
+ /**
1312
+ * A request to an endpoint of a service run: one that is up stays up for its sleepTtl from
1313
+ * now, one that is asleep is started again, if `mayWake` finds its owner may still run it
1314
+ * (else it ends) and there is a place of runs.up.limits for it (else it sleeps on, and the
1315
+ * next request asks again). Answers why it was not woken, or undefined.
1316
+ */
1317
+ visit(id, mayWake) {
1318
+ const entry = this.entries.get(id);
1319
+ if (!entry?.run.up) {
1320
+ return undefined;
1321
+ }
1322
+ const sleepTtl = entry.run.up.sleepTtl;
1323
+ if (entry.run.state === 'up' && sleepTtl) {
1324
+ // only kept in memory: a server that stops puts the run to sleep anyway
1325
+ entry.run.sleepsAt = new Date(this.now() + sleepTtl).toISOString();
1326
+ return undefined;
1327
+ }
1328
+ if (entry.run.state !== 'asleep' || this.closed) {
1329
+ return undefined;
1330
+ }
1331
+ if (!this.maySleep(entry)) {
1332
+ this.expireLater(entry);
1333
+ return 'its time ran out';
1334
+ }
1335
+ const refused = mayWake?.(this.copy(entry.run));
1336
+ if (refused) {
1337
+ this.finish(entry, 'failed', refused);
1338
+ return refused;
1339
+ }
1340
+ const full = this.upLimitReached(entry.run.owner, entry.run.up.name);
1341
+ if (full === 'caller') {
1342
+ return 'its owner has as many service runs starting or up as they may, it wakes once one of them stops or sleeps';
1343
+ }
1344
+ if (full === 'server') {
1345
+ return 'the server has as many service runs starting or up as it may, it wakes once one of them stops or sleeps';
1346
+ }
1347
+ this.clearTimer(entry);
1348
+ entry.abort = new AbortController();
1349
+ entry.released = false;
1350
+ entry.timedOut = false;
1351
+ entry.run.wakes = (entry.run.wakes ?? 0) + 1;
1352
+ entry.run.state = 'queued';
1353
+ entry.run.queuedAt = this.timestamp();
1354
+ this.record(entry, { type: 'state', state: 'queued' });
1355
+ this.queue.push(entry);
1356
+ this.dispatch();
1357
+ return undefined;
1358
+ }
1359
+ // The end of a run that was up: stopped by somebody, its ttl or a newer run it succeeded,
1360
+ // stopped by the server going away it failed, as its record says after a restart.
1361
+ endServices(entry, failure) {
1362
+ if (entry.timedOut) {
1363
+ this.finish(entry, 'failed', this.tooLong());
1364
+ }
1365
+ else if (!entry.abort.signal.aborted) {
1366
+ this.finish(entry, 'failed', failure);
1367
+ }
1368
+ else if (entry.run.stoppedBy) {
1369
+ this.finish(entry, 'succeeded');
1370
+ }
1371
+ else {
1372
+ this.finish(entry, 'failed', 'the server stopped while the services were up, start them again');
477
1373
  }
478
1374
  }
479
1375
  tooLong() {
@@ -481,6 +1377,22 @@ class RunRegistry {
481
1377
  }
482
1378
  finish(entry, state, error) {
483
1379
  this.clearTimer(entry);
1380
+ clearTimeout(entry.durationTimer);
1381
+ entry.targets = undefined;
1382
+ entry.pendingMove = undefined;
1383
+ entry.restartTo = undefined;
1384
+ entry.landing = undefined;
1385
+ entry.mover?.abort.abort();
1386
+ entry.mover = undefined;
1387
+ delete entry.run.moving;
1388
+ if (entry.run.volumesKept) {
1389
+ // it ends without waking: what was kept for it goes
1390
+ delete entry.run.volumesKept;
1391
+ const run = this.copy(entry.run);
1392
+ Promise.resolve()
1393
+ .then(() => this.options.executor.forget?.(run))
1394
+ .catch((e) => this.options.log?.(`the volumes run ${run.id} kept could not be removed: ${e instanceof Error ? e.message : e}`));
1395
+ }
484
1396
  entry.run.state = state;
485
1397
  entry.run.finishedAt = this.timestamp();
486
1398
  if (error) {
@@ -495,9 +1407,23 @@ class RunRegistry {
495
1407
  }
496
1408
  this.record(entry, { type: 'state', state, ...(error ? { error } : {}) }, true);
497
1409
  entry.listeners.clear();
1410
+ try {
1411
+ this.options.onFinished?.(this.copy(entry.run), { ...entry.keys });
1412
+ }
1413
+ catch (e) {
1414
+ this.options.log?.(`run ${entry.run.id} ended, but what it passed was not kept: ${e instanceof Error ? e.message : e}`);
1415
+ }
498
1416
  this.evict();
499
1417
  }
500
1418
  record(entry, recorded, force = false) {
1419
+ if (recorded.type === 'key') {
1420
+ // a task name is the executor's; the first key it reports for a task is the one
1421
+ // computed before anything of the run had run
1422
+ if (!(recorded.task in entry.keys) && Object.keys(entry.keys).length < MAX_KEYED_TASKS) {
1423
+ entry.keys[recorded.task] = recorded.key;
1424
+ }
1425
+ return;
1426
+ }
501
1427
  const event = recorded.type === 'task' ? bounded(recorded) : recorded;
502
1428
  if (event.type === 'log') {
503
1429
  this.keepTail(entry, event.task, event.text);
@@ -579,7 +1505,7 @@ class RunRegistry {
579
1505
  }
580
1506
  // what a restart must not lose: the runs that wait, and how a run ended. A running
581
1507
  // run is not kept up to date, it fails with the server.
582
- if (event.type === 'state' || event.type === 'approval') {
1508
+ if (event.type === 'state' || event.type === 'approval' || event.type === 'extended' || event.type === 'moved') {
583
1509
  this.persist(entry);
584
1510
  }
585
1511
  }