diff --git a/src/agents/status.js b/src/agents/status.js index 3db9ec15..49d84ab1 100644 --- a/src/agents/status.js +++ b/src/agents/status.js @@ -38,7 +38,7 @@ function normalizeSessionForStatus(session, lockCounts) { }; } -function buildAgentsStatus(repoRoot) { +function buildAgentsStatusPayload(repoRoot) { const lockCounts = readLockCounts(repoRoot); return { schemaVersion: 1, @@ -47,6 +47,8 @@ function buildAgentsStatus(repoRoot) { }; } +const buildAgentsStatus = buildAgentsStatusPayload; + function formatValue(value) { const text = String(value || ''); return text || '-'; @@ -72,10 +74,11 @@ function renderAgentsStatus(payload, options = {}) { } function runStatusCommand(repoRoot, options = {}) { - return renderAgentsStatus(buildAgentsStatus(repoRoot), options); + return renderAgentsStatus(buildAgentsStatusPayload(repoRoot), options); } module.exports = { + buildAgentsStatusPayload, buildAgentsStatus, renderAgentsStatus, runStatusCommand, diff --git a/src/cockpit/render.js b/src/cockpit/render.js index 9ca26a79..d04c7f93 100644 --- a/src/cockpit/render.js +++ b/src/cockpit/render.js @@ -12,12 +12,31 @@ function lockSummary(locks) { return `${locks.length} (${preview}${suffix})`; } +function lockCountSummary(session) { + if (Array.isArray(session.locks)) { + return lockSummary(session.locks); + } + + return Number.isFinite(session.lockCount) ? String(session.lockCount) : 'none'; +} + +function worktreeSummary(session) { + const worktreePath = session.worktreePath || '-'; + if (session.worktreeExists === false) { + return `${worktreePath} (missing)`; + } + if (session.worktreeExists === true) { + return `${worktreePath} (present)`; + } + return worktreePath; +} + function renderSession(session, index) { const lines = [ `${index + 1}. ${session.agentName || 'agent'} | ${session.status || 'unknown'}`, ` branch: ${session.branch || '-'}`, - ` worktree: ${session.worktreePath || '-'}`, - ` locks: ${lockSummary(session.locks)}`, + ` worktree: ${worktreeSummary(session)}`, + ` locks: ${lockCountSummary(session)}`, ]; if (session.task) { @@ -58,4 +77,6 @@ module.exports = { renderCockpit, renderSession, lockSummary, + lockCountSummary, + worktreeSummary, }; diff --git a/src/cockpit/state.js b/src/cockpit/state.js index 7e61c94f..607b4083 100644 --- a/src/cockpit/state.js +++ b/src/cockpit/state.js @@ -1,21 +1,6 @@ -const fs = require('node:fs'); const path = require('node:path'); const cp = require('node:child_process'); -const { - listAgentSessions, - sessionFilePath, -} = require('../agents/sessions'); - -const ACTIVE_SESSIONS_DIR = path.join('.omx', 'state', 'active-sessions'); -const LOCK_FILE = path.join('.omx', 'state', 'agent-file-locks.json'); - -function readJson(filePath) { - try { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); - } catch (_error) { - return null; - } -} +const { buildAgentsStatusPayload } = require('../agents/status'); function text(value, fallback = '') { if (typeof value === 'string') { @@ -48,113 +33,34 @@ function readBaseBranch(repoPath) { return originHead.replace(/^origin\//, ''); } -function normalizeSession(input, filePath) { - if (!input || typeof input !== 'object' || Array.isArray(input)) { - return null; - } - - const branch = text(input.branch); - const worktreePath = text(input.worktreePath || input.worktree_path); - if (!branch && !worktreePath) { - return null; - } - +function cockpitSessionFromStatus(session) { return { - agentName: text(input.agentName || input.agent || input.cliName, 'agent'), - branch: branch || '(unknown branch)', - worktreePath: worktreePath || '(unknown worktree)', - status: text(input.status || input.state || input.activity, 'unknown'), - task: text(input.latestTaskPreview || input.taskName || input.task), - lastHeartbeatAt: text(input.lastHeartbeatAt || input.updatedAt || input.updated_at), - filePath, - locks: [], + id: text(session.id), + agentName: text(session.agent, 'agent'), + branch: text(session.branch, '(unknown branch)'), + base: text(session.base), + worktreePath: text(session.worktreePath, '(unknown worktree)'), + worktreeExists: Boolean(session.worktreeExists), + status: text(session.status, 'unknown'), + task: text(session.task), + lockCount: Number.isFinite(session.lockCount) ? session.lockCount : 0, }; } -function readLegacyActiveSessions(repoPath) { - const sessionsDir = path.join(repoPath, ACTIVE_SESSIONS_DIR); - if (!fs.existsSync(sessionsDir)) { - return []; - } - - return fs.readdirSync(sessionsDir, { withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) - .map((entry) => { - const filePath = path.join(sessionsDir, entry.name); - return normalizeSession(readJson(filePath), filePath); - }) - .filter(Boolean) -} - -function readCanonicalActiveSessions(repoPath) { - return listAgentSessions(repoPath) - .map((session) => normalizeSession(session, sessionFilePath(repoPath, session.id))) - .filter(Boolean); -} - -function sessionKey(session) { - return `${session.branch}\0${session.worktreePath}`; -} - -function readActiveSessions(repoPath) { - const byKey = new Map(); - for (const session of readLegacyActiveSessions(repoPath)) { - byKey.set(sessionKey(session), session); - } - for (const session of readCanonicalActiveSessions(repoPath)) { - byKey.set(sessionKey(session), session); - } - - return Array.from(byKey.values()) - .sort((left, right) => left.branch.localeCompare(right.branch)); -} - -function readLocksByBranch(repoPath) { - const parsed = readJson(path.join(repoPath, LOCK_FILE)); - const locks = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed.locks : null; - const byBranch = new Map(); - if (!locks || typeof locks !== 'object' || Array.isArray(locks)) { - return byBranch; - } - - for (const [relativePath, entry] of Object.entries(locks)) { - const branch = text(entry && entry.branch); - if (!branch) { - continue; - } - if (!byBranch.has(branch)) { - byBranch.set(branch, []); - } - byBranch.get(branch).push(relativePath); - } - - for (const entries of byBranch.values()) { - entries.sort((left, right) => left.localeCompare(right)); - } - return byBranch; -} - function readCockpitState(repoPath = process.cwd()) { const resolvedRepoPath = path.resolve(repoPath); - const locksByBranch = readLocksByBranch(resolvedRepoPath); - const sessions = readActiveSessions(resolvedRepoPath).map((session) => ({ - ...session, - locks: locksByBranch.get(session.branch) || [], - })); + const statusPayload = buildAgentsStatusPayload(resolvedRepoPath); return { repoPath: resolvedRepoPath, baseBranch: readBaseBranch(resolvedRepoPath), - sessions, + agentsStatus: statusPayload, + sessions: statusPayload.sessions.map(cockpitSessionFromStatus), }; } module.exports = { - ACTIVE_SESSIONS_DIR, - LOCK_FILE, readCockpitState, - readActiveSessions, - readLegacyActiveSessions, readBaseBranch, - readLocksByBranch, + cockpitSessionFromStatus, }; diff --git a/test/cockpit-render.test.js b/test/cockpit-render.test.js index 8a88e913..747fbb53 100644 --- a/test/cockpit-render.test.js +++ b/test/cockpit-render.test.js @@ -8,6 +8,7 @@ const cp = require('node:child_process'); const { renderCockpit } = require('../src/cockpit/render'); const { readCockpitState } = require('../src/cockpit/state'); const { render } = require('../src/cockpit'); +const { buildAgentsStatusPayload } = require('../src/agents/status'); const { createAgentSession } = require('../src/agents/sessions'); function initRepo() { @@ -26,6 +27,7 @@ test('renderCockpit returns a readable terminal string', () => { agentName: 'codex', branch: 'agent/codex/example', worktreePath: '/repo/example/.omx/agent-worktrees/example', + worktreeExists: true, status: 'working', task: 'implement cockpit', lastHeartbeatAt: '2026-04-29T19:00:00.000Z', @@ -39,18 +41,20 @@ test('renderCockpit returns a readable terminal string', () => { assert.match(output, /base: main/); assert.match(output, /active sessions: 1/); assert.match(output, /branch: agent\/codex\/example/); - assert.match(output, /worktree: \/repo\/example\/\.omx\/agent-worktrees\/example/); + assert.match(output, /worktree: \/repo\/example\/\.omx\/agent-worktrees\/example \(present\)/); assert.match(output, /locks: 4 \(src\/cockpit\/render\.js, src\/cockpit\/state\.js, test\/cockpit-render\.test\.js, \+1 more\)/); assert.match(output, /task: implement cockpit/); }); -test('readCockpitState reads canonical sessions and lock summaries', () => { +test('agents status payload and cockpit state see the same session', () => { const repoPath = initRepo(); + const worktreePath = path.join(repoPath, '.omx', 'agent-worktrees', 'example'); + fs.mkdirSync(worktreePath, { recursive: true }); createAgentSession(repoPath, { id: 'canonical-cockpit', agent: 'codex', branch: 'agent/codex/example', - worktreePath: path.join(repoPath, '.omx', 'agent-worktrees', 'example'), + worktreePath, status: 'working', task: 'implement cockpit', }); @@ -67,38 +71,40 @@ test('readCockpitState reads canonical sessions and lock summaries', () => { 'utf8', ); + const statusPayload = buildAgentsStatusPayload(repoPath); const state = readCockpitState(repoPath); assert.equal(state.repoPath, repoPath); assert.equal(state.baseBranch, 'main'); + assert.deepEqual(state.agentsStatus, statusPayload); assert.equal(state.sessions.length, 1); + assert.equal(state.sessions[0].id, statusPayload.sessions[0].id); + assert.equal(state.sessions[0].branch, statusPayload.sessions[0].branch); + assert.equal(state.sessions[0].worktreePath, statusPayload.sessions[0].worktreePath); assert.equal(state.sessions[0].status, 'working'); assert.equal(state.sessions[0].task, 'implement cockpit'); - assert.deepEqual(state.sessions[0].locks, ['src/cockpit/render.js', 'src/cockpit/state.js']); + assert.equal(state.sessions[0].worktreeExists, true); + assert.equal(state.sessions[0].lockCount, 2); }); -test('readCockpitState still reads legacy .omx active sessions', () => { +test('cockpit marks missing worktrees and renders lock count', () => { const repoPath = initRepo(); - const sessionsDir = path.join(repoPath, '.omx', 'state', 'active-sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); - fs.writeFileSync( - path.join(sessionsDir, 'agent__codex__example.json'), - JSON.stringify({ - agentName: 'codex', - branch: 'agent/codex/example', - worktreePath: path.join(repoPath, '.omx', 'agent-worktrees', 'example'), - state: 'working', - latestTaskPreview: 'implement cockpit', - lastHeartbeatAt: '2026-04-29T19:00:00.000Z', - }), - 'utf8', - ); + const missingWorktree = path.join(repoPath, '.omx', 'agent-worktrees', 'missing'); + createAgentSession(repoPath, { + id: 'missing-cockpit', + agent: 'codex', + branch: 'agent/codex/missing', + worktreePath: missingWorktree, + status: 'stalled', + task: 'repair cockpit', + }); + fs.mkdirSync(path.join(repoPath, '.omx', 'state'), { recursive: true }); fs.writeFileSync( path.join(repoPath, '.omx', 'state', 'agent-file-locks.json'), JSON.stringify({ locks: { - 'src/cockpit/render.js': { branch: 'agent/codex/example' }, - 'src/cockpit/state.js': { branch: 'agent/codex/example' }, + 'src/cockpit/render.js': { branch: 'agent/codex/missing' }, + 'src/cockpit/state.js': { branch: 'agent/codex/missing' }, 'README.md': { branch: 'agent/other/example' }, }, }), @@ -110,16 +116,21 @@ test('readCockpitState still reads legacy .omx active sessions', () => { assert.equal(state.repoPath, repoPath); assert.equal(state.baseBranch, 'main'); assert.equal(state.sessions.length, 1); - assert.equal(state.sessions[0].status, 'working'); - assert.deepEqual(state.sessions[0].locks, ['src/cockpit/render.js', 'src/cockpit/state.js']); + assert.equal(state.sessions[0].worktreeExists, false); + assert.equal(state.sessions[0].lockCount, 2); + + const output = renderCockpit(state); + assert.match(output, new RegExp(`worktree: ${missingWorktree.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} \\(missing\\)`)); + assert.match(output, /locks: 2/); }); -test('non-interactive render returns a string', () => { +test('empty cockpit state renders cleanly', () => { const repoPath = initRepo(); const output = render(repoPath); assert.equal(typeof output, 'string'); assert.match(output, /GitGuardex Cockpit/); + assert.match(output, /active sessions: 0/); assert.match(output, /No active agent sessions\./); });