diff --git a/README.md b/README.md index 963f660..3d633bf 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,9 @@ Built by [Owloops](https://github.com/Owloops), building browser automation tool ## Use Cases -Set up a bird (scheduled task) and it runs on a cron, browses the web, and posts results to your Slack channel. The browser keeps logins and cookies across runs, so it works with sites that require authentication. +Set up an automation (scheduled task) and it runs on a cron, browses the web, and posts results to your Slack channel. The browser keeps logins and cookies across runs, so it works with sites that require authentication. -| Use Case | What the bird does | +| Use Case | What the automation does | | --- | --- | | **Competitor monitoring** | Visits competitor pages on a schedule, screenshots pricing or feature changes, and flags differences in your channel. | | **News and mention digest** | Browses Hacker News, Reddit, or Twitter for mentions of your product or keywords, and posts a morning summary. | @@ -42,7 +42,7 @@ Set up a bird (scheduled task) and it runs on a cron, browses the web, and posts | **Job and listing tracker** | Browses job boards, real estate sites, or marketplaces for new listings matching your criteria. | | **Internal database analytics** | Deployed in your private network with read-only database access, lets non-technical teams ask questions in Slack and get structured answers with tables and trends. | -These are starting points. Every bird has a full AI agent behind it that can browse the web, run shell commands, write and analyze code, call APIs, use MCP servers, and work with any CLI tool installed in the environment. +These are starting points. Every automation has a full AI agent behind it that can browse the web, run shell commands, write and analyze code, call APIs, use MCP servers, and work with any CLI tool installed in the environment. ## Installation @@ -89,29 +89,29 @@ After the stack is created, add a DNS record (CNAME or alias) pointing to the AL ## Slack (Optional) -[![Create Slack App](https://img.shields.io/badge/Slack-Create_App-4A154B?style=for-the-badge&logo=slack&logoColor=white)](https://api.slack.com/apps?new_app=1&manifest_json=%7B%22display_information%22%3A%7B%22name%22%3A%22BrowserBird%22%2C%22description%22%3A%22A%20self-hosted%20AI%20assistant%20in%20Slack%2C%20with%20a%20real%20browser%20and%20a%20scheduler.%22%2C%22background_color%22%3A%22%231a1a2e%22%7D%2C%22features%22%3A%7B%22assistant_view%22%3A%7B%22assistant_description%22%3A%22A%20self-hosted%20AI%20assistant%20in%20Slack%2C%20with%20a%20real%20browser%20and%20a%20scheduler.%22%7D%2C%22app_home%22%3A%7B%22home_tab_enabled%22%3Atrue%2C%22messages_tab_enabled%22%3Atrue%2C%22messages_tab_read_only_enabled%22%3Afalse%7D%2C%22bot_user%22%3A%7B%22display_name%22%3A%22BrowserBird%22%2C%22always_online%22%3Atrue%7D%2C%22slash_commands%22%3A%5B%7B%22command%22%3A%22%2Fbird%22%2C%22description%22%3A%22Manage%20BrowserBird%20birds%22%2C%22usage_hint%22%3A%22list%20%7C%20fly%20%7C%20stop%20%7C%20logs%20%7C%20enable%20%7C%20disable%20%7C%20create%20%7C%20status%22%2C%22should_escape%22%3Afalse%7D%5D%7D%2C%22oauth_config%22%3A%7B%22scopes%22%3A%7B%22bot%22%3A%5B%22app_mentions%3Aread%22%2C%22assistant%3Awrite%22%2C%22channels%3Ahistory%22%2C%22channels%3Aread%22%2C%22chat%3Awrite%22%2C%22files%3Aread%22%2C%22files%3Awrite%22%2C%22groups%3Ahistory%22%2C%22groups%3Aread%22%2C%22im%3Ahistory%22%2C%22im%3Aread%22%2C%22im%3Awrite%22%2C%22mpim%3Ahistory%22%2C%22mpim%3Aread%22%2C%22reactions%3Aread%22%2C%22reactions%3Awrite%22%2C%22users%3Aread%22%2C%22commands%22%5D%7D%7D%2C%22settings%22%3A%7B%22event_subscriptions%22%3A%7B%22bot_events%22%3A%5B%22app_mention%22%2C%22assistant_thread_context_changed%22%2C%22assistant_thread_started%22%2C%22message.channels%22%2C%22message.groups%22%2C%22message.im%22%2C%22message.mpim%22%2C%22app_home_opened%22%5D%7D%2C%22interactivity%22%3A%7B%22is_enabled%22%3Atrue%7D%2C%22org_deploy_enabled%22%3Afalse%2C%22socket_mode_enabled%22%3Atrue%2C%22token_rotation_enabled%22%3Afalse%7D%7D) +[![Create Slack App](https://img.shields.io/badge/Slack-Create_App-4A154B?style=for-the-badge&logo=slack&logoColor=white)](https://api.slack.com/apps?new_app=1&manifest_json=%7B%22display_information%22%3A%7B%22name%22%3A%22BrowserBird%22%2C%22description%22%3A%22A%20self-hosted%20AI%20assistant%20in%20Slack%2C%20with%20a%20real%20browser%20and%20a%20scheduler.%22%2C%22background_color%22%3A%22%231a1a2e%22%7D%2C%22features%22%3A%7B%22assistant_view%22%3A%7B%22assistant_description%22%3A%22A%20self-hosted%20AI%20assistant%20in%20Slack%2C%20with%20a%20real%20browser%20and%20a%20scheduler.%22%7D%2C%22app_home%22%3A%7B%22home_tab_enabled%22%3Atrue%2C%22messages_tab_enabled%22%3Atrue%2C%22messages_tab_read_only_enabled%22%3Afalse%7D%2C%22bot_user%22%3A%7B%22display_name%22%3A%22BrowserBird%22%2C%22always_online%22%3Atrue%7D%2C%22slash_commands%22%3A%5B%7B%22command%22%3A%22%2Fautomation%22%2C%22description%22%3A%22Manage%20BrowserBird%20automations%22%2C%22usage_hint%22%3A%22list%20%7C%20run%20%7C%20stop%20%7C%20logs%20%7C%20enable%20%7C%20disable%20%7C%20create%20%7C%20status%22%2C%22should_escape%22%3Afalse%7D%5D%7D%2C%22oauth_config%22%3A%7B%22scopes%22%3A%7B%22bot%22%3A%5B%22app_mentions%3Aread%22%2C%22assistant%3Awrite%22%2C%22channels%3Ahistory%22%2C%22channels%3Aread%22%2C%22chat%3Awrite%22%2C%22files%3Aread%22%2C%22files%3Awrite%22%2C%22groups%3Ahistory%22%2C%22groups%3Aread%22%2C%22im%3Ahistory%22%2C%22im%3Aread%22%2C%22im%3Awrite%22%2C%22mpim%3Ahistory%22%2C%22mpim%3Aread%22%2C%22reactions%3Aread%22%2C%22reactions%3Awrite%22%2C%22users%3Aread%22%2C%22commands%22%5D%7D%7D%2C%22settings%22%3A%7B%22event_subscriptions%22%3A%7B%22bot_events%22%3A%5B%22app_mention%22%2C%22assistant_thread_context_changed%22%2C%22assistant_thread_started%22%2C%22message.channels%22%2C%22message.groups%22%2C%22message.im%22%2C%22message.mpim%22%2C%22app_home_opened%22%5D%7D%2C%22interactivity%22%3A%7B%22is_enabled%22%3Atrue%7D%2C%22org_deploy_enabled%22%3Afalse%2C%22socket_mode_enabled%22%3Atrue%2C%22token_rotation_enabled%22%3Afalse%7D%7D) The manifest pre-configures all scopes, events, and slash commands. After creating the app, install it to your workspace and grab two tokens: the **Bot User OAuth Token** (`xoxb-...`) from OAuth & Permissions, and an **app-level token** (`xapp-...`) with `connections:write` scope from Basic Information. ### Slash Commands -Once the app is installed, `/bird` is available in any channel: +Once the app is installed, `/automation` is available in any channel: ``` -/bird list Show all configured birds -/bird fly [args] Trigger a bird (replaces $ARGUMENTS in the prompt) -/bird stop Stop a running bird -/bird logs Show recent flights -/bird enable Enable a bird -/bird disable Disable a bird -/bird create Create a new bird (opens modal form) -/bird status Show daemon status +/automation list Show all configured automations +/automation run [args] Trigger an automation (replaces $ARGUMENTS in the prompt) +/automation stop Stop a running automation +/automation logs Show recent runs +/automation enable Enable an automation +/automation disable Disable an automation +/automation create Create a new automation (opens modal form) +/automation status Show daemon status ``` To stop an active response in a thread, send `stop` as a message (or `@BrowserBird stop` in channels). The agent process is killed and a reaction is added to confirm. > [!TIP] -> If `/bird` fails or routes to the wrong app, you may have another Slack app in the workspace with the same slash command. Remove or rename the duplicate from [api.slack.com/apps](https://api.slack.com/apps). +> If `/automation` fails or routes to the wrong app, you may have another Slack app in the workspace with the same slash command. Remove or rename the duplicate from [api.slack.com/apps](https://api.slack.com/apps). ## Configuration @@ -222,17 +222,17 @@ Browser mode (`persistent` or `isolated`) is controlled by the `BROWSER_MODE` en
-birds - Scheduled task settings +automations - Scheduled task settings ```json -"birds": { +"automations": { "maxAttempts": 3 } ``` -- `maxAttempts`: Max job attempts before a bird stops retrying +- `maxAttempts`: Max job attempts before an automation stops retrying -Each bird supports per-bird active hours set via CLI `--active-hours 09:00-17:00` or the API. Wrap-around windows (e.g. `22:00-06:00`) are supported. +Each automation supports per-automation active hours set via CLI `--active-hours 09:00-17:00` or the API. Wrap-around windows (e.g. `22:00-06:00`) are supported.
@@ -245,7 +245,7 @@ Each bird supports per-bird active hours set via CLI `--active-hours 09:00-17:00 } ``` -- `retentionDays`: How long to keep messages, flight logs, jobs, and logs +- `retentionDays`: How long to keep messages, run logs, jobs, and logs @@ -283,7 +283,7 @@ Authentication is handled via the web UI. On first visit, you create an account. | `BROWSERBIRD_DB` | Path to SQLite database file. Overridden by `--db` flag | | `BROWSERBIRD_VAULT_KEY` | Vault encryption key (auto-generated on first start, stored in `.env`) | | `BROWSERBIRD_VERBOSE` | Set to `1` to enable debug logging. Same as `--verbose` flag | -| `BROWSERBIRD_BIRD_DATA` | Persistent data directory for the current bird. Set automatically per bird run | +| `BROWSERBIRD_AUTOMATION_DATA` | Persistent data directory for the current automation. Set automatically per automation run | | `BROWSERBIRD_TOKEN` | CLI auth token. Takes priority over saved credentials files | | `BROWSERBIRD_CREDENTIALS` | Path to a credentials JSON file. Used when `BROWSERBIRD_TOKEN` is unset | | `BROWSERBIRD_API_URL` | Daemon URL the CLI talks to (default `http://127.0.0.1:18800`). Overridden by `--url` flag | @@ -292,25 +292,25 @@ Authentication is handled via the web UI. On first visit, you create an account. > [!NOTE] > **Agent authentication:** `ANTHROPIC_API_KEY` (pay-per-token) is required for shared or commercial deployments per Anthropic's Consumer ToS. `CLAUDE_CODE_OAUTH_TOKEN` is fine for personal self-hosted use. When both are set, OAuth takes priority. This is also why BrowserBird uses the CLI rather than the [Agent SDK](https://docs.anthropic.com/en/docs/agent-sdk/overview); the SDK requires API key auth per Anthropic's [usage policy](https://docs.anthropic.com/en/docs/claude-code/legal-and-compliance). -### Docs +### Skills Store markdown documents in `.browserbird/docs/` that get injected into the agent's system prompt at spawn time. Use them for tone guides, project context, channel-specific instructions, or any reusable prompt content. -- **File-backed.** Each doc is a `.md` file you can edit with any text editor. Drop a file in the directory and it gets auto-discovered. -- **Scoped with bindings.** Bind a doc to specific channels or birds via the web UI or CLI. Bind to `channel:*` to apply everywhere. Unbound docs are not injected (same semantics as vault keys). -- **Managed from the web UI or CLI.** Create, edit, and manage bindings from the Docs page, or use `browserbird docs` from the terminal. +- **File-backed.** Each skill is a `.md` file you can edit with any text editor. Drop a file in the directory and it gets auto-discovered. +- **Scoped with bindings.** Bind a skill to specific channels or automations via the web UI or CLI. Bind to `channel:*` to apply everywhere. Unbound skills are not injected (same semantics as vault keys). +- **Managed from the web UI or CLI.** Create, edit, and manage bindings from the Skills page, or use `browserbird skills` from the terminal. ### Vault Keys -Store API keys and secrets in the web UI (Settings, Keys tab) and bind them to specific channels or birds. At spawn time, bound keys are injected as environment variables into the agent subprocess. +Store API keys and secrets in the web UI (Settings, Keys tab) and bind them to specific channels or automations. At spawn time, bound keys are injected as environment variables into the agent subprocess. - **Encrypted at rest** with AES-256-GCM. The encryption key is auto-generated on first start and stored in `.env` as `BROWSERBIRD_VAULT_KEY`. - **Redacted from output.** If the agent prints a vault key value, it appears as `[redacted]` in Slack and logs. -- **Bound to targets.** A key bound to channel `*` applies to all channels. A key bound to a specific bird applies only when that bird runs. Bird-level keys override channel-level keys on name conflict. +- **Bound to targets.** A key bound to channel `*` applies to all channels. A key bound to a specific automation applies only when that automation runs. Automation-level keys override channel-level keys on name conflict. -**Example: GitHub integration.** Store a GitHub personal access token as `GITHUB_TOKEN` in the vault and bind it to a channel or bird. The agent can then create issues, open PRs, push code, review changes, and manage repositories using the GitHub API or CLI. +**Example: GitHub integration.** Store a GitHub personal access token as `GITHUB_TOKEN` in the vault and bind it to a channel or automation. The agent can then create issues, open PRs, push code, review changes, and manage repositories using the GitHub API or CLI. -**Example: authenticated browsing.** For birds that need to browse logged-in sites (X, LinkedIn, etc.), export cookies from your local browser using [Cookie-Editor](https://github.com/moustachauve/cookie-editor), store the JSON as a vault key (e.g. `X_COOKIES`), and bind it to the bird. In the bird's prompt, instruct it to read the cookies from the env var and inject them via `addCookies` before browsing. Pre-injecting cookies ensures the agent starts in a logged-in state, making scheduled tasks more reliable. +**Example: authenticated browsing.** For automations that need to browse logged-in sites (X, LinkedIn, etc.), export cookies from your local browser using [Cookie-Editor](https://github.com/moustachauve/cookie-editor), store the JSON as a vault key (e.g. `X_COOKIES`), and bind it to the automation. In the automation's prompt, instruct it to read the cookies from the env var and inject them via `addCookies` before browsing. Pre-injecting cookies ensures the agent starts in a logged-in state, making scheduled tasks more reliable. ## CLI @@ -331,8 +331,8 @@ usage: browserbird [command] [options] commands: sessions manage sessions and chat - birds manage scheduled birds - docs manage system prompt documents + automations manage scheduled automations + skills manage skills keys manage vault keys backups manage database backups config view configuration @@ -357,13 +357,13 @@ run 'browserbird --help' for command-specific options. ### Standalone CLI Workflow -BrowserBird works without Slack. Authenticate once, create a bird, trigger it, and check results from the terminal: +BrowserBird works without Slack. Authenticate once, create an automation, trigger it, and check results from the terminal: ```bash browserbird login -browserbird birds add --name "hn-digest" --schedule "0 9 * * *" --prompt "Check Hacker News for AI news and summarize" -browserbird birds fly -browserbird birds flights +browserbird automations add --name "hn-digest" --schedule "0 9 * * *" --prompt "Check Hacker News for AI news and summarize" +browserbird automations run +browserbird automations runs ``` The CLI talks to the daemon over HTTP and authenticates with a JWT. `browserbird login` prompts for your dashboard email and password and saves the token to `~/.config/browserbird/credentials.json` (use `--system` to write to `/.credentials.json` instead, for container deployments). To skip the file entirely, set `BROWSERBIRD_TOKEN`. Point at a remote daemon with `--url` or `BROWSERBIRD_API_URL`. `browserbird logout` clears the saved token; `browserbird whoami` shows the current principal. @@ -387,12 +387,12 @@ Runs at `http://localhost:18800` by default. | Page | Description | | ------------ | ---------------------------------------------------------------------------------- | -| **Mission Control** | System overview, failing birds, upcoming runs, active sessions | +| **Mission Control** | System overview, failing automations, upcoming runs, active sessions | | **Sessions** | Chat with the agent directly, view message history, token usage, and session detail | -| **Docs** | Markdown documents for agent system prompts, with channel/bird bindings | -| **Birds** | Scheduled birds: create, edit, enable/disable, trigger, inline flight history | +| **Skills** | Markdown skills (agent instructions), with channel/automation bindings | +| **Automations** | Scheduled automations: create, edit, enable/disable, trigger, inline run history | | **Computer** | Live noVNC viewer (Docker only) | -| **Settings** | Config editor, agent management, secrets, vault keys, system birds, job queue, and log viewer | +| **Settings** | Config editor, agent management, secrets, vault keys, system automations, job queue, and log viewer | ## Development diff --git a/browserbird.example.json b/browserbird.example.json index 9878412..4a71e6f 100644 --- a/browserbird.example.json +++ b/browserbird.example.json @@ -44,7 +44,7 @@ "novncPort": 6080, "novncHost": "localhost" }, - "birds": { + "automations": { "maxAttempts": 3, "maxConsecutiveFailures": 2 }, diff --git a/manifest.json b/manifest.json index c297d16..51ea8b3 100644 --- a/manifest.json +++ b/manifest.json @@ -19,9 +19,9 @@ }, "slash_commands": [ { - "command": "/bird", - "description": "Manage BrowserBird birds", - "usage_hint": "list | fly | stop | logs | enable | disable | create | status", + "command": "/automation", + "description": "Manage BrowserBird automations", + "usage_hint": "list | run | stop | logs | enable | disable | create | status", "should_escape": false } ] diff --git a/oci/app/config/agent-context.md b/oci/app/config/agent-context.md index 0ad6a97..20e7a18 100644 --- a/oci/app/config/agent-context.md +++ b/oci/app/config/agent-context.md @@ -2,7 +2,7 @@ @SETUP.md -You are running inside BrowserBird, an AI agent orchestrator with a real browser, a cron scheduler, and a web dashboard. Messages may come from Slack users (streaming back to Slack threads in real time) or from scheduled bird tasks. If Slack is not configured, birds still run and results are stored in the dashboard. +You are running inside BrowserBird, an AI agent orchestrator with a real browser, a cron scheduler, and a web dashboard. Messages may come from Slack users (streaming back to Slack threads in real time) or from scheduled automation tasks. If Slack is not configured, automations still run and results are stored in the dashboard. ## Critical Warnings @@ -84,11 +84,11 @@ Check for cookie env vars at the start of any task that requires authenticated b ## Scheduling -When the user asks to schedule a task, create a recurring job, or set up automation, use BrowserBird's birds system via the CLI (`browserbird birds add`). Do not suggest or reference external scheduling tools. +When the user asks to schedule a task, create a recurring job, or set up automation, use BrowserBird's automations system via the CLI (`browserbird automations add`). Do not suggest or reference external scheduling tools. -## Bird Data +## Automation Data -If this session is a scheduled bird run, the environment variable `BROWSERBIRD_BIRD_DATA` points to a persistent directory for this bird. Files written here survive between runs. Use it to store previous results, state, or any data the next run should reference. +If this session is a scheduled automation run, the environment variable `BROWSERBIRD_AUTOMATION_DATA` points to a persistent directory for this automation. Files written here survive between runs. Use it to store previous results, state, or any data the next run should reference. ## Tools @@ -99,12 +99,12 @@ The following npm tools can be used via `npx`. Run with `--help` to see usage. ## CLI -BrowserBird has a CLI at `./bin/browserbird`. Use it when the user asks to manage birds, inspect sessions, check logs, or interact with the system. +BrowserBird has a CLI at `./bin/browserbird`. Use it when the user asks to manage automations, inspect sessions, check logs, or interact with the system. ### CLI operating rules - Do NOT redirect stderr from `browserbird` CLI invocations. No `2>/dev/null`, no `2>&1 >/dev/null`, no suppression of any kind. The CLI surfaces validation errors, auth failures, and daemon-unreachable conditions on stderr. Silencing it has turned real failures into silent no-ops. -- Do NOT trust memory files for a bird's current schedule, prompt, channel, or enabled state. Read the live record with `./bin/browserbird birds list --json` and parse the entry for the uid you care about. `birds list` (without `--json`) truncates the prompt to 50 characters, so memory snapshots of long prompts may be stale or partial. +- Do NOT trust memory files for an automation's current schedule, prompt, channel, or enabled state. Read the live record with `./bin/browserbird automations list --json` and parse the entry for the uid you care about. `automations list` (without `--json`) truncates the prompt to 50 characters, so memory snapshots of long prompts may be stale or partial. - `browserbird reset-password` recovers a lost dashboard password (writes to the DB directly; prints a new generated password and signs out the user's sessions). Run it only when the user explicitly asks, and treat the printed password as a secret. @cli-reference.md diff --git a/oci/app/config/cli-reference.md b/oci/app/config/cli-reference.md index 51bbbba..040f3cd 100644 --- a/oci/app/config/cli-reference.md +++ b/oci/app/config/cli-reference.md @@ -2,42 +2,41 @@ CLI location: `./bin/browserbird` -## Birds (scheduled tasks) +## Automations (scheduled tasks) ``` -browserbird birds list List all birds -browserbird birds add [options] Create a bird (--name, --schedule, --prompt, --channel, --agent) -browserbird birds edit [options] Edit a bird (--name, --schedule, --prompt, --channel, --agent) -browserbird birds remove Delete a bird -browserbird birds enable Enable a bird -browserbird birds disable Disable a bird -browserbird birds fly Trigger a bird immediately -browserbird birds flights Show flight history +browserbird automations list List all automations +browserbird automations add Create an automation (or use --schedule/--prompt) +browserbird automations edit [options] Edit an automation (--name, --schedule, --prompt, --channel, --agent) +browserbird automations remove Delete an automation +browserbird automations enable Enable an automation +browserbird automations disable Disable an automation +browserbird automations run [args] Trigger an automation immediately ($ARGUMENTS substituted) +browserbird automations runs Show run history ``` -Options for add/edit: `--channel `, `--agent `, `--active-hours 09:00-17:00` +Options for add/edit: `--channel `, `--agent `, `--background` (no channel), `--active-hours 09:00-17:00` -All bird schedules use the global `timezone` from the config file (default: UTC). +All automation schedules use the global `timezone` from the config file (default: UTC). Schedule format: standard 5-field cron (`0 9 * * 1-5`) or macros (`@daily`, `@hourly`, `@weekly`, `@monthly`). -`birds edit --prompt ` replaces the entire prompt; there is no patch primitive. Read the current prompt with `birds list --json` before editing so you do not drop unrelated instructions. The plain `birds list` view truncates the prompt column to 50 characters and is not safe to use as the source of truth. +`automations edit --prompt ` replaces the entire prompt; there is no patch primitive. Read the current prompt with `automations list --json` before editing so you do not drop unrelated instructions. The plain `automations list` view truncates the prompt column to 50 characters and is not safe to use as the source of truth. -For long prompts, use `birds edit --prompt-file ` (also available on `birds add`) to read the prompt from a file. Avoids shell-quoting issues with multi-line content or special characters. +For long prompts, use `automations edit --prompt-file ` (also available on `automations add`) to read the prompt from a file. Avoids shell-quoting issues with multi-line content or special characters. -## Docs (system prompt documents) +## Skills (system prompt documents) ``` -browserbird docs list List all docs -browserbird docs add Create a new doc -browserbird docs remove <uid|title> Remove a doc and its file -browserbird docs bind <uid|title> channel <id> Bind a doc to a channel -browserbird docs bind <uid|title> bird <uid> Bind a doc to a bird -browserbird docs unbind <uid|title> channel <id> Unbind a doc from a target -browserbird docs sync Scan for new or removed files +browserbird skills list List all skills +browserbird skills add <title> Create a new skill +browserbird skills remove <uid|title> Remove a skill and its file +browserbird skills bind <uid|title> <channel> Bind a skill to a channel +browserbird skills unbind <uid|title> <channel> Unbind a skill from a channel +browserbird skills sync Scan for new or removed files ``` -Docs are stored as `.md` files in `.browserbird/docs/`. Edit them directly with any text editor. Docs with no bindings are not injected into agent sessions. Use `docs bind` with `*` as the channel ID to make a doc apply to all channels. +Skills are stored as `.md` files in `.browserbird/docs/`. Edit them directly with any text editor. Skills with no bindings are not injected into agent sessions. Use `skills bind` with `*` as the channel ID to make a skill apply to all channels. ## Keys (vault secrets) @@ -46,9 +45,8 @@ browserbird keys list List all vault keys browserbird keys add <name> Add a key (prompts for value) browserbird keys edit <name> Update a key's value browserbird keys remove <name> Remove a key -browserbird keys bind <name> channel <id> Bind a key to a channel -browserbird keys bind <name> bird <uid> Bind a key to a bird -browserbird keys unbind <name> channel <id> Unbind a key from a target +browserbird keys bind <name> <channel> Bind a key to a channel (use * for all) +browserbird keys unbind <name> <channel> Unbind a key from a channel ``` Options for add/edit: `--value <secret>`, `--description <text>` @@ -66,7 +64,7 @@ browserbird backups restore <name> Restore from a backup (requires Options for create: `--name <name>` (custom filename) -Automatic daily backups run at 2:00 AM via system bird. Configure retention and auto-backup in `database.backups` config. Backups are stored in `.browserbird/backups/`. +Automatic daily backups run at 2:00 AM via system automation. Configure retention and auto-backup in `database.backups` config. Backups are stored in `.browserbird/backups/`. ## Sessions diff --git a/package.json b/package.json index 07468f8..f34977b 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "lint:fix": "eslint src/ --fix", "format": "prettier --write src/", "format:check": "prettier --check src/", - "test": "node --test src/**/*.test.ts", + "test": "node --test \"src/**/*.test.ts\"", "build": "tsdown", "dev": "tsdown && ./bin/browserbird", "dev:web": "cd web && npm run dev", diff --git a/src/channel/blocks.ts b/src/channel/blocks.ts index a792f0b..3b8ece6 100644 --- a/src/channel/blocks.ts +++ b/src/channel/blocks.ts @@ -277,7 +277,7 @@ export function sessionErrorBlocks( blocks.push(sectionBlock); const fieldPairs: [string, string][] = []; - if (opts?.birdName) fieldPairs.push(['Bird', opts.birdName]); + if (opts?.birdName) fieldPairs.push(['Automation', opts.birdName]); if (opts?.durationMs) fieldPairs.push(['Duration', formatDuration(opts.durationMs)]); if (fieldPairs.length > 0) blocks.push(fields(...fieldPairs)); @@ -340,7 +340,7 @@ export function birdCreateModal(defaults?: { return { type: 'modal', callback_id: 'bird_create', - title: plain('Create Bird'), + title: plain('Create Automation'), submit: plain('Create'), close: plain('Cancel'), blocks: [ @@ -375,7 +375,7 @@ export function birdCreateModal(defaults?: { type: 'plain_text_input', action_id: 'prompt_input', multiline: true, - placeholder: plain('What should this bird do?'), + placeholder: plain('What should this automation do?'), ...(defaults?.prompt ? { initial_value: defaults.prompt } : {}), }, }, @@ -419,12 +419,12 @@ export function birdListBlocks( ): Block[] { if (birds.length === 0) { return [ - section('*No birds configured*'), - context('Use `/bird create` to create your first bird.'), + section('*No automations configured*'), + context('Use `/automation create` to create your first automation.'), ]; } - const blocks: Block[] = [header('Active Birds')]; + const blocks: Block[] = [header('Active Automations')]; for (const bird of birds) { const status = bird.enabled ? '[on]' : '[off]'; @@ -436,7 +436,7 @@ export function birdListBlocks( ); } - blocks.push(context(`${birds.length} bird${birds.length === 1 ? '' : 's'} total`)); + blocks.push(context(`${birds.length} automation${birds.length === 1 ? '' : 's'} total`)); return blocks; } @@ -453,12 +453,12 @@ export function birdLogsBlocks( ): Block[] { if (flights.length === 0) { return [ - section(`*${birdName}* - No flights yet`), - context('Trigger with `/bird fly ${birdName}`'), + section(`*${birdName}* - No runs yet`), + context('Trigger with `/automation run ${birdName}`'), ]; } - const blocks: Block[] = [header(`Flights: ${birdName}`)]; + const blocks: Block[] = [header(`Runs: ${birdName}`)]; const lines = flights.map((f) => { const icon = f.status === 'success' ? '[ok]' : f.status === 'running' ? '[...]' : '[err]'; @@ -469,7 +469,7 @@ export function birdLogsBlocks( }); blocks.push(section(lines.join('\n'))); - blocks.push(context(`${flights.length} most recent flight${flights.length === 1 ? '' : 's'}`)); + blocks.push(context(`${flights.length} most recent run${flights.length === 1 ? '' : 's'}`)); return blocks; } @@ -486,7 +486,7 @@ function formatAge(isoDate: string): string { } export function birdFlyBlocks(birdName: string, userId: string): Block[] { - return [section(`*${birdName}* is taking flight...`), context(`Triggered by <@${userId}>`)]; + return [section(`*${birdName}* is running...`), context(`Triggered by <@${userId}>`)]; } export function statusBlocks(opts: { @@ -504,13 +504,13 @@ export function statusBlocks(opts: { fields( ['Slack', slackStatus], ['Active Sessions', `${opts.activeCount}/${opts.maxConcurrent}`], - ['Birds', String(opts.birdCount)], + ['Automations', String(opts.birdCount)], ['Uptime', opts.uptime], ), ]; if (opts.runningBirds && opts.runningBirds.length > 0) { - result.push(section(`*In flight:* ${opts.runningBirds.join(', ')}`)); + result.push(section(`*Running:* ${opts.runningBirds.join(', ')}`)); } return result; @@ -527,11 +527,11 @@ export function homeTabView(opts: { header(opts.agentName), section(opts.description), divider(), - header('Scheduled Birds'), + header('Scheduled Automations'), ]; if (opts.birds.length === 0) { - blocks.push(section('No birds configured. Use `/bird create` to get started.')); + blocks.push(section('No automations configured. Use `/automation create` to get started.')); } else { const lines = opts.birds.map((b) => { const status = b.enabled ? 'on' : 'off'; @@ -545,10 +545,10 @@ export function homeTabView(opts: { header('Quick Commands'), section( [ - '`/bird list` -- show all birds', - '`/bird create` -- create a new bird', - '`/bird fly <name>` -- trigger a bird now', - '`/bird status` -- check system status', + '`/automation list` -- show all automations', + '`/automation create` -- create a new automation', + '`/automation run <name>` -- trigger an automation now', + '`/automation status` -- check system status', ].join('\n'), ), divider(), diff --git a/src/channel/commands.ts b/src/channel/commands.ts index 5015d9d..0bdf5fd 100644 --- a/src/channel/commands.ts +++ b/src/channel/commands.ts @@ -1,4 +1,4 @@ -/** @fileoverview Slash command handler for `/bird` commands in Slack. */ +/** @fileoverview Slash command handler for `/automation` commands in Slack. */ import type { WebClient } from '@slack/web-api'; import type { Config } from '../core/types.ts'; @@ -78,16 +78,16 @@ export async function handleSlashCommand( })); const blocks = birdListBlocks(birds); await say({ - text: `${birds.length} bird${birds.length === 1 ? '' : 's'} configured`, + text: `${birds.length} automation${birds.length === 1 ? '' : 's'} configured`, blocks, }); break; } - case 'fly': { + case 'run': { const rest = parts.slice(1); if (rest.length === 0) { - await say({ text: 'Usage: `/bird fly <name or id> [arguments]`' }); + await say({ text: 'Usage: `/automation run <name or id> [arguments]`' }); return; } @@ -98,7 +98,7 @@ export async function handleSlashCommand( flyArgs = ''; } if (!bird) { - await say({ text: `Bird not found: \`${rest[0]}\`` }); + await say({ text: `Automation not found: \`${rest[0]}\`` }); return; } @@ -113,13 +113,13 @@ export async function handleSlashCommand( channelId: bird.target_channel_id, agentId: bird.agent_id, }, - { maxAttempts: config.birds.maxAttempts, timeout: 600, cronJobUid: bird.uid }, + { maxAttempts: config.automations.maxAttempts, timeout: 600, cronJobUid: bird.uid }, ); const blocks = birdFlyBlocks(bird.name, body.user_id); - await say({ text: `${bird.name} is taking flight...`, blocks }); + await say({ text: `${bird.name} is running...`, blocks }); logger.info( - `/bird fly: ${bird.name} triggered by ${body.user_id}${flyArgs ? ` (args: ${flyArgs})` : ''}`, + `/automation run: ${bird.name} triggered by ${body.user_id}${flyArgs ? ` (args: ${flyArgs})` : ''}`, ); break; } @@ -128,13 +128,13 @@ export async function handleSlashCommand( case 'disable': { const birdName = parts.slice(1).join(' '); if (!birdName) { - await say({ text: `Usage: \`/bird ${subcommand} <name or id>\`` }); + await say({ text: `Usage: \`/automation ${subcommand} <name or id>\`` }); return; } const bird = findBird(birdName); if (!bird) { - await say({ text: `Bird not found: \`${birdName}\`` }); + await say({ text: `Automation not found: \`${birdName}\`` }); return; } @@ -147,20 +147,20 @@ export async function handleSlashCommand( db.setCronJobEnabled(bird.uid, enabling); await say({ text: `*${bird.name}* ${subcommand}d.` }); - logger.info(`/bird ${subcommand}: ${bird.name} by ${body.user_id}`); + logger.info(`/automation ${subcommand}: ${bird.name} by ${body.user_id}`); break; } case 'logs': { const birdName = parts.slice(1).join(' '); if (!birdName) { - await say({ text: 'Usage: `/bird logs <name or id>`' }); + await say({ text: 'Usage: `/automation logs <name or id>`' }); return; } const bird = findBird(birdName); if (!bird) { - await say({ text: `Bird not found: \`${birdName}\`` }); + await say({ text: `Automation not found: \`${birdName}\`` }); return; } @@ -180,7 +180,7 @@ export async function handleSlashCommand( }); const blocks = birdLogsBlocks(bird.name, mapped); - const text = `${flights.totalItems} flight${flights.totalItems === 1 ? '' : 's'} for ${bird.name}`; + const text = `${flights.totalItems} run${flights.totalItems === 1 ? '' : 's'} for ${bird.name}`; await say({ text, blocks }); break; } @@ -218,22 +218,22 @@ export async function handleSlashCommand( case 'stop': { const birdName = parts.slice(1).join(' '); if (!birdName) { - await say({ text: 'Usage: `/bird stop <name or id>`' }); + await say({ text: 'Usage: `/automation stop <name or id>`' }); return; } const bird = findBird(birdName); if (!bird) { - await say({ text: `Bird not found: \`${birdName}\`` }); + await say({ text: `Automation not found: \`${birdName}\`` }); return; } const killed = killBird(bird.uid); if (killed) { await say({ text: `Stopped *${bird.name}*.` }); - logger.info(`/bird stop: ${bird.name} killed by ${body.user_id}`); + logger.info(`/automation stop: ${bird.name} killed by ${body.user_id}`); } else { - await say({ text: `*${bird.name}* is not currently in flight.` }); + await say({ text: `*${bird.name}* is not currently running.` }); } break; } @@ -241,16 +241,16 @@ export async function handleSlashCommand( default: await say({ text: [ - '*Usage:* `/bird <command>`', + '*Usage:* `/automation <command>`', '', - '`/bird list` - Show all configured birds', - '`/bird fly <name>` - Trigger a bird immediately', - '`/bird stop [name]` - Stop a running bird', - '`/bird logs <name>` - Show recent flights', - '`/bird enable <name>` - Enable a bird', - '`/bird disable <name>` - Disable a bird', - '`/bird create` - Create a new bird (opens form)', - '`/bird status` - Show daemon status', + '`/automation list` - Show all configured automations', + '`/automation run <name>` - Trigger an automation immediately', + '`/automation stop [name]` - Stop a running automation', + '`/automation logs <name>` - Show recent runs', + '`/automation enable <name>` - Enable an automation', + '`/automation disable <name>` - Disable an automation', + '`/automation create` - Create an automation (opens form)', + '`/automation status` - Show daemon status', ].join('\n'), }); } diff --git a/src/channel/slack.ts b/src/channel/slack.ts index 21e6892..c30e06e 100644 --- a/src/channel/slack.ts +++ b/src/channel/slack.ts @@ -23,6 +23,7 @@ import { logger } from '../core/logger.ts'; import { isWithinTimeRange } from '../core/utils.ts'; import { matchAgent } from '../provider/session.ts'; import { insertFeedback } from '../db/feedback.ts'; +import { resolveAgentForChannel } from '../project.ts'; import { createCronJob, setCronJobEnabled, @@ -393,12 +394,14 @@ export function createSlackChannel(getConfig: () => Config, signal: AbortSignal) socketClient.on('slash_commands', async ({ ack, body }: SocketModeEvent) => { await ack(); const commandBody = body as unknown as SlashCommandBody; - if (commandBody.command !== '/bird') return; + if (commandBody.command !== '/automation') return; try { await handleSlashCommand(commandBody, webClient, channelClient, getConfig(), statusProvider); } catch (err) { - logger.error(`/bird command error: ${err instanceof Error ? err.message : String(err)}`); + logger.error( + `/automation command error: ${err instanceof Error ? err.message : String(err)}`, + ); } }); @@ -409,7 +412,7 @@ export function createSlackChannel(getConfig: () => Config, signal: AbortSignal) if (interactionType === 'view_submission') { const view = body['view'] as Record<string, unknown> | undefined; if (view?.['callback_id'] === 'bird_create') { - await handleBirdCreateSubmission(view, webClient); + await handleBirdCreateSubmission(view, webClient, getConfig()); } } @@ -593,6 +596,7 @@ export function createSlackChannel(getConfig: () => Config, signal: AbortSignal) async function handleBirdCreateSubmission( view: Record<string, unknown>, webClient: WebClient, + config: Config, ): Promise<void> { try { const values = view['state'] as Record<string, unknown> | undefined; @@ -624,14 +628,15 @@ async function handleBirdCreateSubmission( return; } - const bird = createCronJob(name, schedule, prompt, channelId || undefined, 'default'); + const agentId = resolveAgentForChannel(config, channelId || null)?.id || undefined; + const bird = createCronJob(name, schedule, prompt, channelId || undefined, agentId); if (enabledValue !== 'enabled') { setCronJobEnabled(bird.uid, false); } await webClient.chat.postMessage({ channel: channelId || 'general', - text: `Bird *${name}* created. Schedule: \`${schedule}\``, + text: `Automation *${name}* created. Schedule: \`${schedule}\``, }); logger.info(`bird created via modal: ${name}`); diff --git a/src/cli/birds.ts b/src/cli/automations.ts similarity index 73% rename from src/cli/birds.ts rename to src/cli/automations.ts index 09080a0..bc07f92 100644 --- a/src/cli/birds.ts +++ b/src/cli/automations.ts @@ -1,4 +1,4 @@ -/** @fileoverview Birds command: manage scheduled birds (cron jobs). */ +/** @fileoverview Automations command: manage scheduled automations (cron jobs). */ import { parseArgs } from 'node:util'; import { readFileSync } from 'node:fs'; @@ -14,32 +14,33 @@ import { DaemonUnreachableError, } from './daemon-rpc.ts'; -export const BIRDS_HELP = ` -${c('cyan', 'usage:')} browserbird birds <subcommand> [options] +export const AUTOMATIONS_HELP = ` +${c('cyan', 'usage:')} browserbird automations <subcommand> [options] -manage scheduled birds. +manage scheduled automations. ${c('dim', 'subcommands:')} - ${c('cyan', 'list')} list all birds - ${c('cyan', 'add')} <schedule> <prompt> add a new bird - ${c('cyan', 'edit')} <uid> edit a bird - ${c('cyan', 'remove')} <uid> remove a bird - ${c('cyan', 'enable')} <uid> enable a bird - ${c('cyan', 'disable')} <uid> disable a bird - ${c('cyan', 'fly')} <uid> [args] trigger a bird ($ARGUMENTS substituted) - ${c('cyan', 'flights')} <uid> show flight history for a bird + ${c('cyan', 'list')} list all automations + ${c('cyan', 'add')} <schedule> <prompt> add a new automation + ${c('cyan', 'edit')} <uid> edit an automation + ${c('cyan', 'remove')} <uid> remove an automation + ${c('cyan', 'enable')} <uid> enable an automation + ${c('cyan', 'disable')} <uid> disable an automation + ${c('cyan', 'run')} <uid> [args] trigger an automation ($ARGUMENTS substituted) + ${c('cyan', 'runs')} <uid> show run history for an automation ${c('dim', 'options:')} ${c('yellow', '--channel')} <id> target slack channel + ${c('yellow', '--background')} run with no channel (posts nowhere) ${c('yellow', '--agent')} <id> target agent id ${c('yellow', '--schedule')} <expr> cron schedule expression ${c('yellow', '--prompt')} <text> prompt text ${c('yellow', '--prompt-file')} <path> read prompt from a file (alternative to --prompt) ${c('yellow', '--active-hours')} <range> restrict runs to a time window (e.g. "09:00-17:00") - ${c('yellow', '--limit')} <n> number of flights to show (default: 10) - ${c('yellow', '--json')} output as JSON (with list, flights) + ${c('yellow', '--limit')} <n> number of runs to show (default: 10) + ${c('yellow', '--json')} output as JSON (with list, runs) ${c('yellow', '-h, --help')} show this help `.trim(); @@ -91,7 +92,7 @@ async function runDaemonCall<T>(fn: () => Promise<T>): Promise<T | undefined> { } } -export async function handleBirds(argv: string[]): Promise<void> { +export async function handleAutomations(argv: string[]): Promise<void> { const subcommand = argv[0] ?? 'list'; const rest = argv.slice(1); @@ -100,6 +101,7 @@ export async function handleBirds(argv: string[]): Promise<void> { options: { name: { type: 'string' }, channel: { type: 'string' }, + background: { type: 'boolean' }, agent: { type: 'string' }, schedule: { type: 'string' }, prompt: { type: 'string' }, @@ -117,7 +119,7 @@ export async function handleBirds(argv: string[]): Promise<void> { const result = await runDaemonCall<PaginatedResult<CronJobRow>>(() => daemonRequest<PaginatedResult<CronJobRow>>({ method: 'GET', - path: '/api/birds?perPage=100', + path: '/api/automations?perPage=100', }), ); if (!result) return; @@ -125,9 +127,9 @@ export async function handleBirds(argv: string[]): Promise<void> { console.log(JSON.stringify(result.items, null, 2)); return; } - console.log(`birds (${result.totalItems} total):`); + console.log(`automations (${result.totalItems} total):`); if (result.items.length === 0) { - console.log('\n no birds configured'); + console.log('\n no automations configured'); return; } console.log(''); @@ -173,7 +175,20 @@ export async function handleBirds(argv: string[]): Promise<void> { } if (!schedule || !prompt) { logger.error( - 'usage: browserbird birds add --schedule <expr> (--prompt <text> | --prompt-file <path>) [--name <name>] [--channel <id>] [--agent <id>]', + 'usage: browserbird automations add --schedule <expr> (--channel <id> | --background) (--prompt <text> | --prompt-file <path>) [--name <name>] [--agent <id>]', + ); + process.exitCode = 1; + return; + } + const background = values.background as boolean | undefined; + if (background && values.channel) { + logger.error('use --channel or --background, not both'); + process.exitCode = 1; + return; + } + if (!background && !values.channel) { + logger.error( + 'automations add requires --channel <id> or --background (a background automation runs with no channel and posts nowhere)', ); process.exitCode = 1; return; @@ -195,12 +210,12 @@ export async function handleBirds(argv: string[]): Promise<void> { const job = await runDaemonCall<CronJobRow>(() => daemonRequest<CronJobRow>({ method: 'POST', - path: '/api/birds', + path: '/api/automations', body: { name: birdName, schedule, prompt, - channel: values.channel as string | undefined, + channel: background ? undefined : (values.channel as string | undefined), agent: values.agent as string | undefined, activeHoursStart: activeStart, activeHoursEnd: activeEnd, @@ -208,10 +223,12 @@ export async function handleBirds(argv: string[]): Promise<void> { }), ); if (!job) return; - logger.success(`bird ${shortUid(job.uid)} created: "${birdName}"`); + logger.success(`automation ${shortUid(job.uid)} created: "${birdName}"`); process.stderr.write( - c('dim', ` hint: run 'browserbird birds fly ${shortUid(job.uid)}' to trigger it now`) + - '\n', + c( + 'dim', + ` hint: run 'browserbird automations run ${shortUid(job.uid)}' to trigger it now`, + ) + '\n', ); return; } @@ -220,7 +237,7 @@ export async function handleBirds(argv: string[]): Promise<void> { const uidPrefix = positionals[0]; if (!uidPrefix) { logger.error( - 'usage: browserbird birds edit <uid> [--name <name>] [--schedule <expr>] [--prompt <text> | --prompt-file <path>] [--channel <id>] [--agent <id>] [--active-hours <range>]', + 'usage: browserbird automations edit <uid> [--name <name>] [--schedule <expr>] [--prompt <text> | --prompt-file <path>] [--channel <id>] [--agent <id>] [--active-hours <range>]', ); process.exitCode = 1; return; @@ -276,7 +293,7 @@ export async function handleBirds(argv: string[]): Promise<void> { const updated = await runDaemonCall<CronJobRow>(() => daemonRequest<CronJobRow>({ method: 'PATCH', - path: `/api/birds/${uidPrefix}`, + path: `/api/automations/${uidPrefix}`, body: { schedule, prompt, @@ -289,9 +306,9 @@ export async function handleBirds(argv: string[]): Promise<void> { }), ); if (!updated) return; - logger.success(`bird ${shortUid(updated.uid)} updated`); + logger.success(`automation ${shortUid(updated.uid)} updated`); process.stderr.write( - c('dim', ` hint: run 'browserbird birds list' to see updated birds`) + '\n', + c('dim', ` hint: run 'browserbird automations list' to see updated automations`) + '\n', ); return; } @@ -299,18 +316,18 @@ export async function handleBirds(argv: string[]): Promise<void> { case 'remove': { const uidPrefix = positionals[0]; if (!uidPrefix) { - logger.error('usage: browserbird birds remove <uid>'); + logger.error('usage: browserbird automations remove <uid>'); process.exitCode = 1; return; } const removed = await runDaemonCall(() => daemonRequest<{ success?: boolean }>({ method: 'DELETE', - path: `/api/birds/${uidPrefix}`, + path: `/api/automations/${uidPrefix}`, }), ); if (removed === undefined) return; - logger.success(`bird ${uidPrefix} removed`); + logger.success(`automation ${uidPrefix} removed`); return; } @@ -318,7 +335,7 @@ export async function handleBirds(argv: string[]): Promise<void> { case 'disable': { const uidPrefix = positionals[0]; if (!uidPrefix) { - logger.error(`usage: browserbird birds ${subcommand} <uid>`); + logger.error(`usage: browserbird automations ${subcommand} <uid>`); process.exitCode = 1; return; } @@ -326,23 +343,24 @@ export async function handleBirds(argv: string[]): Promise<void> { const result = await runDaemonCall(() => daemonRequest<{ success?: boolean }>({ method: 'PATCH', - path: `/api/birds/${uidPrefix}/${enabled ? 'enable' : 'disable'}`, + path: `/api/automations/${uidPrefix}/${enabled ? 'enable' : 'disable'}`, }), ); if (result === undefined) return; - logger.success(`bird ${uidPrefix} ${enabled ? 'enabled' : 'disabled'}`); + logger.success(`automation ${uidPrefix} ${enabled ? 'enabled' : 'disabled'}`); if (enabled) { process.stderr.write( - c('dim', ` hint: run 'browserbird birds fly ${uidPrefix}' to trigger it now`) + '\n', + c('dim', ` hint: run 'browserbird automations run ${uidPrefix}' to trigger it now`) + + '\n', ); } return; } - case 'fly': { + case 'run': { const uidPrefix = positionals[0]; if (!uidPrefix) { - logger.error('usage: browserbird birds fly <uid> [arguments]'); + logger.error('usage: browserbird automations run <uid> [arguments]'); process.exitCode = 1; return; } @@ -350,23 +368,23 @@ export async function handleBirds(argv: string[]): Promise<void> { const result = await runDaemonCall<{ success?: boolean; jobId?: number }>(() => daemonRequest<{ success?: boolean; jobId?: number }>({ method: 'POST', - path: `/api/birds/${uidPrefix}/fly`, + path: `/api/automations/${uidPrefix}/run`, body: flyArgs ? { args: flyArgs } : {}, }), ); if (!result) return; const jobLabel = result.jobId !== undefined ? `job #${result.jobId}` : 'job'; - logger.success(`enqueued ${jobLabel} for bird ${uidPrefix}`); + logger.success(`enqueued ${jobLabel} for automation ${uidPrefix}`); process.stderr.write( - c('dim', ` hint: run 'browserbird birds flights ${uidPrefix}' to check results`) + '\n', + c('dim', ` hint: run 'browserbird automations runs ${uidPrefix}' to check results`) + '\n', ); return; } - case 'flights': { + case 'runs': { const uidPrefix = positionals[0]; if (!uidPrefix) { - logger.error('usage: browserbird birds flights <uid>'); + logger.error('usage: browserbird automations runs <uid>'); process.exitCode = 1; return; } @@ -379,7 +397,7 @@ export async function handleBirds(argv: string[]): Promise<void> { const result = await runDaemonCall<PaginatedResult<FlightRow>>(() => daemonRequest<PaginatedResult<FlightRow>>({ method: 'GET', - path: `/api/birds/${uidPrefix}/flights?perPage=${perPage}`, + path: `/api/automations/${uidPrefix}/runs?perPage=${perPage}`, }), ); if (!result) return; @@ -387,9 +405,9 @@ export async function handleBirds(argv: string[]): Promise<void> { console.log(JSON.stringify(result.items, null, 2)); return; } - console.log(`flight history for bird ${uidPrefix} (${result.totalItems} total):`); + console.log(`run history for automation ${uidPrefix} (${result.totalItems} total):`); if (result.items.length === 0) { - console.log('\n no flights recorded'); + console.log('\n no runs recorded'); return; } console.log(''); @@ -406,7 +424,7 @@ export async function handleBirds(argv: string[]): Promise<void> { flight.error ?? flight.result?.slice(0, 60) ?? '', ]; }); - printTable(['flight', 'status', 'duration', 'started', 'error / result'], rows, [ + printTable(['run', 'status', 'duration', 'started', 'error / result'], rows, [ undefined, undefined, undefined, @@ -417,15 +435,15 @@ export async function handleBirds(argv: string[]): Promise<void> { } default: - unknownSubcommand(subcommand, 'birds', [ + unknownSubcommand(subcommand, 'automations', [ 'list', 'add', 'edit', 'remove', 'enable', 'disable', - 'fly', - 'flights', + 'run', + 'runs', ]); } } diff --git a/src/cli/config.ts b/src/cli/config.ts index 642a7a9..53d25b2 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -59,8 +59,8 @@ function printConfig(configPath?: string): void { ` ${c('dim', 'quiet hours:')} ${config.slack.quietHours.enabled ? `${config.slack.quietHours.start}-${config.slack.quietHours.end} (${config.slack.quietHours.timezone})` : 'disabled'}`, ); - console.log(`\n${c('cyan', 'birds:')}`); - console.log(` ${c('dim', 'max attempts:')} ${config.birds.maxAttempts}`); + console.log(`\n${c('cyan', 'automations:')}`); + console.log(` ${c('dim', 'max attempts:')} ${config.automations.maxAttempts}`); console.log(`\n${c('cyan', 'browser:')}`); console.log(` ${c('dim', 'enabled:')} ${config.browser.enabled ? 'yes' : 'no'}`); diff --git a/src/cli/keys.ts b/src/cli/keys.ts index b7b2bae..b0a8781 100644 --- a/src/cli/keys.ts +++ b/src/cli/keys.ts @@ -25,8 +25,8 @@ ${c('dim', 'subcommands:')} ${c('cyan', 'add')} <name> add a new key (prompts for value) ${c('cyan', 'edit')} <name> update a key's value or description ${c('cyan', 'remove')} <name> remove a key - ${c('cyan', 'bind')} <name> <type> <target> bind a key to a channel or bird - ${c('cyan', 'unbind')} <name> <type> <target> unbind a key from a target + ${c('cyan', 'bind')} <name> <channel> bind a key to a channel + ${c('cyan', 'unbind')} <name> <channel> unbind a key from a channel ${c('dim', 'options:')} @@ -43,9 +43,8 @@ ${c('dim', 'examples:')} browserbird keys add GITHUB_TOKEN --value ghp_abc123 browserbird keys add DB_NAME --value anova --no-redact browserbird keys edit DB_NAME --no-redact - browserbird keys bind GITHUB_TOKEN channel '*' - browserbird keys bind GITHUB_TOKEN bird br_abc1234 - browserbird keys unbind GITHUB_TOKEN channel '*' + browserbird keys bind GITHUB_TOKEN '*' + browserbird keys unbind GITHUB_TOKEN '*' browserbird keys remove GITHUB_TOKEN `.trim(); @@ -240,27 +239,21 @@ export async function handleKeys(argv: string[]): Promise<void> { case 'bind': { const nameOrUid = positionals[0]; - const targetType = positionals[1] as 'channel' | 'bird' | undefined; - const targetId = positionals[2]; - if (!nameOrUid || !targetType || !targetId) { + const targetId = positionals[1]; + if (!nameOrUid || !targetId) { logger.error( - "usage: browserbird keys bind <name> <channel|bird> <target>\n example: browserbird keys bind GITHUB_TOKEN channel '*'", + "usage: browserbird keys bind <name> <channel>\n example: browserbird keys bind GITHUB_TOKEN '*'", ); process.exitCode = 1; return; } - if (targetType !== 'channel' && targetType !== 'bird') { - logger.error('target type must be "channel" or "bird"'); - process.exitCode = 1; - return; - } const key = await resolveKey(nameOrUid); if (!key) return; - if (key.bindings.some((b) => b.targetType === targetType && b.targetId === targetId)) { - logger.warn(`key ${key.name} is already bound to ${targetType} ${targetId}`); + if (key.bindings.some((b) => b.targetType === 'channel' && b.targetId === targetId)) { + logger.warn(`key ${key.name} is already bound to channel ${targetId}`); return; } - const next: KeyBinding[] = [...key.bindings, { targetType, targetId }]; + const next: KeyBinding[] = [...key.bindings, { targetType: 'channel', targetId }]; const bindResult = await runDaemonCall(() => daemonRequest<{ success?: boolean }>({ method: 'PUT', @@ -269,31 +262,25 @@ export async function handleKeys(argv: string[]): Promise<void> { }), ); if (bindResult === undefined) return; - logger.success(`key ${key.name} bound to ${targetType} ${targetId}`); + logger.success(`key ${key.name} bound to channel ${targetId}`); return; } case 'unbind': { const nameOrUid = positionals[0]; - const targetType = positionals[1] as 'channel' | 'bird' | undefined; - const targetId = positionals[2]; - if (!nameOrUid || !targetType || !targetId) { - logger.error('usage: browserbird keys unbind <name> <channel|bird> <target>'); - process.exitCode = 1; - return; - } - if (targetType !== 'channel' && targetType !== 'bird') { - logger.error('target type must be "channel" or "bird"'); + const targetId = positionals[1]; + if (!nameOrUid || !targetId) { + logger.error('usage: browserbird keys unbind <name> <channel>'); process.exitCode = 1; return; } const key = await resolveKey(nameOrUid); if (!key) return; const filtered = key.bindings.filter( - (b) => !(b.targetType === targetType && b.targetId === targetId), + (b) => !(b.targetType === 'channel' && b.targetId === targetId), ); if (filtered.length === key.bindings.length) { - logger.warn(`key ${key.name} is not bound to ${targetType} ${targetId}`); + logger.warn(`key ${key.name} is not bound to channel ${targetId}`); return; } const unbindResult = await runDaemonCall(() => @@ -304,7 +291,7 @@ export async function handleKeys(argv: string[]): Promise<void> { }), ); if (unbindResult === undefined) return; - logger.success(`key ${key.name} unbound from ${targetType} ${targetId}`); + logger.success(`key ${key.name} unbound from channel ${targetId}`); return; } diff --git a/src/cli/run.ts b/src/cli/run.ts index bc0906b..24695d0 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -10,10 +10,10 @@ import { BANNER, VERSION } from './banner.ts'; import { c } from './style.ts'; import { handleSessions } from './sessions.ts'; import { SESSIONS_HELP } from './sessions.ts'; -import { handleBirds } from './birds.ts'; -import { BIRDS_HELP } from './birds.ts'; -import { handleDocs } from './docs.ts'; -import { DOCS_HELP } from './docs.ts'; +import { handleAutomations } from './automations.ts'; +import { AUTOMATIONS_HELP } from './automations.ts'; +import { handleSkills } from './skills.ts'; +import { SKILLS_HELP } from './skills.ts'; import { handleKeys } from './keys.ts'; import { KEYS_HELP } from './keys.ts'; import { handleLogs } from './logs.ts'; @@ -37,8 +37,8 @@ ${c('cyan', 'usage:')} browserbird [command] [options] ${c('dim', 'commands:')} ${c('cyan', 'sessions')} manage sessions and chat - ${c('cyan', 'birds')} manage scheduled birds - ${c('cyan', 'docs')} manage system prompt documents + ${c('cyan', 'automations')} manage scheduled automations + ${c('cyan', 'skills')} manage skills (agent instructions) ${c('cyan', 'keys')} manage vault keys ${c('cyan', 'config')} view configuration ${c('cyan', 'logs')} show recent log entries @@ -62,8 +62,8 @@ run 'browserbird <command> --help' for command-specific options.`.trimEnd(); const COMMAND_HELP: Record<string, string> = { sessions: SESSIONS_HELP, - birds: BIRDS_HELP, - docs: DOCS_HELP, + automations: AUTOMATIONS_HELP, + skills: SKILLS_HELP, keys: KEYS_HELP, config: CONFIG_HELP, logs: LOGS_HELP, @@ -112,17 +112,17 @@ export async function run(argv: string[]): Promise<void> { break; case COMMANDS.BIRDS: if (isHelp) { - console.log(COMMAND_HELP.birds); + console.log(COMMAND_HELP.automations); return; } - await handleBirds(rest); + await handleAutomations(rest); break; case COMMANDS.DOCS: if (isHelp) { - console.log(COMMAND_HELP.docs); + console.log(COMMAND_HELP.skills); return; } - await handleDocs(rest); + await handleSkills(rest); break; case COMMANDS.KEYS: if (isHelp) { @@ -193,8 +193,8 @@ export async function run(argv: string[]): Promise<void> { default: unknownSubcommand(command, '', [ 'sessions', - 'birds', - 'docs', + 'automations', + 'skills', 'keys', 'config', 'logs', diff --git a/src/cli/docs.ts b/src/cli/skills.ts similarity index 60% rename from src/cli/docs.ts rename to src/cli/skills.ts index d2b28a5..cfa0bb1 100644 --- a/src/cli/docs.ts +++ b/src/cli/skills.ts @@ -1,4 +1,4 @@ -/** @fileoverview Docs command: manage markdown documents for agent system prompts. */ +/** @fileoverview Skills command: manage markdown skills (agent instructions) for agent system prompts. */ import { parseArgs } from 'node:util'; import { basename } from 'node:path'; @@ -15,19 +15,19 @@ import { DaemonUnreachableError, } from './daemon-rpc.ts'; -export const DOCS_HELP = ` -${c('cyan', 'usage:')} browserbird docs <subcommand> [options] +export const SKILLS_HELP = ` +${c('cyan', 'usage:')} browserbird skills <subcommand> [options] -manage markdown documents injected into agent system prompts. +manage skills (markdown instructions) injected into agent system prompts. ${c('dim', 'subcommands:')} - ${c('cyan', 'list')} list all docs - ${c('cyan', 'add')} <title> create a new doc - ${c('cyan', 'remove')} <uid|title> remove a doc and its file - ${c('cyan', 'bind')} <uid|title> <type> <target> bind a doc to a channel or bird - ${c('cyan', 'unbind')} <uid|title> <type> <target> unbind a doc from a target - ${c('cyan', 'sync')} scan docs directory for new or removed files + ${c('cyan', 'list')} list all skills + ${c('cyan', 'add')} <title> create a new skill + ${c('cyan', 'remove')} <uid|title> remove a skill and its file + ${c('cyan', 'bind')} <uid|title> <channel> bind a skill to a channel + ${c('cyan', 'unbind')} <uid|title> <channel> unbind a skill from a channel + ${c('cyan', 'sync')} rescan stored skills for new or removed files ${c('dim', 'options:')} @@ -35,9 +35,9 @@ ${c('dim', 'options:')} ${c('yellow', '--json')} output as JSON (with list) ${c('yellow', '-h, --help')} show this help -${c('dim', 'docs are stored as .md files in .browserbird/docs/.')} +${c('dim', 'skills are stored as .md files in .browserbird/docs/.')} ${c('dim', 'edit them directly with any text editor.')} -${c('dim', "docs with no bindings are not injected. bind to channel '*' for global.")} +${c('dim', "skills with no bindings are not injected. bind to channel '*' for global.")} `.trim(); async function runDaemonCall<T>(fn: () => Promise<T>): Promise<T | undefined> { @@ -61,7 +61,7 @@ async function resolveDoc(nameOrUid: string): Promise<DocInfo | undefined> { const result = await runDaemonCall<PaginatedResult<DocInfo>>(() => daemonRequest<PaginatedResult<DocInfo>>({ method: 'GET', - path: '/api/docs?perPage=1000', + path: '/api/skills?perPage=1000', }), ); if (!result) return undefined; @@ -69,12 +69,12 @@ async function resolveDoc(nameOrUid: string): Promise<DocInfo | undefined> { if (byTitle) return byTitle; const byUid = result.items.find((d) => d.uid === nameOrUid || d.uid.startsWith(nameOrUid)); if (byUid) return byUid; - logger.error(`doc "${nameOrUid}" not found`); + logger.error(`skill "${nameOrUid}" not found`); process.exitCode = 1; return undefined; } -export async function handleDocs(argv: string[]): Promise<void> { +export async function handleSkills(argv: string[]): Promise<void> { const subcommand = argv[0] ?? 'list'; const rest = argv.slice(1); @@ -93,7 +93,7 @@ export async function handleDocs(argv: string[]): Promise<void> { const result = await runDaemonCall<PaginatedResult<DocInfo>>(() => daemonRequest<PaginatedResult<DocInfo>>({ method: 'GET', - path: '/api/docs?perPage=100', + path: '/api/skills?perPage=100', }), ); if (!result) return; @@ -101,9 +101,9 @@ export async function handleDocs(argv: string[]): Promise<void> { console.log(JSON.stringify(result.items, null, 2)); return; } - console.log(`docs (${result.totalItems} total):`); + console.log(`skills (${result.totalItems} total):`); if (result.items.length === 0) { - console.log('\n no docs found'); + console.log('\n no skills found'); return; } console.log(''); @@ -119,19 +119,19 @@ export async function handleDocs(argv: string[]): Promise<void> { case 'add': { const title = positionals.join(' ').trim(); if (!title) { - logger.error('usage: browserbird docs add <title> [--content <text>]'); + logger.error('usage: browserbird skills add <title> [--content <text>]'); process.exitCode = 1; return; } const doc = await runDaemonCall<DocInfo>(() => daemonRequest<DocInfo>({ method: 'POST', - path: '/api/docs', + path: '/api/skills', body: { title, content: (values.content as string | undefined) ?? '' }, }), ); if (!doc) return; - logger.success(`doc "${doc.title}" created at ${basename(doc.file_path)}`); + logger.success(`skill "${doc.title}" created at ${basename(doc.file_path)}`); process.stderr.write(c('dim', ` path: ${doc.file_path}`) + '\n'); return; } @@ -139,7 +139,7 @@ export async function handleDocs(argv: string[]): Promise<void> { case 'remove': { const nameOrUid = positionals[0]; if (!nameOrUid) { - logger.error('usage: browserbird docs remove <uid|title>'); + logger.error('usage: browserbird skills remove <uid|title>'); process.exitCode = 1; return; } @@ -148,80 +148,68 @@ export async function handleDocs(argv: string[]): Promise<void> { const removed = await runDaemonCall(() => daemonRequest<{ success?: boolean }>({ method: 'DELETE', - path: `/api/docs/${doc.uid}`, + path: `/api/skills/${doc.uid}`, }), ); if (removed === undefined) return; - logger.success(`doc "${doc.title}" removed`); + logger.success(`skill "${doc.title}" removed`); return; } case 'bind': { const nameOrUid = positionals[0]; - const targetType = positionals[1] as 'channel' | 'bird' | undefined; - const targetId = positionals[2]; - if (!nameOrUid || !targetType || !targetId) { + const targetId = positionals[1]; + if (!nameOrUid || !targetId) { logger.error( - "usage: browserbird docs bind <uid|title> <channel|bird> <target>\n example: browserbird docs bind tone-guide channel '*'", + "usage: browserbird skills bind <uid|title> <channel>\n example: browserbird skills bind tone-guide '*'", ); process.exitCode = 1; return; } - if (targetType !== 'channel' && targetType !== 'bird') { - logger.error('target type must be "channel" or "bird"'); - process.exitCode = 1; - return; - } const doc = await resolveDoc(nameOrUid); if (!doc) return; - if (doc.bindings.some((b) => b.targetType === targetType && b.targetId === targetId)) { - logger.warn(`doc "${doc.title}" is already bound to ${targetType} ${targetId}`); + if (doc.bindings.some((b) => b.targetType === 'channel' && b.targetId === targetId)) { + logger.warn(`skill "${doc.title}" is already bound to channel ${targetId}`); return; } const bindResult = await runDaemonCall(() => daemonRequest<{ success?: boolean }>({ method: 'PUT', - path: `/api/docs/${doc.uid}/bindings`, - body: [...doc.bindings, { targetType, targetId }], + path: `/api/skills/${doc.uid}/bindings`, + body: [...doc.bindings, { targetType: 'channel', targetId }], }), ); if (bindResult === undefined) return; - logger.success(`doc "${doc.title}" bound to ${targetType} ${targetId}`); + logger.success(`skill "${doc.title}" bound to channel ${targetId}`); return; } case 'unbind': { const nameOrUid = positionals[0]; - const targetType = positionals[1] as 'channel' | 'bird' | undefined; - const targetId = positionals[2]; - if (!nameOrUid || !targetType || !targetId) { - logger.error('usage: browserbird docs unbind <uid|title> <channel|bird> <target>'); - process.exitCode = 1; - return; - } - if (targetType !== 'channel' && targetType !== 'bird') { - logger.error('target type must be "channel" or "bird"'); + const targetId = positionals[1]; + if (!nameOrUid || !targetId) { + logger.error('usage: browserbird skills unbind <uid|title> <channel>'); process.exitCode = 1; return; } const doc = await resolveDoc(nameOrUid); if (!doc) return; const filtered = doc.bindings.filter( - (b) => !(b.targetType === targetType && b.targetId === targetId), + (b) => !(b.targetType === 'channel' && b.targetId === targetId), ); if (filtered.length === doc.bindings.length) { - logger.warn(`doc "${doc.title}" is not bound to ${targetType} ${targetId}`); + logger.warn(`skill "${doc.title}" is not bound to channel ${targetId}`); return; } const unbindResult = await runDaemonCall(() => daemonRequest<{ success?: boolean }>({ method: 'PUT', - path: `/api/docs/${doc.uid}/bindings`, + path: `/api/skills/${doc.uid}/bindings`, body: filtered, }), ); if (unbindResult === undefined) return; - logger.success(`doc "${doc.title}" unbound from ${targetType} ${targetId}`); + logger.success(`skill "${doc.title}" unbound from channel ${targetId}`); return; } @@ -229,19 +217,19 @@ export async function handleDocs(argv: string[]): Promise<void> { const result = await runDaemonCall<{ changed: boolean }>(() => daemonRequest<{ changed: boolean }>({ method: 'POST', - path: '/api/docs/sync', + path: '/api/skills/sync', }), ); if (!result) return; if (result.changed) { - logger.success('docs synced'); + logger.success('skills synced'); } else { - logger.info('docs already in sync'); + logger.info('skills already in sync'); } return; } default: - unknownSubcommand(subcommand, 'docs', ['list', 'add', 'remove', 'bind', 'unbind', 'sync']); + unknownSubcommand(subcommand, 'skills', ['list', 'add', 'remove', 'bind', 'unbind', 'sync']); } } diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..08dc17d --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,49 @@ +/** @fileoverview Tests for config loading, including legacy key migration. */ + +import { describe, it, after } from 'node:test'; +import { strictEqual } from 'node:assert'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { loadConfig } from './config.ts'; + +describe('config: legacy birds key migration', () => { + const dirs: string[] = []; + + after(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); + }); + + function writeConfig(contents: object): string { + const dir = mkdtempSync(join(tmpdir(), 'bb-config-test-')); + dirs.push(dir); + const path = join(dir, 'browserbird.json'); + writeFileSync(path, JSON.stringify(contents), 'utf-8'); + return path; + } + + const agents = [{ id: 'a', name: 'A', model: 'sonnet', channels: ['*'] }]; + + it('reads the legacy "birds" key as "automations"', () => { + const path = writeConfig({ agents, birds: { maxAttempts: 7, maxConsecutiveFailures: 4 } }); + const config = loadConfig(path); + strictEqual(config.automations.maxAttempts, 7); + strictEqual(config.automations.maxConsecutiveFailures, 4); + }); + + it('prefers "automations" when both keys are present', () => { + const path = writeConfig({ + agents, + birds: { maxAttempts: 1, maxConsecutiveFailures: 1 }, + automations: { maxAttempts: 9, maxConsecutiveFailures: 9 }, + }); + const config = loadConfig(path); + strictEqual(config.automations.maxAttempts, 9); + }); + + it('falls back to defaults when neither key is present', () => { + const path = writeConfig({ agents }); + const config = loadConfig(path); + strictEqual(config.automations.maxAttempts, 3); + }); +}); diff --git a/src/config.ts b/src/config.ts index dff1373..0b075a5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -45,7 +45,7 @@ export const DEFAULTS: Config = { novncPort: 6080, novncHost: 'localhost', }, - birds: { maxAttempts: 3, maxConsecutiveFailures: 2 }, + automations: { maxAttempts: 3, maxConsecutiveFailures: 2 }, web: { enabled: true, host: '127.0.0.1', port: 18800, corsOrigin: '' }, }; @@ -106,6 +106,20 @@ export function deepMerge( return result; } +/** + * Renames the legacy `birds` config key to `automations` in a parsed config object, + * keeping older `browserbird.json` files working after the rename. Returns true when a + * legacy key was migrated; the file adopts the new key on the next config save. + */ +function migrateLegacyConfigKeys(parsed: Record<string, unknown>): boolean { + if ('birds' in parsed && !('automations' in parsed)) { + parsed['automations'] = parsed['birds']; + delete parsed['birds']; + return true; + } + return false; +} + /** * Loads configuration from a JSON file, merges with defaults, and resolves env: references. * Searches for browserbird.json in the current directory, then falls back to defaults. @@ -128,6 +142,12 @@ export function loadConfig(configPath?: string): Config { throw new Error(`Failed to parse config file: ${filePath}`); } + if (migrateLegacyConfigKeys(parsed)) { + logger.warn( + 'config key "birds" is deprecated, reading it as "automations". the file adopts the new key on the next config save.', + ); + } + const merged = deepMerge(DEFAULTS as unknown as Record<string, unknown>, parsed); const resolved = resolveEnvValues(merged); const config = resolved as unknown as Config; @@ -198,6 +218,7 @@ export function loadRawConfig(configPath?: string): Record<string, unknown> { } catch { throw new Error(`Failed to parse config file: ${filePath}`); } + migrateLegacyConfigKeys(parsed); return deepMerge(DEFAULTS as unknown as Record<string, unknown>, parsed); } diff --git a/src/core/types.ts b/src/core/types.ts index 17907a6..3988e05 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -64,7 +64,7 @@ export interface DatabaseConfig { backups?: BackupsConfig; } -export interface BirdsConfig { +export interface AutomationsConfig { maxAttempts: number; maxConsecutiveFailures: number; } @@ -83,14 +83,14 @@ export interface Config { sessions: SessionsConfig; database: DatabaseConfig; browser: BrowserConfig; - birds: BirdsConfig; + automations: AutomationsConfig; web: WebConfig; } export const COMMANDS = { SESSIONS: 'sessions', - BIRDS: 'birds', - DOCS: 'docs', + BIRDS: 'automations', + DOCS: 'skills', KEYS: 'keys', CONFIG: 'config', LOGS: 'logs', diff --git a/src/cron/scheduler.ts b/src/cron/scheduler.ts index 51bb2c1..caeac83 100644 --- a/src/cron/scheduler.ts +++ b/src/cron/scheduler.ts @@ -147,7 +147,7 @@ export function startScheduler( if (payload.channelId) targets.push({ type: 'channel', id: payload.channelId }); targets.push({ type: 'bird', id: payload.cronJobUid }); const extraEnv = resolveExtraEnv(targets) ?? {}; - extraEnv['BROWSERBIRD_BIRD_DATA'] = ensureBirdDataDir(payload.cronJobUid); + extraEnv['BROWSERBIRD_AUTOMATION_DATA'] = ensureBirdDataDir(payload.cronJobUid); const { events, kill } = spawnProvider( { @@ -331,7 +331,7 @@ export function startScheduler( agentId: job.agent_id, } satisfies CronRunPayload, { - maxAttempts: config.birds.maxAttempts, + maxAttempts: config.automations.maxAttempts, timeout: 600, cronJobUid: job.uid, }, diff --git a/src/daemon.ts b/src/daemon.ts index 93eef16..bd012ed 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -130,7 +130,7 @@ export async function startDaemon(options: DaemonOptions): Promise<void> { }; startWorker(controller.signal, { - maxConsecutiveFailures: () => currentConfig.birds.maxConsecutiveFailures, + maxConsecutiveFailures: () => currentConfig.automations.maxConsecutiveFailures, onBirdDisabled, }); diff --git a/src/db/bindings.test.ts b/src/db/bindings.test.ts new file mode 100644 index 0000000..6877a09 --- /dev/null +++ b/src/db/bindings.test.ts @@ -0,0 +1,52 @@ +/** @fileoverview Channel-only binding replacement must preserve legacy non-channel rows. */ + +import { describe, it, before, after } from 'node:test'; +import { deepStrictEqual } from 'node:assert'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { openDatabase, closeDatabase, getDb, replaceBindingsFor } from './index.ts'; + +describe('bindings: replaceBindingsFor preserves legacy non-channel rows', () => { + let tmpRoot: string; + + before(() => { + tmpRoot = mkdtempSync(join(tmpdir(), 'bb-bindings-test-')); + openDatabase(join(tmpRoot, 'test.db')); + }); + + after(() => { + closeDatabase(); + rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('keeps a legacy bird binding while replacing channel bindings', () => { + const d = getDb(); + d.prepare( + "INSERT INTO docs (uid, title, file_path) VALUES ('d1', 'Billing', '/tmp/x.md')", + ).run(); + d.prepare( + "INSERT INTO doc_bindings (doc_uid, target_type, target_id) VALUES ('d1', 'bird', 'br_legacy')", + ).run(); + d.prepare( + "INSERT INTO doc_bindings (doc_uid, target_type, target_id) VALUES ('d1', 'channel', 'old')", + ).run(); + + replaceBindingsFor('doc_bindings', 'doc_uid', 'd1', [ + { targetType: 'channel', targetId: 'new' }, + ]); + + const rows = ( + d + .prepare( + 'SELECT target_type, target_id FROM doc_bindings WHERE doc_uid = ? ORDER BY target_type, target_id', + ) + .all('d1') as unknown as Array<{ target_type: string; target_id: string }> + ).map((r) => ({ target_type: r.target_type, target_id: r.target_id })); + + deepStrictEqual(rows, [ + { target_type: 'bird', target_id: 'br_legacy' }, + { target_type: 'channel', target_id: 'new' }, + ]); + }); +}); diff --git a/src/db/core.ts b/src/db/core.ts index aea663d..66d8402 100644 --- a/src/db/core.ts +++ b/src/db/core.ts @@ -506,7 +506,10 @@ export function loadBindingsFor( return map; } -/** Replaces all bindings for an owner UID in a bindings table. */ +/** + * Replaces the channel bindings for an owner UID, preserving any legacy + * non-channel (e.g. 'bird') rows that predate the channel-only binding model. + */ export function replaceBindingsFor( table: string, fkColumn: string, @@ -515,7 +518,9 @@ export function replaceBindingsFor( ): void { transaction(() => { const d = getDb(); - d.prepare(`DELETE FROM ${table} WHERE ${fkColumn} = ?`).run(ownerUid); + d.prepare(`DELETE FROM ${table} WHERE ${fkColumn} = ? AND target_type = 'channel'`).run( + ownerUid, + ); const stmt = d.prepare( `INSERT INTO ${table} (${fkColumn}, target_type, target_id) VALUES (?, ?, ?)`, ); diff --git a/src/project.test.ts b/src/project.test.ts new file mode 100644 index 0000000..7dedeec --- /dev/null +++ b/src/project.test.ts @@ -0,0 +1,120 @@ +/** @fileoverview Tests for the project read-model: flat channel-scoped resolution. */ + +import { describe, it, before, after } from 'node:test'; +import { strictEqual } from 'node:assert'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { openDatabase, closeDatabase, getDb, createCronJob } from './db/index.ts'; +import { listProjects, getProject, resolveAgentForChannel, listProjectsPaged } from './project.ts'; +import type { Config } from './core/types.ts'; + +const config = { + agents: [ + { + id: 'billing', + name: 'Billing Bot', + model: 'sonnet', + maxTurns: 50, + systemPrompt: '', + channels: ['billing'], + }, + { + id: 'catch', + name: 'Catch All', + model: 'sonnet', + maxTurns: 50, + systemPrompt: '', + channels: ['*'], + }, + ], + slack: { channels: ['billing', 'pr-review'] }, +} as unknown as Config; + +describe('project: read-model', () => { + let tmpRoot: string; + + before(() => { + tmpRoot = mkdtempSync(join(tmpdir(), 'bb-project-test-')); + openDatabase(join(tmpRoot, 'test.db')); + const d = getDb(); + const automation = createCronJob('daily', '0 9 * * *', 'do it', 'billing', 'billing'); + d.prepare( + 'INSERT INTO keys (uid, name, value, description, redact) VALUES (?, ?, ?, ?, 1)', + ).run('key.k1', 'GITHUB_TOKEN', 'plaintext', null); + d.prepare( + "INSERT INTO key_bindings (key_uid, target_type, target_id) VALUES ('key.k1', 'channel', 'billing')", + ).run(); + d.prepare( + 'INSERT INTO keys (uid, name, value, description, redact) VALUES (?, ?, ?, ?, 1)', + ).run('key.k2', 'AWS_RO', 'plaintext', null); + d.prepare( + "INSERT INTO key_bindings (key_uid, target_type, target_id) VALUES ('key.k2', 'bird', ?)", + ).run(automation.uid); + }); + + after(() => { + closeDatabase(); + rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('prefers an exact-channel agent over the catch-all', () => { + strictEqual(resolveAgentForChannel(config, 'billing')?.id, 'billing'); + }); + + it('falls back to the catch-all agent for unmatched channels', () => { + strictEqual(resolveAgentForChannel(config, 'pr-review')?.id, 'catch'); + }); + + it('surfaces only channel-scoped keys on the project', () => { + const project = getProject(config, 'billing'); + strictEqual(project.agent?.id, 'billing'); + strictEqual(project.keys.length, 1); + strictEqual(project.keys[0]?.name, 'GITHUB_TOKEN'); + strictEqual(project.keys[0]?.source, 'channel'); + }); + + it('lists automations as members and ignores bird-scoped bindings', () => { + const project = getProject(config, 'billing'); + strictEqual(project.automations.length, 1); + strictEqual(project.automations[0]?.name, 'daily'); + const aws = project.keys.find((k) => k.name === 'AWS_RO'); + strictEqual(aws, undefined); + }); + + it('counts only directly-bound channel resources', () => { + const project = getProject(config, 'billing'); + strictEqual(project.keyCount, 1); + strictEqual(project.docCount, 0); + strictEqual(project.automationCount, 1); + }); + + it('lists a Global project plus one per concrete channel', () => { + const list = listProjects(config); + const global = list.find((w) => w.isGlobal); + strictEqual(global?.agentId, 'catch'); + + const billing = list.find((w) => w.channel === 'billing'); + strictEqual(billing?.keyCount, 1); + strictEqual(billing?.automationCount, 1); + + const prReview = list.find((w) => w.channel === 'pr-review'); + strictEqual(prReview?.agentId, 'catch'); + strictEqual(prReview?.keyCount, 0); + }); + + it('paginates, filters, and sorts the project list', () => { + const all = listProjectsPaged(config); + strictEqual(all.totalItems, 3); + strictEqual(all.page, 1); + + const filtered = listProjectsPaged(config, { search: 'billing' }); + strictEqual(filtered.totalItems, 1); + strictEqual(filtered.items[0]?.channel, 'billing'); + + const paged = listProjectsPaged(config, { perPage: 2 }); + strictEqual(paged.items.length, 2); + strictEqual(paged.totalPages, 2); + strictEqual(paged.items[0]?.isGlobal, true); + }); +}); diff --git a/src/project.ts b/src/project.ts new file mode 100644 index 0000000..639955b --- /dev/null +++ b/src/project.ts @@ -0,0 +1,455 @@ +/** + * @fileoverview Project read-model: a derived view over the binding graph, computed + * from config (agents + channel patterns) joined to key/doc bindings and automations. + * A project has no persistence of its own; the channel id IS its identity. + * + * Resources resolve at the CHANNEL level: a project's keys and docs are whatever is + * bound to its channel (exact) or to `*` (the global floor). Automations inherit their + * channel's resources, so they appear as plain members of the project rather than as + * separate resolution contexts. Each resolved resource carries a `source` + * (channel vs the `*` global floor) and a `bindingCount` (how many bindings it has + * total = fan-out, >1 means it is shared across projects). + */ + +import type { Config, AgentConfig } from './core/types.ts'; +import type { PaginatedResult } from './db/index.ts'; +import { getDb, getDocInfo, getCronJob, DEFAULT_PER_PAGE, MAX_PER_PAGE } from './db/index.ts'; +import { SYSTEM_CRON_PREFIX } from './db/birds.ts'; + +export const GLOBAL_PROJECT_ID = '*'; + +export interface ProjectSummary { + channel: string; + isGlobal: boolean; + agentId: string | null; + agentName: string | null; + keyCount: number; + docCount: number; + automationCount: number; +} + +export type BindingSource = 'channel' | 'channel-global'; + +export interface ResolvedRef { + uid: string; + name: string; + source: BindingSource; + bindingCount: number; +} + +export interface ProjectAutomationMember { + uid: string; + name: string; + schedule: string; + enabled: boolean; + agentId: string; + agentName: string; +} + +export interface ProjectDetail { + channel: string; + isGlobal: boolean; + agent: AgentConfig | null; + agentShared: boolean; + keys: ResolvedRef[]; + docs: ResolvedRef[]; + automations: ProjectAutomationMember[]; + keyCount: number; + docCount: number; + automationCount: number; +} + +function wildcardAgent(config: Config): AgentConfig | null { + return config.agents.find((a) => a.channels.includes(GLOBAL_PROJECT_ID)) ?? null; +} + +/** + * Picks the single agent that serves messages in `channel`. Exact match wins + * over the `*` catch-all; falls back to the catch-all when no agent lists the + * channel exactly. Returns null when no agent serves it. + */ +export function resolveAgentForChannel( + config: Config, + channel: string | null | undefined, +): AgentConfig | null { + const exact = + channel == null ? undefined : config.agents.find((a) => a.channels.includes(channel)); + return exact ?? wildcardAgent(config); +} + +interface CountMaps { + key: Map<string, number>; + doc: Map<string, number>; + automation: Map<string | null, number>; +} + +/** + * One grouped COUNT per table, keyed by channel (target_id / target_channel_id), + * instead of a scalar COUNT per channel. The `*` bucket (global key/doc + * bindings) and the null bucket (channel-less automations) fall out of the same + * GROUP BY, so the Global project reads from the same maps. System (`__bb_`) automations + * are excluded. + */ +function channelCountMaps(): CountMaps { + const d = getDb(); + const bind = (table: string): Map<string, number> => + new Map( + ( + d + .prepare( + `SELECT target_id AS ch, COUNT(*) AS c FROM ${table} + WHERE target_type = 'channel' GROUP BY target_id`, + ) + .all() as unknown as Array<{ ch: string; c: number }> + ).map((r): [string, number] => [r.ch, r.c]), + ); + const automation = new Map( + ( + d + .prepare( + `SELECT target_channel_id AS ch, COUNT(*) AS c FROM cron_jobs + WHERE name NOT LIKE ? GROUP BY target_channel_id`, + ) + .all(`${SYSTEM_CRON_PREFIX}%`) as unknown as Array<{ ch: string | null; c: number }> + ).map((r): [string | null, number] => [r.ch, r.c]), + ); + return { key: bind('key_bindings'), doc: bind('doc_bindings'), automation }; +} + +function globalSummary(config: Config, counts: CountMaps): ProjectSummary | null { + const keyCount = counts.key.get(GLOBAL_PROJECT_ID) ?? 0; + const docCount = counts.doc.get(GLOBAL_PROJECT_ID) ?? 0; + const automationCount = counts.automation.get(null) ?? 0; + const agent = wildcardAgent(config); + if (keyCount === 0 && docCount === 0 && automationCount === 0 && !agent) { + return null; + } + return { + channel: GLOBAL_PROJECT_ID, + isGlobal: true, + agentId: agent?.id ?? null, + agentName: agent?.name ?? null, + keyCount, + docCount, + automationCount, + }; +} + +/** + * Enumerates every project: one per concrete channel referenced by config or + * bindings, plus a leading Global project when anything is bound to `*`. Counts are + * the direct channel bindings (the channel's own surface); the detail view + * resolves the full per-context picture. + */ +export function listProjects(config: Config): ProjectSummary[] { + const counts = channelCountMaps(); + const channels = new Set<string>(); + for (const c of config.slack.channels) { + if (c !== GLOBAL_PROJECT_ID) channels.add(c); + } + for (const agent of config.agents) { + for (const c of agent.channels) { + if (c !== GLOBAL_PROJECT_ID) channels.add(c); + } + } + for (const ch of counts.key.keys()) if (ch !== GLOBAL_PROJECT_ID) channels.add(ch); + for (const ch of counts.doc.keys()) if (ch !== GLOBAL_PROJECT_ID) channels.add(ch); + for (const ch of counts.automation.keys()) if (ch != null) channels.add(ch); + + const summaries: ProjectSummary[] = []; + const global = globalSummary(config, counts); + if (global) summaries.push(global); + + for (const channel of [...channels].sort()) { + const agent = resolveAgentForChannel(config, channel); + summaries.push({ + channel, + isGlobal: false, + agentId: agent?.id ?? null, + agentName: agent?.name ?? null, + keyCount: counts.key.get(channel) ?? 0, + docCount: counts.doc.get(channel) ?? 0, + automationCount: counts.automation.get(channel) ?? 0, + }); + } + + return summaries; +} + +export interface ListProjectsParams { + page?: number; + perPage?: number; + sort?: string; + search?: string; +} + +function projectField(n: ProjectSummary, key: string): string | number { + switch (key) { + case 'agent': + return n.agentName ?? ''; + case 'keyCount': + return n.keyCount; + case 'docCount': + return n.docCount; + case 'automationCount': + return n.automationCount; + default: + return n.channel; + } +} + +/** + * Server-side filtered, sorted, and paginated project list, so the web UI can drive + * it through the same `createDataTable` path as the other index pages. Unsorted, + * the Global project leads (listProjects emits it first); an explicit sort reorders + * every row, Global included. + */ +export function listProjectsPaged( + config: Config, + params: ListProjectsParams = {}, +): PaginatedResult<ProjectSummary> { + let items = listProjects(config); + + const search = params.search?.trim().toLowerCase(); + if (search) { + items = items.filter( + (n) => + n.channel.toLowerCase().includes(search) || + (n.agentName ?? '').toLowerCase().includes(search), + ); + } + + if (params.sort) { + const desc = params.sort.startsWith('-'); + const key = desc ? params.sort.slice(1) : params.sort; + items = [...items].sort((a, b) => { + const av = projectField(a, key); + const bv = projectField(b, key); + const cmp = + typeof av === 'number' && typeof bv === 'number' + ? av - bv + : String(av).localeCompare(String(bv)); + return desc ? -cmp : cmp; + }); + } + + const perPage = Math.min(Math.max(params.perPage ?? DEFAULT_PER_PAGE, 1), MAX_PER_PAGE); + const page = Math.max(params.page ?? 1, 1); + const totalItems = items.length; + const totalPages = Math.max(Math.ceil(totalItems / perPage), 1); + const start = (page - 1) * perPage; + return { items: items.slice(start, start + perPage), page, perPage, totalItems, totalPages }; +} + +const SOURCE_RANK: Record<BindingSource, number> = { + channel: 0, + 'channel-global': 1, +}; + +/** Total bindings per resource across all contexts; >1 means the resource is shared. */ +function bindingCounts(table: string, fk: string): Map<string, number> { + const rows = getDb() + .prepare(`SELECT ${fk} AS uid, COUNT(*) AS c FROM ${table} GROUP BY ${fk}`) + .all() as unknown as Array<{ uid: string; c: number }>; + return new Map(rows.map((r) => [r.uid, r.c])); +} + +/** + * Resolves a project's effective resources, exactly as the daemon would for a + * message in this channel: a resource matches when bound to the channel (exact) + * or to `*` (the global floor). The Global project sees only the `*` floor. When a + * resource is bound both exactly and globally, the exact binding names its + * source, so a channel-bound key reads as the project's own even if it is also + * global. + */ +function resolveChannelRefs( + table: string, + fk: string, + itemTable: string, + nameCol: string, + channel: string, + isGlobal: boolean, + counts: Map<string, number>, +): ResolvedRef[] { + const ids = isGlobal ? ['*'] : [channel, '*']; + const placeholders = ids.map(() => '?').join(', '); + const rows = getDb() + .prepare( + `SELECT b.${fk} AS uid, i.${nameCol} AS name, b.target_id AS ti + FROM ${table} b JOIN ${itemTable} i ON i.uid = b.${fk} + WHERE b.target_type = 'channel' AND b.target_id IN (${placeholders})`, + ) + .all(...ids) as unknown as Array<{ uid: string; name: string; ti: string }>; + + const best = new Map<string, { name: string; source: BindingSource }>(); + for (const r of rows) { + const source: BindingSource = r.ti === '*' ? 'channel-global' : 'channel'; + const cur = best.get(r.uid); + if (!cur || SOURCE_RANK[source] < SOURCE_RANK[cur.source]) { + best.set(r.uid, { name: r.name, source }); + } + } + return [...best.entries()] + .map(([uid, v]) => ({ + uid, + name: v.name, + source: v.source, + bindingCount: counts.get(uid) ?? 0, + })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +interface AutomationRow { + uid: string; + name: string; + agent_id: string; + schedule: string; + target_channel_id: string | null; + enabled: number; +} + +function automationsForProject(channel: string, isGlobal: boolean): AutomationRow[] { + const where = isGlobal ? 'target_channel_id IS NULL' : 'target_channel_id = ?'; + const params = isGlobal ? [`${SYSTEM_CRON_PREFIX}%`] : [channel, `${SYSTEM_CRON_PREFIX}%`]; + return getDb() + .prepare( + `SELECT uid, name, agent_id, schedule, target_channel_id, enabled FROM cron_jobs + WHERE ${where} AND name NOT LIKE ? ORDER BY created_at`, + ) + .all(...params) as unknown as AutomationRow[]; +} + +/** + * Assembles the flat detail for one project: the serving agent, the channel's + * effective keys and docs (channel bindings plus the `*` global floor), and the + * automations that belong to the channel as plain members. Automations inherit the channel's + * resources, so they carry no resource list of their own. + */ +export function getProject(config: Config, channel: string): ProjectDetail { + const isGlobal = channel === GLOBAL_PROJECT_ID; + const keyCounts = bindingCounts('key_bindings', 'key_uid'); + const docCounts = bindingCounts('doc_bindings', 'doc_uid'); + const agentById = new Map(config.agents.map((a) => [a.id, a])); + + const exactAgent = isGlobal + ? null + : (config.agents.find((a) => a.channels.includes(channel)) ?? null); + const servingAgent = isGlobal ? wildcardAgent(config) : (exactAgent ?? wildcardAgent(config)); + const agentShared = !isGlobal && servingAgent != null && exactAgent == null; + + const keys = resolveChannelRefs( + 'key_bindings', + 'key_uid', + 'keys', + 'name', + channel, + isGlobal, + keyCounts, + ); + const docs = resolveChannelRefs( + 'doc_bindings', + 'doc_uid', + 'docs', + 'title', + channel, + isGlobal, + docCounts, + ); + + const automations: ProjectAutomationMember[] = automationsForProject(channel, isGlobal).map( + (b) => { + const bAgent = agentById.get(b.agent_id) ?? null; + return { + uid: b.uid, + name: b.name, + schedule: b.schedule, + enabled: b.enabled === 1, + agentId: b.agent_id, + agentName: bAgent?.name ?? b.agent_id, + }; + }, + ); + + return { + channel, + isGlobal, + agent: servingAgent, + agentShared, + keys, + docs, + automations, + keyCount: keys.length, + docCount: docs.length, + automationCount: automations.length, + }; +} + +export interface ProjectBlueprint { + version: 1; + channel: string; + agent: { + name: string; + model: string; + systemPrompt: string; + permissionMode: string | null; + maxTurns: number; + } | null; + keys: string[]; + docs: Array<{ title: string; content: string }>; + automations: Array<{ name: string; schedule: string; prompt: string }>; +} + +/** + * Builds a shareable, secret-free snapshot of a project's own (directly-bound) + * resources: the agent definition, doc contents, automation definitions, and key + * NAMES only (never values). Globals and inherited resources are excluded; they + * belong to the Global project's blueprint. + */ +export function buildProjectBlueprint(config: Config, channel: string): ProjectBlueprint { + const d = getDb(); + const agent = resolveAgentForChannel(config, channel); + + const keyNames = ( + d + .prepare( + `SELECT k.name FROM keys k JOIN key_bindings kb ON kb.key_uid = k.uid + WHERE kb.target_type = 'channel' AND kb.target_id = ? ORDER BY k.name`, + ) + .all(channel) as unknown as Array<{ name: string }> + ).map((r) => r.name); + + const docUids = ( + d + .prepare( + `SELECT db.doc_uid AS uid FROM doc_bindings db + WHERE db.target_type = 'channel' AND db.target_id = ?`, + ) + .all(channel) as unknown as Array<{ uid: string }> + ).map((r) => r.uid); + const docs = docUids + .map((uid) => getDocInfo(uid)) + .filter((doc): doc is NonNullable<typeof doc> => doc != null) + .map((doc) => ({ title: doc.title, content: doc.content })); + + const automations = automationsForProject(channel, channel === GLOBAL_PROJECT_ID).map((b) => { + const full = getCronJob(b.uid); + return { name: b.name, schedule: b.schedule, prompt: full?.prompt ?? '' }; + }); + + return { + version: 1, + channel, + agent: agent + ? { + name: agent.name, + model: agent.model, + systemPrompt: agent.systemPrompt, + permissionMode: agent.permissionMode ?? null, + maxTurns: agent.maxTurns, + } + : null, + keys: keyNames, + docs, + automations, + }; +} diff --git a/src/server/http.ts b/src/server/http.ts index e899848..460d2dd 100644 --- a/src/server/http.ts +++ b/src/server/http.ts @@ -111,17 +111,16 @@ export function validateBindings(body: unknown, res: ServerResponse): Binding[] jsonError(res, 'Body must be an array of bindings', 400); return null; } + const channelBindings: Binding[] = []; for (const b of body as Binding[]) { - if (b.targetType !== 'channel' && b.targetType !== 'bird') { - jsonError(res, '"targetType" must be "channel" or "bird"', 400); - return null; - } + if (b.targetType !== 'channel') continue; if (!b.targetId || typeof b.targetId !== 'string') { jsonError(res, '"targetId" is required', 400); return null; } + channelBindings.push(b); } - return body as Binding[]; + return channelBindings; } const MAX_BODY_BYTES = 1024 * 1024; diff --git a/src/server/routes/birds.ts b/src/server/routes/automations.ts similarity index 78% rename from src/server/routes/birds.ts rename to src/server/routes/automations.ts index 8527267..6105df7 100644 --- a/src/server/routes/birds.ts +++ b/src/server/routes/automations.ts @@ -28,15 +28,16 @@ import { import { enqueue } from '../../jobs.ts'; import { deriveBirdName } from '../../core/utils.ts'; import { parseCron, nextCronMatch } from '../../cron/parse.ts'; +import { resolveAgentForChannel } from '../../project.ts'; function resolveBird( params: Record<string, string>, res: import('node:http').ServerResponse, ): CronJobRow | null { - return resolveRouteParam<CronJobRow>('cron_jobs', 'Bird', params, res); + return resolveRouteParam<CronJobRow>('cron_jobs', 'Automation', params, res); } -export interface UpcomingBird { +export interface UpcomingAutomation { uid: string; name: string; schedule: string; @@ -44,9 +45,9 @@ export interface UpcomingBird { next_run: string; } -export function computeUpcomingBirds(timezone: string, limit: number): UpcomingBird[] { +export function computeUpcomingAutomations(timezone: string, limit: number): UpcomingAutomation[] { const now = new Date(); - const upcoming: UpcomingBird[] = []; + const upcoming: UpcomingAutomation[] = []; for (const bird of getEnabledCronJobs()) { if (bird.name.startsWith(SYSTEM_CRON_PREFIX)) continue; try { @@ -69,20 +70,20 @@ export function computeUpcomingBirds(timezone: string, limit: number): UpcomingB return upcoming.slice(0, limit); } -export function buildBirdsRoutes(getConfig: () => Config): Route[] { +export function buildAutomationsRoutes(getConfig: () => Config): Route[] { return [ { method: 'GET', - pattern: pathToRegex('/api/birds/upcoming'), + pattern: pathToRegex('/api/automations/upcoming'), handler(req, res) { const url = new URL(req.url ?? '/', `http://${req.headers.host}`); const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 5, 1), 20); - json(res, computeUpcomingBirds(getConfig().timezone, limit)); + json(res, computeUpcomingAutomations(getConfig().timezone, limit)); }, }, { method: 'GET', - pattern: pathToRegex('/api/birds'), + pattern: pathToRegex('/api/automations'), handler(req, res) { const url = new URL(req.url ?? '/', `http://${req.headers.host}`); const { page, perPage } = parsePagination(url); @@ -100,7 +101,7 @@ export function buildBirdsRoutes(getConfig: () => Config): Route[] { }, { method: 'PATCH', - pattern: pathToRegex('/api/birds/:id/enable'), + pattern: pathToRegex('/api/automations/:id/enable'), handler(_req, res, params) { const bird = resolveBird(params, res); if (!bird) return; @@ -111,7 +112,7 @@ export function buildBirdsRoutes(getConfig: () => Config): Route[] { }, { method: 'PATCH', - pattern: pathToRegex('/api/birds/:id/disable'), + pattern: pathToRegex('/api/automations/:id/disable'), handler(_req, res, params) { const bird = resolveBird(params, res); if (!bird) return; @@ -122,7 +123,7 @@ export function buildBirdsRoutes(getConfig: () => Config): Route[] { }, { method: 'POST', - pattern: pathToRegex('/api/birds'), + pattern: pathToRegex('/api/automations'), async handler(req, res) { let body: { name?: string; @@ -147,13 +148,25 @@ export function buildBirdsRoutes(getConfig: () => Config): Route[] { jsonError(res, '"prompt" is required', 400); return; } + const channel = body.channel?.trim() || undefined; const birdName = body.name?.trim() || deriveBirdName(body.prompt); + const agentId = body.agent?.trim() || resolveAgentForChannel(getConfig(), channel)?.id; + if (!agentId) { + jsonError( + res, + channel + ? `No agent serves channel "${channel}". Assign an agent to it or pass "agent".` + : 'No catch-all agent for a background bird. Add a "*" agent or pass "agent".', + 400, + ); + return; + } const job = createCronJob( birdName, body.schedule.trim(), body.prompt.trim(), - body.channel?.trim() || undefined, - body.agent?.trim() || undefined, + channel, + agentId, body.activeHoursStart?.trim() || undefined, body.activeHoursEnd?.trim() || undefined, ); @@ -163,7 +176,7 @@ export function buildBirdsRoutes(getConfig: () => Config): Route[] { }, { method: 'PATCH', - pattern: pathToRegex('/api/birds/:id'), + pattern: pathToRegex('/api/automations/:id'), async handler(req, res, params) { const bird = resolveBird(params, res); if (!bird) return; @@ -182,12 +195,22 @@ export function buildBirdsRoutes(getConfig: () => Config): Route[] { jsonError(res, 'Invalid JSON body', 400); return; } + const channelProvided = body.channel !== undefined; + const newChannel = channelProvided ? body.channel?.trim() || null : undefined; + let agentId = body.agent?.trim() || undefined; + if (channelProvided && !agentId) { + const config = getConfig(); + const oldDerived = resolveAgentForChannel(config, bird.target_channel_id)?.id; + if (bird.agent_id === oldDerived) { + agentId = resolveAgentForChannel(config, newChannel)?.id || undefined; + } + } const updated = updateCronJob(bird.uid, { schedule: body.schedule?.trim() || undefined, prompt: body.prompt?.trim() || undefined, name: body.name?.trim() || undefined, - targetChannelId: body.channel !== undefined ? body.channel?.trim() || null : undefined, - agentId: body.agent?.trim() || undefined, + targetChannelId: channelProvided ? newChannel : undefined, + agentId, activeHoursStart: body.activeHoursStart !== undefined ? body.activeHoursStart?.trim() || null : undefined, activeHoursEnd: @@ -197,13 +220,13 @@ export function buildBirdsRoutes(getConfig: () => Config): Route[] { broadcastSSE('invalidate', { resource: 'birds' }); json(res, updated); } else { - jsonError(res, `Bird ${bird.uid} not found`, 404); + jsonError(res, `Automation ${bird.uid} not found`, 404); } }, }, { method: 'DELETE', - pattern: pathToRegex('/api/birds/:id'), + pattern: pathToRegex('/api/automations/:id'), handler(_req, res, params) { const bird = resolveBird(params, res); if (!bird) return; @@ -218,7 +241,7 @@ export function buildBirdsRoutes(getConfig: () => Config): Route[] { }, { method: 'POST', - pattern: pathToRegex('/api/birds/:id/fly'), + pattern: pathToRegex('/api/automations/:id/run'), async handler(req, res, params) { const bird = resolveBird(params, res); if (!bird) return; @@ -255,7 +278,7 @@ export function buildBirdsRoutes(getConfig: () => Config): Route[] { }, { method: 'GET', - pattern: pathToRegex('/api/flights'), + pattern: pathToRegex('/api/runs'), handler(req, res) { const url = new URL(req.url ?? '/', `http://${req.headers.host}`); const { page, perPage } = parsePagination(url); @@ -276,7 +299,7 @@ export function buildBirdsRoutes(getConfig: () => Config): Route[] { }, { method: 'GET', - pattern: pathToRegex('/api/birds/:id/flights'), + pattern: pathToRegex('/api/automations/:id/runs'), handler(req, res, params) { const bird = resolveBird(params, res); if (!bird) return; diff --git a/src/server/routes/config.ts b/src/server/routes/config.ts index 07d25d6..9e9ee2d 100644 --- a/src/server/routes/config.ts +++ b/src/server/routes/config.ts @@ -23,7 +23,7 @@ const ALLOWED_TOP_LEVEL_KEYS = new Set([ 'agents', 'sessions', 'slack', - 'birds', + 'automations', 'browser', 'database', ]); @@ -54,7 +54,7 @@ function sanitizeConfig(config: Config): object { channels: config.slack.channels, quietHours: config.slack.quietHours, }, - birds: config.birds, + automations: config.automations, browser: { enabled: config.browser.enabled, mode: getBrowserMode(), @@ -160,21 +160,21 @@ function validateConfigPatch(body: Record<string, unknown>): string | null { } } - if ('birds' in body) { - const b = body['birds'] as Record<string, unknown>; - if (typeof b !== 'object' || b == null) return '"birds" must be an object'; + if ('automations' in body) { + const b = body['automations'] as Record<string, unknown>; + if (typeof b !== 'object' || b == null) return '"automations" must be an object'; if ( 'maxAttempts' in b && (!Number.isInteger(b['maxAttempts']) || (b['maxAttempts'] as number) <= 0) ) { - return '"birds.maxAttempts" must be a positive integer'; + return '"automations.maxAttempts" must be a positive integer'; } if ( 'maxConsecutiveFailures' in b && (!Number.isInteger(b['maxConsecutiveFailures']) || (b['maxConsecutiveFailures'] as number) < 0) ) { - return '"birds.maxConsecutiveFailures" must be a non-negative integer'; + return '"automations.maxConsecutiveFailures" must be a non-negative integer'; } } diff --git a/src/server/routes/data.ts b/src/server/routes/data.ts index cbdab6d..9090858 100644 --- a/src/server/routes/data.ts +++ b/src/server/routes/data.ts @@ -29,7 +29,7 @@ import { listFlights, } from '../../db/index.ts'; import type { SessionRow } from '../../db/index.ts'; -import { computeUpcomingBirds } from './birds.ts'; +import { computeUpcomingAutomations } from './automations.ts'; export function buildStatusPayload( getConfig: () => Config, @@ -89,20 +89,20 @@ export function buildDataRoutes(deps: DataRouteDeps): Route[] { pattern: pathToRegex('/api/dashboard'), handler(_req, res) { const allBirds = getEnabledCronJobs(); - const failingBirds = allBirds + const failingAutomations = allBirds .filter((b) => b.failure_count > 0) .sort((a, b) => b.failure_count - a.failure_count); - const upcoming = computeUpcomingBirds(getConfig().timezone, 5); - const runningFlights = listFlights(1, 5, { status: 'running' }); - const recentFlights = listFlights(1, 5, {}); + const upcoming = computeUpcomingAutomations(getConfig().timezone, 5); + const runningRuns = listFlights(1, 5, { status: 'running' }); + const recentRuns = listFlights(1, 5, {}); const recentSessions = listSessions(1, 5); json(res, { - failingBirds, + failingAutomations, upcoming, - runningFlights: runningFlights.items, - recentFlights: recentFlights.items, + runningRuns: runningRuns.items, + recentRuns: recentRuns.items, recentSessions: recentSessions.items, }); }, diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 1c886ee..bdd60b4 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -5,11 +5,12 @@ import type { Config } from '../../core/types.ts'; import type { Route, WebServerDeps } from '../http.ts'; import { buildChatRoutes } from '../chat.ts'; import { buildAuthRoutes } from './auth.ts'; -import { buildBirdsRoutes } from './birds.ts'; +import { buildAutomationsRoutes } from './automations.ts'; import { buildConfigRoutes } from './config.ts'; import { buildDataRoutes } from './data.ts'; -import { buildDocsRoutes } from './docs.ts'; +import { buildSkillsRoutes } from './skills.ts'; import { buildKeysRoutes } from './keys.ts'; +import { buildProjectsRoutes } from './projects.ts'; import { buildBackupsRoutes } from './backups.ts'; import { buildJobsRoutes } from './jobs.ts'; import { buildOnboardingRoutes } from './onboarding.ts'; @@ -47,7 +48,7 @@ export function buildRoutes( envPath: options.envPath, onConfigReload: options.onConfigReload, }), - ...buildBirdsRoutes(getConfig), + ...buildAutomationsRoutes(getConfig), ...buildOnboardingRoutes({ getConfig, configPath: options.configPath, @@ -55,9 +56,10 @@ export function buildRoutes( onLaunch: options.onLaunch, }), ...buildKeysRoutes(), + ...buildProjectsRoutes(getConfig), ...buildBackupsRoutes(dirname(options.configPath), getConfig, options.onRestart), ...buildJobsRoutes(), - ...buildDocsRoutes(), + ...buildSkillsRoutes(), ...buildChatRoutes(() => getDeps().webChannel()), ]; } diff --git a/src/server/routes/projects.ts b/src/server/routes/projects.ts new file mode 100644 index 0000000..cfd1728 --- /dev/null +++ b/src/server/routes/projects.ts @@ -0,0 +1,59 @@ +/** @fileoverview Project read-model API: aggregates agent + keys + skills + automations per channel. */ + +import type { Route } from '../http.ts'; +import type { Config } from '../../core/types.ts'; +import { + pathToRegex, + json, + jsonError, + parsePagination, + parseSortParam, + parseSearchParam, +} from '../http.ts'; +import { listProjectsPaged, getProject, buildProjectBlueprint } from '../../project.ts'; + +export function buildProjectsRoutes(getConfig: () => Config): Route[] { + return [ + { + method: 'GET', + pattern: pathToRegex('/api/projects'), + handler(req, res) { + const url = new URL(req.url ?? '/', `http://${req.headers.host}`); + const { page, perPage } = parsePagination(url); + json( + res, + listProjectsPaged(getConfig(), { + page, + perPage, + sort: parseSortParam(url), + search: parseSearchParam(url), + }), + ); + }, + }, + { + method: 'GET', + pattern: pathToRegex('/api/projects/:channel/blueprint'), + handler(_req, res, params) { + const channel = decodeURIComponent(params['channel'] ?? ''); + if (!channel) { + jsonError(res, 'Missing channel', 400); + return; + } + json(res, buildProjectBlueprint(getConfig(), channel)); + }, + }, + { + method: 'GET', + pattern: pathToRegex('/api/projects/:channel'), + handler(_req, res, params) { + const channel = decodeURIComponent(params['channel'] ?? ''); + if (!channel) { + jsonError(res, 'Missing channel', 400); + return; + } + json(res, getProject(getConfig(), channel)); + }, + }, + ]; +} diff --git a/src/server/routes/docs.ts b/src/server/routes/skills.ts similarity index 79% rename from src/server/routes/docs.ts rename to src/server/routes/skills.ts index f1cbea2..ceeaf09 100644 --- a/src/server/routes/docs.ts +++ b/src/server/routes/skills.ts @@ -25,11 +25,11 @@ import { syncDocs, } from '../../db/index.ts'; -export function buildDocsRoutes(): Route[] { +export function buildSkillsRoutes(): Route[] { return [ { method: 'GET', - pattern: pathToRegex('/api/docs'), + pattern: pathToRegex('/api/skills'), handler(req, res) { const url = new URL(req.url ?? '/', `http://${req.headers.host}`); const { page, perPage } = parsePagination(url); @@ -38,7 +38,7 @@ export function buildDocsRoutes(): Route[] { }, { method: 'POST', - pattern: pathToRegex('/api/docs'), + pattern: pathToRegex('/api/skills'), async handler(req, res) { let body: { title?: string; content?: string }; try { @@ -58,18 +58,18 @@ export function buildDocsRoutes(): Route[] { }, { method: 'GET', - pattern: pathToRegex('/api/docs/:id'), + pattern: pathToRegex('/api/skills/:id'), handler(_req, res, params) { - const doc = resolveRouteParam<DocRow>('docs', 'Doc', params, res); + const doc = resolveRouteParam<DocRow>('docs', 'Skill', params, res); if (!doc) return; json(res, docRowToInfo(doc)); }, }, { method: 'PATCH', - pattern: pathToRegex('/api/docs/:id'), + pattern: pathToRegex('/api/skills/:id'), async handler(req, res, params) { - const doc = resolveRouteParam<DocRow>('docs', 'Doc', params, res); + const doc = resolveRouteParam<DocRow>('docs', 'Skill', params, res); if (!doc) return; let body: { title?: string; content?: string; pinned?: boolean }; try { @@ -87,15 +87,15 @@ export function buildDocsRoutes(): Route[] { broadcastSSE('invalidate', { resource: 'docs' }); json(res, updated); } else { - jsonError(res, `Doc ${doc.uid} not found`, 404); + jsonError(res, `Skill ${doc.uid} not found`, 404); } }, }, { method: 'DELETE', - pattern: pathToRegex('/api/docs/:id'), + pattern: pathToRegex('/api/skills/:id'), handler(_req, res, params) { - const doc = resolveRouteParam<DocRow>('docs', 'Doc', params, res); + const doc = resolveRouteParam<DocRow>('docs', 'Skill', params, res); if (!doc) return; deleteDoc(doc.uid); broadcastSSE('invalidate', { resource: 'docs' }); @@ -104,9 +104,9 @@ export function buildDocsRoutes(): Route[] { }, { method: 'PATCH', - pattern: pathToRegex('/api/docs/:id/pin'), + pattern: pathToRegex('/api/skills/:id/pin'), handler(_req, res, params) { - const doc = resolveRouteParam<DocRow>('docs', 'Doc', params, res); + const doc = resolveRouteParam<DocRow>('docs', 'Skill', params, res); if (!doc) return; setDocPinned(doc.uid, true); broadcastSSE('invalidate', { resource: 'docs' }); @@ -115,9 +115,9 @@ export function buildDocsRoutes(): Route[] { }, { method: 'PATCH', - pattern: pathToRegex('/api/docs/:id/unpin'), + pattern: pathToRegex('/api/skills/:id/unpin'), handler(_req, res, params) { - const doc = resolveRouteParam<DocRow>('docs', 'Doc', params, res); + const doc = resolveRouteParam<DocRow>('docs', 'Skill', params, res); if (!doc) return; setDocPinned(doc.uid, false); broadcastSSE('invalidate', { resource: 'docs' }); @@ -126,9 +126,9 @@ export function buildDocsRoutes(): Route[] { }, { method: 'PUT', - pattern: pathToRegex('/api/docs/:id/bindings'), + pattern: pathToRegex('/api/skills/:id/bindings'), async handler(req, res, params) { - const doc = resolveRouteParam<DocRow>('docs', 'Doc', params, res); + const doc = resolveRouteParam<DocRow>('docs', 'Skill', params, res); if (!doc) return; let body: unknown; try { @@ -146,7 +146,7 @@ export function buildDocsRoutes(): Route[] { }, { method: 'POST', - pattern: pathToRegex('/api/docs/sync'), + pattern: pathToRegex('/api/skills/sync'), handler(_req, res) { const changed = syncDocs(); if (changed) broadcastSSE('invalidate', { resource: 'docs' }); diff --git a/web/src/App.svelte b/web/src/App.svelte index 044543d..19352b4 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -21,21 +21,29 @@ import ConfirmDialog from './components/ConfirmDialog.svelte'; import MissionControl from './pages/MissionControl.svelte'; import Sessions from './pages/Sessions.svelte'; - import Birds from './pages/Birds.svelte'; import Computer from './pages/Computer.svelte'; import Settings from './pages/Settings.svelte'; import SessionDetail from './pages/SessionDetail.svelte'; - import Docs from './pages/Docs.svelte'; - import DocDetail from './pages/DocDetail.svelte'; + import SkillDetail from './pages/SkillDetail.svelte'; + import Projects from './pages/Projects.svelte'; + import ProjectDetail from './pages/ProjectDetail.svelte'; + import ProjectCreate from './pages/ProjectCreate.svelte'; + import Resources from './pages/Resources.svelte'; import Onboarding from './pages/Onboarding.svelte'; const PAGE_TITLES: Record<string, string> = { status: 'Mission Control', + projects: 'Projects', + 'project-detail': 'Project', + 'project-create': 'New Project', sessions: 'Sessions', 'session-detail': 'Session Detail', - docs: 'Docs', - 'doc-detail': 'Doc Detail', - birds: 'Birds', + resources: 'Resources', + agents: 'Resources', + skills: 'Resources', + keys: 'Resources', + automations: 'Resources', + 'skill-detail': 'Skill Detail', computer: 'Computer', settings: 'Settings', }; @@ -84,7 +92,7 @@ $effect(() => { if (currentPage !== 'flights') return; const birdUid = getHashParams().get('birdUid'); - window.location.hash = birdUid ? `#/birds?expand=${birdUid}` : '#/birds'; + window.location.hash = birdUid ? `#/automations?expand=${birdUid}` : '#/automations'; }); $effect(() => { @@ -338,14 +346,18 @@ <div class="content-body"> {#if currentPage === 'sessions'} <Sessions /> + {:else if currentPage === 'projects'} + <Projects /> + {:else if currentPage === 'project-detail'} + <ProjectDetail /> + {:else if currentPage === 'project-create'} + <ProjectCreate /> {:else if currentPage === 'session-detail'} <SessionDetail /> - {:else if currentPage === 'docs'} - <Docs /> - {:else if currentPage === 'doc-detail'} - <DocDetail /> - {:else if currentPage === 'birds'} - <Birds /> + {:else if currentPage === 'skill-detail'} + <SkillDetail /> + {:else if currentPage === 'resources' || currentPage === 'agents' || currentPage === 'skills' || currentPage === 'keys' || currentPage === 'automations'} + <Resources {currentPage} /> {:else if currentPage === 'computer'} <Computer {status} /> {:else if currentPage === 'settings'} diff --git a/web/src/components/BirdDrawer.svelte b/web/src/components/AutomationDrawer.svelte similarity index 92% rename from web/src/components/BirdDrawer.svelte rename to web/src/components/AutomationDrawer.svelte index beecc53..1e3111d 100644 --- a/web/src/components/BirdDrawer.svelte +++ b/web/src/components/AutomationDrawer.svelte @@ -10,15 +10,17 @@ import Toggle from './Toggle.svelte'; import InlineEdit from './InlineEdit.svelte'; import FlightCard from './FlightCard.svelte'; + import { channelToSelect, selectToChannel } from '../lib/channels.ts'; type EditableField = 'name' | 'schedule' | 'prompt' | 'agent_id' | 'target_channel_id'; interface Props { bird: CronJobRow; + channelOptions: { value: string; label: string }[]; onclose: () => void; } - let { bird, onclose }: Props = $props(); + let { bird, channelOptions, onclose }: Props = $props(); let flights: FlightRow[] = $state([]); let flightsLoading = $state(true); @@ -68,7 +70,7 @@ } function buildFlightsUrl(uid: string, page: number, filter: string, search: string): string { - let url = `/api/flights?birdUid=${uid}&perPage=10&sort=-started_at&page=${page}`; + let url = `/api/runs?birdUid=${uid}&perPage=10&sort=-started_at&page=${page}`; if (filter) url += `&status=${filter}`; if (search) url += `&search=${encodeURIComponent(search)}`; return url; @@ -141,8 +143,8 @@ enabledOverride = next; const action = next ? 'enable' : 'disable'; try { - await api(`/api/birds/${bird.uid}/${action}`, { method: 'PATCH' }); - showToast(`Bird ${shortUid(bird.uid)} ${action}d`, 'success'); + await api(`/api/automations/${bird.uid}/${action}`, { method: 'PATCH' }); + showToast(`Automation ${shortUid(bird.uid)} ${action}d`, 'success'); } catch (err) { enabledOverride = null; showToast(`Failed: ${(err as Error).message}`, 'error'); @@ -152,7 +154,8 @@ function startEdit(field: EditableField): void { if (isSystem) return; editingField = field; - editingValue = bird[field] ?? ''; + editingValue = + field === 'target_channel_id' ? channelToSelect(bird.target_channel_id) : (bird[field] ?? ''); } function cancelEdit(): void { @@ -163,13 +166,16 @@ async function saveEdit(): Promise<void> { if (!editingField) return; const trimmed = editingValue.trim(); - const original = (bird[editingField] ?? '').trim(); + const original = ( + editingField === 'target_channel_id' + ? channelToSelect(bird.target_channel_id) + : (bird[editingField] ?? '') + ).trim(); if (trimmed === original) { cancelEdit(); return; } - const clearable = editingField === 'target_channel_id'; - if (!trimmed && !clearable) { + if (!trimmed) { showToast('Value cannot be empty', 'error'); return; } @@ -182,9 +188,10 @@ target_channel_id: 'channel', }; try { - await api(`/api/birds/${bird.uid}`, { + const value = editingField === 'target_channel_id' ? selectToChannel(trimmed) : trimmed; + await api(`/api/automations/${bird.uid}`, { method: 'PATCH', - body: { [fieldMap[editingField]]: trimmed || null }, + body: { [fieldMap[editingField]]: value }, }); showToast(`Updated ${editingField.replace('_', ' ')}`, 'success'); cancelEdit(); @@ -197,10 +204,13 @@ async function flyBird(): Promise<void> { try { - const result = await api<{ success: boolean; jobId: number }>(`/api/birds/${bird.uid}/fly`, { - method: 'POST', - }); - showToast(`Bird ${shortUid(bird.uid)} sent on a flight (job #${result.jobId})`, 'success'); + const result = await api<{ success: boolean; jobId: number }>( + `/api/automations/${bird.uid}/run`, + { + method: 'POST', + }, + ); + showToast(`Automation ${shortUid(bird.uid)} run started (job #${result.jobId})`, 'success'); } catch (err) { showToast(`Failed: ${(err as Error).message}`, 'error'); } @@ -208,12 +218,14 @@ async function deleteBird(): Promise<void> { if ( - !(await showConfirm(`Delete bird "${bird.name}"? This will also remove all flight history.`)) + !(await showConfirm( + `Delete automation "${bird.name}"? This will also remove all run history.`, + )) ) return; try { - await api(`/api/birds/${bird.uid}`, { method: 'DELETE' }); - showToast(`Bird ${shortUid(bird.uid)} deleted`, 'success'); + await api(`/api/automations/${bird.uid}`, { method: 'DELETE' }); + showToast(`Automation ${shortUid(bird.uid)} deleted`, 'success'); close(); } catch (err) { showToast(`Failed: ${(err as Error).message}`, 'error'); @@ -222,7 +234,7 @@ async function retryFlight(flight: FlightRow): Promise<void> { try { - await api(`/api/birds/${flight.bird_uid}/fly`, { method: 'POST' }); + await api(`/api/automations/${flight.bird_uid}/run`, { method: 'POST' }); showToast(`Retrying ${flight.bird_name}`, 'success'); } catch (err) { showToast(`Failed: ${(err as Error).message}`, 'error'); @@ -311,7 +323,7 @@ <InlineEdit bind:value={editingValue} mono - placeholder="Channel ID" + options={channelOptions} saving={editingSaving} onsave={saveEdit} oncancel={cancelEdit} @@ -321,7 +333,7 @@ class="config-value mono" class:editable={!isSystem} onclick={() => startEdit('target_channel_id')} - >{bird.target_channel_id ?? 'default'}</button + >{bird.target_channel_id ?? 'none'}</button > {/if} </div> @@ -348,7 +360,7 @@ </div> <div class="actions-row"> - <button class="btn btn-outline btn-sm" onclick={flyBird}>Fly</button> + <button class="btn btn-outline btn-sm" onclick={flyBird}>Run</button> {#if !isSystem} <button class="btn btn-danger btn-sm" onclick={deleteBird}>Delete</button> {/if} @@ -358,7 +370,7 @@ <div class="flights-section"> <div class="flights-header"> - <span class="flights-title">Flight Log</span> + <span class="flights-title">Run History</span> {#if flightsTotalItems > 0} <span class="flights-count mono">{flightsTotalItems}</span> {/if} @@ -388,17 +400,17 @@ <input type="search" class="flight-search" - placeholder="Search flights..." + placeholder="Search runs..." value={flightSearch} oninput={handleSearchInput} /> </div> {#if flightsLoading} - <div class="flights-empty">Loading flights...</div> + <div class="flights-empty">Loading runs...</div> {:else if flights.length === 0} <div class="flights-empty"> - {flightSearch || statusFilter ? 'No matching flights' : 'No flights yet'} + {flightSearch || statusFilter ? 'No matching runs' : 'No runs yet'} </div> {:else} <div class="flight-list"> diff --git a/web/src/components/BindingEditor.svelte b/web/src/components/BindingEditor.svelte index d9c1acd..1f4c59b 100644 --- a/web/src/components/BindingEditor.svelte +++ b/web/src/components/BindingEditor.svelte @@ -15,16 +15,10 @@ let { bindings, endpoint, channels, birds, emptyLabel = '', onupdate }: Props = $props(); let adding = $state(false); - let bindingType: 'channel' | 'bird' = $state('channel'); let bindingTargetId = $state(''); let saving = $state(false); - const targets = $derived.by(() => { - if (bindingType === 'channel') { - return channels.map((ch) => ({ value: ch, label: ch })); - } - return birds.map((b) => ({ value: b.uid, label: b.name })); - }); + const targets = $derived(channels.map((ch) => ({ value: ch, label: ch }))); function bindingLabel(b: Binding): string { if (b.targetType === 'bird') { @@ -36,7 +30,6 @@ function toggleAdding(): void { adding = !adding; - bindingType = 'channel'; bindingTargetId = ''; } @@ -45,14 +38,14 @@ saving = true; try { const already = bindings.some( - (b) => b.targetType === bindingType && b.targetId === bindingTargetId, + (b) => b.targetType === 'channel' && b.targetId === bindingTargetId, ); if (already) { showToast('Binding already exists', 'info'); saving = false; return; } - const updated = [...bindings, { targetType: bindingType, targetId: bindingTargetId }]; + const updated = [...bindings, { targetType: 'channel' as const, targetId: bindingTargetId }]; await api(endpoint, { method: 'PUT', body: updated }); onupdate?.(updated); showToast('Binding added', 'success'); @@ -124,18 +117,8 @@ </button> {#if adding} <div class="binding-picker"> - <select - class="inline-select" - bind:value={bindingType} - onchange={() => { - bindingTargetId = ''; - }} - > - <option value="channel">Channel</option> - <option value="bird">Bird</option> - </select> <select class="inline-select binding-target" bind:value={bindingTargetId}> - <option value="">Select {bindingType}...</option> + <option value="">Select channel...</option> {#each targets as t} <option value={t.value}>{t.label}</option> {/each} diff --git a/web/src/components/InlineEdit.svelte b/web/src/components/InlineEdit.svelte index 944de22..5c365ee 100644 --- a/web/src/components/InlineEdit.svelte +++ b/web/src/components/InlineEdit.svelte @@ -4,6 +4,7 @@ mono?: boolean; multiline?: boolean; placeholder?: string; + options?: { value: string; label: string }[]; saving?: boolean; onsave: (value: string) => void; oncancel: () => void; @@ -14,6 +15,7 @@ mono = false, multiline = false, placeholder, + options, saving = false, onsave, oncancel, @@ -40,6 +42,10 @@ onkeydown={handleKeydown} {placeholder} ></textarea> + {:else if options} + <select class="inline-input" class:mono bind:value onkeydown={handleKeydown}> + {#each options as opt}<option value={opt.value}>{opt.label}</option>{/each} + </select> {:else} <input class="inline-input" diff --git a/web/src/components/Sidebar.svelte b/web/src/components/Sidebar.svelte index 7bf84a7..679fa26 100644 --- a/web/src/components/Sidebar.svelte +++ b/web/src/components/Sidebar.svelte @@ -1,4 +1,11 @@ <script lang="ts"> + interface NavItem { + page: string; + label: string; + svg: string; + match?: string[]; + } + interface Props { currentPage: string; collapsed: boolean; @@ -8,26 +15,27 @@ onsignout: () => void; } - const NAV_ITEMS = [ + const NAV_ITEMS: NavItem[] = [ { page: 'status', label: 'Mission Control', svg: `<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>`, }, { - page: 'sessions', - label: 'Sessions', - svg: `<path d="M16 10a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 14.286V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/><path d="M20 9a2 2 0 0 1 2 2v10.286a.71.71 0 0 1-1.212.502l-2.202-2.202A2 2 0 0 0 17.172 19H10a2 2 0 0 1-2-2v-1"/>`, + page: 'projects', + label: 'Projects', + svg: `<path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>`, }, { - page: 'docs', - label: 'Docs', - svg: `<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a1 1 0 0 0 1 1h3"/><path d="M10 13H8"/><path d="M16 17H8"/><path d="M16 13h-2"/>`, + page: 'resources', + label: 'Resources', + svg: `<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>`, + match: ['agents', 'skills', 'keys', 'automations'], }, { - page: 'birds', - label: 'Birds', - svg: `<path d="M16 7h.01"/><path d="M3.4 18H12a8 8 0 0 0 8-8V7a4 4 0 0 0-7.28-2.3L2 20"/><path d="m20 7 2 .5-2 .5"/><path d="M10 18v3"/><path d="M14 17.75V21"/><path d="M7 18a6 6 0 0 0 3.84-10.61"/>`, + page: 'sessions', + label: 'Sessions', + svg: `<path d="M16 10a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 14.286V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/><path d="M20 9a2 2 0 0 1 2 2v10.286a.71.71 0 0 1-1.212.502l-2.202-2.202A2 2 0 0 0 17.172 19H10a2 2 0 0 1-2-2v-1"/>`, }, { page: 'computer', @@ -39,10 +47,14 @@ label: 'Settings', svg: `<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/>`, }, - ] as const; + ]; let { currentPage, collapsed, mobileOpen, ontoggle, onmobileclose, onsignout }: Props = $props(); + function isActive(item: NavItem): boolean { + return currentPage === item.page || (item.match?.includes(currentPage) ?? false); + } + function handleNavClick(): void { onmobileclose(); } @@ -84,7 +96,7 @@ {#each NAV_ITEMS as item} <a class="nav-item" - class:active={currentPage === item.page} + class:active={isActive(item)} href="#/{item.page === 'status' ? '' : item.page}" title={collapsed ? item.label : undefined} onclick={handleNavClick} diff --git a/web/src/lib/binding-data.svelte.ts b/web/src/lib/binding-data.svelte.ts index 77bfb60..e289a20 100644 --- a/web/src/lib/binding-data.svelte.ts +++ b/web/src/lib/binding-data.svelte.ts @@ -2,6 +2,7 @@ import type { ConfigResponse, CronJobRow, PaginatedResult } from './types.ts'; import { api } from './api.ts'; +import { channelsFromConfig } from './channels.ts'; export function createBindingData() { let config = $state<ConfigResponse | null>(null); @@ -14,7 +15,7 @@ export function createBindingData() { if (!ac.signal.aborted) config = c; }) .catch(() => {}); - api<PaginatedResult<CronJobRow>>('/api/birds?perPage=100') + api<PaginatedResult<CronJobRow>>('/api/automations?perPage=100') .then((data) => { if (!ac.signal.aborted) birds = data.items.filter((b) => !b.name.startsWith('__bb_')); }) @@ -24,7 +25,7 @@ export function createBindingData() { return { get channels() { - return config?.slack.channels ?? []; + return channelsFromConfig(config); }, get birds() { return birds; diff --git a/web/src/lib/channels.ts b/web/src/lib/channels.ts new file mode 100644 index 0000000..4fde071 --- /dev/null +++ b/web/src/lib/channels.ts @@ -0,0 +1,53 @@ +/** + * @fileoverview Derives the set of known channels from config: the Slack listen + * list plus every channel any agent serves. This matches the union the Projects + * page uses, so a channel that exists only via an agent (e.g. a dedicated agent + * on `pr-review`) is still offered as a bind target. + */ + +import type { ConfigResponse } from './types.ts'; + +export function channelsFromConfig(config: ConfigResponse | null): string[] { + if (!config) return []; + const seen = new Set<string>(config.slack.channels); + for (const agent of config.agents) { + for (const ch of agent.channels) seen.add(ch); + } + return [...seen].sort(); +} + +/** + * Automation channel select options: concrete channels plus a leading "None + * (background)" entry (sentinel `BIRD_NONE`). `*` (all channels) is never a valid + * automation target, so it is excluded. The automation's current channel is always + * included so an edit can faithfully represent a channel not in config (e.g. a raw + * Slack id or a config-removed channel) instead of silently collapsing to None. + */ +export const BIRD_NONE = '__none__'; + +export function birdChannelOptions( + config: ConfigResponse | null, + current?: string | null, +): { value: string; label: string }[] { + const concrete = channelsFromConfig(config).filter((c) => c !== '*'); + if (current && !concrete.includes(current)) concrete.push(current); + return [ + { value: BIRD_NONE, label: 'None (background)' }, + ...concrete.map((c) => ({ value: c, label: c })), + ]; +} + +/** Maps a stored channel (null = background) to its select value. */ +export function channelToSelect(channel: string | null): string { + return channel ?? BIRD_NONE; +} + +/** Maps a select value back to a stored channel (BIRD_NONE -> null = background). */ +export function selectToChannel(value: string): string | null { + return value === BIRD_NONE ? null : value; +} + +/** Heuristic: a raw Slack channel/DM/group id (C/D/G + uppercase alphanumerics) vs a human name. */ +export function isRawChannelId(channel: string): boolean { + return /^[CDG][A-Z0-9]{7,}$/.test(channel); +} diff --git a/web/src/lib/config-editor.svelte.ts b/web/src/lib/config-editor.svelte.ts new file mode 100644 index 0000000..1ce6aa9 --- /dev/null +++ b/web/src/lib/config-editor.svelte.ts @@ -0,0 +1,118 @@ +/** + * @fileoverview Reactive config-editing helper: owns the `/api/config` state, + * inline-edit field state, and PATCH save logic, so config-save lives in one + * place (shared by Settings and the Agents page). Self-loads on creation and + * refetches on the `config` invalidate event. Call it once at component init. + */ + +import type { ConfigResponse } from './types.ts'; +import type { ConfigEditor } from '../pages/settings/types.ts'; +import { api } from './api.ts'; +import { showToast } from './toast.svelte.ts'; +import { onInvalidate } from './invalidate.ts'; + +export function createConfigEditor() { + let config = $state<ConfigResponse | null>(null); + let loading = $state(true); + let editingField: string | null = $state(null); + let editingSaving = $state(false); + + $effect(() => { + const ac = new AbortController(); + api<ConfigResponse>('/api/config') + .then((c) => { + if (!ac.signal.aborted) config = c; + }) + .catch(() => {}) + .finally(() => { + if (!ac.signal.aborted) loading = false; + }); + const unsub = onInvalidate((e) => { + if (e.resource === 'config') { + api<ConfigResponse>('/api/config') + .then((c) => { + config = c; + }) + .catch(() => {}); + } + }); + return () => { + ac.abort(); + unsub(); + }; + }); + + function buildPatch(path: string, value: unknown): Record<string, unknown> { + const parts = path.split('.'); + const root: Record<string, unknown> = {}; + let obj = root; + for (let i = 0; i < parts.length - 1; i++) { + const child: Record<string, unknown> = {}; + obj[parts[i]!] = child; + obj = child; + } + obj[parts[parts.length - 1]!] = value; + return root; + } + + async function saveConfigPatch(patch: Record<string, unknown>): Promise<boolean> { + try { + config = await api<ConfigResponse>('/api/config', { method: 'PATCH', body: patch }); + showToast('Config saved', 'success'); + return true; + } catch (err) { + showToast(`Failed: ${(err as Error).message}`, 'error'); + return false; + } + } + + function startEdit(field: string, _currentValue: string | number): void { + editingField = field; + editingSaving = false; + } + + function cancelEdit(): void { + editingField = null; + editingSaving = false; + } + + async function saveField( + field: string, + value: string, + transform?: (v: string) => unknown, + ): Promise<void> { + editingSaving = true; + const resolved = transform ? transform(value) : value; + const ok = await saveConfigPatch(buildPatch(field, resolved)); + if (ok) cancelEdit(); + else editingSaving = false; + } + + async function toggleField(field: string, currentValue: boolean): Promise<void> { + await saveConfigPatch(buildPatch(field, !currentValue)); + } + + const editor: ConfigEditor = { + get editingField() { + return editingField; + }, + get editingSaving() { + return editingSaving; + }, + startEdit, + cancelEdit, + saveField, + toggleField, + saveConfigPatch, + }; + + return { + get config() { + return config; + }, + get loading() { + return loading; + }, + editor, + }; +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 9cfea5e..3fa8f01 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -15,6 +15,62 @@ export interface PaginatedResult<T> { totalPages: number; } +export interface ProjectSummary { + channel: string; + isGlobal: boolean; + agentId: string | null; + agentName: string | null; + keyCount: number; + docCount: number; + automationCount: number; +} + +export type BindingSource = 'channel' | 'channel-global'; + +export interface ResolvedRef { + uid: string; + name: string; + source: BindingSource; + bindingCount: number; +} + +export interface ProjectAutomationMember { + uid: string; + name: string; + schedule: string; + enabled: boolean; + agentId: string; + agentName: string; +} + +export interface ProjectDetail { + channel: string; + isGlobal: boolean; + agent: ConfigResponse['agents'][number] | null; + agentShared: boolean; + keys: ResolvedRef[]; + docs: ResolvedRef[]; + automations: ProjectAutomationMember[]; + keyCount: number; + docCount: number; + automationCount: number; +} + +export interface ProjectBlueprint { + version: 1; + channel: string; + agent: { + name: string; + model: string; + systemPrompt: string; + permissionMode: string | null; + maxTurns: number; + } | null; + keys: string[]; + docs: Array<{ title: string; content: string }>; + automations: Array<{ name: string; schedule: string; prompt: string }>; +} + export interface FlightStats { running: number; completed: number; @@ -64,7 +120,7 @@ export interface ConfigResponse { channels: string[]; quietHours: { enabled: boolean; start: string; end: string; timezone: string }; }; - birds: { maxAttempts: number; maxConsecutiveFailures: number }; + automations: { maxAttempts: number; maxConsecutiveFailures: number }; browser: { enabled: boolean; mode: string; @@ -147,7 +203,7 @@ export interface CronJobRow { created_at: string; } -export interface UpcomingBird { +export interface UpcomingAutomation { uid: string; name: string; schedule: string; @@ -156,10 +212,10 @@ export interface UpcomingBird { } export interface DashboardResponse { - failingBirds: CronJobRow[]; - upcoming: UpcomingBird[]; - runningFlights: FlightRow[]; - recentFlights: FlightRow[]; + failingAutomations: CronJobRow[]; + upcoming: UpcomingAutomation[]; + runningRuns: FlightRow[]; + recentRuns: FlightRow[]; recentSessions: SessionRow[]; } diff --git a/web/src/pages/Agents.svelte b/web/src/pages/Agents.svelte new file mode 100644 index 0000000..0c23efa --- /dev/null +++ b/web/src/pages/Agents.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + import { createConfigEditor } from '../lib/config-editor.svelte.ts'; + import AgentSection from './settings/AgentSection.svelte'; + + const cfg = createConfigEditor(); +</script> + +{#if cfg.config} + {@const config = cfg.config} + <AgentSection {config} editor={cfg.editor} /> +{:else} + <div class="loading">Loading...</div> +{/if} diff --git a/web/src/pages/Birds.svelte b/web/src/pages/Automations.svelte similarity index 84% rename from web/src/pages/Birds.svelte rename to web/src/pages/Automations.svelte index e622996..765b4b0 100644 --- a/web/src/pages/Birds.svelte +++ b/web/src/pages/Automations.svelte @@ -1,14 +1,15 @@ <script lang="ts"> - import type { ColumnDef, CronJobRow, CreateCronRequest } from '../lib/types.ts'; + import type { ColumnDef, CronJobRow, CreateCronRequest, ConfigResponse } from '../lib/types.ts'; import { api, getHashParams } from '../lib/api.ts'; import { createDataTable } from '../lib/data-table.svelte.ts'; import { formatAge, timeStamp, shortUid } from '../lib/format.ts'; import { showToast } from '../lib/toast.svelte.ts'; import { showConfirm } from '../lib/confirm.svelte.ts'; import DataTable from '../components/DataTable.svelte'; - import BirdDrawer from '../components/BirdDrawer.svelte'; + import AutomationDrawer from '../components/AutomationDrawer.svelte'; import Badge from '../components/Badge.svelte'; import Toggle from '../components/Toggle.svelte'; + import { birdChannelOptions, channelToSelect, selectToChannel } from '../lib/channels.ts'; type EditableField = 'name' | 'schedule' | 'prompt' | 'agent_id' | 'target_channel_id'; @@ -34,7 +35,7 @@ let lastUpdated = $state(timeStamp()); const table = createDataTable<CronJobRow>({ - endpoint: '/api/birds', + endpoint: '/api/automations', columns, defaultSort: 'created_at', invalidateOn: 'birds', @@ -51,6 +52,20 @@ let formAgent = $state(''); let submitting = $state(false); + let config = $state<ConfigResponse | null>(null); + const channelOptions = $derived(birdChannelOptions(config)); + $effect(() => { + let cancelled = false; + api<ConfigResponse>('/api/config') + .then((c) => { + if (!cancelled) config = c; + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }); + let editing: EditingCell | null = $state(null); let saving = $state(false); @@ -84,8 +99,8 @@ async function toggleCron(uid: string, currentlyEnabled: boolean): Promise<void> { const action = currentlyEnabled ? 'disable' : 'enable'; try { - await api(`/api/birds/${uid}/${action}`, { method: 'PATCH' }); - showToast(`Bird ${shortUid(uid)} ${action}d`, 'success'); + await api(`/api/automations/${uid}/${action}`, { method: 'PATCH' }); + showToast(`Automation ${shortUid(uid)} ${action}d`, 'success'); } catch (err) { showToast(`Failed: ${(err as Error).message}`, 'error'); } @@ -109,6 +124,10 @@ showToast('Schedule and prompt are required', 'error'); return; } + if (!formChannel) { + showToast('Pick a channel or background', 'error'); + return; + } submitting = true; try { const body: CreateCronRequest = { @@ -116,10 +135,11 @@ prompt: formPrompt.trim(), }; if (formName.trim()) body.name = formName.trim(); - if (formChannel.trim()) body.channel = formChannel.trim(); + const ch = selectToChannel(formChannel); + if (ch) body.channel = ch; if (formAgent.trim()) body.agent = formAgent.trim(); - await api('/api/birds', { method: 'POST', body }); - showToast('Bird created', 'success'); + await api('/api/automations', { method: 'POST', body }); + showToast('Automation created', 'success'); closeForm(); } catch (err) { showToast(`Failed: ${(err as Error).message}`, 'error'); @@ -130,7 +150,8 @@ function startCellEdit(job: CronJobRow, field: EditableField): void { if (job.name.startsWith('__bb_')) return; - const value = job[field] ?? ''; + const value = + field === 'target_channel_id' ? channelToSelect(job.target_channel_id) : (job[field] ?? ''); editing = { uid: job.uid, field, value, original: value }; } @@ -145,8 +166,7 @@ editing = null; return; } - const clearable = editing.field === 'target_channel_id'; - if (!trimmed && !clearable) { + if (!trimmed) { showToast('Value cannot be empty', 'error'); return; } @@ -159,9 +179,10 @@ target_channel_id: 'channel', }; try { - await api(`/api/birds/${editing.uid}`, { + const value = editing.field === 'target_channel_id' ? selectToChannel(trimmed) : trimmed; + await api(`/api/automations/${editing.uid}`, { method: 'PATCH', - body: { [fieldMap[editing.field]]: trimmed || null }, + body: { [fieldMap[editing.field]]: value }, }); showToast(`Updated ${editing.field.replace('_', ' ')}`, 'success'); editing = null; @@ -190,21 +211,21 @@ async function runCron(uid: string): Promise<void> { try { - const result = await api<{ success: boolean; jobId: number }>(`/api/birds/${uid}/fly`, { + const result = await api<{ success: boolean; jobId: number }>(`/api/automations/${uid}/run`, { method: 'POST', }); - showToast(`Bird ${shortUid(uid)} sent on a flight (job #${result.jobId})`, 'success'); + showToast(`Automation ${shortUid(uid)} run started (job #${result.jobId})`, 'success'); } catch (err) { showToast(`Failed: ${(err as Error).message}`, 'error'); } } async function deleteCron(uid: string, name: string): Promise<void> { - if (!(await showConfirm(`Delete bird "${name}"? This will also remove all flight history.`))) + if (!(await showConfirm(`Delete automation "${name}"? This will also remove all run history.`))) return; try { - await api(`/api/birds/${uid}`, { method: 'DELETE' }); - showToast(`Bird ${shortUid(uid)} deleted`, 'success'); + await api(`/api/automations/${uid}`, { method: 'DELETE' }); + showToast(`Automation ${shortUid(uid)} deleted`, 'success'); } catch (err) { showToast(`Failed: ${(err as Error).message}`, 'error'); } @@ -216,7 +237,7 @@ {:else} {#if showForm} <div class="create-form"> - <div class="form-title">New Bird</div> + <div class="form-title">New Automation</div> <div class="form-row"> <label class="form-label"> Name @@ -232,12 +253,20 @@ /> </label> <label class="form-label"> - Channel ID - <input class="form-input" type="text" placeholder="Optional" bind:value={formChannel} /> + Channel + <select class="form-input" bind:value={formChannel}> + <option value="" disabled selected>Select target...</option> + {#each channelOptions as opt}<option value={opt.value}>{opt.label}</option>{/each} + </select> </label> <label class="form-label"> Agent ID - <input class="form-input" type="text" placeholder="default" bind:value={formAgent} /> + <input + class="form-input" + type="text" + placeholder="blank = channel's agent" + bind:value={formAgent} + /> </label> </div> <label class="form-label"> @@ -305,14 +334,14 @@ <DataTable {columns} isEmpty={table.items.length === 0} - emptyMessage="No birds configured" + emptyMessage="No automations configured" fetching={table.fetching} page={table.page} totalPages={table.totalPages} totalItems={table.totalItems} sort={table.sort} search={table.search} - searchPlaceholder="Search birds..." + searchPlaceholder="Search automations..." onPageChange={table.setPage} onSortChange={table.setSort} onSearchChange={table.setSearch} @@ -323,7 +352,7 @@ onclick={() => { if (showForm) closeForm(); else openCreate(); - }}>{showForm ? 'Cancel' : 'Add Bird'}</button + }}>{showForm ? 'Cancel' : 'Add Automation'}</button > <div class="filter-spacer"></div> <span class="last-updated">Updated {lastUpdated}</span> @@ -369,7 +398,7 @@ {/if} </div> <div class="card-actions" role="none" onclick={(e) => e.stopPropagation()}> - <button class="btn btn-outline btn-sm" onclick={() => runCron(j.uid)}>Fly</button> + <button class="btn btn-outline btn-sm" onclick={() => runCron(j.uid)}>Run</button> {#if !j.name.startsWith('__bb_')} <button class="btn btn-danger btn-sm" onclick={() => deleteCron(j.uid, j.name)} >Delete</button @@ -452,17 +481,18 @@ <span class="mono cell-text" class:cell-text-hidden={isEditingCell(j.uid, 'target_channel_id')} - >{j.target_channel_id ?? '-'}</span + >{j.target_channel_id ?? 'none'}</span > {#if isEditingCell(j.uid, 'target_channel_id')} + {@const cellOpts = birdChannelOptions(config, j.target_channel_id)} <div class="cell-edit-overlay"> - <input + <select class="cell-input mono" - type="text" - placeholder="Channel ID" bind:value={editing!.value} onkeydown={handleCellKeydown} - /> + > + {#each cellOpts as opt}<option value={opt.value}>{opt.label}</option>{/each} + </select> {@render cellActions()} </div> {/if} @@ -484,7 +514,7 @@ </td> <td> <div class="actions-cell"> - <button class="btn btn-outline btn-sm" onclick={() => runCron(j.uid)}>Fly</button> + <button class="btn btn-outline btn-sm" onclick={() => runCron(j.uid)}>Run</button> {#if !isSystem} <button class="btn btn-danger btn-sm" onclick={() => deleteCron(j.uid, j.name)} >Delete</button @@ -497,8 +527,9 @@ </DataTable> {#if selectedBird} - <BirdDrawer + <AutomationDrawer bird={selectedBird} + channelOptions={birdChannelOptions(config, selectedBird.target_channel_id)} onclose={() => { selectedBirdUid = null; }} diff --git a/web/src/pages/Keys.svelte b/web/src/pages/Keys.svelte new file mode 100644 index 0000000..8d3f44b --- /dev/null +++ b/web/src/pages/Keys.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + import { createConfigEditor } from '../lib/config-editor.svelte.ts'; + import KeysTab from './settings/KeysTab.svelte'; + + const cfg = createConfigEditor(); +</script> + +{#if cfg.config} + {@const config = cfg.config} + <KeysTab {config} /> +{:else} + <div class="loading">Loading...</div> +{/if} diff --git a/web/src/pages/MissionControl.svelte b/web/src/pages/MissionControl.svelte index a0dc67a..4ea11ca 100644 --- a/web/src/pages/MissionControl.svelte +++ b/web/src/pages/MissionControl.svelte @@ -5,7 +5,7 @@ CronJobRow, FlightRow, SessionRow, - UpcomingBird, + UpcomingAutomation, } from '../lib/types.ts'; import { api } from '../lib/api.ts'; import { @@ -26,17 +26,17 @@ let { status }: Props = $props(); - let failingBirds: CronJobRow[] = $state([]); - let upcomingBirds: UpcomingBird[] = $state([]); - let runningFlights: FlightRow[] = $state([]); - let recentFlights: FlightRow[] = $state([]); + let failingAutomations: CronJobRow[] = $state([]); + let upcomingBirds: UpcomingAutomation[] = $state([]); + let runningRuns: FlightRow[] = $state([]); + let recentRuns: FlightRow[] = $state([]); let recentSessions: SessionRow[] = $state([]); let lastUpdated = $state(timeStamp()); let loading = $state(true); const failingColumns = [ { key: 'uid', label: 'ID' }, - { key: 'name', label: 'Bird' }, + { key: 'name', label: 'Automation' }, { key: 'failure_count', label: 'Failures' }, { key: 'last_status', label: 'Status' }, { key: 'last_run', label: 'Last Run' }, @@ -45,14 +45,14 @@ const upcomingColumns = [ { key: 'uid', label: 'ID' }, - { key: 'name', label: 'Bird' }, + { key: 'name', label: 'Automation' }, { key: 'schedule', label: 'Schedule' }, { key: 'next_run', label: 'Next Run' }, ]; const flightColumns = [ { key: 'uid', label: 'ID' }, - { key: 'bird_name', label: 'Bird' }, + { key: 'bird_name', label: 'Automation' }, { key: 'status', label: 'Status' }, { key: 'duration', label: 'Duration' }, { key: 'started_at', label: 'Started' }, @@ -70,10 +70,10 @@ try { const data = await api<DashboardResponse>('/api/dashboard'); if (signal.aborted) return; - failingBirds = data.failingBirds; + failingAutomations = data.failingAutomations; upcomingBirds = data.upcoming; - runningFlights = data.runningFlights; - recentFlights = data.recentFlights; + runningRuns = data.runningRuns; + recentRuns = data.recentRuns; recentSessions = data.recentSessions; lastUpdated = timeStamp(); } catch { @@ -96,8 +96,8 @@ async function flyBird(uid: string): Promise<void> { try { - await api(`/api/birds/${uid}/fly`, { method: 'POST' }); - showToast(`Bird ${shortUid(uid)} sent on a flight`, 'success'); + await api(`/api/automations/${uid}/run`, { method: 'POST' }); + showToast(`Automation ${shortUid(uid)} run started`, 'success'); } catch (err) { showToast(`Failed: ${(err as Error).message}`, 'error'); } @@ -110,7 +110,7 @@ <div class="num-grid"> <div class="num-card"> <div class="num-head"> - <span class="num-label">Flights</span> + <span class="num-label">Runs</span> <span class="num-badge mono" >{status.processes.active}/{status.processes.maxConcurrent} processes</span > @@ -146,18 +146,21 @@ </div> </div> - {#if failingBirds.length > 0} + {#if failingAutomations.length > 0} <div class="section"> <div class="section-header"> - <h2 class="section-title section-title-error">Failing Birds</h2> + <h2 class="section-title section-title-error">Failing Automations</h2> <span class="last-updated">Updated {lastUpdated}</span> </div> <DataTable columns={failingColumns} isEmpty={false}> - {#each failingBirds as bird (bird.uid)} + {#each failingAutomations as bird (bird.uid)} <tr> <td class="mono">{shortUid(bird.uid)}</td> <td> - <a class="bird-link" href="#/birds?search={encodeURIComponent(shortUid(bird.uid))}"> + <a + class="bird-link" + href="#/automations?search={encodeURIComponent(shortUid(bird.uid))}" + > {bird.name} </a> </td> @@ -176,17 +179,17 @@ </div> {/if} - {#if runningFlights.length > 0} + {#if runningRuns.length > 0} <div class="section"> <div class="section-header"> - <h2 class="section-title section-title-active">Active Flights</h2> + <h2 class="section-title section-title-active">Active Runs</h2> </div> <DataTable columns={flightColumns} isEmpty={false}> - {#each runningFlights as f (f.uid)} + {#each runningRuns as f (f.uid)} <tr class="clickable-row" onclick={() => { - window.location.hash = `#/birds?expand=${f.bird_uid}`; + window.location.hash = `#/automations?expand=${f.bird_uid}`; }} > <td class="mono">{shortUid(f.uid)}</td> @@ -210,7 +213,7 @@ <tr class="clickable-row" onclick={() => { - window.location.hash = `#/birds?search=${encodeURIComponent(shortUid(bird.uid))}`; + window.location.hash = `#/automations?search=${encodeURIComponent(shortUid(bird.uid))}`; }} > <td class="mono">{shortUid(bird.uid)}</td> @@ -225,19 +228,19 @@ <div class="section"> <div class="section-header"> - <h2 class="section-title">Recent Flights</h2> - <a href="#/birds" class="section-link">View all</a> + <h2 class="section-title">Recent Runs</h2> + <a href="#/automations" class="section-link">View all</a> </div> <DataTable columns={flightColumns} - isEmpty={recentFlights.length === 0} - emptyMessage="No flights recorded" + isEmpty={recentRuns.length === 0} + emptyMessage="No runs recorded" > - {#each recentFlights as f (f.uid)} + {#each recentRuns as f (f.uid)} <tr class="clickable-row" onclick={() => { - window.location.hash = `#/birds?expand=${f.bird_uid}`; + window.location.hash = `#/automations?expand=${f.bird_uid}`; }} > <td class="mono">{shortUid(f.uid)}</td> diff --git a/web/src/pages/Onboarding.svelte b/web/src/pages/Onboarding.svelte index 5ff0f85..7199e44 100644 --- a/web/src/pages/Onboarding.svelte +++ b/web/src/pages/Onboarding.svelte @@ -19,7 +19,7 @@ const STEPS = ['Agent', 'Slack', 'Browser'] as const; const SLACK_MANIFEST_URL = - 'https://api.slack.com/apps?new_app=1&manifest_json=%7B%22display_information%22%3A%7B%22name%22%3A%22BrowserBird%22%2C%22description%22%3A%22A%20self-hosted%20AI%20assistant%20in%20Slack%2C%20with%20a%20real%20browser%20and%20a%20scheduler.%22%2C%22background_color%22%3A%22%231a1a2e%22%7D%2C%22features%22%3A%7B%22assistant_view%22%3A%7B%22assistant_description%22%3A%22A%20self-hosted%20AI%20assistant%20in%20Slack%2C%20with%20a%20real%20browser%20and%20a%20scheduler.%22%7D%2C%22app_home%22%3A%7B%22home_tab_enabled%22%3Atrue%2C%22messages_tab_enabled%22%3Atrue%2C%22messages_tab_read_only_enabled%22%3Afalse%7D%2C%22bot_user%22%3A%7B%22display_name%22%3A%22BrowserBird%22%2C%22always_online%22%3Atrue%7D%2C%22slash_commands%22%3A%5B%7B%22command%22%3A%22%2Fbird%22%2C%22description%22%3A%22Manage%20BrowserBird%20birds%22%2C%22usage_hint%22%3A%22list%20%7C%20fly%20%7C%20stop%20%7C%20logs%20%7C%20enable%20%7C%20disable%20%7C%20create%20%7C%20status%22%2C%22should_escape%22%3Afalse%7D%5D%7D%2C%22oauth_config%22%3A%7B%22scopes%22%3A%7B%22bot%22%3A%5B%22app_mentions%3Aread%22%2C%22assistant%3Awrite%22%2C%22channels%3Ahistory%22%2C%22channels%3Aread%22%2C%22chat%3Awrite%22%2C%22files%3Aread%22%2C%22files%3Awrite%22%2C%22groups%3Ahistory%22%2C%22groups%3Aread%22%2C%22im%3Ahistory%22%2C%22im%3Aread%22%2C%22im%3Awrite%22%2C%22mpim%3Ahistory%22%2C%22mpim%3Aread%22%2C%22reactions%3Aread%22%2C%22reactions%3Awrite%22%2C%22users%3Aread%22%2C%22commands%22%5D%7D%7D%2C%22settings%22%3A%7B%22event_subscriptions%22%3A%7B%22bot_events%22%3A%5B%22app_mention%22%2C%22assistant_thread_context_changed%22%2C%22assistant_thread_started%22%2C%22message.channels%22%2C%22message.groups%22%2C%22message.im%22%2C%22message.mpim%22%2C%22app_home_opened%22%5D%7D%2C%22interactivity%22%3A%7B%22is_enabled%22%3Atrue%7D%2C%22org_deploy_enabled%22%3Afalse%2C%22socket_mode_enabled%22%3Atrue%2C%22token_rotation_enabled%22%3Afalse%7D%7D'; + 'https://api.slack.com/apps?new_app=1&manifest_json=%7B%22display_information%22%3A%7B%22name%22%3A%22BrowserBird%22%2C%22description%22%3A%22A%20self-hosted%20AI%20assistant%20in%20Slack%2C%20with%20a%20real%20browser%20and%20a%20scheduler.%22%2C%22background_color%22%3A%22%231a1a2e%22%7D%2C%22features%22%3A%7B%22assistant_view%22%3A%7B%22assistant_description%22%3A%22A%20self-hosted%20AI%20assistant%20in%20Slack%2C%20with%20a%20real%20browser%20and%20a%20scheduler.%22%7D%2C%22app_home%22%3A%7B%22home_tab_enabled%22%3Atrue%2C%22messages_tab_enabled%22%3Atrue%2C%22messages_tab_read_only_enabled%22%3Afalse%7D%2C%22bot_user%22%3A%7B%22display_name%22%3A%22BrowserBird%22%2C%22always_online%22%3Atrue%7D%2C%22slash_commands%22%3A%5B%7B%22command%22%3A%22%2Fautomation%22%2C%22description%22%3A%22Manage%20BrowserBird%20automations%22%2C%22usage_hint%22%3A%22list%20%7C%20run%20%7C%20stop%20%7C%20logs%20%7C%20enable%20%7C%20disable%20%7C%20create%20%7C%20status%22%2C%22should_escape%22%3Afalse%7D%5D%7D%2C%22oauth_config%22%3A%7B%22scopes%22%3A%7B%22bot%22%3A%5B%22app_mentions%3Aread%22%2C%22assistant%3Awrite%22%2C%22channels%3Ahistory%22%2C%22channels%3Aread%22%2C%22chat%3Awrite%22%2C%22files%3Aread%22%2C%22files%3Awrite%22%2C%22groups%3Ahistory%22%2C%22groups%3Aread%22%2C%22im%3Ahistory%22%2C%22im%3Aread%22%2C%22im%3Awrite%22%2C%22mpim%3Ahistory%22%2C%22mpim%3Aread%22%2C%22reactions%3Aread%22%2C%22reactions%3Awrite%22%2C%22users%3Aread%22%2C%22commands%22%5D%7D%7D%2C%22settings%22%3A%7B%22event_subscriptions%22%3A%7B%22bot_events%22%3A%5B%22app_mention%22%2C%22assistant_thread_context_changed%22%2C%22assistant_thread_started%22%2C%22message.channels%22%2C%22message.groups%22%2C%22message.im%22%2C%22message.mpim%22%2C%22app_home_opened%22%5D%7D%2C%22interactivity%22%3A%7B%22is_enabled%22%3Atrue%7D%2C%22org_deploy_enabled%22%3Afalse%2C%22socket_mode_enabled%22%3Atrue%2C%22token_rotation_enabled%22%3Afalse%7D%7D'; let step = $state(0); let loading = $state(false); @@ -414,18 +414,19 @@ </div> <div class="done-tip"> <span class="done-tip-label">Slash commands</span> - <span class="done-tip-detail">Type /bird in Slack to manage scheduled tasks</span> + <span class="done-tip-detail">Type /automation in Slack to manage scheduled tasks</span> </div> <div class="done-tip"> <span class="done-tip-label">Scheduled tasks</span> <span class="done-tip-detail" - >Create birds from the dashboard, via /bird create, or ask your bot in Slack</span + >Create automations from the dashboard, via /automation create, or ask your bot in + Slack</span > </div> {:else} <div class="done-tip"> - <span class="done-tip-label">Create birds</span> - <span class="done-tip-detail">Schedule tasks from the Birds page or the CLI</span> + <span class="done-tip-label">Create automations</span> + <span class="done-tip-detail">Schedule tasks from the Automations page or the CLI</span> </div> <div class="done-tip"> <span class="done-tip-label">Add Slack later</span> diff --git a/web/src/pages/ProjectCreate.svelte b/web/src/pages/ProjectCreate.svelte new file mode 100644 index 0000000..0547a8c --- /dev/null +++ b/web/src/pages/ProjectCreate.svelte @@ -0,0 +1,431 @@ +<script lang="ts"> + import type { ConfigResponse } from '../lib/types.ts'; + import { api } from '../lib/api.ts'; + import { showToast } from '../lib/toast.svelte.ts'; + + const STEPS = ['Channel', 'Keys', 'Skill', 'Automation', 'Review'] as const; + + let config = $state<ConfigResponse | null>(null); + let step = $state(0); + let creating = $state(false); + let error = $state(''); + + let channel = $state(''); + let agentId = $state(''); + + let keys = $state<Array<{ name: string; value: string }>>([{ name: '', value: '' }]); + + let docTitle = $state(''); + let docContent = $state(''); + + let automationName = $state(''); + let automationSchedule = $state(''); + let automationPrompt = $state(''); + + $effect(() => { + const ac = new AbortController(); + api<ConfigResponse>('/api/config') + .then((c) => { + if (ac.signal.aborted) return; + config = c; + if (c.agents.length > 0 && !agentId) agentId = c.agents[0]!.id; + }) + .catch(() => {}); + return () => ac.abort(); + }); + + const channels = $derived(config?.slack.channels.filter((c) => c !== '*') ?? []); + const agents = $derived(config?.agents ?? []); + const validKeys = $derived(keys.filter((k) => k.name.trim() && k.value.trim())); + const hasAutomation = $derived( + automationSchedule.trim() !== '' && automationPrompt.trim() !== '', + ); + const canProceed = $derived(channel.trim() !== '' && agentId !== ''); + + function addKeyRow(): void { + keys = [...keys, { name: '', value: '' }]; + } + + function removeKeyRow(i: number): void { + keys = keys.filter((_, idx) => idx !== i); + if (keys.length === 0) keys = [{ name: '', value: '' }]; + } + + function next(): void { + error = ''; + if (step < STEPS.length - 1) step += 1; + } + + function back(): void { + error = ''; + if (step > 0) step -= 1; + } + + async function assignAgentToChannel(): Promise<void> { + if (!config) return; + const agent = config.agents.find((a) => a.id === agentId); + if (!agent) return; + if (agent.channels.includes(channel) || agent.channels.includes('*')) return; + const agents = config.agents.map((a) => + a.id === agentId ? { ...a, channels: [...a.channels, channel] } : a, + ); + await api('/api/config', { method: 'PATCH', body: { agents } }); + } + + async function createKeys(ch: string): Promise<void> { + await Promise.all( + validKeys.map((k) => + api('/api/keys', { + method: 'POST', + body: { + name: k.name.trim().toUpperCase(), + value: k.value, + redact: true, + bindings: [{ targetType: 'channel', targetId: ch }], + }, + }), + ), + ); + } + + async function createDoc(ch: string): Promise<void> { + if (!docTitle.trim()) return; + const doc = await api<{ uid: string }>('/api/skills', { + method: 'POST', + body: { title: docTitle.trim() }, + }); + if (docContent.trim()) { + await api(`/api/skills/${doc.uid}`, { method: 'PATCH', body: { content: docContent } }); + } + await api(`/api/skills/${doc.uid}/bindings`, { + method: 'PUT', + body: [{ targetType: 'channel', targetId: ch }], + }); + } + + async function createAutomation(ch: string): Promise<void> { + if (!hasAutomation) return; + await api('/api/automations', { + method: 'POST', + body: { + schedule: automationSchedule.trim(), + prompt: automationPrompt.trim(), + name: automationName.trim() || undefined, + channel: ch, + agent: agentId, + }, + }); + } + + async function submit(): Promise<void> { + const ch = channel.trim(); + if (!ch || !agentId) { + error = 'A channel and an agent are required.'; + return; + } + creating = true; + error = ''; + try { + await assignAgentToChannel(); + await Promise.all([createKeys(ch), createDoc(ch), createAutomation(ch)]); + showToast('Project created', 'success'); + window.location.hash = `#/project-detail?id=${encodeURIComponent(ch)}`; + } catch (err) { + error = (err as Error).message; + } finally { + creating = false; + } + } +</script> + +<a class="ws-back" href="#/projects">← Projects</a> + +<div class="wiz"> + <div class="step-indicator"> + {#each STEPS as label, i} + <div class="step-dot" class:active={i === step} class:completed={i < step}> + <span class="step-number">{i + 1}</span> + </div> + {#if i < STEPS.length - 1} + <div class="step-line" class:completed={i < step}></div> + {/if} + {/each} + </div> + + <h2 class="wiz-heading">{STEPS[step]}</h2> + + {#if step === 0} + <p class="wiz-hint"> + A project is keyed by a channel and served by one agent. Messages in this channel route to the + agent you pick. + </p> + <label class="form-label"> + Channel + <input + class="form-input" + type="text" + placeholder="billing" + list="ws-channels" + bind:value={channel} + /> + </label> + <datalist id="ws-channels"> + {#each channels as c}<option value={c}></option>{/each} + </datalist> + <label class="form-label"> + Agent + {#if agents.length === 0} + <span class="wiz-warn">No agents configured. Create one in Settings first.</span> + {:else} + <select class="form-input" bind:value={agentId}> + {#each agents as a}<option value={a.id}>{a.name} ({a.model})</option>{/each} + </select> + {/if} + </label> + {:else if step === 1} + <p class="wiz-hint"> + Vault keys are injected into the agent and bound to this channel only. Leave blank to skip. + </p> + {#each keys as key, i} + <div class="key-row"> + <input class="form-input" type="text" placeholder="GITHUB_TOKEN" bind:value={key.name} /> + <input + class="form-input" + type="password" + placeholder="secret value" + bind:value={key.value} + /> + <button class="btn btn-outline btn-sm" onclick={() => removeKeyRow(i)} aria-label="Remove"> + × + </button> + </div> + {/each} + <button class="btn btn-outline btn-sm" onclick={addKeyRow}>+ Add key</button> + {:else if step === 2} + <p class="wiz-hint"> + A skill (agent instructions) is prepended to the agent's prompt in this channel. Optional. + </p> + <label class="form-label"> + Title + <input + class="form-input" + type="text" + placeholder="Billing review guidelines" + bind:value={docTitle} + /> + </label> + <label class="form-label"> + Content + <textarea + class="form-input form-textarea" + rows="6" + placeholder="Markdown context for the agent..." + bind:value={docContent} + ></textarea> + </label> + {:else if step === 3} + <p class="wiz-hint"> + An automation runs the agent on a schedule and posts to this channel. Optional. Provide both a + schedule and a prompt to create one. + </p> + <label class="form-label"> + Name + <input + class="form-input" + type="text" + placeholder="weekly cost review" + bind:value={automationName} + /> + </label> + <label class="form-label"> + Schedule (cron) + <input + class="form-input" + type="text" + placeholder="0 9 * * 1" + bind:value={automationSchedule} + /> + </label> + <label class="form-label"> + Prompt + <textarea + class="form-input form-textarea" + rows="4" + placeholder="Summarize this week's AWS spend..." + bind:value={automationPrompt} + ></textarea> + </label> + {:else} + <div class="review"> + <div class="review-row"><span>Channel</span><code>{channel || '-'}</code></div> + <div class="review-row"> + <span>Agent</span><span>{agents.find((a) => a.id === agentId)?.name ?? '-'}</span> + </div> + <div class="review-row"> + <span>Keys</span> + <span + >{validKeys.length ? validKeys.map((k) => k.name.toUpperCase()).join(', ') : 'none'}</span + > + </div> + <div class="review-row"><span>Skill</span><span>{docTitle.trim() || 'none'}</span></div> + <div class="review-row"> + <span>Automation</span><span + >{hasAutomation ? `${automationName || 'unnamed'} (${automationSchedule})` : 'none'}</span + > + </div> + </div> + {/if} + + {#if error}<div class="wiz-error">{error}</div>{/if} + + <div class="wiz-actions"> + {#if step > 0} + <button class="btn btn-outline btn-sm" onclick={back} disabled={creating}>Back</button> + {/if} + <div class="wiz-spacer"></div> + {#if step < STEPS.length - 1} + <button class="btn btn-primary btn-sm" onclick={next} disabled={step === 0 && !canProceed}> + Next + </button> + {:else} + <button class="btn btn-primary btn-sm" onclick={submit} disabled={creating || !canProceed}> + {creating ? 'Creating...' : 'Create Project'} + </button> + {/if} + </div> +</div> + +<style> + .ws-back { + display: inline-block; + margin-bottom: var(--space-4); + font-size: var(--text-sm); + color: var(--color-text-secondary); + } + + .ws-back:hover { + color: var(--color-text-primary); + } + + .wiz { + max-width: 560px; + } + + .step-indicator { + display: flex; + align-items: center; + margin-bottom: var(--space-5); + } + + .step-dot { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: var(--radius-full); + border: 1px solid var(--color-border-subtle); + color: var(--color-text-muted); + font-size: var(--text-xs); + flex-shrink: 0; + } + + .step-dot.active { + border-color: var(--color-accent); + color: var(--color-accent-glow); + } + + .step-dot.completed { + background: var(--color-accent-dim); + border-color: var(--color-accent-dim); + color: var(--color-text-primary); + } + + .step-line { + flex: 1; + height: 1px; + background: var(--color-border); + } + + .step-line.completed { + background: var(--color-accent-dim); + } + + .wiz-heading { + font-size: var(--text-lg); + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: var(--space-2); + } + + .wiz-hint { + font-size: var(--text-sm); + color: var(--color-text-muted); + margin-bottom: var(--space-4); + max-width: 52ch; + } + + .wiz-warn { + display: block; + margin-top: var(--space-1); + color: var(--color-warning); + font-size: var(--text-sm); + } + + .key-row { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: var(--space-2); + margin-bottom: var(--space-2); + } + + .review { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-2); + } + + .review-row { + display: flex; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-2); + font-size: var(--text-sm); + border-bottom: 1px solid var(--color-border); + } + + .review-row:last-child { + border-bottom: none; + } + + .review-row span:first-child { + color: var(--color-text-muted); + } + + .review-row span:last-child, + .review-row code { + color: var(--color-text-primary); + text-align: right; + } + + .wiz-error { + margin-top: var(--space-3); + color: var(--color-error); + background: var(--color-error-bg); + padding: var(--space-2-5); + border-radius: var(--radius-sm); + font-size: var(--text-sm); + } + + .wiz-actions { + display: flex; + align-items: center; + gap: var(--space-2); + margin-top: var(--space-5); + } + + .wiz-spacer { + flex: 1; + } +</style> diff --git a/web/src/pages/ProjectDetail.svelte b/web/src/pages/ProjectDetail.svelte new file mode 100644 index 0000000..aa74343 --- /dev/null +++ b/web/src/pages/ProjectDetail.svelte @@ -0,0 +1,813 @@ +<script lang="ts"> + import type { + ProjectDetail, + ResolvedRef, + ConfigResponse, + PermissionMode, + KeyInfo, + DocInfo, + CronJobRow, + PaginatedResult, + Binding, + } from '../lib/types.ts'; + import { PERMISSION_MODES } from '../lib/types.ts'; + import { api, getHashParams } from '../lib/api.ts'; + import { showToast } from '../lib/toast.svelte.ts'; + import { onInvalidate } from '../lib/invalidate.ts'; + import { isRawChannelId } from '../lib/channels.ts'; + + let project = $state<ProjectDetail | null>(null); + let config = $state<ConfigResponse | null>(null); + let allKeys = $state<KeyInfo[]>([]); + let allDocs = $state<DocInfo[]>([]); + let allAutomations = $state<CronJobRow[]>([]); + let loading = $state(true); + let busy = $state(false); + + let open = $state<string | null>(null); + let addKind = $state<'key' | 'doc' | 'automation' | null>(null); + let addTab = $state<'existing' | 'new'>('existing'); + let editAgent = $state(false); + + const channel = $derived(getHashParams().get('id') ?? ''); + const isRawId = $derived(isRawChannelId(channel)); + + async function load(): Promise<void> { + if (!channel) return; + try { + const [n, c, ks, ds, bs] = await Promise.all([ + api<ProjectDetail>(`/api/projects/${encodeURIComponent(channel)}`), + api<ConfigResponse>('/api/config'), + api<PaginatedResult<KeyInfo>>('/api/keys?perPage=100'), + api<PaginatedResult<DocInfo>>('/api/skills?perPage=100'), + api<PaginatedResult<CronJobRow>>('/api/automations?perPage=100'), + ]); + project = n; + config = c; + allKeys = ks.items; + allDocs = ds.items; + allAutomations = bs.items.filter((b) => !b.name.startsWith('__bb_')); + } catch (err) { + showToast(`Failed to load: ${(err as Error).message}`, 'error'); + } finally { + loading = false; + } + } + + $effect(() => { + void load(); + const unsubs = (['keys', 'docs', 'birds', 'config'] as const).map((r) => + onInvalidate((e) => { + if (e.resource === r) void load(); + }), + ); + return () => unsubs.forEach((u) => u()); + }); + + const title = $derived(project?.isGlobal ? 'Global' : channel); + const realAutomations = $derived(project ? project.automations : []); + + function flag(r: ResolvedRef): 'global' | 'shared' | null { + if (r.source === 'channel-global') return 'global'; + return r.bindingCount > 1 ? 'shared' : null; + } + function toggle(id: string): void { + open = open === id ? null : id; + } + function openAdd(what: 'key' | 'doc' | 'automation'): void { + addKind = addKind === what ? null : what; + addTab = 'existing'; + } + + const channelBinding = $derived<Binding>({ targetType: 'channel', targetId: channel }); + function boundToChannel(bindings: Binding[]): boolean { + return bindings.some((b) => b.targetType === 'channel' && b.targetId === channel); + } + const candidateKeys = $derived(allKeys.filter((k) => !boundToChannel(k.bindings))); + const candidateDocs = $derived(allDocs.filter((d) => !boundToChannel(d.bindings))); + const candidateAutomations = $derived( + allAutomations.filter((b) => b.target_channel_id !== channel), + ); + + async function run(fn: () => Promise<void>, collapse = false): Promise<void> { + busy = true; + try { + await fn(); + await load(); + if (collapse) { + open = null; + addKind = null; + editAgent = false; + } + } catch (err) { + showToast(`Failed: ${(err as Error).message}`, 'error'); + } finally { + busy = false; + } + } + + async function attachKey(uid: string): Promise<void> { + const k = allKeys.find((x) => x.uid === uid); + if (!k) return; + await run( + () => + api(`/api/keys/${uid}/bindings`, { method: 'PUT', body: [...k.bindings, channelBinding] }), + true, + ); + } + async function unbindKey(uid: string): Promise<void> { + const k = allKeys.find((x) => x.uid === uid); + if (!k) return; + await run( + () => + api(`/api/keys/${uid}/bindings`, { + method: 'PUT', + body: k.bindings.filter((b) => !(b.targetType === 'channel' && b.targetId === channel)), + }), + true, + ); + } + async function attachDoc(uid: string): Promise<void> { + const d = allDocs.find((x) => x.uid === uid); + if (!d) return; + await run( + () => + api(`/api/skills/${uid}/bindings`, { + method: 'PUT', + body: [...d.bindings, channelBinding], + }), + true, + ); + } + async function unbindDoc(uid: string): Promise<void> { + const d = allDocs.find((x) => x.uid === uid); + if (!d) return; + await run( + () => + api(`/api/skills/${uid}/bindings`, { + method: 'PUT', + body: d.bindings.filter((b) => !(b.targetType === 'channel' && b.targetId === channel)), + }), + true, + ); + } + async function attachAutomation(uid: string): Promise<void> { + await run(() => api(`/api/automations/${uid}`, { method: 'PATCH', body: { channel } }), true); + } + async function unbindAutomation(uid: string): Promise<void> { + await run( + () => api(`/api/automations/${uid}`, { method: 'PATCH', body: { channel: null } }), + true, + ); + } + + let aName = $state(''); + let aModel = $state(''); + let aPrompt = $state(''); + let aPerm = $state<PermissionMode>('auto'); + function openAgentEdit(): void { + const a = config?.agents.find((x) => x.id === project?.agent?.id); + if (!a) return; + aName = a.name; + aModel = a.model; + aPrompt = a.systemPrompt; + aPerm = a.permissionMode ?? 'auto'; + editAgent = true; + } + async function saveAgent(): Promise<void> { + const id = project?.agent?.id; + if (!id || !config) return; + const agents = config.agents.map((a) => + a.id === id + ? { ...a, name: aName, model: aModel, systemPrompt: aPrompt, permissionMode: aPerm } + : a, + ); + await run(() => api('/api/config', { method: 'PATCH', body: { agents } }), true); + } + + let assignId = $state(''); + async function assignAgent(): Promise<void> { + if (!assignId || !config) return; + const agents = config.agents.map((a) => + a.id === assignId && !a.channels.includes(channel) + ? { ...a, channels: [...a.channels, channel] } + : a, + ); + await run(() => api('/api/config', { method: 'PATCH', body: { agents } })); + } + + let pickKey = $state(''); + let keyName = $state(''); + let keyValue = $state(''); + let pickDoc = $state(''); + let docTitle = $state(''); + let docContent = $state(''); + let pickAutomation = $state(''); + let automationName = $state(''); + let automationSchedule = $state(''); + let automationPrompt = $state(''); + + async function createKey(): Promise<void> { + if (!keyName.trim() || !keyValue.trim()) return; + await run(async () => { + await api('/api/keys', { + method: 'POST', + body: { + name: keyName.trim().toUpperCase(), + value: keyValue, + redact: true, + bindings: [channelBinding], + }, + }); + keyName = ''; + keyValue = ''; + }, true); + } + async function createDoc(): Promise<void> { + if (!docTitle.trim()) return; + await run(async () => { + const doc = await api<{ uid: string }>('/api/skills', { + method: 'POST', + body: { title: docTitle.trim() }, + }); + if (docContent.trim()) { + await api(`/api/skills/${doc.uid}`, { method: 'PATCH', body: { content: docContent } }); + } + await api(`/api/skills/${doc.uid}/bindings`, { method: 'PUT', body: [channelBinding] }); + docTitle = ''; + docContent = ''; + }, true); + } + async function createAutomation(): Promise<void> { + if (!automationSchedule.trim() || !automationPrompt.trim()) return; + await run(async () => { + await api('/api/automations', { + method: 'POST', + body: { + schedule: automationSchedule.trim(), + prompt: automationPrompt.trim(), + name: automationName.trim() || undefined, + channel, + agent: project?.agent?.id ?? undefined, + }, + }); + automationName = ''; + automationSchedule = ''; + automationPrompt = ''; + }, true); + } + + function scheduleFromDoc(d: DocInfo): void { + addKind = 'automation'; + addTab = 'new'; + automationName = `${d.title} digest`; + automationPrompt = `Follow the "${d.title}" skill and post the result here.`; + open = null; + } + + async function exportBlueprint(): Promise<void> { + try { + const bp = await api<unknown>(`/api/projects/${encodeURIComponent(channel)}/blueprint`); + const blob = new Blob([JSON.stringify(bp, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `project-${channel.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + showToast(`Export failed: ${(err as Error).message}`, 'error'); + } + } +</script> + +<a class="ws-back" href="#/projects">← Projects</a> + +{#if loading} + <div class="loading">Loading...</div> +{:else if !project} + <div class="loading">Not found.</div> +{:else} + <div class="head"> + <h2 class="title">{title}</h2> + {#if isRawId}<span class="rawid">channel id</span>{/if} + {#if !project.isGlobal} + <button class="btn btn-outline btn-sm export" onclick={exportBlueprint}>Export</button> + {/if} + </div> + + <div class="block"> + <div class="block-head"><span class="block-title">Agent</span></div> + {#if project.agent} + <div class="item"> + <button + class="item-row" + disabled={project.isGlobal} + onclick={() => (editAgent ? (editAgent = false) : openAgentEdit())} + > + <span class="caret">{editAgent ? '▾' : '▸'}</span> + <span class="row-name">{project.agent.name}</span> + <span class="row-meta">{project.agent.model}</span> + </button> + {#if editAgent} + <div class="detail"> + <label class="form-label">Name<input class="form-input" bind:value={aName} /></label> + <label class="form-label"> + Model<input class="form-input" list="ws-models" bind:value={aModel} /> + </label> + <datalist id="ws-models"> + <option value="sonnet"></option><option value="opus"></option><option value="haiku" + ></option> + </datalist> + <label class="form-label"> + Permission mode + <select class="form-input" bind:value={aPerm}> + {#each PERMISSION_MODES as m}<option value={m}>{m}</option>{/each} + </select> + </label> + <label class="form-label"> + Instructions + <textarea class="form-input form-textarea" rows="5" bind:value={aPrompt}></textarea> + </label> + <div class="actions"> + <button class="btn btn-primary btn-sm" disabled={busy} onclick={saveAgent} + >Save</button + > + <button class="btn btn-outline btn-sm" onclick={() => (editAgent = false)} + >Cancel</button + > + </div> + </div> + {/if} + </div> + {:else} + <div class="warn">No agent answers here yet.</div> + {#if !project.isGlobal && config && config.agents.length > 0} + <div class="assign"> + <select class="form-input" bind:value={assignId}> + <option value="" disabled selected>Choose an agent...</option> + {#each config.agents as a}<option value={a.id}>{a.name}</option>{/each} + </select> + <button class="btn btn-primary btn-sm" disabled={busy || !assignId} onclick={assignAgent} + >Use it</button + > + </div> + {/if} + {/if} + </div> + + <div class="block"> + <div class="block-head"> + <span class="block-title">Keys</span> + <button class="add" onclick={() => openAdd('key')}>+ Add</button> + </div> + {#if addKind === 'key'} + <div class="addbox"> + <div class="addtabs"> + <button class:on={addTab === 'existing'} onclick={() => (addTab = 'existing')} + >Use existing</button + > + <button class:on={addTab === 'new'} onclick={() => (addTab = 'new')}>Add new</button> + </div> + {#if addTab === 'existing'} + <div class="row2"> + <select class="form-input" bind:value={pickKey}> + <option value="" disabled selected>Pick a key...</option> + {#each candidateKeys as k (k.uid)}<option value={k.uid}>{k.name}</option>{/each} + </select> + <button + class="btn btn-primary btn-sm" + disabled={busy || !pickKey} + onclick={() => attachKey(pickKey)}>Add</button + > + </div> + {:else} + <div class="row3"> + <input class="form-input" placeholder="GITHUB_TOKEN" bind:value={keyName} /> + <input class="form-input" type="password" placeholder="value" bind:value={keyValue} /> + <button class="btn btn-primary btn-sm" disabled={busy} onclick={createKey}>Add</button> + </div> + {/if} + </div> + {/if} + {#if project.keys.length === 0} + <div class="empty">None</div> + {:else} + {#each project.keys as k (k.uid)} + <div class="item"> + <button class="item-row" onclick={() => toggle(`key:${k.uid}`)}> + <span class="caret">{open === `key:${k.uid}` ? '▾' : '▸'}</span> + <span class="row-name mono">{k.name}</span> + {#if flag(k)}<span class="tag tag-{flag(k)}">{flag(k)}</span>{/if} + </button> + {#if open === `key:${k.uid}`} + {@const info = allKeys.find((x) => x.uid === k.uid)} + <div class="detail"> + <div class="kv"> + <span>Value</span><span class="mono">{info?.hint ?? '****'}</span> + </div> + <div class="kv"> + <span>Used by</span><span>{info?.bindings.length ?? 1} place(s)</span> + </div> + {#if k.source === 'channel'} + <div class="actions"> + <button + class="btn btn-danger btn-sm" + disabled={busy} + onclick={() => unbindKey(k.uid)}>Remove</button + > + </div> + {:else} + <p class="note"> + Bound to all channels. Manage in <a href="#/keys">Keys</a>. + </p> + {/if} + </div> + {/if} + </div> + {/each} + {/if} + </div> + + <div class="block"> + <div class="block-head"> + <span class="block-title">Skills</span> + <button class="add" onclick={() => openAdd('doc')}>+ Add</button> + </div> + {#if addKind === 'doc'} + <div class="addbox"> + <div class="addtabs"> + <button class:on={addTab === 'existing'} onclick={() => (addTab = 'existing')} + >Use existing</button + > + <button class:on={addTab === 'new'} onclick={() => (addTab = 'new')}>Add new</button> + </div> + {#if addTab === 'existing'} + <div class="row2"> + <select class="form-input" bind:value={pickDoc}> + <option value="" disabled selected>Pick a skill...</option> + {#each candidateDocs as d (d.uid)}<option value={d.uid}>{d.title}</option>{/each} + </select> + <button + class="btn btn-primary btn-sm" + disabled={busy || !pickDoc} + onclick={() => attachDoc(pickDoc)}>Add</button + > + </div> + {:else} + <input class="form-input mb" placeholder="Skill title" bind:value={docTitle} /> + <textarea + class="form-input form-textarea mb" + rows="3" + placeholder="Context (optional)" + bind:value={docContent} + ></textarea> + <button class="btn btn-primary btn-sm" disabled={busy} onclick={createDoc}>Add</button> + {/if} + </div> + {/if} + {#if project.docs.length === 0} + <div class="empty">None</div> + {:else} + {#each project.docs as d (d.uid)} + <div class="item"> + <button class="item-row" onclick={() => toggle(`doc:${d.uid}`)}> + <span class="caret">{open === `doc:${d.uid}` ? '▾' : '▸'}</span> + <span class="row-name">{d.name}</span> + {#if flag(d)}<span class="tag tag-{flag(d)}">{flag(d)}</span>{/if} + </button> + {#if open === `doc:${d.uid}`} + {@const info = allDocs.find((x) => x.uid === d.uid)} + <div class="detail"> + <p class="preview">{(info?.content ?? '').slice(0, 240) || 'Empty skill.'}</p> + <div class="actions"> + <a class="btn btn-outline btn-sm" href={`#/skill-detail?id=${d.uid}`}>Open</a> + {#if !project.isGlobal && info} + <button class="btn btn-outline btn-sm" onclick={() => scheduleFromDoc(info)} + >Schedule as automation</button + > + {/if} + {#if d.source === 'channel'} + <button + class="btn btn-danger btn-sm" + disabled={busy} + onclick={() => unbindDoc(d.uid)}>Remove</button + > + {/if} + </div> + </div> + {/if} + </div> + {/each} + {/if} + </div> + + <div class="block"> + <div class="block-head"> + <span class="block-title">Automations</span> + {#if !project.isGlobal}<button class="add" onclick={() => openAdd('automation')}>+ Add</button + >{/if} + </div> + {#if addKind === 'automation' && !project.isGlobal} + <div class="addbox"> + <div class="addtabs"> + <button class:on={addTab === 'existing'} onclick={() => (addTab = 'existing')} + >Use existing</button + > + <button class:on={addTab === 'new'} onclick={() => (addTab = 'new')}>Add new</button> + </div> + {#if addTab === 'existing'} + <div class="row2"> + <select class="form-input" bind:value={pickAutomation}> + <option value="" disabled selected>Pick an automation...</option> + {#each candidateAutomations as b (b.uid)}<option value={b.uid}>{b.name}</option + >{/each} + </select> + <button + class="btn btn-primary btn-sm" + disabled={busy || !pickAutomation} + onclick={() => attachAutomation(pickAutomation)}>Add</button + > + </div> + {:else} + <input class="form-input mb" placeholder="Name (optional)" bind:value={automationName} /> + <input + class="form-input mb" + placeholder="Schedule, e.g. 0 9 * * 1" + bind:value={automationSchedule} + /> + <textarea + class="form-input form-textarea mb" + rows="3" + placeholder="What should it do?" + bind:value={automationPrompt} + ></textarea> + <button class="btn btn-primary btn-sm" disabled={busy} onclick={createAutomation} + >Add</button + > + {/if} + </div> + {/if} + {#if realAutomations.length === 0} + <div class="empty">None</div> + {:else} + {#each realAutomations as automation (automation.uid)} + <div class="item"> + <button class="item-row" onclick={() => toggle(`automation:${automation.uid}`)}> + <span class="caret">{open === `automation:${automation.uid}` ? '▾' : '▸'}</span> + <span class="row-name">{automation.name}</span> + <code class="row-sched">{automation.schedule}</code> + <span class="dot" class:on={automation.enabled}></span> + </button> + {#if open === `automation:${automation.uid}`} + {@const b = allAutomations.find((x) => x.uid === automation.uid)} + {#if b} + <div class="detail"> + <div class="kv"><span>Schedule</span><code>{b.schedule}</code></div> + <div class="kv"><span>Status</span><span>{b.enabled ? 'on' : 'off'}</span></div> + <div class="kv"><span>Runs</span><span>{b.agent_id}</span></div> + <p class="preview">{b.prompt}</p> + <div class="actions"> + <a class="btn btn-outline btn-sm" href="#/automations">Manage</a> + <button + class="btn btn-danger btn-sm" + disabled={busy} + onclick={() => unbindAutomation(automation.uid)}>Remove</button + > + </div> + </div> + {/if} + {/if} + </div> + {/each} + {/if} + </div> +{/if} + +<style> + .ws-back { + display: inline-block; + margin-bottom: var(--space-4); + font-size: var(--text-sm); + color: var(--color-text-secondary); + } + .ws-back:hover { + color: var(--color-text-primary); + } + .head { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-4); + } + .title { + font-family: var(--font-mono); + font-size: var(--text-xl); + font-weight: 600; + color: var(--color-text-primary); + } + .rawid { + font-size: var(--text-xs); + color: var(--color-text-muted); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-sm); + padding: 0 var(--space-1); + } + .export { + margin-left: auto; + } + + .block { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-4) var(--space-3); + margin-bottom: var(--space-3); + } + .block-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-2) 0; + } + .block-title { + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + } + .add { + background: none; + border: none; + color: var(--color-accent); + font-size: var(--text-sm); + cursor: pointer; + padding: 0; + } + .add:hover { + color: var(--color-accent-glow); + } + + .item { + border-top: 1px solid var(--color-border); + } + .item-row { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + text-align: left; + background: none; + border: none; + padding: var(--space-2) 0; + cursor: pointer; + color: var(--color-text-primary); + font-size: var(--text-sm); + } + .item-row:disabled { + cursor: default; + } + .caret { + color: var(--color-text-muted); + font-size: var(--text-xs); + width: 0.8em; + } + .row-name { + font-weight: 500; + } + .row-name.mono { + font-family: var(--font-mono); + } + .row-meta { + color: var(--color-text-muted); + font-size: var(--text-xs); + margin-left: auto; + } + .row-sched { + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--color-text-muted); + margin-left: auto; + } + .dot { + width: 6px; + height: 6px; + border-radius: var(--radius-full); + background: var(--color-text-muted); + } + .dot.on { + background: var(--color-success); + } + .tag { + font-size: var(--text-xs); + padding: 0 var(--space-1); + border-radius: var(--radius-sm); + margin-left: auto; + } + .tag-global { + color: var(--color-accent); + background: var(--color-accent-bg); + } + .tag-shared { + color: var(--color-text-muted); + border: 1px solid var(--color-border-subtle); + } + .row-sched + .dot { + margin-left: 0; + } + + .detail { + padding: 0 0 var(--space-3) calc(0.8em + var(--space-2)); + } + .kv { + display: flex; + justify-content: space-between; + gap: var(--space-3); + font-size: var(--text-sm); + padding: var(--space-1) 0; + } + .kv span:first-child { + color: var(--color-text-muted); + } + .preview { + font-size: var(--text-sm); + color: var(--color-text-secondary); + white-space: pre-wrap; + max-height: 8em; + overflow: hidden; + margin: var(--space-1) 0 var(--space-2); + } + .note { + font-size: var(--text-sm); + color: var(--color-text-muted); + margin-top: var(--space-1); + } + .actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-2); + flex-wrap: wrap; + } + + .empty { + font-size: var(--text-sm); + color: var(--color-text-muted); + padding: var(--space-2) 0; + border-top: 1px solid var(--color-border); + } + .warn { + font-size: var(--text-sm); + color: var(--color-warning); + background: var(--color-warning-bg); + padding: var(--space-2); + border-radius: var(--radius-sm); + } + .assign { + display: flex; + gap: var(--space-2); + margin-top: var(--space-2); + } + + .addbox { + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: var(--space-2); + margin-bottom: var(--space-2); + } + .addtabs { + display: flex; + gap: var(--space-1); + margin-bottom: var(--space-2); + } + .addtabs button { + background: none; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-text-muted); + font-size: var(--text-xs); + padding: var(--space-1) var(--space-2); + cursor: pointer; + } + .addtabs button.on { + border-color: var(--color-accent); + color: var(--color-text-primary); + } + .row2 { + display: grid; + grid-template-columns: 1fr auto; + gap: var(--space-2); + } + .row3 { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: var(--space-2); + } + .mb { + margin-bottom: var(--space-2); + } + + .form-label { + margin-bottom: var(--space-2); + } +</style> diff --git a/web/src/pages/Projects.svelte b/web/src/pages/Projects.svelte new file mode 100644 index 0000000..d7d0c75 --- /dev/null +++ b/web/src/pages/Projects.svelte @@ -0,0 +1,243 @@ +<script lang="ts"> + import type { + ProjectSummary, + ConfigResponse, + ColumnDef, + ProjectBlueprint, + } from '../lib/types.ts'; + import { api } from '../lib/api.ts'; + import { createDataTable } from '../lib/data-table.svelte.ts'; + import { showToast } from '../lib/toast.svelte.ts'; + import { onInvalidate } from '../lib/invalidate.ts'; + import { isRawChannelId } from '../lib/channels.ts'; + import DataTable from '../components/DataTable.svelte'; + + const columns: ColumnDef[] = [ + { key: 'channel', label: 'Channel', sortable: true }, + { key: 'agent', label: 'Agent', sortable: true }, + { key: 'keyCount', label: 'Keys', sortable: true }, + { key: 'docCount', label: 'Skills', sortable: true }, + { key: 'automationCount', label: 'Automations', sortable: true }, + ]; + + let invalidations = $state(0); + let importing = $state(false); + let fileInput: HTMLInputElement | undefined = $state(); + + const table = createDataTable<ProjectSummary>({ + endpoint: '/api/projects', + columns, + defaultSort: 'channel', + watchExtras: () => invalidations, + }); + + $effect(() => { + const unsubs = (['keys', 'docs', 'birds', 'config'] as const).map((r) => + onInvalidate((e) => { + if (e.resource === r) invalidations += 1; + }), + ); + return () => unsubs.forEach((u) => u()); + }); + + function open(ws: ProjectSummary): void { + window.location.hash = `#/project-detail?id=${encodeURIComponent(ws.channel)}`; + } + function slug(name: string): string { + return ( + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') || 'agent' + ); + } + + async function onImportFile(e: Event): Promise<void> { + const input = e.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ''; + if (!file) return; + importing = true; + try { + const bp = JSON.parse(await file.text()) as ProjectBlueprint; + if (!bp.channel) throw new Error('Blueprint has no channel'); + const config = await api<ConfigResponse>('/api/config'); + const ch = bp.channel; + + let agentId: string | undefined; + if (bp.agent) { + const existing = config.agents.find((a) => a.name === bp.agent?.name); + agentId = existing ? existing.id : slug(bp.agent.name); + if (existing) { + if (!existing.channels.includes(ch) && !existing.channels.includes('*')) { + const agents = config.agents.map((a) => + a.id === existing.id ? { ...a, channels: [...a.channels, ch] } : a, + ); + await api('/api/config', { method: 'PATCH', body: { agents } }); + } + } else { + const newAgent = { + id: agentId, + name: bp.agent.name, + model: bp.agent.model, + fallbackModel: null, + maxBudgetUsd: null, + maxTurns: bp.agent.maxTurns, + permissionMode: bp.agent.permissionMode ?? 'auto', + systemPrompt: bp.agent.systemPrompt, + channels: [ch], + }; + await api('/api/config', { + method: 'PATCH', + body: { agents: [...config.agents, newAgent] }, + }); + } + } + + for (const d of bp.docs ?? []) { + const doc = await api<{ uid: string }>('/api/skills', { + method: 'POST', + body: { title: d.title }, + }); + if (d.content) { + await api(`/api/skills/${doc.uid}`, { method: 'PATCH', body: { content: d.content } }); + } + await api(`/api/skills/${doc.uid}/bindings`, { + method: 'PUT', + body: [{ targetType: 'channel', targetId: ch }], + }); + } + + for (const b of bp.automations ?? []) { + await api('/api/automations', { + method: 'POST', + body: { + name: b.name || undefined, + schedule: b.schedule, + prompt: b.prompt, + channel: ch, + agent: agentId, + }, + }); + } + + const missing = bp.keys ?? []; + showToast( + missing.length ? `Imported. Add key values: ${missing.join(', ')}` : 'Blueprint imported', + 'success', + ); + window.location.hash = `#/project-detail?id=${encodeURIComponent(ch)}`; + } catch (err) { + showToast(`Import failed: ${(err as Error).message}`, 'error'); + } finally { + importing = false; + } + } +</script> + +{#if table.loading} + <div class="loading">Loading...</div> +{:else} + <DataTable + {columns} + isEmpty={table.items.length === 0} + emptyMessage="No projects yet" + fetching={table.fetching} + page={table.page} + totalPages={table.totalPages} + totalItems={table.totalItems} + sort={table.sort} + search={table.search} + searchPlaceholder="Search projects..." + onPageChange={table.setPage} + onSortChange={table.setSort} + onSearchChange={table.setSearch} + > + {#snippet toolbar()} + <button + class="btn btn-outline btn-sm" + disabled={importing} + onclick={() => fileInput?.click()} + > + {importing ? 'Importing...' : 'Import'} + </button> + <a class="btn btn-primary btn-sm" href="#/project-create">New Project</a> + <input + type="file" + accept="application/json" + bind:this={fileInput} + onchange={onImportFile} + style="display: none" + /> + {/snippet} + {#snippet cards()} + {#each table.items as ws (ws.channel)} + <button class="item-card" onclick={() => open(ws)}> + <div class="item-card-header"> + <span class="item-card-id" class:global={ws.isGlobal} + >{ws.isGlobal ? 'Global' : ws.channel}</span + > + <span class="item-card-meta">{ws.agentName ?? 'no agent'}</span> + </div> + <div class="item-card-fields"> + <div class="item-card-field"> + <span class="item-card-label">Keys</span><span>{ws.keyCount}</span> + </div> + <div class="item-card-field"> + <span class="item-card-label">Skills</span><span>{ws.docCount}</span> + </div> + <div class="item-card-field"> + <span class="item-card-label">Automations</span><span>{ws.automationCount}</span> + </div> + </div> + </button> + {/each} + {/snippet} + {#each table.items as ws (ws.channel)} + <tr class="project-row" onclick={() => open(ws)}> + <td> + <span class="ch-name" class:global={ws.isGlobal} + >{ws.isGlobal ? 'Global' : ws.channel}</span + > + {#if ws.isGlobal} + <span class="ch-tag">all channels</span> + {:else if isRawChannelId(ws.channel)} + <span class="ch-tag">channel id</span> + {/if} + </td> + <td + >{#if ws.agentName}{ws.agentName}{:else}<span class="none">none</span>{/if}</td + > + <td>{ws.keyCount}</td> + <td>{ws.docCount}</td> + <td>{ws.automationCount}</td> + </tr> + {/each} + </DataTable> +{/if} + +<style> + .project-row { + cursor: pointer; + } + .ch-name { + font-family: var(--font-mono); + font-weight: 500; + color: var(--color-text-primary); + } + .ch-name.global, + .item-card-id.global { + color: var(--color-accent-glow); + } + .ch-tag { + font-size: var(--text-xs); + color: var(--color-text-muted); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-sm); + padding: 0 var(--space-1); + margin-left: var(--space-2); + } + .none { + color: var(--color-warning); + } +</style> diff --git a/web/src/pages/Resources.svelte b/web/src/pages/Resources.svelte new file mode 100644 index 0000000..d3c0a74 --- /dev/null +++ b/web/src/pages/Resources.svelte @@ -0,0 +1,68 @@ +<script lang="ts"> + import Agents from './Agents.svelte'; + import Skills from './Skills.svelte'; + import Keys from './Keys.svelte'; + import Automations from './Automations.svelte'; + + interface Props { + currentPage: string; + } + + let { currentPage }: Props = $props(); + + const TABS = [ + { page: 'agents', label: 'Agents' }, + { page: 'skills', label: 'Skills' }, + { page: 'keys', label: 'Keys' }, + { page: 'automations', label: 'Automations' }, + ] as const; + + const activeTab = $derived(currentPage === 'resources' ? 'agents' : currentPage); +</script> + +<div class="tabs"> + {#each TABS as tab} + <a class="tab" class:tab-active={activeTab === tab.page} href="#/{tab.page}">{tab.label}</a> + {/each} +</div> + +{#if activeTab === 'skills'} + <Skills /> +{:else if activeTab === 'keys'} + <Keys /> +{:else if activeTab === 'automations'} + <Automations /> +{:else} + <Agents /> +{/if} + +<style> + .tabs { + display: flex; + gap: var(--space-1); + margin-bottom: var(--space-5); + border-bottom: 1px solid var(--color-border); + } + + .tab { + padding: var(--space-1-5) var(--space-3); + font-size: var(--text-base); + font-weight: 500; + color: var(--color-text-muted); + text-decoration: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: + color var(--transition-fast), + border-color var(--transition-fast); + } + + .tab:hover { + color: var(--color-text-secondary); + } + + .tab-active { + color: var(--color-text-primary); + border-bottom-color: var(--color-accent); + } +</style> diff --git a/web/src/pages/Settings.svelte b/web/src/pages/Settings.svelte index c6ac36d..379aa4f 100644 --- a/web/src/pages/Settings.svelte +++ b/web/src/pages/Settings.svelte @@ -1,7 +1,6 @@ <script lang="ts"> import type { StatusResponse, - ConfigResponse, DoctorResponse, SecretsResponse, PaginatedResult, @@ -9,13 +8,11 @@ JobStats, CronJobRow, } from '../lib/types.ts'; - import type { ConfigEditor } from './settings/types.ts'; import { api, getHashParams } from '../lib/api.ts'; - import { showToast } from '../lib/toast.svelte.ts'; import { onInvalidate } from '../lib/invalidate.ts'; + import { createConfigEditor } from '../lib/config-editor.svelte.ts'; import ConfigTab from './settings/ConfigTab.svelte'; import DatabaseTab from './settings/DatabaseTab.svelte'; - import KeysTab from './settings/KeysTab.svelte'; import BackupsTab from './settings/BackupsTab.svelte'; interface Props { @@ -24,7 +21,8 @@ let { status }: Props = $props(); - let config: ConfigResponse | null = $state(null); + const cfg = createConfigEditor(); + let secrets: SecretsResponse | null = $state(null); let doctor: DoctorResponse | null = $state(null); let doctorLoading = $state(true); @@ -32,8 +30,8 @@ let jobStats: JobStats | null = $state(null); let systemBirds: CronJobRow[] = $state([]); let loading = $state(true); - type Tab = 'config' | 'database' | 'keys' | 'backups'; - const VALID_TABS: ReadonlySet<string> = new Set<Tab>(['config', 'database', 'keys', 'backups']); + type Tab = 'config' | 'database' | 'backups'; + const VALID_TABS: ReadonlySet<string> = new Set<Tab>(['config', 'database', 'backups']); const initialTab = getHashParams().get('tab') ?? 'config'; let activeTab: Tab = $state(VALID_TABS.has(initialTab) ? (initialTab as Tab) : 'config'); @@ -49,21 +47,16 @@ history.replaceState(null, '', qs ? `${basePath}?${qs}` : basePath); } - let editingField: string | null = $state(null); - let editingSaving = $state(false); - $effect(() => { const ac = new AbortController(); Promise.all([ - api<ConfigResponse>('/api/config'), api<PaginatedResult<LogRow>>('/api/logs?level=error&perPage=10'), api<JobStats>('/api/jobs/stats'), - api<PaginatedResult<CronJobRow>>('/api/birds?system=true&perPage=100'), + api<PaginatedResult<CronJobRow>>('/api/automations?system=true&perPage=100'), api<SecretsResponse>('/api/secrets'), ]) - .then(([c, logs, js, birds, s]) => { + .then(([logs, js, birds, s]) => { if (ac.signal.aborted) return; - config = c; recentErrors = logs.items; jobStats = js; systemBirds = birds.items.filter((b) => b.name.startsWith('__bb_')); @@ -89,13 +82,6 @@ $effect(() => { return onInvalidate((e) => { - if (e.resource === 'config') { - api<ConfigResponse>('/api/config') - .then((c) => { - config = c; - }) - .catch(() => {}); - } if (e.resource === 'secrets') { fetchSecrets(); } @@ -109,75 +95,12 @@ }) .catch(() => {}); } - - function buildPatch(path: string, value: unknown): Record<string, unknown> { - const parts = path.split('.'); - let obj: Record<string, unknown> = {}; - const root = obj; - for (let i = 0; i < parts.length - 1; i++) { - const child: Record<string, unknown> = {}; - obj[parts[i]!] = child; - obj = child; - } - obj[parts[parts.length - 1]!] = value; - return root; - } - - async function saveConfigPatch(patch: Record<string, unknown>): Promise<boolean> { - try { - const result = await api<ConfigResponse>('/api/config', { - method: 'PATCH', - body: patch, - }); - config = result; - showToast('Config saved', 'success'); - return true; - } catch (err) { - showToast(`Failed: ${(err as Error).message}`, 'error'); - return false; - } - } - - function startEdit(field: string, _currentValue: string | number): void { - editingField = field; - editingSaving = false; - } - - function cancelEdit(): void { - editingField = null; - editingSaving = false; - } - - async function saveField( - field: string, - value: string, - transform?: (v: string) => unknown, - ): Promise<void> { - editingSaving = true; - const resolved = transform ? transform(value) : value; - const ok = await saveConfigPatch(buildPatch(field, resolved)); - if (ok) cancelEdit(); - else editingSaving = false; - } - - async function toggleField(field: string, currentValue: boolean): Promise<void> { - await saveConfigPatch(buildPatch(field, !currentValue)); - } - - const editor: ConfigEditor = $derived({ - editingField, - editingSaving, - startEdit, - cancelEdit, - saveField, - toggleField, - saveConfigPatch, - }); </script> -{#if loading} +{#if loading || cfg.loading} <div class="loading">Loading...</div> -{:else if config} +{:else if cfg.config} + {@const config = cfg.config} <div class="tabs"> <button class="tab" class:tab-active={activeTab === 'config'} onclick={() => setTab('config')} >Config</button @@ -187,9 +110,6 @@ class:tab-active={activeTab === 'database'} onclick={() => setTab('database')}>Database</button > - <button class="tab" class:tab-active={activeTab === 'keys'} onclick={() => setTab('keys')} - >Keys</button - > <button class="tab" class:tab-active={activeTab === 'backups'} onclick={() => setTab('backups')} >Backups</button > @@ -201,7 +121,7 @@ {status} {doctor} {doctorLoading} - {editor} + editor={cfg.editor} {secrets} onsecretsupdate={fetchSecrets} /> @@ -211,12 +131,8 @@ <DatabaseTab {jobStats} {systemBirds} {recentErrors} /> {/if} - {#if activeTab === 'keys'} - <KeysTab {config} /> - {/if} - {#if activeTab === 'backups'} - <BackupsTab {config} onConfigSave={saveConfigPatch} /> + <BackupsTab {config} onConfigSave={cfg.editor.saveConfigPatch} /> {/if} {/if} diff --git a/web/src/pages/DocDetail.svelte b/web/src/pages/SkillDetail.svelte similarity index 89% rename from web/src/pages/DocDetail.svelte rename to web/src/pages/SkillDetail.svelte index d949ec3..b3667b0 100644 --- a/web/src/pages/DocDetail.svelte +++ b/web/src/pages/SkillDetail.svelte @@ -17,10 +17,10 @@ $effect(() => { if (!uid) { - window.location.hash = '#/docs'; + window.location.hash = '#/skills'; return; } - void api<DocInfo>(`/api/docs/${uid}`).then( + void api<DocInfo>(`/api/skills/${uid}`).then( (d) => { doc = d; title = d.title; @@ -28,8 +28,8 @@ loading = false; }, (err) => { - showToast(`Failed to load doc: ${(err as Error).message}`, 'error'); - window.location.hash = '#/docs'; + showToast(`Failed to load skill: ${(err as Error).message}`, 'error'); + window.location.hash = '#/skills'; }, ); }); @@ -38,7 +38,7 @@ if (!doc) return; saving = true; try { - await api<DocInfo>(`/api/docs/${doc.uid}`, { + await api<DocInfo>(`/api/skills/${doc.uid}`, { method: 'PATCH', body: { title, content }, }); @@ -55,9 +55,9 @@ if (!doc) return; if (!(await showConfirm(`Delete "${doc.title}"?`))) return; try { - await api(`/api/docs/${doc.uid}`, { method: 'DELETE' }); - showToast('Doc deleted', 'success'); - window.location.hash = '#/docs'; + await api(`/api/skills/${doc.uid}`, { method: 'DELETE' }); + showToast('Skill deleted', 'success'); + window.location.hash = '#/skills'; } catch (err) { showToast(`Failed: ${(err as Error).message}`, 'error'); } @@ -82,7 +82,7 @@ {:else if doc} <div class="detail-layout"> <div class="detail-header"> - <a class="back-link" href="#/docs"> + <a class="back-link" href="#/skills"> <svg width="16" height="16" @@ -96,7 +96,7 @@ > <path d="m15 18-6-6 6-6" /> </svg> - Docs + Skills </a> <div class="header-actions"> <button class="btn btn-primary btn-sm" disabled={saving} onclick={save} @@ -106,13 +106,13 @@ </div> </div> - <input class="title-input" type="text" placeholder="Document title" bind:value={title} /> + <input class="title-input" type="text" placeholder="Skill title" bind:value={title} /> <div class="bindings-row"> <span class="bindings-label">Bindings</span> <BindingEditor bindings={doc.bindings} - endpoint={`/api/docs/${doc.uid}/bindings`} + endpoint={`/api/skills/${doc.uid}/bindings`} channels={bd.channels} birds={bd.birds} onupdate={handleBindingsUpdate} diff --git a/web/src/pages/Docs.svelte b/web/src/pages/Skills.svelte similarity index 88% rename from web/src/pages/Docs.svelte rename to web/src/pages/Skills.svelte index b56f666..3405262 100644 --- a/web/src/pages/Docs.svelte +++ b/web/src/pages/Skills.svelte @@ -19,7 +19,7 @@ let lastUpdated = $state(timeStamp()); const table = createDataTable<DocInfo>({ - endpoint: '/api/docs', + endpoint: '/api/skills', columns, defaultSort: 'created_at', invalidateOn: 'docs', @@ -35,11 +35,11 @@ async function createNew(): Promise<void> { creating = true; try { - const doc = await api<DocInfo>('/api/docs', { + const doc = await api<DocInfo>('/api/skills', { method: 'POST', body: { title: 'Untitled' }, }); - window.location.hash = `#/doc-detail?id=${doc.uid}`; + window.location.hash = `#/skill-detail?id=${doc.uid}`; } catch (err) { showToast(`Failed: ${(err as Error).message}`, 'error'); } finally { @@ -50,8 +50,8 @@ async function deleteDocAction(uid: string, title: string): Promise<void> { if (!(await showConfirm(`Delete "${title}"?`))) return; try { - await api(`/api/docs/${uid}`, { method: 'DELETE' }); - showToast('Doc deleted', 'success'); + await api(`/api/skills/${uid}`, { method: 'DELETE' }); + showToast('Skill deleted', 'success'); } catch (err) { showToast(`Failed: ${(err as Error).message}`, 'error'); } @@ -59,7 +59,7 @@ function handleRowClick(e: MouseEvent, doc: DocInfo): void { if ((e.target as HTMLElement).closest('.actions-cell, .bindings-cell')) return; - window.location.hash = `#/doc-detail?id=${doc.uid}`; + window.location.hash = `#/skill-detail?id=${doc.uid}`; } </script> @@ -69,21 +69,21 @@ <DataTable {columns} isEmpty={table.items.length === 0} - emptyMessage="No docs yet" + emptyMessage="No skills yet" fetching={table.fetching} page={table.page} totalPages={table.totalPages} totalItems={table.totalItems} sort={table.sort} search={table.search} - searchPlaceholder="Search docs..." + searchPlaceholder="Search skills..." onPageChange={table.setPage} onSortChange={table.setSort} onSearchChange={table.setSearch} > {#snippet toolbar()} <button class="btn btn-primary btn-sm" disabled={creating} onclick={createNew} - >{creating ? 'Creating...' : 'New Doc'}</button + >{creating ? 'Creating...' : 'New Skill'}</button > <div class="filter-spacer"></div> <span class="last-updated">Updated {lastUpdated}</span> @@ -110,7 +110,7 @@ <td> <BindingEditor bindings={doc.bindings} - endpoint={`/api/docs/${doc.uid}/bindings`} + endpoint={`/api/skills/${doc.uid}/bindings`} channels={bd.channels} birds={bd.birds} /> diff --git a/web/src/pages/settings/ConfigTab.svelte b/web/src/pages/settings/ConfigTab.svelte index 3befd0a..2488bb4 100644 --- a/web/src/pages/settings/ConfigTab.svelte +++ b/web/src/pages/settings/ConfigTab.svelte @@ -12,7 +12,6 @@ import { formatUptime } from '../../lib/format.ts'; import InlineEdit from '../../components/InlineEdit.svelte'; import Toggle from '../../components/Toggle.svelte'; - import AgentSection from './AgentSection.svelte'; interface Props { config: ConfigResponse; @@ -547,25 +546,26 @@ <div class="panel"> <div class="panel-header"> - <span class="panel-title">Birds</span> + <span class="panel-title">Automations</span> </div> <div class="panel-body"> <div class="row"> <span class="row-label">Max Attempts</span> <span class="row-value"> - {#if editor.editingField === 'birds.maxAttempts'} + {#if editor.editingField === 'automations.maxAttempts'} <InlineEdit bind:value={editValue} mono saving={editor.editingSaving} - onsave={(v) => editor.saveField('birds.maxAttempts', v, (s) => Number(s))} + onsave={(v) => editor.saveField('automations.maxAttempts', v, (s) => Number(s))} oncancel={editor.cancelEdit} /> {:else} <button class="val-btn mono editable" - onclick={() => handleStartEdit('birds.maxAttempts', config.birds.maxAttempts)} - >{config.birds.maxAttempts}</button + onclick={() => + handleStartEdit('automations.maxAttempts', config.automations.maxAttempts)} + >{config.automations.maxAttempts}</button > {/if} </span> @@ -573,12 +573,13 @@ <div class="row"> <span class="row-label">Max Consecutive Failures</span> <span class="row-value"> - {#if editor.editingField === 'birds.maxConsecutiveFailures'} + {#if editor.editingField === 'automations.maxConsecutiveFailures'} <InlineEdit bind:value={editValue} mono saving={editor.editingSaving} - onsave={(v) => editor.saveField('birds.maxConsecutiveFailures', v, (s) => Number(s))} + onsave={(v) => + editor.saveField('automations.maxConsecutiveFailures', v, (s) => Number(s))} oncancel={editor.cancelEdit} /> {:else} @@ -586,9 +587,9 @@ class="val-btn mono editable" onclick={() => handleStartEdit( - 'birds.maxConsecutiveFailures', - config.birds.maxConsecutiveFailures, - )}>{config.birds.maxConsecutiveFailures}</button + 'automations.maxConsecutiveFailures', + config.automations.maxConsecutiveFailures, + )}>{config.automations.maxConsecutiveFailures}</button > {/if} </span> @@ -626,8 +627,6 @@ </div> </div> -<AgentSection {config} {editor} /> - <style> .config-grid { display: grid; diff --git a/web/src/pages/settings/KeysTab.svelte b/web/src/pages/settings/KeysTab.svelte index 3d6cc78..b5c7504 100644 --- a/web/src/pages/settings/KeysTab.svelte +++ b/web/src/pages/settings/KeysTab.svelte @@ -14,6 +14,7 @@ import DataTable from '../../components/DataTable.svelte'; import BindingEditor from '../../components/BindingEditor.svelte'; import Toggle from '../../components/Toggle.svelte'; + import { channelsFromConfig } from '../../lib/channels.ts'; type EditableField = 'value' | 'description'; @@ -53,7 +54,7 @@ }, }); - const channels: string[] = $derived(config?.slack.channels ?? []); + const channels: string[] = $derived(channelsFromConfig(config)); let showForm = $state(false); let formName = $state(''); @@ -67,7 +68,7 @@ $effect(() => { const ac = new AbortController(); - api<PaginatedResult<CronJobRow>>('/api/birds?perPage=100') + api<PaginatedResult<CronJobRow>>('/api/automations?perPage=100') .then((data) => { if (!ac.signal.aborted) { birds = data.items.filter((b) => !b.name.startsWith('__bb_')); diff --git a/web/src/pages/settings/SystemBirds.svelte b/web/src/pages/settings/SystemBirds.svelte index b5408dc..4009e36 100644 --- a/web/src/pages/settings/SystemBirds.svelte +++ b/web/src/pages/settings/SystemBirds.svelte @@ -17,7 +17,7 @@ if (systemFlights[birdUid]) return; try { const result = await api<PaginatedResult<FlightRow>>( - `/api/birds/${birdUid}/flights?perPage=5`, + `/api/automations/${birdUid}/runs?perPage=5`, ); systemFlights = { ...systemFlights, [birdUid]: result.items }; } catch { @@ -27,10 +27,10 @@ async function fly(uid: string, name: string): Promise<void> { try { - const result = await api<{ success: boolean; jobId: number }>(`/api/birds/${uid}/fly`, { + const result = await api<{ success: boolean; jobId: number }>(`/api/automations/${uid}/run`, { method: 'POST', }); - showToast(`${name} sent on a flight (job #${result.jobId})`, 'success'); + showToast(`${name} run started (job #${result.jobId})`, 'success'); const { [uid]: _, ...rest } = systemFlights; systemFlights = rest; } catch (err) { @@ -41,12 +41,12 @@ <div class="panel"> <div class="panel-header"> - <span class="panel-title">System Birds</span> + <span class="panel-title">System Automations</span> <span class="bird-count mono">{birds.length}</span> </div> <div class="panel-body"> {#if birds.length === 0} - <div class="row"><span class="dim">No system birds registered</span></div> + <div class="row"><span class="dim">No system automations registered</span></div> {:else} {#each birds as bird, i (bird.uid)} <div class="bird-entry" class:bird-entry-border={i < birds.length - 1}> @@ -66,18 +66,18 @@ {/if} {#if !systemFlights[bird.uid]} <button class="btn btn-outline btn-sm" onclick={() => loadFlights(bird.uid)} - >Flights</button + >Runs</button > {/if} <button class="btn btn-outline btn-sm" onclick={() => fly(bird.uid, bird.name)} - >Fly</button + >Run</button > </div> </div> {#if systemFlights[bird.uid] != null} <div class="flight-list"> {#if systemFlights[bird.uid]!.length === 0} - <div class="flight-empty"><span class="dim">No flights recorded</span></div> + <div class="flight-empty"><span class="dim">No runs recorded</span></div> {:else} {#each systemFlights[bird.uid] as flight (flight.uid)} <div class="flight-row">