hammerkit 1.27.0-next.2 → 1.27.0-next.4

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.
@@ -1,12 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.quotePosix = exports.fetchFailure = exports.toSandboxPath = exports.checkSandboxPath = exports.exitCodeOf = exports.oneAtATime = exports.SandboxClient = exports.SandboxClientError = void 0;
3
+ exports.quotePosix = exports.fetchFailure = exports.toSandboxPath = exports.checkSandboxPath = exports.exitCodeOf = exports.followRun = exports.submitRun = exports.FINAL_RUN_STATES = exports.oneAtATime = exports.SandboxClient = exports.SandboxClientError = void 0;
4
4
  const path_1 = require("path");
5
5
  const token_source_1 = require("../remote/token-source");
6
6
  // a write carries up to 32 MiB
7
7
  const TIMEOUT_MS = 60000;
8
8
  // a run is asked for this long at a time, the log is read in between
9
9
  const POLL_WAIT = '1s';
10
+ // a cancelled run waits this long for the server to take the cancel, before it gives up
11
+ const CANCEL_WAIT_MS = 10000;
10
12
  class SandboxClientError extends Error {
11
13
  constructor(message, status) {
12
14
  super(message);
@@ -115,72 +117,26 @@ class SandboxClient {
115
117
  if (options.timeoutSeconds) {
116
118
  runOptions.timeout = `${Math.max(1, Math.ceil(options.timeoutSeconds))}s`;
117
119
  }
118
- const submitted = await json(await call(this.connection, 'POST', '/v1/runs', {
119
- body: {
120
- definition: this.build.hash,
121
- tasks: [task],
122
- source: { type: 'sandbox', id: this.id },
123
- options: runOptions,
124
- },
125
- }));
126
- const path = `/v1/runs/${encodeURIComponent(submitted.id)}`;
127
- const cancel = () => {
128
- call(this.connection, 'POST', `${path}/cancel`).catch(() => undefined);
129
- };
130
- options.signal?.addEventListener('abort', cancel, { once: true });
131
- const lines = [];
132
- let from = 1;
133
- let truncated = false;
134
- const readLog = async () => {
135
- for (;;) {
136
- const page = await json(await call(this.connection, 'GET', `${path}/logs`, { query: { task, from, limit: 1000 } }));
137
- truncated = page.truncated;
138
- for (const line of page.lines) {
139
- lines.push(line.text);
140
- options.onLine?.(line.text);
141
- from = line.line + 1;
142
- }
143
- if (!page.next) {
144
- return;
145
- }
146
- }
120
+ const submitted = await submitRun(this.connection, {
121
+ definition: this.build.hash,
122
+ tasks: [task],
123
+ source: { type: 'sandbox', id: this.id },
124
+ options: runOptions,
125
+ });
126
+ const followed = await followRun(this.connection, submitted, {
127
+ task,
128
+ signal: options.signal,
129
+ onLine: options.onLine ? (line) => options.onLine?.(line.text) : undefined,
130
+ onWaiting: options.onWaiting,
131
+ });
132
+ const result = followed.run.results?.[task];
133
+ return {
134
+ run: followed.run,
135
+ result,
136
+ output: followed.lines.map((line) => line.text).join('\n'),
137
+ truncated: followed.truncated,
138
+ exitCode: exitCodeOf(result),
147
139
  };
148
- try {
149
- let run = submitted;
150
- let waitingSaid = false;
151
- for (;;) {
152
- if (options.signal?.aborted) {
153
- throw new SandboxClientError('the run was cancelled');
154
- }
155
- if (run.state === 'awaiting-approval' && !waitingSaid) {
156
- waitingSaid = true;
157
- options.onWaiting?.(run);
158
- }
159
- await readLog();
160
- if (['succeeded', 'failed', 'cancelled', 'rejected'].includes(run.state)) {
161
- break;
162
- }
163
- run = await json(await call(this.connection, 'GET', path, { query: { wait: POLL_WAIT } }));
164
- }
165
- const result = run.results?.[task];
166
- if (truncated && result) {
167
- // the end of a long log is kept in the result
168
- for (const line of ['… the server cut the log off at its limit, the last lines were:', ...result.lastLog]) {
169
- lines.push(line);
170
- options.onLine?.(line);
171
- }
172
- }
173
- return {
174
- run,
175
- result,
176
- output: lines.join('\n'),
177
- truncated,
178
- exitCode: exitCodeOf(result),
179
- };
180
- }
181
- finally {
182
- options.signal?.removeEventListener('abort', cancel);
183
- }
184
140
  }
185
141
  /**
186
142
  * A shell command on the sandbox: the shell task of the build file with the command as
@@ -245,6 +201,88 @@ function oneAtATime() {
245
201
  };
246
202
  }
247
203
  exports.oneAtATime = oneAtATime;
204
+ // a run that is over, its state does not change any more
205
+ exports.FINAL_RUN_STATES = ['succeeded', 'failed', 'cancelled', 'rejected'];
206
+ // Submits a run, as `POST /v1/runs` takes it, and answers it as the server queued it or
207
+ // holds it for an approval.
208
+ async function submitRun(connection, request) {
209
+ return json(await call(connection, 'POST', '/v1/runs', { body: request }));
210
+ }
211
+ exports.submitRun = submitRun;
212
+ /**
213
+ * Follows a run to its end: its log as it grows, and its state. A signal cancels the run
214
+ * on the server and fails the follow.
215
+ */
216
+ async function followRun(connection, submitted, options = {}) {
217
+ const path = `/v1/runs/${encodeURIComponent(submitted.id)}`;
218
+ let cancelling;
219
+ const cancel = () => {
220
+ cancelling ?? (cancelling = call(connection, 'POST', `${path}/cancel`).catch(() => undefined));
221
+ };
222
+ options.signal?.addEventListener('abort', cancel, { once: true });
223
+ const lines = [];
224
+ let from = 1;
225
+ let truncated = false;
226
+ const add = (line) => {
227
+ lines.push(line);
228
+ options.onLine?.(line);
229
+ };
230
+ const readLog = async () => {
231
+ for (;;) {
232
+ const page = await json(await call(connection, 'GET', `${path}/logs`, { query: { task: options.task, from, limit: 1000 } }));
233
+ truncated = page.truncated;
234
+ for (const line of page.lines) {
235
+ add(line);
236
+ from = line.line + 1;
237
+ }
238
+ if (!page.next) {
239
+ return;
240
+ }
241
+ }
242
+ };
243
+ try {
244
+ let run = submitted;
245
+ let waitingSaid = false;
246
+ for (;;) {
247
+ if (options.signal?.aborted) {
248
+ // the process may exit after this, so the cancel has to reach the server first
249
+ cancel();
250
+ let timer;
251
+ await Promise.race([cancelling, new Promise((resolve) => (timer = setTimeout(resolve, CANCEL_WAIT_MS)))]);
252
+ clearTimeout(timer);
253
+ throw new SandboxClientError('the run was cancelled');
254
+ }
255
+ if (run.state === 'awaiting-approval' && !waitingSaid) {
256
+ waitingSaid = true;
257
+ options.onWaiting?.(run);
258
+ }
259
+ await readLog();
260
+ if (exports.FINAL_RUN_STATES.includes(run.state)) {
261
+ break;
262
+ }
263
+ run = await json(await call(connection, 'GET', path, { query: { wait: POLL_WAIT } }));
264
+ }
265
+ if (truncated) {
266
+ // the end of a long log is kept in the results
267
+ const tasks = options.task !== undefined ? [options.task] : Object.keys(run.results ?? {});
268
+ for (const task of tasks) {
269
+ const result = run.results?.[task];
270
+ if (!result) {
271
+ continue;
272
+ }
273
+ const said = options.task !== undefined ? 'the last lines were' : `the last lines of ${task} were`;
274
+ for (const text of [`… the server cut the log off at its limit, ${said}:`, ...result.lastLog]) {
275
+ add({ line: 0, task, stream: 'stdout', text });
276
+ }
277
+ }
278
+ }
279
+ return { run, lines, truncated };
280
+ }
281
+ finally {
282
+ options.signal?.removeEventListener('abort', cancel);
283
+ }
284
+ }
285
+ exports.followRun = followRun;
248
286
  function exitCodeOf(result) {
249
287
  if (!result) {
250
288
  return null;
package/dist/program.js CHANGED
@@ -159,11 +159,14 @@ async function getProgram(environment, argv) {
159
159
  if (fileIndex >= 0) {
160
160
  args.splice(fileIndex, 2);
161
161
  }
162
- (0, remote_commands_1.registerRemoteCommands)(program, environment, fileName);
162
+ (0, remote_commands_1.registerRemoteCommands)(program, environment, fileName, passthrough);
163
163
  program.hook('preAction', (_, actionCommand) => {
164
- const takesArguments = actionCommand.parent === program && ['run', 'up'].includes(actionCommand.name());
164
+ const takesArguments = (actionCommand.parent === program && ['run', 'up'].includes(actionCommand.name())) ||
165
+ (actionCommand.parent?.name() === 'remote' && actionCommand.name() === 'run');
165
166
  if (passthrough.length > 0 && !takesArguments) {
166
- program.error(`${actionCommand.name()} takes no arguments after --, only run and up do`, { exitCode: 1 });
167
+ program.error(`${actionCommand.name()} takes no arguments after --, only run, up and remote run do`, {
168
+ exitCode: 1,
169
+ });
167
170
  }
168
171
  });
169
172
  if (await environment.file.exists(fileName)) {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.applyAnnotations = exports.collectAnnotations = exports.remoteRepo = void 0;
3
+ exports.applyAnnotations = exports.collectAnnotations = exports.readCheckout = exports.remoteRepo = void 0;
4
4
  const child_process_1 = require("child_process");
5
5
  const os_1 = require("os");
6
6
  const path_1 = require("path");
@@ -36,6 +36,18 @@ function remoteRepo(url) {
36
36
  }
37
37
  }
38
38
  exports.remoteRepo = remoteRepo;
39
+ // The checkout a directory is in: the repository of its `origin`, the branch it is on and
40
+ // its commit, what can be found of them.
41
+ async function readCheckout(cwd) {
42
+ if ((await git(cwd, 'rev-parse', '--is-inside-work-tree')) !== 'true') {
43
+ return {};
44
+ }
45
+ const remote = await git(cwd, 'remote', 'get-url', 'origin');
46
+ // a detached HEAD is on no branch
47
+ const branch = await git(cwd, 'symbolic-ref', '--quiet', '--short', 'HEAD');
48
+ return { repo: remote ? remoteRepo(remote) : undefined, branch, commit: await git(cwd, 'rev-parse', 'HEAD') };
49
+ }
50
+ exports.readCheckout = readCheckout;
39
51
  // Where a build file is published from: its path in its repository (or on this
40
52
  // machine, outside of one), the repository and commit, this machine and its user.
41
53
  // `files` are the paths of the bundle, relative to the build file's directory: when
@@ -16,7 +16,9 @@ const remote_work_commands_1 = require("./remote-work-commands");
16
16
  const mcp_command_1 = require("../harness/mcp-command");
17
17
  // Host-level commands: they only touch the machine's config file, so they work
18
18
  // with or without a build file in the working directory.
19
- function registerRemoteCommands(program, environment, buildFile) {
19
+ function registerRemoteCommands(program, environment, buildFile,
20
+ // what follows `--`, the arguments of `remote run`
21
+ passthrough = []) {
20
22
  const remote = program.command('remote').description('register the servers runs can be sent to');
21
23
  const guarded = (failure, fn) => {
22
24
  return async (...args) => {
@@ -154,7 +156,14 @@ function registerRemoteCommands(program, environment, buildFile) {
154
156
  environment.stdout.write(`repositories: ${me.repos.map(server_client_1.printable).join(', ')}\n`);
155
157
  }
156
158
  }));
157
- (0, remote_work_commands_1.registerWorkCommands)(remote, { environment, buildFile, getServer, guarded });
159
+ (0, remote_work_commands_1.registerWorkCommands)(remote, {
160
+ environment,
161
+ buildFile,
162
+ getServer,
163
+ guarded,
164
+ passthrough,
165
+ fail: (message, exitCode) => program.error(message, { exitCode }),
166
+ });
158
167
  (0, mcp_command_1.registerMcpCommand)(remote, environment);
159
168
  }
160
169
  exports.registerRemoteCommands = registerRemoteCommands;
@@ -12,6 +12,8 @@ const bundle_1 = require("./bundle");
12
12
  const annotations_1 = require("./annotations");
13
13
  const server_client_1 = require("./server-client");
14
14
  const bundle_text_1 = require("./bundle-text");
15
+ const sandbox_client_1 = require("../harness/sandbox-client");
16
+ const path_1 = require("path");
15
17
  const RUN_ID = /^run_[A-Za-z0-9_-]{16}$/;
16
18
  // what the publisher said about where a version comes from, one line
17
19
  // What a publisher said about where a version comes from: the server takes it as said,
@@ -29,6 +31,11 @@ function yankLines(version) {
29
31
  ]
30
32
  : [];
31
33
  }
34
+ // A line a task printed, shown as it is but for control characters, so it can't rewrite
35
+ // the terminal; tabs stay.
36
+ function printableLine(text) {
37
+ return text.split('\t').map(server_client_1.printableWhole).join('\t');
38
+ }
32
39
  const collectOption = (value, previous = []) => [...previous, value];
33
40
  // What a person who approves is shown of a run: everything the decision rests on.
34
41
  function describeApproval(run) {
@@ -89,7 +96,7 @@ function describePlan(plan) {
89
96
  }
90
97
  exports.describePlan = describePlan;
91
98
  function registerWorkCommands(remote, context) {
92
- const { environment, buildFile, getServer, guarded } = context;
99
+ const { environment, buildFile, getServer, guarded, passthrough, fail } = context;
93
100
  const connect = async (name) => {
94
101
  const chosen = await getServer(name);
95
102
  // a CI job or an agent brings its own token, a person signs in
@@ -122,6 +129,87 @@ function registerWorkCommands(remote, context) {
122
129
  environment.stdout.write(`it can not run before somebody who may publish approves it, until ${(0, server_client_1.printable)(version.expiresAt)}\n`);
123
130
  }
124
131
  }));
132
+ remote
133
+ .command('run <tasks...>')
134
+ .description('run tasks of a published build file on a server from a git ref, print their output and exit as they did')
135
+ .addOption(new commander_1.Option('--server <name>', 'the server to run on; the default of remote use when left out'))
136
+ .addOption(new commander_1.Option('--build <hash>', 'the published version to run (sha256:...), or HAMMERKIT_BUILD; the hash of the build file here when left out'))
137
+ .addOption(new commander_1.Option('--repo <host/path>', 'the repository, such as github.com/corp/payments; origin of the checkout here when left out'))
138
+ .addOption(new commander_1.Option('--ref <ref>', 'the branch or tag to run; the branch checked out here when left out'))
139
+ .addOption(new commander_1.Option('--commit <sha>', 'the commit the ref has to be at, else the run is refused; the commit checked out here when --ref is left out'))
140
+ .addOption(new commander_1.Option('--env <name>', 'the environment of the build file, as --env of run'))
141
+ .addOption(new commander_1.Option('--cache <method>', 'as --cache of run').choices(['checksum', 'modify-date', 'none']))
142
+ .addOption(new commander_1.Option('--timeout <duration>', 'stop the run after this long, such as 30m'))
143
+ .action(guarded('Remote run was not successful', async (tasks, options) => {
144
+ const named = options.build ?? environment.processEnvs.HAMMERKIT_BUILD;
145
+ const definition = named ?? (0, bundle_1.hashBundle)(await (0, collect_bundle_1.collectBundle)(buildFile, environment));
146
+ if (!bundle_1.HASH_PATTERN.test(definition)) {
147
+ throw new Error(`${(0, server_client_1.printable)(definition)} is not the hash of a published version (sha256:...)`);
148
+ }
149
+ const checkout = options.repo && options.ref ? {} : await (0, annotations_1.readCheckout)((0, path_1.dirname)(buildFile));
150
+ const repo = options.repo ?? checkout.repo;
151
+ const ref = options.ref ?? checkout.branch;
152
+ // the commit checked out here is the code meant, when the ref is the branch it is on
153
+ const commit = options.commit ?? (options.ref ? undefined : checkout.commit);
154
+ if (!repo) {
155
+ throw new Error('name the repository with --repo, the checkout here has no origin it can be told by');
156
+ }
157
+ if (!ref) {
158
+ throw new Error('name the branch or tag with --ref, the checkout here is on no branch');
159
+ }
160
+ const { name: serverName, server } = await getServer(options.server);
161
+ const connection = { url: server.url, ...(0, token_source_1.getTokenSource)(environment, serverName, server) };
162
+ const runOptions = {};
163
+ for (const key of ['env', 'cache', 'timeout']) {
164
+ if (options[key] !== undefined) {
165
+ runOptions[key] = options[key];
166
+ }
167
+ }
168
+ if (passthrough.length > 0) {
169
+ runOptions.args = passthrough;
170
+ }
171
+ let submitted;
172
+ try {
173
+ submitted = await (0, sandbox_client_1.submitRun)(connection, {
174
+ definition,
175
+ tasks,
176
+ source: { type: 'ref', repo, ref, ...(commit ? { commit } : {}) },
177
+ options: runOptions,
178
+ });
179
+ }
180
+ catch (e) {
181
+ if (e.status === 400 && !named && /no published build file/.test(String(e))) {
182
+ throw new Error(`the build file here is not published on ${serverName} (${definition}), publish it first`);
183
+ }
184
+ throw e;
185
+ }
186
+ const at = submitted.source.type === 'ref' ? ` at ${(0, server_client_1.printable)(submitted.source.commit)}` : '';
187
+ environment.stdout.write(`${(0, server_client_1.printable)(submitted.id)} on ${serverName}: ${tasks.map(server_client_1.printable).join(', ')} from ${(0, server_client_1.printable)(ref)}${at}\n`);
188
+ const prefixed = tasks.length > 1;
189
+ const followed = await (0, sandbox_client_1.followRun)(connection, submitted, {
190
+ signal: environment.abortCtrl.signal,
191
+ onLine: (line) => {
192
+ environment.stdout.write(`${prefixed || line.task !== tasks[0] ? `[${(0, server_client_1.printable)(line.task)}] ` : ''}${printableLine(line.text)}\n`);
193
+ },
194
+ onWaiting: (run) => {
195
+ for (const approval of run.approvals ?? []) {
196
+ environment.stdout.write(`waiting until somebody who uses ${(0, server_client_1.printable)(approval.account)} approves it, before ${(0, server_client_1.printable)(approval.expiresAt)}: hammerkit remote approve ${(0, server_client_1.printable)(run.id)}\n`);
197
+ }
198
+ },
199
+ });
200
+ const run = followed.run;
201
+ if (run.state === 'succeeded') {
202
+ environment.stdout.write(`${(0, server_client_1.printable)(run.id)} succeeded\n`);
203
+ return;
204
+ }
205
+ // the exit code of the first task that failed, as `hammerkit run` would end
206
+ const failed = tasks
207
+ .map((task) => run.results?.[task])
208
+ .concat(Object.values(run.results ?? {}))
209
+ .find((result) => result?.status === 'failed');
210
+ const exitCode = failed?.exitCode && failed.exitCode > 0 ? failed.exitCode : 1;
211
+ fail(`${(0, server_client_1.printable)(run.id)} ${run.state}${run.error ? `: ${(0, server_client_1.printable)(run.error)}` : ''}`, exitCode);
212
+ }));
125
213
  remote
126
214
  .command('versions [server]')
127
215
  .description('list the published versions of build files on a server, newest first, with where they come from')
@@ -75,6 +75,12 @@ exports.runRequestSchema = zod_1.z
75
75
  return repo;
76
76
  }),
77
77
  ref: name,
78
+ // the commit the ref has to be at, such as the one a CI job checked out: a ref that
79
+ // moved since is refused, so the run executes the code that was asked for
80
+ commit: zod_1.z
81
+ .string()
82
+ .regex(/^([0-9a-f]{40}|[0-9a-f]{64})$/, 'expected a full commit hash, 40 or 64 hex digits')
83
+ .optional(),
78
84
  })
79
85
  .strict(),
80
86
  // the name of a tree announced with POST /v1/sources
@@ -5,6 +5,7 @@ const commander_1 = require("commander");
5
5
  const log_1 = require("../log");
6
6
  const server_config_1 = require("./server-config");
7
7
  const server_1 = require("./server");
8
+ const abort_on_signals_1 = require("../utils/abort-on-signals");
8
9
  // Runs this machine as a hammerkit server until interrupted. Like `remote`, it
9
10
  // only needs its own config file, not a build file.
10
11
  function registerServerCommand(program, environment) {
@@ -13,31 +14,46 @@ function registerServerCommand(program, environment) {
13
14
  .description('serve this machine to remote runs (experimental)')
14
15
  .addOption(new commander_1.Option('-c, --config <file>', 'server config file').env('HAMMERKIT_SERVER_CONFIG'))
15
16
  .action(async (options) => {
17
+ // SIGHUP reads the entitlements again, so who may do what changes without a
18
+ // restart; the rest of the config needs one. The server takes it over from the
19
+ // abort of the CLI, which stops other commands on it, before it starts: a SIGHUP
20
+ // while it reads its config and starts waits for it. Windows sends SIGHUP when the
21
+ // console closes, which stops the server there too.
22
+ let server;
23
+ let reloadWhenStarted = false;
24
+ const reload = async (running) => {
25
+ try {
26
+ const result = running.reload(await (0, server_config_1.readServerConfig)(environment, options.config));
27
+ if (result.restart.length > 0) {
28
+ environment.stdout.write(`changed but not reloaded, restart for: ${result.restart.join(', ')}\n`);
29
+ }
30
+ }
31
+ catch (e) {
32
+ environment.stdout.write(`entitlements were not reloaded: ${(0, log_1.getErrorMessage)(e)}\n`);
33
+ }
34
+ };
35
+ const releaseReload = process.platform !== 'win32'
36
+ ? (0, abort_on_signals_1.takeOverSignal)(process, 'SIGHUP', () => {
37
+ if (server) {
38
+ void reload(server);
39
+ }
40
+ else {
41
+ reloadWhenStarted = true;
42
+ }
43
+ })
44
+ : () => undefined;
16
45
  try {
17
46
  if (!options.config) {
18
47
  throw new Error('pass the server config with --config or HAMMERKIT_SERVER_CONFIG');
19
48
  }
20
49
  const config = await (0, server_config_1.readServerConfig)(environment, options.config);
21
- const server = await (0, server_1.startServer)(config, environment, {
50
+ const running = await (0, server_1.startServer)(config, environment, {
22
51
  log: (line) => environment.stdout.write(`${line}\n`),
23
52
  });
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);
53
+ server = running;
54
+ environment.stdout.write(`hammerkit server listening on ${running.url}\n`);
55
+ if (reloadWhenStarted) {
56
+ void reload(running);
41
57
  }
42
58
  await new Promise((resolve) => {
43
59
  if (environment.abortCtrl.signal.aborted) {
@@ -45,8 +61,8 @@ function registerServerCommand(program, environment) {
45
61
  }
46
62
  environment.abortCtrl.signal.addEventListener('abort', () => resolve(), { once: true });
47
63
  });
48
- process.off('SIGHUP', onReload);
49
- await server.close();
64
+ releaseReload();
65
+ await running.close();
50
66
  environment.stdout.write('hammerkit server stopped\n');
51
67
  }
52
68
  catch (e) {
@@ -55,6 +71,9 @@ function registerServerCommand(program, environment) {
55
71
  }
56
72
  program.error(`Server was not successful: ${(0, log_1.getErrorMessage)(e)}`, { exitCode: 1 });
57
73
  }
74
+ finally {
75
+ releaseReload();
76
+ }
58
77
  });
59
78
  }
60
79
  exports.registerServerCommand = registerServerCommand;
@@ -304,6 +304,12 @@ async function submitRun(config, registry, definitions, sources, sandboxes, plan
304
304
  if (request.source.type === 'ref') {
305
305
  try {
306
306
  const commit = await repos.resolve(request.source.repo, request.source.ref);
307
+ if (request.source.commit && commit !== request.source.commit) {
308
+ const error = `${request.source.ref} is at ${commit} now, not at ${request.source.commit}`;
309
+ log(`refuse run iss=${principal.iss} sub=${principal.sub}: ${error}`);
310
+ (0, http_2.send)(res, 409, { error });
311
+ return;
312
+ }
307
313
  source = { type: 'ref', repo: request.source.repo, ref: request.source.ref, commit };
308
314
  }
309
315
  catch (e) {
@@ -1,17 +1,57 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.abortOnSignals = exports.ABORT_SIGNALS = void 0;
3
+ exports.takeOverSignal = exports.abortOnSignals = exports.ABORT_SIGNALS = void 0;
4
4
  // Ctrl-C (SIGINT), a CI job being cancelled or a supervisor stopping hammerkit
5
5
  // (SIGTERM) and a closed terminal (SIGHUP) all stop the run the same way: abort,
6
6
  // so services are removed and local tasks' process groups are killed. Without a
7
7
  // handler Node exits on SIGTERM/SIGHUP at once and leaves both behind.
8
8
  exports.ABORT_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
9
- function abortOnSignals(source, abortCtrl) {
10
- for (const signal of exports.ABORT_SIGNALS) {
11
- source.on(signal, () => {
12
- abortCtrl.abort();
13
- });
9
+ // signals a running command gives a meaning of its own (the server reloads its
10
+ // entitlements on SIGHUP), per source and counted by how many hold them: they don't
11
+ // abort the run
12
+ const takenOver = new WeakMap();
13
+ function heldOn(source) {
14
+ let held = takenOver.get(source);
15
+ if (!held) {
16
+ held = new Map();
17
+ takenOver.set(source, held);
14
18
  }
19
+ return held;
20
+ }
21
+ /**
22
+ * Stops the run on the abort signals, all but those a command took over with
23
+ * `takeOverSignal`. Returns what removes the listeners again.
24
+ */
25
+ function abortOnSignals(source, abortCtrl) {
26
+ const listeners = exports.ABORT_SIGNALS.map((signal) => {
27
+ const listener = () => {
28
+ if (!takenOver.get(source)?.get(signal)) {
29
+ abortCtrl.abort();
30
+ }
31
+ };
32
+ source.on(signal, listener);
33
+ return { signal, listener };
34
+ });
35
+ return () => listeners.forEach(({ signal, listener }) => source.off(signal, listener));
15
36
  }
16
37
  exports.abortOnSignals = abortOnSignals;
38
+ /**
39
+ * Calls `handler` on `signal` instead of stopping the run, until the returned function
40
+ * gives the signal back.
41
+ */
42
+ function takeOverSignal(source, signal, handler) {
43
+ const held = heldOn(source);
44
+ held.set(signal, (held.get(signal) ?? 0) + 1);
45
+ source.on(signal, handler);
46
+ let released = false;
47
+ return () => {
48
+ if (released) {
49
+ return;
50
+ }
51
+ released = true;
52
+ source.off(signal, handler);
53
+ held.set(signal, (held.get(signal) ?? 1) - 1);
54
+ };
55
+ }
56
+ exports.takeOverSignal = takeOverSignal;
17
57
  //# sourceMappingURL=abort-on-signals.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hammerkit",
3
3
  "description": "Containerized build tool with incremental caching that runs the same build file locally, in Docker and on Kubernetes.",
4
- "version": "1.27.0-next.2",
4
+ "version": "1.27.0-next.4",
5
5
  "keywords": [
6
6
  "build",
7
7
  "build-tool",