Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

102 lines · 4.7 KBRaw History
1import type { JobCompletion, JobSpec } from "@diffinery/shared";
2import { LogBatcher, RunnerApi } from "./api.js";
3import { JobRunner, errMsg } from "./job.js";
4import { UsageSampler, describeUsage } from "./usage.js";
5
6export interface AgentOptions {
7 /** `${apiUrl}/runner/jobs/${jobId}`, or null for offline mode (logs to stdout, no reports). */
8 apiBase: string | null;
9 workRoot: string;
10 toolCache?: string;
11 echoLogs?: boolean;
12 /**
13 * Install a SIGTERM/SIGINT handler that cancels the job: the step under way stops and the steps that
14 * still run after a cancel (`if: always()`, `if: cancelled()`, post steps) run. A second signal stops them too.
15 */
16 handleSignals?: boolean;
17 /**
18 * Hosted runners: on SIGTERM (a platform rollout replacing this container), keep
19 * running the job for up to this long before cancelling it. SIGINT or a second
20 * SIGTERM still cancels at once.
21 */
22 drainOnSigtermMs?: number;
23 /** Exposes the runner (e.g. so tests can cancel). */
24 onRunner?: (r: JobRunner) => void;
25 logIntervalMs?: number;
26 baseEnv?: NodeJS.ProcessEnv;
27 /**
28 * Measure what the job uses of the machine and report it with the result (hosted runners, where the
29 * container is the job's own machine; on a shared self-hosted machine the numbers would be the host's).
30 */
31 measureUsage?: boolean | UsageSampler;
32 /** Hosted runners: start the Docker daemon as the job begins when the job will use Docker (docker.ts prewarm). */
33 prewarmDocker?: boolean;
34 /** Hosted runners: start installing the job's Go or Java toolchain as the job begins (prefetch.ts). */
35 prefetchTools?: boolean;
36}
37
38export function apiBaseFor(apiUrl: string | undefined | null, jobId: string): string | null {
39 if (!apiUrl) return null;
40 return `${apiUrl.replace(/\/$/, "")}/runner/jobs/${encodeURIComponent(jobId)}`;
41}
42
43/** Runs one job end-to-end: execute steps, ship logs, POST completion. */
44export async function runAgentJob(spec: JobSpec, o: AgentOptions): Promise<JobCompletion> {
45 const api = new RunnerApi(o.apiBase, spec.token);
46 const batcher = api.online
47 ? new LogBatcher((lines) => api.postLogs(lines), {
48 intervalMs: o.logIntervalMs ?? 500,
49 onError: (e) => process.stderr.write(`[diffinery-runner] log upload failed: ${errMsg(e)}\n`),
50 })
51 : null;
52 const runner = new JobRunner(spec, api, null, { workRoot: o.workRoot, toolCache: o.toolCache, echoLogs: o.echoLogs ?? !api.online, baseEnv: o.baseEnv, prewarmDocker: o.prewarmDocker, prefetchTools: o.prefetchTools }, batcher);
53 o.onRunner?.(runner);
54 const onSignal = (sig: string) => {
55 runner.logger.line(-1, `##[error]Received ${sig}; cancelling the job.`);
56 // While the platform replaces this runner, its container stops soon: no steps run after the cancel.
57 runner.cancel("cancelled", runner.replacing ? 0 : undefined);
58 };
59 let draining: NodeJS.Timeout | null = null;
60 const sigterm = () => {
61 if (!o.drainOnSigtermMs || draining) return onSignal("SIGTERM");
62 const minutes = Math.round(o.drainOnSigtermMs / 60_000);
63 runner.replacing = true;
64 runner.logger.line(-1, `##[warning]The platform is stopping this runner (a rollout replacing it with a new version, or the platform moving it). The job keeps running for up to ${minutes} minutes; if the platform stops a step or the time runs out, the job ends, and Diffinery starts it again on a new runner when doing so can't repeat anything outside the run.`);
65 draining = setTimeout(() => onSignal("SIGTERM"), o.drainOnSigtermMs);
66 draining.unref();
67 };
68 const sigint = () => onSignal("SIGINT");
69 if (o.handleSignals) {
70 process.on("SIGTERM", sigterm);
71 process.on("SIGINT", sigint);
72 }
73 const sampler = o.measureUsage instanceof UsageSampler ? o.measureUsage : o.measureUsage ? new UsageSampler() : null;
74 sampler?.start();
75 let completion: JobCompletion;
76 try {
77 completion = await runner.run();
78 } catch (e) {
79 completion = { conclusion: "failure", outputs: {}, failure: { message: `Runner error: ${errMsg(e)}` } };
80 }
81 const usage = sampler?.stop();
82 if (usage) {
83 completion.usage = usage;
84 runner.logger.line(-1, describeUsage(usage));
85 }
86 if (batcher) {
87 // After a SIGTERM the platform kills the container soon, so the result must not wait long on logs.
88 const ok = await batcher.close(draining ? 30_000 : 120_000);
89 if (!ok) process.stderr.write(`[diffinery-runner] gave up shipping ${batcher.pending} log lines\n`);
90 }
91 try {
92 await api.complete(completion);
93 } catch (e) {
94 process.stderr.write(`[diffinery-runner] failed to report completion: ${errMsg(e)}\n`);
95 }
96 if (draining) clearTimeout(draining);
97 if (o.handleSignals) {
98 process.off("SIGTERM", sigterm);
99 process.off("SIGINT", sigint);
100 }
101 return completion;
102}