diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 7ebb5f6..de1e5e6 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -50,6 +50,30 @@ Notes: - shows orchestrator PID, dashboard URL, and configured projects when healthy - prints orchestrator diagnostics when the runtime has issues +## parallax tasks + +List the 20 most recent tasks with their current status, AI adapter, and model. + +```bash +parallax tasks +``` + +Outputs a table with the following columns: + +| Column | Description | +|--------|-------------| +| TASK ID | External issue ID (e.g. `PROJ-42`) or internal task ID if no external ID is set | +| NAME | Task title, truncated to 50 characters | +| ADAPTER | Agent provider (`claude-code`, `codex`, `gemini`) | +| MODEL | Model configured for the project (`—` if unset) | +| STATUS | Color-coded: green=done, cyan=running, yellow=queued, red=failed, dim=canceled | + +Notes: + +- no flags accepted +- requires Parallax to be running (`parallax start`) +- shows tasks sorted by most recently created, newest first + ## parallax start Start orchestrator and dashboard in background. diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index 9d57f2f..c6eb1e0 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -8,6 +8,7 @@ import type { StartCommandOptions, StopCommandOptions, StatusCommandOptions, + TasksCommandOptions, } from './types.js' export function parseArg(args: string[], key: string): string | undefined { @@ -197,6 +198,14 @@ export function parseStatusOptions(args: string[]): StatusCommandOptions { return {} } +export function parseTasksOptions(args: string[]): TasksCommandOptions { + if (args.length > 0) { + throw new Error('parallax tasks does not accept flags.') + } + + return {} +} + export function resolvePath(raw: string): string { return path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw) } diff --git a/packages/cli/src/commands/tasks.ts b/packages/cli/src/commands/tasks.ts new file mode 100644 index 0000000..b777a30 --- /dev/null +++ b/packages/cli/src/commands/tasks.ts @@ -0,0 +1,128 @@ +import type { CliContext } from '../types.js' + +type TaskEntry = { + id: string + externalId: string + title: string + status: string + projectId: string + createdAt: number +} + +type ProjectEntry = { + id: string + agent: { provider: string; model?: string } +} + +const GREEN = '\x1b[32m' +const RED = '\x1b[31m' +const YELLOW = '\x1b[33m' +const CYAN = '\x1b[36m' +const DIM = '\x1b[2m' +const RESET = '\x1b[0m' +const BOLD = '\x1b[1m' + +function colorStatus(status: string): string { + switch (status) { + case 'done': + return `${GREEN}${status}${RESET}` + case 'running': + return `${CYAN}${status}${RESET}` + case 'queued': + return `${YELLOW}${status}${RESET}` + case 'failed': + return `${RED}${status}${RESET}` + case 'canceled': + return `${DIM}${status}${RESET}` + default: + return status + } +} + +export async function runTasks(_args: string[], context: CliContext) { + let apiBase: string + try { + apiBase = await context.resolveDefaultApiBase() + } catch { + throw new Error("Parallax is not running. Start it first with 'parallax start'.") + } + + const [tasksRes, configRes] = await Promise.all([ + fetch(`${apiBase}/tasks`), + fetch(`${apiBase}/config`), + ]) + + if (!tasksRes.ok) { + throw new Error(`Failed to fetch tasks (${tasksRes.status}): ${tasksRes.statusText}`) + } + if (!configRes.ok) { + throw new Error(`Failed to fetch config (${configRes.status}): ${configRes.statusText}`) + } + + const allTasks = (await tasksRes.json()) as TaskEntry[] + const config = (await configRes.json()) as { projects?: ProjectEntry[] } + const projects = new Map((config.projects ?? []).map((p) => [p.id, p])) + + const tasks = allTasks + .slice() + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, 20) + + if (tasks.length === 0) { + console.log('No tasks found.') + return + } + + const rows = tasks.map((task) => { + const project = projects.get(task.projectId) + const provider = project?.agent.provider ?? '—' + const model = project?.agent.model ?? '—' + const displayId = task.externalId || task.id + const title = task.title.length > 50 ? task.title.slice(0, 47) + '...' : task.title + return { id: displayId, title, provider, model, status: task.status } + }) + + const colWidths = { + id: Math.max(7, ...rows.map((r) => r.id.length)), + title: Math.max(5, ...rows.map((r) => r.title.length)), + provider: Math.max(8, ...rows.map((r) => r.provider.length)), + model: Math.max(5, ...rows.map((r) => r.model.length)), + status: Math.max(6, ...rows.map((r) => r.status.length)), + } + + const pad = (s: string, n: number) => s.padEnd(n) + + const header = [ + pad('TASK ID', colWidths.id), + pad('NAME', colWidths.title), + pad('ADAPTER', colWidths.provider), + pad('MODEL', colWidths.model), + pad('STATUS', colWidths.status), + ].join(' ') + + const divider = [ + '─'.repeat(colWidths.id), + '─'.repeat(colWidths.title), + '─'.repeat(colWidths.provider), + '─'.repeat(colWidths.model), + '─'.repeat(colWidths.status), + ].join(' ') + + console.log() + console.log(`${BOLD}${header}${RESET}`) + console.log(`${DIM}${divider}${RESET}`) + + for (const row of rows) { + console.log( + [ + pad(row.id, colWidths.id), + pad(row.title, colWidths.title), + pad(row.provider, colWidths.provider), + pad(row.model, colWidths.model), + colorStatus(row.status), + ].join(' ') + ) + } + + console.log() +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b2392ca..dad3df9 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -13,6 +13,7 @@ import { parseRetryOptions, parseStartOptions, parseStatusOptions, + parseTasksOptions, parseStopOptions as parseStopOptionsInternal, resolvePath, } from './args.js' @@ -34,6 +35,7 @@ import { runRetry } from './commands/retry.js' import { runStart } from './commands/start.js' import { runStatus } from './commands/status.js' import { runStop } from './commands/stop.js' +import { runTasks } from './commands/tasks.js' import type { CliContext } from './types.js' import { printUsage } from './usage.js' @@ -164,6 +166,9 @@ async function cli() { case 'logs': await runLogs(commandArgs, cliContext) return + case 'tasks': + await runTasks(commandArgs, cliContext) + return default: console.error(`Unknown command: ${command}\n`) printUsage() @@ -183,6 +188,7 @@ export { parseRetryOptions, parseStartOptions, parseStatusOptions, + parseTasksOptions, parseRunningState, resolveDefaultApiBase, resolvePath, diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 2f3b0d5..1785180 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -20,6 +20,8 @@ export type PreflightCommandOptions = Record export type StatusCommandOptions = Record +export type TasksCommandOptions = Record + export type StartCommandOptions = { apiPort: number uiPort: number diff --git a/packages/cli/src/usage.ts b/packages/cli/src/usage.ts index 70ca1e1..e8c0646 100644 --- a/packages/cli/src/usage.ts +++ b/packages/cli/src/usage.ts @@ -6,6 +6,7 @@ export function printUsage(): void { parallax start [--server-api-port ] [--server-ui-port ] [--concurrency ] parallax stop parallax status + parallax tasks parallax open parallax preflight parallax pr-review @@ -18,6 +19,7 @@ Commands: start Start orchestrator + UI in background. stop Force-stop the running Parallax processes. status Show orchestrator state and configured projects. + tasks List the last 20 tasks with their status, AI adapter, and model. open Open the dashboard in your browser. preflight Validate local prerequisites and auth. pr-review [experimental] Apply open human PR review comments to the task's existing open PR. diff --git a/packages/cli/test/tasks.test.ts b/packages/cli/test/tasks.test.ts new file mode 100644 index 0000000..a5294c2 --- /dev/null +++ b/packages/cli/test/tasks.test.ts @@ -0,0 +1,250 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import stripAnsi from 'strip-ansi' +import type { CliContext } from '../src/types.js' + +import { runTasks } from '../src/commands/tasks.js' + +function createContext(overrides: Partial = {}): CliContext { + return { + defaultApiBase: 'http://localhost:9371', + defaultDataDir: '/tmp/.parallax', + manifestFile: 'running.json', + rootDir: '/tmp/parallax', + cliVersion: '0.0.9', + packageVersion: '0.0.9', + resolvePath: (raw) => raw, + ensureFileExists: async () => true, + loadRunningState: async () => ({ + startedAt: Date.now(), + orchestratorPid: 1234, + uiPid: 5678, + apiPort: 9371, + uiPort: 9372, + }), + loadStoredConfig: async () => ({ + version: 1, + projects: [], + slack: null, + secrets: {}, + updatedAt: 0, + }), + saveStoredConfig: async () => {}, + resolveDefaultApiBase: async () => 'http://localhost:9371', + buildEnvConfig: () => ({}), + ...overrides, + } +} + +function makeFetch(tasks: unknown[], projects: unknown[] = []) { + return vi.fn().mockImplementation((url: string) => { + if (String(url).endsWith('/tasks')) { + return Promise.resolve({ ok: true, json: async () => tasks }) + } + if (String(url).endsWith('/config')) { + return Promise.resolve({ ok: true, json: async () => ({ projects }) }) + } + return Promise.resolve({ ok: false, status: 404, statusText: 'Not Found' }) + }) +} + +describe('runTasks', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + }) + + it('throws when orchestrator is not running', async () => { + const context = createContext({ + resolveDefaultApiBase: async () => { + throw new Error('no manifest') + }, + }) + + await expect(runTasks([], context)).rejects.toThrow( + "Parallax is not running. Start it first with 'parallax start'." + ) + }) + + it('prints "No tasks found." when the API returns an empty list', async () => { + vi.stubGlobal('fetch', makeFetch([], [])) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runTasks([], createContext()) + + expect(logSpy).toHaveBeenCalledWith('No tasks found.') + }) + + it('renders a table with task id, name, adapter, model, and status columns', async () => { + const tasks = [ + { + id: 'internal-1', + externalId: 'PROJ-42', + title: 'Fix the login bug', + status: 'running', + projectId: 'proj-a', + createdAt: 1000, + }, + ] + const projects = [ + { id: 'proj-a', agent: { provider: 'claude-code', model: 'claude-sonnet-4-5' } }, + ] + + vi.stubGlobal('fetch', makeFetch(tasks, projects)) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runTasks([], createContext()) + + const lines = logSpy.mock.calls.map((call) => stripAnsi(String(call[0]))) + const header = lines.find((l) => l.includes('TASK ID')) + const row = lines.find((l) => l.includes('PROJ-42')) + + expect(header).toBeDefined() + expect(header).toContain('NAME') + expect(header).toContain('ADAPTER') + expect(header).toContain('MODEL') + expect(header).toContain('STATUS') + + expect(row).toBeDefined() + expect(row).toContain('PROJ-42') + expect(row).toContain('Fix the login bug') + expect(row).toContain('claude-code') + expect(row).toContain('claude-sonnet-4-5') + expect(row).toContain('running') + }) + + it('uses the internal id when externalId is empty', async () => { + const tasks = [ + { + id: 'internal-abc', + externalId: '', + title: 'Some task', + status: 'queued', + projectId: 'proj-b', + createdAt: 1000, + }, + ] + + vi.stubGlobal('fetch', makeFetch(tasks, [])) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runTasks([], createContext()) + + const lines = logSpy.mock.calls.map((call) => stripAnsi(String(call[0]))) + expect(lines.some((l) => l.includes('internal-abc'))).toBe(true) + }) + + it('shows a dash for adapter and model when no matching project exists', async () => { + const tasks = [ + { + id: 'internal-1', + externalId: 'PROJ-1', + title: 'Orphan task', + status: 'done', + projectId: 'unknown-project', + createdAt: 1000, + }, + ] + + vi.stubGlobal('fetch', makeFetch(tasks, [])) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runTasks([], createContext()) + + const lines = logSpy.mock.calls.map((call) => stripAnsi(String(call[0]))) + const row = lines.find((l) => l.includes('PROJ-1')) + expect(row).toBeDefined() + expect(row).toContain('—') + }) + + it('limits output to the 20 most recent tasks by createdAt', async () => { + const tasks = Array.from({ length: 25 }, (_, i) => ({ + id: `id-${i}`, + externalId: `TASK-${i}`, + title: `Task ${i}`, + status: 'done', + projectId: 'proj-a', + createdAt: i, + })) + + vi.stubGlobal('fetch', makeFetch(tasks, [])) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runTasks([], createContext()) + + const lines = logSpy.mock.calls.map((call) => stripAnsi(String(call[0]))) + const taskRows = lines.filter((l) => l.includes('TASK-')) + expect(taskRows).toHaveLength(20) + expect(taskRows.some((l) => l.includes('TASK-24'))).toBe(true) + expect(taskRows.some((l) => l.includes('TASK-4'))).toBe(false) + }) + + it('truncates titles longer than 50 characters', async () => { + const longTitle = 'A'.repeat(60) + const tasks = [ + { + id: 'id-1', + externalId: 'TASK-1', + title: longTitle, + status: 'running', + projectId: 'proj-a', + createdAt: 1000, + }, + ] + + vi.stubGlobal('fetch', makeFetch(tasks, [])) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runTasks([], createContext()) + + const lines = logSpy.mock.calls.map((call) => stripAnsi(String(call[0]))) + const row = lines.find((l) => l.includes('TASK-1')) + expect(row).toBeDefined() + expect(row).toContain('...') + expect(row).not.toContain(longTitle) + }) + + it('applies ANSI color codes to the status column', async () => { + const statuses = ['done', 'running', 'queued', 'failed', 'canceled'] + const tasks = statuses.map((status, i) => ({ + id: `id-${i}`, + externalId: `TASK-${i}`, + title: `Task ${i}`, + status, + projectId: 'proj-a', + createdAt: i, + })) + + vi.stubGlobal('fetch', makeFetch(tasks, [])) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runTasks([], createContext()) + + const rawLines = logSpy.mock.calls.map((call) => String(call[0])) + const stripped = rawLines.map(stripAnsi) + + const taskRows = rawLines.filter((_, i) => { + const plain = stripped[i] + return ( + plain !== undefined && statuses.some((s) => plain.includes(s) && plain.includes('TASK-')) + ) + }) + + for (const row of taskRows) { + expect(row).not.toBe(stripAnsi(row)) + } + }) + + it('throws when the tasks endpoint fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((url: string) => { + if (String(url).endsWith('/tasks')) { + return Promise.resolve({ ok: false, status: 500, statusText: 'Internal Server Error' }) + } + return Promise.resolve({ ok: true, json: async () => ({ projects: [] }) }) + }) + ) + + await expect(runTasks([], createContext())).rejects.toThrow('Failed to fetch tasks (500)') + }) +})