hammerkit 1.23.0 → 1.24.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.
Files changed (39) hide show
  1. package/build.schema.json +10 -0
  2. package/dist/cli.js +6 -0
  3. package/dist/executer/execute-command.js +7 -1
  4. package/dist/planner/utils/append-work-task.js +1 -0
  5. package/dist/program.js +4 -0
  6. package/dist/remote/bundle.js +59 -0
  7. package/dist/remote/collect-bundle.js +41 -0
  8. package/dist/remote/credentials.js +93 -0
  9. package/dist/remote/device-login.js +177 -0
  10. package/dist/remote/host-config.js +170 -0
  11. package/dist/remote/location.js +46 -0
  12. package/dist/remote/remote-commands.js +154 -0
  13. package/dist/remote/remote-work-commands.js +152 -0
  14. package/dist/remote/server-client.js +88 -0
  15. package/dist/remote/source-manifest.js +79 -0
  16. package/dist/remote/text-diff.js +89 -0
  17. package/dist/schema/build-file-base-task-schema.js +4 -0
  18. package/dist/schema/build-file-schema.js +3 -0
  19. package/dist/schema/reference-parser.js +1 -0
  20. package/dist/schema/runner-schema.js +13 -0
  21. package/dist/server/approvals-api.js +99 -0
  22. package/dist/server/definitions-api.js +119 -0
  23. package/dist/server/definitions.js +248 -0
  24. package/dist/server/discovery.js +9 -0
  25. package/dist/server/executor.js +238 -0
  26. package/dist/server/git-checkout.js +63 -0
  27. package/dist/server/http.js +39 -0
  28. package/dist/server/oauth.js +226 -0
  29. package/dist/server/policy.js +90 -0
  30. package/dist/server/proposals-api.js +123 -0
  31. package/dist/server/proposals.js +126 -0
  32. package/dist/server/run-request.js +67 -0
  33. package/dist/server/runs.js +279 -0
  34. package/dist/server/server-command.js +43 -0
  35. package/dist/server/server-config.js +233 -0
  36. package/dist/server/server.js +338 -0
  37. package/dist/server/sources-api.js +106 -0
  38. package/dist/server/sources.js +258 -0
  39. package/package.json +1 -1
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleProposals = void 0;
4
+ const zod_1 = require("zod");
5
+ const bundle_1 = require("../remote/bundle");
6
+ const definitions_1 = require("./definitions");
7
+ const http_1 = require("./http");
8
+ const oauth_1 = require("./oauth");
9
+ const policy_1 = require("./policy");
10
+ const server_config_1 = require("./server-config");
11
+ const decisionSchema = zod_1.z.object({ reason: zod_1.z.string().max(500).optional() }).strict();
12
+ const same = (a, b) => a.iss === b.iss && a.sub === b.sub;
13
+ // What the caller is shown of a proposal: what it is, who proposed it and the
14
+ // version it would replace, so the files can be compared.
15
+ function toView(store, proposal, principal, grant) {
16
+ return {
17
+ ...proposal,
18
+ base: store.find(proposal.name, 'latest')?.hash ?? null,
19
+ mine: same(proposal.proposedBy, principal),
20
+ // approving needs the permission to publish the name, and somebody else's proposal
21
+ canDecide: proposal.state === 'waiting' && (0, policy_1.mayPublish)(grant, proposal.name) && !same(proposal.proposedBy, principal),
22
+ };
23
+ }
24
+ /**
25
+ * New versions of build files that wait for an approval. Whoever may only
26
+ * propose a version (`propose` on an entitlement) puts one here; someone who may
27
+ * `publish` that name approves it, and it is published then.
28
+ *
29
+ * GET /v1/proposals the ones the caller proposed or may decide
30
+ * GET /v1/proposals/<name>/<hash> one of them
31
+ * GET /v1/proposals/<name>/<hash>/files its files, for who decides
32
+ * POST /v1/proposals/<name>/<hash>/approve publish it (body: optional {reason})
33
+ * POST /v1/proposals/<name>/<hash>/reject drop it
34
+ */
35
+ async function handleProposals(config, store, proposals, log, principal, req, res, url) {
36
+ const match = /^\/v1\/proposals(?:\/([^/]+)\/([^/]+)(?:\/(files|approve|reject))?)?$/.exec(url.pathname);
37
+ if (!match) {
38
+ return false;
39
+ }
40
+ const [, name, hash, action] = match;
41
+ const grant = (0, policy_1.getGrant)(config.entitlements, principal);
42
+ // what the caller neither proposed nor may decide does not exist for them
43
+ const visible = (proposal) => same(proposal.proposedBy, principal) || (0, policy_1.mayPublish)(grant, proposal.name);
44
+ if (name === undefined) {
45
+ if (req.method !== 'GET') {
46
+ (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
47
+ return true;
48
+ }
49
+ (0, http_1.send)(res, 200, {
50
+ proposals: proposals
51
+ .all()
52
+ .filter(visible)
53
+ .map((proposal) => toView(store, proposal, principal, grant)),
54
+ });
55
+ return true;
56
+ }
57
+ if (!server_config_1.definitionName.safeParse(name).success || !bundle_1.HASH_PATTERN.test(hash)) {
58
+ (0, http_1.send)(res, 404, { error: 'not found' });
59
+ return true;
60
+ }
61
+ const proposal = proposals.get(name, hash);
62
+ if (!action) {
63
+ if (req.method !== 'GET') {
64
+ (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
65
+ return true;
66
+ }
67
+ proposal && visible(proposal)
68
+ ? (0, http_1.send)(res, 200, toView(store, proposal, principal, grant))
69
+ : (0, http_1.send)(res, 404, { error: 'not found' });
70
+ return true;
71
+ }
72
+ if (action === 'files') {
73
+ if (req.method !== 'GET') {
74
+ (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
75
+ return true;
76
+ }
77
+ // the files are for who decides, and for who wrote them
78
+ const bundle = proposal && visible(proposal) ? proposals.bundle(name, hash) : undefined;
79
+ bundle ? (0, http_1.send)(res, 200, bundle) : (0, http_1.send)(res, 404, { error: 'not found' });
80
+ return true;
81
+ }
82
+ if (req.method !== 'POST') {
83
+ (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'POST' });
84
+ return true;
85
+ }
86
+ const body = decisionSchema.safeParse(req.headers['content-type'] ? await (0, http_1.readJson)(req) : {});
87
+ if (!body.success) {
88
+ const issue = body.error.issues[0];
89
+ (0, http_1.send)(res, 400, { error: `${issue.path.join('.') || 'request'}: ${issue.message}` });
90
+ return true;
91
+ }
92
+ const outcome = action === 'approve' ? 'approved' : 'rejected';
93
+ try {
94
+ const result = await proposals.decide(principal, name, hash, outcome, (n) => (0, policy_1.mayPublish)(grant, n), async (bundle) => {
95
+ const proposed = proposals.get(name, hash);
96
+ await store.publish(name, bundle, proposed?.proposedBy ?? principal, principal);
97
+ }, body.data.reason);
98
+ log(`${action} proposal=${name}@${hash} iss=${principal.iss} sub=${principal.sub} result=${result.status}${body.data.reason ? ` reason=${(0, oauth_1.quote)(body.data.reason)}` : ''}`);
99
+ switch (result.status) {
100
+ case 'ok':
101
+ (0, http_1.send)(res, 200, toView(store, result.proposal, principal, grant));
102
+ break;
103
+ case 'own-proposal':
104
+ (0, http_1.send)(res, 403, { error: 'a version is approved by somebody else than who proposed it' });
105
+ break;
106
+ case 'not-waiting':
107
+ (0, http_1.send)(res, 409, { error: `the proposal is ${result.state}, it does not wait for an approval` });
108
+ break;
109
+ default:
110
+ (0, http_1.send)(res, 404, { error: 'not found' });
111
+ }
112
+ }
113
+ catch (e) {
114
+ if (e instanceof definitions_1.DefinitionError) {
115
+ (0, http_1.send)(res, 400, { error: `the build file does not load: ${e.message}` });
116
+ return true;
117
+ }
118
+ throw e;
119
+ }
120
+ return true;
121
+ }
122
+ exports.handleProposals = handleProposals;
123
+ //# sourceMappingURL=proposals-api.js.map
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ProposalRegistry = exports.ProposalError = void 0;
4
+ class ProposalError extends Error {
5
+ }
6
+ exports.ProposalError = ProposalError;
7
+ // The proposals of the server, in memory: a restart forgets what waits, and
8
+ // whoever proposed it proposes it again. Nothing in here is runnable.
9
+ class ProposalRegistry {
10
+ constructor(options = {}) {
11
+ this.entries = new Map();
12
+ this.closed = false;
13
+ this.maxWaiting = options.maxWaiting ?? 100;
14
+ this.timeout = options.timeout ?? 60 * 60 * 1000;
15
+ this.keep = options.keep ?? 100;
16
+ this.now = options.now ?? Date.now;
17
+ }
18
+ // Proposing what already waits is not an error and changes nothing.
19
+ propose(version, bundle, proposer) {
20
+ if (this.closed) {
21
+ throw new ProposalError('the server is shutting down');
22
+ }
23
+ const key = this.key(version.name, version.hash);
24
+ const existing = this.entries.get(key);
25
+ if (existing && existing.proposal.state === 'waiting') {
26
+ return { proposal: { ...existing.proposal }, created: false };
27
+ }
28
+ if (this.waiting().length >= this.maxWaiting) {
29
+ throw new ProposalError('too many build file versions are waiting for an approval');
30
+ }
31
+ const proposal = {
32
+ name: version.name,
33
+ hash: version.hash,
34
+ entry: version.entry,
35
+ tasks: version.tasks,
36
+ services: version.services,
37
+ proposedBy: { iss: proposer.iss, sub: proposer.sub },
38
+ createdAt: new Date(this.now()).toISOString(),
39
+ expiresAt: new Date(this.now() + this.timeout).toISOString(),
40
+ state: 'waiting',
41
+ };
42
+ const entry = { proposal, bundle };
43
+ entry.timer = setTimeout(() => this.settle(entry, 'expired'), this.timeout);
44
+ entry.timer.unref();
45
+ this.entries.delete(key);
46
+ this.entries.set(key, entry);
47
+ this.evict();
48
+ return { proposal: { ...proposal }, created: true };
49
+ }
50
+ waiting() {
51
+ return [...this.entries.values()].filter((e) => e.proposal.state === 'waiting').map((e) => ({ ...e.proposal }));
52
+ }
53
+ // Everything kept, newest first.
54
+ all() {
55
+ return [...this.entries.values()].map((e) => ({ ...e.proposal })).reverse();
56
+ }
57
+ get(name, hash) {
58
+ const entry = this.entries.get(this.key(name, hash));
59
+ return entry ? { ...entry.proposal } : undefined;
60
+ }
61
+ // The files of one that still waits, for whoever decides on it.
62
+ bundle(name, hash) {
63
+ return this.entries.get(this.key(name, hash))?.bundle;
64
+ }
65
+ // An approval publishes the files first (`publish`), and only then is the
66
+ // proposal approved, so it never claims a version that is not there. Nobody
67
+ // decides on their own proposal.
68
+ async decide(decider, name, hash, outcome, mayDecide, publish, reason) {
69
+ const entry = this.entries.get(this.key(name, hash));
70
+ if (!entry || !mayDecide(name)) {
71
+ return { status: 'not-found' };
72
+ }
73
+ if (entry.proposal.proposedBy.iss === decider.iss && entry.proposal.proposedBy.sub === decider.sub) {
74
+ return { status: 'own-proposal' };
75
+ }
76
+ if (entry.proposal.state !== 'waiting' || !entry.bundle) {
77
+ return { status: 'not-waiting', state: entry.proposal.state };
78
+ }
79
+ if (outcome === 'approved') {
80
+ await publish(entry.bundle);
81
+ if (entry.proposal.state !== 'waiting') {
82
+ return { status: 'not-waiting', state: entry.proposal.state };
83
+ }
84
+ }
85
+ this.settle(entry, outcome, { iss: decider.iss, sub: decider.sub }, reason);
86
+ return { status: 'ok', proposal: { ...entry.proposal } };
87
+ }
88
+ close() {
89
+ this.closed = true;
90
+ for (const entry of this.entries.values()) {
91
+ if (entry.timer) {
92
+ clearTimeout(entry.timer);
93
+ entry.timer = undefined;
94
+ }
95
+ }
96
+ }
97
+ settle(entry, state, by, reason) {
98
+ if (entry.proposal.state !== 'waiting') {
99
+ return;
100
+ }
101
+ if (entry.timer) {
102
+ clearTimeout(entry.timer);
103
+ entry.timer = undefined;
104
+ }
105
+ entry.proposal.state = state;
106
+ entry.proposal.decidedAt = new Date(this.now()).toISOString();
107
+ if (by) {
108
+ entry.proposal.decidedBy = by;
109
+ }
110
+ if (reason) {
111
+ entry.proposal.reason = reason;
112
+ }
113
+ entry.bundle = undefined;
114
+ }
115
+ evict() {
116
+ const decided = [...this.entries.entries()].filter(([, e]) => e.proposal.state !== 'waiting');
117
+ for (const [key] of decided.slice(0, Math.max(0, decided.length - this.keep))) {
118
+ this.entries.delete(key);
119
+ }
120
+ }
121
+ key(name, hash) {
122
+ return `${name}@${hash}`;
123
+ }
124
+ }
125
+ exports.ProposalRegistry = ProposalRegistry;
126
+ //# sourceMappingURL=proposals.js.map
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runRequestSchema = exports.runOptionsSchema = void 0;
4
+ const zod_1 = require("zod");
5
+ const units_1 = require("../utils/units");
6
+ const bundle_1 = require("../remote/bundle");
7
+ const server_config_1 = require("./server-config");
8
+ const name = zod_1.z.string().min(1).max(200);
9
+ // What `POST /v1/runs` takes. Who is asking, which accounts they may use and
10
+ // where code may come from is decided from the token, never from this body.
11
+ const duration = zod_1.z.string().refine((value) => {
12
+ try {
13
+ (0, units_1.parseDuration)(value);
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }, 'expected a duration like 30s, 5m or 1h30m');
20
+ // The settings of `hammerkit run` that make sense for a run on a server. What
21
+ // the server limits itself (workers, resources) and what only a terminal
22
+ // needs (watch, log mode) are left out.
23
+ exports.runOptionsSchema = zod_1.z
24
+ .object({
25
+ // as --env
26
+ env: name.optional(),
27
+ // as --filter and --exclude: labels
28
+ filter: zod_1.z.array(name).max(20).optional(),
29
+ exclude: zod_1.z.array(name).max(20).optional(),
30
+ // as --cache
31
+ cache: zod_1.z.enum(['checksum', 'modify-date', 'none']).optional(),
32
+ // as --cache-read-only
33
+ cacheReadOnly: zod_1.z.boolean().optional(),
34
+ // as --timeout
35
+ timeout: duration.optional(),
36
+ // as --no-skip-deps when false
37
+ skipDeps: zod_1.z.boolean().optional(),
38
+ })
39
+ .strict();
40
+ exports.runRequestSchema = zod_1.z
41
+ .object({
42
+ // the published build file to run, and the version of it: a hash, or the
43
+ // latest when left out. The server answers with the hash it chose.
44
+ definition: zod_1.z
45
+ .object({
46
+ name: server_config_1.definitionName,
47
+ hash: zod_1.z.union([zod_1.z.literal('latest'), zod_1.z.string().regex(bundle_1.HASH_PATTERN)]).default('latest'),
48
+ })
49
+ .strict(),
50
+ // tasks by their name in the build file, as on the command line; labels in
51
+ // the options can select tasks instead
52
+ tasks: zod_1.z.array(name).max(100).default([]),
53
+ // the service account to use; the caller's default when left out
54
+ account: name.optional(),
55
+ source: zod_1.z.discriminatedUnion('type', [
56
+ zod_1.z.object({ type: zod_1.z.literal('ref'), ref: name }).strict(),
57
+ // the name of a tree announced with POST /v1/sources
58
+ zod_1.z.object({ type: zod_1.z.literal('upload'), snapshot: zod_1.z.string().regex(bundle_1.HASH_PATTERN) }).strict(),
59
+ ]),
60
+ options: exports.runOptionsSchema.default({}),
61
+ })
62
+ .strict()
63
+ .refine((request) => request.tasks.length > 0 || (request.options.filter ?? []).length > 0, {
64
+ message: 'name at least one task, or filter by labels',
65
+ path: ['tasks'],
66
+ });
67
+ //# sourceMappingURL=run-request.js.map
@@ -0,0 +1,279 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.unavailableExecutor = exports.RunRegistry = exports.RUN_ID_PATTERN = exports.RunError = exports.isFinal = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const FINAL_STATES = ['succeeded', 'failed', 'cancelled', 'rejected'];
6
+ function isFinal(state) {
7
+ return FINAL_STATES.includes(state);
8
+ }
9
+ exports.isFinal = isFinal;
10
+ class RunError extends Error {
11
+ }
12
+ exports.RunError = RunError;
13
+ exports.RUN_ID_PATTERN = /^run_[A-Za-z0-9_-]{16}$/;
14
+ // Where runs are kept while the server is up. Everything is scoped to the
15
+ // caller that submitted the run: the same id asked for by someone else is
16
+ // "not found", so ids can't be used to find out which runs exist.
17
+ class RunRegistry {
18
+ constructor(options) {
19
+ this.options = options;
20
+ this.entries = new Map();
21
+ this.queue = [];
22
+ this.running = 0;
23
+ this.closed = false;
24
+ this.maxConcurrent = options.maxConcurrent ?? 4;
25
+ this.keep = options.keep ?? 200;
26
+ this.maxEvents = options.maxEvents ?? 5000;
27
+ this.maxWaiting = options.maxWaiting ?? 100;
28
+ this.approvalTimeout = options.approvalTimeout ?? 60 * 60 * 1000;
29
+ this.now = options.now ?? Date.now;
30
+ }
31
+ // A run that needs an approval waits for it, the others are queued at once.
32
+ submit(owner, spec, needsApproval = false) {
33
+ if (this.closed) {
34
+ throw new RunError('the server is shutting down');
35
+ }
36
+ const waiting = needsApproval;
37
+ if (waiting && this.waiting().length >= this.maxWaiting) {
38
+ throw new RunError('too many runs are waiting for an approval');
39
+ }
40
+ const run = {
41
+ ...spec,
42
+ id: `run_${(0, crypto_1.randomBytes)(12).toString('base64url')}`,
43
+ owner: { iss: owner.iss, sub: owner.sub },
44
+ state: waiting ? 'awaiting-approval' : 'queued',
45
+ createdAt: this.timestamp(),
46
+ };
47
+ const entry = { run, events: [], listeners: new Set(), abort: new AbortController(), truncated: false };
48
+ this.entries.set(run.id, entry);
49
+ this.record(entry, { type: 'state', state: run.state });
50
+ if (waiting) {
51
+ run.approval = {
52
+ expiresAt: new Date(this.now() + this.approvalTimeout).toISOString(),
53
+ };
54
+ entry.timer = setTimeout(() => this.expire(entry), this.approvalTimeout);
55
+ entry.timer.unref();
56
+ }
57
+ else {
58
+ this.queue.push(entry);
59
+ }
60
+ this.evict();
61
+ // what the caller is told is the run as accepted, even when it starts at once
62
+ const accepted = this.copy(run);
63
+ this.dispatch();
64
+ return accepted;
65
+ }
66
+ get(owner, id) {
67
+ const entry = this.find(owner, id);
68
+ return entry ? this.copy(entry.run) : undefined;
69
+ }
70
+ // newest first
71
+ list(owner) {
72
+ return [...this.entries.values()]
73
+ .filter((entry) => this.owns(owner, entry))
74
+ .map((entry) => this.copy(entry.run))
75
+ .reverse();
76
+ }
77
+ // 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);
80
+ if (!entry) {
81
+ return undefined;
82
+ }
83
+ if (entry.run.state === 'queued') {
84
+ this.queue.splice(this.queue.indexOf(entry), 1);
85
+ this.finish(entry, 'cancelled');
86
+ }
87
+ else if (entry.run.state === 'awaiting-approval') {
88
+ this.finish(entry, 'cancelled');
89
+ }
90
+ else if (entry.run.state === 'running') {
91
+ entry.abort.abort();
92
+ }
93
+ return this.copy(entry.run);
94
+ }
95
+ // The runs that wait for an approval, of anybody, oldest first. Who may see
96
+ // them is for the caller to decide.
97
+ waiting() {
98
+ return [...this.entries.values()]
99
+ .filter((entry) => entry.run.state === 'awaiting-approval')
100
+ .map((entry) => this.copy(entry.run));
101
+ }
102
+ // One run that waits (or waited), if `canDecide` says the decider is admin of
103
+ // its account.
104
+ getForApproval(id, canDecide) {
105
+ const entry = this.entries.get(id);
106
+ if (!entry || !this.mayDecide(entry, canDecide)) {
107
+ return undefined;
108
+ }
109
+ return this.copy(entry.run);
110
+ }
111
+ // An approval queues the run, a rejection ends it. Nobody decides on their own run.
112
+ decide(decider, id, outcome, canDecide, reason) {
113
+ const entry = this.entries.get(id);
114
+ if (!entry || !this.mayDecide(entry, canDecide)) {
115
+ return { status: 'not-found' };
116
+ }
117
+ if (this.owns(decider, entry)) {
118
+ return { status: 'own-run' };
119
+ }
120
+ if (entry.run.state !== 'awaiting-approval' || !entry.run.approval) {
121
+ return { status: 'not-waiting', state: entry.run.state };
122
+ }
123
+ const by = { iss: decider.iss, sub: decider.sub };
124
+ this.record(entry, { type: 'approval', outcome, by, ...(reason ? { reason } : {}) });
125
+ if (outcome === 'rejected') {
126
+ this.finish(entry, 'rejected', `rejected by ${by.sub}${reason ? `: ${reason}` : ''}`);
127
+ return { status: 'ok', run: this.copy(entry.run) };
128
+ }
129
+ entry.run.approval.approvedBy = by;
130
+ entry.run.approval.approvedAt = this.timestamp();
131
+ this.clearTimer(entry);
132
+ entry.run.state = 'queued';
133
+ this.record(entry, { type: 'state', state: 'queued' });
134
+ this.queue.push(entry);
135
+ const approved = this.copy(entry.run);
136
+ this.dispatch();
137
+ return { status: 'ok', run: approved };
138
+ }
139
+ // The events after `after` (0 for all), and a way to follow new ones. The
140
+ // listener is not called for a run that is already over.
141
+ watch(owner, id, after, listener) {
142
+ const entry = this.find(owner, id);
143
+ if (!entry) {
144
+ return undefined;
145
+ }
146
+ const events = entry.events.filter((event) => event.seq > after);
147
+ const finished = FINAL_STATES.includes(entry.run.state);
148
+ if (!finished) {
149
+ entry.listeners.add(listener);
150
+ }
151
+ return { events, finished, stop: () => entry.listeners.delete(listener) };
152
+ }
153
+ // Stops accepting runs and aborts the ones in flight.
154
+ async close() {
155
+ this.closed = true;
156
+ for (const entry of this.queue.splice(0)) {
157
+ this.finish(entry, 'cancelled');
158
+ }
159
+ for (const entry of this.entries.values()) {
160
+ if (entry.run.state === 'awaiting-approval') {
161
+ this.finish(entry, 'cancelled');
162
+ }
163
+ }
164
+ for (const entry of this.entries.values()) {
165
+ if (entry.run.state === 'running') {
166
+ entry.abort.abort();
167
+ }
168
+ }
169
+ while (this.running > 0) {
170
+ await new Promise((resolve) => setTimeout(resolve, 10));
171
+ }
172
+ }
173
+ find(owner, id) {
174
+ const entry = this.entries.get(id);
175
+ return entry && this.owns(owner, entry) ? entry : undefined;
176
+ }
177
+ mayDecide(entry, canDecide) {
178
+ return entry.run.approval !== undefined && canDecide(entry.run.account);
179
+ }
180
+ expire(entry) {
181
+ if (entry.run.state === 'awaiting-approval') {
182
+ this.finish(entry, 'rejected', 'nobody approved it in time');
183
+ }
184
+ }
185
+ clearTimer(entry) {
186
+ if (entry.timer) {
187
+ clearTimeout(entry.timer);
188
+ entry.timer = undefined;
189
+ }
190
+ }
191
+ owns(owner, entry) {
192
+ return entry.run.owner.iss === owner.iss && entry.run.owner.sub === owner.sub;
193
+ }
194
+ copy(run) {
195
+ return {
196
+ ...run,
197
+ tasks: [...run.tasks],
198
+ definition: { ...run.definition },
199
+ source: { ...run.source },
200
+ options: JSON.parse(JSON.stringify(run.options)),
201
+ owner: { ...run.owner },
202
+ ...(run.approval ? { approval: JSON.parse(JSON.stringify(run.approval)) } : {}),
203
+ };
204
+ }
205
+ timestamp() {
206
+ return new Date(this.now()).toISOString();
207
+ }
208
+ dispatch() {
209
+ while (this.running < this.maxConcurrent && this.queue.length > 0) {
210
+ const entry = this.queue.shift();
211
+ this.running++;
212
+ this.execute(entry).finally(() => {
213
+ this.running--;
214
+ this.dispatch();
215
+ });
216
+ }
217
+ }
218
+ async execute(entry) {
219
+ entry.run.state = 'running';
220
+ entry.run.startedAt = this.timestamp();
221
+ this.record(entry, { type: 'state', state: 'running' });
222
+ try {
223
+ await this.options.executor.execute(this.copy(entry.run), {
224
+ emit: (event) => this.record(entry, event),
225
+ signal: entry.abort.signal,
226
+ });
227
+ this.finish(entry, entry.abort.signal.aborted ? 'cancelled' : 'succeeded');
228
+ }
229
+ catch (e) {
230
+ this.finish(entry, entry.abort.signal.aborted ? 'cancelled' : 'failed', entry.abort.signal.aborted ? undefined : e instanceof Error ? e.message : String(e));
231
+ }
232
+ }
233
+ finish(entry, state, error) {
234
+ this.clearTimer(entry);
235
+ entry.run.state = state;
236
+ entry.run.finishedAt = this.timestamp();
237
+ if (error) {
238
+ entry.run.error = error;
239
+ }
240
+ this.record(entry, { type: 'state', state, ...(error ? { error } : {}) }, true);
241
+ entry.listeners.clear();
242
+ this.evict();
243
+ }
244
+ record(entry, event, force = false) {
245
+ // an executor that keeps talking can't fill the memory of the server; the
246
+ // 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' });
251
+ }
252
+ return;
253
+ }
254
+ this.push(entry, event);
255
+ }
256
+ push(entry, event) {
257
+ const stored = { ...event, seq: entry.events.length + 1, time: this.timestamp() };
258
+ entry.events.push(stored);
259
+ for (const listener of entry.listeners) {
260
+ listener(stored);
261
+ }
262
+ }
263
+ // Only finished runs are dropped, oldest first.
264
+ evict() {
265
+ const finished = [...this.entries.values()].filter((entry) => FINAL_STATES.includes(entry.run.state));
266
+ for (const entry of finished.slice(0, Math.max(0, finished.length - this.keep))) {
267
+ this.entries.delete(entry.run.id);
268
+ }
269
+ }
270
+ }
271
+ exports.RunRegistry = RunRegistry;
272
+ // Used until a backend is wired in: runs are accepted, recorded and failed with
273
+ // a reason, so the API can be used and tested without pretending to execute.
274
+ exports.unavailableExecutor = {
275
+ async execute() {
276
+ throw new Error('this server has no backend that can execute runs yet');
277
+ },
278
+ };
279
+ //# sourceMappingURL=runs.js.map
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerServerCommand = void 0;
4
+ const commander_1 = require("commander");
5
+ const log_1 = require("../log");
6
+ const server_config_1 = require("./server-config");
7
+ const server_1 = require("./server");
8
+ // Runs this machine as a hammerkit server until interrupted. Like `remote`, it
9
+ // only needs its own config file, not a build file.
10
+ function registerServerCommand(program, environment) {
11
+ program
12
+ .command('server')
13
+ .description('serve this machine to remote runs (experimental)')
14
+ .addOption(new commander_1.Option('-c, --config <file>', 'server config file').env('HAMMERKIT_SERVER_CONFIG'))
15
+ .action(async (options) => {
16
+ try {
17
+ if (!options.config) {
18
+ throw new Error('pass the server config with --config or HAMMERKIT_SERVER_CONFIG');
19
+ }
20
+ const config = await (0, server_config_1.readServerConfig)(environment, options.config);
21
+ const server = await (0, server_1.startServer)(config, environment, {
22
+ log: (line) => environment.stdout.write(`${line}\n`),
23
+ });
24
+ environment.stdout.write(`hammerkit server listening on ${server.url}\n`);
25
+ await new Promise((resolve) => {
26
+ if (environment.abortCtrl.signal.aborted) {
27
+ resolve();
28
+ }
29
+ environment.abortCtrl.signal.addEventListener('abort', () => resolve(), { once: true });
30
+ });
31
+ await server.close();
32
+ environment.stdout.write('hammerkit server stopped\n');
33
+ }
34
+ catch (e) {
35
+ if (e instanceof commander_1.CommanderError) {
36
+ throw e;
37
+ }
38
+ program.error(`Server was not successful: ${(0, log_1.getErrorMessage)(e)}`, { exitCode: 1 });
39
+ }
40
+ });
41
+ }
42
+ exports.registerServerCommand = registerServerCommand;
43
+ //# sourceMappingURL=server-command.js.map