From 418bf3fe8781bf67e607013847a1b795ef154b68 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 29 Apr 2026 21:33:45 +0200 Subject: [PATCH] Expose agent lane creation to cockpit actions The cockpit layer needs an action boundary that can start an agent lane without owning the lane bootstrap logic. This adds a thin startAgentLane wrapper that delegates to the same start implementation used by gx agents start and normalizes the result for cockpit callers. Constraint: Keep cockpit action work scoped to cockpit files and tests. Rejected: Reimplement branch/worktree creation in cockpit | would duplicate gx agents start behavior and drift from CLI semantics. Confidence: medium Scope-risk: narrow Directive: Keep lane bootstrap behavior in the gx agents start implementation; cockpit actions should only delegate and normalize. Tested: node --test test/cockpit-actions.test.js test/cockpit-render.test.js Tested: openspec validate --specs Not-tested: Real gx agents start lane launch, because this slice mocks the start implementation dependency. --- src/cockpit/actions.js | 63 +++++++++++++++++++++++++ src/cockpit/index.js | 3 ++ test/cockpit-actions.test.js | 91 ++++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 src/cockpit/actions.js create mode 100644 test/cockpit-actions.test.js diff --git a/src/cockpit/actions.js b/src/cockpit/actions.js new file mode 100644 index 00000000..47cc2d41 --- /dev/null +++ b/src/cockpit/actions.js @@ -0,0 +1,63 @@ +'use strict'; + +function firstString(...values) { + for (const value of values) { + if (typeof value === 'string' && value.length > 0) return value; + } + return undefined; +} + +function normalizeStartResult(result) { + const payload = result && typeof result === 'object' ? result : {}; + const ok = Object.prototype.hasOwnProperty.call(payload, 'ok') + ? Boolean(payload.ok) + : true; + + return { + ok, + sessionId: firstString(payload.sessionId, payload.session?.id, payload.id), + branch: firstString(payload.branch, payload.lane?.branch), + worktreePath: firstString(payload.worktreePath, payload.worktree?.path, payload.path), + message: firstString( + payload.message, + ok ? 'Started agent lane.' : 'Failed to start agent lane.', + ), + }; +} + +function resolveStartImplementation(deps = {}) { + if (typeof deps.startImplementation === 'function') return deps.startImplementation; + if (typeof deps.startAgentLane === 'function') return deps.startAgentLane; + if (typeof deps.startAgent === 'function') return deps.startAgent; + + const startModule = require('../agents/start'); + if (typeof startModule === 'function') return startModule; + if (typeof startModule.startAgentLane === 'function') return startModule.startAgentLane; + if (typeof startModule.startAgent === 'function') return startModule.startAgent; + if (typeof startModule.start === 'function') return startModule.start; + + throw new Error('gx agents start implementation is unavailable'); +} + +function startAgentLane(options = {}, deps = {}) { + const request = { + task: options.task, + agent: options.agent, + base: options.base, + claims: options.claims, + }; + const startImplementation = resolveStartImplementation(deps); + const result = startImplementation(request); + + if (result && typeof result.then === 'function') { + return result.then(normalizeStartResult); + } + + return normalizeStartResult(result); +} + +module.exports = { + startAgentLane, + normalizeStartResult, + resolveStartImplementation, +}; diff --git a/src/cockpit/index.js b/src/cockpit/index.js index 397889f4..ce863f78 100644 --- a/src/cockpit/index.js +++ b/src/cockpit/index.js @@ -1,5 +1,6 @@ const { readCockpitState } = require('./state'); const { renderCockpit } = require('./render'); +const actions = require('./actions'); const { ensureTmuxAvailable, sessionExists, @@ -138,4 +139,6 @@ module.exports = { openCockpit, render, startCockpit, + ...actions, + actions, }; diff --git a/test/cockpit-actions.test.js b/test/cockpit-actions.test.js new file mode 100644 index 00000000..544885d3 --- /dev/null +++ b/test/cockpit-actions.test.js @@ -0,0 +1,91 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const cockpit = require('../src/cockpit'); +const { + startAgentLane, + normalizeStartResult, +} = require('../src/cockpit/actions'); + +test('startAgentLane delegates to the gx agents start implementation', () => { + const calls = []; + + const result = startAgentLane( + { + task: 'fix flaky test', + agent: 'codex', + base: 'main', + claims: ['src/foo.js', 'test/foo.test.js'], + }, + { + startImplementation(request) { + calls.push(request); + return { + ok: true, + sessionId: 'session-123', + branch: 'agent/codex/fix-flaky-test', + worktreePath: '/repo/.omx/agent-worktrees/fix-flaky-test', + message: 'Started agent lane.', + }; + }, + }, + ); + + assert.deepEqual(calls, [ + { + task: 'fix flaky test', + agent: 'codex', + base: 'main', + claims: ['src/foo.js', 'test/foo.test.js'], + }, + ]); + assert.deepEqual(result, { + ok: true, + sessionId: 'session-123', + branch: 'agent/codex/fix-flaky-test', + worktreePath: '/repo/.omx/agent-worktrees/fix-flaky-test', + message: 'Started agent lane.', + }); +}); + +test('startAgentLane normalizes async implementation results', async () => { + const result = await startAgentLane( + { task: 'ship feature', agent: 'claude', base: 'dev', claims: [] }, + { + async startAgentLane() { + return { + session: { id: 'session-async' }, + lane: { branch: 'agent/claude/ship-feature' }, + worktree: { path: '/repo/.omx/agent-worktrees/ship-feature' }, + }; + }, + }, + ); + + assert.deepEqual(result, { + ok: true, + sessionId: 'session-async', + branch: 'agent/claude/ship-feature', + worktreePath: '/repo/.omx/agent-worktrees/ship-feature', + message: 'Started agent lane.', + }); +}); + +test('cockpit index exports startAgentLane action without dropping render API', () => { + assert.equal(cockpit.startAgentLane, startAgentLane); + assert.equal(cockpit.actions.startAgentLane, startAgentLane); + assert.equal(typeof cockpit.render, 'function'); + assert.equal(typeof cockpit.startCockpit, 'function'); +}); + +test('normalizeStartResult preserves failure shape', () => { + assert.deepEqual(normalizeStartResult({ ok: false, message: 'missing agent' }), { + ok: false, + sessionId: undefined, + branch: undefined, + worktreePath: undefined, + message: 'missing agent', + }); +});