@magland/mochi 0.3.1
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/LICENSE +201 -0
- package/README.md +108 -0
- package/dist/ansi.js +174 -0
- package/dist/api/admin.js +416 -0
- package/dist/api/auth.js +166 -0
- package/dist/api/backup.js +598 -0
- package/dist/api/ci.js +336 -0
- package/dist/api/contents.js +339 -0
- package/dist/api/issues.js +165 -0
- package/dist/api/pulls.js +244 -0
- package/dist/api/releases.js +83 -0
- package/dist/api/repos.js +156 -0
- package/dist/api/write.js +518 -0
- package/dist/api.js +326 -0
- package/dist/assets.js +29 -0
- package/dist/atom.js +32 -0
- package/dist/atomic.js +171 -0
- package/dist/avatar.js +81 -0
- package/dist/browse.js +630 -0
- package/dist/build-info.json +4 -0
- package/dist/ci/actionref.js +86 -0
- package/dist/ci/api.js +829 -0
- package/dist/ci/artifacts.js +201 -0
- package/dist/ci/dispatch.js +30 -0
- package/dist/ci/engine.js +1321 -0
- package/dist/ci/expr.js +526 -0
- package/dist/ci/manual.js +199 -0
- package/dist/ci/present.js +82 -0
- package/dist/ci/protocol.js +6 -0
- package/dist/ci/runners.js +256 -0
- package/dist/ci/runs.js +208 -0
- package/dist/ci/trigger.js +28 -0
- package/dist/ci/views.js +441 -0
- package/dist/ci/wake.js +194 -0
- package/dist/ci/web.js +617 -0
- package/dist/ci/workflow.js +436 -0
- package/dist/cli/admin-cmd.js +324 -0
- package/dist/cli/api-cmd.js +128 -0
- package/dist/cli/backup-cmd.js +1500 -0
- package/dist/cli/exit.js +69 -0
- package/dist/cli/input.js +64 -0
- package/dist/cli/issue-cmd.js +243 -0
- package/dist/cli/output.js +93 -0
- package/dist/cli/parse.js +317 -0
- package/dist/cli/pr-cmd.js +289 -0
- package/dist/cli/release-cmd.js +171 -0
- package/dist/cli/repo-cmd.js +763 -0
- package/dist/cli/repo.js +101 -0
- package/dist/cli/run-cmd.js +438 -0
- package/dist/cli/target.js +54 -0
- package/dist/cli-api.js +84 -0
- package/dist/compare.js +111 -0
- package/dist/config.js +212 -0
- package/dist/credentials.js +235 -0
- package/dist/deploy-cli.js +859 -0
- package/dist/deploy-runner-cli.js +592 -0
- package/dist/diff.js +171 -0
- package/dist/discussion.js +253 -0
- package/dist/egress.js +559 -0
- package/dist/filecache.js +68 -0
- package/dist/find.js +162 -0
- package/dist/forms.js +737 -0
- package/dist/git.js +547 -0
- package/dist/githttp.js +428 -0
- package/dist/html.js +87 -0
- package/dist/icons.js +101 -0
- package/dist/import-cli.js +316 -0
- package/dist/index.js +752 -0
- package/dist/issues.js +308 -0
- package/dist/issueweb.js +447 -0
- package/dist/job-cli.js +197 -0
- package/dist/jobtoken.js +96 -0
- package/dist/languages.js +383 -0
- package/dist/layout.js +100 -0
- package/dist/lfs.js +438 -0
- package/dist/lfsstore.js +425 -0
- package/dist/limit.js +259 -0
- package/dist/logo.js +61 -0
- package/dist/markdown.js +382 -0
- package/dist/migrate.js +334 -0
- package/dist/multipart.js +90 -0
- package/dist/ops.js +869 -0
- package/dist/pagescript.js +465 -0
- package/dist/perms.js +370 -0
- package/dist/pointer.js +55 -0
- package/dist/profile.js +106 -0
- package/dist/pulls.js +320 -0
- package/dist/pullweb.js +461 -0
- package/dist/redirects.js +455 -0
- package/dist/releases.js +435 -0
- package/dist/render.js +233 -0
- package/dist/runner/actions.js +448 -0
- package/dist/runner/client.js +428 -0
- package/dist/runner/context.js +247 -0
- package/dist/runner/docker.js +197 -0
- package/dist/runner/externals.js +175 -0
- package/dist/runner/job.js +290 -0
- package/dist/runner/manual-run.js +272 -0
- package/dist/runner/overrides.js +554 -0
- package/dist/runner/steps.js +571 -0
- package/dist/runner/wake.js +84 -0
- package/dist/runner-cli.js +405 -0
- package/dist/scan.js +231 -0
- package/dist/server.js +424 -0
- package/dist/session.js +267 -0
- package/dist/site.js +259 -0
- package/dist/siteshost.js +94 -0
- package/dist/source.js +90 -0
- package/dist/style.js +1295 -0
- package/dist/themes.js +369 -0
- package/dist/vault.js +442 -0
- package/dist/version.js +88 -0
- package/dist/views.js +1007 -0
- package/dist/web.js +182 -0
- package/dist/webops.js +1402 -0
- package/package.json +71 -0
|
@@ -0,0 +1,1321 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.CiEngine = void 0;
|
|
37
|
+
exports.discoverWorkflows = discoverWorkflows;
|
|
38
|
+
exports.expandMatrix = expandMatrix;
|
|
39
|
+
exports.listWorkflowsAt = listWorkflowsAt;
|
|
40
|
+
const crypto = __importStar(require("crypto"));
|
|
41
|
+
const events_1 = require("events");
|
|
42
|
+
const fs = __importStar(require("fs"));
|
|
43
|
+
const path = __importStar(require("path"));
|
|
44
|
+
const git_1 = require("../git");
|
|
45
|
+
const jobtoken_1 = require("../jobtoken");
|
|
46
|
+
const perms_1 = require("../perms");
|
|
47
|
+
const scan_1 = require("../scan");
|
|
48
|
+
const vault_1 = require("../vault");
|
|
49
|
+
const expr_1 = require("./expr");
|
|
50
|
+
const manual_1 = require("./manual");
|
|
51
|
+
const runs_1 = require("./runs");
|
|
52
|
+
const workflow_1 = require("./workflow");
|
|
53
|
+
// The CI engine: the server side of workflow execution. It turns pushes and
|
|
54
|
+
// manual dispatches into planned runs, decides which jobs are runnable,
|
|
55
|
+
// leases them to runners, and folds their reports back into run state. It
|
|
56
|
+
// never executes anything itself: execution belongs to `mochi runner run`
|
|
57
|
+
// on a machine with Docker.
|
|
58
|
+
//
|
|
59
|
+
// The engine keeps an in-memory index of active (queued or running) runs,
|
|
60
|
+
// rebuilt from the vault at startup; the files under <repo>.runs/ remain the
|
|
61
|
+
// durable state, as everything else in a vault.
|
|
62
|
+
const ZERO_SHA = /^0+$/;
|
|
63
|
+
const MAX_WORKFLOW_SIZE = 512 * 1024;
|
|
64
|
+
const LEASE_MS = 90 * 1000;
|
|
65
|
+
const MAX_ATTEMPTS = 3;
|
|
66
|
+
const MAX_LOG_BYTES = 32 * 1024 * 1024;
|
|
67
|
+
function runKey(collection, repo, n) {
|
|
68
|
+
return `${collection}/${repo}#${n}`;
|
|
69
|
+
}
|
|
70
|
+
function nowIso() {
|
|
71
|
+
return new Date().toISOString();
|
|
72
|
+
}
|
|
73
|
+
// A job lease is a bearer token like any other, so it is compared the way
|
|
74
|
+
// runner tokens are in runners.ts rather than with `!==`.
|
|
75
|
+
function sameToken(a, b) {
|
|
76
|
+
const x = Buffer.from(a);
|
|
77
|
+
const y = Buffer.from(b);
|
|
78
|
+
return x.length === y.length && crypto.timingSafeEqual(x, y);
|
|
79
|
+
}
|
|
80
|
+
// ---- workflow discovery ----
|
|
81
|
+
// Workflows live in .mochi/workflows and .github/workflows; both are
|
|
82
|
+
// collected, and a file in .mochi/workflows shadows one with the same
|
|
83
|
+
// basename in .github/workflows, so a repository can adapt a single workflow
|
|
84
|
+
// for mochi without forking the rest.
|
|
85
|
+
async function discoverWorkflows(repo, sha) {
|
|
86
|
+
const dirs = ['.github/workflows', '.mochi/workflows'];
|
|
87
|
+
const byBase = new Map();
|
|
88
|
+
for (const dir of dirs) {
|
|
89
|
+
let entries;
|
|
90
|
+
try {
|
|
91
|
+
entries = await repo.listTree(sha, dir);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
for (const e of entries) {
|
|
97
|
+
if (e.type !== 'blob')
|
|
98
|
+
continue;
|
|
99
|
+
if (!/\.(yml|yaml)$/i.test(e.name))
|
|
100
|
+
continue;
|
|
101
|
+
byBase.set(e.name, { path: `${dir}/${e.name}` });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const [, { path: p }] of [...byBase.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
106
|
+
let source;
|
|
107
|
+
try {
|
|
108
|
+
const buf = await repo.catBlob(sha, p);
|
|
109
|
+
if (buf.length > MAX_WORKFLOW_SIZE) {
|
|
110
|
+
out.push({ path: p, source: '', error: 'workflow file is too large' });
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
source = buf.toString('utf8');
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
out.push({ path: p, source, workflow: (0, workflow_1.parseWorkflow)(source) });
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
out.push({ path: p, source, error: e instanceof workflow_1.WorkflowError ? e.message : String(e) });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
function comboMatchesFilter(combo, filter, keys) {
|
|
128
|
+
return Object.entries(filter).every(([k, v]) => {
|
|
129
|
+
if (!keys.includes(k))
|
|
130
|
+
return true;
|
|
131
|
+
return JSON.stringify(combo[k]) === JSON.stringify(v);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
function expandMatrix(matrix) {
|
|
135
|
+
if (matrix === undefined || matrix === null)
|
|
136
|
+
return null;
|
|
137
|
+
if (typeof matrix !== 'object' || Array.isArray(matrix)) {
|
|
138
|
+
throw new workflow_1.WorkflowError('strategy.matrix must be a map');
|
|
139
|
+
}
|
|
140
|
+
const m = matrix;
|
|
141
|
+
const include = Array.isArray(m.include) ? m.include : [];
|
|
142
|
+
const exclude = Array.isArray(m.exclude) ? m.exclude : [];
|
|
143
|
+
const baseKeys = Object.keys(m).filter((k) => k !== 'include' && k !== 'exclude');
|
|
144
|
+
let combos = [{}];
|
|
145
|
+
for (const k of baseKeys) {
|
|
146
|
+
const values = m[k];
|
|
147
|
+
if (!Array.isArray(values))
|
|
148
|
+
throw new workflow_1.WorkflowError(`matrix key ${k} must be a list`);
|
|
149
|
+
const next = [];
|
|
150
|
+
for (const c of combos)
|
|
151
|
+
for (const v of values)
|
|
152
|
+
next.push({ ...c, [k]: v });
|
|
153
|
+
combos = next;
|
|
154
|
+
}
|
|
155
|
+
if (baseKeys.length === 0)
|
|
156
|
+
combos = [];
|
|
157
|
+
for (const ex of exclude) {
|
|
158
|
+
combos = combos.filter((c) => !comboMatchesFilter(c, ex, baseKeys));
|
|
159
|
+
}
|
|
160
|
+
for (const inc of include) {
|
|
161
|
+
// An include entry extends every combination it does not conflict with on
|
|
162
|
+
// an original matrix key; if it extends none, it becomes a new one.
|
|
163
|
+
let extended = false;
|
|
164
|
+
for (const c of combos) {
|
|
165
|
+
const conflicts = baseKeys.some((k) => k in inc && k in c && JSON.stringify(c[k]) !== JSON.stringify(inc[k]));
|
|
166
|
+
if (!conflicts) {
|
|
167
|
+
for (const [k, v] of Object.entries(inc)) {
|
|
168
|
+
if (!(k in c) || !baseKeys.includes(k))
|
|
169
|
+
c[k] = v;
|
|
170
|
+
}
|
|
171
|
+
extended = true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (!extended)
|
|
175
|
+
combos.push({ ...inc });
|
|
176
|
+
}
|
|
177
|
+
if (combos.length === 0)
|
|
178
|
+
throw new workflow_1.WorkflowError('matrix expanded to zero combinations');
|
|
179
|
+
return combos;
|
|
180
|
+
}
|
|
181
|
+
class CiEngine {
|
|
182
|
+
root;
|
|
183
|
+
active = new Map();
|
|
184
|
+
emitter = new events_1.EventEmitter();
|
|
185
|
+
retention;
|
|
186
|
+
// Retention arrives as a getter rather than a value so that an edit to
|
|
187
|
+
// config.json takes effect without a restart, and required rather than
|
|
188
|
+
// optional so that the vault's defaults in config.ts are the only answer to
|
|
189
|
+
// what the limits are. A fallback here would be a second answer, free to
|
|
190
|
+
// drift from the first, which is what it had done.
|
|
191
|
+
constructor(root, retention) {
|
|
192
|
+
this.root = root;
|
|
193
|
+
this.retention = retention;
|
|
194
|
+
this.emitter.setMaxListeners(100);
|
|
195
|
+
this.loadActiveRuns();
|
|
196
|
+
const t = setInterval(() => this.sweepLeases(), 30 * 1000);
|
|
197
|
+
t.unref();
|
|
198
|
+
}
|
|
199
|
+
loadActiveRuns() {
|
|
200
|
+
for (const { name: collection } of (0, scan_1.listCollections)(this.root)) {
|
|
201
|
+
for (const repoDir of (0, scan_1.listRepoDirs)(this.root, collection)) {
|
|
202
|
+
const repoName = repoDir.replace(/\.git$/, '');
|
|
203
|
+
const base = (0, runs_1.runsDir)(this.root, collection, repoName);
|
|
204
|
+
if (!base || !fs.existsSync(base))
|
|
205
|
+
continue;
|
|
206
|
+
for (const run of (0, runs_1.listRuns)(this.root, collection, repoName)) {
|
|
207
|
+
if (run.status === 'completed')
|
|
208
|
+
continue;
|
|
209
|
+
this.adopt(collection, repoName, run);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const n = this.active.size;
|
|
214
|
+
if (n > 0)
|
|
215
|
+
console.log(`CI: resumed ${n} active run${n === 1 ? '' : 's'}`);
|
|
216
|
+
}
|
|
217
|
+
adopt(collection, repo, run) {
|
|
218
|
+
const jobs = new Map();
|
|
219
|
+
for (const id of run.jobs) {
|
|
220
|
+
const j = (0, runs_1.readJob)(this.root, collection, repo, run.number, id);
|
|
221
|
+
if (j)
|
|
222
|
+
jobs.set(id, j);
|
|
223
|
+
}
|
|
224
|
+
const ar = { collection, repo, run, jobs };
|
|
225
|
+
this.active.set(runKey(collection, repo, run.number), ar);
|
|
226
|
+
return ar;
|
|
227
|
+
}
|
|
228
|
+
saveJob(ar, job) {
|
|
229
|
+
(0, runs_1.writeJob)(this.root, ar.collection, ar.repo, ar.run.number, job);
|
|
230
|
+
}
|
|
231
|
+
saveRun(ar) {
|
|
232
|
+
(0, runs_1.writeRun)(this.root, ar.collection, ar.repo, ar.run);
|
|
233
|
+
}
|
|
234
|
+
wake() {
|
|
235
|
+
this.emitter.emit('wake');
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Call `fn` whenever the set of runnable jobs may have grown.
|
|
239
|
+
*
|
|
240
|
+
* This is the same signal a waiting runner is woken by, offered to a caller
|
|
241
|
+
* that is not a runner: the dispatcher which starts runners that are not
|
|
242
|
+
* connected needs to hear about a job the moment it is queued, and polling
|
|
243
|
+
* for that would be a poor substitute for an event the engine already
|
|
244
|
+
* emits. Returns the way to stop listening.
|
|
245
|
+
*/
|
|
246
|
+
onQueueChanged(fn) {
|
|
247
|
+
this.emitter.on('wake', fn);
|
|
248
|
+
return () => this.emitter.removeListener('wake', fn);
|
|
249
|
+
}
|
|
250
|
+
// ---- event intake ----
|
|
251
|
+
async handlePush(repo, ev) {
|
|
252
|
+
if (ZERO_SHA.test(ev.after))
|
|
253
|
+
return; // ref deletion
|
|
254
|
+
const discovered = await discoverWorkflows(repo, ev.after);
|
|
255
|
+
if (discovered.length === 0)
|
|
256
|
+
return;
|
|
257
|
+
// Changed paths for paths: filters; null for a brand-new ref, in which
|
|
258
|
+
// case a paths filter counts as matched, as on GitHub.
|
|
259
|
+
let changedPaths = null;
|
|
260
|
+
const needsPaths = discovered.some((d) => d.workflow?.on.push && (d.workflow.on.push.paths || d.workflow.on.push.pathsIgnore));
|
|
261
|
+
if (needsPaths && !ZERO_SHA.test(ev.before)) {
|
|
262
|
+
try {
|
|
263
|
+
const out = (await (0, git_1.execGit)(repo.dir, ['diff', '--name-only', `${ev.before}..${ev.after}`])).toString('utf8');
|
|
264
|
+
changedPaths = out.split('\n').filter((l) => l.length > 0);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
changedPaths = null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const payload = await this.pushPayload(repo, ev);
|
|
271
|
+
for (const d of discovered) {
|
|
272
|
+
if (d.error) {
|
|
273
|
+
// Only surface a broken workflow file when it plausibly would have
|
|
274
|
+
// run: on a push, that is any push (we cannot know its filters).
|
|
275
|
+
this.createErrorRun(repo, d.path, 'push', ev, payload, d.error);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const wf = d.workflow;
|
|
279
|
+
if (!wf.on.push)
|
|
280
|
+
continue;
|
|
281
|
+
if (!(0, workflow_1.pushMatches)(wf.on.push, ev.ref, changedPaths))
|
|
282
|
+
continue;
|
|
283
|
+
this.planRun(repo, d.path, wf, {
|
|
284
|
+
event: 'push',
|
|
285
|
+
ref: ev.ref,
|
|
286
|
+
sha: ev.after,
|
|
287
|
+
actor: ev.actor,
|
|
288
|
+
payload,
|
|
289
|
+
inputs: undefined,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
async handleDispatch(repo, workflowPath, ref, sha, actor, inputs) {
|
|
294
|
+
const discovered = await discoverWorkflows(repo, sha);
|
|
295
|
+
const d = discovered.find((x) => x.path === workflowPath);
|
|
296
|
+
if (!d)
|
|
297
|
+
throw new workflow_1.WorkflowError(`no workflow at ${workflowPath} on this ref`);
|
|
298
|
+
const payload = {
|
|
299
|
+
ref,
|
|
300
|
+
inputs,
|
|
301
|
+
repository: await this.repositoryPayload(repo),
|
|
302
|
+
sender: { login: actor },
|
|
303
|
+
};
|
|
304
|
+
if (d.error) {
|
|
305
|
+
return this.createErrorRun(repo, d.path, 'workflow_dispatch', { ref, actor, sha }, payload, d.error);
|
|
306
|
+
}
|
|
307
|
+
const wf = d.workflow;
|
|
308
|
+
if (!wf.on.workflowDispatch) {
|
|
309
|
+
throw new workflow_1.WorkflowError('this workflow has no workflow_dispatch trigger');
|
|
310
|
+
}
|
|
311
|
+
return this.planRun(repo, d.path, wf, {
|
|
312
|
+
event: 'workflow_dispatch',
|
|
313
|
+
ref,
|
|
314
|
+
sha,
|
|
315
|
+
actor,
|
|
316
|
+
payload,
|
|
317
|
+
inputs,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
async repositoryPayload(repo) {
|
|
321
|
+
const branches = await repo.listRefs('heads');
|
|
322
|
+
const def = await repo.defaultBranch(branches);
|
|
323
|
+
return {
|
|
324
|
+
name: repo.name,
|
|
325
|
+
full_name: `${repo.collection}/${repo.name}`,
|
|
326
|
+
owner: { name: repo.collection, login: repo.collection },
|
|
327
|
+
default_branch: def ?? 'main',
|
|
328
|
+
private: (0, perms_1.repoIsPrivate)(repo.dir),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
async pushPayload(repo, ev) {
|
|
332
|
+
const head = await repo.commit(ev.after);
|
|
333
|
+
return {
|
|
334
|
+
ref: ev.ref,
|
|
335
|
+
before: ev.before,
|
|
336
|
+
after: ev.after,
|
|
337
|
+
repository: await this.repositoryPayload(repo),
|
|
338
|
+
pusher: { name: ev.actor },
|
|
339
|
+
head_commit: head
|
|
340
|
+
? {
|
|
341
|
+
id: head.sha,
|
|
342
|
+
message: head.message,
|
|
343
|
+
timestamp: head.date,
|
|
344
|
+
author: { name: head.author, email: head.email },
|
|
345
|
+
}
|
|
346
|
+
: null,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
// ---- planning ----
|
|
350
|
+
createErrorRun(repo, workflowPath, event, ev, payload, error) {
|
|
351
|
+
const sha = ev.sha ?? ev.after ?? '';
|
|
352
|
+
const run = (0, runs_1.createRun)(this.root, repo.collection, repo.name, (n) => ({
|
|
353
|
+
run: {
|
|
354
|
+
number: n,
|
|
355
|
+
workflowPath,
|
|
356
|
+
workflowName: path.basename(workflowPath),
|
|
357
|
+
event,
|
|
358
|
+
ref: ev.ref,
|
|
359
|
+
refName: ev.ref.replace(/^refs\/(heads|tags)\//, ''),
|
|
360
|
+
sha,
|
|
361
|
+
actor: ev.actor,
|
|
362
|
+
message: '',
|
|
363
|
+
payload,
|
|
364
|
+
status: 'completed',
|
|
365
|
+
conclusion: 'failure',
|
|
366
|
+
error,
|
|
367
|
+
createdAt: nowIso(),
|
|
368
|
+
completedAt: nowIso(),
|
|
369
|
+
jobs: [],
|
|
370
|
+
},
|
|
371
|
+
jobs: [],
|
|
372
|
+
}));
|
|
373
|
+
(0, runs_1.pruneRuns)(this.root, repo.collection, repo.name, this.retention());
|
|
374
|
+
return run;
|
|
375
|
+
}
|
|
376
|
+
planRun(repo, workflowPath, wf, ev) {
|
|
377
|
+
const refName = ev.ref.replace(/^refs\/(heads|tags)\//, '');
|
|
378
|
+
const message = typeof ev.payload === 'object' && ev.payload !== null
|
|
379
|
+
? String(ev.payload.head_commit?.message ?? '').split('\n')[0]
|
|
380
|
+
: '';
|
|
381
|
+
const run = (0, runs_1.createRun)(this.root, repo.collection, repo.name, (n) => {
|
|
382
|
+
const record = {
|
|
383
|
+
number: n,
|
|
384
|
+
workflowPath,
|
|
385
|
+
workflowName: wf.name ?? path.basename(workflowPath),
|
|
386
|
+
event: ev.event,
|
|
387
|
+
ref: ev.ref,
|
|
388
|
+
refName,
|
|
389
|
+
sha: ev.sha,
|
|
390
|
+
actor: ev.actor,
|
|
391
|
+
message,
|
|
392
|
+
payload: ev.payload,
|
|
393
|
+
inputs: ev.inputs,
|
|
394
|
+
status: 'queued',
|
|
395
|
+
createdAt: nowIso(),
|
|
396
|
+
jobs: [],
|
|
397
|
+
};
|
|
398
|
+
const github = this.githubContext(record, repo.collection, repo.name, null, '');
|
|
399
|
+
// The concurrency group may be an expression; a failure to evaluate it
|
|
400
|
+
// falls back to the literal text rather than failing the run.
|
|
401
|
+
if (wf.concurrency) {
|
|
402
|
+
const env = { contexts: { github, inputs: (ev.inputs ?? {}), vars: {} } };
|
|
403
|
+
let group = wf.concurrency.group;
|
|
404
|
+
let cancel = false;
|
|
405
|
+
try {
|
|
406
|
+
group = (0, expr_1.render)(wf.concurrency.group, env);
|
|
407
|
+
cancel = (0, expr_1.evalCondition)(wf.concurrency.cancelInProgress, env);
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
// keep the literal group
|
|
411
|
+
}
|
|
412
|
+
record.concurrencyGroup = group;
|
|
413
|
+
record.cancelInProgress = cancel;
|
|
414
|
+
}
|
|
415
|
+
const jobs = [];
|
|
416
|
+
for (const wj of wf.jobs) {
|
|
417
|
+
for (const j of this.planJob(wj, wf, github, ev.inputs)) {
|
|
418
|
+
jobs.push(j);
|
|
419
|
+
record.jobs.push(j.id);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return { run: record, jobs };
|
|
423
|
+
});
|
|
424
|
+
const ar = this.adopt(repo.collection, repo.name, run);
|
|
425
|
+
this.applyConcurrency(ar);
|
|
426
|
+
this.advance(ar);
|
|
427
|
+
(0, runs_1.pruneRuns)(this.root, repo.collection, repo.name, this.retention());
|
|
428
|
+
this.wake();
|
|
429
|
+
return ar.run;
|
|
430
|
+
}
|
|
431
|
+
planJob(wj, wf, github, inputs) {
|
|
432
|
+
const baseEnv = { ...wf.env, ...wj.env };
|
|
433
|
+
const mk = (id, name, matrix, idx, total) => ({
|
|
434
|
+
id,
|
|
435
|
+
key: wj.key,
|
|
436
|
+
name,
|
|
437
|
+
needs: wj.needs,
|
|
438
|
+
runsOn: wj.runsOn,
|
|
439
|
+
if: wj.if,
|
|
440
|
+
matrix,
|
|
441
|
+
strategy: wj.strategy ? { failFast: wj.strategy.failFast, jobIndex: idx, jobTotal: total } : null,
|
|
442
|
+
env: baseEnv,
|
|
443
|
+
defaults: wj.defaults,
|
|
444
|
+
steps: wj.steps,
|
|
445
|
+
outputsTemplate: wj.outputs,
|
|
446
|
+
timeoutMinutes: wj.timeoutMinutes,
|
|
447
|
+
continueOnError: wj.continueOnError,
|
|
448
|
+
status: 'queued',
|
|
449
|
+
outputs: {},
|
|
450
|
+
stepStates: [],
|
|
451
|
+
attempts: 0,
|
|
452
|
+
});
|
|
453
|
+
const failPlanned = (id, name, message) => {
|
|
454
|
+
const j = mk(id, name, null, 0, 1);
|
|
455
|
+
j.status = 'completed';
|
|
456
|
+
j.conclusion = 'failure';
|
|
457
|
+
j.error = message;
|
|
458
|
+
j.completedAt = nowIso();
|
|
459
|
+
return j;
|
|
460
|
+
};
|
|
461
|
+
if (wj.unsupported)
|
|
462
|
+
return [failPlanned(wj.key, wj.key, wj.unsupported)];
|
|
463
|
+
let combos = null;
|
|
464
|
+
if (wj.strategy?.matrix !== undefined && wj.strategy?.matrix !== null) {
|
|
465
|
+
// A dynamic matrix (an expression, typically fromJSON over needs
|
|
466
|
+
// outputs) would have to be expanded when the job starts; not yet.
|
|
467
|
+
if (typeof wj.strategy.matrix === 'string') {
|
|
468
|
+
return [failPlanned(wj.key, wj.key, 'dynamic matrix expressions are not supported yet')];
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
combos = expandMatrix(wj.strategy.matrix);
|
|
472
|
+
}
|
|
473
|
+
catch (e) {
|
|
474
|
+
return [failPlanned(wj.key, wj.key, e instanceof Error ? e.message : String(e))];
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const jobName = (matrix) => {
|
|
478
|
+
const env = {
|
|
479
|
+
contexts: {
|
|
480
|
+
github,
|
|
481
|
+
matrix: matrix,
|
|
482
|
+
inputs: (inputs ?? {}),
|
|
483
|
+
vars: {},
|
|
484
|
+
strategy: {},
|
|
485
|
+
needs: {},
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
if (wj.name) {
|
|
489
|
+
try {
|
|
490
|
+
return (0, expr_1.render)(wj.name, env);
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
return wj.name;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (matrix)
|
|
497
|
+
return `${wj.key} (${Object.values(matrix).map(String).join(', ')})`;
|
|
498
|
+
return wj.key;
|
|
499
|
+
};
|
|
500
|
+
const resolveRunsOn = (j, matrix) => {
|
|
501
|
+
const env = {
|
|
502
|
+
contexts: { github, matrix: matrix, inputs: (inputs ?? {}), vars: {} },
|
|
503
|
+
};
|
|
504
|
+
try {
|
|
505
|
+
j.runsOn = j.runsOn.map((r) => (0, expr_1.render)(r, env)).filter((r) => r !== '');
|
|
506
|
+
if (j.runsOn.length === 0)
|
|
507
|
+
throw new Error('runs-on resolved to nothing');
|
|
508
|
+
}
|
|
509
|
+
catch (e) {
|
|
510
|
+
j.status = 'completed';
|
|
511
|
+
j.conclusion = 'failure';
|
|
512
|
+
j.error = `could not resolve runs-on: ${e instanceof Error ? e.message : String(e)}`;
|
|
513
|
+
j.completedAt = nowIso();
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
if (!combos) {
|
|
517
|
+
const j = mk(wj.key, jobName(null), null, 0, 1);
|
|
518
|
+
resolveRunsOn(j, null);
|
|
519
|
+
return [j];
|
|
520
|
+
}
|
|
521
|
+
return combos.map((c, i) => {
|
|
522
|
+
const j = mk(`${wj.key}-${i + 1}`, jobName(c), c, i, combos.length);
|
|
523
|
+
resolveRunsOn(j, c);
|
|
524
|
+
return j;
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
// ---- the github context ----
|
|
528
|
+
githubContext(run, collection, repo, jobKey, serverUrl) {
|
|
529
|
+
const isTag = run.ref.startsWith('refs/tags/');
|
|
530
|
+
return {
|
|
531
|
+
event_name: run.event,
|
|
532
|
+
event: run.payload,
|
|
533
|
+
ref: run.ref,
|
|
534
|
+
ref_name: run.refName,
|
|
535
|
+
ref_type: isTag ? 'tag' : 'branch',
|
|
536
|
+
ref_protected: false,
|
|
537
|
+
sha: run.sha,
|
|
538
|
+
repository: `${collection}/${repo}`,
|
|
539
|
+
repository_owner: collection,
|
|
540
|
+
workflow: run.workflowName,
|
|
541
|
+
workflow_ref: `${collection}/${repo}/${run.workflowPath}@${run.ref}`,
|
|
542
|
+
run_id: String(run.number),
|
|
543
|
+
run_number: run.number,
|
|
544
|
+
run_attempt: '1',
|
|
545
|
+
actor: run.actor,
|
|
546
|
+
triggering_actor: run.actor,
|
|
547
|
+
job: jobKey,
|
|
548
|
+
server_url: serverUrl,
|
|
549
|
+
api_url: serverUrl ? `${serverUrl}/api` : '',
|
|
550
|
+
workspace: '',
|
|
551
|
+
action: '',
|
|
552
|
+
token: '',
|
|
553
|
+
head_ref: '',
|
|
554
|
+
base_ref: '',
|
|
555
|
+
retention_days: '90',
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
// ---- concurrency ----
|
|
559
|
+
applyConcurrency(ar) {
|
|
560
|
+
const group = ar.run.concurrencyGroup;
|
|
561
|
+
if (!group)
|
|
562
|
+
return;
|
|
563
|
+
for (const other of this.active.values()) {
|
|
564
|
+
if (other === ar)
|
|
565
|
+
continue;
|
|
566
|
+
if (other.collection !== ar.collection || other.repo !== ar.repo)
|
|
567
|
+
continue;
|
|
568
|
+
if (other.run.concurrencyGroup !== group)
|
|
569
|
+
continue;
|
|
570
|
+
if (other.run.number >= ar.run.number)
|
|
571
|
+
continue;
|
|
572
|
+
if (ar.run.cancelInProgress) {
|
|
573
|
+
this.cancelActive(other);
|
|
574
|
+
}
|
|
575
|
+
else if (other.run.status === 'queued') {
|
|
576
|
+
// Without cancel-in-progress, at most one run waits behind the
|
|
577
|
+
// running one; an older still-queued run is superseded.
|
|
578
|
+
this.cancelActive(other);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
// Whether this run must hold its jobs while an older run in its group runs.
|
|
583
|
+
heldByConcurrency(ar) {
|
|
584
|
+
const group = ar.run.concurrencyGroup;
|
|
585
|
+
if (!group)
|
|
586
|
+
return false;
|
|
587
|
+
for (const other of this.active.values()) {
|
|
588
|
+
if (other === ar)
|
|
589
|
+
continue;
|
|
590
|
+
if (other.collection !== ar.collection || other.repo !== ar.repo)
|
|
591
|
+
continue;
|
|
592
|
+
if (other.run.concurrencyGroup !== group)
|
|
593
|
+
continue;
|
|
594
|
+
if (other.run.number < ar.run.number && !other.run.cancelRequested)
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
// ---- needs and status ----
|
|
600
|
+
// needs refers to job keys; a matrix job contributes one record per
|
|
601
|
+
// combination, and the key's result is the worst of them. A failed job
|
|
602
|
+
// whose continue-on-error evaluated true counts as success here and for
|
|
603
|
+
// the run's conclusion, while keeping its own conclusion of failure.
|
|
604
|
+
effectiveConclusion(ar, j) {
|
|
605
|
+
if (j.status !== 'completed')
|
|
606
|
+
return undefined;
|
|
607
|
+
if (j.conclusion === 'failure' && j.continueOnError !== undefined) {
|
|
608
|
+
try {
|
|
609
|
+
const env = {
|
|
610
|
+
contexts: {
|
|
611
|
+
github: this.githubContext(ar.run, ar.collection, ar.repo, j.key, ''),
|
|
612
|
+
matrix: j.matrix,
|
|
613
|
+
inputs: (ar.run.inputs ?? {}),
|
|
614
|
+
vars: {},
|
|
615
|
+
},
|
|
616
|
+
};
|
|
617
|
+
if ((0, expr_1.evalCondition)(j.continueOnError, env))
|
|
618
|
+
return 'success';
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
// treat as not continued
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return j.conclusion;
|
|
625
|
+
}
|
|
626
|
+
needsFor(ar, needKeys) {
|
|
627
|
+
const out = {};
|
|
628
|
+
for (const key of needKeys) {
|
|
629
|
+
const members = [...ar.jobs.values()].filter((j) => j.key === key);
|
|
630
|
+
let result = 'success';
|
|
631
|
+
const outputs = {};
|
|
632
|
+
for (const m of members) {
|
|
633
|
+
const c = this.effectiveConclusion(ar, m);
|
|
634
|
+
if (c === 'failure')
|
|
635
|
+
result = 'failure';
|
|
636
|
+
else if (c === 'cancelled' && result !== 'failure')
|
|
637
|
+
result = 'cancelled';
|
|
638
|
+
else if (c === 'skipped' && result === 'success')
|
|
639
|
+
result = 'skipped';
|
|
640
|
+
Object.assign(outputs, m.outputs);
|
|
641
|
+
}
|
|
642
|
+
out[key] = { result, outputs };
|
|
643
|
+
}
|
|
644
|
+
return out;
|
|
645
|
+
}
|
|
646
|
+
needsDone(ar, j) {
|
|
647
|
+
return j.needs.every((key) => [...ar.jobs.values()].filter((x) => x.key === key).every((x) => x.status === 'completed'));
|
|
648
|
+
}
|
|
649
|
+
// ---- advancing a run ----
|
|
650
|
+
// Called whenever anything about a run changes: evaluates job conditions
|
|
651
|
+
// whose needs have completed, cascades skips and cancellations, and closes
|
|
652
|
+
// the run when every job is done. Runners are woken separately.
|
|
653
|
+
advance(ar) {
|
|
654
|
+
let changed = true;
|
|
655
|
+
while (changed) {
|
|
656
|
+
changed = false;
|
|
657
|
+
for (const j of ar.jobs.values()) {
|
|
658
|
+
if (j.status !== 'queued')
|
|
659
|
+
continue;
|
|
660
|
+
if (ar.run.cancelRequested) {
|
|
661
|
+
this.completeJob(ar, j, 'cancelled');
|
|
662
|
+
changed = true;
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (!this.needsDone(ar, j))
|
|
666
|
+
continue;
|
|
667
|
+
const needs = this.needsFor(ar, j.needs);
|
|
668
|
+
const results = Object.values(needs).map((n) => n.result);
|
|
669
|
+
const allSucceeded = results.every((r) => r === 'success');
|
|
670
|
+
const anyFailed = results.some((r) => r === 'failure');
|
|
671
|
+
let ok;
|
|
672
|
+
try {
|
|
673
|
+
ok = this.jobCondition(ar, j, needs, { allSucceeded, anyFailed });
|
|
674
|
+
}
|
|
675
|
+
catch (e) {
|
|
676
|
+
j.error = `error evaluating if: ${e instanceof Error ? e.message : String(e)}`;
|
|
677
|
+
this.completeJob(ar, j, 'failure');
|
|
678
|
+
changed = true;
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
if (!ok) {
|
|
682
|
+
this.completeJob(ar, j, 'skipped');
|
|
683
|
+
changed = true;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
this.maybeCompleteRun(ar);
|
|
688
|
+
}
|
|
689
|
+
jobCondition(ar, j, needs, st) {
|
|
690
|
+
if (j.if === undefined)
|
|
691
|
+
return st.allSucceeded;
|
|
692
|
+
const env = {
|
|
693
|
+
contexts: {
|
|
694
|
+
github: this.githubContext(ar.run, ar.collection, ar.repo, j.key, ''),
|
|
695
|
+
needs: needs,
|
|
696
|
+
inputs: (ar.run.inputs ?? {}),
|
|
697
|
+
vars: {},
|
|
698
|
+
},
|
|
699
|
+
functions: {
|
|
700
|
+
success: () => st.allSucceeded,
|
|
701
|
+
failure: () => st.anyFailed,
|
|
702
|
+
cancelled: () => ar.run.cancelRequested === true,
|
|
703
|
+
always: () => true,
|
|
704
|
+
},
|
|
705
|
+
};
|
|
706
|
+
return (0, expr_1.evalCondition)(j.if, env);
|
|
707
|
+
}
|
|
708
|
+
completeJob(ar, j, conclusion) {
|
|
709
|
+
j.status = 'completed';
|
|
710
|
+
j.conclusion = conclusion;
|
|
711
|
+
j.completedAt = nowIso();
|
|
712
|
+
delete j.lease;
|
|
713
|
+
this.saveJob(ar, j);
|
|
714
|
+
if (conclusion === 'failure' && j.strategy?.failFast && j.matrix) {
|
|
715
|
+
// fail-fast: cancel queued siblings; running ones learn on heartbeat.
|
|
716
|
+
for (const sib of ar.jobs.values()) {
|
|
717
|
+
if (sib.key === j.key && sib.id !== j.id && sib.status === 'queued') {
|
|
718
|
+
this.completeJob(ar, sib, 'cancelled');
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
maybeCompleteRun(ar) {
|
|
724
|
+
const jobs = [...ar.jobs.values()];
|
|
725
|
+
if (!jobs.every((j) => j.status === 'completed')) {
|
|
726
|
+
this.saveRun(ar);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
ar.run.status = 'completed';
|
|
730
|
+
const effective = jobs.map((j) => ({ j, c: this.effectiveConclusion(ar, j) }));
|
|
731
|
+
if (ar.run.cancelRequested && !effective.some(({ c }) => c === 'failure')) {
|
|
732
|
+
ar.run.conclusion = 'cancelled';
|
|
733
|
+
}
|
|
734
|
+
else if (effective.some(({ c }) => c === 'failure')) {
|
|
735
|
+
ar.run.conclusion = 'failure';
|
|
736
|
+
}
|
|
737
|
+
else if (effective.some(({ c }) => c === 'cancelled')) {
|
|
738
|
+
ar.run.conclusion = 'cancelled';
|
|
739
|
+
}
|
|
740
|
+
else if (effective.every(({ c }) => c === 'skipped')) {
|
|
741
|
+
ar.run.conclusion = 'skipped';
|
|
742
|
+
}
|
|
743
|
+
else {
|
|
744
|
+
ar.run.conclusion = 'success';
|
|
745
|
+
}
|
|
746
|
+
ar.run.completedAt = nowIso();
|
|
747
|
+
this.saveRun(ar);
|
|
748
|
+
this.active.delete(runKey(ar.collection, ar.repo, ar.run.number));
|
|
749
|
+
// A held run in the same group can now proceed.
|
|
750
|
+
this.wake();
|
|
751
|
+
}
|
|
752
|
+
// ---- dispatch to runners ----
|
|
753
|
+
runnableJob(labels, allow) {
|
|
754
|
+
const labelSet = new Set(labels);
|
|
755
|
+
const allowed = (ar) => allow.some((g) => (0, vault_1.globMatch)(g, `${ar.collection}/${ar.repo}`));
|
|
756
|
+
const runs = [...this.active.values()].sort((a, b) => a.run.number - b.run.number);
|
|
757
|
+
for (const ar of runs) {
|
|
758
|
+
if (ar.run.cancelRequested)
|
|
759
|
+
continue;
|
|
760
|
+
if (!allowed(ar))
|
|
761
|
+
continue;
|
|
762
|
+
if (this.heldByConcurrency(ar))
|
|
763
|
+
continue;
|
|
764
|
+
for (const id of ar.run.jobs) {
|
|
765
|
+
const j = ar.jobs.get(id);
|
|
766
|
+
if (!j || j.status !== 'queued')
|
|
767
|
+
continue;
|
|
768
|
+
// A manual job is never handed to a registered runner, whatever its
|
|
769
|
+
// other labels say: `runs-on: [manual, ubuntu-latest]` names the image,
|
|
770
|
+
// not a second way to be taken.
|
|
771
|
+
if ((0, manual_1.isManualJob)(j.runsOn))
|
|
772
|
+
continue;
|
|
773
|
+
if (!this.needsDone(ar, j))
|
|
774
|
+
continue;
|
|
775
|
+
if (!j.runsOn.some((l) => labelSet.has(l)))
|
|
776
|
+
continue;
|
|
777
|
+
// Ready and condition-checked: advance() already skipped false ifs.
|
|
778
|
+
return { ar, job: j };
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
// Try to lease a job now; returns null when nothing matches.
|
|
784
|
+
acquire(runnerName, labels, allow, serverUrl) {
|
|
785
|
+
const found = this.runnableJob(labels, allow);
|
|
786
|
+
if (!found)
|
|
787
|
+
return null;
|
|
788
|
+
return this.leaseJob(found.ar, found.job, runnerName, serverUrl);
|
|
789
|
+
}
|
|
790
|
+
// Lease one chosen job to a runner (registered or manual session) and build
|
|
791
|
+
// the spec it executes from. The caller has already decided the job may go
|
|
792
|
+
// to this runner; everything from here on is the same for both kinds.
|
|
793
|
+
leaseJob(ar, job, runnerName, serverUrl) {
|
|
794
|
+
job.status = 'running';
|
|
795
|
+
job.attempts += 1;
|
|
796
|
+
job.startedAt = job.startedAt ?? nowIso();
|
|
797
|
+
const lease = {
|
|
798
|
+
runner: runnerName,
|
|
799
|
+
token: crypto.randomBytes(16).toString('hex'),
|
|
800
|
+
expiresAt: new Date(Date.now() + LEASE_MS).toISOString(),
|
|
801
|
+
};
|
|
802
|
+
job.lease = lease;
|
|
803
|
+
this.saveJob(ar, job);
|
|
804
|
+
if (ar.run.status === 'queued') {
|
|
805
|
+
ar.run.status = 'running';
|
|
806
|
+
ar.run.startedAt = nowIso();
|
|
807
|
+
}
|
|
808
|
+
this.saveRun(ar);
|
|
809
|
+
const needs = this.needsFor(ar, job.needs);
|
|
810
|
+
const github = this.githubContext(ar.run, ar.collection, ar.repo, job.key, serverUrl);
|
|
811
|
+
github.workspace = '';
|
|
812
|
+
let timeout = 360;
|
|
813
|
+
if (job.timeoutMinutes !== undefined) {
|
|
814
|
+
try {
|
|
815
|
+
const env = {
|
|
816
|
+
contexts: { github, matrix: job.matrix, inputs: (ar.run.inputs ?? {}), vars: {} },
|
|
817
|
+
};
|
|
818
|
+
const v = Number((0, expr_1.render)(job.timeoutMinutes, env));
|
|
819
|
+
if (Number.isFinite(v) && v > 0)
|
|
820
|
+
timeout = v;
|
|
821
|
+
}
|
|
822
|
+
catch {
|
|
823
|
+
// default stands
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
// A private repository cannot be cloned anonymously, so a job for one
|
|
827
|
+
// carries an ephemeral read token for exactly that repository (see
|
|
828
|
+
// src/jobtoken.ts), living a little longer than the job may run. A public
|
|
829
|
+
// repository's job carries none, since none is needed.
|
|
830
|
+
let cloneToken;
|
|
831
|
+
const onDisk = (0, scan_1.findRepo)(this.root, ar.collection, ar.repo);
|
|
832
|
+
if (onDisk && (0, perms_1.repoIsPrivate)(onDisk.dir)) {
|
|
833
|
+
cloneToken = (0, jobtoken_1.mintJobToken)(this.root, { collection: ar.collection, repo: ar.repo, run: String(ar.run.number) }, (timeout + 30) * 60_000);
|
|
834
|
+
}
|
|
835
|
+
return {
|
|
836
|
+
address: { collection: ar.collection, repo: ar.repo, run: ar.run.number, job: job.id },
|
|
837
|
+
lease: lease.token,
|
|
838
|
+
cloneToken,
|
|
839
|
+
name: job.name,
|
|
840
|
+
runsOn: job.runsOn,
|
|
841
|
+
sha: ar.run.sha,
|
|
842
|
+
ref: ar.run.ref,
|
|
843
|
+
refName: ar.run.refName,
|
|
844
|
+
workflowName: ar.run.workflowName,
|
|
845
|
+
workflowPath: ar.run.workflowPath,
|
|
846
|
+
runNumber: ar.run.number,
|
|
847
|
+
env: job.env,
|
|
848
|
+
defaults: job.defaults,
|
|
849
|
+
steps: job.steps,
|
|
850
|
+
matrix: job.matrix,
|
|
851
|
+
strategy: job.strategy,
|
|
852
|
+
needs,
|
|
853
|
+
outputsTemplate: job.outputsTemplate,
|
|
854
|
+
github: github,
|
|
855
|
+
inputs: ar.run.inputs ?? null,
|
|
856
|
+
timeoutMinutes: timeout,
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
// Long-poll: resolve with a job when one becomes available, or null at the
|
|
860
|
+
// deadline. Leasing happens synchronously per waiter, so two runners
|
|
861
|
+
// cannot receive the same job.
|
|
862
|
+
//
|
|
863
|
+
// `gone` is the caller's way of saying the runner behind this wait has hung
|
|
864
|
+
// up. It has to be honoured, not merely recorded: a waiter left in line
|
|
865
|
+
// after its runner went away is handed the next job dispatched, which is
|
|
866
|
+
// then released again with nobody to run it. A runner stopped and started
|
|
867
|
+
// within one poll would otherwise see its first job cancelled before it
|
|
868
|
+
// could ask for one.
|
|
869
|
+
waitForJob(runnerName, labels, allow, serverUrl, timeoutMs, gone) {
|
|
870
|
+
const first = this.acquire(runnerName, labels, allow, serverUrl);
|
|
871
|
+
if (first)
|
|
872
|
+
return Promise.resolve(first);
|
|
873
|
+
if (gone?.aborted)
|
|
874
|
+
return Promise.resolve(null);
|
|
875
|
+
return new Promise((resolve) => {
|
|
876
|
+
const deadline = setTimeout(() => {
|
|
877
|
+
cleanup();
|
|
878
|
+
resolve(null);
|
|
879
|
+
}, timeoutMs);
|
|
880
|
+
const onWake = () => {
|
|
881
|
+
const spec = this.acquire(runnerName, labels, allow, serverUrl);
|
|
882
|
+
if (spec) {
|
|
883
|
+
cleanup();
|
|
884
|
+
resolve(spec);
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
const onGone = () => {
|
|
888
|
+
cleanup();
|
|
889
|
+
resolve(null);
|
|
890
|
+
};
|
|
891
|
+
const cleanup = () => {
|
|
892
|
+
clearTimeout(deadline);
|
|
893
|
+
this.emitter.removeListener('wake', onWake);
|
|
894
|
+
gone?.removeEventListener('abort', onGone);
|
|
895
|
+
};
|
|
896
|
+
this.emitter.on('wake', onWake);
|
|
897
|
+
gone?.addEventListener('abort', onGone);
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
// ---- manual jobs ----
|
|
901
|
+
//
|
|
902
|
+
// A job with `manual` among its runs-on labels is taken by nobody until a
|
|
903
|
+
// person mints a command from the run page and pastes it somewhere. The
|
|
904
|
+
// pasted process redeems the command for a session, then acquires the run's
|
|
905
|
+
// manual jobs through the methods below; everything after the lease (logs,
|
|
906
|
+
// heartbeats, artifacts, the report) travels the registered runner's paths,
|
|
907
|
+
// under the runner name `manual:<grant id>`.
|
|
908
|
+
manualJobsOf(ar) {
|
|
909
|
+
return [...ar.jobs.values()].filter((j) => (0, manual_1.isManualJob)(j.runsOn));
|
|
910
|
+
}
|
|
911
|
+
static matchesJobFilter(j, filter) {
|
|
912
|
+
if (!filter)
|
|
913
|
+
return true;
|
|
914
|
+
return (0, vault_1.globMatch)(filter, j.key) || (0, vault_1.globMatch)(filter, j.id) || filter === j.name;
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Mint the token the pasted command carries. Refused for a run that is not
|
|
918
|
+
* active (nothing left to run) or has no manual jobs (nothing to run this
|
|
919
|
+
* way); the distinction matters to the caller's message.
|
|
920
|
+
*/
|
|
921
|
+
mintManual(collection, repo, n, mintedBy) {
|
|
922
|
+
const ar = this.active.get(runKey(collection, repo, n));
|
|
923
|
+
if (!ar)
|
|
924
|
+
return { error: 'finished' };
|
|
925
|
+
if (this.manualJobsOf(ar).length === 0)
|
|
926
|
+
return { error: 'no-manual' };
|
|
927
|
+
return (0, manual_1.mintGrant)(this.root, collection, repo, n, mintedBy);
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Trade a pasted mint token for a session. The mint token is spent by this;
|
|
931
|
+
* the returned session token is what the process authenticates with from
|
|
932
|
+
* here on, and it exists nowhere but in that process and on the wire.
|
|
933
|
+
*/
|
|
934
|
+
redeemManual(token, host) {
|
|
935
|
+
for (const ar of this.active.values()) {
|
|
936
|
+
const grants = (0, manual_1.loadGrants)(this.root, ar.collection, ar.repo, ar.run.number);
|
|
937
|
+
const grant = (0, manual_1.findMintable)(grants, token);
|
|
938
|
+
if (!grant)
|
|
939
|
+
continue;
|
|
940
|
+
const sessionToken = (0, manual_1.redeemGrant)(this.root, ar.collection, ar.repo, ar.run.number, grant.id, host);
|
|
941
|
+
if (!sessionToken)
|
|
942
|
+
continue;
|
|
943
|
+
grant.host = host;
|
|
944
|
+
this.noteSessionRun(grant.id, ar.collection, ar.repo, ar.run.number);
|
|
945
|
+
return {
|
|
946
|
+
sessionToken,
|
|
947
|
+
collection: ar.collection,
|
|
948
|
+
repo: ar.repo,
|
|
949
|
+
run: ar.run,
|
|
950
|
+
jobs: this.manualJobsOf(ar),
|
|
951
|
+
grant,
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
return null;
|
|
955
|
+
}
|
|
956
|
+
// Where each session's run lives, so a session that outlasts its run (the
|
|
957
|
+
// usual way a session ends) can still be told the run finished rather than
|
|
958
|
+
// being answered with a bare 401. In memory only: after a restart such a
|
|
959
|
+
// session is simply unrecognized, which its client reports as the run being
|
|
960
|
+
// over, and it nearly always is. Bounded, since entries have no natural
|
|
961
|
+
// expiry: the cap is far above any plausible number of concurrent sessions,
|
|
962
|
+
// and evicting the oldest costs that session only a blunter goodbye.
|
|
963
|
+
manualSessionRuns = new Map();
|
|
964
|
+
noteSessionRun(id, collection, repo, n) {
|
|
965
|
+
this.manualSessionRuns.delete(id);
|
|
966
|
+
this.manualSessionRuns.set(id, { collection, repo, n });
|
|
967
|
+
if (this.manualSessionRuns.size > 200) {
|
|
968
|
+
const oldest = this.manualSessionRuns.keys().next().value;
|
|
969
|
+
if (oldest !== undefined)
|
|
970
|
+
this.manualSessionRuns.delete(oldest);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* The session a presented token belongs to. `active` is false when the
|
|
975
|
+
* run has left the live index, in which case `run` is what the disk says
|
|
976
|
+
* became of it.
|
|
977
|
+
*/
|
|
978
|
+
authenticateManualSession(token) {
|
|
979
|
+
for (const ar of this.active.values()) {
|
|
980
|
+
const grants = (0, manual_1.loadGrants)(this.root, ar.collection, ar.repo, ar.run.number);
|
|
981
|
+
const grant = (0, manual_1.findSession)(grants, token);
|
|
982
|
+
if (grant) {
|
|
983
|
+
this.noteSessionRun(grant.id, ar.collection, ar.repo, ar.run.number);
|
|
984
|
+
return { collection: ar.collection, repo: ar.repo, n: ar.run.number, grant, active: true, run: ar.run };
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
for (const at of this.manualSessionRuns.values()) {
|
|
988
|
+
const grants = (0, manual_1.loadGrants)(this.root, at.collection, at.repo, at.n);
|
|
989
|
+
const grant = (0, manual_1.findSession)(grants, token);
|
|
990
|
+
if (grant) {
|
|
991
|
+
return {
|
|
992
|
+
collection: at.collection,
|
|
993
|
+
repo: at.repo,
|
|
994
|
+
n: at.n,
|
|
995
|
+
grant,
|
|
996
|
+
active: this.active.has(runKey(at.collection, at.repo, at.n)),
|
|
997
|
+
run: (0, runs_1.readRun)(this.root, at.collection, at.repo, at.n),
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return null;
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Try to hand this session its run's next manual job. 'wait' means one may
|
|
1005
|
+
* yet become eligible (blocked on needs, or held by concurrency); 'done'
|
|
1006
|
+
* means this session will never be handed another, with the reason.
|
|
1007
|
+
*/
|
|
1008
|
+
acquireManual(collection, repo, n, grant, filter, serverUrl) {
|
|
1009
|
+
const ar = this.active.get(runKey(collection, repo, n));
|
|
1010
|
+
if (!ar) {
|
|
1011
|
+
const run = (0, runs_1.readRun)(this.root, collection, repo, n);
|
|
1012
|
+
return {
|
|
1013
|
+
kind: 'done',
|
|
1014
|
+
run,
|
|
1015
|
+
reason: run?.status === 'completed' ? `the run finished: ${run.conclusion}` : 'the run no longer exists',
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
if (ar.run.cancelRequested)
|
|
1019
|
+
return { kind: 'done', run: ar.run, reason: 'the run was cancelled' };
|
|
1020
|
+
const name = (0, manual_1.sessionRunnerName)(grant);
|
|
1021
|
+
const matching = this.manualJobsOf(ar).filter((j) => CiEngine.matchesJobFilter(j, filter));
|
|
1022
|
+
if (matching.length === 0) {
|
|
1023
|
+
return {
|
|
1024
|
+
kind: 'done',
|
|
1025
|
+
run: ar.run,
|
|
1026
|
+
reason: filter ? `no manual job matches --job ${filter}` : 'this run has no manual jobs',
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
if (!this.heldByConcurrency(ar)) {
|
|
1030
|
+
for (const id of ar.run.jobs) {
|
|
1031
|
+
const j = ar.jobs.get(id);
|
|
1032
|
+
if (!j || !matching.includes(j))
|
|
1033
|
+
continue;
|
|
1034
|
+
if (j.status !== 'queued')
|
|
1035
|
+
continue;
|
|
1036
|
+
if (!this.needsDone(ar, j))
|
|
1037
|
+
continue;
|
|
1038
|
+
// Attribution rides on the record, not the lease: the lease is gone
|
|
1039
|
+
// the moment the job completes, and who ran a job is a fact about the
|
|
1040
|
+
// job. Set before leaseJob so its save persists it.
|
|
1041
|
+
j.manual = { user: grant.mintedBy, host: grant.host ?? '' };
|
|
1042
|
+
return { kind: 'job', spec: this.leaseJob(ar, j, name, serverUrl) };
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
const queued = matching.some((j) => j.status === 'queued');
|
|
1046
|
+
const mine = matching.some((j) => j.status === 'running' && j.lease?.runner === name);
|
|
1047
|
+
if (queued || mine)
|
|
1048
|
+
return { kind: 'wait' };
|
|
1049
|
+
if (matching.some((j) => j.status === 'running')) {
|
|
1050
|
+
return { kind: 'done', run: ar.run, reason: 'the remaining manual jobs are held by another session' };
|
|
1051
|
+
}
|
|
1052
|
+
return { kind: 'done', run: ar.run, reason: 'every manual job of this run has finished' };
|
|
1053
|
+
}
|
|
1054
|
+
/** The long-poll form of acquireManual: 'wait' at the deadline, sooner news sooner. */
|
|
1055
|
+
waitForManualJob(collection, repo, n, grant, filter, serverUrl, timeoutMs, gone) {
|
|
1056
|
+
const first = this.acquireManual(collection, repo, n, grant, filter, serverUrl);
|
|
1057
|
+
if (first.kind !== 'wait')
|
|
1058
|
+
return Promise.resolve(first);
|
|
1059
|
+
if (gone?.aborted)
|
|
1060
|
+
return Promise.resolve({ kind: 'wait' });
|
|
1061
|
+
return new Promise((resolve) => {
|
|
1062
|
+
const deadline = setTimeout(() => {
|
|
1063
|
+
cleanup();
|
|
1064
|
+
resolve({ kind: 'wait' });
|
|
1065
|
+
}, timeoutMs);
|
|
1066
|
+
const onWake = () => {
|
|
1067
|
+
const next = this.acquireManual(collection, repo, n, grant, filter, serverUrl);
|
|
1068
|
+
if (next.kind !== 'wait') {
|
|
1069
|
+
cleanup();
|
|
1070
|
+
resolve(next);
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
const onGone = () => {
|
|
1074
|
+
cleanup();
|
|
1075
|
+
resolve({ kind: 'wait' });
|
|
1076
|
+
};
|
|
1077
|
+
const cleanup = () => {
|
|
1078
|
+
clearTimeout(deadline);
|
|
1079
|
+
this.emitter.removeListener('wake', onWake);
|
|
1080
|
+
gone?.removeEventListener('abort', onGone);
|
|
1081
|
+
};
|
|
1082
|
+
this.emitter.on('wake', onWake);
|
|
1083
|
+
gone?.addEventListener('abort', onGone);
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Hand a leased manual job back untouched: the person at the terminal read
|
|
1088
|
+
* the steps and said no, or hung up before the offer arrived. The job
|
|
1089
|
+
* returns to waiting for a command, unlike a lease that expires mid-run,
|
|
1090
|
+
* which fails the job (see sweepLeases): nothing had executed yet, so there
|
|
1091
|
+
* is nothing to have failed.
|
|
1092
|
+
*/
|
|
1093
|
+
releaseManualJob(collection, repo, n, jobId, lease, runner) {
|
|
1094
|
+
if (!(0, manual_1.isManualRunnerName)(runner))
|
|
1095
|
+
return false;
|
|
1096
|
+
const found = this.findLeased(collection, repo, n, jobId, lease, runner);
|
|
1097
|
+
if (!found)
|
|
1098
|
+
return false;
|
|
1099
|
+
const { ar, job } = found;
|
|
1100
|
+
delete job.lease;
|
|
1101
|
+
delete job.manual;
|
|
1102
|
+
delete job.startedAt;
|
|
1103
|
+
job.status = 'queued';
|
|
1104
|
+
this.saveJob(ar, job);
|
|
1105
|
+
this.wake();
|
|
1106
|
+
return true;
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* The job a runner is entitled to act on, or null. Both halves matter: the
|
|
1110
|
+
* lease token proves the caller holds this job's grant, and the runner name
|
|
1111
|
+
* proves it is the runner the grant was issued to. Checking only the token
|
|
1112
|
+
* would mean that a token leaking to another registered runner - through a
|
|
1113
|
+
* log, a shared image, or an operator's paste - handed that runner the job's
|
|
1114
|
+
* output, and a runner is exactly the party in a position to see one.
|
|
1115
|
+
*/
|
|
1116
|
+
findLeased(collection, repo, n, jobId, lease, runner) {
|
|
1117
|
+
const ar = this.active.get(runKey(collection, repo, n));
|
|
1118
|
+
if (!ar)
|
|
1119
|
+
return null;
|
|
1120
|
+
const job = ar.jobs.get(jobId);
|
|
1121
|
+
if (!job || job.status !== 'running' || !job.lease)
|
|
1122
|
+
return null;
|
|
1123
|
+
if (job.lease.runner !== runner)
|
|
1124
|
+
return null;
|
|
1125
|
+
if (!sameToken(job.lease.token, lease))
|
|
1126
|
+
return null;
|
|
1127
|
+
return { ar, job };
|
|
1128
|
+
}
|
|
1129
|
+
heartbeat(collection, repo, n, jobId, lease, runner) {
|
|
1130
|
+
const found = this.findLeased(collection, repo, n, jobId, lease, runner);
|
|
1131
|
+
if (!found)
|
|
1132
|
+
return null;
|
|
1133
|
+
found.job.lease.expiresAt = new Date(Date.now() + LEASE_MS).toISOString();
|
|
1134
|
+
this.saveJob(found.ar, found.job);
|
|
1135
|
+
return { cancel: found.ar.run.cancelRequested === true };
|
|
1136
|
+
}
|
|
1137
|
+
appendLogs(collection, repo, n, jobId, lease, runner, ndjson) {
|
|
1138
|
+
const found = this.findLeased(collection, repo, n, jobId, lease, runner);
|
|
1139
|
+
if (!found)
|
|
1140
|
+
return false;
|
|
1141
|
+
try {
|
|
1142
|
+
const logPath = (0, runs_1.jobLogPath)(this.root, collection, repo, n, jobId);
|
|
1143
|
+
const size = fs.existsSync(logPath) ? fs.statSync(logPath).size : 0;
|
|
1144
|
+
if (size + ndjson.length > MAX_LOG_BYTES) {
|
|
1145
|
+
// Stop accepting output, but say so in the log itself rather than
|
|
1146
|
+
// going quiet, which would read as the job having stalled.
|
|
1147
|
+
if (!found.job.logCapped) {
|
|
1148
|
+
found.job.logCapped = true;
|
|
1149
|
+
this.saveJob(found.ar, found.job);
|
|
1150
|
+
(0, runs_1.appendJobLog)(this.root, collection, repo, n, jobId, JSON.stringify({
|
|
1151
|
+
s: -1,
|
|
1152
|
+
t: nowIso(),
|
|
1153
|
+
l: `[log truncated: this job produced more than ${Math.round(MAX_LOG_BYTES / (1024 * 1024))} MB of output]`,
|
|
1154
|
+
}) + '\n');
|
|
1155
|
+
}
|
|
1156
|
+
return true;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
catch {
|
|
1160
|
+
// append anyway
|
|
1161
|
+
}
|
|
1162
|
+
(0, runs_1.appendJobLog)(this.root, collection, repo, n, jobId, ndjson);
|
|
1163
|
+
found.job.lease.expiresAt = new Date(Date.now() + LEASE_MS).toISOString();
|
|
1164
|
+
return true;
|
|
1165
|
+
}
|
|
1166
|
+
reportStatus(collection, repo, n, jobId, report) {
|
|
1167
|
+
const found = this.findLeased(collection, repo, n, jobId, report.lease, report.runner);
|
|
1168
|
+
if (!found)
|
|
1169
|
+
return false;
|
|
1170
|
+
const { ar, job } = found;
|
|
1171
|
+
if (report.stepStates)
|
|
1172
|
+
job.stepStates = report.stepStates;
|
|
1173
|
+
if (report.summaries)
|
|
1174
|
+
job.summaries = report.summaries.map((s) => s.slice(0, 64 * 1024));
|
|
1175
|
+
if (report.status === 'completed') {
|
|
1176
|
+
if (report.outputs)
|
|
1177
|
+
job.outputs = report.outputs;
|
|
1178
|
+
this.completeJob(ar, job, report.conclusion ?? 'failure');
|
|
1179
|
+
this.advance(ar);
|
|
1180
|
+
this.wake();
|
|
1181
|
+
}
|
|
1182
|
+
else {
|
|
1183
|
+
job.lease.expiresAt = new Date(Date.now() + LEASE_MS).toISOString();
|
|
1184
|
+
this.saveJob(ar, job);
|
|
1185
|
+
}
|
|
1186
|
+
return true;
|
|
1187
|
+
}
|
|
1188
|
+
// ---- cancellation, re-run, lease expiry ----
|
|
1189
|
+
cancelRun(collection, repo, n) {
|
|
1190
|
+
const ar = this.active.get(runKey(collection, repo, n));
|
|
1191
|
+
if (!ar)
|
|
1192
|
+
return false;
|
|
1193
|
+
ar.run.cancelRequested = true;
|
|
1194
|
+
this.advance(ar); // queued jobs become cancelled; running ones report in
|
|
1195
|
+
this.saveRun(ar);
|
|
1196
|
+
return true;
|
|
1197
|
+
}
|
|
1198
|
+
async rerun(collection, repo, n) {
|
|
1199
|
+
const old = (0, runs_1.readRun)(this.root, collection, repo, n);
|
|
1200
|
+
if (!old)
|
|
1201
|
+
return null;
|
|
1202
|
+
const gitRepo = (0, scan_1.findRepo)(this.root, collection, repo);
|
|
1203
|
+
if (!gitRepo)
|
|
1204
|
+
return null;
|
|
1205
|
+
const discovered = await discoverWorkflows(gitRepo, old.sha);
|
|
1206
|
+
const d = discovered.find((x) => x.path === old.workflowPath);
|
|
1207
|
+
if (!d)
|
|
1208
|
+
throw new workflow_1.WorkflowError(`no workflow at ${old.workflowPath} at ${old.sha.slice(0, 7)}`);
|
|
1209
|
+
if (d.error) {
|
|
1210
|
+
return this.createErrorRun(gitRepo, d.path, old.event, { ref: old.ref, actor: old.actor, sha: old.sha }, old.payload, d.error);
|
|
1211
|
+
}
|
|
1212
|
+
return this.planRun(gitRepo, d.path, d.workflow, {
|
|
1213
|
+
event: old.event,
|
|
1214
|
+
ref: old.ref,
|
|
1215
|
+
sha: old.sha,
|
|
1216
|
+
actor: old.actor,
|
|
1217
|
+
payload: old.payload,
|
|
1218
|
+
inputs: old.inputs,
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
cancelActive(ar) {
|
|
1222
|
+
ar.run.cancelRequested = true;
|
|
1223
|
+
this.advance(ar);
|
|
1224
|
+
this.saveRun(ar);
|
|
1225
|
+
}
|
|
1226
|
+
sweepLeases() {
|
|
1227
|
+
const now = Date.now();
|
|
1228
|
+
for (const ar of this.active.values()) {
|
|
1229
|
+
for (const j of ar.jobs.values()) {
|
|
1230
|
+
if (j.status !== 'running' || !j.lease)
|
|
1231
|
+
continue;
|
|
1232
|
+
if (new Date(j.lease.expiresAt).getTime() > now)
|
|
1233
|
+
continue;
|
|
1234
|
+
if ((0, manual_1.isManualRunnerName)(j.lease.runner)) {
|
|
1235
|
+
// A manual job's lost session fails the job at once rather than
|
|
1236
|
+
// requeueing it: nothing else can pick a manual job up, and a job
|
|
1237
|
+
// silently waiting for a person who has gone would read as a run
|
|
1238
|
+
// that hangs. Running it again is a fresh command from the run page.
|
|
1239
|
+
const who = j.manual ? `run by ${j.manual.user} on ${j.manual.host || 'an unnamed host'}` : '';
|
|
1240
|
+
j.error = `the manual session ${who ? `(${who}) ` : ''}stopped responding; mint a new command from the run page to run it again`;
|
|
1241
|
+
this.completeJob(ar, j, 'failure');
|
|
1242
|
+
this.advance(ar);
|
|
1243
|
+
this.wake();
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
if (j.attempts >= MAX_ATTEMPTS) {
|
|
1247
|
+
j.error = `the runner (${j.lease.runner}) stopped responding after ${j.attempts} attempts`;
|
|
1248
|
+
this.completeJob(ar, j, 'failure');
|
|
1249
|
+
this.advance(ar);
|
|
1250
|
+
}
|
|
1251
|
+
else {
|
|
1252
|
+
delete j.lease;
|
|
1253
|
+
j.status = 'queued';
|
|
1254
|
+
this.saveJob(ar, j);
|
|
1255
|
+
this.wake();
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
artifactLimitBytes() {
|
|
1261
|
+
const mb = this.retention().artifactMb;
|
|
1262
|
+
return Math.max(1, mb) * 1024 * 1024;
|
|
1263
|
+
}
|
|
1264
|
+
// Called when a repository is deleted: drop its runs from the live index so
|
|
1265
|
+
// nothing is dispatched for a repository that no longer exists. Runners
|
|
1266
|
+
// holding one of its jobs fail their next heartbeat and stop.
|
|
1267
|
+
forgetRepo(collection, repo) {
|
|
1268
|
+
for (const [key, ar] of this.active) {
|
|
1269
|
+
if (ar.collection === collection && ar.repo === repo)
|
|
1270
|
+
this.active.delete(key);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
// ---- read access for the UI ----
|
|
1274
|
+
/**
|
|
1275
|
+
* What each runner is holding, and what is waiting for one.
|
|
1276
|
+
*
|
|
1277
|
+
* This is what `mochi runner list` reports beside the registry, because
|
|
1278
|
+
* "the run is stuck" nearly always has one of two answers that the registry
|
|
1279
|
+
* alone cannot give: no runner is connected, or one is connected but nothing
|
|
1280
|
+
* it serves matches the labels the queued jobs asked for.
|
|
1281
|
+
*/
|
|
1282
|
+
runnerLoad() {
|
|
1283
|
+
const running = {};
|
|
1284
|
+
const queued = [];
|
|
1285
|
+
for (const ar of this.active.values()) {
|
|
1286
|
+
for (const job of ar.jobs.values()) {
|
|
1287
|
+
const at = { collection: ar.collection, repo: ar.repo, run: ar.run.number, job: job.id };
|
|
1288
|
+
if (job.status === 'running' && job.lease) {
|
|
1289
|
+
running[job.lease.runner] = { ...at, since: job.startedAt ?? null };
|
|
1290
|
+
}
|
|
1291
|
+
else if (job.status === 'queued' && this.needsDone(ar, job)) {
|
|
1292
|
+
queued.push({ ...at, runsOn: job.runsOn, manual: (0, manual_1.isManualJob)(job.runsOn) });
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
return { running, queued };
|
|
1297
|
+
}
|
|
1298
|
+
activeRun(collection, repo, n) {
|
|
1299
|
+
return this.active.get(runKey(collection, repo, n))?.run ?? null;
|
|
1300
|
+
}
|
|
1301
|
+
jobOf(collection, repo, n, jobId) {
|
|
1302
|
+
const ar = this.active.get(runKey(collection, repo, n));
|
|
1303
|
+
if (ar)
|
|
1304
|
+
return ar.jobs.get(jobId) ?? null;
|
|
1305
|
+
return (0, runs_1.readJob)(this.root, collection, repo, n, jobId);
|
|
1306
|
+
}
|
|
1307
|
+
runOf(collection, repo, n) {
|
|
1308
|
+
return this.activeRun(collection, repo, n) ?? (0, runs_1.readRun)(this.root, collection, repo, n);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
exports.CiEngine = CiEngine;
|
|
1312
|
+
async function listWorkflowsAt(repo, sha) {
|
|
1313
|
+
const discovered = await discoverWorkflows(repo, sha);
|
|
1314
|
+
return discovered.map((d) => ({
|
|
1315
|
+
path: d.path,
|
|
1316
|
+
name: d.workflow?.name ?? path.basename(d.path),
|
|
1317
|
+
dispatch: d.workflow?.on.workflowDispatch?.inputs ?? null,
|
|
1318
|
+
triggers: d.workflow?.on ?? null,
|
|
1319
|
+
error: d.error,
|
|
1320
|
+
}));
|
|
1321
|
+
}
|