@toon-protocol/rig 4.1.0 → 4.3.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/README.md +194 -4
- package/dist/chunk-3RWM7PBM.js +3705 -0
- package/dist/chunk-3RWM7PBM.js.map +1 -0
- package/dist/chunk-5HFOPKAB.js +7668 -0
- package/dist/chunk-5HFOPKAB.js.map +1 -0
- package/dist/{chunk-NCEMQUPA.js → chunk-GWA3CSUR.js} +198 -6
- package/dist/chunk-GWA3CSUR.js.map +1 -0
- package/dist/chunk-U5A7MHBP.js +198 -0
- package/dist/chunk-U5A7MHBP.js.map +1 -0
- package/dist/{chunk-CDSFVZDV.js → chunk-UE5TAFEU.js} +3 -14
- package/dist/chunk-UE5TAFEU.js.map +1 -0
- package/dist/{chunk-YZRR2UO6.js → chunk-XCLUB2CY.js} +1 -183
- package/dist/chunk-XCLUB2CY.js.map +1 -0
- package/dist/ci-serve-ZVJBZ3W3.js +2303 -0
- package/dist/ci-serve-ZVJBZ3W3.js.map +1 -0
- package/dist/cli/rig.js +10 -7815
- package/dist/cli/rig.js.map +1 -1
- package/dist/index.d.ts +754 -6
- package/dist/index.js +170 -10
- package/dist/index.js.map +1 -1
- package/dist/{publisher-DMJ06cuL.d.ts → publisher-Cf6tKnZ3.d.ts} +4 -3
- package/dist/standalone/index.d.ts +1 -1
- package/dist/standalone/index.js +11 -10
- package/dist/{standalone-mode-KTYRTUS6.js → standalone-mode-JIEECDTI.js} +10 -11
- package/dist/{standalone-mode-KTYRTUS6.js.map → standalone-mode-JIEECDTI.js.map} +1 -1
- package/package.json +3 -2
- package/dist/chunk-7VCVR4GK.js +0 -1250
- package/dist/chunk-7VCVR4GK.js.map +0 -1
- package/dist/chunk-CDSFVZDV.js.map +0 -1
- package/dist/chunk-GVXBUL3C.js +0 -71
- package/dist/chunk-GVXBUL3C.js.map +0 -1
- package/dist/chunk-LTFMPOHU.js +0 -194
- package/dist/chunk-LTFMPOHU.js.map +0 -1
- package/dist/chunk-NCEMQUPA.js.map +0 -1
- package/dist/chunk-YZRR2UO6.js.map +0 -1
|
@@ -0,0 +1,2303 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PREFERRED_GATEWAY,
|
|
3
|
+
WS_URL_RE,
|
|
4
|
+
identityReport,
|
|
5
|
+
loadPaidSession,
|
|
6
|
+
renderIdentityLine,
|
|
7
|
+
rigVersion,
|
|
8
|
+
withForcedStandalone
|
|
9
|
+
} from "./chunk-5HFOPKAB.js";
|
|
10
|
+
import {
|
|
11
|
+
CI_MANUAL_TRIGGER_KIND,
|
|
12
|
+
CI_SECRET_UPDATE_KIND,
|
|
13
|
+
CI_SERVICE_REQUEST_KIND,
|
|
14
|
+
CI_SERVICE_STOP_KIND,
|
|
15
|
+
CI_WORKFLOW_PROGRESS_KIND,
|
|
16
|
+
applySecretUpdate,
|
|
17
|
+
buildCiAdvertisement,
|
|
18
|
+
buildCiJobResult,
|
|
19
|
+
buildCiWorkflowProgress,
|
|
20
|
+
buildCiWorkflowResult,
|
|
21
|
+
collectRepoObjects,
|
|
22
|
+
decryptSecretUpdate,
|
|
23
|
+
discoverWorkflows,
|
|
24
|
+
effectiveSecrets,
|
|
25
|
+
emitCliError,
|
|
26
|
+
generateSecretsKey,
|
|
27
|
+
hexToNpub,
|
|
28
|
+
matchesPullRequest,
|
|
29
|
+
matchesPush,
|
|
30
|
+
missingObjectsMessage,
|
|
31
|
+
ownerToHex,
|
|
32
|
+
parseCiManualTrigger,
|
|
33
|
+
parseCiSecretUpdate,
|
|
34
|
+
parseCiServiceControl,
|
|
35
|
+
parseRepoAddress,
|
|
36
|
+
parseWorkflow,
|
|
37
|
+
repoAddress,
|
|
38
|
+
runGit,
|
|
39
|
+
selectServiceRequests,
|
|
40
|
+
validateSecretUpdate,
|
|
41
|
+
writeGitObjects
|
|
42
|
+
} from "./chunk-3RWM7PBM.js";
|
|
43
|
+
import {
|
|
44
|
+
REPOSITORY_STATE_KIND,
|
|
45
|
+
authorizedStatusAuthors,
|
|
46
|
+
defaultWebSocketFactory,
|
|
47
|
+
fetchRemoteState,
|
|
48
|
+
queryRelay
|
|
49
|
+
} from "./chunk-GWA3CSUR.js";
|
|
50
|
+
import {
|
|
51
|
+
ChannelMapStore,
|
|
52
|
+
channelStatus,
|
|
53
|
+
contentTypeForPath,
|
|
54
|
+
resolveChannelPaths
|
|
55
|
+
} from "./chunk-XCLUB2CY.js";
|
|
56
|
+
import {
|
|
57
|
+
uploadChargeFor
|
|
58
|
+
} from "./chunk-U5A7MHBP.js";
|
|
59
|
+
|
|
60
|
+
// src/cli/ci-serve.ts
|
|
61
|
+
import { join as join4 } from "path";
|
|
62
|
+
import { parseArgs } from "util";
|
|
63
|
+
|
|
64
|
+
// src/ci/act-runner.ts
|
|
65
|
+
import { execFile, spawn } from "child_process";
|
|
66
|
+
import {
|
|
67
|
+
existsSync,
|
|
68
|
+
mkdirSync,
|
|
69
|
+
readdirSync,
|
|
70
|
+
readFileSync,
|
|
71
|
+
statSync,
|
|
72
|
+
writeFileSync
|
|
73
|
+
} from "fs";
|
|
74
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
75
|
+
import { tmpdir } from "os";
|
|
76
|
+
import { basename, dirname, join, relative, resolve, sep } from "path";
|
|
77
|
+
import { promisify } from "util";
|
|
78
|
+
import { inflateRawSync } from "zlib";
|
|
79
|
+
import { accessSync, constants } from "fs";
|
|
80
|
+
var RIG_ACT_BIN_ENV = "RIG_ACT_BIN";
|
|
81
|
+
var DEFAULT_ACT_PLATFORMS = {
|
|
82
|
+
"ubuntu-latest": "catthehacker/ubuntu:act-latest"
|
|
83
|
+
};
|
|
84
|
+
var execFileAsync = promisify(execFile);
|
|
85
|
+
function actContainerNamePrefix(workflowName) {
|
|
86
|
+
const prefix = `act-${workflowName}-`.replace(/[^a-zA-Z0-9]/g, "-").replaceAll("--", "-");
|
|
87
|
+
return prefix.length > 63 ? prefix.slice(0, 63) : prefix;
|
|
88
|
+
}
|
|
89
|
+
var CONTAINER_CREATED_SLACK_MS = 5e3;
|
|
90
|
+
function isExecutable(path) {
|
|
91
|
+
try {
|
|
92
|
+
accessSync(path, constants.X_OK);
|
|
93
|
+
return statSync(path).isFile();
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function resolveActBinary(env, explicit) {
|
|
99
|
+
const candidate = explicit ?? env[RIG_ACT_BIN_ENV];
|
|
100
|
+
if (candidate) return isExecutable(candidate) ? candidate : null;
|
|
101
|
+
for (const dir of (env["PATH"] ?? "").split(":")) {
|
|
102
|
+
if (!dir) continue;
|
|
103
|
+
const p = join(dir, "act");
|
|
104
|
+
if (isExecutable(p)) return p;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
var EXITCODE_RE = /^exitcode '(\d+)'/;
|
|
109
|
+
function parseActJsonLine(line) {
|
|
110
|
+
if (!line.startsWith("{")) return null;
|
|
111
|
+
let obj;
|
|
112
|
+
try {
|
|
113
|
+
obj = JSON.parse(line);
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const jobId = obj["jobID"];
|
|
118
|
+
if (typeof jobId !== "string" || jobId === "") return null;
|
|
119
|
+
const display = typeof obj["job"] === "string" ? obj["job"] : jobId;
|
|
120
|
+
const slash = display.indexOf("/");
|
|
121
|
+
const jobName = (slash >= 0 ? display.slice(slash + 1) : display).trim() || jobId;
|
|
122
|
+
const msg = typeof obj["msg"] === "string" ? obj["msg"] : "";
|
|
123
|
+
const out = {
|
|
124
|
+
jobId,
|
|
125
|
+
jobName,
|
|
126
|
+
msg,
|
|
127
|
+
level: typeof obj["level"] === "string" ? obj["level"] : "info",
|
|
128
|
+
rawOutput: obj["raw_output"] === true,
|
|
129
|
+
timeMs: typeof obj["time"] === "string" ? Date.parse(obj["time"]) || 0 : 0
|
|
130
|
+
};
|
|
131
|
+
if (typeof obj["jobResult"] === "string") out.jobResult = obj["jobResult"];
|
|
132
|
+
const exit = EXITCODE_RE.exec(msg);
|
|
133
|
+
if (exit) out.exitCode = Number(exit[1]);
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
function mapJobResult(result, outcome) {
|
|
137
|
+
if (result === "success") return "success";
|
|
138
|
+
if (result === "skipped") return "skipped";
|
|
139
|
+
if (outcome.timedOut) return "timed_out";
|
|
140
|
+
if (outcome.cancelled) return "cancelled";
|
|
141
|
+
switch (result) {
|
|
142
|
+
case "failure":
|
|
143
|
+
return "failure";
|
|
144
|
+
case "cancelled":
|
|
145
|
+
return "cancelled";
|
|
146
|
+
default:
|
|
147
|
+
return outcome.exitCode === 0 ? "success" : "failure";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function summarizeActRun(lines, outcome) {
|
|
151
|
+
const byJob = /* @__PURE__ */ new Map();
|
|
152
|
+
for (const l of lines) {
|
|
153
|
+
const list = byJob.get(l.jobId);
|
|
154
|
+
if (list) list.push(l);
|
|
155
|
+
else byJob.set(l.jobId, [l]);
|
|
156
|
+
}
|
|
157
|
+
const jobs = [];
|
|
158
|
+
for (const [jobId, list] of byJob) {
|
|
159
|
+
const terminal = list.find((l) => l.jobResult !== void 0);
|
|
160
|
+
const exitLine = list.find((l) => l.exitCode !== void 0);
|
|
161
|
+
const conclusion2 = mapJobResult(terminal?.jobResult, outcome);
|
|
162
|
+
const times = list.map((l) => l.timeMs).filter((t) => t > 0);
|
|
163
|
+
const startedAt = times.length > 0 ? Math.floor(Math.min(...times) / 1e3) : 0;
|
|
164
|
+
const finishedAt = times.length > 0 ? Math.floor(Math.max(...times) / 1e3) : 0;
|
|
165
|
+
const log = list.filter((l) => l.exitCode === void 0).map((l) => l.rawOutput || l.msg.endsWith("\n") ? l.msg : `${l.msg}
|
|
166
|
+
`).join("");
|
|
167
|
+
const job = {
|
|
168
|
+
jobId,
|
|
169
|
+
name: list[0]?.jobName ?? jobId,
|
|
170
|
+
conclusion: conclusion2,
|
|
171
|
+
log,
|
|
172
|
+
startedAt,
|
|
173
|
+
finishedAt
|
|
174
|
+
};
|
|
175
|
+
if (exitLine?.exitCode !== void 0) job.exitCode = exitLine.exitCode;
|
|
176
|
+
else if (conclusion2 === "success") job.exitCode = 0;
|
|
177
|
+
jobs.push(job);
|
|
178
|
+
}
|
|
179
|
+
let conclusion;
|
|
180
|
+
if (jobs.length === 0) {
|
|
181
|
+
conclusion = outcome.timedOut ? "timed_out" : outcome.cancelled ? "cancelled" : outcome.exitCode === 0 ? "success" : "startup_failure";
|
|
182
|
+
} else if (outcome.timedOut && jobs.some((j) => j.conclusion === "timed_out")) {
|
|
183
|
+
conclusion = "timed_out";
|
|
184
|
+
} else if (outcome.cancelled && jobs.some((j) => j.conclusion === "cancelled")) {
|
|
185
|
+
conclusion = "cancelled";
|
|
186
|
+
} else if (jobs.some((j) => j.conclusion === "failure")) {
|
|
187
|
+
conclusion = "failure";
|
|
188
|
+
} else if (jobs.some((j) => j.conclusion === "timed_out")) {
|
|
189
|
+
conclusion = "timed_out";
|
|
190
|
+
} else if (jobs.some((j) => j.conclusion === "cancelled")) {
|
|
191
|
+
conclusion = "cancelled";
|
|
192
|
+
} else {
|
|
193
|
+
conclusion = "success";
|
|
194
|
+
}
|
|
195
|
+
return { conclusion, jobs };
|
|
196
|
+
}
|
|
197
|
+
function actEventName(trigger, triggers) {
|
|
198
|
+
if (trigger.reason === "push") return "push";
|
|
199
|
+
if (trigger.reason === "pull_request") return "pull_request";
|
|
200
|
+
if (triggers.push) return "push";
|
|
201
|
+
if (triggers.pull_request) return "pull_request";
|
|
202
|
+
return "workflow_dispatch";
|
|
203
|
+
}
|
|
204
|
+
function shortBranch(ref) {
|
|
205
|
+
if (!ref) return "main";
|
|
206
|
+
return ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
|
|
207
|
+
}
|
|
208
|
+
function buildActEventPayload(trigger) {
|
|
209
|
+
if (trigger.reason === "pull_request" && trigger.pr) {
|
|
210
|
+
const base = shortBranch(trigger.ref);
|
|
211
|
+
return {
|
|
212
|
+
action: "synchronize",
|
|
213
|
+
number: 0,
|
|
214
|
+
ref: `refs/pull/0/merge`,
|
|
215
|
+
pull_request: {
|
|
216
|
+
number: 0,
|
|
217
|
+
head: { sha: trigger.commit, ref: trigger.commit.slice(0, 12) },
|
|
218
|
+
base: { ref: base }
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
ref: trigger.ref ?? "refs/heads/main",
|
|
224
|
+
before: "0".repeat(40),
|
|
225
|
+
after: trigger.commit,
|
|
226
|
+
head_commit: { id: trigger.commit }
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
var EOCD_SIG = 101010256;
|
|
230
|
+
var CDIR_SIG = 33639248;
|
|
231
|
+
var LOCAL_SIG = 67324752;
|
|
232
|
+
function extractZip(zipPath, destDir) {
|
|
233
|
+
const buf = readFileSync(zipPath);
|
|
234
|
+
let eocd = -1;
|
|
235
|
+
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 65535); i--) {
|
|
236
|
+
if (buf.readUInt32LE(i) === EOCD_SIG) {
|
|
237
|
+
eocd = i;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (eocd < 0) throw new Error(`not a zip file: ${zipPath}`);
|
|
242
|
+
const entryCount = buf.readUInt16LE(eocd + 10);
|
|
243
|
+
let offset = buf.readUInt32LE(eocd + 16);
|
|
244
|
+
if (offset === 4294967295)
|
|
245
|
+
throw new Error(`zip64 archives are not supported: ${zipPath}`);
|
|
246
|
+
const root = resolve(destDir);
|
|
247
|
+
const names = [];
|
|
248
|
+
for (let n = 0; n < entryCount; n++) {
|
|
249
|
+
if (buf.readUInt32LE(offset) !== CDIR_SIG)
|
|
250
|
+
throw new Error(`corrupt central directory: ${zipPath}`);
|
|
251
|
+
const method = buf.readUInt16LE(offset + 10);
|
|
252
|
+
const compSize = buf.readUInt32LE(offset + 20);
|
|
253
|
+
const nameLen = buf.readUInt16LE(offset + 28);
|
|
254
|
+
const extraLen = buf.readUInt16LE(offset + 30);
|
|
255
|
+
const commentLen = buf.readUInt16LE(offset + 32);
|
|
256
|
+
const localOffset = buf.readUInt32LE(offset + 42);
|
|
257
|
+
const name = buf.toString("utf-8", offset + 46, offset + 46 + nameLen);
|
|
258
|
+
offset += 46 + nameLen + extraLen + commentLen;
|
|
259
|
+
if (name.endsWith("/")) continue;
|
|
260
|
+
const target = resolve(root, name);
|
|
261
|
+
if (target !== root && !target.startsWith(root + sep)) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`zip entry would escape the destination: ${JSON.stringify(name)}`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if (buf.readUInt32LE(localOffset) !== LOCAL_SIG)
|
|
267
|
+
throw new Error(`corrupt local header: ${zipPath}`);
|
|
268
|
+
const localNameLen = buf.readUInt16LE(localOffset + 26);
|
|
269
|
+
const localExtraLen = buf.readUInt16LE(localOffset + 28);
|
|
270
|
+
const dataStart = localOffset + 30 + localNameLen + localExtraLen;
|
|
271
|
+
const data = buf.subarray(dataStart, dataStart + compSize);
|
|
272
|
+
let bytes;
|
|
273
|
+
if (method === 0) bytes = Buffer.from(data);
|
|
274
|
+
else if (method === 8) bytes = inflateRawSync(data);
|
|
275
|
+
else
|
|
276
|
+
throw new Error(
|
|
277
|
+
`unsupported zip compression method ${method} in ${zipPath}`
|
|
278
|
+
);
|
|
279
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
280
|
+
writeFileSync(target, bytes);
|
|
281
|
+
names.push(name);
|
|
282
|
+
}
|
|
283
|
+
return names;
|
|
284
|
+
}
|
|
285
|
+
function walkFiles(dir) {
|
|
286
|
+
const out = [];
|
|
287
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort(
|
|
288
|
+
(a, b) => a.name.localeCompare(b.name)
|
|
289
|
+
)) {
|
|
290
|
+
const p = join(dir, entry.name);
|
|
291
|
+
if (entry.isDirectory()) out.push(...walkFiles(p));
|
|
292
|
+
else if (entry.isFile()) out.push(p);
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
function collectArtifacts(artifactDir) {
|
|
297
|
+
if (!existsSync(artifactDir)) return [];
|
|
298
|
+
const out = [];
|
|
299
|
+
for (const run of readdirSync(artifactDir, { withFileTypes: true })) {
|
|
300
|
+
if (!run.isDirectory()) continue;
|
|
301
|
+
const runDir = join(artifactDir, run.name);
|
|
302
|
+
for (const art of readdirSync(runDir, { withFileTypes: true }).sort(
|
|
303
|
+
(a, b) => a.name.localeCompare(b.name)
|
|
304
|
+
)) {
|
|
305
|
+
if (!art.isDirectory()) continue;
|
|
306
|
+
const artDir = join(runDir, art.name);
|
|
307
|
+
const unpackedDir = join(artDir, "unpacked");
|
|
308
|
+
for (const file of walkFiles(artDir)) {
|
|
309
|
+
if (file.startsWith(unpackedDir + sep)) continue;
|
|
310
|
+
if (file.toLowerCase().endsWith(".zip")) {
|
|
311
|
+
for (const name of extractZip(file, unpackedDir)) {
|
|
312
|
+
out.push({
|
|
313
|
+
path: join(unpackedDir, name),
|
|
314
|
+
filename: name,
|
|
315
|
+
name: art.name
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
} else {
|
|
319
|
+
out.push({
|
|
320
|
+
path: file,
|
|
321
|
+
filename: relative(artDir, file).split(sep).join("/"),
|
|
322
|
+
name: art.name
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
var ActRunner = class {
|
|
331
|
+
constructor(options = {}) {
|
|
332
|
+
this.options = options;
|
|
333
|
+
this.platforms = options.platforms ?? { ...DEFAULT_ACT_PLATFORMS };
|
|
334
|
+
this.selectors = Object.keys(this.platforms);
|
|
335
|
+
}
|
|
336
|
+
options;
|
|
337
|
+
family = "act";
|
|
338
|
+
selectors;
|
|
339
|
+
platforms;
|
|
340
|
+
/** The resolved act binary, or null when none is available. */
|
|
341
|
+
binary(env = process.env) {
|
|
342
|
+
return resolveActBinary(env, this.options.actBin);
|
|
343
|
+
}
|
|
344
|
+
async run(request) {
|
|
345
|
+
const startedAt = Math.floor(Date.now() / 1e3);
|
|
346
|
+
const bin = this.binary();
|
|
347
|
+
if (bin === null) {
|
|
348
|
+
return startupFailure(
|
|
349
|
+
startedAt,
|
|
350
|
+
"act binary not found (set RIG_ACT_BIN or install act)"
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
const workflowText = readFileSync(
|
|
354
|
+
join(request.checkoutDir, request.workflow.path)
|
|
355
|
+
);
|
|
356
|
+
const parsed = parseWorkflow(request.workflow.path, workflowText);
|
|
357
|
+
if (parsed.parseError !== void 0) {
|
|
358
|
+
return startupFailure(
|
|
359
|
+
startedAt,
|
|
360
|
+
`workflow parse error: ${parsed.parseError}`
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
const scratch = await mkdtemp(join(tmpdir(), "rig-act-"));
|
|
364
|
+
const artifactDir = this.options.artifactDir ?? join(scratch, "artifacts");
|
|
365
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
366
|
+
const eventPath = join(scratch, "event.json");
|
|
367
|
+
writeFileSync(
|
|
368
|
+
eventPath,
|
|
369
|
+
JSON.stringify(buildActEventPayload(request.trigger))
|
|
370
|
+
);
|
|
371
|
+
const args = [
|
|
372
|
+
actEventName(request.trigger, parsed.triggers),
|
|
373
|
+
"-W",
|
|
374
|
+
request.workflow.path,
|
|
375
|
+
"-C",
|
|
376
|
+
request.checkoutDir,
|
|
377
|
+
"--json",
|
|
378
|
+
"-e",
|
|
379
|
+
eventPath,
|
|
380
|
+
"--artifact-server-path",
|
|
381
|
+
artifactDir
|
|
382
|
+
];
|
|
383
|
+
if (this.options.pull === false) args.push("--pull=false");
|
|
384
|
+
for (const [label, image] of Object.entries(this.platforms)) {
|
|
385
|
+
args.push("-P", `${label}=${image}`);
|
|
386
|
+
}
|
|
387
|
+
for (const name of Object.keys(request.secrets)) args.push("-s", name);
|
|
388
|
+
for (const name of Object.keys(request.env ?? {})) args.push("--env", name);
|
|
389
|
+
const env = {
|
|
390
|
+
...process.env,
|
|
391
|
+
...request.env,
|
|
392
|
+
...request.secrets
|
|
393
|
+
};
|
|
394
|
+
const lines = [];
|
|
395
|
+
let timedOut = false;
|
|
396
|
+
let cancelled = false;
|
|
397
|
+
const startedAtMs = Date.now();
|
|
398
|
+
const grace = this.options.killGraceMs ?? 1e4;
|
|
399
|
+
const exitCode = await new Promise((resolveExit) => {
|
|
400
|
+
const child = spawn(bin, args, {
|
|
401
|
+
cwd: request.checkoutDir,
|
|
402
|
+
env,
|
|
403
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
404
|
+
});
|
|
405
|
+
let killTimer;
|
|
406
|
+
const kill = () => {
|
|
407
|
+
child.kill("SIGTERM");
|
|
408
|
+
killTimer = setTimeout(() => child.kill("SIGKILL"), grace);
|
|
409
|
+
};
|
|
410
|
+
const timeout = setTimeout(() => {
|
|
411
|
+
timedOut = true;
|
|
412
|
+
kill();
|
|
413
|
+
}, request.timeoutMs);
|
|
414
|
+
const onAbort = () => {
|
|
415
|
+
cancelled = true;
|
|
416
|
+
kill();
|
|
417
|
+
};
|
|
418
|
+
if (request.signal?.aborted) onAbort();
|
|
419
|
+
else request.signal?.addEventListener("abort", onAbort, { once: true });
|
|
420
|
+
const consume = (stream) => {
|
|
421
|
+
let buffered = "";
|
|
422
|
+
stream.setEncoding("utf-8");
|
|
423
|
+
stream.on("data", (chunk) => {
|
|
424
|
+
buffered += chunk;
|
|
425
|
+
let nl;
|
|
426
|
+
while ((nl = buffered.indexOf("\n")) >= 0) {
|
|
427
|
+
const line = buffered.slice(0, nl);
|
|
428
|
+
buffered = buffered.slice(nl + 1);
|
|
429
|
+
const parsedLine = parseActJsonLine(line);
|
|
430
|
+
if (!parsedLine) continue;
|
|
431
|
+
lines.push(parsedLine);
|
|
432
|
+
if (request.onLog && parsedLine.exitCode === void 0) {
|
|
433
|
+
request.onLog(
|
|
434
|
+
parsedLine.jobId,
|
|
435
|
+
parsedLine.rawOutput || parsedLine.msg.endsWith("\n") ? parsedLine.msg : `${parsedLine.msg}
|
|
436
|
+
`
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
stream.on("end", () => {
|
|
442
|
+
const parsedLine = parseActJsonLine(buffered);
|
|
443
|
+
if (parsedLine) lines.push(parsedLine);
|
|
444
|
+
});
|
|
445
|
+
};
|
|
446
|
+
consume(child.stdout);
|
|
447
|
+
consume(child.stderr);
|
|
448
|
+
child.on("error", () => resolveExit(null));
|
|
449
|
+
child.on("close", (code) => {
|
|
450
|
+
clearTimeout(timeout);
|
|
451
|
+
if (killTimer) clearTimeout(killTimer);
|
|
452
|
+
request.signal?.removeEventListener("abort", onAbort);
|
|
453
|
+
resolveExit(code);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
if (timedOut || cancelled) {
|
|
457
|
+
await this.removeOrphanedContainers(
|
|
458
|
+
actContainerNamePrefix(parsed.name ?? basename(request.workflow.path)),
|
|
459
|
+
startedAtMs,
|
|
460
|
+
(note) => {
|
|
461
|
+
this.options.warn?.(note);
|
|
462
|
+
request.onLog?.("runner", `${note}
|
|
463
|
+
`);
|
|
464
|
+
}
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
const summary = summarizeActRun(lines, { exitCode, timedOut, cancelled });
|
|
468
|
+
const artifacts = collectArtifacts(artifactDir);
|
|
469
|
+
const finishedAt = Math.floor(Date.now() / 1e3);
|
|
470
|
+
const jobs = summary.jobs.map((j) => ({
|
|
471
|
+
jobId: j.jobId,
|
|
472
|
+
name: j.name,
|
|
473
|
+
conclusion: j.conclusion,
|
|
474
|
+
...j.exitCode !== void 0 ? { exitCode: j.exitCode } : {},
|
|
475
|
+
startedAt: j.startedAt || startedAt,
|
|
476
|
+
finishedAt: j.finishedAt || finishedAt,
|
|
477
|
+
log: j.log,
|
|
478
|
+
artifacts: []
|
|
479
|
+
}));
|
|
480
|
+
for (const artifact of artifacts) {
|
|
481
|
+
const owner = jobs.find(
|
|
482
|
+
(j) => j.log.includes(
|
|
483
|
+
`Artifact ${artifact.name} has been successfully uploaded`
|
|
484
|
+
)
|
|
485
|
+
) ?? jobs[0];
|
|
486
|
+
owner?.artifacts.push(artifact);
|
|
487
|
+
}
|
|
488
|
+
if (this.options.artifactDir !== void 0) {
|
|
489
|
+
await rm(scratch, { recursive: true, force: true });
|
|
490
|
+
}
|
|
491
|
+
return { conclusion: summary.conclusion, jobs, startedAt, finishedAt };
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Remove the job containers a killed act left running (see the module
|
|
495
|
+
* header): every container whose name starts with `prefix` and whose
|
|
496
|
+
* Docker `Created` stamp is at or after this run's start (minus a little
|
|
497
|
+
* slack). Never throws — a failed cleanup is reported through `note` and
|
|
498
|
+
* the run result is still returned.
|
|
499
|
+
*/
|
|
500
|
+
async removeOrphanedContainers(prefix, startedAtMs, note) {
|
|
501
|
+
const docker = this.options.dockerBin ?? "docker";
|
|
502
|
+
const run = async (args) => {
|
|
503
|
+
const { stdout } = await execFileAsync(docker, args, {
|
|
504
|
+
encoding: "utf-8",
|
|
505
|
+
timeout: 3e4
|
|
506
|
+
});
|
|
507
|
+
return stdout;
|
|
508
|
+
};
|
|
509
|
+
try {
|
|
510
|
+
const ids = (await run(["ps", "-aq", "--filter", `name=^${prefix}`])).split("\n").map((id) => id.trim()).filter((id) => id !== "");
|
|
511
|
+
const ours = [];
|
|
512
|
+
for (const id of ids) {
|
|
513
|
+
const created = Date.parse(
|
|
514
|
+
(await run(["inspect", "-f", "{{.Created}}", id])).trim()
|
|
515
|
+
);
|
|
516
|
+
if (Number.isNaN(created) || created >= startedAtMs - CONTAINER_CREATED_SLACK_MS) {
|
|
517
|
+
ours.push(id);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (ours.length === 0) return;
|
|
521
|
+
await run(["rm", "-f", ...ours]);
|
|
522
|
+
note(
|
|
523
|
+
`removed ${ours.length} job container(s) act left running after the kill (${prefix}*)`
|
|
524
|
+
);
|
|
525
|
+
} catch (err) {
|
|
526
|
+
note(
|
|
527
|
+
`could not remove act's leftover job containers (${prefix}*): ${err instanceof Error ? err.message : String(err)} \u2014 remove them by hand`
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
function startupFailure(startedAt, reason) {
|
|
533
|
+
return {
|
|
534
|
+
conclusion: "startup_failure",
|
|
535
|
+
startedAt,
|
|
536
|
+
finishedAt: Math.floor(Date.now() / 1e3),
|
|
537
|
+
jobs: [
|
|
538
|
+
{
|
|
539
|
+
jobId: "startup",
|
|
540
|
+
conclusion: "startup_failure",
|
|
541
|
+
startedAt,
|
|
542
|
+
finishedAt: Math.floor(Date.now() / 1e3),
|
|
543
|
+
log: `${reason}
|
|
544
|
+
`,
|
|
545
|
+
artifacts: []
|
|
546
|
+
}
|
|
547
|
+
]
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// src/ci/coordinator.ts
|
|
552
|
+
import { randomUUID } from "crypto";
|
|
553
|
+
import { readFile as readFile2, rm as rm2 } from "fs/promises";
|
|
554
|
+
import { join as join3 } from "path";
|
|
555
|
+
import { REPOSITORY_ANNOUNCEMENT_KIND } from "@toon-protocol/core/nip34";
|
|
556
|
+
|
|
557
|
+
// src/ci/materialize-commit.ts
|
|
558
|
+
import { spawn as spawn2 } from "child_process";
|
|
559
|
+
import { mkdir } from "fs/promises";
|
|
560
|
+
var FULL_SHA_RE = /^[0-9a-f]{40}$/;
|
|
561
|
+
var MaterializeError = class extends Error {
|
|
562
|
+
constructor(message) {
|
|
563
|
+
super(message);
|
|
564
|
+
this.name = "MaterializeError";
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
function gitWithStdin(cwd, args, stdin, env) {
|
|
568
|
+
return new Promise((resolve2, reject) => {
|
|
569
|
+
const child = spawn2("git", args, {
|
|
570
|
+
cwd,
|
|
571
|
+
env,
|
|
572
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
573
|
+
});
|
|
574
|
+
let stdout = "";
|
|
575
|
+
let stderr = "";
|
|
576
|
+
child.stdout.on("data", (chunk) => {
|
|
577
|
+
stdout += chunk.toString("utf-8");
|
|
578
|
+
});
|
|
579
|
+
child.stderr.on("data", (chunk) => {
|
|
580
|
+
stderr += chunk.toString("utf-8");
|
|
581
|
+
});
|
|
582
|
+
child.on(
|
|
583
|
+
"error",
|
|
584
|
+
(err) => reject(new Error(`failed to spawn git: ${err.message}`))
|
|
585
|
+
);
|
|
586
|
+
child.on("close", (code) => {
|
|
587
|
+
if (code !== 0) {
|
|
588
|
+
reject(
|
|
589
|
+
new Error(`git ${args[0]} failed (exit ${code}): ${stderr.trim()}`)
|
|
590
|
+
);
|
|
591
|
+
} else {
|
|
592
|
+
resolve2(stdout);
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
child.stdin.on("error", () => {
|
|
596
|
+
});
|
|
597
|
+
child.stdin.write(stdin);
|
|
598
|
+
child.stdin.end();
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
var materializeCommit = async (opts) => {
|
|
602
|
+
if (!FULL_SHA_RE.test(opts.commit)) {
|
|
603
|
+
throw new MaterializeError(
|
|
604
|
+
`not a full commit sha: ${JSON.stringify(opts.commit)}`
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
const remote = await fetchRemoteState({
|
|
608
|
+
relayUrls: opts.relayUrls,
|
|
609
|
+
ownerPubkey: opts.ownerPubkey,
|
|
610
|
+
repoId: opts.repoId,
|
|
611
|
+
...opts.webSocketFactory ? { webSocketFactory: opts.webSocketFactory } : {},
|
|
612
|
+
...opts.resolveSha ? { resolveSha: opts.resolveSha } : {}
|
|
613
|
+
});
|
|
614
|
+
if (!remote.announced && remote.refsEvent === null) {
|
|
615
|
+
throw new MaterializeError(
|
|
616
|
+
`repository ${opts.ownerPubkey.slice(0, 8)}\u2026/${opts.repoId} is not on the relay`
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
const collected = await collectRepoObjects({
|
|
620
|
+
tips: [opts.commit, ...opts.extraObjectIds ?? []],
|
|
621
|
+
shaToTxId: remote.shaToTxId,
|
|
622
|
+
resolveMissing: (shas) => remote.resolveMissing(shas),
|
|
623
|
+
...opts.fetchFn ? { fetchFn: opts.fetchFn } : {},
|
|
624
|
+
...opts.gateways ? { gateways: opts.gateways } : {}
|
|
625
|
+
});
|
|
626
|
+
const extra = new Set(
|
|
627
|
+
(opts.extraObjectIds ?? []).map((id) => id.toLowerCase())
|
|
628
|
+
);
|
|
629
|
+
const fatal = collected.missing.filter(
|
|
630
|
+
(m) => !extra.has(m.sha.toLowerCase())
|
|
631
|
+
);
|
|
632
|
+
if (fatal.length > 0) {
|
|
633
|
+
throw new MaterializeError(
|
|
634
|
+
missingObjectsMessage(
|
|
635
|
+
fatal,
|
|
636
|
+
`cannot materialize ${opts.commit.slice(0, 7)}`
|
|
637
|
+
)
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
await mkdir(opts.dir, { recursive: true });
|
|
641
|
+
await runGit(opts.dir, ["init", "--quiet", "--initial-branch=main"]);
|
|
642
|
+
const objectsWritten = await writeGitObjects(
|
|
643
|
+
opts.dir,
|
|
644
|
+
collected.objects.values()
|
|
645
|
+
);
|
|
646
|
+
await runGit(opts.dir, ["checkout", "--quiet", "--detach", opts.commit]);
|
|
647
|
+
if (opts.patch) {
|
|
648
|
+
const committer = opts.committer ?? {
|
|
649
|
+
name: "rig ci",
|
|
650
|
+
email: "ci@rig.invalid"
|
|
651
|
+
};
|
|
652
|
+
const env = {
|
|
653
|
+
...process.env,
|
|
654
|
+
GIT_COMMITTER_NAME: committer.name,
|
|
655
|
+
GIT_COMMITTER_EMAIL: committer.email
|
|
656
|
+
};
|
|
657
|
+
try {
|
|
658
|
+
await gitWithStdin(opts.dir, ["am", "--quiet"], opts.patch.content, env);
|
|
659
|
+
} catch (err) {
|
|
660
|
+
throw new MaterializeError(
|
|
661
|
+
`patch does not apply on ${opts.commit.slice(0, 7)}: ${err instanceof Error ? err.message : String(err)}`
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
const head = (await runGit(opts.dir, ["rev-parse", "HEAD"])).trim();
|
|
666
|
+
return { dir: opts.dir, commit: head, objectsWritten };
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
// src/ci/relay-subscription.ts
|
|
670
|
+
import { decode as decodeToon } from "@toon-format/toon";
|
|
671
|
+
var realScheduler = {
|
|
672
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
673
|
+
clearTimeout: (handle) => clearTimeout(handle)
|
|
674
|
+
};
|
|
675
|
+
var WS_OPEN = 1;
|
|
676
|
+
function decodeRelayEvent(payload) {
|
|
677
|
+
if (payload !== null && typeof payload === "object")
|
|
678
|
+
return payload;
|
|
679
|
+
if (typeof payload !== "string") return null;
|
|
680
|
+
try {
|
|
681
|
+
const parsed = JSON.parse(payload);
|
|
682
|
+
if (parsed !== null && typeof parsed === "object")
|
|
683
|
+
return parsed;
|
|
684
|
+
} catch {
|
|
685
|
+
}
|
|
686
|
+
try {
|
|
687
|
+
return decodeToon(payload);
|
|
688
|
+
} catch {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
function subscribeRelay(opts) {
|
|
693
|
+
const scheduler = opts.scheduler ?? realScheduler;
|
|
694
|
+
const initialBackoff = opts.backoff?.initialMs ?? 1e3;
|
|
695
|
+
const maxBackoff = opts.backoff?.maxMs ?? 6e4;
|
|
696
|
+
const base = `ci-${Math.random().toString(36).slice(2, 8)}`;
|
|
697
|
+
let ws = null;
|
|
698
|
+
let closed = false;
|
|
699
|
+
let connected = false;
|
|
700
|
+
let connections = 0;
|
|
701
|
+
let backoffMs = initialBackoff;
|
|
702
|
+
let reconnectHandle = null;
|
|
703
|
+
let subIds = /* @__PURE__ */ new Map();
|
|
704
|
+
const status = (s, detail) => {
|
|
705
|
+
opts.onStatus?.(s, detail);
|
|
706
|
+
};
|
|
707
|
+
const scheduleReconnect = (detail) => {
|
|
708
|
+
if (closed || reconnectHandle !== null) return;
|
|
709
|
+
connected = false;
|
|
710
|
+
status("reconnecting", detail);
|
|
711
|
+
const delay = backoffMs;
|
|
712
|
+
backoffMs = Math.min(backoffMs * 2, maxBackoff);
|
|
713
|
+
reconnectHandle = scheduler.setTimeout(() => {
|
|
714
|
+
reconnectHandle = null;
|
|
715
|
+
connect();
|
|
716
|
+
}, delay);
|
|
717
|
+
};
|
|
718
|
+
const connect = () => {
|
|
719
|
+
if (closed) return;
|
|
720
|
+
const generation = ++connections;
|
|
721
|
+
subIds = /* @__PURE__ */ new Map();
|
|
722
|
+
let socket;
|
|
723
|
+
try {
|
|
724
|
+
socket = opts.webSocketFactory(opts.url);
|
|
725
|
+
} catch (err) {
|
|
726
|
+
scheduleReconnect(
|
|
727
|
+
`connect failed: ${err instanceof Error ? err.message : String(err)}`
|
|
728
|
+
);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
ws = socket;
|
|
732
|
+
const mine = () => ws === socket && generation === connections;
|
|
733
|
+
socket.addEventListener("open", () => {
|
|
734
|
+
if (!mine() || closed) return;
|
|
735
|
+
connected = true;
|
|
736
|
+
backoffMs = initialBackoff;
|
|
737
|
+
status("open");
|
|
738
|
+
opts.filters.forEach((filter, index) => {
|
|
739
|
+
const subId = `${base}-${generation}-${index}`;
|
|
740
|
+
subIds.set(subId, index);
|
|
741
|
+
const since = filter.since ?? opts.since?.(index);
|
|
742
|
+
const sent = since !== void 0 ? { ...filter, since } : { ...filter };
|
|
743
|
+
socket.send(JSON.stringify(["REQ", subId, sent]));
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
socket.addEventListener("message", (msgEvent) => {
|
|
747
|
+
if (!mine() || closed) return;
|
|
748
|
+
let msg;
|
|
749
|
+
try {
|
|
750
|
+
msg = JSON.parse(String(msgEvent.data));
|
|
751
|
+
} catch {
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (!Array.isArray(msg) || msg.length < 2) return;
|
|
755
|
+
const index = subIds.get(String(msg[1]));
|
|
756
|
+
if (index === void 0) return;
|
|
757
|
+
if (msg[0] === "EVENT" && msg[2] !== void 0) {
|
|
758
|
+
const event = decodeRelayEvent(msg[2]);
|
|
759
|
+
if (event) opts.onEvent(event, index);
|
|
760
|
+
} else if (msg[0] === "EOSE") {
|
|
761
|
+
opts.onEose?.(index);
|
|
762
|
+
} else if (msg[0] === "CLOSED") {
|
|
763
|
+
scheduleReconnect(`relay closed subscription: ${String(msg[2] ?? "")}`);
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
socket.addEventListener("error", (event) => {
|
|
767
|
+
if (!mine() || closed) return;
|
|
768
|
+
const detail = typeof event === "object" && event !== null && "message" in event ? String(event.message) : "unknown";
|
|
769
|
+
status("error", detail);
|
|
770
|
+
try {
|
|
771
|
+
socket.close();
|
|
772
|
+
} catch {
|
|
773
|
+
}
|
|
774
|
+
scheduleReconnect(`socket error: ${detail}`);
|
|
775
|
+
});
|
|
776
|
+
socket.addEventListener("close", () => {
|
|
777
|
+
if (!mine() || closed) return;
|
|
778
|
+
scheduleReconnect("socket closed");
|
|
779
|
+
});
|
|
780
|
+
};
|
|
781
|
+
connect();
|
|
782
|
+
return {
|
|
783
|
+
close() {
|
|
784
|
+
if (closed) return;
|
|
785
|
+
closed = true;
|
|
786
|
+
connected = false;
|
|
787
|
+
if (reconnectHandle !== null) {
|
|
788
|
+
scheduler.clearTimeout(reconnectHandle);
|
|
789
|
+
reconnectHandle = null;
|
|
790
|
+
}
|
|
791
|
+
const socket = ws;
|
|
792
|
+
ws = null;
|
|
793
|
+
if (socket) {
|
|
794
|
+
try {
|
|
795
|
+
if (socket.readyState === WS_OPEN) {
|
|
796
|
+
for (const subId of subIds.keys()) {
|
|
797
|
+
socket.send(JSON.stringify(["CLOSE", subId]));
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
socket.close();
|
|
801
|
+
} catch {
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
status("closed");
|
|
805
|
+
},
|
|
806
|
+
get connected() {
|
|
807
|
+
return connected;
|
|
808
|
+
},
|
|
809
|
+
get connections() {
|
|
810
|
+
return connections;
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// src/ci/state.ts
|
|
816
|
+
import { mkdir as mkdir2, readFile, rename, writeFile } from "fs/promises";
|
|
817
|
+
import { homedir } from "os";
|
|
818
|
+
import { join as join2 } from "path";
|
|
819
|
+
var MAX_PROCESSED_IDS = 1e3;
|
|
820
|
+
function cursorPath(stateDir) {
|
|
821
|
+
return join2(stateDir, "cursor.json");
|
|
822
|
+
}
|
|
823
|
+
function secretsPath(stateDir) {
|
|
824
|
+
return join2(stateDir, "secrets.json");
|
|
825
|
+
}
|
|
826
|
+
function defaultCoordinatorStateDir(env, coordinatorPubkey) {
|
|
827
|
+
const home = env["TOON_CLIENT_HOME"] ?? join2(homedir(), ".toon-client");
|
|
828
|
+
return join2(home, "rig-ci", coordinatorPubkey.toLowerCase());
|
|
829
|
+
}
|
|
830
|
+
function boundProcessed(ids) {
|
|
831
|
+
return ids.length > MAX_PROCESSED_IDS ? ids.slice(ids.length - MAX_PROCESSED_IDS) : ids;
|
|
832
|
+
}
|
|
833
|
+
async function readJson(path) {
|
|
834
|
+
let text;
|
|
835
|
+
try {
|
|
836
|
+
text = await readFile(path, "utf-8");
|
|
837
|
+
} catch (err) {
|
|
838
|
+
if (err.code === "ENOENT") return void 0;
|
|
839
|
+
throw err;
|
|
840
|
+
}
|
|
841
|
+
try {
|
|
842
|
+
return JSON.parse(text);
|
|
843
|
+
} catch (err) {
|
|
844
|
+
throw new Error(
|
|
845
|
+
`coordinator state file ${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
async function writeJsonAtomic(path, value) {
|
|
850
|
+
await mkdir2(join2(path, ".."), { recursive: true });
|
|
851
|
+
const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
852
|
+
await writeFile(tmp, JSON.stringify(value, null, 2) + "\n", "utf-8");
|
|
853
|
+
await rename(tmp, path);
|
|
854
|
+
}
|
|
855
|
+
async function loadCoordinatorState(stateDir) {
|
|
856
|
+
const cursorFile = await readJson(cursorPath(stateDir));
|
|
857
|
+
const secrets = await readJson(secretsPath(stateDir)) ?? {};
|
|
858
|
+
const cursor = {};
|
|
859
|
+
for (const [addr, repo] of Object.entries(cursorFile?.repos ?? {})) {
|
|
860
|
+
cursor[addr] = { ...repo, processed: boundProcessed(repo.processed ?? []) };
|
|
861
|
+
}
|
|
862
|
+
const inbox = {
|
|
863
|
+
...cursorFile?.inbox?.lastCreatedAt !== void 0 ? { lastCreatedAt: cursorFile.inbox.lastCreatedAt } : {},
|
|
864
|
+
processed: boundProcessed(cursorFile?.inbox?.processed ?? [])
|
|
865
|
+
};
|
|
866
|
+
return { cursor, inbox, secrets };
|
|
867
|
+
}
|
|
868
|
+
async function saveCursor(stateDir, cursor, inbox) {
|
|
869
|
+
const repos = {};
|
|
870
|
+
for (const [addr, repo] of Object.entries(cursor)) {
|
|
871
|
+
repos[addr] = { ...repo, processed: boundProcessed(repo.processed) };
|
|
872
|
+
}
|
|
873
|
+
const file = {
|
|
874
|
+
repos,
|
|
875
|
+
inbox: { ...inbox, processed: boundProcessed(inbox.processed) }
|
|
876
|
+
};
|
|
877
|
+
await writeJsonAtomic(cursorPath(stateDir), file);
|
|
878
|
+
}
|
|
879
|
+
async function saveSecrets(stateDir, secrets) {
|
|
880
|
+
await writeJsonAtomic(secretsPath(stateDir), secrets);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// src/ci/coordinator.ts
|
|
884
|
+
var DEFAULT_RUN_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
885
|
+
var DEFAULT_ADVERTISEMENT_TTL = 30 * 60;
|
|
886
|
+
var RENEW_FRACTION = 5 / 6;
|
|
887
|
+
var LOG_TAIL_BYTES = 4096;
|
|
888
|
+
var SECRETS_KEY_MAX_AGE = 86400;
|
|
889
|
+
function nowSeconds() {
|
|
890
|
+
return Math.floor(Date.now() / 1e3);
|
|
891
|
+
}
|
|
892
|
+
function logTail(log) {
|
|
893
|
+
const bytes = Buffer.from(log, "utf-8");
|
|
894
|
+
if (bytes.byteLength <= LOG_TAIL_BYTES) return { tail: log, omitted: 0 };
|
|
895
|
+
const cut = bytes.subarray(bytes.byteLength - LOG_TAIL_BYTES);
|
|
896
|
+
return {
|
|
897
|
+
tail: cut.toString("utf-8"),
|
|
898
|
+
omitted: bytes.byteLength - LOG_TAIL_BYTES
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
function gatewayRawUrl(gatewayUrl, txId) {
|
|
902
|
+
return `${gatewayUrl.replace(/\/+$/, "")}/raw/${txId}`;
|
|
903
|
+
}
|
|
904
|
+
function movedRefs(before, after) {
|
|
905
|
+
const moved = [];
|
|
906
|
+
for (const [ref, sha] of Object.entries(after)) {
|
|
907
|
+
if (before[ref] !== sha) moved.push({ ref, sha });
|
|
908
|
+
}
|
|
909
|
+
return moved;
|
|
910
|
+
}
|
|
911
|
+
function parseRefsTags(ev) {
|
|
912
|
+
const refs = {};
|
|
913
|
+
let head = null;
|
|
914
|
+
for (const [name, v1, v2] of ev.tags) {
|
|
915
|
+
if (name === "r" && v1 && v2) {
|
|
916
|
+
if (v1 === "HEAD" && v2.startsWith("ref: ")) head = v2.slice(5);
|
|
917
|
+
else refs[v1] = v2;
|
|
918
|
+
} else if (name === "HEAD" && v1?.startsWith("ref: ")) {
|
|
919
|
+
head = v1.slice(5);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
return { refs, head };
|
|
923
|
+
}
|
|
924
|
+
function tagValues(tags, name) {
|
|
925
|
+
return tags.filter((t) => t[0] === name).map((t) => t[1] ?? "").filter((v) => v !== "");
|
|
926
|
+
}
|
|
927
|
+
var Serial = class {
|
|
928
|
+
chain = Promise.resolve();
|
|
929
|
+
run(fn) {
|
|
930
|
+
const next = this.chain.then(fn, fn);
|
|
931
|
+
this.chain = next.catch(() => void 0);
|
|
932
|
+
return next;
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
async function startCoordinator(opts) {
|
|
936
|
+
const me = opts.coordinatorPubkey.toLowerCase();
|
|
937
|
+
const { publisher, relayUrl, runner } = opts;
|
|
938
|
+
const concurrency = Math.max(1, opts.concurrency ?? 1);
|
|
939
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_RUN_TIMEOUT_MS;
|
|
940
|
+
const clock = opts.clock ?? nowSeconds;
|
|
941
|
+
const scheduler = opts.scheduler ?? realScheduler;
|
|
942
|
+
const log = opts.log ?? (() => void 0);
|
|
943
|
+
const materialize = opts.materialize ?? materializeCommit;
|
|
944
|
+
const canAfford = opts.canAfford ?? (async () => true);
|
|
945
|
+
const acceptedRequesters = new Set(
|
|
946
|
+
[...opts.acceptedRequesters ?? []].map((p) => p.toLowerCase())
|
|
947
|
+
);
|
|
948
|
+
const adTtl = Math.min(
|
|
949
|
+
opts.advertisementTtlSeconds ?? DEFAULT_ADVERTISEMENT_TTL,
|
|
950
|
+
1800
|
|
951
|
+
);
|
|
952
|
+
if (!publisher.uploadBlob) {
|
|
953
|
+
throw new Error(
|
|
954
|
+
"the coordinator needs a publisher that can upload blobs (job logs and artifacts) \u2014 the standalone publisher provides uploadBlob"
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
const uploadBlob = publisher.uploadBlob.bind(publisher);
|
|
958
|
+
if (opts.repos.length === 0)
|
|
959
|
+
throw new Error("the coordinator needs at least one --repo to serve");
|
|
960
|
+
const serial = new Serial();
|
|
961
|
+
const publish = (event) => serial.run(
|
|
962
|
+
async () => (await publisher.publishEvent(event, [relayUrl])).eventId
|
|
963
|
+
);
|
|
964
|
+
const state = await loadCoordinatorState(opts.stateDir);
|
|
965
|
+
const repos = /* @__PURE__ */ new Map();
|
|
966
|
+
for (const r of opts.repos) {
|
|
967
|
+
const owner = r.ownerPubkey.toLowerCase();
|
|
968
|
+
const addr = repoAddress(owner, r.repoId);
|
|
969
|
+
repos.set(addr, {
|
|
970
|
+
addr,
|
|
971
|
+
ownerPubkey: owner,
|
|
972
|
+
repoId: r.repoId,
|
|
973
|
+
authorized: /* @__PURE__ */ new Set([owner]),
|
|
974
|
+
controls: /* @__PURE__ */ new Map(),
|
|
975
|
+
cursor: state.cursor[addr],
|
|
976
|
+
secrets: state.secrets[addr] ?? {},
|
|
977
|
+
prEvents: /* @__PURE__ */ new Map(),
|
|
978
|
+
defaultBranch: null
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
const inbox = state.inbox;
|
|
982
|
+
const inboxProcessed = new Set(inbox.processed);
|
|
983
|
+
const persistCursor = async () => {
|
|
984
|
+
const cursor = {};
|
|
985
|
+
for (const repo of repos.values())
|
|
986
|
+
if (repo.cursor) cursor[repo.addr] = repo.cursor;
|
|
987
|
+
inbox.processed = [...inboxProcessed];
|
|
988
|
+
await saveCursor(opts.stateDir, cursor, inbox);
|
|
989
|
+
};
|
|
990
|
+
const persistSecrets = async () => {
|
|
991
|
+
const secrets = {};
|
|
992
|
+
for (const repo of repos.values()) secrets[repo.addr] = repo.secrets;
|
|
993
|
+
await saveSecrets(opts.stateDir, secrets);
|
|
994
|
+
};
|
|
995
|
+
for (const repo of repos.values()) {
|
|
996
|
+
const remote = await fetchRemoteState({
|
|
997
|
+
relayUrls: [relayUrl],
|
|
998
|
+
ownerPubkey: repo.ownerPubkey,
|
|
999
|
+
repoId: repo.repoId,
|
|
1000
|
+
...opts.webSocketFactory ? { webSocketFactory: opts.webSocketFactory } : {},
|
|
1001
|
+
...opts.resolveSha ? { resolveSha: opts.resolveSha } : {}
|
|
1002
|
+
});
|
|
1003
|
+
if (remote.announceEvent) {
|
|
1004
|
+
repo.authorized = authorizedStatusAuthors(
|
|
1005
|
+
repo.ownerPubkey,
|
|
1006
|
+
remote.announceEvent.tags
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
repo.defaultBranch = remote.headSymref;
|
|
1010
|
+
if (repo.cursor === void 0) {
|
|
1011
|
+
repo.cursor = {
|
|
1012
|
+
lastCreatedAt: remote.refsEvent?.created_at ?? 0,
|
|
1013
|
+
lastEventId: remote.refsEvent?.id ?? "",
|
|
1014
|
+
refs: Object.fromEntries(remote.refs),
|
|
1015
|
+
processed: remote.refsEvent ? [remote.refsEvent.id] : []
|
|
1016
|
+
};
|
|
1017
|
+
log(
|
|
1018
|
+
`[ci] ${repo.repoId}: recorded ${remote.refs.size} ref(s); watching for pushes`
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
await persistCursor();
|
|
1023
|
+
const isServing = (repo) => selectServiceRequests([...repo.controls.values()], {
|
|
1024
|
+
coordinatorPubkey: me,
|
|
1025
|
+
repoAddr: repo.addr,
|
|
1026
|
+
authorized: repo.authorized,
|
|
1027
|
+
acceptedRequesters
|
|
1028
|
+
}).active !== null;
|
|
1029
|
+
const handleControl = (ev) => {
|
|
1030
|
+
const control = parseCiServiceControl(ev);
|
|
1031
|
+
if (!control || control.coordinatorPubkey !== me) return;
|
|
1032
|
+
const repo = repos.get(control.repoAddr);
|
|
1033
|
+
if (!repo || repo.controls.has(control.eventId)) return;
|
|
1034
|
+
const before = isServing(repo);
|
|
1035
|
+
repo.controls.set(control.eventId, control);
|
|
1036
|
+
const after = isServing(repo);
|
|
1037
|
+
if (before !== after) {
|
|
1038
|
+
const who = repo.authorized.has(control.pubkey) ? "maintainer" : "operator-accepted requester";
|
|
1039
|
+
log(
|
|
1040
|
+
`[ci] ${repo.repoId}: ${after ? `serving (${who} Service Request accepted)` : "service stopped"}`
|
|
1041
|
+
);
|
|
1042
|
+
} else if (control.kind === "request" && !repo.authorized.has(control.pubkey) && !acceptedRequesters.has(control.pubkey.toLowerCase())) {
|
|
1043
|
+
log(
|
|
1044
|
+
`[ci] ${repo.repoId}: Service Request from non-maintainer ${control.pubkey.slice(0, 8)} ignored`
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
const controlFilter = {
|
|
1049
|
+
kinds: [CI_SERVICE_REQUEST_KIND, CI_SERVICE_STOP_KIND],
|
|
1050
|
+
"#p": [me]
|
|
1051
|
+
};
|
|
1052
|
+
try {
|
|
1053
|
+
const history = await queryRelay(
|
|
1054
|
+
relayUrl,
|
|
1055
|
+
controlFilter,
|
|
1056
|
+
1e4,
|
|
1057
|
+
opts.webSocketFactory ?? defaultWebSocketFactory
|
|
1058
|
+
);
|
|
1059
|
+
for (const ev of history) handleControl(ev);
|
|
1060
|
+
} catch (err) {
|
|
1061
|
+
log(
|
|
1062
|
+
`[ci] could not read the Service Request history: ${err instanceof Error ? err.message : String(err)}`
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
let secretsKey = generateSecretsKey();
|
|
1066
|
+
let secretsKeyBorn = clock();
|
|
1067
|
+
let advertisementId = null;
|
|
1068
|
+
const liveAdvertisements = /* @__PURE__ */ new Map();
|
|
1069
|
+
let adTimer = null;
|
|
1070
|
+
let stopping = false;
|
|
1071
|
+
const advertise = async () => {
|
|
1072
|
+
if (stopping) return;
|
|
1073
|
+
const now = clock();
|
|
1074
|
+
if (now - secretsKeyBorn >= SECRETS_KEY_MAX_AGE) {
|
|
1075
|
+
secretsKey = generateSecretsKey();
|
|
1076
|
+
secretsKeyBorn = now;
|
|
1077
|
+
liveAdvertisements.clear();
|
|
1078
|
+
log("[ci] rotated the secrets key");
|
|
1079
|
+
}
|
|
1080
|
+
const expiresAt = now + adTtl;
|
|
1081
|
+
const event = buildCiAdvertisement(
|
|
1082
|
+
{
|
|
1083
|
+
version: opts.version,
|
|
1084
|
+
selectors: runner.selectors,
|
|
1085
|
+
admission: "maintainer-request",
|
|
1086
|
+
execution: "request-required",
|
|
1087
|
+
billing: "out-of-band",
|
|
1088
|
+
secretsKey: { pubkey: secretsKey.pubkey, inboxRelays: [relayUrl] },
|
|
1089
|
+
expiresAt
|
|
1090
|
+
},
|
|
1091
|
+
now
|
|
1092
|
+
);
|
|
1093
|
+
try {
|
|
1094
|
+
advertisementId = await publish(event);
|
|
1095
|
+
liveAdvertisements.set(advertisementId, { createdAt: now, expiresAt });
|
|
1096
|
+
} catch (err) {
|
|
1097
|
+
log(
|
|
1098
|
+
`[ci] advertisement publish failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
if (!stopping) {
|
|
1102
|
+
adTimer = scheduler.setTimeout(
|
|
1103
|
+
() => void advertise(),
|
|
1104
|
+
adTtl * RENEW_FRACTION * 1e3
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
};
|
|
1108
|
+
await advertise();
|
|
1109
|
+
const queue = [];
|
|
1110
|
+
const activeAborts = /* @__PURE__ */ new Set();
|
|
1111
|
+
let active = 0;
|
|
1112
|
+
let inflight = 0;
|
|
1113
|
+
const idleWaiters = [];
|
|
1114
|
+
const checkIdle = () => {
|
|
1115
|
+
if (inflight === 0 && queue.length === 0 && eventQueue.length === 0 && !draining) {
|
|
1116
|
+
for (const w of idleWaiters.splice(0)) w();
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1119
|
+
const releaseCheckout = async (checkout) => {
|
|
1120
|
+
checkout.refs -= 1;
|
|
1121
|
+
if (checkout.refs <= 0 && !opts.keepCheckouts) {
|
|
1122
|
+
await rm2(checkout.dir, { recursive: true, force: true }).catch(
|
|
1123
|
+
() => void 0
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
};
|
|
1127
|
+
const progressAddress = (runId) => `${CI_WORKFLOW_PROGRESS_KIND}:${me}:${runId}`;
|
|
1128
|
+
const concludeWithoutJobs = async (run, conclusion, provenance, startedAt) => {
|
|
1129
|
+
const base = {
|
|
1130
|
+
trigger: run.trigger,
|
|
1131
|
+
runId: run.runId,
|
|
1132
|
+
queuedAt: run.queuedAt,
|
|
1133
|
+
...startedAt !== void 0 ? { startedAt } : {},
|
|
1134
|
+
...provenance ? { provenance } : {},
|
|
1135
|
+
jobs: []
|
|
1136
|
+
};
|
|
1137
|
+
const now = clock();
|
|
1138
|
+
await publish(buildCiWorkflowResult({ ...base, conclusion }, now));
|
|
1139
|
+
await publish(
|
|
1140
|
+
buildCiWorkflowProgress(
|
|
1141
|
+
{ ...base, status: "concluded", conclusion, expiresAt: now + adTtl },
|
|
1142
|
+
now
|
|
1143
|
+
)
|
|
1144
|
+
);
|
|
1145
|
+
};
|
|
1146
|
+
const executeRun = async (run) => {
|
|
1147
|
+
const { repo, trigger, workflow } = run;
|
|
1148
|
+
const label = `${repo.repoId} ${workflow.path}@${trigger.commit.slice(0, 7)} [${run.runId.slice(0, 8)}]`;
|
|
1149
|
+
let provenance = run.manualProvenance;
|
|
1150
|
+
if (!provenance) {
|
|
1151
|
+
const { active: request } = selectServiceRequests(
|
|
1152
|
+
[...repo.controls.values()],
|
|
1153
|
+
{
|
|
1154
|
+
coordinatorPubkey: me,
|
|
1155
|
+
repoAddr: repo.addr,
|
|
1156
|
+
authorized: repo.authorized,
|
|
1157
|
+
acceptedRequesters
|
|
1158
|
+
}
|
|
1159
|
+
);
|
|
1160
|
+
if (!request) {
|
|
1161
|
+
log(`[ci] ${label}: service stopped before handoff \u2014 cancelled`);
|
|
1162
|
+
await concludeWithoutJobs(run, "cancelled", void 0);
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
provenance = {
|
|
1166
|
+
kind: "service-request",
|
|
1167
|
+
eventId: request.eventId,
|
|
1168
|
+
relayUrl,
|
|
1169
|
+
pubkey: request.pubkey
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
if (workflow.parseError !== void 0) {
|
|
1173
|
+
log(`[ci] ${label}: workflow parse error \u2014 startup_failure`);
|
|
1174
|
+
await concludeWithoutJobs(run, "startup_failure", provenance, clock());
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
const startedAt = clock();
|
|
1178
|
+
const jobIds = workflow.jobs.map((j) => j.id);
|
|
1179
|
+
const quotes = [];
|
|
1180
|
+
const progress = (status, conclusion2) => {
|
|
1181
|
+
const now2 = clock();
|
|
1182
|
+
return buildCiWorkflowProgress(
|
|
1183
|
+
{
|
|
1184
|
+
trigger,
|
|
1185
|
+
runId: run.runId,
|
|
1186
|
+
status,
|
|
1187
|
+
...conclusion2 !== void 0 ? { conclusion: conclusion2 } : {},
|
|
1188
|
+
inProgress: status === "in_progress" ? jobIds.filter((id) => !quotes.some((q) => q.jobId === id)) : [],
|
|
1189
|
+
queuedAt: run.queuedAt,
|
|
1190
|
+
startedAt,
|
|
1191
|
+
provenance,
|
|
1192
|
+
jobs: quotes,
|
|
1193
|
+
expiresAt: now2 + adTtl
|
|
1194
|
+
},
|
|
1195
|
+
now2
|
|
1196
|
+
);
|
|
1197
|
+
};
|
|
1198
|
+
await publish(progress("in_progress"));
|
|
1199
|
+
log(`[ci] ${label}: in_progress (${jobIds.length} job(s))`);
|
|
1200
|
+
let renewTimer = null;
|
|
1201
|
+
const scheduleRenew = () => {
|
|
1202
|
+
renewTimer = scheduler.setTimeout(
|
|
1203
|
+
() => {
|
|
1204
|
+
void publish(progress("in_progress")).catch(() => void 0);
|
|
1205
|
+
scheduleRenew();
|
|
1206
|
+
},
|
|
1207
|
+
adTtl * RENEW_FRACTION * 1e3
|
|
1208
|
+
);
|
|
1209
|
+
};
|
|
1210
|
+
scheduleRenew();
|
|
1211
|
+
const abort = new AbortController();
|
|
1212
|
+
activeAborts.add(abort);
|
|
1213
|
+
let timedOut = false;
|
|
1214
|
+
const timeoutHandle = scheduler.setTimeout(() => {
|
|
1215
|
+
timedOut = true;
|
|
1216
|
+
abort.abort();
|
|
1217
|
+
}, timeoutMs);
|
|
1218
|
+
let result;
|
|
1219
|
+
try {
|
|
1220
|
+
result = await Promise.race([
|
|
1221
|
+
runner.run({
|
|
1222
|
+
checkoutDir: run.checkout.dir,
|
|
1223
|
+
workflow: { path: workflow.path, sha256: workflow.sha256 },
|
|
1224
|
+
trigger,
|
|
1225
|
+
secrets: run.secrets,
|
|
1226
|
+
timeoutMs,
|
|
1227
|
+
signal: abort.signal
|
|
1228
|
+
}),
|
|
1229
|
+
new Promise((resolve2) => {
|
|
1230
|
+
abort.signal.addEventListener(
|
|
1231
|
+
"abort",
|
|
1232
|
+
() => {
|
|
1233
|
+
scheduler.setTimeout(
|
|
1234
|
+
() => resolve2({
|
|
1235
|
+
conclusion: timedOut ? "timed_out" : "cancelled",
|
|
1236
|
+
jobs: [],
|
|
1237
|
+
startedAt,
|
|
1238
|
+
finishedAt: clock()
|
|
1239
|
+
}),
|
|
1240
|
+
1e3
|
|
1241
|
+
);
|
|
1242
|
+
},
|
|
1243
|
+
{ once: true }
|
|
1244
|
+
);
|
|
1245
|
+
})
|
|
1246
|
+
]);
|
|
1247
|
+
} catch (err) {
|
|
1248
|
+
log(
|
|
1249
|
+
`[ci] ${label}: runner failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1250
|
+
);
|
|
1251
|
+
result = {
|
|
1252
|
+
conclusion: "startup_failure",
|
|
1253
|
+
jobs: [],
|
|
1254
|
+
startedAt,
|
|
1255
|
+
finishedAt: clock()
|
|
1256
|
+
};
|
|
1257
|
+
} finally {
|
|
1258
|
+
scheduler.clearTimeout(timeoutHandle);
|
|
1259
|
+
scheduler.clearTimeout(renewTimer);
|
|
1260
|
+
activeAborts.delete(abort);
|
|
1261
|
+
}
|
|
1262
|
+
let conclusion = result.conclusion;
|
|
1263
|
+
if (timedOut) conclusion = "timed_out";
|
|
1264
|
+
else if (stopping && abort.signal.aborted) conclusion = "cancelled";
|
|
1265
|
+
for (const job of result.jobs) {
|
|
1266
|
+
const { tail, omitted } = logTail(job.log);
|
|
1267
|
+
const logReceipt = await serial.run(
|
|
1268
|
+
() => uploadBlob({
|
|
1269
|
+
body: Buffer.from(job.log, "utf-8"),
|
|
1270
|
+
contentType: "text/plain; charset=utf-8",
|
|
1271
|
+
repoId: repo.repoId
|
|
1272
|
+
})
|
|
1273
|
+
);
|
|
1274
|
+
const artifacts = [];
|
|
1275
|
+
for (const artifact of job.artifacts) {
|
|
1276
|
+
try {
|
|
1277
|
+
const body = await readFile2(artifact.path);
|
|
1278
|
+
const receipt = await serial.run(
|
|
1279
|
+
() => uploadBlob({
|
|
1280
|
+
body,
|
|
1281
|
+
contentType: contentTypeForPath(artifact.filename),
|
|
1282
|
+
repoId: repo.repoId
|
|
1283
|
+
})
|
|
1284
|
+
);
|
|
1285
|
+
artifacts.push({
|
|
1286
|
+
url: gatewayRawUrl(opts.gatewayUrl, receipt.txId),
|
|
1287
|
+
filename: artifact.filename,
|
|
1288
|
+
name: artifact.name
|
|
1289
|
+
});
|
|
1290
|
+
} catch (err) {
|
|
1291
|
+
log(
|
|
1292
|
+
`[ci] ${label}: artifact ${artifact.filename} skipped: ${err instanceof Error ? err.message : String(err)}`
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
const runsOn = workflow.jobs.find((j) => j.id === job.jobId)?.runsOn;
|
|
1297
|
+
const jobConclusion = timedOut && job.conclusion === "failure" ? "timed_out" : job.conclusion;
|
|
1298
|
+
const jobEventId = await publish(
|
|
1299
|
+
buildCiJobResult(
|
|
1300
|
+
{
|
|
1301
|
+
trigger,
|
|
1302
|
+
progressAddress: progressAddress(run.runId),
|
|
1303
|
+
relayUrl,
|
|
1304
|
+
jobId: job.jobId,
|
|
1305
|
+
...job.name !== void 0 ? { name: job.name } : {},
|
|
1306
|
+
conclusion: jobConclusion,
|
|
1307
|
+
logsUrl: gatewayRawUrl(opts.gatewayUrl, logReceipt.txId),
|
|
1308
|
+
logTail: tail,
|
|
1309
|
+
logOmittedBytes: omitted,
|
|
1310
|
+
artifacts,
|
|
1311
|
+
queuedAt: run.queuedAt,
|
|
1312
|
+
startedAt: job.startedAt,
|
|
1313
|
+
...job.exitCode !== void 0 ? { exitCode: job.exitCode } : {},
|
|
1314
|
+
...runsOn ? { runsOn } : {}
|
|
1315
|
+
},
|
|
1316
|
+
clock()
|
|
1317
|
+
)
|
|
1318
|
+
);
|
|
1319
|
+
quotes.push({
|
|
1320
|
+
eventId: jobEventId,
|
|
1321
|
+
relayUrl,
|
|
1322
|
+
pubkey: me,
|
|
1323
|
+
jobId: job.jobId
|
|
1324
|
+
});
|
|
1325
|
+
await publish(progress("in_progress"));
|
|
1326
|
+
}
|
|
1327
|
+
const now = clock();
|
|
1328
|
+
await publish(
|
|
1329
|
+
buildCiWorkflowResult(
|
|
1330
|
+
{
|
|
1331
|
+
trigger,
|
|
1332
|
+
runId: run.runId,
|
|
1333
|
+
conclusion,
|
|
1334
|
+
queuedAt: run.queuedAt,
|
|
1335
|
+
startedAt,
|
|
1336
|
+
provenance,
|
|
1337
|
+
jobs: quotes
|
|
1338
|
+
},
|
|
1339
|
+
now
|
|
1340
|
+
)
|
|
1341
|
+
);
|
|
1342
|
+
await publish(progress("concluded", conclusion));
|
|
1343
|
+
log(`[ci] ${label}: ${conclusion}`);
|
|
1344
|
+
};
|
|
1345
|
+
const pump = () => {
|
|
1346
|
+
while (active < concurrency && queue.length > 0 && !stopping) {
|
|
1347
|
+
const run = queue.shift();
|
|
1348
|
+
active += 1;
|
|
1349
|
+
inflight += 1;
|
|
1350
|
+
void executeRun(run).catch((err) => {
|
|
1351
|
+
log(
|
|
1352
|
+
`[ci] run ${run.runId.slice(0, 8)} failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1353
|
+
);
|
|
1354
|
+
}).finally(async () => {
|
|
1355
|
+
active -= 1;
|
|
1356
|
+
inflight -= 1;
|
|
1357
|
+
await releaseCheckout(run.checkout);
|
|
1358
|
+
pump();
|
|
1359
|
+
checkIdle();
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
};
|
|
1363
|
+
const enqueueRun = async (repo, trigger, workflow, checkout, secrets, manualProvenance) => {
|
|
1364
|
+
let rates;
|
|
1365
|
+
try {
|
|
1366
|
+
rates = await publisher.getFeeRates();
|
|
1367
|
+
} catch (err) {
|
|
1368
|
+
log(
|
|
1369
|
+
`[ci] refusing run of ${workflow.path} for ${repo.repoId}: cannot read fee rates (${err instanceof Error ? err.message : String(err)}) \u2014 is the wallet funded?`
|
|
1370
|
+
);
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
const jobs = Math.max(1, workflow.jobs.length);
|
|
1374
|
+
if (!await canAfford({ events: 4 + 2 * jobs, uploads: jobs, rates })) {
|
|
1375
|
+
log(
|
|
1376
|
+
`[ci] refusing run of ${workflow.path} for ${repo.repoId}: wallet cannot cover its writes`
|
|
1377
|
+
);
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
const run = {
|
|
1381
|
+
repo,
|
|
1382
|
+
trigger,
|
|
1383
|
+
workflow,
|
|
1384
|
+
checkout,
|
|
1385
|
+
secrets,
|
|
1386
|
+
...manualProvenance ? { manualProvenance } : {},
|
|
1387
|
+
runId: randomUUID(),
|
|
1388
|
+
queuedAt: clock()
|
|
1389
|
+
};
|
|
1390
|
+
checkout.refs += 1;
|
|
1391
|
+
const position = active + queue.length + 1;
|
|
1392
|
+
const now = clock();
|
|
1393
|
+
await publish(
|
|
1394
|
+
buildCiWorkflowProgress(
|
|
1395
|
+
{
|
|
1396
|
+
trigger,
|
|
1397
|
+
runId: run.runId,
|
|
1398
|
+
status: "queued",
|
|
1399
|
+
queue: Math.ceil(position / concurrency),
|
|
1400
|
+
queuedAt: run.queuedAt,
|
|
1401
|
+
...manualProvenance ? { provenance: manualProvenance } : {},
|
|
1402
|
+
jobs: [],
|
|
1403
|
+
expiresAt: now + adTtl
|
|
1404
|
+
},
|
|
1405
|
+
now
|
|
1406
|
+
)
|
|
1407
|
+
);
|
|
1408
|
+
queue.push(run);
|
|
1409
|
+
log(
|
|
1410
|
+
`[ci] queued ${workflow.path} for ${repo.repoId}@${trigger.commit.slice(0, 7)} [${run.runId.slice(0, 8)}]`
|
|
1411
|
+
);
|
|
1412
|
+
pump();
|
|
1413
|
+
};
|
|
1414
|
+
let checkoutCounter = 0;
|
|
1415
|
+
const handleTrigger = async (t) => {
|
|
1416
|
+
const { repo } = t;
|
|
1417
|
+
inflight += 1;
|
|
1418
|
+
try {
|
|
1419
|
+
const dir = join3(
|
|
1420
|
+
opts.workdir,
|
|
1421
|
+
`${repo.repoId}-${t.commit.slice(0, 7)}-${++checkoutCounter}`
|
|
1422
|
+
);
|
|
1423
|
+
let materialized;
|
|
1424
|
+
try {
|
|
1425
|
+
materialized = await materialize({
|
|
1426
|
+
ownerPubkey: repo.ownerPubkey,
|
|
1427
|
+
repoId: repo.repoId,
|
|
1428
|
+
relayUrls: [relayUrl],
|
|
1429
|
+
commit: t.commit,
|
|
1430
|
+
dir,
|
|
1431
|
+
...t.patch ? { patch: { content: t.patch.content } } : {},
|
|
1432
|
+
...t.manual?.tagObjectIds && t.manual.tagObjectIds.length > 0 ? { extraObjectIds: t.manual.tagObjectIds } : {},
|
|
1433
|
+
...opts.webSocketFactory ? { webSocketFactory: opts.webSocketFactory } : {},
|
|
1434
|
+
...opts.fetchFn ? { fetchFn: opts.fetchFn } : {},
|
|
1435
|
+
...opts.resolveSha ? { resolveSha: opts.resolveSha } : {},
|
|
1436
|
+
...opts.gateways ? { gateways: opts.gateways } : {}
|
|
1437
|
+
});
|
|
1438
|
+
} catch (err) {
|
|
1439
|
+
const reason = err instanceof MaterializeError ? err.message : `materialize failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
1440
|
+
log(`[ci] ${repo.repoId}@${t.commit.slice(0, 7)}: ${reason}`);
|
|
1441
|
+
if (t.manual) {
|
|
1442
|
+
const trigger = {
|
|
1443
|
+
repoAddr: repo.addr,
|
|
1444
|
+
commit: t.commit,
|
|
1445
|
+
workflow: t.manual.workflow,
|
|
1446
|
+
reason: "manual",
|
|
1447
|
+
...t.ref !== void 0 ? { ref: t.ref } : {},
|
|
1448
|
+
...t.pr ? { pr: t.pr } : {}
|
|
1449
|
+
};
|
|
1450
|
+
const checkout2 = { dir, refs: 0 };
|
|
1451
|
+
const provenance = {
|
|
1452
|
+
kind: "manual-trigger",
|
|
1453
|
+
eventId: t.manual.eventId,
|
|
1454
|
+
relayUrl,
|
|
1455
|
+
pubkey: t.manual.pubkey
|
|
1456
|
+
};
|
|
1457
|
+
const broken = {
|
|
1458
|
+
...t.manual.workflow,
|
|
1459
|
+
triggers: {},
|
|
1460
|
+
jobs: [],
|
|
1461
|
+
parseError: reason
|
|
1462
|
+
};
|
|
1463
|
+
await enqueueRun(repo, trigger, broken, checkout2, {}, provenance);
|
|
1464
|
+
}
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
const checkout = { dir: materialized.dir, refs: 0 };
|
|
1468
|
+
const commit = materialized.commit;
|
|
1469
|
+
if (t.manual?.tagObjectIds && t.manual.tagObjectIds.length > 0) {
|
|
1470
|
+
const mismatch = await findUnpeelableTagId(
|
|
1471
|
+
materialized.dir,
|
|
1472
|
+
t.manual.tagObjectIds,
|
|
1473
|
+
commit
|
|
1474
|
+
);
|
|
1475
|
+
if (mismatch !== null) {
|
|
1476
|
+
log(
|
|
1477
|
+
`[ci] manual trigger ignored: c ${mismatch.slice(0, 7)} does not peel to ${commit.slice(0, 7)} (or is not in the repo)`
|
|
1478
|
+
);
|
|
1479
|
+
if (!opts.keepCheckouts)
|
|
1480
|
+
await rm2(materialized.dir, { recursive: true, force: true }).catch(
|
|
1481
|
+
() => void 0
|
|
1482
|
+
);
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
const workflows = await discoverWorkflows(materialized.dir);
|
|
1487
|
+
const tagObjectIds = [
|
|
1488
|
+
...t.manual?.tagObjectIds ?? [],
|
|
1489
|
+
...commit !== t.commit && !t.patch ? [t.commit] : [],
|
|
1490
|
+
...t.patch?.declaredTip && t.patch.declaredTip !== commit ? [t.patch.declaredTip] : []
|
|
1491
|
+
];
|
|
1492
|
+
const secretsAllowed = repo.authorized.has(t.authorPubkey.toLowerCase());
|
|
1493
|
+
const secrets = secretsAllowed ? effectiveSecrets(repo.secrets) : {};
|
|
1494
|
+
let selected;
|
|
1495
|
+
let manualProvenance;
|
|
1496
|
+
if (t.manual) {
|
|
1497
|
+
const wanted = t.manual.workflow;
|
|
1498
|
+
const wf = workflows.find((w) => w.path === wanted.path);
|
|
1499
|
+
if (!wf) {
|
|
1500
|
+
log(
|
|
1501
|
+
`[ci] manual trigger ignored: ${wanted.path} does not exist at ${commit.slice(0, 7)}`
|
|
1502
|
+
);
|
|
1503
|
+
selected = [];
|
|
1504
|
+
} else if (wf.sha256 !== wanted.sha256.toLowerCase()) {
|
|
1505
|
+
log(
|
|
1506
|
+
`[ci] manual trigger ignored: ${wanted.path} at ${commit.slice(0, 7)} has sha256 ${wf.sha256}, request named ${wanted.sha256}`
|
|
1507
|
+
);
|
|
1508
|
+
selected = [];
|
|
1509
|
+
} else {
|
|
1510
|
+
selected = [wf];
|
|
1511
|
+
}
|
|
1512
|
+
manualProvenance = {
|
|
1513
|
+
kind: "manual-trigger",
|
|
1514
|
+
eventId: t.manual.eventId,
|
|
1515
|
+
relayUrl,
|
|
1516
|
+
pubkey: t.manual.pubkey
|
|
1517
|
+
};
|
|
1518
|
+
} else if (t.reason === "push") {
|
|
1519
|
+
selected = workflows.filter(
|
|
1520
|
+
(wf) => wf.parseError !== void 0 || matchesPush(wf, t.ref)
|
|
1521
|
+
);
|
|
1522
|
+
} else {
|
|
1523
|
+
const base = repo.defaultBranch ?? void 0;
|
|
1524
|
+
selected = workflows.filter(
|
|
1525
|
+
(wf) => wf.parseError !== void 0 || matchesPullRequest(wf, base)
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1528
|
+
if (selected.length === 0) {
|
|
1529
|
+
if (!opts.keepCheckouts)
|
|
1530
|
+
await rm2(materialized.dir, { recursive: true, force: true }).catch(
|
|
1531
|
+
() => void 0
|
|
1532
|
+
);
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
for (const wf of selected) {
|
|
1536
|
+
const trigger = {
|
|
1537
|
+
repoAddr: repo.addr,
|
|
1538
|
+
commit,
|
|
1539
|
+
...tagObjectIds.length > 0 ? { tagObjectIds } : {},
|
|
1540
|
+
workflow: { path: wf.path, sha256: wf.sha256 },
|
|
1541
|
+
reason: t.reason,
|
|
1542
|
+
...t.pr ? { pr: t.pr } : t.ref !== void 0 ? { ref: t.ref } : {}
|
|
1543
|
+
};
|
|
1544
|
+
await enqueueRun(
|
|
1545
|
+
repo,
|
|
1546
|
+
trigger,
|
|
1547
|
+
wf,
|
|
1548
|
+
checkout,
|
|
1549
|
+
secrets,
|
|
1550
|
+
manualProvenance
|
|
1551
|
+
);
|
|
1552
|
+
}
|
|
1553
|
+
if (checkout.refs === 0 && !opts.keepCheckouts) {
|
|
1554
|
+
await rm2(materialized.dir, { recursive: true, force: true }).catch(
|
|
1555
|
+
() => void 0
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
} finally {
|
|
1559
|
+
inflight -= 1;
|
|
1560
|
+
checkIdle();
|
|
1561
|
+
}
|
|
1562
|
+
};
|
|
1563
|
+
const eventQueue = [];
|
|
1564
|
+
let draining = false;
|
|
1565
|
+
const markRepoProcessed = (repo, ev) => {
|
|
1566
|
+
const cursor = repo.cursor;
|
|
1567
|
+
cursor.processed.push(ev.id);
|
|
1568
|
+
if (ev.created_at > cursor.lastCreatedAt) {
|
|
1569
|
+
cursor.lastCreatedAt = ev.created_at;
|
|
1570
|
+
cursor.lastEventId = ev.id;
|
|
1571
|
+
}
|
|
1572
|
+
};
|
|
1573
|
+
const repoSeen = (repo, ev) => (repo.cursor?.processed ?? []).includes(ev.id);
|
|
1574
|
+
const handleRepoState = async (repo, ev) => {
|
|
1575
|
+
const cursor = repo.cursor;
|
|
1576
|
+
if (repoSeen(repo, ev) || ev.created_at < cursor.lastCreatedAt) return;
|
|
1577
|
+
const { refs, head } = parseRefsTags(ev);
|
|
1578
|
+
if (head) repo.defaultBranch = head;
|
|
1579
|
+
const moved = movedRefs(cursor.refs, refs);
|
|
1580
|
+
cursor.refs = refs;
|
|
1581
|
+
markRepoProcessed(repo, ev);
|
|
1582
|
+
await persistCursor();
|
|
1583
|
+
if (moved.length === 0) return;
|
|
1584
|
+
if (!isServing(repo)) {
|
|
1585
|
+
log(
|
|
1586
|
+
`[ci] ${repo.repoId}: push seen but no maintainer Service Request \u2014 not running`
|
|
1587
|
+
);
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
for (const { ref, sha } of moved) {
|
|
1591
|
+
if (!ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/"))
|
|
1592
|
+
continue;
|
|
1593
|
+
await handleTrigger({
|
|
1594
|
+
repo,
|
|
1595
|
+
reason: "push",
|
|
1596
|
+
authorPubkey: ev.pubkey,
|
|
1597
|
+
commit: sha,
|
|
1598
|
+
ref
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
};
|
|
1602
|
+
const handlePullRequest = async (repo, ev) => {
|
|
1603
|
+
if (repoSeen(repo, ev)) return;
|
|
1604
|
+
if (ev.kind === 1617 || ev.kind === 1618) repo.prEvents.set(ev.id, ev);
|
|
1605
|
+
markRepoProcessed(repo, ev);
|
|
1606
|
+
await persistCursor();
|
|
1607
|
+
if (!isServing(repo)) {
|
|
1608
|
+
log(
|
|
1609
|
+
`[ci] ${repo.repoId}: pull request seen but no maintainer Service Request \u2014 not running`
|
|
1610
|
+
);
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
const author = ev.pubkey.toLowerCase();
|
|
1614
|
+
if (ev.kind === 1617) {
|
|
1615
|
+
const parents = tagValues(ev.tags, "parent-commit");
|
|
1616
|
+
const commits = tagValues(ev.tags, "commit");
|
|
1617
|
+
const base = parents[0];
|
|
1618
|
+
if (!base || ev.content.trim() === "") {
|
|
1619
|
+
log(
|
|
1620
|
+
`[ci] ${repo.repoId}: patch ${ev.id.slice(0, 8)} has no parent-commit/content \u2014 cannot run`
|
|
1621
|
+
);
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
const declaredTip = commits[commits.length - 1];
|
|
1625
|
+
await handleTrigger({
|
|
1626
|
+
repo,
|
|
1627
|
+
reason: "pull_request",
|
|
1628
|
+
authorPubkey: author,
|
|
1629
|
+
commit: base,
|
|
1630
|
+
patch: { content: ev.content, ...declaredTip ? { declaredTip } : {} },
|
|
1631
|
+
pr: {
|
|
1632
|
+
prEventId: ev.id,
|
|
1633
|
+
prAuthor: author,
|
|
1634
|
+
prKind: 1617,
|
|
1635
|
+
sourceEventId: ev.id,
|
|
1636
|
+
sourceAuthor: author,
|
|
1637
|
+
sourceKind: 1617
|
|
1638
|
+
}
|
|
1639
|
+
});
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
if (ev.kind === 1618) {
|
|
1643
|
+
const tip2 = tagValues(ev.tags, "c")[0];
|
|
1644
|
+
if (!tip2) {
|
|
1645
|
+
log(
|
|
1646
|
+
`[ci] ${repo.repoId}: pull request ${ev.id.slice(0, 8)} has no tip commit \u2014 cannot run`
|
|
1647
|
+
);
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
await handleTrigger({
|
|
1651
|
+
repo,
|
|
1652
|
+
reason: "pull_request",
|
|
1653
|
+
authorPubkey: author,
|
|
1654
|
+
commit: tip2,
|
|
1655
|
+
pr: {
|
|
1656
|
+
prEventId: ev.id,
|
|
1657
|
+
prAuthor: author,
|
|
1658
|
+
prKind: 1618,
|
|
1659
|
+
sourceEventId: ev.id,
|
|
1660
|
+
sourceAuthor: author,
|
|
1661
|
+
sourceKind: 1618
|
|
1662
|
+
}
|
|
1663
|
+
});
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
const prId = tagValues(ev.tags, "E")[0];
|
|
1667
|
+
const pr = prId ? repo.prEvents.get(prId) : void 0;
|
|
1668
|
+
const tip = tagValues(ev.tags, "c")[0];
|
|
1669
|
+
if (!pr || !tip) {
|
|
1670
|
+
log(
|
|
1671
|
+
`[ci] ${repo.repoId}: PR update ${ev.id.slice(0, 8)} references an unknown PR or no commit \u2014 ignored`
|
|
1672
|
+
);
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
await handleTrigger({
|
|
1676
|
+
repo,
|
|
1677
|
+
reason: "pull_request",
|
|
1678
|
+
authorPubkey: author,
|
|
1679
|
+
commit: tip,
|
|
1680
|
+
pr: {
|
|
1681
|
+
prEventId: pr.id,
|
|
1682
|
+
prAuthor: pr.pubkey.toLowerCase(),
|
|
1683
|
+
prKind: pr.kind === 1617 ? 1617 : 1618,
|
|
1684
|
+
sourceEventId: ev.id,
|
|
1685
|
+
sourceAuthor: author,
|
|
1686
|
+
sourceKind: 1619
|
|
1687
|
+
}
|
|
1688
|
+
});
|
|
1689
|
+
};
|
|
1690
|
+
const handleManualTrigger = async (ev) => {
|
|
1691
|
+
if (inboxProcessed.has(ev.id)) return;
|
|
1692
|
+
inboxProcessed.add(ev.id);
|
|
1693
|
+
inbox.lastCreatedAt = Math.max(inbox.lastCreatedAt ?? 0, ev.created_at);
|
|
1694
|
+
await persistCursor();
|
|
1695
|
+
const manual = parseCiManualTrigger(ev, me);
|
|
1696
|
+
if (!manual) {
|
|
1697
|
+
log(`[ci] malformed manual trigger ${ev.id.slice(0, 8)} ignored`);
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
const repo = repos.get(manual.trigger.repoAddr);
|
|
1701
|
+
if (!repo) {
|
|
1702
|
+
log(
|
|
1703
|
+
`[ci] manual trigger for unserved repo ${manual.trigger.repoAddr} ignored`
|
|
1704
|
+
);
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
if (!repo.authorized.has(manual.pubkey)) {
|
|
1708
|
+
log(
|
|
1709
|
+
`[ci] manual trigger from non-maintainer ${manual.pubkey.slice(0, 8)} ignored`
|
|
1710
|
+
);
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
const t = manual.trigger;
|
|
1714
|
+
await handleTrigger({
|
|
1715
|
+
repo,
|
|
1716
|
+
reason: "manual",
|
|
1717
|
+
authorPubkey: manual.pubkey,
|
|
1718
|
+
commit: t.commit,
|
|
1719
|
+
...t.ref !== void 0 ? { ref: t.ref } : {},
|
|
1720
|
+
...t.pr ? { pr: t.pr } : {},
|
|
1721
|
+
manual: {
|
|
1722
|
+
eventId: manual.eventId,
|
|
1723
|
+
pubkey: manual.pubkey,
|
|
1724
|
+
workflow: t.workflow,
|
|
1725
|
+
...t.tagObjectIds ? { tagObjectIds: t.tagObjectIds } : {}
|
|
1726
|
+
}
|
|
1727
|
+
});
|
|
1728
|
+
};
|
|
1729
|
+
const handleSecretUpdate = async (ev) => {
|
|
1730
|
+
if (inboxProcessed.has(ev.id)) return;
|
|
1731
|
+
inboxProcessed.add(ev.id);
|
|
1732
|
+
inbox.lastCreatedAt = Math.max(inbox.lastCreatedAt ?? 0, ev.created_at);
|
|
1733
|
+
await persistCursor();
|
|
1734
|
+
const update = parseCiSecretUpdate(ev);
|
|
1735
|
+
if (!update || update.coordinatorPubkey !== me) return;
|
|
1736
|
+
const perspective = parseRepoAddress(update.repoAddr);
|
|
1737
|
+
const repo = perspective ? [...repos.values()].find((r) => r.repoId === perspective.repoId) : void 0;
|
|
1738
|
+
if (!repo) {
|
|
1739
|
+
log(`[ci] secret update for unserved repo ${update.repoAddr} ignored`);
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
if (!repo.authorized.has(update.pubkey)) {
|
|
1743
|
+
log(
|
|
1744
|
+
`[ci] ${repo.repoId}: secret update from non-maintainer ${update.pubkey.slice(0, 8)} ignored`
|
|
1745
|
+
);
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
const ad = liveAdvertisements.get(update.advertisementId);
|
|
1749
|
+
if (!ad || update.recipientPubkey !== secretsKey.pubkey) {
|
|
1750
|
+
log(
|
|
1751
|
+
`[ci] ${repo.repoId}: secret update ${ev.id.slice(0, 8)} addressed to a stale secrets key \u2014 re-send it`
|
|
1752
|
+
);
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
if (update.createdAt < ad.createdAt || update.createdAt >= ad.expiresAt) {
|
|
1756
|
+
log(
|
|
1757
|
+
`[ci] ${repo.repoId}: secret update ${ev.id.slice(0, 8)} outside its advertisement window \u2014 ignored`
|
|
1758
|
+
);
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
try {
|
|
1762
|
+
const plain = decryptSecretUpdate(
|
|
1763
|
+
update.ciphertext,
|
|
1764
|
+
secretsKey.secretKey,
|
|
1765
|
+
update.senderPubkey
|
|
1766
|
+
);
|
|
1767
|
+
validateSecretUpdate(plain, {
|
|
1768
|
+
pubkey: ev.pubkey,
|
|
1769
|
+
created_at: ev.created_at
|
|
1770
|
+
});
|
|
1771
|
+
repo.secrets = applySecretUpdate(repo.secrets, plain, ev.id);
|
|
1772
|
+
await persistSecrets();
|
|
1773
|
+
const names = [...Object.keys(plain.set), ...plain.remove];
|
|
1774
|
+
log(
|
|
1775
|
+
`[ci] ${repo.repoId}: secrets updated by ${update.pubkey.slice(0, 8)} (${names.join(", ")})`
|
|
1776
|
+
);
|
|
1777
|
+
} catch (err) {
|
|
1778
|
+
log(
|
|
1779
|
+
`[ci] ${repo.repoId}: secret update ${ev.id.slice(0, 8)} rejected: ${err instanceof Error ? err.message : String(err)}`
|
|
1780
|
+
);
|
|
1781
|
+
}
|
|
1782
|
+
};
|
|
1783
|
+
const handleEvent = async (ev) => {
|
|
1784
|
+
switch (ev.kind) {
|
|
1785
|
+
case REPOSITORY_ANNOUNCEMENT_KIND: {
|
|
1786
|
+
const repoId = tagValues(ev.tags, "d")[0];
|
|
1787
|
+
const repo = repoId ? repos.get(repoAddress(ev.pubkey, repoId)) : void 0;
|
|
1788
|
+
if (repo && ev.pubkey.toLowerCase() === repo.ownerPubkey) {
|
|
1789
|
+
repo.authorized = authorizedStatusAuthors(repo.ownerPubkey, ev.tags);
|
|
1790
|
+
}
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
case REPOSITORY_STATE_KIND: {
|
|
1794
|
+
const repoId = tagValues(ev.tags, "d")[0];
|
|
1795
|
+
const repo = repoId ? repos.get(repoAddress(ev.pubkey, repoId)) : void 0;
|
|
1796
|
+
if (repo && ev.pubkey.toLowerCase() === repo.ownerPubkey)
|
|
1797
|
+
await handleRepoState(repo, ev);
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
case 1617:
|
|
1801
|
+
case 1618:
|
|
1802
|
+
case 1619: {
|
|
1803
|
+
for (const addr of tagValues(ev.tags, "a")) {
|
|
1804
|
+
const repo = repos.get(addr);
|
|
1805
|
+
if (repo) {
|
|
1806
|
+
await handlePullRequest(repo, ev);
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
case CI_SERVICE_REQUEST_KIND:
|
|
1813
|
+
case CI_SERVICE_STOP_KIND:
|
|
1814
|
+
handleControl(ev);
|
|
1815
|
+
return;
|
|
1816
|
+
case CI_MANUAL_TRIGGER_KIND:
|
|
1817
|
+
await handleManualTrigger(ev);
|
|
1818
|
+
return;
|
|
1819
|
+
case CI_SECRET_UPDATE_KIND:
|
|
1820
|
+
await handleSecretUpdate(ev);
|
|
1821
|
+
return;
|
|
1822
|
+
default:
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
};
|
|
1826
|
+
const drain = async () => {
|
|
1827
|
+
if (draining) return;
|
|
1828
|
+
draining = true;
|
|
1829
|
+
try {
|
|
1830
|
+
while (eventQueue.length > 0) {
|
|
1831
|
+
const ev = eventQueue.shift();
|
|
1832
|
+
try {
|
|
1833
|
+
await handleEvent(ev);
|
|
1834
|
+
} catch (err) {
|
|
1835
|
+
log(
|
|
1836
|
+
`[ci] event ${ev.id.slice(0, 8)} (kind ${ev.kind}) failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1837
|
+
);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
} finally {
|
|
1841
|
+
draining = false;
|
|
1842
|
+
checkIdle();
|
|
1843
|
+
}
|
|
1844
|
+
};
|
|
1845
|
+
const filters = [controlFilter];
|
|
1846
|
+
const sinceFor = [null];
|
|
1847
|
+
for (const repo of repos.values()) {
|
|
1848
|
+
filters.push({
|
|
1849
|
+
kinds: [REPOSITORY_ANNOUNCEMENT_KIND, REPOSITORY_STATE_KIND],
|
|
1850
|
+
authors: [repo.ownerPubkey],
|
|
1851
|
+
"#d": [repo.repoId]
|
|
1852
|
+
});
|
|
1853
|
+
sinceFor.push(null);
|
|
1854
|
+
filters.push({ kinds: [1617, 1618, 1619], "#a": [repo.addr] });
|
|
1855
|
+
sinceFor.push(() => repo.cursor?.lastCreatedAt);
|
|
1856
|
+
}
|
|
1857
|
+
filters.push({
|
|
1858
|
+
kinds: [CI_MANUAL_TRIGGER_KIND, CI_SECRET_UPDATE_KIND],
|
|
1859
|
+
"#p": [me]
|
|
1860
|
+
});
|
|
1861
|
+
sinceFor.push(() => inbox.lastCreatedAt);
|
|
1862
|
+
const subscription = subscribeRelay({
|
|
1863
|
+
url: relayUrl,
|
|
1864
|
+
filters,
|
|
1865
|
+
webSocketFactory: opts.webSocketFactory ?? defaultWebSocketFactory,
|
|
1866
|
+
scheduler,
|
|
1867
|
+
onEvent: (ev) => {
|
|
1868
|
+
eventQueue.push(ev);
|
|
1869
|
+
void drain();
|
|
1870
|
+
},
|
|
1871
|
+
onStatus: (status, detail) => {
|
|
1872
|
+
if (status === "open") log(`[ci] connected to ${relayUrl}`);
|
|
1873
|
+
else if (status === "reconnecting")
|
|
1874
|
+
log(`[ci] relay connection lost (${detail ?? ""}) \u2014 reconnecting`);
|
|
1875
|
+
},
|
|
1876
|
+
since: (index) => sinceFor[index]?.() ?? void 0
|
|
1877
|
+
});
|
|
1878
|
+
let stopResolve = () => void 0;
|
|
1879
|
+
const stopped = new Promise((resolve2) => {
|
|
1880
|
+
stopResolve = resolve2;
|
|
1881
|
+
});
|
|
1882
|
+
let stopPromise = null;
|
|
1883
|
+
const stop = () => {
|
|
1884
|
+
if (stopPromise) return stopPromise;
|
|
1885
|
+
stopPromise = (async () => {
|
|
1886
|
+
stopping = true;
|
|
1887
|
+
subscription.close();
|
|
1888
|
+
if (adTimer !== null) scheduler.clearTimeout(adTimer);
|
|
1889
|
+
const dropped = queue.splice(0);
|
|
1890
|
+
for (const run of dropped) {
|
|
1891
|
+
log(
|
|
1892
|
+
`[ci] dropping queued run ${run.runId.slice(0, 8)} (its queued marker expires on its own)`
|
|
1893
|
+
);
|
|
1894
|
+
await releaseCheckout(run.checkout);
|
|
1895
|
+
}
|
|
1896
|
+
for (const abort of activeAborts) abort.abort();
|
|
1897
|
+
await new Promise((resolve2) => {
|
|
1898
|
+
if (inflight === 0 && !draining) return resolve2();
|
|
1899
|
+
idleWaiters.push(resolve2);
|
|
1900
|
+
});
|
|
1901
|
+
await persistCursor();
|
|
1902
|
+
stopResolve();
|
|
1903
|
+
})();
|
|
1904
|
+
return stopPromise;
|
|
1905
|
+
};
|
|
1906
|
+
return {
|
|
1907
|
+
pubkey: me,
|
|
1908
|
+
get secretsKeyPubkey() {
|
|
1909
|
+
return secretsKey.pubkey;
|
|
1910
|
+
},
|
|
1911
|
+
get advertisementId() {
|
|
1912
|
+
return advertisementId;
|
|
1913
|
+
},
|
|
1914
|
+
stopped,
|
|
1915
|
+
stop,
|
|
1916
|
+
idle: () => new Promise((resolve2) => {
|
|
1917
|
+
if (inflight === 0 && queue.length === 0 && eventQueue.length === 0 && !draining)
|
|
1918
|
+
return resolve2();
|
|
1919
|
+
idleWaiters.push(resolve2);
|
|
1920
|
+
}),
|
|
1921
|
+
serving: () => [...repos.values()].map((repo) => ({
|
|
1922
|
+
repoAddr: repo.addr,
|
|
1923
|
+
serving: isServing(repo)
|
|
1924
|
+
}))
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
async function findUnpeelableTagId(checkoutDir, tagObjectIds, commit) {
|
|
1928
|
+
for (const id of tagObjectIds) {
|
|
1929
|
+
if (!/^[0-9a-f]{40}$/i.test(id)) return id;
|
|
1930
|
+
let peeled;
|
|
1931
|
+
try {
|
|
1932
|
+
peeled = (await runGit(checkoutDir, [
|
|
1933
|
+
"rev-parse",
|
|
1934
|
+
"--verify",
|
|
1935
|
+
"--quiet",
|
|
1936
|
+
`${id}^{commit}`
|
|
1937
|
+
])).trim();
|
|
1938
|
+
} catch {
|
|
1939
|
+
return id;
|
|
1940
|
+
}
|
|
1941
|
+
if (peeled.toLowerCase() !== commit.toLowerCase()) return id;
|
|
1942
|
+
}
|
|
1943
|
+
return null;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
// src/cli/ci-serve.ts
|
|
1947
|
+
var CI_SERVE_USAGE = `Usage: rig ci serve --relay <ws-url> --repo <owner>/<repo-id> [--repo \u2026] [options]
|
|
1948
|
+
|
|
1949
|
+
Run a CI coordinator: watch the named repos on the relay, run their
|
|
1950
|
+
GitHub-Actions-syntax workflows with act on this host's Docker, and publish
|
|
1951
|
+
NIP-C1 progress and results back to the relay as PAID writes from THIS
|
|
1952
|
+
identity's channel. Every run is a handful of relay writes (one per progress
|
|
1953
|
+
update, job, and result) plus one store upload per job log and artifact.
|
|
1954
|
+
|
|
1955
|
+
A repo is served only while one of its maintainers has published a Service
|
|
1956
|
+
Request (\`rig ci request <this-coordinator>\`) with no later Stop; a
|
|
1957
|
+
maintainer's \`rig ci trigger\` runs one workflow without a standing request.
|
|
1958
|
+
Secrets set with \`rig ci secret\` are injected only into runs authored by a
|
|
1959
|
+
maintainer \u2014 never into a third-party pull request.
|
|
1960
|
+
|
|
1961
|
+
Options:
|
|
1962
|
+
--relay <url> the relay to watch AND publish to (ws:// or wss://)
|
|
1963
|
+
[required, exactly one]
|
|
1964
|
+
--repo <owner>/<id> a repo to serve (owner as npub or hex); repeatable
|
|
1965
|
+
--requester <pubkey> also accept Service Requests from this pubkey (npub
|
|
1966
|
+
or hex; NIP-C1 operator policy); repeatable \u2014 for
|
|
1967
|
+
running your own coordinator against a repo you do
|
|
1968
|
+
not maintain; such runs show as lower trust
|
|
1969
|
+
--concurrency <n> runs executed at once (default 1); more queue
|
|
1970
|
+
--timeout <seconds> wall-clock budget per run \u2192 timed_out (default 1800)
|
|
1971
|
+
--gateway <url> gateway that serves the store's raw bytes, for log +
|
|
1972
|
+
artifact links (default: ${PREFERRED_GATEWAY})
|
|
1973
|
+
--act-bin <path> the act executable (default: RIG_ACT_BIN, else PATH)
|
|
1974
|
+
--platform <label=img> runs-on label \u2192 Docker image; repeatable
|
|
1975
|
+
(default: ubuntu-latest=${DEFAULT_ACT_PLATFORMS["ubuntu-latest"]})
|
|
1976
|
+
--workdir <dir> where commits are checked out for runs (default:
|
|
1977
|
+
<state-dir>/work)
|
|
1978
|
+
--json emit ONE JSON document describing the coordinator
|
|
1979
|
+
at start; everything else goes to stderr
|
|
1980
|
+
--standalone accepted for symmetry; the coordinator always runs
|
|
1981
|
+
embedded (alias: --no-daemon)
|
|
1982
|
+
-h, --help show this help
|
|
1983
|
+
|
|
1984
|
+
State (cursor, secret inventory) lives under
|
|
1985
|
+
<TOON_CLIENT_HOME|~/.toon-client>/rig-ci/<coordinator-pubkey>/ so a restart
|
|
1986
|
+
resumes from the last processed event. The NIP-44 secrets-key is generated
|
|
1987
|
+
fresh every start and never written to disk: after a restart maintainers
|
|
1988
|
+
re-send \`rig ci secret set\`; values already accepted survive.
|
|
1989
|
+
|
|
1990
|
+
Before each run the coordinator checks that a recorded payment channel (or,
|
|
1991
|
+
before the first channel opens, the wallet's USDC) can cover the run's writes;
|
|
1992
|
+
otherwise it says so on stderr and starts nothing, so a run is never
|
|
1993
|
+
half-published (story 15).
|
|
1994
|
+
|
|
1995
|
+
Stop with Ctrl-C (SIGINT) or SIGTERM: active runs conclude \`cancelled\`.`;
|
|
1996
|
+
var ServeUsageError = class extends Error {
|
|
1997
|
+
};
|
|
1998
|
+
function parseServeArgs(args) {
|
|
1999
|
+
const { values } = parseArgs({
|
|
2000
|
+
args,
|
|
2001
|
+
options: {
|
|
2002
|
+
relay: { type: "string" },
|
|
2003
|
+
repo: { type: "string", multiple: true },
|
|
2004
|
+
requester: { type: "string", multiple: true },
|
|
2005
|
+
concurrency: { type: "string" },
|
|
2006
|
+
timeout: { type: "string" },
|
|
2007
|
+
gateway: { type: "string" },
|
|
2008
|
+
"act-bin": { type: "string" },
|
|
2009
|
+
platform: { type: "string", multiple: true },
|
|
2010
|
+
workdir: { type: "string" },
|
|
2011
|
+
json: { type: "boolean", default: false },
|
|
2012
|
+
standalone: { type: "boolean", default: false },
|
|
2013
|
+
"no-daemon": { type: "boolean", default: false },
|
|
2014
|
+
help: { type: "boolean", short: "h", default: false }
|
|
2015
|
+
},
|
|
2016
|
+
allowPositionals: false,
|
|
2017
|
+
strict: true
|
|
2018
|
+
});
|
|
2019
|
+
if (values.help) return "help";
|
|
2020
|
+
if (!values.relay) throw new ServeUsageError("--relay <ws-url> is required");
|
|
2021
|
+
if (!WS_URL_RE.test(values.relay)) {
|
|
2022
|
+
throw new ServeUsageError(
|
|
2023
|
+
`--relay must be a ws:// or wss:// URL, got ${JSON.stringify(values.relay)}`
|
|
2024
|
+
);
|
|
2025
|
+
}
|
|
2026
|
+
const repos = [];
|
|
2027
|
+
for (const spec of values.repo ?? []) {
|
|
2028
|
+
const slash = spec.indexOf("/");
|
|
2029
|
+
if (slash <= 0 || slash === spec.length - 1) {
|
|
2030
|
+
throw new ServeUsageError(
|
|
2031
|
+
`--repo expects <owner>/<repo-id>, got ${JSON.stringify(spec)}`
|
|
2032
|
+
);
|
|
2033
|
+
}
|
|
2034
|
+
let ownerPubkey;
|
|
2035
|
+
try {
|
|
2036
|
+
ownerPubkey = ownerToHex(spec.slice(0, slash));
|
|
2037
|
+
} catch (err) {
|
|
2038
|
+
throw new ServeUsageError(
|
|
2039
|
+
`--repo ${JSON.stringify(spec)}: ${err instanceof Error ? err.message : String(err)}`
|
|
2040
|
+
);
|
|
2041
|
+
}
|
|
2042
|
+
repos.push({ ownerPubkey, repoId: spec.slice(slash + 1) });
|
|
2043
|
+
}
|
|
2044
|
+
if (repos.length === 0)
|
|
2045
|
+
throw new ServeUsageError(
|
|
2046
|
+
"at least one --repo <owner>/<repo-id> is required"
|
|
2047
|
+
);
|
|
2048
|
+
const requesters = [];
|
|
2049
|
+
for (const spec of values.requester ?? []) {
|
|
2050
|
+
try {
|
|
2051
|
+
requesters.push(ownerToHex(spec));
|
|
2052
|
+
} catch (err) {
|
|
2053
|
+
throw new ServeUsageError(
|
|
2054
|
+
`--requester ${JSON.stringify(spec)}: ${err instanceof Error ? err.message : String(err)}`
|
|
2055
|
+
);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
const positiveInt = (flag, raw, fallback) => {
|
|
2059
|
+
if (raw === void 0) return fallback;
|
|
2060
|
+
const n = Number(raw);
|
|
2061
|
+
if (!Number.isInteger(n) || n <= 0)
|
|
2062
|
+
throw new ServeUsageError(`${flag} expects a positive integer`);
|
|
2063
|
+
return n;
|
|
2064
|
+
};
|
|
2065
|
+
const concurrency = positiveInt("--concurrency", values.concurrency, 1);
|
|
2066
|
+
const timeoutMs = positiveInt("--timeout", values.timeout, DEFAULT_RUN_TIMEOUT_MS / 1e3) * 1e3;
|
|
2067
|
+
let platforms;
|
|
2068
|
+
if (values.platform && values.platform.length > 0) {
|
|
2069
|
+
platforms = {};
|
|
2070
|
+
for (const spec of values.platform) {
|
|
2071
|
+
const eq = spec.indexOf("=");
|
|
2072
|
+
if (eq <= 0 || eq === spec.length - 1) {
|
|
2073
|
+
throw new ServeUsageError(
|
|
2074
|
+
`--platform expects <label>=<image>, got ${JSON.stringify(spec)}`
|
|
2075
|
+
);
|
|
2076
|
+
}
|
|
2077
|
+
platforms[spec.slice(0, eq)] = spec.slice(eq + 1);
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
return {
|
|
2081
|
+
relay: values.relay,
|
|
2082
|
+
repos,
|
|
2083
|
+
requesters,
|
|
2084
|
+
concurrency,
|
|
2085
|
+
timeoutMs,
|
|
2086
|
+
gateway: values.gateway ?? PREFERRED_GATEWAY,
|
|
2087
|
+
...values["act-bin"] !== void 0 ? { actBin: values["act-bin"] } : {},
|
|
2088
|
+
...platforms ? { platforms } : {},
|
|
2089
|
+
...values.workdir !== void 0 ? { workdir: values.workdir } : {},
|
|
2090
|
+
json: values.json
|
|
2091
|
+
};
|
|
2092
|
+
}
|
|
2093
|
+
function estimateRunCost(estimate) {
|
|
2094
|
+
return BigInt(estimate.events) * estimate.rates.eventFee + BigInt(estimate.uploads) * uploadChargeFor(estimate.rates, 64 * 1024);
|
|
2095
|
+
}
|
|
2096
|
+
function makeAffordabilityCheck(args) {
|
|
2097
|
+
const { ctx, env, log } = args;
|
|
2098
|
+
return async (estimate) => {
|
|
2099
|
+
const cost = estimateRunCost(estimate);
|
|
2100
|
+
const store = new ChannelMapStore(resolveChannelPaths(env));
|
|
2101
|
+
const mine = store.list().filter(
|
|
2102
|
+
(record) => record.identity.toLowerCase() === ctx.ownerPubkey.toLowerCase() && record.supersededAt === void 0
|
|
2103
|
+
);
|
|
2104
|
+
if (mine.length > 0) {
|
|
2105
|
+
let best2 = 0n;
|
|
2106
|
+
for (const record of mine) {
|
|
2107
|
+
const watermark = store.readWatermark(record.channelId);
|
|
2108
|
+
if (channelStatus(watermark) !== "open") continue;
|
|
2109
|
+
if (record.depositTotal === void 0) continue;
|
|
2110
|
+
const remaining = BigInt(record.depositTotal) - BigInt(watermark?.cumulativeAmount ?? "0");
|
|
2111
|
+
if (remaining > best2) best2 = remaining;
|
|
2112
|
+
}
|
|
2113
|
+
if (best2 >= cost) return true;
|
|
2114
|
+
log(
|
|
2115
|
+
`[ci] wallet check: the best open channel has ${best2} available, the run needs ~${cost} \u2014 fund this coordinator (rig fund / rig channel open --deposit) to resume`
|
|
2116
|
+
);
|
|
2117
|
+
return false;
|
|
2118
|
+
}
|
|
2119
|
+
if (!ctx.money) {
|
|
2120
|
+
log(
|
|
2121
|
+
"[ci] wallet check: no channel recorded and no wallet reader \u2014 refusing"
|
|
2122
|
+
);
|
|
2123
|
+
return false;
|
|
2124
|
+
}
|
|
2125
|
+
let chains;
|
|
2126
|
+
try {
|
|
2127
|
+
chains = await ctx.money.walletChainBalances();
|
|
2128
|
+
} catch (err) {
|
|
2129
|
+
log(
|
|
2130
|
+
`[ci] wallet check: wallet unreadable (${err instanceof Error ? err.message : String(err)}) \u2014 refusing`
|
|
2131
|
+
);
|
|
2132
|
+
return false;
|
|
2133
|
+
}
|
|
2134
|
+
let best = 0n;
|
|
2135
|
+
for (const chain of chains) {
|
|
2136
|
+
if (chain.unreadable) continue;
|
|
2137
|
+
for (const token of chain.tokens) {
|
|
2138
|
+
if (token.symbol !== void 0 && !/usdc/i.test(token.symbol)) continue;
|
|
2139
|
+
const amount = BigInt(token.amount);
|
|
2140
|
+
if (amount > best) best = amount;
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
if (best >= cost) return true;
|
|
2144
|
+
log(
|
|
2145
|
+
`[ci] wallet check: no channel recorded yet and the wallet holds ${best} USDC base units, the run needs ~${cost} \u2014 fund this coordinator (rig fund) to resume`
|
|
2146
|
+
);
|
|
2147
|
+
return false;
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
2150
|
+
function untilStopRequested(signal) {
|
|
2151
|
+
return new Promise((resolve2) => {
|
|
2152
|
+
let settled = false;
|
|
2153
|
+
const finish = (reason) => {
|
|
2154
|
+
if (settled) return;
|
|
2155
|
+
settled = true;
|
|
2156
|
+
process.off("SIGINT", onSigint);
|
|
2157
|
+
process.off("SIGTERM", onSigterm);
|
|
2158
|
+
resolve2(reason);
|
|
2159
|
+
};
|
|
2160
|
+
const onSigint = () => finish("SIGINT");
|
|
2161
|
+
const onSigterm = () => finish("SIGTERM");
|
|
2162
|
+
if (signal?.aborted) return finish("abort");
|
|
2163
|
+
signal?.addEventListener("abort", () => finish("abort"), { once: true });
|
|
2164
|
+
process.once("SIGINT", onSigint);
|
|
2165
|
+
process.once("SIGTERM", onSigterm);
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
2168
|
+
async function runCiServe(args, deps) {
|
|
2169
|
+
const forced = withForcedStandalone(deps);
|
|
2170
|
+
const { io } = forced;
|
|
2171
|
+
let flags;
|
|
2172
|
+
try {
|
|
2173
|
+
const parsed = parseServeArgs(args);
|
|
2174
|
+
if (parsed === "help") {
|
|
2175
|
+
io.out(CI_SERVE_USAGE);
|
|
2176
|
+
return 0;
|
|
2177
|
+
}
|
|
2178
|
+
flags = parsed;
|
|
2179
|
+
} catch (err) {
|
|
2180
|
+
io.err(err instanceof Error ? err.message : String(err));
|
|
2181
|
+
io.err(CI_SERVE_USAGE);
|
|
2182
|
+
return 2;
|
|
2183
|
+
}
|
|
2184
|
+
let runner;
|
|
2185
|
+
if (forced.runner) {
|
|
2186
|
+
runner = forced.runner;
|
|
2187
|
+
} else {
|
|
2188
|
+
const act = new ActRunner({
|
|
2189
|
+
...flags.actBin !== void 0 ? { actBin: flags.actBin } : {},
|
|
2190
|
+
...flags.platforms ? { platforms: flags.platforms } : {}
|
|
2191
|
+
});
|
|
2192
|
+
if (act.binary(forced.env) === null) {
|
|
2193
|
+
io.err(
|
|
2194
|
+
"rig ci serve: the act executable was not found \u2014 install nektos/act (https://github.com/nektos/act) on this host, or point --act-bin / RIG_ACT_BIN at it. Nothing was started or paid."
|
|
2195
|
+
);
|
|
2196
|
+
return 1;
|
|
2197
|
+
}
|
|
2198
|
+
runner = act;
|
|
2199
|
+
}
|
|
2200
|
+
let ctx;
|
|
2201
|
+
let handle;
|
|
2202
|
+
try {
|
|
2203
|
+
const session = await loadPaidSession(forced, flags.relay);
|
|
2204
|
+
if (session.path !== "standalone") {
|
|
2205
|
+
throw new Error("rig ci serve requires the standalone paid path");
|
|
2206
|
+
}
|
|
2207
|
+
ctx = session.ctx;
|
|
2208
|
+
const identity = identityReport(ctx);
|
|
2209
|
+
const coordinator = ctx.ownerPubkey.toLowerCase();
|
|
2210
|
+
const stateDir = forced.stateDir ?? defaultCoordinatorStateDir(forced.env, coordinator);
|
|
2211
|
+
const workdir = flags.workdir ?? join4(stateDir, "work");
|
|
2212
|
+
const version = rigVersion();
|
|
2213
|
+
handle = await startCoordinator({
|
|
2214
|
+
coordinatorPubkey: coordinator,
|
|
2215
|
+
publisher: ctx.publisher,
|
|
2216
|
+
relayUrl: flags.relay,
|
|
2217
|
+
gatewayUrl: flags.gateway,
|
|
2218
|
+
repos: flags.repos,
|
|
2219
|
+
runner,
|
|
2220
|
+
stateDir,
|
|
2221
|
+
workdir,
|
|
2222
|
+
version,
|
|
2223
|
+
concurrency: flags.concurrency,
|
|
2224
|
+
timeoutMs: flags.timeoutMs,
|
|
2225
|
+
acceptedRequesters: flags.requesters,
|
|
2226
|
+
canAfford: forced.canAfford ?? makeAffordabilityCheck({
|
|
2227
|
+
ctx,
|
|
2228
|
+
env: forced.env,
|
|
2229
|
+
log: (line) => io.err(line)
|
|
2230
|
+
}),
|
|
2231
|
+
...forced.webSocketFactory ? { webSocketFactory: forced.webSocketFactory } : {},
|
|
2232
|
+
...forced.fetchFn ? { fetchFn: forced.fetchFn } : {},
|
|
2233
|
+
...forced.resolveSha ? { resolveSha: forced.resolveSha } : {},
|
|
2234
|
+
...forced.clock ? { clock: forced.clock } : {},
|
|
2235
|
+
log: (line) => io.err(line)
|
|
2236
|
+
});
|
|
2237
|
+
const npub = hexToNpub(coordinator);
|
|
2238
|
+
const repoLabels = flags.repos.map(
|
|
2239
|
+
(r) => `${hexToNpub(r.ownerPubkey)}/${r.repoId}`
|
|
2240
|
+
);
|
|
2241
|
+
if (flags.json) {
|
|
2242
|
+
io.emitJson({
|
|
2243
|
+
command: "ci serve",
|
|
2244
|
+
path: "standalone",
|
|
2245
|
+
identity,
|
|
2246
|
+
coordinator,
|
|
2247
|
+
coordinatorNpub: npub,
|
|
2248
|
+
relay: flags.relay,
|
|
2249
|
+
gateway: flags.gateway,
|
|
2250
|
+
repos: flags.repos,
|
|
2251
|
+
requesters: flags.requesters,
|
|
2252
|
+
runner: { family: runner.family, selectors: runner.selectors },
|
|
2253
|
+
concurrency: flags.concurrency,
|
|
2254
|
+
timeoutSeconds: flags.timeoutMs / 1e3,
|
|
2255
|
+
stateDir,
|
|
2256
|
+
workdir,
|
|
2257
|
+
secretsKey: handle.secretsKeyPubkey,
|
|
2258
|
+
advertisementId: handle.advertisementId,
|
|
2259
|
+
version
|
|
2260
|
+
});
|
|
2261
|
+
} else {
|
|
2262
|
+
io.out(renderIdentityLine(identity));
|
|
2263
|
+
io.out(`Coordinator: ${npub}`);
|
|
2264
|
+
io.out(`Relay: ${flags.relay}`);
|
|
2265
|
+
io.out(`Serving: ${repoLabels.join(", ")}`);
|
|
2266
|
+
if (flags.requesters.length > 0)
|
|
2267
|
+
io.out(
|
|
2268
|
+
`Requesters: maintainers + ${flags.requesters.map(hexToNpub).join(", ")}`
|
|
2269
|
+
);
|
|
2270
|
+
io.out(
|
|
2271
|
+
`Runner: ${runner.family} (${runner.selectors.join(", ")}); concurrency ${flags.concurrency}, timeout ${flags.timeoutMs / 1e3}s`
|
|
2272
|
+
);
|
|
2273
|
+
io.out(`State: ${stateDir}`);
|
|
2274
|
+
io.out("Maintainers authorize this coordinator with:");
|
|
2275
|
+
io.out(` rig ci request ${npub}`);
|
|
2276
|
+
io.out("Listening \u2014 press Ctrl-C to stop.");
|
|
2277
|
+
}
|
|
2278
|
+
const reason = await untilStopRequested(forced.signal);
|
|
2279
|
+
io.err(
|
|
2280
|
+
`rig ci serve: ${reason} \u2014 stopping (active runs conclude cancelled)`
|
|
2281
|
+
);
|
|
2282
|
+
await handle.stop();
|
|
2283
|
+
handle = void 0;
|
|
2284
|
+
return 0;
|
|
2285
|
+
} catch (err) {
|
|
2286
|
+
if (handle) await handle.stop().catch(() => void 0);
|
|
2287
|
+
return emitCliError(io, flags.json, "ci serve", err);
|
|
2288
|
+
} finally {
|
|
2289
|
+
if (ctx) {
|
|
2290
|
+
try {
|
|
2291
|
+
await ctx.stop();
|
|
2292
|
+
} catch {
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
export {
|
|
2298
|
+
CI_SERVE_USAGE,
|
|
2299
|
+
estimateRunCost,
|
|
2300
|
+
makeAffordabilityCheck,
|
|
2301
|
+
runCiServe
|
|
2302
|
+
};
|
|
2303
|
+
//# sourceMappingURL=ci-serve-ZVJBZ3W3.js.map
|