Files
pikasTech-unidesk/scripts/src/jobs.ts
T

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",
},
};
}