hammerkit 1.24.0 → 1.25.0

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.
@@ -0,0 +1,179 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.workerMain = exports.runTasks = exports.workerInputSchema = void 0;
4
+ const zod_1 = require("zod");
5
+ const cli_1 = require("../cli");
6
+ const get_file_context_1 = require("../file/get-file-context");
7
+ const log_1 = require("../log");
8
+ const schema_parser_1 = require("../schema/schema-parser");
9
+ const reference_parser_1 = require("../schema/reference-parser");
10
+ const work_scope_parser_1 = require("../schema/work-scope-parser");
11
+ const work_item_status_1 = require("../planner/work-item-status");
12
+ const plan_work_tasks_1 = require("../planner/utils/plan-work-tasks");
13
+ const redact_1 = require("../utils/redact");
14
+ const empty_writable_1 = require("../utils/empty-writable");
15
+ const units_1 = require("../utils/units");
16
+ const run_request_1 = require("./run-request");
17
+ const run_plan_1 = require("./run-plan");
18
+ // one log line of a task is cut here, the registry caps how many events a run keeps
19
+ const MAX_LOG_TEXT = 8 * 1024;
20
+ /**
21
+ * What the process that executes a run is told, on its standard input. A run executes
22
+ * in a process of its own, started with an environment the server built, so what the
23
+ * engine reads of its process (the home directory, the docker host, the cache directory)
24
+ * is the run's and not the server's.
25
+ */
26
+ exports.workerInputSchema = zod_1.z
27
+ .object({
28
+ // the build file to start from, in the checkout
29
+ entry: zod_1.z.string().min(1),
30
+ tasks: zod_1.z.array(zod_1.z.string()),
31
+ options: run_request_1.runOptionsSchema,
32
+ // the accounts the run was admitted for; a secret of any other is refused
33
+ accounts: zod_1.z.array(zod_1.z.string()),
34
+ // the operator's providers and accounts, as the server read them from its config
35
+ catalog: zod_1.z.custom((value) => typeof value === 'object' && value !== null),
36
+ })
37
+ .strict();
38
+ /**
39
+ * Executes the tasks of a run with the engine of `hammerkit run`. The caller says which
40
+ * process this is in: it reports what happens through `emit` and stops when `abortCtrl`
41
+ * is aborted.
42
+ */
43
+ async function runTasks(input, cwd, processEnvs, abortCtrl,
44
+ // whether the run was stopped from outside; the engine aborts too when a task fails
45
+ cancelled, emit) {
46
+ const secrets = (0, redact_1.createSecretRegistry)();
47
+ const status = (0, work_item_status_1.statusConsole)((0, empty_writable_1.emptyWritable)(), secrets);
48
+ // what the engine would print to a terminal is not wanted, its status console
49
+ // is what is streamed: it is redacted with the secrets of this run
50
+ const environment = {
51
+ processEnvs,
52
+ hosted: { catalog: input.catalog, accounts: input.accounts },
53
+ abortCtrl,
54
+ cwd,
55
+ file: (0, get_file_context_1.getFileContext)(cwd),
56
+ console: (0, log_1.consoleContext)((0, empty_writable_1.emptyWritable)()),
57
+ status,
58
+ secrets,
59
+ stdout: (0, empty_writable_1.emptyWritable)(),
60
+ stderr: (0, empty_writable_1.emptyWritable)(),
61
+ stdoutColumns: 120,
62
+ };
63
+ const listener = status.on((message) => {
64
+ if (message.type === 'console') {
65
+ emit({
66
+ type: 'log',
67
+ task: message.context.name,
68
+ stream: message.console,
69
+ text: message.message.slice(0, MAX_LOG_TEXT),
70
+ });
71
+ }
72
+ else if (message.level === 'error' || message.level === 'warn') {
73
+ emit({
74
+ type: 'log',
75
+ task: message.context.name,
76
+ stream: 'stderr',
77
+ text: message.message.slice(0, MAX_LOG_TEXT),
78
+ });
79
+ }
80
+ });
81
+ try {
82
+ const options = input.options;
83
+ const execOptions = {
84
+ logMode: 'live',
85
+ cacheDefault: options.cache ?? 'checksum',
86
+ cacheReadOnly: options.cacheReadOnly ?? false,
87
+ timeout: options.timeout ? (0, units_1.parseDuration)(options.timeout) : null,
88
+ skipDeps: options.skipDeps !== false,
89
+ };
90
+ for (const { name, scope } of (0, run_plan_1.getRunScopes)(input.tasks, options)) {
91
+ const { ctx, scope: parsed } = await (0, schema_parser_1.createParseContext)(input.entry, environment);
92
+ const workTree = (0, work_scope_parser_1.getWorkContext)(await (0, reference_parser_1.parseReferences)(ctx, parsed, environment), scope, environment);
93
+ const cli = (0, cli_1.getCli)(workTree, environment);
94
+ if (cli.tasks().length === 0) {
95
+ throw new Error(`no tasks found for ${name}`);
96
+ }
97
+ const execution = cli.exec({ ...execOptions, workers: cli.defaultWorkers() });
98
+ const seen = new Map();
99
+ const watch = execution.state.on('run-events', () => reportTasks(emit, execution.state.current, seen));
100
+ try {
101
+ const result = await execution.start();
102
+ reportTasks(emit, execution.state.current, seen);
103
+ if (!result.success) {
104
+ throw new Error(cancelled() ? 'the run was cancelled' : `${name} failed`);
105
+ }
106
+ }
107
+ finally {
108
+ watch.close();
109
+ }
110
+ }
111
+ }
112
+ finally {
113
+ listener.close();
114
+ }
115
+ }
116
+ exports.runTasks = runTasks;
117
+ // A task event when a task has moved on since the last look.
118
+ function reportTasks(emit, workTree, seen) {
119
+ for (const task of (0, plan_work_tasks_1.iterateWorkTasks)(workTree)) {
120
+ const state = task.state.current;
121
+ let event = null;
122
+ if (state.type === 'running') {
123
+ event = 'running';
124
+ }
125
+ else if (state.type === 'completed') {
126
+ event = state.skipped ? null : state.cached ? 'cached' : 'succeeded';
127
+ }
128
+ else if (state.type === 'error' || state.type === 'crash') {
129
+ event = 'failed';
130
+ }
131
+ if (event && seen.get(task.name) !== event) {
132
+ seen.set(task.name, event);
133
+ emit({ type: 'task', task: task.name, state: event });
134
+ }
135
+ }
136
+ }
137
+ async function readAll(stream) {
138
+ const chunks = [];
139
+ for await (const chunk of stream) {
140
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
141
+ }
142
+ return Buffer.concat(chunks).toString('utf8');
143
+ }
144
+ /**
145
+ * The process of one run: reads its input, executes the tasks in the current directory
146
+ * and reports as lines of JSON. It exits 0 when it could say how the run ended, and the
147
+ * server reads that from the last line; a process that dies before has crashed.
148
+ */
149
+ async function workerMain(stdin, stdout, processEnvs, cwd, abortCtrl) {
150
+ const say = (message) => stdout.write(`${JSON.stringify(message)}\n`);
151
+ // the server stops a run with a signal
152
+ let cancelled = false;
153
+ const stop = () => {
154
+ cancelled = true;
155
+ abortCtrl.abort();
156
+ };
157
+ const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
158
+ signals.forEach((signal) => process.on(signal, stop));
159
+ let input;
160
+ try {
161
+ input = exports.workerInputSchema.parse(JSON.parse(await readAll(stdin)));
162
+ }
163
+ catch (e) {
164
+ say({ kind: 'result', ok: false, error: `the run could not be read: ${e instanceof Error ? e.message : e}` });
165
+ return;
166
+ }
167
+ try {
168
+ await runTasks(input, cwd, processEnvs, abortCtrl, () => cancelled, (event) => say({ kind: 'event', event }));
169
+ say({ kind: 'result', ok: true });
170
+ }
171
+ catch (e) {
172
+ say({ kind: 'result', ok: false, error: e instanceof Error ? e.message : String(e) });
173
+ }
174
+ finally {
175
+ signals.forEach((signal) => process.off(signal, stop));
176
+ }
177
+ }
178
+ exports.workerMain = workerMain;
179
+ //# sourceMappingURL=run-worker.js.map
@@ -1,15 +1,29 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.unavailableExecutor = exports.RunRegistry = exports.RUN_ID_PATTERN = exports.RunError = exports.isFinal = void 0;
3
+ exports.unavailableExecutor = exports.RunRegistry = exports.RUN_ID_PATTERN = exports.QuotaError = exports.RunError = exports.isFinal = void 0;
4
4
  const crypto_1 = require("crypto");
5
+ const units_1 = require("../utils/units");
5
6
  const FINAL_STATES = ['succeeded', 'failed', 'cancelled', 'rejected'];
6
7
  function isFinal(state) {
7
8
  return FINAL_STATES.includes(state);
8
9
  }
9
10
  exports.isFinal = isFinal;
11
+ // The server can not take the run now; `status` is what the caller is told
10
12
  class RunError extends Error {
13
+ constructor() {
14
+ super(...arguments);
15
+ this.status = 503;
16
+ }
11
17
  }
12
18
  exports.RunError = RunError;
19
+ // A caller is over one of their limits, others may still submit
20
+ class QuotaError extends RunError {
21
+ constructor() {
22
+ super(...arguments);
23
+ this.status = 429;
24
+ }
25
+ }
26
+ exports.QuotaError = QuotaError;
13
27
  exports.RUN_ID_PATTERN = /^run_[A-Za-z0-9_-]{16}$/;
14
28
  // Where runs are kept while the server is up. Everything is scoped to the
15
29
  // caller that submitted the run: the same id asked for by someone else is
@@ -27,16 +41,58 @@ class RunRegistry {
27
41
  this.maxWaiting = options.maxWaiting ?? 100;
28
42
  this.approvalTimeout = options.approvalTimeout ?? 60 * 60 * 1000;
29
43
  this.now = options.now ?? Date.now;
44
+ this.maxLogBytes = options.maxLogBytes ?? Infinity;
30
45
  }
31
- // A run that needs an approval waits for it, the others are queued at once.
32
- submit(owner, spec, needsApproval = false) {
46
+ /**
47
+ * Takes back the runs of a previous start. Those that waited for an approval wait
48
+ * again, for what is left of their time; those that were queued or running when the
49
+ * server stopped failed with it, and say so.
50
+ */
51
+ async restore() {
52
+ const store = this.options.store;
53
+ if (!store) {
54
+ return;
55
+ }
56
+ const { records, skipped } = await store.load();
57
+ skipped.forEach((name) => this.options.log?.(`skip run record ${name}: it can not be read`));
58
+ for (const { run, events } of records) {
59
+ const entry = {
60
+ run,
61
+ events,
62
+ listeners: new Set(),
63
+ abort: new AbortController(),
64
+ truncated: events.some((event) => event.type === 'truncated'),
65
+ bytes: 0,
66
+ saving: Promise.resolve(),
67
+ };
68
+ this.entries.set(run.id, entry);
69
+ if (run.state === 'awaiting-approval') {
70
+ const left = Date.parse(run.approvals?.[0]?.expiresAt ?? '') - this.now();
71
+ if (Number.isFinite(left) && left > 0) {
72
+ entry.timer = setTimeout(() => this.expire(entry), left);
73
+ entry.timer.unref();
74
+ }
75
+ else {
76
+ this.finish(entry, 'rejected', 'nobody approved it in time');
77
+ }
78
+ }
79
+ else if (run.state === 'queued' || run.state === 'running') {
80
+ this.finish(entry, 'failed', `the server stopped while the run was ${run.state}, submit it again`);
81
+ }
82
+ }
83
+ this.evict();
84
+ }
85
+ // A run that needs approvals (for the accounts named) waits for them, the others
86
+ // are queued at once.
87
+ submit(owner, spec, approvalAccounts = []) {
33
88
  if (this.closed) {
34
89
  throw new RunError('the server is shutting down');
35
90
  }
36
- const waiting = needsApproval;
91
+ const waiting = approvalAccounts.length > 0;
37
92
  if (waiting && this.waiting().length >= this.maxWaiting) {
38
93
  throw new RunError('too many runs are waiting for an approval');
39
94
  }
95
+ this.checkLimits(owner, waiting);
40
96
  const run = {
41
97
  ...spec,
42
98
  id: `run_${(0, crypto_1.randomBytes)(12).toString('base64url')}`,
@@ -44,13 +100,23 @@ class RunRegistry {
44
100
  state: waiting ? 'awaiting-approval' : 'queued',
45
101
  createdAt: this.timestamp(),
46
102
  };
47
- const entry = { run, events: [], listeners: new Set(), abort: new AbortController(), truncated: false };
103
+ const entry = {
104
+ run,
105
+ events: [],
106
+ listeners: new Set(),
107
+ abort: new AbortController(),
108
+ truncated: false,
109
+ bytes: 0,
110
+ saving: Promise.resolve(),
111
+ };
48
112
  this.entries.set(run.id, entry);
113
+ if (waiting) {
114
+ // before the first event, which is written down with the run
115
+ const expiresAt = new Date(this.now() + this.approvalTimeout).toISOString();
116
+ run.approvals = approvalAccounts.map((account) => ({ account, expiresAt }));
117
+ }
49
118
  this.record(entry, { type: 'state', state: run.state });
50
119
  if (waiting) {
51
- run.approval = {
52
- expiresAt: new Date(this.now() + this.approvalTimeout).toISOString(),
53
- };
54
120
  entry.timer = setTimeout(() => this.expire(entry), this.approvalTimeout);
55
121
  entry.timer.unref();
56
122
  }
@@ -63,8 +129,10 @@ class RunRegistry {
63
129
  this.dispatch();
64
130
  return accepted;
65
131
  }
66
- get(owner, id) {
67
- const entry = this.find(owner, id);
132
+ // `canUse` also lets who uses an account the run needed approved read it: they
133
+ // decided about it, so they may follow it and stop it.
134
+ get(owner, id, canUse) {
135
+ const entry = this.find(owner, id, canUse);
68
136
  return entry ? this.copy(entry.run) : undefined;
69
137
  }
70
138
  // newest first
@@ -75,8 +143,8 @@ class RunRegistry {
75
143
  .reverse();
76
144
  }
77
145
  // A queued run is cancelled at once; a running one when its executor stops.
78
- cancel(owner, id) {
79
- const entry = this.find(owner, id);
146
+ cancel(owner, id, canUse) {
147
+ const entry = this.find(owner, id, canUse);
80
148
  if (!entry) {
81
149
  return undefined;
82
150
  }
@@ -99,8 +167,8 @@ class RunRegistry {
99
167
  .filter((entry) => entry.run.state === 'awaiting-approval')
100
168
  .map((entry) => this.copy(entry.run));
101
169
  }
102
- // One run that waits (or waited), if `canDecide` says the decider is admin of
103
- // its account.
170
+ // One run that waits (or waited), if `canDecide` says the decider uses one of the
171
+ // accounts it still needs approved.
104
172
  getForApproval(id, canDecide) {
105
173
  const entry = this.entries.get(id);
106
174
  if (!entry || !this.mayDecide(entry, canDecide)) {
@@ -108,7 +176,9 @@ class RunRegistry {
108
176
  }
109
177
  return this.copy(entry.run);
110
178
  }
111
- // An approval queues the run, a rejection ends it. Nobody decides on their own run.
179
+ // An approval covers the accounts the approver uses; the run is queued when all
180
+ // of its accounts are covered. A rejection by anybody who covers one of them ends
181
+ // it. Nobody decides on their own run.
112
182
  decide(decider, id, outcome, canDecide, reason) {
113
183
  const entry = this.entries.get(id);
114
184
  if (!entry || !this.mayDecide(entry, canDecide)) {
@@ -117,17 +187,31 @@ class RunRegistry {
117
187
  if (this.owns(decider, entry)) {
118
188
  return { status: 'own-run' };
119
189
  }
120
- if (entry.run.state !== 'awaiting-approval' || !entry.run.approval) {
190
+ if (entry.run.state !== 'awaiting-approval' || !entry.run.approvals) {
121
191
  return { status: 'not-waiting', state: entry.run.state };
122
192
  }
123
193
  const by = { iss: decider.iss, sub: decider.sub };
124
- this.record(entry, { type: 'approval', outcome, by, ...(reason ? { reason } : {}) });
194
+ const covered = entry.run.approvals.filter((approval) => !approval.approvedBy && canDecide(approval.account));
195
+ if (outcome === 'approved' && covered.length === 0) {
196
+ return { status: 'already-approved' };
197
+ }
198
+ const accounts = entry.run.approvals
199
+ .filter((approval) => canDecide(approval.account))
200
+ .map((approval) => approval.account);
201
+ this.record(entry, { type: 'approval', outcome, by, accounts, ...(reason ? { reason } : {}) });
125
202
  if (outcome === 'rejected') {
126
203
  this.finish(entry, 'rejected', `rejected by ${by.sub}${reason ? `: ${reason}` : ''}`);
127
204
  return { status: 'ok', run: this.copy(entry.run) };
128
205
  }
129
- entry.run.approval.approvedBy = by;
130
- entry.run.approval.approvedAt = this.timestamp();
206
+ const approvedAt = this.timestamp();
207
+ for (const approval of covered) {
208
+ approval.approvedBy = by;
209
+ approval.approvedAt = approvedAt;
210
+ }
211
+ if (entry.run.approvals.some((approval) => !approval.approvedBy)) {
212
+ // some accounts still wait for somebody who uses them
213
+ return { status: 'ok', run: this.copy(entry.run) };
214
+ }
131
215
  this.clearTimer(entry);
132
216
  entry.run.state = 'queued';
133
217
  this.record(entry, { type: 'state', state: 'queued' });
@@ -138,8 +222,8 @@ class RunRegistry {
138
222
  }
139
223
  // The events after `after` (0 for all), and a way to follow new ones. The
140
224
  // listener is not called for a run that is already over.
141
- watch(owner, id, after, listener) {
142
- const entry = this.find(owner, id);
225
+ watch(owner, id, after, listener, canUse) {
226
+ const entry = this.find(owner, id, canUse);
143
227
  if (!entry) {
144
228
  return undefined;
145
229
  }
@@ -158,7 +242,13 @@ class RunRegistry {
158
242
  }
159
243
  for (const entry of this.entries.values()) {
160
244
  if (entry.run.state === 'awaiting-approval') {
161
- this.finish(entry, 'cancelled');
245
+ if (this.options.store) {
246
+ // the record keeps it waiting, the next start takes it back
247
+ this.clearTimer(entry);
248
+ }
249
+ else {
250
+ this.finish(entry, 'cancelled');
251
+ }
162
252
  }
163
253
  }
164
254
  for (const entry of this.entries.values()) {
@@ -169,13 +259,16 @@ class RunRegistry {
169
259
  while (this.running > 0) {
170
260
  await new Promise((resolve) => setTimeout(resolve, 10));
171
261
  }
262
+ // the runs that were stopped are written down before the server is gone
263
+ await Promise.all([...this.entries.values()].map((entry) => entry.saving));
172
264
  }
173
- find(owner, id) {
265
+ find(owner, id, canUse) {
174
266
  const entry = this.entries.get(id);
175
- return entry && this.owns(owner, entry) ? entry : undefined;
267
+ return entry && (this.owns(owner, entry) || (canUse && this.mayDecide(entry, canUse))) ? entry : undefined;
176
268
  }
269
+ // Whoever uses an account the run needed approved is to see it and decide.
177
270
  mayDecide(entry, canDecide) {
178
- return entry.run.approval !== undefined && canDecide(entry.run.account);
271
+ return (entry.run.approvals ?? []).some((approval) => canDecide(approval.account));
179
272
  }
180
273
  expire(entry) {
181
274
  if (entry.run.state === 'awaiting-approval') {
@@ -199,15 +292,40 @@ class RunRegistry {
199
292
  source: { ...run.source },
200
293
  options: JSON.parse(JSON.stringify(run.options)),
201
294
  owner: { ...run.owner },
202
- ...(run.approval ? { approval: JSON.parse(JSON.stringify(run.approval)) } : {}),
295
+ accounts: [...run.accounts],
296
+ ...(run.approvals ? { approvals: JSON.parse(JSON.stringify(run.approvals)) } : {}),
203
297
  };
204
298
  }
205
299
  timestamp() {
206
300
  return new Date(this.now()).toISOString();
207
301
  }
302
+ // What a caller has now, against what they may have.
303
+ checkLimits(owner, waiting) {
304
+ const limits = this.options.limits;
305
+ if (!limits) {
306
+ return;
307
+ }
308
+ const mine = [...this.entries.values()].filter((entry) => this.owns(owner, entry));
309
+ const count = (state) => mine.filter((entry) => entry.run.state === state).length;
310
+ if (waiting && count('awaiting-approval') >= limits.waiting) {
311
+ throw new QuotaError(`you have ${limits.waiting} runs waiting for an approval, wait for a decision or cancel one`);
312
+ }
313
+ if (!waiting && count('queued') >= limits.queued) {
314
+ throw new QuotaError(`you have ${limits.queued} runs in the queue, wait for one to start or cancel one`);
315
+ }
316
+ }
317
+ runningOf(owner) {
318
+ return [...this.entries.values()].filter((entry) => entry.run.state === 'running' && this.owns(owner, entry)).length;
319
+ }
320
+ // The first run in the queue whose caller has a place left: one caller with many runs
321
+ // does not keep the others from the machine.
208
322
  dispatch() {
209
- while (this.running < this.maxConcurrent && this.queue.length > 0) {
210
- const entry = this.queue.shift();
323
+ while (this.running < this.maxConcurrent) {
324
+ const index = this.queue.findIndex((entry) => !this.options.limits || this.runningOf(entry.run.owner) < this.options.limits.running);
325
+ if (index < 0) {
326
+ return;
327
+ }
328
+ const [entry] = this.queue.splice(index, 1);
211
329
  this.running++;
212
330
  this.execute(entry).finally(() => {
213
331
  this.running--;
@@ -219,17 +337,32 @@ class RunRegistry {
219
337
  entry.run.state = 'running';
220
338
  entry.run.startedAt = this.timestamp();
221
339
  this.record(entry, { type: 'state', state: 'running' });
340
+ const limit = this.options.maxDuration;
341
+ const timer = limit
342
+ ? setTimeout(() => {
343
+ entry.timedOut = true;
344
+ entry.abort.abort();
345
+ }, limit)
346
+ : undefined;
347
+ timer?.unref();
222
348
  try {
223
349
  await this.options.executor.execute(this.copy(entry.run), {
224
350
  emit: (event) => this.record(entry, event),
225
351
  signal: entry.abort.signal,
226
352
  });
227
- this.finish(entry, entry.abort.signal.aborted ? 'cancelled' : 'succeeded');
353
+ this.finish(entry, entry.timedOut ? 'failed' : entry.abort.signal.aborted ? 'cancelled' : 'succeeded', entry.timedOut ? this.tooLong() : undefined);
228
354
  }
229
355
  catch (e) {
230
- this.finish(entry, entry.abort.signal.aborted ? 'cancelled' : 'failed', entry.abort.signal.aborted ? undefined : e instanceof Error ? e.message : String(e));
356
+ const cancelled = entry.abort.signal.aborted && !entry.timedOut;
357
+ this.finish(entry, cancelled ? 'cancelled' : 'failed', entry.timedOut ? this.tooLong() : cancelled ? undefined : e instanceof Error ? e.message : String(e));
358
+ }
359
+ finally {
360
+ clearTimeout(timer);
231
361
  }
232
362
  }
363
+ tooLong() {
364
+ return `the run took longer than ${(0, units_1.formatDuration)(this.options.maxDuration)} and was stopped`;
365
+ }
233
366
  finish(entry, state, error) {
234
367
  this.clearTimer(entry);
235
368
  entry.run.state = state;
@@ -242,14 +375,18 @@ class RunRegistry {
242
375
  this.evict();
243
376
  }
244
377
  record(entry, event, force = false) {
245
- // an executor that keeps talking can't fill the memory of the server; the
378
+ // an executor that keeps talking can't fill the memory of the server or its disk; the
246
379
  // state changes, which are few, are always kept
247
- if (!force && event.type !== 'state' && entry.events.length >= this.maxEvents) {
248
- if (!entry.truncated) {
249
- entry.truncated = true;
250
- this.push(entry, { type: 'truncated' });
380
+ if (!force && event.type !== 'state') {
381
+ const size = event.type === 'log' ? Buffer.byteLength(event.text) + 64 : 64;
382
+ if (entry.events.length >= this.maxEvents || entry.bytes + size > this.maxLogBytes) {
383
+ if (!entry.truncated) {
384
+ entry.truncated = true;
385
+ this.push(entry, { type: 'truncated' });
386
+ }
387
+ return;
251
388
  }
252
- return;
389
+ entry.bytes += size;
253
390
  }
254
391
  this.push(entry, event);
255
392
  }
@@ -259,12 +396,28 @@ class RunRegistry {
259
396
  for (const listener of entry.listeners) {
260
397
  listener(stored);
261
398
  }
399
+ // what a restart must not lose: the runs that wait, and how a run ended. A running
400
+ // run is not kept up to date, it fails with the server.
401
+ if (event.type === 'state' || event.type === 'approval') {
402
+ this.persist(entry);
403
+ }
404
+ }
405
+ persist(entry) {
406
+ const store = this.options.store;
407
+ if (!store) {
408
+ return;
409
+ }
410
+ const record = { run: this.copy(entry.run), events: [...entry.events] };
411
+ entry.saving = entry.saving
412
+ .then(() => store.save(record))
413
+ .catch((e) => this.options.log?.(`could not keep run ${record.run.id}: ${e instanceof Error ? e.message : e}`));
262
414
  }
263
415
  // Only finished runs are dropped, oldest first.
264
416
  evict() {
265
417
  const finished = [...this.entries.values()].filter((entry) => FINAL_STATES.includes(entry.run.state));
266
418
  for (const entry of finished.slice(0, Math.max(0, finished.length - this.keep))) {
267
419
  this.entries.delete(entry.run.id);
420
+ void entry.saving.then(() => this.options.store?.remove(entry.run.id)).catch(() => undefined);
268
421
  }
269
422
  }
270
423
  }
@@ -22,12 +22,30 @@ function registerServerCommand(program, environment) {
22
22
  log: (line) => environment.stdout.write(`${line}\n`),
23
23
  });
24
24
  environment.stdout.write(`hammerkit server listening on ${server.url}\n`);
25
+ // SIGHUP reads the entitlements again, so who may do what changes without
26
+ // a restart; the rest of the config needs one
27
+ const reload = async () => {
28
+ try {
29
+ const result = server.reload(await (0, server_config_1.readServerConfig)(environment, options.config));
30
+ if (result.restart.length > 0) {
31
+ environment.stdout.write(`changed but not reloaded, restart for: ${result.restart.join(', ')}\n`);
32
+ }
33
+ }
34
+ catch (e) {
35
+ environment.stdout.write(`entitlements were not reloaded: ${(0, log_1.getErrorMessage)(e)}\n`);
36
+ }
37
+ };
38
+ const onReload = () => void reload();
39
+ if (process.platform !== 'win32') {
40
+ process.on('SIGHUP', onReload);
41
+ }
25
42
  await new Promise((resolve) => {
26
43
  if (environment.abortCtrl.signal.aborted) {
27
44
  resolve();
28
45
  }
29
46
  environment.abortCtrl.signal.addEventListener('abort', () => resolve(), { once: true });
30
47
  });
48
+ process.off('SIGHUP', onReload);
31
49
  await server.close();
32
50
  environment.stdout.write('hammerkit server stopped\n');
33
51
  }