From ce764c3ca43ed6821750fcd6052b286ba7d3b95d Mon Sep 17 00:00:00 2001 From: Jianke LIN Date: Sat, 25 Jul 2026 22:59:29 +0200 Subject: [PATCH] fix cross-workspace job lookup --- plugins/codex/scripts/lib/job-control.mjs | 105 +++++++++++++--- plugins/codex/scripts/lib/state.mjs | 51 +++++++- tests/job-control.test.mjs | 143 ++++++++++++++++++++++ 3 files changed, 280 insertions(+), 19 deletions(-) create mode 100644 tests/job-control.test.mjs diff --git a/plugins/codex/scripts/lib/job-control.mjs b/plugins/codex/scripts/lib/job-control.mjs index ad152c157..bcfa7c9fe 100644 --- a/plugins/codex/scripts/lib/job-control.mjs +++ b/plugins/codex/scripts/lib/job-control.mjs @@ -1,7 +1,9 @@ import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; import { getSessionRuntimeStatus } from "./codex.mjs"; -import { getConfig, listJobs, readJobFile, resolveJobFile } from "./state.mjs"; +import { getConfig, listJobs, listJobsAcrossWorkspaces, readJobFile, resolveJobFile } from "./state.mjs"; import { SESSION_ID_ENV } from "./tracked-jobs.mjs"; import { resolveWorkspaceRoot } from "./workspace.mjs"; @@ -188,7 +190,7 @@ export function readStoredJob(workspaceRoot, jobId) { return readJobFile(jobFile); } -function matchJobReference(jobs, reference, predicate = () => true) { +function matchJobReference(jobs, reference, predicate = () => true, options = {}) { const filtered = jobs.filter(predicate); if (!reference) { return filtered[0] ?? null; @@ -207,9 +209,58 @@ function matchJobReference(jobs, reference, predicate = () => true) { throw new Error(`Job reference "${reference}" is ambiguous. Use a longer job id.`); } + if (options.allowMissing) { + return null; + } throw new Error(`No job found for "${reference}". Run /codex:status to list known jobs.`); } +function matchJobAcrossWorkspaces(reference, predicate = () => true, options = {}) { + if (!reference) { + return null; + } + + const entries = []; + const seen = new Set(); + const addJob = (job, workspaceRoot) => { + if (!job || !workspaceRoot || !predicate(job)) { + return; + } + const key = `${pathKey(workspaceRoot)}\0${job.id}`; + if (seen.has(key)) { + return; + } + seen.add(key); + entries.push({ workspaceRoot, job }); + }; + + for (const job of options.currentJobs ?? []) { + addJob(job, options.currentWorkspaceRoot ?? job.workspaceRoot); + } + for (const job of listJobsAcrossWorkspaces()) { + addJob(job, job.workspaceRoot); + } + + const exact = entries.find((entry) => entry.job.id === reference); + if (exact) { + return exact; + } + + const prefixMatches = entries.filter((entry) => entry.job.id.startsWith(reference)); + if (prefixMatches.length === 1) { + return prefixMatches[0]; + } + if (prefixMatches.length > 1) { + throw new Error(`Job reference "${reference}" is ambiguous. Use a longer job id.`); + } + return null; +} + +function pathKey(value) { + const resolved = path.resolve(value); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + export function buildStatusSnapshot(cwd, options = {}) { const workspaceRoot = resolveWorkspaceRoot(cwd); const config = getConfig(workspaceRoot); @@ -242,33 +293,55 @@ export function buildStatusSnapshot(cwd, options = {}) { export function buildSingleJobSnapshot(cwd, reference, options = {}) { const workspaceRoot = resolveWorkspaceRoot(cwd); const jobs = sortJobsNewestFirst(listJobs(workspaceRoot)); - const selected = matchJobReference(jobs, reference); - if (!selected) { + const matched = matchJobAcrossWorkspaces(reference, () => true, { + currentWorkspaceRoot: workspaceRoot, + currentJobs: jobs + }); + if (!matched) { throw new Error(`No job found for "${reference}". Run /codex:status to inspect known jobs.`); } return { - workspaceRoot, - job: enrichJob(selected, { maxProgressLines: options.maxProgressLines }) + workspaceRoot: matched.workspaceRoot, + job: enrichJob(matched.job, { maxProgressLines: options.maxProgressLines }) }; } export function resolveResultJob(cwd, reference) { const workspaceRoot = resolveWorkspaceRoot(cwd); const jobs = sortJobsNewestFirst(reference ? listJobs(workspaceRoot) : filterJobsForCurrentSession(listJobs(workspaceRoot))); - const selected = matchJobReference( - jobs, - reference, - (job) => job.status === "completed" || job.status === "failed" || job.status === "cancelled" - ); - - if (selected) { - return { workspaceRoot, job: selected }; + if (!reference) { + const selected = matchJobReference( + jobs, + null, + (job) => job.status === "completed" || job.status === "failed" || job.status === "cancelled", + { allowMissing: true } + ); + if (selected) { + return { workspaceRoot, job: selected }; + } + } else { + const finished = matchJobAcrossWorkspaces( + reference, + (job) => job.status === "completed" || job.status === "failed" || job.status === "cancelled", + { currentWorkspaceRoot: workspaceRoot, currentJobs: jobs } + ); + if (finished) { + return finished; + } } - const active = matchJobReference(jobs, reference, (job) => job.status === "queued" || job.status === "running"); + const active = reference + ? matchJobAcrossWorkspaces( + reference, + (job) => job.status === "queued" || job.status === "running", + { currentWorkspaceRoot: workspaceRoot, currentJobs: jobs } + ) + : null; if (active) { - throw new Error(`Job ${active.id} is still ${active.status}. Check /codex:status and try again once it finishes.`); + throw new Error( + `Job ${active.job.id} is still ${active.job.status}. Check /codex:status and try again once it finishes.` + ); } if (reference) { diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs index 2da23498f..3851d3ff6 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/codex/scripts/lib/state.mjs @@ -12,6 +12,11 @@ const STATE_FILE_NAME = "state.json"; const JOBS_DIR_NAME = "jobs"; const MAX_JOBS = 50; +function resolveStateRoot() { + const pluginDataDir = process.env[PLUGIN_DATA_ENV]; + return pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR; +} + function nowIso() { return new Date().toISOString(); } @@ -38,9 +43,7 @@ export function resolveStateDir(cwd) { 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}`); + return path.join(resolveStateRoot(), `${slug}-${hash}`); } export function resolveStateFile(cwd) { @@ -150,6 +153,48 @@ export function listJobs(cwd) { return loadState(cwd).jobs; } +export function listJobsAcrossWorkspaces() { + const stateRoot = resolveStateRoot(); + if (!fs.existsSync(stateRoot)) { + return []; + } + + const jobs = []; + for (const entry of fs.readdirSync(stateRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + const stateDir = path.join(stateRoot, entry.name); + const stateFile = path.join(stateDir, STATE_FILE_NAME); + if (!fs.existsSync(stateFile)) { + continue; + } + + try { + const parsed = JSON.parse(fs.readFileSync(stateFile, "utf8")); + for (const job of Array.isArray(parsed.jobs) ? parsed.jobs : []) { + if (!job || typeof job.id !== "string" || typeof job.workspaceRoot !== "string") { + continue; + } + + // A state file is only authoritative for its own workspace. Do not + // trust a record that points at another directory merely because it + // was found beneath the shared plugin-data root. + if (path.resolve(resolveStateDir(job.workspaceRoot)) !== path.resolve(stateDir)) { + continue; + } + jobs.push(job); + } + } catch { + // Match loadState(): one damaged workspace index must not hide healthy + // job records belonging to other workspaces. + } + } + + return jobs; +} + export function setConfig(cwd, key, value) { return updateState(cwd, (state) => { state.config = { diff --git a/tests/job-control.test.mjs b/tests/job-control.test.mjs new file mode 100644 index 000000000..af7984d25 --- /dev/null +++ b/tests/job-control.test.mjs @@ -0,0 +1,143 @@ +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildSingleJobSnapshot, + readStoredJob, + resolveResultJob +} from "../plugins/codex/scripts/lib/job-control.mjs"; +import { resolveStateFile, saveState, writeJobFile } from "../plugins/codex/scripts/lib/state.mjs"; +import { initGitRepo, makeTempDir } from "./helpers.mjs"; + +function completedJob(id, workspaceRoot) { + return { + id, + kind: "task", + jobClass: "task", + title: "Codex Task", + workspaceRoot, + status: "completed", + createdAt: "2026-07-25T10:00:00.000Z", + updatedAt: "2026-07-25T10:01:00.000Z" + }; +} + +function saveCompletedJob(workspaceRoot, job) { + saveState(workspaceRoot, { + version: 1, + config: { stopReviewGate: false }, + jobs: [job] + }); + writeJobFile(workspaceRoot, job.id, { + ...job, + result: { rawOutput: `result for ${job.id}` } + }); +} + +test("explicit status and result lookups find a job after cwd moves to another repository", () => { + const previousPluginData = process.env.CLAUDE_PLUGIN_DATA; + const pluginData = makeTempDir(); + const originalWorkspace = makeTempDir(); + const currentWorkspace = makeTempDir(); + process.env.CLAUDE_PLUGIN_DATA = pluginData; + + try { + initGitRepo(originalWorkspace); + initGitRepo(currentWorkspace); + const job = completedJob("task-cross-workspace-1234", originalWorkspace); + saveCompletedJob(originalWorkspace, job); + + const status = buildSingleJobSnapshot(currentWorkspace, job.id); + assert.equal(status.workspaceRoot, originalWorkspace); + assert.equal(status.job.id, job.id); + + const result = resolveResultJob(currentWorkspace, "task-cross-workspace"); + assert.equal(result.workspaceRoot, originalWorkspace); + assert.equal(result.job.id, job.id); + assert.equal(readStoredJob(result.workspaceRoot, result.job.id).result.rawOutput, `result for ${job.id}`); + } finally { + if (previousPluginData == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginData; + } + } +}); + +test("cross-workspace lookup rejects ambiguous prefixes", () => { + const previousPluginData = process.env.CLAUDE_PLUGIN_DATA; + const pluginData = makeTempDir(); + const firstWorkspace = makeTempDir(); + const secondWorkspace = makeTempDir(); + const currentWorkspace = makeTempDir(); + process.env.CLAUDE_PLUGIN_DATA = pluginData; + + try { + for (const workspace of [firstWorkspace, secondWorkspace, currentWorkspace]) { + initGitRepo(workspace); + } + saveCompletedJob(firstWorkspace, completedJob("task-shared-prefix-one", firstWorkspace)); + saveCompletedJob(secondWorkspace, completedJob("task-shared-prefix-two", secondWorkspace)); + + assert.throws( + () => buildSingleJobSnapshot(currentWorkspace, "task-shared-prefix"), + /Job reference "task-shared-prefix" is ambiguous/ + ); + + const exactJob = completedJob("task-shared-prefix", currentWorkspace); + saveCompletedJob(currentWorkspace, exactJob); + const exact = buildSingleJobSnapshot(currentWorkspace, exactJob.id); + assert.equal(path.normalize(exact.workspaceRoot), path.normalize(currentWorkspace)); + assert.equal(exact.job.id, exactJob.id); + } finally { + if (previousPluginData == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginData; + } + } +}); + +test("cross-workspace lookup ignores records stored under the wrong workspace key", () => { + const previousPluginData = process.env.CLAUDE_PLUGIN_DATA; + const pluginData = makeTempDir(); + const claimedWorkspace = makeTempDir(); + const storageWorkspace = makeTempDir(); + const currentWorkspace = makeTempDir(); + process.env.CLAUDE_PLUGIN_DATA = pluginData; + + try { + for (const workspace of [claimedWorkspace, storageWorkspace, currentWorkspace]) { + initGitRepo(workspace); + } + const forgedJob = completedJob("task-mismatched-workspace", claimedWorkspace); + const stateFile = resolveStateFile(storageWorkspace); + fs.mkdirSync(path.dirname(stateFile), { recursive: true }); + fs.writeFileSync( + stateFile, + `${JSON.stringify( + { + version: 1, + config: { stopReviewGate: false }, + jobs: [forgedJob] + }, + null, + 2 + )}\n`, + "utf8" + ); + + assert.throws( + () => buildSingleJobSnapshot(currentWorkspace, forgedJob.id), + /No job found for "task-mismatched-workspace"/ + ); + } finally { + if (previousPluginData == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginData; + } + } +});