diff --git a/.codex/skills/add-tests/SKILL.md b/.codex/skills/add-tests/SKILL.md deleted file mode 100644 index 4956361..0000000 --- a/.codex/skills/add-tests/SKILL.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: add-tests -description: Add or update automated tests for parallax-cli changes that affect behavior, parsing, prompts, workflows, adapters, or user-visible output. Use when implementing or reviewing non-trivial code changes in this repo. ---- - -# Add Tests - -Use this skill whenever a change affects runtime behavior, parsing, prompt construction, API responses, CLI output, task lifecycle behavior, or PR/review flows. - -## Workflow - -1. Identify the narrowest existing test file that already covers the changed subsystem. -2. Extend existing tests before creating a new file unless the area has no coverage yet. -3. Cover the intended success path and the most likely regression or fail-fast path. -4. Keep assertions behavior-focused; avoid overspecifying incidental implementation details. -5. Run the smallest relevant test command first, then broaden only if needed. - -## Repo guidance - -- CLI parsing and command behavior usually belong in `packages/cli/test`. -- Orchestrator runtime, adapters, and git flows usually belong in `packages/orchestrator/test`. -- For prompt changes, assert on the generated prompt content or parsed metadata, not just that a command ran. -- For output formatting changes, preserve readable text and verify formatting only where it matters. - -## When tests may be skipped - -Tests can be skipped only for purely editorial/doc-only changes or repo metadata changes with no runtime effect. If skipped, say why in the final summary. - diff --git a/.codex/skills/update-docs/SKILL.md b/.codex/skills/update-docs/SKILL.md deleted file mode 100644 index ac19fc3..0000000 --- a/.codex/skills/update-docs/SKILL.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: update-docs -description: Review and update parallax-cli documentation when a change affects commands, setup, configuration, workflows, approvals, PR behavior, or other user-facing expectations. Use alongside implementation work in this repo. ---- - -# Update Docs - -Use this skill whenever a change could alter what a user sees, runs, configures, or expects. - -## Workflow - -1. Check the nearest docs first: - - `README.md` for top-level product behavior and quickstart - - `docs/` for command and workflow details - - package README files for package-specific usage -2. Update only the docs impacted by the change. -3. Keep wording concrete and aligned with the actual behavior in code. -4. If no docs change is needed, confirm that you checked and mention that in the final summary. - -## Common triggers - -- CLI flags, defaults, output, or command semantics changed -- Setup or prerequisite expectations changed -- Approval, planning, retry, PR, or review flows changed -- Configuration schema or examples changed - -## Avoid - -- Broad doc rewrites unrelated to the task -- Leaving docs for a follow-up when the behavior change is already merged - diff --git a/CLAUDE.md b/CLAUDE.md index 5b810e7..da1a077 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,9 +18,10 @@ pnpm --filter parallax-cli test # local development — use this entrypoint for all manual testing pnpm parallax preflight -pnpm parallax start --server-api-port 3000 --server-ui-port 8080 --concurrency 2 -pnpm parallax register ./parallax.example.yml --env-file ./.env -pnpm parallax pending +pnpm parallax init +pnpm parallax start --server-api-port 9371 --server-ui-port 9372 --concurrency 2 +pnpm parallax status +pnpm parallax open pnpm parallax stop ``` @@ -32,9 +33,10 @@ Parallax is a plan-first AI orchestration runtime. It pulls work from Linear or ### Package layout -- **`packages/common`** — shared models, enums (`TASK_STATUS`, `TaskPlanState`, `AGENT_PROVIDER`, etc.), interfaces (`Task`, `ProjectConfig`, `AgentResult`, `PlanResult`), and the `HostExecutor` abstraction. All cross-package types live here. +- **`packages/common`** — shared models, enums (`TASK_STATUS`, `TaskPlanState`, `AGENT_PROVIDER`, etc.), interfaces (`Task`, `ProjectConfig`, `StoredConfig`, `AgentResult`, `PlanResult`), and the `HostExecutor` abstraction. All cross-package types live here. - **`packages/orchestrator`** — the runtime process: polling loop, task state machine, AI adapter dispatch, Fastify REST API, Socket.io streaming, SQLite persistence. - **`packages/cli`** — the published `parallax-cli` npm package. It is the sole entry point for users. Commands talk to the orchestrator over HTTP. The `start` command forks the orchestrator as a child process and writes `~/.parallax/running.json`. +- **`packages/slack`** — optional Slack bot (`SlackBot`) that posts task lifecycle notifications and handles interactive commands (approve, reject, cancel). Integrated at runtime via `setSlackBot()` / `getSlackBot()` in `slack-integration.ts`. - **`packages/ui`** — React/Vite dashboard served by the orchestrator's UI server in production. - **`packages/marketing`** — standalone marketing site, not part of the runtime. @@ -42,13 +44,17 @@ Parallax is a plan-first AI orchestration runtime. It pulls work from Linear or | File/Dir | Purpose | |---|---| -| `registry.json` | Registered `parallax.yml` configs and optional env file paths | +| `config.json` | All project, agent, Slack, and secrets config (managed by `parallax init` and dashboard) | | `running.json` | PID, ports, concurrency of the active orchestrator process | | `parallax.db` | SQLite — tasks and task logs tables | | `worktrees/` | Ephemeral git worktrees created per task, cleaned up after execution | Override via `PARALLAX_DATA_DIR` env var. +### Configuration flow + +`~/.parallax/config.json` is the single source of truth. `loadConfig()` in `packages/orchestrator/src/config-loader.ts` reads it via `config-store.ts`, injects `secrets` into `process.env`, validates the structure via `config-validation.ts`, and returns `AppConfig`. Agent processes inherit secrets through `process.env`. No YAML files. + ### Task state machine Tasks move through two parallel dimensions: @@ -71,11 +77,19 @@ Cancellation is tracked via an in-memory `canceledTasks: Set` checked at ### AI adapters (`packages/orchestrator/src/ai-adapters/`) -`BaseAgentAdapter` defines two abstract methods: `runPlan(task, workingDir, project)` and `runTask(task, workingDir, project, approvedPlan?, outputMode?)`. Concrete implementations: `CodexAdapter`, `GeminiAdapter`, `ClaudeCodeAdapter`. The adapter is selected from `project.agent.provider` in `parallax.yml` and cached per project in an `adapterCache` map. +`BaseAgentAdapter` defines two abstract methods: `runPlan(task, workingDir, project)` and `runTask(task, workingDir, project, approvedPlan?, outputMode?)`. Concrete implementations: `CodexAdapter`, `GeminiAdapter`, `ClaudeCodeAdapter`. The adapter is selected from `project.agent.provider` and cached per project in an `adapterCache` map. Secrets are available in `process.env` (injected by `loadConfig()`). -### Configuration flow +### Dashboard layout + +Three-column layout: icon nav (left, 52px) | list panel (280px) | main content (fills remainder). + +- **NavBar** (`NavBar.tsx`) — icon-only vertical navigation for Tasks / Projects / Integrations +- **ListPanel** (`ListPanel.tsx`) — scrollable list for the active section +- **Main content** — `LogViewer`, `ProjectEditor`, `IntegrationDetail`, or `EmptyState` + +### API server (`packages/orchestrator/src/runtime/api-server.ts`) -`parallax register ./parallax.yml` writes the config path to `~/.parallax/registry.json`. At runtime, `loadConfig()` reads and merges all registered YAML files. Required fields per project entry: `id`, `workspaceDir`, `pullFrom.provider`, `pullFrom.filters`, `agent.provider`. +The `mutateConfig(updater)` helper reads `config.json`, applies an updater, writes back atomically, reloads the runtime, and emits `config_updated` over Socket.io. All CRUD endpoints for projects, agents, Slack, and secrets use it. ## Key conventions diff --git a/README.md b/README.md index fc4a6ba..e6c1546 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,8 @@ It pulls work from Linear or GitHub, creates isolated worktrees, runs an agent i - Plan-first task lifecycle with explicit approval/rejection. - Issue intake from Linear and GitHub. - Global runtime state under `~/.parallax`. -- CLI control plane plus dashboard UI. +- CLI onboarding wizard plus dashboard UI. - Codex, Gemini, and Claude Code adapters (configurable per project). -- Named agents with system prompts and per-label routing. - Slack bot for plan approvals and task notifications (Socket Mode, no public URL needed). ## Requirements @@ -24,7 +23,6 @@ It pulls work from Linear or GitHub, creates isolated worktrees, runs an agent i - `git` - `gh` - at least one supported agent CLI (`codex`, `gemini`, or `claude`) -- Provider credentials in your shell environment (optional per-project `.env` via `parallax register --env-file`) ## Local development setup @@ -42,113 +40,72 @@ npm i -g parallax-cli parallax preflight ``` -## Configuration (`parallax.yml`) +## First-time setup -Repository config is stored per repo, then registered into the global Parallax runtime: +Run the interactive setup wizard: ```bash -pnpm parallax start --server-api-port 3000 --server-ui-port 8080 --concurrency 2 -pnpm parallax register ./parallax.yml --env-file ./.env +parallax init ``` -`parallax.yml` is a YAML array. Items are distinguished by key — you can define named agents, a Slack integration, and one or more project entries: - -```yaml -# Optional: named agent personalities -- agents: - - name: developer - provider: claude-code - model: claude-opus-4-5 - systemPrompt: | - You are a senior backend engineer. Prioritize correctness and minimal diffs. - Always run existing tests before submitting. - - name: reviewer - provider: codex - model: o3 - -# Optional: Slack bot integration -- slack: - botToken: xoxb-your-bot-token - appToken: xapp-your-app-level-token - channel: "#ai-tasks" - -# Project entries -- id: example-repo - workspaceDir: /absolute/path/to/your/repo - pullFrom: - provider: github - filters: - owner: your-github-org-or-user - repo: your-repo - state: open - labels: [ai-ready] - agent: - name: developer - agentLabels: - ai-frontend: reviewer +The wizard collects: +- Project ID and path to your local git repository +- Issue source (GitHub or Linear) and filter settings +- AI agent (Claude Code, Codex, or Gemini) +- Slack notifications (optional) +- API secrets (Linear key if needed) + +Configuration is stored in `~/.parallax/config.json`. Projects and integrations can also be managed from the dashboard UI. + +## Starting Parallax + +```bash +parallax start +parallax open # opens the dashboard in your browser +parallax status # check health + running projects +parallax stop ``` -Projects that do not use named agents can still specify `agent.provider` directly — the old format continues to work unchanged. +## CLI + +```bash +parallax --version +parallax init # first-time setup wizard +parallax start [--server-api-port ] [--server-ui-port ] [--concurrency ] +parallax stop +parallax status +parallax open +parallax preflight +parallax pr-review +parallax retry +parallax cancel +parallax logs [--task ] +``` ## Slack bot Parallax can connect to a Slack workspace using Bolt Socket Mode. When configured, it posts plan-ready notifications with Approve and Reject buttons directly in Slack, posts PR and failure events, and responds to a `/parallax` slash command for retry, cancel, status, and pr-review. Because Socket Mode uses an outbound WebSocket, no public URL is required — it works on localhost and behind NAT. -See [docs/slack-bot.md](docs/slack-bot.md) for the full setup guide. +Configure Slack during `parallax init` or via the **Integrations** tab in the dashboard. -## CLI +See [docs/slack-bot.md](docs/slack-bot.md) for the full setup guide. -```bash -pnpm parallax --version -pnpm parallax start --server-api-port 3000 --server-ui-port 8080 --concurrency 2 -pnpm parallax register ./parallax.yml --env-file ./.env -pnpm parallax unregister ./parallax.yml -pnpm parallax stop -pnpm parallax preflight -pnpm parallax status -pnpm parallax pending -pnpm parallax pr-review -pnpm parallax retry -pnpm parallax cancel -pnpm parallax logs --task -``` +## Dashboard -Commands: +The dashboard is accessible at `http://localhost:9372` (default): -- `parallax start [--server-api-port ] [--server-ui-port ] [--concurrency ]` -- `parallax register [--env-file ]` -- `parallax unregister ` -- `parallax stop` -- `parallax preflight` -- `parallax status` -- `parallax pending [--approve ] [--reject ]` -- `parallax pr-review ` (experimental) -- `parallax retry ` -- `parallax cancel ` -- `parallax logs [--task ]` +- **Tasks** — live task list with plan approval and log streaming +- **Projects** — add, edit, and delete project configurations +- **Integrations** — configure GitHub, Linear, Slack, and API keys ## Runtime behavior 1. Pull eligible tasks from provider filters. 2. Generate plan text and persist it. -3. Wait for explicit plan approval from UI or CLI. +3. Wait for explicit plan approval from UI, CLI, or Slack. 4. Execute only approved plan steps. 5. Open/update PR and move task lifecycle state. -## Dashboard behavior - -- Pending plans are editable in a textarea and can be approved/rejected in-place. -- Task logs stream in real time. -- File changes are shown as clickable entries with side-panel diff view. - -## Development - -See [CONTRIBUTING.md](CONTRIBUTING.md). - -## Documentation - -For full user guides, see [docs/README.md](docs/README.md). - ## Publish Global CLI (`parallax-cli`) Parallax is published as a single global CLI package: @@ -163,31 +120,21 @@ Releases are published through the manual GitHub Actions workflow: - trigger it with `Run workflow` - the workflow publishes the exact version already set in [`packages/cli/package.json`](packages/cli/package.json) -Repository requirement: - -- configure npm trusted publishing for this repository/package in npm +Before triggering the release, update the version in `packages/cli/package.json`. -Before triggering the release, update the version in: +Default runtime locations and ports: -```bash -packages/cli/package.json -``` +- runtime state: `~/.parallax` +- API: `http://localhost:9371` +- dashboard: `http://localhost:9372` -Then on Raspberry Pi / any machine: +## Development -```bash -npm i -g parallax-cli -parallax preflight -parallax start --server-api-port 3000 --server-ui-port 8080 --concurrency 2 -parallax status -parallax register ./parallax.yml -``` +See [CONTRIBUTING.md](CONTRIBUTING.md). -Default runtime locations and ports: +## Documentation -- runtime state: `~/.parallax` -- API: `http://localhost:3000` -- dashboard: `http://localhost:8080` +For full user guides, see [docs/README.md](docs/README.md). ## License diff --git a/docs/README.md b/docs/README.md index 8b88165..f84f0af 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,8 +4,8 @@ This documentation is for people trying Parallax for the first time and running ## Documentation map -- [Getting Started](./getting-started.md): install Parallax, start it, register your repo, and open the dashboard. -- [Configuration Reference](./configuration.md): what goes in `parallax.yml` and how to register it. +- [Getting Started](./getting-started.md): install Parallax, run the setup wizard, and open the dashboard. +- [Configuration Reference](./configuration.md): how Parallax stores config and what each field means. - [CLI Reference](./cli-reference.md): the day-to-day commands you will actually run. - [Task Lifecycle](./task-lifecycle.md): how Parallax processes tasks from pull to PR. - [Slack Bot](./slack-bot.md): connect Parallax to Slack for plan approvals and task notifications. @@ -23,8 +23,7 @@ This documentation is for people trying Parallax for the first time and running 1. Install: `npm i -g parallax-cli` 2. Validate dependencies: `parallax preflight` -3. Create `parallax.yml` +3. Run the setup wizard: `parallax init` 4. Start Parallax: `parallax start` -5. Check runtime status: `parallax status` -6. Register config: `parallax register ./parallax.yml` -7. Open dashboard: `http://localhost:8080` +5. Open the dashboard: `parallax open` +6. Check runtime status: `parallax status` diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 11e3c47..7ebb5f6 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -9,6 +9,18 @@ parallax --version parallax --help ``` +## parallax init + +Run the interactive setup wizard to configure Parallax for the first time or add another project. + +```bash +parallax init +``` + +The wizard covers: project ID, workspace directory, issue source (GitHub or Linear), agent selection, optional secrets, and optional Slack configuration. All settings are saved to `~/.parallax/config.json`. + +If a config already exists, the wizard offers to add another project, open the dashboard, or exit. + ## parallax preflight Validate local prerequisites before first run. @@ -35,8 +47,8 @@ Notes: - no flags accepted - prints a clear message when Parallax is not running -- shows orchestrator PID and dashboard URL when healthy -- prints orchestrator stderr diagnostics when the runtime has issues +- shows orchestrator PID, dashboard URL, and configured projects when healthy +- prints orchestrator diagnostics when the runtime has issues ## parallax start @@ -46,43 +58,16 @@ Start orchestrator and dashboard in background. parallax start [--server-api-port ] [--server-ui-port ] [--concurrency ] ``` -`parallax start` initializes the global Parallax runtime from `~/.parallax` using the provided runtime flags. -Repository configs are added separately with `parallax register `. +`parallax start` reads project and secret configuration from `~/.parallax/config.json`. +If no projects are configured, it exits with an error: run `parallax init` first. Examples: ```bash -parallax start --server-api-port 3000 --server-ui-port 8080 --concurrency 2 -parallax register ./parallax.yml --env-file ./.env +parallax start +parallax start --server-api-port 9371 --server-ui-port 9372 --concurrency 2 ``` -## parallax register - -Register a repository config in the global Parallax registry. - -```bash -parallax register [--env-file ] -``` - -Notes: - -- stores the config in `~/.parallax/registry.json` -- optional `--env-file` is attached to that registered project config -- if Parallax is already running, the runtime reloads immediately - -## parallax unregister - -Remove a repository config from the global Parallax registry. - -```bash -parallax unregister -``` - -Notes: - -- fails if the config is not registered -- if Parallax is already running, the runtime reloads immediately - ## parallax stop Stop background processes recorded in the running manifest. @@ -91,20 +76,15 @@ Stop background processes recorded in the running manifest. parallax stop ``` -## parallax pending +## parallax open -List pending plans and optionally approve or reject one from the CLI. +Open the dashboard in your default browser. ```bash -parallax pending [--approve ] [--reject ] +parallax open ``` -Examples: - -```bash -parallax pending --approve 3ed59f6e7cea -parallax pending --reject 3ed59f6e7cea -``` +Reads the UI port from `~/.parallax/running.json`. Prints the URL if the orchestrator is not running. ## parallax retry @@ -159,6 +139,6 @@ Parallax stores runtime state in `~/.parallax`. Common files: +- `config.json`: project and integration configuration (managed by `parallax init` and the dashboard) - `running.json`: process manifest (`orchestratorPid`, `uiPid`, ports, start timestamp) - `parallax.db`: SQLite state database -- `registry.json`: registered repository configs diff --git a/docs/configuration.md b/docs/configuration.md index d031261..4bd6212 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,288 +1,99 @@ # Configuration Reference -Parallax project configuration is stored per repository in `parallax.yml`, then registered globally with: - -```bash -parallax register ./parallax.yml -parallax register ./parallax.yml --env-file ./.env -``` - -Runtime options such as API/UI ports and concurrency are not configured in `parallax.yml`. -Those are set when you start Parallax: - -```bash -parallax start --server-api-port 3000 --server-ui-port 8080 --concurrency 2 -``` - -## File format - -`parallax.yml` is a YAML array of project entries. - -## Minimal valid config - -```yaml -- id: example - workspaceDir: /absolute/path/to/repo - pullFrom: - provider: linear - filters: - team: ENG - agent: - provider: codex -``` - -## Project fields - -### id (required) - -- non-empty string -- must be unique across all registered configs - -### workspaceDir (required) - -- absolute path to a local repo directory -- relative paths are rejected - -### pullFrom (required) - -#### pullFrom.provider (required) - -- allowed values: `linear`, `github` - -#### pullFrom.filters (required object) - -Common fields: - -- `team` -- `state` -- `labels` -- `project` -- `owner` -- `repo` - -Provider-specific requirement: - -- for `github`, both `owner` and `repo` are required - -### agent (required) - -#### agent.provider (required) - -- allowed values: `codex`, `gemini`, `claude-code` - -#### agent.model (optional) - -- model string forwarded to the selected agent provider - -Parallax always runs supported agents in a sandbox. Approval behavior and MCP/runtime flags are built in and are not configured in `parallax.yml`. - -## Example: GitHub - -```yaml -- id: example-repo - workspaceDir: /absolute/path/to/your/repo - pullFrom: - provider: github - filters: - owner: your-github-org-or-user - repo: your-repo - state: open - labels: [ai-ready] - agent: - provider: codex - model: gpt-5.4 +Parallax stores all configuration in `~/.parallax/config.json`. You do not edit this file manually — use `parallax init` or the dashboard to manage it. + +## config.json structure + +```json +{ + "version": 1, + "projects": [...], + "slack": null, + "secrets": { "LINEAR_API_KEY": "..." }, + "updatedAt": 1716300000000 +} ``` -## Example: Linear - -```yaml -- id: platform-api - workspaceDir: /Users/you/src/platform-api - pullFrom: - provider: linear - filters: - team: API - state: Todo - agent: - provider: gemini - model: gemini-2.5-pro -``` - -## Named agents - -### The `agents:` top-level item - -`parallax.yml` supports an optional top-level `agents:` item that defines reusable agent personalities. Projects reference them by name instead of specifying a provider directly. - -```yaml -- agents: - - name: developer - provider: claude-code - model: claude-opus-4-5 - systemPrompt: | - You are a senior backend engineer. Prioritize correctness and minimal diffs. - Always run existing tests before submitting. - - name: reviewer - provider: codex - model: o3 - systemPrompt: | - You are a strict code reviewer. Focus on security, edge cases, and regressions. +### projects + +Array of project entries. Each project maps to one repository and one issue source. + +#### Minimal project + +```json +{ + "id": "my-app", + "workspaceDir": "/absolute/path/to/repo", + "pullFrom": { + "provider": "github", + "filters": { + "owner": "myorg", + "repo": "my-app", + "state": "open" + } + }, + "agent": { + "provider": "claude-code" + } +} ``` -#### Agent fields - -##### name (required) - -- non-empty string -- must be unique across the `agents:` list -- used to reference this agent from project entries - -##### provider (required) - -- allowed values: `codex`, `gemini`, `claude-code` - -##### model (optional) +#### Project fields -- model string forwarded to the selected agent provider +**id** (required) — unique identifier across all projects, no spaces. -##### systemPrompt (optional) +**workspaceDir** (required) — absolute path to a local git repository. Must contain `.git/`. -- multi-line string prepended to every prompt sent to this agent -- use it to encode team conventions, preferred patterns, or persona instructions +**pullFrom.provider** (required) — `github` or `linear`. -### Referencing a named agent from a project entry +**pullFrom.filters** — provider-specific: -Use `agent.name` instead of `agent.provider` on a project entry: +- GitHub: `owner` (required), `repo` (required), `state`, `labels` +- Linear: `team` (required), `labels`, `state` -```yaml -- id: my-repo - workspaceDir: /path/to/repo - pullFrom: - provider: github - filters: - owner: myorg - repo: my-repo - labels: [ai-ready] - agent: - name: developer -``` +**agent.provider** (required) — `claude-code`, `codex`, or `gemini`. -`agent.provider` continues to work as before for projects that do not need a named agent. +**agent.model** (optional) — pin a specific model version. Omit to use the provider default. -### Per-label agent routing (`agentLabels`) +### slack -`agentLabels` is an optional map on a project entry. It routes tickets with specific labels to a named agent, overriding the project's default agent for those tickets. +Slack bot configuration, or `null` if not configured. -```yaml -- id: my-repo - workspaceDir: /path/to/repo - pullFrom: - provider: github - filters: - owner: myorg - repo: my-repo - labels: [ai-ready] - agent: - name: developer - agentLabels: - ai-frontend: reviewer - ai-security: reviewer +```json +{ + "botToken": "xoxb-...", + "appToken": "xapp-...", + "channel": "#eng-ai" +} ``` -In this example, tickets labeled `ai-frontend` or `ai-security` are handled by the `reviewer` agent; all other tickets go to `developer`. +Managed from the **Integrations → Slack** tab in the dashboard or during `parallax init`. -## Slack bot integration +See [Slack Bot](./slack-bot.md) for the full setup guide. -### The `slack:` top-level item +### secrets -Add a `slack:` item to `parallax.yml` to enable the Slack bot integration: +Key-value map of environment variables injected into the orchestrator process at startup. Agent processes inherit them automatically. -```yaml -- slack: - botToken: xoxb-your-bot-token - appToken: xapp-your-app-level-token - channel: "#ai-tasks" +```json +{ + "LINEAR_API_KEY": "lin_api_...", + "SOME_OTHER_KEY": "value" +} ``` -#### Slack fields - -##### botToken (required) - -- Bot User OAuth Token from your Slack app -- starts with `xoxb-` -- requires `chat:write` and `commands` bot token scopes - -##### appToken (required) - -- App-Level Token from your Slack app -- starts with `xapp-` -- requires `connections:write` scope -- used for Socket Mode (outbound WebSocket; no public URL needed) - -##### channel (required) +Managed from the **Integrations** tab in the dashboard. Values are masked in the UI (`•••••••`) and never returned by the API. -- the Slack channel name where notifications are posted (e.g. `"#ai-tasks"`) -- the bot must be invited to this channel before it can post +Common secrets: -See [Slack Bot](./slack-bot.md) for the full setup guide. - -## Complete example - -The following shows all three item types in a single `parallax.yml`: - -```yaml -# Named agent personalities -- agents: - - name: developer - provider: claude-code - model: claude-opus-4-5 - systemPrompt: | - You are a senior backend engineer. Prioritize correctness and minimal diffs. - Always run existing tests before submitting. - - name: reviewer - provider: codex - model: o3 - systemPrompt: | - You are a strict code reviewer. Focus on security, edge cases, and regressions. +- `LINEAR_API_KEY` — required if any project uses Linear as the issue provider -# Slack bot integration -- slack: - botToken: xoxb-your-bot-token - appToken: xapp-your-app-level-token - channel: "#ai-tasks" - -# Project entries -- id: my-repo - workspaceDir: /path/to/repo - pullFrom: - provider: github - filters: - owner: myorg - repo: my-repo - state: open - labels: [ai-ready] - agent: - name: developer - agentLabels: - ai-frontend: reviewer - ai-security: reviewer - -- id: platform-api - workspaceDir: /Users/you/src/platform-api - pullFrom: - provider: linear - filters: - team: API - state: Todo - agent: - provider: gemini - model: gemini-2.5-pro -``` +## Managing configuration -## Validation failures you may see +| Where | What you can do | +|---|---| +| `parallax init` | First-time setup wizard; add a project | +| Dashboard → Projects | Add, edit, delete projects | +| Dashboard → Integrations | Configure GitHub, Linear, Slack, and API keys | -- `Invalid parallax config` -- `project.workspaceDir ... must be an absolute path` -- `Unsupported pull provider` -- `Unsupported agent provider` -- `Duplicate project id` +Changes made in the dashboard take effect immediately without restarting Parallax. diff --git a/docs/getting-started.md b/docs/getting-started.md index 9e45908..6c78477 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,6 +1,6 @@ # Getting Started -Parallax runs as a local service on your machine. You start it once, register repositories with `parallax.yml`, then use the dashboard to review plans and task output. +Parallax runs as a local service on your machine. Run the setup wizard once, then use the dashboard to review plans and task output. ## 1. Install @@ -38,7 +38,7 @@ parallax preflight - `codex` CLI (optional) - `gemini` CLI (optional) - `claude` CLI (optional) -- at least one agent CLI (`codex`, `gemini`, or `claude`) is available +- at least one agent CLI is available If a required check fails, fix it before moving on. @@ -51,62 +51,59 @@ gh auth login gh auth status ``` -Provider credentials: +If you plan to use Linear, have your API key ready — the setup wizard will ask for it. -- export required provider credentials in your shell environment before starting Parallax. -- use `parallax register ./parallax.yml --env-file ./.env` if a project should load credentials from a repo-specific env file. +## 4. Run the setup wizard -## 4. Create your config (`parallax.yml`) +```bash +parallax init +``` -Example: +The wizard walks through: -```yaml -- id: web-app - workspaceDir: /absolute/path/to/your/repo - pullFrom: - provider: linear - filters: - team: ENG - state: Todo - agent: - provider: codex - model: gpt-5.4 -``` +1. **Project ID** — a short identifier (e.g. `my-app`) +2. **Workspace directory** — absolute path to your local git repository +3. **Issue source** — GitHub Issues or Linear, with owner/repo or team filter +4. **Label filter** — optional, to narrow which issues Parallax picks up (e.g. `ai-ready`) +5. **AI agent** — Claude Code, OpenAI Codex, or Google Gemini +6. **Model override** — optional, to pin a specific model version +7. **Secrets** — Linear API key if you selected Linear and it is not already stored +8. **Slack notifications** — optional, configures bot/app tokens and a notification channel -For field details and more examples, see [Configuration Reference](./configuration.md). +Configuration is saved to `~/.parallax/config.json`. You can manage projects and integrations later from the dashboard. ## 5. Start Parallax -Start the runtime first, then register the repository config: - ```bash parallax start -parallax register ./parallax.yml --env-file ./.env ``` What this does: -- `parallax start` launches the background API and dashboard from `~/.parallax` -- `parallax register` adds this repository to the active project registry +- launches the background orchestrator and dashboard +- reads projects and secrets from `~/.parallax/config.json` -## 6. Open dashboard +## 6. Open the dashboard -Default URL: +```bash +parallax open +``` -- API: `http://localhost:3000` -- UI: `http://localhost:8080` +Or open `http://localhost:9372` in your browser. -Parallax stores runtime state in `~/.parallax`. +The dashboard has three sections (left navigation): -## 7. Check runtime status +- **Tasks** — live task list, plan approval, log streaming +- **Projects** — add, edit, and remove project configurations +- **Integrations** — configure GitHub, Linear, and Slack (including API keys) -Use: +## 7. Check runtime status ```bash parallax status ``` -This reports whether the local runtime is healthy and surfaces orchestrator issues when present. +Reports whether the local runtime is healthy and lists your configured projects. ## 8. Stop Parallax diff --git a/docs/slack-bot.md b/docs/slack-bot.md index a0488c8..43e5d54 100644 --- a/docs/slack-bot.md +++ b/docs/slack-bot.md @@ -72,35 +72,27 @@ In Slack, open the channel you want Parallax to post in and run: Replace `Parallax` with whatever you named your app. -## Step 7 — Add the `slack:` block to `parallax.yml` - -Add a top-level `slack:` item to your `parallax.yml` array: - -```yaml -- slack: - botToken: xoxb-your-bot-token - appToken: xapp-your-app-level-token - channel: "#ai-tasks" - -- id: my-repo - workspaceDir: /path/to/repo - pullFrom: - provider: github - filters: - owner: myorg - repo: my-repo - labels: [ai-ready] - agent: - name: developer +## Step 7 — Configure Slack in Parallax + +Run the setup wizard and follow the Slack prompts: + +```bash +parallax init ``` +Or, if Parallax is already running, open the dashboard and go to **Integrations → Slack**. Fill in: + +- **Bot token** — starts with `xoxb-` +- **App token** — starts with `xapp-` +- **Channel** — the channel where you invited the bot (e.g. `#ai-tasks`) + The `channel` value must match the channel where you invited the bot. ## Step 8 — Restart Parallax ```bash parallax stop -parallax start --server-api-port 3000 --server-ui-port 8080 --concurrency 2 +parallax start ``` Parallax reads the config at startup. You must restart for Slack changes to take effect. @@ -113,37 +105,9 @@ If no message appears, check: - The bot is invited to the correct channel. - Both tokens are correct (bot token starts with `xoxb-`, app token with `xapp-`). -- The `channel` value in `parallax.yml` matches exactly (including the `#`). +- The `channel` value matches exactly (including the `#`). - `parallax status` shows the orchestrator is running. -## Security: keeping tokens out of source control - -The `botToken` and `appToken` values are sensitive credentials. Do not commit `parallax.yml` to a repository if it contains them. - -**Important**: Parallax does not support environment variable substitution in YAML values. Writing `botToken: ${SLACK_BOT_TOKEN}` will not expand to anything — it will be passed as the literal string `${SLACK_BOT_TOKEN}`. - -The two safe approaches are: - -**Option A — Keep `parallax.yml` outside the repository** - -Store your config in a location that is never committed, such as your home directory, and register it from there: - -```bash -parallax register ~/parallax.yml -``` - -**Option B — Gitignore `parallax.yml`** - -If you want to keep the file inside the repo directory, add it to `.gitignore`: - -``` -parallax.yml -``` - -Then register it normally: - -```bash -parallax register ./parallax.yml -``` +## Security: keeping tokens safe -In both cases, document the required fields (without values) in a `parallax.example.yml` that is safe to commit, so teammates know what to fill in. +Bot and app tokens are sensitive credentials. Parallax stores them in `~/.parallax/config.json`, which is outside any repository by default. The tokens are never returned by the API and are masked in the dashboard UI. diff --git a/packages/cli/README.md b/packages/cli/README.md index 116cb19..a929809 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -22,43 +22,23 @@ npm i -g parallax-cli ```bash parallax preflight +parallax init parallax start -parallax status -parallax register ./parallax.yml +parallax open ``` What this flow does: - `parallax preflight` checks your local tooling before you start +- `parallax init` runs the interactive setup wizard (project, issue source, agent, optional Slack) - `parallax start` launches the background runtime and dashboard -- `parallax status` confirms the runtime is healthy -- `parallax register` adds a repository config to the active Parallax registry - -Open the dashboard at `http://localhost:8080` after `parallax start`. - -For flags such as custom ports, concurrency, or `--env-file`, use the hosted docs and CLI reference. - -## Example `parallax.yml` - -```yaml -- id: my-repo - workspaceDir: /absolute/path/to/local/repo - pullFrom: - provider: github - filters: - owner: your-github-org-or-user - repo: your-repo - state: open - labels: [ai-ready] - agent: - provider: codex - model: gpt-5.4 -``` +- `parallax open` opens the dashboard in your browser + +The dashboard is at `http://localhost:9372` after `parallax start`. ## How it works -- Parallax stores runtime state under `~/.parallax` -- Each registered repository keeps its own `parallax.yml` +- Parallax stores all configuration and runtime state under `~/.parallax` - Tasks run in isolated worktrees so changes stay scoped and reviewable - The dashboard is where you review plans, inspect logs, retry work, and follow PR results - When a PR receives human review comments, you can trigger: 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..9d57f2f 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, @@ -86,8 +84,8 @@ export function parseStartOptions(args: string[]): StartCommandOptions { throw new Error('parallax start accepts flags only.') } - const apiPort = parseStrictPort(args, 'server-api-port', 3000) - const uiPort = parseStrictPort(args, 'server-ui-port', 8080) + const apiPort = parseStrictPort(args, 'server-api-port', 9371) + const uiPort = parseStrictPort(args, 'server-ui-port', 9372) const rawConcurrency = parseOptionalArg(args, 'concurrency') const concurrency = rawConcurrency === undefined ? 2 : Number.parseInt(rawConcurrency, 10) @@ -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..aa8ed6f --- /dev/null +++ b/packages/cli/src/commands/init.ts @@ -0,0 +1,373 @@ +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:9372` + 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) + + // --- 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, + }, + } + + // --- 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..c8632ae --- /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:9372` + + 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..7647295 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.` ) } @@ -117,7 +121,6 @@ export async function runStart(args: string[], context: CliContext) { '--filter', '@parallax/ui', 'start', - '--', '--host', '0.0.0.0', '--port', @@ -176,12 +179,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..5c7828e 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,58 @@ 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']) : [], + 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: [], + 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..f6b454d 100644 --- a/packages/cli/test/logs.test.ts +++ b/packages/cli/test/logs.test.ts @@ -17,10 +17,9 @@ import { formatLogLine, runLogs } from '../src/commands/logs.js' function createContext(overrides: Partial = {}): CliContext { return { - defaultApiBase: 'http://localhost:3000', + defaultApiBase: 'http://localhost:9371', defaultDataDir: '/tmp/.parallax', manifestFile: 'running.json', - registryFile: 'registry.json', rootDir: '/tmp/parallax', cliVersion: '0.0.8', packageVersion: '0.0.8', @@ -29,13 +28,18 @@ function createContext(overrides: Partial = {}): CliContext { loadRunningState: async () => ({ startedAt: Date.now(), orchestratorPid: 1, - apiPort: 3000, - uiPort: 8080, + apiPort: 9371, + uiPort: 9372, }), - loadRegistry: async () => ({ configs: [] }), - saveRegistry: async () => {}, - resolveDefaultApiBase: async () => 'http://localhost:3000', - validateConfigFile: async () => {}, + loadStoredConfig: async () => ({ + version: 1, + projects: [], + slack: null, + secrets: {}, + updatedAt: 0, + }), + saveStoredConfig: async () => {}, + resolveDefaultApiBase: async () => 'http://localhost:9371', buildEnvConfig: () => ({}), ...overrides, } @@ -58,7 +62,7 @@ describe('runLogs', () => { await expect(runLogs([], createContext())).rejects.toBe(stopLoop) - expect(fetch).toHaveBeenCalledWith('http://localhost:3000/logs?since=5000&limit=500') + expect(fetch).toHaveBeenCalledWith('http://localhost:9371/logs?since=5000&limit=500') }) it('prints only new entries once while preserving the existing output shape', async () => { @@ -120,7 +124,7 @@ describe('runLogs', () => { expect(stripAnsi(String(logSpy.mock.calls[1]?.[0]))).toBe( '1970-01-01T00:00:05.001Z [task-2] ERROR ✖ second fresh log' ) - expect(fetch).toHaveBeenNthCalledWith(2, 'http://localhost:3000/logs?since=5000&limit=500') + expect(fetch).toHaveBeenNthCalledWith(2, 'http://localhost:9371/logs?since=5000&limit=500') }) it('applies severity-based ANSI styling without changing the readable text', () => { diff --git a/packages/cli/test/open.test.ts b/packages/cli/test/open.test.ts new file mode 100644 index 0000000..7adf46c --- /dev/null +++ b/packages/cli/test/open.test.ts @@ -0,0 +1,104 @@ +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:9371', + 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: 9371, + uiPort: 9372, + }), + loadStoredConfig: async () => ({ + version: 1, + projects: [], + 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:9372"') + expect(logSpy).toHaveBeenCalledWith('Opened http://localhost:9372') + }) + + 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:9372') + }) +}) 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..4a1f741 100644 --- a/packages/cli/test/status.test.ts +++ b/packages/cli/test/status.test.ts @@ -20,10 +20,9 @@ import { runStatus } from '../src/commands/status.js' function createContext(overrides: Partial = {}): CliContext { return { - defaultApiBase: 'http://localhost:3000', + defaultApiBase: 'http://localhost:9371', defaultDataDir: '/tmp/.parallax', manifestFile: 'running.json', - registryFile: 'registry.json', rootDir: '/tmp/parallax', cliVersion: '0.0.5', packageVersion: '0.0.5', @@ -32,10 +31,15 @@ function createContext(overrides: Partial = {}): CliContext { loadRunningState: async () => { throw new Error('offline') }, - loadRegistry: async () => ({ configs: [] }), - saveRegistry: async () => {}, - resolveDefaultApiBase: async () => 'http://localhost:3000', - validateConfigFile: async () => {}, + loadStoredConfig: async () => ({ + version: 1, + projects: [], + slack: null, + secrets: {}, + updatedAt: 0, + }), + saveStoredConfig: async () => {}, + resolveDefaultApiBase: async () => 'http://localhost:9371', buildEnvConfig: () => ({}), ...overrides, } @@ -46,8 +50,8 @@ function createRunningState(overrides: Partial = {}): RunningState startedAt: Date.now(), orchestratorPid: 1234, uiPid: 5678, - apiPort: 3000, - uiPort: 8080, + apiPort: 9371, + uiPort: 9372, ...overrides, } } @@ -104,7 +108,7 @@ describe('runStatus', () => { expect(startSpinnerMock).toHaveBeenCalledWith('Checking Parallax status...') expect(isProcessAliveMock).toHaveBeenCalledWith(1234) expect(isProcessAliveMock).toHaveBeenCalledWith(5678) - expect(fetch).toHaveBeenCalledWith('http://localhost:3000/runtime/errors') + expect(fetch).toHaveBeenCalledWith('http://localhost:9371/runtime/errors') expect(stop).toHaveBeenCalledOnce() expect(logSpy.mock.calls.map((call) => String(call[0]))).toEqual([ '', diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 7838e8d..4cd4d3b 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -146,23 +146,23 @@ export interface Task { updatedAt: number } -export interface AgentDefinition { - name: string - provider: AgentProvider - model?: string - systemPrompt?: string -} - export interface SlackConfig { botToken: string appToken: string channel: string } +export interface StoredConfig { + version: number + projects: ProjectConfig[] + 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: { @@ -177,10 +177,7 @@ export interface ProjectConfig { agent: { provider: AgentProvider model?: string - name?: string - systemPrompt?: string } - agentLabels?: Record } export interface ServerConfig { @@ -188,13 +185,12 @@ export interface ServerConfig { uiPort: number } -export const DEFAULT_API_PORT = 3000 -export const DEFAULT_UI_PORT = 8080 +export const DEFAULT_API_PORT = 9371 +export const DEFAULT_UI_PORT = 9372 export const DEFAULT_CONCURRENCY = 2 export interface AppConfig { projects: ProjectConfig[] - agents: AgentDefinition[] slack?: SlackConfig concurrency: number logs: LogLevel[] diff --git a/packages/marketing/src/components/ConfigSection.tsx b/packages/marketing/src/components/ConfigSection.tsx index 2ac638b..dba8d5d 100644 --- a/packages/marketing/src/components/ConfigSection.tsx +++ b/packages/marketing/src/components/ConfigSection.tsx @@ -1,18 +1,23 @@ -import CodeBlock from "./CodeBlock"; -import InlineCopyCode from "./InlineCopyCode"; +import { LayoutDashboard, Terminal } from "lucide-react"; +import TerminalBlock from "./TerminalBlock"; -const configExample = `- id: example-repo - workspaceDir: /absolute/path/to/your/repo - pullFrom: - provider: github - filters: - owner: your-github-org-or-user - repo: your-repo - state: open - labels: [ai-ready] - agent: - provider: codex - model: gpt-5.4`; +const wizardLines: { number: number; content: React.ReactNode; isCommand?: boolean }[] = [ + { number: 1, content: $ parallax init, isCommand: true }, + { number: 2, content: parallax_ }, + { number: 3, content: "Local-first AI orchestration runtime" }, + { number: 4, content: "" }, + { number: 5, content: "◆ Welcome — let's get you set up" }, + { number: 6, content: "│" }, + { number: 7, content: <> Project ID my-app }, + { number: 8, content: <> Local git repo path ~/code/my-app }, + { number: 9, content: <> Issue source ▸ Linear }, + { number: 10, content: <> AI agent ▸ Claude Code }, + { number: 11, content: <> Model ▸ claude-opus-4-7 }, + { number: 12, content: <> Connect Slack? yes }, + { number: 13, content: "│" }, + { number: 14, content: Setup complete. }, + { number: 15, content: "Run 'parallax start' to begin." }, +]; const ConfigSection = () => { return ( @@ -20,20 +25,37 @@ const ConfigSection = () => {

- One config per project. + From wizard to dashboard.

- Define your project, providers, and agent preferences in a{" "} - parallax.yml file. - Register it with a single command and parallax_ handles the rest. + One interactive command sets up your first project, agent, and integrations. + Everything else — additional projects, secrets, Slack — lives in the dashboard.

- + -
- Then register it: - +
+
+
+ +
+

Guided setup

+

+ Pick your repo, ticket source, AI agent, and optional Slack channel. + Config is stored at ~/.parallax/config.json — managed for you. +

+
+
+
+ +
+

Dashboard-managed

+

+ Add more projects, rotate secrets, and reconfigure integrations from the dashboard + without ever editing a file. +

+
diff --git a/packages/marketing/src/components/FlowSection.tsx b/packages/marketing/src/components/FlowSection.tsx deleted file mode 100644 index 159bb8c..0000000 --- a/packages/marketing/src/components/FlowSection.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { ArrowDown, GitPullRequest, Bot, CheckCircle } from "lucide-react"; - -const steps = [ - { - icon: , - title: "Ticket Created", - description: "A new ticket is created in your project management tool (Jira, Linear, GitHub Issues).", - terminal: "$ parallax detected ticket PROJ-142", - }, - { - icon: , - title: "Agent Plans", - description: "An AI agent spins up, analyzes the codebase and ticket, then generates a detailed implementation plan for your review.", - terminal: "$ parallax plan --ticket PROJ-142\n → analyzing codebase...\n → plan ready for review", - }, - { - icon: , - title: "Approved → PR Opened", - description: "Once you approve the plan, the agent implements the changes and opens a pull request automatically.", - terminal: "$ parallax execute --approved\n → implementing changes...\n → PR #87 opened", - }, - { - icon: , - title: "Feedback → Iterate", - description: "Review the PR. Leave feedback. A new agent session spins up to address comments and push updates.", - terminal: "$ parallax review --pr 87\n → processing feedback...\n → changes committed", - }, -]; - -const FlowSection = () => { - return ( -
-
-
-

- From ticket to merged PR, autonomously. -

-
- -
- {steps.map((step, index) => ( -
- {/* Connector line */} - {index < steps.length - 1 && ( -
- )} - -
- {/* Icon */} -
- {step.icon} -
- - {/* Content */} -
-

- {step.title} -

-

- {step.description} -

-
- {step.terminal} -
-
-
-
- ))} -
-
-
- ); -}; - -export default FlowSection; diff --git a/packages/marketing/src/components/HeroSection.tsx b/packages/marketing/src/components/HeroSection.tsx index ed81cae..9e3164a 100644 --- a/packages/marketing/src/components/HeroSection.tsx +++ b/packages/marketing/src/components/HeroSection.tsx @@ -1,4 +1,4 @@ -import { Download, ListPlus, MessageSquareMore } from "lucide-react"; +import { Download, Sparkles, MessageSquareMore } from "lucide-react"; import { Link } from "react-router-dom"; import { Badge } from "./ui/badge"; import CodeBlock from "./CodeBlock"; @@ -26,9 +26,8 @@ const HeroSection = () => { @@ -38,14 +37,14 @@ parallax register ./parallax.yml`}

Install

-

Set up the CLI, run preflight, and start the local runtime.

+

Set up the CLI and run preflight to verify your toolchain.

- +
-

Register

-

Add each repository with its own parallax.yml.

+

Configure

+

Run parallax init for an interactive wizard. No config files to hand-edit.

diff --git a/packages/marketing/src/components/InlineCopyCode.tsx b/packages/marketing/src/components/InlineCopyCode.tsx deleted file mode 100644 index d0a32f6..0000000 --- a/packages/marketing/src/components/InlineCopyCode.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useState } from "react"; -import { Check, Copy } from "lucide-react"; - -interface InlineCopyCodeProps { - code: string; - shellPrompt?: boolean; -} - -const InlineCopyCode = ({ code, shellPrompt = false }: InlineCopyCodeProps) => { - const [copied, setCopied] = useState(false); - - async function handleCopy() { - await navigator.clipboard.writeText(code); - setCopied(true); - window.setTimeout(() => setCopied(false), 1500); - } - - return ( -
- - {shellPrompt ? $ : null} - {code} - - -
- ); -}; - -export default InlineCopyCode; diff --git a/packages/marketing/src/test/config-section.test.tsx b/packages/marketing/src/test/config-section.test.tsx index 953229c..73cc26f 100644 --- a/packages/marketing/src/test/config-section.test.tsx +++ b/packages/marketing/src/test/config-section.test.tsx @@ -1,48 +1,23 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; import ConfigSection from "../components/ConfigSection"; describe("ConfigSection", () => { - beforeEach(() => { - vi.restoreAllMocks(); - Object.assign(navigator, { - clipboard: { - writeText: vi.fn().mockResolvedValue(undefined), - }, - }); - }); - - it("renders the shared yaml code block and copies the register command inline", async () => { + it("tells the wizard + dashboard story without referencing YAML", () => { render(); - expect(screen.getAllByText("parallax.yml").length).toBeGreaterThan(0); - expect(screen.getByText("workspaceDir")).toBeTruthy(); - expect(screen.getByText("/absolute/path/to/your/repo")).toBeTruthy(); - - const buttons = screen.getAllByRole("button"); - const inlineCopyButton = buttons.find((button) => - button.getAttribute("aria-label") === "Copy inline code" - ); - - expect(inlineCopyButton).toBeTruthy(); - - fireEvent.click(inlineCopyButton!); - - await waitFor(() => { - expect(navigator.clipboard.writeText).toHaveBeenCalledWith("parallax register ./parallax.yml"); - }); + expect(screen.getByText("From wizard to dashboard.")).toBeTruthy(); + expect(screen.getAllByText(/parallax init/).length).toBeGreaterThan(0); + expect(screen.getByText("Guided setup")).toBeTruthy(); + expect(screen.getByText("Dashboard-managed")).toBeTruthy(); }); - it("renders yaml syntax-highlighted content while keeping raw code copyable", () => { + it("does not surface the old YAML / register flow", () => { render(); - expect(screen.getByText("workspaceDir")).toBeTruthy(); - expect(screen.getByText("/absolute/path/to/your/repo")).toBeTruthy(); - expect(screen.getByText("[ai-ready]")).toBeTruthy(); - expect(screen.getAllByText("provider").length).toBeGreaterThan(0); - expect(screen.getByText("codex")).toBeTruthy(); - expect(screen.getByText("model")).toBeTruthy(); - expect(screen.getByText("gpt-5.4")).toBeTruthy(); + expect(screen.queryByText(/parallax\.yml/)).toBeNull(); + expect(screen.queryByText(/workspaceDir/)).toBeNull(); + expect(screen.queryByText(/parallax register/)).toBeNull(); }); }); diff --git a/packages/marketing/src/test/hero-section.test.tsx b/packages/marketing/src/test/hero-section.test.tsx index 3270168..7c0ffef 100644 --- a/packages/marketing/src/test/hero-section.test.tsx +++ b/packages/marketing/src/test/hero-section.test.tsx @@ -14,10 +14,11 @@ describe("HeroSection", () => { expect(screen.getByText("Alpha")).toBeTruthy(); expect(screen.getByRole("button", { name: "Copy code" })).toBeTruthy(); - expect(screen.getByText(/parallax preflight/)).toBeTruthy(); + expect(screen.getAllByText(/parallax init/).length).toBeGreaterThan(0); expect(screen.getByText("Install")).toBeTruthy(); - expect(screen.getByText("Register")).toBeTruthy(); + expect(screen.getByText("Configure")).toBeTruthy(); expect(screen.getByText("Review")).toBeTruthy(); - expect(screen.queryByText(/--env-file/)).toBeNull(); + expect(screen.queryByText(/parallax register/)).toBeNull(); + expect(screen.queryByText(/parallax\.yml/)).toBeNull(); }); }); 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..e41f6b5 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 @@ -15,26 +11,8 @@ export abstract class BaseAgentAdapter { this.logger.info(`Workspace already prepared via git worktree: ${workingDir}`, task.id) } - protected buildContextPrefix(project: ProjectConfig, _task: Task): string { - 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 + protected buildContextPrefix(_project: ProjectConfig, _task: Task): string { + return '' } abstract runTask( 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..6b868b9 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,343 +44,29 @@ 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, slack } = validateStoredConfig(stored) return { concurrency: parseRuntimeConcurrency(), logs: ['info', 'success', 'warn', 'error'], server: parseRuntimeServerConfig(), projects, - agents, 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..8240ed5 --- /dev/null +++ b/packages/orchestrator/src/config-store.ts @@ -0,0 +1,66 @@ +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: [], + 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']) : [], + 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..f09cabb --- /dev/null +++ b/packages/orchestrator/src/config-validation.ts @@ -0,0 +1,116 @@ +import path from 'node:path' +import { + AppConfig, + PULL_PROVIDER, + ProjectConfig, + SlackConfig, + StoredConfig, +} from '@parallax/common' + +const ALLOWED_AGENT_PROVIDERS = ['codex', 'gemini', '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 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): 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 agentProviderRaw = assertNonEmptyString( + agentRaw.provider, + `project.agent.provider for "${id}"` + ) + 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(', ')}.` + ) + } + + return { + id, + workspaceDir, + pullFrom: { + provider: provider as ProjectConfig['pullFrom']['provider'], + filters, + }, + agent: { + provider: agentProviderRaw as ProjectConfig['agent']['provider'], + model: assertOptionalString(agentRaw.model, `project.agent.model for "${id}"`), + }, + } +} + +export function validateStoredConfig(stored: StoredConfig): Pick { + const projectIds = new Set() + const projects: ProjectConfig[] = [] + for (const raw of stored.projects) { + const project = validateProject(raw) + 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, slack } +} diff --git a/packages/orchestrator/src/index.ts b/packages/orchestrator/src/index.ts index 1276616..56424a3 100644 --- a/packages/orchestrator/src/index.ts +++ b/packages/orchestrator/src/index.ts @@ -11,17 +11,12 @@ 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' import { createTaskId } from './task-id.js' -import { - buildExternalServices, - fetchProjectTasks, - resolveAgentNameForTask, - resolveAgentForTask, -} from './runtime/provider-services.js' +import { buildExternalServices, fetchProjectTasks } from './runtime/provider-services.js' import { createApiServer } from './runtime/api-server.js' import { validateRuntimeRequirements } from './runtime/preflight.js' import { resolveUiDistPath, startUiServer } from './runtime/ui-server.js' @@ -59,6 +54,7 @@ async function startRuntimeServers( activeTasks, canceledTasks, activeWorktrees, + dataDir: resolveDataDir(), }) const config = getConfig() @@ -100,9 +96,8 @@ async function pollProjects( } for (const taskWithLabels of newIssues) { - const agentName = resolveAgentNameForTask(taskWithLabels.labels, project, config) const { labels: _labels, ...task } = taskWithLabels - dbService.saveTask({ ...task, agentName }) + dbService.saveTask(task) const savedTask = dbService.getTaskByExternalId(task.externalId)! dbService.updateTaskPlanState(savedTask.id, TaskPlanState.PLAN_GENERATING) taskLifecycle.queue(savedTask.id, 'Queued for execution plan') @@ -139,7 +134,7 @@ async function pollProjects( continue } - const resolvedProject = resolveAgentForTask(task, project, config) + const resolvedProject = project const adapter = getAdapterForTask(task, resolvedProject) if (requiresPlan(task)) { @@ -155,8 +150,7 @@ async function pollProjects( adapter, gitService, canceledTasks, - services, - config + services ) } finally { canceledTasks.delete(task.id) @@ -183,8 +177,7 @@ async function pollProjects( gitService, canceledTasks, services, - activeWorktrees, - config + activeWorktrees ) } finally { canceledTasks.delete(task.id) @@ -256,8 +249,7 @@ async function main() { const adapterCache = new Map() const getAdapterForTask = (task: Task, resolvedProject: ProjectConfig) => { - const agentKey = resolvedProject.agent.name ?? resolvedProject.agent.provider - const key = `${resolvedProject.id}:${agentKey}` + const key = `${resolvedProject.id}:${resolvedProject.agent.provider}` const existing = adapterCache.get(key) if (existing) { return existing @@ -312,8 +304,7 @@ async function main() { }) dbService.updateTaskReviewState(reviewTask.id, TASK_REVIEW_STATE.REVIEW_PENDING) - const resolvedReviewProject = resolveAgentForTask(reviewTask, project, getConfig()) - const adapter = getAdapterForTask(reviewTask, resolvedReviewProject) + const adapter = getAdapterForTask(reviewTask, project) activeTasks.add(reviewTask.id) taskLifecycle.queue(reviewTask.id, `Queued PR review run for #${reviewTask.prNumber}`) void limit(async () => { @@ -358,6 +349,7 @@ async function main() { const bot = new SlackBot({ config: runtimeConfig.slack, apiBaseUrl: `http://127.0.0.1:${runtimeConfig.server.apiPort}`, + onError: (err) => logger.error(`Slack bot error: ${err.message}`), }) await bot.start() setSlackBot(bot) diff --git a/packages/orchestrator/src/runtime/api-server.ts b/packages/orchestrator/src/runtime/api-server.ts index 065de66..5a04c90 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, 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,10 +75,21 @@ 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+)?$/, + methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'], }) fastify.get('/tasks', async () => dbService.listTasks().map((task) => serializeTaskForApi(task))) @@ -358,5 +372,156 @@ 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) + 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 }) + 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 } + }) + + // --- 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/src/runtime/provider-services.ts b/packages/orchestrator/src/runtime/provider-services.ts index e2e3dcf..4c3290f 100644 --- a/packages/orchestrator/src/runtime/provider-services.ts +++ b/packages/orchestrator/src/runtime/provider-services.ts @@ -1,4 +1,4 @@ -import { PULL_PROVIDER, ProjectConfig, Task, AppConfig } from '@parallax/common' +import { PULL_PROVIDER, ProjectConfig, Task } from '@parallax/common' import { HostExecutor } from '@parallax/common/executor' import { GitHubService, TaskWithLabels } from '../github/service.js' import { LinearService } from '../linear/service.js' @@ -47,48 +47,6 @@ export async function fetchProjectTasks( return services.githubService.fetchNewIssues(project) } -export function resolveAgentNameForTask( - taskLabels: string[], - project: ProjectConfig, - _config: AppConfig -): string | undefined { - if (project.agentLabels) { - for (const label of taskLabels) { - const agentName = project.agentLabels[label] - if (agentName) { - return agentName - } - } - } - return project.agent.name -} - -export function resolveAgentForTask( - task: Task, - project: ProjectConfig, - config: AppConfig -): ProjectConfig { - const agentName = task.agentName ?? project.agent.name - if (!agentName) { - return project - } - - const namedAgent = config.agents.find((a) => a.name === agentName) - if (!namedAgent) { - return project - } - - return { - ...project, - agent: { - provider: namedAgent.provider, - model: project.agent.model ?? namedAgent.model, - name: agentName, - systemPrompt: namedAgent.systemPrompt, - }, - } -} - export async function markTaskInProgress( task: Task, project: ProjectConfig, diff --git a/packages/orchestrator/src/slack-integration.ts b/packages/orchestrator/src/slack-integration.ts index 3c25988..6a79ea2 100644 --- a/packages/orchestrator/src/slack-integration.ts +++ b/packages/orchestrator/src/slack-integration.ts @@ -1,4 +1,4 @@ -import type { Task, AgentDefinition } from '@parallax/common' +import type { Task } from '@parallax/common' export type SlackNotificationEvent = | 'plan_ready' @@ -10,7 +10,6 @@ export type SlackNotificationEvent = export interface SlackNotificationPayload { task: Task event: SlackNotificationEvent - agentDef?: AgentDefinition extra?: string } diff --git a/packages/orchestrator/src/workflow/task-runner.ts b/packages/orchestrator/src/workflow/task-runner.ts index 2dd5a95..f25947f 100644 --- a/packages/orchestrator/src/workflow/task-runner.ts +++ b/packages/orchestrator/src/workflow/task-runner.ts @@ -5,7 +5,6 @@ import { TASK_LOG_LEVEL, TASK_LOG_SOURCE, AGENT_PROVIDER, - AppConfig, PlanResult, PlanResultStatus, ProjectConfig, @@ -81,8 +80,7 @@ export async function processTaskPlan( adapter: BaseAgentAdapter, gitService: GitService, canceledTasks: Set, - services: ExternalServices, - config?: AppConfig + services: ExternalServices ) { logger.info(`Starting plan generation: ${task.title}`, task.id) dbService.updateTaskPlanState(task.id, TaskPlanState.PLAN_GENERATING) @@ -104,7 +102,7 @@ export async function processTaskPlan( dbService.updateAgentSessionId(task.id, planResult.sessionId) } - await persistPlanResult(task, project, planResult, config) + await persistPlanResult(task, project, planResult) } catch (error: any) { if (error instanceof TaskCanceledError) { taskLifecycle.cancel(task.id, 'Plan generation canceled') @@ -131,28 +129,22 @@ export async function processTaskPlan( } } -async function persistPlanResult( - task: Task, - project: ProjectConfig, - planResult: PlanResult, - config?: AppConfig -) { +async function persistPlanResult(task: Task, project: ProjectConfig, planResult: PlanResult) { const nextState = getNextPlanState(planResult.status as PlanResultStatus) dbService.updateTaskPlanOutput(task.id, { planState: nextState, planMarkdown: planResult.planMarkdown ?? null, planPrompt: assertPlanPrompt(planResult.planPrompt, task.id), planResult: planResult.output, - lastAgent: project.agent.name ?? project.agent.provider, + lastAgent: project.agent.provider, }) if (nextState === TaskPlanState.PLAN_READY) { taskLifecycle.queue(task.id, 'Plan ready. Awaiting approval.') const updatedTask = dbService.getTaskById(task.id) if (updatedTask) { - const agentDef = config?.agents.find((a) => a.name === (project.agent.name ?? task.agentName)) getSlackBot() - ?.notify({ task: updatedTask, event: 'plan_ready', agentDef }) + ?.notify({ task: updatedTask, event: 'plan_ready' }) .catch((err: any) => logger.error(`Slack notify failed: ${err?.message ?? err}`, task.id)) } return @@ -166,9 +158,8 @@ async function persistPlanResult( taskLifecycle.fail(task.id, failMessage) const failedTask = dbService.getTaskById(task.id) if (failedTask) { - const agentDef = config?.agents.find((a) => a.name === (project.agent.name ?? task.agentName)) getSlackBot() - ?.notify({ task: failedTask, event: 'failed', agentDef, extra: failMessage }) + ?.notify({ task: failedTask, event: 'failed', extra: failMessage }) .catch((err: any) => logger.error(`Slack notify failed: ${err?.message ?? err}`, task.id)) } } @@ -180,8 +171,7 @@ export async function processTask( gitService: GitService, canceledTasks: Set, services: ExternalServices, - activeWorktrees: Map, - config?: AppConfig + activeWorktrees: Map ) { logger.info(`Starting process: ${task.title}`, task.id) @@ -220,8 +210,6 @@ export async function processTask( dbService.updateAgentSessionId(task.id, result.sessionId) } - const agentDef = config?.agents.find((a) => a.name === (project.agent.name ?? task.agentName)) - if (result.success) { await emitWorktreeDiffLogs(task, gitService, worktreePath) const branchName = await gitService.commitAndPush(worktreePath, task) @@ -229,7 +217,7 @@ export async function processTask( logger.error('No changes made by agent.', task.id) taskLifecycle.fail(task.id, 'No changes made by agent.') getSlackBot() - ?.notify({ task, event: 'failed', agentDef, extra: 'No changes made by agent.' }) + ?.notify({ task, event: 'failed', extra: 'No changes made by agent.' }) .catch((err: any) => logger.error(`Slack notify failed: ${err?.message ?? err}`, task.id)) return } @@ -252,12 +240,12 @@ export async function processTask( dbService.updateTaskPlanOutput(task.id, { planState: TaskPlanState.PLAN_APPROVED, planPrompt: getTaskPlanPrompt(task), - lastAgent: project.agent.name ?? project.agent.provider, + lastAgent: project.agent.provider, }) const completedTask = dbService.getTaskById(task.id) if (completedTask) { getSlackBot() - ?.notify({ task: completedTask, event: 'pr_created', agentDef, extra: prUrl }) + ?.notify({ task: completedTask, event: 'pr_created', extra: prUrl }) .catch((err: any) => logger.error(`Slack notify failed: ${err?.message ?? err}`, task.id)) } return @@ -268,7 +256,7 @@ export async function processTask( planState: TaskPlanState.PLAN_REQUIRES_CLARIFICATION, planResult: result.error, planPrompt: getTaskPlanPrompt(task), - lastAgent: project.agent.name ?? project.agent.provider, + lastAgent: project.agent.provider, }) taskLifecycle.queue( task.id, @@ -281,7 +269,7 @@ export async function processTask( logger.error(`Agent failed: ${result.error}`, task.id) taskLifecycle.fail(task.id, `Agent failed: ${result.error}`) getSlackBot() - ?.notify({ task, event: 'failed', agentDef, extra: result.error }) + ?.notify({ task, event: 'failed', extra: result.error }) .catch((err: any) => logger.error(`Slack notify failed: ${err?.message ?? err}`, task.id)) } catch (error: any) { if (error instanceof TaskCanceledError) { @@ -295,9 +283,8 @@ export async function processTask( taskLifecycle.fail(task.id, criticalMsg) const failedTask = dbService.getTaskById(task.id) if (failedTask) { - const agentDef = config?.agents.find((a) => a.name === (project.agent.name ?? task.agentName)) getSlackBot() - ?.notify({ task: failedTask, event: 'failed', agentDef, extra: error.message }) + ?.notify({ task: failedTask, event: 'failed', extra: error.message }) .catch((err: any) => logger.error(`Slack notify failed: ${err?.message ?? err}`, task.id)) } } finally { diff --git a/packages/orchestrator/test/api-server.test.ts b/packages/orchestrator/test/api-server.test.ts index 73aba33..aa96df3 100644 --- a/packages/orchestrator/test/api-server.test.ts +++ b/packages/orchestrator/test/api-server.test.ts @@ -29,18 +29,29 @@ 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: [], + 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: [], slack: null }), + reloadRuntime: vi.fn().mockResolvedValue({ projects: [], 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, } } @@ -77,9 +88,9 @@ describe('createApiServer – CORS', () => { const res = await server.inject({ method: 'GET', url: '/tasks', - headers: { origin: 'http://localhost:3000' }, + headers: { origin: 'http://localhost:9371' }, }) - expect(res.headers['access-control-allow-origin']).toBe('http://localhost:3000') + expect(res.headers['access-control-allow-origin']).toBe('http://localhost:9371') }) it('allows requests from 127.0.0.1 origin', async () => { @@ -107,6 +118,20 @@ describe('createApiServer – CORS', () => { const res = await server.inject({ method: 'GET', url: '/tasks' }) expect(res.headers['access-control-allow-origin']).not.toBe('*') }) + + it('allows PUT and DELETE in preflight response', async () => { + const res = await server.inject({ + method: 'OPTIONS', + url: '/integrations/slack', + headers: { + origin: 'http://localhost:9372', + 'access-control-request-method': 'PUT', + }, + }) + const allowed = res.headers['access-control-allow-methods'] ?? '' + expect(allowed).toContain('PUT') + expect(allowed).toContain('DELETE') + }) }) describe('GET /tasks', () => { @@ -298,3 +323,195 @@ 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' }], + 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' }], 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: [], + 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: [], + 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..3cc6c25 100644 --- a/packages/orchestrator/test/config-loader.test.ts +++ b/packages/orchestrator/test/config-loader.test.ts @@ -34,441 +34,233 @@ afterEach(async () => { } }) +function makeStoredConfig(overrides: object = {}) { + return JSON.stringify( + { + version: 1, + projects: [], + 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 const config = await loadConfig() expect(config.projects).toHaveLength(0) - expect(config.server.apiPort).toBe(3000) - expect(config.server.uiPort).toBe(8080) + expect(config.server.apiPort).toBe(9371) + expect(config.server.uiPort).toBe(9372) 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 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, - [ - '- 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') - ) - await fs.writeFile( - registryPath, - JSON.stringify({ configs: [{ configPath, addedAt: Date.now() }] }, null, 2) - ) - 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') - expect(config.agents[0].model).toBe('claude-opus-4-5') - expect(config.agents[0].systemPrompt).toBe('You are a senior engineer.') - expect(config.projects[0].agent.provider).toBe('claude-code') - expect(config.projects[0].agent.name).toBe('developer') - expect(config.projects[0].agent.systemPrompt).toBe('You are a senior engineer.') - }) + const dataDir = await setupDataDir(root) - it('loads agentLabels mapping on a project entry', 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') - ) 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.projects[0].agentLabels).toEqual({ - 'ai-frontend': 'reviewer', - 'ai-security': 'reviewer', + expect(config.slack).toEqual({ + botToken: 'xoxb-test-token', + appToken: 'xapp-test-token', + channel: '#ai-tasks', }) }) - it('rejects an agentLabels value that references an unknown agent', 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, - [ - '- 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({ + 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('unknown agent "does-not-exist"') + await expect(loadConfig()).rejects.toThrow('xoxb-') }) - it('loads slack config from slack: item', 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 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') - ) - await fs.writeFile( - registryPath, - JSON.stringify({ configs: [{ configPath, addedAt: Date.now() }] }, null, 2) - ) - process.env.PARALLAX_DATA_DIR = dataDir - process.chdir(root) + const dataDir = await setupDataDir(root) - const config = await loadConfig() - - expect(config.slack).toEqual({ - botToken: 'xoxb-test-token', - appToken: 'xapp-test-token', - channel: '#ai-tasks', - }) - }) + const project = { + id: 'test', + workspaceDir: workspace, + pullFrom: { provider: 'github', filters: { owner: 'org', repo: 'repo' } }, + agent: { provider: 'codex' }, + } - it('rejects slack botToken that does not start 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') + path.join(dataDir, 'config.json'), + makeStoredConfig({ projects: [project, project] }) ) - await fs.writeFile( - registryPath, - JSON.stringify({ configs: [{ configPath, addedAt: Date.now() }] }, null, 2) - ) - process.env.PARALLAX_DATA_DIR = dataDir - process.chdir(root) - await expect(loadConfig()).rejects.toThrow('xoxb-') + await expect(loadConfig()).rejects.toThrow('Duplicate project id "test"') }) - it('rejects duplicate agent names across registered configs', async () => { + it('injects secrets into process.env without overwriting existing values', 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) + + process.env.EXISTING_KEY = 'existing' + delete process.env.NEW_KEY + 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({ + 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 expect(loadConfig()).rejects.toThrow('Duplicate agent name "developer"') + await loadConfig() + expect(process.env.EXISTING_KEY).toBe('existing') + expect(process.env.NEW_KEY).toBe('injected') + + delete process.env.EXISTING_KEY + delete process.env.NEW_KEY }) - it('rejects unknown agent fields', async () => { + it('returns empty config when config.json is empty object', 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', - ' sandbox: true', - ].join('\n') - ) - await fs.writeFile( - registryPath, - JSON.stringify({ configs: [{ configPath, addedAt: Date.now() }] }, null, 2) - ) + const dataDir = await setupDataDir(root) - process.env.PARALLAX_DATA_DIR = dataDir - process.chdir(root) + await fs.writeFile(path.join(dataDir, 'config.json'), '{}') - await expect(loadConfig()).rejects.toThrow('project.agent for "test" in') + const config = await loadConfig() + expect(config.projects).toHaveLength(0) + expect(config.slack).toBeUndefined() }) }) diff --git a/packages/orchestrator/test/provider-services.test.ts b/packages/orchestrator/test/provider-services.test.ts deleted file mode 100644 index ad062c9..0000000 --- a/packages/orchestrator/test/provider-services.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { resolveAgentNameForTask, resolveAgentForTask } from '../src/runtime/provider-services' -import type { AppConfig, ProjectConfig, Task } from '@parallax/common' - -const baseProject: ProjectConfig = { - id: 'p1', - workspaceDir: '/tmp/repo', - pullFrom: { provider: 'github', filters: { owner: 'org', repo: 'repo' } }, - agent: { provider: 'codex', name: 'developer' }, -} - -const baseConfig: AppConfig = { - projects: [], - agents: [ - { name: 'developer', provider: 'codex' }, - { name: 'reviewer', provider: 'gemini', model: 'gemini-2.5-pro' }, - ], - concurrency: 2, - logs: ['info', 'success', 'warn', 'error'], - server: { apiPort: 3000, uiPort: 8080 }, -} - -describe('resolveAgentNameForTask', () => { - it('returns the matching agent name when a task label matches agentLabels', () => { - const project: ProjectConfig = { - ...baseProject, - agentLabels: { 'ai-frontend': 'reviewer' }, - } - const result = resolveAgentNameForTask(['ai-ready', 'ai-frontend'], project, baseConfig) - expect(result).toBe('reviewer') - }) - - it('returns the first matching label when multiple labels match', () => { - const project: ProjectConfig = { - ...baseProject, - agentLabels: { 'ai-frontend': 'reviewer', 'ai-security': 'reviewer' }, - } - const result = resolveAgentNameForTask(['ai-security'], project, baseConfig) - expect(result).toBe('reviewer') - }) - - it('falls back to project.agent.name when no label matches', () => { - const project: ProjectConfig = { - ...baseProject, - agentLabels: { 'ai-frontend': 'reviewer' }, - } - const result = resolveAgentNameForTask(['ai-ready'], project, baseConfig) - expect(result).toBe('developer') - }) - - it('returns project.agent.name when agentLabels is undefined', () => { - const result = resolveAgentNameForTask(['ai-ready'], baseProject, baseConfig) - expect(result).toBe('developer') - }) - - it('returns undefined when no label matches and project has no agent name', () => { - const project: ProjectConfig = { - ...baseProject, - agent: { provider: 'codex' }, - } - const result = resolveAgentNameForTask(['ai-ready'], project, baseConfig) - expect(result).toBeUndefined() - }) - - it('returns undefined when task has no labels and no default agent name', () => { - const project: ProjectConfig = { - ...baseProject, - agent: { provider: 'codex' }, - } - const result = resolveAgentNameForTask([], project, baseConfig) - expect(result).toBeUndefined() - }) -}) - -describe('resolveAgentForTask', () => { - const baseTask: Task = { - id: 't1', - externalId: 'ORG/REPO#1', - title: 'Test', - description: '', - status: 'PENDING', - projectId: 'p1', - createdAt: 0, - updatedAt: 0, - } - - it('merges named agent definition into project when task.agentName matches', () => { - const task: Task = { ...baseTask, agentName: 'reviewer' } - const result = resolveAgentForTask(task, baseProject, baseConfig) - - expect(result.agent.provider).toBe('gemini') - expect(result.agent.model).toBe('gemini-2.5-pro') - expect(result.agent.name).toBe('reviewer') - }) - - it('falls back to project.agent.name when task.agentName is undefined', () => { - const configWithSystemPrompt: AppConfig = { - ...baseConfig, - agents: [ - { name: 'developer', provider: 'codex', systemPrompt: 'Be concise.' }, - { name: 'reviewer', provider: 'gemini' }, - ], - } - const result = resolveAgentForTask(baseTask, baseProject, configWithSystemPrompt) - - expect(result.agent.provider).toBe('codex') - expect(result.agent.systemPrompt).toBe('Be concise.') - }) - - it('prefers project.agent.model over named agent model when both set', () => { - const project: ProjectConfig = { - ...baseProject, - agent: { provider: 'gemini', name: 'reviewer', model: 'gemini-1.5-flash' }, - } - const task: Task = { ...baseTask, agentName: 'reviewer' } - const result = resolveAgentForTask(task, project, baseConfig) - - expect(result.agent.model).toBe('gemini-1.5-flash') - }) - - it('returns project unchanged when no agent name on task or project', () => { - const project: ProjectConfig = { ...baseProject, agent: { provider: 'codex' } } - const result = resolveAgentForTask(baseTask, project, baseConfig) - - expect(result).toBe(project) - }) - - it('returns project unchanged when named agent is not found in config', () => { - const task: Task = { ...baseTask, agentName: 'ghost-agent' } - const result = resolveAgentForTask(task, baseProject, baseConfig) - - expect(result).toBe(baseProject) - }) -}) diff --git a/packages/slack/src/bot.ts b/packages/slack/src/bot.ts index 5eeced1..70bf67f 100644 --- a/packages/slack/src/bot.ts +++ b/packages/slack/src/bot.ts @@ -12,7 +12,7 @@ export class SlackBot { private apiBaseUrl: string private threadRegistry = new Map() - constructor({ config, apiBaseUrl }: SlackBotOptions) { + constructor({ config, apiBaseUrl, onError }: SlackBotOptions) { this.channel = config.channel this.apiBaseUrl = apiBaseUrl this.app = new App({ @@ -22,6 +22,10 @@ export class SlackBot { }) this.client = new WebClient(config.botToken) + this.app.error(async (err) => { + onError?.(err) + }) + registerPlanApprovalHandlers(this.app, apiBaseUrl) registerSlashCommands(this.app, apiBaseUrl) } diff --git a/packages/slack/src/formatters.ts b/packages/slack/src/formatters.ts index 67e1112..3f80d6c 100644 --- a/packages/slack/src/formatters.ts +++ b/packages/slack/src/formatters.ts @@ -1,14 +1,10 @@ -import type { AgentDefinition, Task } from '@parallax/common' +import type { Task } from '@parallax/common' import type { Block, KnownBlock } from '@slack/web-api' const MAX_PLAN_LENGTH = 2500 -function agentIdentityLine(agentDef?: AgentDefinition): string { - if (!agentDef) { - return 'Unknown agent' - } - const model = agentDef.model ? ` / ${agentDef.model}` : '' - return `${agentDef.name} (${agentDef.provider}${model})` +function agentIdentityLine(task: Task): string { + return task.lastAgent ?? 'Agent' } function truncate(text: string, max: number): string { @@ -30,10 +26,7 @@ function markdownToMrkdwn(text: string): string { ) } -export function buildPlanApprovalMessage( - task: Task, - agentDef?: AgentDefinition -): (Block | KnownBlock)[] { +export function buildPlanApprovalMessage(task: Task): (Block | KnownBlock)[] { const planText = task.planMarkdown ? truncate(task.planMarkdown, MAX_PLAN_LENGTH) : 'No plan content available.' @@ -43,7 +36,7 @@ export function buildPlanApprovalMessage( type: 'header', text: { type: 'plain_text', - text: `Plan Ready — ${agentIdentityLine(agentDef)}`, + text: `Plan Ready — ${agentIdentityLine(task)}`, emoji: true, }, }, @@ -88,7 +81,6 @@ export function buildPlanApprovalMessage( export function buildEventMessage( task: Task, event: string, - agentDef?: AgentDefinition, extra?: string ): (Block | KnownBlock)[] { const eventLabels: Record = { @@ -99,7 +91,7 @@ export function buildEventMessage( } const label = eventLabels[event] ?? event - const agentLine = agentIdentityLine(agentDef) + const agentLine = agentIdentityLine(task) const blocks: (Block | KnownBlock)[] = [ { diff --git a/packages/slack/src/notifications.ts b/packages/slack/src/notifications.ts index 3120ce6..4bbe58b 100644 --- a/packages/slack/src/notifications.ts +++ b/packages/slack/src/notifications.ts @@ -8,12 +8,12 @@ export async function sendNotification( payload: SlackNotificationPayload, threadRegistry: Map ): Promise { - const { task, event, agentDef, extra } = payload + const { task, event, extra } = payload if (event === 'plan_ready') { const result = await client.chat.postMessage({ channel, - blocks: buildPlanApprovalMessage(task, agentDef), + blocks: buildPlanApprovalMessage(task), text: `Plan ready for ${task.externalId}: ${task.title}`, }) if (result.ts) { @@ -24,7 +24,7 @@ export async function sendNotification( await client.chat.postMessage({ channel, - blocks: buildEventMessage(task, event, agentDef, extra), + blocks: buildEventMessage(task, event, extra), text: `[${event}] ${task.externalId}: ${task.title}`, }) } diff --git a/packages/slack/src/test/formatters.test.ts b/packages/slack/src/test/formatters.test.ts index 3a7c808..5b7b0d3 100644 --- a/packages/slack/src/test/formatters.test.ts +++ b/packages/slack/src/test/formatters.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { buildPlanApprovalMessage, buildEventMessage } from '../formatters.js' -import type { AgentDefinition, Task } from '@parallax/common' +import type { Task } from '@parallax/common' const baseTask: Task = { id: 'task-1', @@ -13,43 +13,42 @@ const baseTask: Task = { updatedAt: 0, } -const agentDef: AgentDefinition = { - name: 'developer', - provider: 'claude-code', - model: 'claude-opus-4-5', -} - describe('buildPlanApprovalMessage', () => { - it('includes agent name and provider in the header', () => { - const blocks = buildPlanApprovalMessage(baseTask, agentDef) + it('includes lastAgent in the header', () => { + const task: Task = { ...baseTask, lastAgent: 'claude-code' } + const blocks = buildPlanApprovalMessage(task) const header = blocks.find((b: any) => b.type === 'header') as any - expect(header?.text?.text).toContain('developer') expect(header?.text?.text).toContain('claude-code') - expect(header?.text?.text).toContain('claude-opus-4-5') + }) + + it('falls back to "Agent" when lastAgent is not set', () => { + const blocks = buildPlanApprovalMessage(baseTask) + const header = blocks.find((b: any) => b.type === 'header') as any + expect(header?.text?.text).toContain('Agent') }) it('includes task externalId and title in the section block', () => { - const blocks = buildPlanApprovalMessage(baseTask, agentDef) + const blocks = buildPlanApprovalMessage(baseTask) const json = JSON.stringify(blocks) expect(json).toContain('ORG/REPO#42') expect(json).toContain('Add rate limiting') }) it('shows project before task in the section block', () => { - const blocks = buildPlanApprovalMessage(baseTask, agentDef) + const blocks = buildPlanApprovalMessage(baseTask) const section = blocks.find((b: any) => b.type === 'section' && b.text) as any const text: string = section?.text?.text ?? '' expect(text.indexOf('my-repo')).toBeLessThan(text.indexOf('ORG/REPO#42')) }) it('does not include description text', () => { - const blocks = buildPlanApprovalMessage(baseTask, agentDef) + const blocks = buildPlanApprovalMessage(baseTask) const json = JSON.stringify(blocks) expect(json).not.toContain('Protect the API from abuse') }) it('includes Approve and Reject action buttons', () => { - const blocks = buildPlanApprovalMessage(baseTask, agentDef) + const blocks = buildPlanApprovalMessage(baseTask) const actions = blocks.find((b: any) => b.type === 'actions') as any const actionIds = actions?.elements?.map((e: any) => e.action_id) expect(actionIds).toContain('plan_approve') @@ -57,7 +56,7 @@ describe('buildPlanApprovalMessage', () => { }) it('sets button values to task.id', () => { - const blocks = buildPlanApprovalMessage(baseTask, agentDef) + const blocks = buildPlanApprovalMessage(baseTask) const actions = blocks.find((b: any) => b.type === 'actions') as any const values = actions?.elements?.map((e: any) => e.value) expect(values).toEqual(['task-1', 'task-1']) @@ -65,7 +64,7 @@ describe('buildPlanApprovalMessage', () => { it('shows plan markdown in a section block', () => { const task: Task = { ...baseTask, planMarkdown: 'Step 1: do the thing\nStep 2: test it' } - const blocks = buildPlanApprovalMessage(task, agentDef) + const blocks = buildPlanApprovalMessage(task) const json = JSON.stringify(blocks) expect(json).toContain('Step 1: do the thing') }) @@ -73,49 +72,43 @@ describe('buildPlanApprovalMessage', () => { it('truncates plan markdown longer than 2500 characters', () => { const longPlan = 'x'.repeat(3000) const task: Task = { ...baseTask, planMarkdown: longPlan } - const blocks = buildPlanApprovalMessage(task, agentDef) + const blocks = buildPlanApprovalMessage(task) const json = JSON.stringify(blocks) expect(json).toContain('truncated') expect(json).not.toContain('x'.repeat(2600)) }) - - it('works without an agentDef (unknown agent fallback)', () => { - const blocks = buildPlanApprovalMessage(baseTask, undefined) - const header = blocks.find((b: any) => b.type === 'header') as any - expect(header?.text?.text).toContain('Unknown agent') - }) }) describe('buildEventMessage', () => { - it('includes agent identity in the message text', () => { - const blocks = buildEventMessage(baseTask, 'pr_created', agentDef, 'https://github.com/pr/1') + it('includes lastAgent in the message text', () => { + const task: Task = { ...baseTask, lastAgent: 'claude-code' } + const blocks = buildEventMessage(task, 'pr_created', 'https://github.com/pr/1') const section = blocks.find((b: any) => b.type === 'section') as any - expect(section?.text?.text).toContain('developer') expect(section?.text?.text).toContain('claude-code') }) it('includes the event label in the message', () => { - const blocks = buildEventMessage(baseTask, 'pr_created', agentDef) + const blocks = buildEventMessage(baseTask, 'pr_created') const section = blocks.find((b: any) => b.type === 'section') as any expect(section?.text?.text).toContain('PR Created') }) it('includes task externalId and title', () => { - const blocks = buildEventMessage(baseTask, 'failed', agentDef) + const blocks = buildEventMessage(baseTask, 'failed') const json = JSON.stringify(blocks) expect(json).toContain('ORG/REPO#42') expect(json).toContain('Add rate limiting') }) it('appends an extra detail block when extra is provided', () => { - const blocks = buildEventMessage(baseTask, 'failed', agentDef, 'Agent out of tokens') + const blocks = buildEventMessage(baseTask, 'failed', 'Agent out of tokens') expect(blocks).toHaveLength(2) const extra = blocks[1] as any expect(extra.text.text).toContain('Agent out of tokens') }) it('returns a single block when extra is not provided', () => { - const blocks = buildEventMessage(baseTask, 'canceled', agentDef) + const blocks = buildEventMessage(baseTask, 'canceled') expect(blocks).toHaveLength(1) }) }) diff --git a/packages/slack/src/types.ts b/packages/slack/src/types.ts index 97caa54..b8c7b15 100644 --- a/packages/slack/src/types.ts +++ b/packages/slack/src/types.ts @@ -1,4 +1,4 @@ -import type { AgentDefinition, SlackConfig, Task } from '@parallax/common' +import type { SlackConfig, Task } from '@parallax/common' export type SlackNotificationEvent = | 'plan_ready' @@ -10,11 +10,11 @@ export type SlackNotificationEvent = export interface SlackNotificationPayload { task: Task event: SlackNotificationEvent - agentDef?: AgentDefinition extra?: string } export interface SlackBotOptions { config: SlackConfig apiBaseUrl: string + onError?: (err: Error) => void } diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 174f0b1..78c662a 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -18,8 +18,10 @@ const App = () => ( } /> } /> } /> - } /> - } /> + } /> + } /> + } /> + } /> } /> diff --git a/packages/ui/src/components/AddProjectWizard.tsx b/packages/ui/src/components/AddProjectWizard.tsx new file mode 100644 index 0000000..a67fec2 --- /dev/null +++ b/packages/ui/src/components/AddProjectWizard.tsx @@ -0,0 +1,313 @@ +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 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, + }, + } + 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" + /> + + + )} + + {step === 'confirm' && ( +
+ + + + {provider === 'github' ? ( + + ) : ( + + )} + {labelFilter.trim() && } + +
+ )} +
+ + {/* Footer */} +
+ + {step === 'confirm' ? ( + + ) : ( + + )} +
+
+
+ ) +} + +function Field({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +} + +function SummaryRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value || '—'} +
+ ) +} diff --git a/packages/ui/src/components/AddSecretModal.tsx b/packages/ui/src/components/AddSecretModal.tsx new file mode 100644 index 0000000..2b25ff5 --- /dev/null +++ b/packages/ui/src/components/AddSecretModal.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react' +import { X } from 'lucide-react' + +interface AddSecretModalProps { + existingKeys: string[] + onAdd: (key: string, value: string) => Promise + onClose: () => void +} + +export function AddSecretModal({ existingKeys, onAdd, onClose }: AddSecretModalProps) { + const [key, setKey] = useState('') + const [value, setValue] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const isUpdate = existingKeys.includes(key.trim()) + + const handleSave = async () => { + const trimmedKey = key.trim() + const trimmedValue = value.trim() + if (!trimmedKey) { setError('Key is required.'); return } + if (/\s/.test(trimmedKey)) { setError('Key must not contain spaces.'); return } + if (!trimmedValue) { setError('Value is required.'); return } + + setSaving(true) + setError(null) + try { + await onAdd(trimmedKey, trimmedValue) + onClose() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save.') + } finally { + setSaving(false) + } + } + + return ( +
+
+ {/* Header */} +
+ + Add Secret + + +
+ + {/* Content */} +
+ {error && ( +
+ {error} +
+ )} + +
+ + setKey(e.target.value)} + placeholder="LINEAR_API_KEY" + autoFocus + 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 font-mono" + /> + {isUpdate && ( +

+ This key already exists. Saving will overwrite the current value. +

+ )} +
+ +
+ + setValue(e.target.value)} + placeholder="secret value" + 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" + /> +

+ Stored locally in ~/.parallax/config.json and injected as an env var at runtime. +

+
+
+ + {/* Footer */} +
+ + +
+
+
+ ) +} diff --git a/packages/ui/src/components/EmptyState.tsx b/packages/ui/src/components/EmptyState.tsx index a99c975..13afb4e 100644 --- a/packages/ui/src/components/EmptyState.tsx +++ b/packages/ui/src/components/EmptyState.tsx @@ -1,13 +1,21 @@ import { WifiOff } from 'lucide-react' import { getRequiredApiBase } from '@/lib/runtime-config' +import type { ActiveView } from './ListPanel' interface EmptyStateProps { - view: 'tasks' | 'settings' + view: ActiveView isConnected: boolean hasTasks: boolean waitingTasks: number } +const VIEW_HINTS: Record = { + tasks: '', + projects: 'Select a project from the sidebar, or click "Add project" to create one.', + integrations: 'Select an integration from the sidebar to configure it.', + secrets: 'Manage runtime environment variables on the right.', +} + export function EmptyState({ view, isConnected, hasTasks, waitingTasks }: EmptyStateProps) { const apiBase = getRequiredApiBase() @@ -25,6 +33,13 @@ export function EmptyState({ view, isConnected, hasTasks, waitingTasks }: EmptyS ) } + const hint = + view === 'tasks' + ? hasTasks + ? 'Select a task from the sidebar to inspect details.' + : 'No tasks loaded yet. Parallax is polling for work.' + : VIEW_HINTS[view] + return (
@@ -34,14 +49,8 @@ export function EmptyState({ view, isConnected, hasTasks, waitingTasks }: EmptyS _
-

Waiting tasks: {waitingTasks}

-

- {view === 'settings' - ? 'Select a project from the config tab.' - : hasTasks - ? 'Select a task from the sidebar to inspect details.' - : 'No tasks loaded yet. Parallax is polling for work.'} -

+ {view === 'tasks' &&

Waiting tasks: {waitingTasks}

} + {hint &&

{hint}

}
diff --git a/packages/ui/src/components/IntegrationDetail.tsx b/packages/ui/src/components/IntegrationDetail.tsx new file mode 100644 index 0000000..40f14c0 --- /dev/null +++ b/packages/ui/src/components/IntegrationDetail.tsx @@ -0,0 +1,305 @@ +import { useState } from 'react' +import { Github, Hash, ExternalLink } from 'lucide-react' +import type { AppConfig, SlackConfig } from '@parallax/common' + +type IntegrationName = 'github' | 'linear' | 'slack' + +interface IntegrationDetailProps { + name: IntegrationName + config: AppConfig | null + secrets: Record + onSetSecret: (key: string, value: string) => Promise + onSaveSlack: (config: SlackConfig) => Promise + onRemoveSlack: () => Promise +} + +export function IntegrationDetail({ + name, + config, + secrets, + onSetSecret, + onSaveSlack, + onRemoveSlack, +}: IntegrationDetailProps) { + if (name === 'github') return + if (name === 'linear') return + if (name === 'slack') { + return ( + + ) + } + return null +} + +function GitHubDetail() { + return ( +
+
} title="GitHub" /> +
+

Parallax uses the GitHub CLI (gh) for authentication.

+

Authenticate by running:

+
gh auth login
+

+ Once authenticated, Parallax can read issues and open pull requests on your behalf. + No additional configuration is needed here. +

+ + GitHub CLI docs + +
+
+ ) +} + +function LinearDetail({ + secrets, + onSetSecret, +}: { + secrets: Record + onSetSecret: (key: string, value: string) => Promise +}) { + const hasKey = 'LINEAR_API_KEY' in secrets + const [value, setValue] = useState('') + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) + + const handleSave = async () => { + if (!value.trim()) { setError('API key is required.'); return } + setSaving(true) + setError(null) + try { + await onSetSecret('LINEAR_API_KEY', value.trim()) + setValue('') + setSaved(true) + setTimeout(() => setSaved(false), 2000) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save.') + } finally { + setSaving(false) + } + } + + return ( +
+
L} + title="Linear" + /> +
+
+ + + {hasKey ? 'API key configured' : 'API key not set'} + +
+ + {error && ( +
+ {error} +
+ )} + +
+ + setValue(e.target.value)} + placeholder="lin_api_..." + 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" + /> +
+ + + +

+ Get your API key from{' '} + + linear.app/settings/api + + . +

+
+
+ ) +} + +function SlackDetail({ + current, + onSave, + onRemove, +}: { + current: SlackConfig | null | undefined + onSave: (config: SlackConfig) => Promise + onRemove: () => Promise +}) { + const [botToken, setBotToken] = useState('') + const [appToken, setAppToken] = useState('') + const [channel, setChannel] = useState(current?.channel ?? '') + const [saving, setSaving] = useState(false) + const [removing, setRemoving] = useState(false) + const [confirmRemove, setConfirmRemove] = useState(false) + const [error, setError] = useState(null) + + const handleSave = async () => { + if (botToken.trim() && !botToken.trim().startsWith('xoxb-')) { setError('Bot token must start with xoxb-'); return } + if (appToken.trim() && !appToken.trim().startsWith('xapp-')) { setError('App token must start with xapp-'); return } + if (!current && !botToken.trim()) { setError('Bot token is required.'); return } + if (!current && !appToken.trim()) { setError('App token is required.'); return } + if (!channel.trim()) { setError('Channel is required.'); return } + setSaving(true) + setError(null) + try { + await onSave({ + botToken: botToken.trim() || current!.botToken, + appToken: appToken.trim() || current!.appToken, + channel: channel.trim(), + }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save.') + } finally { + setSaving(false) + } + } + + const handleRemove = async () => { + setRemoving(true) + try { + await onRemove() + setBotToken('') + setAppToken('') + setChannel('') + setConfirmRemove(false) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to remove.') + } finally { + setRemoving(false) + } + } + + return ( +
+
+
+ + Slack + {current && ( + + Connected + + )} +
+ {current && ( + confirmRemove ? ( +
+ Disconnect Slack? + + +
+ ) : ( + + ) + )} +
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setBotToken(e.target.value)} + placeholder={current ? '(unchanged)' : 'xoxb-...'} + 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" + /> + + + setAppToken(e.target.value)} + placeholder={current ? '(unchanged)' : 'xapp-...'} + 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" + /> + + + setChannel(e.target.value)} + placeholder="#eng-ai" + 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" + /> + +
+ + +
+
+ ) +} + +function Header({ icon, title }: { icon: React.ReactNode; title: string }) { + return ( +
+ {icon} + {title} +
+ ) +} + +function FormField({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +} diff --git a/packages/ui/src/components/ListPanel.tsx b/packages/ui/src/components/ListPanel.tsx new file mode 100644 index 0000000..a331947 --- /dev/null +++ b/packages/ui/src/components/ListPanel.tsx @@ -0,0 +1,231 @@ +import { useMemo } from 'react' +import { cn } from '@/lib/utils' +import type { TaskInfo } from '@/hooks/useParallax' +import type { AppConfig } from '@parallax/common' +import { + FolderOpen, + Github, + Hash, + Layers, + List, + Plug, + Plus, +} from 'lucide-react' +import { TASK_STATUS, TASK_STATUS_LABEL, type TaskStatus } from '@/lib/task-constants' + +export type ActiveView = 'tasks' | 'projects' | 'integrations' + +interface ListPanelProps { + selectedId: string | null + onSelectItem: (id: string) => void + activeView: ActiveView + onAddProject: () => void + tasks: TaskInfo[] + config: AppConfig | null +} + +const STATUS_ORDER: TaskStatus[] = [ + TASK_STATUS.QUEUED, + TASK_STATUS.RUNNING, + TASK_STATUS.CANCELED, + TASK_STATUS.FAILED, + TASK_STATUS.DONE, +] + +const STATUS_DOT_COLOR: Record = { + [TASK_STATUS.QUEUED]: '#71717a', + [TASK_STATUS.RUNNING]: '#3b82f6', + [TASK_STATUS.CANCELED]: '#eab308', + [TASK_STATUS.FAILED]: '#ef4444', + [TASK_STATUS.DONE]: '#22c55e', +} + +const INTEGRATION_ITEMS = [ + { + id: 'github', + label: 'GitHub', + description: 'Issues & pull requests', + icon: , + }, + { + id: 'linear', + label: 'Linear', + description: 'Issue tracker', + icon: , + }, + { + id: 'slack', + label: 'Slack', + description: 'Notifications', + icon: , + }, +] as const + +const VIEW_META: Record = { + tasks: { label: 'Tasks', icon: }, + projects: { label: 'Projects', icon: }, + integrations: { label: 'Integrations', icon: }, +} + +export function ListPanel({ + selectedId, + onSelectItem, + activeView, + onAddProject, + tasks, + config, +}: ListPanelProps) { + const counts = useMemo(() => { + const initial: Record = { + [TASK_STATUS.QUEUED]: 0, + [TASK_STATUS.RUNNING]: 0, + [TASK_STATUS.CANCELED]: 0, + [TASK_STATUS.FAILED]: 0, + [TASK_STATUS.DONE]: 0, + } + for (const task of tasks) { + initial[task.status] += 1 + } + return initial + }, [tasks]) + + return ( + + ) +} diff --git a/packages/ui/src/components/NavBar.tsx b/packages/ui/src/components/NavBar.tsx new file mode 100644 index 0000000..f8d53c8 --- /dev/null +++ b/packages/ui/src/components/NavBar.tsx @@ -0,0 +1,79 @@ +import { cn } from '@/lib/utils' +import { Github, List, FolderOpen, Plug } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import type { ActiveView } from './ListPanel' + +interface NavBarProps { + activeView: ActiveView + onViewChange: (view: ActiveView) => void + isConnected: boolean +} + +const NAV_ITEMS: { id: ActiveView; icon: React.ReactNode; label: string }[] = [ + { id: 'tasks', icon: , label: 'Tasks' }, + { id: 'projects', icon: , label: 'Projects' }, + { id: 'integrations', icon: , label: 'Integrations' }, +] + +export function NavBar({ activeView, onViewChange, isConnected }: NavBarProps) { + return ( + + ) +} diff --git a/packages/ui/src/components/ProjectEditor.tsx b/packages/ui/src/components/ProjectEditor.tsx new file mode 100644 index 0000000..68acb9b --- /dev/null +++ b/packages/ui/src/components/ProjectEditor.tsx @@ -0,0 +1,287 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { Pencil, Trash2, Save, X, FolderOpen } from 'lucide-react' +import type { ProjectConfig } from '@parallax/common' + +interface ProjectEditorProps { + project: ProjectConfig + onUpdate: (id: string, patch: Partial) => Promise + onDelete: (id: string) => Promise +} + +export function ProjectEditor({ project, onUpdate, onDelete }: ProjectEditorProps) { + const navigate = useNavigate() + const [editing, setEditing] = useState(false) + const [confirmDelete, setConfirmDelete] = useState(false) + const [saving, setSaving] = useState(false) + const [deleting, setDeleting] = useState(false) + const [error, setError] = useState(null) + + const [workspaceDir, setWorkspaceDir] = useState(project.workspaceDir) + const [agentProvider, setAgentProvider] = useState(project.agent.provider) + const [agentModel, setAgentModel] = useState(project.agent.model ?? '') + const [labelFilter, setLabelFilter] = useState( + project.pullFrom.filters.labels?.[0] ?? '' + ) + + const resetForm = () => { + setWorkspaceDir(project.workspaceDir) + setAgentProvider(project.agent.provider) + setAgentModel(project.agent.model ?? '') + setLabelFilter(project.pullFrom.filters.labels?.[0] ?? '') + setError(null) + } + + useEffect(() => { + setWorkspaceDir(project.workspaceDir) + setAgentProvider(project.agent.provider) + setAgentModel(project.agent.model ?? '') + setLabelFilter(project.pullFrom.filters.labels?.[0] ?? '') + setError(null) + setEditing(false) + setConfirmDelete(false) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [project.id]) + + const handleSave = async () => { + setSaving(true) + setError(null) + try { + const patch: Partial = { + workspaceDir: workspaceDir.trim() || project.workspaceDir, + pullFrom: { + ...project.pullFrom, + filters: { + ...project.pullFrom.filters, + labels: labelFilter.trim() ? [labelFilter.trim()] : undefined, + }, + }, + agent: { + provider: agentProvider, + model: agentModel.trim() || undefined, + }, + } + await onUpdate(project.id, patch) + setEditing(false) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save.') + } finally { + setSaving(false) + } + } + + const handleDelete = async () => { + setDeleting(true) + try { + await onDelete(project.id) + navigate('/projects') + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete.') + setDeleting(false) + setConfirmDelete(false) + } + } + + const provider = project.pullFrom.provider + const filters = project.pullFrom.filters + + return ( +
+
+
+ + + Project + + — {project.id} +
+
+ {editing ? ( + <> + + + + ) : ( + <> + {confirmDelete ? ( + <> + Delete this project? + + + + ) : ( + <> + + + + )} + + )} +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ {/* Identity */} +
+

Identity

+
+ + {editing ? ( + + ) : ( + + )} +
+
+ + {/* Issue source */} +
+

Issue Source

+
+ + {provider === 'github' && ( + <> + + + + )} + {provider === 'linear' && ( + + )} + {editing ? ( + + ) : ( + + )} +
+
+ + {/* Agent */} +
+

Agent

+
+ {editing ? ( + setAgentProvider(v as ProjectConfig['agent']['provider'])} + options={[ + { value: 'claude-code', label: 'Claude Code' }, + { value: 'codex', label: 'OpenAI Codex' }, + { value: 'gemini', label: 'Google Gemini' }, + ]} + /> + ) : ( + + )} + {editing ? ( + + ) : ( + + )} +
+
+ +
+
+ ) +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ) +} + +function EditRow({ + label, + value, + onChange, + placeholder, +}: { + label: string + value: string + onChange: (v: string) => void + placeholder?: string +}) { + return ( +
+ {label} + onChange(e.target.value)} + placeholder={placeholder} + className="flex-1 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-[12px] text-zinc-100 placeholder:text-zinc-600 focus:border-orange-600 focus:outline-none" + /> +
+ ) +} + +function SelectRow({ + label, + value, + onChange, + options, +}: { + label: string + value: string + onChange: (v: string) => void + options: { value: string; label: string }[] +}) { + return ( +
+ {label} + +
+ ) +} + diff --git a/packages/ui/src/components/SecretsEditor.tsx b/packages/ui/src/components/SecretsEditor.tsx new file mode 100644 index 0000000..2e2b55e --- /dev/null +++ b/packages/ui/src/components/SecretsEditor.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react' +import { Plus, Trash2, KeyRound } from 'lucide-react' +import { AddSecretModal } from './AddSecretModal' + +interface SecretsEditorProps { + secrets: Record + onSetSecret: (key: string, value: string) => Promise + onDeleteSecret: (key: string) => Promise +} + +export function SecretsEditor({ secrets, onSetSecret, onDeleteSecret }: SecretsEditorProps) { + const [showAddModal, setShowAddModal] = useState(false) + const [deletingKey, setDeletingKey] = useState(null) + const [confirmDeleteKey, setConfirmDeleteKey] = useState(null) + + const keys = Object.keys(secrets).sort() + + const handleDelete = async (key: string) => { + setDeletingKey(key) + try { + await onDeleteSecret(key) + setConfirmDeleteKey(null) + } finally { + setDeletingKey(null) + } + } + + return ( +
+
+
+ + + Secrets + + — values are masked +
+ +
+ +
+ {keys.length === 0 ? ( +
+ +

No secrets configured.

+

+ Add API keys and runtime environment variables here. +

+ +
+ ) : ( +
+ {keys.map((key) => ( +
+
+ {key} + ••••••• +
+
+ {confirmDeleteKey === key ? ( + <> + Delete? + + + + ) : ( + + )} +
+
+ ))} +
+ )} +
+ + {showAddModal && ( + setShowAddModal(false)} + /> + )} +
+ ) +} diff --git a/packages/ui/src/components/SettingsViewer.tsx b/packages/ui/src/components/SettingsViewer.tsx deleted file mode 100644 index 660bfaa..0000000 --- a/packages/ui/src/components/SettingsViewer.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useMemo } from 'react' -import { FileCode2 } from 'lucide-react' -import type { AppConfig } from '@parallax/common' - -interface SettingsViewerProps { - projectIndex: number - config: AppConfig | null -} - -export function SettingsViewer({ projectIndex, config }: SettingsViewerProps) { - const project = config?.projects?.[projectIndex] - - const configText = useMemo(() => { - if (!project) return '# No project selected' - return JSON.stringify(project, null, 2) - .replace(/{/g, '') - .replace(/}/g, '') - .replace(/"([^"]+)":/g, '$1:') - .replace(/,/g, '') - }, [project]) - - const lines = useMemo(() => configText.split('\n').filter((line) => line.trim()), [configText]) - - const renderLine = (line: string) => { - const trimmed = line.trimStart(); - const indent = line.length - trimmed.length; - const spaces = '\u00A0'.repeat(indent); - - if (trimmed.includes(': ')) { - const [key, ...rest] = trimmed.split(': '); - const value = rest.join(': '); - return ( - - {spaces} - {key} - : - {value} - - ) - } - - return ( - - {spaces} - {trimmed} - - ) - } - - return ( -
-
-
- - - Project Config - - — {project?.id || 'unknown'} -
-
- -
- {lines.map((line, index) => ( -
- - {index + 1} - - {renderLine(line)} -
- ))} -
-
- ) -} diff --git a/packages/ui/src/components/TaskSidebar.tsx b/packages/ui/src/components/TaskSidebar.tsx deleted file mode 100644 index a3a5ba7..0000000 --- a/packages/ui/src/components/TaskSidebar.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { useMemo } from 'react' -import { cn } from '@/lib/utils' -import type { TaskInfo } from '@/hooks/useParallax' -import type { AppConfig, ProjectConfig } from '@parallax/common' -import { Bot, Github, Hash, Settings } from 'lucide-react' -import { TASK_STATUS, TASK_STATUS_LABEL, type TaskStatus } from '@/lib/task-constants' - -interface TaskSidebarProps { - selectedTaskId: string | null - onSelectTask: (id: string) => void - activeView: 'tasks' | 'settings' - onViewChange: (view: 'tasks' | 'settings') => void - tasks: TaskInfo[] - config: AppConfig | null - isConnected: boolean -} - -const STATUS_ORDER: TaskStatus[] = [ - TASK_STATUS.QUEUED, - TASK_STATUS.RUNNING, - TASK_STATUS.CANCELED, - TASK_STATUS.FAILED, - TASK_STATUS.DONE, -] - -const STATUS_DOT_COLOR: Record = { - [TASK_STATUS.QUEUED]: '#71717a', - [TASK_STATUS.RUNNING]: '#3b82f6', - [TASK_STATUS.CANCELED]: '#eab308', - [TASK_STATUS.FAILED]: '#ef4444', - [TASK_STATUS.DONE]: '#22c55e', -} - -export function TaskSidebar({ - selectedTaskId, - onSelectTask, - activeView, - onViewChange, - tasks, - config, - isConnected, -}: TaskSidebarProps) { - const counts = useMemo(() => { - const initial: Record = { - [TASK_STATUS.QUEUED]: 0, - [TASK_STATUS.RUNNING]: 0, - [TASK_STATUS.CANCELED]: 0, - [TASK_STATUS.FAILED]: 0, - [TASK_STATUS.DONE]: 0, - } - - for (const task of tasks) { - initial[task.status] += 1 - } - return initial - }, [tasks]) - - return ( - - ) -} diff --git a/packages/ui/src/hooks/useParallax.ts b/packages/ui/src/hooks/useParallax.ts index 9aa22f9..02841b7 100644 --- a/packages/ui/src/hooks/useParallax.ts +++ b/packages/ui/src/hooks/useParallax.ts @@ -3,6 +3,8 @@ import axios from 'axios' import { io } from 'socket.io-client' import type { AppConfig, + ProjectConfig, + SlackConfig, TaskLogEntry, TaskPlanState, TaskReviewState, @@ -15,7 +17,6 @@ import { hasTaskState, removeTaskState, replaceTasksFromApi, - upsertTaskState, } from '@/lib/task-store' export interface TaskInfo { @@ -47,10 +48,16 @@ export function useParallax() { const apiBase = getRequiredApiBase() const [tasks, setTasks] = useState>({}) const [config, setConfig] = useState(null) + const [secrets, setSecrets] = useState>({}) const [isConnected, setIsConnected] = useState(false) const [error, setError] = useState(null) const [orchestratorErrors, setOrchestratorErrors] = useState([]) + const refreshSecrets = async () => { + const res = await axios.get(`${apiBase}/secrets`) + setSecrets((res.data as { secrets: Record }).secrets) + } + const refreshState = async () => { const [tasksRes, configRes] = await Promise.all([ axios.get(`${apiBase}/tasks`), @@ -67,6 +74,7 @@ export function useParallax() { setConfig(configRes.data as AppConfig) setTasks((prev) => replaceTasksFromApi(prev, incoming)) setError(null) + await refreshSecrets() } const refreshOrchestratorErrors = async () => { @@ -108,6 +116,41 @@ export function useParallax() { await refreshState() } + const createProject = async (project: ProjectConfig) => { + await axios.post(`${apiBase}/projects`, project) + await refreshState() + } + + const updateProject = async (id: string, patch: Partial) => { + await axios.put(`${apiBase}/projects/${encodeURIComponent(id)}`, patch) + await refreshState() + } + + const deleteProject = async (id: string) => { + await axios.delete(`${apiBase}/projects/${encodeURIComponent(id)}`) + await refreshState() + } + + const saveSlack = async (slackConfig: SlackConfig) => { + await axios.put(`${apiBase}/integrations/slack`, slackConfig) + await refreshState() + } + + const removeSlack = async () => { + await axios.delete(`${apiBase}/integrations/slack`) + await refreshState() + } + + const setSecret = async (key: string, value: string) => { + await axios.patch(`${apiBase}/secrets/${encodeURIComponent(key)}`, { value }) + await refreshSecrets() + } + + const deleteSecret = async (key: string) => { + await axios.delete(`${apiBase}/secrets/${encodeURIComponent(key)}`) + await refreshSecrets() + } + useEffect(() => { let socket: ReturnType | undefined @@ -168,6 +211,7 @@ export function useParallax() { return { tasks, config, + secrets, isConnected, error, orchestratorErrors, @@ -175,5 +219,12 @@ export function useParallax() { cancelTask, approvePlan, rejectPlan, + createProject, + updateProject, + deleteProject, + saveSlack, + removeSlack, + setSecret, + deleteSecret, } } diff --git a/packages/ui/src/lib/task-store.ts b/packages/ui/src/lib/task-store.ts index 72aabe7..bf55377 100644 --- a/packages/ui/src/lib/task-store.ts +++ b/packages/ui/src/lib/task-store.ts @@ -127,21 +127,6 @@ export function replaceTasksFromApi( return next } -export function upsertTaskState( - previous: Record, - taskId: string, - patch: Partial -): Record { - const current = requireTask(previous, taskId) - return { - ...previous, - [taskId]: { - ...current, - ...patch, - }, - } -} - export function applyTaskLogEvent( previous: Record, event: LogEvent diff --git a/packages/ui/src/pages/Index.tsx b/packages/ui/src/pages/Index.tsx index a319467..8e29b9e 100644 --- a/packages/ui/src/pages/Index.tsx +++ b/packages/ui/src/pages/Index.tsx @@ -1,33 +1,33 @@ +import { useState, useMemo } from 'react' +import { useLocation, useNavigate, useParams } from 'react-router-dom' import { EmptyState } from '@/components/EmptyState' import { LogViewer } from '@/components/LogViewer' import { OrchestratorErrorOverlay } from '@/components/OrchestratorErrorOverlay' -import { SettingsViewer } from '@/components/SettingsViewer' -import { TaskSidebar } from '@/components/TaskSidebar' +import { NavBar } from '@/components/NavBar' +import { ListPanel, type ActiveView } from '@/components/ListPanel' +import { ProjectEditor } from '@/components/ProjectEditor' +import { AddProjectWizard } from '@/components/AddProjectWizard' +import { IntegrationDetail } from '@/components/IntegrationDetail' import { useParallax } from '@/hooks/useParallax' import { TASK_STATUS } from '@/lib/task-constants' -import { useMemo } from 'react' -import { useLocation, useNavigate, useParams } from 'react-router-dom' -const TASK_VIEW = 'tasks' -const SETTINGS_VIEW = 'settings' -const DASHBOARD_VIEW = 'dashboard' -const LOGS_VIEW = 'logs' - -type ActiveView = typeof TASK_VIEW | typeof SETTINGS_VIEW -type TaskDetailView = typeof DASHBOARD_VIEW | typeof LOGS_VIEW +type TaskDetailView = 'dashboard' | 'logs' function resolveActiveView(pathname: string): ActiveView { - return pathname.startsWith('/settings') ? SETTINGS_VIEW : TASK_VIEW + if (pathname.startsWith('/projects')) return 'projects' + if (pathname.startsWith('/integrations')) return 'integrations' + return 'tasks' } function resolveTaskDetailView(pathname: string): TaskDetailView { - return pathname.endsWith('/logs') ? LOGS_VIEW : DASHBOARD_VIEW + return pathname.endsWith('/logs') ? 'logs' : 'dashboard' } const Index = () => { const { tasks, config, + secrets, isConnected, error, orchestratorErrors, @@ -35,15 +35,24 @@ const Index = () => { cancelTask, approvePlan, rejectPlan, - } = - useParallax() + createProject, + updateProject, + deleteProject, + saveSlack, + removeSlack, + setSecret, + } = useParallax() + const navigate = useNavigate() const location = useLocation() - const { taskId, projectIndex } = useParams<{ + const { taskId, projectId, integrationName } = useParams<{ taskId?: string - projectIndex?: string + projectId?: string + integrationName?: string }>() + const [showAddProject, setShowAddProject] = useState(false) + if (error) { throw error } @@ -51,32 +60,49 @@ const Index = () => { const activeView = resolveActiveView(location.pathname) const taskDetailView = resolveTaskDetailView(location.pathname) const selectedTask = taskId ? tasks[taskId] ?? null : null - const selectedTaskId = taskId ?? null - const selectedSettingsId = projectIndex ? `project-${projectIndex}` : null - const selectedSidebarId = activeView === TASK_VIEW ? selectedTaskId : selectedSettingsId + + const selectedSidebarId = useMemo(() => { + if (activeView === 'tasks') return taskId ?? null + if (activeView === 'projects' && projectId) return `project-${projectId}` + if (activeView === 'integrations' && integrationName) return `integration-${integrationName}` + return null + }, [activeView, taskId, projectId, integrationName]) const waitingTasks = useMemo( () => Object.values(tasks).filter((task) => task.status === TASK_STATUS.QUEUED).length, [tasks] ) - const handleSelectTask = (id: string) => { + const handleSelectItem = (id: string) => { if (id.startsWith('project-')) { - navigate(`/settings/${id.replace('project-', '')}`) + navigate(`/projects/${id.replace('project-', '')}`) + return + } + if (id.startsWith('integration-')) { + navigate(`/integrations/${id.replace('integration-', '')}`) return } - navigate(`/tasks/${id}`) } const handleViewChange = (view: ActiveView) => { - navigate(view === SETTINGS_VIEW ? '/settings' : '/') + const routes: Record = { + tasks: '/', + projects: '/projects', + integrations: '/integrations', + } + navigate(routes[view]) } - return ( -
-
- {activeView === TASK_VIEW && selectedTask ? ( + const selectedProject = useMemo(() => { + if (!projectId || !config) return null + return config.projects.find((p) => p.id === projectId) ?? null + }, [projectId, config]) + + const mainContent = () => { + if (activeView === 'tasks') { + if (selectedTask) { + return ( { onOpenLogs={() => navigate(`/tasks/${selectedTask.id}/logs`)} onOpenDashboard={() => navigate(`/tasks/${selectedTask.id}`)} /> - ) : activeView === SETTINGS_VIEW && projectIndex !== undefined ? ( - - ) : ( - 0} - waitingTasks={waitingTasks} + ) + } + return ( + 0} + waitingTasks={waitingTasks} + /> + ) + } + + if (activeView === 'projects') { + if (selectedProject) { + return ( + + ) + } + return ( + + ) + } + + if (activeView === 'integrations') { + if ( + integrationName && + (integrationName === 'github' || + integrationName === 'linear' || + integrationName === 'slack') + ) { + return ( + - )} -
+ ) + } + return ( + + ) + } - + {/* Left icon nav */} + + + {/* List panel */} + setShowAddProject(true)} tasks={Object.values(tasks)} config={config} - isConnected={isConnected} /> + + {/* Main content */} +
+ {mainContent()} +
+ + + {showAddProject && ( + p.id) ?? []} + onAdd={createProject} + onClose={() => setShowAddProject(false)} + /> + )}
) } diff --git a/packages/ui/src/test/index-routing.test.tsx b/packages/ui/src/test/index-routing.test.tsx index 64cdc56..2c07520 100644 --- a/packages/ui/src/test/index-routing.test.tsx +++ b/packages/ui/src/test/index-routing.test.tsx @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' import { AGENT_PROVIDER, LOG_LEVEL, PULL_PROVIDER, TaskPlanState, TASK_REVIEW_STATE } from '@parallax/common' import Index from '@/pages/Index' +import { TooltipProvider } from '@/components/ui/tooltip' vi.mock('@/hooks/useParallax', () => ({ useParallax: () => ({ @@ -48,6 +49,7 @@ vi.mock('@/hooks/useParallax', () => ({ }, ], }, + secrets: {}, isConnected: true, error: null, orchestratorErrors: [], @@ -55,6 +57,13 @@ vi.mock('@/hooks/useParallax', () => ({ cancelTask: vi.fn(), approvePlan: vi.fn(), rejectPlan: vi.fn(), + createProject: vi.fn(), + updateProject: vi.fn(), + deleteProject: vi.fn(), + saveSlack: vi.fn(), + removeSlack: vi.fn(), + setSecret: vi.fn(), + deleteSecret: vi.fn(), }), })) @@ -69,8 +78,9 @@ function LocationProbe() { function renderIndex(initialEntries: string[]) { return render( - - + + + } /> - - + + + ) } diff --git a/packages/ui/src/test/task-helpers.test.ts b/packages/ui/src/test/task-helpers.test.ts index 8c249b1..da58ad2 100644 --- a/packages/ui/src/test/task-helpers.test.ts +++ b/packages/ui/src/test/task-helpers.test.ts @@ -21,8 +21,8 @@ describe('task helpers', () => { concurrency: 1, logs: [LOG_LEVEL.INFO], server: { - apiPort: 3000, - uiPort: 8080, + apiPort: 9371, + uiPort: 9372, }, projects: [ { @@ -47,8 +47,8 @@ describe('task helpers', () => { concurrency: 1, logs: [LOG_LEVEL.INFO], server: { - apiPort: 3000, - uiPort: 8080, + apiPort: 9371, + uiPort: 9372, }, projects: [ { @@ -77,8 +77,8 @@ describe('task helpers', () => { concurrency: 1, logs: [LOG_LEVEL.INFO], server: { - apiPort: 3000, - uiPort: 8080, + apiPort: 9371, + uiPort: 9372, }, projects: [ { diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index 01a7a31..8a676a6 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -6,7 +6,7 @@ import path from "path"; export default defineConfig(({ mode }) => ({ server: { host: "0.0.0.0", - port: 8080, + port: 9372, }, plugins: [ react(), @@ -14,6 +14,7 @@ export default defineConfig(({ mode }) => ({ resolve: { alias: { "@": path.resolve(__dirname, "./src"), + "@parallax/common": path.resolve(__dirname, "../common/src/index.ts"), }, }, })); 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 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