// Pure job-board logic — schema, dispatch selection, PR parsing, status derivation. // Kept free of I/O and shared state so it is testable in isolation; the stateful // dispatcher (timers, spawning, git) lives in server/jobs.js. import { randomBytes } from 'crypto'; import { basename, dirname } from 'path'; import { nextCronIso, parseCron } from './cron.js'; import { parseCommand } from './helpers.js'; // Every state a job can be in. A job's *state* is workflow position only — // "the agent is stuck waiting for you" is deliberately NOT a state here (see // deriveJobStatus): it is a live property of the agent, not a place the card // moves to. // // `done` is the one state with no column. A job whose PR has merged is finished // work, and leaving its card on the board means the Review column slowly fills // with things nobody has to look at again — the column stops meaning "needs // your review". The job itself is kept, not deleted: it is the record of what // an agent did and where the PR is, reachable through the Finished jobs view. export const JOB_STATES = ['todo', 'in-progress', 'review', 'done']; // What each state is called when it is written out rather than drawn. The // board's own columns carry these labels in public/modules/jobs.js, which the // browser cannot import from here (only public/ is served), so a test keeps // the two in step. `done` has no column — it is the Finished jobs archive. export const STATE_LABELS = { 'todo': 'To do', 'in-progress': 'In progress', 'review': 'Review', 'done': 'Finished', }; // What kind of work a card is. // // one-time dispatched once; its agent reports back with finish_job (or the // board spots its PR), it waits in Review, and leaves at Done. // scheduled a SCHEDULE, not a job: it stays in To do and is never // dispatched itself. Each time it comes due it posts a one-time // run card (scheduleId -> the schedule), and that run goes through // the one-time lifecycle like any other card. See canFire for // when a due schedule holds off instead. // // Cards written before this existed have no `type` at all, so every read goes // through jobType() rather than touching job.type directly: a missing type is // one-time, which is what those cards have always been. export const JOB_TYPES = ['one-time', 'scheduled']; export const DEFAULT_JOB_TYPE = 'one-time'; export function jobType(job) { return job && job.type === 'scheduled' ? 'scheduled' : DEFAULT_JOB_TYPE; } export function jobAgent(job) { return isValidJobAgent(job && job.agent) ? job.agent : DEFAULT_JOB_AGENT; } export function isScheduled(job) { return jobType(job) === 'scheduled'; } // Whether a one-time card's work ends in a pull request. Not every task is a // code change — research, an investigation, an ops chore — and a card that // demanded a PR of those pushed its agent into inventing one. Cards written // before this existed have no field, and they were all written to produce a // PR, so a missing field reads as true. // // On a schedule it is what its runs get, and there the default is false: a // recurring job was never expected to open a PR (a report, a check), so it has // to be asked for. export function jobRequiresPr(job) { return isScheduled(job) ? job?.requiresPr === true : job?.requiresPr !== false; } // What a card gets when nobody said: see jobRequiresPr. export function defaultRequiresPr(type) { return type !== 'scheduled'; } // Defaults for the dispatcher. Exported so the UI can show them and tests can // override without touching module state. export const DISPATCH_INTERVAL_MS = 5 * 60 * 1000; // scan cadence export const MAX_AGENTS_PER_REPO = 2; // concurrent board agents export const STALLED_AFTER_MS = 3 * 60 * 1000; // quiet WAITING -> "stalled" // Board agents run in auto mode. The classifier that mode brings is the only // thing that reviews a dispatched agent's actions before they run, and that // matters more here than anywhere else in the app: a board agent's prompt is a // job card's detail text plus whatever it reads out of the repo, none of which // is necessarily trustworthy. Under `bypassPermissions` nothing reviews // anything — the agent runs Bash and edits files unprompted, as the user (see // the board-credential section of DESIGN.md). // // Auto mode is not available everywhere, which is why this is a default rather // than the only setting. It needs a supported model and an organisation that // has not turned it off, so on an Amazon Bedrock or Vertex account running an // unsupported model, or behind `permissions.disableAutoMode`, it is missing — // and Claude Code does NOT error or exit in that case: "When the flag, a // settings file, or the built-in default selects auto but auto mode isn't // available to the session, Claude Code starts the session in Manual instead" // (docs/en/permission-modes). The agent spawns fine and works until it needs a // permission, then waits for a human who is not there. Every job. // // That failure is a stall, not a crash: `deriveJobStatus` reports the card as // `needs-input`, then `stalled` once the quiet window passes, so the board is // not blind to it. Because it is visible AND there is now a lever — a board // permission mode in the toolbar, and a per-card override on the form — `auto` // is the right default again. A machine without the classifier sets the board // setting once; a card that needs more says so on itself. // // Two caveats, so neither this comment nor the release notes oversell it. // Choosing `bypassPermissions` does not make dispatch hands-off: Claude Code // shows its workspace-trust dialog the first time it runs in any directory, // every board agent gets a brand-new worktree, and no permission mode skips it // (verified against 2.1.250 — see the trust pre-seed entry in TODOS.md). So a // job still costs one human click to start; what the mode changes is what // happens after that, not before. And on a machine where nobody has used the // mode before, the docs say the first session started in it asks the user to // accept responsibility once and remembers thereafter — until someone answers // that by hand, a board agent waits there instead of working. export const DEFAULT_PERMISSION_MODE = 'auto'; // A command-line argument in double quotes, as parseCommand() in lib/helpers.js // reads it back: backslash escapes for quotes and backslashes. export const quote = (text) => `"${String(text).replace(/([\\"])/g, '\\$1')}"`; // Billion (server/billion.js). Here because a card it posted tells its worker // whom to ask, and this module is where the worker's prompt is written. export const BILLION_NAME = 'Billion'; // The modes `claude --permission-mode` accepts. buildJobCommand interpolates // this value into a command string that parseCommand splits into argv, so an // unvalidated value from the wire becomes extra FLAGS on the spawned agent // (e.g. "auto --dangerously-skip-permissions"). No shell is involved, so this // is not shell injection — but it is argv injection, and the allowlist closes // it. Keep in sync with `claude --permission-mode` choices. export const PERMISSION_MODES = [ 'acceptEdits', 'auto', 'bypassPermissions', 'manual', 'dontAsk', 'plan', ]; export function isValidPermissionMode(mode) { return PERMISSION_MODES.includes(mode); } // A card's own permission mode, as it arrives from a form or an API call. // // `null` is a real third state and not a copy of the board's mode taken when // the card was written: it means "whatever the board is set to at dispatch", // so changing the board setting still moves every queued card that never asked // for its own. An unrecognised mode is refused rather than quietly falling back // to the default — the caller named a mode, and silently running something // else is the wrong answer in both directions. export function resolveJobPermissionMode(mode) { if (mode === undefined || mode === null || mode === '') return { permissionMode: null }; if (!isValidPermissionMode(mode)) { return { error: `Unknown permission mode "${mode}" \u2014 expected one of: ${PERMISSION_MODES.join(', ')}` }; } return { permissionMode: mode }; } // Which CLI a card's agent is. The value is interpolated into the spawned // command's argv, so it is an allowlist for the same reason PERMISSION_MODES // is. export const JOB_AGENTS = ['claude', 'codex']; export const DEFAULT_JOB_AGENT = 'claude'; // Codex has no --permission-mode, so each Claude mode maps onto the nearest // thing Codex's own two flags say. The form only offers a Codex card board // default, auto and bypassPermissions; the rest are reached through a board // set to one of them, and a strict board must bind a Codex card too — without // this a read-only board dispatched a Codex that wrote files, and an agent // that cannot pick a permission mode through the board's MCP tool could widen // its successor's simply by posting the card with agent: codex. // // auto and acceptEdits are Codex's default (workspace-write sandbox, approval // asked on request), so they add nothing. That default runs a command it // judges safe inside the sandbox without asking, where Claude's acceptEdits // would have prompted for it — the nearest flag, not an equivalent one. // manual is the read-only sandbox with approvals on request: every write and // every escape from the sandbox comes back as a question, which is as close // as Codex gets to asking before each tool. (Its `untrusted` approval policy, // used here before, is gone from codex-cli 0.153 — the flag was rejected at // parse time and the agent died on the spot.) // Keep in sync with `codex --help`; test/jobs-agent.test.js runs each entry // through the installed CLI's parser when there is one. export const CODEX_MODE_FLAGS = { auto: '', acceptEdits: '', plan: '--sandbox read-only', manual: '--ask-for-approval on-request --sandbox read-only', dontAsk: '--ask-for-approval never', bypassPermissions: '--dangerously-bypass-approvals-and-sandbox', }; // The permission mode .env gives every agent of one CLI that nothing more // specific decides: CLAUDE_PERMISSION_MODE / CODEX_PERMISSION_MODE, one of // PERMISSION_MODES. Anything else (unset, a typo) is null: no default. export const ENV_PERMISSION_MODE = { claude: 'CLAUDE_PERMISSION_MODE', codex: 'CODEX_PERMISSION_MODE' }; export function envPermissionMode(agent, env = process.env) { const key = Object.prototype.hasOwnProperty.call(ENV_PERMISSION_MODE, agent) ? ENV_PERMISSION_MODE[agent] : null; const mode = key ? String(env[key] ?? '').trim() : ''; return isValidPermissionMode(mode) ? mode : null; } // The flags that mode is on that CLI's command line: Claude takes the mode by // name; Codex its sandbox and approval flags (none for auto and acceptEdits). export function permissionModeFlags(agent, mode) { if (!isValidPermissionMode(mode)) return []; if (agent === 'claude') return ['--permission-mode', mode]; if (agent === 'codex') return CODEX_MODE_FLAGS[mode].split(' ').filter(Boolean); return []; } // A command someone typed, or a preset, with the .env default for its CLI // added — unless it already says how it asks for permission, which then // stands. Only claude and codex; any other command comes back unchanged. The // flags go into the command itself, right after the executable, so everything // that reads a session's mode off its command (who may message it, what it // re-spawns with) sees the mode it really runs in. // // "Already says" is judged by the flag's name, not its value: a value the // allowlist doesn't know (`--permission-mode default`, `-a untrusted`) is // still the person's choice, and a default put in front of it would be what // the session records — so a re-spawn would come back in the default instead. export function withDefaultPermission(command, env = process.env) { const text = String(command || ''); const agent = sessionAgentFromCommand(text); if (!agent || namesPermissionFlag(agent, parseCommand(text).args)) return text; const flags = permissionModeFlags(agent, envPermissionMode(agent, env)); if (!flags.length) return text; const { file, args } = parseCommand(text); // A plain first word keeps the rest exactly as typed; a quoted executable // path is rebuilt, quoting every argument the way parseCommand reads it back. const plain = text.match(/^\s*[^\s"'\\]+(?=\s|$)/); return plain ? `${plain[0]} ${flags.join(' ')}${text.slice(plain[0].length)}` : [quote(file), ...flags, ...args.map(quote)].join(' '); } // Codex also takes config overrides and profiles, which can set any // permission the allowlist cannot see. Shared with server/messages.js // isUnguarded, which reads them as never asking. export const isCodexConfigFlag = (arg) => /^(-c|--config|-p|--profile|--full-auto)(=|$)/.test(arg) || /^-[cp]\S/.test(arg); // Whether `args` (before any `--`) name one of the CLI's permission flags, in // any spelling normalizePermissionFlags reads, whatever the value (or, for // Codex, a config override or profile). function namesPermissionFlag(agent, args) { const table = PERMISSION_FLAGS[agent]; const aliases = PERMISSION_FLAG_ALIASES[agent]; for (const raw of args) { if (raw === '--') return false; if (agent === 'codex' && isCodexConfigFlag(raw)) return true; const name = raw.startsWith('--') ? raw.split('=')[0] : raw.slice(0, 2); if (Object.prototype.hasOwnProperty.call(table, aliases[name] || name)) return true; } return false; } export function isValidJobAgent(agent) { return JOB_AGENTS.includes(agent); } // The agent a session's command line runs — so a card an agent posts can // default to the same CLI as its poster. Anything not recognised (gemini, a // plain shell) is claude, the board's own default. Judged the way // takesMcpConfig in server/agent-mcp.js judges it — the executable's basename, // Windows extension stripped — so a session spawned as /opt/homebrew/bin/codex // gets the board tool AND posts codex cards, rather than one without the other. export function jobAgentFromCommand(command) { const file = basename(parseCommand(String(command || '')).file).replace(/\.(cmd|exe|bat|ps1)$/i, ''); return file === 'codex' ? 'codex' : DEFAULT_JOB_AGENT; } // What a session's own record should say it ran, for the orphan it may // become: 'claude' or 'codex' when the command is that CLI, else null. Unlike // jobAgentFromCommand this does NOT default to claude — the note outranks // every other witness at re-adopt time, so a shell tab, a `bash -lc codex`, // or a gemini must leave it blank and let the job card and the transcripts // on disk say what actually ran there. export function sessionAgentFromCommand(command) { const file = basename(parseCommand(String(command || '')).file).replace(/\.(cmd|exe|bat|ps1)$/i, ''); return JOB_AGENTS.includes(file) ? file : null; } // The permission flags each CLI takes on its command line, as a hand-spawned // agent might carry them: an allowlist of flag → accepted values (null for a // bare switch). Anything else on the command is not a permission and is not // kept. Keep the Codex entries in sync with `codex --help`; the Claude ones // with `claude --help`. test/jobs-agent.test.js runs the Codex forms through // the installed CLI's parser when there is one. export const PERMISSION_FLAGS = { codex: { '--sandbox': ['read-only', 'workspace-write', 'danger-full-access'], '--ask-for-approval': ['on-request', 'never'], '--dangerously-bypass-approvals-and-sandbox': null, '--approve-for-me': null, }, claude: { '--permission-mode': PERMISSION_MODES, '--dangerously-skip-permissions': null, }, }; // Other spellings each CLI accepts for the same permission, mapped onto the // long form above. Codex also takes a short option's value attached // (`-sread-only`, `-a=never`); those are unpacked below. const PERMISSION_FLAG_ALIASES = { codex: { '-s': '--sandbox', '-a': '--ask-for-approval', '--yolo': '--dangerously-bypass-approvals-and-sandbox' }, claude: {}, }; // The permission flags among `tokens` for `agent`, normalised to their long // form, one per flag, or [] when the agent is unknown. Anything not in the // allowlist — an unknown flag, a value the CLI would reject, a bare switch // given a value, a non-string — is dropped, so what comes back can be put on // a command line verbatim. Scanning stops at `--`: what follows is the prompt, // however flag-shaped, and must never come back as a permission. A flag // given twice keeps its last value: Codex refuses a repeat outright, and // Claude Code takes the last one anyway. // Used both on a fresh command (to record what the agent ran with) and on a // stored record (config.json is hand-editable), so the two can never disagree // about what is a permission. export function normalizePermissionFlags(agent, tokens) { // Own-property lookup: `agent` can come from a hand-edited record, and a // prototype key ('constructor') must find no table, not a function. const table = Object.prototype.hasOwnProperty.call(PERMISSION_FLAGS, agent) ? PERMISSION_FLAGS[agent] : null; if (!table || !Array.isArray(tokens)) return []; const aliases = PERMISSION_FLAG_ALIASES[agent]; const seen = new Map(); for (let i = 0; i < tokens.length; i++) { const raw = typeof tokens[i] === 'string' ? tokens[i] : ''; if (raw === '--') break; let flag = raw; let inline; if (raw.startsWith('--') && raw.includes('=')) { [flag, inline] = [raw.slice(0, raw.indexOf('=')), raw.slice(raw.indexOf('=') + 1)]; } else if (/^-[^-]/.test(raw) && raw.length > 2 && aliases[raw.slice(0, 2)]) { // A short option with its value attached: -sread-only, -a=never. [flag, inline] = [raw.slice(0, 2), raw.slice(2).replace(/^=/, '')]; } flag = aliases[flag] || flag; if (!Object.prototype.hasOwnProperty.call(table, flag)) continue; const values = table[flag]; if (values === null) { if (inline === undefined) seen.set(flag, null); continue; } // The next token is the value only if it is not itself an option (or the // `--` terminator): both CLIs refuse `--sandbox --` at parse time, and a // guard that consumed it would read what follows as flags again. const next = tokens[i + 1]; const value = inline !== undefined ? inline : (typeof next === 'string' && !next.startsWith('-') ? tokens[++i] : undefined); if (values.includes(value)) seen.set(flag, value); } const out = []; for (const [flag, value] of seen) { out.push(flag); if (value !== null) out.push(value); } return out; } // The permission flags a stored record (an active session, an orphan) says // its session ran with, through the allowlist again on the way back — the // record is hand-editable — and only for a record whose CLI is known, since // the flags are that CLI's. Both readers of config.json go through here. export function recordedPermissionFlags(record) { return record && isValidJobAgent(record.agent) ? normalizePermissionFlags(record.agent, record.permissionFlags) : []; } // The permission flags a session was spawned with, read off its command — // what its re-spawn should run with when no job card says otherwise. export function permissionFlagsFromCommand(command) { const agent = sessionAgentFromCommand(command); if (!agent) return []; return normalizePermissionFlags(agent, parseCommand(String(command || '')).args); } // A Codex session id as it appears in a rollout's session_meta: a UUID. The // one gate for what may be named on a resume argv, and for which rollout // field is the id. const CODEX_SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; // The --model / -m a command was started with, or null. export function modelFromCommand(command) { const { args } = parseCommand(String(command || '')); for (let i = 0; i < args.length && args[i] !== '--'; i++) { const [flag, value] = args[i].includes('=') ? [args[i].slice(0, args[i].indexOf('=')), args[i].slice(args[i].indexOf('=') + 1)] : [args[i], args[i + 1]]; if (flag === '--model' || flag === '-m') return isSafeModelName(value) ? value : null; } return null; } export function isCodexSessionId(value) { return typeof value === 'string' && CODEX_SESSION_ID.test(value); } // The command that picks an interrupted session back up in its worktree — // what re-adopting an orphan runs. Each CLI keeps its own transcripts, so a // Codex agent revived with `claude --continue` finds nothing to continue and // dies on the spot. A Codex agent resumes by `sessionId`, the newest session // recorded in exactly its worktree: `codex resume --last` scopes by repo, not // worktree, so sibling worktrees of one repo would resume each other's // sessions. With no id to go on it opens Codex's picker rather than guess. // // `mode` is the permission mode the session was dispatched with, when its job // card is known. Neither CLI remembers it: without the flag a Codex card sent // out read-only comes back with Codex's default workspace-write sandbox, and // an unattended dontAsk card comes back prompting and stalls. `flags` are the // permission flags the session was spawned with, for an agent with no card // (one spawned by hand): they come back as given, so a bypass agent does not // return asking, nor a read-only one writing. The card wins when both are // known — it is the board's current word. With neither, the CLI's own // default applies. Both go through their allowlists here, so this is the one // place that guarantees nothing but a permission reaches the argv. // // `model` is the card's model, so a worker picked for a strong model does not // come back on the CLI's default. // // `sessionId` is the conversation to resume: Codex's, or Claude Code's when // it is known exactly (a Restart of an exited session), where it beats // `--continue`'s newest-in-this-folder. export function resumeCommand(agent, mode, flags, sessionId, model = null) { const validMode = isValidPermissionMode(mode) ? mode : null; const own = validMode ? '' : normalizePermissionFlags(agent === 'codex' ? 'codex' : 'claude', flags).join(' '); const m = modelFlag(agent === 'codex' ? 'codex' : 'claude', model); if (agent === 'codex') { const flag = validMode ? CODEX_MODE_FLAGS[validMode] : own; // The id reaches the argv, so only a real session id passes. const target = isCodexSessionId(sessionId) ? ` ${sessionId}` : ''; return `codex resume${target}${flag ? ` ${flag}` : ''}${m ? ` ${m}` : ''}`; } const flag = validMode ? `--permission-mode ${validMode}` : own; const target = isCodexSessionId(sessionId) ? `--resume ${sessionId}` : '--continue'; return `claude ${target}${flag ? ` ${flag}` : ''}${m ? ` ${m}` : ''}`; } // A card's model: a name its CLI's --model / -m takes. It reaches the argv, so // besides being one the server discovered (server/models.js) it must be a // single plain token — no space, quote or leading dash. export function isSafeModelName(model) { return typeof model === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:\[\]-]{0,99}$/.test(model); } // Empty is null: the CLI's own default. `available` is the discovered list per // CLI; left out (a schedule's run, copying a model its schedule already // passed), only the shape is checked. export function resolveJobModel(model, agent, available) { if (model === undefined || model === null || model === '') return { model: null }; const list = available ? (available[agent] || []) : null; if (!isSafeModelName(model) || (list && !list.includes(model))) { const shown = typeof model === 'string' ? `"${model.slice(0, 40)}"` : `a ${typeof model}`; return { error: `Unknown model ${shown} for ${agent} \u2014 expected one of: ${list?.length ? list.join(', ') : '(none discovered; leave it empty for the CLI default)'}` }; } return { model }; } // The model flag for that CLI, or nothing for the default. Quoted though the // name is already a plain token, the same as every other value put on the argv. export function modelFlag(agent, model) { if (!isSafeModelName(model)) return ''; return `${agent === 'codex' ? '-m' : '--model'} ${quote(model)}`; } export function resolveJobAgent(agent) { if (agent === undefined || agent === null || agent === '') return { agent: DEFAULT_JOB_AGENT }; if (!isValidJobAgent(agent)) { // Echoed bounded and only when it is text: this reaches a ws notification // and a 400 body, and the ws door does not type-check it first. const shown = typeof agent === 'string' ? `"${agent.slice(0, 40)}"` : `a ${typeof agent}`; return { error: `Unknown agent ${shown} \u2014 expected one of: ${JOB_AGENTS.join(', ')}` }; } return { agent }; } // Branch name derived from the job title, so a glance at `git branch` says what // each branch is for. The `/` prefix is added by createWorktree. // // Kept to [a-z0-9-] and length-bounded: that side-steps every git ref rule at // once (no `..`, no leading `-`, no `~^:?*[`, no trailing `.lock`) rather than // trying to enumerate them, and keeps the name readable in a branch listing. export const MAX_BRANCH_SLUG_LEN = 40; export function branchSlugFromTitle(title) { const slug = String(title || '') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, MAX_BRANCH_SLUG_LEN) .replace(/-+$/, ''); // a trim mid-word can leave a trailing dash // A title of only punctuation or non-Latin script slugs to nothing; a job // still needs a branch, so fall back rather than failing the dispatch. return slug || 'job'; } // Shared by createJob and updateJob: both have to answer "is this a valid // (type, schedule) pair?", and a second copy of the rules would drift. // // A one-time job silently drops any schedule rather than refusing it. That is // the edit path: switching a scheduled card back to one-time leaves the cron // text sitting in the form field, and failing that save would be baffling. export function resolveJobType({ type, schedule }) { const raw = typeof type === 'string' && type ? type : (schedule ? 'scheduled' : DEFAULT_JOB_TYPE); if (!JOB_TYPES.includes(raw)) { return { error: `Unknown job type "${raw}" — expected one of: ${JOB_TYPES.join(', ')}` }; } if (raw !== 'scheduled') return { type: raw, schedule: null }; // Trimmed but NOT truncated: parseCron owns the length rule, and slicing an // over-long string first would either hide that error or, worse, silently // store a valid-looking prefix of something the user did not write. const text = String(schedule || '').trim(); const parsed = parseCron(text); if (parsed.error) return { error: parsed.error }; return { type: 'scheduled', schedule: text }; } // --- Scheduled one-time jobs --- // // "Follow up on 24 September" is one job on one date: an ordinary one-time // card carrying `runAt`, which waits in To do until then (see isJobDue) and is // then dispatched like any other. `run_at`, an ISO date-time, is the way in. // The same bound the old one-date cron had, kept so a mistyped year is caught. export const MAX_RUN_AT_MS = 365 * 24 * 60 * 60 * 1000; export function parseRunAt(runAt, now = Date.now()) { if (typeof runAt !== 'string' || !runAt.trim()) { return { error: 'run_at must be an ISO date-time, for example "2026-10-24T10:00"' }; } const at = new Date(runAt.trim()); if (Number.isNaN(at.getTime())) { return { error: `run_at "${runAt.slice(0, 40)}" is not a date-time — use ISO, for example "2026-10-24T10:00"` }; } // On the minute: a run_at of 10:00:30 is the 10:00 start, which must still // be ahead of now. at.setSeconds(0, 0); if (at.getTime() <= now) return { error: `run_at ${at.toLocaleString()} has already gone by` }; if (at.getTime() - now > MAX_RUN_AT_MS) { return { error: 'run_at is more than a year away; post it closer to the date' }; } return { runAt: at.toISOString() }; } // A pending once schedule from before scheduled jobs were one card (#178-#188): // a schedule with `once: true` still in To do. Converted on a server start // (convertOnceSchedules in server/jobs.js). export function isPendingOnceSchedule(job) { return isScheduled(job) && job.once === true && job.state === 'todo'; } // "0 10 24 9 *": a fixed minute, hour, day and month on any weekday — the // shape that names one calendar date. @yearly and friends are left out: they // are written to recur. export function isOneDateCron(expr) { const parts = String(expr || '').trim().split(/\s+/); if (parts.length !== 5) return false; return parts.slice(0, 4).every(p => /^\d+$/.test(p)) && parts[4] === '*'; } // The schedules a server start archives: still in To do, on a one-date cron // written before `once` existed, and already run, so the date they name has been and gone. A one-date // schedule that has not fired yet is left to fire. export function spentOneDateSchedules(jobs) { return jobs.filter(j => isScheduled(j) && j.state === 'todo' && isOneDateCron(j.schedule) && (Number(j.runCount) > 0 || !!j.lastRunAt)); } export function newJobId() { return `job-${Date.now()}-${randomBytes(3).toString('hex')}`; } // Bound the stored text so a paste of a whole log file can't bloat config.json. export const MAX_TITLE_LEN = 200; export const MAX_DETAIL_LEN = 20000; // The skill families a card switches on for its worker (server/skill-families.js): // family names, from a list or comma-separated text. None takes the defaults, // which are engineering on a card that ends in a pull request. export const MAX_JOB_SKILLS = 20; const SKILL_FAMILY_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/; export function resolveJobSkills(skills) { if (skills == null || skills === '') return { skills: null }; const list = typeof skills === 'string' ? skills.split(',') : skills; if (!Array.isArray(list) || list.some(s => typeof s !== 'string')) { return { error: 'skills must be a list of skill family names, like ["marketing"]' }; } const names = [...new Set(list.map(s => s.trim().toLowerCase()).filter(Boolean))]; const bad = names.find(n => !SKILL_FAMILY_NAME.test(n)); if (bad) return { error: `"${bad}" is not a skill family name: lower-case letters, digits and dashes` }; if (names.length > MAX_JOB_SKILLS) return { error: `At most ${MAX_JOB_SKILLS} skill families on a card` }; return { skills: names.length ? names : null }; } export function createJob({ title, detail, repoPath, type, schedule, runAt, permissionMode, agent, model, skills, availableModels, requiresPr, urgent, postedBy, postedByName, postedByAgent, postedByBillion, postedById }) { const cleanTitle = String(title || '').trim().slice(0, MAX_TITLE_LEN); if (!cleanTitle) return { error: 'Title is required' }; if (!repoPath) return { error: 'Repository is required' }; // A caller that names no type but passes a schedule means a scheduled job: // the MCP tool and POST /api/jobs both take just `schedule`, so the rule that // turns one into the other lives here, once, rather than at each door. const resolved = resolveJobType({ type, schedule }); if (resolved.error) return { error: resolved.error }; let start = null; if (runAt != null && runAt !== '') { if (resolved.type === 'scheduled') return { error: 'Pass schedule or run_at, not both: a job with run_at runs once, at that time.' }; start = parseRunAt(runAt); if (start.error) return { error: start.error }; } const mode = resolveJobPermissionMode(permissionMode); if (mode.error) return { error: mode.error }; const cli = resolveJobAgent(agent); if (cli.error) return { error: cli.error }; const chosen = resolveJobModel(model, cli.agent, availableModels); if (chosen.error) return { error: chosen.error }; const families = resolveJobSkills(skills); if (families.error) return { error: families.error }; return { job: { id: newJobId(), title: cleanTitle, type: resolved.type, // Cron text as typed, null on a one-time job. Validated above, so // nextCronIso can never be handed something it will refuse. schedule: resolved.schedule, // When this schedule is next due. Recomputed from the moment of each // firing (see fireSchedules), so a skipped firing is never replayed. nextRunAt: resolved.schedule ? nextCronIso(resolved.schedule) : null, // A one-time job that waits in To do until then (see isJobDue). null // for one that goes as soon as it can. runAt: start ? start.runAt : null, lastRunAt: null, runCount: 0, // Held out of dispatch until resumed. Cards written before this existed // have no field at all, which reads as not paused — same as `type`. paused: false, // What this card's agent is spawned with. null inherits the board's // setting at dispatch — see resolveJobPermissionMode. Only read from To // do, which is the one state editableInPlace still lets anyone change. permissionMode: mode.permissionMode, // Which CLI is spawned for it. Cards written before this existed have no // field, which jobAgent reads as claude. agent: cli.agent, // The model its CLI runs, or null for the CLI's default. See resolveJobModel. model: chosen.model, // Skill families its worker gets fully listed, or null for the defaults. skills: families.skills, // See jobRequiresPr. Unset takes the type's default. requiresPr: typeof requiresPr === 'boolean' ? requiresPr : defaultRequiresPr(resolved.type), detail: String(detail || '').trim().slice(0, MAX_DETAIL_LEN), // Files posted with the card ({ name, path }), written to disk by the // server once the id exists. The prompt hands the agent their paths. attachments: [], repoPath, state: 'todo', // Who posted it and when (requirement 3). postedBy: postedBy || null, postedByName: postedByName || null, // Set when an agent typed the card on a person's behalf, via the board's // MCP tool. Kept separate from postedByName rather than folded into it: // postedBy* is the human the work belongs to, and the board still needs // to show that a machine, not they, put it there. postedByAgent: postedByAgent || null, // Billion's trust rides on this, never on the name: set only by the // server, from the posting session (server/jobs.js postJobForAgent). postedByBillion: postedByBillion === true, // Ahead of the queue (see dispatchPriority). Only Billion's own cards // may jump it, so a worker's card cannot push the owner's work back. urgent: urgent === true && postedByBillion === true, // The credential that posted it ("user:", "agent:", or // "anonymous" on a board with auth off), also server-set: the card's // poster may close it and message its worker over HTTP (server/http.js). postedById: postedById || null, postedAt: new Date().toISOString(), // Who is working on it and when they started (requirement 3). Null until // dispatched; kept after the PR lands so Review cards still show credit. agentSessionId: null, agentName: null, startedAt: null, branchName: null, worktreePath: null, prUrl: null, prNumber: null, reviewAt: null, // What the agent reported when it called finish_job — the result of a // card that opens no PR, and an optional note on one that does. resultSummary: null, // Why the board cannot check for this job's PR, when that is the case. // Separate from lastError: both can be true at once, and one must not // silence the other. prCheckError: null, prCheckErrorAt: null, // When the PR merged, and when the card left the board. Separate because // they answer different questions: prMergedAt is a fact about GitHub, and // a card filed away by hand has a doneAt without one — which is how the // archive knows to say "finished" rather than "merged". doneAt is when // this board stopped showing it. prMergedAt: null, doneAt: null, }, }; } // --- Dispatch selection --- // How many jobs a repo currently has in flight. Only In progress counts: an // agent kept alive with its card in Review has finished its work and sits idle // at its prompt, so it is not what the cap limits — agents working at once. It // holds a PTY and a worktree until the card reaches Done. Counting jobs is // enough, and there is no second source of truth to keep // in sync. // // One exclusion: a job whose agent has died. A session that no longer exists // cannot be occupying a slot, and cards are deliberately never auto-reverted to // To do, so without this a single crashed agent would block its repo forever. // // A schedule's runs are ordinary cards and count like any other. export function countInFlightByRepo(jobs, liveSessionIds = null) { const counts = new Map(); for (const job of jobs) { if (job.state !== 'in-progress') continue; if (liveSessionIds && !liveSessionIds.has(job.agentSessionId)) continue; counts.set(job.repoPath, (counts.get(job.repoPath) || 0) + 1); } return counts; } // How long a card that comes BACK to To do waits before re-dispatch. A card // that returns is usually one whose text needs changing first, and edit_job // only works in To do. A freshly posted card gets no hold. export const REQUEUE_HOLD_MS = 60_000; // Is this card allowed to go out right now? One-time cards always are — being // in To do is the whole condition. A schedule fires when it is due. export function isJobDue(job, now = Date.now()) { // Paused: held in To do until someone resumes it. Checked before everything // else and for every card, not just scheduled ones, because this is the one // place every dispatch route passes through — a guard anywhere else would // have to be repeated at each door. Firings missed while paused are not // replayed: resuming re-arms nextRunAt from that moment (setJobPaused), the // same rule a skipped firing follows. if (job.paused) return false; // Sent back to To do: held a moment so its text can be edited before a new // worker picks it up (see REQUEUE_HOLD_MS). if (job.holdUntil && Date.parse(job.holdUntil) > now) return false; // Scheduled for later: waits in To do until its start time. if (job.runAt && Date.parse(job.runAt) > now) return false; if (!isScheduled(job)) return true; if (!job.nextRunAt) { // No due time recorded — a card whose schedule was just edited, or one // hand-edited in config.json. Fire it and let the firing re-arm it, // rather than leaving it permanently stuck. // // EXCEPT when the schedule has no future occurrence at all. "0 0 30 2 *" // parses fine and never matches, so it has no due time and never will: // treating that as due would dispatch it on every single scan, for ever. return job.schedule ? nextCronIso(job.schedule, now) !== null : true; } const at = Date.parse(job.nextRunAt); return Number.isNaN(at) || at <= now; } // The order queued cards go out in: Billion's urgent cards and the runs of a // schedule first (a run is due at a time, and is late every minute it waits), // then everything else, oldest first. 0 goes before 1. export function dispatchPriority(job) { return (job.urgent === true && job.postedByBillion === true) || job.scheduleId ? 0 : 1; } export function compareDispatchOrder(a, b) { return dispatchPriority(a) - dispatchPriority(b) || String(a.postedAt).localeCompare(String(b.postedAt)); } // Board workers at work across every repo (Billion is not a card, so it is // never one of them). export function countInFlight(jobs, liveSessionIds = null) { let n = 0; for (const count of countInFlightByRepo(jobs, liveSessionIds).values()) n += count; return n; } // maxWorkers: the limit across all repos, already lowered for memory pressure // by the caller (lib/memory.js). export function selectDispatchableJobs(jobs, { maxPerRepo = MAX_AGENTS_PER_REPO, maxWorkers = Infinity, availableRepos = null, liveSessionIds = null, now = Date.now() } = {}) { const counts = countInFlightByRepo(jobs, liveSessionIds); let total = countInFlight(jobs, liveSessionIds); const selected = []; // Schedules are fired (see canFire), never dispatched themselves. const todo = jobs .filter(j => j.state === 'todo' && !isScheduled(j)) .sort(compareDispatchOrder); for (const job of todo) { // Paused. `continue`, not a break: one held card must not hold up the // queue behind it. if (!isJobDue(job, now)) continue; // A repo that has been removed (or whose path vanished) can't be spawned // into; leave the job queued rather than failing it. if (availableRepos && !availableRepos.has(job.repoPath)) continue; // The cap bounds how many agents the board piles onto one repo. A run // waiting behind it waits in To do; its schedule holds off meanwhile (a // run not yet started is still unfinished), so nothing queues up. const inFlight = counts.get(job.repoPath) || 0; if (inFlight >= maxPerRepo) continue; // The board-wide limit: nothing more goes out anywhere once it is full. if (total >= maxWorkers) break; counts.set(job.repoPath, inFlight + 1); total++; selected.push(job); } return selected; } // --- Prompt --- // Delivered as a single argv to `claude`, never as simulated keystrokes, so it // cannot race whatever the TUI happens to be showing. The preamble nudges the // agent toward assumptions over questions (each question is a stall the user // has to come clear by hand), points it at /ship as the single finishing step — // /ship already merges, tests, reviews and fix-loops internally, so anything // run ahead of it pays for that work twice — and tells it how the job gets // marked done. It deliberately does not name the skills /ship subsumes: a // dispatched agent arrives with no memory of them, and naming one to forbid it // is what puts it on the table. export function buildJobPrompt(job) { const parts = [job.title]; if (job.detail) parts.push('', job.detail); // Absolute paths outside the worktree, so nothing lands in the branch by // accident. Claude Code's Read tool renders images, so a screenshot is // enough on its own. const files = Array.isArray(job.attachments) ? job.attachments.filter(a => a && a.path) : []; if (files.length) parts.push('', 'Attached files (read them with your file tools):', ...files.map(a => ` ${a.path}`)); parts.push('', '---', ...oneTimePromptSuffix(jobAgent(job), jobRequiresPr(job), job.postedByBillion === true)); return parts.join('\n'); } // A one-time job ends when its agent calls finish_job, which moves the card to // Review. A card that requires a PR gets the /ship path first and hands the PR // link over; one that does not gets no /ship at all — telling an agent doing // research to run it would push it into inventing a change to ship. // // The same skill under each CLI's spelling: /ship in Claude Code, $ship in Codex. // A card Billion posted has someone to ask who is not a person: Billion // answers from its terminal, so a question to it does not stall the job. function oneTimePromptSuffix(agent = DEFAULT_JOB_AGENT, requiresPr = true, fromBillion = false) { const ship = agent === 'codex' ? '$ship' : '/ship'; const finish = requiresPr ? [ `When the work is finished, run ${ship}. It is the whole path from there to the`, 'pull request, so nothing else needs running first. Wait for it to finish.', '', 'Then call the finish_job tool (agent-007-board MCP server) with the pull', 'request URL as pr_url. That moves this job to Review.', ] : [ 'This job does not need a pull request. When the work is finished, call the', 'finish_job tool (agent-007-board MCP server) with a summary of what you did', 'or found. That moves this job to Review, where the summary is what gets read.', 'Put everything that matters in it: this worktree is removed once the job is', 'done, and only committed, pushed work survives that.', ]; const done = requiresPr ? `${ship} has opened the pull request and you have called finish_job` : 'you have called finish_job'; return [ 'This task was dispatched from the Agent 007 job board. You are in a dedicated', 'git worktree on your own branch, so work directly here.', '', 'Push with `git push -u origin HEAD`. Never put credentials or a token in a', 'remote URL: git saves the URL, token included, in plain text. gh and git push', 'already use the GitHub account that owns this repo (GH_TOKEN). Never run', '`gh auth switch`, `gh auth login` or `gh auth logout`: they change the account', 'for the whole machine, under every other agent and the owner.', '', 'Prefer making a reasonable assumption over asking a question — every question', 'stalls the job until a human notices. Record any assumptions you made in', requiresPr ? 'the pull request description.' : 'your summary.', ...(fromBillion ? [ '', `${BILLION_NAME} posted this card. If you are blocked on a decision only it can`, `make, ask it with the agent-007-board send_message tool (to: "${BILLION_NAME}") rather`, 'than waiting for a person; its answer arrives in this terminal.', ] : []), '', ...finish, '', `Do not end your turn until ${done}. There is no`, 'one waiting to read a progress report and tell you to continue — if you stop', 'to describe what you would do next, the job simply stalls there. If you find', 'yourself about to write a summary ending in what comes next, do that thing', 'instead. The only reasons to stop early are a question you genuinely cannot', 'answer yourself, or a failure you cannot get past.', ]; } // The mode a card actually dispatches with. The card's own wins; a card // without one inherits whatever the board is set to at that moment, which is // the whole point of storing null rather than a snapshot of the board value. // // Each level is re-checked against the allowlist rather than trusted, and an // invalid card mode falls through to the BOARD setting rather than skipping // past it to the default. That distinction matters: a card can hold a mode // that was valid when it was queued and is not any more (dropped from the // allowlist by a later release, or hand-edited into config.json), and a board // deliberately set to something strict is exactly the safety net that case // should land in. Only when neither level survives does the default apply. // // Exported because dispatchOnce needs the same answer twice — once to build // the argv, once to confirm nothing retuned the card while the agent spawned. export function dispatchPermissionMode(job, boardMode = DEFAULT_PERMISSION_MODE) { if (job && isValidPermissionMode(job.permissionMode)) return job.permissionMode; if (isValidPermissionMode(boardMode)) return boardMode; return DEFAULT_PERMISSION_MODE; } export function buildJobCommand(job, { permissionMode = DEFAULT_PERMISSION_MODE } = {}) { // Resolved here rather than at the call site so every door into the // dispatcher gets the same rule — and validated here as well as at the // settings boundary, because this is the function that builds the argv and // so the last place that can guarantee the mode is a single token and not a // smuggled second flag. const mode = dispatchPermissionMode(job, permissionMode); // Attachments live outside the worktree, and Claude Code asks before it // reads outside its working directory; --add-dir grants that up front so // an unattended job does not stop at a permission prompt on its first // screenshot. After the prompt, so the argv positions tests rely on hold. const dirs = [...new Set((Array.isArray(job.attachments) ? job.attachments : []).filter(a => a && a.path).map(a => dirname(a.path)))]; if (jobAgent(job) === 'codex') { // No --add-dir: every Codex sandbox, read-only included, reads anywhere // on disk and gates only writes, and attachments are only ever read. const flags = [...permissionModeFlags('codex', mode), modelFlag('codex', job.model)].filter(Boolean).join(' '); return `codex ${flags ? `${flags} ` : ''}${quote(buildJobPrompt(job))}`; } const m = modelFlag('claude', job.model); return `claude ${permissionModeFlags('claude', mode).join(' ')}${m ? ` ${m}` : ''} ${quote(buildJobPrompt(job))}${dirs.map(d => ` --add-dir ${quote(d)}`).join('')}`; } // --- Live status (derived, never stored) --- // Why derived: the card's workflow state is durable, but "needs you" is a fact // about a live PTY that changes second to second and is meaningless once the // server restarts. Storing it would guarantee a stale badge. export function deriveJobStatus(job, session, { now = Date.now(), stalledAfterMs = STALLED_AFTER_MS } = {}) { if (job.state !== 'in-progress') return null; if (!session || session.exited) return 'gone'; // MESSAGE is already exactly "agent is asking the user something" — the same // signal that turns the tab dot orange and gives the office character a // thought bubble (see MESSAGE_PATTERNS in lib/helpers.js). if (session.state === 'MESSAGE') return 'needs-input'; // A TUI agent parked at its prompt reads as WAITING whether it asked a prose // question or quietly finished without opening a PR. Both need a human, so // both surface once the quiet window passes. if (session.state === 'WAITING' && (now - (session.lastOutputAt || 0)) > stalledAfterMs) return 'stalled'; return 'running'; } // --- Schedules --- // May a due schedule post its next run? At most one run of a schedule is // unfinished at a time, so an hourly job nobody reads cannot fill the board: // // - a run still in To do or In progress: skip this firing. Two runs at once // would race each other, and a stuck run shows on its own card. // - a run in Review that opened a PR: skip until that PR is merged or // closed (either files the run to Done), or the card is done. A second // dependency-bump PR on an unmerged one is noise. // - a run in Review with no PR: fire. The new run supersedes it once it // reaches Review itself (see supersededRuns), so the newest result is the // one waiting to be read. // // Returns null to fire, or the reason it held off. export function scheduleHold(schedule, jobs) { const open = jobs.filter(j => j.scheduleId === schedule.id && j.state !== 'done'); if (open.some(j => j.state === 'in-progress')) return 'the previous run is still going'; const queued = open.find(j => j.state === 'todo'); if (queued) return queued.lastError ? `the previous run could not start: ${queued.lastError}` : 'the previous run has not started yet'; const prRun = open.find(j => j.state === 'review' && jobRequiresPr(j)); if (prRun) return prRun.prNumber ? `waiting on PR #${prRun.prNumber}` : 'waiting on the previous run\'s pull request'; return null; } // The Review runs of each schedule that a newer Review run has replaced: every // no-PR run of a schedule except the one that reached Review last. Ranked by // reviewAt, not postedAt: an older run sent back for a follow-up and returned // is the newest result, and must not be filed away the moment it lands. PR runs are never superseded — // scheduleHold keeps a schedule from firing past one. export function supersededRuns(jobs) { const newest = new Map(); // The newest is taken over every Review run, PR runs included, so a schedule // switched to PR runs still replaces the no-PR run it left in Review. const reviewRuns = jobs.filter(j => j.scheduleId && j.state === 'review'); for (const j of reviewRuns) { const cur = newest.get(j.scheduleId); // >= so a tie goes to the later card in the list, which was pushed later. const at = (x) => String(x.reviewAt || x.postedAt); if (!cur || at(j).localeCompare(at(cur)) >= 0) newest.set(j.scheduleId, j); } return reviewRuns .filter(j => !jobRequiresPr(j) && newest.get(j.scheduleId) !== j) .map(j => ({ old: j, by: newest.get(j.scheduleId) })); } // How many finished runs of each schedule the archive keeps. An hourly // schedule posts ~8,760 runs a year, every one of them a card in config.json // and in each board broadcast; past this many, the oldest go. The schedule's // runCount keeps counting regardless. export const MAX_FINISHED_RUNS = 50; // The finished runs to delete: every schedule's done runs beyond the newest // `keep`, oldest first by when they finished. export function runsToPrune(jobs, keep = MAX_FINISHED_RUNS) { const bySchedule = new Map(); for (const j of jobs) { if (!j.scheduleId || j.state !== 'done') continue; if (!bySchedule.has(j.scheduleId)) bySchedule.set(j.scheduleId, []); bySchedule.get(j.scheduleId).push(j); } const prune = []; for (const runs of bySchedule.values()) { if (runs.length <= keep) continue; runs.sort((a, b) => String(b.doneAt || '').localeCompare(String(a.doneAt || ''))); prune.push(...runs.slice(keep)); } return prune; } // The run card a schedule posts. An ordinary one-time card, carrying what the // schedule says its runs should be, and who the schedule belongs to. Pure: // the server copies the schedule's attachment files into the run's own // directory when it posts it (copyRunAttachments). export function createRunJob(schedule) { const result = createJob({ title: schedule.title, detail: schedule.detail, repoPath: schedule.repoPath, type: 'one-time', permissionMode: schedule.permissionMode, agent: schedule.agent, model: schedule.model, skills: schedule.skills, requiresPr: jobRequiresPr(schedule), postedBy: schedule.postedBy, postedByName: schedule.postedByName, // Kept on every run: an agent-posted schedule runs unattended, again and // again, and each run must still say a machine queued it. postedByAgent: schedule.postedByAgent, postedByBillion: schedule.postedByBillion === true, postedById: schedule.postedById, }); if (result.error) return result; result.job.scheduleId = schedule.id; // An agent's rewrite of the schedule is what each run carries out. result.job.editedByAgent = schedule.editedByAgent || null; result.job.editedAt = schedule.editedAt || null; return result; } // --- PR detection --- // The two `gh pr list` queries the board runs, each kept beside the parser that // reads its output so the requested --json fields and the fields the parser // reads cannot drift apart. `--state` is the load-bearing part of each: it is // the difference between "is there a PR to review" and "did that PR land", and // getting the merged one wrong (--state closed) would take cards off the board // for work that never shipped. Pure, so both are pinned by a test. export function openPrListArgs(branchName) { return ['pr', 'list', '--head', branchName, '--state', 'open', '--json', 'number,url,state,isDraft,isCrossRepository']; } export function mergedPrListArgs(branchName) { return ['pr', 'list', '--head', branchName, '--state', 'merged', '--json', 'number,url,state,mergedAt']; } // A card whose PR was closed without merging is filed to Done (see // checkMergedPullRequests). Asked about the card's own PR by number, not by // listing the branch: a number is one answer, where a branch listing is // capped (30 by default) and shared by every card that reused the name. export function closedPrViewArgs(number) { return ['pr', 'view', String(number), '--json', 'number,url,state,mergedAt']; } // The card's PR with its CI: state, the head commit and every check on it. export function prCiViewArgs(number) { return ['pr', 'view', String(number), '--json', 'number,state,mergedAt,headRefOid,statusCheckRollup']; } // Parses prCiViewArgs' output into { state, headSha, ci }. `ci` is null while // any check on the head commit is still running, or when it has none yet (CI // has not started); otherwise { failed: [names], finishedAt }, `failed` empty // when all passed. `finishedAt` is when the last check finished: a re-run of a // failed job keeps the head commit but moves it, so it tells the runs apart. // A rollup holds two shapes: CheckRun (status + conclusion, GitHub Actions) // and StatusContext (one state, the older commit-status API). const CI_OK = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']); // As GitHub shows it: every job in this repo's workflows is called "test", so // only the workflow name tells Ubuntu from Windows. const checkName = c => c.context || [c.workflowName, c.name].filter(Boolean).join(' / ') || 'unnamed check'; export function parsePrCi(stdout) { let pr; try { pr = JSON.parse(stdout); } catch { return null; } if (!pr || typeof pr !== 'object') return null; const state = pr.mergedAt ? 'MERGED' : String(pr.state || '').toUpperCase(); const checks = Array.isArray(pr.statusCheckRollup) ? pr.statusCheckRollup.filter(Boolean) : []; let ci = null; if (checks.length) { const outcome = c => String((c.__typename === 'StatusContext' || c.context ? c.state : c.conclusion) || '').toUpperCase(); const done = c => (c.__typename === 'StatusContext' || c.context) ? !['PENDING', 'EXPECTED', ''].includes(outcome(c)) : String(c.status || '').toUpperCase() === 'COMPLETED'; if (checks.every(done)) { const finishedAt = checks.map(c => c.completedAt || c.startedAt || '').sort().pop() || null; ci = { failed: checks.filter(c => !CI_OK.has(outcome(c))).map(checkName), finishedAt }; } } return { number: pr.number ?? null, state, headSha: pr.headRefOid || null, ci }; } // The card's own PR, if it was closed WITHOUT merging, or null. A merged PR is // never "closed" here: that is the merge path's to file away. Takes the one // object `gh pr view` prints (or a list, for older callers). export function parseClosedPr(stdout, number) { if (number == null) return null; let parsed; try { parsed = JSON.parse(stdout); } catch { return null; } const list = Array.isArray(parsed) ? parsed : (parsed && typeof parsed === 'object' ? [parsed] : null); if (!list) return null; const pr = list.find(p => p && p.number === number); if (!pr || pr.mergedAt || String(pr.state || '').toUpperCase() !== 'CLOSED') return null; return { url: pr.url || null, number: pr.number }; } // Parses `gh pr list --head --json number,url,state,isDraft`. Returns // the first OPEN pr, or null. Drafts count: opening a draft PR is still the // author saying "this is ready to look at". export function parsePrList(stdout) { let list; try { list = JSON.parse(stdout); } catch { return null; } if (!Array.isArray(list)) return null; // Never a PR from someone else's fork that happens to share the head ref // name: the board would adopt it, and its author closing it would close the // card and its agent. const open = list.find(pr => (!pr.state || String(pr.state).toUpperCase() === 'OPEN') && !pr.isCrossRepository); if (!open) return null; return { url: open.url || null, number: open.number ?? null, isDraft: !!open.isDraft }; } // Parses `gh pr list --head --state merged --json number,url,state,mergedAt`. // Returns the merged PR that belongs to THIS job, or null. // // The identity check is the whole point, because `--head` matches the head ref // NAME and that name outlives the branch. A merged PR stays in the listing // forever, and board branch names are reused: the branch is deleted when its // agent is retired, which frees the name both locally and (with GitHub's // delete-on-merge) on the remote, so the next job with the same title gets it // back. "Some PR on this branch merged" is therefore NOT "this card's PR // merged", and treating them as the same files a card away for work that is // still open — taking its PR number with it. // // - `number`: the card's PR of record. When it has one, only that PR can // finish it. Nothing else on the branch is this card's PR. // - `mergedAfter`: for a card with no PR of record, the earliest merge that // could plausibly be its work (when its agent started, or when it reached // Review). An older merge belongs to whatever used this branch name before. // A PR with no mergedAt cannot be placed in time, so it is rejected — the // cost of that is a card staying on the board, which is the safe direction. // // `--state merged` already filters server-side; the merged check here is // belt-and-braces, because a PR CLOSED without merging must never read as // merged: nothing landed. That case has its own path (parseClosedPr). export function parseMergedPr(stdout, { number = null, mergedAfter = null } = {}) { let list; try { list = JSON.parse(stdout); } catch { return null; } if (!Array.isArray(list)) return null; const isMerged = pr => !!pr && (pr.mergedAt || String(pr.state || '').toUpperCase() === 'MERGED'); const shape = pr => ({ url: pr.url || null, number: pr.number ?? null, mergedAt: pr.mergedAt || null }); if (number != null) { const exact = list.find(pr => isMerged(pr) && pr.number === number); return exact ? shape(exact) : null; } const floor = mergedAfter ? Date.parse(mergedAfter) : NaN; const candidate = list.find((pr) => { if (!isMerged(pr)) return false; if (Number.isNaN(floor)) return true; // nothing to compare against const at = Date.parse(pr.mergedAt || ''); return !Number.isNaN(at) && at >= floor; }); return candidate ? shape(candidate) : null; }