211 lines
6.3 KiB
TypeScript
211 lines
6.3 KiB
TypeScript
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<JobRecord> {
|
|
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 <jobId> --tail-bytes 12000",
|
|
},
|
|
};
|
|
}
|