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.
- package/dist/optimizer/get-cache-directory.js +5 -0
- package/dist/planner/work-secret.js +6 -2
- package/dist/program.js +2 -0
- package/dist/remote/remote-commands.js +16 -12
- package/dist/remote/remote-work-commands.js +75 -3
- package/dist/schema/reference-parser.js +8 -2
- package/dist/secrets/provider-values.js +4 -0
- package/dist/server/accounts-api.js +42 -0
- package/dist/server/approvals-api.js +11 -6
- package/dist/server/definitions-api.js +65 -21
- package/dist/server/definitions.js +54 -142
- package/dist/server/executor.js +180 -181
- package/dist/server/git-checkout.js +55 -3
- package/dist/server/plan.js +296 -0
- package/dist/server/policy.js +98 -50
- package/dist/server/proposals-api.js +30 -8
- package/dist/server/proposals.js +50 -3
- package/dist/server/repo-access.js +127 -0
- package/dist/server/repo.js +94 -0
- package/dist/server/run-environment.js +20 -0
- package/dist/server/run-plan.js +44 -0
- package/dist/server/run-request.js +23 -5
- package/dist/server/run-store.js +61 -0
- package/dist/server/run-worker-command.js +17 -0
- package/dist/server/run-worker-main.js +7 -0
- package/dist/server/run-worker.js +179 -0
- package/dist/server/runs.js +189 -36
- package/dist/server/server-command.js +18 -0
- package/dist/server/server-config.js +203 -42
- package/dist/server/server.js +148 -52
- package/dist/server/sources-api.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
package/dist/server/runs.js
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
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 =
|
|
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 = {
|
|
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
|
-
|
|
67
|
-
|
|
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
|
|
103
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
210
|
-
const
|
|
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
|
-
|
|
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'
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
}
|