diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5b810e7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +pnpm install # install all workspace dependencies +pnpm build # build all packages (tsc) +pnpm test # run all tests +pnpm lint # lint all packages +pnpm lint:fix # auto-fix lint issues +pnpm clean # remove local DB and worktrees artifacts + +# run a single package's tests +pnpm --filter @parallax/orchestrator test +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 stop +``` + +Node.js >= 23.7.0 and pnpm 10.x are required. The `--filter` flag targets individual workspace packages by their `name` in `package.json`. + +## Architecture + +Parallax is a plan-first AI orchestration runtime. It pulls work from Linear or GitHub, generates a plan via an AI agent, waits for human approval, then executes the approved plan in an isolated git worktree and opens a PR. + +### 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/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/ui`** — React/Vite dashboard served by the orchestrator's UI server in production. +- **`packages/marketing`** — standalone marketing site, not part of the runtime. + +### Runtime state (`~/.parallax/`) + +| File/Dir | Purpose | +|---|---| +| `registry.json` | Registered `parallax.yml` configs and optional env file paths | +| `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. + +### Task state machine + +Tasks move through two parallel dimensions: + +**`TASK_STATUS`**: `PENDING` → `IN_PROGRESS` → `COMPLETED` / `FAILED` / `CANCELED` + +**`TaskPlanState`**: `PLAN_GENERATING` → `PLAN_READY` / `PLAN_REQUIRES_CLARIFICATION` → _(user approves)_ → `PLAN_APPROVED` → execution → `PLAN_APPROVED` (persisted on PR creation). `NOT_REQUIRED` is used for PR-review tasks that skip planning. + +State transitions are coordinated through `taskLifecycle` (`packages/orchestrator/src/task-lifecycle.ts`) which writes to the DB and updates the in-memory log display. + +### Orchestrator polling loop (`packages/orchestrator/src/index.ts`) + +The `main()` function runs an infinite loop (15 s interval) calling `pollProjects()`. For each registered project it: +1. Fetches new issues from the configured provider (Linear or GitHub). +2. Creates worktrees and runs `adapter.runPlan()` for tasks needing a plan. +3. Dispatches `adapter.runTask()` for tasks with an approved plan. +4. Enforces a `pLimit` concurrency cap across all projects. + +Cancellation is tracked via an in-memory `canceledTasks: Set` checked at each `throwIfCancellationRequested()` call. + +### 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. + +### Configuration flow + +`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`. + +## Key conventions + +- **Fail fast**: missing required config or malformed input throws immediately — no silent fallbacks. +- **Strict parsing**: all CLI arg and request parsing goes through dedicated parser functions in `args.ts` and `runtime/api/request-parsers.ts`; never parse inline. +- **`pnpm parallax `** is the canonical local testing entrypoint — do not invoke package-level scripts directly for runtime flows. +- **Docs updates belong in the same commit** as behavior changes. +- Tests live in `packages//test/` and mirror the `src/` structure. diff --git a/README.md b/README.md index 935c042..fc4a6ba 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ It pulls work from Linear or GitHub, creates isolated worktrees, runs an agent i - Global runtime state under `~/.parallax`. - CLI control plane 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 @@ -49,9 +51,28 @@ pnpm parallax start --server-api-port 3000 --server-ui-port 8080 --concurrency 2 pnpm parallax register ./parallax.yml --env-file ./.env ``` -`parallax.yml` is a YAML array of project entries: +`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: @@ -62,10 +83,19 @@ pnpm parallax register ./parallax.yml --env-file ./.env state: open labels: [ai-ready] agent: - provider: codex - model: gpt-5.4 + name: developer + agentLabels: + ai-frontend: reviewer ``` +Projects that do not use named agents can still specify `agent.provider` directly — the old format continues to work unchanged. + +## 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. + ## CLI ```bash diff --git a/docs/README.md b/docs/README.md index 470b6ba..8b88165 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,8 @@ This documentation is for people trying Parallax for the first time and running - [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. - [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. - [Troubleshooting](./troubleshooting.md): fixes for common setup and runtime problems. ## What Parallax does diff --git a/docs/configuration.md b/docs/configuration.md index 232d83e..d031261 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -108,6 +108,177 @@ Parallax always runs supported agents in a sandbox. Approval behavior and MCP/ru 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. +``` + +#### 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) + +- model string forwarded to the selected agent provider + +##### systemPrompt (optional) + +- multi-line string prepended to every prompt sent to this agent +- use it to encode team conventions, preferred patterns, or persona instructions + +### Referencing a named agent from a project entry + +Use `agent.name` instead of `agent.provider` on a project entry: + +```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` continues to work as before for projects that do not need a named agent. + +### Per-label agent routing (`agentLabels`) + +`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. + +```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 +``` + +In this example, tickets labeled `ai-frontend` or `ai-security` are handled by the `reviewer` agent; all other tickets go to `developer`. + +## Slack bot integration + +### The `slack:` top-level item + +Add a `slack:` item to `parallax.yml` to enable the Slack bot integration: + +```yaml +- slack: + botToken: xoxb-your-bot-token + appToken: xapp-your-app-level-token + channel: "#ai-tasks" +``` + +#### 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) + +- the Slack channel name where notifications are posted (e.g. `"#ai-tasks"`) +- the bot must be invited to this channel before it can post + +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. + +# 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 +``` + ## Validation failures you may see - `Invalid parallax config` diff --git a/docs/slack-bot.md b/docs/slack-bot.md new file mode 100644 index 0000000..a0488c8 --- /dev/null +++ b/docs/slack-bot.md @@ -0,0 +1,149 @@ +# Slack Bot + +Parallax can connect to a Slack workspace to post plan notifications, accept approvals, and respond to slash commands — all over an outbound WebSocket connection. No public URL is required. + +## What the Slack bot does + +- **Plan-ready notifications**: when Parallax finishes generating a plan, it posts a Block Kit message to your channel showing the agent identity, task details, and plan text. The message includes Approve and Reject buttons so you can approve plans without opening the dashboard. +- **Execution started**: posts when the agent begins implementation work. +- **PR created**: posts the PR URL when a pull request is opened. +- **Failed / Canceled**: posts the error detail when a task fails or is canceled. +- **`/parallax` slash command**: lets you act on tasks directly from Slack. + +### `/parallax` slash command subcommands + +| Subcommand | Effect | +|---|---| +| `/parallax retry ` | Queues a retry for a failed or rejected task | +| `/parallax cancel ` | Cancels a pending or running task | +| `/parallax status ` | Prints the current task status and plan state | +| `/parallax pr-review ` | Triggers an on-demand PR review | + +Task IDs appear in plan-ready messages and in the dashboard. + +## How it works + +Parallax uses Bolt **Socket Mode**: it opens an outbound WebSocket to Slack's API servers. There is no inbound HTTP server to expose, no public URL to configure, and no need to punch through a firewall or NAT. It works on localhost and on air-gapped machines as long as they have outbound HTTPS/WSS access. + +## Step 1 — Create a Slack App + +1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App**. +2. Choose **From scratch**. +3. Enter an app name (e.g. `Parallax`) and select the workspace you want to install it in. +4. Click **Create App**. + +## Step 2 — Enable Socket Mode and generate an App-Level Token + +1. In the left sidebar, click **Socket Mode**. +2. Toggle **Enable Socket Mode** on. +3. You will be prompted to generate an App-Level Token. Give it a name (e.g. `parallax-socket`) and add the `connections:write` scope. +4. Click **Generate**. Copy the token — it starts with `xapp-`. You will not be able to view it again. + +## Step 3 — Add OAuth scopes + +1. In the left sidebar, click **OAuth & Permissions**. +2. Scroll to **Bot Token Scopes** and add: + - `chat:write` + - `commands` +3. Click **Save Changes**. + +## Step 4 — Install the app to your workspace + +1. Still on the **OAuth & Permissions** page, scroll to the top and click **Install to Workspace**. +2. Review the permissions and click **Allow**. +3. Copy the **Bot User OAuth Token** — it starts with `xoxb-`. + +## Step 5 — Create the `/parallax` slash command + +1. In the left sidebar, click **Slash Commands**, then **Create New Command**. +2. Fill in: + - **Command**: `/parallax` + - **Request URL**: any valid URL (e.g. `https://example.com/slack`) — Socket Mode intercepts delivery before this URL is ever called. + - **Short Description**: `Manage Parallax tasks` +3. Click **Save**. + +## Step 6 — Invite the bot to your channel + +In Slack, open the channel you want Parallax to post in and run: + +``` +/invite @Parallax +``` + +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 +``` + +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 reads the config at startup. You must restart for Slack changes to take effect. + +## Step 9 — Verify it works + +Create or trigger a task that Parallax will pick up. When the plan finishes generating you should see a message appear in your configured channel with Approve and Reject buttons. + +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 `#`). +- `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 +``` + +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. diff --git a/docs/task-lifecycle.md b/docs/task-lifecycle.md index b2c178b..219af8e 100644 --- a/docs/task-lifecycle.md +++ b/docs/task-lifecycle.md @@ -30,11 +30,13 @@ Plan approval can be done from: - task dashboard plan section - CLI via `parallax pending --approve ` +- Slack, by clicking the **Approve** button in the plan-ready message (requires the [Slack bot](./slack-bot.md) to be configured) Rejection: - dashboard - CLI via `parallax pending --reject ` +- Slack, by clicking the **Reject** button in the plan-ready message ## 4. Execution phase diff --git a/eslint.config.js b/eslint.config.js index d7581e8..b968fce 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,7 +22,10 @@ export default tseslint.config( curly: ['error', 'all'], '@typescript-eslint/no-explicit-any': 'off', 'no-empty': 'off', - '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], }, }, { diff --git a/packages/cli/package.json b/packages/cli/package.json index f1a35ce..dbe910d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,6 +37,7 @@ "@fastify/cors": "11.2.0", "@parallax/common": "workspace:*", "@parallax/orchestrator": "workspace:*", + "@parallax/slack": "workspace:*", "@parallax/ui": "workspace:*", "chalk": "4", "dotenv": "16.4.7", @@ -53,6 +54,7 @@ "bundleDependencies": [ "@parallax/common", "@parallax/orchestrator", + "@parallax/slack", "@parallax/ui" ], "devDependencies": { diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index c1797c4..7838e8d 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -140,10 +140,25 @@ export interface Task { prNumber?: number lastReviewEventAt?: string reviewState?: TaskReviewState + agentName?: string + agentSessionId?: string createdAt: number updatedAt: number } +export interface AgentDefinition { + name: string + provider: AgentProvider + model?: string + systemPrompt?: string +} + +export interface SlackConfig { + botToken: string + appToken: string + channel: string +} + export interface ProjectConfig { id: string workspaceDir: string // Absolute path to existing local repo @@ -162,7 +177,10 @@ export interface ProjectConfig { agent: { provider: AgentProvider model?: string + name?: string + systemPrompt?: string } + agentLabels?: Record } export interface ServerConfig { @@ -176,8 +194,10 @@ export const DEFAULT_CONCURRENCY = 2 export interface AppConfig { projects: ProjectConfig[] + agents: AgentDefinition[] + slack?: SlackConfig concurrency: number - logs: LogLevel[] // Replaces logLevel + logs: LogLevel[] server: ServerConfig } @@ -189,6 +209,7 @@ export interface AgentResult { prSummary?: string commitMessage?: string planMarkdown?: string + sessionId?: string } export interface PlanResult { @@ -199,6 +220,7 @@ export interface PlanResult { planPrompt?: string summary?: string error?: string + sessionId?: string } export interface Logger { diff --git a/packages/orchestrator/package.json b/packages/orchestrator/package.json index 5ee4409..e475242 100644 --- a/packages/orchestrator/package.json +++ b/packages/orchestrator/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@parallax/common": "workspace:*", + "@parallax/slack": "workspace:*", "@fastify/cors": "11.2.0", "chalk": "4", "dotenv": "16.4.7", diff --git a/packages/orchestrator/src/ai-adapters/base-adapter.ts b/packages/orchestrator/src/ai-adapters/base-adapter.ts index 1a21c58..67f0d4b 100644 --- a/packages/orchestrator/src/ai-adapters/base-adapter.ts +++ b/packages/orchestrator/src/ai-adapters/base-adapter.ts @@ -15,6 +15,10 @@ 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> { diff --git a/packages/orchestrator/src/ai-adapters/claude-code-adapter.ts b/packages/orchestrator/src/ai-adapters/claude-code-adapter.ts index 221c016..522c102 100644 --- a/packages/orchestrator/src/ai-adapters/claude-code-adapter.ts +++ b/packages/orchestrator/src/ai-adapters/claude-code-adapter.ts @@ -22,20 +22,29 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { super(executor, logger) } - private buildCommand(task: Task, project: ProjectConfig, prompt: string): string[] { + private buildCommand( + task: Task, + project: ProjectConfig, + prompt: string, + resumeSessionId?: string + ): string[] { const command = ['claude'] if (project.agent.model) { command.push('--model', project.agent.model) } + if (resumeSessionId) { + command.push('--resume', resumeSessionId) + } + command.push('--permission-mode', 'acceptEdits') command.push('--output-format', 'stream-json') command.push('--verbose') command.push('-p', prompt) this.logger.info( - `Claude Code command profile: model=${project.agent.model ?? 'default'}, permission=acceptEdits, sandbox=managed`, + `Claude Code command profile: model=${project.agent.model ?? 'default'}, permission=acceptEdits${resumeSessionId ? `, resume=${resumeSessionId}` : ''}`, task.id ) @@ -126,8 +135,9 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { } } - private buildPlanPrompt(task: Task): string { + private buildPlanPrompt(task: Task, contextPrefix?: string): string { return [ + ...(contextPrefix ? [contextPrefix, ''] : []), 'You are running plan mode for a coding task.', 'Return plain text only (no JSON, no markdown code fences).', `First line must be exactly: STATUS: ${PlanResultStatus.PLAN_READY}|${PlanResultStatus.NEEDS_CLARIFICATION}|${PlanResultStatus.PLAN_FAILED}`, @@ -163,7 +173,8 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { private buildExecutionPrompt( task: Task, approvedPlan?: string, - outputMode: 'pr' | 'commit' = 'pr' + outputMode: 'pr' | 'commit' = 'pr', + contextPrefix?: string ): string { const base = `Task ID: ${task.externalId}\nTitle: ${task.title}\nDescription:\n${task.description}` const planLine = approvedPlan ? `\n\nApproved Plan:\n${approvedPlan}` : '' @@ -177,6 +188,7 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { ].join(' ') return [ + ...(contextPrefix ? [contextPrefix, ''] : []), 'You are executing an implementation plan.', 'Only perform steps described in the approved plan and keep scope bounded.', 'If blocked, return a short explanation and stop without guessing.', @@ -209,15 +221,10 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { async runPlan(task: Task, workingDir: string, project: ProjectConfig): Promise { try { await this.setupWorkspace(task, workingDir) - const command = this.buildCommand(task, project, this.buildPlanPrompt(task)) + const contextPrefix = this.buildContextPrefix(project, task) + const command = this.buildCommand(task, project, this.buildPlanPrompt(task, contextPrefix)) const collector = new ClaudeCodeEventCollector(this.logger, task, 'plan') - let result: any - - try { - result = await this.executeClaudeCommand(workingDir, project, command, collector) - } catch (e: any) { - this.logger.error('DEBUG error running command', e) - } + const result = await this.executeClaudeCommand(workingDir, project, command, collector) if (result.exitCode === 127) { return { @@ -238,8 +245,11 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { } } - const { finalMessage } = collector.getResult() - return this.parsePlanOutput(finalMessage || result.stdout || result.output) + const { finalMessage, sessionId } = collector.getResult() + return { + ...this.parsePlanOutput(finalMessage || result.stdout || result.output), + sessionId, + } } catch (error: any) { return { success: false, @@ -259,10 +269,12 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { ): Promise { try { await this.setupWorkspace(task, workingDir) + const contextPrefix = this.buildContextPrefix(project, task) const command = this.buildCommand( task, project, - this.buildExecutionPrompt(task, approvedPlan, outputMode) + this.buildExecutionPrompt(task, approvedPlan, outputMode, contextPrefix), + task.agentSessionId ) const collector = new ClaudeCodeEventCollector(this.logger, task, 'task') const result = await this.executeClaudeCommand(workingDir, project, command, collector) @@ -276,13 +288,14 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { } } - const { finalMessage } = collector.getResult() + const { finalMessage, sessionId } = collector.getResult() const parsedOutput = finalMessage || result.stdout || result.output return { success: result.exitCode === 0, output: parsedOutput, ...extractExecutionMetadata(parsedOutput), + sessionId, error: result.exitCode !== 0 ? `Claude Code exited with code ${result.exitCode}` : undefined, } diff --git a/packages/orchestrator/src/ai-adapters/claude-code-event-collector.ts b/packages/orchestrator/src/ai-adapters/claude-code-event-collector.ts index c025ed1..e99eb22 100644 --- a/packages/orchestrator/src/ai-adapters/claude-code-event-collector.ts +++ b/packages/orchestrator/src/ai-adapters/claude-code-event-collector.ts @@ -48,6 +48,7 @@ function extractTextContent(value: unknown): string | undefined { export class ClaudeCodeEventCollector { private finalMessage: string | undefined + private sessionId: string | undefined constructor( private logger: Logger, @@ -174,6 +175,7 @@ export class ClaudeCodeEventCollector { if (!sessionId) { return } + this.sessionId = sessionId this.emitBlock({ title: 'Claude session', message: `Session started: ${sessionId}`, @@ -235,6 +237,7 @@ export class ClaudeCodeEventCollector { getResult() { return { finalMessage: this.finalMessage, + sessionId: this.sessionId, } } } diff --git a/packages/orchestrator/src/ai-adapters/codex-adapter.ts b/packages/orchestrator/src/ai-adapters/codex-adapter.ts index 2e025ec..5160070 100644 --- a/packages/orchestrator/src/ai-adapters/codex-adapter.ts +++ b/packages/orchestrator/src/ai-adapters/codex-adapter.ts @@ -294,7 +294,8 @@ export class CodexAdapter extends BaseAgentAdapter { } async runPlan(task: Task, workingDir: string, project: ProjectConfig): Promise { - const command = this.buildCommand(task, project, this.buildPlanPrompt(task)) + 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') @@ -345,10 +346,11 @@ export class CodexAdapter extends BaseAgentAdapter { } } + const contextPrefix = this.buildContextPrefix(project, task) const command = this.buildCommand( task, project, - this.buildExecutionPrompt(task, approvedPlan, outputMode) + this.buildExecutionPrompt(task, approvedPlan, outputMode, contextPrefix) ) const env = await this.resolveProjectEnv(project) const collector = new CodexEventCollector(this.logger, task, 'task') @@ -381,8 +383,9 @@ export class CodexAdapter extends BaseAgentAdapter { } } - private buildPlanPrompt(task: Task): string { + private buildPlanPrompt(task: Task, contextPrefix?: string): string { return [ + ...(contextPrefix ? [contextPrefix, ''] : []), 'You are running plan mode for a coding task.', 'Return plain text only (no JSON, no markdown code fences).', `First line must be exactly: STATUS: ${PlanResultStatus.PLAN_READY}|${PlanResultStatus.NEEDS_CLARIFICATION}|${PlanResultStatus.PLAN_FAILED}`, @@ -419,7 +422,8 @@ export class CodexAdapter extends BaseAgentAdapter { private buildExecutionPrompt( task: Task, approvedPlan?: string, - outputMode: 'pr' | 'commit' = 'pr' + outputMode: 'pr' | 'commit' = 'pr', + contextPrefix?: string ): string { const base = `Task ID: ${task.externalId}\nTitle: ${task.title}\nDescription:\n${task.description}` const planLine = approvedPlan ? `\n\nApproved Plan:\n${approvedPlan}` : '' @@ -433,6 +437,7 @@ export class CodexAdapter extends BaseAgentAdapter { ].join(' ') return [ + ...(contextPrefix ? [contextPrefix, ''] : []), 'You are executing an implementation plan.', 'Only perform steps described in the approved plan and keep scope bounded.', 'If blocked, return a short explanation and stop without guessing.', diff --git a/packages/orchestrator/src/ai-adapters/gemini-adapter.ts b/packages/orchestrator/src/ai-adapters/gemini-adapter.ts index 93a7d90..8da00b1 100644 --- a/packages/orchestrator/src/ai-adapters/gemini-adapter.ts +++ b/packages/orchestrator/src/ai-adapters/gemini-adapter.ts @@ -154,7 +154,7 @@ export class GeminiAdapter extends BaseAgentAdapter { return JSON.stringify(payload, null, 2) } - private buildPlanPrompt(task: Task): string { + private buildPlanPrompt(task: Task, contextPrefix?: string): string { const instructions = [ 'You are generating an implementation plan, not executing code.', 'No tool usage is allowed in this pass.', @@ -179,7 +179,8 @@ export class GeminiAdapter extends BaseAgentAdapter { `- Return ${PlanResultStatus.PLAN_FAILED} only when execution is impossible without unsafe assumptions.`, ] - return `\nTask ID: ${task.externalId}\nTitle: ${task.title}\n\nDescription:\n${task.description}\n\nInstructions:\n${this.formatInstructions(instructions)}`.trim() + const prefix = contextPrefix ? `${contextPrefix}\n\n` : '' + return `${prefix}\nTask ID: ${task.externalId}\nTitle: ${task.title}\n\nDescription:\n${task.description}\n\nInstructions:\n${this.formatInstructions(instructions)}`.trim() } private buildPlanPromptTemplate() { @@ -192,7 +193,8 @@ export class GeminiAdapter extends BaseAgentAdapter { private constructExecutionPrompt( task: Task, approvedPlan?: string, - outputMode: 'pr' | 'commit' = 'pr' + outputMode: 'pr' | 'commit' = 'pr', + contextPrefix?: string ): string { const instructions = [ 'Implement only the plan that was approved. Do not expand scope.', @@ -220,14 +222,16 @@ export class GeminiAdapter extends BaseAgentAdapter { ] const planHint = approvedPlan ? `\n\nApproved plan:\n${approvedPlan}` : '' + const prefix = contextPrefix ? `${contextPrefix}\n\n` : '' - return `\nTask ID: ${task.externalId}\nTitle: ${task.title}\n\nDescription:\n${task.description}${planHint}\n\nInstructions:\n${this.formatInstructions(instructions)}`.trim() + return `${prefix}\nTask ID: ${task.externalId}\nTitle: ${task.title}\n\nDescription:\n${task.description}${planHint}\n\nInstructions:\n${this.formatInstructions(instructions)}`.trim() } async runPlan(task: Task, workingDir: string, project: ProjectConfig): Promise { try { await this.setupWorkspace(task, workingDir) - const prompt = this.buildPlanPrompt(task) + 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, { @@ -266,11 +270,12 @@ export class GeminiAdapter extends BaseAgentAdapter { ): Promise { try { await this.setupWorkspace(task, workingDir) + const contextPrefix = this.buildContextPrefix(project, task) return this.executeAgent( task, workingDir, project, - this.constructExecutionPrompt(task, approvedPlan, outputMode), + this.constructExecutionPrompt(task, approvedPlan, outputMode, contextPrefix), outputMode === 'pr' || outputMode === 'commit' ) } catch (error: any) { diff --git a/packages/orchestrator/src/config-loader.ts b/packages/orchestrator/src/config-loader.ts index f83df98..266511f 100644 --- a/packages/orchestrator/src/config-loader.ts +++ b/packages/orchestrator/src/config-loader.ts @@ -5,14 +5,14 @@ import os from 'os' import dotenv from 'dotenv' import { AGENT_PROVIDER, + AgentDefinition, AppConfig, DEFAULT_API_PORT, DEFAULT_UI_PORT, - LOG_LEVEL, - LogLevel, PULL_PROVIDER, ProjectConfig, ServerConfig, + SlackConfig, } from '@parallax/common' type RegisteredConfig = { configPath: string @@ -78,7 +78,6 @@ export async function loadConfig(): Promise { return mergeConfigs(configs) } -const ALLOWED_LOG_LEVELS: LogLevel[] = Object.values(LOG_LEVEL) const ALLOWED_AGENT_PROVIDERS = [ AGENT_PROVIDER.CODEX, AGENT_PROVIDER.GEMINI, @@ -115,25 +114,6 @@ function assertNoUnknownKeys(value: Record, allowedKeys: string } } -function parseLogs(raw: unknown, source: string): LogLevel[] { - if (raw === undefined) { - return ['info', 'success', 'warn', 'error'] - } - - if (!Array.isArray(raw)) { - throw new Error(`logs in ${source} must be an array.`) - } - - const parsed = raw.map((entry) => assertNonEmptyString(entry, `logs entry in ${source}`)) - for (const level of parsed) { - if (!ALLOWED_LOG_LEVELS.includes(level as LogLevel)) { - throw new Error(`Unsupported log level "${level}" in ${source}.`) - } - } - - return [...new Set(parsed)] as LogLevel[] -} - function parseRuntimeConcurrency(): number { const raw = process.env.PARALLAX_CONCURRENCY if (raw === undefined) { @@ -186,6 +166,7 @@ function buildEmptyConfig(): AppConfig { logs: ['info', 'success', 'warn', 'error'], server: parseRuntimeServerConfig(), projects: [], + agents: [], } } @@ -231,7 +212,92 @@ function parseRegistry(raw: string, source: string): RegistryState { } } -function parseProject(raw: unknown, source: string): ProjectConfig { +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}`) @@ -258,21 +324,58 @@ function parseProject(raw: unknown, source: string): ProjectConfig { assertNonEmptyString(filters.repo, `project.pullFrom.filters.repo for "${id}" in ${source}`) } - const agent = raw.agent - assertObject(agent, `project.agent for "${id}" in ${source}`) - assertNoUnknownKeys(agent, ['provider', 'model'], `project.agent for "${id}" in ${source}`) - const agentProviderRaw = assertNonEmptyString( - agent.provider, - `project.agent.provider 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}` ) - 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 providers: ${ALLOWED_AGENT_PROVIDERS.join(', ')}.` + + 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 agentProvider = agentProviderRaw as ProjectConfig['agent']['provider'] + + const agentLabels = parseAgentLabels(raw.agentLabels, id, source, knownAgentNames) return { id, @@ -283,8 +386,11 @@ function parseProject(raw: unknown, source: string): ProjectConfig { }, agent: { provider: agentProvider, - model: assertOptionalString(agent.model, `project.agent.model for "${id}" in ${source}`), + model: agentModel, + name: agentName, + systemPrompt: agentSystemPrompt, }, + agentLabels: Object.keys(agentLabels).length > 0 ? agentLabels : undefined, } } @@ -304,23 +410,44 @@ async function validateConfig( envFilePath?: string ): Promise { if (!Array.isArray(raw) || raw.length === 0) { - throw new Error(`config ${source} must define a non-empty project array.`) + throw new Error(`config ${source} must define a non-empty array.`) } - const projects = raw.map((project) => { - const parsed = parseProject(project, source) - return { - ...parsed, - envFilePath, + // 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.`) } - }) + + 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:".` + ) + } + } + + const projects: ProjectConfig[] = [] const uniqueIds = new Set() - for (const project of projects) { + 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) } return { @@ -328,14 +455,32 @@ async function validateConfig( 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.`) diff --git a/packages/orchestrator/src/database.ts b/packages/orchestrator/src/database.ts index 3891c83..3a3a610 100644 --- a/packages/orchestrator/src/database.ts +++ b/packages/orchestrator/src/database.ts @@ -83,6 +83,8 @@ ensureColumn('approvedBy', 'TEXT') ensureColumn('approvedAt', 'INTEGER') ensureColumn('executionAttempts', 'INTEGER DEFAULT 0') ensureColumn('lastAgent', 'TEXT') +ensureColumn('agentName', 'TEXT') +ensureColumn('agentSessionId', 'TEXT') const existingLogColumns = new Set( (db.prepare('PRAGMA table_info(task_logs)').all() as Array<{ name: string }>).map( @@ -107,8 +109,8 @@ ensureLogColumn('title', 'TEXT') export const dbService = { saveTask(task: Task) { const upsert = db.prepare(` - INSERT INTO tasks (id, externalId, title, description, status, projectId, branchName, prUrl, prNumber, lastReviewEventAt, reviewState, planState, planMarkdown, planPrompt, planResult, approvedBy, approvedAt, executionAttempts, lastAgent, createdAt, updatedAt) - VALUES (@id, @externalId, @title, @description, @status, @projectId, @branchName, @prUrl, @prNumber, @lastReviewEventAt, @reviewState, @planState, @planMarkdown, @planPrompt, @planResult, @approvedBy, @approvedAt, @executionAttempts, @lastAgent, @createdAt, @updatedAt) + INSERT INTO tasks (id, externalId, title, description, status, projectId, branchName, prUrl, prNumber, lastReviewEventAt, reviewState, planState, planMarkdown, planPrompt, planResult, approvedBy, approvedAt, executionAttempts, lastAgent, agentName, agentSessionId, createdAt, updatedAt) + VALUES (@id, @externalId, @title, @description, @status, @projectId, @branchName, @prUrl, @prNumber, @lastReviewEventAt, @reviewState, @planState, @planMarkdown, @planPrompt, @planResult, @approvedBy, @approvedAt, @executionAttempts, @lastAgent, @agentName, @agentSessionId, @createdAt, @updatedAt) ON CONFLICT(externalId) DO UPDATE SET title=excluded.title, description=excluded.description, @@ -121,6 +123,8 @@ export const dbService = { approvedAt=COALESCE(excluded.approvedAt, approvedAt), executionAttempts=COALESCE(excluded.executionAttempts, executionAttempts), lastAgent=COALESCE(excluded.lastAgent, lastAgent), + agentName=COALESCE(excluded.agentName, agentName), + agentSessionId=COALESCE(excluded.agentSessionId, agentSessionId), updatedAt=excluded.updatedAt `) @@ -138,6 +142,8 @@ export const dbService = { approvedAt: null, executionAttempts: 0, lastAgent: task.lastAgent || null, + agentName: null, + agentSessionId: null, ...task, }) }, @@ -353,12 +359,21 @@ export const dbService = { approvedBy = NULL, approvedAt = NULL, executionAttempts = 0, + agentSessionId = NULL, updatedAt = ? WHERE id = ? ` ).run(TaskPlanState.PLAN_GENERATING, Date.now(), id) }, + updateAgentSessionId(id: string, sessionId: string) { + db.prepare('UPDATE tasks SET agentSessionId = ?, updatedAt = ? WHERE id = ?').run( + sessionId, + Date.now(), + id + ) + }, + listTaskLogs(options?: { since?: number; taskExternalId?: string; limit?: number }) { const since = options?.since ?? 0 const limit = Math.max(1, Math.min(500, options?.limit ?? 200)) diff --git a/packages/orchestrator/src/git-service.ts b/packages/orchestrator/src/git-service.ts index 96a4b0b..94ba2ee 100644 --- a/packages/orchestrator/src/git-service.ts +++ b/packages/orchestrator/src/git-service.ts @@ -10,6 +10,10 @@ import { sanitizeCommitMessage, } from './ai-adapters/execution-metadata.js' +function sanitizeBranchName(name: string): string { + return name.replace(/[#\s]/g, '-').replace(/-{2,}/g, '-').replace(/^-|-$/g, '') +} + export class GitService { constructor(private executor: HostExecutor) {} @@ -281,20 +285,33 @@ export class GitService { options?: { commitMessage?: string } ): Promise { const git: SimpleGit = simpleGit(worktreePath) - const remoteBranchName = task.branchName || `task/${task.externalId.toLowerCase()}` + const remoteBranchName = + task.branchName || sanitizeBranchName(`task/${task.externalId.toLowerCase()}`) const status = await git.status() - if (status.isClean()) { + if (!status.isClean()) { + await git.add('.') + await git.commit( + sanitizeCommitMessage(options?.commitMessage) ?? + buildDefaultCommitMessage(task.externalId, task.title) + ) + } + + // Push if there are local commits ahead of origin/main (covers agent self-commits) + let hasCommitsToPush = false + try { + const log = await git.log(['origin/main..HEAD']) + hasCommitsToPush = log.total > 0 + } catch { + // Can't compare (e.g. fresh repo) — attempt push anyway + hasCommitsToPush = true + } + + if (!hasCommitsToPush) { return null } - await git.add('.') - await git.commit( - sanitizeCommitMessage(options?.commitMessage) ?? - buildDefaultCommitMessage(task.externalId, task.title) - ) await git.raw(['push', 'origin', `HEAD:${remoteBranchName}`, '--set-upstream']) - return remoteBranchName } @@ -311,7 +328,7 @@ export class GitService { async createPullRequest( worktreePath: string, task: Task, - options?: { prTitle?: string; prSummary?: string } + options?: { prTitle?: string; prSummary?: string; head?: string } ): Promise { await this.ensureManagedLabelExists(worktreePath) @@ -329,27 +346,29 @@ export class GitService { task.description || 'No task description provided.', ].join('\n') + const ghArgs = [ + 'gh', + 'pr', + 'create', + '--title', + title, + '--body', + body, + '--base', + 'main', + '--label', + PARALLAX_MANAGED_LABEL, + ] + if (options?.head) { + ghArgs.push('--head', options.head) + } + // NOTE: Removed literal quotes from arguments because we use shell: false - const result = await this.executor.executeCommand( - [ - 'gh', - 'pr', - 'create', - '--title', - title, - '--body', - body, - '--base', - 'main', - '--label', - PARALLAX_MANAGED_LABEL, - ], - { - cwd: worktreePath, - // Force gh to use its own authenticated host session for PR creation. - env: { GITHUB_TOKEN: undefined, GH_TOKEN: undefined }, - } - ) + const result = await this.executor.executeCommand(ghArgs, { + cwd: worktreePath, + // Force gh to use its own authenticated host session for PR creation. + env: { GITHUB_TOKEN: undefined, GH_TOKEN: undefined }, + }) if (result.exitCode !== 0) { if (result.output.includes('Resource not accessible by personal access token')) { diff --git a/packages/orchestrator/src/github/service.ts b/packages/orchestrator/src/github/service.ts index 91511c3..2de64b3 100644 --- a/packages/orchestrator/src/github/service.ts +++ b/packages/orchestrator/src/github/service.ts @@ -3,10 +3,13 @@ import { HostExecutor } from '@parallax/common/executor' import { createTaskId } from '../task-id.js' import { parseGitHubIssueNumber, requireGitHubRepoDetails } from './repository.js' +export type TaskWithLabels = Task & { labels: string[] } + interface GitHubIssueSummary { number: number title: string body?: string | null + labels?: Array<{ name: string }> } export class GitHubService { @@ -23,7 +26,7 @@ export class GitHubService { '--repo', `${owner}/${repo}`, '--json', - 'number,title,body', + 'number,title,body,labels', '--limit', '100', ] @@ -41,7 +44,7 @@ export class GitHubService { return command } - async fetchNewIssues(project: ProjectConfig): Promise { + async fetchNewIssues(project: ProjectConfig): Promise { if (project.pullFrom.provider !== PULL_PROVIDER.GITHUB) { return [] } @@ -68,6 +71,7 @@ export class GitHubService { description: issue.body ?? '', status: TASK_STATUS.PENDING, projectId: project.id, + labels: issue.labels?.map((l) => l.name) ?? [], createdAt: Date.now(), updatedAt: Date.now(), })) diff --git a/packages/orchestrator/src/index.ts b/packages/orchestrator/src/index.ts index 724dd32..1276616 100644 --- a/packages/orchestrator/src/index.ts +++ b/packages/orchestrator/src/index.ts @@ -16,7 +16,12 @@ 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 } from './runtime/provider-services.js' +import { + buildExternalServices, + fetchProjectTasks, + resolveAgentNameForTask, + resolveAgentForTask, +} 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' @@ -34,6 +39,7 @@ import { requiresPlan, } from './workflow/task-state.js' import { taskLifecycle } from './task-lifecycle.js' +import { setSlackBot } from './slack-integration.js' const activeWorktrees = new Map() @@ -79,13 +85,12 @@ async function pollProjects( gitService: GitService, activeTasks: Set, canceledTasks: Set, - getAdapterForProject: (project: ProjectConfig) => BaseAgentAdapter, + getAdapterForTask: (task: Task, resolvedProject: ProjectConfig) => BaseAgentAdapter, services: ReturnType, limit: ReturnType ) { for (const project of config.projects) { try { - const adapter = getAdapterForProject(project) const issues = await fetchProjectTasks(project, services) const newIssues = issues.filter((task) => !dbService.getTaskByExternalId(task.externalId)) @@ -94,8 +99,10 @@ async function pollProjects( await gitService.syncMainBranch(project.workspaceDir) } - for (const task of newIssues) { - dbService.saveTask(task) + for (const taskWithLabels of newIssues) { + const agentName = resolveAgentNameForTask(taskWithLabels.labels, project, config) + const { labels: _labels, ...task } = taskWithLabels + dbService.saveTask({ ...task, agentName }) const savedTask = dbService.getTaskByExternalId(task.externalId)! dbService.updateTaskPlanState(savedTask.id, TaskPlanState.PLAN_GENERATING) taskLifecycle.queue(savedTask.id, 'Queued for execution plan') @@ -132,6 +139,9 @@ async function pollProjects( continue } + const resolvedProject = resolveAgentForTask(task, project, config) + const adapter = getAdapterForTask(task, resolvedProject) + if (requiresPlan(task)) { activeTasks.add(task.id) limit(async () => { @@ -139,7 +149,15 @@ async function pollProjects( if (canceledTasks.has(task.id)) { return } - await processTaskPlan(task, project, adapter, gitService, canceledTasks, services) + await processTaskPlan( + task, + resolvedProject, + adapter, + gitService, + canceledTasks, + services, + config + ) } finally { canceledTasks.delete(task.id) activeTasks.delete(task.id) @@ -160,12 +178,13 @@ async function pollProjects( } await processTask( task, - project, + resolvedProject, adapter, gitService, canceledTasks, services, - activeWorktrees + activeWorktrees, + config ) } finally { canceledTasks.delete(task.id) @@ -236,14 +255,15 @@ async function main() { const canceledTasks = new Set() const adapterCache = new Map() - const getAdapterForProject = (project: ProjectConfig) => { - const key = `${project.id}:${project.agent.provider}` + const getAdapterForTask = (task: Task, resolvedProject: ProjectConfig) => { + const agentKey = resolvedProject.agent.name ?? resolvedProject.agent.provider + const key = `${resolvedProject.id}:${agentKey}` const existing = adapterCache.get(key) if (existing) { return existing } - const adapter = createAgentAdapter(project, executor, logger) + const adapter = createAgentAdapter(resolvedProject, executor, logger) adapterCache.set(key, adapter) return adapter } @@ -292,7 +312,8 @@ async function main() { }) dbService.updateTaskReviewState(reviewTask.id, TASK_REVIEW_STATE.REVIEW_PENDING) - const adapter = getAdapterForProject(project) + const resolvedReviewProject = resolveAgentForTask(reviewTask, project, getConfig()) + const adapter = getAdapterForTask(reviewTask, resolvedReviewProject) activeTasks.add(reviewTask.id) taskLifecycle.queue(reviewTask.id, `Queued PR review run for #${reviewTask.prNumber}`) void limit(async () => { @@ -331,6 +352,21 @@ async function main() { canceledTasks ) + if (runtimeConfig.slack) { + try { + const { SlackBot } = await import('@parallax/slack') + const bot = new SlackBot({ + config: runtimeConfig.slack, + apiBaseUrl: `http://127.0.0.1:${runtimeConfig.server.apiPort}`, + }) + await bot.start() + setSlackBot(bot) + logger.info(`Slack bot connected (channel: ${runtimeConfig.slack.channel})`) + } catch (err: any) { + logger.error(`Slack bot failed to start: ${err?.message ?? err}`) + } + } + while (true) { try { const config = getConfig() @@ -339,7 +375,7 @@ async function main() { gitService, activeTasks, canceledTasks, - getAdapterForProject, + getAdapterForTask, getServices(), limit ) diff --git a/packages/orchestrator/src/linear/service.ts b/packages/orchestrator/src/linear/service.ts index 099f198..036d18d 100644 --- a/packages/orchestrator/src/linear/service.ts +++ b/packages/orchestrator/src/linear/service.ts @@ -1,5 +1,6 @@ -import { PULL_PROVIDER, TASK_STATUS, Task, ProjectConfig } from '@parallax/common' +import { PULL_PROVIDER, TASK_STATUS, ProjectConfig } from '@parallax/common' import { createTaskId } from '../task-id.js' +import type { TaskWithLabels } from '../github/service.js' type GraphqlResponse = { data?: T @@ -11,6 +12,7 @@ type IssueNode = { identifier: string title: string description?: string | null + labels?: { nodes: Array<{ name: string }> } } type WorkflowStateNode = { @@ -53,7 +55,7 @@ export class LinearService { return body.data } - async fetchNewIssues(project: ProjectConfig): Promise { + async fetchNewIssues(project: ProjectConfig): Promise { if (project.pullFrom.provider !== PULL_PROVIDER.LINEAR) { return [] } @@ -83,6 +85,11 @@ export class LinearService { identifier title description + labels { + nodes { + name + } + } } } } @@ -97,6 +104,7 @@ export class LinearService { description: issue.description ?? '', status: TASK_STATUS.PENDING, projectId: project.id, + labels: issue.labels?.nodes.map((l) => l.name) ?? [], createdAt: Date.now(), updatedAt: Date.now(), })) diff --git a/packages/orchestrator/src/runtime/api-server.ts b/packages/orchestrator/src/runtime/api-server.ts index 9849ad7..065de66 100644 --- a/packages/orchestrator/src/runtime/api-server.ts +++ b/packages/orchestrator/src/runtime/api-server.ts @@ -33,6 +33,16 @@ type ApiServerDependencies = { activeWorktrees: Map } +function sanitizeConfigForApi(config: AppConfig): AppConfig { + if (!config.slack) { + return config + } + return { + ...config, + slack: { ...config.slack, botToken: '***', appToken: '***' }, + } +} + function resolveTaskProject(config: AppConfig, projectId: string) { const project = config.projects.find((candidate) => candidate.id === projectId) if (!project) { @@ -80,7 +90,7 @@ export async function createApiServer( .map((task) => serializeTaskForApi(task)) ) - fastify.get('/config', async () => getConfig()) + fastify.get('/config', async () => sanitizeConfigForApi(getConfig())) fastify.get('/runtime/errors', async (_request, reply) => { try { diff --git a/packages/orchestrator/src/runtime/provider-services.ts b/packages/orchestrator/src/runtime/provider-services.ts index a953563..e2e3dcf 100644 --- a/packages/orchestrator/src/runtime/provider-services.ts +++ b/packages/orchestrator/src/runtime/provider-services.ts @@ -1,8 +1,10 @@ -import { PULL_PROVIDER, ProjectConfig, Task } from '@parallax/common' +import { PULL_PROVIDER, ProjectConfig, Task, AppConfig } from '@parallax/common' import { HostExecutor } from '@parallax/common/executor' -import { GitHubService } from '../github/service.js' +import { GitHubService, TaskWithLabels } from '../github/service.js' import { LinearService } from '../linear/service.js' +export type { TaskWithLabels } + export type ExternalServices = { linearService?: LinearService githubService?: GitHubService @@ -28,7 +30,7 @@ function getPullProvider(project: ProjectConfig) { export async function fetchProjectTasks( project: ProjectConfig, services: ExternalServices -): Promise { +): Promise { const provider = getPullProvider(project) if (provider === PULL_PROVIDER.LINEAR) { @@ -45,6 +47,48 @@ 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 new file mode 100644 index 0000000..3c25988 --- /dev/null +++ b/packages/orchestrator/src/slack-integration.ts @@ -0,0 +1,30 @@ +import type { Task, AgentDefinition } from '@parallax/common' + +export type SlackNotificationEvent = + | 'plan_ready' + | 'pr_created' + | 'failed' + | 'canceled' + | 'execution_started' + +export interface SlackNotificationPayload { + task: Task + event: SlackNotificationEvent + agentDef?: AgentDefinition + extra?: string +} + +export interface SlackBotInterface { + notify(payload: SlackNotificationPayload): Promise + stop(): Promise +} + +let slackBot: SlackBotInterface | undefined + +export function setSlackBot(bot: SlackBotInterface) { + slackBot = bot +} + +export function getSlackBot(): SlackBotInterface | undefined { + return slackBot +} diff --git a/packages/orchestrator/src/workflow/task-runner.ts b/packages/orchestrator/src/workflow/task-runner.ts index 2077842..2dd5a95 100644 --- a/packages/orchestrator/src/workflow/task-runner.ts +++ b/packages/orchestrator/src/workflow/task-runner.ts @@ -5,6 +5,7 @@ import { TASK_LOG_LEVEL, TASK_LOG_SOURCE, AGENT_PROVIDER, + AppConfig, PlanResult, PlanResultStatus, ProjectConfig, @@ -30,6 +31,7 @@ import { logger } from '../logger.js' import { dbService } from '../database.js' import { taskLifecycle } from '../task-lifecycle.js' import { ExternalServices, markTaskInProgress } from '../runtime/provider-services.js' +import { getSlackBot } from '../slack-integration.js' import { assertPlanPrompt, getNextPlanState, @@ -79,7 +81,8 @@ export async function processTaskPlan( adapter: BaseAgentAdapter, gitService: GitService, canceledTasks: Set, - services: ExternalServices + services: ExternalServices, + config?: AppConfig ) { logger.info(`Starting plan generation: ${task.title}`, task.id) dbService.updateTaskPlanState(task.id, TaskPlanState.PLAN_GENERATING) @@ -97,7 +100,11 @@ export async function processTaskPlan( const planResult = await adapter.runPlan(task, worktreePath, project) throwIfCancellationRequested(task.id, canceledTasks) - await persistPlanResult(task, project, planResult) + if (planResult.sessionId) { + dbService.updateAgentSessionId(task.id, planResult.sessionId) + } + + await persistPlanResult(task, project, planResult, config) } catch (error: any) { if (error instanceof TaskCanceledError) { taskLifecycle.cancel(task.id, 'Plan generation canceled') @@ -124,18 +131,30 @@ export async function processTaskPlan( } } -async function persistPlanResult(task: Task, project: ProjectConfig, planResult: PlanResult) { +async function persistPlanResult( + task: Task, + project: ProjectConfig, + planResult: PlanResult, + config?: AppConfig +) { 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.provider, + lastAgent: project.agent.name ?? 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 }) + .catch((err: any) => logger.error(`Slack notify failed: ${err?.message ?? err}`, task.id)) + } return } if (nextState === TaskPlanState.PLAN_REQUIRES_CLARIFICATION) { @@ -143,7 +162,15 @@ async function persistPlanResult(task: Task, project: ProjectConfig, planResult: return } - taskLifecycle.fail(task.id, planResult.error ?? 'Plan generation failed') + const failMessage = planResult.error ?? 'Plan generation failed' + 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 }) + .catch((err: any) => logger.error(`Slack notify failed: ${err?.message ?? err}`, task.id)) + } } export async function processTask( @@ -153,7 +180,8 @@ export async function processTask( gitService: GitService, canceledTasks: Set, services: ExternalServices, - activeWorktrees: Map + activeWorktrees: Map, + config?: AppConfig ) { logger.info(`Starting process: ${task.title}`, task.id) @@ -188,18 +216,28 @@ export async function processTask( const result = await adapter.runTask(task, worktreePath, project, task.planMarkdown) throwIfCancellationRequested(task.id, canceledTasks) + if (result.sessionId) { + 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) if (!branchName) { 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.' }) + .catch((err: any) => logger.error(`Slack notify failed: ${err?.message ?? err}`, task.id)) return } const prUrl = await gitService.createPullRequest(worktreePath, task, { prTitle: result.prTitle, prSummary: result.prSummary, + head: branchName, }) const prNumberMatch = prUrl.match(/\/pull\/(\d+)/) if (prNumberMatch) { @@ -214,8 +252,14 @@ export async function processTask( dbService.updateTaskPlanOutput(task.id, { planState: TaskPlanState.PLAN_APPROVED, planPrompt: getTaskPlanPrompt(task), - lastAgent: project.agent.provider, + lastAgent: project.agent.name ?? project.agent.provider, }) + const completedTask = dbService.getTaskById(task.id) + if (completedTask) { + getSlackBot() + ?.notify({ task: completedTask, event: 'pr_created', agentDef, extra: prUrl }) + .catch((err: any) => logger.error(`Slack notify failed: ${err?.message ?? err}`, task.id)) + } return } @@ -224,7 +268,7 @@ export async function processTask( planState: TaskPlanState.PLAN_REQUIRES_CLARIFICATION, planResult: result.error, planPrompt: getTaskPlanPrompt(task), - lastAgent: project.agent.provider, + lastAgent: project.agent.name ?? project.agent.provider, }) taskLifecycle.queue( task.id, @@ -236,6 +280,9 @@ 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 }) + .catch((err: any) => logger.error(`Slack notify failed: ${err?.message ?? err}`, task.id)) } catch (error: any) { if (error instanceof TaskCanceledError) { taskLifecycle.cancel(task.id, 'Task canceled before completion.') @@ -243,8 +290,16 @@ export async function processTask( return } - logger.error(`Critical error: ${error.message}`, task.id) - taskLifecycle.fail(task.id, `Critical error: ${error.message}`) + const criticalMsg = `Critical error: ${error.message}` + logger.error(criticalMsg, task.id) + 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 }) + .catch((err: any) => logger.error(`Slack notify failed: ${err?.message ?? err}`, task.id)) + } } finally { activeWorktrees.delete(task.id) if (worktreePath) { diff --git a/packages/orchestrator/test/ai-adapters/claude-code.test.ts b/packages/orchestrator/test/ai-adapters/claude-code.test.ts index e7a7f41..c3383ca 100644 --- a/packages/orchestrator/test/ai-adapters/claude-code.test.ts +++ b/packages/orchestrator/test/ai-adapters/claude-code.test.ts @@ -132,6 +132,74 @@ describe('ClaudeCodeAdapter', () => { expect(result.planMarkdown).toContain('Inspect auth middleware') }) + it('returns sessionId captured from system:init event', async () => { + const mockExecutor = { + executeCommand: vi.fn().mockImplementation(async (_command: string[], options: any) => { + options.onData({ + stream: 'stdout', + line: JSON.stringify({ type: 'system', subtype: 'init', session_id: 'ses-abc123' }), + }) + options.onData({ + stream: 'stdout', + line: JSON.stringify({ type: 'result', result: 'Done\nPARALLAX_COMMIT_MESSAGE: Fix it' }), + }) + return { exitCode: 0, output: '', stdout: '', stderr: '' } + }), + } + const adapter = new ClaudeCodeAdapter(mockExecutor as any, mockLogger as any) + const task = { id: 't1', externalId: 'REV-500', title: 'Fix', description: 'Desc' } as any + const project = { agent: {} } as any + + vi.spyOn(adapter, 'setupWorkspace').mockResolvedValue() + + const result = await adapter.runTask(task, '/tmp/dir', project, undefined, 'commit') + + expect(result.sessionId).toBe('ses-abc123') + }) + + it('adds --resume flag when task.agentSessionId is set', async () => { + const mockExecutor = { + executeCommand: vi + .fn() + .mockResolvedValue({ exitCode: 0, output: '', stdout: '', stderr: '' }), + } + const adapter = new ClaudeCodeAdapter(mockExecutor as any, mockLogger as any) + const task = { + id: 't2', + externalId: 'REV-501', + title: 'Resume', + description: 'Desc', + agentSessionId: 'ses-prev', + } as any + const project = { agent: {} } as any + + vi.spyOn(adapter, 'setupWorkspace').mockResolvedValue() + + await adapter.runTask(task, '/tmp/dir', project) + + const command = mockExecutor.executeCommand.mock.calls[0][0] + expect(command).toContain('--resume') + expect(command).toContain('ses-prev') + }) + + it('does not add --resume flag when task has no agentSessionId', async () => { + const mockExecutor = { + executeCommand: vi + .fn() + .mockResolvedValue({ exitCode: 0, output: '', stdout: '', stderr: '' }), + } + const adapter = new ClaudeCodeAdapter(mockExecutor as any, mockLogger as any) + const task = { id: 't3', externalId: 'REV-502', title: 'Fresh', description: 'Desc' } as any + const project = { agent: {} } as any + + vi.spyOn(adapter, 'setupWorkspace').mockResolvedValue() + + await adapter.runTask(task, '/tmp/dir', project) + + const command = mockExecutor.executeCommand.mock.calls[0][0] + expect(command).not.toContain('--resume') + }) + it('emits streamed assistant updates before the final result', async () => { const localLogger = { info: vi.fn(), diff --git a/packages/orchestrator/test/config-loader.test.ts b/packages/orchestrator/test/config-loader.test.ts index 59a682b..467d381 100644 --- a/packages/orchestrator/test/config-loader.test.ts +++ b/packages/orchestrator/test/config-loader.test.ts @@ -190,6 +190,254 @@ describe('config-loader', () => { await expect(loadConfig()).rejects.toThrow('Unsupported agent provider "unknown-agent"') }) + it('loads named agents defined in agents: item', 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.') + }) + + 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) + ) + process.env.PARALLAX_DATA_DIR = dataDir + process.chdir(root) + + const config = await loadConfig() + + expect(config.projects[0].agentLabels).toEqual({ + 'ai-frontend': 'reviewer', + 'ai-security': 'reviewer', + }) + }) + + it('rejects an agentLabels value that references an unknown agent', async () => { + 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') + ) + 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('unknown agent "does-not-exist"') + }) + + it('loads slack config from slack: item', 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 config = await loadConfig() + + expect(config.slack).toEqual({ + botToken: 'xoxb-test-token', + appToken: 'xapp-test-token', + channel: '#ai-tasks', + }) + }) + + 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') + ) + 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-') + }) + + it('rejects duplicate agent names across registered configs', 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')}`) + await fs.writeFile( + registryPath, + JSON.stringify( + { + configs: [ + { configPath: configPath1, addedAt: Date.now() }, + { configPath: configPath2, addedAt: Date.now() }, + ], + }, + null, + 2 + ) + ) + process.env.PARALLAX_DATA_DIR = dataDir + process.chdir(root) + + await expect(loadConfig()).rejects.toThrow('Duplicate agent name "developer"') + }) + it('rejects unknown agent fields', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'parallax-config-')) const dataDir = path.join(root, '.parallax') diff --git a/packages/orchestrator/test/github-service.test.ts b/packages/orchestrator/test/github-service.test.ts index 1a914d8..8345536 100644 --- a/packages/orchestrator/test/github-service.test.ts +++ b/packages/orchestrator/test/github-service.test.ts @@ -16,6 +16,7 @@ describe('GitHubService', () => { number: 27, title: 'Fix dashboard refresh', body: 'Description', + labels: [{ name: 'ai-ready' }], }, ]), }) @@ -48,7 +49,7 @@ describe('GitHubService', () => { '--repo', 'org/repo', '--json', - 'number,title,body', + 'number,title,body,labels', '--limit', '100', '--state', @@ -60,6 +61,49 @@ describe('GitHubService', () => { ) }) + it('maps issue labels to the task labels array', async () => { + executeCommandMock.mockResolvedValue({ + exitCode: 0, + output: JSON.stringify([ + { + number: 42, + title: 'Add rate limiting', + body: 'Desc', + labels: [{ name: 'ai-ready' }, { name: 'ai-frontend' }], + }, + ]), + }) + + const service = new GitHubService({ executeCommand: executeCommandMock } as any) + const project = { + id: 'p1', + workspaceDir: '/tmp/repo', + pullFrom: { provider: 'github', filters: { owner: 'org', repo: 'repo' } }, + } as any + + const tasks = await service.fetchNewIssues(project) + + expect(tasks[0].labels).toEqual(['ai-ready', 'ai-frontend']) + }) + + it('maps tasks with no labels to an empty array', async () => { + executeCommandMock.mockResolvedValue({ + exitCode: 0, + output: JSON.stringify([{ number: 5, title: 'No labels', body: '' }]), + }) + + const service = new GitHubService({ executeCommand: executeCommandMock } as any) + const project = { + id: 'p1', + workspaceDir: '/tmp/repo', + pullFrom: { provider: 'github', filters: { owner: 'org', repo: 'repo' } }, + } as any + + const tasks = await service.fetchNewIssues(project) + + expect(tasks[0].labels).toEqual([]) + }) + it('should comment when a GitHub task starts', async () => { executeCommandMock.mockResolvedValue({ exitCode: 0, output: '' }) const service = new GitHubService({ executeCommand: executeCommandMock } as any) diff --git a/packages/orchestrator/test/linear-service.test.ts b/packages/orchestrator/test/linear-service.test.ts index 004e69c..e45bf1b 100644 --- a/packages/orchestrator/test/linear-service.test.ts +++ b/packages/orchestrator/test/linear-service.test.ts @@ -26,6 +26,7 @@ describe('LinearService', () => { identifier: 'TEST-1', title: 'Test Issue', description: 'Description', + labels: { nodes: [{ name: 'ai-ready' }] }, }, ], }, @@ -50,6 +51,7 @@ describe('LinearService', () => { expect(tasks).toHaveLength(1) expect(tasks[0].externalId).toBe('TEST-1') expect(tasks[0].status).toBe('PENDING') + expect(tasks[0].labels).toEqual(['ai-ready']) expect(fetchMock).toHaveBeenCalledTimes(1) expect(fetchMock).toHaveBeenCalledWith('https://api.linear.app/graphql', { method: 'POST', @@ -61,6 +63,48 @@ describe('LinearService', () => { }) }) + it('returns an empty labels array when issue has no labels', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + issues: { + nodes: [{ id: 'lin_2', identifier: 'TEST-2', title: 'No labels', description: null }], + }, + }, + }), + }) + + const service = new LinearService('fake-key') + const project = { + id: 'p1', + pullFrom: { provider: 'linear', filters: { team: 'TEST' } }, + } as any + + const tasks = await service.fetchNewIssues(project) + + expect(tasks[0].labels).toEqual([]) + }) + + it('includes labels field in the GraphQL query', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { issues: { nodes: [] } }, + }), + }) + + const service = new LinearService('fake-key') + await service.fetchNewIssues({ + id: 'p1', + pullFrom: { provider: 'linear', filters: {} }, + } as any) + + const body = JSON.parse(fetchMock.mock.calls[0][1].body) + expect(body.query).toContain('labels') + expect(body.query).toContain('nodes') + }) + it('should skip non-linear providers', async () => { const service = new LinearService('fake-key') const tasks = await service.fetchNewIssues({ diff --git a/packages/orchestrator/test/provider-services.test.ts b/packages/orchestrator/test/provider-services.test.ts new file mode 100644 index 0000000..ad062c9 --- /dev/null +++ b/packages/orchestrator/test/provider-services.test.ts @@ -0,0 +1,134 @@ +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/package.json b/packages/slack/package.json new file mode 100644 index 0000000..3995b52 --- /dev/null +++ b/packages/slack/package.json @@ -0,0 +1,23 @@ +{ + "name": "@parallax/slack", + "version": "0.0.9", + "private": true, + "type": "module", + "scripts": { + "build": "rm -rf dist && tsc", + "lint": "eslint src", + "lint:fix": "eslint src --fix", + "test": "vitest run" + }, + "dependencies": { + "@parallax/common": "workspace:*", + "@slack/bolt": "^4.0.0", + "@slack/web-api": "^7.0.0" + }, + "files": [ + "dist" + ], + "exports": { + ".": "./dist/slack/src/index.js" + } +} diff --git a/packages/slack/src/bot.ts b/packages/slack/src/bot.ts new file mode 100644 index 0000000..5eeced1 --- /dev/null +++ b/packages/slack/src/bot.ts @@ -0,0 +1,40 @@ +import { App } from '@slack/bolt' +import { WebClient } from '@slack/web-api' +import type { SlackBotOptions, SlackNotificationPayload } from './types.js' +import { sendNotification } from './notifications.js' +import { registerPlanApprovalHandlers } from './handlers/plan-approval.js' +import { registerSlashCommands } from './handlers/commands.js' + +export class SlackBot { + private app: App + private client: WebClient + private channel: string + private apiBaseUrl: string + private threadRegistry = new Map() + + constructor({ config, apiBaseUrl }: SlackBotOptions) { + this.channel = config.channel + this.apiBaseUrl = apiBaseUrl + this.app = new App({ + token: config.botToken, + appToken: config.appToken, + socketMode: true, + }) + this.client = new WebClient(config.botToken) + + registerPlanApprovalHandlers(this.app, apiBaseUrl) + registerSlashCommands(this.app, apiBaseUrl) + } + + async start(): Promise { + await this.app.start() + } + + async stop(): Promise { + await this.app.stop() + } + + async notify(payload: SlackNotificationPayload): Promise { + await sendNotification(this.client, this.channel, payload, this.threadRegistry) + } +} diff --git a/packages/slack/src/formatters.ts b/packages/slack/src/formatters.ts new file mode 100644 index 0000000..67e1112 --- /dev/null +++ b/packages/slack/src/formatters.ts @@ -0,0 +1,122 @@ +import type { AgentDefinition, 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 truncate(text: string, max: number): string { + if (text.length <= max) { + return text + } + return `${text.slice(0, max)}… (truncated)` +} + +function markdownToMrkdwn(text: string): string { + return ( + text + // Headers → bold line + .replace(/^#{1,6}\s+(.+)$/gm, '*$1*') + // **bold** → *bold* + .replace(/\*\*(.+?)\*\*/gs, '*$1*') + // [text](url) → + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<$2|$1>') + ) +} + +export function buildPlanApprovalMessage( + task: Task, + agentDef?: AgentDefinition +): (Block | KnownBlock)[] { + const planText = task.planMarkdown + ? truncate(task.planMarkdown, MAX_PLAN_LENGTH) + : 'No plan content available.' + + return [ + { + type: 'header', + text: { + type: 'plain_text', + text: `Plan Ready — ${agentIdentityLine(agentDef)}`, + emoji: true, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Project:* ${task.projectId}\n*Task:* ${task.externalId} · ${task.title}`, + }, + }, + { type: 'divider' }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Proposed Plan*\n${markdownToMrkdwn(planText)}`, + }, + }, + { + type: 'actions', + block_id: `plan_actions_${task.id}`, + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: '✅ Approve', emoji: true }, + style: 'primary', + action_id: 'plan_approve', + value: task.id, + }, + { + type: 'button', + text: { type: 'plain_text', text: '❌ Reject', emoji: true }, + style: 'danger', + action_id: 'plan_reject', + value: task.id, + }, + ], + }, + ] as (Block | KnownBlock)[] +} + +export function buildEventMessage( + task: Task, + event: string, + agentDef?: AgentDefinition, + extra?: string +): (Block | KnownBlock)[] { + const eventLabels: Record = { + pr_created: '🔀 PR Created', + failed: '❌ Failed', + canceled: '🚫 Canceled', + execution_started: '🚀 Execution Started', + } + + const label = eventLabels[event] ?? event + const agentLine = agentIdentityLine(agentDef) + + const blocks: (Block | KnownBlock)[] = [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${agentLine}* · ${label}\n*Task:* ${task.externalId} — ${task.title}`, + }, + }, + ] + + if (extra) { + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: extra }, + }) + } + + return blocks +} diff --git a/packages/slack/src/handlers/commands.ts b/packages/slack/src/handlers/commands.ts new file mode 100644 index 0000000..8bfe2f5 --- /dev/null +++ b/packages/slack/src/handlers/commands.ts @@ -0,0 +1,68 @@ +import type { App } from '@slack/bolt' + +export function registerSlashCommands(app: App, apiBaseUrl: string): void { + app.command('/parallax', async ({ command, ack, respond }) => { + await ack() + const [subcommand, taskId] = command.text.trim().split(/\s+/) + + if (!subcommand) { + await respond('Usage: `/parallax `') + return + } + + if (!taskId) { + await respond(`Usage: \`/parallax ${subcommand} \``) + return + } + + try { + switch (subcommand) { + case 'retry': { + const res = await fetch(`${apiBaseUrl}/tasks/${taskId}/retry`, { method: 'POST' }) + if (!res.ok) { + await respond(`Failed to retry task \`${taskId}\`: ${res.statusText}`) + return + } + await respond(`🔁 Retry triggered for task \`${taskId}\`.`) + break + } + case 'cancel': { + const res = await fetch(`${apiBaseUrl}/tasks/${taskId}/cancel`, { method: 'POST' }) + if (!res.ok) { + await respond(`Failed to cancel task \`${taskId}\`: ${res.statusText}`) + return + } + await respond(`🚫 Cancel requested for task \`${taskId}\`.`) + break + } + case 'status': { + const res = await fetch(`${apiBaseUrl}/tasks/${taskId}`) + if (!res.ok) { + await respond(`Task \`${taskId}\` not found.`) + return + } + const data = (await res.json()) as { status: string; planState?: string; title?: string } + await respond( + `*Task \`${taskId}\`:* ${data.title ?? ''}\nStatus: \`${data.status}\` Plan: \`${data.planState ?? 'n/a'}\`` + ) + break + } + case 'pr-review': { + const res = await fetch(`${apiBaseUrl}/tasks/${taskId}/pr-review`, { method: 'POST' }) + if (!res.ok) { + await respond(`Failed to trigger PR review for task \`${taskId}\`: ${res.statusText}`) + return + } + await respond(`🔍 PR review triggered for task \`${taskId}\`.`) + break + } + default: + await respond( + `Unknown subcommand \`${subcommand}\`. Use: retry | cancel | status | pr-review` + ) + } + } catch (err: any) { + await respond(`Error: ${err.message}`) + } + }) +} diff --git a/packages/slack/src/handlers/plan-approval.ts b/packages/slack/src/handlers/plan-approval.ts new file mode 100644 index 0000000..1b2ecd3 --- /dev/null +++ b/packages/slack/src/handlers/plan-approval.ts @@ -0,0 +1,63 @@ +import type { App } from '@slack/bolt' + +export function registerPlanApprovalHandlers(app: App, apiBaseUrl: string): void { + app.action('plan_approve', async ({ action, ack, respond, body }) => { + await ack() + const taskId = (action as { value: string }).value + try { + const res = await fetch(`${apiBaseUrl}/tasks/${taskId}/approve`, { method: 'POST' }) + if (!res.ok) { + await respond({ + text: `Failed to approve plan for task ${taskId}: ${res.statusText}`, + replace_original: false, + }) + return + } + const originalBlocks = ((body as any).message?.blocks ?? []).filter( + (b: any) => b.type !== 'actions' + ) + await respond({ + replace_original: true, + blocks: [ + ...originalBlocks, + { + type: 'section', + text: { type: 'mrkdwn', text: `✅ *Plan approved*` }, + }, + ], + }) + } catch (err: any) { + await respond({ text: `Error approving plan: ${err.message}`, replace_original: false }) + } + }) + + app.action('plan_reject', async ({ action, ack, respond, body }) => { + await ack() + const taskId = (action as { value: string }).value + try { + const res = await fetch(`${apiBaseUrl}/tasks/${taskId}/reject`, { method: 'POST' }) + if (!res.ok) { + await respond({ + text: `Failed to reject plan for task ${taskId}: ${res.statusText}`, + replace_original: false, + }) + return + } + const originalBlocks = ((body as any).message?.blocks ?? []).filter( + (b: any) => b.type !== 'actions' + ) + await respond({ + replace_original: true, + blocks: [ + ...originalBlocks, + { + type: 'section', + text: { type: 'mrkdwn', text: `❌ *Plan rejected*` }, + }, + ], + }) + } catch (err: any) { + await respond({ text: `Error rejecting plan: ${err.message}`, replace_original: false }) + } + }) +} diff --git a/packages/slack/src/index.ts b/packages/slack/src/index.ts new file mode 100644 index 0000000..9b21a84 --- /dev/null +++ b/packages/slack/src/index.ts @@ -0,0 +1,2 @@ +export { SlackBot } from './bot.js' +export type { SlackBotOptions, SlackNotificationPayload, SlackNotificationEvent } from './types.js' diff --git a/packages/slack/src/notifications.ts b/packages/slack/src/notifications.ts new file mode 100644 index 0000000..3120ce6 --- /dev/null +++ b/packages/slack/src/notifications.ts @@ -0,0 +1,30 @@ +import type { WebClient } from '@slack/web-api' +import type { SlackNotificationPayload } from './types.js' +import { buildEventMessage, buildPlanApprovalMessage } from './formatters.js' + +export async function sendNotification( + client: WebClient, + channel: string, + payload: SlackNotificationPayload, + threadRegistry: Map +): Promise { + const { task, event, agentDef, extra } = payload + + if (event === 'plan_ready') { + const result = await client.chat.postMessage({ + channel, + blocks: buildPlanApprovalMessage(task, agentDef), + text: `Plan ready for ${task.externalId}: ${task.title}`, + }) + if (result.ts) { + threadRegistry.set(result.ts, task.id) + } + return + } + + await client.chat.postMessage({ + channel, + blocks: buildEventMessage(task, event, agentDef, 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 new file mode 100644 index 0000000..3a7c808 --- /dev/null +++ b/packages/slack/src/test/formatters.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest' +import { buildPlanApprovalMessage, buildEventMessage } from '../formatters.js' +import type { AgentDefinition, Task } from '@parallax/common' + +const baseTask: Task = { + id: 'task-1', + externalId: 'ORG/REPO#42', + title: 'Add rate limiting', + description: 'Protect the API from abuse', + status: 'PENDING', + projectId: 'my-repo', + createdAt: 0, + 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) + 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('includes task externalId and title in the section block', () => { + const blocks = buildPlanApprovalMessage(baseTask, agentDef) + 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 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 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 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') + expect(actionIds).toContain('plan_reject') + }) + + it('sets button values to task.id', () => { + const blocks = buildPlanApprovalMessage(baseTask, agentDef) + 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']) + }) + + 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 json = JSON.stringify(blocks) + expect(json).toContain('Step 1: do the thing') + }) + + 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 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') + 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 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 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') + 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) + expect(blocks).toHaveLength(1) + }) +}) diff --git a/packages/slack/src/types.ts b/packages/slack/src/types.ts new file mode 100644 index 0000000..97caa54 --- /dev/null +++ b/packages/slack/src/types.ts @@ -0,0 +1,20 @@ +import type { AgentDefinition, SlackConfig, Task } from '@parallax/common' + +export type SlackNotificationEvent = + | 'plan_ready' + | 'pr_created' + | 'failed' + | 'canceled' + | 'execution_started' + +export interface SlackNotificationPayload { + task: Task + event: SlackNotificationEvent + agentDef?: AgentDefinition + extra?: string +} + +export interface SlackBotOptions { + config: SlackConfig + apiBaseUrl: string +} diff --git a/packages/slack/tsconfig.json b/packages/slack/tsconfig.json new file mode 100644 index 0000000..57315fd --- /dev/null +++ b/packages/slack/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["src/**/*"] +} diff --git a/packages/slack/vitest.config.ts b/packages/slack/vitest.config.ts new file mode 100644 index 0000000..22ead24 --- /dev/null +++ b/packages/slack/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config' +import path from 'node:path' + +export default defineConfig({ + resolve: { + alias: { + '@parallax/common': path.resolve(__dirname, '../common/src/index.ts'), + }, + }, + test: { + exclude: ['node_modules/**', 'dist/**'], + }, +}) diff --git a/packages/ui/src/components/LogViewer.tsx b/packages/ui/src/components/LogViewer.tsx index ecbe96e..95a7e15 100644 --- a/packages/ui/src/components/LogViewer.tsx +++ b/packages/ui/src/components/LogViewer.tsx @@ -25,6 +25,7 @@ import { FileText, GitBranch, GitPullRequest, + Hash, Info, LoaderCircle, Logs, @@ -432,6 +433,13 @@ export function LogViewer({ {taskMetadata.model ? ( ) : null} + {config?.slack ? ( + + ) : null} diff --git a/packages/ui/src/components/TaskSidebar.tsx b/packages/ui/src/components/TaskSidebar.tsx index a72e6e3..a3a5ba7 100644 --- a/packages/ui/src/components/TaskSidebar.tsx +++ b/packages/ui/src/components/TaskSidebar.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react' import { cn } from '@/lib/utils' import type { TaskInfo } from '@/hooks/useParallax' import type { AppConfig, ProjectConfig } from '@parallax/common' -import { Github, Settings } from 'lucide-react' +import { Bot, Github, Hash, Settings } from 'lucide-react' import { TASK_STATUS, TASK_STATUS_LABEL, type TaskStatus } from '@/lib/task-constants' interface TaskSidebarProps { @@ -143,27 +143,65 @@ export function TaskSidebar({ ) : ( -
-
- - Projects -
-
- {config?.projects?.map((project: ProjectConfig, index: number) => ( - - ))} +
+
+
+ + Projects +
+
+ {config?.projects?.map((project: ProjectConfig, index: number) => ( + + ))} +
+ + {config?.agents && config.agents.length > 0 && ( +
+
+ + Agents +
+
+ {config.agents.map((agent) => ( +
+

{agent.name}

+

+ {agent.provider} + {agent.model ? ` · ${agent.model}` : ''} +

+
+ ))} +
+
+ )} + + {config?.slack && ( +
+
+ + Slack +
+
+

Channel

+

{config.slack.channel}

+
+
+ )}
)} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fca2e2b..66d3385 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: '@parallax/orchestrator': specifier: workspace:* version: link:../orchestrator + '@parallax/slack': + specifier: workspace:* + version: link:../slack '@parallax/ui': specifier: workspace:* version: link:../ui @@ -334,6 +337,9 @@ importers: '@parallax/common': specifier: workspace:* version: link:../common + '@parallax/slack': + specifier: workspace:* + version: link:../slack chalk: specifier: '4' version: 4.1.2 @@ -375,6 +381,18 @@ importers: specifier: 10.0.0 version: 10.0.0 + packages/slack: + dependencies: + '@parallax/common': + specifier: workspace:* + version: link:../common + '@slack/bolt': + specifier: ^4.0.0 + version: 4.7.2(@types/express@5.0.6) + '@slack/web-api': + specifier: ^7.0.0 + version: 7.15.2 + packages/ui: devDependencies: '@eslint/js': @@ -1828,6 +1846,32 @@ packages: cpu: [x64] os: [win32] + '@slack/bolt@4.7.2': + resolution: {integrity: sha512-ALHtaS2iaP2WAWgX08yXsoCxEDitC6AqZs26ot6smXJQzBFMM4slVP+w3blLwzUV551xZ/+9RlBmWHsZDJJ5HA==} + engines: {node: '>=18', npm: '>=8.6.0'} + peerDependencies: + '@types/express': ^5.0.0 + + '@slack/logger@4.0.1': + resolution: {integrity: sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + + '@slack/oauth@3.0.5': + resolution: {integrity: sha512-exqFQySKhNDptWYSWhvRUJ4/+ndu2gayIy7vg/JfmJq3wGtGdHk531P96fAZyBm5c1Le3yaPYqv92rL4COlU3A==} + engines: {node: '>=18', npm: '>=8.6.0'} + + '@slack/socket-mode@2.0.7': + resolution: {integrity: sha512-qYy07je71WnEHgRwmw12DlAnZLi5HXmdlI2WUzUK2LH/rYXQpP6uEg462S5CwfE8FoCKUdIigHtYnOOfzZH1lQ==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + + '@slack/types@2.21.1': + resolution: {integrity: sha512-I8vmSjNYWsaxuWPx6dz4yeh0h7vRBWbgAMK14LEmblbZ404BtrPbXs6jDPx4cYgGf8msDGF4A9opLZBu21FViQ==} + engines: {node: '>= 12.13.0', npm: '>= 6.12.0'} + + '@slack/web-api@7.15.2': + resolution: {integrity: sha512-/m9qVFkiq85Oa/FSQwYIRDa/AO4qNYkDh4sRBK1WqEc2+RyG7w4tbU6rBIwUOcc/TmWOIr24Nraquxg7um5mYw==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} @@ -1953,9 +1997,15 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} @@ -1992,12 +2042,27 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/express-serve-static-core@5.1.1': + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@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==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@22.16.5': resolution: {integrity: sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==} @@ -2007,6 +2072,12 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: @@ -2015,9 +2086,21 @@ packages: '@types/react@18.3.23': resolution: {integrity: sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==} + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.38.0': resolution: {integrity: sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2210,6 +2293,10 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-globals@7.0.1: resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} @@ -2350,6 +2437,9 @@ packages: avvio@9.2.0: resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + axios@1.16.1: + resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} + axios@1.7.9: resolution: {integrity: sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==} @@ -2373,6 +2463,10 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2392,6 +2486,13 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -2474,6 +2575,22 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -2617,6 +2734,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -2660,6 +2781,12 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.302: resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} @@ -2679,6 +2806,10 @@ packages: emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + engine.io-client@6.6.4: resolution: {integrity: sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==} @@ -2747,6 +2878,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2841,13 +2975,24 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -2906,6 +3051,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-my-way@9.5.0: resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==} engines: {node: '>=20'} @@ -2930,6 +3079,15 @@ packages: debug: optional: true + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -2938,6 +3096,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} @@ -2969,6 +3131,10 @@ packages: react-dom: optional: true + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3073,6 +3239,10 @@ packages: resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} engines: {node: '>=12'} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@5.0.0: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} engines: {node: '>= 6'} @@ -3085,6 +3255,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3105,6 +3279,9 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + input-otp@1.4.2: resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} peerDependencies: @@ -3119,6 +3296,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + ipaddr.js@2.3.0: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} @@ -3159,6 +3340,9 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-electron@2.2.2: + resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -3198,6 +3382,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3210,6 +3397,10 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -3290,10 +3481,20 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3392,12 +3593,30 @@ packages: lodash.castarray@4.4.0: resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -3428,6 +3647,14 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -3440,10 +3667,18 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -3490,6 +3725,10 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + next-themes@0.3.0: resolution: {integrity: sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w==} peerDependencies: @@ -3550,6 +3789,13 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -3562,6 +3808,10 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -3574,6 +3824,18 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3581,6 +3843,10 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3592,6 +3858,9 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -3703,9 +3972,17 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} @@ -3713,6 +3990,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} @@ -3722,6 +4003,14 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + react-day-picker@8.10.1: resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==} peerDependencies: @@ -3878,6 +4167,10 @@ packages: resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} engines: {node: '>=10'} + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -3890,6 +4183,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -3897,6 +4194,9 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -3934,6 +4234,14 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -3949,6 +4257,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -4026,6 +4337,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -4156,6 +4471,10 @@ packages: resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} engines: {node: '>=12'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tough-cookie@4.1.4: resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} @@ -4176,6 +4495,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsscmp@1.0.6: + resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} + engines: {node: '>=0.6.x'} + tsx@4.19.2: resolution: {integrity: sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==} engines: {node: '>=18.0.0'} @@ -4185,6 +4508,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -4239,6 +4566,10 @@ packages: resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} engines: {node: '>= 4.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -4448,6 +4779,9 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.3: resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} @@ -5554,6 +5888,74 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true + '@slack/bolt@4.7.2(@types/express@5.0.6)': + dependencies: + '@slack/logger': 4.0.1 + '@slack/oauth': 3.0.5 + '@slack/socket-mode': 2.0.7 + '@slack/types': 2.21.1 + '@slack/web-api': 7.15.2 + '@types/express': 5.0.6 + axios: 1.16.1 + express: 5.2.1 + path-to-regexp: 8.4.2 + raw-body: 3.0.2 + tsscmp: 1.0.6 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + + '@slack/logger@4.0.1': + dependencies: + '@types/node': 25.3.0 + + '@slack/oauth@3.0.5': + dependencies: + '@slack/logger': 4.0.1 + '@slack/web-api': 7.15.2 + '@types/jsonwebtoken': 9.0.10 + '@types/node': 25.3.0 + jsonwebtoken: 9.0.3 + transitivePeerDependencies: + - debug + - supports-color + + '@slack/socket-mode@2.0.7': + dependencies: + '@slack/logger': 4.0.1 + '@slack/web-api': 7.15.2 + '@types/node': 25.3.0 + '@types/ws': 8.18.1 + eventemitter3: 5.0.4 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + + '@slack/types@2.21.1': {} + + '@slack/web-api@7.15.2': + dependencies: + '@slack/logger': 4.0.1 + '@slack/types': 2.21.1 + '@types/node': 25.3.0 + '@types/retry': 0.12.0 + axios: 1.16.1 + eventemitter3: 5.0.4 + form-data: 4.0.5 + is-electron: 2.2.2 + is-stream: 2.0.1 + p-queue: 6.6.2 + p-retry: 4.6.2 + retry: 0.13.1 + transitivePeerDependencies: + - debug + - supports-color + '@socket.io/component-emitter@3.1.2': {} '@swc/core-darwin-arm64@1.15.17': @@ -5658,11 +6060,20 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 25.3.0 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/connect@3.4.38': + dependencies: + '@types/node': 25.3.0 + '@types/cors@2.8.19': dependencies: '@types/node': 25.3.0 @@ -5695,10 +6106,32 @@ snapshots: '@types/estree@1.0.8': {} + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 25.3.0 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + '@types/js-yaml@4.0.9': {} '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 25.3.0 + + '@types/ms@2.1.0': {} + '@types/node@22.16.5': dependencies: undici-types: 6.21.0 @@ -5709,6 +6142,10 @@ snapshots: '@types/prop-types@15.7.15': {} + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + '@types/react-dom@18.3.7(@types/react@18.3.23)': dependencies: '@types/react': 18.3.23 @@ -5718,8 +6155,23 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@types/retry@0.12.0': {} + + '@types/send@1.2.1': + dependencies: + '@types/node': 25.3.0 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 25.3.0 + '@types/uuid@10.0.0': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.3.0 + '@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -6007,6 +6459,11 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-globals@7.0.1: dependencies: acorn: 8.16.0 @@ -6167,6 +6624,16 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 + axios@1.16.1: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + axios@1.7.9: dependencies: follow-redirects: 1.15.11 @@ -6185,6 +6652,20 @@ snapshots: binary-extensions@2.3.0: {} + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.1 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -6210,6 +6691,10 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer-equal-constant-time@1.0.1: {} + + bytes@3.1.2: {} + cac@6.7.14: {} call-bind-apply-helpers@1.0.2: @@ -6303,6 +6788,14 @@ snapshots: concat-map@0.0.1: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + cookie-signature@1.2.2: {} + cookie@0.7.2: {} cookie@1.1.1: {} @@ -6426,6 +6919,8 @@ snapshots: delayed-stream@1.0.0: {} + depd@2.0.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: @@ -6462,6 +6957,12 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + electron-to-chromium@1.5.302: {} embla-carousel-react@8.6.0(react@18.3.1): @@ -6478,6 +6979,8 @@ snapshots: emoji-regex@10.6.0: {} + encodeurl@2.0.0: {} + engine.io-client@6.6.4: dependencies: '@socket.io/component-emitter': 3.1.2 @@ -6670,6 +7173,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} escodegen@2.1.0: @@ -6794,10 +7299,47 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + eventemitter3@4.0.7: {} + eventemitter3@5.0.4: {} + expect-type@1.3.0: {} + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.1 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} @@ -6869,6 +7411,17 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-my-way@9.5.0: dependencies: fast-deep-equal: 3.1.3 @@ -6889,6 +7442,8 @@ snapshots: follow-redirects@1.15.11: {} + follow-redirects@1.16.0: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -6901,6 +7456,8 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + forwarded@0.2.0: {} + fraction.js@4.3.7: {} framer-motion@12.35.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -6921,6 +7478,8 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + fresh@2.0.0: {} + fsevents@2.3.3: optional: true @@ -7020,6 +7579,14 @@ snapshots: dependencies: whatwg-encoding: 2.0.0 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@5.0.0: dependencies: '@tootallnate/once': 2.0.0 @@ -7039,6 +7606,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -7052,6 +7623,8 @@ snapshots: indent-string@4.0.0: {} + inherits@2.0.4: {} + input-otp@1.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -7065,6 +7638,8 @@ snapshots: internmap@2.0.3: {} + ipaddr.js@1.9.1: {} + ipaddr.js@2.3.0: {} is-array-buffer@3.0.5: @@ -7111,6 +7686,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-electron@2.2.2: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -7146,6 +7723,8 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -7159,6 +7738,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-stream@2.0.1: {} + is-string@1.1.1: dependencies: call-bound: 1.0.4 @@ -7260,6 +7841,19 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.4 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -7267,6 +7861,17 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -7342,10 +7947,22 @@ snapshots: lodash.castarray@4.4.0: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + lodash.isplainobject@4.0.6: {} + lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash@4.17.23: {} log-update@7.1.0: @@ -7374,6 +7991,10 @@ snapshots: math-intrinsics@1.1.0: {} + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -7383,10 +8004,16 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mimic-function@5.0.1: {} min-indent@1.0.1: {} @@ -7427,6 +8054,8 @@ snapshots: negotiator@0.6.3: {} + negotiator@1.0.0: {} + next-themes@0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -7487,6 +8116,14 @@ snapshots: on-exit-leak-free@2.1.2: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -7506,6 +8143,8 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + p-finally@1.0.0: {} + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -7518,6 +8157,20 @@ snapshots: dependencies: p-limit: 3.1.0 + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -7526,12 +8179,16 @@ snapshots: dependencies: entities: 6.0.1 + parseurl@1.3.3: {} + path-exists@4.0.0: {} path-key@3.1.1: {} path-parse@1.0.7: {} + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} pathval@2.0.1: {} @@ -7634,20 +8291,40 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} + psl@1.15.0: dependencies: punycode: 2.3.1 punycode@2.3.1: {} + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + querystringify@2.2.0: {} queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + react-day-picker@8.10.1(date-fns@3.6.0)(react@18.3.1): dependencies: date-fns: 3.6.0 @@ -7816,6 +8493,8 @@ snapshots: ret@0.5.0: {} + retry@0.13.1: {} + reusify@1.1.0: {} rfdc@1.4.1: {} @@ -7851,6 +8530,16 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -7863,6 +8552,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -7896,6 +8587,31 @@ snapshots: semver@7.7.4: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-cookie-parser@2.7.2: {} set-function-length@1.2.2: @@ -7920,6 +8636,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -8030,6 +8748,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} stop-iteration-iterator@1.1.0: @@ -8195,6 +8915,8 @@ snapshots: toad-cache@3.7.0: {} + toidentifier@1.0.1: {} + tough-cookie@4.1.4: dependencies: psl: 1.15.0 @@ -8218,6 +8940,8 @@ snapshots: tslib@2.8.1: {} + tsscmp@1.0.6: {} + tsx@4.19.2: dependencies: esbuild: 0.23.1 @@ -8229,6 +8953,12 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -8301,6 +9031,8 @@ snapshots: universalify@0.2.0: {} + unpipe@1.0.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -8573,6 +9305,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.2 + wrappy@1.0.2: {} + ws@8.18.3: {} xml-name-validator@4.0.0: {} diff --git a/tsconfig.base.json b/tsconfig.base.json index 99e8780..417e93b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -8,6 +8,7 @@ "@parallax/common": ["packages/common/src/index.ts"], "@parallax/common/executor": ["packages/common/src/executor.ts"], "@parallax/orchestrator": ["packages/orchestrator/src/index.ts"], + "@parallax/slack": ["packages/slack/src/index.ts"], "@parallax/ui": ["packages/ui/src/main.tsx"] }, "strict": true,