hammerkit 1.27.0-next.13 → 1.27.0-next.15

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.
@@ -112,9 +112,22 @@ class RunRegistry {
112
112
  this.finish(entry, 'rejected', 'nobody approved it in time');
113
113
  }
114
114
  }
115
+ else if (this.maySleep(entry) && ['queued', 'running', 'up', 'asleep'].includes(run.state)) {
116
+ // the services went with the process that held them, the next request wakes them
117
+ if (run.state === 'queued' || run.state === 'running' || run.state === 'up') {
118
+ this.sleep(entry);
119
+ }
120
+ else {
121
+ this.expireLater(entry);
122
+ }
123
+ }
115
124
  else if (run.state === 'queued' || run.state === 'running') {
116
125
  this.finish(entry, 'failed', `the server stopped while the run was ${run.state}, submit it again`);
117
126
  }
127
+ else if (run.state === 'up') {
128
+ // the services went with the process that held them
129
+ this.finish(entry, 'failed', 'the server stopped while the services were up, start them again');
130
+ }
118
131
  }
119
132
  this.evict();
120
133
  }
@@ -129,10 +142,18 @@ class RunRegistry {
129
142
  throw new RunError('too many runs are waiting for an approval');
130
143
  }
131
144
  this.checkLimits(owner, waiting);
145
+ if (spec.up) {
146
+ this.checkUpLimits(owner);
147
+ this.checkHosts(owner, spec.up);
148
+ }
132
149
  const run = {
133
150
  ...spec,
134
151
  id: `run_${(0, crypto_1.randomBytes)(12).toString('base64url')}`,
135
- owner: { iss: owner.iss, sub: owner.sub },
152
+ owner: {
153
+ iss: owner.iss,
154
+ sub: owner.sub,
155
+ ...(spec.up?.sleepTtl ? { groups: [...(owner.groups ?? [])], claims: { ...(owner.claims ?? {}) } } : {}),
156
+ },
136
157
  state: waiting ? 'awaiting-approval' : 'queued',
137
158
  createdAt: this.timestamp(),
138
159
  };
@@ -179,30 +200,79 @@ class RunRegistry {
179
200
  .map((entry) => this.copy(entry.run))
180
201
  .reverse();
181
202
  }
182
- // A queued run is cancelled at once; a running one when its executor stops.
203
+ // A queued run is cancelled at once; a running one when its executor stops. A service run
204
+ // that is up stops its services, and says who stopped it.
183
205
  cancel(owner, id, canUse) {
184
206
  const entry = this.find(owner, id, canUse);
185
207
  if (!entry) {
186
208
  return undefined;
187
209
  }
188
- this.stop(entry);
210
+ this.stop(entry, undefined, { iss: owner.iss, sub: owner.sub });
189
211
  return this.copy(entry.run);
190
212
  }
213
+ /**
214
+ * Moves the end of a service run that is up (or asleep) to `ttl` (ms) from now, at most
215
+ * the server's maxTtl, and never sooner than it was. Its owner may, and whoever `mayOpen`
216
+ * says may open its endpoints.
217
+ */
218
+ extend(caller, id, ttl, mayOpen) {
219
+ const entry = this.entries.get(id);
220
+ if (!entry || !(this.owns(caller, entry) || (entry.run.up && mayOpen?.(this.copy(entry.run))))) {
221
+ return { status: 'not-found' };
222
+ }
223
+ // up, asleep, or woken and starting again
224
+ if (!entry.run.upAt || isFinal(entry.run.state)) {
225
+ return { status: 'not-up', state: entry.run.state };
226
+ }
227
+ // never sooner than it was: who wants it gone stops it
228
+ const ms = Math.min(ttl, this.options.up?.maxTtl ?? ttl);
229
+ const expiresAt = Math.max(Date.parse(entry.run.expiresAt ?? '') || 0, this.now() + ms);
230
+ entry.run.expiresAt = new Date(expiresAt).toISOString();
231
+ this.record(entry, { type: 'extended', by: { iss: caller.iss, sub: caller.sub }, expiresAt: entry.run.expiresAt });
232
+ this.expireLater(entry);
233
+ return { status: 'ok', run: this.copy(entry.run) };
234
+ }
235
+ /**
236
+ * The service run an endpoint host belongs to: the newest that is up, so one that replaces
237
+ * a run of the same name takes its hosts over as soon as it is up; else the newest that
238
+ * had it, for a page that says how it stands. With the address of the endpoint when up.
239
+ */
240
+ endpoint(host) {
241
+ let found;
242
+ for (const entry of this.entries.values()) {
243
+ const endpoint = entry.run.up?.endpoints.find((candidate) => candidate.host === host);
244
+ if (endpoint && (entry.run.state === 'up' || found?.entry.run.state !== 'up')) {
245
+ found = { entry, endpoint };
246
+ }
247
+ }
248
+ if (!found) {
249
+ return undefined;
250
+ }
251
+ const { entry, endpoint } = found;
252
+ const target = entry.run.state === 'up'
253
+ ? entry.targets?.find((candidate) => candidate.service === endpoint.service && candidate.port === endpoint.port)
254
+ : undefined;
255
+ return { run: this.copy(entry.run), endpoint: { ...endpoint }, ...(target ? { target } : {}) };
256
+ }
191
257
  // 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.
258
+ // when it is submitted, so the files are not moved under what an approver saw. A service
259
+ // run that is up works on a copy of the files, and holds it no longer.
193
260
  holder(sandbox) {
194
- return [...this.entries.values()].find((entry) => onSandbox(entry.run, sandbox) && !isFinal(entry.run.state))?.run
195
- .id;
261
+ return [...this.entries.values()].find((entry) => onSandbox(entry.run, sandbox) &&
262
+ !isFinal(entry.run.state) &&
263
+ entry.run.state !== 'up' &&
264
+ entry.run.state !== 'asleep')?.run.id;
196
265
  }
197
266
  // Cancels every run on a sandbox and resolves when none of them runs anymore.
198
267
  async stopSandbox(sandbox, reason) {
199
268
  const on = () => [...this.entries.values()].filter((entry) => onSandbox(entry.run, sandbox));
200
269
  on().forEach((entry) => this.stop(entry, reason));
201
- while (on().some((entry) => entry.run.state === 'running')) {
270
+ while (on().some((entry) => entry.run.state === 'running' || entry.run.state === 'up')) {
202
271
  await new Promise((resolve) => setTimeout(resolve, 20));
203
272
  }
204
273
  }
205
- stop(entry, reason) {
274
+ stop(entry, reason, by) {
275
+ var _a, _b;
206
276
  if (entry.run.state === 'queued') {
207
277
  this.queue.splice(this.queue.indexOf(entry), 1);
208
278
  this.finish(entry, 'cancelled', reason);
@@ -213,6 +283,15 @@ class RunRegistry {
213
283
  else if (entry.run.state === 'running') {
214
284
  entry.abort.abort();
215
285
  }
286
+ else if (entry.run.state === 'up') {
287
+ (_a = entry.run).stoppedBy ?? (_a.stoppedBy = by ?? 'replaced');
288
+ entry.sleeping = false;
289
+ entry.abort.abort();
290
+ }
291
+ else if (entry.run.state === 'asleep') {
292
+ (_b = entry.run).stoppedBy ?? (_b.stoppedBy = by ?? 'replaced');
293
+ this.finish(entry, 'succeeded');
294
+ }
216
295
  }
217
296
  // The runs that wait for an approval, of anybody, oldest first. Who may see
218
297
  // them is for the caller to decide.
@@ -289,18 +368,20 @@ class RunRegistry {
289
368
  return { events, finished, stop: () => entry.listeners.delete(listener) };
290
369
  }
291
370
  /**
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).
371
+ * The run once it is over (a service run: once it is up), or as it is after `ms` or when
372
+ * `signal` aborts, for clients that can not hold a stream of events open (a tool call of
373
+ * an agent).
294
374
  */
295
375
  async wait(owner, id, ms, canUse, signal) {
296
376
  const entry = this.find(owner, id, canUse);
297
377
  if (!entry) {
298
378
  return undefined;
299
379
  }
300
- if (!isFinal(entry.run.state) && ms > 0 && !this.closed && !signal?.aborted) {
380
+ if (!isFinal(entry.run.state) && entry.run.state !== 'up' && ms > 0 && !this.closed && !signal?.aborted) {
301
381
  await new Promise((resolve) => {
302
382
  const listener = (event) => {
303
- if (event.type === 'state' && isFinal(event.state)) {
383
+ // a service run is there once it is up
384
+ if (event.type === 'state' && (isFinal(event.state) || event.state === 'up')) {
304
385
  done();
305
386
  }
306
387
  };
@@ -335,10 +416,19 @@ class RunRegistry {
335
416
  async close() {
336
417
  this.closed = true;
337
418
  for (const entry of this.queue.splice(0)) {
338
- this.finish(entry, 'cancelled');
419
+ if (this.maySleep(entry) && this.options.store) {
420
+ // a woken service run sleeps again, the next start keeps it
421
+ this.sleep(entry);
422
+ }
423
+ else {
424
+ this.finish(entry, 'cancelled');
425
+ }
339
426
  }
340
427
  for (const entry of this.entries.values()) {
341
- if (entry.run.state === 'awaiting-approval') {
428
+ if (entry.run.state === 'asleep') {
429
+ this.clearTimer(entry);
430
+ }
431
+ else if (entry.run.state === 'awaiting-approval') {
342
432
  if (this.options.store) {
343
433
  // the record keeps it waiting, the next start takes it back
344
434
  this.clearTimer(entry);
@@ -353,11 +443,15 @@ class RunRegistry {
353
443
  done();
354
444
  }
355
445
  for (const entry of this.entries.values()) {
356
- if (entry.run.state === 'running') {
446
+ if (entry.run.state === 'running' || entry.run.state === 'up') {
447
+ // a service run that may sleep is asleep in its record, the next request after the
448
+ // start wakes it
449
+ entry.sleeping = this.maySleep(entry) && !!this.options.store;
357
450
  entry.abort.abort();
358
451
  }
359
452
  }
360
- while (this.running > 0) {
453
+ while (this.running > 0 ||
454
+ [...this.entries.values()].some((entry) => entry.run.state === 'running' || entry.run.state === 'up')) {
361
455
  await new Promise((resolve) => setTimeout(resolve, 10));
362
456
  }
363
457
  // the runs that were stopped are written down before the server is gone
@@ -388,6 +482,8 @@ class RunRegistry {
388
482
  copy(run) {
389
483
  return {
390
484
  ...run,
485
+ ...(run.up ? { up: JSON.parse(JSON.stringify(run.up)) } : {}),
486
+ ...(run.stoppedBy && typeof run.stoppedBy === 'object' ? { stoppedBy: { ...run.stoppedBy } } : {}),
391
487
  tasks: [...run.tasks],
392
488
  source: { ...run.source },
393
489
  options: JSON.parse(JSON.stringify(run.options)),
@@ -415,6 +511,37 @@ class RunRegistry {
415
511
  throw new QuotaError(`you have ${limits.queued} runs in the queue, wait for one to start or cancel one`);
416
512
  }
417
513
  }
514
+ // Service runs that are not over, of the caller and of everybody, against what may be:
515
+ // one that is asleep counts, it wakes without asking for a place.
516
+ checkUpLimits(owner) {
517
+ const limits = this.options.up;
518
+ if (!limits) {
519
+ return;
520
+ }
521
+ const open = [...this.entries.values()].filter((entry) => entry.run.up && !isFinal(entry.run.state));
522
+ if (open.filter((entry) => this.owns(owner, entry)).length >= limits.perCaller) {
523
+ throw new QuotaError(`you have ${limits.perCaller} service runs starting, up or asleep, stop one or wait for its end`);
524
+ }
525
+ if (open.length >= limits.total) {
526
+ throw new RunError(`the server has ${limits.total} service runs starting, up or asleep, try again later`);
527
+ }
528
+ }
529
+ // A host is one run's, or a run's that replaces it (same owner and name) once it is up:
530
+ // two labels that come out the same are refused, they would reach each other's services.
531
+ checkHosts(owner, up) {
532
+ const hosts = new Set(up.endpoints.map((endpoint) => endpoint.host));
533
+ for (const entry of this.entries.values()) {
534
+ if (isFinal(entry.run.state) || !entry.run.up) {
535
+ continue;
536
+ }
537
+ const taken = entry.run.up.endpoints.find((endpoint) => hosts.has(endpoint.host));
538
+ if (taken && !(this.owns(owner, entry) && entry.run.up.name === up.name)) {
539
+ const error = new RunError(`the address ${taken.host} is another service run's, pick another name`);
540
+ error.status = 409;
541
+ throw error;
542
+ }
543
+ }
544
+ }
418
545
  runningOf(owner) {
419
546
  return [...this.entries.values()].filter((entry) => entry.run.state === 'running' && this.owns(owner, entry)).length;
420
547
  }
@@ -444,7 +571,10 @@ class RunRegistry {
444
571
  const [entry] = this.queue.splice(index, 1);
445
572
  this.running++;
446
573
  this.execute(entry).finally(() => {
447
- this.running--;
574
+ // a service run that is up gave its place back already
575
+ if (!entry.released) {
576
+ this.running--;
577
+ }
448
578
  this.dispatch();
449
579
  });
450
580
  }
@@ -453,27 +583,188 @@ class RunRegistry {
453
583
  entry.run.state = 'running';
454
584
  entry.run.startedAt = this.timestamp();
455
585
  this.record(entry, { type: 'state', state: 'running' });
586
+ // a service run gets this long to be up, then its ttl counts
456
587
  const limit = this.options.maxDuration;
457
- const timer = limit
588
+ entry.durationTimer = limit
458
589
  ? setTimeout(() => {
459
590
  entry.timedOut = true;
460
591
  entry.abort.abort();
461
592
  }, limit)
462
593
  : undefined;
463
- timer?.unref();
594
+ entry.durationTimer?.unref();
464
595
  try {
465
596
  await this.options.executor.execute(this.copy(entry.run), {
466
597
  emit: (event) => this.record(entry, event),
467
598
  signal: entry.abort.signal,
599
+ ...(entry.run.up ? { up: (targets) => this.markUp(entry, targets) } : {}),
468
600
  });
469
- this.finish(entry, entry.timedOut ? 'failed' : entry.abort.signal.aborted ? 'cancelled' : 'succeeded', entry.timedOut ? this.tooLong() : undefined);
601
+ if (entry.sleeping) {
602
+ this.sleep(entry);
603
+ }
604
+ else if (entry.run.upAt) {
605
+ // services stay until they are stopped: ones that stopped on their own failed
606
+ this.endServices(entry, 'the services stopped on their own');
607
+ }
608
+ else {
609
+ this.finish(entry, entry.timedOut ? 'failed' : entry.abort.signal.aborted ? 'cancelled' : 'succeeded', entry.timedOut ? this.tooLong() : undefined);
610
+ }
470
611
  }
471
612
  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));
613
+ const message = e instanceof Error ? e.message : String(e);
614
+ if (entry.sleeping) {
615
+ this.sleep(entry);
616
+ }
617
+ else if (entry.run.upAt) {
618
+ this.endServices(entry, message);
619
+ }
620
+ else {
621
+ const cancelled = entry.abort.signal.aborted && !entry.timedOut;
622
+ this.finish(entry, cancelled ? 'cancelled' : 'failed', entry.timedOut ? this.tooLong() : cancelled ? undefined : message);
623
+ }
474
624
  }
475
625
  finally {
476
- clearTimeout(timer);
626
+ clearTimeout(entry.durationTimer);
627
+ }
628
+ }
629
+ // The services of a service run are ready: its ttl counts from now, its place of
630
+ // maxConcurrent goes to the next run, and an older run of its owner with the same name
631
+ // stops, so its hosts are this run's.
632
+ markUp(entry, targets) {
633
+ const spec = entry.run.up;
634
+ if (!spec || entry.run.state !== 'running' || entry.abort.signal.aborted) {
635
+ return;
636
+ }
637
+ clearTimeout(entry.durationTimer);
638
+ entry.durationTimer = undefined;
639
+ entry.targets = targets.map((target) => ({ ...target, address: { ...target.address } }));
640
+ entry.run.state = 'up';
641
+ // the ttl counts from the first time, not from a wake
642
+ if (!entry.run.upAt) {
643
+ entry.run.upAt = this.timestamp();
644
+ entry.run.expiresAt = new Date(this.now() + spec.ttl).toISOString();
645
+ }
646
+ if (spec.sleepTtl) {
647
+ entry.run.sleepsAt = new Date(this.now() + spec.sleepTtl).toISOString();
648
+ }
649
+ this.record(entry, { type: 'state', state: 'up' });
650
+ this.expireLater(entry);
651
+ entry.released = true;
652
+ this.running--;
653
+ const entries = [...this.entries.values()];
654
+ for (const other of entries.slice(0, entries.indexOf(entry))) {
655
+ if (other.run.up?.name === spec.name &&
656
+ this.owns(entry.run.owner, other) &&
657
+ !isFinal(other.run.state) &&
658
+ other.run.state !== 'awaiting-approval') {
659
+ other.run.stoppedBy = 'replaced';
660
+ this.stop(other, `replaced by ${entry.run.id}`);
661
+ }
662
+ }
663
+ this.dispatch();
664
+ }
665
+ // Stops a service run at its expiry, and puts one that is up to sleep at its sleepsAt. A
666
+ // timer can't wait longer than about 24 days, so a longer one looks again then.
667
+ expireLater(entry) {
668
+ this.clearTimer(entry);
669
+ const expiresAt = Date.parse(entry.run.expiresAt ?? '');
670
+ const sleepsAt = entry.run.state === 'up' ? Date.parse(entry.run.sleepsAt ?? '') : NaN;
671
+ const next = Number.isFinite(sleepsAt) ? Math.min(expiresAt, sleepsAt) : expiresAt;
672
+ entry.timer = setTimeout(() => {
673
+ var _a;
674
+ if (isFinal(entry.run.state)) {
675
+ return;
676
+ }
677
+ if (Date.parse(entry.run.expiresAt ?? '') <= this.now()) {
678
+ (_a = entry.run).stoppedBy ?? (_a.stoppedBy = 'ttl');
679
+ entry.sleeping = false;
680
+ if (entry.run.state === 'asleep') {
681
+ this.finish(entry, 'succeeded');
682
+ }
683
+ else {
684
+ this.stop(entry);
685
+ }
686
+ return;
687
+ }
688
+ if (entry.run.state === 'up' && Date.parse(entry.run.sleepsAt ?? '') <= this.now()) {
689
+ entry.sleeping = true;
690
+ entry.abort.abort();
691
+ return;
692
+ }
693
+ this.expireLater(entry);
694
+ }, Math.max(0, Math.min(next - this.now(), 2 ** 31 - 1)));
695
+ entry.timer.unref();
696
+ }
697
+ // Whether a service run sleeps instead of ending: it has a sleepTtl, its ttl has not
698
+ // ended, and it starts from a commit (the files of a sandbox change under it).
699
+ maySleep(entry) {
700
+ return (!!entry.run.up?.sleepTtl &&
701
+ !!entry.run.upAt &&
702
+ entry.run.source.type === 'ref' &&
703
+ Date.parse(entry.run.expiresAt ?? '') > this.now());
704
+ }
705
+ // The services stopped, the run waits for a request to wake it, or for its end.
706
+ sleep(entry) {
707
+ entry.sleeping = false;
708
+ entry.targets = undefined;
709
+ entry.run.state = 'asleep';
710
+ delete entry.run.sleepsAt;
711
+ this.record(entry, { type: 'state', state: 'asleep' });
712
+ this.expireLater(entry);
713
+ }
714
+ /**
715
+ * A request to an endpoint of a service run: one that is up stays up for its sleepTtl from
716
+ * now, one that is asleep is started again (it holds its place of runs.up.limits while it
717
+ * sleeps), if `mayWake` finds its owner may still run it: else it ends. Answers why it was
718
+ * not woken, or undefined.
719
+ */
720
+ visit(id, mayWake) {
721
+ const entry = this.entries.get(id);
722
+ if (!entry?.run.up) {
723
+ return undefined;
724
+ }
725
+ const sleepTtl = entry.run.up.sleepTtl;
726
+ if (entry.run.state === 'up' && sleepTtl) {
727
+ // only kept in memory: a server that stops puts the run to sleep anyway
728
+ entry.run.sleepsAt = new Date(this.now() + sleepTtl).toISOString();
729
+ return undefined;
730
+ }
731
+ if (entry.run.state !== 'asleep' || this.closed) {
732
+ return undefined;
733
+ }
734
+ if (!this.maySleep(entry)) {
735
+ this.expireLater(entry);
736
+ return 'its time ran out';
737
+ }
738
+ const refused = mayWake?.(this.copy(entry.run));
739
+ if (refused) {
740
+ this.finish(entry, 'failed', refused);
741
+ return refused;
742
+ }
743
+ this.clearTimer(entry);
744
+ entry.abort = new AbortController();
745
+ entry.released = false;
746
+ entry.timedOut = false;
747
+ entry.run.wakes = (entry.run.wakes ?? 0) + 1;
748
+ entry.run.state = 'queued';
749
+ this.record(entry, { type: 'state', state: 'queued' });
750
+ this.queue.push(entry);
751
+ this.dispatch();
752
+ return undefined;
753
+ }
754
+ // The end of a run that was up: stopped by somebody, its ttl or a newer run it succeeded,
755
+ // stopped by the server going away it failed, as its record says after a restart.
756
+ endServices(entry, failure) {
757
+ if (entry.timedOut) {
758
+ this.finish(entry, 'failed', this.tooLong());
759
+ }
760
+ else if (!entry.abort.signal.aborted) {
761
+ this.finish(entry, 'failed', failure);
762
+ }
763
+ else if (entry.run.stoppedBy) {
764
+ this.finish(entry, 'succeeded');
765
+ }
766
+ else {
767
+ this.finish(entry, 'failed', 'the server stopped while the services were up, start them again');
477
768
  }
478
769
  }
479
770
  tooLong() {
@@ -481,6 +772,8 @@ class RunRegistry {
481
772
  }
482
773
  finish(entry, state, error) {
483
774
  this.clearTimer(entry);
775
+ clearTimeout(entry.durationTimer);
776
+ entry.targets = undefined;
484
777
  entry.run.state = state;
485
778
  entry.run.finishedAt = this.timestamp();
486
779
  if (error) {
@@ -579,7 +872,7 @@ class RunRegistry {
579
872
  }
580
873
  // what a restart must not lose: the runs that wait, and how a run ended. A running
581
874
  // run is not kept up to date, it fails with the server.
582
- if (event.type === 'state' || event.type === 'approval') {
875
+ if (event.type === 'state' || event.type === 'approval' || event.type === 'extended') {
583
876
  this.persist(entry);
584
877
  }
585
878
  }