diff --git a/src/agents/status.js b/src/agents/status.js new file mode 100644 index 00000000..3db9ec15 --- /dev/null +++ b/src/agents/status.js @@ -0,0 +1,82 @@ +const { fs, path, LOCK_FILE_RELATIVE, TOOL_NAME } = require('../context'); +const { listAgentSessions } = require('./sessions'); + +function readLockCounts(repoRoot) { + const lockPath = path.join(repoRoot, LOCK_FILE_RELATIVE); + let parsed = null; + try { + parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + } catch (_error) { + parsed = null; + } + + const locks = parsed?.locks && typeof parsed.locks === 'object' && !Array.isArray(parsed.locks) + ? parsed.locks + : {}; + const counts = new Map(); + for (const entry of Object.values(locks)) { + const branch = typeof entry?.branch === 'string' ? entry.branch : ''; + if (!branch) continue; + counts.set(branch, (counts.get(branch) || 0) + 1); + } + return counts; +} + +function normalizeSessionForStatus(session, lockCounts) { + const branch = session.branch || ''; + const worktreePath = session.worktreePath || ''; + return { + id: session.id || '', + agent: session.agent || '', + task: session.task || '', + branch, + base: session.base || '', + status: session.status || '', + worktreePath, + worktreeExists: worktreePath ? fs.existsSync(worktreePath) : false, + lockCount: lockCounts.get(branch) || 0, + }; +} + +function buildAgentsStatus(repoRoot) { + const lockCounts = readLockCounts(repoRoot); + return { + schemaVersion: 1, + repoRoot, + sessions: listAgentSessions(repoRoot).map((session) => normalizeSessionForStatus(session, lockCounts)), + }; +} + +function formatValue(value) { + const text = String(value || ''); + return text || '-'; +} + +function renderAgentsStatus(payload, options = {}) { + if (options.json) return `${JSON.stringify(payload, null, 2)}\n`; + + if (payload.sessions.length === 0) { + return `[${TOOL_NAME}] Agent sessions: none (${payload.repoRoot})\n`; + } + + const lines = [`[${TOOL_NAME}] Agent sessions: ${payload.sessions.length} (${payload.repoRoot})`]; + for (const session of payload.sessions) { + lines.push( + `- ${formatValue(session.id)} ${formatValue(session.agent)} ${formatValue(session.status)} ` + + `branch=${formatValue(session.branch)} base=${formatValue(session.base)} ` + + `worktreeExists=${session.worktreeExists ? 'yes' : 'no'} locks=${session.lockCount} ` + + `task=${formatValue(session.task)} worktree=${formatValue(session.worktreePath)}`, + ); + } + return `${lines.join('\n')}\n`; +} + +function runStatusCommand(repoRoot, options = {}) { + return renderAgentsStatus(buildAgentsStatus(repoRoot), options); +} + +module.exports = { + buildAgentsStatus, + renderAgentsStatus, + runStatusCommand, +}; diff --git a/src/cli/args.js b/src/cli/args.js index a8ba9db4..aea52225 100644 --- a/src/cli/args.js +++ b/src/cli/args.js @@ -447,8 +447,8 @@ function parseAgentsArgs(rawArgs) { if (options.branch && !['files', 'diff', 'locks', 'finish'].includes(options.subcommand)) { throw new Error('--branch is only supported with `gx agents files|diff|locks|finish`'); } - if (options.json && !['files', 'diff', 'locks'].includes(options.subcommand)) { - throw new Error('--json is only supported with `gx agents files|diff|locks`'); + if (options.json && !['status', 'files', 'diff', 'locks'].includes(options.subcommand)) { + throw new Error('--json is only supported with `gx agents status|files|diff|locks`'); } return options; diff --git a/src/cli/main.js b/src/cli/main.js index be647b15..6b842c38 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -6,6 +6,7 @@ const toolchainModule = require('../toolchain'); const finishCommands = require('../finish'); const doctorModule = require('../doctor'); const agentInspect = require('../agents/inspect'); +const agentStatus = require('../agents/status'); const { finishAgentSession } = require('../agents/finish'); const sessionSeverityReport = require('../report/session-severity'); const cockpitModule = require('../cockpit'); @@ -2815,18 +2816,7 @@ function agents(rawArgs) { return; } - const existingState = readAgentsState(repoRoot); - if (!existingState) { - console.log(`[${TOOL_NAME}] Repo agents status: inactive (${repoRoot})`); - process.exitCode = 0; - return; - } - - const reviewPid = Number.parseInt(String(existingState?.review?.pid || ''), 10); - const cleanupPid = Number.parseInt(String(existingState?.cleanup?.pid || ''), 10); - console.log( - `[${TOOL_NAME}] Repo agents status: review=${processAlive(reviewPid) ? 'running' : 'stopped'}(pid=${reviewPid || 0}), cleanup=${processAlive(cleanupPid) ? 'running' : 'stopped'}(pid=${cleanupPid || 0})`, - ); + process.stdout.write(agentStatus.runStatusCommand(repoRoot, options)); process.exitCode = 0; } diff --git a/test/agents-status.test.js b/test/agents-status.test.js new file mode 100644 index 00000000..77dfa512 --- /dev/null +++ b/test/agents-status.test.js @@ -0,0 +1,122 @@ +const { + test, + assert, + fs, + path, + runNode, + initRepo, + seedCommit, +} = require('./helpers/install-test-helpers'); +const { createAgentSession } = require('../src/agents/sessions'); + +function makeRepo() { + const repoDir = initRepo(); + seedCommit(repoDir); + return repoDir; +} + +test('agents status prints a compact empty state', () => { + const repoDir = makeRepo(); + + const result = runNode(['agents', 'status', '--target', repoDir], repoDir); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(result.stdout.trim(), `[gitguardex] Agent sessions: none (${repoDir})`); +}); + +test('agents status prints one canonical session with worktree and lock count', () => { + const repoDir = makeRepo(); + const worktreePath = path.join(repoDir, '.omx', 'agent-worktrees', 'demo'); + fs.mkdirSync(worktreePath, { recursive: true }); + createAgentSession(repoDir, { + id: 'session-1', + agent: 'codex', + task: 'Build status', + branch: 'agent/codex/status', + base: 'main', + status: 'working', + worktreePath, + }); + const lockPath = path.join(repoDir, '.omx', 'state', 'agent-file-locks.json'); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync(lockPath, `${JSON.stringify({ + locks: { + 'src/a.js': { branch: 'agent/codex/status' }, + 'src/b.js': { branch: 'agent/codex/status' }, + 'src/other.js': { branch: 'agent/codex/other' }, + }, + }, null, 2)}\n`, 'utf8'); + + const result = runNode(['agents', 'status', '--target', repoDir], repoDir); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, new RegExp(`Agent sessions: 1 \\(${repoDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\)`)); + assert.match(result.stdout, /session-1 codex working branch=agent\/codex\/status base=main/); + assert.match(result.stdout, /worktreeExists=yes locks=2 task=Build status/); + assert.match(result.stdout, new RegExp(`worktree=${worktreePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)); +}); + +test('agents status marks missing worktrees', () => { + const repoDir = makeRepo(); + const missingWorktree = path.join(repoDir, '.omx', 'agent-worktrees', 'missing'); + createAgentSession(repoDir, { + id: 'session-missing', + agent: 'claude', + task: 'Missing worktree', + branch: 'agent/claude/missing', + base: 'dev', + status: 'stale', + worktreePath: missingWorktree, + }); + + const result = runNode(['agents', 'status', '--target', repoDir], repoDir); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /session-missing claude stale branch=agent\/claude\/missing base=dev/); + assert.match(result.stdout, /worktreeExists=no locks=0 task=Missing worktree/); +}); + +test('agents status --json emits stable cockpit-ready payload', () => { + const repoDir = makeRepo(); + const worktreePath = path.join(repoDir, '.omx', 'agent-worktrees', 'json'); + fs.mkdirSync(worktreePath, { recursive: true }); + createAgentSession(repoDir, { + id: 'session-json', + agent: 'codex', + task: 'JSON status', + branch: 'agent/codex/json', + base: 'main', + status: 'active', + worktreePath, + }); + + const result = runNode(['agents', 'status', '--target', repoDir, '--json'], repoDir); + + assert.equal(result.status, 0, result.stderr || result.stdout); + const payload = JSON.parse(result.stdout); + assert.deepEqual(Object.keys(payload), ['schemaVersion', 'repoRoot', 'sessions']); + assert.equal(payload.schemaVersion, 1); + assert.equal(payload.repoRoot, repoDir); + assert.deepEqual(Object.keys(payload.sessions[0]), [ + 'id', + 'agent', + 'task', + 'branch', + 'base', + 'status', + 'worktreePath', + 'worktreeExists', + 'lockCount', + ]); + assert.deepEqual(payload.sessions[0], { + id: 'session-json', + agent: 'codex', + task: 'JSON status', + branch: 'agent/codex/json', + base: 'main', + status: 'active', + worktreePath, + worktreeExists: true, + lockCount: 0, + }); +});