From 7c6e53bbcb917e2cd6e8c018a91e935b35772150 Mon Sep 17 00:00:00 2001 From: Maxi Gimenez Date: Fri, 22 May 2026 15:26:40 +0100 Subject: [PATCH 1/8] feat(orchestrator): replace YAML config with JSON config-store Introduce config-store.ts (atomic read/write of ~/.parallax/config.json) and config-validation.ts (structural validators reused by loadConfig and API write endpoints). Rewrite loadConfig() to read from the JSON store, inject secrets into process.env, and remove all YAML parsing. Add CRUD API endpoints for projects, agents, slack, and secrets; wire saveConfig into ApiServerDependencies. Remove parallax.example.yml. Co-Authored-By: Claude Sonnet 4.6 --- packages/common/src/index.ts | 10 +- packages/orchestrator/package.json | 3 - .../src/ai-adapters/base-adapter.ts | 22 - .../src/ai-adapters/claude-code-adapter.ts | 3 - .../src/ai-adapters/codex-adapter.ts | 4 - .../src/ai-adapters/gemini-adapter.ts | 5 - packages/orchestrator/src/config-loader.ts | 447 +-------------- packages/orchestrator/src/config-store.ts | 68 +++ .../orchestrator/src/config-validation.ts | 206 +++++++ packages/orchestrator/src/index.ts | 3 +- .../orchestrator/src/runtime/api-server.ts | 223 +++++++- packages/orchestrator/test/api-server.test.ts | 213 ++++++- .../orchestrator/test/config-loader.test.ts | 532 ++++++++---------- parallax.example.yml | 12 - 14 files changed, 955 insertions(+), 796 deletions(-) create mode 100644 packages/orchestrator/src/config-store.ts create mode 100644 packages/orchestrator/src/config-validation.ts delete mode 100644 parallax.example.yml diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 7838e8d..197a192 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -159,10 +159,18 @@ export interface SlackConfig { channel: string } +export interface StoredConfig { + version: number + projects: ProjectConfig[] + agents: AgentDefinition[] + slack: SlackConfig | null + secrets: Record + updatedAt: number +} + export interface ProjectConfig { id: string workspaceDir: string // Absolute path to existing local repo - envFilePath?: string pullFrom: { provider: PullProvider filters: { diff --git a/packages/orchestrator/package.json b/packages/orchestrator/package.json index e475242..a1e57d9 100644 --- a/packages/orchestrator/package.json +++ b/packages/orchestrator/package.json @@ -15,9 +15,7 @@ "@parallax/slack": "workspace:*", "@fastify/cors": "11.2.0", "chalk": "4", - "dotenv": "16.4.7", "fastify": "5.7.4", - "js-yaml": "4.1.0", "log-update": "7.1.0", "p-limit": "6.1.0", "simple-git": "3.32.3", @@ -27,7 +25,6 @@ "uuid": "11.0.0" }, "devDependencies": { - "@types/js-yaml": "4.0.9", "@types/uuid": "10.0.0" }, "files": [ diff --git a/packages/orchestrator/src/ai-adapters/base-adapter.ts b/packages/orchestrator/src/ai-adapters/base-adapter.ts index 67f0d4b..2565b26 100644 --- a/packages/orchestrator/src/ai-adapters/base-adapter.ts +++ b/packages/orchestrator/src/ai-adapters/base-adapter.ts @@ -1,11 +1,7 @@ -import fs from 'node:fs/promises' -import dotenv from 'dotenv' import { Task, Logger, ProjectConfig, AgentResult, PlanResult } from '@parallax/common' import { LocalExecutor } from '@parallax/common/executor' export abstract class BaseAgentAdapter { - private envFileCache = new Map>() - constructor( protected executor: LocalExecutor, protected logger: Logger @@ -19,24 +15,6 @@ export abstract class BaseAgentAdapter { return project.agent.systemPrompt ?? '' } - protected async resolveProjectEnv( - project: ProjectConfig - ): Promise | undefined> { - if (!project.envFilePath) { - return undefined - } - - const cached = this.envFileCache.get(project.envFilePath) - if (cached) { - return cached - } - - const content = await fs.readFile(project.envFilePath, 'utf8') - const parsed = dotenv.parse(content) - this.envFileCache.set(project.envFilePath, parsed) - return parsed - } - abstract runTask( task: Task, workingDir: string, diff --git a/packages/orchestrator/src/ai-adapters/claude-code-adapter.ts b/packages/orchestrator/src/ai-adapters/claude-code-adapter.ts index 522c102..5a54d83 100644 --- a/packages/orchestrator/src/ai-adapters/claude-code-adapter.ts +++ b/packages/orchestrator/src/ai-adapters/claude-code-adapter.ts @@ -204,15 +204,12 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { command: string[], collector: ClaudeCodeEventCollector ) { - const env = await this.resolveProjectEnv(project) - const result = await this.executor.executeCommand(command, { cwd: workingDir, onData: (chunk) => chunk.stream === 'stdout' ? collector.handleStdoutLine(chunk.line) : collector.handleStderrLine(chunk.line), - env, }) return result diff --git a/packages/orchestrator/src/ai-adapters/codex-adapter.ts b/packages/orchestrator/src/ai-adapters/codex-adapter.ts index 5160070..adbd9ac 100644 --- a/packages/orchestrator/src/ai-adapters/codex-adapter.ts +++ b/packages/orchestrator/src/ai-adapters/codex-adapter.ts @@ -296,7 +296,6 @@ export class CodexAdapter extends BaseAgentAdapter { async runPlan(task: Task, workingDir: string, project: ProjectConfig): Promise { const contextPrefix = this.buildContextPrefix(project, task) const command = this.buildCommand(task, project, this.buildPlanPrompt(task, contextPrefix)) - const env = await this.resolveProjectEnv(project) const collector = new CodexEventCollector(this.logger, task, 'plan') const result = await this.executor.executeCommand(command, { @@ -305,7 +304,6 @@ export class CodexAdapter extends BaseAgentAdapter { chunk.stream === 'stdout' ? collector.handleStdoutLine(chunk.line) : collector.handleStderrLine(chunk.line), - env, }) if (result.exitCode === 127) { @@ -352,7 +350,6 @@ export class CodexAdapter extends BaseAgentAdapter { project, this.buildExecutionPrompt(task, approvedPlan, outputMode, contextPrefix) ) - const env = await this.resolveProjectEnv(project) const collector = new CodexEventCollector(this.logger, task, 'task') const result = await this.executor.executeCommand(command, { @@ -361,7 +358,6 @@ export class CodexAdapter extends BaseAgentAdapter { chunk.stream === 'stdout' ? collector.handleStdoutLine(chunk.line) : collector.handleStderrLine(chunk.line), - env, }) if (result.exitCode === 127) { diff --git a/packages/orchestrator/src/ai-adapters/gemini-adapter.ts b/packages/orchestrator/src/ai-adapters/gemini-adapter.ts index 8da00b1..d4ba122 100644 --- a/packages/orchestrator/src/ai-adapters/gemini-adapter.ts +++ b/packages/orchestrator/src/ai-adapters/gemini-adapter.ts @@ -233,11 +233,9 @@ export class GeminiAdapter extends BaseAgentAdapter { const contextPrefix = this.buildContextPrefix(project, task) const prompt = this.buildPlanPrompt(task, contextPrefix) const command = this.buildCommand(task, project, prompt) - const env = await this.resolveProjectEnv(project) const result = await this.executor.executeCommand(command, { cwd: workingDir, onData: (chunk) => this.handleLogChunk(task, chunk), - env, }) if (result.exitCode === 127) { @@ -291,12 +289,9 @@ export class GeminiAdapter extends BaseAgentAdapter { includeExecutionMetadata = false ): Promise { const command = this.buildCommand(task, project, prompt) - const env = await this.resolveProjectEnv(project) - const result = await this.executor.executeCommand(command, { cwd: workingDir, onData: (chunk) => this.handleLogChunk(task, chunk), - env, }) if (result.exitCode === 127) { diff --git a/packages/orchestrator/src/config-loader.ts b/packages/orchestrator/src/config-loader.ts index 266511f..439d5b5 100644 --- a/packages/orchestrator/src/config-loader.ts +++ b/packages/orchestrator/src/config-loader.ts @@ -1,130 +1,24 @@ -import fs from 'fs/promises' -import yaml from 'js-yaml' -import path from 'path' -import os from 'os' -import dotenv from 'dotenv' -import { - AGENT_PROVIDER, - AgentDefinition, - AppConfig, - DEFAULT_API_PORT, - DEFAULT_UI_PORT, - PULL_PROVIDER, - ProjectConfig, - ServerConfig, - SlackConfig, -} from '@parallax/common' -type RegisteredConfig = { - configPath: string - addedAt: number - envFilePath?: string -} - -type RegistryState = { - configs: RegisteredConfig[] -} - -async function fileExists(filePath: string): Promise { - try { - await fs.access(filePath) - return true - } catch { - return false - } -} +import path from 'node:path' +import os from 'node:os' +import { AppConfig, DEFAULT_API_PORT, DEFAULT_UI_PORT, ServerConfig } from '@parallax/common' +import { readConfigStore } from './config-store.js' +import { validateStoredConfig } from './config-validation.js' -function resolveDataDir(): string { +export function resolveDataDir(): string { return process.env.PARALLAX_DATA_DIR ? path.resolve(process.env.PARALLAX_DATA_DIR) : path.join(os.homedir(), '.parallax') } -export async function loadConfig(): Promise { - const dataDir = resolveDataDir() - const registryPath = path.join(dataDir, 'registry.json') - if (!(await fileExists(registryPath))) { - return buildEmptyConfig() - } - - const registry = parseRegistry(await fs.readFile(registryPath, 'utf8'), registryPath) - if (registry.configs.length === 0) { - return buildEmptyConfig() - } - - const configs = await Promise.all( - registry.configs.map(async (entry) => { - if (!(await fileExists(entry.configPath))) { - throw new Error(`Registered config file not found: ${entry.configPath}`) - } - if (entry.envFilePath) { - if (!(await fileExists(entry.envFilePath))) { - throw new Error(`Registered env file not found: ${entry.envFilePath}`) - } - const envContent = await fs.readFile(entry.envFilePath, 'utf8') - const envValues = dotenv.parse(envContent) - for (const [key, value] of Object.entries(envValues)) { - if (process.env[key] === undefined) { - process.env[key] = value - } - } - } - - const fileContent = await fs.readFile(entry.configPath, 'utf8') - const parsed = yaml.load(fileContent) - return validateConfig(parsed, entry.configPath, entry.envFilePath) - }) - ) - - return mergeConfigs(configs) -} - -const ALLOWED_AGENT_PROVIDERS = [ - AGENT_PROVIDER.CODEX, - AGENT_PROVIDER.GEMINI, - AGENT_PROVIDER.CLAUDE_CODE, -] as const -const ALLOWED_PULL_PROVIDERS = [PULL_PROVIDER.LINEAR, PULL_PROVIDER.GITHUB] as const - -function assertObject(value: unknown, label: string): asserts value is Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error(`${label} must be an object.`) - } -} - -function assertNonEmptyString(value: unknown, label: string): string { - if (typeof value !== 'string' || !value.trim()) { - throw new Error(`${label} must be a non-empty string.`) - } - - return value.trim() -} - -function assertOptionalString(value: unknown, label: string): string | undefined { - if (value === undefined) { - return undefined - } - - return assertNonEmptyString(value, label) -} - -function assertNoUnknownKeys(value: Record, allowedKeys: string[], label: string) { - const unknownKeys = Object.keys(value).filter((key) => !allowedKeys.includes(key)) - if (unknownKeys.length > 0) { - throw new Error(`${label} contains unsupported fields: ${unknownKeys.join(', ')}.`) - } -} - function parseRuntimeConcurrency(): number { const raw = process.env.PARALLAX_CONCURRENCY if (raw === undefined) { return 2 } - const parsed = Number.parseInt(raw, 10) if (!Number.isInteger(parsed) || parsed < 1 || parsed > 16) { throw new Error('PARALLAX_CONCURRENCY must be an integer between 1 and 16.') } - return parsed } @@ -132,12 +26,10 @@ function parseRuntimePort(raw: string | undefined, label: string, fallback: numb if (raw === undefined) { return fallback } - const parsed = Number.parseInt(raw, 10) if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { throw new Error(`${label} must be an integer between 1 and 65535.`) } - return parsed } @@ -152,303 +44,23 @@ function parseRuntimeServerConfig(): ServerConfig { 'PARALLAX_SERVER_UI_PORT', DEFAULT_UI_PORT ) - if (apiPort === uiPort) { throw new Error('PARALLAX_SERVER_API_PORT and PARALLAX_SERVER_UI_PORT must be different.') } - return { apiPort, uiPort } } -function buildEmptyConfig(): AppConfig { - return { - concurrency: parseRuntimeConcurrency(), - logs: ['info', 'success', 'warn', 'error'], - server: parseRuntimeServerConfig(), - projects: [], - agents: [], - } -} - -function parseRegistry(raw: string, source: string): RegistryState { - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch (error) { - throw new Error( - `Invalid config registry at ${source}: ${error instanceof Error ? error.message : 'unknown error'}`, - { cause: error } - ) - } - - if ( - !parsed || - typeof parsed !== 'object' || - !Array.isArray((parsed as { configs?: unknown }).configs) - ) { - throw new Error(`Invalid config registry at ${source}.`) - } - - return { - configs: (parsed as { configs: unknown[] }).configs.map((entry, index) => { - if ( - !entry || - typeof entry !== 'object' || - typeof (entry as { configPath?: unknown }).configPath !== 'string' || - typeof (entry as { addedAt?: unknown }).addedAt !== 'number' || - ('envFilePath' in entry && - (entry as { envFilePath?: unknown }).envFilePath !== undefined && - typeof (entry as { envFilePath?: unknown }).envFilePath !== 'string') - ) { - throw new Error(`Invalid config registry entry ${index + 1} in ${source}.`) - } - - return { - configPath: (entry as { configPath: string }).configPath, - addedAt: (entry as { addedAt: number }).addedAt, - envFilePath: (entry as { envFilePath?: string }).envFilePath?.trim() || undefined, - } - }), - } -} - -function parseAgentDefinitions(raw: unknown, source: string): AgentDefinition[] { - if (!Array.isArray(raw)) { - throw new Error(`agents in ${source} must be an array.`) - } - - const names = new Set() - return raw.map((entry, index) => { - assertObject(entry, `agents[${index}] in ${source}`) - assertNoUnknownKeys( - entry, - ['name', 'provider', 'model', 'systemPrompt'], - `agents[${index}] in ${source}` - ) - const name = assertNonEmptyString(entry.name, `agents[${index}].name in ${source}`) - if (names.has(name)) { - throw new Error(`Duplicate agent name "${name}" in ${source}.`) - } - names.add(name) - - const providerRaw = assertNonEmptyString( - entry.provider, - `agents[${index}].provider in ${source}` - ) - if ( - !ALLOWED_AGENT_PROVIDERS.includes(providerRaw as (typeof ALLOWED_AGENT_PROVIDERS)[number]) - ) { - throw new Error( - `Unsupported agent provider "${providerRaw}" for agent "${name}" in ${source}.` - ) - } - - return { - name, - provider: providerRaw as AgentDefinition['provider'], - model: assertOptionalString(entry.model, `agents[${index}].model in ${source}`), - systemPrompt: assertOptionalString( - entry.systemPrompt, - `agents[${index}].systemPrompt in ${source}` - ), - } - }) -} - -function parseSlackConfig(raw: unknown, source: string): SlackConfig { - assertObject(raw, `slack in ${source}`) - assertNoUnknownKeys(raw, ['botToken', 'appToken', 'channel'], `slack in ${source}`) - const botToken = assertNonEmptyString(raw.botToken, `slack.botToken in ${source}`) - const appToken = assertNonEmptyString(raw.appToken, `slack.appToken in ${source}`) - const channel = assertNonEmptyString(raw.channel, `slack.channel in ${source}`) - if (!botToken.startsWith('xoxb-')) { - throw new Error(`slack.botToken in ${source} must start with xoxb-`) - } - if (!appToken.startsWith('xapp-')) { - throw new Error(`slack.appToken in ${source} must start with xapp-`) - } - return { botToken, appToken, channel } -} - -function parseAgentLabels( - raw: unknown, - projectId: string, - source: string, - knownAgentNames: Set -): Record { - if (raw === undefined) { - return {} - } - assertObject(raw, `project.agentLabels for "${projectId}" in ${source}`) - const result: Record = {} - for (const [label, agentName] of Object.entries(raw)) { - if (typeof agentName !== 'string' || !agentName.trim()) { - throw new Error( - `project.agentLabels["${label}"] for "${projectId}" in ${source} must be a non-empty string.` - ) - } - if (knownAgentNames.size > 0 && !knownAgentNames.has(agentName.trim())) { - throw new Error( - `project.agentLabels["${label}"] for "${projectId}" in ${source} references unknown agent "${agentName}".` - ) - } - result[label] = agentName.trim() - } - return result -} - -function parseProject(raw: unknown, source: string, agents: AgentDefinition[]): ProjectConfig { - assertObject(raw, `project entry in ${source}`) - - const id = assertNonEmptyString(raw.id, `project.id in ${source}`) - const workspaceDir = assertNonEmptyString(raw.workspaceDir, `project.workspaceDir in ${source}`) - if (!path.isAbsolute(workspaceDir)) { - throw new Error(`project.workspaceDir for "${id}" in ${source} must be an absolute path.`) - } - - const pullFrom = raw.pullFrom - assertObject(pullFrom, `project.pullFrom for "${id}" in ${source}`) - const provider = assertNonEmptyString( - pullFrom.provider, - `project.pullFrom.provider for "${id}" in ${source}` - ) - if (!ALLOWED_PULL_PROVIDERS.includes(provider as ProjectConfig['pullFrom']['provider'])) { - throw new Error(`Unsupported pull provider "${provider}" for project "${id}" in ${source}.`) - } - - const pullFromFilters = pullFrom.filters - assertObject(pullFromFilters, `project.pullFrom.filters for "${id}" in ${source}`) - const filters = pullFromFilters as ProjectConfig['pullFrom']['filters'] - if (provider === PULL_PROVIDER.GITHUB) { - assertNonEmptyString(filters.owner, `project.pullFrom.filters.owner for "${id}" in ${source}`) - assertNonEmptyString(filters.repo, `project.pullFrom.filters.repo for "${id}" in ${source}`) - } - - const agentRaw = raw.agent - assertObject(agentRaw, `project.agent for "${id}" in ${source}`) - assertNoUnknownKeys( - agentRaw, - ['provider', 'model', 'name'], - `project.agent for "${id}" in ${source}` - ) - - const agentName = assertOptionalString( - agentRaw.name, - `project.agent.name for "${id}" in ${source}` - ) - const knownAgentNames = new Set(agents.map((a) => a.name)) - - let agentProvider: ProjectConfig['agent']['provider'] - let agentModel: string | undefined - let agentSystemPrompt: string | undefined - - if (agentName) { - const namedAgent = agents.find((a) => a.name === agentName) - if (!namedAgent) { - throw new Error( - `project.agent.name "${agentName}" for "${id}" in ${source} references an unknown agent.` - ) - } - agentProvider = namedAgent.provider - agentModel = - assertOptionalString(agentRaw.model, `project.agent.model for "${id}" in ${source}`) ?? - namedAgent.model - agentSystemPrompt = namedAgent.systemPrompt - } else { - const agentProviderRaw = assertNonEmptyString( - agentRaw.provider, - `project.agent.provider for "${id}" in ${source} (required when agent.name is not set)` - ) - if ( - !ALLOWED_AGENT_PROVIDERS.includes( - agentProviderRaw as (typeof ALLOWED_AGENT_PROVIDERS)[number] - ) - ) { - throw new Error( - `Unsupported agent provider "${agentProviderRaw}" for project "${id}" in ${source}. Supported: ${ALLOWED_AGENT_PROVIDERS.join(', ')}.` - ) - } - agentProvider = agentProviderRaw as ProjectConfig['agent']['provider'] - agentModel = assertOptionalString( - agentRaw.model, - `project.agent.model for "${id}" in ${source}` - ) - } - - const agentLabels = parseAgentLabels(raw.agentLabels, id, source, knownAgentNames) - - return { - id, - workspaceDir, - pullFrom: { - provider: provider as ProjectConfig['pullFrom']['provider'], - filters, - }, - agent: { - provider: agentProvider, - model: agentModel, - name: agentName, - systemPrompt: agentSystemPrompt, - }, - agentLabels: Object.keys(agentLabels).length > 0 ? agentLabels : undefined, - } -} - -async function assertWorkspaceExists(project: ProjectConfig, source: string): Promise { - const stat = await fs.stat(project.workspaceDir).catch(() => null) - - if (!stat || !stat.isDirectory()) { - throw new Error( - `project.workspaceDir for "${project.id}" in ${source} does not exist or is not a directory: ${project.workspaceDir}` - ) - } -} - -async function validateConfig( - raw: unknown, - source: string, - envFilePath?: string -): Promise { - if (!Array.isArray(raw) || raw.length === 0) { - throw new Error(`config ${source} must define a non-empty array.`) - } - - // Partition items by type: agents, slack, projects - let agents: AgentDefinition[] = [] - let slack: SlackConfig | undefined - const projectRaws: unknown[] = [] - - for (const item of raw) { - if (!item || typeof item !== 'object' || Array.isArray(item)) { - throw new Error(`config ${source} contains an invalid entry.`) - } +export async function loadConfig(): Promise { + const dataDir = resolveDataDir() + const stored = await readConfigStore(dataDir) - const record = item as Record - if ('agents' in record) { - agents = parseAgentDefinitions(record.agents, source) - } else if ('slack' in record) { - slack = parseSlackConfig(record.slack, source) - } else if ('id' in record) { - projectRaws.push(record) - } else { - throw new Error( - `config ${source} contains an unrecognized entry. Expected "agents:", "slack:", or a project entry with "id:".` - ) + for (const [key, value] of Object.entries(stored.secrets)) { + if (process.env[key] === undefined) { + process.env[key] = value } } - const projects: ProjectConfig[] = [] - const uniqueIds = new Set() - for (const projectRaw of projectRaws) { - const parsed = parseProject(projectRaw, source, agents) - const project = { ...parsed, envFilePath } - if (uniqueIds.has(project.id)) { - throw new Error(`Duplicate project id "${project.id}" in ${source}.`) - } - uniqueIds.add(project.id) - await assertWorkspaceExists(project, source) - projects.push(project) - } + const { projects, agents, slack } = validateStoredConfig(stored) return { concurrency: parseRuntimeConcurrency(), @@ -459,36 +71,3 @@ async function validateConfig( slack, } } - -function mergeConfigs(configs: AppConfig[]): AppConfig { - const merged = buildEmptyConfig() - const projectIds = new Set() - const agentNames = new Set() - - for (const config of configs) { - for (const agent of config.agents) { - if (agentNames.has(agent.name)) { - throw new Error(`Duplicate agent name "${agent.name}" across registered configs.`) - } - agentNames.add(agent.name) - merged.agents.push(agent) - } - - if (config.slack) { - if (merged.slack) { - throw new Error('Duplicate slack configuration across registered configs.') - } - merged.slack = config.slack - } - - for (const project of config.projects) { - if (projectIds.has(project.id)) { - throw new Error(`Duplicate project id "${project.id}" across registered configs.`) - } - projectIds.add(project.id) - merged.projects.push(project) - } - } - - return merged -} diff --git a/packages/orchestrator/src/config-store.ts b/packages/orchestrator/src/config-store.ts new file mode 100644 index 0000000..40aed03 --- /dev/null +++ b/packages/orchestrator/src/config-store.ts @@ -0,0 +1,68 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import type { StoredConfig } from '@parallax/common' + +const CONFIG_FILE = 'config.json' + +export function emptyStoredConfig(): StoredConfig { + return { + version: 1, + projects: [], + agents: [], + slack: null, + secrets: {}, + updatedAt: 0, + } +} + +export async function readConfigStore(dataDir: string): Promise { + const configPath = path.join(dataDir, CONFIG_FILE) + let raw: string + try { + raw = await fs.readFile(configPath, 'utf8') + } catch (error: any) { + if (error.code === 'ENOENT') { + return emptyStoredConfig() + } + throw error + } + + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch (error) { + throw new Error( + `Invalid config at ${configPath}: ${error instanceof Error ? error.message : 'unknown error'}`, + { cause: error } + ) + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Invalid config at ${configPath}: must be an object.`) + } + + const obj = parsed as Record + + return { + version: typeof obj.version === 'number' ? obj.version : 1, + projects: Array.isArray(obj.projects) ? (obj.projects as StoredConfig['projects']) : [], + agents: Array.isArray(obj.agents) ? (obj.agents as StoredConfig['agents']) : [], + slack: + obj.slack && typeof obj.slack === 'object' && !Array.isArray(obj.slack) + ? (obj.slack as StoredConfig['slack']) + : null, + secrets: + obj.secrets && typeof obj.secrets === 'object' && !Array.isArray(obj.secrets) + ? (obj.secrets as Record) + : {}, + updatedAt: typeof obj.updatedAt === 'number' ? obj.updatedAt : 0, + } +} + +export async function writeConfigStore(dataDir: string, config: StoredConfig): Promise { + await fs.mkdir(dataDir, { recursive: true }) + const configPath = path.join(dataDir, CONFIG_FILE) + const tmpPath = `${configPath}.tmp` + await fs.writeFile(tmpPath, JSON.stringify({ ...config, updatedAt: Date.now() }, null, 2)) + await fs.rename(tmpPath, configPath) +} diff --git a/packages/orchestrator/src/config-validation.ts b/packages/orchestrator/src/config-validation.ts new file mode 100644 index 0000000..6700046 --- /dev/null +++ b/packages/orchestrator/src/config-validation.ts @@ -0,0 +1,206 @@ +import path from 'node:path' +import { + AGENT_PROVIDER, + AgentDefinition, + AppConfig, + PULL_PROVIDER, + ProjectConfig, + SlackConfig, + StoredConfig, +} from '@parallax/common' + +const ALLOWED_AGENT_PROVIDERS = [ + AGENT_PROVIDER.CODEX, + AGENT_PROVIDER.GEMINI, + AGENT_PROVIDER.CLAUDE_CODE, +] as const + +const ALLOWED_PULL_PROVIDERS = [PULL_PROVIDER.LINEAR, PULL_PROVIDER.GITHUB] as const + +function assertObject(value: unknown, label: string): asserts value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object.`) + } +} + +function assertNonEmptyString(value: unknown, label: string): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${label} must be a non-empty string.`) + } + return value.trim() +} + +function assertOptionalString(value: unknown, label: string): string | undefined { + if (value === undefined) { + return undefined + } + return assertNonEmptyString(value, label) +} + +export function validateAgent( + raw: unknown, + index: number, + knownNames: Set +): AgentDefinition { + assertObject(raw, `agents[${index}]`) + const name = assertNonEmptyString(raw.name, `agents[${index}].name`) + if (knownNames.has(name)) { + throw new Error(`Duplicate agent name "${name}".`) + } + const providerRaw = assertNonEmptyString(raw.provider, `agents[${index}].provider`) + if (!ALLOWED_AGENT_PROVIDERS.includes(providerRaw as (typeof ALLOWED_AGENT_PROVIDERS)[number])) { + throw new Error(`Unsupported agent provider "${providerRaw}" for agent "${name}".`) + } + return { + name, + provider: providerRaw as AgentDefinition['provider'], + model: assertOptionalString(raw.model, `agents[${index}].model`), + systemPrompt: assertOptionalString(raw.systemPrompt, `agents[${index}].systemPrompt`), + } +} + +export function validateAgents(raw: unknown): AgentDefinition[] { + if (!Array.isArray(raw)) { + return [] + } + const names = new Set() + return raw.map((entry, i) => { + const agent = validateAgent(entry, i, names) + names.add(agent.name) + return agent + }) +} + +export function validateSlack(raw: unknown): SlackConfig { + assertObject(raw, 'slack') + const botToken = assertNonEmptyString(raw.botToken, 'slack.botToken') + const appToken = assertNonEmptyString(raw.appToken, 'slack.appToken') + const channel = assertNonEmptyString(raw.channel, 'slack.channel') + if (!botToken.startsWith('xoxb-')) { + throw new Error('slack.botToken must start with xoxb-') + } + if (!appToken.startsWith('xapp-')) { + throw new Error('slack.appToken must start with xapp-') + } + return { botToken, appToken, channel } +} + +export function validateProject(raw: unknown, agents: AgentDefinition[]): ProjectConfig { + assertObject(raw, 'project') + + const id = assertNonEmptyString(raw.id, 'project.id') + const workspaceDir = assertNonEmptyString(raw.workspaceDir, `project.workspaceDir for "${id}"`) + if (!path.isAbsolute(workspaceDir)) { + throw new Error(`project.workspaceDir for "${id}" must be an absolute path.`) + } + + const pullFrom = raw.pullFrom + assertObject(pullFrom, `project.pullFrom for "${id}"`) + const provider = assertNonEmptyString(pullFrom.provider, `project.pullFrom.provider for "${id}"`) + if (!ALLOWED_PULL_PROVIDERS.includes(provider as ProjectConfig['pullFrom']['provider'])) { + throw new Error(`Unsupported pull provider "${provider}" for project "${id}".`) + } + + const filtersRaw = pullFrom.filters + assertObject(filtersRaw, `project.pullFrom.filters for "${id}"`) + const filters = filtersRaw as ProjectConfig['pullFrom']['filters'] + if (provider === PULL_PROVIDER.GITHUB) { + assertNonEmptyString(filters.owner, `project.pullFrom.filters.owner for "${id}"`) + assertNonEmptyString(filters.repo, `project.pullFrom.filters.repo for "${id}"`) + } + + const agentRaw = raw.agent + assertObject(agentRaw, `project.agent for "${id}"`) + + const agentName = assertOptionalString(agentRaw.name, `project.agent.name for "${id}"`) + const knownAgentNames = new Set(agents.map((a) => a.name)) + + let agentProvider: ProjectConfig['agent']['provider'] + let agentModel: string | undefined + let agentSystemPrompt: string | undefined + + if (agentName) { + const namedAgent = agents.find((a) => a.name === agentName) + if (!namedAgent) { + throw new Error(`project.agent.name "${agentName}" for "${id}" references an unknown agent.`) + } + agentProvider = namedAgent.provider + agentModel = + assertOptionalString(agentRaw.model, `project.agent.model for "${id}"`) ?? namedAgent.model + agentSystemPrompt = namedAgent.systemPrompt + } else { + const agentProviderRaw = assertNonEmptyString( + agentRaw.provider, + `project.agent.provider for "${id}" (required when agent.name is not set)` + ) + if ( + !ALLOWED_AGENT_PROVIDERS.includes( + agentProviderRaw as (typeof ALLOWED_AGENT_PROVIDERS)[number] + ) + ) { + throw new Error( + `Unsupported agent provider "${agentProviderRaw}" for project "${id}". Supported: ${ALLOWED_AGENT_PROVIDERS.join(', ')}.` + ) + } + agentProvider = agentProviderRaw as ProjectConfig['agent']['provider'] + agentModel = assertOptionalString(agentRaw.model, `project.agent.model for "${id}"`) + } + + const agentLabelsRaw = raw.agentLabels + let agentLabels: Record | undefined + if (agentLabelsRaw !== undefined) { + assertObject(agentLabelsRaw, `project.agentLabels for "${id}"`) + agentLabels = {} + for (const [label, name] of Object.entries(agentLabelsRaw)) { + if (typeof name !== 'string' || !name.trim()) { + throw new Error(`project.agentLabels["${label}"] for "${id}" must be a non-empty string.`) + } + if (knownAgentNames.size > 0 && !knownAgentNames.has(name.trim())) { + throw new Error( + `project.agentLabels["${label}"] for "${id}" references unknown agent "${name}".` + ) + } + agentLabels[label] = name.trim() + } + if (Object.keys(agentLabels).length === 0) { + agentLabels = undefined + } + } + + return { + id, + workspaceDir, + pullFrom: { + provider: provider as ProjectConfig['pullFrom']['provider'], + filters, + }, + agent: { + provider: agentProvider, + model: agentModel, + name: agentName, + systemPrompt: agentSystemPrompt, + }, + agentLabels, + } +} + +export function validateStoredConfig( + stored: StoredConfig +): Pick { + const agents = validateAgents(stored.agents) + + const projectIds = new Set() + const projects: ProjectConfig[] = [] + for (const raw of stored.projects) { + const project = validateProject(raw, agents) + if (projectIds.has(project.id)) { + throw new Error(`Duplicate project id "${project.id}".`) + } + projectIds.add(project.id) + projects.push(project) + } + + const slack = stored.slack ? validateSlack(stored.slack) : undefined + + return { projects, agents, slack } +} diff --git a/packages/orchestrator/src/index.ts b/packages/orchestrator/src/index.ts index 1276616..0fa6e3f 100644 --- a/packages/orchestrator/src/index.ts +++ b/packages/orchestrator/src/index.ts @@ -11,7 +11,7 @@ import { type Task, sleep, } from '@parallax/common' -import { loadConfig } from './config-loader.js' +import { loadConfig, resolveDataDir } from './config-loader.js' import { logger, setIo, setLogLevels } from './logger.js' import { HostExecutor } from '@parallax/common/executor' import { GitHubReviewService } from './github/review-service.js' @@ -59,6 +59,7 @@ async function startRuntimeServers( activeTasks, canceledTasks, activeWorktrees, + dataDir: resolveDataDir(), }) const config = getConfig() diff --git a/packages/orchestrator/src/runtime/api-server.ts b/packages/orchestrator/src/runtime/api-server.ts index 065de66..18ca2e2 100644 --- a/packages/orchestrator/src/runtime/api-server.ts +++ b/packages/orchestrator/src/runtime/api-server.ts @@ -1,6 +1,6 @@ import cors from '@fastify/cors' import Fastify, { type FastifyInstance } from 'fastify' -import { AppConfig, TASK_STATUS, TaskPlanState } from '@parallax/common' +import { AppConfig, StoredConfig, TASK_STATUS, TaskPlanState } from '@parallax/common' import { dbService } from '../database.js' import { resetTaskRuntimeState } from '../logger.js' import { GitService } from '../git-service.js' @@ -17,6 +17,8 @@ import { type RetryMode, } from './api/request-parsers.js' import { serializeTaskForApi } from './api/task-response.js' +import { readConfigStore, writeConfigStore } from '../config-store.js' +import { validateProject, validateAgent, validateSlack } from '../config-validation.js' type TaskDiffFile = { path: string @@ -31,6 +33,7 @@ type ApiServerDependencies = { activeTasks: Set canceledTasks: Set activeWorktrees: Map + dataDir: string } function sanitizeConfigForApi(config: AppConfig): AppConfig { @@ -72,8 +75,18 @@ export async function createApiServer( activeTasks, canceledTasks, activeWorktrees, + dataDir, } = dependencies + async function mutateConfig(updater: (cfg: StoredConfig) => StoredConfig): Promise { + const current = await readConfigStore(dataDir) + const updated = updater(current) + await writeConfigStore(dataDir, updated) + const newConfig = await reloadRuntime() + emitConfigUpdated() + return newConfig + } + await fastify.register(cors, { origin: /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/, }) @@ -358,5 +371,213 @@ export async function createApiServer( } }) + // --- Projects CRUD --- + + fastify.get('/projects', async () => ({ projects: getConfig().projects })) + + fastify.post('/projects', async (request, reply) => { + try { + const body = request.body as Record + const existing = getConfig() + const project = validateProject(body, existing.agents) + if (existing.projects.some((p) => p.id === project.id)) { + return reply.status(409).send({ error: `Project "${project.id}" already exists.` }) + } + await mutateConfig((cfg) => ({ ...cfg, projects: [...cfg.projects, project] })) + return { ok: true, project } + } catch (error) { + return reply + .status(400) + .send({ error: error instanceof Error ? error.message : String(error) }) + } + }) + + fastify.put('/projects/:projectId', async (request, reply) => { + try { + const { projectId } = request.params as { projectId: string } + const body = request.body as Record + const existing = getConfig() + if (!existing.projects.some((p) => p.id === projectId)) { + return reply.status(404).send({ error: `Project "${projectId}" not found.` }) + } + const project = validateProject({ ...body, id: projectId }, existing.agents) + await mutateConfig((cfg) => ({ + ...cfg, + projects: cfg.projects.map((p) => (p.id === projectId ? project : p)), + })) + return { ok: true, project } + } catch (error) { + return reply + .status(400) + .send({ error: error instanceof Error ? error.message : String(error) }) + } + }) + + fastify.delete('/projects/:projectId', async (request, reply) => { + const { projectId } = request.params as { projectId: string } + if (!getConfig().projects.some((p) => p.id === projectId)) { + return reply.status(404).send({ error: `Project "${projectId}" not found.` }) + } + const inFlight = dbService + .listTasks() + .filter( + (t: { id: string; projectId: string }) => t.projectId === projectId && activeTasks.has(t.id) + ) + if (inFlight.length > 0) { + return reply.status(409).send({ + error: `Project "${projectId}" has ${inFlight.length} active task(s). Cancel them first.`, + }) + } + await mutateConfig((cfg) => ({ + ...cfg, + projects: cfg.projects.filter((p) => p.id !== projectId), + })) + return { ok: true } + }) + + // --- Agents CRUD --- + + fastify.get('/agents', async () => ({ agents: getConfig().agents })) + + fastify.post('/agents', async (request, reply) => { + try { + const body = request.body as Record + const existing = getConfig() + const knownNames = new Set(existing.agents.map((a) => a.name)) + const agent = validateAgent(body, 0, knownNames) + if (existing.agents.some((a) => a.name === agent.name)) { + return reply.status(409).send({ error: `Agent "${agent.name}" already exists.` }) + } + await mutateConfig((cfg) => ({ ...cfg, agents: [...cfg.agents, agent] })) + return { ok: true, agent } + } catch (error) { + return reply + .status(400) + .send({ error: error instanceof Error ? error.message : String(error) }) + } + }) + + fastify.put('/agents/:agentName', async (request, reply) => { + try { + const { agentName } = request.params as { agentName: string } + const body = request.body as Record + const existing = getConfig() + if (!existing.agents.some((a) => a.name === agentName)) { + return reply.status(404).send({ error: `Agent "${agentName}" not found.` }) + } + const knownNames = new Set(existing.agents.map((a) => a.name)) + knownNames.delete(agentName) + const agent = validateAgent({ ...body, name: agentName }, 0, knownNames) + await mutateConfig((cfg) => ({ + ...cfg, + agents: cfg.agents.map((a) => (a.name === agentName ? agent : a)), + })) + return { ok: true, agent } + } catch (error) { + return reply + .status(400) + .send({ error: error instanceof Error ? error.message : String(error) }) + } + }) + + fastify.delete('/agents/:agentName', async (request, reply) => { + const { agentName } = request.params as { agentName: string } + if (!getConfig().agents.some((a) => a.name === agentName)) { + return reply.status(404).send({ error: `Agent "${agentName}" not found.` }) + } + await mutateConfig((cfg) => ({ + ...cfg, + agents: cfg.agents.filter((a) => a.name !== agentName), + })) + return { ok: true } + }) + + // --- Slack integration --- + + fastify.get('/integrations/slack', async () => { + const slack = getConfig().slack + if (!slack) { + return { configured: false } + } + return { configured: true, channel: slack.channel } + }) + + fastify.put('/integrations/slack', async (request, reply) => { + try { + const body = request.body as Record + const existing = getConfig().slack + // Allow omitting tokens when updating an existing connection (keep current values) + const merged = + existing && (!body.botToken || !body.appToken) + ? { + botToken: body.botToken || existing.botToken, + appToken: body.appToken || existing.appToken, + channel: body.channel, + } + : body + const slack = validateSlack(merged) + await mutateConfig((cfg) => ({ ...cfg, slack })) + return { ok: true } + } catch (error) { + return reply + .status(400) + .send({ error: error instanceof Error ? error.message : String(error) }) + } + }) + + fastify.delete('/integrations/slack', async () => { + await mutateConfig((cfg) => ({ ...cfg, slack: null })) + return { ok: true } + }) + + // --- Secrets --- + + fastify.get('/secrets', async () => { + const stored = await readConfigStore(dataDir) + const masked = Object.fromEntries(Object.keys(stored.secrets).map((k) => [k, '***'])) + return { secrets: masked } + }) + + fastify.patch('/secrets/:key', async (request, reply) => { + try { + const { key } = request.params as { key: string } + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + return reply.status(400).send({ + error: + 'Secret key must be a valid environment variable name (letters, digits, underscores; cannot start with a digit).', + }) + } + const body = request.body as Record + if (typeof body.value !== 'string') { + return reply.status(400).send({ error: 'value must be a string.' }) + } + if (!body.value) { + return reply.status(400).send({ error: 'value must not be empty.' }) + } + await mutateConfig((cfg) => ({ + ...cfg, + secrets: { ...cfg.secrets, [key]: body.value as string }, + })) + return { ok: true } + } catch (error) { + return reply + .status(400) + .send({ error: error instanceof Error ? error.message : String(error) }) + } + }) + + fastify.delete('/secrets/:key', async (request, reply) => { + const { key } = request.params as { key: string } + const stored = await readConfigStore(dataDir) + if (!(key in stored.secrets)) { + return reply.status(404).send({ error: `Secret "${key}" not found.` }) + } + await mutateConfig((cfg) => { + const { [key]: _removed, ...rest } = cfg.secrets + return { ...cfg, secrets: rest } + }) + return { ok: true } + }) + return fastify } diff --git a/packages/orchestrator/test/api-server.test.ts b/packages/orchestrator/test/api-server.test.ts index 73aba33..3e5a385 100644 --- a/packages/orchestrator/test/api-server.test.ts +++ b/packages/orchestrator/test/api-server.test.ts @@ -29,18 +29,30 @@ vi.mock('../src/task-lifecycle.js', () => ({ vi.mock('../src/runtime/diagnostics.js', () => ({ readOrchestratorErrors: vi.fn().mockResolvedValue([]), })) +vi.mock('../src/config-store.js', () => ({ + readConfigStore: vi.fn().mockResolvedValue({ + version: 1, + projects: [], + agents: [], + slack: null, + secrets: {}, + updatedAt: 0, + }), + writeConfigStore: vi.fn().mockResolvedValue(undefined), +})) // ── helpers ────────────────────────────────────────────────────────────────── function buildDependencies(overrides: Record = {}) { return { - getConfig: vi.fn().mockReturnValue({ projects: [] }), - reloadRuntime: vi.fn().mockResolvedValue({ projects: [] }), + getConfig: vi.fn().mockReturnValue({ projects: [], agents: [], slack: null }), + reloadRuntime: vi.fn().mockResolvedValue({ projects: [], agents: [], slack: null }), triggerPullRequestReview: vi.fn(), gitService: { getWorktreeChangedFiles: vi.fn(), getTaskUnifiedDiff: vi.fn() } as any, activeTasks: new Set(), canceledTasks: new Set(), activeWorktrees: new Map(), + dataDir: '/tmp/test-parallax', ...overrides, } } @@ -298,3 +310,200 @@ describe('POST /tasks/:taskId/cancel', () => { expect(res.statusCode).toBe(409) }) }) + +describe('DELETE /projects/:projectId', () => { + let server: FastifyInstance + let dbService: any + + beforeEach(async () => { + const mod = await import('../src/database.js') + dbService = mod.dbService + server = await createApiServer( + buildDependencies({ + getConfig: vi.fn().mockReturnValue({ + projects: [{ id: 'proj-1' }], + agents: [], + slack: null, + }), + }) + ) + }) + + afterEach(async () => { + await server.close() + }) + + it('returns 404 when project not found', async () => { + const res = await server.inject({ method: 'DELETE', url: '/projects/unknown' }) + expect(res.statusCode).toBe(404) + expect(JSON.parse(res.body).error).toContain('"unknown" not found') + }) + + it('returns 409 when project has active tasks', async () => { + vi.mocked(dbService.listTasks).mockReturnValue([{ id: 'task-1', projectId: 'proj-1' }]) + const localServer = await createApiServer( + buildDependencies({ + getConfig: vi + .fn() + .mockReturnValue({ projects: [{ id: 'proj-1' }], agents: [], slack: null }), + activeTasks: new Set(['task-1']), + }) + ) + const res = await localServer.inject({ method: 'DELETE', url: '/projects/proj-1' }) + expect(res.statusCode).toBe(409) + expect(JSON.parse(res.body).error).toContain('active task') + await localServer.close() + }) + + it('returns 200 when project has no active tasks', async () => { + vi.mocked(dbService.listTasks).mockReturnValue([]) + const res = await server.inject({ method: 'DELETE', url: '/projects/proj-1' }) + expect(res.statusCode).toBe(200) + expect(JSON.parse(res.body)).toEqual({ ok: true }) + }) + + it('returns 200 when task belongs to project but is not active', async () => { + vi.mocked(dbService.listTasks).mockReturnValue([{ id: 'task-1', projectId: 'proj-1' }]) + // task-1 is not in activeTasks + const res = await server.inject({ method: 'DELETE', url: '/projects/proj-1' }) + expect(res.statusCode).toBe(200) + }) +}) + +describe('PATCH /secrets/:key', () => { + let server: FastifyInstance + + beforeEach(async () => { + server = await createApiServer(buildDependencies()) + }) + + afterEach(async () => { + await server.close() + }) + + it('returns 400 for key starting with a digit', async () => { + const res = await server.inject({ + method: 'PATCH', + url: '/secrets/1INVALID', + payload: { value: 'secret' }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toContain('environment variable name') + }) + + it('returns 400 for key containing spaces', async () => { + const res = await server.inject({ + method: 'PATCH', + url: '/secrets/MY%20KEY', + payload: { value: 'secret' }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toContain('environment variable name') + }) + + it('returns 400 for key containing hyphens', async () => { + const res = await server.inject({ + method: 'PATCH', + url: '/secrets/MY-KEY', + payload: { value: 'secret' }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toContain('environment variable name') + }) + + it('returns 400 when value is missing', async () => { + const res = await server.inject({ + method: 'PATCH', + url: '/secrets/VALID_KEY', + payload: {}, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toContain('value must be a string') + }) + + it('returns 400 when value is empty', async () => { + const res = await server.inject({ + method: 'PATCH', + url: '/secrets/VALID_KEY', + payload: { value: '' }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toContain('must not be empty') + }) + + it('returns 200 for valid snake_case key', async () => { + const res = await server.inject({ + method: 'PATCH', + url: '/secrets/LINEAR_API_KEY', + payload: { value: 'lin_abc123' }, + }) + expect(res.statusCode).toBe(200) + expect(JSON.parse(res.body)).toEqual({ ok: true }) + }) + + it('returns 200 for lowercase key', async () => { + const res = await server.inject({ + method: 'PATCH', + url: '/secrets/my_token', + payload: { value: 'somevalue' }, + }) + expect(res.statusCode).toBe(200) + }) +}) + +describe('PUT /integrations/slack', () => { + let server: FastifyInstance + + afterEach(async () => { + await server.close() + }) + + it('returns 400 when bot token is missing on new connection', async () => { + server = await createApiServer(buildDependencies()) + const res = await server.inject({ + method: 'PUT', + url: '/integrations/slack', + payload: { appToken: 'xapp-1', channel: '#eng' }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toContain('botToken') + }) + + it('returns 200 when tokens are omitted on update with existing config', async () => { + server = await createApiServer( + buildDependencies({ + getConfig: vi.fn().mockReturnValue({ + projects: [], + agents: [], + slack: { botToken: 'xoxb-real', appToken: 'xapp-real', channel: '#old' }, + }), + }) + ) + const res = await server.inject({ + method: 'PUT', + url: '/integrations/slack', + payload: { channel: '#new-channel' }, + }) + expect(res.statusCode).toBe(200) + expect(JSON.parse(res.body)).toEqual({ ok: true }) + }) + + it('returns 400 when new token has invalid prefix even on update', async () => { + server = await createApiServer( + buildDependencies({ + getConfig: vi.fn().mockReturnValue({ + projects: [], + agents: [], + slack: { botToken: 'xoxb-real', appToken: 'xapp-real', channel: '#old' }, + }), + }) + ) + const res = await server.inject({ + method: 'PUT', + url: '/integrations/slack', + payload: { botToken: 'invalid-token', channel: '#eng' }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toContain('xoxb-') + }) +}) diff --git a/packages/orchestrator/test/config-loader.test.ts b/packages/orchestrator/test/config-loader.test.ts index 467d381..40da240 100644 --- a/packages/orchestrator/test/config-loader.test.ts +++ b/packages/orchestrator/test/config-loader.test.ts @@ -34,8 +34,31 @@ afterEach(async () => { } }) +function makeStoredConfig(overrides: object = {}) { + return JSON.stringify( + { + version: 1, + projects: [], + agents: [], + slack: null, + secrets: {}, + updatedAt: Date.now(), + ...overrides, + }, + null, + 2 + ) +} + +async function setupDataDir(root: string) { + const dataDir = path.join(root, '.parallax') + await fs.mkdir(dataDir, { recursive: true }) + process.env.PARALLAX_DATA_DIR = dataDir + return dataDir +} + describe('config-loader', () => { - it('returns empty config when registry is missing', async () => { + it('returns empty config when config.json is missing', async () => { const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'parallax-config-')) process.env.PARALLAX_DATA_DIR = dataDir @@ -46,186 +69,114 @@ describe('config-loader', () => { expect(config.concurrency).toBe(2) }) - it('loads a strict valid config', async () => { + it('loads a valid config from config.json', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'parallax-config-')) - const dataDir = path.join(root, '.parallax') const workspace = path.join(root, 'workspace') - const configPath = path.join(root, 'parallax.yml') - const registryPath = path.join(dataDir, 'registry.json') await fs.mkdir(workspace, { recursive: true }) - await fs.mkdir(dataDir, { recursive: true }) - await fs.writeFile( - configPath, - [ - '- id: test', - ` workspaceDir: ${workspace}`, - ' pullFrom:', - ' provider: github', - ' filters:', - ' owner: org', - ' repo: repo', - ' agent:', - ' provider: codex', - ].join('\n') - ) + const dataDir = await setupDataDir(root) + await fs.writeFile( - registryPath, - JSON.stringify({ configs: [{ configPath, addedAt: Date.now() }] }, null, 2) + path.join(dataDir, 'config.json'), + makeStoredConfig({ + projects: [ + { + id: 'test', + workspaceDir: workspace, + pullFrom: { provider: 'github', filters: { owner: 'org', repo: 'repo' } }, + agent: { provider: 'codex' }, + }, + ], + }) ) - process.env.PARALLAX_DATA_DIR = dataDir process.env.PARALLAX_CONCURRENCY = '4' process.env.PARALLAX_SERVER_API_PORT = '4100' process.env.PARALLAX_SERVER_UI_PORT = '4101' - process.chdir(root) const config = await loadConfig() expect(config.projects).toHaveLength(1) + expect(config.projects[0].id).toBe('test') expect(config.concurrency).toBe(4) expect(config.server.apiPort).toBe(4100) expect(config.server.uiPort).toBe(4101) - expect(config.projects[0].id).toBe('test') - }) - - it('attaches registered env file path to the project config', async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'parallax-config-')) - const dataDir = path.join(root, '.parallax') - const workspace = path.join(root, 'workspace') - const configPath = path.join(root, 'parallax.yml') - const envFilePath = path.join(root, '.env') - const registryPath = path.join(dataDir, 'registry.json') - await fs.mkdir(workspace, { recursive: true }) - await fs.mkdir(dataDir, { recursive: true }) - await fs.writeFile(envFilePath, 'TEST_VALUE=1\n') - await fs.writeFile( - configPath, - [ - '- id: test', - ` workspaceDir: ${workspace}`, - ' pullFrom:', - ' provider: github', - ' filters:', - ' owner: org', - ' repo: repo', - ' agent:', - ' provider: codex', - ].join('\n') - ) - await fs.writeFile( - registryPath, - JSON.stringify({ configs: [{ configPath, envFilePath, addedAt: Date.now() }] }, null, 2) - ) - - process.env.PARALLAX_DATA_DIR = dataDir - process.chdir(root) - - const config = await loadConfig() - expect(config.projects[0].envFilePath).toBe(envFilePath) }) it('accepts claude-code as a supported agent provider', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'parallax-config-')) - const dataDir = path.join(root, '.parallax') const workspace = path.join(root, 'workspace') - const configPath = path.join(root, 'parallax.yml') - const registryPath = path.join(dataDir, 'registry.json') await fs.mkdir(workspace, { recursive: true }) - await fs.mkdir(dataDir, { recursive: true }) - await fs.writeFile( - configPath, - [ - '- id: test', - ` workspaceDir: ${workspace}`, - ' pullFrom:', - ' provider: github', - ' filters:', - ' owner: org', - ' repo: repo', - ' agent:', - ' provider: claude-code', - ].join('\n') - ) + const dataDir = await setupDataDir(root) + await fs.writeFile( - registryPath, - JSON.stringify({ configs: [{ configPath, addedAt: Date.now() }] }, null, 2) + path.join(dataDir, 'config.json'), + makeStoredConfig({ + projects: [ + { + id: 'test', + workspaceDir: workspace, + pullFrom: { provider: 'github', filters: { owner: 'org', repo: 'repo' } }, + agent: { provider: 'claude-code' }, + }, + ], + }) ) - process.env.PARALLAX_DATA_DIR = dataDir - process.chdir(root) - const config = await loadConfig() expect(config.projects[0].agent.provider).toBe('claude-code') }) it('rejects unsupported agent provider', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'parallax-config-')) - const dataDir = path.join(root, '.parallax') const workspace = path.join(root, 'workspace') - const configPath = path.join(root, 'parallax.yml') - const registryPath = path.join(dataDir, 'registry.json') await fs.mkdir(workspace, { recursive: true }) - await fs.mkdir(dataDir, { recursive: true }) - await fs.writeFile( - configPath, - [ - '- id: test', - ` workspaceDir: ${workspace}`, - ' pullFrom:', - ' provider: github', - ' filters:', - ' owner: org', - ' repo: repo', - ' agent:', - ' provider: unknown-agent', - ].join('\n') - ) + const dataDir = await setupDataDir(root) + await fs.writeFile( - registryPath, - JSON.stringify({ configs: [{ configPath, addedAt: Date.now() }] }, null, 2) + path.join(dataDir, 'config.json'), + makeStoredConfig({ + projects: [ + { + id: 'test', + workspaceDir: workspace, + pullFrom: { provider: 'github', filters: { owner: 'org', repo: 'repo' } }, + agent: { provider: 'unknown-agent' }, + }, + ], + }) ) - process.env.PARALLAX_DATA_DIR = dataDir - process.chdir(root) - await expect(loadConfig()).rejects.toThrow('Unsupported agent provider "unknown-agent"') }) - it('loads named agents defined in agents: item', async () => { + it('loads named agents from agents array', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'parallax-config-')) - const dataDir = path.join(root, '.parallax') const workspace = path.join(root, 'workspace') - const configPath = path.join(root, 'parallax.yml') - const registryPath = path.join(dataDir, 'registry.json') await fs.mkdir(workspace, { recursive: true }) - await fs.mkdir(dataDir, { recursive: true }) - await fs.writeFile( - configPath, - [ - '- agents:', - ' - name: developer', - ' provider: claude-code', - ' model: claude-opus-4-5', - ' systemPrompt: "You are a senior engineer."', - `- id: test`, - ` workspaceDir: ${workspace}`, - ' pullFrom:', - ' provider: github', - ' filters:', - ' owner: org', - ' repo: repo', - ' agent:', - ' name: developer', - ].join('\n') - ) + const dataDir = await setupDataDir(root) + await fs.writeFile( - registryPath, - JSON.stringify({ configs: [{ configPath, addedAt: Date.now() }] }, null, 2) + path.join(dataDir, 'config.json'), + makeStoredConfig({ + agents: [ + { + name: 'developer', + provider: 'claude-code', + model: 'claude-opus-4-5', + systemPrompt: 'You are a senior engineer.', + }, + ], + projects: [ + { + id: 'test', + workspaceDir: workspace, + pullFrom: { provider: 'github', filters: { owner: 'org', repo: 'repo' } }, + agent: { name: 'developer' }, + }, + ], + }) ) - process.env.PARALLAX_DATA_DIR = dataDir - process.chdir(root) const config = await loadConfig() - expect(config.agents).toHaveLength(1) expect(config.agents[0].name).toBe('developer') expect(config.agents[0].provider).toBe('claude-code') @@ -236,123 +187,85 @@ describe('config-loader', () => { expect(config.projects[0].agent.systemPrompt).toBe('You are a senior engineer.') }) - it('loads agentLabels mapping on a project entry', async () => { + it('loads agentLabels mapping on a project', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'parallax-config-')) - const dataDir = path.join(root, '.parallax') const workspace = path.join(root, 'workspace') - const configPath = path.join(root, 'parallax.yml') - const registryPath = path.join(dataDir, 'registry.json') await fs.mkdir(workspace, { recursive: true }) - await fs.mkdir(dataDir, { recursive: true }) - await fs.writeFile( - configPath, - [ - '- agents:', - ' - name: developer', - ' provider: codex', - ' - name: reviewer', - ' provider: gemini', - `- id: test`, - ` workspaceDir: ${workspace}`, - ' pullFrom:', - ' provider: github', - ' filters:', - ' owner: org', - ' repo: repo', - ' agent:', - ' name: developer', - ' agentLabels:', - ' ai-frontend: reviewer', - ' ai-security: reviewer', - ].join('\n') - ) + const dataDir = await setupDataDir(root) + await fs.writeFile( - registryPath, - JSON.stringify({ configs: [{ configPath, addedAt: Date.now() }] }, null, 2) + path.join(dataDir, 'config.json'), + makeStoredConfig({ + agents: [ + { name: 'developer', provider: 'codex' }, + { name: 'reviewer', provider: 'gemini' }, + ], + projects: [ + { + id: 'test', + workspaceDir: workspace, + pullFrom: { provider: 'github', filters: { owner: 'org', repo: 'repo' } }, + agent: { name: 'developer' }, + agentLabels: { 'ai-frontend': 'reviewer', 'ai-security': 'reviewer' }, + }, + ], + }) ) - process.env.PARALLAX_DATA_DIR = dataDir - process.chdir(root) const config = await loadConfig() - expect(config.projects[0].agentLabels).toEqual({ 'ai-frontend': 'reviewer', 'ai-security': 'reviewer', }) }) - it('rejects an agentLabels value that references an unknown agent', async () => { + it('rejects agentLabels value referencing an unknown agent', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'parallax-config-')) - const dataDir = path.join(root, '.parallax') const workspace = path.join(root, 'workspace') - const configPath = path.join(root, 'parallax.yml') - const registryPath = path.join(dataDir, 'registry.json') await fs.mkdir(workspace, { recursive: true }) - await fs.mkdir(dataDir, { recursive: true }) - await fs.writeFile( - configPath, - [ - '- agents:', - ' - name: developer', - ' provider: codex', - `- id: test`, - ` workspaceDir: ${workspace}`, - ' pullFrom:', - ' provider: github', - ' filters:', - ' owner: org', - ' repo: repo', - ' agent:', - ' name: developer', - ' agentLabels:', - ' ai-frontend: does-not-exist', - ].join('\n') - ) + const dataDir = await setupDataDir(root) + await fs.writeFile( - registryPath, - JSON.stringify({ configs: [{ configPath, addedAt: Date.now() }] }, null, 2) + path.join(dataDir, 'config.json'), + makeStoredConfig({ + agents: [{ name: 'developer', provider: 'codex' }], + projects: [ + { + id: 'test', + workspaceDir: workspace, + pullFrom: { provider: 'github', filters: { owner: 'org', repo: 'repo' } }, + agent: { name: 'developer' }, + agentLabels: { 'ai-frontend': 'does-not-exist' }, + }, + ], + }) ) - process.env.PARALLAX_DATA_DIR = dataDir - process.chdir(root) await expect(loadConfig()).rejects.toThrow('unknown agent "does-not-exist"') }) - it('loads slack config from slack: item', async () => { + it('loads slack config', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'parallax-config-')) - const dataDir = path.join(root, '.parallax') const workspace = path.join(root, 'workspace') - const configPath = path.join(root, 'parallax.yml') - const registryPath = path.join(dataDir, 'registry.json') await fs.mkdir(workspace, { recursive: true }) - await fs.mkdir(dataDir, { recursive: true }) - await fs.writeFile( - configPath, - [ - '- slack:', - ' botToken: xoxb-test-token', - ' appToken: xapp-test-token', - ' channel: "#ai-tasks"', - `- id: test`, - ` workspaceDir: ${workspace}`, - ' pullFrom:', - ' provider: github', - ' filters:', - ' owner: org', - ' repo: repo', - ' agent:', - ' provider: codex', - ].join('\n') - ) + const dataDir = await setupDataDir(root) + await fs.writeFile( - registryPath, - JSON.stringify({ configs: [{ configPath, addedAt: Date.now() }] }, null, 2) + path.join(dataDir, 'config.json'), + makeStoredConfig({ + slack: { botToken: 'xoxb-test-token', appToken: 'xapp-test-token', channel: '#ai-tasks' }, + projects: [ + { + id: 'test', + workspaceDir: workspace, + pullFrom: { provider: 'github', filters: { owner: 'org', repo: 'repo' } }, + agent: { provider: 'codex' }, + }, + ], + }) ) - process.env.PARALLAX_DATA_DIR = dataDir - process.chdir(root) const config = await loadConfig() - expect(config.slack).toEqual({ botToken: 'xoxb-test-token', appToken: 'xapp-test-token', @@ -360,115 +273,118 @@ describe('config-loader', () => { }) }) - it('rejects slack botToken that does not start with xoxb-', async () => { + it('rejects slack botToken not starting with xoxb-', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'parallax-config-')) - const dataDir = path.join(root, '.parallax') const workspace = path.join(root, 'workspace') - const configPath = path.join(root, 'parallax.yml') - const registryPath = path.join(dataDir, 'registry.json') await fs.mkdir(workspace, { recursive: true }) - await fs.mkdir(dataDir, { recursive: true }) - await fs.writeFile( - configPath, - [ - '- slack:', - ' botToken: bad-token', - ' appToken: xapp-test-token', - ' channel: "#ai-tasks"', - `- id: test`, - ` workspaceDir: ${workspace}`, - ' pullFrom:', - ' provider: github', - ' filters:', - ' owner: org', - ' repo: repo', - ' agent:', - ' provider: codex', - ].join('\n') - ) + const dataDir = await setupDataDir(root) + await fs.writeFile( - registryPath, - JSON.stringify({ configs: [{ configPath, addedAt: Date.now() }] }, null, 2) + path.join(dataDir, 'config.json'), + makeStoredConfig({ + slack: { botToken: 'bad-token', appToken: 'xapp-test-token', channel: '#ai-tasks' }, + projects: [ + { + id: 'test', + workspaceDir: workspace, + pullFrom: { provider: 'github', filters: { owner: 'org', repo: 'repo' } }, + agent: { provider: 'codex' }, + }, + ], + }) ) - process.env.PARALLAX_DATA_DIR = dataDir - process.chdir(root) await expect(loadConfig()).rejects.toThrow('xoxb-') }) - it('rejects duplicate agent names across registered configs', async () => { + it('rejects duplicate project ids', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'parallax-config-')) - const dataDir = path.join(root, '.parallax') const workspace = path.join(root, 'workspace') - const configPath1 = path.join(root, 'parallax1.yml') - const configPath2 = path.join(root, 'parallax2.yml') - const registryPath = path.join(dataDir, 'registry.json') await fs.mkdir(workspace, { recursive: true }) - await fs.mkdir(dataDir, { recursive: true }) - const agentBlock = ['- agents:', ' - name: developer', ' provider: codex'].join('\n') - const projectBlock = [ - `- id: test-X`, - ` workspaceDir: ${workspace}`, - ' pullFrom:', - ' provider: github', - ' filters:', - ' owner: org', - ' repo: repo', - ' agent:', - ' provider: codex', - ].join('\n') - await fs.writeFile(configPath1, `${agentBlock}\n${projectBlock.replace('test-X', 'test-1')}`) - await fs.writeFile(configPath2, `${agentBlock}\n${projectBlock.replace('test-X', 'test-2')}`) + const dataDir = await setupDataDir(root) + + const project = { + id: 'test', + workspaceDir: workspace, + pullFrom: { provider: 'github', filters: { owner: 'org', repo: 'repo' } }, + agent: { provider: 'codex' }, + } + await fs.writeFile( - registryPath, - JSON.stringify( - { - configs: [ - { configPath: configPath1, addedAt: Date.now() }, - { configPath: configPath2, addedAt: Date.now() }, - ], - }, - null, - 2 - ) + path.join(dataDir, 'config.json'), + makeStoredConfig({ projects: [project, project] }) ) - process.env.PARALLAX_DATA_DIR = dataDir - process.chdir(root) - await expect(loadConfig()).rejects.toThrow('Duplicate agent name "developer"') + await expect(loadConfig()).rejects.toThrow('Duplicate project id "test"') }) - it('rejects unknown agent fields', async () => { + it('rejects duplicate agent names', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'parallax-config-')) - const dataDir = path.join(root, '.parallax') const workspace = path.join(root, 'workspace') - const configPath = path.join(root, 'parallax.yml') - const registryPath = path.join(dataDir, 'registry.json') await fs.mkdir(workspace, { recursive: true }) - await fs.mkdir(dataDir, { recursive: true }) + const dataDir = await setupDataDir(root) + + const agent = { name: 'developer', provider: 'codex' } + await fs.writeFile( - configPath, - [ - '- id: test', - ` workspaceDir: ${workspace}`, - ' pullFrom:', - ' provider: github', - ' filters:', - ' owner: org', - ' repo: repo', - ' agent:', - ' provider: codex', - ' sandbox: true', - ].join('\n') + path.join(dataDir, 'config.json'), + makeStoredConfig({ + agents: [agent, agent], + projects: [ + { + id: 'test', + workspaceDir: workspace, + pullFrom: { provider: 'github', filters: { owner: 'org', repo: 'repo' } }, + agent: { provider: 'codex' }, + }, + ], + }) ) + + await expect(loadConfig()).rejects.toThrow('Duplicate agent name "developer"') + }) + + it('injects secrets into process.env without overwriting existing values', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'parallax-config-')) + const workspace = path.join(root, 'workspace') + await fs.mkdir(workspace, { recursive: true }) + const dataDir = await setupDataDir(root) + + process.env.EXISTING_KEY = 'existing' + delete process.env.NEW_KEY + await fs.writeFile( - registryPath, - JSON.stringify({ configs: [{ configPath, addedAt: Date.now() }] }, null, 2) + path.join(dataDir, 'config.json'), + makeStoredConfig({ + secrets: { EXISTING_KEY: 'should-not-overwrite', NEW_KEY: 'injected' }, + projects: [ + { + id: 'test', + workspaceDir: workspace, + pullFrom: { provider: 'github', filters: { owner: 'org', repo: 'repo' } }, + agent: { provider: 'codex' }, + }, + ], + }) ) - process.env.PARALLAX_DATA_DIR = dataDir - process.chdir(root) + await loadConfig() + expect(process.env.EXISTING_KEY).toBe('existing') + expect(process.env.NEW_KEY).toBe('injected') - await expect(loadConfig()).rejects.toThrow('project.agent for "test" in') + delete process.env.EXISTING_KEY + delete process.env.NEW_KEY + }) + + it('returns empty config when config.json is empty object', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'parallax-config-')) + const dataDir = await setupDataDir(root) + + await fs.writeFile(path.join(dataDir, 'config.json'), '{}') + + const config = await loadConfig() + expect(config.projects).toHaveLength(0) + expect(config.agents).toHaveLength(0) + expect(config.slack).toBeUndefined() }) }) diff --git a/parallax.example.yml b/parallax.example.yml deleted file mode 100644 index e0c43d9..0000000 --- a/parallax.example.yml +++ /dev/null @@ -1,12 +0,0 @@ -- id: www - workspaceDir: /Users/maxi/projects/www - pullFrom: - provider: github - filters: - owner: maxigimenez - repo: wwww - state: open - labels: [ai-ready] - agent: - provider: codex - model: gpt-5.4 From b6f33a4247e0968e7369271cd5b9ce97985e573c Mon Sep 17 00:00:00 2001 From: Maxi Gimenez Date: Fri, 22 May 2026 15:26:54 +0100 Subject: [PATCH 2/8] feat(cli): add init wizard, open, and status commands; remove register/pending Replace the YAML-based register/unregister/pending commands with: - parallax init: interactive @clack/prompts wizard that writes config.json - parallax open: reads running.json and opens the dashboard in the browser - parallax status: reads running.json + DB directly, no orchestrator required Remove parallax register, parallax unregister, and parallax pending. Update usage.ts, args.ts, and types.ts accordingly. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/package.json | 4 +- packages/cli/src/agent-models.ts | 24 ++ packages/cli/src/args.ts | 79 ------ packages/cli/src/commands/cancel.ts | 27 +- packages/cli/src/commands/init.ts | 378 ++++++++++++++++++++++++++ packages/cli/src/commands/open.ts | 28 ++ packages/cli/src/commands/pending.ts | 115 -------- packages/cli/src/commands/register.ts | 95 ------- packages/cli/src/commands/retry.ts | 30 +- packages/cli/src/commands/start.ts | 14 +- packages/cli/src/commands/status.ts | 25 +- packages/cli/src/commands/stop.ts | 12 +- packages/cli/src/config.ts | 206 ++++---------- packages/cli/src/git-detect.ts | 26 ++ packages/cli/src/index.ts | 51 +--- packages/cli/src/types.ts | 40 +-- packages/cli/src/usage.ts | 20 +- packages/cli/test/logs.test.ts | 13 +- packages/cli/test/open.test.ts | 105 +++++++ packages/cli/test/pending.test.ts | 175 ------------ packages/cli/test/status.test.ts | 13 +- pnpm-lock.yaml | 84 +++--- 22 files changed, 778 insertions(+), 786 deletions(-) create mode 100644 packages/cli/src/agent-models.ts create mode 100644 packages/cli/src/commands/init.ts create mode 100644 packages/cli/src/commands/open.ts delete mode 100644 packages/cli/src/commands/pending.ts delete mode 100644 packages/cli/src/commands/register.ts create mode 100644 packages/cli/src/git-detect.ts create mode 100644 packages/cli/test/open.test.ts delete mode 100644 packages/cli/test/pending.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index dbe910d..c6e3dec 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,15 +34,14 @@ "node": ">=23.7.0" }, "dependencies": { + "@clack/prompts": "1.4.0", "@fastify/cors": "11.2.0", "@parallax/common": "workspace:*", "@parallax/orchestrator": "workspace:*", "@parallax/slack": "workspace:*", "@parallax/ui": "workspace:*", "chalk": "4", - "dotenv": "16.4.7", "fastify": "5.7.4", - "js-yaml": "4.1.0", "log-update": "7.1.0", "p-limit": "6.1.0", "simple-git": "3.32.3", @@ -58,7 +57,6 @@ "@parallax/ui" ], "devDependencies": { - "@types/js-yaml": "4.0.9", "@types/node": "25.3.0", "tsx": "4.19.2" }, diff --git a/packages/cli/src/agent-models.ts b/packages/cli/src/agent-models.ts new file mode 100644 index 0000000..94e1152 --- /dev/null +++ b/packages/cli/src/agent-models.ts @@ -0,0 +1,24 @@ +import type { AgentProvider } from '@parallax/common' + +type ModelOption = { value: string; label: string; hint?: string } + +const MODELS_BY_PROVIDER: Record = { + 'claude-code': [ + { value: 'claude-opus-4-7', label: 'claude-opus-4-7', hint: 'most capable' }, + { value: 'claude-sonnet-4-6', label: 'claude-sonnet-4-6', hint: 'balanced (default)' }, + { value: 'claude-haiku-4-5', label: 'claude-haiku-4-5', hint: 'fast, low cost' }, + ], + codex: [ + { value: 'gpt-5-codex', label: 'gpt-5-codex', hint: 'optimized for coding' }, + { value: 'gpt-5', label: 'gpt-5', hint: 'general purpose' }, + { value: 'o3', label: 'o3', hint: 'reasoning' }, + ], + gemini: [ + { value: 'gemini-2.5-pro', label: 'gemini-2.5-pro', hint: 'most capable' }, + { value: 'gemini-2.5-flash', label: 'gemini-2.5-flash', hint: 'fast' }, + ], +} + +export function getModelOptions(provider: AgentProvider): ModelOption[] { + return MODELS_BY_PROVIDER[provider] ?? [] +} diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index 6a1dc56..15bfc75 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -2,10 +2,8 @@ import path from 'node:path' import type { CancelCommandOptions, LogsCommandOptions, - PendingCommandOptions, PreflightCommandOptions, PrReviewCommandOptions, - RegisterCommandOptions, RetryCommandOptions, StartCommandOptions, StopCommandOptions, @@ -110,36 +108,6 @@ export function parseStopOptions(args: string[]): StopCommandOptions { return {} } -export function parsePendingOptions(args: string[]): PendingCommandOptions { - const allowedFlags = new Set(['--approve', '--reject']) - for (let index = 0; index < args.length; index += 1) { - const arg = args[index] - if (arg.startsWith('--')) { - const flag = arg.includes('=') ? arg.split('=')[0] : arg - if (!allowedFlags.has(flag)) { - throw new Error(`Unsupported flag for parallax pending: ${arg}`) - } - if (!arg.includes('=')) { - index += 1 - } - continue - } - - throw new Error('parallax pending accepts flags only.') - } - - const approve = parseOptionalArg(args, 'approve') - const reject = parseOptionalArg(args, 'reject') - - if (approve && reject) { - throw new Error('Use either --approve or --reject, not both.') - } - return { - approve, - reject, - } -} - export function parseRetryOptions(args: string[]): RetryCommandOptions { const taskId = args[0] if (!taskId || taskId.startsWith('--')) { @@ -229,53 +197,6 @@ export function parseStatusOptions(args: string[]): StatusCommandOptions { return {} } -export function parseRegisterOptions( - args: string[], - command: 'register' | 'unregister' -): RegisterCommandOptions { - const configPath = args[0] - if (!configPath || configPath.startsWith('--')) { - throw new Error(`parallax ${command} requires .`) - } - - const envFilePath = parseOptionalArg(args.slice(1), 'env-file') - const allowedFlags = command === 'register' ? new Set(['--env-file']) : new Set() - for (let index = 1; index < args.length; index += 1) { - const arg = args[index] - if (!arg.startsWith('--')) { - throw new Error(`parallax ${command} accepts exactly one .`) - } - - const flag = arg.includes('=') ? arg.split('=')[0] : arg - if (!allowedFlags.has(flag)) { - throw new Error(`Unsupported flag for parallax ${command}: ${arg}`) - } - if (!arg.includes('=')) { - index += 1 - } - } - - if (command === 'unregister' && envFilePath !== undefined) { - throw new Error('parallax unregister does not accept flags.') - } - - const positionalArgs = args.slice(1).filter((entry, index, entries) => { - const previous = entries[index - 1] - if (previous === '--env-file') { - return false - } - return !entry.startsWith('--') - }) - if (positionalArgs.length > 0) { - throw new Error(`parallax ${command} accepts exactly one .`) - } - - return { - configPath, - envFilePath, - } -} - export function resolvePath(raw: string): string { return path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw) } diff --git a/packages/cli/src/commands/cancel.ts b/packages/cli/src/commands/cancel.ts index 28b56f4..7957d29 100644 --- a/packages/cli/src/commands/cancel.ts +++ b/packages/cli/src/commands/cancel.ts @@ -1,21 +1,32 @@ import { parseCancelOptions } from '../args.js' import type { CliContext } from '../types.js' -async function postJson(url: string, body: unknown) { +export async function runCancel(args: string[], context: CliContext) { + const options = parseCancelOptions(args) + + let apiBase: string + try { + apiBase = await context.resolveDefaultApiBase() + } catch { + throw new Error("Parallax is not running. Start it first with 'parallax start'.") + } + + const url = `${apiBase}/tasks/${encodeURIComponent(options.taskId)}/cancel` const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), + body: '{}', }) + if (response.status === 404) { + throw new Error( + `Task not found: ${options.taskId}. List tasks in the dashboard or check 'parallax status'.` + ) + } if (!response.ok) { - throw new Error(`Request failed: ${url} ${response.status} ${response.statusText}`) + const body = await response.text().catch(() => '') + throw new Error(`Cancel failed (${response.status}): ${body || response.statusText}`) } -} -export async function runCancel(args: string[], context: CliContext) { - const options = parseCancelOptions(args) - const apiBase = await context.resolveDefaultApiBase() - await postJson(`${apiBase}/tasks/${encodeURIComponent(options.taskId)}/cancel`, {}) console.log(`Canceled: ${options.taskId}`) } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts new file mode 100644 index 0000000..be1c76e --- /dev/null +++ b/packages/cli/src/commands/init.ts @@ -0,0 +1,378 @@ +import * as p from '@clack/prompts' +import chalk from 'chalk' +import fs from 'node:fs' +import path from 'node:path' +import type { ProjectConfig, SlackConfig } from '@parallax/common' +import type { CliContext } from '../types.js' +import { getModelOptions } from '../agent-models.js' +import { detectGitHubRemote } from '../git-detect.js' + +const orange = chalk.hex('#f97316') + +function isCancel(value: unknown): value is symbol { + return typeof value === 'symbol' +} + +function assertNotCancel(value: T | symbol): T { + if (isCancel(value)) { + p.cancel('Setup cancelled.') + process.exit(0) + } + return value as T +} + +function printWelcomeBanner(version: string) { + console.log('') + console.log(` ${orange.bold('parallax')}${orange('_')} ${chalk.dim(`v${version}`)}`) + console.log(` ${chalk.dim('Local-first AI orchestration runtime')}`) + console.log('') +} + +function validateWorkspaceDir(v: string | undefined): string | undefined { + const resolved = v?.trim() || process.cwd() + if (!path.isAbsolute(resolved)) { + return 'Path must be absolute.' + } + try { + const stat = fs.statSync(resolved) + if (!stat.isDirectory()) { + return 'Path must be a directory.' + } + } catch { + return 'Directory not found.' + } + if (!fs.existsSync(path.join(resolved, '.git'))) { + return 'Not a git repository (no .git directory found).' + } +} + +async function promptModel( + provider: ProjectConfig['agent']['provider'] +): Promise { + const options = getModelOptions(provider) + const choice = assertNotCancel( + await p.select({ + message: 'Model', + options: [ + { value: '', label: 'Provider default' }, + ...options.map((o) => ({ value: o.value, label: o.label, hint: o.hint })), + { value: '__custom__', label: 'Custom…' }, + ], + }) + ) as string + + if (choice === '') { + return undefined + } + if (choice !== '__custom__') { + return choice + } + + const custom = assertNotCancel( + await p.text({ + message: 'Custom model identifier', + validate: (v) => (!v?.trim() ? 'Required.' : undefined), + }) + ) as string + return custom.trim() +} + +export async function runInit(_args: string[], context: CliContext) { + printWelcomeBanner(context.cliVersion) + + const storedConfig = await context.loadStoredConfig() + const isFirstRun = storedConfig.projects.length === 0 + + if (!isFirstRun) { + p.intro(`${orange('◆')} ${chalk.bold('Add another project')}`) + + const action = assertNotCancel( + await p.select({ + message: `Found ${storedConfig.projects.length} existing project(s). What would you like to do?`, + options: [ + { value: 'add', label: 'Add another project' }, + { value: 'open', label: 'Open dashboard' }, + { value: 'exit', label: 'Exit' }, + ], + }) + ) + + if (action === 'open') { + let url = `http://localhost:8080` + try { + const state = await context.loadRunningState() + url = `http://localhost:${state.uiPort}` + } catch { + // orchestrator not running, use default port + } + p.note(url, 'Dashboard URL') + p.outro("Open the URL above in your browser, or run 'parallax open'.") + return + } + + if (action === 'exit') { + p.outro('Bye.') + return + } + } else { + p.intro(`${orange('◆')} ${chalk.bold("Welcome — let's get you set up")}`) + p.note( + [ + 'This wizard sets up your first project. You can add more', + 'projects, integrations (Slack, Linear, etc.), and secrets', + `from the dashboard at any time — or by running ${chalk.cyan('parallax init')} again.`, + ].join('\n'), + 'First project setup' + ) + } + + // --- Project setup --- + + const projectId = assertNotCancel( + await p.text({ + message: 'Project ID', + placeholder: 'my-app', + validate: (v) => { + if (!v?.trim()) { + return 'Project ID is required.' + } + if (/\s/.test(v)) { + return 'Project ID must not contain spaces.' + } + if (storedConfig.projects.some((proj) => proj.id === v.trim())) { + return `Project ID "${v.trim()}" already exists.` + } + }, + }) + ) + + const workspaceDir = assertNotCancel( + await p.path({ + message: 'Local git repository (use Tab to navigate, Enter to accept)', + directory: true, + initialValue: process.cwd(), + validate: validateWorkspaceDir, + }) + ) as string + + const detected = detectGitHubRemote(workspaceDir.trim()) + + const provider = assertNotCancel( + await p.select({ + message: 'Where should Parallax pull tasks from?', + options: [ + { + value: 'github', + label: 'GitHub Issues', + hint: detected ? `detected: ${detected.owner}/${detected.repo}` : undefined, + }, + { value: 'linear', label: 'Linear' }, + ], + }) + ) as 'github' | 'linear' + + let filters: ProjectConfig['pullFrom']['filters'] = {} + let needsLinearKey = false + + if (provider === 'github') { + const owner = assertNotCancel( + await p.text({ + message: 'GitHub owner or org', + initialValue: detected?.owner, + validate: (v) => (!v?.trim() ? 'Required.' : undefined), + }) + ) + const repo = assertNotCancel( + await p.text({ + message: 'GitHub repository name', + initialValue: detected?.repo, + validate: (v) => (!v?.trim() ? 'Required.' : undefined), + }) + ) + const labelFilter = assertNotCancel( + await p.text({ message: 'Filter by label (optional, e.g. ai-ready)', placeholder: '' }) + ) + filters = { + owner: owner.trim(), + repo: repo.trim(), + state: 'open', + labels: labelFilter.trim() ? [labelFilter.trim()] : undefined, + } + } else { + const team = assertNotCancel( + await p.text({ + message: 'Linear team ID or key', + validate: (v) => (!v?.trim() ? 'Required.' : undefined), + }) + ) + const labelFilter = assertNotCancel( + await p.text({ message: 'Filter by label (optional)', placeholder: '' }) + ) + filters = { + team: team.trim(), + labels: labelFilter.trim() ? [labelFilter.trim()] : undefined, + } + needsLinearKey = !storedConfig.secrets['LINEAR_API_KEY'] + } + + const agentProvider = assertNotCancel( + await p.select({ + message: 'Which AI agent should work on this project?', + options: [ + { value: 'claude-code', label: 'Claude Code' }, + { value: 'codex', label: 'OpenAI Codex' }, + { value: 'gemini', label: 'Google Gemini' }, + ], + }) + ) as ProjectConfig['agent']['provider'] + + const modelOverride = await promptModel(agentProvider) + + const systemPrompt = assertNotCancel( + await p.text({ message: 'Custom system prompt (optional)', placeholder: '' }) + ) + + // --- Secrets --- + + let linearApiKey: string | undefined + + if (needsLinearKey) { + linearApiKey = assertNotCancel( + await p.password({ + message: 'Linear API key', + validate: (v) => (!v?.trim() ? 'Required for Linear integration.' : undefined), + }) + ) as string + } + + // --- Slack (offered once if not already configured) --- + + let slackConfig: SlackConfig | undefined + + if (!storedConfig.slack) { + const wantSlack = assertNotCancel( + await p.confirm({ message: 'Set up Slack notifications?', initialValue: false }) + ) + + if (wantSlack) { + const botToken = assertNotCancel( + await p.password({ + message: 'Bot token', + validate: (v) => { + if (!v?.trim()) { + return 'Required.' + } + if (!v.trim().startsWith('xoxb-')) { + return 'Must start with xoxb-' + } + }, + }) + ) + const appToken = assertNotCancel( + await p.password({ + message: 'App token', + validate: (v) => { + if (!v?.trim()) { + return 'Required.' + } + if (!v.trim().startsWith('xapp-')) { + return 'Must start with xapp-' + } + }, + }) + ) + const channel = assertNotCancel( + await p.text({ + message: 'Slack channel', + placeholder: '#eng-ai', + validate: (v) => (!v?.trim() ? 'Required.' : undefined), + }) + ) + slackConfig = { + botToken: botToken.trim(), + appToken: appToken.trim(), + channel: channel.trim(), + } + } + } + + // --- Build new project --- + + const newProject: ProjectConfig = { + id: projectId.trim(), + workspaceDir: workspaceDir.trim() || process.cwd(), + pullFrom: { provider, filters }, + agent: { + provider: agentProvider, + model: modelOverride, + systemPrompt: systemPrompt.trim() || undefined, + }, + } + + // --- Confirmation --- + + p.note( + [ + `ID: ${newProject.id}`, + `Workspace: ${newProject.workspaceDir}`, + `Provider: ${provider}`, + `Agent: ${agentProvider}${newProject.agent.model ? ` (${newProject.agent.model})` : ''}`, + slackConfig ? `Slack: ${slackConfig.channel}` : '', + ] + .filter(Boolean) + .join('\n'), + 'Summary' + ) + + const confirmed = assertNotCancel(await p.confirm({ message: 'Save this configuration?' })) + + if (!confirmed) { + p.cancel('Setup cancelled.') + return + } + + // --- Write --- + + const updatedConfig = { + ...storedConfig, + projects: [...storedConfig.projects, newProject], + slack: slackConfig ?? storedConfig.slack, + secrets: linearApiKey + ? { ...storedConfig.secrets, LINEAR_API_KEY: linearApiKey } + : storedConfig.secrets, + } + + await context.saveStoredConfig(updatedConfig) + + // Reload orchestrator if running + let alreadyRunning = false + try { + const state = await context.loadRunningState() + const reloadRes = await fetch(`http://localhost:${state.apiPort}/runtime/reload`, { + method: 'POST', + }) + if (reloadRes.ok) { + alreadyRunning = true + } else { + console.warn( + `Warning: orchestrator reload returned ${reloadRes.status}. You may need to restart Parallax.` + ) + } + } catch { + // not running, ignore + } + + const nextSteps = alreadyRunning + ? [ + `${chalk.dim('•')} Project added. Parallax is already running.`, + `${chalk.dim('•')} Run ${chalk.cyan('parallax open')} to view the dashboard.`, + ] + : [ + `${chalk.dim('•')} Run ${chalk.cyan('parallax start')} to launch the orchestrator.`, + `${chalk.dim('•')} Run ${chalk.cyan('parallax open')} to view the dashboard.`, + `${chalk.dim('•')} Manage projects, integrations and secrets from the dashboard.`, + ] + + p.note(nextSteps.join('\n'), 'Next steps') + p.outro(orange('Setup complete.')) +} diff --git a/packages/cli/src/commands/open.ts b/packages/cli/src/commands/open.ts new file mode 100644 index 0000000..15b4ffe --- /dev/null +++ b/packages/cli/src/commands/open.ts @@ -0,0 +1,28 @@ +import { execSync } from 'node:child_process' +import type { CliContext } from '../types.js' + +export async function runOpen(_args: string[], context: CliContext) { + let url = `http://localhost:8080` + + try { + const state = await context.loadRunningState() + url = `http://localhost:${state.uiPort}` + } catch { + throw new Error( + `Parallax is not running. Start it first with 'parallax start', then open: ${url}` + ) + } + + try { + const opener = + process.platform === 'darwin' + ? 'open' + : process.platform === 'win32' + ? 'start ""' + : 'xdg-open' + execSync(`${opener} "${url}"`, { stdio: 'ignore' }) + console.log(`Opened ${url}`) + } catch { + console.log(`Dashboard: ${url}`) + } +} diff --git a/packages/cli/src/commands/pending.ts b/packages/cli/src/commands/pending.ts deleted file mode 100644 index 8607f45..0000000 --- a/packages/cli/src/commands/pending.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { parsePendingOptions } from '../args.js' -import type { CliContext, TaskPendingState } from '../types.js' - -export function scopePendingTasks( - tasks: TaskPendingState[], - allowedProjectIds: Set | undefined -): TaskPendingState[] { - if (!allowedProjectIds) { - return tasks - } - - return tasks.filter((task) => { - if (!task.projectId) { - throw new Error(`Pending task ${task.id} has no projectId. Cannot apply project-level scope.`) - } - - return allowedProjectIds.has(task.projectId) - }) -} - -export function resolveApproveTargets(tasks: TaskPendingState[], approveValue: string): string[] { - const available = new Set(tasks.map((task) => task.id)) - const normalized = approveValue.trim() - if (!normalized) { - throw new Error('approve value must include a task id.') - } - - if (normalized.includes(',')) { - throw new Error('Approve accepts a single task id.') - } - - if (!available.has(normalized)) { - throw new Error(`Unknown task id: ${normalized}`) - } - - return [normalized] -} - -export function resolveRejectTarget(tasks: TaskPendingState[], rejectId: string): string { - const available = new Set(tasks.map((task) => task.id)) - if (!available.has(rejectId)) { - throw new Error(`Unknown task id: ${rejectId}`) - } - - return rejectId -} - -async function fetchJson(url: string): Promise { - const response = await fetch(url) - if (!response.ok) { - throw new Error(`Request failed: ${url} ${response.status} ${response.statusText}`) - } - - return (await response.json()) as T -} - -async function postJson(url: string, body: unknown) { - const response = await fetch(url, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }) - - if (!response.ok) { - throw new Error(`Request failed: ${url} ${response.status} ${response.statusText}`) - } -} - -function printPendingSummary(tasks: TaskPendingState[]) { - for (const task of tasks) { - console.log( - `- ${task.id} | project=${task.projectId} | plan=${task.planState} | agent=${task.lastAgent ?? 'n/a'}` - ) - console.log(` title: ${task.title ?? '(no title)'}`) - const snippet = task.planMarkdown ?? task.planResult - if (snippet) { - const cleaned = snippet.replace(/\s+/g, ' ').trim() - console.log(` plan: ${cleaned.slice(0, 280)}${cleaned.length > 280 ? '...' : ''}`) - } - } -} - -export async function runPending(args: string[], context: CliContext) { - const options = parsePendingOptions(args) - const apiBase = await context.resolveDefaultApiBase() - - const pendingTasks = await fetchJson(`${apiBase}/tasks/pending-plans`) - const scopedTasks = pendingTasks - - if (options.approve) { - const approvedIds = resolveApproveTargets(scopedTasks, options.approve) - for (const taskId of approvedIds) { - await postJson(`${apiBase}/tasks/${encodeURIComponent(taskId)}/approve`, {}) - console.log(`Approved: ${taskId}`) - } - return - } - - if (options.reject) { - const rejectedId = resolveRejectTarget(scopedTasks, options.reject) - await postJson(`${apiBase}/tasks/${encodeURIComponent(rejectedId)}/reject`, {}) - console.log(`Rejected: ${rejectedId}`) - return - } - - if (scopedTasks.length === 0) { - console.log('No pending plans right now.') - return - } - - printPendingSummary(scopedTasks) - console.log( - '\nApprove/reject with:\n parallax pending --approve \n parallax pending --reject ' - ) -} diff --git a/packages/cli/src/commands/register.ts b/packages/cli/src/commands/register.ts deleted file mode 100644 index 31ac203..0000000 --- a/packages/cli/src/commands/register.ts +++ /dev/null @@ -1,95 +0,0 @@ -import fs from 'node:fs/promises' -import { parseRegisterOptions } from '../args.js' -import { isProcessAlive } from '../process.js' -import type { CliContext } from '../types.js' - -async function reloadRunningRuntime(context: CliContext) { - let state - try { - state = await context.loadRunningState() - } catch { - return false - } - - if (!isProcessAlive(state.orchestratorPid)) { - return false - } - - const response = await fetch(`http://localhost:${state.apiPort}/runtime/reload`, { - method: 'POST', - }) - if (!response.ok) { - const payload = (await response.json().catch(() => undefined)) as { error?: string } | undefined - throw new Error( - payload?.error ?? `Failed to reload running Parallax instance (${response.status}).` - ) - } - - return true -} - -async function saveRegistryAndReload( - context: CliContext, - previousRegistry: Awaited>, - nextRegistry: Awaited> -) { - await context.saveRegistry(nextRegistry) - - try { - return await reloadRunningRuntime(context) - } catch (error) { - await context.saveRegistry(previousRegistry) - throw error - } -} - -export async function runRegister( - args: string[], - context: CliContext, - command: 'register' | 'unregister' -) { - const options = parseRegisterOptions(args, command) - const configPath = context.resolvePath(options.configPath) - const envFilePath = options.envFilePath ? context.resolvePath(options.envFilePath) : undefined - - await fs.mkdir(context.defaultDataDir, { recursive: true }) - - if (command === 'register') { - if (!(await context.ensureFileExists(configPath))) { - throw new Error(`Config file not found: ${configPath}`) - } - if (envFilePath && !(await context.ensureFileExists(envFilePath))) { - throw new Error(`Env file not found: ${envFilePath}`) - } - - await context.validateConfigFile(configPath) - const registry = await context.loadRegistry() - if (registry.configs.some((entry) => entry.configPath === configPath)) { - console.log(`Already registered: ${configPath}`) - return - } - - const nextRegistry = { - configs: [...registry.configs, { configPath, envFilePath, addedAt: Date.now() }], - } - const reloaded = await saveRegistryAndReload(context, registry, nextRegistry) - console.log(`Registered: ${configPath}`) - if (reloaded) { - console.log('Reloaded running Parallax instance.') - } - return - } - - const registry = await context.loadRegistry() - const nextConfigs = registry.configs.filter((entry) => entry.configPath !== configPath) - if (nextConfigs.length === registry.configs.length) { - throw new Error(`Config is not registered: ${configPath}`) - } - - const nextRegistry = { configs: nextConfigs } - const reloaded = await saveRegistryAndReload(context, registry, nextRegistry) - console.log(`Unregistered: ${configPath}`) - if (reloaded) { - console.log('Reloaded running Parallax instance.') - } -} diff --git a/packages/cli/src/commands/retry.ts b/packages/cli/src/commands/retry.ts index caabf4f..b80b526 100644 --- a/packages/cli/src/commands/retry.ts +++ b/packages/cli/src/commands/retry.ts @@ -1,21 +1,35 @@ import { parseRetryOptions } from '../args.js' import type { CliContext } from '../types.js' -async function postJson(url: string, body: unknown) { +export async function runRetry(args: string[], context: CliContext) { + const options = parseRetryOptions(args) + + let apiBase: string + try { + apiBase = await context.resolveDefaultApiBase() + } catch { + throw new Error("Parallax is not running. Start it first with 'parallax start'.") + } + + const url = `${apiBase}/tasks/${encodeURIComponent(options.taskId)}/retry` const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), + body: '{}', }) + if (response.status === 404) { + throw new Error( + `Task not found: ${options.taskId}. List tasks in the dashboard or check 'parallax status'.` + ) + } + if (response.status === 409) { + throw new Error(`Task ${options.taskId} is already running.`) + } if (!response.ok) { - throw new Error(`Request failed: ${url} ${response.status} ${response.statusText}`) + const body = await response.text().catch(() => '') + throw new Error(`Retry failed (${response.status}): ${body || response.statusText}`) } -} -export async function runRetry(args: string[], context: CliContext) { - const options = parseRetryOptions(args) - const apiBase = await context.resolveDefaultApiBase() - await postJson(`${apiBase}/tasks/${encodeURIComponent(options.taskId)}/retry`, {}) console.log(`Retried: ${options.taskId}`) } diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 14376b5..16fa74f 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -61,7 +61,11 @@ export async function runStart(args: string[], context: CliContext) { console.log(`${BLUE}📁 Data Dir:${RESET} ${DIM}${dataDir}${RESET}`) console.log('') - const registry = await context.loadRegistry() + const storedConfig = await context.loadStoredConfig() + if (storedConfig.projects.length === 0) { + console.error(`${YELLOW}No projects configured. Run 'parallax init' to get started.${RESET}`) + process.exit(1) + } const env = context.buildEnvConfig(dataDir, { apiPort: options.apiPort, uiPort: options.uiPort, @@ -85,7 +89,7 @@ export async function runStart(args: string[], context: CliContext) { existingState?.uiPid !== undefined ? isProcessAlive(existingState.uiPid) : false if (existingState && (isProcessAlive(existingState.orchestratorPid) || existingUiAlive)) { throw new Error( - `Parallax is already running. Stop it first with "parallax stop". Manifest: ${existingManifestPath}` + `Parallax is already running on http://localhost:${existingState.uiPort}. Run 'parallax open' to view the dashboard, or 'parallax stop' to stop it.` ) } @@ -176,12 +180,10 @@ export async function runStart(args: string[], context: CliContext) { console.log(`${GREEN}✓ Parallax started in background.${RESET}`) console.log(`${DIM}Orchestrator PID:${RESET} ${orchestratorPid}`) console.log(`${DIM}Dashboard:${RESET} http://localhost:${options.uiPort}`) - console.log(`${DIM}Registered Configs:${RESET} ${registry.configs.length}`) + console.log(`${DIM}Projects:${RESET} ${storedConfig.projects.length}`) console.log('') console.log('') - console.log( - `${YELLOW}💡 Register a repository config with:${RESET} ${DIM}parallax register ${RESET}` - ) + console.log(`${YELLOW}💡 Run 'parallax open' to view the dashboard.${RESET}`) } catch (error) { const processAlive = orchestratorPid > 0 ? isProcessAlive(orchestratorPid) : false await stopProcessBestEffort(orchestratorPid, 'orchestrator', true) diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 7fe0239..769dbc6 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -18,12 +18,6 @@ async function fetchRuntimeErrors(apiBase: string): Promise = [] + try { + const configRes = await fetch(`${apiBase}/config`) + if (configRes.ok) { + const cfg = (await configRes.json()) as { projects?: typeof projects } + projects = cfg.projects ?? [] + } + } catch { + // ignore + } + output.push('') output.push(`${GREEN}✓ Parallax status: healthy.${RESET}`) output.push(`${DIM}Orchestrator PID:${RESET} ${state.orchestratorPid}`) output.push(`${DIM}Dashboard:${RESET} http://localhost:${state.uiPort}`) + + if (projects.length > 0) { + output.push('') + output.push(`${DIM}Projects (${projects.length}):${RESET}`) + for (const project of projects) { + output.push(` ${project.id.padEnd(20)} ${project.agent.provider}`) + } + } } finally { const remaining = 400 - (Date.now() - startTime) if (remaining > 0) { diff --git a/packages/cli/src/commands/stop.ts b/packages/cli/src/commands/stop.ts index 0fc8e4f..77f3d30 100644 --- a/packages/cli/src/commands/stop.ts +++ b/packages/cli/src/commands/stop.ts @@ -9,16 +9,22 @@ export async function runStop(args: string[], context: CliContext) { const manifestPath = path.join(context.defaultDataDir, context.manifestFile) const spinner = startSpinner('Stopping Parallax...') + let state try { - const state = await context.loadRunningState() + state = await context.loadRunningState() + } catch { + spinner?.stop() + console.log('Parallax is not running.') + return + } + try { await stopProcessBestEffort(state.orchestratorPid, 'orchestrator', true) await stopProcessBestEffort(state.uiPid, 'UI', true) - await fs.unlink(manifestPath).catch(() => undefined) } finally { spinner?.stop() } - console.log(`Stopped parallax instance from ${manifestPath}.`) + console.log('Parallax stopped.') } diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 065263f..3eb1bd8 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -1,36 +1,8 @@ import fs from 'node:fs/promises' import fsSync from 'node:fs' import path from 'node:path' -import { createRequire } from 'node:module' -import type { ServerConfig } from '@parallax/common' -import type { RegistryState, RunningState } from './types.js' - -const requireFromCli = createRequire(import.meta.url) - -function loadYamlModule() { - try { - return requireFromCli('js-yaml') as { load: (input: string) => unknown } - } catch (error) { - throw new Error( - 'Missing runtime dependency "js-yaml". Reinstall parallax-cli (npm i -g parallax-cli).', - { cause: error } - ) - } -} - -function ensureArray(value: unknown, source: string): string[] { - if (!Array.isArray(value)) { - throw new Error(`Invalid array value in ${source}.`) - } - - return value.map((entry) => { - if (typeof entry !== 'string' || !entry.trim()) { - throw new Error(`Invalid item in array value from ${source}.`) - } - - return entry.trim() - }) -} +import type { StoredConfig } from '@parallax/common' +import type { RunningState } from './types.js' export function resolveCliRoot(startDir: string): string { let current = startDir @@ -96,78 +68,6 @@ export function parseRunningState(raw: string, source: string): RunningState { return parsed as RunningState } -export function parseRegistryState(raw: string, source: string): RegistryState { - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch (error) { - throw new Error( - `Invalid config registry at ${source}: ${error instanceof Error ? error.message : 'unknown error'}`, - { cause: error } - ) - } - - if ( - !parsed || - typeof parsed !== 'object' || - !Array.isArray((parsed as { configs?: unknown }).configs) - ) { - throw new Error(`Invalid config registry at ${source}.`) - } - - return { - configs: (parsed as { configs: unknown[] }).configs.map((entry, index) => { - if ( - !entry || - typeof entry !== 'object' || - typeof (entry as { configPath?: unknown }).configPath !== 'string' || - typeof (entry as { addedAt?: unknown }).addedAt !== 'number' || - ('envFilePath' in entry && - (entry as { envFilePath?: unknown }).envFilePath !== undefined && - typeof (entry as { envFilePath?: unknown }).envFilePath !== 'string') - ) { - throw new Error(`Invalid config registry entry ${index + 1} in ${source}.`) - } - - return { - configPath: (entry as { configPath: string }).configPath, - addedAt: (entry as { addedAt: number }).addedAt, - envFilePath: (entry as { envFilePath?: string }).envFilePath?.trim() || undefined, - } - }), - } -} - -export function parseConfigProjectIds(raw: string, source: string): Set { - const parsed = loadYamlModule().load(raw) - if (!Array.isArray(parsed)) { - throw new Error(`Invalid parallax config at ${source}.`) - } - - const projects = ensureArray( - parsed.map((project) => - typeof project === 'object' && project && 'id' in project - ? (project as { id?: unknown }).id - : undefined - ), - `projects section in ${source}` - ) - - if (projects.length === 0) { - throw new Error(`Config ${source} has no projects.`) - } - - return new Set(projects) -} - -export function parseServerPortsFromConfig(raw: string, source: string): ServerConfig { - throw new Error(`Server ports are no longer configured in ${source}; use "parallax start" flags.`) -} - -export async function resolveServerPorts(configPath: string): Promise { - return parseServerPortsFromConfig(await fs.readFile(configPath, 'utf8'), configPath) -} - export async function loadRunningState( dataDir: string, manifestFile: string @@ -180,72 +80,60 @@ export async function loadRunningState( return parseRunningState(await fs.readFile(manifestPath, 'utf8'), manifestPath) } -export async function loadRegistry(dataDir: string, registryFile: string): Promise { - const registryPath = path.join(dataDir, registryFile) - if (!(await ensureFileExists(registryPath))) { - return { configs: [] } - } - - return parseRegistryState(await fs.readFile(registryPath, 'utf8'), registryPath) -} +const CONFIG_FILE = 'config.json' -export async function saveRegistry( - dataDir: string, - registryFile: string, - registry: RegistryState -): Promise { - await fs.writeFile(path.join(dataDir, registryFile), JSON.stringify(registry, null, 2)) -} - -export async function resolveProjectIdsFromRegistry( - dataDir: string, - registryFile: string -): Promise> { - const registry = await loadRegistry(dataDir, registryFile) - const ids = new Set() - - for (const config of registry.configs) { - if (!(await ensureFileExists(config.configPath))) { - throw new Error(`Registered config file not found: ${config.configPath}`) - } - - const configIds = parseConfigProjectIds( - await fs.readFile(config.configPath, 'utf8'), - config.configPath +function parseStoredConfigFromDisk(raw: string, source: string): StoredConfig { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch (error) { + throw new Error( + `Invalid config at ${source}: ${error instanceof Error ? error.message : 'unknown error'}`, + { cause: error } ) - - for (const id of configIds) { - if (ids.has(id)) { - throw new Error(`Duplicate project id "${id}" across registered configs.`) - } - ids.add(id) - } } - return ids -} + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Invalid config at ${source}: must be an object.`) + } -export async function validateConfigFile(configPath: string): Promise { - const raw = await fs.readFile(configPath, 'utf8') - const parsed = loadYamlModule().load(raw) - if (!Array.isArray(parsed) || parsed.length === 0) { - throw new Error(`Invalid parallax config at ${configPath}`) + const obj = parsed as Record + return { + version: typeof obj.version === 'number' ? obj.version : 1, + projects: Array.isArray(obj.projects) ? (obj.projects as StoredConfig['projects']) : [], + agents: Array.isArray(obj.agents) ? (obj.agents as StoredConfig['agents']) : [], + slack: + obj.slack && typeof obj.slack === 'object' && !Array.isArray(obj.slack) + ? (obj.slack as StoredConfig['slack']) + : null, + secrets: + obj.secrets && typeof obj.secrets === 'object' && !Array.isArray(obj.secrets) + ? (obj.secrets as Record) + : {}, + updatedAt: typeof obj.updatedAt === 'number' ? obj.updatedAt : 0, } } -export async function resolveEnvFilePath( - explicitValue: string | undefined, - resolvePath: (value: string) => string, - ensureExists: (filePath: string) => Promise -): Promise { - if (!explicitValue) { - return undefined +export async function loadStoredConfig(dataDir: string): Promise { + const configPath = path.join(dataDir, CONFIG_FILE) + if (!(await ensureFileExists(configPath))) { + return { + version: 1, + projects: [], + agents: [], + slack: null, + secrets: {}, + updatedAt: 0, + } } - const resolved = resolvePath(explicitValue) - if (!(await ensureExists(resolved))) { - throw new Error(`Env file not found: ${resolved}`) - } + return parseStoredConfigFromDisk(await fs.readFile(configPath, 'utf8'), configPath) +} - return resolved +export async function saveStoredConfig(dataDir: string, config: StoredConfig): Promise { + await fs.mkdir(dataDir, { recursive: true }) + const configPath = path.join(dataDir, CONFIG_FILE) + const tmpPath = `${configPath}.tmp` + await fs.writeFile(tmpPath, JSON.stringify({ ...config, updatedAt: Date.now() }, null, 2)) + await fs.rename(tmpPath, configPath) } diff --git a/packages/cli/src/git-detect.ts b/packages/cli/src/git-detect.ts new file mode 100644 index 0000000..d075c46 --- /dev/null +++ b/packages/cli/src/git-detect.ts @@ -0,0 +1,26 @@ +import { execSync } from 'node:child_process' + +export function detectGitHubRemote(workspaceDir: string): { owner: string; repo: string } | null { + try { + const url = execSync('git config --get remote.origin.url', { + cwd: workspaceDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() + + // git@github.com:owner/repo.git OR https://github.com/owner/repo.git + const sshMatch = url.match(/git@github\.com:([^/]+)\/([^/]+?)(\.git)?$/) + if (sshMatch) { + return { owner: sshMatch[1], repo: sshMatch[2] } + } + + const httpsMatch = url.match(/https?:\/\/github\.com\/([^/]+)\/([^/]+?)(\.git)?$/) + if (httpsMatch) { + return { owner: httpsMatch[1], repo: httpsMatch[2] } + } + + return null + } catch { + return null + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 48d7531..b2392ca 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -8,10 +8,8 @@ import { hasFlag, parseCancelOptions, parseLogsOptions, - parsePendingOptions, parsePreflightOptions, parsePrReviewOptions, - parseRegisterOptions, parseRetryOptions, parseStartOptions, parseStatusOptions, @@ -20,26 +18,18 @@ import { } from './args.js' import { ensureFileExists, - loadRegistry as loadRegistryFromDisk, loadRunningState as loadRunningStateFromDisk, - parseConfigProjectIds, - parseRegistryState, + loadStoredConfig as loadStoredConfigFromDisk, parseRunningState, resolveCliRoot, - saveRegistry as saveRegistryToDisk, - validateConfigFile, + saveStoredConfig as saveStoredConfigToDisk, } from './config.js' import { runCancel } from './commands/cancel.js' +import { runInit } from './commands/init.js' import { runLogs } from './commands/logs.js' -import { - resolveApproveTargets, - resolveRejectTarget, - runPending, - scopePendingTasks, -} from './commands/pending.js' +import { runOpen } from './commands/open.js' import { runPreflight } from './commands/preflight.js' import { runPrReview } from './commands/pr-review.js' -import { runRegister } from './commands/register.js' import { runRetry } from './commands/retry.js' import { runStart } from './commands/start.js' import { runStatus } from './commands/status.js' @@ -53,7 +43,6 @@ const __dirname = path.dirname(__filename) const DEFAULT_DATA_DIR = path.join(os.homedir(), '.parallax') const DEFAULT_API_BASE = `http://localhost:${DEFAULT_API_PORT}` const MANIFEST_FILE = 'running.json' -const REGISTRY_FILE = 'registry.json' const ROOT_DIR = resolveCliRoot(__dirname) function resolvePackageVersion(rootDir: string): string { @@ -115,17 +104,15 @@ const cliContext: CliContext = { defaultApiBase: DEFAULT_API_BASE, defaultDataDir: DEFAULT_DATA_DIR, manifestFile: MANIFEST_FILE, - registryFile: REGISTRY_FILE, rootDir: ROOT_DIR, cliVersion: CLI_VERSION, packageVersion: CLI_VERSION, resolvePath, ensureFileExists, loadRunningState: () => loadRunningStateFromDisk(DEFAULT_DATA_DIR, MANIFEST_FILE), - loadRegistry: () => loadRegistryFromDisk(DEFAULT_DATA_DIR, REGISTRY_FILE), - saveRegistry: (registry) => saveRegistryToDisk(DEFAULT_DATA_DIR, REGISTRY_FILE, registry), + loadStoredConfig: () => loadStoredConfigFromDisk(DEFAULT_DATA_DIR), + saveStoredConfig: (config) => saveStoredConfigToDisk(DEFAULT_DATA_DIR, config), resolveDefaultApiBase, - validateConfigFile, buildEnvConfig, } @@ -147,24 +134,21 @@ async function cli() { try { switch (command) { + case 'init': + await runInit(commandArgs, cliContext) + return case 'start': await runStart(commandArgs, cliContext) return - case 'register': - await runRegister(commandArgs, cliContext, 'register') - return - case 'unregister': - await runRegister(commandArgs, cliContext, 'unregister') + case 'status': + await runStatus(commandArgs, cliContext) return - case 'pending': - await runPending(commandArgs, cliContext) + case 'open': + await runOpen(commandArgs, cliContext) return case 'preflight': await runPreflight(commandArgs) return - case 'status': - await runStatus(commandArgs, cliContext) - return case 'pr-review': await runPrReview(commandArgs, cliContext) return @@ -181,7 +165,9 @@ async function cli() { await runLogs(commandArgs, cliContext) return default: + console.error(`Unknown command: ${command}\n`) printUsage() + process.exit(1) } } catch (error: any) { console.error(`Error: ${error.message}`) @@ -191,22 +177,15 @@ async function cli() { export { parseCancelOptions, - parseConfigProjectIds, parseLogsOptions, - parsePendingOptions, parsePreflightOptions, parsePrReviewOptions, - parseRegisterOptions, - parseRegistryState, parseRetryOptions, parseStartOptions, parseStatusOptions, parseRunningState, - resolveApproveTargets, - resolveRejectTarget, resolveDefaultApiBase, resolvePath, - scopePendingTasks, } export function parseStopOptions(args: string[]) { diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 20e1f15..2f3b0d5 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -1,21 +1,4 @@ -import { TaskPlanState } from '@parallax/common' - -export type TaskPendingState = { - id: string - externalId?: string - title?: string - planState?: TaskPlanState - projectId?: string - planMarkdown?: string - planResult?: string - lastAgent?: string - status?: string -} - -export type PendingCommandOptions = { - approve?: string - reject?: string -} +import type { StoredConfig } from '@parallax/common' export type StopCommandOptions = Record @@ -37,11 +20,6 @@ export type PreflightCommandOptions = Record export type StatusCommandOptions = Record -export type RegisterCommandOptions = { - configPath: string - envFilePath?: string -} - export type StartCommandOptions = { apiPort: number uiPort: number @@ -56,16 +34,6 @@ export type RunningState = { uiPort: number } -export type RegisteredConfig = { - configPath: string - addedAt: number - envFilePath?: string -} - -export type RegistryState = { - configs: RegisteredConfig[] -} - export type VerifyCheck = { name: string ok: boolean @@ -77,17 +45,15 @@ export type CliContext = { defaultApiBase: string defaultDataDir: string manifestFile: string - registryFile: string rootDir: string cliVersion: string resolvePath: (raw: string) => string ensureFileExists: (filePath: string) => Promise loadRunningState: () => Promise - loadRegistry: () => Promise - saveRegistry: (registry: RegistryState) => Promise + loadStoredConfig: () => Promise + saveStoredConfig: (config: StoredConfig) => Promise resolveDefaultApiBase: () => Promise packageVersion: string - validateConfigFile: (configPath: string) => Promise buildEnvConfig: ( dataDir: string, runtime: { apiPort: number; uiPort: number; concurrency: number } diff --git a/packages/cli/src/usage.ts b/packages/cli/src/usage.ts index ff90940..70ca1e1 100644 --- a/packages/cli/src/usage.ts +++ b/packages/cli/src/usage.ts @@ -2,28 +2,26 @@ export function printUsage(): void { console.log(`Usage: parallax --version parallax --help + parallax init parallax start [--server-api-port ] [--server-ui-port ] [--concurrency ] - parallax register [--env-file ] - parallax unregister - parallax pending [--approve ] [--reject ] - parallax preflight + parallax stop parallax status + parallax open + parallax preflight parallax pr-review parallax retry parallax cancel - parallax stop parallax logs [--task ] Commands: - start Start orchestrator + UI in background using the provided runtime flags. - register Register a repository config in ~/.parallax, with optional project env file. - unregister Remove a repository config from ~/.parallax. - pending List pending plans and optionally approve/reject them. + init Set up Parallax for the first time (interactive wizard). + start Start orchestrator + UI in background. + stop Force-stop the running Parallax processes. + status Show orchestrator state and configured projects. + open Open the dashboard in your browser. preflight Validate local prerequisites and auth. - status Show overall orchestrator status and runtime diagnostics. pr-review [experimental] Apply open human PR review comments to the task's existing open PR. retry Queue a task for manual retry. cancel Cancel a pending or running task. - stop Force-stop the running Parallax processes. logs Tail new task logs from the running Parallax API.`) } diff --git a/packages/cli/test/logs.test.ts b/packages/cli/test/logs.test.ts index ed36cf3..1d08604 100644 --- a/packages/cli/test/logs.test.ts +++ b/packages/cli/test/logs.test.ts @@ -20,7 +20,6 @@ function createContext(overrides: Partial = {}): CliContext { defaultApiBase: 'http://localhost:3000', defaultDataDir: '/tmp/.parallax', manifestFile: 'running.json', - registryFile: 'registry.json', rootDir: '/tmp/parallax', cliVersion: '0.0.8', packageVersion: '0.0.8', @@ -32,10 +31,16 @@ function createContext(overrides: Partial = {}): CliContext { apiPort: 3000, uiPort: 8080, }), - loadRegistry: async () => ({ configs: [] }), - saveRegistry: async () => {}, + loadStoredConfig: async () => ({ + version: 1, + projects: [], + agents: [], + slack: null, + secrets: {}, + updatedAt: 0, + }), + saveStoredConfig: async () => {}, resolveDefaultApiBase: async () => 'http://localhost:3000', - validateConfigFile: async () => {}, buildEnvConfig: () => ({}), ...overrides, } diff --git a/packages/cli/test/open.test.ts b/packages/cli/test/open.test.ts new file mode 100644 index 0000000..5de9993 --- /dev/null +++ b/packages/cli/test/open.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { CliContext } from '../src/types.js' + +const { execSyncMock } = vi.hoisted(() => ({ + execSyncMock: vi.fn(), +})) + +vi.mock('node:child_process', () => ({ + execSync: execSyncMock, +})) + +import { runOpen } from '../src/commands/open.js' + +function createContext(overrides: Partial = {}): CliContext { + return { + defaultApiBase: 'http://localhost:3000', + defaultDataDir: '/tmp/.parallax', + manifestFile: 'running.json', + rootDir: '/tmp/parallax', + cliVersion: '0.0.1', + packageVersion: '0.0.1', + resolvePath: (raw) => raw, + ensureFileExists: async () => true, + loadRunningState: async () => ({ + startedAt: Date.now(), + orchestratorPid: 1, + apiPort: 3000, + uiPort: 8080, + }), + loadStoredConfig: async () => ({ + version: 1, + projects: [], + agents: [], + slack: null, + secrets: {}, + updatedAt: 0, + }), + saveStoredConfig: async () => {}, + resolveDefaultApiBase: async () => 'http://localhost:3000', + buildEnvConfig: () => ({}), + ...overrides, + } +} + +describe('runOpen', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + }) + + it('throws when Parallax is not running', async () => { + const context = createContext({ + loadRunningState: async () => { + throw new Error('not found') + }, + }) + + await expect(runOpen([], context)).rejects.toThrow( + "Parallax is not running. Start it first with 'parallax start'" + ) + }) + + it('opens the URL from running state', async () => { + execSyncMock.mockImplementation(() => {}) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runOpen([], createContext()) + + expect(execSyncMock).toHaveBeenCalledOnce() + const cmd = execSyncMock.mock.calls[0][0] as string + expect(cmd).toContain('"http://localhost:8080"') + expect(logSpy).toHaveBeenCalledWith('Opened http://localhost:8080') + }) + + it('uses uiPort from running state', async () => { + execSyncMock.mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runOpen( + [], + createContext({ + loadRunningState: async () => ({ + startedAt: Date.now(), + orchestratorPid: 1, + apiPort: 3001, + uiPort: 9999, + }), + }) + ) + + const cmd = execSyncMock.mock.calls[0][0] as string + expect(cmd).toContain('"http://localhost:9999"') + }) + + it('falls back to printing URL when browser open fails', async () => { + execSyncMock.mockImplementation(() => { + throw new Error('open failed') + }) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runOpen([], createContext()) + + expect(logSpy).toHaveBeenCalledWith('Dashboard: http://localhost:8080') + }) +}) diff --git a/packages/cli/test/pending.test.ts b/packages/cli/test/pending.test.ts deleted file mode 100644 index d9d977e..0000000 --- a/packages/cli/test/pending.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - parseConfigProjectIds, - resolveApproveTargets, - resolveRejectTarget, - parseStartOptions, - parseStopOptions, - parsePendingOptions, - parseRetryOptions, - parseCancelOptions, - parseStatusOptions, - parseLogsOptions, - parsePreflightOptions, - parseRegisterOptions, - scopePendingTasks, -} from '../src/index.js' - -describe('CLI pending scope and approval helpers', () => { - it('parses project IDs from valid config YAML', () => { - const raw = `- id: revora-mvp\n- id: www\n` - - const ids = parseConfigProjectIds(raw, 'parallax.yml') - - expect(ids.has('revora-mvp')).toBe(true) - expect(ids.has('www')).toBe(true) - expect(ids.size).toBe(2) - }) - - it('throws for malformed config YAML', () => { - const raw = `projects: [1,2,3]` - - expect(() => parseConfigProjectIds(raw, 'parallax.yml')).toThrow('Invalid parallax config') - }) - - it('filters pending tasks by configured project IDs', () => { - const tasks = [ - { id: 'a', projectId: 'revora-mvp' }, - { id: 'b', projectId: 'www' }, - { id: 'c', projectId: 'api' }, - ] as any[] - - const scoped = scopePendingTasks(tasks, new Set(['revora-mvp', 'www'])) - - expect(scoped.map((task) => task.id)).toEqual(['a', 'b']) - }) - - it('throws when scoped tasks include tasks without projectId', () => { - const tasks = [{ id: 'a' }] as any[] - - expect(() => scopePendingTasks(tasks, new Set(['revora-mvp']))).toThrow( - 'Pending task a has no projectId. Cannot apply project-level scope.' - ) - }) - - it('rejects approvals for unknown task ids', () => { - const tasks = [{ id: 'a' }, { id: 'b' }] as any[] - - expect(() => resolveApproveTargets(tasks, 'unknown')).toThrow('Unknown task id: unknown') - }) - - it('resolves a single explicit approval target', () => { - const tasks = [{ id: 'a' }, { id: 'b' }] as any[] - - expect(resolveApproveTargets(tasks, 'a')).toEqual(['a']) - }) - - it('rejects multiple approval targets', () => { - const tasks = [{ id: 'a' }, { id: 'b' }] as any[] - - expect(() => resolveApproveTargets(tasks, 'a,b')).toThrow('Approve accepts a single task id.') - }) - - it('rejects unknown task id on reject action', () => { - const tasks = [{ id: 'a' }] as any[] - - expect(() => resolveRejectTarget(tasks, 'nope')).toThrow('Unknown task id: nope') - }) - - it('throws when config has no projects section', () => { - const raw = `concurrency: 1\n` - - expect(() => parseConfigProjectIds(raw, 'parallax.yml')).toThrow('Invalid parallax config') - }) - - it('parses start options with defaults', () => { - const options = parseStartOptions([]) - expect(options.apiPort).toBe(3000) - expect(options.uiPort).toBe(8080) - expect(options.concurrency).toBe(2) - }) - - it('throws on approve without a value', () => { - expect(() => parsePendingOptions(['--approve'])).toThrow('Missing value for --approve.') - }) - - it('throws when both approve and reject are used together', () => { - expect(() => parsePendingOptions(['--approve', 'abc-123', '--reject', 'xyz-456'])).toThrow( - 'Use either --approve or --reject, not both.' - ) - }) - - it('parses strict pending options when valid', () => { - const options = parsePendingOptions(['--approve', 'abc-123']) - - expect(options.approve).toBe('abc-123') - }) - - it('parses stop options with defaults', () => { - const options = parseStopOptions([]) - expect(options).toEqual({}) - }) - - it('rejects stop flags', () => { - expect(() => parseStopOptions(['--force'])).toThrow('parallax stop does not accept flags.') - }) - - it('parses retry options with default mode', () => { - const options = parseRetryOptions(['eng-123']) - expect(options.taskId).toBe('eng-123') - }) - - it('rejects retry flags', () => { - expect(() => parseRetryOptions(['eng-123', '--mode', 'execution'])).toThrow( - 'parallax retry does not accept flags.' - ) - }) - - it('parses cancel options', () => { - const options = parseCancelOptions(['eng-123']) - expect(options.taskId).toBe('eng-123') - }) - - it('parses logs options with task', () => { - const options = parseLogsOptions(['--task', 'eng-123']) - expect(options.taskId).toBe('eng-123') - }) - - it('rejects unsupported logs flags', () => { - expect(() => parseLogsOptions(['--since', '-1'])).toThrow( - 'parallax logs only accepts optional --task .' - ) - }) - - it('parses preflight options with defaults', () => { - const options = parsePreflightOptions([]) - expect(options).toEqual({}) - }) - - it('rejects preflight flags', () => { - expect(() => parsePreflightOptions(['--config', './parallax.yml'])).toThrow( - 'parallax preflight does not accept flags.' - ) - }) - - it('parses status options with defaults', () => { - const options = parseStatusOptions([]) - expect(options).toEqual({}) - }) - - it('rejects status flags', () => { - expect(() => parseStatusOptions(['--verbose'])).toThrow( - 'parallax status does not accept flags.' - ) - }) - - it('parses register options', () => { - const options = parseRegisterOptions(['./config.yml'], 'register') - expect(options.configPath).toBe('./config.yml') - }) - - it('parses register env file option', () => { - const options = parseRegisterOptions(['./config.yml', '--env-file', './.env'], 'register') - expect(options.envFilePath).toBe('./.env') - }) -}) diff --git a/packages/cli/test/status.test.ts b/packages/cli/test/status.test.ts index 185aade..48a20b6 100644 --- a/packages/cli/test/status.test.ts +++ b/packages/cli/test/status.test.ts @@ -23,7 +23,6 @@ function createContext(overrides: Partial = {}): CliContext { defaultApiBase: 'http://localhost:3000', defaultDataDir: '/tmp/.parallax', manifestFile: 'running.json', - registryFile: 'registry.json', rootDir: '/tmp/parallax', cliVersion: '0.0.5', packageVersion: '0.0.5', @@ -32,10 +31,16 @@ function createContext(overrides: Partial = {}): CliContext { loadRunningState: async () => { throw new Error('offline') }, - loadRegistry: async () => ({ configs: [] }), - saveRegistry: async () => {}, + loadStoredConfig: async () => ({ + version: 1, + projects: [], + agents: [], + slack: null, + secrets: {}, + updatedAt: 0, + }), + saveStoredConfig: async () => {}, resolveDefaultApiBase: async () => 'http://localhost:3000', - validateConfigFile: async () => {}, buildEnvConfig: () => ({}), ...overrides, } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66d3385..4a6c40c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: packages/cli: dependencies: + '@clack/prompts': + specifier: 1.4.0 + version: 1.4.0 '@fastify/cors': specifier: 11.2.0 version: 11.2.0 @@ -65,15 +68,9 @@ importers: chalk: specifier: '4' version: 4.1.2 - dotenv: - specifier: 16.4.7 - version: 16.4.7 fastify: specifier: 5.7.4 version: 5.7.4 - js-yaml: - specifier: 4.1.0 - version: 4.1.0 log-update: specifier: 7.1.0 version: 7.1.0 @@ -96,9 +93,6 @@ importers: specifier: 11.0.0 version: 11.0.0 devDependencies: - '@types/js-yaml': - specifier: 4.0.9 - version: 4.0.9 '@types/node': specifier: 25.3.0 version: 25.3.0 @@ -343,15 +337,9 @@ importers: chalk: specifier: '4' version: 4.1.2 - dotenv: - specifier: 16.4.7 - version: 16.4.7 fastify: specifier: 5.7.4 version: 5.7.4 - js-yaml: - specifier: 4.1.0 - version: 4.1.0 log-update: specifier: 7.1.0 version: 7.1.0 @@ -374,9 +362,6 @@ importers: specifier: 11.0.0 version: 11.0.0 devDependencies: - '@types/js-yaml': - specifier: 4.0.9 - version: 4.0.9 '@types/uuid': specifier: 10.0.0 version: 10.0.0 @@ -639,6 +624,14 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} + '@clack/core@1.3.1': + resolution: {integrity: sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.4.0': + resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==} + engines: {node: '>= 20.12.0'} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -2051,9 +2044,6 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} - '@types/js-yaml@4.0.9': - resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} - '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2773,10 +2763,6 @@ packages: engines: {node: '>=12'} deprecated: Use your platform's native DOMException instead - dotenv@16.4.7: - resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} - engines: {node: '>=12'} - dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -3022,9 +3008,18 @@ packages: fast-querystring@1.1.2: resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} @@ -3449,10 +3444,6 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} - hasBin: true - js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -4294,6 +4285,9 @@ packages: simple-git@3.32.3: resolution: {integrity: sha512-56a5oxFdWlsGygOXHWrG+xjj5w9ZIt2uQbzqiIGdR/6i5iococ7WQ/bNPzWxCJdEUGUCmyMH0t9zMpRJTaKxmw==} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slice-ansi@7.1.2: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} @@ -4837,6 +4831,18 @@ snapshots: '@babel/runtime@7.28.6': {} + '@clack/core@1.3.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.4.0': + dependencies: + '@clack/core': 1.3.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -6121,8 +6127,6 @@ snapshots: '@types/http-errors@2.0.5': {} - '@types/js-yaml@4.0.9': {} - '@types/json-schema@7.0.15': {} '@types/jsonwebtoken@9.0.10': @@ -6949,8 +6953,6 @@ snapshots: dependencies: webidl-conversions: 7.0.0 - dotenv@16.4.7: {} - dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -7373,8 +7375,18 @@ snapshots: dependencies: fast-decode-uri-component: 1.0.1 + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + fast-uri@3.1.0: {} + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fastify-plugin@5.1.0: {} fastify@5.7.4: @@ -7788,10 +7800,6 @@ snapshots: js-tokens@9.0.1: {} - js-yaml@4.1.0: - dependencies: - argparse: 2.0.1 - js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -8684,6 +8692,8 @@ snapshots: transitivePeerDependencies: - supports-color + sisteransi@1.0.5: {} + slice-ansi@7.1.2: dependencies: ansi-styles: 6.2.3 From 7a3433957aedba6a14a9e05c3d23422989f298c7 Mon Sep 17 00:00:00 2001 From: Maxi Gimenez Date: Fri, 22 May 2026 15:27:06 +0100 Subject: [PATCH 3/8] feat(ui): three-column dashboard with Projects, Integrations, and Secrets Replace the two-tab sidebar (Tasks / Config) with a three-column layout: icon nav (52px) | list panel (280px) | main content. Add four nav sections: Tasks, Projects, Integrations, Secrets. New components: NavBar, ListPanel, ProjectEditor, AddProjectWizard, IntegrationDetail, SecretsEditor, AddSecretModal. Delete SettingsViewer and TaskSidebar (replaced by ListPanel + NavBar). Extend useParallax with CRUD mutations for projects, slack, and secrets. Add routes for /projects, /integrations, and /secrets. Co-Authored-By: Claude Sonnet 4.6 --- packages/ui/src/App.tsx | 7 +- .../ui/src/components/AddProjectWizard.tsx | 323 +++++++++++++++++ packages/ui/src/components/AddSecretModal.tsx | 112 ++++++ packages/ui/src/components/EmptyState.tsx | 27 +- .../ui/src/components/IntegrationDetail.tsx | 305 ++++++++++++++++ packages/ui/src/components/ListPanel.tsx | 249 +++++++++++++ packages/ui/src/components/NavBar.tsx | 80 +++++ packages/ui/src/components/ProjectEditor.tsx | 339 ++++++++++++++++++ packages/ui/src/components/SecretsEditor.tsx | 112 ++++++ packages/ui/src/components/SettingsViewer.tsx | 74 ---- packages/ui/src/components/TaskSidebar.tsx | 209 ----------- packages/ui/src/hooks/useParallax.ts | 53 ++- packages/ui/src/lib/task-store.ts | 15 - packages/ui/src/pages/Index.tsx | 190 +++++++--- packages/ui/src/test/index-routing.test.tsx | 19 +- 15 files changed, 1758 insertions(+), 356 deletions(-) create mode 100644 packages/ui/src/components/AddProjectWizard.tsx create mode 100644 packages/ui/src/components/AddSecretModal.tsx create mode 100644 packages/ui/src/components/IntegrationDetail.tsx create mode 100644 packages/ui/src/components/ListPanel.tsx create mode 100644 packages/ui/src/components/NavBar.tsx create mode 100644 packages/ui/src/components/ProjectEditor.tsx create mode 100644 packages/ui/src/components/SecretsEditor.tsx delete mode 100644 packages/ui/src/components/SettingsViewer.tsx delete mode 100644 packages/ui/src/components/TaskSidebar.tsx diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 174f0b1..299be5b 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -18,8 +18,11 @@ const App = () => ( } /> } /> } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/packages/ui/src/components/AddProjectWizard.tsx b/packages/ui/src/components/AddProjectWizard.tsx new file mode 100644 index 0000000..a5341a9 --- /dev/null +++ b/packages/ui/src/components/AddProjectWizard.tsx @@ -0,0 +1,323 @@ +import { useState } from 'react' +import { X, ChevronRight, ChevronLeft, Check } from 'lucide-react' +import type { ProjectConfig } from '@parallax/common' + +interface AddProjectWizardProps { + existingIds: string[] + onAdd: (project: ProjectConfig) => Promise + onClose: () => void +} + +type Step = 'identity' | 'source' | 'agent' | 'confirm' +const STEPS: Step[] = ['identity', 'source', 'agent', 'confirm'] + +const STEP_LABELS: Record = { + identity: 'Project', + source: 'Issue Source', + agent: 'Agent', + confirm: 'Confirm', +} + +export function AddProjectWizard({ existingIds, onAdd, onClose }: AddProjectWizardProps) { + const [step, setStep] = useState('identity') + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + // Identity + const [projectId, setProjectId] = useState('') + const [workspaceDir, setWorkspaceDir] = useState('') + + // Source + const [provider, setProvider] = useState<'github' | 'linear'>('github') + const [ghOwner, setGhOwner] = useState('') + const [ghRepo, setGhRepo] = useState('') + const [linearTeam, setLinearTeam] = useState('') + const [labelFilter, setLabelFilter] = useState('') + + // Agent + const [agentProvider, setAgentProvider] = useState('claude-code') + const [agentModel, setAgentModel] = useState('') + const [systemPrompt, setSystemPrompt] = useState('') + + const stepIndex = STEPS.indexOf(step) + + const validateStep = (): string | null => { + if (step === 'identity') { + if (!projectId.trim()) return 'Project ID is required.' + if (/\s/.test(projectId)) return 'Project ID must not contain spaces.' + if (existingIds.includes(projectId.trim())) return `Project "${projectId.trim()}" already exists.` + if (!workspaceDir.trim()) return 'Workspace directory is required.' + } + if (step === 'source') { + if (provider === 'github') { + if (!ghOwner.trim()) return 'GitHub owner is required.' + if (!ghRepo.trim()) return 'GitHub repository is required.' + } else { + if (!linearTeam.trim()) return 'Linear team ID is required.' + } + } + return null + } + + const handleNext = () => { + const err = validateStep() + if (err) { setError(err); return } + setError(null) + const nextIndex = stepIndex + 1 + if (nextIndex < STEPS.length) setStep(STEPS[nextIndex]) + } + + const handleBack = () => { + setError(null) + const prevIndex = stepIndex - 1 + if (prevIndex >= 0) setStep(STEPS[prevIndex]) + } + + const handleSave = async () => { + setSaving(true) + setError(null) + try { + const project: ProjectConfig = { + id: projectId.trim(), + workspaceDir: workspaceDir.trim(), + pullFrom: { + provider, + filters: + provider === 'github' + ? { + owner: ghOwner.trim(), + repo: ghRepo.trim(), + state: 'open', + labels: labelFilter.trim() ? [labelFilter.trim()] : undefined, + } + : { + team: linearTeam.trim(), + labels: labelFilter.trim() ? [labelFilter.trim()] : undefined, + }, + }, + agent: { + provider: agentProvider, + model: agentModel.trim() || undefined, + systemPrompt: systemPrompt.trim() || undefined, + }, + } + await onAdd(project) + onClose() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add project.') + } finally { + setSaving(false) + } + } + + return ( +
+
+ {/* Header */} +
+ + Add Project + + +
+ + {/* Step indicators */} +
+ {STEPS.map((s, i) => ( +
+ {i < stepIndex ? : STEP_LABELS[s]} +
+ ))} +
+ + {/* Content */} +
+ {error && ( +
+ {error} +
+ )} + + {step === 'identity' && ( + <> + + setProjectId(e.target.value)} + placeholder="my-app" + className="w-full rounded border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs text-zinc-100 placeholder:text-zinc-600 focus:border-orange-600 focus:outline-none" + /> + + + setWorkspaceDir(e.target.value)} + placeholder="/path/to/repo" + className="w-full rounded border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs text-zinc-100 placeholder:text-zinc-600 focus:border-orange-600 focus:outline-none" + /> + + + )} + + {step === 'source' && ( + <> + + + + {provider === 'github' ? ( + <> + + setGhOwner(e.target.value)} + placeholder="acme" + className="w-full rounded border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs text-zinc-100 placeholder:text-zinc-600 focus:border-orange-600 focus:outline-none" + /> + + + setGhRepo(e.target.value)} + placeholder="my-app" + className="w-full rounded border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs text-zinc-100 placeholder:text-zinc-600 focus:border-orange-600 focus:outline-none" + /> + + + ) : ( + + setLinearTeam(e.target.value)} + placeholder="ENG" + className="w-full rounded border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs text-zinc-100 placeholder:text-zinc-600 focus:border-orange-600 focus:outline-none" + /> + + )} + + setLabelFilter(e.target.value)} + placeholder="ai-ready" + className="w-full rounded border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs text-zinc-100 placeholder:text-zinc-600 focus:border-orange-600 focus:outline-none" + /> + + + )} + + {step === 'agent' && ( + <> + + + + + setAgentModel(e.target.value)} + placeholder="provider default" + className="w-full rounded border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs text-zinc-100 placeholder:text-zinc-600 focus:border-orange-600 focus:outline-none" + /> + + +