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.

79 lines · 3.1 KBRaw History
1import type { JobCompletion, JobSpec } from "@diffinery/shared";
2import { LogBatcher, RunnerApi } from "./api.js";
3import { JobRunner, errMsg } from "./job.js";
4
5export interface AgentOptions {
6 /** `${apiUrl}/runner/jobs/${jobId}`, or null for offline mode (logs to stdout, no reports). */
7 apiBase: string | null;
8 workRoot: string;
9 toolCache?: string;
10 echoLogs?: boolean;
11 /** Install a SIGTERM/SIGINT handler that cancels the job. */
12 handleSignals?: boolean;
13 /**
14 * Hosted runners: on SIGTERM (a platform rollout replacing this container), keep
15 * running the job for up to this long before cancelling it. SIGINT or a second
16 * SIGTERM still cancels at once.
17 */
18 drainOnSigtermMs?: number;
19 /** Exposes the runner (e.g. so tests can cancel). */
20 onRunner?: (r: JobRunner) => void;
21 logIntervalMs?: number;
22 baseEnv?: NodeJS.ProcessEnv;
23}
24
25export function apiBaseFor(apiUrl: string | undefined | null, jobId: string): string | null {
26 if (!apiUrl) return null;
27 return `${apiUrl.replace(/\/$/, "")}/runner/jobs/${encodeURIComponent(jobId)}`;
28}
29
30/** Runs one job end-to-end: execute steps, ship logs, POST completion. */
31export async function runAgentJob(spec: JobSpec, o: AgentOptions): Promise<JobCompletion> {
32 const api = new RunnerApi(o.apiBase, spec.token);
33 const batcher = api.online
34 ? new LogBatcher((lines) => api.postLogs(lines), {
35 intervalMs: o.logIntervalMs ?? 500,
36 onError: (e) => process.stderr.write(`[diffinery-runner] log upload failed: ${errMsg(e)}\n`),
37 })
38 : null;
39 const runner = new JobRunner(spec, api, null, { workRoot: o.workRoot, toolCache: o.toolCache, echoLogs: o.echoLogs ?? !api.online, baseEnv: o.baseEnv }, batcher);
40 o.onRunner?.(runner);
41 const onSignal = (sig: string) => {
42 runner.logger.line(-1, `##[error]Received ${sig}; cancelling the job.`);
43 runner.cancel("cancelled");
44 };
45 let draining: NodeJS.Timeout | null = null;
46 const sigterm = () => {
47 if (!o.drainOnSigtermMs || draining) return onSignal("SIGTERM");
48 const minutes = Math.round(o.drainOnSigtermMs / 60_000);
49 runner.logger.line(-1, `##[warning]This runner is being replaced by a new version. The job keeps running for up to ${minutes} minutes before it is cancelled.`);
50 draining = setTimeout(() => onSignal("SIGTERM"), o.drainOnSigtermMs);
51 draining.unref();
52 };
53 const sigint = () => onSignal("SIGINT");
54 if (o.handleSignals) {
55 process.on("SIGTERM", sigterm);
56 process.on("SIGINT", sigint);
57 }
58 let completion: JobCompletion;
59 try {
60 completion = await runner.run();
61 } catch (e) {
62 completion = { conclusion: "failure", outputs: {}, failure: { message: `Runner error: ${errMsg(e)}` } };
63 }
64 if (batcher) {
65 const ok = await batcher.close(120_000);
66 if (!ok) process.stderr.write(`[diffinery-runner] gave up shipping ${batcher.pending} log lines\n`);
67 }
68 try {
69 await api.complete(completion);
70 } catch (e) {
71 process.stderr.write(`[diffinery-runner] failed to report completion: ${errMsg(e)}\n`);
72 }
73 if (draining) clearTimeout(draining);
74 if (o.handleSignals) {
75 process.off("SIGTERM", sigterm);
76 process.off("SIGINT", sigint);
77 }
78 return completion;
79}