@outerlayer/cli 0.2.0 → 0.4.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/CHANGELOG.md +124 -0
- package/README.md +226 -59
- package/dist/agent-setup-IFH3FB5X.js +197 -0
- package/dist/build-TZUUTRGH.js +9 -0
- package/dist/build-info.json +1 -1
- package/dist/check-WQP2CZDW.js +94 -0
- package/dist/chunk-2E54Z3P5.js +232 -0
- package/dist/chunk-3TT7RQYB.js +55 -0
- package/dist/{chunk-U32VSRLO.js → chunk-3YPKXE5L.js} +90 -632
- package/dist/{chunk-Q6CL5THG.js → chunk-5DLBAXY5.js} +208 -61
- package/dist/{chunk-R5KBGVII.js → chunk-5QRP3MVS.js} +30 -14
- package/dist/chunk-5Y3QQRUH.js +300 -0
- package/dist/chunk-6Y63SXFO.js +383 -0
- package/dist/chunk-774HEQL6.js +178 -0
- package/dist/chunk-7A6RVXPZ.js +2233 -0
- package/dist/chunk-7YIOOFVJ.js +71 -0
- package/dist/chunk-ABIWDUMS.js +104 -0
- package/dist/chunk-AUFG23AA.js +534 -0
- package/dist/chunk-B2G7JHHB.js +42 -0
- package/dist/chunk-BCJNHMZT.js +34 -0
- package/dist/chunk-BCLJSQCV.js +56 -0
- package/dist/chunk-BFESLKPP.js +61 -0
- package/dist/chunk-BJ3KTMDH.js +63 -0
- package/dist/chunk-BKO6JEZI.js +47 -0
- package/dist/chunk-BOLTI6LR.js +37 -0
- package/dist/chunk-CBPPJSAR.js +89 -0
- package/dist/chunk-CBZB6XY6.js +102 -0
- package/dist/{chunk-65WXAANR.js → chunk-DVDEBNQJ.js} +17 -2
- package/dist/{work-pr-cmd-AZQPWH4H.js → chunk-DVYHCMB2.js} +9 -26
- package/dist/{chunk-TFUIDMOB.js → chunk-EABW6AJQ.js} +12 -1
- package/dist/{chunk-KFYJV2ZG.js → chunk-EMY4I27X.js} +1 -1
- package/dist/{chunk-NTTPJV35.js → chunk-F47JBIAW.js} +3 -1
- package/dist/chunk-F6GFZXZ3.js +60 -0
- package/dist/{chunk-YOOSBOKS.js → chunk-F7CU5ABH.js} +1 -1
- package/dist/chunk-FAJSS2WN.js +1357 -0
- package/dist/chunk-FS2VYGZT.js +1764 -0
- package/dist/chunk-FV2CCGXK.js +9 -0
- package/dist/{chunk-4TNZMO7V.js → chunk-G7FSV7JX.js} +3452 -751
- package/dist/{emit-cmd-TWSYYEZI.js → chunk-H5NCNVDA.js} +68 -17
- package/dist/chunk-HE2D4EY5.js +83 -0
- package/dist/chunk-L2DBRKRA.js +94 -0
- package/dist/chunk-LF6MLJCY.js +38 -0
- package/dist/chunk-M5POMKKW.js +1051 -0
- package/dist/{mcp-install-cmd-FDQH6SEN.js → chunk-MQ3IPHIZ.js} +35 -18
- package/dist/{chunk-D77LS3UI.js → chunk-N5FOE5PS.js} +33 -4
- package/dist/chunk-N6LURUEF.js +64 -0
- package/dist/chunk-NYC54VBD.js +1084 -0
- package/dist/chunk-OGFGZQA3.js +23 -0
- package/dist/chunk-QOZKPLVJ.js +226 -0
- package/dist/chunk-RA3O54FC.js +30 -0
- package/dist/chunk-TBT347UY.js +41 -0
- package/dist/{chunk-NXLLURA4.js → chunk-UFFXNXLQ.js} +115 -71
- package/dist/chunk-USO2DKBD.js +421 -0
- package/dist/chunk-W4SQCOZU.js +206 -0
- package/dist/{chunk-A3WLZX2F.js → chunk-WIOZAJ2W.js} +15 -2
- package/dist/chunk-WO2BXCTQ.js +83 -0
- package/dist/{chunk-I3ETLSNE.js → chunk-XCCLFVXM.js} +120 -11
- package/dist/{context-materialize-6WKT3RBQ.js → chunk-XDDW4FRS.js} +134 -26
- package/dist/chunk-YYYXRJUW.js +101 -0
- package/dist/chunk-ZM2IMMYN.js +76 -0
- package/dist/chunk-ZMYPLFG3.js +71 -0
- package/dist/chunk-ZNA27WEV.js +470 -0
- package/dist/{cli-G7DILYJY.js → cli-NHUXABYH.js} +590 -876
- package/dist/{paths-D2VGWWFI.js → cli-build-K56DK4DS.js} +1 -1
- package/dist/config-XVJYZ4IQ.js +9 -0
- package/dist/connect-cmd-OOG4TPU7.js +16 -0
- package/dist/context-adopt-UMA4O5NY.js +105 -0
- package/dist/context-materialize-G2FUFC72.js +19 -0
- package/dist/dist-RW4UPPC2.js +6 -0
- package/dist/docker-NEGDLU6D.js +7 -0
- package/dist/doctor-53F2PYY7.js +43 -0
- package/dist/doctor-UFVL4PZY.js +426 -0
- package/dist/{emit-artifact-cmd-UQVT3OKW.js → emit-artifact-cmd-KTYW2TN2.js} +24 -20
- package/dist/emit-cmd-DCERAU27.js +9 -0
- package/dist/emit-criteria-cmd-VAUUSAYD.js +166 -0
- package/dist/{emit-finding-cmd-FDOU2OPD.js → emit-finding-cmd-UR4ID45Z.js} +34 -31
- package/dist/{emit-result-cmd-H2T4C2K3.js → emit-result-cmd-FDTUKGKE.js} +34 -62
- package/dist/exec-client-4XGXV3XN.js +8 -0
- package/dist/guest-init-GENRHNP7.js +8 -0
- package/dist/hook-fast-EYENPK6F.js +9 -0
- package/dist/{hook-wrap-fast-KXYNX3AD.js → hook-wrap-fast-NPLHJXFK.js} +2 -2
- package/dist/host-key-KDYZ5ECC.js +7 -0
- package/dist/import-capture-cmd-AVZ4VIVJ.js +68 -0
- package/dist/{import-ruler-cmd-7LH2QOMG.js → import-ruler-cmd-GUHL6IY5.js} +1 -1
- package/dist/index.js +4 -4
- package/dist/init-DXBCOAJK.js +36 -0
- package/dist/init-YEXK35CF.js +221 -0
- package/dist/init-cmd-NHR7PRSQ.js +135 -0
- package/dist/install-cmd-7WXOIFDO.js +55 -0
- package/dist/lima-XN65D7GN.js +55 -0
- package/dist/login-browser-7XGSV7MA.js +10 -0
- package/dist/{config-POF7DEQW.js → logout-cmd-QAUFDJ6J.js} +3 -1
- package/dist/{logs-TRNPQM42.js → logs-7RYUH5A6.js} +1 -1
- package/dist/loop-VZOLL4WQ.js +43 -0
- package/dist/machine-WSG52J75.js +35 -0
- package/dist/mcp-install-cmd-RLK4SO3N.js +9 -0
- package/dist/{mcp-serve-cmd-57EZZOTL.js → mcp-serve-cmd-JPTP5FMS.js} +20 -9
- package/dist/paths-OYKMVYJP.js +6 -0
- package/dist/{pidfile-PTW76F56.js → pidfile-UZRH774M.js} +2 -3
- package/dist/real-deps-SU24ZA2K.js +21 -0
- package/dist/relay-L76HDX72.js +46 -0
- package/dist/settings-J2652U5N.js +7 -0
- package/dist/starter-pack-LIZYMKYQ.js +10 -0
- package/dist/{status-I27IST4L.js → status-2UZKKNB7.js} +30 -13
- package/dist/{statusline-fast-3C5OXHDD.js → statusline-fast-SVTMBMD7.js} +3 -2
- package/dist/sync-cmd-VPPYCNNM.js +28 -0
- package/dist/version-BWM6VLDI.js +6 -0
- package/dist/{watch-3DTPJETH.js → watch-5C4ZBBGO.js} +19 -8
- package/dist/{work-claim-cmd-5AUNCLS2.js → work-claim-cmd-LSO2B2EX.js} +33 -20
- package/dist/work-cmd-UOQO2AD4.js +17 -0
- package/dist/work-comment-cmd-WF3EOLAE.js +106 -0
- package/dist/{work-launch-LT663PB3.js → work-launch-YI4CJDAP.js} +1 -1
- package/dist/work-open-pr-cmd-4SSQZMYG.js +156 -0
- package/dist/work-pr-cmd-6AN6RRQJ.js +16 -0
- package/package.json +13 -3
- package/skill-pack/maintained/amend/SKILL.md +104 -0
- package/skill-pack/maintained/emitting-evidence/SKILL.md +99 -0
- package/skill-pack/maintained/emitting-evidence/references/agents-snippet.md +20 -0
- package/skill-pack/maintained/outerlayer/SKILL.md +55 -0
- package/skill-pack/maintained/reporting-findings/SKILL.md +148 -0
- package/skill-pack/template/build/SKILL.md +193 -0
- package/skill-pack/template/build/references/agent-briefs.md +243 -0
- package/skill-pack/template/build/references/criteria-judge.md +91 -0
- package/skill-pack/template/build/references/evidence.md +42 -0
- package/skill-pack/template/build/references/release.md +74 -0
- package/skill-pack/template/build/references/review-briefs.md +275 -0
- package/skill-pack/template/build/references/review-loop.md +158 -0
- package/skill-pack/template/build/scripts/record-criteria.mjs +235 -0
- package/skill-pack/template/spec/SKILL.md +84 -0
- package/skill-pack/template/writing-specs/SKILL.md +134 -0
- package/dist/chunk-DCNOXRMV.js +0 -589
- package/dist/chunk-JJP7YLMN.js +0 -25
- package/dist/chunk-OZ7C3XUE.js +0 -34
- package/dist/chunk-WQ6VGRGZ.js +0 -150
- package/dist/hook-fast-J5LCDHUJ.js +0 -8
- package/dist/import-capture-cmd-EUMBGIV3.js +0 -176
- package/dist/init-PTBITAUO.js +0 -103
- package/dist/login-cmd-IRX6LZT7.js +0 -62
- package/dist/loop-ASZRX3CZ.js +0 -648
- package/dist/sync-cmd-BBAUZ5JD.js +0 -17
- package/dist/work-cmd-2P4BVX47.js +0 -18
|
@@ -0,0 +1,1764 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { LEASE_SECONDS, releaseLease, isUnfinished, writeJobFile, setStage, jobEnv, runHook, describeHookEnd, provisionMicroVm, deliverCommitHooks, provisionProcess, provisionEnvironment, readProvisionOut, resolveHostBuild, workdirProblem, listenOnBroker, hostEnvironment, hostCommandEnvironment, execArgv, microVmCommandLine, runCommand, execCommandLine, startHostServices, finishJob, isGroupAlive, outcomeFromExitCode, needsRenew, renewLease, RENEW_INTERVAL_SECONDS, isTimedOut, readCpuUsec, isIdle, stopOrphanedMicroVms, unfinishedJobs, listAdoptableJobs, retryFailedReleases, recoverLostJobs, readJobFile, hookLimitsFor } from './chunk-FAJSS2WN.js';
|
|
4
|
+
import { RUNNER_PROTOCOL } from './chunk-FV2CCGXK.js';
|
|
5
|
+
import { fetchVersionMeta, npmVersion, npmSupportsProvenance, fetchTarballDigest, fetchProvenance, installVersion, auditSignatures } from './chunk-ABIWDUMS.js';
|
|
6
|
+
import { readMarker, currentVersion, removeMarker, writeMarker, entryIn, versionDir, switchCurrent, cleanVersions, isInLayout, startedThroughCurrent, stagingDir } from './chunk-YYYXRJUW.js';
|
|
7
|
+
import { RepoFilter } from './chunk-TBT347UY.js';
|
|
8
|
+
import { describeCallFailure, retrySend, shortClaim, relativeToRunner, formatDuration, provisionContainer, lifecycleSteps, lifecycleFailureReason, prepareBranch, containerCommandLine, BuildTokens, classifyRefusal, refusalCode, formatClock, measureDisk, createRunnerLog, ensureTunnel, keepGhConfigCurrent, createContainerRuntime } from './chunk-7A6RVXPZ.js';
|
|
9
|
+
import { startTimeOf, claimPidFile, readPidFile, isSameProcess, releasePidFile } from './chunk-N5FOE5PS.js';
|
|
10
|
+
import { isSignatureRefusal, WorkCommandError, getJson, postJson } from './chunk-5DLBAXY5.js';
|
|
11
|
+
import { isBuiltinContainer, isBuiltinVm, isBuiltinProcess, loadRunnerConfig, checkHooks } from './chunk-5Y3QQRUH.js';
|
|
12
|
+
import { exceedsDisk } from './chunk-F6GFZXZ3.js';
|
|
13
|
+
import { parseClaimIdentity, sharedUserEnvironment, JOB_MOUNT } from './chunk-AUFG23AA.js';
|
|
14
|
+
import { resolveVersion } from './chunk-RA3O54FC.js';
|
|
15
|
+
import { loadHostKey, ensureHostKey, signerFor } from './chunk-M5POMKKW.js';
|
|
16
|
+
import { RUNNER_DIR_MODE, runnerDir, buildDir, attemptStamp, jobDir, jobsDir, pidFilePath, RUNNER_FILE_MODE } from './chunk-ZM2IMMYN.js';
|
|
17
|
+
import { HOOKS_DIR_NAME, writeCommitHooks } from './chunk-XCCLFVXM.js';
|
|
18
|
+
import { randomBytes } from 'crypto';
|
|
19
|
+
import { mkdirSync, existsSync, chmodSync, statSync, readdirSync, readFileSync, rmSync, renameSync, writeFileSync } from 'fs';
|
|
20
|
+
import { join, dirname } from 'path';
|
|
21
|
+
|
|
22
|
+
createRequire(import.meta.url);
|
|
23
|
+
|
|
24
|
+
// src/runner/heartbeat.ts
|
|
25
|
+
var HEARTBEAT_TIMEOUT_MS = 1e4;
|
|
26
|
+
var ERROR_CODE_MAX = 100;
|
|
27
|
+
var ERROR_MESSAGE_MAX = 500;
|
|
28
|
+
var UNREACHABLE_MESSAGE = "the request did not reach the platform";
|
|
29
|
+
function noPlatformMessage(status) {
|
|
30
|
+
return `the request got HTTP ${status} with no error message from the platform`;
|
|
31
|
+
}
|
|
32
|
+
function cutForGateway(value, max) {
|
|
33
|
+
let cut = value.replaceAll("\0", "").toWellFormed().slice(0, max);
|
|
34
|
+
const last = cut.charCodeAt(cut.length - 1);
|
|
35
|
+
if (last >= 55296 && last <= 56319) cut = cut.slice(0, -1);
|
|
36
|
+
return cut;
|
|
37
|
+
}
|
|
38
|
+
function shapeWorkRequestFailure(code, status, message) {
|
|
39
|
+
const shapedCode = code !== void 0 ? code : status !== void 0 ? `http_${status}` : "unreachable";
|
|
40
|
+
const fallback = status !== void 0 ? noPlatformMessage(status) : UNREACHABLE_MESSAGE;
|
|
41
|
+
const bodyMessage = code !== void 0 && message !== void 0 ? cutForGateway(message, ERROR_MESSAGE_MAX) : "";
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
code: cutForGateway(shapedCode, ERROR_CODE_MAX),
|
|
45
|
+
message: bodyMessage === "" ? fallback : bodyMessage
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function workRequestFromFailure(err) {
|
|
49
|
+
if (!(err instanceof WorkCommandError)) return shapeWorkRequestFailure(void 0, void 0, void 0);
|
|
50
|
+
return shapeWorkRequestFailure(err.code, err.status, err.message);
|
|
51
|
+
}
|
|
52
|
+
function workRequestFromRetryFailure(outcome) {
|
|
53
|
+
return shapeWorkRequestFailure(outcome.code, outcome.status, outcome.message);
|
|
54
|
+
}
|
|
55
|
+
async function sendHeartbeat(args, fetchImpl) {
|
|
56
|
+
const body = {
|
|
57
|
+
host: args.host,
|
|
58
|
+
cliVersion: args.cliVersion,
|
|
59
|
+
runnerProtocol: RUNNER_PROTOCOL,
|
|
60
|
+
pollSeconds: args.pollSeconds,
|
|
61
|
+
slotsUsed: args.slotsUsed,
|
|
62
|
+
slotsTotal: args.slotsTotal,
|
|
63
|
+
workRequest: args.workRequest,
|
|
64
|
+
// The attempt count stays on the host; the gateway stores the outcome, its reason and its time.
|
|
65
|
+
lastUpdate: args.lastUpdate === void 0 ? void 0 : {
|
|
66
|
+
from: args.lastUpdate.from,
|
|
67
|
+
to: args.lastUpdate.to,
|
|
68
|
+
outcome: args.lastUpdate.outcome,
|
|
69
|
+
reason: args.lastUpdate.reason,
|
|
70
|
+
...args.lastUpdate.at !== void 0 ? { at: args.lastUpdate.at } : {}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
try {
|
|
74
|
+
const response = await postJson(
|
|
75
|
+
args.url,
|
|
76
|
+
"/v1/hosts/heartbeat",
|
|
77
|
+
args.apiKey,
|
|
78
|
+
args.appId,
|
|
79
|
+
body,
|
|
80
|
+
fetchImpl,
|
|
81
|
+
HEARTBEAT_TIMEOUT_MS,
|
|
82
|
+
await signerFor(args.configPath)
|
|
83
|
+
);
|
|
84
|
+
const runnerVersion = response.data?.runnerVersion;
|
|
85
|
+
return typeof runnerVersion === "string" ? { ok: true, runnerVersion } : { ok: true };
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return { ok: false, reason: describeCallFailure(err) };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
var OUTCOMES = ["updated", "rolled_back", "verification_failed", "not_published", "retrying"];
|
|
91
|
+
function lastUpdatePath(configPath) {
|
|
92
|
+
return join(runnerDir(configPath), "last-update.json");
|
|
93
|
+
}
|
|
94
|
+
function readLastUpdate(configPath) {
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(readFileSync(lastUpdatePath(configPath), "utf8"));
|
|
97
|
+
if (typeof parsed.from !== "string" || typeof parsed.to !== "string") return void 0;
|
|
98
|
+
if (typeof parsed.outcome !== "string" || !OUTCOMES.includes(parsed.outcome)) return void 0;
|
|
99
|
+
if (parsed.reason !== null && typeof parsed.reason !== "string") return void 0;
|
|
100
|
+
const update = { from: parsed.from, to: parsed.to, outcome: parsed.outcome, reason: parsed.reason };
|
|
101
|
+
const at = typeof parsed.at === "string" ? isoTime(parsed.at) : void 0;
|
|
102
|
+
if (at !== void 0) update.at = at;
|
|
103
|
+
if (typeof parsed.attempts === "number" && Number.isInteger(parsed.attempts) && parsed.attempts > 0) update.attempts = parsed.attempts;
|
|
104
|
+
return update;
|
|
105
|
+
} catch {
|
|
106
|
+
return void 0;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function isoTime(text) {
|
|
110
|
+
const ms = Date.parse(text);
|
|
111
|
+
if (!Number.isFinite(ms)) return void 0;
|
|
112
|
+
const iso = new Date(ms).toISOString();
|
|
113
|
+
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(iso) ? iso : void 0;
|
|
114
|
+
}
|
|
115
|
+
var REASON_MAX = 500;
|
|
116
|
+
function storableReason(reason) {
|
|
117
|
+
if (reason === null) return null;
|
|
118
|
+
const cut = reason.replaceAll("\0", "").toWellFormed().slice(0, REASON_MAX);
|
|
119
|
+
const last = cut.charCodeAt(cut.length - 1);
|
|
120
|
+
const trimmed = last >= 55296 && last <= 56319 ? cut.slice(0, -1) : cut;
|
|
121
|
+
return trimmed === "" ? null : trimmed;
|
|
122
|
+
}
|
|
123
|
+
function writeLastUpdate(configPath, update) {
|
|
124
|
+
mkdirSync(runnerDir(configPath), { recursive: true, mode: RUNNER_DIR_MODE });
|
|
125
|
+
const path = lastUpdatePath(configPath);
|
|
126
|
+
const temporary = `${path}.tmp`;
|
|
127
|
+
writeFileSync(temporary, `${JSON.stringify({ ...update, reason: storableReason(update.reason) })}
|
|
128
|
+
`, { mode: RUNNER_FILE_MODE });
|
|
129
|
+
renameSync(temporary, path);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// src/runner/update/version-order.ts
|
|
133
|
+
function parse(version) {
|
|
134
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version);
|
|
135
|
+
if (match === null) return void 0;
|
|
136
|
+
return { core: [Number(match[1]), Number(match[2]), Number(match[3])], pre: match[4] };
|
|
137
|
+
}
|
|
138
|
+
function comparePre(a, b) {
|
|
139
|
+
const left = a.split(".");
|
|
140
|
+
const right = b.split(".");
|
|
141
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
142
|
+
const x = left[i];
|
|
143
|
+
const y = right[i];
|
|
144
|
+
if (x === void 0) return -1;
|
|
145
|
+
if (y === void 0) return 1;
|
|
146
|
+
const xNum = /^\d+$/.test(x);
|
|
147
|
+
const yNum = /^\d+$/.test(y);
|
|
148
|
+
if (xNum && yNum) {
|
|
149
|
+
const difference = Number(x) - Number(y);
|
|
150
|
+
if (difference !== 0) return Math.sign(difference);
|
|
151
|
+
} else if (xNum !== yNum) {
|
|
152
|
+
return xNum ? -1 : 1;
|
|
153
|
+
} else if (x < y) {
|
|
154
|
+
return -1;
|
|
155
|
+
} else if (x > y) {
|
|
156
|
+
return 1;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
function compareVersions(a, b) {
|
|
162
|
+
const left = parse(a);
|
|
163
|
+
const right = parse(b);
|
|
164
|
+
if (left === void 0 || right === void 0) return void 0;
|
|
165
|
+
for (let i = 0; i < 3; i++) {
|
|
166
|
+
const difference = left.core[i] - right.core[i];
|
|
167
|
+
if (difference !== 0) return Math.sign(difference);
|
|
168
|
+
}
|
|
169
|
+
if (left.pre === right.pre) return 0;
|
|
170
|
+
if (left.pre === void 0) return 1;
|
|
171
|
+
if (right.pre === void 0) return -1;
|
|
172
|
+
return comparePre(left.pre, right.pre);
|
|
173
|
+
}
|
|
174
|
+
function compareCores(a, b) {
|
|
175
|
+
for (let i = 0; i < 3; i++) {
|
|
176
|
+
const difference = a[i] - b[i];
|
|
177
|
+
if (difference !== 0) return Math.sign(difference);
|
|
178
|
+
}
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
function parsePartial(text) {
|
|
182
|
+
if (text === "" || text === "*" || text === "x" || text === "X") return [];
|
|
183
|
+
const match = /^v?(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?$/.exec(text);
|
|
184
|
+
if (match === null) return void 0;
|
|
185
|
+
const numbers = [];
|
|
186
|
+
for (const part of [match[1], match[2], match[3]]) {
|
|
187
|
+
if (part === void 0 || !/^\d+$/.test(part)) break;
|
|
188
|
+
numbers.push(Number(part));
|
|
189
|
+
}
|
|
190
|
+
return numbers;
|
|
191
|
+
}
|
|
192
|
+
function bumpLast(numbers) {
|
|
193
|
+
const core = [numbers[0] ?? 0, numbers[1] ?? 0, numbers[2] ?? 0];
|
|
194
|
+
const last = numbers.length - 1;
|
|
195
|
+
core[last] = core[last] + 1;
|
|
196
|
+
for (let i = last + 1; i < 3; i++) core[i] = 0;
|
|
197
|
+
return core;
|
|
198
|
+
}
|
|
199
|
+
function fill(numbers) {
|
|
200
|
+
return [numbers[0] ?? 0, numbers[1] ?? 0, numbers[2] ?? 0];
|
|
201
|
+
}
|
|
202
|
+
function meets(version, comparator) {
|
|
203
|
+
const match = /^(>=|<=|>|<|=|\^|~)?(.*)$/.exec(comparator);
|
|
204
|
+
const op = match[1] ?? "";
|
|
205
|
+
const numbers = parsePartial(match[2]);
|
|
206
|
+
if (numbers === void 0) return void 0;
|
|
207
|
+
if (numbers.length === 0) return op === "" || op === "=" || op === ">=" || op === "<=" ? true : void 0;
|
|
208
|
+
const low = fill(numbers);
|
|
209
|
+
const whole = numbers.length === 3;
|
|
210
|
+
switch (op) {
|
|
211
|
+
case ">=":
|
|
212
|
+
return compareCores(version, low) >= 0;
|
|
213
|
+
case ">":
|
|
214
|
+
return whole ? compareCores(version, low) > 0 : compareCores(version, bumpLast(numbers)) >= 0;
|
|
215
|
+
case "<":
|
|
216
|
+
return compareCores(version, low) < 0;
|
|
217
|
+
case "<=":
|
|
218
|
+
return whole ? compareCores(version, low) <= 0 : compareCores(version, bumpLast(numbers)) < 0;
|
|
219
|
+
case "~":
|
|
220
|
+
return compareCores(version, low) >= 0 && compareCores(version, bumpLast(numbers.slice(0, Math.min(numbers.length, 2)))) < 0;
|
|
221
|
+
case "^": {
|
|
222
|
+
let pivot = numbers.findIndex((n) => n !== 0);
|
|
223
|
+
if (pivot === -1) pivot = numbers.length - 1;
|
|
224
|
+
return compareCores(version, low) >= 0 && compareCores(version, bumpLast(numbers.slice(0, pivot + 1))) < 0;
|
|
225
|
+
}
|
|
226
|
+
default:
|
|
227
|
+
return compareCores(version, low) >= 0 && (whole ? compareCores(version, low) <= 0 : compareCores(version, bumpLast(numbers)) < 0);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function satisfiesRange(version, range) {
|
|
231
|
+
const parsed = parse(version.trim().replace(/^v/, ""));
|
|
232
|
+
if (parsed === void 0 || parsed.pre !== void 0) return void 0;
|
|
233
|
+
let anyUnreadable = false;
|
|
234
|
+
for (const alternative of range.split("||")) {
|
|
235
|
+
if (/\s-\s/.test(alternative)) {
|
|
236
|
+
anyUnreadable = true;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const comparators = alternative.trim().replace(/(>=|<=|>|<|=|\^|~)\s+/g, "$1").split(/\s+/).filter((c) => c !== "");
|
|
240
|
+
if (comparators.length === 0) comparators.push("*");
|
|
241
|
+
let all = true;
|
|
242
|
+
for (const comparator of comparators) {
|
|
243
|
+
const met = meets(parsed.core, comparator);
|
|
244
|
+
if (met === void 0) {
|
|
245
|
+
anyUnreadable = true;
|
|
246
|
+
all = false;
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
if (!met) {
|
|
250
|
+
all = false;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (all) return true;
|
|
255
|
+
}
|
|
256
|
+
return anyUnreadable ? void 0 : false;
|
|
257
|
+
}
|
|
258
|
+
var RELEASE_REPOSITORY = "https://github.com/outerlayerai/outerlayer";
|
|
259
|
+
var RELEASE_WORKFLOW_PATH = ".github/workflows/outerlayer-release.yml";
|
|
260
|
+
var UpdateStepError = class extends Error {
|
|
261
|
+
constructor(reason, detail) {
|
|
262
|
+
super(reason);
|
|
263
|
+
this.reason = reason;
|
|
264
|
+
this.detail = detail;
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
function failed(reason) {
|
|
268
|
+
return { ok: false, outcome: "verification_failed", reason };
|
|
269
|
+
}
|
|
270
|
+
async function verifyVersion(configPath, version, deps) {
|
|
271
|
+
const staging = stagingDir(configPath, version);
|
|
272
|
+
rmSync(staging, { recursive: true, force: true });
|
|
273
|
+
try {
|
|
274
|
+
const result = await verifyInto(staging, version, deps);
|
|
275
|
+
if (!result.ok) rmSync(staging, { recursive: true, force: true });
|
|
276
|
+
return result;
|
|
277
|
+
} catch (err) {
|
|
278
|
+
rmSync(staging, { recursive: true, force: true });
|
|
279
|
+
throw err;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async function verifyInto(staging, version, deps) {
|
|
283
|
+
const meta = await fetchVersionMeta(version, deps.fetchImpl);
|
|
284
|
+
if (meta.kind === "not_published") {
|
|
285
|
+
return { ok: false, outcome: "not_published", reason: `the registry has no @outerlayer/cli ${version}` };
|
|
286
|
+
}
|
|
287
|
+
if (!meta.integrity.startsWith("sha512-")) return failed(`the registry's integrity for ${version} is not a sha512 digest`);
|
|
288
|
+
const expected = meta.integrity.slice("sha512-".length);
|
|
289
|
+
const digest = await fetchTarballDigest(meta.tarball, deps.fetchImpl);
|
|
290
|
+
if (digest !== expected) return failed(`the tarball for ${version} does not match the registry's integrity digest`);
|
|
291
|
+
const provenance = await fetchProvenance(version, deps.fetchImpl);
|
|
292
|
+
if (provenance === void 0) return failed(`${version} has no provenance attestation`);
|
|
293
|
+
if (provenance.repository !== RELEASE_REPOSITORY) {
|
|
294
|
+
return failed(`the provenance of ${version} names the repository ${provenance.repository}, not ${RELEASE_REPOSITORY}`);
|
|
295
|
+
}
|
|
296
|
+
if (provenance.workflowPath !== RELEASE_WORKFLOW_PATH) {
|
|
297
|
+
return failed(`the provenance of ${version} names the workflow ${provenance.workflowPath}, not ${RELEASE_WORKFLOW_PATH}`);
|
|
298
|
+
}
|
|
299
|
+
if (Buffer.from(provenance.subjectSha512, "hex").toString("base64") !== digest) {
|
|
300
|
+
return failed(`the provenance of ${version} covers a different package than the tarball`);
|
|
301
|
+
}
|
|
302
|
+
mkdirSync(staging, { recursive: true, mode: RUNNER_DIR_MODE });
|
|
303
|
+
const npm = await npmVersion(deps.runNpm, staging);
|
|
304
|
+
if (npm === void 0 || !npmSupportsProvenance(npm)) {
|
|
305
|
+
throw new Error(`npm ${npm ?? "(not found)"} cannot verify provenance; 9.5 or later is needed`);
|
|
306
|
+
}
|
|
307
|
+
const install = await installVersion(deps.runNpm, staging, version);
|
|
308
|
+
if (install.status !== 0) {
|
|
309
|
+
throw new UpdateStepError(`npm install of ${version} failed with exit ${install.status}`, `npm install of ${version} failed: ${install.stderr.trim().slice(0, 500)}`);
|
|
310
|
+
}
|
|
311
|
+
const audit = await auditSignatures(deps.runNpm, staging);
|
|
312
|
+
if (!audit.ok) {
|
|
313
|
+
if (!audit.verdict) throw new Error(audit.reason);
|
|
314
|
+
return failed(audit.reason);
|
|
315
|
+
}
|
|
316
|
+
const locked = lockedIntegrity(staging);
|
|
317
|
+
if (locked !== meta.integrity) return failed(`the installed package's integrity does not match the verified tarball's`);
|
|
318
|
+
const smoke = await deps.runNode([entryIn(staging), "--version"], staging);
|
|
319
|
+
if (smoke.status !== 0) {
|
|
320
|
+
throw new UpdateStepError(
|
|
321
|
+
`the installed ${version} exited ${smoke.status} when run`,
|
|
322
|
+
`the installed ${version} exited ${smoke.status} when run: ${(smoke.stderr.trim() || smoke.stdout.trim()).slice(0, 500)}`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (!smoke.stdout.trim().startsWith(version)) return failed(`the installed ${version} did not print its version when run`);
|
|
326
|
+
return { ok: true, dir: staging };
|
|
327
|
+
}
|
|
328
|
+
function lockedIntegrity(staging) {
|
|
329
|
+
try {
|
|
330
|
+
const lock = JSON.parse(readFileSync(join(staging, "package-lock.json"), "utf8"));
|
|
331
|
+
return lock.packages["node_modules/@outerlayer/cli"].integrity;
|
|
332
|
+
} catch {
|
|
333
|
+
return void 0;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// src/runner/update/self-update.ts
|
|
338
|
+
var RESTART_EXIT_STATUS = 75;
|
|
339
|
+
var MAX_MARKER_STARTS = 3;
|
|
340
|
+
var RETRY_FIRST_WAIT_MS = 5 * 6e4;
|
|
341
|
+
var RETRY_MAX_WAIT_MS = 6 * 60 * 6e4;
|
|
342
|
+
function retryAfterMs(record) {
|
|
343
|
+
const at = record.at === void 0 ? Number.NaN : Date.parse(record.at);
|
|
344
|
+
if (!Number.isFinite(at)) return 0;
|
|
345
|
+
const failures = Math.max(1, record.attempts ?? 1);
|
|
346
|
+
return at + Math.min(RETRY_FIRST_WAIT_MS * 2 ** (failures - 1), RETRY_MAX_WAIT_MS);
|
|
347
|
+
}
|
|
348
|
+
function decide(input) {
|
|
349
|
+
const order = compareVersions(input.named, input.own);
|
|
350
|
+
if (order === void 0 || order <= 0) return "none";
|
|
351
|
+
if (!input.autoUpdate) return "none";
|
|
352
|
+
if (!input.fromCurrent) return "cannot_self_update";
|
|
353
|
+
const { record } = input;
|
|
354
|
+
if (record !== void 0 && record.to === input.named) {
|
|
355
|
+
if (record.outcome === "verification_failed" || record.outcome === "rolled_back") return "skip_known_bad";
|
|
356
|
+
if (record.outcome === "retrying" && input.nowMs < retryAfterMs(record)) return "retry_later";
|
|
357
|
+
}
|
|
358
|
+
return "update";
|
|
359
|
+
}
|
|
360
|
+
function messageOf(err) {
|
|
361
|
+
return err instanceof Error ? err.message : String(err);
|
|
362
|
+
}
|
|
363
|
+
function isoAt(now) {
|
|
364
|
+
return new Date(now()).toISOString();
|
|
365
|
+
}
|
|
366
|
+
function retryFailure(version, err) {
|
|
367
|
+
if (err instanceof UpdateStepError) return { outcome: "retrying", reason: err.reason, detail: err.detail };
|
|
368
|
+
const code = err?.code;
|
|
369
|
+
if (typeof code === "string" && err.syscall !== void 0) {
|
|
370
|
+
return { outcome: "retrying", reason: `could not install ${version}: ${code}`, detail: messageOf(err) };
|
|
371
|
+
}
|
|
372
|
+
return { outcome: "retrying", reason: messageOf(err) };
|
|
373
|
+
}
|
|
374
|
+
function finish(args, failure) {
|
|
375
|
+
let update = { from: args.own, to: args.version, outcome: failure.outcome, reason: failure.reason, at: isoAt(args.now) };
|
|
376
|
+
if (failure.outcome === "retrying") {
|
|
377
|
+
const previous = readLastUpdate(args.configPath);
|
|
378
|
+
const attempts = previous?.to === args.version && previous.outcome === "retrying" ? (previous.attempts ?? 1) + 1 : 1;
|
|
379
|
+
update = { ...update, attempts };
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
writeLastUpdate(args.configPath, update);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
args.log.error(`could not record the update attempt: ${messageOf(err)}`);
|
|
385
|
+
}
|
|
386
|
+
if (failure.outcome === "retrying") {
|
|
387
|
+
return {
|
|
388
|
+
kind: "retrying",
|
|
389
|
+
reason: failure.reason,
|
|
390
|
+
detail: failure.detail ?? failure.reason,
|
|
391
|
+
attempts: update.attempts ?? 1,
|
|
392
|
+
retryAtMs: retryAfterMs(update)
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
return { kind: "failed", outcome: failure.outcome, reason: failure.reason };
|
|
396
|
+
}
|
|
397
|
+
async function earlyFailure(args) {
|
|
398
|
+
const current = currentVersion(args.configPath);
|
|
399
|
+
if (current !== args.own) return { outcome: "retrying", reason: `current names ${current ?? "nothing"}, not the running ${args.own}` };
|
|
400
|
+
let meta;
|
|
401
|
+
try {
|
|
402
|
+
meta = await fetchVersionMeta(args.version, args.deps.fetchImpl);
|
|
403
|
+
} catch (err) {
|
|
404
|
+
return retryFailure(args.version, err);
|
|
405
|
+
}
|
|
406
|
+
if (meta.kind === "not_published") return { outcome: "not_published", reason: `the registry has no @outerlayer/cli ${args.version}` };
|
|
407
|
+
const node = args.deps.nodeVersion ?? process.version;
|
|
408
|
+
if (meta.nodeRange !== void 0 && satisfiesRange(node, meta.nodeRange) === false) {
|
|
409
|
+
return {
|
|
410
|
+
outcome: "retrying",
|
|
411
|
+
reason: `${args.version} needs Node ${meta.nodeRange}, which this host does not run`,
|
|
412
|
+
detail: `${args.version} needs Node ${meta.nodeRange}; this host runs ${node}`
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
const npm = await npmVersion(args.deps.runNpm, dirname(args.configPath));
|
|
416
|
+
if (npm === void 0 || !npmSupportsProvenance(npm)) {
|
|
417
|
+
return {
|
|
418
|
+
outcome: "retrying",
|
|
419
|
+
reason: "npm on this host cannot verify provenance; 9.5 or later is needed",
|
|
420
|
+
detail: `npm ${npm ?? "(not found)"} cannot verify provenance; 9.5 or later is needed`
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
return void 0;
|
|
424
|
+
}
|
|
425
|
+
async function preflightUpdate(args) {
|
|
426
|
+
const failure = await earlyFailure(args);
|
|
427
|
+
return failure === void 0 ? void 0 : finish(args, failure);
|
|
428
|
+
}
|
|
429
|
+
async function performUpdate(args) {
|
|
430
|
+
const { configPath, own, version, deps } = args;
|
|
431
|
+
const early = await earlyFailure(args);
|
|
432
|
+
if (early !== void 0) return finish(args, early);
|
|
433
|
+
let verified;
|
|
434
|
+
try {
|
|
435
|
+
verified = await verifyVersion(configPath, version, deps);
|
|
436
|
+
} catch (err) {
|
|
437
|
+
return finish(args, retryFailure(version, err));
|
|
438
|
+
}
|
|
439
|
+
if (!verified.ok) return finish(args, { outcome: verified.outcome, reason: verified.reason });
|
|
440
|
+
const staged = await deps.runNode([entryIn(verified.dir), "runner", "check", "--config", configPath], verified.dir);
|
|
441
|
+
if (staged.status !== 0) {
|
|
442
|
+
try {
|
|
443
|
+
rmSync(verified.dir, { recursive: true, force: true });
|
|
444
|
+
} catch {
|
|
445
|
+
}
|
|
446
|
+
return finish(args, {
|
|
447
|
+
outcome: "retrying",
|
|
448
|
+
reason: `${version} failed \`runner check\` on this host (exit ${staged.status})`,
|
|
449
|
+
detail: `${version} failed \`runner check\` on this host: ${(staged.stderr.trim() || staged.stdout.trim()).slice(0, 500)}`
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
if (version !== own) rmSync(versionDir(configPath, version), { recursive: true, force: true });
|
|
454
|
+
renameSync(verified.dir, versionDir(configPath, version));
|
|
455
|
+
writeMarker(configPath, { from: own, to: version, starts: 0 });
|
|
456
|
+
switchCurrent(configPath, version);
|
|
457
|
+
} catch (err) {
|
|
458
|
+
try {
|
|
459
|
+
rmSync(verified.dir, { recursive: true, force: true });
|
|
460
|
+
if (currentVersion(configPath) !== version) removeMarker(configPath);
|
|
461
|
+
} catch {
|
|
462
|
+
}
|
|
463
|
+
return finish(args, retryFailure(version, err));
|
|
464
|
+
}
|
|
465
|
+
try {
|
|
466
|
+
cleanVersions(configPath, [version, own]);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
args.log.error(`could not remove an old version from the install layout: ${messageOf(err)}`);
|
|
469
|
+
}
|
|
470
|
+
args.log.line(`updating ${own} -> ${version} \xB7 restarting`);
|
|
471
|
+
return { kind: "switched" };
|
|
472
|
+
}
|
|
473
|
+
function rollBack(configPath, marker, reason, detail, now, log) {
|
|
474
|
+
try {
|
|
475
|
+
writeLastUpdate(configPath, { from: marker.from, to: marker.to, outcome: "rolled_back", reason, at: isoAt(now) });
|
|
476
|
+
} catch (err) {
|
|
477
|
+
log.error(`could not record the rollback: ${messageOf(err)}`);
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
switchCurrent(configPath, marker.from);
|
|
481
|
+
removeMarker(configPath);
|
|
482
|
+
} catch (err) {
|
|
483
|
+
log.error(`could not put ${marker.from} back as current: ${messageOf(err)}`);
|
|
484
|
+
}
|
|
485
|
+
log.error(`rolled back ${marker.to} -> ${marker.from}: ${detail}`);
|
|
486
|
+
}
|
|
487
|
+
async function onStartUnderMarker(args) {
|
|
488
|
+
const { configPath, own, now, log } = args;
|
|
489
|
+
const marker = readMarker(configPath);
|
|
490
|
+
if (marker === void 0) return "proceed";
|
|
491
|
+
if (marker.to !== own) {
|
|
492
|
+
const switchedAway = marker.from === own && currentVersion(configPath) === marker.to;
|
|
493
|
+
const recorded = readLastUpdate(configPath);
|
|
494
|
+
if (switchedAway && !(recorded?.to === marker.to && recorded.outcome === "rolled_back")) {
|
|
495
|
+
const reason = `the service started ${own}, not ${marker.to}: it does not start the runner through current`;
|
|
496
|
+
rollBack(configPath, marker, reason, reason, now, log);
|
|
497
|
+
return "proceed";
|
|
498
|
+
}
|
|
499
|
+
removeMarker(configPath);
|
|
500
|
+
return "proceed";
|
|
501
|
+
}
|
|
502
|
+
const starts = marker.starts + 1;
|
|
503
|
+
if (starts > MAX_MARKER_STARTS) {
|
|
504
|
+
const reason = `${marker.to} started ${marker.starts} times without completing its first poll`;
|
|
505
|
+
rollBack(configPath, marker, reason, reason, now, log);
|
|
506
|
+
return "restart";
|
|
507
|
+
}
|
|
508
|
+
writeMarker(configPath, { ...marker, starts });
|
|
509
|
+
const result = await args.check();
|
|
510
|
+
if (!result.ok) {
|
|
511
|
+
rollBack(configPath, marker, `${result.check} failed`, `${result.check} failed: ${result.message}`, now, log);
|
|
512
|
+
return "restart";
|
|
513
|
+
}
|
|
514
|
+
return "proceed";
|
|
515
|
+
}
|
|
516
|
+
function settleUpdate(args) {
|
|
517
|
+
const marker = readMarker(args.configPath);
|
|
518
|
+
if (marker === void 0 || marker.to !== args.own) return;
|
|
519
|
+
try {
|
|
520
|
+
removeMarker(args.configPath);
|
|
521
|
+
writeLastUpdate(args.configPath, { from: marker.from, to: marker.to, outcome: "updated", reason: null, at: isoAt(args.now) });
|
|
522
|
+
args.log.line(`updated ${marker.from} -> ${marker.to}`);
|
|
523
|
+
} catch (err) {
|
|
524
|
+
args.log.error(`could not keep the update to ${marker.to}: ${messageOf(err)}`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// src/runner/loop.ts
|
|
529
|
+
var QUEUE_ORDER = ["amend", "implement"];
|
|
530
|
+
var TRACKER_REPOSITORY_HOSTS = {
|
|
531
|
+
github: "github.com",
|
|
532
|
+
gitlab: "gitlab.com"
|
|
533
|
+
};
|
|
534
|
+
function logOf(state) {
|
|
535
|
+
state.deps.log ??= createRunnerLog(state.deps.now ?? (() => /* @__PURE__ */ new Date()));
|
|
536
|
+
return state.deps.log;
|
|
537
|
+
}
|
|
538
|
+
function nowOf(state) {
|
|
539
|
+
return (state.deps.now ?? (() => /* @__PURE__ */ new Date()))();
|
|
540
|
+
}
|
|
541
|
+
function runtimeOf(state) {
|
|
542
|
+
state.containerRuntime ??= createContainerRuntime(state.deps.container);
|
|
543
|
+
return state.containerRuntime;
|
|
544
|
+
}
|
|
545
|
+
function finishDepsOf(state) {
|
|
546
|
+
return {
|
|
547
|
+
fetchImpl: state.deps.fetchImpl,
|
|
548
|
+
spawnFn: state.deps.spawnFn,
|
|
549
|
+
now: state.deps.now,
|
|
550
|
+
sleep: state.deps.sleep,
|
|
551
|
+
log: logOf(state),
|
|
552
|
+
cliVersion: state.deps.cliVersion,
|
|
553
|
+
// Any job that has a container needs the runtime to stop and remove it, including one a config change left behind.
|
|
554
|
+
container: runtimeOf(state)
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
var DRAIN_LOG_EVERY_PASSES = 30;
|
|
558
|
+
var REPEAT_EVERY_TICKS = 10;
|
|
559
|
+
function reportRepeating(state, key, message, level = "error") {
|
|
560
|
+
const log = logOf(state);
|
|
561
|
+
const repeats = state.repeats ??= /* @__PURE__ */ new Map();
|
|
562
|
+
const seen = repeats.get(key);
|
|
563
|
+
if (message === void 0) {
|
|
564
|
+
if (seen) {
|
|
565
|
+
if (level === "error") log.line(`recovered: ${key} is working again, after ${seen.ticks} tick(s) failing`);
|
|
566
|
+
repeats.delete(key);
|
|
567
|
+
}
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (!seen || seen.message !== message) {
|
|
571
|
+
repeats.set(key, { message, ticks: 1 });
|
|
572
|
+
log[level](message);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
seen.ticks++;
|
|
576
|
+
if (seen.ticks % REPEAT_EVERY_TICKS === 0) {
|
|
577
|
+
log[level](`still failing: ${message} (${seen.ticks} ticks)`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
function deriveRepo(item) {
|
|
581
|
+
const host = item.tracker === void 0 ? void 0 : TRACKER_REPOSITORY_HOSTS[item.tracker];
|
|
582
|
+
if (host === void 0) return void 0;
|
|
583
|
+
const hash = item.trackerKey.lastIndexOf("#");
|
|
584
|
+
const repoPart = hash === -1 ? item.trackerKey : item.trackerKey.slice(0, hash);
|
|
585
|
+
return repoPart === "" ? void 0 : `${host}/${repoPart}`;
|
|
586
|
+
}
|
|
587
|
+
async function listStartable(config, queue, fetchImpl) {
|
|
588
|
+
const query = queue === "amend" ? "needs=amend&startable=true" : "startable=true";
|
|
589
|
+
const page = await getJson(
|
|
590
|
+
config.url,
|
|
591
|
+
`/v1/work-items?${query}`,
|
|
592
|
+
config.apiKey,
|
|
593
|
+
config.appId,
|
|
594
|
+
fetchImpl,
|
|
595
|
+
await signerFor(config.configPath)
|
|
596
|
+
);
|
|
597
|
+
return Array.isArray(page.data) ? page.data : [];
|
|
598
|
+
}
|
|
599
|
+
function reloadConfig(state) {
|
|
600
|
+
let mtimeMs;
|
|
601
|
+
try {
|
|
602
|
+
mtimeMs = statSync(state.config.configPath).mtimeMs;
|
|
603
|
+
} catch {
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (state.configMtimeMs === mtimeMs) return;
|
|
607
|
+
state.configMtimeMs = mtimeMs;
|
|
608
|
+
try {
|
|
609
|
+
const reloaded = loadRunnerConfig(state.config.configPath);
|
|
610
|
+
const hookProblem = checkHooks(reloaded);
|
|
611
|
+
if (hookProblem) {
|
|
612
|
+
logOf(state).error(`config re-read rejected: ${hookProblem} \xB7 keeping the last good config`);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
state.config = reloaded;
|
|
616
|
+
const timerNote = state.pollTimerSeconds !== void 0 && state.pollTimerSeconds !== reloaded.pollSeconds ? ` \xB7 still polling every ${state.pollTimerSeconds}s until the runner restarts` : "";
|
|
617
|
+
logOf(state).line(`config re-read \xB7 ${describeConfig(reloaded)}${timerNote}`);
|
|
618
|
+
} catch (err) {
|
|
619
|
+
logOf(state).error(`config re-read rejected: ${describeCallFailure(err)} \xB7 keeping the last good config`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
function describeConfig(config) {
|
|
623
|
+
return `queues ${config.queues.join(",")} \xB7 concurrency ${config.concurrency} \xB7 poll ${config.pollSeconds}s`;
|
|
624
|
+
}
|
|
625
|
+
async function sendTickHeartbeat(state, workRequest) {
|
|
626
|
+
if (state.heartbeating) {
|
|
627
|
+
if (workRequest !== void 0) state.queuedWorkRequest = workRequest;
|
|
628
|
+
reportRepeating(state, "the heartbeat's previous call", "heartbeat held: the previous heartbeat is still in flight", "line");
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
state.heartbeating = true;
|
|
632
|
+
try {
|
|
633
|
+
let next = workRequest;
|
|
634
|
+
for (; ; ) {
|
|
635
|
+
reportRepeating(state, "the heartbeat's previous call", void 0, "line");
|
|
636
|
+
const result = await sendHeartbeat(
|
|
637
|
+
{
|
|
638
|
+
configPath: state.config.configPath,
|
|
639
|
+
url: state.config.url,
|
|
640
|
+
apiKey: state.config.apiKey,
|
|
641
|
+
appId: state.config.appId,
|
|
642
|
+
host: state.config.host,
|
|
643
|
+
cliVersion: state.deps.cliVersion ?? resolveVersion(),
|
|
644
|
+
pollSeconds: state.pollTimerSeconds ?? state.config.pollSeconds,
|
|
645
|
+
slotsUsed: state.jobs.size,
|
|
646
|
+
slotsTotal: state.config.concurrency,
|
|
647
|
+
workRequest: next,
|
|
648
|
+
lastUpdate: readLastUpdate(state.config.configPath)
|
|
649
|
+
},
|
|
650
|
+
state.deps.fetchImpl
|
|
651
|
+
);
|
|
652
|
+
reportRepeating(state, "the heartbeat", result.ok ? void 0 : `heartbeat failed: ${result.reason}`);
|
|
653
|
+
if (result.ok) considerNamedVersion(state, result.runnerVersion);
|
|
654
|
+
next = state.queuedWorkRequest;
|
|
655
|
+
state.queuedWorkRequest = void 0;
|
|
656
|
+
if (next === void 0) break;
|
|
657
|
+
}
|
|
658
|
+
} finally {
|
|
659
|
+
state.heartbeating = false;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
function ownVersionOf(deps) {
|
|
663
|
+
return (deps.cliVersion ?? resolveVersion()).split(" ")[0] ?? "";
|
|
664
|
+
}
|
|
665
|
+
function selfUpdateBlocker(configPath, entryPath, own) {
|
|
666
|
+
const reinstall = "run `outerlayer runner install` and start the runner from the ExecStart line it prints";
|
|
667
|
+
if (!isInLayout(configPath, entryPath)) return `it was not started from its install layout \xB7 ${reinstall}`;
|
|
668
|
+
if (!startedThroughCurrent(configPath, entryPath)) {
|
|
669
|
+
return `its service starts a version directory instead of \`current\`, so a switch would not change what runs \xB7 ${reinstall}`;
|
|
670
|
+
}
|
|
671
|
+
const current = currentVersion(configPath);
|
|
672
|
+
if (current !== own) return `\`current\` names ${current ?? "nothing"}, not the running ${own} \xB7 restart the runner`;
|
|
673
|
+
return void 0;
|
|
674
|
+
}
|
|
675
|
+
function considerNamedVersion(state, named) {
|
|
676
|
+
const update = state.deps.update;
|
|
677
|
+
if (update === void 0 || named === void 0) return;
|
|
678
|
+
const configPath = state.config.configPath;
|
|
679
|
+
const own = ownVersionOf(state.deps);
|
|
680
|
+
const blocker = selfUpdateBlocker(configPath, update.entryPath, own);
|
|
681
|
+
const decision = decide({
|
|
682
|
+
own,
|
|
683
|
+
named,
|
|
684
|
+
autoUpdate: state.config.autoUpdate,
|
|
685
|
+
fromCurrent: blocker === void 0,
|
|
686
|
+
record: readLastUpdate(configPath),
|
|
687
|
+
nowMs: nowOf(state).getTime()
|
|
688
|
+
});
|
|
689
|
+
const updating = decision === "update" && !retryHeld(state, named);
|
|
690
|
+
state.pendingUpdate = updating ? named : void 0;
|
|
691
|
+
if (!updating || state.preflightedUpdate !== named) state.preflightedUpdate = void 0;
|
|
692
|
+
if (decision === "cannot_self_update" && !state.cannotSelfUpdateLogged) {
|
|
693
|
+
state.cannotSelfUpdateLogged = true;
|
|
694
|
+
logOf(state).line(`this runner cannot update itself to ${named}: ${blocker}`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
function retryHeld(state, version) {
|
|
698
|
+
return state.updateRetry?.version === version && nowOf(state).getTime() < state.updateRetry.retryAtMs;
|
|
699
|
+
}
|
|
700
|
+
function updateArgsOf(state, update, version) {
|
|
701
|
+
return {
|
|
702
|
+
configPath: state.config.configPath,
|
|
703
|
+
own: ownVersionOf(state.deps),
|
|
704
|
+
version,
|
|
705
|
+
deps: update,
|
|
706
|
+
now: () => nowOf(state).getTime(),
|
|
707
|
+
log: logOf(state)
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
function dropPendingUpdate(state) {
|
|
711
|
+
state.pendingUpdate = void 0;
|
|
712
|
+
state.preflightedUpdate = void 0;
|
|
713
|
+
}
|
|
714
|
+
function reportUpdateResult(state, version, result) {
|
|
715
|
+
if (result.kind === "retrying") state.updateRetry = { version, retryAtMs: result.retryAtMs };
|
|
716
|
+
if (result.kind === "failed") {
|
|
717
|
+
if (result.outcome === "verification_failed") logOf(state).error(`not updating to ${version}: ${result.reason}`);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
const retryAt = new Date(result.retryAtMs).toISOString().slice(11, 16);
|
|
721
|
+
logOf(state).error(`could not update to ${version}: ${result.detail} \xB7 attempt ${result.attempts}, trying again after ${retryAt} UTC`);
|
|
722
|
+
}
|
|
723
|
+
async function updateWhenIdle(state) {
|
|
724
|
+
const update = state.deps.update;
|
|
725
|
+
const version = state.pendingUpdate;
|
|
726
|
+
if (update === void 0 || version === void 0) return false;
|
|
727
|
+
if (!state.config.autoUpdate || retryHeld(state, version)) {
|
|
728
|
+
dropPendingUpdate(state);
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
731
|
+
if (state.jobs.size > 0) {
|
|
732
|
+
const record = readLastUpdate(state.config.configPath);
|
|
733
|
+
if (record?.to === version && record.outcome === "retrying") {
|
|
734
|
+
const failedAtMs = record.at === void 0 ? Number.NaN : Date.parse(record.at);
|
|
735
|
+
if (!(nowOf(state).getTime() >= failedAtMs + RETRY_MAX_WAIT_MS)) return false;
|
|
736
|
+
}
|
|
737
|
+
if (state.preflightedUpdate === version) return true;
|
|
738
|
+
const early = await preflightUpdate(updateArgsOf(state, update, version));
|
|
739
|
+
if (early !== void 0) {
|
|
740
|
+
dropPendingUpdate(state);
|
|
741
|
+
reportUpdateResult(state, version, early);
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
state.preflightedUpdate = version;
|
|
745
|
+
return true;
|
|
746
|
+
}
|
|
747
|
+
dropPendingUpdate(state);
|
|
748
|
+
const result = await performUpdate(updateArgsOf(state, update, version));
|
|
749
|
+
if (result.kind === "switched") {
|
|
750
|
+
(state.exitForUpdate ?? (() => update.exit(RESTART_EXIT_STATUS)))();
|
|
751
|
+
return true;
|
|
752
|
+
}
|
|
753
|
+
reportUpdateResult(state, version, result);
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
async function tick(state) {
|
|
757
|
+
if (state.ticking) {
|
|
758
|
+
await sendTickHeartbeat(state, void 0);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
state.ticking = true;
|
|
762
|
+
state.lastWorkRequest = void 0;
|
|
763
|
+
try {
|
|
764
|
+
await runTick(state);
|
|
765
|
+
} catch (err) {
|
|
766
|
+
logOf(state).error(`unexpected error: ${describeCallFailure(err)}`);
|
|
767
|
+
} finally {
|
|
768
|
+
state.ticking = false;
|
|
769
|
+
}
|
|
770
|
+
await sendTickHeartbeat(state, state.lastWorkRequest);
|
|
771
|
+
}
|
|
772
|
+
async function runTick(state) {
|
|
773
|
+
reloadConfig(state);
|
|
774
|
+
if (await updateWhenIdle(state)) return;
|
|
775
|
+
const log = logOf(state);
|
|
776
|
+
const repoFilter = new RepoFilter(state.config.repos);
|
|
777
|
+
let filtered = 0;
|
|
778
|
+
let attempts = 0;
|
|
779
|
+
while (state.jobs.size < state.config.concurrency && attempts < state.config.concurrency) {
|
|
780
|
+
attempts++;
|
|
781
|
+
let claimed = false;
|
|
782
|
+
for (const queue of QUEUE_ORDER) {
|
|
783
|
+
if (!state.config.queues.includes(queue)) continue;
|
|
784
|
+
let items;
|
|
785
|
+
try {
|
|
786
|
+
items = await listStartable(state.config, queue, state.deps.fetchImpl);
|
|
787
|
+
reportRepeating(state, "the work item list", void 0);
|
|
788
|
+
state.lastWorkRequest = { ok: true };
|
|
789
|
+
} catch (err) {
|
|
790
|
+
state.lastWorkRequest = workRequestFromFailure(err);
|
|
791
|
+
reportRepeating(state, "the work item list", `list failed: ${describeCallFailure(err)} \xB7 retrying next tick`);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
for (const item of items) {
|
|
795
|
+
if (item.startable === false) continue;
|
|
796
|
+
if (!repoFilter.allows(deriveRepo(item))) {
|
|
797
|
+
filtered++;
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
const attempt = await tryClaimAndStart(state, item, queue);
|
|
801
|
+
if (attempt.kind === "started") {
|
|
802
|
+
claimed = true;
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
if (attempt.kind === "outdated" || attempt.kind === "signature" || attempt.kind === "no_item_key" || attempt.kind === "no_branch") {
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (attempt.kind === "error") {
|
|
809
|
+
log.error(`claim for #${item.number} failed: ${attempt.reason} \xB7 retrying next tick`);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
if (claimed) break;
|
|
814
|
+
}
|
|
815
|
+
if (!claimed) break;
|
|
816
|
+
}
|
|
817
|
+
reportRepeating(
|
|
818
|
+
state,
|
|
819
|
+
"the repository filter",
|
|
820
|
+
filtered === 0 ? void 0 : `skipped ${filtered} listed item(s) not allowed by repos.include/exclude`,
|
|
821
|
+
"line"
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
var CLAIM_REFUSAL_CODES = new Set(
|
|
825
|
+
"pull_request_from_fork pull_request_on_default_branch pull_request_ambiguous branch_claimed_elsewhere".split(" ")
|
|
826
|
+
);
|
|
827
|
+
async function tryClaimAndStart(state, item, queue) {
|
|
828
|
+
const { postJson: postJson2 } = await import('./work-cmd-UOQO2AD4.js');
|
|
829
|
+
const log = logOf(state);
|
|
830
|
+
const hostKey = await loadHostKey(state.config.configPath);
|
|
831
|
+
const claim = await retrySend(
|
|
832
|
+
() => postJson2(
|
|
833
|
+
state.config.url,
|
|
834
|
+
`/v1/work-items/${item.number}/claim`,
|
|
835
|
+
state.config.apiKey,
|
|
836
|
+
state.config.appId,
|
|
837
|
+
{
|
|
838
|
+
host: state.config.host,
|
|
839
|
+
kind: queue,
|
|
840
|
+
seconds: LEASE_SECONDS,
|
|
841
|
+
runnerProtocol: RUNNER_PROTOCOL,
|
|
842
|
+
...hostKey === void 0 ? {} : { hostKey: hostKey.hostKey }
|
|
843
|
+
},
|
|
844
|
+
state.deps.fetchImpl,
|
|
845
|
+
void 0,
|
|
846
|
+
hostKey?.signer
|
|
847
|
+
),
|
|
848
|
+
{ now: state.deps.now, sleep: state.deps.sleep }
|
|
849
|
+
);
|
|
850
|
+
if (!claim.ok) {
|
|
851
|
+
if (CLAIM_REFUSAL_CODES.has(claim.code ?? "")) {
|
|
852
|
+
log.line(`#${item.number} skipped: ${claim.code} \xB7 ${claim.message ?? claim.reason}`);
|
|
853
|
+
return { kind: "held" };
|
|
854
|
+
}
|
|
855
|
+
if (claim.code === "claim_not_held" || claim.reason.includes("(409)") || /work_item_claimed|held by/i.test(claim.reason)) {
|
|
856
|
+
log.line(`#${item.number} claim refused: ${claim.reason}`);
|
|
857
|
+
return { kind: "held" };
|
|
858
|
+
}
|
|
859
|
+
state.lastWorkRequest = workRequestFromRetryFailure(claim);
|
|
860
|
+
if (isSignatureRefusal(claim.code)) {
|
|
861
|
+
reportRepeating(state, "the request signature", claim.message ?? claim.reason);
|
|
862
|
+
return { kind: "signature" };
|
|
863
|
+
}
|
|
864
|
+
if (claim.code === "runner_upgrade_required") {
|
|
865
|
+
reportRepeating(state, "the runner protocol", claim.message ?? claim.reason);
|
|
866
|
+
return { kind: "outdated" };
|
|
867
|
+
}
|
|
868
|
+
return { kind: "error", reason: `${claim.reason} (${claim.attempts} attempt(s))` };
|
|
869
|
+
}
|
|
870
|
+
reportRepeating(state, "the runner protocol", void 0);
|
|
871
|
+
reportRepeating(state, "the request signature", void 0);
|
|
872
|
+
const { data } = claim.value;
|
|
873
|
+
const claimId = data.id === void 0 || data.id === null ? "" : String(data.id);
|
|
874
|
+
if (claimId === "") {
|
|
875
|
+
return { kind: "error", reason: "the claim response carried no claim id" };
|
|
876
|
+
}
|
|
877
|
+
if (wasExtended(data)) {
|
|
878
|
+
log.line(
|
|
879
|
+
`#${item.number} skipped: the claim extended a live lease under host "${state.config.host}" \xB7 set a distinct runner.host per config file`
|
|
880
|
+
);
|
|
881
|
+
return { kind: "held" };
|
|
882
|
+
}
|
|
883
|
+
if (claimIsAlreadyRunning(state, claimId)) {
|
|
884
|
+
log.line(`#${item.number} skipped: claim ${shortClaim(claimId)} is already running here`);
|
|
885
|
+
return { kind: "held" };
|
|
886
|
+
}
|
|
887
|
+
const itemKey = typeof data.itemKey === "string" && data.itemKey !== "" ? data.itemKey : void 0;
|
|
888
|
+
if (itemKey === void 0) {
|
|
889
|
+
const released = await retrySend(
|
|
890
|
+
() => releaseLease(state.config, item.number, void 0, {}, state.deps.fetchImpl),
|
|
891
|
+
{ now: state.deps.now, sleep: state.deps.sleep }
|
|
892
|
+
);
|
|
893
|
+
reportRepeating(
|
|
894
|
+
state,
|
|
895
|
+
"the item key",
|
|
896
|
+
`the gateway returned no item key for #${item.number}; upgrade the gateway. ` + (released.ok ? "The claim was released and no build started" : `No build started, but releasing the claim failed (${released.reason}), so it stays held until its lease lapses`)
|
|
897
|
+
);
|
|
898
|
+
return { kind: "no_item_key" };
|
|
899
|
+
}
|
|
900
|
+
reportRepeating(state, "the item key", void 0);
|
|
901
|
+
const branch = typeof data.branch === "string" && data.branch !== "" ? data.branch : void 0;
|
|
902
|
+
if (branch === void 0) {
|
|
903
|
+
const released = await retrySend(
|
|
904
|
+
() => releaseLease(state.config, item.number, void 0, {}, state.deps.fetchImpl),
|
|
905
|
+
{ now: state.deps.now, sleep: state.deps.sleep }
|
|
906
|
+
);
|
|
907
|
+
reportRepeating(
|
|
908
|
+
state,
|
|
909
|
+
"the branch",
|
|
910
|
+
`the gateway named no branch for #${item.number}; upgrade the gateway. ` + (released.ok ? "The claim was released and no build started" : `No build started, but releasing the claim failed (${released.reason}), so it stays held until its lease lapses`)
|
|
911
|
+
);
|
|
912
|
+
return { kind: "no_branch" };
|
|
913
|
+
}
|
|
914
|
+
reportRepeating(state, "the branch", void 0);
|
|
915
|
+
const identity = parseClaimIdentity(data);
|
|
916
|
+
try {
|
|
917
|
+
await startJob(state, item, queue, claimId, itemKey, branch, identity);
|
|
918
|
+
} catch (err) {
|
|
919
|
+
const stranded = [...state.jobs.values()].find((active) => active.job.claimId === claimId);
|
|
920
|
+
if (stranded === void 0) throw err;
|
|
921
|
+
const reason = `provision failed: ${describeCallFailure(err)}`;
|
|
922
|
+
logOf(state).error(`#${item.number} ${reason} \xB7 ending job as failed`);
|
|
923
|
+
await endJob(state, stranded, "failed", { stage: "provision", reason });
|
|
924
|
+
}
|
|
925
|
+
return { kind: "started" };
|
|
926
|
+
}
|
|
927
|
+
function claimIsAlreadyRunning(state, claimId) {
|
|
928
|
+
for (const active of state.jobs.values()) {
|
|
929
|
+
if (active.job.claimId === claimId) return true;
|
|
930
|
+
}
|
|
931
|
+
for (const { job } of listJobFiles(state.config.configPath)) {
|
|
932
|
+
if (job.claimId === claimId && isUnfinished(job)) return true;
|
|
933
|
+
}
|
|
934
|
+
return false;
|
|
935
|
+
}
|
|
936
|
+
function wasExtended(claim) {
|
|
937
|
+
const createdAtMs = Date.parse(String(claim.createdAt ?? ""));
|
|
938
|
+
const expiresAtMs = Date.parse(String(claim.expiresAt ?? ""));
|
|
939
|
+
if (!Number.isFinite(createdAtMs) || !Number.isFinite(expiresAtMs)) return false;
|
|
940
|
+
return expiresAtMs - createdAtMs > LEASE_SECONDS * 1e3 + 2e3;
|
|
941
|
+
}
|
|
942
|
+
async function startJob(state, item, queue, claimId, itemKey, branch, identity) {
|
|
943
|
+
const log = logOf(state);
|
|
944
|
+
const startedAt = nowOf(state);
|
|
945
|
+
const dirPath = freeJobDir(state.config.configPath, queue, item.number, claimId, startedAt);
|
|
946
|
+
mkdirSync(dirPath, { recursive: true, mode: RUNNER_DIR_MODE });
|
|
947
|
+
const runnerRoot = runnerDir(state.config.configPath);
|
|
948
|
+
const jobFilePath = join(dirPath, "job.json");
|
|
949
|
+
let job = {
|
|
950
|
+
item: item.number,
|
|
951
|
+
queue,
|
|
952
|
+
claimId,
|
|
953
|
+
itemKey,
|
|
954
|
+
branch,
|
|
955
|
+
commitIdentity: identity,
|
|
956
|
+
startedAt: startedAt.toISOString()
|
|
957
|
+
};
|
|
958
|
+
writeJobFile(jobFilePath, job);
|
|
959
|
+
const active = {
|
|
960
|
+
job,
|
|
961
|
+
dirPath,
|
|
962
|
+
lastRenewAtMs: startedAt.getTime(),
|
|
963
|
+
cmd: null,
|
|
964
|
+
cpuSample: null,
|
|
965
|
+
ending: false
|
|
966
|
+
};
|
|
967
|
+
state.jobs.set(dirPath, active);
|
|
968
|
+
log.line(
|
|
969
|
+
`claimed ${queue} #${item.number} ${item.trackerKey} (claim ${shortClaim(claimId)}) \u2192 ${relativeToRunner(runnerRoot, dirPath)}`
|
|
970
|
+
);
|
|
971
|
+
const hookLimits = () => hookLimitsFor(state.config, startedAt.getTime(), state.deps.now);
|
|
972
|
+
const tag = `#${item.number}`;
|
|
973
|
+
const containerMode = isBuiltinContainer(state.config.hooks.provision);
|
|
974
|
+
const vmMode = isBuiltinVm(state.config.hooks.provision);
|
|
975
|
+
let tokens;
|
|
976
|
+
if (containerMode || vmMode) {
|
|
977
|
+
active.job.environment = { isolation: vmMode ? "vm" : "container", cliVersion: state.deps.cliVersion ?? resolveVersion() };
|
|
978
|
+
writeJobFile(jobFilePath, active.job);
|
|
979
|
+
tokens = buildTokensFor(state, active, item.number);
|
|
980
|
+
runtimeOf(state).credentials.set(dirPath, tokens);
|
|
981
|
+
try {
|
|
982
|
+
await tokens.token("read");
|
|
983
|
+
} catch (err) {
|
|
984
|
+
if (active.ending) return;
|
|
985
|
+
await endOnTokenRefusal(state, active, err);
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
if (active.ending) return;
|
|
989
|
+
}
|
|
990
|
+
if (state.config.hooks.report) {
|
|
991
|
+
setStage(job, dirPath, "report");
|
|
992
|
+
log.line(`${tag} report started \xB7 phase start`);
|
|
993
|
+
const reportStartedMs = nowOf(state).getTime();
|
|
994
|
+
const startEnv = { ...jobEnv(state.config, item.number, itemKey, dirPath, process.env, branch, identity?.author), OUTERLAYER_PHASE: "start" };
|
|
995
|
+
const report = await runHook(
|
|
996
|
+
state.config.hooks.report,
|
|
997
|
+
startEnv,
|
|
998
|
+
dirPath,
|
|
999
|
+
join(dirPath, "report.log"),
|
|
1000
|
+
state.deps.spawnFn,
|
|
1001
|
+
hookLimits()
|
|
1002
|
+
).catch((err) => {
|
|
1003
|
+
log.error(`${tag} report failed: ${describeCallFailure(err)} \xB7 continuing`);
|
|
1004
|
+
return { code: -1 };
|
|
1005
|
+
});
|
|
1006
|
+
const took = formatDuration(nowOf(state).getTime() - reportStartedMs);
|
|
1007
|
+
if (report.endedBy !== void 0) {
|
|
1008
|
+
log.error(`${tag} ${describeHookEnd("report", report.endedBy, state.config)} \xB7 continuing`);
|
|
1009
|
+
} else if (report.code === 0) {
|
|
1010
|
+
log.line(`${tag} report ok (${took})`);
|
|
1011
|
+
} else if (report.code > 0) {
|
|
1012
|
+
log.error(`${tag} report exited ${report.code} (${took}) \xB7 continuing`);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
setStage(job, dirPath, "provision");
|
|
1016
|
+
log.line(`${tag} provision started`);
|
|
1017
|
+
const provisionStartedMs = nowOf(state).getTime();
|
|
1018
|
+
const processMode = isBuiltinProcess(state.config.hooks.provision);
|
|
1019
|
+
const cliVersion = state.deps.cliVersion ?? resolveVersion();
|
|
1020
|
+
let workdir;
|
|
1021
|
+
let cgroup;
|
|
1022
|
+
if (containerMode) {
|
|
1023
|
+
const provisioned = await provisionContainer({
|
|
1024
|
+
config: state.config,
|
|
1025
|
+
runtime: runtimeOf(state),
|
|
1026
|
+
item,
|
|
1027
|
+
itemKey,
|
|
1028
|
+
tokens,
|
|
1029
|
+
branch,
|
|
1030
|
+
jobDir: dirPath,
|
|
1031
|
+
cliVersion,
|
|
1032
|
+
log,
|
|
1033
|
+
save: (container, environment) => {
|
|
1034
|
+
active.job.container = container;
|
|
1035
|
+
active.job.environment = environment;
|
|
1036
|
+
writeJobFile(jobFilePath, active.job);
|
|
1037
|
+
},
|
|
1038
|
+
aborted: () => active.ending,
|
|
1039
|
+
deadlineMs: startedAt.getTime() + state.config.timeLimitMinutes * 6e4,
|
|
1040
|
+
fetchImpl: state.deps.fetchImpl,
|
|
1041
|
+
now: state.deps.now
|
|
1042
|
+
}).catch((err) => ({ ok: false, reason: `provision failed: ${describeCallFailure(err)}`, aborted: false }));
|
|
1043
|
+
const provisionTook = formatDuration(nowOf(state).getTime() - provisionStartedMs);
|
|
1044
|
+
if (active.ending) return;
|
|
1045
|
+
if (!provisioned.ok) {
|
|
1046
|
+
log.error(`${tag} provision failed (${provisionTook}): ${provisioned.reason} \xB7 ending job as failed`);
|
|
1047
|
+
await endJob(state, active, "failed", { stage: "provision", reason: provisioned.reason });
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
log.line(`${tag} provision ok (${provisionTook}) \xB7 container ${provisioned.state.container}`);
|
|
1051
|
+
workdir = provisioned.workdir;
|
|
1052
|
+
active.job.container = provisioned.state;
|
|
1053
|
+
active.job.environment = provisioned.environment;
|
|
1054
|
+
installCommitHooks(active.job, log, tag);
|
|
1055
|
+
} else if (vmMode) {
|
|
1056
|
+
const provisioned = await provisionMicroVm({
|
|
1057
|
+
config: state.config,
|
|
1058
|
+
runtime: runtimeOf(state),
|
|
1059
|
+
item,
|
|
1060
|
+
itemKey,
|
|
1061
|
+
tokens,
|
|
1062
|
+
branch,
|
|
1063
|
+
jobDir: dirPath,
|
|
1064
|
+
cliVersion,
|
|
1065
|
+
log,
|
|
1066
|
+
save: (microvm, environment) => {
|
|
1067
|
+
active.job.microvm = microvm;
|
|
1068
|
+
active.job.environment = environment;
|
|
1069
|
+
writeJobFile(jobFilePath, active.job);
|
|
1070
|
+
},
|
|
1071
|
+
aborted: () => active.ending,
|
|
1072
|
+
deadlineMs: startedAt.getTime() + state.config.timeLimitMinutes * 6e4,
|
|
1073
|
+
fetchImpl: state.deps.fetchImpl,
|
|
1074
|
+
now: state.deps.now
|
|
1075
|
+
}).catch((err) => ({ ok: false, reason: `provision failed: ${describeCallFailure(err)}`, aborted: false }));
|
|
1076
|
+
const provisionTook = formatDuration(nowOf(state).getTime() - provisionStartedMs);
|
|
1077
|
+
if (active.ending) return;
|
|
1078
|
+
if (!provisioned.ok) {
|
|
1079
|
+
log.error(`${tag} provision failed (${provisionTook}): ${provisioned.reason} \xB7 ending job as failed`);
|
|
1080
|
+
await endJob(state, active, "failed", { stage: "provision", reason: provisioned.reason });
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
log.line(`${tag} provision ok (${provisionTook}) \xB7 microVM build ${provisioned.state.buildId}`);
|
|
1084
|
+
workdir = provisioned.workdir;
|
|
1085
|
+
active.job.microvm = provisioned.state;
|
|
1086
|
+
active.job.environment = provisioned.environment;
|
|
1087
|
+
installCommitHooks(active.job, log, tag);
|
|
1088
|
+
const identity2 = active.job.commitIdentity;
|
|
1089
|
+
if (identity2?.hooksPath !== void 0) {
|
|
1090
|
+
try {
|
|
1091
|
+
await deliverCommitHooks({ runtime: runtimeOf(state), state: provisioned.state, hooksDir: join(provisioned.state.buildDir, HOOKS_DIR_NAME) });
|
|
1092
|
+
} catch (err) {
|
|
1093
|
+
identity2.hooksPath = void 0;
|
|
1094
|
+
log.error(`${tag} could not write the commit hooks into the microVM: ${describeCallFailure(err)} \xB7 continuing without a co-author line`);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
} else if (processMode) {
|
|
1098
|
+
active.job.environment = sharedUserEnvironment(cliVersion);
|
|
1099
|
+
writeJobFile(jobFilePath, active.job);
|
|
1100
|
+
const provisioned = await provisionProcess({
|
|
1101
|
+
config: state.config,
|
|
1102
|
+
runtime: runtimeOf(state),
|
|
1103
|
+
item,
|
|
1104
|
+
itemKey,
|
|
1105
|
+
jobDir: dirPath,
|
|
1106
|
+
cliVersion,
|
|
1107
|
+
log,
|
|
1108
|
+
save: (build, environment) => {
|
|
1109
|
+
active.job.build = build;
|
|
1110
|
+
active.job.environment = environment;
|
|
1111
|
+
writeJobFile(jobFilePath, active.job);
|
|
1112
|
+
},
|
|
1113
|
+
aborted: () => active.ending
|
|
1114
|
+
}).catch((err) => ({ ok: false, reason: `provision failed: ${describeCallFailure(err)}` }));
|
|
1115
|
+
const provisionTook = formatDuration(nowOf(state).getTime() - provisionStartedMs);
|
|
1116
|
+
if (active.ending) return;
|
|
1117
|
+
if (!provisioned.ok) {
|
|
1118
|
+
log.error(`${tag} provision failed (${provisionTook}): ${provisioned.reason} \xB7 ending job as failed`);
|
|
1119
|
+
await endJob(state, active, "failed", { stage: "provision", reason: provisioned.reason });
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
log.line(`${tag} provision ok (${provisionTook}) \xB7 workdir ${relativeToRunner(runnerRoot, provisioned.workdir)}`);
|
|
1123
|
+
workdir = provisioned.workdir;
|
|
1124
|
+
active.job.build = provisioned.state;
|
|
1125
|
+
active.job.environment = provisioned.environment;
|
|
1126
|
+
installCommitHooks(active.job, log, tag);
|
|
1127
|
+
} else {
|
|
1128
|
+
const runtime = runtimeOf(state);
|
|
1129
|
+
const build = await startHostBuild(state, active, item.specRepository ?? void 0);
|
|
1130
|
+
const provisionEnv = provisionEnvironment(jobEnv(state.config, item.number, itemKey, dirPath, process.env, branch, identity?.author), {
|
|
1131
|
+
buildDir: build.state.buildDir,
|
|
1132
|
+
repository: item.specRepository,
|
|
1133
|
+
recipeCommit: build.recipeCommit,
|
|
1134
|
+
limits: {
|
|
1135
|
+
memory: state.config.build?.memory ?? "8g",
|
|
1136
|
+
cpus: state.config.build?.cpus ?? 4,
|
|
1137
|
+
pids: state.config.build?.pids ?? 4096,
|
|
1138
|
+
disk: state.config.build?.disk ?? "40g"
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
const provision = await runHook(
|
|
1142
|
+
state.config.hooks.provision,
|
|
1143
|
+
provisionEnv,
|
|
1144
|
+
dirPath,
|
|
1145
|
+
join(dirPath, "provision.log"),
|
|
1146
|
+
state.deps.spawnFn,
|
|
1147
|
+
hookLimits()
|
|
1148
|
+
).catch((err) => {
|
|
1149
|
+
log.error(`${tag} provision failed: ${describeCallFailure(err)}`);
|
|
1150
|
+
return { code: -1 };
|
|
1151
|
+
});
|
|
1152
|
+
const provisionExit = provision.code;
|
|
1153
|
+
const provisionTook = formatDuration(nowOf(state).getTime() - provisionStartedMs);
|
|
1154
|
+
if (active.ending) return;
|
|
1155
|
+
if (provision.endedBy !== void 0) {
|
|
1156
|
+
log.error(`${tag} ${describeHookEnd("provision", provision.endedBy, state.config)}`);
|
|
1157
|
+
await endJob(state, active, "failed");
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
if (provision.signal !== void 0) {
|
|
1161
|
+
log.error(`${tag} provision killed by ${provision.signal} (${provisionTook})`);
|
|
1162
|
+
await endJob(state, active, "failed");
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
if (provisionExit === 75) {
|
|
1166
|
+
log.line(`${tag} provision declined the item (exit 75, ${provisionTook})`);
|
|
1167
|
+
await endJob(state, active, void 0);
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
if (provisionExit !== 0) {
|
|
1171
|
+
log.error(`${tag} provision exited ${provisionExit} (${provisionTook}) \xB7 ending job as failed`);
|
|
1172
|
+
await endJob(state, active, "failed", {
|
|
1173
|
+
stage: "provision",
|
|
1174
|
+
exitCode: provisionExit < 0 ? void 0 : provisionExit
|
|
1175
|
+
});
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
const provisioned = readProvisionOut(dirPath);
|
|
1179
|
+
if (!provisioned.ok) {
|
|
1180
|
+
log.error(`${tag} provision wrote no workdir= line to provision.out \xB7 ending job as failed`);
|
|
1181
|
+
await endJob(state, active, "failed");
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
const resolved = resolveHostBuild(provisioned);
|
|
1185
|
+
if (!resolved.ok) {
|
|
1186
|
+
log.error(`${tag} ${resolved.reason} \xB7 ending job as failed`);
|
|
1187
|
+
await endJob(state, active, "failed", { stage: "provision", reason: resolved.reason });
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
const named = resolved.build;
|
|
1191
|
+
const badWorkdir = named.exec === void 0 ? workdirProblem(provisioned.workdir) : void 0;
|
|
1192
|
+
if (badWorkdir !== void 0) {
|
|
1193
|
+
log.error(
|
|
1194
|
+
`${tag} provision reported a workdir that ${badWorkdir}: ${provisioned.workdir} \xB7 ending job as failed`
|
|
1195
|
+
);
|
|
1196
|
+
await endJob(state, active, "failed");
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
if (named.exec !== void 0 || named.broker !== void 0) {
|
|
1200
|
+
const tunnel = runtime.tunnels.get(build.state.buildId);
|
|
1201
|
+
if (tunnel === void 0) {
|
|
1202
|
+
const why = `the tunnel could not start: ${build.failed ?? "no reason recorded"}`;
|
|
1203
|
+
log.error(`${tag} provision failed: ${why} \xB7 ending job as failed`);
|
|
1204
|
+
await endJob(state, active, "failed", { stage: "provision", reason: why });
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
if (named.broker !== void 0) {
|
|
1208
|
+
try {
|
|
1209
|
+
await listenOnBroker(tunnel, named.broker.text);
|
|
1210
|
+
} catch (err) {
|
|
1211
|
+
const why = `the tunnel could not listen on ${named.broker.text}: ${describeCallFailure(err)}`;
|
|
1212
|
+
log.error(`${tag} provision failed: ${why} \xB7 ending job as failed`);
|
|
1213
|
+
await endJob(state, active, "failed", { stage: "provision", reason: why });
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
build.state.broker = named.broker.text;
|
|
1217
|
+
}
|
|
1218
|
+
if (named.exec !== void 0) build.state.exec = named.exec;
|
|
1219
|
+
}
|
|
1220
|
+
active.job.environment = hostEnvironment(named, cliVersion);
|
|
1221
|
+
writeJobFile(jobFilePath, active.job);
|
|
1222
|
+
if (named.exec !== void 0 && named.recipe !== void 0) {
|
|
1223
|
+
const lifecycleEnv = hostCommandEnvironment(jobEnv(state.config, item.number, itemKey, dirPath, process.env, branch, identity?.author), build.state, state.config);
|
|
1224
|
+
for (const step of lifecycleSteps(named.recipe.apply.lifecycle)) {
|
|
1225
|
+
if (active.ending) return;
|
|
1226
|
+
log.line(`${tag} lifecycle ${step.name} (${step.source})`);
|
|
1227
|
+
const ran = await runHook(
|
|
1228
|
+
named.exec,
|
|
1229
|
+
lifecycleEnv,
|
|
1230
|
+
dirPath,
|
|
1231
|
+
join(dirPath, "provision.log"),
|
|
1232
|
+
state.deps.spawnFn,
|
|
1233
|
+
hookLimits(),
|
|
1234
|
+
execArgv(provisioned.workdir, step.argv)
|
|
1235
|
+
).catch((err) => {
|
|
1236
|
+
log.error(`${tag} lifecycle ${step.name} failed to start: ${describeCallFailure(err)}`);
|
|
1237
|
+
return { code: -1 };
|
|
1238
|
+
});
|
|
1239
|
+
if (active.ending) return;
|
|
1240
|
+
if (ran.code !== 0) {
|
|
1241
|
+
const why = lifecycleFailureReason(step, ran.code);
|
|
1242
|
+
log.error(`${tag} provision failed: ${why} \xB7 ending job as failed`);
|
|
1243
|
+
await endJob(state, active, "failed", { stage: "provision", reason: why });
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
log.line(`${tag} provision ok (${provisionTook}) \xB7 workdir ${relativeToRunner(runnerRoot, provisioned.workdir)}`);
|
|
1249
|
+
workdir = provisioned.workdir;
|
|
1250
|
+
cgroup = provisioned.cgroup;
|
|
1251
|
+
installCommitHooks(active.job, log, tag);
|
|
1252
|
+
}
|
|
1253
|
+
job = { ...active.job, workdir, cgroup };
|
|
1254
|
+
active.job = job;
|
|
1255
|
+
writeJobFile(jobFilePath, job);
|
|
1256
|
+
if (!containerMode && !vmMode) {
|
|
1257
|
+
if (existsSync(join(workdir, ".git"))) {
|
|
1258
|
+
try {
|
|
1259
|
+
const prepared = await prepareBranch({ workdir, branch });
|
|
1260
|
+
log.line(`${tag} on ${branch} at ${prepared.head.slice(0, 8)}`);
|
|
1261
|
+
} catch (err) {
|
|
1262
|
+
log.error(`${tag} could not put the workdir on ${branch}: ${describeCallFailure(err)} \xB7 ending job as failed`);
|
|
1263
|
+
await endJob(state, active, "failed");
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
} else {
|
|
1267
|
+
log.line(`${tag} workdir is not a git checkout, so ${branch} was not checked out`);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
const cmdline = state.config.commands[queue];
|
|
1271
|
+
if (!cmdline) {
|
|
1272
|
+
log.error(`${tag} has no runner.commands.${queue} in the config file \xB7 ending job as failed`);
|
|
1273
|
+
await endJob(state, active, "failed", { stage: "command" });
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
let running;
|
|
1277
|
+
try {
|
|
1278
|
+
if (job.microvm !== void 0) {
|
|
1279
|
+
const runtime = runtimeOf(state);
|
|
1280
|
+
const composed = microVmCommandLine({ job, cmdline, runtime, config: state.config, cli: join(runtime.cliBuild().distDir, "index.js") });
|
|
1281
|
+
running = runCommand(composed.line, { ...composed.env, OUTERLAYER_JOB_DIR: dirPath }, dirPath, join(dirPath, "command.log"), state.deps.spawnFn);
|
|
1282
|
+
} else if (job.container !== void 0) {
|
|
1283
|
+
const composed = containerCommandLine({ job, cmdline, runtime: runtimeOf(state), config: state.config });
|
|
1284
|
+
running = runCommand(
|
|
1285
|
+
composed.line,
|
|
1286
|
+
{ ...composed.env, OUTERLAYER_JOB_DIR: dirPath },
|
|
1287
|
+
dirPath,
|
|
1288
|
+
join(dirPath, "command.log"),
|
|
1289
|
+
state.deps.spawnFn
|
|
1290
|
+
);
|
|
1291
|
+
} else {
|
|
1292
|
+
const build = job.build;
|
|
1293
|
+
const commandEnv = hostCommandEnvironment(jobEnv(state.config, item.number, itemKey, dirPath, process.env, branch, identity?.author), build, state.config, identity?.hooksPath);
|
|
1294
|
+
running = build.exec === void 0 ? runCommand(cmdline, commandEnv, workdir, join(dirPath, "command.log"), state.deps.spawnFn) : runCommand(execCommandLine(build.exec, workdir, cmdline), commandEnv, dirPath, join(dirPath, "command.log"), state.deps.spawnFn);
|
|
1295
|
+
}
|
|
1296
|
+
} catch (err) {
|
|
1297
|
+
log.error(`${tag} command could not start: ${describeCallFailure(err)} \xB7 ending job as failed`);
|
|
1298
|
+
await endJob(state, active, "failed", { stage: "command" });
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
active.cmd = running;
|
|
1302
|
+
job = { ...job, stage: "command", pid: running.pid, start: running.start ?? void 0 };
|
|
1303
|
+
active.job = job;
|
|
1304
|
+
writeJobFile(jobFilePath, job);
|
|
1305
|
+
const commandStartedMs = nowOf(state).getTime();
|
|
1306
|
+
log.line(`${tag} command started \xB7 pid ${running.pid}`);
|
|
1307
|
+
running.onExit.then(({ code, signal, error }) => {
|
|
1308
|
+
if (active.ending) return;
|
|
1309
|
+
if (error !== void 0) {
|
|
1310
|
+
log.error(`${tag} command could not start: ${error.message} \xB7 ending job as failed`);
|
|
1311
|
+
void endJob(state, active, "failed");
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
const took = formatDuration(nowOf(state).getTime() - commandStartedMs);
|
|
1315
|
+
if (signal !== null) {
|
|
1316
|
+
log.error(`${tag} command killed by ${signal} (${took})`);
|
|
1317
|
+
void endJob(state, active, "failed");
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
log.line(`${tag} command exited ${code ?? 0} (${took})`);
|
|
1321
|
+
if (code !== null) active.job.exitCode = code;
|
|
1322
|
+
void endJob(state, active, code === 0 ? "ok" : "failed");
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
function buildTokensFor(state, active, item) {
|
|
1326
|
+
return new BuildTokens({
|
|
1327
|
+
config: state.config,
|
|
1328
|
+
item,
|
|
1329
|
+
fetchImpl: state.deps.fetchImpl,
|
|
1330
|
+
now: state.deps.now,
|
|
1331
|
+
sleep: state.deps.sleep,
|
|
1332
|
+
apiUrl: runtimeOf(state).github.apiUrl,
|
|
1333
|
+
onUsed: (kinds) => {
|
|
1334
|
+
const cliVersion = state.deps.cliVersion ?? resolveVersion();
|
|
1335
|
+
active.job.environment = { ...active.job.environment ?? { isolation: "container", cliVersion }, credentials: kinds };
|
|
1336
|
+
writeJobFile(join(active.dirPath, "job.json"), active.job);
|
|
1337
|
+
}
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
async function endOnTokenRefusal(state, active, err) {
|
|
1341
|
+
const log = logOf(state);
|
|
1342
|
+
const tag = `#${active.job.item}`;
|
|
1343
|
+
const refusal = classifyRefusal(err);
|
|
1344
|
+
if (refusal.kind === "retry") {
|
|
1345
|
+
log.error(`${tag} the build's read token could not be had (${describeCallFailure(err)}) \xB7 releasing the claim to try again`);
|
|
1346
|
+
await endJob(state, active, void 0);
|
|
1347
|
+
} else if (refusal.kind === "lease_lost") {
|
|
1348
|
+
log.error(`${tag} the build's read token was refused: ${refusalCode(err)} \xB7 ending job as lease_lost`);
|
|
1349
|
+
await endJob(state, active, "lease_lost");
|
|
1350
|
+
} else {
|
|
1351
|
+
log.error(`${tag} the build's read token was refused: ${refusal.reason} \xB7 ending job as failed`);
|
|
1352
|
+
await endJob(state, active, "failed", { stage: "provision", reason: refusal.reason });
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
function installCommitHooks(job, log, tag) {
|
|
1356
|
+
const identity = job.commitIdentity;
|
|
1357
|
+
if (identity?.coAuthor === void 0) return;
|
|
1358
|
+
const buildDir2 = job.container?.buildDir ?? job.microvm?.buildDir ?? (job.build.exec === void 0 ? job.build.buildDir : void 0);
|
|
1359
|
+
if (buildDir2 === void 0) {
|
|
1360
|
+
log.line(`${tag} the build runs through an exec, so its commits are not given a co-author line`);
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
try {
|
|
1364
|
+
const dir = writeCommitHooks(buildDir2, identity.coAuthor);
|
|
1365
|
+
identity.hooksPath = job.container === void 0 && job.microvm === void 0 ? dir : `${JOB_MOUNT}/${HOOKS_DIR_NAME}`;
|
|
1366
|
+
} catch (err) {
|
|
1367
|
+
log.error(`${tag} could not write the commit hooks: ${describeCallFailure(err)} \xB7 continuing without a co-author line`);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
async function startHostBuild(state, active, repository) {
|
|
1371
|
+
const buildId = randomBytes(6).toString("hex");
|
|
1372
|
+
const buildDir2 = buildDir(state.config.configPath, buildId);
|
|
1373
|
+
const build = { buildId, buildDir: buildDir2 };
|
|
1374
|
+
active.job.build = build;
|
|
1375
|
+
writeJobFile(join(active.dirPath, "job.json"), active.job);
|
|
1376
|
+
mkdirSync(buildDir2, { recursive: true });
|
|
1377
|
+
chmodSync(buildDir2, 493);
|
|
1378
|
+
const runtime = runtimeOf(state);
|
|
1379
|
+
let failed2;
|
|
1380
|
+
try {
|
|
1381
|
+
await startHostServices({ runtime, config: state.config, build, jobDir: active.dirPath, itemKey: active.job.itemKey, process: false });
|
|
1382
|
+
} catch (err) {
|
|
1383
|
+
failed2 = describeCallFailure(err);
|
|
1384
|
+
logOf(state).error(`#${active.job.item} the tunnel and destination servers could not start: ${failed2} \xB7 continuing`);
|
|
1385
|
+
}
|
|
1386
|
+
writeJobFile(join(active.dirPath, "job.json"), active.job);
|
|
1387
|
+
let recipeCommit;
|
|
1388
|
+
if (repository !== void 0 && repository !== "") {
|
|
1389
|
+
const remote = runtime.remoteFor?.(repository) ?? `https://github.com/${repository}.git`;
|
|
1390
|
+
recipeCommit = await runtime.readHead(remote, runnerDir(state.config.configPath)).catch(() => void 0);
|
|
1391
|
+
}
|
|
1392
|
+
return { state: build, recipeCommit, failed: failed2 };
|
|
1393
|
+
}
|
|
1394
|
+
function freeJobDir(configPath, queue, item, claimId, startedAt) {
|
|
1395
|
+
const stamp = attemptStamp(startedAt);
|
|
1396
|
+
for (let i = 0; i < 100; i++) {
|
|
1397
|
+
const candidate = jobDir(configPath, queue, item, claimId, i === 0 ? stamp : `${stamp}-${i}`);
|
|
1398
|
+
if (!existsSync(candidate)) return candidate;
|
|
1399
|
+
}
|
|
1400
|
+
return jobDir(configPath, queue, item, claimId, `${stamp}-${process.pid}`);
|
|
1401
|
+
}
|
|
1402
|
+
async function endJob(state, active, outcome, ending) {
|
|
1403
|
+
if (active.ending) return;
|
|
1404
|
+
active.ending = true;
|
|
1405
|
+
try {
|
|
1406
|
+
const finished = await finishJob(active.job, active.dirPath, outcome, state.config, finishDepsOf(state), ending);
|
|
1407
|
+
active.job = finished;
|
|
1408
|
+
} catch (err) {
|
|
1409
|
+
logOf(state).error(`#${active.job.item} could not be ended cleanly: ${describeCallFailure(err)}`);
|
|
1410
|
+
} finally {
|
|
1411
|
+
state.jobs.delete(active.dirPath);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
async function watchTick(state, active, nowMs, logMtimeMs) {
|
|
1415
|
+
if (active.ending) return;
|
|
1416
|
+
const log = logOf(state);
|
|
1417
|
+
const tag = `#${active.job.item}`;
|
|
1418
|
+
const pid = active.job.pid;
|
|
1419
|
+
if (pid !== void 0 && !isGroupAlive(pid)) {
|
|
1420
|
+
if (active.cmd === null) {
|
|
1421
|
+
const recorded = outcomeFromExitCode(active.dirPath);
|
|
1422
|
+
if (recorded === void 0) {
|
|
1423
|
+
log.line(`${tag} command gone, no exit code recorded \xB7 ending job as interrupted`);
|
|
1424
|
+
await endJob(state, active, "interrupted");
|
|
1425
|
+
} else {
|
|
1426
|
+
log.line(`${tag} command exited ${recorded.code}`);
|
|
1427
|
+
active.job.exitCode = recorded.code;
|
|
1428
|
+
await endJob(state, active, recorded.outcome);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
if (needsRenew(active.lastRenewAtMs, nowMs)) {
|
|
1434
|
+
const renew = await retrySend(() => renewLease(state.config, active.job.item, state.deps.fetchImpl), {
|
|
1435
|
+
now: state.deps.now,
|
|
1436
|
+
sleep: state.deps.sleep
|
|
1437
|
+
});
|
|
1438
|
+
const result = renew.ok ? renew.value : "retry_later";
|
|
1439
|
+
if (result === "lease_lost") {
|
|
1440
|
+
log.error(`${tag} lease lost \xB7 ending job as lease_lost`);
|
|
1441
|
+
await endJob(state, active, "lease_lost");
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
if (result === "renewed") {
|
|
1445
|
+
active.job.lastRenewAt = new Date(nowMs).toISOString();
|
|
1446
|
+
writeJobFile(join(active.dirPath, "job.json"), active.job);
|
|
1447
|
+
log.line(`${tag} lease renewed \xB7 expires ${formatClock(new Date(nowMs + LEASE_SECONDS * 1e3), new Date(nowMs))}`);
|
|
1448
|
+
} else {
|
|
1449
|
+
log.error(`${tag} renew did not land \xB7 retrying in ${RENEW_INTERVAL_SECONDS / 60}m`);
|
|
1450
|
+
}
|
|
1451
|
+
active.lastRenewAtMs = nowMs;
|
|
1452
|
+
}
|
|
1453
|
+
const tokens = active.job.container === void 0 && active.job.microvm === void 0 ? void 0 : runtimeOf(state).credentials.get(active.dirPath);
|
|
1454
|
+
if (tokens !== void 0) {
|
|
1455
|
+
await tokens.renewReadIfDue();
|
|
1456
|
+
const refusal = tokens.refusal();
|
|
1457
|
+
if (refusal?.kind === "lease_lost") {
|
|
1458
|
+
log.error(`${tag} a token request was refused as a lost lease \xB7 ending job as lease_lost`);
|
|
1459
|
+
await endJob(state, active, "lease_lost");
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
if (refusal?.kind === "failed") {
|
|
1463
|
+
log.error(`${tag} a token request was refused: ${refusal.reason} \xB7 ending job as failed`);
|
|
1464
|
+
await endJob(state, active, "failed", { stage: pid === void 0 ? "provision" : "command", reason: refusal.reason });
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
if (isTimedOut(new Date(active.job.startedAt).getTime(), nowMs, state.config.timeLimitMinutes)) {
|
|
1469
|
+
log.line(`${tag} past the ${state.config.timeLimitMinutes}m time limit \xB7 ending job as timed_out`);
|
|
1470
|
+
await endJob(state, active, "timed_out");
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
if (pid === void 0) return;
|
|
1474
|
+
if (active.job.container !== void 0) {
|
|
1475
|
+
const limit = state.config.build?.disk ?? "40g";
|
|
1476
|
+
const used = await measureDisk(runtimeOf(state), active.job.container);
|
|
1477
|
+
if (exceedsDisk(used ?? 0, limit)) {
|
|
1478
|
+
log.line(`${tag} the build's volume holds ${used} KiB, past ${limit} \xB7 ending job as failed`);
|
|
1479
|
+
await endJob(state, active, "failed", { stage: "command", reason: "disk_limit_exceeded" });
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
let cpuUsecPerMinute = null;
|
|
1484
|
+
if (active.job.cgroup) {
|
|
1485
|
+
const usec = readCpuUsec(active.job.cgroup);
|
|
1486
|
+
if (usec !== null && active.cpuSample) {
|
|
1487
|
+
const deltaUsec = usec - active.cpuSample.usec;
|
|
1488
|
+
const deltaMinutes = (nowMs - active.cpuSample.atMs) / 6e4;
|
|
1489
|
+
cpuUsecPerMinute = deltaMinutes > 0 ? deltaUsec / deltaMinutes : null;
|
|
1490
|
+
}
|
|
1491
|
+
if (usec !== null) active.cpuSample = { usec, atMs: nowMs };
|
|
1492
|
+
}
|
|
1493
|
+
if (isIdle(logMtimeMs, nowMs, state.config.idleLimitMinutes, cpuUsecPerMinute)) {
|
|
1494
|
+
log.line(`${tag} no output for ${state.config.idleLimitMinutes}m \xB7 ending job as idle`);
|
|
1495
|
+
await endJob(state, active, "idle");
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
async function probeCredentials(config, fetchImpl = fetch) {
|
|
1499
|
+
try {
|
|
1500
|
+
await listStartable(config, config.queues[0] ?? "implement", fetchImpl);
|
|
1501
|
+
return { ok: true };
|
|
1502
|
+
} catch (err) {
|
|
1503
|
+
const status = err instanceof WorkCommandError ? err.status : void 0;
|
|
1504
|
+
if (err instanceof WorkCommandError && isSignatureRefusal(err.code)) {
|
|
1505
|
+
return {
|
|
1506
|
+
ok: false,
|
|
1507
|
+
fatal: err.code === "runner_key_signature_invalid",
|
|
1508
|
+
reason: `${config.configPath}: ${err.message}`
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
if (status === 401 || status === 403) {
|
|
1512
|
+
return {
|
|
1513
|
+
ok: false,
|
|
1514
|
+
fatal: true,
|
|
1515
|
+
reason: `${config.configPath}: the API key was refused (${status}). A runner key needs git.read and work.read to list and read work items, and work.claim to claim, renew and release.`
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
return { ok: false, fatal: false, reason: describeCallFailure(err) };
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
async function start(configPath, deps = { fetchImpl: fetch }) {
|
|
1522
|
+
const log = deps.log ??= createRunnerLog(deps.now ?? (() => /* @__PURE__ */ new Date()));
|
|
1523
|
+
const update = deps.update;
|
|
1524
|
+
if (update !== void 0) {
|
|
1525
|
+
const outcome = await onStartUnderMarker({
|
|
1526
|
+
configPath,
|
|
1527
|
+
own: ownVersionOf(deps),
|
|
1528
|
+
check: update.check ? () => update.check(configPath) : async () => (await import('./check-WQP2CZDW.js')).runRunnerCheck(configPath, { fetchImpl: deps.fetchImpl }),
|
|
1529
|
+
now: () => (deps.now ?? (() => /* @__PURE__ */ new Date()))().getTime(),
|
|
1530
|
+
log
|
|
1531
|
+
});
|
|
1532
|
+
if (outcome === "restart") {
|
|
1533
|
+
update.exit(RESTART_EXIT_STATUS);
|
|
1534
|
+
throw new RunnerStartError("went back to the previous version; restarting");
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
const config = loadRunnerConfig(configPath);
|
|
1538
|
+
const hookProblem = checkHooks(config);
|
|
1539
|
+
if (hookProblem) throw new RunnerStartError(hookProblem);
|
|
1540
|
+
await ensureHostKey(configPath);
|
|
1541
|
+
const probe = await probeCredentials(config, deps.fetchImpl);
|
|
1542
|
+
if (!probe.ok && probe.fatal) throw new RunnerStartError(probe.reason);
|
|
1543
|
+
if (!probe.ok) log.error(`the gateway did not answer at start: ${probe.reason} \xB7 starting anyway`);
|
|
1544
|
+
mkdirSync(jobsDir(configPath), { recursive: true, mode: RUNNER_DIR_MODE });
|
|
1545
|
+
const self = { pid: process.pid, start: startTimeOf(process.pid) ?? "" };
|
|
1546
|
+
claimPidFile(pidFilePath(configPath), self);
|
|
1547
|
+
let configMtimeMs;
|
|
1548
|
+
try {
|
|
1549
|
+
configMtimeMs = statSync(configPath).mtimeMs;
|
|
1550
|
+
} catch {
|
|
1551
|
+
configMtimeMs = void 0;
|
|
1552
|
+
}
|
|
1553
|
+
const state = { config, jobs: /* @__PURE__ */ new Map(), deps, configMtimeMs };
|
|
1554
|
+
log.line(`runner ${config.host} started \xB7 config ${configPath} \xB7 ${describeConfig(config)}`);
|
|
1555
|
+
const nowMs = () => deps.now ? deps.now().getTime() : Date.now();
|
|
1556
|
+
const runningMicroVms = await stopOrphanedMicroVms({ jobs: unfinishedJobs(jobsDir(configPath)), runtime: runtimeOf(state) }).catch((err) => {
|
|
1557
|
+
log.error(`a microVM an earlier runner left could not be stopped: ${describeCallFailure(err)}`);
|
|
1558
|
+
return [];
|
|
1559
|
+
});
|
|
1560
|
+
for (const buildId of runningMicroVms) log.line(`stopped microVM build ${buildId}, left running by an earlier runner`);
|
|
1561
|
+
for (const { dirPath, job } of listAdoptableJobs(jobsDir(configPath))) {
|
|
1562
|
+
const adopted = { job, dirPath, lastRenewAtMs: nowMs(), cmd: null, cpuSample: null, ending: false };
|
|
1563
|
+
state.jobs.set(dirPath, adopted);
|
|
1564
|
+
if (job.container !== void 0) {
|
|
1565
|
+
const tokens = buildTokensFor(state, adopted, job.item);
|
|
1566
|
+
runtimeOf(state).credentials.set(dirPath, tokens);
|
|
1567
|
+
await ensureTunnel(runtimeOf(state), job.container, dirPath, config, job.itemKey, job.branch);
|
|
1568
|
+
try {
|
|
1569
|
+
await tokens.token("read");
|
|
1570
|
+
keepGhConfigCurrent(
|
|
1571
|
+
tokens,
|
|
1572
|
+
job.container.buildDir,
|
|
1573
|
+
(err) => log.error(`#${job.item} the read token could not be written for gh: ${describeCallFailure(err)}`)
|
|
1574
|
+
);
|
|
1575
|
+
} catch (err) {
|
|
1576
|
+
log.error(`#${job.item} could not give the adopted build a read token: ${describeCallFailure(err)}`);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
if (job.build !== void 0 && !runtimeOf(state).tunnels.has(job.build.buildId)) {
|
|
1580
|
+
await startHostServices({ runtime: runtimeOf(state), config, build: job.build, jobDir: dirPath, itemKey: job.itemKey, process: job.build.workDir !== void 0 }).catch((err) => {
|
|
1581
|
+
log.error(`the tunnel of #${job.item} could not start again: ${describeCallFailure(err)}`);
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
log.line(`adopted job ${job.queue} #${job.item} (claim ${shortClaim(job.claimId)}) \xB7 pid ${job.pid} still running`);
|
|
1585
|
+
}
|
|
1586
|
+
for (const { job, released } of await retryFailedReleases(jobsDir(configPath), config, {
|
|
1587
|
+
fetchImpl: deps.fetchImpl,
|
|
1588
|
+
now: deps.now,
|
|
1589
|
+
log
|
|
1590
|
+
})) {
|
|
1591
|
+
log.line(
|
|
1592
|
+
released ? `released the lease on #${job.item} that an earlier attempt could not \xB7 outcome ${job.outcome ?? "none"}` : `the lease on #${job.item} still cannot be released: ${job.releaseFailed ?? "no reason recorded"}`
|
|
1593
|
+
);
|
|
1594
|
+
}
|
|
1595
|
+
for (const recovered of await recoverLostJobs(jobsDir(configPath), config, finishDepsOf(state))) {
|
|
1596
|
+
const why = recovered.exitCode === void 0 ? `pid ${recovered.pid ?? "unknown"} is gone, no exit code recorded` : `its command recorded exit ${recovered.exitCode}`;
|
|
1597
|
+
log.line(
|
|
1598
|
+
`recovered job ${recovered.queue} #${recovered.item} (claim ${shortClaim(recovered.claimId)}) as ${recovered.outcome ?? "interrupted"}: ${why}`
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
let intakeClosed = false;
|
|
1602
|
+
let closed = false;
|
|
1603
|
+
let draining = false;
|
|
1604
|
+
let stopping = false;
|
|
1605
|
+
let exited = false;
|
|
1606
|
+
let interrupts = 0;
|
|
1607
|
+
state.pollTimerSeconds = state.config.pollSeconds;
|
|
1608
|
+
const pollTimer = setInterval(() => void tick(state), state.pollTimerSeconds * 1e3);
|
|
1609
|
+
const watchTimer = setInterval(() => {
|
|
1610
|
+
const now = nowMs();
|
|
1611
|
+
for (const active of state.jobs.values()) {
|
|
1612
|
+
let mtime = now;
|
|
1613
|
+
try {
|
|
1614
|
+
mtime = statSync(join(active.dirPath, "command.log")).mtimeMs;
|
|
1615
|
+
} catch {
|
|
1616
|
+
}
|
|
1617
|
+
void watchTick(state, active, now, mtime).catch((err) => {
|
|
1618
|
+
log.error(`#${active.job.item} health check failed: ${describeCallFailure(err)}`);
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
}, deps.watchIntervalMs ?? 3e4);
|
|
1622
|
+
const onSigterm = () => void drain();
|
|
1623
|
+
const onSigusr1 = () => void stopNow();
|
|
1624
|
+
const onSigint = () => {
|
|
1625
|
+
interrupts++;
|
|
1626
|
+
if (interrupts === 1) {
|
|
1627
|
+
log.line("stopping: draining, press Ctrl-C again to stop now");
|
|
1628
|
+
void drain();
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
void stopNow();
|
|
1632
|
+
};
|
|
1633
|
+
const detachSignals = () => {
|
|
1634
|
+
process.off("SIGTERM", onSigterm);
|
|
1635
|
+
process.off("SIGUSR1", onSigusr1);
|
|
1636
|
+
process.off("SIGINT", onSigint);
|
|
1637
|
+
};
|
|
1638
|
+
const stopIntake = () => {
|
|
1639
|
+
if (intakeClosed) return;
|
|
1640
|
+
intakeClosed = true;
|
|
1641
|
+
clearInterval(pollTimer);
|
|
1642
|
+
};
|
|
1643
|
+
const close = () => {
|
|
1644
|
+
stopIntake();
|
|
1645
|
+
detachSignals();
|
|
1646
|
+
if (closed) return;
|
|
1647
|
+
closed = true;
|
|
1648
|
+
clearInterval(watchTimer);
|
|
1649
|
+
};
|
|
1650
|
+
const finish2 = () => {
|
|
1651
|
+
if (exited) return;
|
|
1652
|
+
exited = true;
|
|
1653
|
+
close();
|
|
1654
|
+
releasePidFile(pidFilePath(configPath), self);
|
|
1655
|
+
log.line("runner exited");
|
|
1656
|
+
};
|
|
1657
|
+
const drain = async (onWaiting) => {
|
|
1658
|
+
if (draining) return;
|
|
1659
|
+
draining = true;
|
|
1660
|
+
stopIntake();
|
|
1661
|
+
let passes = 0;
|
|
1662
|
+
while (state.jobs.size > 0 && !exited) {
|
|
1663
|
+
onWaiting?.([...state.jobs.keys()]);
|
|
1664
|
+
if (passes % DRAIN_LOG_EVERY_PASSES === 0) {
|
|
1665
|
+
log.line(
|
|
1666
|
+
`stopping: draining, waiting on ${[...state.jobs.values()].map((a) => describeWaiting(a, nowMs())).join(", ")}`
|
|
1667
|
+
);
|
|
1668
|
+
}
|
|
1669
|
+
passes++;
|
|
1670
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
1671
|
+
}
|
|
1672
|
+
finish2();
|
|
1673
|
+
};
|
|
1674
|
+
const stopNow = async () => {
|
|
1675
|
+
if (stopping) return;
|
|
1676
|
+
stopping = true;
|
|
1677
|
+
stopIntake();
|
|
1678
|
+
clearInterval(watchTimer);
|
|
1679
|
+
log.line(`stopping: ending ${state.jobs.size} running job(s) now`);
|
|
1680
|
+
await Promise.all([...state.jobs.values()].map((active) => endJob(state, active, "stopped")));
|
|
1681
|
+
finish2();
|
|
1682
|
+
};
|
|
1683
|
+
if (update !== void 0) {
|
|
1684
|
+
state.exitForUpdate = () => {
|
|
1685
|
+
finish2();
|
|
1686
|
+
update.exit(RESTART_EXIT_STATUS);
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
const handle = { state, drain, stopNow, close };
|
|
1690
|
+
process.on("SIGTERM", onSigterm);
|
|
1691
|
+
process.on("SIGUSR1", onSigusr1);
|
|
1692
|
+
process.on("SIGINT", onSigint);
|
|
1693
|
+
deps.onReady?.();
|
|
1694
|
+
await tick(state);
|
|
1695
|
+
if (update !== void 0) settleUpdate({ configPath, own: ownVersionOf(deps), now: () => nowOf(state).getTime(), log });
|
|
1696
|
+
return handle;
|
|
1697
|
+
}
|
|
1698
|
+
function describeWaiting(active, nowMs) {
|
|
1699
|
+
const elapsed = formatDuration(nowMs - new Date(active.job.startedAt).getTime());
|
|
1700
|
+
return `#${active.job.item} (${active.job.stage ?? "starting"}, ${elapsed})`;
|
|
1701
|
+
}
|
|
1702
|
+
var RunnerStartError = class extends Error {
|
|
1703
|
+
};
|
|
1704
|
+
async function requestStop(configPath, stopNow = false, pollMs = 5e3) {
|
|
1705
|
+
const entry = readPidFile(pidFilePath(configPath));
|
|
1706
|
+
if (!entry) {
|
|
1707
|
+
process.stdout.write("no runner is running on this config\n");
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
if (!isSameProcess(entry.pid, entry.start)) {
|
|
1711
|
+
releasePidFile(pidFilePath(configPath), entry);
|
|
1712
|
+
process.stdout.write(`stale pid file for pid ${entry.pid} (process gone), removed
|
|
1713
|
+
`);
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
const signal = stopNow ? "SIGUSR1" : "SIGTERM";
|
|
1717
|
+
try {
|
|
1718
|
+
process.kill(entry.pid, signal);
|
|
1719
|
+
} catch (err) {
|
|
1720
|
+
const code = err?.code;
|
|
1721
|
+
if (code === "EPERM") {
|
|
1722
|
+
process.stdout.write(`the runner (pid ${entry.pid}) belongs to another user and cannot be signalled from here
|
|
1723
|
+
`);
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
releasePidFile(pidFilePath(configPath), entry);
|
|
1727
|
+
process.stdout.write(`stale pid file for pid ${entry.pid} (process gone), removed
|
|
1728
|
+
`);
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
process.stdout.write(
|
|
1732
|
+
stopNow ? `sent SIGUSR1 to runner pid ${entry.pid}, stopping now
|
|
1733
|
+
` : `sent SIGTERM to runner pid ${entry.pid}, draining
|
|
1734
|
+
`
|
|
1735
|
+
);
|
|
1736
|
+
while (isSameProcess(entry.pid, entry.start)) {
|
|
1737
|
+
const now = /* @__PURE__ */ new Date();
|
|
1738
|
+
for (const { job } of listJobFiles(configPath).filter(({ job: job2 }) => isUnfinished(job2))) {
|
|
1739
|
+
const elapsed = formatDuration(now.getTime() - new Date(job.startedAt).getTime());
|
|
1740
|
+
process.stdout.write(`waiting on #${job.item} (${job.stage ?? "starting"}, ${elapsed})
|
|
1741
|
+
`);
|
|
1742
|
+
}
|
|
1743
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
1744
|
+
}
|
|
1745
|
+
process.stdout.write("runner exited\n");
|
|
1746
|
+
}
|
|
1747
|
+
function listJobFiles(configPath) {
|
|
1748
|
+
const dir = jobsDir(configPath);
|
|
1749
|
+
if (!existsSync(dir)) return [];
|
|
1750
|
+
const result = [];
|
|
1751
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
1752
|
+
if (!entry.isDirectory()) continue;
|
|
1753
|
+
const job = readJobFile(join(dir, entry.name, "job.json"));
|
|
1754
|
+
if (job) result.push({ dirName: entry.name, job });
|
|
1755
|
+
}
|
|
1756
|
+
return result;
|
|
1757
|
+
}
|
|
1758
|
+
function runnerProcessState(configPath) {
|
|
1759
|
+
const entry = readPidFile(pidFilePath(configPath));
|
|
1760
|
+
if (!entry) return { state: "absent" };
|
|
1761
|
+
return isSameProcess(entry.pid, entry.start) ? { state: "running", pid: entry.pid } : { state: "stale", pid: entry.pid };
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
export { RunnerStartError, listJobFiles, probeCredentials, requestStop, runnerProcessState, start, tick, watchTick };
|