192 lines
4.7 KiB
JavaScript
192 lines
4.7 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
import { resolveWorkspaceRoot } from "./workspace.mjs";
|
|
|
|
const STATE_VERSION = 1;
|
|
const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA";
|
|
const FALLBACK_STATE_ROOT_DIR = path.join(os.tmpdir(), "codex-companion");
|
|
const STATE_FILE_NAME = "state.json";
|
|
const JOBS_DIR_NAME = "jobs";
|
|
const MAX_JOBS = 50;
|
|
|
|
function nowIso() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function defaultState() {
|
|
return {
|
|
version: STATE_VERSION,
|
|
config: {
|
|
stopReviewGate: false
|
|
},
|
|
jobs: []
|
|
};
|
|
}
|
|
|
|
export function resolveStateDir(cwd) {
|
|
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
|
let canonicalWorkspaceRoot = workspaceRoot;
|
|
try {
|
|
canonicalWorkspaceRoot = fs.realpathSync.native(workspaceRoot);
|
|
} catch {
|
|
canonicalWorkspaceRoot = workspaceRoot;
|
|
}
|
|
|
|
const slugSource = path.basename(workspaceRoot) || "workspace";
|
|
const slug = slugSource.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace";
|
|
const hash = createHash("sha256").update(canonicalWorkspaceRoot).digest("hex").slice(0, 16);
|
|
const pluginDataDir = process.env[PLUGIN_DATA_ENV];
|
|
const stateRoot = pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR;
|
|
return path.join(stateRoot, `${slug}-${hash}`);
|
|
}
|
|
|
|
export function resolveStateFile(cwd) {
|
|
return path.join(resolveStateDir(cwd), STATE_FILE_NAME);
|
|
}
|
|
|
|
export function resolveJobsDir(cwd) {
|
|
return path.join(resolveStateDir(cwd), JOBS_DIR_NAME);
|
|
}
|
|
|
|
export function ensureStateDir(cwd) {
|
|
fs.mkdirSync(resolveJobsDir(cwd), { recursive: true });
|
|
}
|
|
|
|
export function loadState(cwd) {
|
|
const stateFile = resolveStateFile(cwd);
|
|
if (!fs.existsSync(stateFile)) {
|
|
return defaultState();
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
|
return {
|
|
...defaultState(),
|
|
...parsed,
|
|
config: {
|
|
...defaultState().config,
|
|
...(parsed.config ?? {})
|
|
},
|
|
jobs: Array.isArray(parsed.jobs) ? parsed.jobs : []
|
|
};
|
|
} catch {
|
|
return defaultState();
|
|
}
|
|
}
|
|
|
|
function pruneJobs(jobs) {
|
|
return [...jobs]
|
|
.sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")))
|
|
.slice(0, MAX_JOBS);
|
|
}
|
|
|
|
function removeFileIfExists(filePath) {
|
|
if (filePath && fs.existsSync(filePath)) {
|
|
fs.unlinkSync(filePath);
|
|
}
|
|
}
|
|
|
|
export function saveState(cwd, state) {
|
|
const previousJobs = loadState(cwd).jobs;
|
|
ensureStateDir(cwd);
|
|
const nextJobs = pruneJobs(state.jobs ?? []);
|
|
const nextState = {
|
|
version: STATE_VERSION,
|
|
config: {
|
|
...defaultState().config,
|
|
...(state.config ?? {})
|
|
},
|
|
jobs: nextJobs
|
|
};
|
|
|
|
const retainedIds = new Set(nextJobs.map((job) => job.id));
|
|
for (const job of previousJobs) {
|
|
if (retainedIds.has(job.id)) {
|
|
continue;
|
|
}
|
|
removeJobFile(resolveJobFile(cwd, job.id));
|
|
removeFileIfExists(job.logFile);
|
|
}
|
|
|
|
fs.writeFileSync(resolveStateFile(cwd), `${JSON.stringify(nextState, null, 2)}\n`, "utf8");
|
|
return nextState;
|
|
}
|
|
|
|
export function updateState(cwd, mutate) {
|
|
const state = loadState(cwd);
|
|
mutate(state);
|
|
return saveState(cwd, state);
|
|
}
|
|
|
|
export function generateJobId(prefix = "job") {
|
|
const random = Math.random().toString(36).slice(2, 8);
|
|
return `${prefix}-${Date.now().toString(36)}-${random}`;
|
|
}
|
|
|
|
export function upsertJob(cwd, jobPatch) {
|
|
return updateState(cwd, (state) => {
|
|
const timestamp = nowIso();
|
|
const existingIndex = state.jobs.findIndex((job) => job.id === jobPatch.id);
|
|
if (existingIndex === -1) {
|
|
state.jobs.unshift({
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
...jobPatch
|
|
});
|
|
return;
|
|
}
|
|
state.jobs[existingIndex] = {
|
|
...state.jobs[existingIndex],
|
|
...jobPatch,
|
|
updatedAt: timestamp
|
|
};
|
|
});
|
|
}
|
|
|
|
export function listJobs(cwd) {
|
|
return loadState(cwd).jobs;
|
|
}
|
|
|
|
export function setConfig(cwd, key, value) {
|
|
return updateState(cwd, (state) => {
|
|
state.config = {
|
|
...state.config,
|
|
[key]: value
|
|
};
|
|
});
|
|
}
|
|
|
|
export function getConfig(cwd) {
|
|
return loadState(cwd).config;
|
|
}
|
|
|
|
export function writeJobFile(cwd, jobId, payload) {
|
|
ensureStateDir(cwd);
|
|
const jobFile = resolveJobFile(cwd, jobId);
|
|
fs.writeFileSync(jobFile, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
return jobFile;
|
|
}
|
|
|
|
export function readJobFile(jobFile) {
|
|
return JSON.parse(fs.readFileSync(jobFile, "utf8"));
|
|
}
|
|
|
|
function removeJobFile(jobFile) {
|
|
if (fs.existsSync(jobFile)) {
|
|
fs.unlinkSync(jobFile);
|
|
}
|
|
}
|
|
|
|
export function resolveJobLogFile(cwd, jobId) {
|
|
ensureStateDir(cwd);
|
|
return path.join(resolveJobsDir(cwd), `${jobId}.log`);
|
|
}
|
|
|
|
export function resolveJobFile(cwd, jobId) {
|
|
ensureStateDir(cwd);
|
|
return path.join(resolveJobsDir(cwd), `${jobId}.json`);
|
|
}
|