@toon-protocol/rig 4.2.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2360 @@
1
+ import {
2
+ PREFERRED_GATEWAY,
3
+ WS_URL_RE,
4
+ identityReport,
5
+ loadPaidSession,
6
+ renderIdentityLine,
7
+ rigVersion,
8
+ withForcedStandalone
9
+ } from "./chunk-CTRP2UAQ.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-RL5XUWR4.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
+ const activeRuns = /* @__PURE__ */ new Set();
1112
+ let active = 0;
1113
+ let inflight = 0;
1114
+ const idleWaiters = [];
1115
+ const checkIdle = () => {
1116
+ if (inflight === 0 && queue.length === 0 && eventQueue.length === 0 && !draining) {
1117
+ for (const w of idleWaiters.splice(0)) w();
1118
+ }
1119
+ };
1120
+ const releaseCheckout = async (checkout) => {
1121
+ checkout.refs -= 1;
1122
+ if (checkout.refs <= 0 && !opts.keepCheckouts) {
1123
+ await rm2(checkout.dir, { recursive: true, force: true }).catch(
1124
+ () => void 0
1125
+ );
1126
+ }
1127
+ };
1128
+ const progressAddress = (runId) => `${CI_WORKFLOW_PROGRESS_KIND}:${me}:${runId}`;
1129
+ const concludeWithoutJobs = async (run, conclusion, provenance, startedAt) => {
1130
+ const base = {
1131
+ trigger: run.trigger,
1132
+ runId: run.runId,
1133
+ queuedAt: run.queuedAt,
1134
+ ...startedAt !== void 0 ? { startedAt } : {},
1135
+ ...provenance ? { provenance } : {},
1136
+ jobs: []
1137
+ };
1138
+ const now = clock();
1139
+ const resultEventId = await publish(
1140
+ buildCiWorkflowResult({ ...base, conclusion }, now)
1141
+ );
1142
+ await publish(
1143
+ buildCiWorkflowProgress(
1144
+ { ...base, status: "concluded", conclusion, expiresAt: now + adTtl },
1145
+ now
1146
+ )
1147
+ );
1148
+ opts.onRunConcluded?.({
1149
+ runId: run.runId,
1150
+ repoAddr: run.repo.addr,
1151
+ trigger: run.trigger,
1152
+ conclusion,
1153
+ resultEventId
1154
+ });
1155
+ };
1156
+ const executeRun = async (run) => {
1157
+ const { repo, trigger, workflow } = run;
1158
+ const label = `${repo.repoId} ${workflow.path}@${trigger.commit.slice(0, 7)} [${run.runId.slice(0, 8)}]`;
1159
+ let provenance = run.manualProvenance;
1160
+ if (!provenance) {
1161
+ const { active: request } = selectServiceRequests(
1162
+ [...repo.controls.values()],
1163
+ {
1164
+ coordinatorPubkey: me,
1165
+ repoAddr: repo.addr,
1166
+ authorized: repo.authorized,
1167
+ acceptedRequesters
1168
+ }
1169
+ );
1170
+ if (!request) {
1171
+ log(`[ci] ${label}: service stopped before handoff \u2014 cancelled`);
1172
+ await concludeWithoutJobs(run, "cancelled", void 0);
1173
+ return;
1174
+ }
1175
+ provenance = {
1176
+ kind: "service-request",
1177
+ eventId: request.eventId,
1178
+ relayUrl,
1179
+ pubkey: request.pubkey
1180
+ };
1181
+ }
1182
+ if (workflow.parseError !== void 0) {
1183
+ log(`[ci] ${label}: workflow parse error \u2014 startup_failure`);
1184
+ await concludeWithoutJobs(run, "startup_failure", provenance, clock());
1185
+ return;
1186
+ }
1187
+ const startedAt = clock();
1188
+ const jobIds = workflow.jobs.map((j) => j.id);
1189
+ const quotes = [];
1190
+ const progress = (status, conclusion2) => {
1191
+ const now2 = clock();
1192
+ return buildCiWorkflowProgress(
1193
+ {
1194
+ trigger,
1195
+ runId: run.runId,
1196
+ status,
1197
+ ...conclusion2 !== void 0 ? { conclusion: conclusion2 } : {},
1198
+ inProgress: status === "in_progress" ? jobIds.filter((id) => !quotes.some((q) => q.jobId === id)) : [],
1199
+ queuedAt: run.queuedAt,
1200
+ startedAt,
1201
+ provenance,
1202
+ jobs: quotes,
1203
+ expiresAt: now2 + adTtl
1204
+ },
1205
+ now2
1206
+ );
1207
+ };
1208
+ await publish(progress("in_progress"));
1209
+ log(`[ci] ${label}: in_progress (${jobIds.length} job(s))`);
1210
+ let renewTimer = null;
1211
+ const scheduleRenew = () => {
1212
+ renewTimer = scheduler.setTimeout(
1213
+ () => {
1214
+ void publish(progress("in_progress")).catch(() => void 0);
1215
+ scheduleRenew();
1216
+ },
1217
+ adTtl * RENEW_FRACTION * 1e3
1218
+ );
1219
+ };
1220
+ scheduleRenew();
1221
+ const abort = new AbortController();
1222
+ activeAborts.add(abort);
1223
+ run.abort = abort;
1224
+ let timedOut = false;
1225
+ const timeoutHandle = scheduler.setTimeout(() => {
1226
+ timedOut = true;
1227
+ abort.abort();
1228
+ }, timeoutMs);
1229
+ let result;
1230
+ try {
1231
+ result = await Promise.race([
1232
+ runner.run({
1233
+ checkoutDir: run.checkout.dir,
1234
+ workflow: { path: workflow.path, sha256: workflow.sha256 },
1235
+ trigger,
1236
+ secrets: run.secrets,
1237
+ timeoutMs,
1238
+ signal: abort.signal
1239
+ }),
1240
+ new Promise((resolve2) => {
1241
+ abort.signal.addEventListener(
1242
+ "abort",
1243
+ () => {
1244
+ scheduler.setTimeout(
1245
+ () => resolve2({
1246
+ conclusion: timedOut ? "timed_out" : "cancelled",
1247
+ jobs: [],
1248
+ startedAt,
1249
+ finishedAt: clock()
1250
+ }),
1251
+ 1e3
1252
+ );
1253
+ },
1254
+ { once: true }
1255
+ );
1256
+ })
1257
+ ]);
1258
+ } catch (err) {
1259
+ log(
1260
+ `[ci] ${label}: runner failed: ${err instanceof Error ? err.message : String(err)}`
1261
+ );
1262
+ result = {
1263
+ conclusion: "startup_failure",
1264
+ jobs: [],
1265
+ startedAt,
1266
+ finishedAt: clock()
1267
+ };
1268
+ } finally {
1269
+ scheduler.clearTimeout(timeoutHandle);
1270
+ scheduler.clearTimeout(renewTimer);
1271
+ activeAborts.delete(abort);
1272
+ }
1273
+ let conclusion = result.conclusion;
1274
+ if (timedOut) conclusion = "timed_out";
1275
+ else if (run.superseded || stopping && abort.signal.aborted)
1276
+ conclusion = "cancelled";
1277
+ for (const job of result.jobs) {
1278
+ const { tail, omitted } = logTail(job.log);
1279
+ const logReceipt = await serial.run(
1280
+ () => uploadBlob({
1281
+ body: Buffer.from(job.log, "utf-8"),
1282
+ contentType: "text/plain; charset=utf-8",
1283
+ repoId: repo.repoId
1284
+ })
1285
+ );
1286
+ const artifacts = [];
1287
+ for (const artifact of job.artifacts) {
1288
+ try {
1289
+ const body = await readFile2(artifact.path);
1290
+ const receipt = await serial.run(
1291
+ () => uploadBlob({
1292
+ body,
1293
+ contentType: contentTypeForPath(artifact.filename),
1294
+ repoId: repo.repoId
1295
+ })
1296
+ );
1297
+ artifacts.push({
1298
+ url: gatewayRawUrl(opts.gatewayUrl, receipt.txId),
1299
+ filename: artifact.filename,
1300
+ name: artifact.name
1301
+ });
1302
+ } catch (err) {
1303
+ log(
1304
+ `[ci] ${label}: artifact ${artifact.filename} skipped: ${err instanceof Error ? err.message : String(err)}`
1305
+ );
1306
+ }
1307
+ }
1308
+ const runsOn = workflow.jobs.find((j) => j.id === job.jobId)?.runsOn;
1309
+ const jobConclusion = timedOut && job.conclusion === "failure" ? "timed_out" : job.conclusion;
1310
+ const jobEventId = await publish(
1311
+ buildCiJobResult(
1312
+ {
1313
+ trigger,
1314
+ progressAddress: progressAddress(run.runId),
1315
+ relayUrl,
1316
+ jobId: job.jobId,
1317
+ ...job.name !== void 0 ? { name: job.name } : {},
1318
+ conclusion: jobConclusion,
1319
+ logsUrl: gatewayRawUrl(opts.gatewayUrl, logReceipt.txId),
1320
+ logTail: tail,
1321
+ logOmittedBytes: omitted,
1322
+ artifacts,
1323
+ queuedAt: run.queuedAt,
1324
+ startedAt: job.startedAt,
1325
+ ...job.exitCode !== void 0 ? { exitCode: job.exitCode } : {},
1326
+ ...runsOn ? { runsOn } : {}
1327
+ },
1328
+ clock()
1329
+ )
1330
+ );
1331
+ quotes.push({
1332
+ eventId: jobEventId,
1333
+ relayUrl,
1334
+ pubkey: me,
1335
+ jobId: job.jobId
1336
+ });
1337
+ await publish(progress("in_progress"));
1338
+ }
1339
+ const now = clock();
1340
+ const resultEventId = await publish(
1341
+ buildCiWorkflowResult(
1342
+ {
1343
+ trigger,
1344
+ runId: run.runId,
1345
+ conclusion,
1346
+ queuedAt: run.queuedAt,
1347
+ startedAt,
1348
+ provenance,
1349
+ jobs: quotes
1350
+ },
1351
+ now
1352
+ )
1353
+ );
1354
+ await publish(progress("concluded", conclusion));
1355
+ log(`[ci] ${label}: ${conclusion}`);
1356
+ opts.onRunConcluded?.({
1357
+ runId: run.runId,
1358
+ repoAddr: repo.addr,
1359
+ trigger,
1360
+ conclusion,
1361
+ resultEventId
1362
+ });
1363
+ };
1364
+ const pump = () => {
1365
+ while (active < concurrency && queue.length > 0 && !stopping) {
1366
+ const run = queue.shift();
1367
+ active += 1;
1368
+ inflight += 1;
1369
+ activeRuns.add(run);
1370
+ void executeRun(run).catch((err) => {
1371
+ log(
1372
+ `[ci] run ${run.runId.slice(0, 8)} failed: ${err instanceof Error ? err.message : String(err)}`
1373
+ );
1374
+ }).finally(async () => {
1375
+ activeRuns.delete(run);
1376
+ active -= 1;
1377
+ inflight -= 1;
1378
+ await releaseCheckout(run.checkout);
1379
+ pump();
1380
+ checkIdle();
1381
+ });
1382
+ }
1383
+ };
1384
+ const enqueueRun = async (repo, trigger, workflow, checkout, secrets, manualProvenance) => {
1385
+ let rates;
1386
+ try {
1387
+ rates = await publisher.getFeeRates();
1388
+ } catch (err) {
1389
+ log(
1390
+ `[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?`
1391
+ );
1392
+ return;
1393
+ }
1394
+ const jobs = Math.max(1, workflow.jobs.length);
1395
+ if (!await canAfford({ events: 4 + 2 * jobs, uploads: jobs, rates })) {
1396
+ log(
1397
+ `[ci] refusing run of ${workflow.path} for ${repo.repoId}: wallet cannot cover its writes`
1398
+ );
1399
+ return;
1400
+ }
1401
+ const run = {
1402
+ repo,
1403
+ trigger,
1404
+ workflow,
1405
+ checkout,
1406
+ secrets,
1407
+ ...manualProvenance ? { manualProvenance } : {},
1408
+ runId: randomUUID(),
1409
+ queuedAt: clock()
1410
+ };
1411
+ checkout.refs += 1;
1412
+ const position = active + queue.length + 1;
1413
+ const now = clock();
1414
+ await publish(
1415
+ buildCiWorkflowProgress(
1416
+ {
1417
+ trigger,
1418
+ runId: run.runId,
1419
+ status: "queued",
1420
+ queue: Math.ceil(position / concurrency),
1421
+ queuedAt: run.queuedAt,
1422
+ ...manualProvenance ? { provenance: manualProvenance } : {},
1423
+ jobs: [],
1424
+ expiresAt: now + adTtl
1425
+ },
1426
+ now
1427
+ )
1428
+ );
1429
+ queue.push(run);
1430
+ log(
1431
+ `[ci] queued ${workflow.path} for ${repo.repoId}@${trigger.commit.slice(0, 7)} [${run.runId.slice(0, 8)}]`
1432
+ );
1433
+ pump();
1434
+ };
1435
+ const supersedePrRuns = async (repo, prEventId, updateEventId) => {
1436
+ const samePr = (run) => run.repo === repo && run.trigger.pr?.prEventId === prEventId;
1437
+ const why = `superseded by PR update ${updateEventId.slice(0, 8)} \u2014 cancelled`;
1438
+ for (const run of queue.filter(samePr)) {
1439
+ queue.splice(queue.indexOf(run), 1);
1440
+ run.superseded = true;
1441
+ log(
1442
+ `[ci] ${repo.repoId} ${run.workflow.path}@${run.trigger.commit.slice(0, 7)} [${run.runId.slice(0, 8)}]: queued run ${why}`
1443
+ );
1444
+ await concludeWithoutJobs(run, "cancelled", void 0);
1445
+ await releaseCheckout(run.checkout);
1446
+ }
1447
+ for (const run of activeRuns) {
1448
+ if (!samePr(run) || run.superseded) continue;
1449
+ run.superseded = true;
1450
+ log(
1451
+ `[ci] ${repo.repoId} ${run.workflow.path}@${run.trigger.commit.slice(0, 7)} [${run.runId.slice(0, 8)}]: ${why}`
1452
+ );
1453
+ run.abort?.abort();
1454
+ }
1455
+ };
1456
+ let checkoutCounter = 0;
1457
+ const handleTrigger = async (t) => {
1458
+ const { repo } = t;
1459
+ inflight += 1;
1460
+ try {
1461
+ const dir = join3(
1462
+ opts.workdir,
1463
+ `${repo.repoId}-${t.commit.slice(0, 7)}-${++checkoutCounter}`
1464
+ );
1465
+ let materialized;
1466
+ try {
1467
+ materialized = await materialize({
1468
+ ownerPubkey: repo.ownerPubkey,
1469
+ repoId: repo.repoId,
1470
+ relayUrls: [relayUrl],
1471
+ commit: t.commit,
1472
+ dir,
1473
+ ...t.patch ? { patch: { content: t.patch.content } } : {},
1474
+ ...t.manual?.tagObjectIds && t.manual.tagObjectIds.length > 0 ? { extraObjectIds: t.manual.tagObjectIds } : {},
1475
+ ...opts.webSocketFactory ? { webSocketFactory: opts.webSocketFactory } : {},
1476
+ ...opts.fetchFn ? { fetchFn: opts.fetchFn } : {},
1477
+ ...opts.resolveSha ? { resolveSha: opts.resolveSha } : {},
1478
+ ...opts.gateways ? { gateways: opts.gateways } : {}
1479
+ });
1480
+ } catch (err) {
1481
+ const reason = err instanceof MaterializeError ? err.message : `materialize failed: ${err instanceof Error ? err.message : String(err)}`;
1482
+ log(`[ci] ${repo.repoId}@${t.commit.slice(0, 7)}: ${reason}`);
1483
+ if (t.manual) {
1484
+ const trigger = {
1485
+ repoAddr: repo.addr,
1486
+ commit: t.commit,
1487
+ workflow: t.manual.workflow,
1488
+ reason: "manual",
1489
+ ...t.ref !== void 0 ? { ref: t.ref } : {},
1490
+ ...t.pr ? { pr: t.pr } : {}
1491
+ };
1492
+ const checkout2 = { dir, refs: 0 };
1493
+ const provenance = {
1494
+ kind: "manual-trigger",
1495
+ eventId: t.manual.eventId,
1496
+ relayUrl,
1497
+ pubkey: t.manual.pubkey
1498
+ };
1499
+ const broken = {
1500
+ ...t.manual.workflow,
1501
+ triggers: {},
1502
+ jobs: [],
1503
+ parseError: reason
1504
+ };
1505
+ await enqueueRun(repo, trigger, broken, checkout2, {}, provenance);
1506
+ }
1507
+ return;
1508
+ }
1509
+ const checkout = { dir: materialized.dir, refs: 0 };
1510
+ const commit = materialized.commit;
1511
+ if (t.manual?.tagObjectIds && t.manual.tagObjectIds.length > 0) {
1512
+ const mismatch = await findUnpeelableTagId(
1513
+ materialized.dir,
1514
+ t.manual.tagObjectIds,
1515
+ commit
1516
+ );
1517
+ if (mismatch !== null) {
1518
+ log(
1519
+ `[ci] manual trigger ignored: c ${mismatch.slice(0, 7)} does not peel to ${commit.slice(0, 7)} (or is not in the repo)`
1520
+ );
1521
+ if (!opts.keepCheckouts)
1522
+ await rm2(materialized.dir, { recursive: true, force: true }).catch(
1523
+ () => void 0
1524
+ );
1525
+ return;
1526
+ }
1527
+ }
1528
+ const workflows = await discoverWorkflows(materialized.dir);
1529
+ const tagObjectIds = [
1530
+ ...t.manual?.tagObjectIds ?? [],
1531
+ ...commit !== t.commit && !t.patch ? [t.commit] : [],
1532
+ ...t.patch?.declaredTip && t.patch.declaredTip !== commit ? [t.patch.declaredTip] : []
1533
+ ];
1534
+ const secretsAllowed = repo.authorized.has(t.authorPubkey.toLowerCase());
1535
+ const secrets = secretsAllowed ? effectiveSecrets(repo.secrets) : {};
1536
+ let selected;
1537
+ let manualProvenance;
1538
+ if (t.manual) {
1539
+ const wanted = t.manual.workflow;
1540
+ const wf = workflows.find((w) => w.path === wanted.path);
1541
+ if (!wf) {
1542
+ log(
1543
+ `[ci] manual trigger ignored: ${wanted.path} does not exist at ${commit.slice(0, 7)}`
1544
+ );
1545
+ selected = [];
1546
+ } else if (wf.sha256 !== wanted.sha256.toLowerCase()) {
1547
+ log(
1548
+ `[ci] manual trigger ignored: ${wanted.path} at ${commit.slice(0, 7)} has sha256 ${wf.sha256}, request named ${wanted.sha256}`
1549
+ );
1550
+ selected = [];
1551
+ } else {
1552
+ selected = [wf];
1553
+ }
1554
+ manualProvenance = {
1555
+ kind: "manual-trigger",
1556
+ eventId: t.manual.eventId,
1557
+ relayUrl,
1558
+ pubkey: t.manual.pubkey
1559
+ };
1560
+ } else if (t.reason === "push") {
1561
+ selected = workflows.filter(
1562
+ (wf) => wf.parseError !== void 0 || matchesPush(wf, t.ref)
1563
+ );
1564
+ } else {
1565
+ const base = repo.defaultBranch ?? void 0;
1566
+ selected = workflows.filter(
1567
+ (wf) => wf.parseError !== void 0 || matchesPullRequest(wf, base)
1568
+ );
1569
+ }
1570
+ if (selected.length === 0) {
1571
+ if (!opts.keepCheckouts)
1572
+ await rm2(materialized.dir, { recursive: true, force: true }).catch(
1573
+ () => void 0
1574
+ );
1575
+ return;
1576
+ }
1577
+ for (const wf of selected) {
1578
+ const trigger = {
1579
+ repoAddr: repo.addr,
1580
+ commit,
1581
+ ...tagObjectIds.length > 0 ? { tagObjectIds } : {},
1582
+ workflow: { path: wf.path, sha256: wf.sha256 },
1583
+ reason: t.reason,
1584
+ ...t.pr ? { pr: t.pr } : t.ref !== void 0 ? { ref: t.ref } : {}
1585
+ };
1586
+ await enqueueRun(
1587
+ repo,
1588
+ trigger,
1589
+ wf,
1590
+ checkout,
1591
+ secrets,
1592
+ manualProvenance
1593
+ );
1594
+ }
1595
+ if (checkout.refs === 0 && !opts.keepCheckouts) {
1596
+ await rm2(materialized.dir, { recursive: true, force: true }).catch(
1597
+ () => void 0
1598
+ );
1599
+ }
1600
+ } finally {
1601
+ inflight -= 1;
1602
+ checkIdle();
1603
+ }
1604
+ };
1605
+ const eventQueue = [];
1606
+ let draining = false;
1607
+ const markRepoProcessed = (repo, ev) => {
1608
+ const cursor = repo.cursor;
1609
+ cursor.processed.push(ev.id);
1610
+ if (ev.created_at > cursor.lastCreatedAt) {
1611
+ cursor.lastCreatedAt = ev.created_at;
1612
+ cursor.lastEventId = ev.id;
1613
+ }
1614
+ };
1615
+ const repoSeen = (repo, ev) => (repo.cursor?.processed ?? []).includes(ev.id);
1616
+ const handleRepoState = async (repo, ev) => {
1617
+ const cursor = repo.cursor;
1618
+ if (repoSeen(repo, ev) || ev.created_at < cursor.lastCreatedAt) return;
1619
+ const { refs, head } = parseRefsTags(ev);
1620
+ if (head) repo.defaultBranch = head;
1621
+ const moved = movedRefs(cursor.refs, refs);
1622
+ cursor.refs = refs;
1623
+ markRepoProcessed(repo, ev);
1624
+ await persistCursor();
1625
+ if (moved.length === 0) return;
1626
+ if (!isServing(repo)) {
1627
+ log(
1628
+ `[ci] ${repo.repoId}: push seen but no maintainer Service Request \u2014 not running`
1629
+ );
1630
+ return;
1631
+ }
1632
+ for (const { ref, sha } of moved) {
1633
+ if (!ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/"))
1634
+ continue;
1635
+ await handleTrigger({
1636
+ repo,
1637
+ reason: "push",
1638
+ authorPubkey: ev.pubkey,
1639
+ commit: sha,
1640
+ ref
1641
+ });
1642
+ }
1643
+ };
1644
+ const handlePullRequest = async (repo, ev) => {
1645
+ if (repoSeen(repo, ev)) return;
1646
+ if (ev.kind === 1617 || ev.kind === 1618) repo.prEvents.set(ev.id, ev);
1647
+ markRepoProcessed(repo, ev);
1648
+ await persistCursor();
1649
+ if (!isServing(repo)) {
1650
+ log(
1651
+ `[ci] ${repo.repoId}: pull request seen but no maintainer Service Request \u2014 not running`
1652
+ );
1653
+ return;
1654
+ }
1655
+ const author = ev.pubkey.toLowerCase();
1656
+ if (ev.kind === 1617) {
1657
+ const parents = tagValues(ev.tags, "parent-commit");
1658
+ const commits = tagValues(ev.tags, "commit");
1659
+ const base = parents[0];
1660
+ if (!base || ev.content.trim() === "") {
1661
+ log(
1662
+ `[ci] ${repo.repoId}: patch ${ev.id.slice(0, 8)} has no parent-commit/content \u2014 cannot run`
1663
+ );
1664
+ return;
1665
+ }
1666
+ const declaredTip = commits[commits.length - 1];
1667
+ await handleTrigger({
1668
+ repo,
1669
+ reason: "pull_request",
1670
+ authorPubkey: author,
1671
+ commit: base,
1672
+ patch: { content: ev.content, ...declaredTip ? { declaredTip } : {} },
1673
+ pr: {
1674
+ prEventId: ev.id,
1675
+ prAuthor: author,
1676
+ prKind: 1617,
1677
+ sourceEventId: ev.id,
1678
+ sourceAuthor: author,
1679
+ sourceKind: 1617
1680
+ }
1681
+ });
1682
+ return;
1683
+ }
1684
+ if (ev.kind === 1618) {
1685
+ const tip2 = tagValues(ev.tags, "c")[0];
1686
+ if (!tip2) {
1687
+ log(
1688
+ `[ci] ${repo.repoId}: pull request ${ev.id.slice(0, 8)} has no tip commit \u2014 cannot run`
1689
+ );
1690
+ return;
1691
+ }
1692
+ await handleTrigger({
1693
+ repo,
1694
+ reason: "pull_request",
1695
+ authorPubkey: author,
1696
+ commit: tip2,
1697
+ pr: {
1698
+ prEventId: ev.id,
1699
+ prAuthor: author,
1700
+ prKind: 1618,
1701
+ sourceEventId: ev.id,
1702
+ sourceAuthor: author,
1703
+ sourceKind: 1618
1704
+ }
1705
+ });
1706
+ return;
1707
+ }
1708
+ const prId = tagValues(ev.tags, "E")[0];
1709
+ const pr = prId ? repo.prEvents.get(prId) : void 0;
1710
+ const tip = tagValues(ev.tags, "c")[0];
1711
+ if (!pr || !tip) {
1712
+ log(
1713
+ `[ci] ${repo.repoId}: PR update ${ev.id.slice(0, 8)} references an unknown PR or no commit \u2014 ignored`
1714
+ );
1715
+ return;
1716
+ }
1717
+ await supersedePrRuns(repo, pr.id, ev.id);
1718
+ await handleTrigger({
1719
+ repo,
1720
+ reason: "pull_request",
1721
+ authorPubkey: author,
1722
+ commit: tip,
1723
+ pr: {
1724
+ prEventId: pr.id,
1725
+ prAuthor: pr.pubkey.toLowerCase(),
1726
+ prKind: pr.kind === 1617 ? 1617 : 1618,
1727
+ sourceEventId: ev.id,
1728
+ sourceAuthor: author,
1729
+ sourceKind: 1619
1730
+ }
1731
+ });
1732
+ };
1733
+ const handleManualTrigger = async (ev) => {
1734
+ if (inboxProcessed.has(ev.id)) return;
1735
+ inboxProcessed.add(ev.id);
1736
+ inbox.lastCreatedAt = Math.max(inbox.lastCreatedAt ?? 0, ev.created_at);
1737
+ await persistCursor();
1738
+ const manual = parseCiManualTrigger(ev, me);
1739
+ if (!manual) {
1740
+ log(`[ci] malformed manual trigger ${ev.id.slice(0, 8)} ignored`);
1741
+ return;
1742
+ }
1743
+ const repo = repos.get(manual.trigger.repoAddr);
1744
+ if (!repo) {
1745
+ log(
1746
+ `[ci] manual trigger for unserved repo ${manual.trigger.repoAddr} ignored`
1747
+ );
1748
+ return;
1749
+ }
1750
+ if (!repo.authorized.has(manual.pubkey)) {
1751
+ log(
1752
+ `[ci] manual trigger from non-maintainer ${manual.pubkey.slice(0, 8)} ignored`
1753
+ );
1754
+ return;
1755
+ }
1756
+ const t = manual.trigger;
1757
+ await handleTrigger({
1758
+ repo,
1759
+ reason: "manual",
1760
+ authorPubkey: manual.pubkey,
1761
+ commit: t.commit,
1762
+ ...t.ref !== void 0 ? { ref: t.ref } : {},
1763
+ ...t.pr ? { pr: t.pr } : {},
1764
+ manual: {
1765
+ eventId: manual.eventId,
1766
+ pubkey: manual.pubkey,
1767
+ workflow: t.workflow,
1768
+ ...t.tagObjectIds ? { tagObjectIds: t.tagObjectIds } : {}
1769
+ }
1770
+ });
1771
+ };
1772
+ const handleSecretUpdate = async (ev) => {
1773
+ if (inboxProcessed.has(ev.id)) return;
1774
+ inboxProcessed.add(ev.id);
1775
+ inbox.lastCreatedAt = Math.max(inbox.lastCreatedAt ?? 0, ev.created_at);
1776
+ await persistCursor();
1777
+ const update = parseCiSecretUpdate(ev);
1778
+ if (!update || update.coordinatorPubkey !== me) return;
1779
+ const perspective = parseRepoAddress(update.repoAddr);
1780
+ const repo = perspective ? [...repos.values()].find((r) => r.repoId === perspective.repoId) : void 0;
1781
+ if (!repo) {
1782
+ log(`[ci] secret update for unserved repo ${update.repoAddr} ignored`);
1783
+ return;
1784
+ }
1785
+ if (!repo.authorized.has(update.pubkey)) {
1786
+ log(
1787
+ `[ci] ${repo.repoId}: secret update from non-maintainer ${update.pubkey.slice(0, 8)} ignored`
1788
+ );
1789
+ return;
1790
+ }
1791
+ const ad = liveAdvertisements.get(update.advertisementId);
1792
+ if (!ad || update.recipientPubkey !== secretsKey.pubkey) {
1793
+ log(
1794
+ `[ci] ${repo.repoId}: secret update ${ev.id.slice(0, 8)} addressed to a stale secrets key \u2014 re-send it`
1795
+ );
1796
+ return;
1797
+ }
1798
+ if (update.createdAt < ad.createdAt || update.createdAt >= ad.expiresAt) {
1799
+ log(
1800
+ `[ci] ${repo.repoId}: secret update ${ev.id.slice(0, 8)} outside its advertisement window \u2014 ignored`
1801
+ );
1802
+ return;
1803
+ }
1804
+ try {
1805
+ const plain = decryptSecretUpdate(
1806
+ update.ciphertext,
1807
+ secretsKey.secretKey,
1808
+ update.senderPubkey
1809
+ );
1810
+ validateSecretUpdate(plain, {
1811
+ pubkey: ev.pubkey,
1812
+ created_at: ev.created_at
1813
+ });
1814
+ repo.secrets = applySecretUpdate(repo.secrets, plain, ev.id);
1815
+ await persistSecrets();
1816
+ const names = [...Object.keys(plain.set), ...plain.remove];
1817
+ log(
1818
+ `[ci] ${repo.repoId}: secrets updated by ${update.pubkey.slice(0, 8)} (${names.join(", ")})`
1819
+ );
1820
+ } catch (err) {
1821
+ log(
1822
+ `[ci] ${repo.repoId}: secret update ${ev.id.slice(0, 8)} rejected: ${err instanceof Error ? err.message : String(err)}`
1823
+ );
1824
+ }
1825
+ };
1826
+ const handleEvent = async (ev) => {
1827
+ switch (ev.kind) {
1828
+ case REPOSITORY_ANNOUNCEMENT_KIND: {
1829
+ const repoId = tagValues(ev.tags, "d")[0];
1830
+ const repo = repoId ? repos.get(repoAddress(ev.pubkey, repoId)) : void 0;
1831
+ if (repo && ev.pubkey.toLowerCase() === repo.ownerPubkey) {
1832
+ repo.authorized = authorizedStatusAuthors(repo.ownerPubkey, ev.tags);
1833
+ }
1834
+ return;
1835
+ }
1836
+ case REPOSITORY_STATE_KIND: {
1837
+ const repoId = tagValues(ev.tags, "d")[0];
1838
+ const repo = repoId ? repos.get(repoAddress(ev.pubkey, repoId)) : void 0;
1839
+ if (repo && ev.pubkey.toLowerCase() === repo.ownerPubkey)
1840
+ await handleRepoState(repo, ev);
1841
+ return;
1842
+ }
1843
+ case 1617:
1844
+ case 1618:
1845
+ case 1619: {
1846
+ for (const addr of tagValues(ev.tags, "a")) {
1847
+ const repo = repos.get(addr);
1848
+ if (repo) {
1849
+ await handlePullRequest(repo, ev);
1850
+ return;
1851
+ }
1852
+ }
1853
+ return;
1854
+ }
1855
+ case CI_SERVICE_REQUEST_KIND:
1856
+ case CI_SERVICE_STOP_KIND:
1857
+ handleControl(ev);
1858
+ return;
1859
+ case CI_MANUAL_TRIGGER_KIND:
1860
+ await handleManualTrigger(ev);
1861
+ return;
1862
+ case CI_SECRET_UPDATE_KIND:
1863
+ await handleSecretUpdate(ev);
1864
+ return;
1865
+ default:
1866
+ return;
1867
+ }
1868
+ };
1869
+ const drain = async () => {
1870
+ if (draining) return;
1871
+ draining = true;
1872
+ try {
1873
+ while (eventQueue.length > 0) {
1874
+ const ev = eventQueue.shift();
1875
+ try {
1876
+ await handleEvent(ev);
1877
+ } catch (err) {
1878
+ log(
1879
+ `[ci] event ${ev.id.slice(0, 8)} (kind ${ev.kind}) failed: ${err instanceof Error ? err.message : String(err)}`
1880
+ );
1881
+ }
1882
+ }
1883
+ } finally {
1884
+ draining = false;
1885
+ checkIdle();
1886
+ }
1887
+ };
1888
+ const filters = [controlFilter];
1889
+ const sinceFor = [null];
1890
+ for (const repo of repos.values()) {
1891
+ filters.push({
1892
+ kinds: [REPOSITORY_ANNOUNCEMENT_KIND, REPOSITORY_STATE_KIND],
1893
+ authors: [repo.ownerPubkey],
1894
+ "#d": [repo.repoId]
1895
+ });
1896
+ sinceFor.push(null);
1897
+ filters.push({ kinds: [1617, 1618, 1619], "#a": [repo.addr] });
1898
+ sinceFor.push(() => repo.cursor?.lastCreatedAt);
1899
+ }
1900
+ filters.push({
1901
+ kinds: [CI_MANUAL_TRIGGER_KIND, CI_SECRET_UPDATE_KIND],
1902
+ "#p": [me]
1903
+ });
1904
+ sinceFor.push(() => inbox.lastCreatedAt);
1905
+ const subscription = subscribeRelay({
1906
+ url: relayUrl,
1907
+ filters,
1908
+ webSocketFactory: opts.webSocketFactory ?? defaultWebSocketFactory,
1909
+ scheduler,
1910
+ onEvent: (ev) => {
1911
+ eventQueue.push(ev);
1912
+ void drain();
1913
+ },
1914
+ onStatus: (status, detail) => {
1915
+ if (status === "open") log(`[ci] connected to ${relayUrl}`);
1916
+ else if (status === "reconnecting")
1917
+ log(`[ci] relay connection lost (${detail ?? ""}) \u2014 reconnecting`);
1918
+ },
1919
+ since: (index) => sinceFor[index]?.() ?? void 0
1920
+ });
1921
+ let stopResolve = () => void 0;
1922
+ const stopped = new Promise((resolve2) => {
1923
+ stopResolve = resolve2;
1924
+ });
1925
+ let stopPromise = null;
1926
+ const stop = () => {
1927
+ if (stopPromise) return stopPromise;
1928
+ stopPromise = (async () => {
1929
+ stopping = true;
1930
+ subscription.close();
1931
+ if (adTimer !== null) scheduler.clearTimeout(adTimer);
1932
+ const dropped = queue.splice(0);
1933
+ for (const run of dropped) {
1934
+ log(
1935
+ `[ci] dropping queued run ${run.runId.slice(0, 8)} (its queued marker expires on its own)`
1936
+ );
1937
+ await releaseCheckout(run.checkout);
1938
+ }
1939
+ for (const abort of activeAborts) abort.abort();
1940
+ await new Promise((resolve2) => {
1941
+ if (inflight === 0 && !draining) return resolve2();
1942
+ idleWaiters.push(resolve2);
1943
+ });
1944
+ await persistCursor();
1945
+ stopResolve();
1946
+ })();
1947
+ return stopPromise;
1948
+ };
1949
+ return {
1950
+ pubkey: me,
1951
+ get secretsKeyPubkey() {
1952
+ return secretsKey.pubkey;
1953
+ },
1954
+ get advertisementId() {
1955
+ return advertisementId;
1956
+ },
1957
+ stopped,
1958
+ stop,
1959
+ idle: () => new Promise((resolve2) => {
1960
+ if (inflight === 0 && queue.length === 0 && eventQueue.length === 0 && !draining)
1961
+ return resolve2();
1962
+ idleWaiters.push(resolve2);
1963
+ }),
1964
+ serving: () => [...repos.values()].map((repo) => ({
1965
+ repoAddr: repo.addr,
1966
+ serving: isServing(repo)
1967
+ }))
1968
+ };
1969
+ }
1970
+ async function findUnpeelableTagId(checkoutDir, tagObjectIds, commit) {
1971
+ for (const id of tagObjectIds) {
1972
+ if (!/^[0-9a-f]{40}$/i.test(id)) return id;
1973
+ let peeled;
1974
+ try {
1975
+ peeled = (await runGit(checkoutDir, [
1976
+ "rev-parse",
1977
+ "--verify",
1978
+ "--quiet",
1979
+ `${id}^{commit}`
1980
+ ])).trim();
1981
+ } catch {
1982
+ return id;
1983
+ }
1984
+ if (peeled.toLowerCase() !== commit.toLowerCase()) return id;
1985
+ }
1986
+ return null;
1987
+ }
1988
+
1989
+ // src/cli/ci-serve.ts
1990
+ var CI_SERVE_USAGE = `Usage: rig ci serve --relay <ws-url> --repo <owner>/<repo-id> [--repo \u2026] [options]
1991
+
1992
+ Run a CI coordinator: watch the named repos on the relay, run their
1993
+ GitHub-Actions-syntax workflows with act on this host's Docker, and publish
1994
+ NIP-C1 progress and results back to the relay as PAID writes from THIS
1995
+ identity's channel. Every run is a handful of relay writes (one per progress
1996
+ update, job, and result) plus one store upload per job log and artifact.
1997
+
1998
+ A repo is served only while one of its maintainers has published a Service
1999
+ Request (\`rig ci request <this-coordinator>\`) with no later Stop; a
2000
+ maintainer's \`rig ci trigger\` runs one workflow without a standing request.
2001
+ Secrets set with \`rig ci secret\` are injected only into runs authored by a
2002
+ maintainer \u2014 never into a third-party pull request.
2003
+
2004
+ Options:
2005
+ --relay <url> the relay to watch AND publish to (ws:// or wss://)
2006
+ [required, exactly one]
2007
+ --repo <owner>/<id> a repo to serve (owner as npub or hex); repeatable
2008
+ --requester <pubkey> also accept Service Requests from this pubkey (npub
2009
+ or hex; NIP-C1 operator policy); repeatable \u2014 for
2010
+ running your own coordinator against a repo you do
2011
+ not maintain; such runs show as lower trust
2012
+ --concurrency <n> runs executed at once (default 1); more queue
2013
+ --timeout <seconds> wall-clock budget per run \u2192 timed_out (default 1800)
2014
+ --gateway <url> gateway that serves the store's raw bytes, for log +
2015
+ artifact links (default: ${PREFERRED_GATEWAY})
2016
+ --act-bin <path> the act executable (default: RIG_ACT_BIN, else PATH)
2017
+ --platform <label=img> runs-on label \u2192 Docker image; repeatable
2018
+ (default: ubuntu-latest=${DEFAULT_ACT_PLATFORMS["ubuntu-latest"]})
2019
+ --workdir <dir> where commits are checked out for runs (default:
2020
+ <state-dir>/work)
2021
+ --once stop after the first run concludes (its Workflow
2022
+ Result and final progress marker are on the relay);
2023
+ ignored or refused triggers do not count
2024
+ --json emit ONE JSON document describing the coordinator
2025
+ at start; everything else goes to stderr
2026
+ --standalone accepted for symmetry; the coordinator always runs
2027
+ embedded (alias: --no-daemon)
2028
+ -h, --help show this help
2029
+
2030
+ State (cursor, secret inventory) lives under
2031
+ <TOON_CLIENT_HOME|~/.toon-client>/rig-ci/<coordinator-pubkey>/ so a restart
2032
+ resumes from the last processed event. The NIP-44 secrets-key is generated
2033
+ fresh every start and never written to disk: after a restart maintainers
2034
+ re-send \`rig ci secret set\`; values already accepted survive.
2035
+
2036
+ Before each run the coordinator checks that a recorded payment channel (or,
2037
+ before the first channel opens, the wallet's USDC) can cover the run's writes;
2038
+ otherwise it says so on stderr and starts nothing, so a run is never
2039
+ half-published (story 15).
2040
+
2041
+ Stop with Ctrl-C (SIGINT) or SIGTERM: active runs conclude \`cancelled\`.`;
2042
+ var ServeUsageError = class extends Error {
2043
+ };
2044
+ function parseServeArgs(args) {
2045
+ const { values } = parseArgs({
2046
+ args,
2047
+ options: {
2048
+ relay: { type: "string" },
2049
+ repo: { type: "string", multiple: true },
2050
+ requester: { type: "string", multiple: true },
2051
+ concurrency: { type: "string" },
2052
+ timeout: { type: "string" },
2053
+ gateway: { type: "string" },
2054
+ "act-bin": { type: "string" },
2055
+ platform: { type: "string", multiple: true },
2056
+ workdir: { type: "string" },
2057
+ once: { type: "boolean", default: false },
2058
+ json: { type: "boolean", default: false },
2059
+ standalone: { type: "boolean", default: false },
2060
+ "no-daemon": { type: "boolean", default: false },
2061
+ help: { type: "boolean", short: "h", default: false }
2062
+ },
2063
+ allowPositionals: false,
2064
+ strict: true
2065
+ });
2066
+ if (values.help) return "help";
2067
+ if (!values.relay) throw new ServeUsageError("--relay <ws-url> is required");
2068
+ if (!WS_URL_RE.test(values.relay)) {
2069
+ throw new ServeUsageError(
2070
+ `--relay must be a ws:// or wss:// URL, got ${JSON.stringify(values.relay)}`
2071
+ );
2072
+ }
2073
+ const repos = [];
2074
+ for (const spec of values.repo ?? []) {
2075
+ const slash = spec.indexOf("/");
2076
+ if (slash <= 0 || slash === spec.length - 1) {
2077
+ throw new ServeUsageError(
2078
+ `--repo expects <owner>/<repo-id>, got ${JSON.stringify(spec)}`
2079
+ );
2080
+ }
2081
+ let ownerPubkey;
2082
+ try {
2083
+ ownerPubkey = ownerToHex(spec.slice(0, slash));
2084
+ } catch (err) {
2085
+ throw new ServeUsageError(
2086
+ `--repo ${JSON.stringify(spec)}: ${err instanceof Error ? err.message : String(err)}`
2087
+ );
2088
+ }
2089
+ repos.push({ ownerPubkey, repoId: spec.slice(slash + 1) });
2090
+ }
2091
+ if (repos.length === 0)
2092
+ throw new ServeUsageError(
2093
+ "at least one --repo <owner>/<repo-id> is required"
2094
+ );
2095
+ const requesters = [];
2096
+ for (const spec of values.requester ?? []) {
2097
+ try {
2098
+ requesters.push(ownerToHex(spec));
2099
+ } catch (err) {
2100
+ throw new ServeUsageError(
2101
+ `--requester ${JSON.stringify(spec)}: ${err instanceof Error ? err.message : String(err)}`
2102
+ );
2103
+ }
2104
+ }
2105
+ const positiveInt = (flag, raw, fallback) => {
2106
+ if (raw === void 0) return fallback;
2107
+ const n = Number(raw);
2108
+ if (!Number.isInteger(n) || n <= 0)
2109
+ throw new ServeUsageError(`${flag} expects a positive integer`);
2110
+ return n;
2111
+ };
2112
+ const concurrency = positiveInt("--concurrency", values.concurrency, 1);
2113
+ const timeoutMs = positiveInt("--timeout", values.timeout, DEFAULT_RUN_TIMEOUT_MS / 1e3) * 1e3;
2114
+ let platforms;
2115
+ if (values.platform && values.platform.length > 0) {
2116
+ platforms = {};
2117
+ for (const spec of values.platform) {
2118
+ const eq = spec.indexOf("=");
2119
+ if (eq <= 0 || eq === spec.length - 1) {
2120
+ throw new ServeUsageError(
2121
+ `--platform expects <label>=<image>, got ${JSON.stringify(spec)}`
2122
+ );
2123
+ }
2124
+ platforms[spec.slice(0, eq)] = spec.slice(eq + 1);
2125
+ }
2126
+ }
2127
+ return {
2128
+ relay: values.relay,
2129
+ repos,
2130
+ requesters,
2131
+ concurrency,
2132
+ timeoutMs,
2133
+ gateway: values.gateway ?? PREFERRED_GATEWAY,
2134
+ ...values["act-bin"] !== void 0 ? { actBin: values["act-bin"] } : {},
2135
+ ...platforms ? { platforms } : {},
2136
+ ...values.workdir !== void 0 ? { workdir: values.workdir } : {},
2137
+ once: values.once,
2138
+ json: values.json
2139
+ };
2140
+ }
2141
+ function estimateRunCost(estimate) {
2142
+ return BigInt(estimate.events) * estimate.rates.eventFee + BigInt(estimate.uploads) * uploadChargeFor(estimate.rates, 64 * 1024);
2143
+ }
2144
+ function makeAffordabilityCheck(args) {
2145
+ const { ctx, env, log } = args;
2146
+ return async (estimate) => {
2147
+ const cost = estimateRunCost(estimate);
2148
+ const store = new ChannelMapStore(resolveChannelPaths(env));
2149
+ const mine = store.list().filter(
2150
+ (record) => record.identity.toLowerCase() === ctx.ownerPubkey.toLowerCase() && record.supersededAt === void 0
2151
+ );
2152
+ if (mine.length > 0) {
2153
+ let best2 = 0n;
2154
+ for (const record of mine) {
2155
+ const watermark = store.readWatermark(record.channelId);
2156
+ if (channelStatus(watermark) !== "open") continue;
2157
+ if (record.depositTotal === void 0) continue;
2158
+ const remaining = BigInt(record.depositTotal) - BigInt(watermark?.cumulativeAmount ?? "0");
2159
+ if (remaining > best2) best2 = remaining;
2160
+ }
2161
+ if (best2 >= cost) return true;
2162
+ log(
2163
+ `[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`
2164
+ );
2165
+ return false;
2166
+ }
2167
+ if (!ctx.money) {
2168
+ log(
2169
+ "[ci] wallet check: no channel recorded and no wallet reader \u2014 refusing"
2170
+ );
2171
+ return false;
2172
+ }
2173
+ let chains;
2174
+ try {
2175
+ chains = await ctx.money.walletChainBalances();
2176
+ } catch (err) {
2177
+ log(
2178
+ `[ci] wallet check: wallet unreadable (${err instanceof Error ? err.message : String(err)}) \u2014 refusing`
2179
+ );
2180
+ return false;
2181
+ }
2182
+ let best = 0n;
2183
+ for (const chain of chains) {
2184
+ if (chain.unreadable) continue;
2185
+ for (const token of chain.tokens) {
2186
+ if (token.symbol !== void 0 && !/usdc/i.test(token.symbol)) continue;
2187
+ const amount = BigInt(token.amount);
2188
+ if (amount > best) best = amount;
2189
+ }
2190
+ }
2191
+ if (best >= cost) return true;
2192
+ log(
2193
+ `[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`
2194
+ );
2195
+ return false;
2196
+ };
2197
+ }
2198
+ function untilStopRequested(signal, firstRun) {
2199
+ return new Promise((resolve2) => {
2200
+ let settled = false;
2201
+ const finish = (reason) => {
2202
+ if (settled) return;
2203
+ settled = true;
2204
+ process.off("SIGINT", onSigint);
2205
+ process.off("SIGTERM", onSigterm);
2206
+ resolve2(reason);
2207
+ };
2208
+ const onSigint = () => finish("SIGINT");
2209
+ const onSigterm = () => finish("SIGTERM");
2210
+ if (signal?.aborted) return finish("abort");
2211
+ signal?.addEventListener("abort", () => finish("abort"), { once: true });
2212
+ process.once("SIGINT", onSigint);
2213
+ process.once("SIGTERM", onSigterm);
2214
+ void firstRun?.then(finish);
2215
+ });
2216
+ }
2217
+ async function runCiServe(args, deps) {
2218
+ const forced = withForcedStandalone(deps);
2219
+ const { io } = forced;
2220
+ let flags;
2221
+ try {
2222
+ const parsed = parseServeArgs(args);
2223
+ if (parsed === "help") {
2224
+ io.out(CI_SERVE_USAGE);
2225
+ return 0;
2226
+ }
2227
+ flags = parsed;
2228
+ } catch (err) {
2229
+ io.err(err instanceof Error ? err.message : String(err));
2230
+ io.err(CI_SERVE_USAGE);
2231
+ return 2;
2232
+ }
2233
+ let runner;
2234
+ if (forced.runner) {
2235
+ runner = forced.runner;
2236
+ } else {
2237
+ const act = new ActRunner({
2238
+ ...flags.actBin !== void 0 ? { actBin: flags.actBin } : {},
2239
+ ...flags.platforms ? { platforms: flags.platforms } : {}
2240
+ });
2241
+ if (act.binary(forced.env) === null) {
2242
+ io.err(
2243
+ "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."
2244
+ );
2245
+ return 1;
2246
+ }
2247
+ runner = act;
2248
+ }
2249
+ let ctx;
2250
+ let handle;
2251
+ try {
2252
+ const session = await loadPaidSession(forced, flags.relay);
2253
+ if (session.path !== "standalone") {
2254
+ throw new Error("rig ci serve requires the standalone paid path");
2255
+ }
2256
+ ctx = session.ctx;
2257
+ const identity = identityReport(ctx);
2258
+ const coordinator = ctx.ownerPubkey.toLowerCase();
2259
+ const stateDir = forced.stateDir ?? defaultCoordinatorStateDir(forced.env, coordinator);
2260
+ const workdir = flags.workdir ?? join4(stateDir, "work");
2261
+ const version = rigVersion();
2262
+ let concludeFirstRun = () => void 0;
2263
+ const firstRun = flags.once ? new Promise((resolve2) => {
2264
+ concludeFirstRun = resolve2;
2265
+ }) : void 0;
2266
+ handle = await startCoordinator({
2267
+ coordinatorPubkey: coordinator,
2268
+ publisher: ctx.publisher,
2269
+ relayUrl: flags.relay,
2270
+ gatewayUrl: flags.gateway,
2271
+ repos: flags.repos,
2272
+ runner,
2273
+ stateDir,
2274
+ workdir,
2275
+ version,
2276
+ concurrency: flags.concurrency,
2277
+ timeoutMs: flags.timeoutMs,
2278
+ acceptedRequesters: flags.requesters,
2279
+ canAfford: forced.canAfford ?? makeAffordabilityCheck({
2280
+ ctx,
2281
+ env: forced.env,
2282
+ log: (line) => io.err(line)
2283
+ }),
2284
+ ...forced.webSocketFactory ? { webSocketFactory: forced.webSocketFactory } : {},
2285
+ ...forced.fetchFn ? { fetchFn: forced.fetchFn } : {},
2286
+ ...forced.resolveSha ? { resolveSha: forced.resolveSha } : {},
2287
+ ...forced.clock ? { clock: forced.clock } : {},
2288
+ ...firstRun ? { onRunConcluded: (run) => concludeFirstRun(run) } : {},
2289
+ log: (line) => io.err(line)
2290
+ });
2291
+ const npub = hexToNpub(coordinator);
2292
+ const repoLabels = flags.repos.map(
2293
+ (r) => `${hexToNpub(r.ownerPubkey)}/${r.repoId}`
2294
+ );
2295
+ if (flags.json) {
2296
+ io.emitJson({
2297
+ command: "ci serve",
2298
+ path: "standalone",
2299
+ identity,
2300
+ coordinator,
2301
+ coordinatorNpub: npub,
2302
+ relay: flags.relay,
2303
+ gateway: flags.gateway,
2304
+ repos: flags.repos,
2305
+ requesters: flags.requesters,
2306
+ runner: { family: runner.family, selectors: runner.selectors },
2307
+ concurrency: flags.concurrency,
2308
+ timeoutSeconds: flags.timeoutMs / 1e3,
2309
+ once: flags.once,
2310
+ stateDir,
2311
+ workdir,
2312
+ secretsKey: handle.secretsKeyPubkey,
2313
+ advertisementId: handle.advertisementId,
2314
+ version
2315
+ });
2316
+ } else {
2317
+ io.out(renderIdentityLine(identity));
2318
+ io.out(`Coordinator: ${npub}`);
2319
+ io.out(`Relay: ${flags.relay}`);
2320
+ io.out(`Serving: ${repoLabels.join(", ")}`);
2321
+ if (flags.requesters.length > 0)
2322
+ io.out(
2323
+ `Requesters: maintainers + ${flags.requesters.map(hexToNpub).join(", ")}`
2324
+ );
2325
+ io.out(
2326
+ `Runner: ${runner.family} (${runner.selectors.join(", ")}); concurrency ${flags.concurrency}, timeout ${flags.timeoutMs / 1e3}s`
2327
+ );
2328
+ io.out(`State: ${stateDir}`);
2329
+ io.out("Maintainers authorize this coordinator with:");
2330
+ io.out(` rig ci request ${npub}`);
2331
+ io.out(
2332
+ flags.once ? "Listening for one run \u2014 stops after it concludes (or Ctrl-C)." : "Listening \u2014 press Ctrl-C to stop."
2333
+ );
2334
+ }
2335
+ const reason = await untilStopRequested(forced.signal, firstRun);
2336
+ io.err(
2337
+ typeof reason === "string" ? `rig ci serve: ${reason} \u2014 stopping (active runs conclude cancelled)` : `rig ci serve: run ${reason.runId.slice(0, 8)} concluded ${reason.conclusion} (result ${reason.resultEventId.slice(0, 8)}) \u2014 --once, stopping`
2338
+ );
2339
+ await handle.stop();
2340
+ handle = void 0;
2341
+ return 0;
2342
+ } catch (err) {
2343
+ if (handle) await handle.stop().catch(() => void 0);
2344
+ return emitCliError(io, flags.json, "ci serve", err);
2345
+ } finally {
2346
+ if (ctx) {
2347
+ try {
2348
+ await ctx.stop();
2349
+ } catch {
2350
+ }
2351
+ }
2352
+ }
2353
+ }
2354
+ export {
2355
+ CI_SERVE_USAGE,
2356
+ estimateRunCost,
2357
+ makeAffordabilityCheck,
2358
+ runCiServe
2359
+ };
2360
+ //# sourceMappingURL=ci-serve-P4HOLAMP.js.map