import { spawn, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { repoRoot, rootPath } from "./config"; import { runCommandToFiles, tailFile } from "./command"; export type JobStatus = "queued" | "running" | "succeeded" | "failed"; export interface JobRecord { id: string; name: string; status: JobStatus; command: string[]; cwd: string; runner: "local" | "docker"; runnerPid?: number | null; runnerContainer?: string | null; createdAt: string; startedAt: string | null; finishedAt: string | null; exitCode: number | null; stdoutFile: string; stderrFile: string; note: string; } export interface StartJobOptions { runner?: "local" | "docker"; dockerImage?: string; } function jobsDir(): string { const dir = rootPath(".state", "jobs"); mkdirSync(dir, { recursive: true }); return dir; } function jobPath(id: string): string { return join(jobsDir(), `${id}.json`); } function writeJob(job: JobRecord): void { writeFileSync(jobPath(job.id), `${JSON.stringify(job, null, 2)}\n`, "utf8"); } export function readJob(id: string): JobRecord { const path = jobPath(id); if (!existsSync(path)) throw new Error(`job not found: ${id}`); return JSON.parse(readFileSync(path, "utf8")) as JobRecord; } export function listJobs(): JobRecord[] { return readdirSync(jobsDir()) .filter((name) => name.endsWith(".json")) .map((name) => JSON.parse(readFileSync(join(jobsDir(), name), "utf8")) as JobRecord) .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); } export function startJob(name: string, command: string[], note: string, options: StartJobOptions = {}): JobRecord { const id = `${name}_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${Math.random().toString(16).slice(2, 8)}`; const stdoutFile = rootPath(".state", "jobs", `${id}.stdout.log`); const stderrFile = rootPath(".state", "jobs", `${id}.stderr.log`); const runner = options.runner ?? "local"; const job: JobRecord = { id, name, status: "queued", command, cwd: repoRoot, runner, runnerPid: null, runnerContainer: null, createdAt: new Date().toISOString(), startedAt: null, finishedAt: null, exitCode: null, stdoutFile, stderrFile, note, }; writeJob(job); if (runner === "docker") { const containerName = `unidesk-job-runner-${id}`.replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 120); job.runnerContainer = containerName; writeJob(job); const dockerArgs = [ "run", "-d", "--rm", "--name", containerName, "-v", "/var/run/docker.sock:/var/run/docker.sock", "-v", `${repoRoot}:${repoRoot}`, "-w", repoRoot, "--entrypoint", "bun", options.dockerImage ?? "unidesk-code-queue:latest", rootPath("scripts", "cli.ts"), "internal", "run-job", id, ]; const result = spawnSync("docker", dockerArgs, { cwd: repoRoot, encoding: "utf8" }); if (result.status !== 0) { job.status = "failed"; job.startedAt = new Date().toISOString(); job.finishedAt = new Date().toISOString(); job.exitCode = result.status ?? 127; writeFileSync(stderrFile, result.stderr || result.error?.message || "failed to start docker job runner\n", "utf8"); writeFileSync(stdoutFile, result.stdout || "", "utf8"); writeJob(job); } return job; } const child = spawn(process.execPath, [rootPath("scripts", "cli.ts"), "internal", "run-job", id], { cwd: repoRoot, detached: true, stdio: "ignore", env: process.env, }); job.runnerPid = child.pid ?? null; writeJob(job); child.unref(); return job; } export async function runJob(id: string): Promise { const job = readJob(id); job.status = "running"; job.runnerPid = process.pid; job.startedAt = new Date().toISOString(); writeJob(job); const exitCode = await runCommandToFiles(job.command, job.cwd, job.stdoutFile, job.stderrFile); job.exitCode = exitCode; job.status = exitCode === 0 ? "succeeded" : "failed"; job.finishedAt = new Date().toISOString(); writeJob(job); return job; } export function jobWithTail(job: JobRecord, maxBytes = 12000): JobRecord & { tailPolicy: { requestedTailBytes: number; stdoutBytes: number; stderrBytes: number; stdoutTruncated: boolean; stderrTruncated: boolean; fullLogPaths: { stdoutFile: string; stderrFile: string }; }; stdoutTail: string; stderrTail: string; } { const stdoutBytes = existsSync(job.stdoutFile) ? statSync(job.stdoutFile).size : 0; const stderrBytes = existsSync(job.stderrFile) ? statSync(job.stderrFile).size : 0; return { ...job, tailPolicy: { requestedTailBytes: maxBytes, stdoutBytes, stderrBytes, stdoutTruncated: stdoutBytes > maxBytes, stderrTruncated: stderrBytes > maxBytes, fullLogPaths: { stdoutFile: job.stdoutFile, stderrFile: job.stderrFile }, }, stdoutTail: tailFile(job.stdoutFile, maxBytes), stderrTail: tailFile(job.stderrFile, maxBytes), }; } export interface JobListOptions { limit?: number; includeCommand?: boolean; } export function listJobsSummary(options: JobListOptions = {}): unknown { const limit = Math.max(1, Math.floor(options.limit ?? 50)); const jobs = listJobs(); const returned = jobs.slice(0, limit).map((job) => ({ id: job.id, name: job.name, status: job.status, runner: job.runner, runnerPid: job.runnerPid ?? null, runnerContainer: job.runnerContainer ?? null, createdAt: job.createdAt, startedAt: job.startedAt, finishedAt: job.finishedAt, exitCode: job.exitCode, note: job.note, stdoutFile: job.stdoutFile, stderrFile: job.stderrFile, ...(options.includeCommand === true ? { command: job.command, cwd: job.cwd } : {}), })); return { jobs: returned, total: jobs.length, returned: returned.length, limit, truncated: jobs.length > returned.length, disclosure: { defaultLimit: 50, nextCommand: jobs.length > returned.length ? `bun scripts/cli.ts job list --limit ${Math.min(jobs.length, limit * 2)}` : null, includeCommandCommand: "bun scripts/cli.ts job list --include-command", statusCommand: "bun scripts/cli.ts job status --tail-bytes 12000", }, }; }