From 0c396e50467bfba2156dd642897ad819638d9ae4 Mon Sep 17 00:00:00 2001 From: Papuna Gagnidze Date: Fri, 5 Jun 2026 12:44:27 +0400 Subject: [PATCH 01/17] feat(projects): add channel-centric read-model and read-only api --- src/project.test.ts | 120 +++++++++ src/project.ts | 453 ++++++++++++++++++++++++++++++++++ src/server/routes/index.ts | 2 + src/server/routes/projects.ts | 59 +++++ 4 files changed, 634 insertions(+) create mode 100644 src/project.test.ts create mode 100644 src/project.ts create mode 100644 src/server/routes/projects.ts diff --git a/src/project.test.ts b/src/project.test.ts new file mode 100644 index 0000000..59730d3 --- /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 bird = 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(bird.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 birds as members and ignores bird-scoped bindings', () => { + const project = getProject(config, 'billing'); + strictEqual(project.birds.length, 1); + strictEqual(project.birds[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.birdCount, 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?.birdCount, 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..1e231f0 --- /dev/null +++ b/src/project.ts @@ -0,0 +1,453 @@ +/** + * @fileoverview Project read-model: a derived view over the binding graph, computed + * from config (agents + channel patterns) joined to key/doc bindings and birds. + * 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). Birds 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; + birdCount: number; +} + +export type BindingSource = 'channel' | 'channel-global'; + +export interface ResolvedRef { + uid: string; + name: string; + source: BindingSource; + bindingCount: number; +} + +export interface ProjectBirdMember { + 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[]; + birds: ProjectBirdMember[]; + keyCount: number; + docCount: number; + birdCount: 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; + doc: Map; + bird: Map; +} + +/** + * 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 birds) fall out of the same + * GROUP BY, so the Global project reads from the same maps. System (`__bb_`) birds + * are excluded. + */ +function channelCountMaps(): CountMaps { + const d = getDb(); + const bind = (table: string): Map => + 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 bird = 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'), bird }; +} + +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 birdCount = counts.bird.get(null) ?? 0; + const agent = wildcardAgent(config); + if (keyCount === 0 && docCount === 0 && birdCount === 0 && !agent) { + return null; + } + return { + channel: GLOBAL_PROJECT_ID, + isGlobal: true, + agentId: agent?.id ?? null, + agentName: agent?.name ?? null, + keyCount, + docCount, + birdCount, + }; +} + +/** + * 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(); + 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.bird.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, + birdCount: counts.bird.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 'birdCount': + return n.birdCount; + 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 { + 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 = { + 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 { + 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, +): 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(); + 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 BirdRow { + uid: string; + name: string; + agent_id: string; + schedule: string; + target_channel_id: string | null; + enabled: number; +} + +function birdsForProject(channel: string, isGlobal: boolean): BirdRow[] { + 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 BirdRow[]; +} + +/** + * 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 + * birds that belong to the channel as plain members. Birds 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 birds: ProjectBirdMember[] = birdsForProject(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, + birds, + keyCount: keys.length, + docCount: docs.length, + birdCount: birds.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 }>; + birds: 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, bird 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 => doc != null) + .map((doc) => ({ title: doc.title, content: doc.content })); + + const birds = birdsForProject(channel, false).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, + birds, + }; +} diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 1c886ee..82482c5 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -10,6 +10,7 @@ import { buildConfigRoutes } from './config.ts'; import { buildDataRoutes } from './data.ts'; import { buildDocsRoutes } from './docs.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'; @@ -55,6 +56,7 @@ export function buildRoutes( onLaunch: options.onLaunch, }), ...buildKeysRoutes(), + ...buildProjectsRoutes(getConfig), ...buildBackupsRoutes(dirname(options.configPath), getConfig, options.onRestart), ...buildJobsRoutes(), ...buildDocsRoutes(), diff --git a/src/server/routes/projects.ts b/src/server/routes/projects.ts new file mode 100644 index 0000000..979b5fb --- /dev/null +++ b/src/server/routes/projects.ts @@ -0,0 +1,59 @@ +/** @fileoverview Project read-model API: aggregates agent + keys + docs + birds 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)); + }, + }, + ]; +} From 7e9aee8c5e0168f14155b309140bcc905781c5f7 Mon Sep 17 00:00:00 2001 From: Papuna Gagnidze Date: Fri, 5 Jun 2026 12:47:19 +0400 Subject: [PATCH 02/17] refactor(api)!: rename birds to automations, docs to skills, scope bindings to channels --- README.md | 72 +++++------ manifest.json | 6 +- oci/app/config/agent-context.md | 12 +- src/channel/blocks.ts | 34 ++--- src/channel/commands.ts | 52 ++++---- src/channel/slack.ts | 13 +- src/cli/{birds.ts => automations.ts} | 116 ++++++++++-------- src/cli/keys.ts | 42 +++---- src/cli/run.ts | 24 ++-- src/cli/{docs.ts => skills.ts} | 98 +++++++-------- src/core/types.ts | 4 +- src/server/http.ts | 4 +- .../routes/{birds.ts => automations.ts} | 61 ++++++--- src/server/routes/data.ts | 16 +-- src/server/routes/index.ts | 8 +- src/server/routes/{docs.ts => skills.ts} | 20 +-- 16 files changed, 302 insertions(+), 280 deletions(-) rename src/cli/{birds.ts => automations.ts} (73%) rename src/cli/{docs.ts => skills.ts} (60%) rename src/server/routes/{birds.ts => automations.ts} (79%) rename src/server/routes/{docs.ts => skills.ts} (90%) diff --git a/README.md b/README.md index 963f660..2d15a9a 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 @@ -230,9 +230,9 @@ Browser mode (`persistent` or `isolated`) is controlled by the `BROWSER_MODE` en } ``` -- `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_BIRD_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/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..832ae06 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_BIRD_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/src/channel/blocks.ts b/src/channel/blocks.ts index a792f0b..a409771 100644 --- a/src/channel/blocks.ts +++ b/src/channel/blocks.ts @@ -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; } @@ -454,7 +454,7 @@ export function birdLogsBlocks( if (flights.length === 0) { return [ section(`*${birdName}* - No flights yet`), - context('Trigger with `/bird fly ${birdName}`'), + context('Trigger with `/automation run ${birdName}`'), ]; } @@ -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 ` -- trigger a bird now', - '`/bird status` -- check system status', + '`/automation list` -- show all automations', + '`/automation create` -- create a new automation', + '`/automation run ` -- 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..c992278 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 [arguments]`' }); + await say({ text: 'Usage: `/automation run [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; } @@ -117,9 +117,9 @@ export async function handleSlashCommand( ); 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} \`` }); + await say({ text: `Usage: \`/automation ${subcommand} \`` }); 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 `' }); + await say({ text: 'Usage: `/automation logs `' }); 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 `' }); + await say({ text: 'Usage: `/automation stop `' }); 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 `', + '*Usage:* `/automation `', '', - '`/bird list` - Show all configured birds', - '`/bird fly ` - Trigger a bird immediately', - '`/bird stop [name]` - 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 form)', - '`/bird status` - Show daemon status', + '`/automation list` - Show all configured automations', + '`/automation run ` - Trigger an automation immediately', + '`/automation stop [name]` - Stop a running automation', + '`/automation logs ` - Show recent runs', + '`/automation enable ` - Enable an automation', + '`/automation disable ` - 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..292d52a 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 | 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, webClient: WebClient, + config: Config, ): Promise { try { const values = view['state'] as Record | undefined; @@ -624,7 +628,8 @@ 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); } 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 [options] +export const AUTOMATIONS_HELP = ` +${c('cyan', 'usage:')} browserbird automations [options] -manage scheduled birds. +manage scheduled automations. ${c('dim', 'subcommands:')} - ${c('cyan', 'list')} list all birds - ${c('cyan', 'add')} add a new bird - ${c('cyan', 'edit')} edit a bird - ${c('cyan', 'remove')} remove a bird - ${c('cyan', 'enable')} enable a bird - ${c('cyan', 'disable')} disable a bird - ${c('cyan', 'fly')} [args] trigger a bird ($ARGUMENTS substituted) - ${c('cyan', 'flights')} show flight history for a bird + ${c('cyan', 'list')} list all automations + ${c('cyan', 'add')} add a new automation + ${c('cyan', 'edit')} edit an automation + ${c('cyan', 'remove')} remove an automation + ${c('cyan', 'enable')} enable an automation + ${c('cyan', 'disable')} disable an automation + ${c('cyan', 'run')} [args] trigger an automation ($ARGUMENTS substituted) + ${c('cyan', 'runs')} show run history for an automation ${c('dim', 'options:')} ${c('yellow', '--channel')} target slack channel + ${c('yellow', '--background')} run with no channel (posts nowhere) ${c('yellow', '--agent')} target agent id ${c('yellow', '--schedule')} cron schedule expression ${c('yellow', '--prompt')} prompt text ${c('yellow', '--prompt-file')} read prompt from a file (alternative to --prompt) ${c('yellow', '--active-hours')} restrict runs to a time window (e.g. "09:00-17:00") - ${c('yellow', '--limit')} number of flights to show (default: 10) - ${c('yellow', '--json')} output as JSON (with list, flights) + ${c('yellow', '--limit')} 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(fn: () => Promise): Promise { } } -export async function handleBirds(argv: string[]): Promise { +export async function handleAutomations(argv: string[]): Promise { const subcommand = argv[0] ?? 'list'; const rest = argv.slice(1); @@ -100,6 +101,7 @@ export async function handleBirds(argv: string[]): Promise { 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 { const result = await runDaemonCall>(() => daemonRequest>({ 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 { 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 { } if (!schedule || !prompt) { logger.error( - 'usage: browserbird birds add --schedule (--prompt | --prompt-file ) [--name ] [--channel ] [--agent ]', + 'usage: browserbird automations add --schedule (--channel | --background) (--prompt | --prompt-file ) [--name ] [--agent ]', + ); + 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 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 { const job = await runDaemonCall(() => daemonRequest({ 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 { }), ); 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 { const uidPrefix = positionals[0]; if (!uidPrefix) { logger.error( - 'usage: browserbird birds edit [--name ] [--schedule ] [--prompt | --prompt-file ] [--channel ] [--agent ] [--active-hours ]', + 'usage: browserbird automations edit [--name ] [--schedule ] [--prompt | --prompt-file ] [--channel ] [--agent ] [--active-hours ]', ); process.exitCode = 1; return; @@ -276,7 +293,7 @@ export async function handleBirds(argv: string[]): Promise { const updated = await runDaemonCall(() => daemonRequest({ method: 'PATCH', - path: `/api/birds/${uidPrefix}`, + path: `/api/automations/${uidPrefix}`, body: { schedule, prompt, @@ -289,9 +306,9 @@ export async function handleBirds(argv: string[]): Promise { }), ); 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 { case 'remove': { const uidPrefix = positionals[0]; if (!uidPrefix) { - logger.error('usage: browserbird birds remove '); + logger.error('usage: browserbird automations remove '); 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 { case 'disable': { const uidPrefix = positionals[0]; if (!uidPrefix) { - logger.error(`usage: browserbird birds ${subcommand} `); + logger.error(`usage: browserbird automations ${subcommand} `); process.exitCode = 1; return; } @@ -326,23 +343,24 @@ export async function handleBirds(argv: string[]): Promise { 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 [arguments]'); + logger.error('usage: browserbird automations run [arguments]'); process.exitCode = 1; return; } @@ -350,23 +368,23 @@ export async function handleBirds(argv: string[]): Promise { 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 '); + logger.error('usage: browserbird automations runs '); process.exitCode = 1; return; } @@ -379,7 +397,7 @@ export async function handleBirds(argv: string[]): Promise { const result = await runDaemonCall>(() => daemonRequest>({ 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 { 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 { 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 { } default: - unknownSubcommand(subcommand, 'birds', [ + unknownSubcommand(subcommand, 'automations', [ 'list', 'add', 'edit', 'remove', 'enable', 'disable', - 'fly', - 'flights', + 'run', + 'runs', ]); } } diff --git a/src/cli/keys.ts b/src/cli/keys.ts index b7b2bae..1a8ca25 100644 --- a/src/cli/keys.ts +++ b/src/cli/keys.ts @@ -25,8 +25,8 @@ ${c('dim', 'subcommands:')} ${c('cyan', 'add')} add a new key (prompts for value) ${c('cyan', 'edit')} update a key's value or description ${c('cyan', 'remove')} remove a key - ${c('cyan', 'bind')} bind a key to a channel or bird - ${c('cyan', 'unbind')} unbind a key from a target + ${c('cyan', 'bind')} bind a key to a channel + ${c('cyan', 'unbind')} unbind a key from a channel ${c('dim', 'options:')} @@ -240,27 +240,21 @@ export async function handleKeys(argv: string[]): Promise { 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 \n example: browserbird keys bind GITHUB_TOKEN channel '*'", + "usage: browserbird keys bind \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 +263,25 @@ export async function handleKeys(argv: string[]): Promise { }), ); 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 '); - 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 '); 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 +292,7 @@ export async function handleKeys(argv: string[]): Promise { }), ); 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..80116f5 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 --help' for command-specific options.`.trimEnd(); const COMMAND_HELP: Record = { sessions: SESSIONS_HELP, - birds: BIRDS_HELP, - docs: DOCS_HELP, + automations: AUTOMATIONS_HELP, + skills: SKILLS_HELP, keys: KEYS_HELP, config: CONFIG_HELP, logs: LOGS_HELP, @@ -115,14 +115,14 @@ export async function run(argv: string[]): Promise { console.log(COMMAND_HELP.birds); return; } - await handleBirds(rest); + await handleAutomations(rest); break; case COMMANDS.DOCS: if (isHelp) { console.log(COMMAND_HELP.docs); return; } - await handleDocs(rest); + await handleSkills(rest); break; case COMMANDS.KEYS: if (isHelp) { @@ -193,8 +193,8 @@ export async function run(argv: string[]): Promise { 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 [options] +export const SKILLS_HELP = ` +${c('cyan', 'usage:')} browserbird skills [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')} 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/core/types.ts b/src/core/types.ts index 17907a6..17232a9 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -89,8 +89,8 @@ export interface Config { export const COMMANDS = { SESSIONS: 'sessions', - BIRDS: 'birds', - DOCS: 'docs', + BIRDS: 'automations', + DOCS: 'skills', KEYS: 'keys', CONFIG: 'config', LOGS: 'logs', diff --git a/src/server/http.ts b/src/server/http.ts index e899848..3031c84 100644 --- a/src/server/http.ts +++ b/src/server/http.ts @@ -112,8 +112,8 @@ export function validateBindings(body: unknown, res: ServerResponse): Binding[] return null; } for (const b of body as Binding[]) { - if (b.targetType !== 'channel' && b.targetType !== 'bird') { - jsonError(res, '"targetType" must be "channel" or "bird"', 400); + if (b.targetType !== 'channel') { + jsonError(res, '"targetType" must be "channel"', 400); return null; } if (!b.targetId || typeof b.targetId !== 'string') { diff --git a/src/server/routes/birds.ts b/src/server/routes/automations.ts similarity index 79% rename from src/server/routes/birds.ts rename to src/server/routes/automations.ts index 8527267..75a9ba9 100644 --- a/src/server/routes/birds.ts +++ b/src/server/routes/automations.ts @@ -28,6 +28,7 @@ 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>, @@ -36,7 +37,7 @@ function resolveBird( return resolveRouteParam<CronJobRow>('cron_jobs', 'Bird', 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: @@ -203,7 +226,7 @@ export function buildBirdsRoutes(getConfig: () => Config): Route[] { }, { 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/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 82482c5..bdd60b4 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -5,10 +5,10 @@ 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'; @@ -48,7 +48,7 @@ export function buildRoutes( envPath: options.envPath, onConfigReload: options.onConfigReload, }), - ...buildBirdsRoutes(getConfig), + ...buildAutomationsRoutes(getConfig), ...buildOnboardingRoutes({ getConfig, configPath: options.configPath, @@ -59,7 +59,7 @@ export function buildRoutes( ...buildProjectsRoutes(getConfig), ...buildBackupsRoutes(dirname(options.configPath), getConfig, options.onRestart), ...buildJobsRoutes(), - ...buildDocsRoutes(), + ...buildSkillsRoutes(), ...buildChatRoutes(() => getDeps().webChannel()), ]; } diff --git a/src/server/routes/docs.ts b/src/server/routes/skills.ts similarity index 90% rename from src/server/routes/docs.ts rename to src/server/routes/skills.ts index f1cbea2..0b80cf4 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,7 +58,7 @@ 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); if (!doc) return; @@ -67,7 +67,7 @@ export function buildDocsRoutes(): Route[] { }, { 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); if (!doc) return; @@ -93,7 +93,7 @@ export function buildDocsRoutes(): Route[] { }, { method: 'DELETE', - pattern: pathToRegex('/api/docs/:id'), + pattern: pathToRegex('/api/skills/:id'), handler(_req, res, params) { const doc = resolveRouteParam<DocRow>('docs', 'Doc', params, res); if (!doc) return; @@ -104,7 +104,7 @@ 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); if (!doc) return; @@ -115,7 +115,7 @@ 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); if (!doc) return; @@ -126,7 +126,7 @@ 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); if (!doc) return; @@ -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' }); From bf75caa974b584dc7feb2b8ba4e2a61db897f70a Mon Sep 17 00:00:00 2001 From: Papuna Gagnidze <pgagnidze@pm.me> Date: Fri, 5 Jun 2026 12:55:26 +0400 Subject: [PATCH 03/17] refactor(web): rename birds/docs pages to automations/skills, scope bindings to channels --- web/src/App.svelte | 26 ++--- ...dDrawer.svelte => AutomationDrawer.svelte} | 62 +++++++----- web/src/components/BindingEditor.svelte | 25 +---- web/src/components/InlineEdit.svelte | 6 ++ web/src/components/Sidebar.svelte | 10 +- web/src/lib/binding-data.svelte.ts | 5 +- web/src/lib/channels.ts | 53 ++++++++++ web/src/lib/types.ts | 10 +- .../{Birds.svelte => Automations.svelte} | 97 ++++++++++++------- web/src/pages/MissionControl.svelte | 61 ++++++------ web/src/pages/Onboarding.svelte | 11 ++- .../{DocDetail.svelte => SkillDetail.svelte} | 24 ++--- web/src/pages/{Docs.svelte => Skills.svelte} | 20 ++-- web/src/pages/settings/SystemBirds.svelte | 16 +-- 14 files changed, 258 insertions(+), 168 deletions(-) rename web/src/components/{BirdDrawer.svelte => AutomationDrawer.svelte} (92%) create mode 100644 web/src/lib/channels.ts rename web/src/pages/{Birds.svelte => Automations.svelte} (84%) rename web/src/pages/{DocDetail.svelte => SkillDetail.svelte} (89%) rename web/src/pages/{Docs.svelte => Skills.svelte} (88%) diff --git a/web/src/App.svelte b/web/src/App.svelte index 044543d..c8b52ce 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -21,21 +21,21 @@ 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 Automations from './pages/Automations.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 Skills from './pages/Skills.svelte'; + import SkillDetail from './pages/SkillDetail.svelte'; import Onboarding from './pages/Onboarding.svelte'; const PAGE_TITLES: Record<string, string> = { status: 'Mission Control', sessions: 'Sessions', 'session-detail': 'Session Detail', - docs: 'Docs', - 'doc-detail': 'Doc Detail', - birds: 'Birds', + skills: 'Skills', + 'skill-detail': 'Skill Detail', + automations: 'Automations', computer: 'Computer', settings: 'Settings', }; @@ -84,7 +84,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(() => { @@ -340,12 +340,12 @@ <Sessions /> {: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 === 'skills'} + <Skills /> + {:else if currentPage === 'skill-detail'} + <SkillDetail /> + {:else if currentPage === 'automations'} + <Automations /> {: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..aee428f 100644 --- a/web/src/components/Sidebar.svelte +++ b/web/src/components/Sidebar.svelte @@ -20,14 +20,14 @@ 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: 'docs', - label: 'Docs', + page: 'skills', + label: 'Skills', 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: '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: 'automations', + label: 'Automations', + svg: `<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>`, }, { page: 'computer', 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..6e19347 --- /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(); +} + +/** + * Bird channel select options: concrete channels plus a leading "None + * (background)" entry (sentinel `BIRD_NONE`). `*` (all channels) is never a valid + * bird target, so it is excluded. The bird'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/types.ts b/web/src/lib/types.ts index 9cfea5e..c599392 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -147,7 +147,7 @@ export interface CronJobRow { created_at: string; } -export interface UpcomingBird { +export interface UpcomingAutomation { uid: string; name: string; schedule: string; @@ -156,10 +156,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/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/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/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/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"> From 64fb5aa3515aa59dc4f85cc35259d58a6fe2fc06 Mon Sep 17 00:00:00 2001 From: Papuna Gagnidze <pgagnidze@pm.me> Date: Fri, 5 Jun 2026 12:57:55 +0400 Subject: [PATCH 04/17] feat(web): add projects pages (list, detail, and create wizard) --- web/src/App.svelte | 12 + web/src/components/Sidebar.svelte | 5 + web/src/lib/types.ts | 56 ++ web/src/pages/ProjectCreate.svelte | 424 +++++++++++++++ web/src/pages/ProjectDetail.svelte | 807 +++++++++++++++++++++++++++++ web/src/pages/Projects.svelte | 243 +++++++++ 6 files changed, 1547 insertions(+) create mode 100644 web/src/pages/ProjectCreate.svelte create mode 100644 web/src/pages/ProjectDetail.svelte create mode 100644 web/src/pages/Projects.svelte diff --git a/web/src/App.svelte b/web/src/App.svelte index c8b52ce..b9b2315 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -27,10 +27,16 @@ import SessionDetail from './pages/SessionDetail.svelte'; import Skills from './pages/Skills.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 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', skills: 'Skills', @@ -338,6 +344,12 @@ <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 === 'skills'} diff --git a/web/src/components/Sidebar.svelte b/web/src/components/Sidebar.svelte index aee428f..4bfbd86 100644 --- a/web/src/components/Sidebar.svelte +++ b/web/src/components/Sidebar.svelte @@ -14,6 +14,11 @@ 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: '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: 'sessions', label: 'Sessions', diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index c599392..3fd28b0 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; + birdCount: number; +} + +export type BindingSource = 'channel' | 'channel-global'; + +export interface ResolvedRef { + uid: string; + name: string; + source: BindingSource; + bindingCount: number; +} + +export interface ProjectBirdMember { + 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[]; + birds: ProjectBirdMember[]; + keyCount: number; + docCount: number; + birdCount: 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 }>; + birds: Array<{ name: string; schedule: string; prompt: string }>; +} + export interface FlightStats { running: number; completed: number; diff --git a/web/src/pages/ProjectCreate.svelte b/web/src/pages/ProjectCreate.svelte new file mode 100644 index 0000000..6715f13 --- /dev/null +++ b/web/src/pages/ProjectCreate.svelte @@ -0,0 +1,424 @@ +<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 birdName = $state(''); + let birdSchedule = $state(''); + let birdPrompt = $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 hasBird = $derived(birdSchedule.trim() !== '' && birdPrompt.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 createBird(ch: string): Promise<void> { + if (!hasBird) return; + await api('/api/automations', { + method: 'POST', + body: { + schedule: birdSchedule.trim(), + prompt: birdPrompt.trim(), + name: birdName.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), createBird(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"> + A bird 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={birdName} + /> + </label> + <label class="form-label"> + Schedule (cron) + <input class="form-input" type="text" placeholder="0 9 * * 1" bind:value={birdSchedule} /> + </label> + <label class="form-label"> + Prompt + <textarea + class="form-input form-textarea" + rows="4" + placeholder="Summarize this week's AWS spend..." + bind:value={birdPrompt} + ></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 + >{hasBird ? `${birdName || 'unnamed'} (${birdSchedule})` : '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..c19c222 --- /dev/null +++ b/web/src/pages/ProjectDetail.svelte @@ -0,0 +1,807 @@ +<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 allBirds = $state<CronJobRow[]>([]); + let loading = $state(true); + let busy = $state(false); + + let open = $state<string | null>(null); + let addKind = $state<'key' | 'doc' | 'bird' | 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; + allBirds = 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 realBirds = $derived(project ? project.birds : []); + + 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' | 'bird'): 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 candidateBirds = $derived(allBirds.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 attachBird(uid: string): Promise<void> { + await run(() => api(`/api/automations/${uid}`, { method: 'PATCH', body: { channel } }), true); + } + async function unbindBird(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 pickBird = $state(''); + let birdName = $state(''); + let birdSchedule = $state(''); + let birdPrompt = $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 createBird(): Promise<void> { + if (!birdSchedule.trim() || !birdPrompt.trim()) return; + await run(async () => { + await api('/api/automations', { + method: 'POST', + body: { + schedule: birdSchedule.trim(), + prompt: birdPrompt.trim(), + name: birdName.trim() || undefined, + channel, + agent: project?.agent?.id ?? undefined, + }, + }); + birdName = ''; + birdSchedule = ''; + birdPrompt = ''; + }, true); + } + + function scheduleFromDoc(d: DocInfo): void { + addKind = 'bird'; + addTab = 'new'; + birdName = `${d.title} digest`; + birdPrompt = `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="#/settings?tab=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="Doc 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 doc.'}</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('bird')}>+ Add</button>{/if} + </div> + {#if addKind === 'bird' && !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={pickBird}> + <option value="" disabled selected>Pick an automation...</option> + {#each candidateBirds as b (b.uid)}<option value={b.uid}>{b.name}</option>{/each} + </select> + <button + class="btn btn-primary btn-sm" + disabled={busy || !pickBird} + onclick={() => attachBird(pickBird)}>Add</button + > + </div> + {:else} + <input class="form-input mb" placeholder="Name (optional)" bind:value={birdName} /> + <input + class="form-input mb" + placeholder="Schedule, e.g. 0 9 * * 1" + bind:value={birdSchedule} + /> + <textarea + class="form-input form-textarea mb" + rows="3" + placeholder="What should it do?" + bind:value={birdPrompt} + ></textarea> + <button class="btn btn-primary btn-sm" disabled={busy} onclick={createBird}>Add</button> + {/if} + </div> + {/if} + {#if realBirds.length === 0} + <div class="empty">None</div> + {:else} + {#each realBirds as bird (bird.uid)} + <div class="item"> + <button class="item-row" onclick={() => toggle(`bird:${bird.uid}`)}> + <span class="caret">{open === `bird:${bird.uid}` ? '▾' : '▸'}</span> + <span class="row-name">{bird.name}</span> + <code class="row-sched">{bird.schedule}</code> + <span class="dot" class:on={bird.enabled}></span> + </button> + {#if open === `bird:${bird.uid}`} + {@const b = allBirds.find((x) => x.uid === bird.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={() => unbindBird(bird.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..5038c1a --- /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: 'birdCount', 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.birds ?? []) { + 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.birdCount}</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.birdCount}</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> From b92f2b1248a45f58b3f6ed32ff15a683025eee64 Mon Sep 17 00:00:00 2001 From: Papuna Gagnidze <pgagnidze@pm.me> Date: Fri, 5 Jun 2026 12:59:20 +0400 Subject: [PATCH 05/17] feat(web): add keys and agents pages and extract createconfigeditor for settings --- web/src/App.svelte | 8 ++ web/src/components/Sidebar.svelte | 10 ++ web/src/lib/config-editor.svelte.ts | 118 ++++++++++++++++++++++++ web/src/pages/Agents.svelte | 13 +++ web/src/pages/Keys.svelte | 13 +++ web/src/pages/Settings.svelte | 108 +++------------------- web/src/pages/settings/ConfigTab.svelte | 5 +- web/src/pages/settings/KeysTab.svelte | 5 +- 8 files changed, 178 insertions(+), 102 deletions(-) create mode 100644 web/src/lib/config-editor.svelte.ts create mode 100644 web/src/pages/Agents.svelte create mode 100644 web/src/pages/Keys.svelte diff --git a/web/src/App.svelte b/web/src/App.svelte index b9b2315..49a7185 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -30,6 +30,8 @@ import Projects from './pages/Projects.svelte'; import ProjectDetail from './pages/ProjectDetail.svelte'; import ProjectCreate from './pages/ProjectCreate.svelte'; + import Agents from './pages/Agents.svelte'; + import Keys from './pages/Keys.svelte'; import Onboarding from './pages/Onboarding.svelte'; const PAGE_TITLES: Record<string, string> = { @@ -42,6 +44,8 @@ skills: 'Skills', 'skill-detail': 'Skill Detail', automations: 'Automations', + agents: 'Agents', + keys: 'Keys', computer: 'Computer', settings: 'Settings', }; @@ -358,6 +362,10 @@ <SkillDetail /> {:else if currentPage === 'automations'} <Automations /> + {:else if currentPage === 'agents'} + <Agents /> + {:else if currentPage === 'keys'} + <Keys /> {:else if currentPage === 'computer'} <Computer {status} /> {:else if currentPage === 'settings'} diff --git a/web/src/components/Sidebar.svelte b/web/src/components/Sidebar.svelte index 4bfbd86..67cf8cb 100644 --- a/web/src/components/Sidebar.svelte +++ b/web/src/components/Sidebar.svelte @@ -19,6 +19,11 @@ 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: 'agents', + label: 'Agents', + svg: `<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>`, + }, { page: 'sessions', label: 'Sessions', @@ -29,6 +34,11 @@ label: 'Skills', 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: 'keys', + label: 'Keys', + svg: `<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>`, + }, { page: 'automations', label: 'Automations', 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/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/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/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/settings/ConfigTab.svelte b/web/src/pages/settings/ConfigTab.svelte index 3befd0a..8703fd7 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,7 +546,7 @@ <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"> @@ -626,8 +625,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_')); From 23d21804131f05a0ba9138ce526aac0de32b3a1d Mon Sep 17 00:00:00 2001 From: Papuna Gagnidze <pgagnidze@pm.me> Date: Fri, 5 Jun 2026 13:23:31 +0400 Subject: [PATCH 06/17] refactor: finish automations/skills rename in cli reference, slack, and ui strings --- oci/app/config/cli-reference.md | 50 ++++++++++++------------- src/channel/blocks.ts | 6 +-- src/channel/slack.ts | 2 +- src/cli/keys.ts | 5 +-- src/server/routes/automations.ts | 4 +- src/server/routes/skills.ts | 14 +++---- web/src/components/BindingEditor.svelte | 4 +- web/src/lib/channels.ts | 8 ++-- web/src/pages/ProjectCreate.svelte | 2 +- web/src/pages/ProjectDetail.svelte | 4 +- 10 files changed, 49 insertions(+), 50 deletions(-) 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 <uid> [options] Edit a bird (--name, --schedule, --prompt, --channel, --agent) -browserbird birds remove <uid> Delete a bird -browserbird birds enable <uid> Enable a bird -browserbird birds disable <uid> Disable a bird -browserbird birds fly <uid> Trigger a bird immediately -browserbird birds flights <uid> Show flight history +browserbird automations list List all automations +browserbird automations add <schedule> <prompt> Create an automation (or use --schedule/--prompt) +browserbird automations edit <uid> [options] Edit an automation (--name, --schedule, --prompt, --channel, --agent) +browserbird automations remove <uid> Delete an automation +browserbird automations enable <uid> Enable an automation +browserbird automations disable <uid> Disable an automation +browserbird automations run <uid> [args] Trigger an automation immediately ($ARGUMENTS substituted) +browserbird automations runs <uid> Show run history ``` -Options for add/edit: `--channel <id>`, `--agent <id>`, `--active-hours 09:00-17:00` +Options for add/edit: `--channel <id>`, `--agent <id>`, `--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 <text>` 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 <text>` 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 <uid> --prompt-file <path>` (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 <uid> --prompt-file <path>` (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 <title> 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/src/channel/blocks.ts b/src/channel/blocks.ts index a409771..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)); @@ -453,12 +453,12 @@ export function birdLogsBlocks( ): Block[] { if (flights.length === 0) { return [ - section(`*${birdName}* - No flights yet`), + 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]'; diff --git a/src/channel/slack.ts b/src/channel/slack.ts index 292d52a..c30e06e 100644 --- a/src/channel/slack.ts +++ b/src/channel/slack.ts @@ -636,7 +636,7 @@ async function handleBirdCreateSubmission( 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/keys.ts b/src/cli/keys.ts index 1a8ca25..b0a8781 100644 --- a/src/cli/keys.ts +++ b/src/cli/keys.ts @@ -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(); diff --git a/src/server/routes/automations.ts b/src/server/routes/automations.ts index 75a9ba9..6105df7 100644 --- a/src/server/routes/automations.ts +++ b/src/server/routes/automations.ts @@ -34,7 +34,7 @@ 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 UpcomingAutomation { @@ -220,7 +220,7 @@ export function buildAutomationsRoutes(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); } }, }, diff --git a/src/server/routes/skills.ts b/src/server/routes/skills.ts index 0b80cf4..ceeaf09 100644 --- a/src/server/routes/skills.ts +++ b/src/server/routes/skills.ts @@ -60,7 +60,7 @@ export function buildSkillsRoutes(): Route[] { method: 'GET', 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)); }, @@ -69,7 +69,7 @@ export function buildSkillsRoutes(): Route[] { method: 'PATCH', 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,7 +87,7 @@ export function buildSkillsRoutes(): 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); } }, }, @@ -95,7 +95,7 @@ export function buildSkillsRoutes(): Route[] { method: 'DELETE', 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' }); @@ -106,7 +106,7 @@ export function buildSkillsRoutes(): Route[] { method: 'PATCH', 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' }); @@ -117,7 +117,7 @@ export function buildSkillsRoutes(): Route[] { method: 'PATCH', 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' }); @@ -128,7 +128,7 @@ export function buildSkillsRoutes(): Route[] { method: 'PUT', 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 { diff --git a/web/src/components/BindingEditor.svelte b/web/src/components/BindingEditor.svelte index 1f4c59b..c515886 100644 --- a/web/src/components/BindingEditor.svelte +++ b/web/src/components/BindingEditor.svelte @@ -76,7 +76,9 @@ {/if} {#each bindings as binding} <span class="chip"> - <span class="chip-type">{binding.targetType}</span> + <span class="chip-type" + >{binding.targetType === 'bird' ? 'automation' : binding.targetType}</span + > <span class="chip-label">{bindingLabel(binding)}</span> <button class="chip-remove" diff --git a/web/src/lib/channels.ts b/web/src/lib/channels.ts index 6e19347..4fde071 100644 --- a/web/src/lib/channels.ts +++ b/web/src/lib/channels.ts @@ -17,11 +17,11 @@ export function channelsFromConfig(config: ConfigResponse | null): string[] { } /** - * Bird channel select options: concrete channels plus a leading "None + * Automation channel select options: concrete channels plus a leading "None * (background)" entry (sentinel `BIRD_NONE`). `*` (all channels) is never a valid - * bird target, so it is excluded. The bird'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. + * 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__'; diff --git a/web/src/pages/ProjectCreate.svelte b/web/src/pages/ProjectCreate.svelte index 6715f13..2cec292 100644 --- a/web/src/pages/ProjectCreate.svelte +++ b/web/src/pages/ProjectCreate.svelte @@ -223,7 +223,7 @@ </label> {:else if step === 3} <p class="wiz-hint"> - A bird runs the agent on a schedule and posts to this channel. Optional. Provide both a + 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"> diff --git a/web/src/pages/ProjectDetail.svelte b/web/src/pages/ProjectDetail.svelte index c19c222..33326eb 100644 --- a/web/src/pages/ProjectDetail.svelte +++ b/web/src/pages/ProjectDetail.svelte @@ -453,7 +453,7 @@ > </div> {:else} - <input class="form-input mb" placeholder="Doc title" bind:value={docTitle} /> + <input class="form-input mb" placeholder="Skill title" bind:value={docTitle} /> <textarea class="form-input form-textarea mb" rows="3" @@ -477,7 +477,7 @@ {#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 doc.'}</p> + <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} From 2c740e76ded54994738374e47b5066cfba3c741e Mon Sep 17 00:00:00 2001 From: Papuna Gagnidze <pgagnidze@pm.me> Date: Fri, 5 Jun 2026 13:23:42 +0400 Subject: [PATCH 07/17] refactor!: rename BROWSERBIRD_BIRD_DATA env var to BROWSERBIRD_AUTOMATION_DATA --- README.md | 2 +- oci/app/config/agent-context.md | 2 +- src/cron/scheduler.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2d15a9a..adbd7eb 100644 --- a/README.md +++ b/README.md @@ -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 automation. Set automatically per automation 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 | diff --git a/oci/app/config/agent-context.md b/oci/app/config/agent-context.md index 832ae06..20e7a18 100644 --- a/oci/app/config/agent-context.md +++ b/oci/app/config/agent-context.md @@ -88,7 +88,7 @@ When the user asks to schedule a task, create a recurring job, or set up automat ## Automation Data -If this session is a scheduled automation run, the environment variable `BROWSERBIRD_BIRD_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. +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 diff --git a/src/cron/scheduler.ts b/src/cron/scheduler.ts index 51bb2c1..ebd4ac0 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( { From a79c32463ff55cfb2f0878d45ccfe7c9bc61ef09 Mon Sep 17 00:00:00 2001 From: Papuna Gagnidze <pgagnidze@pm.me> Date: Fri, 5 Jun 2026 13:29:53 +0400 Subject: [PATCH 08/17] refactor(web): drop dead-code bird binding chip relabel --- web/src/components/BindingEditor.svelte | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/web/src/components/BindingEditor.svelte b/web/src/components/BindingEditor.svelte index c515886..1f4c59b 100644 --- a/web/src/components/BindingEditor.svelte +++ b/web/src/components/BindingEditor.svelte @@ -76,9 +76,7 @@ {/if} {#each bindings as binding} <span class="chip"> - <span class="chip-type" - >{binding.targetType === 'bird' ? 'automation' : binding.targetType}</span - > + <span class="chip-type">{binding.targetType}</span> <span class="chip-label">{bindingLabel(binding)}</span> <button class="chip-remove" From fc8a051413a6b8c4e99f71535e31b5f8ae25491e Mon Sep 17 00:00:00 2001 From: Papuna Gagnidze <pgagnidze@pm.me> Date: Fri, 5 Jun 2026 13:42:25 +0400 Subject: [PATCH 09/17] test: quote test glob so node runs top-level src/*.test.ts files --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 5cfc5dd0cf9cc979bbb800bf78f1f0b85e15dc3d Mon Sep 17 00:00:00 2001 From: Papuna Gagnidze <pgagnidze@pm.me> Date: Fri, 5 Jun 2026 13:42:26 +0400 Subject: [PATCH 10/17] refactor(config): rename birds config key to automations with back-compat --- README.md | 4 +- browserbird.example.json | 2 +- src/channel/commands.ts | 2 +- src/cli/config.ts | 4 +- src/config.test.ts | 49 +++++++++++++++++++++++++ src/config.ts | 23 +++++++++++- src/core/types.ts | 4 +- src/cron/scheduler.ts | 2 +- src/daemon.ts | 2 +- src/server/routes/config.ts | 14 +++---- web/src/lib/types.ts | 2 +- web/src/pages/settings/ConfigTab.svelte | 20 +++++----- 12 files changed, 100 insertions(+), 28 deletions(-) create mode 100644 src/config.test.ts diff --git a/README.md b/README.md index adbd7eb..3d633bf 100644 --- a/README.md +++ b/README.md @@ -222,10 +222,10 @@ Browser mode (`persistent` or `isolated`) is controlled by the `BROWSER_MODE` en </details> <details> -<summary><strong>birds</strong> - Scheduled task settings</summary> +<summary><strong>automations</strong> - Scheduled task settings</summary> ```json -"birds": { +"automations": { "maxAttempts": 3 } ``` 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/src/channel/commands.ts b/src/channel/commands.ts index c992278..0bdf5fd 100644 --- a/src/channel/commands.ts +++ b/src/channel/commands.ts @@ -113,7 +113,7 @@ 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); 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/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 17232a9..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,7 +83,7 @@ export interface Config { sessions: SessionsConfig; database: DatabaseConfig; browser: BrowserConfig; - birds: BirdsConfig; + automations: AutomationsConfig; web: WebConfig; } diff --git a/src/cron/scheduler.ts b/src/cron/scheduler.ts index ebd4ac0..caeac83 100644 --- a/src/cron/scheduler.ts +++ b/src/cron/scheduler.ts @@ -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/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/web/src/lib/types.ts b/web/src/lib/types.ts index 3fd28b0..31d3ab8 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -120,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; diff --git a/web/src/pages/settings/ConfigTab.svelte b/web/src/pages/settings/ConfigTab.svelte index 8703fd7..2488bb4 100644 --- a/web/src/pages/settings/ConfigTab.svelte +++ b/web/src/pages/settings/ConfigTab.svelte @@ -552,19 +552,20 @@ <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> @@ -572,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} @@ -585,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> From eb1c339ddb4d1c41c11edc969f9bf14f04e643a9 Mon Sep 17 00:00:00 2001 From: Papuna Gagnidze <pgagnidze@pm.me> Date: Fri, 5 Jun 2026 13:56:42 +0400 Subject: [PATCH 11/17] refactor(projects): use automation vocabulary in the projects feature --- src/project.test.ts | 14 ++--- src/project.ts | 80 +++++++++++++------------- src/server/routes/projects.ts | 2 +- web/src/lib/types.ts | 10 ++-- web/src/pages/ProjectCreate.svelte | 35 +++++++----- web/src/pages/ProjectDetail.svelte | 92 ++++++++++++++++-------------- web/src/pages/Projects.svelte | 8 +-- 7 files changed, 128 insertions(+), 113 deletions(-) diff --git a/src/project.test.ts b/src/project.test.ts index 59730d3..7dedeec 100644 --- a/src/project.test.ts +++ b/src/project.test.ts @@ -38,7 +38,7 @@ describe('project: read-model', () => { tmpRoot = mkdtempSync(join(tmpdir(), 'bb-project-test-')); openDatabase(join(tmpRoot, 'test.db')); const d = getDb(); - const bird = createCronJob('daily', '0 9 * * *', 'do it', 'billing', 'billing'); + 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); @@ -50,7 +50,7 @@ describe('project: read-model', () => { ).run('key.k2', 'AWS_RO', 'plaintext', null); d.prepare( "INSERT INTO key_bindings (key_uid, target_type, target_id) VALUES ('key.k2', 'bird', ?)", - ).run(bird.uid); + ).run(automation.uid); }); after(() => { @@ -74,10 +74,10 @@ describe('project: read-model', () => { strictEqual(project.keys[0]?.source, 'channel'); }); - it('lists birds as members and ignores bird-scoped bindings', () => { + it('lists automations as members and ignores bird-scoped bindings', () => { const project = getProject(config, 'billing'); - strictEqual(project.birds.length, 1); - strictEqual(project.birds[0]?.name, 'daily'); + strictEqual(project.automations.length, 1); + strictEqual(project.automations[0]?.name, 'daily'); const aws = project.keys.find((k) => k.name === 'AWS_RO'); strictEqual(aws, undefined); }); @@ -86,7 +86,7 @@ describe('project: read-model', () => { const project = getProject(config, 'billing'); strictEqual(project.keyCount, 1); strictEqual(project.docCount, 0); - strictEqual(project.birdCount, 1); + strictEqual(project.automationCount, 1); }); it('lists a Global project plus one per concrete channel', () => { @@ -96,7 +96,7 @@ describe('project: read-model', () => { const billing = list.find((w) => w.channel === 'billing'); strictEqual(billing?.keyCount, 1); - strictEqual(billing?.birdCount, 1); + strictEqual(billing?.automationCount, 1); const prReview = list.find((w) => w.channel === 'pr-review'); strictEqual(prReview?.agentId, 'catch'); diff --git a/src/project.ts b/src/project.ts index 1e231f0..ffe24af 100644 --- a/src/project.ts +++ b/src/project.ts @@ -1,10 +1,10 @@ /** * @fileoverview Project read-model: a derived view over the binding graph, computed - * from config (agents + channel patterns) joined to key/doc bindings and birds. + * 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). Birds inherit their + * 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 @@ -25,7 +25,7 @@ export interface ProjectSummary { agentName: string | null; keyCount: number; docCount: number; - birdCount: number; + automationCount: number; } export type BindingSource = 'channel' | 'channel-global'; @@ -37,7 +37,7 @@ export interface ResolvedRef { bindingCount: number; } -export interface ProjectBirdMember { +export interface ProjectAutomationMember { uid: string; name: string; schedule: string; @@ -53,10 +53,10 @@ export interface ProjectDetail { agentShared: boolean; keys: ResolvedRef[]; docs: ResolvedRef[]; - birds: ProjectBirdMember[]; + automations: ProjectAutomationMember[]; keyCount: number; docCount: number; - birdCount: number; + automationCount: number; } function wildcardAgent(config: Config): AgentConfig | null { @@ -80,14 +80,14 @@ export function resolveAgentForChannel( interface CountMaps { key: Map<string, number>; doc: Map<string, number>; - bird: Map<string | null, 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 birds) fall out of the same - * GROUP BY, so the Global project reads from the same maps. System (`__bb_`) birds + * 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 { @@ -103,7 +103,7 @@ function channelCountMaps(): CountMaps { .all() as unknown as Array<{ ch: string; c: number }> ).map((r): [string, number] => [r.ch, r.c]), ); - const bird = new Map( + const automation = new Map( ( d .prepare( @@ -113,15 +113,15 @@ function channelCountMaps(): CountMaps { .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'), bird }; + 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 birdCount = counts.bird.get(null) ?? 0; + const automationCount = counts.automation.get(null) ?? 0; const agent = wildcardAgent(config); - if (keyCount === 0 && docCount === 0 && birdCount === 0 && !agent) { + if (keyCount === 0 && docCount === 0 && automationCount === 0 && !agent) { return null; } return { @@ -131,7 +131,7 @@ function globalSummary(config: Config, counts: CountMaps): ProjectSummary | null agentName: agent?.name ?? null, keyCount, docCount, - birdCount, + automationCount, }; } @@ -154,7 +154,7 @@ export function listProjects(config: Config): ProjectSummary[] { } 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.bird.keys()) if (ch != null) channels.add(ch); + for (const ch of counts.automation.keys()) if (ch != null) channels.add(ch); const summaries: ProjectSummary[] = []; const global = globalSummary(config, counts); @@ -169,7 +169,7 @@ export function listProjects(config: Config): ProjectSummary[] { agentName: agent?.name ?? null, keyCount: counts.key.get(channel) ?? 0, docCount: counts.doc.get(channel) ?? 0, - birdCount: counts.bird.get(channel) ?? 0, + automationCount: counts.automation.get(channel) ?? 0, }); } @@ -191,8 +191,8 @@ function projectField(n: ProjectSummary, key: string): string | number { return n.keyCount; case 'docCount': return n.docCount; - case 'birdCount': - return n.birdCount; + case 'automationCount': + return n.automationCount; default: return n.channel; } @@ -299,7 +299,7 @@ function resolveChannelRefs( .sort((a, b) => a.name.localeCompare(b.name)); } -interface BirdRow { +interface AutomationRow { uid: string; name: string; agent_id: string; @@ -308,7 +308,7 @@ interface BirdRow { enabled: number; } -function birdsForProject(channel: string, isGlobal: boolean): BirdRow[] { +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() @@ -316,13 +316,13 @@ function birdsForProject(channel: string, isGlobal: boolean): BirdRow[] { `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 BirdRow[]; + .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 - * birds that belong to the channel as plain members. Birds inherit the channel's + * 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 { @@ -356,17 +356,19 @@ export function getProject(config: Config, channel: string): ProjectDetail { docCounts, ); - const birds: ProjectBirdMember[] = birdsForProject(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, - }; - }); + 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, @@ -375,10 +377,10 @@ export function getProject(config: Config, channel: string): ProjectDetail { agentShared, keys, docs, - birds, + automations, keyCount: keys.length, docCount: docs.length, - birdCount: birds.length, + automationCount: automations.length, }; } @@ -394,12 +396,12 @@ export interface ProjectBlueprint { } | null; keys: string[]; docs: Array<{ title: string; content: string }>; - birds: Array<{ name: string; schedule: string; prompt: 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, bird definitions, and key + * 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. */ @@ -429,7 +431,7 @@ export function buildProjectBlueprint(config: Config, channel: string): ProjectB .filter((doc): doc is NonNullable<typeof doc> => doc != null) .map((doc) => ({ title: doc.title, content: doc.content })); - const birds = birdsForProject(channel, false).map((b) => { + const automations = automationsForProject(channel, false).map((b) => { const full = getCronJob(b.uid); return { name: b.name, schedule: b.schedule, prompt: full?.prompt ?? '' }; }); @@ -448,6 +450,6 @@ export function buildProjectBlueprint(config: Config, channel: string): ProjectB : null, keys: keyNames, docs, - birds, + automations, }; } diff --git a/src/server/routes/projects.ts b/src/server/routes/projects.ts index 979b5fb..cfd1728 100644 --- a/src/server/routes/projects.ts +++ b/src/server/routes/projects.ts @@ -1,4 +1,4 @@ -/** @fileoverview Project read-model API: aggregates agent + keys + docs + birds per channel. */ +/** @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'; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 31d3ab8..3fa8f01 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -22,7 +22,7 @@ export interface ProjectSummary { agentName: string | null; keyCount: number; docCount: number; - birdCount: number; + automationCount: number; } export type BindingSource = 'channel' | 'channel-global'; @@ -34,7 +34,7 @@ export interface ResolvedRef { bindingCount: number; } -export interface ProjectBirdMember { +export interface ProjectAutomationMember { uid: string; name: string; schedule: string; @@ -50,10 +50,10 @@ export interface ProjectDetail { agentShared: boolean; keys: ResolvedRef[]; docs: ResolvedRef[]; - birds: ProjectBirdMember[]; + automations: ProjectAutomationMember[]; keyCount: number; docCount: number; - birdCount: number; + automationCount: number; } export interface ProjectBlueprint { @@ -68,7 +68,7 @@ export interface ProjectBlueprint { } | null; keys: string[]; docs: Array<{ title: string; content: string }>; - birds: Array<{ name: string; schedule: string; prompt: string }>; + automations: Array<{ name: string; schedule: string; prompt: string }>; } export interface FlightStats { diff --git a/web/src/pages/ProjectCreate.svelte b/web/src/pages/ProjectCreate.svelte index 2cec292..0547a8c 100644 --- a/web/src/pages/ProjectCreate.svelte +++ b/web/src/pages/ProjectCreate.svelte @@ -18,9 +18,9 @@ let docTitle = $state(''); let docContent = $state(''); - let birdName = $state(''); - let birdSchedule = $state(''); - let birdPrompt = $state(''); + let automationName = $state(''); + let automationSchedule = $state(''); + let automationPrompt = $state(''); $effect(() => { const ac = new AbortController(); @@ -37,7 +37,9 @@ 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 hasBird = $derived(birdSchedule.trim() !== '' && birdPrompt.trim() !== ''); + const hasAutomation = $derived( + automationSchedule.trim() !== '' && automationPrompt.trim() !== '', + ); const canProceed = $derived(channel.trim() !== '' && agentId !== ''); function addKeyRow(): void { @@ -101,14 +103,14 @@ }); } - async function createBird(ch: string): Promise<void> { - if (!hasBird) return; + async function createAutomation(ch: string): Promise<void> { + if (!hasAutomation) return; await api('/api/automations', { method: 'POST', body: { - schedule: birdSchedule.trim(), - prompt: birdPrompt.trim(), - name: birdName.trim() || undefined, + schedule: automationSchedule.trim(), + prompt: automationPrompt.trim(), + name: automationName.trim() || undefined, channel: ch, agent: agentId, }, @@ -125,7 +127,7 @@ error = ''; try { await assignAgentToChannel(); - await Promise.all([createKeys(ch), createDoc(ch), createBird(ch)]); + await Promise.all([createKeys(ch), createDoc(ch), createAutomation(ch)]); showToast('Project created', 'success'); window.location.hash = `#/project-detail?id=${encodeURIComponent(ch)}`; } catch (err) { @@ -232,12 +234,17 @@ class="form-input" type="text" placeholder="weekly cost review" - bind:value={birdName} + bind:value={automationName} /> </label> <label class="form-label"> Schedule (cron) - <input class="form-input" type="text" placeholder="0 9 * * 1" bind:value={birdSchedule} /> + <input + class="form-input" + type="text" + placeholder="0 9 * * 1" + bind:value={automationSchedule} + /> </label> <label class="form-label"> Prompt @@ -245,7 +252,7 @@ class="form-input form-textarea" rows="4" placeholder="Summarize this week's AWS spend..." - bind:value={birdPrompt} + bind:value={automationPrompt} ></textarea> </label> {:else} @@ -263,7 +270,7 @@ <div class="review-row"><span>Skill</span><span>{docTitle.trim() || 'none'}</span></div> <div class="review-row"> <span>Automation</span><span - >{hasBird ? `${birdName || 'unnamed'} (${birdSchedule})` : 'none'}</span + >{hasAutomation ? `${automationName || 'unnamed'} (${automationSchedule})` : 'none'}</span > </div> </div> diff --git a/web/src/pages/ProjectDetail.svelte b/web/src/pages/ProjectDetail.svelte index 33326eb..978835d 100644 --- a/web/src/pages/ProjectDetail.svelte +++ b/web/src/pages/ProjectDetail.svelte @@ -20,12 +20,12 @@ let config = $state<ConfigResponse | null>(null); let allKeys = $state<KeyInfo[]>([]); let allDocs = $state<DocInfo[]>([]); - let allBirds = $state<CronJobRow[]>([]); + let allAutomations = $state<CronJobRow[]>([]); let loading = $state(true); let busy = $state(false); let open = $state<string | null>(null); - let addKind = $state<'key' | 'doc' | 'bird' | null>(null); + let addKind = $state<'key' | 'doc' | 'automation' | null>(null); let addTab = $state<'existing' | 'new'>('existing'); let editAgent = $state(false); @@ -46,7 +46,7 @@ config = c; allKeys = ks.items; allDocs = ds.items; - allBirds = bs.items.filter((b) => !b.name.startsWith('__bb_')); + allAutomations = bs.items.filter((b) => !b.name.startsWith('__bb_')); } catch (err) { showToast(`Failed to load: ${(err as Error).message}`, 'error'); } finally { @@ -65,7 +65,7 @@ }); const title = $derived(project?.isGlobal ? 'Global' : channel); - const realBirds = $derived(project ? project.birds : []); + const realAutomations = $derived(project ? project.automations : []); function flag(r: ResolvedRef): 'global' | 'shared' | null { if (r.source === 'channel-global') return 'global'; @@ -74,7 +74,7 @@ function toggle(id: string): void { open = open === id ? null : id; } - function openAdd(what: 'key' | 'doc' | 'bird'): void { + function openAdd(what: 'key' | 'doc' | 'automation'): void { addKind = addKind === what ? null : what; addTab = 'existing'; } @@ -85,7 +85,9 @@ } const candidateKeys = $derived(allKeys.filter((k) => !boundToChannel(k.bindings))); const candidateDocs = $derived(allDocs.filter((d) => !boundToChannel(d.bindings))); - const candidateBirds = $derived(allBirds.filter((b) => b.target_channel_id !== channel)); + const candidateAutomations = $derived( + allAutomations.filter((b) => b.target_channel_id !== channel), + ); async function run(fn: () => Promise<void>, collapse = false): Promise<void> { busy = true; @@ -149,10 +151,10 @@ true, ); } - async function attachBird(uid: string): Promise<void> { + async function attachAutomation(uid: string): Promise<void> { await run(() => api(`/api/automations/${uid}`, { method: 'PATCH', body: { channel } }), true); } - async function unbindBird(uid: string): Promise<void> { + async function unbindAutomation(uid: string): Promise<void> { await run( () => api(`/api/automations/${uid}`, { method: 'PATCH', body: { channel: null } }), true, @@ -200,10 +202,10 @@ let pickDoc = $state(''); let docTitle = $state(''); let docContent = $state(''); - let pickBird = $state(''); - let birdName = $state(''); - let birdSchedule = $state(''); - let birdPrompt = $state(''); + let pickAutomation = $state(''); + let automationName = $state(''); + let automationSchedule = $state(''); + let automationPrompt = $state(''); async function createKey(): Promise<void> { if (!keyName.trim() || !keyValue.trim()) return; @@ -236,30 +238,30 @@ docContent = ''; }, true); } - async function createBird(): Promise<void> { - if (!birdSchedule.trim() || !birdPrompt.trim()) return; + async function createAutomation(): Promise<void> { + if (!automationSchedule.trim() || !automationPrompt.trim()) return; await run(async () => { await api('/api/automations', { method: 'POST', body: { - schedule: birdSchedule.trim(), - prompt: birdPrompt.trim(), - name: birdName.trim() || undefined, + schedule: automationSchedule.trim(), + prompt: automationPrompt.trim(), + name: automationName.trim() || undefined, channel, agent: project?.agent?.id ?? undefined, }, }); - birdName = ''; - birdSchedule = ''; - birdPrompt = ''; + automationName = ''; + automationSchedule = ''; + automationPrompt = ''; }, true); } function scheduleFromDoc(d: DocInfo): void { - addKind = 'bird'; + addKind = 'automation'; addTab = 'new'; - birdName = `${d.title} digest`; - birdPrompt = `Follow the "${d.title}" skill and post the result here.`; + automationName = `${d.title} digest`; + automationPrompt = `Follow the "${d.title}" skill and post the result here.`; open = null; } @@ -503,9 +505,10 @@ <div class="block"> <div class="block-head"> <span class="block-title">Automations</span> - {#if !project.isGlobal}<button class="add" onclick={() => openAdd('bird')}>+ Add</button>{/if} + {#if !project.isGlobal}<button class="add" onclick={() => openAdd('automation')}>+ Add</button + >{/if} </div> - {#if addKind === 'bird' && !project.isGlobal} + {#if addKind === 'automation' && !project.isGlobal} <div class="addbox"> <div class="addtabs"> <button class:on={addTab === 'existing'} onclick={() => (addTab = 'existing')} @@ -515,46 +518,49 @@ </div> {#if addTab === 'existing'} <div class="row2"> - <select class="form-input" bind:value={pickBird}> + <select class="form-input" bind:value={pickAutomation}> <option value="" disabled selected>Pick an automation...</option> - {#each candidateBirds as b (b.uid)}<option value={b.uid}>{b.name}</option>{/each} + {#each candidateAutomations as b (b.uid)}<option value={b.uid}>{b.name}</option + >{/each} </select> <button class="btn btn-primary btn-sm" - disabled={busy || !pickBird} - onclick={() => attachBird(pickBird)}>Add</button + disabled={busy || !pickAutomation} + onclick={() => attachAutomation(pickAutomation)}>Add</button > </div> {:else} - <input class="form-input mb" placeholder="Name (optional)" bind:value={birdName} /> + <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={birdSchedule} + bind:value={automationSchedule} /> <textarea class="form-input form-textarea mb" rows="3" placeholder="What should it do?" - bind:value={birdPrompt} + bind:value={automationPrompt} ></textarea> - <button class="btn btn-primary btn-sm" disabled={busy} onclick={createBird}>Add</button> + <button class="btn btn-primary btn-sm" disabled={busy} onclick={createAutomation} + >Add</button + > {/if} </div> {/if} - {#if realBirds.length === 0} + {#if realAutomations.length === 0} <div class="empty">None</div> {:else} - {#each realBirds as bird (bird.uid)} + {#each realAutomations as automation (automation.uid)} <div class="item"> - <button class="item-row" onclick={() => toggle(`bird:${bird.uid}`)}> - <span class="caret">{open === `bird:${bird.uid}` ? '▾' : '▸'}</span> - <span class="row-name">{bird.name}</span> - <code class="row-sched">{bird.schedule}</code> - <span class="dot" class:on={bird.enabled}></span> + <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 === `bird:${bird.uid}`} - {@const b = allBirds.find((x) => x.uid === bird.uid)} + {#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> @@ -566,7 +572,7 @@ <button class="btn btn-danger btn-sm" disabled={busy} - onclick={() => unbindBird(bird.uid)}>Remove</button + onclick={() => unbindAutomation(automation.uid)}>Remove</button > </div> </div> diff --git a/web/src/pages/Projects.svelte b/web/src/pages/Projects.svelte index 5038c1a..d7d0c75 100644 --- a/web/src/pages/Projects.svelte +++ b/web/src/pages/Projects.svelte @@ -17,7 +17,7 @@ { key: 'agent', label: 'Agent', sortable: true }, { key: 'keyCount', label: 'Keys', sortable: true }, { key: 'docCount', label: 'Skills', sortable: true }, - { key: 'birdCount', label: 'Automations', sortable: true }, + { key: 'automationCount', label: 'Automations', sortable: true }, ]; let invalidations = $state(0); @@ -108,7 +108,7 @@ }); } - for (const b of bp.birds ?? []) { + for (const b of bp.automations ?? []) { await api('/api/automations', { method: 'POST', body: { @@ -187,7 +187,7 @@ <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.birdCount}</span> + <span class="item-card-label">Automations</span><span>{ws.automationCount}</span> </div> </div> </button> @@ -210,7 +210,7 @@ > <td>{ws.keyCount}</td> <td>{ws.docCount}</td> - <td>{ws.birdCount}</td> + <td>{ws.automationCount}</td> </tr> {/each} </DataTable> From fe89e975767c27500fb68ee0bef871f48640313f Mon Sep 17 00:00:00 2001 From: Papuna Gagnidze <pgagnidze@pm.me> Date: Fri, 5 Jun 2026 14:01:48 +0400 Subject: [PATCH 12/17] feat(web): nest agents, skills, keys, automations under projects in sidebar --- web/src/components/Sidebar.svelte | 194 +++++++++++++++++++++++------- 1 file changed, 153 insertions(+), 41 deletions(-) diff --git a/web/src/components/Sidebar.svelte b/web/src/components/Sidebar.svelte index 67cf8cb..bbc91c1 100644 --- a/web/src/components/Sidebar.svelte +++ b/web/src/components/Sidebar.svelte @@ -1,4 +1,14 @@ <script lang="ts"> + interface NavChild { + page: string; + label: string; + svg: string; + } + + interface NavItem extends NavChild { + children?: NavChild[]; + } + interface Props { currentPage: string; collapsed: boolean; @@ -8,7 +18,7 @@ onsignout: () => void; } - const NAV_ITEMS = [ + const NAV_ITEMS: NavItem[] = [ { page: 'status', label: 'Mission Control', @@ -18,32 +28,34 @@ 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: 'agents', - label: 'Agents', - svg: `<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>`, + children: [ + { + page: 'agents', + label: 'Agents', + svg: `<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>`, + }, + { + page: 'skills', + label: 'Skills', + 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: 'keys', + label: 'Keys', + svg: `<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>`, + }, + { + page: 'automations', + label: 'Automations', + svg: `<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>`, + }, + ], }, { 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: 'skills', - label: 'Skills', - 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: 'keys', - label: 'Keys', - svg: `<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>`, - }, - { - page: 'automations', - label: 'Automations', - svg: `<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>`, - }, { page: 'computer', label: 'Computer', @@ -54,15 +66,49 @@ 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(); + const CHILD_PAGES = NAV_ITEMS.flatMap((i) => i.children?.map((c) => c.page) ?? []); + + let projectsExpanded = $state(true); + + $effect(() => { + if (currentPage === 'projects' || CHILD_PAGES.includes(currentPage)) projectsExpanded = true; + }); + + function toggleProjects(): void { + projectsExpanded = !projectsExpanded; + } + function handleNavClick(): void { onmobileclose(); } </script> +{#snippet navLink(page: string, label: string, svg: string, childClass: string)} + <a + class="nav-item {childClass}" + class:active={currentPage === page} + href="#/{page === 'status' ? '' : page}" + title={collapsed ? label : undefined} + onclick={handleNavClick} + > + <svg + class="nav-icon" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true">{@html svg}</svg + > + <span class="nav-label">{label}</span> + </a> +{/snippet} + {#if mobileOpen} <div class="drawer-backdrop" onclick={onmobileclose} role="presentation"></div> {/if} @@ -97,25 +143,41 @@ </div> <div class="sidebar-nav"> {#each NAV_ITEMS as item} - <a - class="nav-item" - class:active={currentPage === item.page} - href="#/{item.page === 'status' ? '' : item.page}" - title={collapsed ? item.label : undefined} - onclick={handleNavClick} - > - <svg - class="nav-icon" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true">{@html item.svg}</svg - > - <span class="nav-label">{item.label}</span> - </a> + {#if item.children && !collapsed} + <div class="nav-parent"> + {@render navLink(item.page, item.label, item.svg, '')} + <button + class="nav-caret" + class:open={projectsExpanded} + onclick={toggleProjects} + aria-label="Toggle {item.label}" + aria-expanded={projectsExpanded} + > + <svg + class="caret-icon" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg + > + </button> + </div> + {#if projectsExpanded} + {#each item.children as child} + {@render navLink(child.page, child.label, child.svg, 'nav-child')} + {/each} + {/if} + {:else if item.children && collapsed} + {@render navLink(item.page, item.label, item.svg, '')} + {#each item.children as child} + {@render navLink(child.page, child.label, child.svg, '')} + {/each} + {:else} + {@render navLink(item.page, item.label, item.svg, '')} + {/if} {/each} </div> <div class="sidebar-footer"> @@ -354,6 +416,56 @@ opacity: 0; } + .nav-parent { + position: relative; + } + + .nav-caret { + position: absolute; + right: var(--space-3); + top: 50%; + transform: translateY(-50%); + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + padding: 0; + border: none; + background: none; + color: var(--color-text-muted); + cursor: pointer; + border-radius: var(--radius-sm); + transition: + color var(--transition-fast), + background var(--transition-fast); + } + + .nav-caret:hover { + color: var(--color-text-secondary); + background: var(--color-bg-hover); + } + + .nav-caret .caret-icon { + width: 0.875rem; + height: 0.875rem; + transition: transform var(--transition-fast); + } + + .nav-caret.open .caret-icon { + transform: rotate(90deg); + } + + .nav-child { + padding-left: calc(var(--space-4) + 1.75rem); + } + + .nav-child .nav-icon { + width: 1rem; + height: 1rem; + } + .sidebar-footer { flex-shrink: 0; display: flex; From 3f74a0d09a7fb582cabe1093a3d27fca7d667b31 Mon Sep 17 00:00:00 2001 From: Papuna Gagnidze <pgagnidze@pm.me> Date: Fri, 5 Jun 2026 14:10:32 +0400 Subject: [PATCH 13/17] feat(web): nest projects nav always-expanded with a grouped cluster when collapsed --- web/src/components/Sidebar.svelte | 112 +++++++++--------------------- 1 file changed, 34 insertions(+), 78 deletions(-) diff --git a/web/src/components/Sidebar.svelte b/web/src/components/Sidebar.svelte index bbc91c1..699f125 100644 --- a/web/src/components/Sidebar.svelte +++ b/web/src/components/Sidebar.svelte @@ -70,18 +70,6 @@ let { currentPage, collapsed, mobileOpen, ontoggle, onmobileclose, onsignout }: Props = $props(); - const CHILD_PAGES = NAV_ITEMS.flatMap((i) => i.children?.map((c) => c.page) ?? []); - - let projectsExpanded = $state(true); - - $effect(() => { - if (currentPage === 'projects' || CHILD_PAGES.includes(currentPage)) projectsExpanded = true; - }); - - function toggleProjects(): void { - projectsExpanded = !projectsExpanded; - } - function handleNavClick(): void { onmobileclose(); } @@ -143,38 +131,19 @@ </div> <div class="sidebar-nav"> {#each NAV_ITEMS as item} - {#if item.children && !collapsed} - <div class="nav-parent"> - {@render navLink(item.page, item.label, item.svg, '')} - <button - class="nav-caret" - class:open={projectsExpanded} - onclick={toggleProjects} - aria-label="Toggle {item.label}" - aria-expanded={projectsExpanded} - > - <svg - class="caret-icon" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg - > - </button> - </div> - {#if projectsExpanded} + {#if item.children} + {@render navLink(item.page, item.label, item.svg, '')} + {#if collapsed} + <div class="nav-cluster"> + {#each item.children as child} + {@render navLink(child.page, child.label, child.svg, '')} + {/each} + </div> + {:else} {#each item.children as child} {@render navLink(child.page, child.label, child.svg, 'nav-child')} {/each} {/if} - {:else if item.children && collapsed} - {@render navLink(item.page, item.label, item.svg, '')} - {#each item.children as child} - {@render navLink(child.page, child.label, child.svg, '')} - {/each} {:else} {@render navLink(item.page, item.label, item.svg, '')} {/if} @@ -416,54 +385,41 @@ opacity: 0; } - .nav-parent { - position: relative; - } - - .nav-caret { - position: absolute; - right: var(--space-3); - top: 50%; - transform: translateY(-50%); - z-index: 1; - display: flex; - align-items: center; - justify-content: center; - width: 1.25rem; - height: 1.25rem; - padding: 0; - border: none; - background: none; - color: var(--color-text-muted); - cursor: pointer; - border-radius: var(--radius-sm); - transition: - color var(--transition-fast), - background var(--transition-fast); + /* Expanded: children nested under their parent */ + .nav-child { + padding-left: calc(var(--space-4) + 1.75rem); } - .nav-caret:hover { - color: var(--color-text-secondary); - background: var(--color-bg-hover); + .nav-child .nav-icon { + width: 1rem; + height: 1rem; } - .nav-caret .caret-icon { - width: 0.875rem; - height: 0.875rem; - transition: transform var(--transition-fast); + /* Collapsed: children grouped in a contained cluster */ + .nav-cluster { + margin: var(--space-1) var(--space-2); + padding: var(--space-1) 0; + background: var(--color-bg-elevated); + border-radius: var(--radius-md); + display: flex; + flex-direction: column; + gap: 1px; } - .nav-caret.open .caret-icon { - transform: rotate(90deg); + .nav-cluster .nav-item { + margin: 0; + padding: var(--space-2) 0; + justify-content: center; + border-radius: var(--radius-sm); } - .nav-child { - padding-left: calc(var(--space-4) + 1.75rem); + .nav-cluster .nav-item::before { + display: none; } - .nav-child .nav-icon { - width: 1rem; - height: 1rem; + .nav-cluster .nav-item.active { + background: var(--color-bg-hover); + box-shadow: inset 0 0 0 1.5px var(--color-accent); } .sidebar-footer { From 1abf926b34ad6c36285ac6b8ff0eebdadda87db8 Mon Sep 17 00:00:00 2001 From: Papuna Gagnidze <pgagnidze@pm.me> Date: Fri, 5 Jun 2026 14:12:34 +0400 Subject: [PATCH 14/17] feat(web): reveal projects sub-nav only within the projects section --- web/src/components/Sidebar.svelte | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/web/src/components/Sidebar.svelte b/web/src/components/Sidebar.svelte index 699f125..cf47f3f 100644 --- a/web/src/components/Sidebar.svelte +++ b/web/src/components/Sidebar.svelte @@ -70,6 +70,9 @@ let { currentPage, collapsed, mobileOpen, ontoggle, onmobileclose, onsignout }: Props = $props(); + const CHILD_PAGES = NAV_ITEMS.flatMap((i) => i.children?.map((c) => c.page) ?? []); + const inProjects = $derived(currentPage === 'projects' || CHILD_PAGES.includes(currentPage)); + function handleNavClick(): void { onmobileclose(); } @@ -133,16 +136,18 @@ {#each NAV_ITEMS as item} {#if item.children} {@render navLink(item.page, item.label, item.svg, '')} - {#if collapsed} - <div class="nav-cluster"> + {#if inProjects} + {#if collapsed} + <div class="nav-cluster"> + {#each item.children as child} + {@render navLink(child.page, child.label, child.svg, '')} + {/each} + </div> + {:else} {#each item.children as child} - {@render navLink(child.page, child.label, child.svg, '')} + {@render navLink(child.page, child.label, child.svg, 'nav-child')} {/each} - </div> - {:else} - {#each item.children as child} - {@render navLink(child.page, child.label, child.svg, 'nav-child')} - {/each} + {/if} {/if} {:else} {@render navLink(item.page, item.label, item.svg, '')} From afd22aefff4c97187cba74700943a7ee3d8c6dd8 Mon Sep 17 00:00:00 2001 From: Papuna Gagnidze <pgagnidze@pm.me> Date: Fri, 5 Jun 2026 14:25:07 +0400 Subject: [PATCH 15/17] feat(web): collapse agents/skills/keys/automations into a resources hub --- web/src/App.svelte | 24 ++---- web/src/components/Sidebar.svelte | 136 +++++++----------------------- web/src/pages/Resources.svelte | 68 +++++++++++++++ 3 files changed, 106 insertions(+), 122 deletions(-) create mode 100644 web/src/pages/Resources.svelte diff --git a/web/src/App.svelte b/web/src/App.svelte index 49a7185..19352b4 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -21,17 +21,14 @@ import ConfirmDialog from './components/ConfirmDialog.svelte'; import MissionControl from './pages/MissionControl.svelte'; import Sessions from './pages/Sessions.svelte'; - import Automations from './pages/Automations.svelte'; import Computer from './pages/Computer.svelte'; import Settings from './pages/Settings.svelte'; import SessionDetail from './pages/SessionDetail.svelte'; - import Skills from './pages/Skills.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 Agents from './pages/Agents.svelte'; - import Keys from './pages/Keys.svelte'; + import Resources from './pages/Resources.svelte'; import Onboarding from './pages/Onboarding.svelte'; const PAGE_TITLES: Record<string, string> = { @@ -41,11 +38,12 @@ 'project-create': 'New Project', sessions: 'Sessions', 'session-detail': 'Session Detail', - skills: 'Skills', + resources: 'Resources', + agents: 'Resources', + skills: 'Resources', + keys: 'Resources', + automations: 'Resources', 'skill-detail': 'Skill Detail', - automations: 'Automations', - agents: 'Agents', - keys: 'Keys', computer: 'Computer', settings: 'Settings', }; @@ -356,16 +354,10 @@ <ProjectCreate /> {:else if currentPage === 'session-detail'} <SessionDetail /> - {:else if currentPage === 'skills'} - <Skills /> {:else if currentPage === 'skill-detail'} <SkillDetail /> - {:else if currentPage === 'automations'} - <Automations /> - {:else if currentPage === 'agents'} - <Agents /> - {:else if currentPage === 'keys'} - <Keys /> + {: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/Sidebar.svelte b/web/src/components/Sidebar.svelte index cf47f3f..679fa26 100644 --- a/web/src/components/Sidebar.svelte +++ b/web/src/components/Sidebar.svelte @@ -1,12 +1,9 @@ <script lang="ts"> - interface NavChild { + interface NavItem { page: string; label: string; svg: string; - } - - interface NavItem extends NavChild { - children?: NavChild[]; + match?: string[]; } interface Props { @@ -28,28 +25,12 @@ 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"/>`, - children: [ - { - page: 'agents', - label: 'Agents', - svg: `<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>`, - }, - { - page: 'skills', - label: 'Skills', - 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: 'keys', - label: 'Keys', - svg: `<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>`, - }, - { - page: 'automations', - label: 'Automations', - svg: `<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>`, - }, - ], + }, + { + 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: 'sessions', @@ -70,36 +51,15 @@ let { currentPage, collapsed, mobileOpen, ontoggle, onmobileclose, onsignout }: Props = $props(); - const CHILD_PAGES = NAV_ITEMS.flatMap((i) => i.children?.map((c) => c.page) ?? []); - const inProjects = $derived(currentPage === 'projects' || CHILD_PAGES.includes(currentPage)); + function isActive(item: NavItem): boolean { + return currentPage === item.page || (item.match?.includes(currentPage) ?? false); + } function handleNavClick(): void { onmobileclose(); } </script> -{#snippet navLink(page: string, label: string, svg: string, childClass: string)} - <a - class="nav-item {childClass}" - class:active={currentPage === page} - href="#/{page === 'status' ? '' : page}" - title={collapsed ? label : undefined} - onclick={handleNavClick} - > - <svg - class="nav-icon" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true">{@html svg}</svg - > - <span class="nav-label">{label}</span> - </a> -{/snippet} - {#if mobileOpen} <div class="drawer-backdrop" onclick={onmobileclose} role="presentation"></div> {/if} @@ -134,24 +94,25 @@ </div> <div class="sidebar-nav"> {#each NAV_ITEMS as item} - {#if item.children} - {@render navLink(item.page, item.label, item.svg, '')} - {#if inProjects} - {#if collapsed} - <div class="nav-cluster"> - {#each item.children as child} - {@render navLink(child.page, child.label, child.svg, '')} - {/each} - </div> - {:else} - {#each item.children as child} - {@render navLink(child.page, child.label, child.svg, 'nav-child')} - {/each} - {/if} - {/if} - {:else} - {@render navLink(item.page, item.label, item.svg, '')} - {/if} + <a + class="nav-item" + class:active={isActive(item)} + href="#/{item.page === 'status' ? '' : item.page}" + title={collapsed ? item.label : undefined} + onclick={handleNavClick} + > + <svg + class="nav-icon" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true">{@html item.svg}</svg + > + <span class="nav-label">{item.label}</span> + </a> {/each} </div> <div class="sidebar-footer"> @@ -390,43 +351,6 @@ opacity: 0; } - /* Expanded: children nested under their parent */ - .nav-child { - padding-left: calc(var(--space-4) + 1.75rem); - } - - .nav-child .nav-icon { - width: 1rem; - height: 1rem; - } - - /* Collapsed: children grouped in a contained cluster */ - .nav-cluster { - margin: var(--space-1) var(--space-2); - padding: var(--space-1) 0; - background: var(--color-bg-elevated); - border-radius: var(--radius-md); - display: flex; - flex-direction: column; - gap: 1px; - } - - .nav-cluster .nav-item { - margin: 0; - padding: var(--space-2) 0; - justify-content: center; - border-radius: var(--radius-sm); - } - - .nav-cluster .nav-item::before { - display: none; - } - - .nav-cluster .nav-item.active { - background: var(--color-bg-hover); - box-shadow: inset 0 0 0 1.5px var(--color-accent); - } - .sidebar-footer { flex-shrink: 0; display: flex; 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> From 27affca3d1908b3ad5c952d387e5907c210fa41b Mon Sep 17 00:00:00 2001 From: Papuna Gagnidze <pgagnidze@pm.me> Date: Fri, 5 Jun 2026 14:40:47 +0400 Subject: [PATCH 16/17] fix(bindings): preserve legacy bird bindings on the channel-only write path --- src/db/bindings.test.ts | 52 +++++++++++++++++++++++++++++++++++++++++ src/db/core.ts | 9 +++++-- src/server/http.ts | 9 ++++--- 3 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 src/db/bindings.test.ts 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/server/http.ts b/src/server/http.ts index 3031c84..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') { - jsonError(res, '"targetType" must be "channel"', 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; From adc1e6bb8745d046f5a9efa5e434598bc798bc51 Mon Sep 17 00:00:00 2001 From: Papuna Gagnidze <pgagnidze@pm.me> Date: Fri, 5 Jun 2026 14:40:47 +0400 Subject: [PATCH 17/17] fix: correct help-text keys, keys cross-link, and global blueprint automations --- src/cli/run.ts | 4 ++-- src/project.ts | 2 +- web/src/pages/ProjectDetail.svelte | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cli/run.ts b/src/cli/run.ts index 80116f5..24695d0 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -112,14 +112,14 @@ 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 handleAutomations(rest); break; case COMMANDS.DOCS: if (isHelp) { - console.log(COMMAND_HELP.docs); + console.log(COMMAND_HELP.skills); return; } await handleSkills(rest); diff --git a/src/project.ts b/src/project.ts index ffe24af..639955b 100644 --- a/src/project.ts +++ b/src/project.ts @@ -431,7 +431,7 @@ export function buildProjectBlueprint(config: Config, channel: string): ProjectB .filter((doc): doc is NonNullable<typeof doc> => doc != null) .map((doc) => ({ title: doc.title, content: doc.content })); - const automations = automationsForProject(channel, false).map((b) => { + 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 ?? '' }; }); diff --git a/web/src/pages/ProjectDetail.svelte b/web/src/pages/ProjectDetail.svelte index 978835d..aa74343 100644 --- a/web/src/pages/ProjectDetail.svelte +++ b/web/src/pages/ProjectDetail.svelte @@ -419,7 +419,7 @@ </div> {:else} <p class="note"> - Bound to all channels. Manage in <a href="#/settings?tab=keys">Keys</a>. + Bound to all channels. Manage in <a href="#/keys">Keys</a>. </p> {/if} </div>