From 745b5793fc25af95377c343fb68693d635c0609c Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 29 Apr 2026 22:18:44 +0200 Subject: [PATCH] Unify cockpit with agents status payload Cockpit state now consumes the gx agents status payload builder instead of rebuilding canonical session and lock state on its own. The render layer keeps a pure string contract while displaying worktree presence and lock counts from that shared payload. Constraint: Cockpit must show the same sessions as gx agents status. Rejected: Keep legacy active-session scanning in cockpit | it would preserve the duplicated status/lock logic this change removes. Confidence: high Scope-risk: narrow Directive: Extend src/agents/status.js payload first when cockpit needs more session fields. Tested: node --test test/agents-status.test.js test/cockpit-render.test.js Tested: openspec validate --specs Not-tested: Full npm test is not green on this branch due unrelated baseline failures in agents launch/session/args tests. --- src/agents/status.js | 7 +- src/cockpit/render.js | 25 +++++++- src/cockpit/state.js | 124 +++++------------------------------- test/cockpit-render.test.js | 59 ++++++++++------- 4 files changed, 78 insertions(+), 137 deletions(-) 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\./); });