mirror of
https://github.com/coleam00/Archon
synced 2026-04-21 13:37:41 +00:00
Archon Web UI: React frontend, web adapter, API routes, workflow events
* Archon UI PRD and phase 1 plan
* Initial Archon UI
* Workflow tracking
* UI improvements (with a couple schema changes)
* Message history for web adapter and UI adjustments
* Chat/Project/Tasks in Archon UI
* UI fixes
* UI fixes
* UI improvements
* Fix PR review issues: type safety, error handling, cleanup, docs
- Convert MessageChunk to discriminated union with workflow_dispatch variant
- Add IWebPlatformAdapter interface and isWebAdapter() type guard
- Replace unsafe 'in' type guards with proper type narrowing in orchestrator
- Add logging to silent catch blocks in api.ts, web.ts
- Add WebAdapter buffer cleanup with 60s delayed cleanup on disconnect
- Wrap SQLite migration ALTER TABLE groups in try/catch
- Log SSE parse errors in useSSE.ts
- Truncate API error bodies to 200 chars, include URL path
- Surface background workflow errors to parent conversation
- Fix getWorkflowRunByWorker to only swallow 404s, re-throw 500s
- Add documentation comments to ChatMessage and SSEEvent types
- Update table counts from 3/5/6 to 8 across all docs
* Remove agent plan files from PR, gitignore .agents/plans/
These are local development artifacts that shouldn't be in the repo.
* Gitignore .claude/PRPs/
* Fix PR review issues: error handling, type safety, silent failures
- Fix acquireLock error swallowing: wrap handler in try/catch/finally to
always release UI lock and surface errors to SSE stream
- Restrict CORS to configurable origin (WEB_UI_ORIGIN env var)
- Validate status query param instead of unsafe `as` cast
- Add per-codebase JSON.parse error isolation for corrupted commands
- Type request body in POST /api/conversations
- Return 404 for missing conversation instead of empty array
- Add warning log when flushAssistantMessage has no DB ID mapping
- Add debug logging to 6 SSE catch blocks (was silently swallowing)
- Replace 17x .catch(() => undefined) with void on fire-and-forget calls
- Make workflow status='running' update a blocking error
- Split MessageChunk into proper per-type discriminated union
- Add hidden field to Conversation interface
- Add guard in addMessage for undefined row
- Extract WorkflowRunStatus/WorkflowStepStatus type aliases
- Fix WorkflowArtifact.type to use literal union
- Separate SSE parse errors from handler errors in useSSE hook
- Gitignore e2e testing session artifact
* Wire up dead endpoints: workflow run UI, health display, worker→run link
- Extract dispatchToOrchestrator() helper to deduplicate lock/dispatch
logic between message send and workflow run endpoints
- Fix workflow run endpoint to construct /workflow run <name> <msg>
from URL param + body, with name validation
- Add run panel to WorkflowList with message input, error display,
and navigation to chat on success
- Add System Health section to SettingsPage with independent query
and actionable error messages
- Add worker→run navigation link in WorkflowExecution header
- Regenerate 000_combined.sql to include migrations 001-015
- Fix silent .catch() patterns: log errors instead of swallowing
- Wrap emitSSE in catch block, log full error objects
* Fix routing for conversation IDs with special characters
GitHub issue conversation IDs like "user/repo#42" contain / and #
which break React Router's :param matching. Fix by:
- Use /chat/* splat route instead of /chat/:conversationId
- encodeURIComponent() all platform_conversation_id values in URLs
- decodeURIComponent() in ChatPage when reading the splat param
* Restructure sidebar: project selector nav, remove dead components
Replace multi-view sidebar (ChatView, ProjectView, TaskView, ViewSwitcher)
with a simpler project-scoped navigation. Add ProjectSelector component
for switching between codebases. Remove unused ConversationsList,
useNotifications hook, and related dead code.
* Add project CRUD: clone/register from UI, delete with cleanup
Extract clone logic from command-handler into standalone clone.ts module
with cloneRepository() and registerRepository() entry points. Add REST
endpoints POST /api/codebases (clone URL or register local path) and
DELETE /api/codebases/:id (destroy worktrees, unlink DB, remove workspace).
Update sidebar with "+" button for inline project add input that auto-detects
URL vs local path, and hover-to-delete with AlertDialog confirmation on each
project in ProjectSelector.
* Harden backend guard rails, polish UX, remove dead project pages
Backend:
- Guard output callback and cleanup timer with try-catch
- Add zombie stream reaper (5min interval) with proper cleanup
- Force-flush assistant buffer at 50 segments to prevent unbounded growth
- Log warning on message buffer overflow
- Emit SSE warning when user message persistence fails
Frontend:
- Show spinner on send button while processing
- Add native tooltip on truncated conversation titles
- Make sidebar resize handle visible with subtle background
Dead code:
- Delete orphaned ProjectDetail, ProjectsList, ProjectsPage
- Remove /projects/:id route from App.tsx
* Dashboard landing page, chat empty state, API error quality, SQLite indexes
- Replace DashboardPage redirect with landing page showing recent
conversations and workflow runs in a two-column grid
- Fix /chat empty state to render ChatInterface in new-chat mode
instead of a static placeholder
- Add proper HTTP status codes to API: 404 for missing conversations,
400 for invalid codebase IDs, empty array for failed workflow discovery
- Add 3 missing indexes to SQLite schema from PG migrations 009/010
(workflow run staleness, session parent, session conversation lookup)
* Scope workflow runs to selected project
Pass the selected project's codebaseId when creating a conversation
for workflow execution. Show which project the workflow will target,
and disable the run controls when no project is selected.
* Inline project selector in workflow invocation panel
Replace static "Running on {project}" text with a <select> dropdown
so users can pick a project without leaving the workflow view.
* Fix chat header showing "No project" and new chat missing project
Header now resolves project name from codebase_id when cwd is absent.
Dashboard "New Chat" creates conversation with selected project instead
of navigating to an orphaned /chat route. Buttons disabled when no
project is selected.
* Sidebar nav, workflow builder route, workflow invoker in project detail
Add Workflows and Workflow Builder nav links to sidebar, extract
navLinkClass helper. Wire WorkflowInvoker into ProjectDetail. Add
WorkflowBuilderPage route. Remove dead redirect routes and unused
Header from pages that don't need it.
* Fix review issues: guard rm-rf, error handling, Error Boundary, dedup utility
- Guard DELETE /api/codebases/:id to only rm-rf paths under ~/.archon/workspaces/
(externally registered repos only get DB record deleted)
- Remove accidental e2e-testing-findings-session2.md
- Replace 3x .catch(() => undefined) with proper error logging
- Add React Error Boundary at app root to prevent white-screen crashes
- Wrap JSON.parse(metadata) in try-catch in ChatInterface and WorkflowLogs
- Upgrade SSE write failure logging from debug to warn, add missing logging
- Fix bare catch in SSE heartbeat to only swallow disconnect errors
- Remove unused _createErrorHandler parameter from registerApiRoutes
- Extract shared findMarkdownFilesRecursive to packages/core/src/utils/commands.ts
- Convert boolean to integer explicitly for SQLite hidden column
- Surface workflow discovery errors as warning in API response
* Address PR review: error handling, SSE resilience, test coverage
- Fix path traversal in codebase deletion (use normalizedCwd)
- Buffer SSE write failures for reconnect delivery (3 catch blocks)
- Extract flushBufferedMessages helper with partial-failure recovery
- Add defensive outer catch to background workflow dispatch
- Surface API/load errors to users (history, metadata, React Query)
- Add SSE parse validation and handler error surfacing
- Show stale indicator when workflow polling loses connection
- Add delete error handling to ConversationItem dialog
- Import shared types from @archon/core instead of duplicating
- Lower activity update failure threshold from 5 to 3
- Add test coverage for messages.ts and workflow-events.ts (100%)
* docs: Update documentation for Web UI, workflow events, and message persistence
- Update database schema description (workflow_events detail, messages metadata)
- Add Web UI platform adapter to architecture docs
- Document REST API endpoints for Web UI
- Add SSE streaming pattern documentation
- Update conversation schema (title, deleted_at columns)
- Expand Web UI feature list (workflow invocation, message persistence)
* Address PR review: error handling, type safety, SSE resilience
- Add null check on createWorkflowRun INSERT return
- Narrow catch in registerRepoAtPath to not swallow command loading errors
- Add SSE onerror logging and user notification on permanent close
- Add WarningEvent to SSE union and surface warnings in ChatInterface
- Fix WorkflowEventRow.data SQLite type mismatch (parse JSON string)
- Separate conversation lookup from auto-titling error handling
- Fix log levels for data loss scenarios (warn/debug → error)
- Emit SSE warning to user on flushAssistantMessage failure
- Set workflow run parent link regardless of success/failure
- Cap total buffered conversations at 200
- Define ArtifactType once in core, use everywhere
- Use imported status types in SSE event interfaces
- Add exhaustiveness checks in switch statements
- Preserve original git error in registerRepository
- Remove unused recoverable field from ErrorEvent
---------
Co-authored-by: Rasmus Widing <rasmus.widing@gmail.com>
This commit is contained in:
parent
76e71b98ae
commit
352171474b
93 changed files with 10735 additions and 388 deletions
11
.claude/commands/archon/merge-decision.md
Normal file
11
.claude/commands/archon/merge-decision.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
Study @ARGUMENTS and rebase if needed. Check if there are any mentioned issues in the review reports that should be addressed or have follow-up issues before we merge this PR.
|
||||
|
||||
The CI is failing due to a billing issue, please run validation locally.
|
||||
|
||||
issue #367 is a housekeeping issue list, suggest if we should add any of the low items from the PR to the list, address them now or just skip fully.
|
||||
|
||||
Documentation issues should be addressed before merging if related to the PR otherwise added to housekeeping, we dont skip documentation issues.
|
||||
|
||||
IF the user tells you to merge merge with:
|
||||
|
||||
gh pr merge <pr-number> --rebase OR squash
|
||||
251
.claude/skills/agent-browser/SKILL.md
Normal file
251
.claude/skills/agent-browser/SKILL.md
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
---
|
||||
name: agent-browser
|
||||
description: Automates browser interactions for web testing, form filling, screenshots, and data extraction. Use when the user needs to navigate websites, interact with web pages, fill forms, take screenshots, test web applications, or extract information from web pages.
|
||||
---
|
||||
|
||||
# Browser Automation with agent-browser
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
agent-browser open <url> # Navigate to page
|
||||
agent-browser snapshot -i # Get interactive elements with refs
|
||||
agent-browser click @e1 # Click element by ref
|
||||
agent-browser fill @e2 "text" # Fill input by ref
|
||||
agent-browser close # Close browser
|
||||
```
|
||||
|
||||
## Core workflow
|
||||
|
||||
1. Navigate: `agent-browser open <url>`
|
||||
2. Snapshot: `agent-browser snapshot -i` (returns elements with refs like `@e1`, `@e2`)
|
||||
3. Interact using refs from the snapshot
|
||||
4. Re-snapshot after navigation or significant DOM changes
|
||||
|
||||
## Commands
|
||||
|
||||
### Navigation
|
||||
```bash
|
||||
agent-browser open <url> # Navigate to URL
|
||||
agent-browser back # Go back
|
||||
agent-browser forward # Go forward
|
||||
agent-browser reload # Reload page
|
||||
agent-browser close # Close browser
|
||||
```
|
||||
|
||||
### Snapshot (page analysis)
|
||||
```bash
|
||||
agent-browser snapshot # Full accessibility tree
|
||||
agent-browser snapshot -i # Interactive elements only (recommended)
|
||||
agent-browser snapshot -c # Compact output
|
||||
agent-browser snapshot -d 3 # Limit depth to 3
|
||||
agent-browser snapshot -s "#main" # Scope to CSS selector
|
||||
```
|
||||
|
||||
### Interactions (use @refs from snapshot)
|
||||
```bash
|
||||
agent-browser click @e1 # Click
|
||||
agent-browser dblclick @e1 # Double-click
|
||||
agent-browser focus @e1 # Focus element
|
||||
agent-browser fill @e2 "text" # Clear and type
|
||||
agent-browser type @e2 "text" # Type without clearing
|
||||
agent-browser press Enter # Press key
|
||||
agent-browser press Control+a # Key combination
|
||||
agent-browser keydown Shift # Hold key down
|
||||
agent-browser keyup Shift # Release key
|
||||
agent-browser hover @e1 # Hover
|
||||
agent-browser check @e1 # Check checkbox
|
||||
agent-browser uncheck @e1 # Uncheck checkbox
|
||||
agent-browser select @e1 "value" # Select dropdown
|
||||
agent-browser scroll down 500 # Scroll page
|
||||
agent-browser scrollintoview @e1 # Scroll element into view
|
||||
agent-browser drag @e1 @e2 # Drag and drop
|
||||
agent-browser upload @e1 file.pdf # Upload files
|
||||
```
|
||||
|
||||
### Get information
|
||||
```bash
|
||||
agent-browser get text @e1 # Get element text
|
||||
agent-browser get html @e1 # Get innerHTML
|
||||
agent-browser get value @e1 # Get input value
|
||||
agent-browser get attr @e1 href # Get attribute
|
||||
agent-browser get title # Get page title
|
||||
agent-browser get url # Get current URL
|
||||
agent-browser get count ".item" # Count matching elements
|
||||
agent-browser get box @e1 # Get bounding box
|
||||
```
|
||||
|
||||
### Check state
|
||||
```bash
|
||||
agent-browser is visible @e1 # Check if visible
|
||||
agent-browser is enabled @e1 # Check if enabled
|
||||
agent-browser is checked @e1 # Check if checked
|
||||
```
|
||||
|
||||
### Screenshots & PDF
|
||||
```bash
|
||||
agent-browser screenshot # Screenshot to stdout
|
||||
agent-browser screenshot path.png # Save to file
|
||||
agent-browser screenshot --full # Full page
|
||||
agent-browser pdf output.pdf # Save as PDF
|
||||
```
|
||||
|
||||
### Video recording
|
||||
```bash
|
||||
agent-browser record start ./demo.webm # Start recording (uses current URL + state)
|
||||
agent-browser click @e1 # Perform actions
|
||||
agent-browser record stop # Stop and save video
|
||||
agent-browser record restart ./take2.webm # Stop current + start new recording
|
||||
```
|
||||
Recording creates a fresh context but preserves cookies/storage from your session. If no URL is provided, it automatically returns to your current page. For smooth demos, explore first, then start recording.
|
||||
|
||||
### Wait
|
||||
```bash
|
||||
agent-browser wait @e1 # Wait for element
|
||||
agent-browser wait 2000 # Wait milliseconds
|
||||
agent-browser wait --text "Success" # Wait for text
|
||||
agent-browser wait --url "**/dashboard" # Wait for URL pattern
|
||||
agent-browser wait --load networkidle # Wait for network idle
|
||||
agent-browser wait --fn "window.ready" # Wait for JS condition
|
||||
```
|
||||
|
||||
### Mouse control
|
||||
```bash
|
||||
agent-browser mouse move 100 200 # Move mouse
|
||||
agent-browser mouse down left # Press button
|
||||
agent-browser mouse up left # Release button
|
||||
agent-browser mouse wheel 100 # Scroll wheel
|
||||
```
|
||||
|
||||
### Semantic locators (alternative to refs)
|
||||
```bash
|
||||
agent-browser find role button click --name "Submit"
|
||||
agent-browser find text "Sign In" click
|
||||
agent-browser find label "Email" fill "user@test.com"
|
||||
agent-browser find first ".item" click
|
||||
agent-browser find nth 2 "a" text
|
||||
```
|
||||
|
||||
### Browser settings
|
||||
```bash
|
||||
agent-browser set viewport 1920 1080 # Set viewport size
|
||||
agent-browser set device "iPhone 14" # Emulate device
|
||||
agent-browser set geo 37.7749 -122.4194 # Set geolocation
|
||||
agent-browser set offline on # Toggle offline mode
|
||||
agent-browser set headers '{"X-Key":"v"}' # Extra HTTP headers
|
||||
agent-browser set credentials user pass # HTTP basic auth
|
||||
agent-browser set media dark # Emulate color scheme
|
||||
```
|
||||
|
||||
### Cookies & Storage
|
||||
```bash
|
||||
agent-browser cookies # Get all cookies
|
||||
agent-browser cookies set name value # Set cookie
|
||||
agent-browser cookies clear # Clear cookies
|
||||
agent-browser storage local # Get all localStorage
|
||||
agent-browser storage local key # Get specific key
|
||||
agent-browser storage local set k v # Set value
|
||||
agent-browser storage local clear # Clear all
|
||||
```
|
||||
|
||||
### Network
|
||||
```bash
|
||||
agent-browser network route <url> # Intercept requests
|
||||
agent-browser network route <url> --abort # Block requests
|
||||
agent-browser network route <url> --body '{}' # Mock response
|
||||
agent-browser network unroute [url] # Remove routes
|
||||
agent-browser network requests # View tracked requests
|
||||
agent-browser network requests --filter api # Filter requests
|
||||
```
|
||||
|
||||
### Tabs & Windows
|
||||
```bash
|
||||
agent-browser tab # List tabs
|
||||
agent-browser tab new [url] # New tab
|
||||
agent-browser tab 2 # Switch to tab
|
||||
agent-browser tab close # Close tab
|
||||
agent-browser window new # New window
|
||||
```
|
||||
|
||||
### Frames
|
||||
```bash
|
||||
agent-browser frame "#iframe" # Switch to iframe
|
||||
agent-browser frame main # Back to main frame
|
||||
```
|
||||
|
||||
### Dialogs
|
||||
```bash
|
||||
agent-browser dialog accept [text] # Accept dialog
|
||||
agent-browser dialog dismiss # Dismiss dialog
|
||||
```
|
||||
|
||||
### JavaScript
|
||||
```bash
|
||||
agent-browser eval "document.title" # Run JavaScript
|
||||
```
|
||||
|
||||
## Example: Form submission
|
||||
|
||||
```bash
|
||||
agent-browser open https://example.com/form
|
||||
agent-browser snapshot -i
|
||||
# Output shows: textbox "Email" [ref=e1], textbox "Password" [ref=e2], button "Submit" [ref=e3]
|
||||
|
||||
agent-browser fill @e1 "user@example.com"
|
||||
agent-browser fill @e2 "password123"
|
||||
agent-browser click @e3
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser snapshot -i # Check result
|
||||
```
|
||||
|
||||
## Example: Authentication with saved state
|
||||
|
||||
```bash
|
||||
# Login once
|
||||
agent-browser open https://app.example.com/login
|
||||
agent-browser snapshot -i
|
||||
agent-browser fill @e1 "username"
|
||||
agent-browser fill @e2 "password"
|
||||
agent-browser click @e3
|
||||
agent-browser wait --url "**/dashboard"
|
||||
agent-browser state save auth.json
|
||||
|
||||
# Later sessions: load saved state
|
||||
agent-browser state load auth.json
|
||||
agent-browser open https://app.example.com/dashboard
|
||||
```
|
||||
|
||||
## Sessions (parallel browsers)
|
||||
|
||||
```bash
|
||||
agent-browser --session test1 open site-a.com
|
||||
agent-browser --session test2 open site-b.com
|
||||
agent-browser session list
|
||||
```
|
||||
|
||||
## JSON output (for parsing)
|
||||
|
||||
Add `--json` for machine-readable output:
|
||||
```bash
|
||||
agent-browser snapshot -i --json
|
||||
agent-browser get text @e1 --json
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
```bash
|
||||
agent-browser open example.com --headed # Show browser window
|
||||
agent-browser console # View console messages
|
||||
agent-browser errors # View page errors
|
||||
agent-browser record start ./debug.webm # Record from current page
|
||||
agent-browser record stop # Save recording
|
||||
agent-browser open example.com --headed # Show browser window
|
||||
agent-browser --cdp 9222 snapshot # Connect via CDP
|
||||
agent-browser console # View console messages
|
||||
agent-browser console --clear # Clear console
|
||||
agent-browser errors # View page errors
|
||||
agent-browser errors --clear # Clear errors
|
||||
agent-browser highlight @e1 # Highlight element
|
||||
agent-browser trace start # Start recording trace
|
||||
agent-browser trace stop trace.zip # Stop and save trace
|
||||
```
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -32,14 +32,17 @@ Caddyfile
|
|||
# Session files
|
||||
sessions/
|
||||
api_sessions/
|
||||
e2e-screenshots/
|
||||
|
||||
# Archon logs (generated at runtime)
|
||||
.archon/logs/
|
||||
|
||||
# RCA reports (generated, local only)
|
||||
# Agent artifacts (generated, local only)
|
||||
.agents/rca-reports/
|
||||
.claude/PRPs/features
|
||||
.agents/plans/
|
||||
.agents/pr-reviews
|
||||
.claude/PRPs/
|
||||
e2e-testing-findings-session2.md
|
||||
|
||||
# Local workspace
|
||||
workspace/
|
||||
|
|
|
|||
77
CLAUDE.md
77
CLAUDE.md
|
|
@ -8,7 +8,7 @@
|
|||
- No multi-tenant complexity
|
||||
- Commands versioned with Git (not stored in database)
|
||||
- All credentials in environment variables only
|
||||
- 5-table database schema (see Database Schema section)
|
||||
- 8-table database schema (see Database Schema section)
|
||||
|
||||
**User-Controlled Workflows**
|
||||
- Manual phase transitions via slash commands
|
||||
|
|
@ -41,19 +41,25 @@
|
|||
|
||||
### Development (Recommended)
|
||||
|
||||
Run postgres in Docker, app locally for hot reload:
|
||||
Run app locally for hot reload (SQLite auto-detected if no `DATABASE_URL`):
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start postgres only
|
||||
docker-compose --profile with-db up -d postgres
|
||||
|
||||
# Terminal 2: Run app with hot reload
|
||||
# Start server + Web UI together (hot reload for both)
|
||||
bun run dev
|
||||
|
||||
# Or start individually
|
||||
bun run dev:server # Backend only (port 3090)
|
||||
bun run dev:web # Frontend only (port 5173)
|
||||
```
|
||||
|
||||
Requires `DATABASE_URL=postgresql://postgres:postgres@localhost:5432/remote_coding_agent` in `.env` (or omit for SQLite auto-detection).
|
||||
Optional: Use PostgreSQL instead of SQLite by setting `DATABASE_URL` in `.env`:
|
||||
|
||||
Code changes auto-reload instantly. Telegram/Slack work from any device (polling-based, no port forwarding needed).
|
||||
```bash
|
||||
docker-compose --profile with-db up -d postgres
|
||||
# Set DATABASE_URL=postgresql://postgres:postgres@localhost:5432/remote_coding_agent in .env
|
||||
```
|
||||
|
||||
Code changes auto-reload instantly. Web UI available at `http://localhost:5173`. Telegram/Slack work from any device (polling-based, no port forwarding needed).
|
||||
|
||||
### Build Commands
|
||||
|
||||
|
|
@ -284,16 +290,26 @@ packages/
|
|||
│ │ └── archon-paths.ts
|
||||
│ ├── workflows/ # YAML workflow engine
|
||||
│ └── index.ts # Package exports
|
||||
└── server/ # @archon/server - HTTP server + adapters
|
||||
├── server/ # @archon/server - HTTP server + adapters
|
||||
│ └── src/
|
||||
│ ├── adapters/ # Platform adapters (Slack, Telegram, GitHub, Discord, Web, Test)
|
||||
│ │ ├── slack.ts
|
||||
│ │ ├── telegram.ts
|
||||
│ │ ├── github.ts
|
||||
│ │ ├── discord.ts
|
||||
│ │ ├── web.ts # Web UI adapter (SSE streaming)
|
||||
│ │ └── test.ts
|
||||
│ ├── routes/ # API routes
|
||||
│ │ └── api.ts # REST + SSE endpoints for Web UI
|
||||
│ ├── scripts/ # Setup utilities
|
||||
│ └── index.ts # Hono server entry point
|
||||
└── web/ # @archon/web - React frontend (Web UI)
|
||||
└── src/
|
||||
├── adapters/ # Platform adapters (Slack, Telegram, GitHub, Discord, Test)
|
||||
│ ├── slack.ts
|
||||
│ ├── telegram.ts
|
||||
│ ├── github.ts
|
||||
│ ├── discord.ts
|
||||
│ └── test.ts
|
||||
├── scripts/ # Setup utilities
|
||||
└── index.ts # Hono server entry point
|
||||
├── components/ # React components (chat, layout, projects, ui)
|
||||
├── hooks/ # Custom hooks (useSSE, etc.)
|
||||
├── lib/ # API client, types, utilities
|
||||
├── pages/ # Route pages (ChatPage, ProjectsPage)
|
||||
└── App.tsx # Router + layout
|
||||
```
|
||||
|
||||
**Import Patterns:**
|
||||
|
|
@ -323,12 +339,15 @@ import * as core from '@archon/core'; // Don't do this
|
|||
|
||||
### Database Schema
|
||||
|
||||
**5 Tables (all prefixed with `remote_agent_`):**
|
||||
**8 Tables (all prefixed with `remote_agent_`):**
|
||||
1. **`codebases`** - Repository metadata and commands (JSONB)
|
||||
2. **`conversations`** - Track platform conversations (Slack thread, Telegram chat, GitHub issue)
|
||||
2. **`conversations`** - Track platform conversations with titles and soft-delete support
|
||||
3. **`sessions`** - Track AI SDK sessions with resume capability
|
||||
4. **`command_templates`** - Global command templates (manually added via `/template-add`)
|
||||
5. **`isolation_environments`** - Git worktree isolation tracking
|
||||
6. **`workflow_runs`** - Workflow execution tracking and state
|
||||
7. **`workflow_events`** - Step-level workflow event log (step transitions, artifacts, errors)
|
||||
8. **`messages`** - Conversation message history with tool call metadata (JSONB)
|
||||
|
||||
**Key Patterns:**
|
||||
- Conversation ID format: Platform-specific (`thread_ts`, `chat_id`, `user/repo#123`)
|
||||
|
|
@ -347,11 +366,13 @@ import * as core from '@archon/core'; // Don't do this
|
|||
**Package Split:**
|
||||
- **@archon/cli**: Command-line interface for running workflows
|
||||
- **@archon/core**: Business logic, database, orchestration, workflows
|
||||
- **@archon/server**: Platform adapters, Hono server, HTTP endpoints
|
||||
- **@archon/server**: Platform adapters, Hono server, HTTP endpoints, Web UI static serving
|
||||
- **@archon/web**: React frontend (Vite + Tailwind v4 + shadcn/ui), SSE streaming to server
|
||||
|
||||
**1. Platform Adapters** (`packages/server/src/adapters/`)
|
||||
- Implement `IPlatformAdapter` interface
|
||||
- Handle platform-specific message formats
|
||||
- **Web**: Server-Sent Events (SSE) streaming, conversation ID = user-provided string
|
||||
- **Slack**: SDK with polling (not webhooks), conversation ID = `thread_ts`
|
||||
- **Telegram**: Bot API with polling, conversation ID = `chat_id`
|
||||
- **GitHub**: Webhooks + GitHub CLI, conversation ID = `owner/repo#number`
|
||||
|
|
@ -677,6 +698,22 @@ try {
|
|||
|
||||
### API Endpoints
|
||||
|
||||
**Web UI REST API** (`packages/server/src/routes/api.ts`):
|
||||
- `GET /api/conversations` - List all conversations
|
||||
- `POST /api/conversations` - Create new conversation
|
||||
- `GET /api/conversations/:id` - Get conversation details
|
||||
- `DELETE /api/conversations/:id` - Soft-delete conversation
|
||||
- `POST /api/conversations/:id/messages` - Send message to conversation
|
||||
- `GET /api/conversations/:id/messages` - Get message history
|
||||
- `GET /api/conversations/:id/stream` - SSE stream for real-time updates
|
||||
- `POST /api/conversations/:id/workflow` - Invoke workflow
|
||||
- `GET /api/codebases` - List registered codebases
|
||||
- `POST /api/codebases` - Clone or register repository
|
||||
- `DELETE /api/codebases/:id` - Remove codebase
|
||||
- `GET /api/workflows` - List available workflows
|
||||
- `GET /api/workflow-runs/:id` - Get workflow run details
|
||||
- `GET /api/workflow-runs/:id/events` - Get workflow event log
|
||||
|
||||
**Webhooks:**
|
||||
- `POST /webhooks/github` - GitHub webhook events
|
||||
- Signature verification required (HMAC SHA-256)
|
||||
|
|
|
|||
97
README.md
97
README.md
|
|
@ -1,12 +1,13 @@
|
|||
# Dynamous Remote Coding Agent
|
||||
|
||||
Control AI coding assistants (Claude Code, Codex) remotely from Telegram, GitHub, and more. Built for developers who want to code from anywhere with persistent sessions and flexible workflows/systems.
|
||||
Control AI coding assistants (Claude Code, Codex) remotely from a Web UI, Telegram, GitHub, and more. Built for developers who want to code from anywhere with persistent sessions and flexible workflows/systems.
|
||||
|
||||
**Quick Start:** [Getting Started](#getting-started) • [Server Setup](#server-quick-start) • [AI Assistant Setup](#2-ai-assistant-setup-choose-at-least-one) • [Platform Setup](#3-platform-adapter-setup-choose-at-least-one) • [Usage Guide](#usage)
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-Platform Support**: Interact via Telegram, Slack, Discord, GitHub issues/PRs, and more
|
||||
- **Web UI**: Built-in React dashboard with real-time streaming, tool call visualization, and conversation management — no external platform required
|
||||
- **Multi-Platform Support**: Interact via Web UI, Telegram, Slack, Discord, GitHub issues/PRs, and more
|
||||
- **Multiple AI Assistants**: Choose between Claude Code or Codex (or both)
|
||||
- **Persistent Sessions**: Sessions survive container restarts with full context preservation
|
||||
- **Codebase Management**: Clone and work with any GitHub repository
|
||||
|
|
@ -105,7 +106,7 @@ archon workflow run assist "What does this codebase do?"
|
|||
**Accounts Required:**
|
||||
- GitHub account (for repository cloning via `/clone` command)
|
||||
- At least one of: Claude Pro/Max subscription OR Codex account
|
||||
- At least one of: Telegram, Slack, Discord, or GitHub account (for server interaction)
|
||||
- Optional: Telegram, Slack, Discord, or GitHub account (the built-in Web UI works without any external platform)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -139,19 +140,21 @@ bun install
|
|||
|
||||
# 2. Configure
|
||||
cp .env.example .env
|
||||
nano .env # Add your tokens
|
||||
nano .env # Add your AI assistant tokens (Claude or Codex)
|
||||
|
||||
# 3. Start database
|
||||
docker compose --profile with-db up -d postgres
|
||||
|
||||
# 4. Run migrations
|
||||
psql $DATABASE_URL < migrations/000_combined.sql
|
||||
|
||||
# 5. Start with hot reload
|
||||
# 3. Start server + Web UI (SQLite auto-detected, no database setup needed)
|
||||
bun run dev
|
||||
|
||||
# 6. Validate setup
|
||||
bun run validate
|
||||
# 4. Open Web UI
|
||||
# http://localhost:5173
|
||||
```
|
||||
|
||||
Optional: Use PostgreSQL instead of SQLite:
|
||||
|
||||
```bash
|
||||
docker compose --profile with-db up -d postgres
|
||||
# Set DATABASE_URL=postgresql://postgres:postgres@localhost:5432/remote_coding_agent in .env
|
||||
psql $DATABASE_URL < migrations/000_combined.sql
|
||||
```
|
||||
|
||||
### Option 3: Self-Hosted Production
|
||||
|
|
@ -264,12 +267,15 @@ DATABASE_URL=postgresql://user:password@host:5432/dbname
|
|||
psql $DATABASE_URL < migrations/000_combined.sql
|
||||
```
|
||||
|
||||
This creates 5 tables:
|
||||
This creates 8 tables:
|
||||
- `remote_agent_codebases` - Repository metadata
|
||||
- `remote_agent_conversations` - Platform conversation tracking
|
||||
- `remote_agent_sessions` - AI session management
|
||||
- `remote_agent_command_templates` - Global command templates
|
||||
- `remote_agent_isolation_environments` - Worktree isolation tracking
|
||||
- `remote_agent_workflow_runs` - Workflow execution tracking
|
||||
- `remote_agent_workflow_events` - Step-level workflow event log
|
||||
- `remote_agent_messages` - Conversation message history
|
||||
|
||||
**For updates to existing installations**, run only the migrations you haven't applied yet:
|
||||
|
||||
|
|
@ -432,9 +438,39 @@ DEFAULT_AI_ASSISTANT=codex
|
|||
|
||||
---
|
||||
|
||||
### 3. Platform Adapter Setup (Choose At Least One)
|
||||
### 3. Platform Adapter Setup (Optional)
|
||||
|
||||
You must configure **at least one** platform to interact with your AI assistant.
|
||||
The built-in **Web UI** works out of the box with no additional configuration. Optionally, configure one or more external platforms for remote access:
|
||||
|
||||
<details>
|
||||
<summary><b>🌐 Web UI (Built-in — No Setup Required)</b></summary>
|
||||
|
||||
The Web UI is available automatically when you start the server. No tokens or configuration needed.
|
||||
|
||||
**Development:**
|
||||
```bash
|
||||
bun run dev
|
||||
# Web UI: http://localhost:5173
|
||||
# API server: http://localhost:3090
|
||||
```
|
||||
|
||||
**Production:**
|
||||
```bash
|
||||
bun run build # Build the frontend
|
||||
bun run start # Server serves both API and Web UI on port 3090
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Real-time streaming of AI responses via Server-Sent Events (SSE)
|
||||
- Tool call visualization with collapsible cards showing inputs/outputs
|
||||
- Conversation management (create, switch, rename, delete, persist across sessions)
|
||||
- Project/codebase browsing and management (clone, register, remove)
|
||||
- Workflow invocation from UI with real-time progress tracking
|
||||
- Lock indicator showing when the agent is working
|
||||
- Connected/disconnected status indicator
|
||||
- Message history persistence across page refreshes
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>💬 Telegram</b></summary>
|
||||
|
|
@ -759,11 +795,13 @@ docker compose logs -f app-with-db
|
|||
|
||||
**Option C: Local Development (No Docker)**
|
||||
|
||||
Run directly with Bun (requires local PostgreSQL or remote `DATABASE_URL` in `.env`):
|
||||
Run directly with Bun. Uses SQLite by default (no database setup needed), or set `DATABASE_URL` for PostgreSQL:
|
||||
|
||||
```bash
|
||||
bun install # First time only
|
||||
bun run dev
|
||||
bun run dev # Starts server + Web UI with hot reload
|
||||
# Web UI: http://localhost:5173
|
||||
# API: http://localhost:3090
|
||||
```
|
||||
|
||||
**Stop the application:**
|
||||
|
|
@ -1168,7 +1206,8 @@ prompt: |
|
|||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Platform Adapters (Telegram, Slack, Discord, GitHub) │
|
||||
│ Platform Adapters (Web UI, Telegram, Slack, Discord, │
|
||||
│ GitHub) │
|
||||
└──────────────────────────┬──────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
|
|
@ -1190,14 +1229,16 @@ prompt: |
|
|||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ PostgreSQL (6 Tables) │
|
||||
│ SQLite / PostgreSQL (8 Tables) │
|
||||
│ Codebases • Conversations • Sessions • Workflow Runs │
|
||||
│ Command Templates • Isolation Environments │
|
||||
│ Command Templates • Isolation Environments • Messages │
|
||||
│ Workflow Events │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Design Patterns
|
||||
|
||||
- **Web UI**: React dashboard with SSE streaming, served by the backend in production
|
||||
- **Adapter Pattern**: Platform-agnostic via `IPlatformAdapter` interface
|
||||
- **Strategy Pattern**: Swappable AI assistants via `IAssistantClient` interface
|
||||
- **Session Persistence**: AI context survives restarts via database storage
|
||||
|
|
@ -1209,7 +1250,7 @@ prompt: |
|
|||
### Database Schema
|
||||
|
||||
<details>
|
||||
<summary><b>6 tables with `remote_agent_` prefix</b></summary>
|
||||
<summary><b>8 tables with `remote_agent_` prefix</b></summary>
|
||||
|
||||
1. **`remote_agent_codebases`** - Repository metadata
|
||||
- Commands stored as JSONB: `{command_name: {path, description}}`
|
||||
|
|
@ -1237,7 +1278,17 @@ prompt: |
|
|||
6. **`remote_agent_workflow_runs`** - Workflow execution tracking
|
||||
- Tracks active workflows per conversation
|
||||
- Prevents concurrent workflow execution
|
||||
- Stores workflow state and step progress
|
||||
- Stores workflow state, step progress, and parent conversation linkage
|
||||
|
||||
7. **`remote_agent_workflow_events`** - Step-level workflow event log
|
||||
- Records step transitions, artifacts, and errors per workflow run
|
||||
- Lean UI-relevant events (verbose logs stored in JSONL files)
|
||||
- Enables workflow run detail views and debugging
|
||||
|
||||
8. **`remote_agent_messages`** - Conversation message history
|
||||
- Persists user and assistant messages with timestamps
|
||||
- Stores tool call metadata (name, input, duration) in JSONB
|
||||
- Enables message history in Web UI across page refreshes
|
||||
|
||||
</details>
|
||||
|
||||
|
|
|
|||
|
|
@ -8,15 +8,17 @@ Comprehensive guide to understanding and extending the Remote Coding Agent platf
|
|||
|
||||
## System Overview
|
||||
|
||||
The Remote Coding Agent is a **platform-agnostic AI coding assistant orchestrator** that connects messaging platforms (Telegram, GitHub, Slack) to AI coding assistants (Claude Code, Codex) via a unified interface.
|
||||
The Remote Coding Agent is a **platform-agnostic AI coding assistant orchestrator** that connects messaging platforms (Web UI, Telegram, GitHub, Slack, Discord) to AI coding assistants (Claude Code, Codex) via a unified interface. The built-in Web UI provides a complete standalone experience with real-time streaming, tool call visualization, and workflow management.
|
||||
|
||||
### Core Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Platform Adapters (Telegram, GitHub, CLI) │
|
||||
│ Platform Adapters (Web UI, Telegram, │
|
||||
│ GitHub, Slack, Discord, CLI) │
|
||||
│ • IPlatformAdapter interface │
|
||||
│ • Handle platform-specific messaging │
|
||||
│ • Web: SSE streaming + REST API │
|
||||
│ • Others: Platform-specific messaging │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
│
|
||||
▼
|
||||
|
|
@ -26,6 +28,7 @@ The Remote Coding Agent is a **platform-agnostic AI coding assistant orchestrato
|
|||
│ • Route AI queries → Assistant Clients │
|
||||
│ • Manage session lifecycle │
|
||||
│ • Stream responses back to platforms │
|
||||
│ • Emit workflow events to Web UI │
|
||||
└──────────────┬──────────────────────────────┘
|
||||
│
|
||||
┌───────┼────────┐
|
||||
|
|
@ -42,8 +45,10 @@ The Remote Coding Agent is a **platform-agnostic AI coding assistant orchestrato
|
|||
└───────────────┼───────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ PostgreSQL/SQLite (3 Tables) │
|
||||
│ PostgreSQL/SQLite (8 Tables) │
|
||||
│ • Codebases • Conversations • Sessions │
|
||||
│ • Command Templates • Isolation Envs │
|
||||
│ • Workflow Runs • Workflow Events • Messages│
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
|
|
@ -163,6 +168,7 @@ YOUR_PLATFORM_STREAMING_MODE=stream # stream | batch
|
|||
|
||||
Each platform must provide a unique, stable conversation ID:
|
||||
|
||||
- **Web UI**: User-provided string or auto-generated UUID
|
||||
- **Telegram**: `chat_id` (e.g., `"123456789"`)
|
||||
- **GitHub**: `owner/repo#issue_number` (e.g., `"user/repo#42"`)
|
||||
- **Slack**: `thread_ts` or `channel_id+thread_ts`
|
||||
|
|
@ -190,6 +196,40 @@ async sendMessage(conversationId: string, message: string): Promise<void> {
|
|||
|
||||
**Reference:** `packages/server/src/adapters/telegram.ts`
|
||||
|
||||
#### Server-Sent Events (SSE)
|
||||
|
||||
**SSE** (Web UI pattern):
|
||||
|
||||
```typescript
|
||||
// Web adapter maintains SSE connections per conversation
|
||||
registerStream(conversationId: string, stream: SSEWriter): void {
|
||||
this.streams.set(conversationId, stream);
|
||||
}
|
||||
|
||||
async sendMessage(conversationId: string, message: string): Promise<void> {
|
||||
const stream = this.streams.get(conversationId);
|
||||
if (stream && !stream.closed) {
|
||||
await stream.writeSSE({ data: JSON.stringify({ type: 'text', content: message }) });
|
||||
} else {
|
||||
// Buffer messages if client disconnected (reconnection recovery)
|
||||
this.messageBuffer.set(conversationId, [...(this.messageBuffer.get(conversationId) ?? []), message]);
|
||||
}
|
||||
}
|
||||
|
||||
// Structured events for tool calls, workflow progress, errors
|
||||
async sendStructuredEvent(conversationId: string, event: MessageChunk): Promise<void> {
|
||||
await this.emitSSE(conversationId, JSON.stringify(event));
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Real-time streaming without polling overhead
|
||||
- Automatic reconnection handling in browser
|
||||
- Message buffering during disconnections
|
||||
- Structured events (tool calls, workflow progress, lock state)
|
||||
|
||||
**Reference:** `packages/server/src/adapters/web.ts`
|
||||
|
||||
#### Polling vs Webhooks
|
||||
|
||||
**Polling** (Telegram pattern):
|
||||
|
|
@ -965,7 +1005,7 @@ export function formatToolCall(toolName: string, toolInput?: Record<string, unkn
|
|||
|
||||
## Database Schema
|
||||
|
||||
The platform uses a minimal 3-table schema with `remote_agent_` prefix.
|
||||
The platform uses an 8-table schema with `remote_agent_` prefix.
|
||||
|
||||
### Schema Overview
|
||||
|
||||
|
|
@ -980,11 +1020,13 @@ remote_agent_codebases
|
|||
|
||||
remote_agent_conversations
|
||||
├── id (UUID)
|
||||
├── platform_type (VARCHAR) -- 'telegram' | 'github' | 'slack'
|
||||
├── platform_type (VARCHAR) -- 'web' | 'telegram' | 'github' | 'slack'
|
||||
├── platform_conversation_id (VARCHAR) -- Platform-specific ID
|
||||
├── codebase_id (UUID → remote_agent_codebases.id)
|
||||
├── cwd (VARCHAR) -- Current working directory
|
||||
├── ai_assistant_type (VARCHAR) -- LOCKED at creation
|
||||
├── title (VARCHAR) -- User-friendly conversation title (Web UI)
|
||||
├── deleted_at (TIMESTAMP) -- Soft-delete support
|
||||
└── UNIQUE(platform_type, platform_conversation_id)
|
||||
|
||||
remote_agent_sessions
|
||||
|
|
@ -997,6 +1039,49 @@ remote_agent_sessions
|
|||
├── parent_session_id (UUID → remote_agent_sessions.id) -- Previous session for audit trail
|
||||
├── transition_reason (TEXT) -- Why this session was created (TransitionTrigger)
|
||||
└── metadata (JSONB) -- {lastCommand: "plan-feature", ...}
|
||||
|
||||
remote_agent_command_templates
|
||||
├── id (UUID)
|
||||
├── name (VARCHAR, UNIQUE)
|
||||
├── description (TEXT)
|
||||
└── content (TEXT)
|
||||
|
||||
remote_agent_isolation_environments
|
||||
├── id (UUID)
|
||||
├── codebase_id (UUID → remote_agent_codebases.id)
|
||||
├── workflow_type (VARCHAR)
|
||||
├── workflow_id (VARCHAR)
|
||||
├── working_path (VARCHAR)
|
||||
├── branch_name (VARCHAR)
|
||||
├── status (VARCHAR) -- 'active' | 'destroyed'
|
||||
└── metadata (JSONB)
|
||||
|
||||
remote_agent_workflow_runs
|
||||
├── id (UUID)
|
||||
├── conversation_id (UUID → remote_agent_conversations.id)
|
||||
├── codebase_id (UUID → remote_agent_codebases.id)
|
||||
├── workflow_name (VARCHAR)
|
||||
├── status (VARCHAR) -- 'pending' | 'running' | 'completed' | 'failed'
|
||||
├── current_step_index (INTEGER)
|
||||
├── parent_conversation_id (UUID) -- Parent chat that dispatched this run
|
||||
└── metadata (JSONB)
|
||||
|
||||
remote_agent_workflow_events
|
||||
├── id (UUID)
|
||||
├── workflow_run_id (UUID → remote_agent_workflow_runs.id)
|
||||
├── event_type (VARCHAR) -- 'step-start' | 'step-complete' | 'step-fail' | 'artifact' | 'error'
|
||||
├── step_index (INTEGER)
|
||||
├── step_name (VARCHAR)
|
||||
├── data (JSONB) -- Event-specific data (artifacts, error messages, etc.)
|
||||
└── created_at (TIMESTAMP)
|
||||
|
||||
remote_agent_messages
|
||||
├── id (UUID)
|
||||
├── conversation_id (UUID → remote_agent_conversations.id)
|
||||
├── role (VARCHAR) -- 'user' | 'assistant'
|
||||
├── content (TEXT)
|
||||
├── metadata (JSONB) -- {toolCalls: [{name, input, duration}], workflowDispatch, ...}
|
||||
└── created_at (TIMESTAMP)
|
||||
```
|
||||
|
||||
### Database Operations
|
||||
|
|
|
|||
90
docs/e2e-testing-wsl.md
Normal file
90
docs/e2e-testing-wsl.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# E2E Testing with agent-browser on Windows (via WSL)
|
||||
|
||||
`agent-browser` (Vercel) has a [known Windows bug](https://github.com/vercel-labs/agent-browser/issues/56) where the daemon fails to start due to Unix domain socket incompatibility. The workaround is to run agent-browser inside WSL while the dev servers run on Windows.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- WSL2 with Ubuntu installed (`wsl --list --verbose`)
|
||||
- agent-browser installed in WSL: `npm install -g agent-browser`
|
||||
- Playwright chromium installed: `agent-browser install --with-deps` (needs sudo)
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Find the Windows host IP accessible from WSL
|
||||
|
||||
```bash
|
||||
ipconfig | findstr "IPv4" | findstr "WSL"
|
||||
# Example output: IPv4 Address. . . . . . . . . . . : 172.18.64.1
|
||||
```
|
||||
|
||||
Or from inside WSL:
|
||||
```bash
|
||||
wsl -d Ubuntu -- bash -c "cat /etc/resolv.conf | grep nameserver"
|
||||
```
|
||||
|
||||
The Windows host IP for this system is `172.18.64.1`.
|
||||
|
||||
### 2. Start dev servers on Windows (bound to all interfaces)
|
||||
|
||||
```bash
|
||||
# Backend (Hono on port 3090) - already binds to 0.0.0.0 by default
|
||||
bun run dev:server &
|
||||
|
||||
# Frontend (Vite on port 5173) - needs --host flag
|
||||
cd packages/web && bun x vite --host 0.0.0.0 &
|
||||
```
|
||||
|
||||
### 3. Verify WSL can reach the servers
|
||||
|
||||
```bash
|
||||
wsl -d Ubuntu -- curl -s http://172.18.64.1:3090/api/health
|
||||
wsl -d Ubuntu -- curl -s -o /dev/null -w "%{http_code}" http://172.18.64.1:5173
|
||||
```
|
||||
|
||||
## Running agent-browser Commands
|
||||
|
||||
All commands are run from the Windows terminal, prefixed with `wsl -d Ubuntu --`:
|
||||
|
||||
```bash
|
||||
# Open a page
|
||||
wsl -d Ubuntu -- agent-browser open http://172.18.64.1:5173
|
||||
|
||||
# Take interactive snapshot (get element refs like @e1, @e2)
|
||||
wsl -d Ubuntu -- agent-browser snapshot -i
|
||||
|
||||
# Click, fill, press
|
||||
wsl -d Ubuntu -- agent-browser click @e1
|
||||
wsl -d Ubuntu -- agent-browser fill @e2 "some text"
|
||||
wsl -d Ubuntu -- agent-browser press Enter
|
||||
|
||||
# Wait for content to load
|
||||
wsl -d Ubuntu -- agent-browser wait 3000
|
||||
|
||||
# Reload page (hard refresh)
|
||||
wsl -d Ubuntu -- agent-browser reload
|
||||
|
||||
# Close browser
|
||||
wsl -d Ubuntu -- agent-browser close
|
||||
```
|
||||
|
||||
## Taking Screenshots
|
||||
|
||||
Screenshots must be saved to a WSL-native path first, then copied to the Windows filesystem via the `/mnt/c/` mount:
|
||||
|
||||
```bash
|
||||
# Save to WSL home, then copy to project
|
||||
wsl -d Ubuntu -- bash -c '
|
||||
agent-browser screenshot /home/coleam/screenshot.png 2>&1 &&
|
||||
cp /home/coleam/screenshot.png /mnt/c/Users/colem/dynamous-community/remote-coding-agent/e2e-screenshots/my-test.png
|
||||
'
|
||||
```
|
||||
|
||||
**Why not save directly to `/mnt/c/...`?** agent-browser resolves paths through its Node.js process, which on some setups mangles `/mnt/c/` paths (e.g., prepending `C:/Program Files/Git/`). Saving to a WSL-native path and copying avoids this.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`localhost` doesn't work from WSL2** - must use the Windows host IP (`172.18.64.1`)
|
||||
- **Vite must bind to `0.0.0.0`** - default `localhost` isn't reachable from WSL
|
||||
- **Git Bash path expansion** - `/status` gets expanded to `C:/Program Files/Git/status` when passed through Git Bash. Not an agent-browser issue; it's the shell expanding `/` paths
|
||||
- **SSE `Connected` indicator** - only shows for `web` platform conversations; Telegram/Slack conversations show `Disconnected` (expected)
|
||||
- **Daemon startup** - if `agent-browser open` fails with "Daemon failed to start", kill stale daemons: `wsl -d Ubuntu -- pkill -f daemon.js` and retry
|
||||
|
|
@ -18,6 +18,10 @@ export default tseslint.config(
|
|||
'*.mjs',
|
||||
'**/*.test.ts',
|
||||
'*.d.ts', // Root-level declaration files (not in tsconfig project scope)
|
||||
'packages/web/vite.config.ts', // Vite config doesn't need type-checked linting
|
||||
'packages/web/components.json',
|
||||
'packages/web/src/components/ui/**', // shadcn/ui auto-generated components
|
||||
'packages/web/src/lib/utils.ts', // shadcn/ui utility file
|
||||
],
|
||||
},
|
||||
|
||||
|
|
@ -32,7 +36,7 @@ export default tseslint.config(
|
|||
|
||||
// Project-specific settings
|
||||
{
|
||||
files: ['packages/*/src/**/*.ts'],
|
||||
files: ['packages/*/src/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
|
|
@ -61,7 +65,7 @@ export default tseslint.config(
|
|||
custom: { regex: '^I?[A-Z]', match: true },
|
||||
},
|
||||
{ selector: 'typeAlias', format: ['PascalCase'] },
|
||||
{ selector: 'function', format: ['camelCase'] },
|
||||
{ selector: 'function', format: ['camelCase', 'PascalCase'] },
|
||||
{ selector: 'variable', format: ['camelCase', 'UPPER_CASE'] },
|
||||
],
|
||||
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
-- Remote Coding Agent - Combined Schema
|
||||
-- Version: Combined (includes migrations 001-008)
|
||||
-- Version: Combined (includes migrations 001-015)
|
||||
-- Description: Complete database schema (idempotent - safe to run multiple times)
|
||||
|
||||
-- ============================================================================
|
||||
|
|
@ -26,6 +26,9 @@ CREATE TABLE IF NOT EXISTS remote_agent_conversations (
|
|||
codebase_id UUID REFERENCES remote_agent_codebases(id),
|
||||
cwd VARCHAR(500),
|
||||
ai_assistant_type VARCHAR(20) DEFAULT 'claude',
|
||||
title VARCHAR(255),
|
||||
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
hidden BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(platform_type, platform_conversation_id)
|
||||
|
|
@ -42,6 +45,8 @@ CREATE TABLE IF NOT EXISTS remote_agent_sessions (
|
|||
assistant_session_id VARCHAR(255),
|
||||
active BOOLEAN DEFAULT true,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
parent_session_id UUID REFERENCES remote_agent_sessions(id),
|
||||
transition_reason TEXT,
|
||||
started_at TIMESTAMP DEFAULT NOW(),
|
||||
ended_at TIMESTAMP
|
||||
);
|
||||
|
|
@ -167,3 +172,113 @@ CREATE INDEX IF NOT EXISTS idx_workflow_runs_status
|
|||
|
||||
COMMENT ON TABLE remote_agent_workflow_runs IS
|
||||
'Tracks workflow execution state for resumption and observability';
|
||||
|
||||
-- ============================================================================
|
||||
-- Migration 009: Workflow Last Activity
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE remote_agent_workflow_runs
|
||||
ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW();
|
||||
|
||||
-- Partial index for efficient staleness queries on running workflows
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_runs_last_activity
|
||||
ON remote_agent_workflow_runs(last_activity_at)
|
||||
WHERE status = 'running';
|
||||
|
||||
-- ============================================================================
|
||||
-- Migration 010: Immutable Sessions (parent linkage + transition tracking)
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE remote_agent_sessions
|
||||
ADD COLUMN IF NOT EXISTS parent_session_id UUID REFERENCES remote_agent_sessions(id);
|
||||
|
||||
ALTER TABLE remote_agent_sessions
|
||||
ADD COLUMN IF NOT EXISTS transition_reason TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON remote_agent_sessions(parent_session_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_conversation_started
|
||||
ON remote_agent_sessions(conversation_id, started_at DESC);
|
||||
|
||||
COMMENT ON COLUMN remote_agent_sessions.parent_session_id IS
|
||||
'Links to the previous session in this conversation (for audit trail)';
|
||||
COMMENT ON COLUMN remote_agent_sessions.transition_reason IS
|
||||
'Why this session was created: plan-to-execute, isolation-changed, reset-requested, etc.';
|
||||
|
||||
-- ============================================================================
|
||||
-- Migration 011: Partial Unique Constraint Fix
|
||||
-- ============================================================================
|
||||
|
||||
-- Drop the existing full constraint (if it exists from older migrations)
|
||||
ALTER TABLE remote_agent_isolation_environments
|
||||
DROP CONSTRAINT IF EXISTS unique_workflow;
|
||||
|
||||
-- Partial unique index already created in Migration 006 above (unique_active_workflow)
|
||||
|
||||
-- ============================================================================
|
||||
-- Migration 012: Workflow Events
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS remote_agent_workflow_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workflow_run_id UUID NOT NULL REFERENCES remote_agent_workflow_runs(id) ON DELETE CASCADE,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
step_index INTEGER,
|
||||
step_name VARCHAR(255),
|
||||
data JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_events_run_id
|
||||
ON remote_agent_workflow_events(workflow_run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_events_type
|
||||
ON remote_agent_workflow_events(event_type);
|
||||
|
||||
COMMENT ON TABLE remote_agent_workflow_events IS
|
||||
'Lean UI-relevant workflow events for observability (step transitions, artifacts, errors)';
|
||||
|
||||
-- ============================================================================
|
||||
-- Migration 013: Conversation Titles + Soft Delete
|
||||
-- ============================================================================
|
||||
|
||||
-- title and deleted_at already included in conversations CREATE TABLE above.
|
||||
-- ALTER statements kept for idempotent upgrades from older schemas:
|
||||
ALTER TABLE remote_agent_conversations
|
||||
ADD COLUMN IF NOT EXISTS title VARCHAR(255);
|
||||
|
||||
ALTER TABLE remote_agent_conversations
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE;
|
||||
|
||||
-- ============================================================================
|
||||
-- Migration 014: Message History
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS remote_agent_messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
conversation_id UUID NOT NULL REFERENCES remote_agent_conversations(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id
|
||||
ON remote_agent_messages(conversation_id, created_at ASC);
|
||||
|
||||
-- ============================================================================
|
||||
-- Migration 015: Background Dispatch
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE remote_agent_workflow_runs
|
||||
ADD COLUMN IF NOT EXISTS parent_conversation_id UUID
|
||||
REFERENCES remote_agent_conversations(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE remote_agent_conversations
|
||||
ADD COLUMN IF NOT EXISTS hidden BOOLEAN DEFAULT FALSE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_runs_parent_conv
|
||||
ON remote_agent_workflow_runs(parent_conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_hidden
|
||||
ON remote_agent_conversations(hidden);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_codebase
|
||||
ON remote_agent_conversations(codebase_id) WHERE deleted_at IS NULL;
|
||||
|
|
|
|||
21
migrations/012_workflow_events.sql
Normal file
21
migrations/012_workflow_events.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
-- Workflow events - lean UI-relevant events for observability
|
||||
-- Stores step transitions, parallel agent status, artifacts, errors.
|
||||
-- Verbose assistant/tool content stays in JSONL logs at {cwd}/.archon/logs/{runId}.jsonl
|
||||
|
||||
CREATE TABLE IF NOT EXISTS remote_agent_workflow_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workflow_run_id UUID NOT NULL REFERENCES remote_agent_workflow_runs(id) ON DELETE CASCADE,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
step_index INTEGER,
|
||||
step_name VARCHAR(255),
|
||||
data JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_events_run_id
|
||||
ON remote_agent_workflow_events(workflow_run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_events_type
|
||||
ON remote_agent_workflow_events(event_type);
|
||||
|
||||
COMMENT ON TABLE remote_agent_workflow_events IS
|
||||
'Lean UI-relevant workflow events for observability (step transitions, artifacts, errors)';
|
||||
6
migrations/013_conversation_titles.sql
Normal file
6
migrations/013_conversation_titles.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
-- Add title and soft-delete support to conversations
|
||||
ALTER TABLE remote_agent_conversations
|
||||
ADD COLUMN IF NOT EXISTS title VARCHAR(255);
|
||||
|
||||
ALTER TABLE remote_agent_conversations
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE;
|
||||
11
migrations/014_message_history.sql
Normal file
11
migrations/014_message_history.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
CREATE TABLE IF NOT EXISTS remote_agent_messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
conversation_id UUID NOT NULL REFERENCES remote_agent_conversations(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id
|
||||
ON remote_agent_messages(conversation_id, created_at ASC);
|
||||
16
migrations/015_background_dispatch.sql
Normal file
16
migrations/015_background_dispatch.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
-- Parent conversation link for background workflow runs
|
||||
ALTER TABLE remote_agent_workflow_runs
|
||||
ADD COLUMN IF NOT EXISTS parent_conversation_id UUID
|
||||
REFERENCES remote_agent_conversations(id) ON DELETE SET NULL;
|
||||
|
||||
-- Hide worker conversations from sidebar
|
||||
ALTER TABLE remote_agent_conversations
|
||||
ADD COLUMN IF NOT EXISTS hidden BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Index for filtering
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_runs_parent_conv
|
||||
ON remote_agent_workflow_runs(parent_conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_hidden
|
||||
ON remote_agent_conversations(hidden);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_codebase
|
||||
ON remote_agent_conversations(codebase_id) WHERE deleted_at IS NULL;
|
||||
|
|
@ -8,7 +8,8 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"cli": "bun --env-file=.env --cwd packages/cli src/cli.ts",
|
||||
"dev": "bun --filter @archon/server dev",
|
||||
"dev": "bun --filter '*' dev",
|
||||
"dev:server": "bun --filter @archon/server dev",
|
||||
"start": "bun --filter @archon/server start",
|
||||
"build": "bun --filter '*' build",
|
||||
"build:binaries": "bash scripts/build-binaries.sh",
|
||||
|
|
@ -20,6 +21,8 @@
|
|||
"lint:fix": "bun x eslint . --cache --fix",
|
||||
"format": "bun x prettier --write .",
|
||||
"format:check": "bun x prettier --check .",
|
||||
"dev:web": "bun --filter @archon/web dev",
|
||||
"build:web": "bun --filter @archon/web build",
|
||||
"validate": "bun run type-check && bun run lint --max-warnings 0 && bun run format:check && bun run test",
|
||||
"prepare": "husky",
|
||||
"setup-auth": "bun --filter @archon/server setup-auth"
|
||||
|
|
|
|||
|
|
@ -99,18 +99,56 @@ export class SqliteAdapter implements IDatabase {
|
|||
}
|
||||
|
||||
/**
|
||||
* Initialize database schema
|
||||
* Initialize database schema.
|
||||
* Always runs createSchema() since all statements use IF NOT EXISTS,
|
||||
* ensuring new tables from migrations are created in existing databases.
|
||||
*/
|
||||
private initSchema(): void {
|
||||
const schemaExists = this.db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='remote_agent_codebases'"
|
||||
)
|
||||
.get();
|
||||
this.createSchema();
|
||||
this.migrateColumns();
|
||||
}
|
||||
|
||||
if (!schemaExists) {
|
||||
console.log('[SQLite] Initializing database schema...');
|
||||
this.createSchema();
|
||||
/**
|
||||
* Add columns to existing tables that predate newer schema additions.
|
||||
* SQLite's CREATE TABLE IF NOT EXISTS skips entirely for existing tables,
|
||||
* so new columns must be added via ALTER TABLE for databases created before
|
||||
* the columns were added to createSchema().
|
||||
*/
|
||||
private migrateColumns(): void {
|
||||
// Conversations columns
|
||||
try {
|
||||
const cols = this.db.prepare("PRAGMA table_info('remote_agent_conversations')").all() as {
|
||||
name: string;
|
||||
}[];
|
||||
const colNames = new Set(cols.map(c => c.name));
|
||||
|
||||
if (!colNames.has('title')) {
|
||||
this.db.run('ALTER TABLE remote_agent_conversations ADD COLUMN title TEXT');
|
||||
}
|
||||
if (!colNames.has('deleted_at')) {
|
||||
this.db.run('ALTER TABLE remote_agent_conversations ADD COLUMN deleted_at TEXT');
|
||||
}
|
||||
if (!colNames.has('hidden')) {
|
||||
this.db.run('ALTER TABLE remote_agent_conversations ADD COLUMN hidden INTEGER DEFAULT 0');
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn('[SQLite] Migration for conversations columns failed:', (e as Error).message);
|
||||
}
|
||||
|
||||
// Workflow runs columns
|
||||
try {
|
||||
const wfCols = this.db.prepare("PRAGMA table_info('remote_agent_workflow_runs')").all() as {
|
||||
name: string;
|
||||
}[];
|
||||
const wfColNames = new Set(wfCols.map(c => c.name));
|
||||
|
||||
if (!wfColNames.has('parent_conversation_id')) {
|
||||
this.db.run(
|
||||
'ALTER TABLE remote_agent_workflow_runs ADD COLUMN parent_conversation_id TEXT'
|
||||
);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn('[SQLite] Migration for workflow_runs columns failed:', (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -141,6 +179,9 @@ export class SqliteAdapter implements IDatabase {
|
|||
codebase_id TEXT REFERENCES remote_agent_codebases(id) ON DELETE SET NULL,
|
||||
cwd TEXT,
|
||||
isolation_env_id TEXT,
|
||||
title TEXT,
|
||||
deleted_at TEXT,
|
||||
hidden INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
last_activity_at TEXT DEFAULT (datetime('now')),
|
||||
|
|
@ -204,11 +245,33 @@ export class SqliteAdapter implements IDatabase {
|
|||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
current_step_index INTEGER,
|
||||
metadata TEXT DEFAULT '{}',
|
||||
parent_conversation_id TEXT REFERENCES remote_agent_conversations(id) ON DELETE SET NULL,
|
||||
started_at TEXT DEFAULT (datetime('now')),
|
||||
completed_at TEXT,
|
||||
last_activity_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Workflow events table
|
||||
CREATE TABLE IF NOT EXISTS remote_agent_workflow_events (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
workflow_run_id TEXT NOT NULL REFERENCES remote_agent_workflow_runs(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
step_index INTEGER,
|
||||
step_name TEXT,
|
||||
data TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Messages table (conversation history for Web UI)
|
||||
CREATE TABLE IF NOT EXISTS remote_agent_messages (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
conversation_id TEXT NOT NULL REFERENCES remote_agent_conversations(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
metadata TEXT DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_platform ON remote_agent_conversations(platform_type, platform_conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_conversation ON remote_agent_sessions(conversation_id);
|
||||
|
|
@ -217,6 +280,22 @@ export class SqliteAdapter implements IDatabase {
|
|||
CREATE INDEX IF NOT EXISTS idx_isolation_workflow ON remote_agent_isolation_environments(workflow_type, workflow_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_runs_conversation ON remote_agent_workflow_runs(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON remote_agent_workflow_runs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_events_run_id ON remote_agent_workflow_events(workflow_run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_events_type ON remote_agent_workflow_events(event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON remote_agent_messages(conversation_id, created_at ASC);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_runs_parent_conv ON remote_agent_workflow_runs(parent_conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_hidden ON remote_agent_conversations(hidden);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_codebase ON remote_agent_conversations(codebase_id);
|
||||
|
||||
-- From PG migration 009: staleness detection for running workflows
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_runs_last_activity
|
||||
ON remote_agent_workflow_runs(last_activity_at) WHERE status = 'running';
|
||||
|
||||
-- From PG migration 010: session audit trail
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_parent
|
||||
ON remote_agent_sessions(parent_session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_conversation_started
|
||||
ON remote_agent_sessions(conversation_id, started_at DESC);
|
||||
`);
|
||||
console.log('[SQLite] Schema initialized successfully');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,13 @@ export async function updateCodebase(id: string, data: { default_cwd?: string })
|
|||
);
|
||||
}
|
||||
|
||||
export async function listCodebases(): Promise<readonly Codebase[]> {
|
||||
const result = await pool.query<Codebase>(
|
||||
'SELECT * FROM remote_agent_codebases ORDER BY name ASC'
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function deleteCodebase(id: string): Promise<void> {
|
||||
// First, unlink any sessions referencing this codebase (FK has no cascade)
|
||||
await pool.query('UPDATE remote_agent_sessions SET codebase_id = NULL WHERE codebase_id = $1', [
|
||||
|
|
|
|||
|
|
@ -5,6 +5,17 @@ import { pool, getDialect } from './connection';
|
|||
import type { Conversation } from '../types';
|
||||
import { ConversationNotFoundError } from '../types';
|
||||
|
||||
/**
|
||||
* Get a conversation by its database ID
|
||||
*/
|
||||
export async function getConversationById(id: string): Promise<Conversation | null> {
|
||||
const result = await pool.query<Conversation>(
|
||||
'SELECT * FROM remote_agent_conversations WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a conversation by platform type and platform ID
|
||||
* Returns null if not found (unlike getOrCreate which creates)
|
||||
|
|
@ -79,10 +90,12 @@ export async function getOrCreateConversation(
|
|||
|
||||
export async function updateConversation(
|
||||
id: string,
|
||||
updates: Partial<Pick<Conversation, 'codebase_id' | 'cwd' | 'isolation_env_id'>>
|
||||
updates: Partial<Pick<Conversation, 'codebase_id' | 'cwd' | 'isolation_env_id'>> & {
|
||||
hidden?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
const fields: string[] = [];
|
||||
const values: (string | null)[] = [];
|
||||
const values: (string | number | null)[] = [];
|
||||
let i = 1;
|
||||
|
||||
if (updates.codebase_id !== undefined) {
|
||||
|
|
@ -97,6 +110,10 @@ export async function updateConversation(
|
|||
fields.push(`isolation_env_id = $${String(i++)}`);
|
||||
values.push(updates.isolation_env_id);
|
||||
}
|
||||
if (updates.hidden !== undefined) {
|
||||
fields.push(`hidden = $${String(i++)}`);
|
||||
values.push(updates.hidden ? 1 : 0);
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return; // No updates
|
||||
|
|
@ -145,6 +162,36 @@ export async function getConversationsByIsolationEnvId(
|
|||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all conversations ordered by recent activity
|
||||
*/
|
||||
export async function listConversations(
|
||||
limit = 50,
|
||||
platformType?: string,
|
||||
codebaseId?: string
|
||||
): Promise<readonly Conversation[]> {
|
||||
const params: unknown[] = [];
|
||||
let sql =
|
||||
'SELECT * FROM remote_agent_conversations WHERE deleted_at IS NULL AND (hidden IS NULL OR hidden = false)';
|
||||
|
||||
if (platformType) {
|
||||
params.push(platformType);
|
||||
sql += ` AND platform_type = $${String(params.length)}`;
|
||||
}
|
||||
|
||||
if (codebaseId) {
|
||||
params.push(codebaseId);
|
||||
sql += ` AND codebase_id = $${String(params.length)}`;
|
||||
}
|
||||
|
||||
sql += ' ORDER BY last_activity_at DESC NULLS LAST';
|
||||
params.push(limit);
|
||||
sql += ` LIMIT $${String(params.length)}`;
|
||||
|
||||
const result = await pool.query<Conversation>(sql, params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last_activity_at for staleness tracking
|
||||
*/
|
||||
|
|
@ -155,3 +202,31 @@ export async function touchConversation(id: string): Promise<void> {
|
|||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update conversation title
|
||||
*/
|
||||
export async function updateConversationTitle(id: string, title: string): Promise<void> {
|
||||
const dialect = getDialect();
|
||||
const result = await pool.query(
|
||||
`UPDATE remote_agent_conversations SET title = $1, updated_at = ${dialect.now()} WHERE id = $2`,
|
||||
[title, id]
|
||||
);
|
||||
if (result.rowCount === 0) {
|
||||
throw new ConversationNotFoundError(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete a conversation (sets deleted_at timestamp)
|
||||
*/
|
||||
export async function softDeleteConversation(id: string): Promise<void> {
|
||||
const dialect = getDialect();
|
||||
const result = await pool.query(
|
||||
`UPDATE remote_agent_conversations SET deleted_at = ${dialect.now()}, updated_at = ${dialect.now()} WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (result.rowCount === 0) {
|
||||
throw new ConversationNotFoundError(id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
124
packages/core/src/db/messages.test.ts
Normal file
124
packages/core/src/db/messages.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { mock, describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { createQueryResult, mockPostgresDialect } from '../test/mocks/database';
|
||||
import type { MessageRow } from './messages';
|
||||
|
||||
const mockQuery = mock(() => Promise.resolve(createQueryResult([])));
|
||||
|
||||
// Mock the connection module before importing the module under test
|
||||
mock.module('./connection', () => ({
|
||||
pool: {
|
||||
query: mockQuery,
|
||||
},
|
||||
getDialect: () => mockPostgresDialect,
|
||||
}));
|
||||
|
||||
import { addMessage, listMessages } from './messages';
|
||||
|
||||
describe('messages', () => {
|
||||
beforeEach(() => {
|
||||
mockQuery.mockClear();
|
||||
});
|
||||
|
||||
const mockMessage: MessageRow = {
|
||||
id: 'msg-123',
|
||||
conversation_id: 'conv-456',
|
||||
role: 'user',
|
||||
content: 'Hello, world!',
|
||||
metadata: '{}',
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('addMessage', () => {
|
||||
test('calls pool.query with correct SQL and parameters', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([mockMessage]));
|
||||
|
||||
const result = await addMessage('conv-456', 'user', 'Hello, world!');
|
||||
|
||||
expect(result).toEqual(mockMessage);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
`INSERT INTO remote_agent_messages (conversation_id, role, content, metadata, created_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
RETURNING *`,
|
||||
['conv-456', 'user', 'Hello, world!', '{}']
|
||||
);
|
||||
});
|
||||
|
||||
test('includes metadata as JSON string when provided', async () => {
|
||||
const messageWithMetadata: MessageRow = {
|
||||
...mockMessage,
|
||||
metadata: '{"toolCalls":[{"name":"read"}],"error":null}',
|
||||
};
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([messageWithMetadata]));
|
||||
|
||||
const metadata = { toolCalls: [{ name: 'read' }], error: null };
|
||||
const result = await addMessage('conv-456', 'assistant', 'Done.', metadata);
|
||||
|
||||
expect(result).toEqual(messageWithMetadata);
|
||||
expect(mockQuery).toHaveBeenCalledWith(expect.any(String), [
|
||||
'conv-456',
|
||||
'assistant',
|
||||
'Done.',
|
||||
JSON.stringify(metadata),
|
||||
]);
|
||||
});
|
||||
|
||||
test('defaults metadata to empty object when not provided', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([mockMessage]));
|
||||
|
||||
await addMessage('conv-456', 'user', 'Hello, world!');
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(expect.any(String), expect.arrayContaining(['{}']));
|
||||
});
|
||||
|
||||
test('throws wrapped error when INSERT returns no rows', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([]));
|
||||
|
||||
await expect(addMessage('conv-456', 'user', 'Hello')).rejects.toThrow(
|
||||
'Failed to persist message: INSERT returned no rows (conversation: conv-456)'
|
||||
);
|
||||
});
|
||||
|
||||
test('propagates query errors', async () => {
|
||||
mockQuery.mockRejectedValueOnce(new Error('connection refused'));
|
||||
|
||||
await expect(addMessage('conv-456', 'user', 'Hello')).rejects.toThrow('connection refused');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listMessages', () => {
|
||||
test('returns rows from query result', async () => {
|
||||
const messages: MessageRow[] = [
|
||||
mockMessage,
|
||||
{ ...mockMessage, id: 'msg-124', role: 'assistant', content: 'Hi!' },
|
||||
];
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult(messages));
|
||||
|
||||
const result = await listMessages('conv-456');
|
||||
|
||||
expect(result).toEqual(messages);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
`SELECT * FROM remote_agent_messages
|
||||
WHERE conversation_id = $1
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $2`,
|
||||
['conv-456', 200]
|
||||
);
|
||||
});
|
||||
|
||||
test('returns empty array for no results', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([]));
|
||||
|
||||
const result = await listMessages('conv-456');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test('respects custom limit parameter', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([]));
|
||||
|
||||
await listMessages('conv-456', 50);
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(expect.any(String), ['conv-456', 50]);
|
||||
});
|
||||
});
|
||||
});
|
||||
57
packages/core/src/db/messages.ts
Normal file
57
packages/core/src/db/messages.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* Database operations for conversation messages (Web UI history)
|
||||
*/
|
||||
import { pool, getDialect } from './connection';
|
||||
|
||||
export interface MessageRow {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
metadata: string; // JSON string - parsed by frontend
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to conversation history.
|
||||
* metadata should contain toolCalls array and/or error object if applicable.
|
||||
*/
|
||||
export async function addMessage(
|
||||
conversationId: string,
|
||||
role: 'user' | 'assistant',
|
||||
content: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): Promise<MessageRow> {
|
||||
const dialect = getDialect();
|
||||
const result = await pool.query<MessageRow>(
|
||||
`INSERT INTO remote_agent_messages (conversation_id, role, content, metadata, created_at)
|
||||
VALUES ($1, $2, $3, $4, ${dialect.now()})
|
||||
RETURNING *`,
|
||||
[conversationId, role, content, JSON.stringify(metadata ?? {})]
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
throw new Error(
|
||||
`Failed to persist message: INSERT returned no rows (conversation: ${conversationId})`
|
||||
);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* List messages for a conversation, oldest first.
|
||||
* conversationId is the database UUID (not platform_conversation_id).
|
||||
*/
|
||||
export async function listMessages(
|
||||
conversationId: string,
|
||||
limit = 200
|
||||
): Promise<readonly MessageRow[]> {
|
||||
const result = await pool.query<MessageRow>(
|
||||
`SELECT * FROM remote_agent_messages
|
||||
WHERE conversation_id = $1
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $2`,
|
||||
[conversationId, limit]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
191
packages/core/src/db/workflow-events.test.ts
Normal file
191
packages/core/src/db/workflow-events.test.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { mock, describe, test, expect, beforeEach, spyOn } from 'bun:test';
|
||||
import { createQueryResult, mockPostgresDialect } from '../test/mocks/database';
|
||||
import type { WorkflowEventRow } from './workflow-events';
|
||||
|
||||
const mockQuery = mock(() => Promise.resolve(createQueryResult([])));
|
||||
|
||||
// Mock the connection module before importing the module under test
|
||||
mock.module('./connection', () => ({
|
||||
pool: {
|
||||
query: mockQuery,
|
||||
},
|
||||
getDialect: () => mockPostgresDialect,
|
||||
}));
|
||||
|
||||
import { createWorkflowEvent, listWorkflowEvents, listRecentEvents } from './workflow-events';
|
||||
|
||||
describe('workflow-events', () => {
|
||||
beforeEach(() => {
|
||||
mockQuery.mockClear();
|
||||
});
|
||||
|
||||
const mockEvent: WorkflowEventRow = {
|
||||
id: 'evt-123',
|
||||
workflow_run_id: 'run-456',
|
||||
event_type: 'step_started',
|
||||
step_index: 0,
|
||||
step_name: 'plan',
|
||||
data: {},
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('createWorkflowEvent', () => {
|
||||
test('calls pool.query with correct SQL and parameters', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([]));
|
||||
|
||||
await createWorkflowEvent({
|
||||
workflow_run_id: 'run-456',
|
||||
event_type: 'step_started',
|
||||
step_index: 0,
|
||||
step_name: 'plan',
|
||||
data: { duration: 100 },
|
||||
});
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
`INSERT INTO remote_agent_workflow_events (id, workflow_run_id, event_type, step_index, step_name, data)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[
|
||||
expect.any(String), // generated UUID
|
||||
'run-456',
|
||||
'step_started',
|
||||
0,
|
||||
'plan',
|
||||
JSON.stringify({ duration: 100 }),
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('defaults optional fields to null and empty data', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([]));
|
||||
|
||||
await createWorkflowEvent({
|
||||
workflow_run_id: 'run-456',
|
||||
event_type: 'workflow_started',
|
||||
});
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(expect.any(String), [
|
||||
expect.any(String),
|
||||
'run-456',
|
||||
'workflow_started',
|
||||
null,
|
||||
null,
|
||||
'{}',
|
||||
]);
|
||||
});
|
||||
|
||||
test('does NOT throw when query fails (fire-and-forget)', async () => {
|
||||
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockQuery.mockRejectedValueOnce(new Error('connection refused'));
|
||||
|
||||
// Should NOT throw
|
||||
await createWorkflowEvent({
|
||||
workflow_run_id: 'run-456',
|
||||
event_type: 'step_started',
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[DB:WorkflowEvents] Failed to create event (non-critical):',
|
||||
expect.objectContaining({
|
||||
error: 'connection refused',
|
||||
eventType: 'step_started',
|
||||
runId: 'run-456',
|
||||
})
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listWorkflowEvents', () => {
|
||||
test('returns rows from query result', async () => {
|
||||
const events: WorkflowEventRow[] = [
|
||||
mockEvent,
|
||||
{ ...mockEvent, id: 'evt-124', event_type: 'step_completed', step_index: 1 },
|
||||
];
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult(events));
|
||||
|
||||
const result = await listWorkflowEvents('run-456');
|
||||
|
||||
expect(result).toEqual(events);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
`SELECT * FROM remote_agent_workflow_events
|
||||
WHERE workflow_run_id = $1
|
||||
ORDER BY created_at ASC`,
|
||||
['run-456']
|
||||
);
|
||||
});
|
||||
|
||||
test('returns empty array for no results', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([]));
|
||||
|
||||
const result = await listWorkflowEvents('run-456');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test('throws wrapped error when query fails', async () => {
|
||||
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockQuery.mockRejectedValueOnce(new Error('timeout'));
|
||||
|
||||
await expect(listWorkflowEvents('run-456')).rejects.toThrow(
|
||||
'Failed to list workflow events: timeout'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listRecentEvents', () => {
|
||||
test('returns events filtered by since parameter', async () => {
|
||||
const events: WorkflowEventRow[] = [mockEvent];
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult(events));
|
||||
|
||||
const since = new Date('2025-01-01T00:00:00.000Z');
|
||||
const result = await listRecentEvents('run-456', since);
|
||||
|
||||
expect(result).toEqual(events);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
`SELECT * FROM remote_agent_workflow_events
|
||||
WHERE workflow_run_id = $1 AND created_at > $2
|
||||
ORDER BY created_at ASC`,
|
||||
['run-456', since.toISOString()]
|
||||
);
|
||||
});
|
||||
|
||||
test('delegates to listWorkflowEvents without since parameter', async () => {
|
||||
const events: WorkflowEventRow[] = [mockEvent];
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult(events));
|
||||
|
||||
const result = await listRecentEvents('run-456');
|
||||
|
||||
expect(result).toEqual(events);
|
||||
// Should use the same query as listWorkflowEvents (no created_at filter)
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
`SELECT * FROM remote_agent_workflow_events
|
||||
WHERE workflow_run_id = $1
|
||||
ORDER BY created_at ASC`,
|
||||
['run-456']
|
||||
);
|
||||
});
|
||||
|
||||
test('returns empty array for no results', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([]));
|
||||
|
||||
const since = new Date('2025-06-01T00:00:00.000Z');
|
||||
const result = await listRecentEvents('run-456', since);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test('throws wrapped error on query failure', async () => {
|
||||
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockQuery.mockRejectedValueOnce(new Error('connection lost'));
|
||||
|
||||
await expect(listRecentEvents('run-456', new Date())).rejects.toThrow(
|
||||
'Failed to list recent workflow events: connection lost'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
110
packages/core/src/db/workflow-events.ts
Normal file
110
packages/core/src/db/workflow-events.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* Database operations for workflow events (lean UI-relevant events).
|
||||
*
|
||||
* Stores step transitions, parallel agent status, artifacts, and errors.
|
||||
* Verbose assistant/tool content stays in JSONL logs only.
|
||||
*
|
||||
* All write operations use fire-and-forget pattern (catch + log, never throw)
|
||||
* because workflow execution must not fail due to event logging.
|
||||
*/
|
||||
import { pool, getDialect } from './connection';
|
||||
|
||||
export interface WorkflowEventRow {
|
||||
id: string;
|
||||
workflow_run_id: string;
|
||||
event_type: string;
|
||||
step_index: number | null;
|
||||
step_name: string | null;
|
||||
/** Normalized to object — SQLite returns JSON as string, PG returns object. */
|
||||
data: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workflow event. Fire-and-forget - never throws.
|
||||
*/
|
||||
export async function createWorkflowEvent(data: {
|
||||
workflow_run_id: string;
|
||||
event_type: string;
|
||||
step_index?: number;
|
||||
step_name?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const dialect = getDialect();
|
||||
const id = dialect.generateUuid();
|
||||
await pool.query(
|
||||
`INSERT INTO remote_agent_workflow_events (id, workflow_run_id, event_type, step_index, step_name, data)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[
|
||||
id,
|
||||
data.workflow_run_id,
|
||||
data.event_type,
|
||||
data.step_index ?? null,
|
||||
data.step_name ?? null,
|
||||
JSON.stringify(data.data ?? {}),
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[DB:WorkflowEvents] Failed to create event (non-critical):', {
|
||||
error: (error as Error).message,
|
||||
eventType: data.event_type,
|
||||
runId: data.workflow_run_id,
|
||||
});
|
||||
// Fire-and-forget: never throw
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all events for a workflow run, ordered by creation time.
|
||||
*/
|
||||
export async function listWorkflowEvents(workflowRunId: string): Promise<WorkflowEventRow[]> {
|
||||
try {
|
||||
const result = await pool.query<WorkflowEventRow>(
|
||||
`SELECT * FROM remote_agent_workflow_events
|
||||
WHERE workflow_run_id = $1
|
||||
ORDER BY created_at ASC`,
|
||||
[workflowRunId]
|
||||
);
|
||||
return [...result.rows].map(row => ({
|
||||
...row,
|
||||
data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[DB:WorkflowEvents] Failed to list events:', {
|
||||
error: (error as Error).message,
|
||||
runId: workflowRunId,
|
||||
});
|
||||
throw new Error(`Failed to list workflow events: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List recent events for a workflow run since a given timestamp.
|
||||
*/
|
||||
export async function listRecentEvents(
|
||||
workflowRunId: string,
|
||||
since?: Date
|
||||
): Promise<WorkflowEventRow[]> {
|
||||
try {
|
||||
if (since) {
|
||||
const result = await pool.query<WorkflowEventRow>(
|
||||
`SELECT * FROM remote_agent_workflow_events
|
||||
WHERE workflow_run_id = $1 AND created_at > $2
|
||||
ORDER BY created_at ASC`,
|
||||
[workflowRunId, since.toISOString()]
|
||||
);
|
||||
return [...result.rows].map(row => ({
|
||||
...row,
|
||||
data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data,
|
||||
}));
|
||||
}
|
||||
return await listWorkflowEvents(workflowRunId);
|
||||
} catch (error) {
|
||||
console.error('[DB:WorkflowEvents] Failed to list recent events:', {
|
||||
error: (error as Error).message,
|
||||
runId: workflowRunId,
|
||||
});
|
||||
throw new Error(`Failed to list recent workflow events: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -58,7 +58,13 @@ export async function createWorkflowRun(data: {
|
|||
metadataJson,
|
||||
]
|
||||
);
|
||||
return result.rows[0];
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
throw new Error(
|
||||
`Failed to create workflow run: INSERT returned no rows (workflow: ${data.workflow_name})`
|
||||
);
|
||||
}
|
||||
return row;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error('[DB:Workflows] Failed to create workflow run:', err.message);
|
||||
|
|
@ -96,6 +102,29 @@ export async function getActiveWorkflowRun(conversationId: string): Promise<Work
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent workflow run for a worker platform conversation ID.
|
||||
* Joins with conversations table to resolve platform_conversation_id → DB id.
|
||||
*/
|
||||
export async function getWorkflowRunByWorkerPlatformId(
|
||||
platformConversationId: string
|
||||
): Promise<WorkflowRun | null> {
|
||||
try {
|
||||
const result = await pool.query<WorkflowRun>(
|
||||
`SELECT r.* FROM remote_agent_workflow_runs r
|
||||
JOIN remote_agent_conversations c ON r.conversation_id = c.id
|
||||
WHERE c.platform_conversation_id = $1
|
||||
ORDER BY r.started_at DESC LIMIT 1`,
|
||||
[platformConversationId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error('[DB:Workflows] Failed to get workflow run by worker platform ID:', err.message);
|
||||
throw new Error(`Failed to get workflow run by worker platform ID: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Partially update a workflow run.
|
||||
* - Dynamically builds SQL from provided fields
|
||||
|
|
@ -182,6 +211,72 @@ export async function failWorkflowRun(id: string, error: string): Promise<void>
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List workflow runs with optional filters.
|
||||
*/
|
||||
export async function listWorkflowRuns(options?: {
|
||||
conversationId?: string;
|
||||
status?: 'pending' | 'running' | 'completed' | 'failed';
|
||||
limit?: number;
|
||||
codebaseId?: string;
|
||||
}): Promise<WorkflowRun[]> {
|
||||
const whereClauses: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
if (options?.conversationId) {
|
||||
values.push(options.conversationId);
|
||||
whereClauses.push(`conversation_id = $${String(values.length)}`);
|
||||
}
|
||||
if (options?.status) {
|
||||
values.push(options.status);
|
||||
whereClauses.push(`status = $${String(values.length)}`);
|
||||
}
|
||||
if (options?.codebaseId) {
|
||||
values.push(options.codebaseId);
|
||||
whereClauses.push(
|
||||
`conversation_id IN (SELECT id FROM remote_agent_conversations WHERE codebase_id = $${String(values.length)})`
|
||||
);
|
||||
}
|
||||
|
||||
const limit = options?.limit ?? 50;
|
||||
values.push(limit);
|
||||
const limitParam = `$${String(values.length)}`;
|
||||
|
||||
const whereStr = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
|
||||
|
||||
try {
|
||||
const result = await pool.query<WorkflowRun>(
|
||||
`SELECT * FROM remote_agent_workflow_runs ${whereStr} ORDER BY started_at DESC LIMIT ${limitParam}`,
|
||||
values
|
||||
);
|
||||
return [...result.rows];
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error('[DB:Workflows] Failed to list workflow runs:', err.message);
|
||||
throw new Error(`Failed to list workflow runs: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update parent_conversation_id on a workflow run.
|
||||
* Non-critical — logs error but does not throw.
|
||||
*/
|
||||
export async function updateWorkflowRunParent(
|
||||
runId: string,
|
||||
parentConversationId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await pool.query(
|
||||
'UPDATE remote_agent_workflow_runs SET parent_conversation_id = $1 WHERE id = $2',
|
||||
[parentConversationId, runId]
|
||||
);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error('[DB:Workflows] Failed to update parent conversation:', err.message);
|
||||
// Non-critical — don't throw
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last_activity_at timestamp for a workflow run.
|
||||
* Used for activity-based staleness detection.
|
||||
|
|
|
|||
248
packages/core/src/handlers/clone.ts
Normal file
248
packages/core/src/handlers/clone.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
/**
|
||||
* Standalone repository clone/register logic.
|
||||
* Extracted from command-handler.ts for reuse by REST endpoints.
|
||||
*/
|
||||
import { access } from 'fs/promises';
|
||||
import { join, basename } from 'path';
|
||||
import * as codebaseDb from '../db/codebases';
|
||||
import { sanitizeError } from '../utils/credential-sanitizer';
|
||||
import { execFileAsync } from '../utils/git';
|
||||
import { getArchonWorkspacesPath, getCommandFolderSearchPaths } from '../utils/archon-paths';
|
||||
import { findMarkdownFilesRecursive } from '../utils/commands';
|
||||
|
||||
export interface RegisterResult {
|
||||
codebaseId: string;
|
||||
name: string;
|
||||
repositoryUrl: string | null;
|
||||
defaultCwd: string;
|
||||
commandCount: number;
|
||||
alreadyExisted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared logic: register a repo at a given path in the DB and load commands.
|
||||
*/
|
||||
async function registerRepoAtPath(
|
||||
targetPath: string,
|
||||
name: string,
|
||||
repositoryUrl: string | null
|
||||
): Promise<RegisterResult> {
|
||||
// Auto-detect assistant type based on folder structure
|
||||
let suggestedAssistant = 'claude';
|
||||
const codexFolder = join(targetPath, '.codex');
|
||||
const claudeFolder = join(targetPath, '.claude');
|
||||
|
||||
try {
|
||||
await access(codexFolder);
|
||||
suggestedAssistant = 'codex';
|
||||
console.log('[Clone] Detected .codex folder - using Codex assistant');
|
||||
} catch {
|
||||
try {
|
||||
await access(claudeFolder);
|
||||
suggestedAssistant = 'claude';
|
||||
console.log('[Clone] Detected .claude folder - using Claude assistant');
|
||||
} catch {
|
||||
console.log('[Clone] No assistant folder detected - defaulting to Claude');
|
||||
}
|
||||
}
|
||||
|
||||
const codebase = await codebaseDb.createCodebase({
|
||||
name,
|
||||
repository_url: repositoryUrl ?? undefined,
|
||||
default_cwd: targetPath,
|
||||
ai_assistant_type: suggestedAssistant,
|
||||
});
|
||||
|
||||
// Auto-load commands if found
|
||||
let commandsLoaded = 0;
|
||||
for (const folder of getCommandFolderSearchPaths()) {
|
||||
const commandPath = join(targetPath, folder);
|
||||
try {
|
||||
await access(commandPath);
|
||||
} catch {
|
||||
continue; // Folder doesn't exist, try next
|
||||
}
|
||||
// Command loading errors should NOT be swallowed
|
||||
const markdownFiles = await findMarkdownFilesRecursive(commandPath);
|
||||
if (markdownFiles.length > 0) {
|
||||
const commands = await codebaseDb.getCodebaseCommands(codebase.id);
|
||||
markdownFiles.forEach(({ commandName, relativePath }) => {
|
||||
commands[commandName] = {
|
||||
path: join(folder, relativePath),
|
||||
description: `From ${folder}`,
|
||||
};
|
||||
});
|
||||
await codebaseDb.updateCodebaseCommands(codebase.id, commands);
|
||||
commandsLoaded = markdownFiles.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
codebaseId: codebase.id,
|
||||
name: codebase.name,
|
||||
repositoryUrl: repositoryUrl,
|
||||
defaultCwd: targetPath,
|
||||
commandCount: commandsLoaded,
|
||||
alreadyExisted: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a repo URL: strip trailing slashes and convert SSH to HTTPS.
|
||||
*/
|
||||
function normalizeRepoUrl(rawUrl: string): {
|
||||
workingUrl: string;
|
||||
ownerName: string;
|
||||
repoName: string;
|
||||
targetPath: string;
|
||||
} {
|
||||
const normalizedUrl = rawUrl.replace(/\/+$/, '');
|
||||
|
||||
let workingUrl = normalizedUrl;
|
||||
if (normalizedUrl.startsWith('git@github.com:')) {
|
||||
workingUrl = normalizedUrl.replace('git@github.com:', 'https://github.com/');
|
||||
}
|
||||
|
||||
const urlParts = workingUrl.replace(/\.git$/, '').split('/');
|
||||
const repoName = urlParts.pop() ?? 'unknown';
|
||||
const ownerName = urlParts.pop() ?? 'unknown';
|
||||
|
||||
const workspacePath = getArchonWorkspacesPath();
|
||||
const targetPath = join(workspacePath, ownerName, repoName);
|
||||
|
||||
return { workingUrl, ownerName, repoName, targetPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a repository from a URL and register it in the database.
|
||||
*/
|
||||
export async function cloneRepository(repoUrl: string): Promise<RegisterResult> {
|
||||
const { workingUrl, ownerName, repoName, targetPath } = normalizeRepoUrl(repoUrl);
|
||||
|
||||
// Check if target directory already exists
|
||||
let directoryExists = false;
|
||||
try {
|
||||
await access(targetPath);
|
||||
directoryExists = true;
|
||||
} catch {
|
||||
// Directory doesn't exist, proceed with clone
|
||||
}
|
||||
|
||||
if (directoryExists) {
|
||||
// Directory exists - try to find existing codebase by repo URL
|
||||
const urlNoGit = workingUrl.replace(/\.git$/, '');
|
||||
const urlWithGit = urlNoGit + '.git';
|
||||
|
||||
const existingCodebase =
|
||||
(await codebaseDb.findCodebaseByRepoUrl(urlNoGit)) ??
|
||||
(await codebaseDb.findCodebaseByRepoUrl(urlWithGit));
|
||||
|
||||
if (existingCodebase) {
|
||||
return {
|
||||
codebaseId: existingCodebase.id,
|
||||
name: existingCodebase.name,
|
||||
repositoryUrl: existingCodebase.repository_url,
|
||||
defaultCwd: existingCodebase.default_cwd,
|
||||
commandCount: 0,
|
||||
alreadyExisted: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Directory exists but no codebase found
|
||||
throw new Error(
|
||||
`Directory already exists: ${targetPath}\n\nNo matching codebase found in database. Remove the directory and re-clone.`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[Clone] Cloning ${workingUrl} to ${targetPath}`);
|
||||
|
||||
// Build clone command with authentication if GitHub token is available
|
||||
let cloneUrl = workingUrl;
|
||||
const ghToken = process.env.GH_TOKEN;
|
||||
|
||||
if (ghToken && workingUrl.includes('github.com')) {
|
||||
if (workingUrl.startsWith('https://github.com')) {
|
||||
cloneUrl = workingUrl.replace('https://github.com', `https://${ghToken}@github.com`);
|
||||
} else if (workingUrl.startsWith('http://github.com')) {
|
||||
cloneUrl = workingUrl.replace('http://github.com', `https://${ghToken}@github.com`);
|
||||
} else if (!workingUrl.startsWith('http')) {
|
||||
cloneUrl = `https://${ghToken}@${workingUrl}`;
|
||||
}
|
||||
console.log('[Clone] Using authenticated GitHub clone');
|
||||
}
|
||||
|
||||
try {
|
||||
await execFileAsync('git', ['clone', cloneUrl, targetPath]);
|
||||
} catch (error) {
|
||||
const safeErr = sanitizeError(error as Error);
|
||||
throw new Error(`Failed to clone repository: ${safeErr.message}`);
|
||||
}
|
||||
|
||||
// Add to git safe.directory
|
||||
await execFileAsync('git', ['config', '--global', '--add', 'safe.directory', targetPath]);
|
||||
console.log(`[Clone] Added ${targetPath} to git safe.directory`);
|
||||
|
||||
return registerRepoAtPath(targetPath, `${ownerName}/${repoName}`, workingUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an existing local repository in the database (no git clone).
|
||||
*/
|
||||
export async function registerRepository(localPath: string): Promise<RegisterResult> {
|
||||
// Validate path exists and is a git repo
|
||||
try {
|
||||
await execFileAsync('git', ['-C', localPath, 'rev-parse', '--git-dir']);
|
||||
} catch (error) {
|
||||
throw new Error(`Path is not a git repository: ${localPath} (${(error as Error).message})`);
|
||||
}
|
||||
|
||||
// Check if already registered by path
|
||||
const existing = await codebaseDb.findCodebaseByDefaultCwd(localPath);
|
||||
if (existing) {
|
||||
return {
|
||||
codebaseId: existing.id,
|
||||
name: existing.name,
|
||||
repositoryUrl: existing.repository_url,
|
||||
defaultCwd: existing.default_cwd,
|
||||
commandCount: 0,
|
||||
alreadyExisted: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Get remote URL (optional — local-only repos may not have one)
|
||||
let remoteUrl: string | null = null;
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['-C', localPath, 'remote', 'get-url', 'origin']);
|
||||
remoteUrl = stdout.trim() || null;
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message ?? '';
|
||||
if (!msg.includes('No such remote')) {
|
||||
console.warn('[Clone] Unexpected error fetching remote URL', {
|
||||
path: localPath,
|
||||
error: msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Extract repo name from directory name
|
||||
const repoName = basename(localPath);
|
||||
|
||||
// Try to build owner/repo name from remote URL
|
||||
let name = repoName;
|
||||
if (remoteUrl) {
|
||||
const cleaned = remoteUrl.replace(/\.git$/, '').replace(/\/+$/, '');
|
||||
let workingRemote = cleaned;
|
||||
if (cleaned.startsWith('git@github.com:')) {
|
||||
workingRemote = cleaned.replace('git@github.com:', 'https://github.com/');
|
||||
}
|
||||
const parts = workingRemote.split('/');
|
||||
const r = parts.pop();
|
||||
const o = parts.pop();
|
||||
if (o && r) {
|
||||
name = `${o}/${r}`;
|
||||
}
|
||||
}
|
||||
|
||||
return registerRepoAtPath(localPath, name, remoteUrl);
|
||||
}
|
||||
|
|
@ -25,6 +25,8 @@ import { discoverWorkflows } from '../workflows';
|
|||
import { isSingleStep, type WorkflowDefinition } from '../workflows/types';
|
||||
import * as workflowDb from '../db/workflows';
|
||||
import { getTriggerForCommand } from '../state/session-transitions';
|
||||
import { cloneRepository } from './clone';
|
||||
import { findMarkdownFilesRecursive } from '../utils/commands';
|
||||
|
||||
// Workflow staleness thresholds (in milliseconds)
|
||||
const WORKFLOW_SLOW_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
|
@ -168,40 +170,6 @@ async function formatRepoContext(
|
|||
return `${codebase.name} @ ${branchName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find all .md files in a directory and its subdirectories
|
||||
*/
|
||||
async function findMarkdownFilesRecursive(
|
||||
rootPath: string,
|
||||
relativePath = ''
|
||||
): Promise<{ commandName: string; relativePath: string }[]> {
|
||||
const results: { commandName: string; relativePath: string }[] = [];
|
||||
const fullPath = join(rootPath, relativePath);
|
||||
|
||||
const entries = await readdir(fullPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip hidden directories and common exclusions
|
||||
if (entry.name.startsWith('.') || entry.name === 'node_modules') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Recurse into subdirectory
|
||||
const subResults = await findMarkdownFilesRecursive(rootPath, join(relativePath, entry.name));
|
||||
results.push(...subResults);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
// Found a markdown file - use filename as command name
|
||||
results.push({
|
||||
commandName: basename(entry.name, '.md'),
|
||||
relativePath: join(relativePath, entry.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a repository with nested owner/repo structure
|
||||
*/
|
||||
|
|
@ -292,52 +260,52 @@ export async function handleCommand(
|
|||
case 'help':
|
||||
return {
|
||||
success: true,
|
||||
message: `Available Commands:
|
||||
message: `## Available Commands
|
||||
|
||||
Command Templates (global):
|
||||
/<name> [args] - Invoke a template directly
|
||||
/templates - List all templates
|
||||
/template-add <name> <path> - Add template from file
|
||||
/template-delete <name> - Remove a template
|
||||
### Command Templates (global)
|
||||
- \`/<name> [args]\` - Invoke a template directly
|
||||
- \`/templates\` - List all templates
|
||||
- \`/template-add <name> <path>\` - Add template from file
|
||||
- \`/template-delete <name>\` - Remove a template
|
||||
|
||||
Codebase Commands (per-project):
|
||||
/command-set <name> <path> [text] - Register command
|
||||
/load-commands <folder> - Bulk load (recursive)
|
||||
/command-invoke <name> [args] - Execute
|
||||
/commands - List registered
|
||||
Note: Commands use relative paths (e.g., .archon/commands)
|
||||
### Codebase Commands (per-project)
|
||||
- \`/command-set <name> <path> [text]\` - Register command
|
||||
- \`/load-commands <folder>\` - Bulk load (recursive)
|
||||
- \`/command-invoke <name> [args]\` - Execute
|
||||
- \`/commands\` - List registered
|
||||
- *Commands use relative paths (e.g., .archon/commands)*
|
||||
|
||||
Codebase:
|
||||
/clone <repo-url> - Clone repository
|
||||
/repos - List repositories (numbered)
|
||||
/repo <#|name> [pull] - Switch repo (auto-loads commands)
|
||||
/repo-remove <#|name> - Remove repo and codebase record
|
||||
/getcwd - Show working directory
|
||||
/setcwd <path> - Set directory
|
||||
Note: Use /repo for quick switching, /setcwd for manual paths
|
||||
### Codebase
|
||||
- \`/clone <repo-url>\` - Clone repository
|
||||
- \`/repos\` - List repositories (numbered)
|
||||
- \`/repo <#|name> [pull]\` - Switch repo (auto-loads commands)
|
||||
- \`/repo-remove <#|name>\` - Remove repo and codebase record
|
||||
- \`/getcwd\` - Show working directory
|
||||
- \`/setcwd <path>\` - Set directory
|
||||
- *Use /repo for quick switching, /setcwd for manual paths*
|
||||
|
||||
Worktrees:
|
||||
/worktree create <branch> - Create isolated worktree
|
||||
/worktree list - Show worktrees for this repo
|
||||
/worktree remove [--force] - Remove current worktree
|
||||
/worktree cleanup merged|stale - Clean up worktrees
|
||||
/worktree orphans - Show all worktrees from git
|
||||
### Worktrees
|
||||
- \`/worktree create <branch>\` - Create isolated worktree
|
||||
- \`/worktree list\` - Show worktrees for this repo
|
||||
- \`/worktree remove [--force]\` - Remove current worktree
|
||||
- \`/worktree cleanup merged|stale\` - Clean up worktrees
|
||||
- \`/worktree orphans\` - Show all worktrees from git
|
||||
|
||||
Workflows:
|
||||
/workflow list - Show available workflows
|
||||
/workflow reload - Reload workflow definitions
|
||||
/workflow status - Show running workflow details
|
||||
/workflow cancel - Cancel running workflow
|
||||
Note: Workflows are YAML files in .archon/workflows/
|
||||
### Workflows
|
||||
- \`/workflow list\` - Show available workflows
|
||||
- \`/workflow reload\` - Reload workflow definitions
|
||||
- \`/workflow status\` - Show running workflow details
|
||||
- \`/workflow cancel\` - Cancel running workflow
|
||||
- *Workflows are YAML files in .archon/workflows/*
|
||||
|
||||
Session:
|
||||
/status - Show state
|
||||
/reset - Clear session
|
||||
/reset-context - Reset AI context, keep worktree
|
||||
/help - Show help
|
||||
### Session
|
||||
- \`/status\` - Show state
|
||||
- \`/reset\` - Clear session
|
||||
- \`/reset-context\` - Reset AI context, keep worktree
|
||||
- \`/help\` - Show help
|
||||
|
||||
Setup:
|
||||
/init - Create .archon structure in current repo`,
|
||||
### Setup
|
||||
- \`/init\` - Create .archon structure in current repo`,
|
||||
};
|
||||
|
||||
case 'status': {
|
||||
|
|
@ -506,180 +474,26 @@ Setup:
|
|||
return { success: false, message: 'Usage: /clone <repo-url>' };
|
||||
}
|
||||
|
||||
// Normalize URL: strip trailing slashes
|
||||
const normalizedUrl: string = args[0].replace(/\/+$/, '');
|
||||
|
||||
// Convert SSH URL to HTTPS format if needed
|
||||
// git@github.com:user/repo.git -> https://github.com/user/repo.git
|
||||
let workingUrl = normalizedUrl;
|
||||
if (normalizedUrl.startsWith('git@github.com:')) {
|
||||
workingUrl = normalizedUrl.replace('git@github.com:', 'https://github.com/');
|
||||
}
|
||||
|
||||
// Extract owner and repo from URL
|
||||
// https://github.com/owner/repo.git -> owner, repo
|
||||
const urlParts = workingUrl.replace(/\.git$/, '').split('/');
|
||||
const repoName = urlParts.pop() ?? 'unknown';
|
||||
const ownerName = urlParts.pop() ?? 'unknown';
|
||||
|
||||
// Use Archon workspaces path (ARCHON_HOME/workspaces or ~/.archon/workspaces)
|
||||
// Include owner in path to prevent collisions (e.g., alice/utils vs bob/utils)
|
||||
const workspacePath = getArchonWorkspacesPath();
|
||||
const targetPath = join(workspacePath, ownerName, repoName);
|
||||
|
||||
try {
|
||||
// Check if target directory already exists
|
||||
try {
|
||||
await access(targetPath);
|
||||
const result = await cloneRepository(args[0]);
|
||||
|
||||
// Directory exists - try to find existing codebase by repo URL
|
||||
// Check both with and without .git suffix (per github.ts pattern)
|
||||
const urlNoGit = workingUrl.replace(/\.git$/, '');
|
||||
const urlWithGit = urlNoGit + '.git';
|
||||
|
||||
const existingCodebase =
|
||||
(await codebaseDb.findCodebaseByRepoUrl(urlNoGit)) ??
|
||||
(await codebaseDb.findCodebaseByRepoUrl(urlWithGit));
|
||||
|
||||
if (existingCodebase) {
|
||||
// Link conversation to existing codebase
|
||||
try {
|
||||
await db.updateConversation(conversation.id, {
|
||||
codebase_id: existingCodebase.id,
|
||||
cwd: targetPath,
|
||||
});
|
||||
} catch (updateError) {
|
||||
if (updateError instanceof ConversationNotFoundError) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
'Failed to link existing codebase: conversation state changed. Please try again.',
|
||||
};
|
||||
}
|
||||
throw updateError;
|
||||
}
|
||||
|
||||
// Reset session when switching codebases
|
||||
const session = await sessionDb.getActiveSession(conversation.id);
|
||||
if (session) {
|
||||
await sessionDb.deactivateSession(session.id);
|
||||
console.log(
|
||||
`[Command] Deactivated session: ${getTriggerForCommand('clone') ?? 'codebase-cloned'}`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for command folders (same logic as successful clone)
|
||||
let commandFolder: string | null = null;
|
||||
for (const folder of getCommandFolderSearchPaths()) {
|
||||
try {
|
||||
await access(join(targetPath, folder));
|
||||
commandFolder = folder;
|
||||
break;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
let responseMessage = `Repository already cloned.\n\nLinked to existing codebase: ${existingCodebase.name}\nPath: ${targetPath}\n\nSession reset - starting fresh on next message.`;
|
||||
|
||||
if (commandFolder) {
|
||||
responseMessage += `\n\n📁 Found: ${commandFolder}/\nUse /load-commands ${commandFolder} to register commands.`;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: responseMessage,
|
||||
modified: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Directory exists but no codebase found
|
||||
return {
|
||||
success: false,
|
||||
message: `Directory already exists: ${targetPath}\n\nNo matching codebase found in database. Options:\n- Remove the directory and re-clone\n- Use /setcwd ${targetPath} (limited functionality)`,
|
||||
};
|
||||
} catch {
|
||||
// Directory doesn't exist, proceed with clone
|
||||
}
|
||||
|
||||
console.log(`[Clone] Cloning ${workingUrl} to ${targetPath}`);
|
||||
|
||||
// Build clone command with authentication if GitHub token is available
|
||||
let cloneUrl = workingUrl;
|
||||
const ghToken = process.env.GH_TOKEN;
|
||||
|
||||
if (ghToken && workingUrl.includes('github.com')) {
|
||||
// Inject token into GitHub URL for private repo access
|
||||
// Convert: https://github.com/user/repo.git -> https://token@github.com/user/repo.git
|
||||
if (workingUrl.startsWith('https://github.com')) {
|
||||
cloneUrl = workingUrl.replace('https://github.com', `https://${ghToken}@github.com`);
|
||||
} else if (workingUrl.startsWith('http://github.com')) {
|
||||
cloneUrl = workingUrl.replace('http://github.com', `https://${ghToken}@github.com`);
|
||||
} else if (!workingUrl.startsWith('http')) {
|
||||
// Handle github.com/user/repo format (bare domain)
|
||||
cloneUrl = `https://${ghToken}@${workingUrl}`;
|
||||
}
|
||||
console.log('[Clone] Using authenticated GitHub clone');
|
||||
}
|
||||
|
||||
await execFileAsync('git', ['clone', cloneUrl, targetPath]);
|
||||
|
||||
// Add the cloned repository to git safe.directory to prevent ownership errors
|
||||
// This is needed because we run as non-root user but git might see different ownership
|
||||
await execFileAsync('git', ['config', '--global', '--add', 'safe.directory', targetPath]);
|
||||
console.log(`[Clone] Added ${targetPath} to git safe.directory`);
|
||||
|
||||
// Auto-detect assistant type based on folder structure
|
||||
let suggestedAssistant = 'claude';
|
||||
const codexFolder = join(targetPath, '.codex');
|
||||
const claudeFolder = join(targetPath, '.claude');
|
||||
|
||||
try {
|
||||
await access(codexFolder);
|
||||
suggestedAssistant = 'codex';
|
||||
console.log('[Clone] Detected .codex folder - using Codex assistant');
|
||||
} catch {
|
||||
try {
|
||||
await access(claudeFolder);
|
||||
suggestedAssistant = 'claude';
|
||||
console.log('[Clone] Detected .claude folder - using Claude assistant');
|
||||
} catch {
|
||||
// Default to claude
|
||||
console.log('[Clone] No assistant folder detected - defaulting to Claude');
|
||||
}
|
||||
}
|
||||
|
||||
const codebase = await codebaseDb.createCodebase({
|
||||
name: `${ownerName}/${repoName}`,
|
||||
repository_url: workingUrl,
|
||||
default_cwd: targetPath,
|
||||
ai_assistant_type: suggestedAssistant,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Clone] Updating conversation ${conversation.id} with codebase ${codebase.id}`
|
||||
);
|
||||
// Link conversation to the codebase
|
||||
try {
|
||||
await db.updateConversation(conversation.id, {
|
||||
codebase_id: codebase.id,
|
||||
cwd: targetPath,
|
||||
codebase_id: result.codebaseId,
|
||||
cwd: result.defaultCwd,
|
||||
});
|
||||
} catch (updateError) {
|
||||
if (updateError instanceof ConversationNotFoundError) {
|
||||
console.error('[Clone] Failed to link conversation - state changed unexpectedly', {
|
||||
conversationId: conversation.id,
|
||||
codebaseId: codebase.id,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
'Failed to complete clone: conversation state changed unexpectedly. Please try again.',
|
||||
message: 'Failed to link codebase: conversation state changed. Please try again.',
|
||||
};
|
||||
}
|
||||
throw updateError;
|
||||
}
|
||||
|
||||
// Reset session when cloning a new repository
|
||||
// Reset session when cloning/switching codebases
|
||||
const session = await sessionDb.getActiveSession(conversation.id);
|
||||
if (session) {
|
||||
await sessionDb.deactivateSession(session.id);
|
||||
|
|
@ -688,44 +502,36 @@ Setup:
|
|||
);
|
||||
}
|
||||
|
||||
// Auto-load commands if found (defaults loaded at runtime, not copied)
|
||||
let commandsLoaded = 0;
|
||||
for (const folder of getCommandFolderSearchPaths()) {
|
||||
try {
|
||||
const commandPath = join(targetPath, folder);
|
||||
await access(commandPath);
|
||||
|
||||
const markdownFiles = await findMarkdownFilesRecursive(commandPath);
|
||||
if (markdownFiles.length > 0) {
|
||||
const commands = await codebaseDb.getCodebaseCommands(codebase.id);
|
||||
markdownFiles.forEach(({ commandName, relativePath }) => {
|
||||
commands[commandName] = {
|
||||
path: join(folder, relativePath),
|
||||
description: `From ${folder}`,
|
||||
};
|
||||
});
|
||||
await codebaseDb.updateCodebaseCommands(codebase.id, commands);
|
||||
commandsLoaded = markdownFiles.length;
|
||||
if (result.alreadyExisted) {
|
||||
// Check for command folders
|
||||
let commandFolder: string | null = null;
|
||||
for (const folder of getCommandFolderSearchPaths()) {
|
||||
try {
|
||||
await access(join(result.defaultCwd, folder));
|
||||
commandFolder = folder;
|
||||
break;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} catch {
|
||||
// Folder doesn't exist, try next
|
||||
}
|
||||
|
||||
let responseMessage = `Repository already cloned.\n\nLinked to existing codebase: ${result.name}\nPath: ${result.defaultCwd}\n\nSession reset - starting fresh on next message.`;
|
||||
if (commandFolder) {
|
||||
responseMessage += `\n\n📁 Found: ${commandFolder}/\nUse /load-commands ${commandFolder} to register commands.`;
|
||||
}
|
||||
|
||||
return { success: true, message: responseMessage, modified: true };
|
||||
}
|
||||
|
||||
let responseMessage = `Repository cloned successfully!\n\nRepository: ${repoName}`;
|
||||
if (commandsLoaded > 0) {
|
||||
responseMessage += `\n✓ Loaded ${String(commandsLoaded)} repo commands`;
|
||||
let responseMessage = `Repository cloned successfully!\n\nRepository: ${result.name}`;
|
||||
if (result.commandCount > 0) {
|
||||
responseMessage += `\n✓ Loaded ${String(result.commandCount)} repo commands`;
|
||||
}
|
||||
responseMessage += '\n✓ App defaults available at runtime';
|
||||
responseMessage +=
|
||||
'\n\nSession reset - starting fresh on next message.\n\nYou can now start asking questions about the code.';
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: responseMessage,
|
||||
modified: true,
|
||||
};
|
||||
return { success: true, message: responseMessage, modified: true };
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
const safeErr = sanitizeError(err);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export {
|
|||
type CommandTemplate,
|
||||
type CommandResult,
|
||||
type IPlatformAdapter,
|
||||
type IWebPlatformAdapter,
|
||||
isWebAdapter,
|
||||
type MessageChunk,
|
||||
type IAssistantClient,
|
||||
} from './types';
|
||||
|
|
@ -47,6 +49,7 @@ export * as sessionDb from './db/sessions';
|
|||
export * as commandTemplateDb from './db/command-templates';
|
||||
export * as isolationEnvDb from './db/isolation-environments';
|
||||
export * as workflowDb from './db/workflows';
|
||||
export * as messageDb from './db/messages';
|
||||
|
||||
// Re-export SessionNotFoundError for error handling
|
||||
export { SessionNotFoundError } from './db/sessions';
|
||||
|
|
@ -72,6 +75,9 @@ export {
|
|||
type LoopConfig,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowRun,
|
||||
type WorkflowRunStatus,
|
||||
type WorkflowStepStatus,
|
||||
type ArtifactType,
|
||||
type StepResult,
|
||||
type LoadCommandResult,
|
||||
type WorkflowExecutionResult,
|
||||
|
|
@ -108,6 +114,16 @@ export {
|
|||
logParallelBlockComplete,
|
||||
} from './workflows/logger';
|
||||
|
||||
// Event Emitter
|
||||
export {
|
||||
type WorkflowEmitterEvent,
|
||||
getWorkflowEventEmitter,
|
||||
resetWorkflowEventEmitter,
|
||||
} from './workflows/event-emitter';
|
||||
|
||||
// Workflow Events DB
|
||||
export * as workflowEventDb from './db/workflow-events';
|
||||
|
||||
// =============================================================================
|
||||
// Isolation
|
||||
// =============================================================================
|
||||
|
|
@ -128,6 +144,7 @@ export { handleMessage } from './orchestrator/orchestrator';
|
|||
// Handlers
|
||||
// =============================================================================
|
||||
export { handleCommand, parseCommand } from './handlers/command-handler';
|
||||
export { cloneRepository, registerRepository, type RegisterResult } from './handlers/clone';
|
||||
|
||||
// =============================================================================
|
||||
// Config
|
||||
|
|
@ -168,7 +185,7 @@ export {
|
|||
// =============================================================================
|
||||
|
||||
// Conversation lock
|
||||
export { ConversationLockManager } from './utils/conversation-lock';
|
||||
export { ConversationLockManager, type LockAcquisitionResult } from './utils/conversation-lock';
|
||||
|
||||
// Error formatting
|
||||
export { classifyAndFormatError } from './utils/error-formatter';
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
Conversation,
|
||||
Codebase,
|
||||
ConversationNotFoundError,
|
||||
isWebAdapter,
|
||||
} from '../types';
|
||||
import * as db from '../db/conversations';
|
||||
import * as codebaseDb from '../db/codebases';
|
||||
|
|
@ -38,6 +39,7 @@ import {
|
|||
executeWorkflow,
|
||||
} from '../workflows';
|
||||
import type { WorkflowDefinition, RouterContext } from '../workflows';
|
||||
import * as workflowDb from '../db/workflows';
|
||||
import {
|
||||
cleanupToMakeRoom,
|
||||
getWorktreeStatusBreakdown,
|
||||
|
|
@ -338,6 +340,8 @@ async function resolveIsolation(
|
|||
error: err.message,
|
||||
stack: err.stack,
|
||||
codebaseId: codebase.id,
|
||||
codebaseName: codebase.name,
|
||||
defaultCwd: codebase.default_cwd,
|
||||
});
|
||||
|
||||
await platform.sendMessage(
|
||||
|
|
@ -477,7 +481,13 @@ async function tryWorkflowRouting(
|
|||
? { branchName: ctx.isolationEnv.branch_name, isPrReview, prSha, prBranch }
|
||||
: undefined;
|
||||
|
||||
// executeWorkflow handles its own errors and user messaging
|
||||
// Background dispatch for web platform — workflow runs in a worker conversation
|
||||
if (ctx.platform.getPlatformType() === 'web') {
|
||||
await dispatchBackgroundWorkflow(ctx, workflow, isolationContext);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Inline execution for all other platforms
|
||||
await executeWorkflow(
|
||||
ctx.platform,
|
||||
ctx.conversationId,
|
||||
|
|
@ -493,6 +503,115 @@ async function tryWorkflowRouting(
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a workflow to run in a background worker conversation (web platform only).
|
||||
* Creates a hidden worker conversation, sets up event bridging from worker to parent,
|
||||
* and fires-and-forgets the workflow execution.
|
||||
*/
|
||||
async function dispatchBackgroundWorkflow(
|
||||
ctx: WorkflowRoutingContext,
|
||||
workflow: WorkflowDefinition,
|
||||
isolationContext?: {
|
||||
branchName?: string;
|
||||
isPrReview?: boolean;
|
||||
prSha?: string;
|
||||
prBranch?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
// 1. Generate worker conversation ID
|
||||
const workerPlatformId = `web-worker-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
|
||||
|
||||
// 2. Create worker conversation in DB (inherit context from parent)
|
||||
const workerConv = await db.getOrCreateConversation('web', workerPlatformId);
|
||||
await db.updateConversation(workerConv.id, {
|
||||
cwd: ctx.cwd,
|
||||
codebase_id: ctx.codebaseId ?? null,
|
||||
hidden: true,
|
||||
});
|
||||
|
||||
// 3. Notify parent chat that workflow is dispatching
|
||||
await ctx.platform.sendMessage(
|
||||
ctx.conversationId,
|
||||
`\u{1F680} Dispatching workflow: **${workflow.name}** (background)`
|
||||
);
|
||||
|
||||
// Narrow to web adapter for web-specific operations
|
||||
const webAdapter = isWebAdapter(ctx.platform) ? ctx.platform : null;
|
||||
|
||||
// Send structured dispatch event for Web UI
|
||||
if (webAdapter) {
|
||||
await webAdapter.sendStructuredEvent(ctx.conversationId, {
|
||||
type: 'workflow_dispatch',
|
||||
workerConversationId: workerPlatformId,
|
||||
workflowName: workflow.name,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Set up DB ID mapping for worker (needed for message persistence)
|
||||
if (webAdapter) {
|
||||
webAdapter.setConversationDbId(workerPlatformId, workerConv.id);
|
||||
}
|
||||
|
||||
// 5. Set up event bridge (worker events → parent SSE stream)
|
||||
let unsubscribeBridge: (() => void) | undefined;
|
||||
if (webAdapter) {
|
||||
unsubscribeBridge = webAdapter.setupEventBridge(workerPlatformId, ctx.conversationId);
|
||||
}
|
||||
|
||||
// 6. Fire-and-forget: run workflow in background
|
||||
void (async (): Promise<void> => {
|
||||
try {
|
||||
try {
|
||||
const result = await executeWorkflow(
|
||||
ctx.platform,
|
||||
workerPlatformId,
|
||||
ctx.cwd,
|
||||
workflow,
|
||||
ctx.originalMessage,
|
||||
workerConv.id,
|
||||
ctx.codebaseId,
|
||||
ctx.issueContext,
|
||||
isolationContext
|
||||
);
|
||||
// Store parent link on the workflow run (regardless of success/failure)
|
||||
if (result.workflowRunId) {
|
||||
await workflowDb.updateWorkflowRunParent(result.workflowRunId, ctx.conversationDbId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Orchestrator] Background workflow failed:', {
|
||||
workflowName: workflow.name,
|
||||
workerConversationId: workerPlatformId,
|
||||
error: (error as Error).message,
|
||||
});
|
||||
// Surface error to parent conversation so the user knows
|
||||
await ctx.platform
|
||||
.sendMessage(
|
||||
ctx.conversationId,
|
||||
`Workflow **${workflow.name}** failed: ${(error as Error).message}`
|
||||
)
|
||||
.catch((sendErr: unknown) => {
|
||||
console.error('[Orchestrator] Failed to notify parent of workflow error:', {
|
||||
error: (sendErr as Error).message,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
// Clean up event bridge
|
||||
if (unsubscribeBridge) {
|
||||
unsubscribeBridge();
|
||||
}
|
||||
if (webAdapter) {
|
||||
webAdapter.removeOutputCallback(workerPlatformId);
|
||||
webAdapter.emitLockEvent(workerPlatformId, false);
|
||||
}
|
||||
}
|
||||
} catch (outerError) {
|
||||
console.error('[Orchestrator] Unhandled error in background workflow:', {
|
||||
error: (outerError as Error).message,
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps command content with execution context to signal the AI should execute immediately.
|
||||
* @param commandName - The name of the command being invoked (e.g., 'create-pr')
|
||||
|
|
@ -663,17 +782,32 @@ export async function handleMessage(
|
|||
// Build the user message with workflow args
|
||||
const userMessage = workflowArgs || message;
|
||||
|
||||
// Execute the workflow
|
||||
await executeWorkflow(
|
||||
platform,
|
||||
conversationId,
|
||||
cwd,
|
||||
workflow,
|
||||
userMessage,
|
||||
conversation.id,
|
||||
conversation.codebase_id,
|
||||
issueContext
|
||||
);
|
||||
// Background dispatch for web platform
|
||||
if (platform.getPlatformType() === 'web') {
|
||||
const routingContext: WorkflowRoutingContext = {
|
||||
platform,
|
||||
conversationId,
|
||||
cwd,
|
||||
originalMessage: userMessage,
|
||||
conversationDbId: conversation.id,
|
||||
codebaseId: conversation.codebase_id ?? undefined,
|
||||
availableWorkflows: workflows,
|
||||
issueContext,
|
||||
};
|
||||
await dispatchBackgroundWorkflow(routingContext, workflow);
|
||||
} else {
|
||||
// Inline execution for all other platforms
|
||||
await executeWorkflow(
|
||||
platform,
|
||||
conversationId,
|
||||
cwd,
|
||||
workflow,
|
||||
userMessage,
|
||||
conversation.id,
|
||||
conversation.codebase_id,
|
||||
issueContext
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -1018,8 +1152,18 @@ export async function handleMessage(
|
|||
} else if (msg.type === 'tool' && msg.toolName) {
|
||||
const toolMessage = formatToolCall(msg.toolName, msg.toolInput);
|
||||
await platform.sendMessage(conversationId, toolMessage);
|
||||
|
||||
// Send structured event to adapters that support it (Web UI)
|
||||
if (platform.sendStructuredEvent) {
|
||||
await platform.sendStructuredEvent(conversationId, msg);
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.sessionId) {
|
||||
newSessionId = msg.sessionId;
|
||||
|
||||
// Send session info to adapters that support structured events
|
||||
if (platform.sendStructuredEvent) {
|
||||
await platform.sendStructuredEvent(conversationId, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ export interface Conversation {
|
|||
cwd: string | null;
|
||||
isolation_env_id: string | null; // UUID FK to isolation_environments
|
||||
ai_assistant_type: string;
|
||||
title: string | null;
|
||||
hidden: boolean;
|
||||
deleted_at: Date | null;
|
||||
last_activity_at: Date | null; // For staleness detection
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
|
|
@ -151,21 +154,48 @@ export interface IPlatformAdapter {
|
|||
* Stop the platform adapter gracefully
|
||||
*/
|
||||
stop(): void;
|
||||
|
||||
/**
|
||||
* Optional: Send a structured event (MessageChunk) to the platform.
|
||||
* Only implemented by adapters that can display rich structured data (e.g., Web UI).
|
||||
* Other adapters (Telegram, Slack) continue using sendMessage() for formatted text.
|
||||
*/
|
||||
sendStructuredEvent?(conversationId: string, event: MessageChunk): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message chunk from AI assistant
|
||||
* Extended platform adapter for the Web UI.
|
||||
* Adds methods for SSE event bridging, message persistence, and lock events
|
||||
* that are only meaningful in the web context.
|
||||
*/
|
||||
export interface MessageChunk {
|
||||
type: 'assistant' | 'result' | 'system' | 'tool' | 'thinking';
|
||||
content?: string;
|
||||
sessionId?: string;
|
||||
|
||||
// For tool calls
|
||||
toolName?: string;
|
||||
toolInput?: Record<string, unknown>;
|
||||
export interface IWebPlatformAdapter extends IPlatformAdapter {
|
||||
sendStructuredEvent(conversationId: string, event: MessageChunk): Promise<void>;
|
||||
setConversationDbId(platformConversationId: string, dbId: string): void;
|
||||
setupEventBridge(workerConversationId: string, parentConversationId: string): () => void;
|
||||
emitLockEvent(conversationId: string, locked: boolean, queuePosition?: number): void;
|
||||
registerOutputCallback(conversationId: string, callback: (text: string) => void): void;
|
||||
removeOutputCallback(conversationId: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for web platform adapter.
|
||||
*/
|
||||
export function isWebAdapter(adapter: IPlatformAdapter): adapter is IWebPlatformAdapter {
|
||||
return adapter.getPlatformType() === 'web';
|
||||
}
|
||||
|
||||
/**
|
||||
* Message chunk from AI assistant.
|
||||
* Discriminated union with per-type required fields for type safety.
|
||||
*/
|
||||
export type MessageChunk =
|
||||
| { type: 'assistant'; content: string }
|
||||
| { type: 'system'; content: string }
|
||||
| { type: 'thinking'; content: string }
|
||||
| { type: 'result'; sessionId?: string }
|
||||
| { type: 'tool'; toolName: string; toolInput?: Record<string, unknown> }
|
||||
| { type: 'workflow_dispatch'; workerConversationId: string; workflowName: string };
|
||||
|
||||
/**
|
||||
* Generic AI assistant client interface
|
||||
* Allows supporting multiple AI assistants (Claude, Codex, etc.)
|
||||
|
|
|
|||
37
packages/core/src/utils/commands.ts
Normal file
37
packages/core/src/utils/commands.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Shared command utilities for markdown file discovery.
|
||||
*/
|
||||
import { readdir } from 'fs/promises';
|
||||
import { join, basename } from 'path';
|
||||
|
||||
/**
|
||||
* Recursively find all .md files in a directory and its subdirectories.
|
||||
* Skips hidden directories and node_modules.
|
||||
*/
|
||||
export async function findMarkdownFilesRecursive(
|
||||
rootPath: string,
|
||||
relativePath = ''
|
||||
): Promise<{ commandName: string; relativePath: string }[]> {
|
||||
const results: { commandName: string; relativePath: string }[] = [];
|
||||
const fullPath = join(rootPath, relativePath);
|
||||
|
||||
const entries = await readdir(fullPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.') || entry.name === 'node_modules') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const subResults = await findMarkdownFilesRecursive(rootPath, join(relativePath, entry.name));
|
||||
results.push(...subResults);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
results.push({
|
||||
commandName: basename(entry.name, '.md'),
|
||||
relativePath: join(relativePath, entry.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
|
@ -15,6 +15,13 @@ interface QueuedMessage {
|
|||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of acquiring a lock, indicating whether the message was started or queued
|
||||
*/
|
||||
export interface LockAcquisitionResult {
|
||||
status: 'started' | 'queued-conversation' | 'queued-capacity';
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages conversation locks for concurrent message processing
|
||||
*/
|
||||
|
|
@ -40,11 +47,14 @@ export class ConversationLockManager {
|
|||
* @param conversationId - Unique conversation identifier
|
||||
* @param handler - Async function to execute
|
||||
*/
|
||||
async acquireLock(conversationId: string, handler: () => Promise<void>): Promise<void> {
|
||||
async acquireLock(
|
||||
conversationId: string,
|
||||
handler: () => Promise<void>
|
||||
): Promise<LockAcquisitionResult> {
|
||||
// Check if conversation already active - queue if yes
|
||||
if (this.activeConversations.has(conversationId)) {
|
||||
this.queueMessage(conversationId, handler);
|
||||
return;
|
||||
return { status: 'queued-conversation' };
|
||||
}
|
||||
|
||||
// Check if at max capacity - queue if yes
|
||||
|
|
@ -53,7 +63,7 @@ export class ConversationLockManager {
|
|||
`[ConversationLock] At max capacity (${String(this.maxConcurrent)}), queuing ${conversationId}`
|
||||
);
|
||||
this.queueMessage(conversationId, handler);
|
||||
return;
|
||||
return { status: 'queued-capacity' };
|
||||
}
|
||||
|
||||
// Execute immediately
|
||||
|
|
@ -89,6 +99,7 @@ export class ConversationLockManager {
|
|||
this.activeConversations.set(conversationId, promise);
|
||||
|
||||
// Fire-and-forget: don't await here, return immediately
|
||||
return { status: 'started' };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
237
packages/core/src/workflows/event-emitter.ts
Normal file
237
packages/core/src/workflows/event-emitter.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
/**
|
||||
* WorkflowEventEmitter - typed event emitter for workflow execution observability.
|
||||
*
|
||||
* Lives in @archon/core so the executor can emit events.
|
||||
* The Web adapter in @archon/server subscribes to forward events to SSE streams.
|
||||
*
|
||||
* Design:
|
||||
* - Singleton pattern via getWorkflowEventEmitter()
|
||||
* - Fire-and-forget: listener errors never propagate to the executor
|
||||
* - Conversation-scoped subscriptions via registerRun() mapping
|
||||
*/
|
||||
import { EventEmitter } from 'events';
|
||||
import type { ArtifactType } from './types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface WorkflowStartedEvent {
|
||||
type: 'workflow_started';
|
||||
runId: string;
|
||||
workflowName: string;
|
||||
conversationId: string;
|
||||
totalSteps: number;
|
||||
isLoop: boolean;
|
||||
}
|
||||
|
||||
interface WorkflowCompletedEvent {
|
||||
type: 'workflow_completed';
|
||||
runId: string;
|
||||
workflowName: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface WorkflowFailedEvent {
|
||||
type: 'workflow_failed';
|
||||
runId: string;
|
||||
workflowName: string;
|
||||
error: string;
|
||||
stepIndex?: number;
|
||||
}
|
||||
|
||||
interface StepStartedEvent {
|
||||
type: 'step_started';
|
||||
runId: string;
|
||||
stepIndex: number;
|
||||
stepName: string;
|
||||
totalSteps: number;
|
||||
}
|
||||
|
||||
interface StepCompletedEvent {
|
||||
type: 'step_completed';
|
||||
runId: string;
|
||||
stepIndex: number;
|
||||
stepName: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface StepFailedEvent {
|
||||
type: 'step_failed';
|
||||
runId: string;
|
||||
stepIndex: number;
|
||||
stepName: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface ParallelAgentStartedEvent {
|
||||
type: 'parallel_agent_started';
|
||||
runId: string;
|
||||
stepIndex: number;
|
||||
agentIndex: number;
|
||||
totalAgents: number;
|
||||
agentName: string;
|
||||
}
|
||||
|
||||
interface ParallelAgentCompletedEvent {
|
||||
type: 'parallel_agent_completed';
|
||||
runId: string;
|
||||
stepIndex: number;
|
||||
agentIndex: number;
|
||||
agentName: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface ParallelAgentFailedEvent {
|
||||
type: 'parallel_agent_failed';
|
||||
runId: string;
|
||||
stepIndex: number;
|
||||
agentIndex: number;
|
||||
agentName: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface LoopIterationStartedEvent {
|
||||
type: 'loop_iteration_started';
|
||||
runId: string;
|
||||
iteration: number;
|
||||
maxIterations: number;
|
||||
}
|
||||
|
||||
interface LoopIterationCompletedEvent {
|
||||
type: 'loop_iteration_completed';
|
||||
runId: string;
|
||||
iteration: number;
|
||||
duration: number;
|
||||
completionDetected: boolean;
|
||||
}
|
||||
|
||||
interface WorkflowArtifactEvent {
|
||||
type: 'workflow_artifact';
|
||||
runId: string;
|
||||
artifactType: ArtifactType;
|
||||
label: string;
|
||||
url?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export type WorkflowEmitterEvent =
|
||||
| WorkflowStartedEvent
|
||||
| WorkflowCompletedEvent
|
||||
| WorkflowFailedEvent
|
||||
| StepStartedEvent
|
||||
| StepCompletedEvent
|
||||
| StepFailedEvent
|
||||
| ParallelAgentStartedEvent
|
||||
| ParallelAgentCompletedEvent
|
||||
| ParallelAgentFailedEvent
|
||||
| LoopIterationStartedEvent
|
||||
| LoopIterationCompletedEvent
|
||||
| WorkflowArtifactEvent;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Emitter class
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Listener = (event: WorkflowEmitterEvent) => void;
|
||||
|
||||
const WORKFLOW_EVENT = 'workflow_event';
|
||||
|
||||
class WorkflowEventEmitter {
|
||||
private emitter = new EventEmitter();
|
||||
private conversationMap = new Map<string, string>(); // runId -> conversationId
|
||||
|
||||
constructor() {
|
||||
// Allow many subscribers (adapters, DB persistence, tests, etc.)
|
||||
this.emitter.setMaxListeners(50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a run-to-conversation mapping so subscribers can filter by conversation.
|
||||
*/
|
||||
registerRun(runId: string, conversationId: string): void {
|
||||
this.conversationMap.set(runId, conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the run-to-conversation mapping (called at workflow end).
|
||||
*/
|
||||
unregisterRun(runId: string): void {
|
||||
this.conversationMap.delete(runId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the conversation ID for a given run.
|
||||
*/
|
||||
getConversationId(runId: string): string | undefined {
|
||||
return this.conversationMap.get(runId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a workflow event. Fire-and-forget: listener errors are caught and logged.
|
||||
*/
|
||||
emit(event: WorkflowEmitterEvent): void {
|
||||
try {
|
||||
this.emitter.emit(WORKFLOW_EVENT, event);
|
||||
} catch (error) {
|
||||
console.error('[WorkflowEventEmitter] Error emitting event', {
|
||||
eventType: event.type,
|
||||
error: (error as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to all workflow events. Returns an unsubscribe function.
|
||||
*/
|
||||
subscribe(listener: Listener): () => void {
|
||||
// Wrap listener to catch errors - listener failures must not propagate
|
||||
const safeListener = (event: WorkflowEmitterEvent): void => {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
console.error('[WorkflowEventEmitter] Listener error', {
|
||||
eventType: event.type,
|
||||
error: (error as Error).message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.emitter.on(WORKFLOW_EVENT, safeListener);
|
||||
return (): void => {
|
||||
this.emitter.removeListener(WORKFLOW_EVENT, safeListener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to events for a specific conversation only. Returns unsubscribe function.
|
||||
*/
|
||||
subscribeForConversation(conversationId: string, listener: Listener): () => void {
|
||||
return this.subscribe((event: WorkflowEmitterEvent) => {
|
||||
const eventConversationId = this.conversationMap.get(event.runId);
|
||||
if (eventConversationId === conversationId) {
|
||||
listener(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Singleton
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let instance: WorkflowEventEmitter | null = null;
|
||||
|
||||
export function getWorkflowEventEmitter(): WorkflowEventEmitter {
|
||||
if (!instance) {
|
||||
instance = new WorkflowEventEmitter();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset singleton for testing.
|
||||
*/
|
||||
export function resetWorkflowEventEmitter(): void {
|
||||
instance = null;
|
||||
}
|
||||
|
|
@ -185,7 +185,7 @@ describe('Workflow Executor', () => {
|
|||
// First call should be the workflow start notification
|
||||
expect(calls[0][1]).toContain('🚀 **Starting workflow**: `test-workflow`');
|
||||
expect(calls[0][1]).toContain('A test workflow');
|
||||
expect(calls[0][1]).toContain('`command-one` → `command-two`');
|
||||
// Steps are now shown visually in WorkflowProgressCard, not in the text notification
|
||||
});
|
||||
|
||||
it('should execute each step and send notifications', async () => {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import type {
|
|||
SingleStep,
|
||||
WorkflowExecutionResult,
|
||||
} from './types';
|
||||
import { isParallelBlock, isSingleStep } from './types';
|
||||
import { isParallelBlock } from './types';
|
||||
import {
|
||||
logWorkflowStart,
|
||||
logStepStart,
|
||||
|
|
@ -31,6 +31,8 @@ import {
|
|||
logParallelBlockStart,
|
||||
logParallelBlockComplete,
|
||||
} from './logger';
|
||||
import { getWorkflowEventEmitter } from './event-emitter';
|
||||
import * as workflowEventDb from '../db/workflow-events';
|
||||
|
||||
/** Context for platform message sending */
|
||||
interface SendMessageContext {
|
||||
|
|
@ -101,6 +103,11 @@ function detectCompletionSignal(output: string, signal: string): boolean {
|
|||
return endPattern.test(output) || ownLinePattern.test(output);
|
||||
}
|
||||
|
||||
/** Strip internal completion signal tags before sending to user-facing output. */
|
||||
function stripCompletionTags(content: string): string {
|
||||
return content.replace(/<promise>[\s\S]*?<\/promise>/gi, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error message matches any pattern in the list
|
||||
*/
|
||||
|
|
@ -150,7 +157,7 @@ function logSendError(
|
|||
const UNKNOWN_ERROR_THRESHOLD = 3;
|
||||
|
||||
/** Threshold for consecutive activity update failures before warning user */
|
||||
const ACTIVITY_WARNING_THRESHOLD = 5;
|
||||
const ACTIVITY_WARNING_THRESHOLD = 3;
|
||||
|
||||
/** Mutable counter for tracking consecutive unknown errors across calls */
|
||||
interface UnknownErrorTracker {
|
||||
|
|
@ -520,6 +527,7 @@ async function executeStepInternal(
|
|||
|
||||
console.log(`[WorkflowExecutor] Executing step ${stepId}: ${commandName}`);
|
||||
await logStepStart(cwd, workflowRun.id, commandName, Number(stepId.split('.')[0]));
|
||||
const stepStartTime = Date.now();
|
||||
|
||||
// Load command prompt
|
||||
const promptResult = await loadCommandPrompt(cwd, commandName, configuredCommandFolder);
|
||||
|
|
@ -634,6 +642,11 @@ async function executeStepInternal(
|
|||
unknownErrorTracker
|
||||
);
|
||||
if (!sent) droppedMessageCount++;
|
||||
|
||||
// Send structured event to adapters that support it (Web UI)
|
||||
if (platform.sendStructuredEvent) {
|
||||
await platform.sendStructuredEvent(conversationId, msg);
|
||||
}
|
||||
}
|
||||
await logTool(cwd, workflowRun.id, msg.toolName, msg.toolInput ?? {});
|
||||
} else if (msg.type === 'result' && msg.sessionId) {
|
||||
|
|
@ -672,6 +685,24 @@ async function executeStepInternal(
|
|||
|
||||
await logStepComplete(cwd, workflowRun.id, commandName, Number(stepId.split('.')[0]));
|
||||
|
||||
// Emit step_completed event (fire-and-forget)
|
||||
const emitter = getWorkflowEventEmitter();
|
||||
const stepIdx = Number(stepId.split('.')[0]);
|
||||
emitter.emit({
|
||||
type: 'step_completed',
|
||||
runId: workflowRun.id,
|
||||
stepIndex: stepIdx,
|
||||
stepName: commandName,
|
||||
duration: Date.now() - stepStartTime,
|
||||
});
|
||||
void workflowEventDb.createWorkflowEvent({
|
||||
workflow_run_id: workflowRun.id,
|
||||
event_type: 'step_completed',
|
||||
step_index: stepIdx,
|
||||
step_name: commandName,
|
||||
data: { duration_ms: Date.now() - stepStartTime },
|
||||
});
|
||||
|
||||
return {
|
||||
commandName,
|
||||
success: true,
|
||||
|
|
@ -685,6 +716,24 @@ async function executeStepInternal(
|
|||
errorType,
|
||||
});
|
||||
|
||||
// Emit step_failed event (fire-and-forget)
|
||||
const emitter = getWorkflowEventEmitter();
|
||||
const stepIdx = Number(stepId.split('.')[0]);
|
||||
emitter.emit({
|
||||
type: 'step_failed',
|
||||
runId: workflowRun.id,
|
||||
stepIndex: stepIdx,
|
||||
stepName: commandName,
|
||||
error: err.message,
|
||||
});
|
||||
void workflowEventDb.createWorkflowEvent({
|
||||
workflow_run_id: workflowRun.id,
|
||||
event_type: 'step_failed',
|
||||
step_index: stepIdx,
|
||||
step_name: commandName,
|
||||
data: { error: err.message },
|
||||
});
|
||||
|
||||
// Add user-friendly hints based on error classification
|
||||
let userHint = '';
|
||||
const lowerMessage = err.message.toLowerCase();
|
||||
|
|
@ -758,6 +807,9 @@ async function executeParallelBlock(
|
|||
`[WorkflowExecutor] Starting parallel block with ${String(parallelSteps.length)} agents on ${cwd}`
|
||||
);
|
||||
|
||||
const emitter = getWorkflowEventEmitter();
|
||||
const totalAgents = parallelSteps.length;
|
||||
|
||||
// Spawn all agents concurrently - each gets its own fresh session
|
||||
const results = await Promise.all(
|
||||
parallelSteps.map(async (step, i) => {
|
||||
|
|
@ -765,6 +817,25 @@ async function executeParallelBlock(
|
|||
`[WorkflowExecutor] Spawning agent ${String(blockIndex)}.${String(i)}: ${step.command}`
|
||||
);
|
||||
|
||||
// Emit parallel_agent_started
|
||||
emitter.emit({
|
||||
type: 'parallel_agent_started',
|
||||
runId: workflowRun.id,
|
||||
stepIndex: blockIndex,
|
||||
agentIndex: i,
|
||||
totalAgents,
|
||||
agentName: step.command,
|
||||
});
|
||||
void workflowEventDb.createWorkflowEvent({
|
||||
workflow_run_id: workflowRun.id,
|
||||
event_type: 'parallel_agent_started',
|
||||
step_index: blockIndex,
|
||||
step_name: step.command,
|
||||
data: { agentIndex: i, totalAgents },
|
||||
});
|
||||
|
||||
const agentStart = Date.now();
|
||||
|
||||
// Each parallel step is an independent agent
|
||||
// clearContext is always effectively true (fresh session)
|
||||
const result = await executeStepInternal(
|
||||
|
|
@ -781,6 +852,41 @@ async function executeParallelBlock(
|
|||
issueContext
|
||||
);
|
||||
|
||||
// Emit parallel_agent_completed or parallel_agent_failed
|
||||
if (result.success) {
|
||||
emitter.emit({
|
||||
type: 'parallel_agent_completed',
|
||||
runId: workflowRun.id,
|
||||
stepIndex: blockIndex,
|
||||
agentIndex: i,
|
||||
agentName: step.command,
|
||||
duration: Date.now() - agentStart,
|
||||
});
|
||||
void workflowEventDb.createWorkflowEvent({
|
||||
workflow_run_id: workflowRun.id,
|
||||
event_type: 'parallel_agent_completed',
|
||||
step_index: blockIndex,
|
||||
step_name: step.command,
|
||||
data: { agentIndex: i, duration_ms: Date.now() - agentStart },
|
||||
});
|
||||
} else {
|
||||
emitter.emit({
|
||||
type: 'parallel_agent_failed',
|
||||
runId: workflowRun.id,
|
||||
stepIndex: blockIndex,
|
||||
agentIndex: i,
|
||||
agentName: step.command,
|
||||
error: result.error,
|
||||
});
|
||||
void workflowEventDb.createWorkflowEvent({
|
||||
workflow_run_id: workflowRun.id,
|
||||
event_type: 'parallel_agent_failed',
|
||||
step_index: blockIndex,
|
||||
step_name: step.command,
|
||||
data: { agentIndex: i, error: result.error },
|
||||
});
|
||||
}
|
||||
|
||||
return { index: i, result };
|
||||
})
|
||||
);
|
||||
|
|
@ -848,10 +954,27 @@ async function executeLoopWorkflow(
|
|||
await safeSendMessage(
|
||||
platform,
|
||||
conversationId,
|
||||
`⏳ **Iteration ${String(i)}/${String(loop.max_iterations)}**`,
|
||||
`\n⏳ **Iteration ${String(i)}/${String(loop.max_iterations)}**\n`,
|
||||
workflowContext
|
||||
);
|
||||
|
||||
// Emit loop_iteration_started
|
||||
const loopEmitter = getWorkflowEventEmitter();
|
||||
const iterationStart = Date.now();
|
||||
loopEmitter.emit({
|
||||
type: 'loop_iteration_started',
|
||||
runId: workflowRun.id,
|
||||
iteration: i,
|
||||
maxIterations: loop.max_iterations,
|
||||
});
|
||||
void workflowEventDb.createWorkflowEvent({
|
||||
workflow_run_id: workflowRun.id,
|
||||
event_type: 'loop_iteration_started',
|
||||
step_index: i - 1,
|
||||
step_name: `iteration-${String(i)}`,
|
||||
data: { iteration: i, maxIterations: loop.max_iterations },
|
||||
});
|
||||
|
||||
// Determine session handling
|
||||
const needsFreshSession = loop.fresh_context === true || i === 1;
|
||||
const resumeSessionId = needsFreshSession ? undefined : currentSessionId;
|
||||
|
|
@ -925,20 +1048,21 @@ async function executeLoopWorkflow(
|
|||
}
|
||||
|
||||
if (msg.type === 'assistant' && msg.content) {
|
||||
fullOutput += msg.content;
|
||||
if (streamingMode === 'stream') {
|
||||
fullOutput += msg.content; // Keep raw content for signal detection
|
||||
const cleanedContent = stripCompletionTags(msg.content);
|
||||
if (streamingMode === 'stream' && cleanedContent) {
|
||||
const sent = await safeSendMessage(
|
||||
platform,
|
||||
conversationId,
|
||||
msg.content,
|
||||
cleanedContent,
|
||||
workflowContext,
|
||||
unknownErrorTracker
|
||||
);
|
||||
if (!sent) droppedMessageCount++;
|
||||
} else {
|
||||
assistantMessages.push(msg.content);
|
||||
} else if (streamingMode === 'batch' && cleanedContent) {
|
||||
assistantMessages.push(cleanedContent);
|
||||
}
|
||||
await logAssistant(cwd, workflowRun.id, msg.content);
|
||||
await logAssistant(cwd, workflowRun.id, msg.content); // Log raw for debugging
|
||||
} else if (msg.type === 'tool' && msg.toolName) {
|
||||
if (streamingMode === 'stream') {
|
||||
const toolMessage = formatToolCall(msg.toolName, msg.toolInput);
|
||||
|
|
@ -992,6 +1116,27 @@ async function executeLoopWorkflow(
|
|||
// Check for completion signal
|
||||
if (detectCompletionSignal(fullOutput, loop.until)) {
|
||||
console.log(`[WorkflowExecutor] Completion signal detected at iteration ${String(i)}`);
|
||||
|
||||
// Emit loop_iteration_completed with completionDetected
|
||||
loopEmitter.emit({
|
||||
type: 'loop_iteration_completed',
|
||||
runId: workflowRun.id,
|
||||
iteration: i,
|
||||
duration: Date.now() - iterationStart,
|
||||
completionDetected: true,
|
||||
});
|
||||
void workflowEventDb.createWorkflowEvent({
|
||||
workflow_run_id: workflowRun.id,
|
||||
event_type: 'loop_iteration_completed',
|
||||
step_index: i - 1,
|
||||
step_name: `iteration-${String(i)}`,
|
||||
data: {
|
||||
iteration: i,
|
||||
duration_ms: Date.now() - iterationStart,
|
||||
completionDetected: true,
|
||||
},
|
||||
});
|
||||
|
||||
await workflowDb.completeWorkflowRun(workflowRun.id);
|
||||
await logWorkflowComplete(cwd, workflowRun.id);
|
||||
await sendCriticalMessage(
|
||||
|
|
@ -1014,6 +1159,26 @@ async function executeLoopWorkflow(
|
|||
}
|
||||
|
||||
await logStepComplete(cwd, workflowRun.id, `iteration-${String(i)}`, i - 1);
|
||||
|
||||
// Emit loop_iteration_completed
|
||||
loopEmitter.emit({
|
||||
type: 'loop_iteration_completed',
|
||||
runId: workflowRun.id,
|
||||
iteration: i,
|
||||
duration: Date.now() - iterationStart,
|
||||
completionDetected: false,
|
||||
});
|
||||
void workflowEventDb.createWorkflowEvent({
|
||||
workflow_run_id: workflowRun.id,
|
||||
event_type: 'loop_iteration_completed',
|
||||
step_index: i - 1,
|
||||
step_name: `iteration-${String(i)}`,
|
||||
data: {
|
||||
iteration: i,
|
||||
duration_ms: Date.now() - iterationStart,
|
||||
completionDetected: false,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error(`[WorkflowExecutor] Loop iteration ${String(i)} failed:`, err.message);
|
||||
|
|
@ -1202,6 +1367,43 @@ export async function executeWorkflow(
|
|||
console.log(`[WorkflowExecutor] Starting workflow: ${workflow.name} (${workflowRun.id})`);
|
||||
await logWorkflowStart(cwd, workflowRun.id, workflow.name, userMessage);
|
||||
|
||||
// Register run with emitter and emit workflow_started
|
||||
const emitter = getWorkflowEventEmitter();
|
||||
const workflowStartTime = Date.now();
|
||||
emitter.registerRun(workflowRun.id, conversationId);
|
||||
|
||||
const totalSteps = workflow.steps ? workflow.steps.length : 0;
|
||||
const isLoop = !!workflow.loop;
|
||||
emitter.emit({
|
||||
type: 'workflow_started',
|
||||
runId: workflowRun.id,
|
||||
workflowName: workflow.name,
|
||||
conversationId: conversationDbId,
|
||||
totalSteps,
|
||||
isLoop,
|
||||
});
|
||||
void workflowEventDb.createWorkflowEvent({
|
||||
workflow_run_id: workflowRun.id,
|
||||
event_type: 'workflow_started',
|
||||
data: { workflowName: workflow.name, totalSteps, isLoop },
|
||||
});
|
||||
|
||||
// Set status to running now that execution has started
|
||||
try {
|
||||
await workflowDb.updateWorkflowRun(workflowRun.id, { status: 'running' });
|
||||
} catch (dbError) {
|
||||
console.error('[WorkflowExecutor] Failed to set workflow status to running', {
|
||||
error: (dbError as Error).message,
|
||||
workflowRunId: workflowRun.id,
|
||||
});
|
||||
await sendCriticalMessage(
|
||||
platform,
|
||||
conversationId,
|
||||
'Workflow blocked: Unable to update status. Please try again.'
|
||||
);
|
||||
return { success: false, error: 'Database error setting workflow to running' };
|
||||
}
|
||||
|
||||
// Context for error logging
|
||||
const workflowContext: SendMessageContext = {
|
||||
workflowId: workflowRun.id,
|
||||
|
|
@ -1235,14 +1437,18 @@ export async function executeWorkflow(
|
|||
}
|
||||
}
|
||||
|
||||
// Add workflow start message
|
||||
startupMessage += `🚀 **Starting workflow**: \`${workflow.name}\`\n\n> ${workflow.description}`;
|
||||
|
||||
// Add steps info - use type narrowing from discriminated union
|
||||
const stepsInfo = workflow.steps
|
||||
? `\n\n**Steps**: ${workflow.steps.map(s => (isSingleStep(s) ? `\`${s.command}\`` : `[${String(s.parallel.length)} parallel]`)).join(' → ')}`
|
||||
: `\n\n**Loop**: until \`${workflow.loop.until}\` (max ${String(workflow.loop.max_iterations)} iterations)`;
|
||||
startupMessage += stepsInfo;
|
||||
// Add workflow start message (steps shown visually in WorkflowProgressCard)
|
||||
// Strip routing metadata from description (Use when:, Handles:, NOT for:, Capability:, Triggers:)
|
||||
const cleanDescription = (workflow.description ?? '')
|
||||
.split('\n')
|
||||
.filter(
|
||||
line =>
|
||||
!/^\s*(Use when|Handles|NOT for|Capability|Triggers)[:\s]/i.test(line) && line.trim()
|
||||
)
|
||||
.join('\n')
|
||||
.trim();
|
||||
const descriptionText = cleanDescription || workflow.name;
|
||||
startupMessage += `🚀 **Starting workflow**: \`${workflow.name}\`\n\n> ${descriptionText}`;
|
||||
|
||||
// Send consolidated message - use critical send with limited retries (1 retry max)
|
||||
// to avoid blocking workflow execution while still catching transient failures
|
||||
|
|
@ -1309,6 +1515,22 @@ export async function executeWorkflow(
|
|||
// Log parallel block start
|
||||
await logParallelBlockStart(cwd, workflowRun.id, i, stepCommands);
|
||||
|
||||
// Emit step_started for the parallel block
|
||||
emitter.emit({
|
||||
type: 'step_started',
|
||||
runId: workflowRun.id,
|
||||
stepIndex: i,
|
||||
stepName: `parallel(${stepCommands.join(', ')})`,
|
||||
totalSteps: steps.length,
|
||||
});
|
||||
void workflowEventDb.createWorkflowEvent({
|
||||
workflow_run_id: workflowRun.id,
|
||||
event_type: 'step_started',
|
||||
step_index: i,
|
||||
step_name: `parallel(${stepCommands.join(', ')})`,
|
||||
data: { totalSteps: steps.length, parallelAgents: stepCount },
|
||||
});
|
||||
|
||||
// Notify user
|
||||
const stepNames = parallelSteps.map(s => `\`${s.command}\``).join(', ');
|
||||
await safeSendMessage(
|
||||
|
|
@ -1347,6 +1569,22 @@ export async function executeWorkflow(
|
|||
const errorMsg = `${String(failures.length)} parallel step(s) failed:\n${failureDetails.join('\n')}`;
|
||||
await logWorkflowError(cwd, workflowRun.id, errorMsg);
|
||||
|
||||
// Emit workflow_failed for parallel block failure
|
||||
emitter.emit({
|
||||
type: 'workflow_failed',
|
||||
runId: workflowRun.id,
|
||||
workflowName: workflow.name,
|
||||
error: errorMsg,
|
||||
stepIndex: i,
|
||||
});
|
||||
void workflowEventDb.createWorkflowEvent({
|
||||
workflow_run_id: workflowRun.id,
|
||||
event_type: 'workflow_failed',
|
||||
step_index: i,
|
||||
data: { error: errorMsg },
|
||||
});
|
||||
emitter.unregisterRun(workflowRun.id);
|
||||
|
||||
// Record failure in database (non-critical - log but don't prevent user notification)
|
||||
try {
|
||||
await workflowDb.failWorkflowRun(workflowRun.id, errorMsg);
|
||||
|
|
@ -1377,6 +1615,22 @@ export async function executeWorkflow(
|
|||
}));
|
||||
await logParallelBlockComplete(cwd, workflowRun.id, i, blockResults);
|
||||
|
||||
// Emit step_completed for the parallel block
|
||||
emitter.emit({
|
||||
type: 'step_completed',
|
||||
runId: workflowRun.id,
|
||||
stepIndex: i,
|
||||
stepName: `parallel(${stepCommands.join(', ')})`,
|
||||
duration: 0, // Duration tracked per-agent, not per-block
|
||||
});
|
||||
void workflowEventDb.createWorkflowEvent({
|
||||
workflow_run_id: workflowRun.id,
|
||||
event_type: 'step_completed',
|
||||
step_index: i,
|
||||
step_name: `parallel(${stepCommands.join(', ')})`,
|
||||
data: {},
|
||||
});
|
||||
|
||||
// All parallel steps succeeded - no session to carry forward
|
||||
currentSessionId = undefined;
|
||||
} else {
|
||||
|
|
@ -1395,6 +1649,22 @@ export async function executeWorkflow(
|
|||
);
|
||||
}
|
||||
|
||||
// Emit step_started event
|
||||
emitter.emit({
|
||||
type: 'step_started',
|
||||
runId: workflowRun.id,
|
||||
stepIndex: i,
|
||||
stepName: step.command,
|
||||
totalSteps: steps.length,
|
||||
});
|
||||
void workflowEventDb.createWorkflowEvent({
|
||||
workflow_run_id: workflowRun.id,
|
||||
event_type: 'step_started',
|
||||
step_index: i,
|
||||
step_name: step.command,
|
||||
data: { totalSteps: steps.length },
|
||||
});
|
||||
|
||||
const result = await executeStepInternal(
|
||||
platform,
|
||||
conversationId,
|
||||
|
|
@ -1412,6 +1682,23 @@ export async function executeWorkflow(
|
|||
if (!result.success) {
|
||||
await logWorkflowError(cwd, workflowRun.id, result.error);
|
||||
|
||||
// Emit workflow_failed for step failure
|
||||
emitter.emit({
|
||||
type: 'workflow_failed',
|
||||
runId: workflowRun.id,
|
||||
workflowName: workflow.name,
|
||||
error: result.error,
|
||||
stepIndex: i,
|
||||
});
|
||||
void workflowEventDb.createWorkflowEvent({
|
||||
workflow_run_id: workflowRun.id,
|
||||
event_type: 'workflow_failed',
|
||||
step_index: i,
|
||||
step_name: result.commandName,
|
||||
data: { error: result.error },
|
||||
});
|
||||
emitter.unregisterRun(workflowRun.id);
|
||||
|
||||
// Record failure in database (non-critical - log but don't prevent user notification)
|
||||
try {
|
||||
await workflowDb.failWorkflowRun(workflowRun.id, result.error);
|
||||
|
|
@ -1489,6 +1776,20 @@ export async function executeWorkflow(
|
|||
|
||||
console.log(`[WorkflowExecutor] Workflow completed: ${workflow.name}`);
|
||||
|
||||
// Emit workflow_completed
|
||||
emitter.emit({
|
||||
type: 'workflow_completed',
|
||||
runId: workflowRun.id,
|
||||
workflowName: workflow.name,
|
||||
duration: Date.now() - workflowStartTime,
|
||||
});
|
||||
void workflowEventDb.createWorkflowEvent({
|
||||
workflow_run_id: workflowRun.id,
|
||||
event_type: 'workflow_completed',
|
||||
data: { duration_ms: Date.now() - workflowStartTime },
|
||||
});
|
||||
emitter.unregisterRun(workflowRun.id);
|
||||
|
||||
// Safety net: Commit any artifacts created during workflow but not yet committed
|
||||
await commitWorkflowArtifacts(
|
||||
platform,
|
||||
|
|
@ -1533,6 +1834,21 @@ export async function executeWorkflow(
|
|||
});
|
||||
}
|
||||
|
||||
// Emit workflow_failed event
|
||||
const emitter = getWorkflowEventEmitter();
|
||||
emitter.emit({
|
||||
type: 'workflow_failed',
|
||||
runId: workflowRun.id,
|
||||
workflowName: workflow.name,
|
||||
error: err.message,
|
||||
});
|
||||
void workflowEventDb.createWorkflowEvent({
|
||||
workflow_run_id: workflowRun.id,
|
||||
event_type: 'workflow_failed',
|
||||
data: { error: err.message },
|
||||
});
|
||||
emitter.unregisterRun(workflowRun.id);
|
||||
|
||||
// Notify user about the failure
|
||||
const delivered = await sendCriticalMessage(
|
||||
platform,
|
||||
|
|
@ -1572,6 +1888,23 @@ async function commitWorkflowArtifacts(
|
|||
if (committed) {
|
||||
console.log(`[WorkflowExecutor] Committed remaining artifacts for workflow: ${workflowName}`);
|
||||
|
||||
// Emit workflow_artifact event
|
||||
const emitter = getWorkflowEventEmitter();
|
||||
emitter.emit({
|
||||
type: 'workflow_artifact',
|
||||
runId: workflowId,
|
||||
artifactType: 'commit',
|
||||
label: `Auto-commit workflow artifacts (${workflowName})`,
|
||||
});
|
||||
void workflowEventDb.createWorkflowEvent({
|
||||
workflow_run_id: workflowId,
|
||||
event_type: 'workflow_artifact',
|
||||
data: {
|
||||
artifactType: 'commit',
|
||||
label: `Auto-commit workflow artifacts (${workflowName})`,
|
||||
},
|
||||
});
|
||||
|
||||
// Push the committed artifacts
|
||||
try {
|
||||
await execFileAsync('git', ['-C', cwd, 'push', 'origin', 'HEAD'], { timeout: 30000 });
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@
|
|||
* types to enforce mutual exclusivity between steps and loop at compile time.
|
||||
*/
|
||||
|
||||
export type WorkflowRunStatus = 'pending' | 'running' | 'completed' | 'failed';
|
||||
export type WorkflowStepStatus = 'pending' | 'running' | 'completed' | 'failed';
|
||||
export type ArtifactType = 'pr' | 'commit' | 'file_created' | 'file_modified' | 'branch';
|
||||
|
||||
/**
|
||||
* A single step with a command
|
||||
*/
|
||||
|
|
@ -97,9 +101,10 @@ export interface WorkflowRun {
|
|||
id: string;
|
||||
workflow_name: string;
|
||||
conversation_id: string;
|
||||
parent_conversation_id: string | null;
|
||||
codebase_id: string | null;
|
||||
current_step_index: number;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
status: WorkflowRunStatus;
|
||||
user_message: string; // Original user intent
|
||||
metadata: Record<string, unknown>;
|
||||
started_at: Date;
|
||||
|
|
|
|||
655
packages/server/src/adapters/web.ts
Normal file
655
packages/server/src/adapters/web.ts
Normal file
|
|
@ -0,0 +1,655 @@
|
|||
/**
|
||||
* Web platform adapter implementing IPlatformAdapter with SSE stream management.
|
||||
* Bridge between the orchestrator and the React frontend via Server-Sent Events.
|
||||
*/
|
||||
import type { IWebPlatformAdapter, MessageChunk, WorkflowEmitterEvent } from '@archon/core';
|
||||
import { getWorkflowEventEmitter } from '@archon/core';
|
||||
|
||||
interface SSEWriter {
|
||||
writeSSE(data: { data: string; event?: string; id?: string }): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
readonly closed: boolean;
|
||||
}
|
||||
|
||||
export class WebAdapter implements IWebPlatformAdapter {
|
||||
private streams = new Map<string, SSEWriter>();
|
||||
private messageBuffer = new Map<string, string[]>();
|
||||
private assistantBuffer = new Map<
|
||||
string,
|
||||
{
|
||||
segments: {
|
||||
content: string;
|
||||
toolCalls: {
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
startedAt: number;
|
||||
duration?: number;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
>();
|
||||
private dbIdMap = new Map<string, string>(); // platform_conversation_id → DB UUID
|
||||
private dispatchBuffer = new Map<
|
||||
string,
|
||||
{ workerConversationId: string; workflowName: string }
|
||||
>();
|
||||
private unsubscribeWorkflowEvents: (() => void) | null = null;
|
||||
private outputCallbacks = new Map<string, (text: string) => void>();
|
||||
private cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
private zombieReaperHandle: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
* Register an SSE stream for a conversation.
|
||||
* Closes any existing stream (browser refresh / new tab replaces old).
|
||||
*/
|
||||
registerStream(conversationId: string, stream: SSEWriter): void {
|
||||
const existing = this.streams.get(conversationId);
|
||||
if (existing && !existing.closed) {
|
||||
existing.close().catch((e: unknown) => {
|
||||
console.warn('[Web] SSE write failed', { conversationId, error: (e as Error).message });
|
||||
});
|
||||
}
|
||||
this.streams.set(conversationId, stream);
|
||||
|
||||
// Cancel pending cleanup — client reconnected
|
||||
const pendingCleanup = this.cleanupTimers.get(conversationId);
|
||||
if (pendingCleanup) {
|
||||
clearTimeout(pendingCleanup);
|
||||
this.cleanupTimers.delete(conversationId);
|
||||
}
|
||||
|
||||
// Flush buffered events
|
||||
const buffered = this.messageBuffer.get(conversationId);
|
||||
if (buffered) {
|
||||
this.messageBuffer.delete(conversationId);
|
||||
void this.flushBufferedMessages(conversationId, stream, buffered);
|
||||
}
|
||||
}
|
||||
|
||||
removeStream(conversationId: string): void {
|
||||
this.streams.delete(conversationId);
|
||||
// Schedule buffer cleanup after delay (allows reconnection without data loss)
|
||||
this.scheduleCleanup(conversationId, 60_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a platform conversation ID to its database UUID for message persistence.
|
||||
*/
|
||||
setConversationDbId(platformConversationId: string, dbId: string): void {
|
||||
this.dbIdMap.set(platformConversationId, dbId);
|
||||
}
|
||||
|
||||
async sendMessage(conversationId: string, message: string): Promise<void> {
|
||||
// Skip formatted tool call text - Web adapter gets structured data via sendStructuredEvent()
|
||||
if (message.startsWith('\u{1F527}')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip isolation context line (📍) - cwd is shown in the header already.
|
||||
// The executor sends it combined with the 🚀 workflow start, so strip just
|
||||
// the 📍 line and keep the rest (workflow name + description).
|
||||
if (message.startsWith('\u{1F4CD}')) {
|
||||
const stripped = message.replace(/^\u{1F4CD}[^\n]*\n\n?/u, '');
|
||||
if (!stripped.trim()) return;
|
||||
message = stripped;
|
||||
}
|
||||
|
||||
// Buffer assistant text for persistence (segment-based to preserve message structure)
|
||||
const buf = this.assistantBuffer.get(conversationId) ?? { segments: [] };
|
||||
const lastSeg = buf.segments[buf.segments.length - 1];
|
||||
const isWorkflowStatus = /^[\u{1F680}\u{2705}]/u.test(message);
|
||||
|
||||
// Start a new segment when:
|
||||
// 1. No segments yet
|
||||
// 2. Previous segment has tool calls (text after tool = new message in live view)
|
||||
// 3. This is a workflow status message (🚀/✅ should be its own bubble)
|
||||
// 4. Previous segment was a workflow status (next text should be separate)
|
||||
const needsNewSegment =
|
||||
!lastSeg ||
|
||||
lastSeg.toolCalls.length > 0 ||
|
||||
isWorkflowStatus ||
|
||||
/^[\u{1F680}\u{2705}]/u.test(lastSeg.content);
|
||||
|
||||
if (needsNewSegment) {
|
||||
buf.segments.push({ content: message, toolCalls: [] });
|
||||
} else {
|
||||
lastSeg.content += message;
|
||||
}
|
||||
this.assistantBuffer.set(conversationId, buf);
|
||||
|
||||
// Prevent unbounded buffer growth — force flush if too many segments
|
||||
if (buf.segments.length > 50) {
|
||||
console.warn('[Web] Assistant buffer overflow, force-flushing', {
|
||||
conversationId,
|
||||
segments: buf.segments.length,
|
||||
});
|
||||
void this.flushAssistantMessage(conversationId);
|
||||
}
|
||||
|
||||
const event = JSON.stringify({
|
||||
type: 'text',
|
||||
content: message,
|
||||
isComplete: true,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Forward output to registered callback (for event bridge preview)
|
||||
const callback = this.outputCallbacks.get(conversationId);
|
||||
if (callback) {
|
||||
try {
|
||||
callback(message);
|
||||
} catch (e: unknown) {
|
||||
console.warn('[Web] Output callback failed', {
|
||||
conversationId,
|
||||
error: (e as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.emitSSE(conversationId, event);
|
||||
}
|
||||
|
||||
async sendStructuredEvent(conversationId: string, chunk: MessageChunk): Promise<void> {
|
||||
let event: string;
|
||||
|
||||
if (chunk.type === 'tool' && chunk.toolName) {
|
||||
// Buffer tool call for persistence (add to current segment)
|
||||
const buf = this.assistantBuffer.get(conversationId) ?? { segments: [] };
|
||||
if (buf.segments.length === 0) {
|
||||
buf.segments.push({ content: '', toolCalls: [] });
|
||||
}
|
||||
const lastSeg = buf.segments[buf.segments.length - 1];
|
||||
// Finalize duration on previous running tool (agent moved on to next tool)
|
||||
const now = Date.now();
|
||||
const prevTool = lastSeg.toolCalls[lastSeg.toolCalls.length - 1];
|
||||
if (prevTool && prevTool.duration === undefined) {
|
||||
prevTool.duration = now - prevTool.startedAt;
|
||||
}
|
||||
lastSeg.toolCalls.push({
|
||||
name: chunk.toolName,
|
||||
input: chunk.toolInput ?? {},
|
||||
startedAt: now,
|
||||
});
|
||||
this.assistantBuffer.set(conversationId, buf);
|
||||
|
||||
event = JSON.stringify({
|
||||
type: 'tool_call',
|
||||
name: chunk.toolName,
|
||||
input: chunk.toolInput ?? {},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else if (chunk.type === 'result' && chunk.sessionId) {
|
||||
event = JSON.stringify({
|
||||
type: 'session_info',
|
||||
sessionId: chunk.sessionId,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else if (chunk.type === 'workflow_dispatch') {
|
||||
event = JSON.stringify({
|
||||
type: 'workflow_dispatch',
|
||||
workerConversationId: chunk.workerConversationId,
|
||||
workflowName: chunk.workflowName,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
// Buffer dispatch for persistence
|
||||
this.dispatchBuffer.set(conversationId, {
|
||||
workerConversationId: chunk.workerConversationId,
|
||||
workflowName: chunk.workflowName,
|
||||
});
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.emitSSE(conversationId, event);
|
||||
}
|
||||
|
||||
async ensureThread(originalConversationId: string): Promise<string> {
|
||||
return originalConversationId;
|
||||
}
|
||||
|
||||
getStreamingMode(): 'stream' | 'batch' {
|
||||
return 'stream';
|
||||
}
|
||||
|
||||
getPlatformType(): string {
|
||||
return 'web';
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.subscribeToWorkflowEvents();
|
||||
|
||||
// Reap zombie streams every 5 minutes
|
||||
this.zombieReaperHandle = setInterval(() => {
|
||||
for (const [id, stream] of this.streams) {
|
||||
if (stream.closed) {
|
||||
this.removeStream(id);
|
||||
}
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
console.log('[Web] Web adapter ready');
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// Stop zombie stream reaper
|
||||
if (this.zombieReaperHandle) {
|
||||
clearInterval(this.zombieReaperHandle);
|
||||
this.zombieReaperHandle = null;
|
||||
}
|
||||
|
||||
// Unsubscribe from workflow events
|
||||
if (this.unsubscribeWorkflowEvents) {
|
||||
this.unsubscribeWorkflowEvents();
|
||||
this.unsubscribeWorkflowEvents = null;
|
||||
}
|
||||
|
||||
for (const [id, stream] of this.streams) {
|
||||
if (!stream.closed) {
|
||||
stream.close().catch((e: unknown) => {
|
||||
console.warn('[Web] SSE write failed', {
|
||||
conversationId: id,
|
||||
error: (e as Error).message,
|
||||
});
|
||||
});
|
||||
}
|
||||
console.log(`[Web] Closed stream for ${id}`);
|
||||
}
|
||||
this.streams.clear();
|
||||
this.messageBuffer.clear();
|
||||
for (const timer of this.cleanupTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.cleanupTimers.clear();
|
||||
console.log('[Web] Web adapter stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a lock event to the SSE stream for a conversation.
|
||||
* Called by API routes based on acquireLock() return status.
|
||||
*/
|
||||
emitLockEvent(conversationId: string, locked: boolean, queuePosition?: number): void {
|
||||
if (!locked) {
|
||||
void this.flushAssistantMessage(conversationId);
|
||||
}
|
||||
|
||||
const event = JSON.stringify({
|
||||
type: 'conversation_lock',
|
||||
conversationId,
|
||||
locked,
|
||||
queuePosition,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const stream = this.streams.get(conversationId);
|
||||
if (stream && !stream.closed) {
|
||||
stream.writeSSE({ data: event }).catch((e: unknown) => {
|
||||
console.warn('[Web] Critical SSE write failed, buffering for reconnect', {
|
||||
conversationId,
|
||||
error: (e as Error).message,
|
||||
});
|
||||
this.bufferMessage(conversationId, event);
|
||||
this.streams.delete(conversationId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hasActiveStream(conversationId: string): boolean {
|
||||
const stream = this.streams.get(conversationId);
|
||||
return stream !== undefined && !stream.closed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to WorkflowEventEmitter and forward events to SSE streams.
|
||||
*/
|
||||
private subscribeToWorkflowEvents(): void {
|
||||
const emitter = getWorkflowEventEmitter();
|
||||
this.unsubscribeWorkflowEvents = emitter.subscribe((event: WorkflowEmitterEvent) => {
|
||||
const conversationId = emitter.getConversationId(event.runId);
|
||||
if (!conversationId) return;
|
||||
|
||||
const sseEvent = this.mapWorkflowEvent(event);
|
||||
if (sseEvent) {
|
||||
this.emitWorkflowEvent(conversationId, sseEvent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a WorkflowEmitterEvent to an SSE event JSON string.
|
||||
*/
|
||||
private mapWorkflowEvent(event: WorkflowEmitterEvent): string | null {
|
||||
switch (event.type) {
|
||||
case 'workflow_started':
|
||||
case 'workflow_completed':
|
||||
case 'workflow_failed':
|
||||
return JSON.stringify({
|
||||
type: 'workflow_status',
|
||||
runId: event.runId,
|
||||
workflowName: event.workflowName,
|
||||
status:
|
||||
event.type === 'workflow_started'
|
||||
? 'running'
|
||||
: event.type === 'workflow_completed'
|
||||
? 'completed'
|
||||
: 'failed',
|
||||
error: event.type === 'workflow_failed' ? event.error : undefined,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
case 'step_started':
|
||||
return JSON.stringify({
|
||||
type: 'workflow_step',
|
||||
runId: event.runId,
|
||||
step: event.stepIndex,
|
||||
total: event.totalSteps,
|
||||
name: event.stepName,
|
||||
status: 'running',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
case 'step_completed':
|
||||
return JSON.stringify({
|
||||
type: 'workflow_step',
|
||||
runId: event.runId,
|
||||
step: event.stepIndex,
|
||||
total: 0,
|
||||
name: event.stepName,
|
||||
status: 'completed',
|
||||
duration: event.duration,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
case 'step_failed':
|
||||
return JSON.stringify({
|
||||
type: 'workflow_step',
|
||||
runId: event.runId,
|
||||
step: event.stepIndex,
|
||||
total: 0,
|
||||
name: event.stepName,
|
||||
status: 'failed',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
case 'parallel_agent_started':
|
||||
case 'parallel_agent_completed':
|
||||
case 'parallel_agent_failed':
|
||||
return JSON.stringify({
|
||||
type: 'parallel_agent',
|
||||
runId: event.runId,
|
||||
step: event.stepIndex,
|
||||
agentIndex: event.agentIndex,
|
||||
totalAgents: event.type === 'parallel_agent_started' ? event.totalAgents : 0,
|
||||
name: event.agentName,
|
||||
status:
|
||||
event.type === 'parallel_agent_started'
|
||||
? 'running'
|
||||
: event.type === 'parallel_agent_completed'
|
||||
? 'completed'
|
||||
: 'failed',
|
||||
duration: event.type === 'parallel_agent_completed' ? event.duration : undefined,
|
||||
error: event.type === 'parallel_agent_failed' ? event.error : undefined,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
case 'loop_iteration_started':
|
||||
return JSON.stringify({
|
||||
type: 'workflow_step',
|
||||
runId: event.runId,
|
||||
step: event.iteration - 1,
|
||||
total: event.maxIterations,
|
||||
name: `iteration-${String(event.iteration)}`,
|
||||
status: 'running',
|
||||
iteration: event.iteration,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
case 'loop_iteration_completed':
|
||||
return JSON.stringify({
|
||||
type: 'workflow_step',
|
||||
runId: event.runId,
|
||||
step: event.iteration - 1,
|
||||
total: 0,
|
||||
name: `iteration-${String(event.iteration)}`,
|
||||
status: 'completed',
|
||||
duration: event.duration,
|
||||
iteration: event.iteration,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
case 'workflow_artifact':
|
||||
return JSON.stringify({
|
||||
type: 'workflow_artifact',
|
||||
runId: event.runId,
|
||||
artifactType: event.artifactType,
|
||||
label: event.label,
|
||||
url: event.url,
|
||||
path: event.path,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
default: {
|
||||
const exhaustiveCheck: never = event;
|
||||
console.warn('[Web] Unhandled workflow event type', {
|
||||
type: (exhaustiveCheck as { type: string }).type,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a workflow event to the SSE stream for a conversation. Fire-and-forget.
|
||||
*/
|
||||
private emitWorkflowEvent(conversationId: string, event: string): void {
|
||||
const stream = this.streams.get(conversationId);
|
||||
if (stream && !stream.closed) {
|
||||
stream.writeSSE({ data: event }).catch((e: unknown) => {
|
||||
console.warn('[Web] SSE workflow event write failed, buffering for reconnect', {
|
||||
conversationId,
|
||||
error: (e as Error).message,
|
||||
});
|
||||
this.bufferMessage(conversationId, event);
|
||||
this.streams.delete(conversationId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush buffered assistant segments to the database as individual message rows.
|
||||
* Each segment maps to one ChatMessage in the frontend, preserving the same
|
||||
* structure as the live streaming view (text+tools interleaving).
|
||||
*/
|
||||
private async flushAssistantMessage(conversationId: string): Promise<void> {
|
||||
const buf = this.assistantBuffer.get(conversationId);
|
||||
this.assistantBuffer.delete(conversationId);
|
||||
if (!buf || buf.segments.length === 0) return;
|
||||
|
||||
const dbId = this.dbIdMap.get(conversationId);
|
||||
if (!dbId) {
|
||||
console.warn('[Web] Cannot persist assistant message: no DB ID mapping', {
|
||||
conversationId,
|
||||
segmentCount: buf.segments.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Finalize any remaining tool durations (last tool in each segment)
|
||||
const now = Date.now();
|
||||
for (const seg of buf.segments) {
|
||||
const lastTool = seg.toolCalls[seg.toolCalls.length - 1];
|
||||
if (lastTool && lastTool.duration === undefined) {
|
||||
lastTool.duration = now - lastTool.startedAt;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { addMessage } = await import('@archon/core/db/messages');
|
||||
for (const seg of buf.segments) {
|
||||
if (!seg.content && seg.toolCalls.length === 0) continue;
|
||||
// Store tool calls with name, input, duration (strip startedAt - not needed)
|
||||
const toolCalls = seg.toolCalls.map(tc => ({
|
||||
name: tc.name,
|
||||
input: tc.input,
|
||||
duration: tc.duration,
|
||||
}));
|
||||
const isDispatchMsg = seg.content.startsWith('\u{1F680}');
|
||||
const dispatch = isDispatchMsg ? this.dispatchBuffer.get(conversationId) : undefined;
|
||||
const metadata = {
|
||||
...(toolCalls.length > 0 ? { toolCalls } : {}),
|
||||
...(dispatch ? { workflowDispatch: dispatch } : {}),
|
||||
};
|
||||
await addMessage(dbId, 'assistant', seg.content, metadata);
|
||||
}
|
||||
this.dispatchBuffer.delete(conversationId);
|
||||
} catch (e: unknown) {
|
||||
console.error('[Web] Message persistence failed (data loss)', {
|
||||
conversationId,
|
||||
error: (e as Error).message,
|
||||
});
|
||||
void this.emitSSE(
|
||||
conversationId,
|
||||
JSON.stringify({
|
||||
type: 'warning',
|
||||
message: 'Assistant response could not be saved to history',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge workflow events from a worker conversation to a parent conversation's SSE stream.
|
||||
* Forwards compact progress events (step progress, status) and output previews.
|
||||
*/
|
||||
setupEventBridge(workerConversationId: string, parentConversationId: string): () => void {
|
||||
const emitter = getWorkflowEventEmitter();
|
||||
|
||||
const unsubscribe = emitter.subscribeForConversation(
|
||||
workerConversationId,
|
||||
(event: WorkflowEmitterEvent) => {
|
||||
const sseEvent = this.mapWorkflowEvent(event);
|
||||
if (sseEvent) {
|
||||
// Send to parent's stream (not worker's)
|
||||
const parentStream = this.streams.get(parentConversationId);
|
||||
if (parentStream && !parentStream.closed) {
|
||||
parentStream.writeSSE({ data: sseEvent }).catch((e: unknown) => {
|
||||
console.warn('[Web] SSE bridge write failed, buffering for reconnect', {
|
||||
conversationId: parentConversationId,
|
||||
error: (e as Error).message,
|
||||
});
|
||||
this.bufferMessage(parentConversationId, sseEvent);
|
||||
this.streams.delete(parentConversationId);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}
|
||||
|
||||
registerOutputCallback(conversationId: string, callback: (text: string) => void): void {
|
||||
this.outputCallbacks.set(conversationId, callback);
|
||||
}
|
||||
|
||||
removeOutputCallback(conversationId: string): void {
|
||||
this.outputCallbacks.delete(conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush buffered messages to a newly connected stream.
|
||||
* Stops on first write failure and re-buffers the remaining messages.
|
||||
*/
|
||||
private async flushBufferedMessages(
|
||||
conversationId: string,
|
||||
stream: SSEWriter,
|
||||
messages: string[]
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
try {
|
||||
await stream.writeSSE({ data: messages[i] });
|
||||
} catch (e: unknown) {
|
||||
console.warn('[Web] SSE flush failed, re-buffering remaining messages', {
|
||||
conversationId,
|
||||
error: (e as Error).message,
|
||||
flushed: i,
|
||||
remaining: messages.length - i,
|
||||
});
|
||||
const remaining = messages.slice(i);
|
||||
for (const msg of remaining) {
|
||||
this.bufferMessage(conversationId, msg);
|
||||
}
|
||||
this.streams.delete(conversationId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule cleanup of all buffers for a conversation after a delay.
|
||||
* If the client reconnects before the timer fires, the cleanup is cancelled.
|
||||
*/
|
||||
private scheduleCleanup(conversationId: string, delayMs: number): void {
|
||||
// Cancel any existing timer for this conversation
|
||||
const existing = this.cleanupTimers.get(conversationId);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
this.cleanupTimers.delete(conversationId);
|
||||
// Only clean up if stream is still absent (client didn't reconnect)
|
||||
if (!this.streams.has(conversationId)) {
|
||||
this.messageBuffer.delete(conversationId);
|
||||
this.assistantBuffer.delete(conversationId);
|
||||
this.dispatchBuffer.delete(conversationId);
|
||||
this.dbIdMap.delete(conversationId);
|
||||
this.outputCallbacks.delete(conversationId);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn('[Web] Cleanup timer failed', {
|
||||
conversationId,
|
||||
error: (e as Error).message,
|
||||
});
|
||||
}
|
||||
}, delayMs);
|
||||
|
||||
this.cleanupTimers.set(conversationId, timer);
|
||||
}
|
||||
|
||||
async emitSSE(conversationId: string, event: string): Promise<void> {
|
||||
const stream = this.streams.get(conversationId);
|
||||
if (stream && !stream.closed) {
|
||||
try {
|
||||
await stream.writeSSE({ data: event });
|
||||
} catch (e: unknown) {
|
||||
console.warn('[Web] SSE write failed, buffering event', {
|
||||
conversationId,
|
||||
error: (e as Error).message,
|
||||
});
|
||||
this.removeStream(conversationId);
|
||||
this.bufferMessage(conversationId, event);
|
||||
}
|
||||
} else {
|
||||
if (stream?.closed) {
|
||||
this.removeStream(conversationId);
|
||||
}
|
||||
this.bufferMessage(conversationId, event);
|
||||
}
|
||||
}
|
||||
|
||||
private bufferMessage(conversationId: string, event: string): void {
|
||||
if (!this.messageBuffer.has(conversationId) && this.messageBuffer.size > 200) {
|
||||
console.warn('[Web] Too many buffered conversations, skipping buffer', { conversationId });
|
||||
return;
|
||||
}
|
||||
const buffer = this.messageBuffer.get(conversationId) ?? [];
|
||||
buffer.push(event);
|
||||
this.messageBuffer.set(conversationId, buffer);
|
||||
// Cap buffer size to prevent memory leaks
|
||||
if (buffer.length > 100) {
|
||||
console.warn('[Web] Message buffer overflow, dropping oldest event', { conversationId });
|
||||
buffer.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,9 +10,11 @@ import 'dotenv/config';
|
|||
import { Hono } from 'hono';
|
||||
import { TelegramAdapter } from './adapters/telegram';
|
||||
import { TestAdapter } from './adapters/test';
|
||||
import { WebAdapter } from './adapters/web';
|
||||
import { GitHubAdapter } from './adapters/github';
|
||||
import { DiscordAdapter } from './adapters/discord';
|
||||
import { SlackAdapter } from './adapters/slack';
|
||||
import { registerApiRoutes } from './routes/api';
|
||||
import {
|
||||
handleMessage,
|
||||
pool,
|
||||
|
|
@ -107,18 +109,21 @@ async function main(): Promise<void> {
|
|||
const testAdapter = new TestAdapter();
|
||||
await testAdapter.start();
|
||||
|
||||
// Initialize web adapter (always enabled)
|
||||
const webAdapter = new WebAdapter();
|
||||
await webAdapter.start();
|
||||
|
||||
// Check that at least one platform is configured
|
||||
const hasTelegram = Boolean(process.env.TELEGRAM_BOT_TOKEN);
|
||||
const hasDiscord = Boolean(process.env.DISCORD_BOT_TOKEN);
|
||||
const hasGitHub = Boolean(process.env.GITHUB_TOKEN && process.env.WEBHOOK_SECRET);
|
||||
|
||||
if (!hasTelegram && !hasDiscord && !hasGitHub) {
|
||||
console.error('[App] No platform adapters configured.');
|
||||
console.error('[App] You must configure at least one platform:');
|
||||
console.error('[App] - Telegram: Set TELEGRAM_BOT_TOKEN');
|
||||
console.error('[App] - Discord: Set DISCORD_BOT_TOKEN');
|
||||
console.error('[App] - GitHub: Set GITHUB_TOKEN and WEBHOOK_SECRET');
|
||||
process.exit(1);
|
||||
console.warn('[App] No platform adapters configured.');
|
||||
console.warn('[App] Web UI is available. To enable other platforms:');
|
||||
console.warn('[App] - Telegram: Set TELEGRAM_BOT_TOKEN');
|
||||
console.warn('[App] - Discord: Set DISCORD_BOT_TOKEN');
|
||||
console.warn('[App] - GitHub: Set GITHUB_TOKEN and WEBHOOK_SECRET');
|
||||
}
|
||||
|
||||
// Initialize GitHub adapter (conditional)
|
||||
|
|
@ -274,6 +279,9 @@ async function main(): Promise<void> {
|
|||
return c.json({ error: 'Internal server error' }, 500);
|
||||
});
|
||||
|
||||
// Register Web UI API routes
|
||||
registerApiRoutes(app, webAdapter, lockManager);
|
||||
|
||||
// GitHub webhook endpoint
|
||||
if (github) {
|
||||
app.post('/webhooks/github', async c => {
|
||||
|
|
@ -396,9 +404,26 @@ async function main(): Promise<void> {
|
|||
return c.json({ success: true, mode });
|
||||
});
|
||||
|
||||
// Serve web UI static files in production
|
||||
// Uses import.meta.dir for absolute path (CWD varies with bun --filter)
|
||||
if (process.env.NODE_ENV === 'production' || !process.env.WEB_UI_DEV) {
|
||||
const { serveStatic } = await import('hono/bun');
|
||||
const pathModule = await import('path');
|
||||
const webDistPath = pathModule.join(
|
||||
pathModule.dirname(pathModule.dirname(import.meta.dir)),
|
||||
'web',
|
||||
'dist'
|
||||
);
|
||||
|
||||
app.use('/assets/*', serveStatic({ root: webDistPath }));
|
||||
// SPA fallback - serve index.html for unmatched routes (after all API routes)
|
||||
app.get('*', serveStatic({ root: webDistPath, path: 'index.html' }));
|
||||
}
|
||||
|
||||
const server = Bun.serve({
|
||||
fetch: app.fetch,
|
||||
port,
|
||||
idleTimeout: 255, // Max value (seconds) - prevents SSE connections from being killed
|
||||
});
|
||||
console.log(`[Hono] Server listening on port ${String(server.port)}`);
|
||||
|
||||
|
|
@ -434,6 +459,7 @@ async function main(): Promise<void> {
|
|||
telegram?.stop();
|
||||
discord?.stop();
|
||||
slack?.stop();
|
||||
webAdapter.stop();
|
||||
} catch (error) {
|
||||
console.error('[App] Error stopping adapters:', error);
|
||||
}
|
||||
|
|
@ -454,7 +480,7 @@ async function main(): Promise<void> {
|
|||
process.once('SIGTERM', shutdown);
|
||||
|
||||
// Show active platforms
|
||||
const activePlatforms = [];
|
||||
const activePlatforms = ['Web'];
|
||||
if (telegram) activePlatforms.push('Telegram');
|
||||
if (discord) activePlatforms.push('Discord');
|
||||
if (slack) activePlatforms.push('Slack');
|
||||
|
|
|
|||
612
packages/server/src/routes/api.ts
Normal file
612
packages/server/src/routes/api.ts
Normal file
|
|
@ -0,0 +1,612 @@
|
|||
/**
|
||||
* REST API routes for the Archon Web UI.
|
||||
* Provides conversation, codebase, and SSE streaming endpoints.
|
||||
*/
|
||||
import type { Hono } from 'hono';
|
||||
import { streamSSE } from 'hono/streaming';
|
||||
import { cors } from 'hono/cors';
|
||||
import type { WebAdapter } from '../adapters/web';
|
||||
import { rm } from 'fs/promises';
|
||||
import { normalize } from 'path';
|
||||
import type { Context } from 'hono';
|
||||
import type { ConversationLockManager } from '@archon/core';
|
||||
import {
|
||||
handleMessage,
|
||||
getDatabaseType,
|
||||
loadConfig,
|
||||
discoverWorkflows,
|
||||
cloneRepository,
|
||||
registerRepository,
|
||||
removeWorktree,
|
||||
ConversationNotFoundError,
|
||||
getArchonWorkspacesPath,
|
||||
} from '@archon/core';
|
||||
import * as conversationDb from '@archon/core/db/conversations';
|
||||
import * as codebaseDb from '@archon/core/db/codebases';
|
||||
import * as isolationEnvDb from '@archon/core/db/isolation-environments';
|
||||
import * as workflowDb from '@archon/core/db/workflows';
|
||||
import * as workflowEventDb from '@archon/core/db/workflow-events';
|
||||
import * as messageDb from '@archon/core/db/messages';
|
||||
|
||||
/**
|
||||
* Register all /api/* routes on the Hono app.
|
||||
*/
|
||||
export function registerApiRoutes(
|
||||
app: Hono,
|
||||
webAdapter: WebAdapter,
|
||||
lockManager: ConversationLockManager
|
||||
): void {
|
||||
function apiError(
|
||||
c: Context,
|
||||
status: 400 | 404 | 500,
|
||||
message: string,
|
||||
detail?: string
|
||||
): Response {
|
||||
return c.json({ error: message, ...(detail ? { detail } : {}) }, status);
|
||||
}
|
||||
|
||||
// CORS for Web UI — allow-all is fine for a single-developer tool.
|
||||
// Override with WEB_UI_ORIGIN env var to restrict if exposing publicly.
|
||||
app.use('/api/*', cors({ origin: process.env.WEB_UI_ORIGIN || '*' }));
|
||||
|
||||
// Shared lock/dispatch/error handling for message and workflow endpoints
|
||||
async function dispatchToOrchestrator(
|
||||
conversationId: string,
|
||||
message: string
|
||||
): Promise<{ accepted: boolean; status: string }> {
|
||||
const result = await lockManager.acquireLock(conversationId, async () => {
|
||||
try {
|
||||
await handleMessage(webAdapter, conversationId, message);
|
||||
} catch (error) {
|
||||
console.error('[API] handleMessage failed', {
|
||||
conversationId,
|
||||
error,
|
||||
});
|
||||
try {
|
||||
await webAdapter.emitSSE(
|
||||
conversationId,
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
message: `Failed to process message: ${(error as Error).message ?? 'unknown error'}. Try /reset if the problem persists.`,
|
||||
classification: 'transient',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
);
|
||||
} catch (sseError) {
|
||||
console.error('[API] Failed to emit error SSE', {
|
||||
conversationId,
|
||||
sseError,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
webAdapter.emitLockEvent(conversationId, false);
|
||||
}
|
||||
});
|
||||
|
||||
if (result.status === 'queued-conversation' || result.status === 'queued-capacity') {
|
||||
webAdapter.emitLockEvent(conversationId, true);
|
||||
}
|
||||
|
||||
return { accepted: true, status: result.status };
|
||||
}
|
||||
|
||||
// GET /api/conversations - List conversations
|
||||
app.get('/api/conversations', async c => {
|
||||
try {
|
||||
const platformType = c.req.query('platform') ?? undefined;
|
||||
const codebaseId = c.req.query('codebaseId') ?? undefined;
|
||||
const conversations = await conversationDb.listConversations(50, platformType, codebaseId);
|
||||
return c.json(conversations);
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to list conversations', { error });
|
||||
return c.json({ error: 'Failed to list conversations' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/conversations - Create new conversation
|
||||
app.post('/api/conversations', async c => {
|
||||
try {
|
||||
const body: { codebaseId?: unknown } = await c.req.json();
|
||||
const codebaseId = typeof body.codebaseId === 'string' ? body.codebaseId : undefined;
|
||||
|
||||
// Validate codebase exists if provided
|
||||
if (codebaseId) {
|
||||
const codebase = await codebaseDb.getCodebase(codebaseId);
|
||||
if (!codebase) {
|
||||
return apiError(c, 400, 'Codebase not found', `No codebase with id "${codebaseId}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const conversationId = `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const conversation = await conversationDb.getOrCreateConversation(
|
||||
'web',
|
||||
conversationId,
|
||||
codebaseId
|
||||
);
|
||||
return c.json({ conversationId: conversation.platform_conversation_id, id: conversation.id });
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to create conversation', { error });
|
||||
return apiError(c, 500, 'Failed to create conversation');
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/conversations/:id - Update conversation (title)
|
||||
app.patch('/api/conversations/:id', async c => {
|
||||
const id = c.req.param('id');
|
||||
try {
|
||||
const body: { title?: unknown } = await c.req.json();
|
||||
if (typeof body.title === 'string') {
|
||||
const title = body.title.slice(0, 255);
|
||||
await conversationDb.updateConversationTitle(id, title);
|
||||
}
|
||||
return c.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof ConversationNotFoundError) {
|
||||
return apiError(c, 404, 'Conversation not found');
|
||||
}
|
||||
console.error('[API] Failed to update conversation', { error });
|
||||
return apiError(c, 500, 'Failed to update conversation');
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/conversations/:id - Soft delete
|
||||
app.delete('/api/conversations/:id', async c => {
|
||||
const id = c.req.param('id');
|
||||
try {
|
||||
await conversationDb.softDeleteConversation(id);
|
||||
return c.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof ConversationNotFoundError) {
|
||||
return apiError(c, 404, 'Conversation not found');
|
||||
}
|
||||
console.error('[API] Failed to delete conversation', { error });
|
||||
return apiError(c, 500, 'Failed to delete conversation');
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/conversations/:id/messages - Message history
|
||||
app.get('/api/conversations/:id/messages', async c => {
|
||||
const platformConversationId = c.req.param('id');
|
||||
const limit = Math.min(Number(c.req.query('limit') ?? '200'), 500);
|
||||
try {
|
||||
const conv = await conversationDb.getConversationByPlatformId('web', platformConversationId);
|
||||
if (!conv) {
|
||||
return c.json({ error: 'Conversation not found' }, 404);
|
||||
}
|
||||
const messages = await messageDb.listMessages(conv.id, limit);
|
||||
return c.json(messages);
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to list messages', { error });
|
||||
return c.json({ error: 'Failed to list messages' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/conversations/:id/message - Send message
|
||||
app.post('/api/conversations/:id/message', async c => {
|
||||
const conversationId = c.req.param('id');
|
||||
|
||||
let body: { message?: unknown };
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: 'Invalid JSON in request body' }, 400);
|
||||
}
|
||||
|
||||
if (typeof body.message !== 'string' || !body.message) {
|
||||
return c.json({ error: 'message must be a non-empty string' }, 400);
|
||||
}
|
||||
|
||||
const message = body.message;
|
||||
|
||||
// Look up conversation for persistence and auto-titling
|
||||
let conv: Awaited<ReturnType<typeof conversationDb.getConversationByPlatformId>> = null;
|
||||
try {
|
||||
conv = await conversationDb.getConversationByPlatformId('web', conversationId);
|
||||
} catch (e: unknown) {
|
||||
console.error('[API] Failed to look up conversation', {
|
||||
conversationId,
|
||||
error: (e as Error).message,
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-title from first non-command message (non-critical)
|
||||
if (conv && !conv.title && !message.startsWith('/')) {
|
||||
try {
|
||||
const title = message.length > 80 ? message.slice(0, 77) + '...' : message;
|
||||
await conversationDb.updateConversationTitle(conv.id, title);
|
||||
} catch (e: unknown) {
|
||||
console.warn('[API] Auto-title failed', {
|
||||
conversationId,
|
||||
error: (e as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Persist user message and pass DB ID to adapter for assistant message persistence
|
||||
if (conv) {
|
||||
try {
|
||||
await messageDb.addMessage(conv.id, 'user', message);
|
||||
} catch (e: unknown) {
|
||||
console.error('[API] Message persistence failed', {
|
||||
conversationId: conv.id,
|
||||
error: (e as Error).message,
|
||||
});
|
||||
try {
|
||||
await webAdapter.emitSSE(
|
||||
conversationId,
|
||||
JSON.stringify({
|
||||
type: 'warning',
|
||||
message: 'Message could not be saved to history',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
);
|
||||
} catch (sseErr: unknown) {
|
||||
console.error('[API] SSE warning also failed (double failure)', {
|
||||
conversationId: conv?.id,
|
||||
error: (sseErr as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
webAdapter.setConversationDbId(conversationId, conv.id);
|
||||
}
|
||||
|
||||
const result = await dispatchToOrchestrator(conversationId, message);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// GET /api/stream/:conversationId - SSE streaming
|
||||
app.get('/api/stream/:conversationId', async c => {
|
||||
const conversationId = c.req.param('conversationId');
|
||||
|
||||
return streamSSE(c, async stream => {
|
||||
// Send initial heartbeat immediately to flush HTTP headers.
|
||||
// Without this, EventSource stays in CONNECTING state until the first write.
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({ type: 'heartbeat', timestamp: Date.now() }),
|
||||
});
|
||||
|
||||
webAdapter.registerStream(conversationId, stream);
|
||||
console.log(`[Web] SSE stream opened: ${conversationId}`);
|
||||
|
||||
stream.onAbort(() => {
|
||||
console.log(`[Web] SSE client disconnected: ${conversationId}`);
|
||||
webAdapter.removeStream(conversationId);
|
||||
});
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
await stream.sleep(30000);
|
||||
if (!stream.closed) {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({ type: 'heartbeat', timestamp: Date.now() }),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
// stream.sleep() throws when client disconnects — expected behavior.
|
||||
// Log unexpected errors for debugging.
|
||||
const msg = (e as Error).message ?? '';
|
||||
if (!msg.includes('aborted') && !msg.includes('closed') && !msg.includes('cancel')) {
|
||||
console.warn('[Web] Unexpected SSE heartbeat error', { error: msg });
|
||||
}
|
||||
} finally {
|
||||
webAdapter.removeStream(conversationId);
|
||||
console.log(`[Web] SSE stream closed: ${conversationId}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/codebases - List codebases
|
||||
app.get('/api/codebases', async c => {
|
||||
try {
|
||||
const codebases = await codebaseDb.listCodebases();
|
||||
return c.json(
|
||||
codebases.map(cb => {
|
||||
let commands = cb.commands;
|
||||
if (typeof commands === 'string') {
|
||||
try {
|
||||
commands = JSON.parse(commands);
|
||||
} catch (parseErr) {
|
||||
console.error('[API] Corrupted commands JSON for codebase', {
|
||||
codebaseId: cb.id,
|
||||
error: (parseErr as Error).message,
|
||||
});
|
||||
commands = {};
|
||||
}
|
||||
}
|
||||
return { ...cb, commands };
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to list codebases', { error });
|
||||
return c.json({ error: 'Failed to list codebases' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/codebases/:id - Codebase detail
|
||||
app.get('/api/codebases/:id', async c => {
|
||||
try {
|
||||
const codebase = await codebaseDb.getCodebase(c.req.param('id'));
|
||||
if (!codebase) {
|
||||
return c.json({ error: 'Codebase not found' }, 404);
|
||||
}
|
||||
let commands = codebase.commands;
|
||||
if (typeof commands === 'string') {
|
||||
try {
|
||||
commands = JSON.parse(commands);
|
||||
} catch (parseErr) {
|
||||
console.error('[API] Corrupted commands JSON for codebase', {
|
||||
codebaseId: codebase.id,
|
||||
error: (parseErr as Error).message,
|
||||
});
|
||||
commands = {};
|
||||
}
|
||||
}
|
||||
return c.json({ ...codebase, commands });
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to get codebase', { error });
|
||||
return c.json({ error: 'Failed to get codebase' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/codebases - Add a project (clone from URL or register local path)
|
||||
app.post('/api/codebases', async c => {
|
||||
let body: { url?: unknown; path?: unknown };
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: 'Invalid JSON in request body' }, 400);
|
||||
}
|
||||
|
||||
const hasUrl = typeof body.url === 'string' && body.url.length > 0;
|
||||
const hasPath = typeof body.path === 'string' && body.path.length > 0;
|
||||
|
||||
if ((!hasUrl && !hasPath) || (hasUrl && hasPath)) {
|
||||
return c.json({ error: 'Provide either "url" or "path", not both' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = hasUrl
|
||||
? await cloneRepository(body.url as string)
|
||||
: await registerRepository(body.path as string);
|
||||
|
||||
// Fetch the full codebase record for a consistent response
|
||||
const codebase = await codebaseDb.getCodebase(result.codebaseId);
|
||||
if (!codebase) {
|
||||
return c.json({ error: 'Codebase created but not found' }, 500);
|
||||
}
|
||||
|
||||
return c.json(codebase, result.alreadyExisted ? 200 : 201);
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to add codebase', { error });
|
||||
return c.json(
|
||||
{ error: `Failed to add codebase: ${(error as Error).message ?? 'unknown error'}` },
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/codebases/:id - Delete a project and clean up
|
||||
app.delete('/api/codebases/:id', async c => {
|
||||
const id = c.req.param('id');
|
||||
try {
|
||||
const codebase = await codebaseDb.getCodebase(id);
|
||||
if (!codebase) {
|
||||
return c.json({ error: 'Codebase not found' }, 404);
|
||||
}
|
||||
|
||||
// Clean up isolation environments (worktrees)
|
||||
const environments = await isolationEnvDb.listByCodebase(id);
|
||||
for (const env of environments) {
|
||||
try {
|
||||
await removeWorktree(codebase.default_cwd, env.working_path);
|
||||
console.log(`[API] Removed worktree: ${env.working_path}`);
|
||||
} catch (wtErr) {
|
||||
// Worktree may already be gone — log but continue
|
||||
console.warn('[API] Failed to remove worktree', {
|
||||
path: env.working_path,
|
||||
error: (wtErr as Error).message,
|
||||
});
|
||||
}
|
||||
await isolationEnvDb.updateStatus(env.id, 'destroyed');
|
||||
}
|
||||
|
||||
// Delete from database (unlinks conversations and sessions)
|
||||
await codebaseDb.deleteCodebase(id);
|
||||
|
||||
// Remove workspace directory from disk — only for Archon-managed repos
|
||||
const workspacesRoot = normalize(getArchonWorkspacesPath());
|
||||
const normalizedCwd = normalize(codebase.default_cwd);
|
||||
if (
|
||||
normalizedCwd.startsWith(workspacesRoot + '/') ||
|
||||
normalizedCwd.startsWith(workspacesRoot + '\\')
|
||||
) {
|
||||
try {
|
||||
await rm(normalizedCwd, { recursive: true, force: true });
|
||||
console.log(`[API] Removed workspace: ${normalizedCwd}`);
|
||||
} catch (rmErr) {
|
||||
// Directory may not exist — log but don't fail
|
||||
console.warn('[API] Failed to remove workspace directory', {
|
||||
path: codebase.default_cwd,
|
||||
error: (rmErr as Error).message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`[API] Skipping filesystem deletion for externally registered repo: ${codebase.default_cwd}`
|
||||
);
|
||||
}
|
||||
|
||||
return c.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to delete codebase', { error });
|
||||
return c.json({ error: 'Failed to delete codebase' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Workflow endpoints
|
||||
// =========================================================================
|
||||
|
||||
// GET /api/workflows - Discover available workflows
|
||||
app.get('/api/workflows', async c => {
|
||||
try {
|
||||
const cwd = c.req.query('cwd');
|
||||
let workingDir = cwd;
|
||||
|
||||
// Fallback to first codebase's default_cwd
|
||||
if (!workingDir) {
|
||||
const codebases = await codebaseDb.listCodebases();
|
||||
if (codebases.length > 0) {
|
||||
workingDir = codebases[0].default_cwd;
|
||||
}
|
||||
}
|
||||
|
||||
if (!workingDir) {
|
||||
return c.json({ workflows: [] });
|
||||
}
|
||||
|
||||
const workflows = await discoverWorkflows(workingDir);
|
||||
return c.json({ workflows });
|
||||
} catch (error) {
|
||||
// Workflow discovery can fail if cwd is stale or deleted — return empty with warning
|
||||
console.warn('[API] Failed to discover workflows, returning empty', { error });
|
||||
return c.json({
|
||||
workflows: [],
|
||||
warning: `Workflow discovery failed: ${(error as Error).message}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/workflows/:name/run - Run a workflow via the orchestrator
|
||||
app.post('/api/workflows/:name/run', async c => {
|
||||
try {
|
||||
let body: { conversationId?: unknown; message?: unknown };
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: 'Invalid JSON' }, 400);
|
||||
}
|
||||
|
||||
const conversationId = typeof body.conversationId === 'string' ? body.conversationId : null;
|
||||
const message = typeof body.message === 'string' ? body.message : null;
|
||||
|
||||
if (!conversationId || !message) {
|
||||
return c.json({ error: 'conversationId and message are required' }, 400);
|
||||
}
|
||||
|
||||
const workflowName = c.req.param('name');
|
||||
if (!/^[\w-]+$/.test(workflowName)) {
|
||||
return c.json({ error: 'Invalid workflow name' }, 400);
|
||||
}
|
||||
const fullMessage = `/workflow run ${workflowName} ${message}`;
|
||||
const result = await dispatchToOrchestrator(conversationId, fullMessage);
|
||||
return c.json(result);
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to run workflow', { error });
|
||||
return c.json({ error: 'Failed to run workflow' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/workflows/runs - List workflow runs
|
||||
app.get('/api/workflows/runs', async c => {
|
||||
try {
|
||||
const conversationId = c.req.query('conversationId') ?? undefined;
|
||||
const rawStatus = c.req.query('status');
|
||||
const validStatuses = ['pending', 'running', 'completed', 'failed'] as const;
|
||||
type WorkflowRunStatus = (typeof validStatuses)[number];
|
||||
const status: WorkflowRunStatus | undefined =
|
||||
rawStatus && (validStatuses as readonly string[]).includes(rawStatus)
|
||||
? (rawStatus as WorkflowRunStatus)
|
||||
: undefined;
|
||||
const codebaseId = c.req.query('codebaseId') ?? undefined;
|
||||
const limitStr = c.req.query('limit');
|
||||
const limit = Math.min(Math.max(1, limitStr ? Number(limitStr) : 50), 200);
|
||||
|
||||
const runs = await workflowDb.listWorkflowRuns({
|
||||
conversationId,
|
||||
status,
|
||||
limit,
|
||||
codebaseId,
|
||||
});
|
||||
return c.json({ runs });
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to list workflow runs', { error });
|
||||
return c.json({ error: 'Failed to list workflow runs' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/workflows/runs/by-worker/:platformId - Look up run by worker conversation
|
||||
// Must be registered before :runId to avoid "by-worker" matching as a runId
|
||||
app.get('/api/workflows/runs/by-worker/:platformId', async c => {
|
||||
try {
|
||||
const platformId = c.req.param('platformId');
|
||||
const run = await workflowDb.getWorkflowRunByWorkerPlatformId(platformId);
|
||||
if (!run) {
|
||||
return c.json({ error: 'No workflow run found for this worker' }, 404);
|
||||
}
|
||||
return c.json({ run });
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to look up workflow run by worker', { error });
|
||||
return c.json({ error: 'Failed to look up workflow run' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/workflows/runs/:runId - Get run details with events
|
||||
app.get('/api/workflows/runs/:runId', async c => {
|
||||
try {
|
||||
const runId = c.req.param('runId');
|
||||
const run = await workflowDb.getWorkflowRun(runId);
|
||||
if (!run) {
|
||||
return c.json({ error: 'Workflow run not found' }, 404);
|
||||
}
|
||||
const events = await workflowEventDb.listWorkflowEvents(runId);
|
||||
|
||||
// Look up worker conversation to get its platform_conversation_id for SSE/messages
|
||||
let workerPlatformId: string | undefined;
|
||||
if (run.conversation_id) {
|
||||
const conv = await conversationDb.getConversationById(run.conversation_id);
|
||||
workerPlatformId = conv?.platform_conversation_id;
|
||||
}
|
||||
|
||||
// Look up parent conversation to get its platform_conversation_id for navigation
|
||||
let parentPlatformId: string | undefined;
|
||||
if (run.parent_conversation_id) {
|
||||
const parentConv = await conversationDb.getConversationById(run.parent_conversation_id);
|
||||
parentPlatformId = parentConv?.platform_conversation_id;
|
||||
}
|
||||
|
||||
return c.json({
|
||||
run: { ...run, worker_platform_id: workerPlatformId, parent_platform_id: parentPlatformId },
|
||||
events,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to get workflow run', { error });
|
||||
return c.json({ error: 'Failed to get workflow run' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/config - Read-only configuration
|
||||
app.get('/api/config', async c => {
|
||||
try {
|
||||
const config = await loadConfig();
|
||||
return c.json({
|
||||
config,
|
||||
database: getDatabaseType(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to get config', { error });
|
||||
return c.json({ error: 'Failed to get config' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/health - Health check with web adapter info
|
||||
app.get('/api/health', c => {
|
||||
return c.json({
|
||||
status: 'ok',
|
||||
adapter: 'web',
|
||||
concurrency: lockManager.getStats(),
|
||||
});
|
||||
});
|
||||
}
|
||||
23
packages/web/components.json
Normal file
23
packages/web/components.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
23
packages/web/index.html
Normal file
23
packages/web/index.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Archon</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
body {
|
||||
background-color: #0f1117;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
40
packages/web/package.json
Normal file
40
packages/web/package.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"name": "@archon/web",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tanstack/react-virtual": "^3.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^9.0.0",
|
||||
"react-router": "^7.0.0",
|
||||
"rehype-highlight": "^7.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"shadcn": "^3.8.4",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
90
packages/web/src/App.tsx
Normal file
90
packages/web/src/App.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { Component } from 'react';
|
||||
import type { ReactNode, ErrorInfo } from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import { DashboardPage } from '@/routes/DashboardPage';
|
||||
import { ChatPage } from '@/routes/ChatPage';
|
||||
import { WorkflowsPage } from '@/routes/WorkflowsPage';
|
||||
import { WorkflowExecutionPage } from '@/routes/WorkflowExecutionPage';
|
||||
import { WorkflowBuilderPage } from '@/routes/WorkflowBuilderPage';
|
||||
import { SettingsPage } from '@/routes/SettingsPage';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 10_000,
|
||||
refetchOnWindowFocus: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<{ children: ReactNode }, ErrorBoundaryState> {
|
||||
constructor(props: { children: ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error('[ErrorBoundary] Uncaught rendering error', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
componentStack: info.componentStack,
|
||||
});
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-zinc-950 p-8">
|
||||
<div className="max-w-md text-center">
|
||||
<h1 className="mb-2 text-xl font-semibold text-zinc-100">Something went wrong</h1>
|
||||
<p className="mb-4 text-sm text-zinc-400">
|
||||
{this.state.error?.message ?? 'An unexpected error occurred.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={(): void => {
|
||||
window.location.reload();
|
||||
}}
|
||||
className="rounded-md bg-zinc-800 px-4 py-2 text-sm text-zinc-200 hover:bg-zinc-700"
|
||||
>
|
||||
Reload page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export function App(): React.ReactElement {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/chat/*" element={<ChatPage />} />
|
||||
<Route path="/workflows" element={<WorkflowsPage />} />
|
||||
<Route path="/workflows/builder" element={<WorkflowBuilderPage />} />
|
||||
<Route path="/workflows/runs/:runId" element={<WorkflowExecutionPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
418
packages/web/src/components/chat/ChatInterface.tsx
Normal file
418
packages/web/src/components/chat/ChatInterface.tsx
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { MessageList } from './MessageList';
|
||||
import { MessageInput } from './MessageInput';
|
||||
import { LockIndicator } from './LockIndicator';
|
||||
import { WorkflowProgressCard } from './WorkflowProgressCard';
|
||||
import { useSSE } from '@/hooks/useSSE';
|
||||
import { useWorkflowStatus } from '@/hooks/useWorkflowStatus';
|
||||
import {
|
||||
sendMessage as apiSendMessage,
|
||||
listConversations,
|
||||
listCodebases,
|
||||
getMessages,
|
||||
createConversation,
|
||||
} from '@/lib/api';
|
||||
import type { ConversationResponse, CodebaseResponse, MessageResponse } from '@/lib/api';
|
||||
import type {
|
||||
ChatMessage,
|
||||
ToolCallDisplay,
|
||||
ErrorDisplay,
|
||||
WorkflowDispatchEvent,
|
||||
} from '@/lib/types';
|
||||
import { getCachedMessages, setCachedMessages } from '@/lib/message-cache';
|
||||
|
||||
interface ChatInterfaceProps {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export function ChatInterface({ conversationId }: ChatInterfaceProps): React.ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const isNewChat = conversationId === 'new';
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() =>
|
||||
isNewChat ? [] : getCachedMessages(conversationId).map(m => ({ ...m, isStreaming: false }))
|
||||
);
|
||||
const [locked, setLocked] = useState(false);
|
||||
const [queuePosition, setQueuePosition] = useState<number | undefined>();
|
||||
const [sending, setSending] = useState(false);
|
||||
const [hasSentMessage, setHasSentMessage] = useState(false);
|
||||
const messageIdCounter = useRef(0);
|
||||
const { activeWorkflow, handlers: workflowHandlers } = useWorkflowStatus();
|
||||
|
||||
// Sync messages to cache for persistence across navigation
|
||||
useEffect(() => {
|
||||
if (!isNewChat) {
|
||||
setCachedMessages(conversationId, messages);
|
||||
}
|
||||
}, [conversationId, messages, isNewChat]);
|
||||
|
||||
// Load message history from server on mount (survives hard refresh)
|
||||
useEffect(() => {
|
||||
if (isNewChat) return;
|
||||
void getMessages(conversationId)
|
||||
.then((rows: MessageResponse[]) => {
|
||||
if (rows.length === 0) return;
|
||||
const hydrated: ChatMessage[] = rows.map(row => {
|
||||
let meta: {
|
||||
toolCalls?: {
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
duration?: number;
|
||||
}[];
|
||||
error?: ErrorDisplay;
|
||||
workflowDispatch?: { workerConversationId: string; workflowName: string };
|
||||
} = {};
|
||||
try {
|
||||
meta = JSON.parse(row.metadata) as typeof meta;
|
||||
} catch (parseErr) {
|
||||
console.warn('[Chat] Corrupted message metadata', {
|
||||
messageId: row.id,
|
||||
error: (parseErr as Error).message,
|
||||
});
|
||||
meta = {
|
||||
error: {
|
||||
message: 'Message data corrupted',
|
||||
classification: 'fatal' as const,
|
||||
suggestedActions: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
role: row.role,
|
||||
content: row.content,
|
||||
toolCalls: meta.toolCalls?.map((tc, i) => ({
|
||||
...tc,
|
||||
id: `${row.id}-tool-${String(i)}`,
|
||||
startedAt: 0,
|
||||
isExpanded: false,
|
||||
duration: tc.duration ?? 0, // Use stored duration, fallback to 0
|
||||
})),
|
||||
error: meta.error,
|
||||
workflowDispatch: meta.workflowDispatch,
|
||||
timestamp: new Date(row.created_at).getTime(),
|
||||
isStreaming: false,
|
||||
};
|
||||
});
|
||||
// Only set if no messages arrived via SSE while loading
|
||||
setMessages(prev => (prev.length > 0 ? prev : hydrated));
|
||||
setHasSentMessage(true);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
console.error('[Chat] Failed to load message history', {
|
||||
conversationId,
|
||||
error: e instanceof Error ? e.message : e,
|
||||
});
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: 'error-load-history',
|
||||
role: 'assistant' as const,
|
||||
content: '',
|
||||
error: {
|
||||
message: 'Failed to load message history. Try refreshing the page.',
|
||||
classification: 'transient' as const,
|
||||
suggestedActions: ['Refresh page'],
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
}, [conversationId, isNewChat]);
|
||||
|
||||
// Share conversations cache with sidebar for title/context display
|
||||
const { data: conversations, isError: conversationsError } = useQuery<ConversationResponse[]>({
|
||||
queryKey: ['conversations'],
|
||||
queryFn: () => listConversations(),
|
||||
});
|
||||
const { data: codebases, isError: codebasesError } = useQuery<CodebaseResponse[]>({
|
||||
queryKey: ['codebases'],
|
||||
queryFn: listCodebases,
|
||||
});
|
||||
const currentConv = conversations?.find(c => c.platform_conversation_id === conversationId);
|
||||
const currentCodebase = codebases?.find(cb => cb.id === currentConv?.codebase_id);
|
||||
const headerTitle = currentConv?.title ?? 'Chat';
|
||||
const headerSubtitle = currentConv?.cwd ?? undefined;
|
||||
|
||||
const nextId = (): string => {
|
||||
messageIdCounter.current += 1;
|
||||
return `msg-${String(messageIdCounter.current)}`;
|
||||
};
|
||||
|
||||
const onText = useCallback((content: string): void => {
|
||||
setMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
// Workflow status messages (🚀 start, ✅ complete) should always be their own message
|
||||
const isWorkflowStatus = /^[\u{1F680}\u{2705}]/u.test(content);
|
||||
|
||||
if (last?.role === 'assistant' && last.isStreaming) {
|
||||
const lastIsWorkflowStatus = /^[\u{1F680}\u{2705}]/u.test(last.content);
|
||||
|
||||
if ((isWorkflowStatus && last.content) || (lastIsWorkflowStatus && !isWorkflowStatus)) {
|
||||
// Close the current streaming message and start a new one when:
|
||||
// 1. Incoming is a workflow status and current has content
|
||||
// 2. Current is a workflow status and incoming is regular text
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ ...last, isStreaming: false },
|
||||
{
|
||||
id: `msg-${String(Date.now())}`,
|
||||
role: 'assistant' as const,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
isStreaming: true,
|
||||
toolCalls: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
// Append to existing streaming message (replace thinking placeholder if empty)
|
||||
return [...prev.slice(0, -1), { ...last, content: last.content + content }];
|
||||
}
|
||||
// New assistant message
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: `msg-${String(Date.now())}`,
|
||||
role: 'assistant' as const,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
isStreaming: true,
|
||||
toolCalls: [],
|
||||
},
|
||||
];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onToolCall = useCallback((name: string, input: Record<string, unknown>): void => {
|
||||
setMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.role === 'assistant') {
|
||||
const now = Date.now();
|
||||
// Mark any previous running tools as complete (agent moved on)
|
||||
const updatedExistingTools = (last.toolCalls ?? []).map(tc =>
|
||||
!tc.output && tc.duration === undefined ? { ...tc, duration: now - tc.startedAt } : tc
|
||||
);
|
||||
const newTool: ToolCallDisplay = {
|
||||
id: `tool-${String(now)}`,
|
||||
name,
|
||||
input,
|
||||
startedAt: now,
|
||||
isExpanded: false,
|
||||
};
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
...last,
|
||||
isStreaming: false,
|
||||
toolCalls: [...updatedExistingTools, newTool],
|
||||
},
|
||||
];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onToolResult = useCallback((name: string, output: string, duration: number): void => {
|
||||
setMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.role === 'assistant' && last.toolCalls) {
|
||||
const updatedTools = last.toolCalls.map(tc =>
|
||||
tc.name === name && !tc.output ? { ...tc, output, duration } : tc
|
||||
);
|
||||
return [...prev.slice(0, -1), { ...last, toolCalls: updatedTools }];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onError = useCallback((error: ErrorDisplay): void => {
|
||||
setMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.role === 'assistant') {
|
||||
return [...prev.slice(0, -1), { ...last, isStreaming: false, error }];
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: `msg-${String(Date.now())}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
error,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onLockChange = useCallback((isLocked: boolean, position?: number): void => {
|
||||
setLocked(isLocked);
|
||||
setQueuePosition(position);
|
||||
if (!isLocked) {
|
||||
const now = Date.now();
|
||||
// Mark ALL streaming messages as complete and all running tools as finished
|
||||
setMessages(prev =>
|
||||
prev.map(msg => {
|
||||
const needsToolFix = msg.toolCalls?.some(tc => !tc.output && tc.duration === undefined);
|
||||
const needsStreamFix = msg.isStreaming;
|
||||
if (!needsToolFix && !needsStreamFix) return msg;
|
||||
return {
|
||||
...msg,
|
||||
isStreaming: false,
|
||||
toolCalls: needsToolFix
|
||||
? msg.toolCalls?.map(tc =>
|
||||
!tc.output && tc.duration === undefined
|
||||
? { ...tc, duration: now - tc.startedAt }
|
||||
: tc
|
||||
)
|
||||
: msg.toolCalls,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onSessionInfo = useCallback((_sessionId: string, _cost?: number): void => {
|
||||
// Session info can be stored for display later
|
||||
}, []);
|
||||
|
||||
const onWorkflowDispatch = useCallback((event: WorkflowDispatchEvent): void => {
|
||||
setMessages(prev => {
|
||||
let lastAssistantIdx = -1;
|
||||
for (let i = prev.length - 1; i >= 0; i--) {
|
||||
if (prev[i].role === 'assistant') {
|
||||
lastAssistantIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastAssistantIdx < 0) return prev;
|
||||
const msg = prev[lastAssistantIdx];
|
||||
return [
|
||||
...prev.slice(0, lastAssistantIdx),
|
||||
{
|
||||
...msg,
|
||||
workflowDispatch: {
|
||||
workerConversationId: event.workerConversationId,
|
||||
workflowName: event.workflowName,
|
||||
},
|
||||
},
|
||||
...prev.slice(lastAssistantIdx + 1),
|
||||
];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onWarning = useCallback(
|
||||
(message: string): void => {
|
||||
console.warn('[SSE] Warning from server:', message);
|
||||
onError({
|
||||
message,
|
||||
classification: 'transient',
|
||||
suggestedActions: [],
|
||||
});
|
||||
},
|
||||
[onError]
|
||||
);
|
||||
|
||||
const { connected } = useSSE(isNewChat ? null : conversationId, {
|
||||
onText,
|
||||
onToolCall,
|
||||
onToolResult,
|
||||
onError,
|
||||
onLockChange,
|
||||
onSessionInfo,
|
||||
onWorkflowDispatch,
|
||||
onWarning,
|
||||
...workflowHandlers,
|
||||
});
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (message: string): Promise<void> => {
|
||||
// Add user message + thinking indicator to UI immediately
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
role: 'user',
|
||||
content: message,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const thinkingMsg: ChatMessage = {
|
||||
id: `thinking-${String(Date.now())}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
isStreaming: true,
|
||||
};
|
||||
setMessages(prev => [...prev, userMsg, thinkingMsg]);
|
||||
setSending(true);
|
||||
setHasSentMessage(true);
|
||||
|
||||
let targetConversationId = conversationId;
|
||||
|
||||
// Create conversation on first message if this is a new chat
|
||||
if (isNewChat) {
|
||||
try {
|
||||
const selectedProjectId = localStorage.getItem('archon-selected-project');
|
||||
const { conversationId: newId } = await createConversation(
|
||||
selectedProjectId ?? undefined
|
||||
);
|
||||
targetConversationId = newId;
|
||||
navigate(`/chat/${newId}`, { replace: true });
|
||||
} catch (error) {
|
||||
console.error('[Chat] Failed to create conversation', { error });
|
||||
onError({
|
||||
message: 'Failed to create conversation. Please try again.',
|
||||
classification: 'transient',
|
||||
suggestedActions: ['Retry'],
|
||||
});
|
||||
setSending(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await apiSendMessage(targetConversationId, message);
|
||||
} catch (error) {
|
||||
console.error('[Chat] Failed to send message', { error });
|
||||
onError({
|
||||
message: 'Failed to send message. Please try again.',
|
||||
classification: 'transient',
|
||||
suggestedActions: ['Retry'],
|
||||
});
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[conversationId, isNewChat, navigate, onError]
|
||||
);
|
||||
|
||||
const handleCancelWorkflow = useCallback((): void => {
|
||||
void handleSend('/workflow cancel');
|
||||
}, [handleSend]);
|
||||
|
||||
const isStreaming = messages.some(m => m.isStreaming);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Header
|
||||
title={headerTitle}
|
||||
subtitle={headerSubtitle}
|
||||
projectName={currentCodebase?.name}
|
||||
connected={connected}
|
||||
/>
|
||||
{(conversationsError || codebasesError) && (
|
||||
<div className="flex gap-2 px-4 py-1">
|
||||
{conversationsError && (
|
||||
<span className="text-xs text-red-400">Failed to load conversations</span>
|
||||
)}
|
||||
{codebasesError && <span className="text-xs text-red-400">Failed to load projects</span>}
|
||||
</div>
|
||||
)}
|
||||
<MessageList messages={messages} isStreaming={isStreaming} />
|
||||
{activeWorkflow && (
|
||||
<WorkflowProgressCard workflow={activeWorkflow} onCancel={handleCancelWorkflow} />
|
||||
)}
|
||||
<LockIndicator locked={locked && hasSentMessage} queuePosition={queuePosition} />
|
||||
<MessageInput onSend={handleSend} disabled={sending} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
packages/web/src/components/chat/ErrorCard.tsx
Normal file
54
packages/web/src/components/chat/ErrorCard.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { AlertCircle } from 'lucide-react';
|
||||
import type { ErrorDisplay } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ErrorCardProps {
|
||||
error: ErrorDisplay;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function ErrorCard({ error, onRetry }: ErrorCardProps): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg border border-border border-l-[3px] border-l-error bg-surface p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-error" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-text-primary">{error.message}</p>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium',
|
||||
error.classification === 'transient'
|
||||
? 'bg-warning/20 text-warning'
|
||||
: 'bg-error/20 text-error'
|
||||
)}
|
||||
>
|
||||
{error.classification === 'transient' ? 'Transient' : 'Fatal'}
|
||||
</span>
|
||||
</div>
|
||||
{error.suggestedActions.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{error.suggestedActions.map((action, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={action === 'Retry' ? onRetry : undefined}
|
||||
className="text-xs text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
{action}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{error.classification === 'transient' && onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="mt-2 text-xs text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
packages/web/src/components/chat/LockIndicator.tsx
Normal file
29
packages/web/src/components/chat/LockIndicator.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LockIndicatorProps {
|
||||
locked: boolean;
|
||||
queuePosition?: number;
|
||||
}
|
||||
|
||||
export function LockIndicator({ locked, queuePosition }: LockIndicatorProps): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden transition-all duration-200',
|
||||
locked ? 'h-8 opacity-100' : 'h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
<div className="flex h-8 items-center gap-2 border-l-2 border-l-primary bg-surface px-4">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
|
||||
<span className="text-sm text-text-secondary">
|
||||
Agent is working...
|
||||
{queuePosition !== undefined && queuePosition > 0 && (
|
||||
<span className="ml-1 text-text-tertiary">
|
||||
Position {String(queuePosition)} in queue
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
packages/web/src/components/chat/MessageBubble.tsx
Normal file
106
packages/web/src/components/chat/MessageBubble.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import type { ChatMessage } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: MessageBubbleProps): React.ReactElement {
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
return (
|
||||
<div className={cn('group flex w-full', isUser ? 'justify-end' : 'justify-start')}>
|
||||
<div
|
||||
className={cn(
|
||||
'relative',
|
||||
isUser
|
||||
? 'max-w-[70%] rounded-2xl rounded-br-sm bg-accent-muted px-4 py-2.5'
|
||||
: 'max-w-full rounded-lg border-l-2 border-border pl-4'
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<p className="text-sm text-text-primary whitespace-pre-wrap">{message.content}</p>
|
||||
) : (
|
||||
<div className="chat-markdown max-w-none text-sm text-text-primary">
|
||||
{message.isStreaming && !message.content && (
|
||||
<div className="flex items-center gap-1.5 py-1">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-text-tertiary" />
|
||||
<span
|
||||
className="h-1.5 w-1.5 animate-pulse rounded-full bg-text-tertiary"
|
||||
style={{ animationDelay: '0.2s' }}
|
||||
/>
|
||||
<span
|
||||
className="h-1.5 w-1.5 animate-pulse rounded-full bg-text-tertiary"
|
||||
style={{ animationDelay: '0.4s' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkBreaks]}
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
components={{
|
||||
pre: ({ children, ...props }): React.ReactElement => (
|
||||
<pre
|
||||
className="overflow-x-auto rounded-lg border border-border bg-surface p-4 font-mono text-sm"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ children, className, ...props }): React.ReactElement => {
|
||||
const isBlock =
|
||||
className?.startsWith('language-') || className?.startsWith('hljs');
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code className={cn(className, 'font-mono')} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code
|
||||
className="rounded bg-surface px-1.5 py-0.5 font-mono text-sm text-primary"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
blockquote: ({ children, ...props }): React.ReactElement => (
|
||||
<blockquote
|
||||
className="border-l-2 border-primary pl-4 text-text-secondary"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
a: ({ children, ...props }): React.ReactElement => (
|
||||
<a
|
||||
className="text-primary underline decoration-primary/40 hover:decoration-primary"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
{message.isStreaming && (
|
||||
<span className="inline-block h-4 w-0.5 animate-pulse bg-primary align-text-bottom" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-0.5 text-[11px] text-text-tertiary">
|
||||
{new Date(message.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
packages/web/src/components/chat/MessageInput.tsx
Normal file
70
packages/web/src/components/chat/MessageInput.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { useState, useRef, useCallback, type KeyboardEvent } from 'react';
|
||||
import { ArrowUp, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface MessageInputProps {
|
||||
onSend: (message: string) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export function MessageInput({ onSend, disabled }: MessageInputProps): React.ReactElement {
|
||||
const [value, setValue] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleSend = useCallback((): void => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || disabled) return;
|
||||
onSend(trimmed);
|
||||
setValue('');
|
||||
// Reset textarea height
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [value, disabled, onSend]);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>): void => {
|
||||
setValue(e.target.value);
|
||||
// Auto-expand textarea
|
||||
const textarea = e.target;
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = `${String(Math.min(textarea.scrollHeight, 200))}px`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-border bg-surface p-4">
|
||||
<div className="mx-auto flex max-w-3xl items-end gap-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
placeholder="Message Archon..."
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-lg border border-border bg-background px-4 py-3 text-sm text-text-primary placeholder:text-text-tertiary focus:border-primary focus:outline-none disabled:opacity-50"
|
||||
style={{ maxHeight: '200px' }}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={disabled || !value.trim()}
|
||||
size="icon"
|
||||
className="h-10 w-10 shrink-0 rounded-lg bg-primary text-primary-foreground hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{disabled ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
packages/web/src/components/chat/MessageList.tsx
Normal file
121
packages/web/src/components/chat/MessageList.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { useRef } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { ArrowDown, MessageSquare } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
import { ToolCallCard } from './ToolCallCard';
|
||||
import { ErrorCard } from './ErrorCard';
|
||||
import { useAutoScroll } from '@/hooks/useAutoScroll';
|
||||
import { getWorkflowRunByWorker } from '@/lib/api';
|
||||
import type { ChatMessage } from '@/lib/types';
|
||||
|
||||
function WorkflowDispatchInline({
|
||||
workflowName,
|
||||
workerConversationId,
|
||||
}: {
|
||||
workflowName: string;
|
||||
workerConversationId: string;
|
||||
}): React.ReactElement {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: runData } = useQuery({
|
||||
queryKey: ['workflowRunByWorker', workerConversationId],
|
||||
queryFn: () => getWorkflowRunByWorker(workerConversationId),
|
||||
refetchInterval: (query): number | false => {
|
||||
const status = query.state.data?.run.status;
|
||||
if (status === 'completed' || status === 'failed') return false;
|
||||
return 3000;
|
||||
},
|
||||
});
|
||||
|
||||
const status = runData?.run.status;
|
||||
|
||||
const handleClick = (): void => {
|
||||
if (runData?.run.id) {
|
||||
navigate(`/workflows/runs/${runData.run.id}`);
|
||||
} else {
|
||||
navigate(`/chat/${encodeURIComponent(workerConversationId)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const statusIcon =
|
||||
status === 'completed' ? (
|
||||
<span className="text-success text-xs shrink-0">✓</span>
|
||||
) : status === 'failed' ? (
|
||||
<span className="text-error text-xs shrink-0">✗</span>
|
||||
) : (
|
||||
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-accent border-t-transparent shrink-0" />
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-surface px-3 py-2 text-xs transition-colors hover:bg-surface-elevated hover:border-primary/50 text-left max-w-sm"
|
||||
>
|
||||
{statusIcon}
|
||||
<span className="truncate text-text-primary font-medium">{workflowName}</span>
|
||||
<span className="text-primary font-medium shrink-0">View →</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface MessageListProps {
|
||||
messages: ChatMessage[];
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
export function MessageList({ messages, isStreaming }: MessageListProps): React.ReactElement {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { isAtBottom, scrollToBottom } = useAutoScroll(containerRef, [messages, isStreaming]);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3 text-text-tertiary">
|
||||
<MessageSquare className="h-10 w-10" />
|
||||
<p className="text-sm">Send a message to start chatting</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<div ref={containerRef} className="h-full overflow-y-auto px-3 py-2">
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-1.5 pb-6">
|
||||
{messages.map(msg => (
|
||||
<div key={msg.id} className="flex flex-col gap-1">
|
||||
<MessageBubble message={msg} />
|
||||
{msg.toolCalls?.map(tool => (
|
||||
<ToolCallCard key={tool.id} tool={tool} />
|
||||
))}
|
||||
{msg.error && <ErrorCard error={msg.error} />}
|
||||
{msg.workflowDispatch && (
|
||||
<WorkflowDispatchInline
|
||||
workflowName={msg.workflowDispatch.workflowName}
|
||||
workerConversationId={msg.workflowDispatch.workerConversationId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Jump to bottom button */}
|
||||
{!isAtBottom && (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2">
|
||||
<Button
|
||||
onClick={scrollToBottom}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="rounded-full bg-surface-elevated shadow-lg"
|
||||
>
|
||||
<ArrowDown className="mr-1 h-3 w-3" />
|
||||
Jump to bottom
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
packages/web/src/components/chat/ToolCallCard.tsx
Normal file
100
packages/web/src/components/chat/ToolCallCard.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { useState } from 'react';
|
||||
import { ChevronRight, Loader2, Terminal } from 'lucide-react';
|
||||
import type { ToolCallDisplay } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ToolCallCardProps {
|
||||
tool: ToolCallDisplay;
|
||||
}
|
||||
|
||||
export function ToolCallCard({ tool }: ToolCallCardProps): React.ReactElement {
|
||||
const [expanded, setExpanded] = useState(tool.isExpanded);
|
||||
const [showAllOutput, setShowAllOutput] = useState(false);
|
||||
const isRunning = !tool.output && tool.duration === undefined;
|
||||
|
||||
// Get a brief summary from the input
|
||||
const summary = Object.values(tool.input)[0];
|
||||
const summaryText =
|
||||
typeof summary === 'string' ? summary.slice(0, 60) + (summary.length > 60 ? '...' : '') : '';
|
||||
|
||||
// Limit output display
|
||||
const outputLines = tool.output?.split('\n') ?? [];
|
||||
const isLongOutput = outputLines.length > 50;
|
||||
const displayOutput = showAllOutput ? tool.output : outputLines.slice(0, 20).join('\n');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border border-border bg-surface transition-colors hover:border-border-bright',
|
||||
isRunning && 'border-l-2 border-l-primary'
|
||||
)}
|
||||
>
|
||||
{/* Header - clickable to expand */}
|
||||
<button
|
||||
onClick={(): void => {
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
className="flex h-9 w-full items-center gap-2 px-3 text-left"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 shrink-0 text-text-tertiary transition-transform duration-150',
|
||||
expanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
{isRunning ? (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-primary" />
|
||||
) : (
|
||||
<Terminal className="h-3.5 w-3.5 shrink-0 text-text-secondary" />
|
||||
)}
|
||||
<span className="truncate font-mono text-xs text-text-secondary">{tool.name}</span>
|
||||
{summaryText && <span className="truncate text-xs text-text-tertiary">{summaryText}</span>}
|
||||
<span className="ml-auto shrink-0">
|
||||
{tool.duration !== undefined && (
|
||||
<span className="rounded-full bg-surface-elevated px-2 py-0.5 text-[10px] text-text-secondary">
|
||||
{tool.duration < 1000
|
||||
? `${String(tool.duration)}ms`
|
||||
: `${(tool.duration / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div className="border-t border-border px-3 py-2">
|
||||
{Object.keys(tool.input).length > 0 && (
|
||||
<div className="mb-2">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-text-tertiary">
|
||||
Input
|
||||
</span>
|
||||
<pre className="mt-1 overflow-x-auto rounded-md bg-background p-2 font-mono text-xs text-text-secondary">
|
||||
{JSON.stringify(tool.input, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{tool.output && (
|
||||
<div>
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-text-tertiary">
|
||||
Output
|
||||
</span>
|
||||
<pre className="mt-1 max-h-80 overflow-auto rounded-md bg-background p-2 font-mono text-xs text-text-secondary">
|
||||
{displayOutput}
|
||||
</pre>
|
||||
{isLongOutput && !showAllOutput && (
|
||||
<button
|
||||
onClick={(): void => {
|
||||
setShowAllOutput(true);
|
||||
}}
|
||||
className="mt-1 text-xs text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
Show {String(outputLines.length - 20)} more lines
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
packages/web/src/components/chat/WorkflowProgressCard.tsx
Normal file
185
packages/web/src/components/chat/WorkflowProgressCard.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { useNavigate } from 'react-router';
|
||||
import { LoopIterationView } from '@/components/workflows/LoopIterationView';
|
||||
import type { WorkflowState } from '@/lib/types';
|
||||
|
||||
interface WorkflowProgressCardProps {
|
||||
workflow: WorkflowState;
|
||||
onCancel?: () => void;
|
||||
compact?: boolean;
|
||||
onViewFullScreen?: () => void;
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: string }): React.ReactElement {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <span className="text-success">✓</span>;
|
||||
case 'running':
|
||||
return (
|
||||
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-accent border-t-transparent" />
|
||||
);
|
||||
case 'failed':
|
||||
return <span className="text-error">✗</span>;
|
||||
default:
|
||||
return <span className="text-text-secondary">○</span>;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${String(ms)}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${(ms / 60000).toFixed(1)}m`;
|
||||
}
|
||||
|
||||
export function WorkflowProgressCard({
|
||||
workflow,
|
||||
onCancel,
|
||||
compact,
|
||||
onViewFullScreen,
|
||||
}: WorkflowProgressCardProps): React.ReactElement {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const completedSteps = workflow.steps.filter(s => s.status === 'completed').length;
|
||||
const totalSteps = workflow.steps.length;
|
||||
const elapsed = Math.max(0, (workflow.completedAt ?? Date.now()) - workflow.startedAt);
|
||||
|
||||
const handleViewFullScreen = (): void => {
|
||||
if (onViewFullScreen) {
|
||||
onViewFullScreen();
|
||||
} else {
|
||||
navigate(`/workflows/runs/${workflow.runId}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Compact mode: single-line summary card
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon status={workflow.status} />
|
||||
<span className="flex-1 truncate text-xs font-medium text-text-primary">
|
||||
{workflow.workflowName}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-secondary">
|
||||
{workflow.isLoop
|
||||
? `Iter ${String(workflow.currentIteration ?? 0)}`
|
||||
: `${String(completedSteps)}/${String(totalSteps)}`}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-tertiary">{formatDuration(elapsed)}</span>
|
||||
{workflow.stale && workflow.status === 'running' && (
|
||||
<span
|
||||
className="text-[10px] text-yellow-400"
|
||||
title="Connection lost — status may be outdated"
|
||||
>
|
||||
!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<button
|
||||
onClick={handleViewFullScreen}
|
||||
className="text-[10px] text-accent hover:text-accent-bright transition-colors"
|
||||
>
|
||||
Open Full View
|
||||
</button>
|
||||
{workflow.status === 'running' && onCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-[10px] text-text-secondary hover:text-error transition-colors ml-auto"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full mode: detailed card
|
||||
return (
|
||||
<div className="my-2 rounded-lg border border-border bg-surface p-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon status={workflow.status} />
|
||||
<span className="font-semibold text-sm text-text-primary">
|
||||
{workflow.status === 'running'
|
||||
? 'Running'
|
||||
: workflow.status === 'completed'
|
||||
? 'Completed'
|
||||
: 'Failed'}{' '}
|
||||
{workflow.workflowName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-text-secondary">
|
||||
{workflow.isLoop ? (
|
||||
<span>
|
||||
Iteration {String(workflow.currentIteration ?? 0)}
|
||||
{workflow.maxIterations ? `/${String(workflow.maxIterations)}` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{String(completedSteps)}/{String(totalSteps)} steps
|
||||
</span>
|
||||
)}
|
||||
<span>{formatDuration(elapsed)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step list */}
|
||||
{!workflow.isLoop && workflow.steps.length > 0 && (
|
||||
<div className="space-y-1 mb-2">
|
||||
{workflow.steps.map(step => (
|
||||
<div key={step.index} className="flex items-center gap-2 text-xs">
|
||||
<StatusIcon status={step.status} />
|
||||
<span
|
||||
className={step.status === 'running' ? 'text-text-primary' : 'text-text-secondary'}
|
||||
>
|
||||
{step.name}
|
||||
</span>
|
||||
{step.duration !== undefined && (
|
||||
<span className="ml-auto text-text-secondary">{formatDuration(step.duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loop iteration display */}
|
||||
{workflow.isLoop && (
|
||||
<div className="mb-2">
|
||||
<LoopIterationView workflow={workflow} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stale indicator — polling lost contact with server */}
|
||||
{workflow.stale && workflow.status === 'running' && (
|
||||
<div className="mb-2 text-xs text-yellow-400 bg-yellow-400/10 rounded p-2">
|
||||
Connection lost — status may be outdated. Retrying...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{workflow.error && (
|
||||
<div className="mb-2 text-xs text-error bg-error/10 rounded p-2">{workflow.error}</div>
|
||||
)}
|
||||
|
||||
{/* Footer buttons */}
|
||||
<div className="flex items-center gap-2 pt-1 border-t border-border">
|
||||
<button
|
||||
onClick={handleViewFullScreen}
|
||||
className="text-xs text-accent hover:text-accent-bright transition-colors"
|
||||
>
|
||||
View Full Screen
|
||||
</button>
|
||||
{workflow.status === 'running' && onCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-xs text-text-secondary hover:text-error transition-colors ml-auto"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
packages/web/src/components/conversations/ConversationItem.tsx
Normal file
205
packages/web/src/components/conversations/ConversationItem.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { useState, useRef, useCallback } from 'react';
|
||||
import { NavLink, useNavigate, useParams } from 'react-router';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { ConversationResponse } from '@/lib/api';
|
||||
import { deleteConversation, updateConversation } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface ConversationItemProps {
|
||||
conversation: ConversationResponse;
|
||||
badge?: number;
|
||||
projectName?: string;
|
||||
status?: 'idle' | 'running' | 'failed';
|
||||
}
|
||||
|
||||
export function ConversationItem({
|
||||
conversation,
|
||||
badge,
|
||||
projectName,
|
||||
status = 'idle',
|
||||
}: ConversationItemProps): React.ReactElement {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const params = useParams<{ conversationId: string }>();
|
||||
|
||||
const displayName = conversation.title
|
||||
? conversation.title.length > 30
|
||||
? conversation.title.slice(0, 30) + '...'
|
||||
: conversation.title
|
||||
: 'Untitled conversation';
|
||||
|
||||
const lastActivity = conversation.last_activity_at
|
||||
? new Date(
|
||||
conversation.last_activity_at.endsWith('Z')
|
||||
? conversation.last_activity_at
|
||||
: conversation.last_activity_at + 'Z'
|
||||
).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: 'No activity';
|
||||
|
||||
const handleDelete = useCallback((): void => {
|
||||
setDeleteError(null);
|
||||
void deleteConversation(conversation.id)
|
||||
.then(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['conversations'] });
|
||||
if (params.conversationId === conversation.platform_conversation_id) {
|
||||
void navigate('/');
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setDeleteError(err.message);
|
||||
});
|
||||
}, [
|
||||
conversation.id,
|
||||
conversation.platform_conversation_id,
|
||||
queryClient,
|
||||
navigate,
|
||||
params.conversationId,
|
||||
]);
|
||||
|
||||
const handleRenameSubmit = useCallback((): void => {
|
||||
const trimmed = editValue.trim();
|
||||
if (trimmed && trimmed !== conversation.title) {
|
||||
void updateConversation(conversation.id, { title: trimmed }).then(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['conversations'] });
|
||||
});
|
||||
}
|
||||
setIsEditing(false);
|
||||
}, [editValue, conversation.id, conversation.title, queryClient]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent): void => {
|
||||
if (e.key === 'Enter') {
|
||||
handleRenameSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsEditing(false);
|
||||
}
|
||||
},
|
||||
[handleRenameSubmit]
|
||||
);
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
to={`/chat/${encodeURIComponent(conversation.platform_conversation_id)}`}
|
||||
className={({ isActive }): string =>
|
||||
cn(
|
||||
'group relative flex min-h-[2.75rem] w-full items-start gap-2 rounded-md px-3 py-2 transition-colors duration-150',
|
||||
isActive ? 'border-l-2 border-l-primary bg-accent-muted' : 'hover:bg-surface-elevated'
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-2 w-2 shrink-0 rounded-full',
|
||||
status === 'running' && 'bg-primary animate-pulse',
|
||||
status === 'failed' && 'bg-destructive',
|
||||
status === 'idle' && 'bg-text-tertiary'
|
||||
)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e): void => {
|
||||
setEditValue(e.target.value);
|
||||
}}
|
||||
onBlur={handleRenameSubmit}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="w-full bg-transparent text-sm text-text-primary outline-none border-b border-primary"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="truncate text-sm text-text-primary"
|
||||
title={conversation.title ?? 'Untitled conversation'}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-[11px] text-text-tertiary">{lastActivity}</span>
|
||||
{projectName && (
|
||||
<span className="truncate text-[10px] text-text-tertiary">{projectName}</span>
|
||||
)}
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<>
|
||||
<div className="absolute right-2 top-2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity duration-150 z-10">
|
||||
<button
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setEditValue(conversation.title ?? '');
|
||||
setIsEditing(true);
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, 0);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-surface-elevated"
|
||||
title="Rename conversation"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-text-tertiary hover:text-primary" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDeleteError(null);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-surface-elevated"
|
||||
title="Delete conversation"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-text-tertiary hover:text-error" />
|
||||
</button>
|
||||
</div>
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete conversation?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this conversation and its messages. This action
|
||||
cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{deleteError && <p className="text-sm text-error px-1">{deleteError}</p>}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
{badge !== undefined && badge > 0 && (
|
||||
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-error text-[10px] font-semibold text-white px-1">
|
||||
{badge > 99 ? '99+' : String(badge)}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
92
packages/web/src/components/layout/Header.tsx
Normal file
92
packages/web/src/components/layout/Header.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { useState } from 'react';
|
||||
import { ExternalLink, Copy, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface HeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
projectName?: string;
|
||||
connected?: boolean;
|
||||
}
|
||||
|
||||
function smartPath(fullPath: string): string {
|
||||
const segments = fullPath.split('/').filter(Boolean);
|
||||
if (segments.length <= 3) return fullPath;
|
||||
return '.../' + segments.slice(-3).join('/');
|
||||
}
|
||||
|
||||
export function Header({
|
||||
title,
|
||||
subtitle,
|
||||
projectName,
|
||||
connected,
|
||||
}: HeaderProps): React.ReactElement {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const openInVSCode = (): void => {
|
||||
if (subtitle) {
|
||||
// Normalize backslashes to forward slashes for the vscode:// URI
|
||||
const normalizedPath = subtitle.replace(/\\/g, '/');
|
||||
window.open(`vscode://file/${normalizedPath}`, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const copyPath = (): void => {
|
||||
if (subtitle) {
|
||||
void navigator.clipboard.writeText(subtitle).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex h-12 shrink-0 items-center border-b border-border px-6">
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
||||
<h1 className="text-base font-semibold text-text-primary">{title}</h1>
|
||||
{subtitle ? (
|
||||
<button
|
||||
onClick={copyPath}
|
||||
className="group flex items-center gap-1 text-xs text-text-secondary truncate max-w-sm hover:text-text-primary transition-colors text-left"
|
||||
title={subtitle}
|
||||
>
|
||||
<span className="truncate">{smartPath(subtitle)}</span>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 shrink-0 text-success" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3 shrink-0 opacity-0 group-hover:opacity-100" />
|
||||
)}
|
||||
</button>
|
||||
) : projectName ? (
|
||||
<span className="text-xs text-text-secondary">{projectName}</span>
|
||||
) : connected !== undefined ? (
|
||||
<span className="text-xs text-text-tertiary italic">No project</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{subtitle && (
|
||||
<button
|
||||
onClick={openInVSCode}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-text-secondary hover:bg-surface hover:text-text-primary transition-colors"
|
||||
title="Open in VS Code"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
<span>Open in IDE</span>
|
||||
</button>
|
||||
)}
|
||||
{connected !== undefined && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn('h-2 w-2 rounded-full', connected ? 'bg-success' : 'bg-text-tertiary')}
|
||||
/>
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
13
packages/web/src/components/layout/Layout.tsx
Normal file
13
packages/web/src/components/layout/Layout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Outlet } from 'react-router';
|
||||
import { Sidebar } from './Sidebar';
|
||||
|
||||
export function Layout(): React.ReactElement {
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar />
|
||||
<main className="flex flex-1 flex-col overflow-hidden">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
312
packages/web/src/components/layout/Sidebar.tsx
Normal file
312
packages/web/src/components/layout/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import { useState, useRef, useMemo, useEffect, useCallback } from 'react';
|
||||
import { NavLink } from 'react-router';
|
||||
import { Plus, Settings, Loader2, Workflow, Hammer } from 'lucide-react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { SearchBar } from '@/components/sidebar/SearchBar';
|
||||
import { ProjectSelector } from '@/components/sidebar/ProjectSelector';
|
||||
import { ProjectDetail } from '@/components/sidebar/ProjectDetail';
|
||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
||||
import { listCodebases, addCodebase } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const SIDEBAR_MIN = 240;
|
||||
const SIDEBAR_MAX = 400;
|
||||
const SIDEBAR_DEFAULT = 260;
|
||||
const STORAGE_KEY = 'archon-sidebar-width';
|
||||
const PROJECT_STORAGE_KEY = 'archon-selected-project';
|
||||
|
||||
function getInitialWidth(): number {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = Number(stored);
|
||||
if (parsed >= SIDEBAR_MIN && parsed <= SIDEBAR_MAX) return parsed;
|
||||
}
|
||||
return SIDEBAR_DEFAULT;
|
||||
}
|
||||
|
||||
function getInitialProjectId(): string | null {
|
||||
return localStorage.getItem(PROJECT_STORAGE_KEY);
|
||||
}
|
||||
|
||||
const navLinkClass = ({ isActive }: { isActive: boolean }): string =>
|
||||
cn(
|
||||
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150',
|
||||
isActive
|
||||
? 'border-l-2 border-primary bg-accent-muted text-primary'
|
||||
: 'text-text-secondary hover:bg-surface-elevated hover:text-text-primary'
|
||||
);
|
||||
|
||||
export function Sidebar(): React.ReactElement {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [width, setWidth] = useState(getInitialWidth);
|
||||
const isResizing = useRef(false);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(getInitialProjectId);
|
||||
|
||||
// Add-project state
|
||||
const [showAddInput, setShowAddInput] = useState(false);
|
||||
const [addValue, setAddValue] = useState('');
|
||||
const [addLoading, setAddLoading] = useState(false);
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
const addInputRef = useRef<HTMLInputElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: codebases, isLoading: isLoadingCodebases } = useQuery({
|
||||
queryKey: ['codebases'],
|
||||
queryFn: listCodebases,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const selectedProject = codebases?.find(cb => cb.id === selectedProjectId) ?? null;
|
||||
|
||||
// Auto-select: clear stale selection or auto-select first project
|
||||
useEffect(() => {
|
||||
if (!codebases) return;
|
||||
if (selectedProjectId && !codebases.some(cb => cb.id === selectedProjectId)) {
|
||||
// Selected project was deleted — clear it
|
||||
setSelectedProjectId(null);
|
||||
localStorage.removeItem(PROJECT_STORAGE_KEY);
|
||||
} else if (!selectedProjectId && codebases.length > 0) {
|
||||
// No project selected but projects exist — auto-select first
|
||||
const firstId = codebases[0].id;
|
||||
setSelectedProjectId(firstId);
|
||||
localStorage.setItem(PROJECT_STORAGE_KEY, firstId);
|
||||
}
|
||||
}, [codebases, selectedProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, String(width));
|
||||
}, [width]);
|
||||
|
||||
// Focus input when shown
|
||||
useEffect(() => {
|
||||
if (showAddInput) {
|
||||
addInputRef.current?.focus();
|
||||
}
|
||||
}, [showAddInput]);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
e.preventDefault();
|
||||
isResizing.current = true;
|
||||
const startX = e.clientX;
|
||||
const startWidth = width;
|
||||
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.cursor = 'col-resize';
|
||||
|
||||
const onMouseMove = (moveEvent: MouseEvent): void => {
|
||||
const newWidth = Math.min(
|
||||
SIDEBAR_MAX,
|
||||
Math.max(SIDEBAR_MIN, startWidth + moveEvent.clientX - startX)
|
||||
);
|
||||
setWidth(newWidth);
|
||||
};
|
||||
|
||||
const onMouseUp = (): void => {
|
||||
isResizing.current = false;
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = '';
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
},
|
||||
[width]
|
||||
);
|
||||
|
||||
const handleSelectProject = useCallback((id: string): void => {
|
||||
setSelectedProjectId(id);
|
||||
localStorage.setItem(PROJECT_STORAGE_KEY, id);
|
||||
}, []);
|
||||
|
||||
const handleAddSubmit = useCallback((): void => {
|
||||
const trimmed = addValue.trim();
|
||||
if (!trimmed || addLoading) return;
|
||||
|
||||
setAddLoading(true);
|
||||
setAddError(null);
|
||||
|
||||
// Detect: starts with / or ~ → local path; otherwise → URL
|
||||
const input =
|
||||
trimmed.startsWith('/') || trimmed.startsWith('~') ? { path: trimmed } : { url: trimmed };
|
||||
|
||||
void addCodebase(input)
|
||||
.then(codebase => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['codebases'] });
|
||||
handleSelectProject(codebase.id);
|
||||
setShowAddInput(false);
|
||||
setAddValue('');
|
||||
setAddError(null);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setAddError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setAddLoading(false);
|
||||
});
|
||||
}, [addValue, addLoading, queryClient, handleSelectProject]);
|
||||
|
||||
const handleAddKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent): void => {
|
||||
if (e.key === 'Enter') {
|
||||
handleAddSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowAddInput(false);
|
||||
setAddValue('');
|
||||
setAddError(null);
|
||||
}
|
||||
},
|
||||
[handleAddSubmit]
|
||||
);
|
||||
|
||||
const shortcuts = useMemo(
|
||||
() => ({
|
||||
'/': (): void => searchInputRef.current?.focus(),
|
||||
Escape: (): void => {
|
||||
setSearchQuery('');
|
||||
searchInputRef.current?.blur();
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
useKeyboardShortcuts(shortcuts);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="relative flex h-full flex-col border-r border-border bg-surface"
|
||||
style={{ width: `${String(width)}px` }}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
|
||||
<span className="text-sm font-semibold text-primary-foreground">A</span>
|
||||
</div>
|
||||
<span className="text-base font-semibold text-text-primary">Archon</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-border" />
|
||||
|
||||
{/* Project Selector */}
|
||||
<div className="px-2 py-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-text-tertiary">
|
||||
Projects
|
||||
</span>
|
||||
<button
|
||||
onClick={(): void => {
|
||||
setShowAddInput(prev => !prev);
|
||||
setAddError(null);
|
||||
setAddValue('');
|
||||
}}
|
||||
className="p-0.5 rounded hover:bg-surface-elevated transition-colors"
|
||||
title="Add project"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-text-tertiary hover:text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddInput && (
|
||||
<div className="mt-1.5 px-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
ref={addInputRef}
|
||||
value={addValue}
|
||||
onChange={(e): void => {
|
||||
setAddValue(e.target.value);
|
||||
}}
|
||||
onKeyDown={handleAddKeyDown}
|
||||
onBlur={(): void => {
|
||||
// Close on blur only if empty and no error
|
||||
if (!addValue.trim() && !addError) {
|
||||
setShowAddInput(false);
|
||||
}
|
||||
}}
|
||||
placeholder="GitHub URL or local path"
|
||||
disabled={addLoading}
|
||||
className="w-full rounded-md border border-border bg-surface-elevated px-2 py-1 text-xs text-text-primary placeholder:text-text-tertiary focus:border-primary focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
{addLoading && <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-primary" />}
|
||||
</div>
|
||||
{addError && <p className="mt-1 text-[10px] text-error line-clamp-2">{addError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProjectSelector
|
||||
projects={codebases ?? []}
|
||||
selectedProjectId={selectedProjectId}
|
||||
onSelectProject={handleSelectProject}
|
||||
isLoading={isLoadingCodebases}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-border" />
|
||||
|
||||
{/* Project-scoped content */}
|
||||
{selectedProjectId ? (
|
||||
<>
|
||||
{/* Search */}
|
||||
<div className="px-3 py-2">
|
||||
<SearchBar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search..."
|
||||
inputRef={searchInputRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scoped content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full px-2 pb-2">
|
||||
<ProjectDetail
|
||||
codebaseId={selectedProjectId}
|
||||
projectName={selectedProject?.name ?? ''}
|
||||
repositoryUrl={selectedProject?.repository_url}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4">
|
||||
<span className="text-xs text-text-tertiary text-center">
|
||||
{codebases && codebases.length > 0 ? 'Select a project' : 'Click + to add a repository'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator className="bg-border" />
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex flex-col gap-1 p-2">
|
||||
<NavLink to="/workflows" end className={navLinkClass}>
|
||||
<Workflow className="h-4 w-4" />
|
||||
Workflows
|
||||
</NavLink>
|
||||
<NavLink to="/workflows/builder" className={navLinkClass}>
|
||||
<Hammer className="h-4 w-4" />
|
||||
Workflow Builder
|
||||
<span className="ml-auto rounded-full bg-accent-muted px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary">
|
||||
Soon
|
||||
</span>
|
||||
</NavLink>
|
||||
<NavLink to="/settings" className={navLinkClass}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</NavLink>
|
||||
</nav>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize bg-border/50 hover:bg-primary/40 transition-colors"
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
158
packages/web/src/components/sidebar/ProjectDetail.tsx
Normal file
158
packages/web/src/components/sidebar/ProjectDetail.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { listConversations, listWorkflowRuns, createConversation } from '@/lib/api';
|
||||
import type { WorkflowRunResponse } from '@/lib/api';
|
||||
import { ConversationItem } from '@/components/conversations/ConversationItem';
|
||||
import { WorkflowInvoker } from '@/components/sidebar/WorkflowInvoker';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ProjectDetailProps {
|
||||
codebaseId: string;
|
||||
projectName: string;
|
||||
repositoryUrl?: string | null;
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
function RunStatusBadge({ status }: { status: string }): React.ReactElement {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium',
|
||||
status === 'running' && 'bg-primary/10 text-primary',
|
||||
status === 'completed' && 'bg-success/10 text-success',
|
||||
status === 'failed' && 'bg-error/10 text-error'
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ensureUtc(timestamp: string): string {
|
||||
return timestamp.endsWith('Z') ? timestamp : timestamp + 'Z';
|
||||
}
|
||||
|
||||
function formatDuration(startedAt: string, completedAt: string | null): string {
|
||||
const start = new Date(ensureUtc(startedAt)).getTime();
|
||||
const end = completedAt ? new Date(ensureUtc(completedAt)).getTime() : Date.now();
|
||||
const ms = end - start;
|
||||
if (ms < 1000) return `${String(ms)}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${(ms / 60000).toFixed(1)}m`;
|
||||
}
|
||||
|
||||
export function ProjectDetail({
|
||||
codebaseId,
|
||||
projectName,
|
||||
repositoryUrl,
|
||||
searchQuery,
|
||||
}: ProjectDetailProps): React.ReactElement {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: conversations } = useQuery({
|
||||
queryKey: ['conversations', { codebaseId }],
|
||||
queryFn: () => listConversations(codebaseId),
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
const { data: runs } = useQuery({
|
||||
queryKey: ['workflow-runs', { codebaseId }],
|
||||
queryFn: () => listWorkflowRuns({ codebaseId, limit: 20 }),
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
const handleNewChat = async (): Promise<void> => {
|
||||
try {
|
||||
const { conversationId } = await createConversation(codebaseId);
|
||||
navigate(`/chat/${conversationId}`);
|
||||
} catch (error) {
|
||||
console.error('[ProjectDetail] Failed to create conversation', { error });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunClick = (run: WorkflowRunResponse): void => {
|
||||
navigate(`/workflows/runs/${run.id}`);
|
||||
};
|
||||
|
||||
// Filter conversations by search
|
||||
const filteredConversations = conversations?.filter(conv => {
|
||||
if (!searchQuery) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (conv.title ?? conv.platform_conversation_id).toLowerCase().includes(query);
|
||||
});
|
||||
|
||||
// Filter and sort runs by search and status
|
||||
const sortedRuns = runs
|
||||
?.filter(run => {
|
||||
if (!searchQuery) return true;
|
||||
return run.workflow_name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const priority: Record<string, number> = { failed: 0, running: 1, completed: 2 };
|
||||
return (priority[a.status] ?? 3) - (priority[b.status] ?? 3);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="px-1">
|
||||
<h3 className="text-sm font-semibold text-text-primary truncate">{projectName}</h3>
|
||||
{repositoryUrl && (
|
||||
<p className="text-[10px] text-text-tertiary truncate">{repositoryUrl}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
className="mx-1 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
New Chat
|
||||
</button>
|
||||
|
||||
<WorkflowInvoker codebaseId={codebaseId} />
|
||||
|
||||
{/* Conversations section */}
|
||||
<div>
|
||||
<span className="px-1 text-[11px] font-semibold uppercase tracking-wider text-text-tertiary">
|
||||
Conversations
|
||||
</span>
|
||||
<div className="mt-1 flex flex-col gap-0.5">
|
||||
{filteredConversations && filteredConversations.length > 0 ? (
|
||||
filteredConversations.map(conv => (
|
||||
<ConversationItem key={conv.id} conversation={conv} />
|
||||
))
|
||||
) : (
|
||||
<span className="px-1 text-xs text-text-tertiary">No conversations</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow runs section */}
|
||||
<div>
|
||||
<span className="px-1 text-[11px] font-semibold uppercase tracking-wider text-text-tertiary">
|
||||
Workflow Runs
|
||||
</span>
|
||||
<div className="mt-1 flex flex-col gap-0.5">
|
||||
{sortedRuns && sortedRuns.length > 0 ? (
|
||||
sortedRuns.map(run => (
|
||||
<button
|
||||
key={run.id}
|
||||
onClick={(): void => {
|
||||
handleRunClick(run);
|
||||
}}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-surface-elevated transition-colors w-full text-left"
|
||||
>
|
||||
<span className="truncate flex-1 text-text-primary">{run.workflow_name}</span>
|
||||
<RunStatusBadge status={run.status} />
|
||||
<span className="text-text-tertiary shrink-0">
|
||||
{formatDuration(run.started_at, run.completed_at)}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="px-1 text-xs text-text-tertiary">No workflow runs</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
packages/web/src/components/sidebar/ProjectSelector.tsx
Normal file
134
packages/web/src/components/sidebar/ProjectSelector.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { FolderGit2, Trash2 } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { CodebaseResponse } from '@/lib/api';
|
||||
import { deleteCodebase } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface ProjectSelectorProps {
|
||||
projects: CodebaseResponse[];
|
||||
selectedProjectId: string | null;
|
||||
onSelectProject: (id: string) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function ProjectSelector({
|
||||
projects,
|
||||
selectedProjectId,
|
||||
onSelectProject,
|
||||
isLoading,
|
||||
}: ProjectSelectorProps): React.ReactElement {
|
||||
const queryClient = useQueryClient();
|
||||
const [deleteTarget, setDeleteTarget] = useState<CodebaseResponse | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = useCallback((): void => {
|
||||
if (!deleteTarget) return;
|
||||
const id = deleteTarget.id;
|
||||
setDeleteError(null);
|
||||
void deleteCodebase(id)
|
||||
.then(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['codebases'] });
|
||||
setDeleteTarget(null);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setDeleteError(err.message);
|
||||
});
|
||||
}, [deleteTarget, queryClient]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<span className="text-xs text-text-tertiary">Loading...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-6">
|
||||
<FolderGit2 className="h-8 w-8 text-text-tertiary" />
|
||||
<span className="text-xs text-text-tertiary">No projects yet</span>
|
||||
<span className="text-[10px] text-text-tertiary">Click + to add a repository</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-0.5 mt-1">
|
||||
{projects.map(project => (
|
||||
<div key={project.id} className="group relative">
|
||||
<button
|
||||
onClick={(): void => {
|
||||
onSelectProject(project.id);
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md px-3 py-1.5 text-left transition-colors w-full',
|
||||
selectedProjectId === project.id
|
||||
? 'border-l-2 border-primary bg-accent-muted text-primary'
|
||||
: 'text-text-secondary hover:bg-surface-elevated hover:text-text-primary'
|
||||
)}
|
||||
>
|
||||
<FolderGit2 className="h-4 w-4 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{project.name}</span>
|
||||
{project.repository_url && (
|
||||
<span className="truncate text-[10px] text-text-tertiary">
|
||||
{project.repository_url}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
setDeleteError(null);
|
||||
setDeleteTarget(project);
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-elevated"
|
||||
title="Remove project"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-text-tertiary hover:text-error" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
setDeleteTarget(null);
|
||||
setDeleteError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove project?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove <strong>{deleteTarget?.name}</strong> from Archon, delete its
|
||||
workspace directory and worktrees. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{deleteError && <p className="text-sm text-error px-1">{deleteError}</p>}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>Remove</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
42
packages/web/src/components/sidebar/SearchBar.tsx
Normal file
42
packages/web/src/components/sidebar/SearchBar.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Search, X } from 'lucide-react';
|
||||
import type { RefObject } from 'react';
|
||||
|
||||
interface SearchBarProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
inputRef?: RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
inputRef,
|
||||
}: SearchBarProps): React.ReactElement {
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<Search className="absolute left-2 h-3.5 w-3.5 text-text-tertiary" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e): void => {
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface pl-7 pr-7 text-sm text-text-primary placeholder:text-text-tertiary outline-none focus:border-primary transition-colors"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
onClick={(): void => {
|
||||
onChange('');
|
||||
}}
|
||||
className="absolute right-2 p-0.5 rounded hover:bg-surface-elevated"
|
||||
>
|
||||
<X className="h-3 w-3 text-text-tertiary" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
packages/web/src/components/sidebar/WorkflowInvoker.tsx
Normal file
117
packages/web/src/components/sidebar/WorkflowInvoker.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2, X } from 'lucide-react';
|
||||
import { listWorkflows, createConversation, runWorkflow } from '@/lib/api';
|
||||
|
||||
interface WorkflowInvokerProps {
|
||||
codebaseId: string;
|
||||
}
|
||||
|
||||
export function WorkflowInvoker({ codebaseId }: WorkflowInvokerProps): React.ReactElement | null {
|
||||
const navigate = useNavigate();
|
||||
const [selectedWorkflow, setSelectedWorkflow] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState('');
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: workflows } = useQuery({
|
||||
queryKey: ['workflows'],
|
||||
queryFn: () => listWorkflows(),
|
||||
});
|
||||
|
||||
if (!workflows || workflows.length === 0) return null;
|
||||
|
||||
const handleCancel = (): void => {
|
||||
setSelectedWorkflow(null);
|
||||
setMessage('');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleRun = async (): Promise<void> => {
|
||||
if (!selectedWorkflow || !message.trim() || running) return;
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { conversationId } = await createConversation(codebaseId);
|
||||
await runWorkflow(selectedWorkflow, conversationId, message.trim());
|
||||
setSelectedWorkflow(null);
|
||||
setMessage('');
|
||||
navigate(`/chat/${conversationId}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to start workflow');
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedWorkflow) {
|
||||
return (
|
||||
<div className="mx-1">
|
||||
<select
|
||||
value=""
|
||||
onChange={(e): void => {
|
||||
setSelectedWorkflow(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
className="w-full rounded-md border border-border bg-surface-elevated px-2 py-1.5 text-xs text-text-secondary cursor-pointer focus:border-primary focus:outline-none"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Run workflow...
|
||||
</option>
|
||||
{workflows.map(wf => (
|
||||
<option key={wf.name} value={wf.name}>
|
||||
{wf.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-1 rounded-md border border-border bg-surface-elevated p-2">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs font-medium text-text-primary">{selectedWorkflow}</span>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="p-0.5 rounded hover:bg-surface transition-colors"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-3 w-3 text-text-tertiary" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e): void => {
|
||||
setMessage(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleRun();
|
||||
}
|
||||
if (e.key === 'Escape') handleCancel();
|
||||
}}
|
||||
placeholder="Enter message..."
|
||||
disabled={running}
|
||||
className="w-full rounded-md border border-border bg-surface px-2 py-1 text-xs text-text-primary placeholder:text-text-tertiary focus:border-primary focus:outline-none disabled:opacity-50"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2 mt-1.5">
|
||||
{error && <span className="text-[10px] text-error flex-1 line-clamp-1">{error}</span>}
|
||||
<button
|
||||
onClick={(): void => {
|
||||
void handleRun();
|
||||
}}
|
||||
disabled={running || !message.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground hover:bg-accent-hover transition-colors disabled:opacity-50"
|
||||
>
|
||||
{running && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{running ? 'Starting...' : 'Run'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
packages/web/src/components/ui/alert-dialog.tsx
Normal file
121
packages/web/src/components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import * as React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-surface-elevated p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
function AlertDialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
}
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||
|
||||
function AlertDialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold text-text-primary', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-text-secondary', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: 'destructive' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
46
packages/web/src/components/ui/badge.tsx
Normal file
46
packages/web/src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Slot } from 'radix-ui';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||
secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||
ghost: '[a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 [a&]:hover:underline',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : 'span';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
62
packages/web/src/components/ui/button.tsx
Normal file
62
packages/web/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Slot } from 'radix-ui';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
75
packages/web/src/components/ui/card.tsx
Normal file
75
packages/web/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn('leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
|
||||
21
packages/web/src/components/ui/collapsible.tsx
Normal file
21
packages/web/src/components/ui/collapsible.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
'use client';
|
||||
|
||||
import { Collapsible as CollapsiblePrimitive } from 'radix-ui';
|
||||
|
||||
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />;
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
21
packages/web/src/components/ui/input.tsx
Normal file
21
packages/web/src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
54
packages/web/src/components/ui/scroll-area.tsx
Normal file
54
packages/web/src/components/ui/scroll-area.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import * as React from 'react';
|
||||
import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn('relative', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none p-px transition-colors select-none',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
28
packages/web/src/components/ui/separator.tsx
Normal file
28
packages/web/src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Separator as SeparatorPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
18
packages/web/src/components/ui/textarea.tsx
Normal file
18
packages/web/src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
51
packages/web/src/components/ui/tooltip.tsx
Normal file
51
packages/web/src/components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import * as React from 'react';
|
||||
import { Tooltip as TooltipPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
63
packages/web/src/components/workflows/ArtifactSummary.tsx
Normal file
63
packages/web/src/components/workflows/ArtifactSummary.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type { WorkflowArtifact } from '@/lib/types';
|
||||
|
||||
interface ArtifactSummaryProps {
|
||||
artifacts: WorkflowArtifact[];
|
||||
}
|
||||
|
||||
function ArtifactIcon({ type }: { type: string }): React.ReactElement {
|
||||
switch (type) {
|
||||
case 'pr':
|
||||
return <span className="text-accent">PR</span>;
|
||||
case 'commit':
|
||||
return <span className="text-success">C</span>;
|
||||
case 'branch':
|
||||
return <span className="text-text-secondary">B</span>;
|
||||
case 'file':
|
||||
case 'file_created':
|
||||
case 'file_modified':
|
||||
return <span className="text-text-secondary">F</span>;
|
||||
default:
|
||||
return <span className="text-text-secondary">*</span>;
|
||||
}
|
||||
}
|
||||
|
||||
export function ArtifactSummary({ artifacts }: ArtifactSummaryProps): React.ReactElement {
|
||||
if (artifacts.length === 0) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-3">
|
||||
<h4 className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-2">
|
||||
Artifacts
|
||||
</h4>
|
||||
<div className="space-y-1.5">
|
||||
{artifacts.map((artifact, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-sm">
|
||||
<ArtifactIcon type={artifact.type} />
|
||||
{artifact.url ? (
|
||||
<a
|
||||
href={artifact.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:text-accent-bright transition-colors truncate"
|
||||
>
|
||||
{artifact.label}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-text-primary truncate">{artifact.label}</span>
|
||||
)}
|
||||
{artifact.path && (
|
||||
<span
|
||||
className="text-xs text-text-secondary ml-auto shrink-0 truncate max-w-[200px]"
|
||||
title={artifact.path}
|
||||
>
|
||||
{artifact.path}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
packages/web/src/components/workflows/LoopIterationView.tsx
Normal file
67
packages/web/src/components/workflows/LoopIterationView.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import type { WorkflowState } from '@/lib/types';
|
||||
|
||||
interface LoopIterationViewProps {
|
||||
workflow: WorkflowState;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${String(ms)}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${(ms / 60000).toFixed(1)}m`;
|
||||
}
|
||||
|
||||
export function LoopIterationView({ workflow }: LoopIterationViewProps): React.ReactElement {
|
||||
const elapsed = (workflow.completedAt ?? Date.now()) - workflow.startedAt;
|
||||
const current = workflow.currentIteration ?? 0;
|
||||
const max = workflow.maxIterations;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Iteration counter */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-primary font-medium">
|
||||
Iteration {String(current)}
|
||||
{max ? ` of ${String(max)}` : ''}
|
||||
</span>
|
||||
<span className="text-xs text-text-secondary">{formatDuration(elapsed)}</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{max && max > 0 && (
|
||||
<div className="h-1.5 rounded-full bg-surface-inset overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
workflow.status === 'failed' ? 'bg-error' : 'bg-accent'
|
||||
}`}
|
||||
style={{ width: `${String(Math.min(100, (current / max) * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<div className="text-xs text-text-secondary">
|
||||
{workflow.status === 'running' && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2 w-2 animate-spin rounded-full border border-accent border-t-transparent" />
|
||||
Running iteration...
|
||||
</span>
|
||||
)}
|
||||
{workflow.status === 'completed' && (
|
||||
<span className="text-success">Loop completed after {String(current)} iterations</span>
|
||||
)}
|
||||
{workflow.status === 'failed' && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-error">
|
||||
{max && current >= max
|
||||
? `Reached max iterations (${String(max)}). Consider increasing the limit.`
|
||||
: 'Loop failed'}
|
||||
</span>
|
||||
{workflow.error && (
|
||||
<div className="text-error bg-error/10 rounded p-1.5">{workflow.error}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
packages/web/src/components/workflows/ParallelBlockView.tsx
Normal file
77
packages/web/src/components/workflows/ParallelBlockView.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import type { ParallelAgentState } from '@/lib/types';
|
||||
|
||||
interface ParallelBlockViewProps {
|
||||
agents: ParallelAgentState[];
|
||||
stepName: string;
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: string }): React.ReactElement {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <span className="text-success text-sm">✓</span>;
|
||||
case 'running':
|
||||
return (
|
||||
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-accent border-t-transparent" />
|
||||
);
|
||||
case 'failed':
|
||||
return <span className="text-error text-sm">✗</span>;
|
||||
default:
|
||||
return <span className="text-text-secondary text-sm">○</span>;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${String(ms)}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${(ms / 60000).toFixed(1)}m`;
|
||||
}
|
||||
|
||||
function overallStatus(agents: ParallelAgentState[]): string {
|
||||
if (agents.some(a => a.status === 'failed')) return 'failed';
|
||||
if (agents.some(a => a.status === 'running')) return 'running';
|
||||
if (agents.every(a => a.status === 'completed')) return 'completed';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export function ParallelBlockView({
|
||||
agents,
|
||||
stepName,
|
||||
}: ParallelBlockViewProps): React.ReactElement {
|
||||
const status = overallStatus(agents);
|
||||
const completed = agents.filter(a => a.status === 'completed').length;
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{/* Parent row */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<StatusIcon status={status} />
|
||||
<span className="truncate flex-1">{stepName}</span>
|
||||
<span className="text-xs text-text-secondary shrink-0">
|
||||
({String(completed)}/{String(agents.length)} agents)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Nested agent list */}
|
||||
<div className="ml-4 border-l border-border pl-3 space-y-0.5">
|
||||
{agents.map(agent => (
|
||||
<div key={agent.index} className="flex items-center gap-2 text-xs">
|
||||
<StatusIcon status={agent.status} />
|
||||
<span
|
||||
className={agent.status === 'running' ? 'text-text-primary' : 'text-text-secondary'}
|
||||
>
|
||||
{agent.name}
|
||||
</span>
|
||||
{agent.duration !== undefined && (
|
||||
<span className="ml-auto text-text-secondary">{formatDuration(agent.duration)}</span>
|
||||
)}
|
||||
{agent.error && (
|
||||
<span className="ml-auto text-error truncate max-w-[150px]" title={agent.error}>
|
||||
{agent.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
packages/web/src/components/workflows/StepLogs.tsx
Normal file
77
packages/web/src/components/workflows/StepLogs.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { useRef } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useAutoScroll } from '@/hooks/useAutoScroll';
|
||||
|
||||
interface StepLogsProps {
|
||||
runId: string;
|
||||
stepIndex: number;
|
||||
lines?: string[];
|
||||
}
|
||||
|
||||
export function StepLogs({ runId, stepIndex, lines = [] }: StepLogsProps): React.ReactElement {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const { isAtBottom, scrollToBottom } = useAutoScroll(containerRef, [lines.length]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: lines.length,
|
||||
getScrollElement: () => containerRef.current,
|
||||
estimateSize: () => 24,
|
||||
overscan: 20,
|
||||
});
|
||||
|
||||
if (lines.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto p-4 font-mono text-sm bg-surface-inset">
|
||||
<div className="text-text-secondary text-xs mb-2">
|
||||
Step {String(stepIndex)} logs · Run {runId.slice(0, 8)}
|
||||
</div>
|
||||
<div className="text-text-secondary italic">
|
||||
Live log output will appear here during workflow execution.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-surface-inset relative">
|
||||
<div className="text-text-secondary text-xs px-4 pt-3 pb-1">
|
||||
Step {String(stepIndex)} logs · Run {runId.slice(0, 8)} ·{' '}
|
||||
{String(lines.length)} lines
|
||||
</div>
|
||||
<div ref={containerRef} className="flex-1 overflow-auto px-4 pb-4 font-mono text-sm">
|
||||
<div
|
||||
style={{
|
||||
height: `${String(virtualizer.getTotalSize())}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map(virtualRow => (
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${String(virtualRow.size)}px`,
|
||||
transform: `translateY(${String(virtualRow.start)}px)`,
|
||||
}}
|
||||
className="text-text-primary whitespace-pre-wrap break-all"
|
||||
>
|
||||
{lines[virtualRow.index]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!isAtBottom && (
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className="absolute bottom-4 right-4 bg-accent text-white text-xs px-3 py-1.5 rounded-full shadow-lg hover:bg-accent-bright transition-colors"
|
||||
>
|
||||
Jump to bottom
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
packages/web/src/components/workflows/StepProgress.tsx
Normal file
67
packages/web/src/components/workflows/StepProgress.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { ParallelBlockView } from './ParallelBlockView';
|
||||
import type { WorkflowStepState } from '@/lib/types';
|
||||
|
||||
interface StepProgressProps {
|
||||
steps: WorkflowStepState[];
|
||||
activeStepIndex: number;
|
||||
onStepClick: (index: number) => void;
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: string }): React.ReactElement {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <span className="text-success text-sm">✓</span>;
|
||||
case 'running':
|
||||
return (
|
||||
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-accent border-t-transparent" />
|
||||
);
|
||||
case 'failed':
|
||||
return <span className="text-error text-sm">✗</span>;
|
||||
default:
|
||||
return <span className="text-text-secondary text-sm">○</span>;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${String(ms)}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${(ms / 60000).toFixed(1)}m`;
|
||||
}
|
||||
|
||||
export function StepProgress({
|
||||
steps,
|
||||
activeStepIndex,
|
||||
onStepClick,
|
||||
}: StepProgressProps): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-1 p-2">
|
||||
{steps.map(step => (
|
||||
<button
|
||||
key={step.index}
|
||||
onClick={(): void => {
|
||||
onStepClick(step.index);
|
||||
}}
|
||||
className={`w-full text-left px-2 py-1.5 rounded transition-colors ${
|
||||
step.index === activeStepIndex
|
||||
? 'bg-accent/10 border-l-2 border-accent'
|
||||
: 'hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{step.agents && step.agents.length > 0 ? (
|
||||
<ParallelBlockView agents={step.agents} stepName={step.name} />
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<StatusIcon status={step.status} />
|
||||
<span className="truncate flex-1">{step.name}</span>
|
||||
{step.duration !== undefined && (
|
||||
<span className="text-xs text-text-secondary shrink-0">
|
||||
{formatDuration(step.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
272
packages/web/src/components/workflows/WorkflowExecution.tsx
Normal file
272
packages/web/src/components/workflows/WorkflowExecution.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { StepProgress } from './StepProgress';
|
||||
import { StepLogs } from './StepLogs';
|
||||
import { WorkflowLogs } from './WorkflowLogs';
|
||||
import { ArtifactSummary } from './ArtifactSummary';
|
||||
import { useWorkflowStatus } from '@/hooks/useWorkflowStatus';
|
||||
import { getWorkflowRun, getWorkflowRunByWorker, getCodebase } from '@/lib/api';
|
||||
import type { WorkflowState, ArtifactType } from '@/lib/types';
|
||||
|
||||
function ensureUtc(timestamp: string): string {
|
||||
return timestamp.endsWith('Z') ? timestamp : timestamp + 'Z';
|
||||
}
|
||||
|
||||
interface WorkflowExecutionProps {
|
||||
runId: string;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }): React.ReactElement {
|
||||
const colors: Record<string, string> = {
|
||||
pending: 'bg-accent/20 text-accent',
|
||||
running: 'bg-accent/20 text-accent',
|
||||
completed: 'bg-success/20 text-success',
|
||||
failed: 'bg-error/20 text-error',
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-medium ${colors[status] ?? 'bg-surface text-text-secondary'}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${String(ms)}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${(ms / 60000).toFixed(1)}m`;
|
||||
}
|
||||
|
||||
export function WorkflowExecution({ runId }: WorkflowExecutionProps): React.ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const { workflows, handlers: workflowHandlers } = useWorkflowStatus();
|
||||
const [selectedStep, setSelectedStep] = useState(0);
|
||||
const [initialData, setInitialData] = useState<WorkflowState | null>(null);
|
||||
const [workerPlatformId, setWorkerPlatformId] = useState<string | null>(null);
|
||||
const [parentPlatformId, setParentPlatformId] = useState<string | null>(null);
|
||||
const [codebaseName, setCodebaseName] = useState<string | null>(null);
|
||||
const [workerRunId, setWorkerRunId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch initial data
|
||||
useEffect(() => {
|
||||
getWorkflowRun(runId)
|
||||
.then(data => {
|
||||
if (data.run.worker_platform_id) {
|
||||
setWorkerPlatformId(data.run.worker_platform_id);
|
||||
}
|
||||
if (data.run.parent_platform_id) {
|
||||
setParentPlatformId(data.run.parent_platform_id);
|
||||
}
|
||||
setInitialData({
|
||||
runId: data.run.id,
|
||||
workflowName: data.run.workflow_name,
|
||||
status: data.run.status,
|
||||
steps: ((): {
|
||||
index: number;
|
||||
name: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
duration?: number;
|
||||
}[] => {
|
||||
const stepMap = new Map<
|
||||
number,
|
||||
{
|
||||
index: number;
|
||||
name: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
duration?: number;
|
||||
}
|
||||
>();
|
||||
for (const e of data.events.filter(
|
||||
ev => ev.event_type.startsWith('step_') || ev.event_type.startsWith('loop_iteration_')
|
||||
)) {
|
||||
const idx = e.step_index ?? 0;
|
||||
const existing = stepMap.get(idx);
|
||||
const status =
|
||||
e.event_type === 'step_started' || e.event_type === 'loop_iteration_started'
|
||||
? ('running' as const)
|
||||
: e.event_type === 'step_completed' || e.event_type === 'loop_iteration_completed'
|
||||
? ('completed' as const)
|
||||
: ('failed' as const);
|
||||
if (!existing || status !== 'running') {
|
||||
stepMap.set(idx, {
|
||||
index: idx,
|
||||
name: e.step_name ?? `Step ${String(idx + 1)}`,
|
||||
status,
|
||||
duration: e.data.duration_ms as number | undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(stepMap.values()).sort((a, b) => a.index - b.index);
|
||||
})(),
|
||||
artifacts: data.events
|
||||
.filter(e => e.event_type === 'workflow_artifact')
|
||||
.map(e => {
|
||||
const d = e.data;
|
||||
return {
|
||||
type: (d.artifactType as ArtifactType) ?? 'commit',
|
||||
label: (d.label as string) ?? '',
|
||||
url: d.url as string | undefined,
|
||||
path: d.path as string | undefined,
|
||||
};
|
||||
})
|
||||
.filter(a => a.label || a.url || a.path),
|
||||
isLoop: data.events.some(ev => ev.event_type.startsWith('loop_iteration_')),
|
||||
startedAt: new Date(ensureUtc(data.run.started_at)).getTime(),
|
||||
completedAt: data.run.completed_at
|
||||
? new Date(ensureUtc(data.run.completed_at)).getTime()
|
||||
: undefined,
|
||||
});
|
||||
if (data.run.codebase_id) {
|
||||
getCodebase(data.run.codebase_id)
|
||||
.then(cb => {
|
||||
setCodebaseName(cb.name);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.warn('[WorkflowExecution] Failed to load codebase name', {
|
||||
codebaseId: data.run.codebase_id,
|
||||
error: err instanceof Error ? err.message : err,
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError((err as Error).message);
|
||||
});
|
||||
}, [runId]);
|
||||
|
||||
// Look up the workflow run associated with this worker conversation
|
||||
useEffect(() => {
|
||||
if (!workerPlatformId) return;
|
||||
getWorkflowRunByWorker(workerPlatformId)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
setWorkerRunId(result.run.id);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// Non-critical — "View Run" link just won't appear
|
||||
console.warn('[WorkflowExecution] Failed to look up worker run', {
|
||||
workerPlatformId,
|
||||
error: err instanceof Error ? err.message : err,
|
||||
});
|
||||
});
|
||||
}, [workerPlatformId]);
|
||||
|
||||
// Prefer live state over initial data
|
||||
const workflow = workflows.get(runId) ?? initialData;
|
||||
|
||||
// Force re-render every second while workflow is running (for live timer)
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (workflow?.status !== 'running' && workflow?.status !== 'pending') return;
|
||||
const interval = setInterval(() => {
|
||||
setTick(t => t + 1);
|
||||
}, 1000);
|
||||
return (): void => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [workflow?.status]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-error">
|
||||
<p>Failed to load workflow run: {error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!workflow) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-text-secondary">
|
||||
<p>Loading workflow execution...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const elapsed = Math.max(0, (workflow.completedAt ?? Date.now()) - workflow.startedAt);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-border">
|
||||
<button
|
||||
onClick={(): void => {
|
||||
navigate(-1);
|
||||
}}
|
||||
className="text-text-secondary hover:text-text-primary transition-colors text-sm"
|
||||
title="Back"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h2 className="font-semibold text-text-primary truncate">{workflow.workflowName}</h2>
|
||||
<StatusBadge status={workflow.status} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-auto shrink-0">
|
||||
{codebaseName && <span className="text-xs text-text-secondary">{codebaseName}</span>}
|
||||
{parentPlatformId && (
|
||||
<button
|
||||
onClick={(): void => {
|
||||
navigate(`/chat/${encodeURIComponent(parentPlatformId)}`);
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:text-accent-bright transition-colors"
|
||||
title="View parent conversation"
|
||||
>
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
<span>Chat</span>
|
||||
</button>
|
||||
)}
|
||||
{workerRunId && (
|
||||
<button
|
||||
onClick={(): void => {
|
||||
navigate(`/workflows/runs/${workerRunId}`);
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:text-accent-bright transition-colors"
|
||||
title="View workflow run details"
|
||||
>
|
||||
<span>View Run</span>
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-text-secondary">{formatDuration(elapsed)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body: Step list + Logs */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left panel: Step list */}
|
||||
<div className="w-64 border-r border-border overflow-auto">
|
||||
<StepProgress
|
||||
steps={workflow.steps}
|
||||
activeStepIndex={selectedStep}
|
||||
onStepClick={setSelectedStep}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right panel: Logs + Artifacts */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-auto">
|
||||
{workerPlatformId ? (
|
||||
<WorkflowLogs
|
||||
conversationId={workerPlatformId}
|
||||
startedAt={workflow.startedAt}
|
||||
workflowHandlers={workflowHandlers}
|
||||
/>
|
||||
) : (
|
||||
<StepLogs runId={runId} stepIndex={selectedStep} />
|
||||
)}
|
||||
</div>
|
||||
{workflow.status !== 'running' &&
|
||||
workflow.status !== 'pending' &&
|
||||
workflow.artifacts.length > 0 && (
|
||||
<div className="border-t border-border p-3">
|
||||
<ArtifactSummary artifacts={workflow.artifacts} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
207
packages/web/src/components/workflows/WorkflowList.tsx
Normal file
207
packages/web/src/components/workflows/WorkflowList.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
listWorkflows,
|
||||
listWorkflowRuns,
|
||||
listCodebases,
|
||||
createConversation,
|
||||
runWorkflow,
|
||||
type WorkflowDefinitionResponse,
|
||||
type WorkflowRunResponse,
|
||||
} from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const PROJECT_STORAGE_KEY = 'archon-selected-project';
|
||||
|
||||
export function WorkflowList(): React.ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const [selectedWorkflow, setSelectedWorkflow] = useState<string | null>(null);
|
||||
const [runMessage, setRunMessage] = useState('');
|
||||
const [running, setRunning] = useState(false);
|
||||
const [runError, setRunError] = useState<string | null>(null);
|
||||
|
||||
const [projectId, setProjectId] = useState<string | null>(
|
||||
localStorage.getItem(PROJECT_STORAGE_KEY)
|
||||
);
|
||||
|
||||
const { data: codebases } = useQuery({
|
||||
queryKey: ['codebases'],
|
||||
queryFn: listCodebases,
|
||||
});
|
||||
|
||||
const handleRun = async (workflowName: string): Promise<void> => {
|
||||
if (!runMessage.trim() || running || !projectId) return;
|
||||
setRunning(true);
|
||||
setRunError(null);
|
||||
try {
|
||||
const { conversationId } = await createConversation(projectId);
|
||||
await runWorkflow(workflowName, conversationId, runMessage.trim());
|
||||
setRunMessage('');
|
||||
setSelectedWorkflow(null);
|
||||
navigate(`/chat/${conversationId}`);
|
||||
} catch (error) {
|
||||
console.error('[Workflows] Failed to run workflow', { error });
|
||||
setRunError(
|
||||
error instanceof Error
|
||||
? `Failed to start workflow: ${error.message}`
|
||||
: 'Failed to start workflow. Check server connectivity.'
|
||||
);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const { data: workflows, isLoading: loadingWorkflows } = useQuery({
|
||||
queryKey: ['workflows'],
|
||||
queryFn: () => listWorkflows(),
|
||||
});
|
||||
|
||||
const { data: runs, isLoading: loadingRuns } = useQuery({
|
||||
queryKey: ['workflow-runs'],
|
||||
queryFn: () => listWorkflowRuns({ limit: 20 }),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
if (loadingWorkflows) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32 text-text-secondary text-sm">
|
||||
Loading workflows...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Available Workflows */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-3">Available Workflows</h3>
|
||||
{!workflows || workflows.length === 0 ? (
|
||||
<div className="text-sm text-text-secondary">
|
||||
No workflows found. Add workflow definitions to{' '}
|
||||
<code className="text-xs bg-surface-inset px-1 py-0.5 rounded">.archon/workflows/</code>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{workflows.map((wf: WorkflowDefinitionResponse) => (
|
||||
<div key={wf.name}>
|
||||
<button
|
||||
onClick={(): void => {
|
||||
setSelectedWorkflow(selectedWorkflow === wf.name ? null : wf.name);
|
||||
setRunMessage('');
|
||||
setRunError(null);
|
||||
}}
|
||||
className={`w-full text-left rounded-lg border p-3 transition-colors ${
|
||||
selectedWorkflow === wf.name
|
||||
? 'border-accent bg-accent/5'
|
||||
: 'border-border bg-surface hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm text-text-primary">{wf.name}</span>
|
||||
</div>
|
||||
{wf.description && (
|
||||
<p className="text-xs text-text-secondary mt-1">{wf.description}</p>
|
||||
)}
|
||||
</button>
|
||||
{selectedWorkflow === wf.name && (
|
||||
<div className="mt-2 p-3 rounded-lg border border-border bg-surface-inset">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<label className="text-xs text-text-secondary shrink-0">Run on</label>
|
||||
<select
|
||||
value={projectId ?? ''}
|
||||
onChange={(e): void => {
|
||||
setProjectId(e.target.value || null);
|
||||
}}
|
||||
className="flex-1 min-w-0 rounded-md border border-border bg-surface px-2 py-1 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select a project...
|
||||
</option>
|
||||
{codebases?.map(cb => (
|
||||
<option key={cb.id} value={cb.id}>
|
||||
{cb.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={runMessage}
|
||||
onChange={(e): void => {
|
||||
setRunMessage(e.target.value);
|
||||
}}
|
||||
placeholder="Enter a message for this workflow..."
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-surface text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-1 focus:ring-accent disabled:opacity-50"
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleRun(wf.name);
|
||||
}
|
||||
}}
|
||||
disabled={running || !projectId}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(): void => {
|
||||
void handleRun(wf.name);
|
||||
}}
|
||||
disabled={running || !runMessage.trim() || !projectId}
|
||||
>
|
||||
{running ? 'Starting...' : `Run ${wf.name}`}
|
||||
</Button>
|
||||
</div>
|
||||
{runError && <p className="text-xs text-error mt-1">{runError}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Recent Runs */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-3">Recent Runs</h3>
|
||||
{loadingRuns ? (
|
||||
<div className="text-sm text-text-secondary">Loading...</div>
|
||||
) : !runs || runs.length === 0 ? (
|
||||
<div className="text-sm text-text-secondary">No workflow runs yet.</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{runs.map((run: WorkflowRunResponse) => (
|
||||
<button
|
||||
key={run.id}
|
||||
onClick={(): void => {
|
||||
navigate(`/workflows/runs/${run.id}`);
|
||||
}}
|
||||
className="flex items-center gap-3 w-full text-left px-3 py-2 rounded-md hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<RunStatusDot status={run.status} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-text-primary truncate">{run.workflow_name}</div>
|
||||
<div className="text-xs text-text-secondary truncate">{run.user_message}</div>
|
||||
</div>
|
||||
<span className="text-xs text-text-secondary shrink-0">
|
||||
{new Date(run.started_at).toLocaleDateString()}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunStatusDot({ status }: { status: string }): React.ReactElement {
|
||||
const colors: Record<string, string> = {
|
||||
running: 'bg-accent',
|
||||
completed: 'bg-success',
|
||||
failed: 'bg-error',
|
||||
};
|
||||
return (
|
||||
<span className={`h-2 w-2 rounded-full shrink-0 ${colors[status] ?? 'bg-text-secondary'}`} />
|
||||
);
|
||||
}
|
||||
205
packages/web/src/components/workflows/WorkflowLogs.tsx
Normal file
205
packages/web/src/components/workflows/WorkflowLogs.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { MessageList } from '@/components/chat/MessageList';
|
||||
import { useSSE } from '@/hooks/useSSE';
|
||||
import { getMessages } from '@/lib/api';
|
||||
import type { MessageResponse } from '@/lib/api';
|
||||
import type {
|
||||
ChatMessage,
|
||||
ToolCallDisplay,
|
||||
ErrorDisplay,
|
||||
WorkflowStepEvent,
|
||||
WorkflowStatusEvent,
|
||||
ParallelAgentEvent,
|
||||
WorkflowArtifactEvent,
|
||||
} from '@/lib/types';
|
||||
|
||||
interface WorkflowLogsProps {
|
||||
conversationId: string;
|
||||
startedAt?: number;
|
||||
workflowHandlers?: {
|
||||
onWorkflowStep: (event: WorkflowStepEvent) => void;
|
||||
onWorkflowStatus: (event: WorkflowStatusEvent) => void;
|
||||
onParallelAgent: (event: ParallelAgentEvent) => void;
|
||||
onWorkflowArtifact: (event: WorkflowArtifactEvent) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only chat view for a workflow's worker conversation.
|
||||
* Loads historical messages and streams live updates via SSE.
|
||||
*/
|
||||
export function WorkflowLogs({
|
||||
conversationId,
|
||||
startedAt,
|
||||
workflowHandlers,
|
||||
}: WorkflowLogsProps): React.ReactElement {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
|
||||
// Load historical messages on mount
|
||||
useEffect(() => {
|
||||
void getMessages(conversationId)
|
||||
.then((rows: MessageResponse[]) => {
|
||||
if (rows.length === 0) return;
|
||||
const hydrated: ChatMessage[] = rows.map(row => {
|
||||
let meta: {
|
||||
toolCalls?: {
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
duration?: number;
|
||||
}[];
|
||||
error?: ErrorDisplay;
|
||||
} = {};
|
||||
try {
|
||||
meta = JSON.parse(row.metadata) as typeof meta;
|
||||
} catch {
|
||||
console.warn('[WorkflowLogs] Corrupted message metadata', { messageId: row.id });
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
role: row.role,
|
||||
content: row.content,
|
||||
toolCalls: meta.toolCalls?.map((tc, i) => ({
|
||||
...tc,
|
||||
id: `${row.id}-tool-${String(i)}`,
|
||||
startedAt: 0,
|
||||
isExpanded: false,
|
||||
duration: tc.duration ?? 0,
|
||||
})),
|
||||
error: meta.error,
|
||||
timestamp: new Date(row.created_at).getTime(),
|
||||
isStreaming: false,
|
||||
};
|
||||
});
|
||||
const filtered = startedAt ? hydrated.filter(m => m.timestamp >= startedAt) : hydrated;
|
||||
setMessages(prev => (prev.length > 0 ? prev : filtered));
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
console.error('[WorkflowLogs] Failed to load message history', {
|
||||
conversationId,
|
||||
error: e instanceof Error ? e.message : e,
|
||||
});
|
||||
});
|
||||
}, [conversationId, startedAt]);
|
||||
|
||||
const onText = useCallback((content: string): void => {
|
||||
setMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.role === 'assistant' && last.isStreaming) {
|
||||
return [...prev.slice(0, -1), { ...last, content: last.content + content }];
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: `msg-${String(Date.now())}`,
|
||||
role: 'assistant' as const,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
isStreaming: true,
|
||||
toolCalls: [],
|
||||
},
|
||||
];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onToolCall = useCallback((name: string, input: Record<string, unknown>): void => {
|
||||
setMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.role === 'assistant') {
|
||||
const now = Date.now();
|
||||
const updatedExistingTools = (last.toolCalls ?? []).map(tc =>
|
||||
!tc.output && tc.duration === undefined ? { ...tc, duration: now - tc.startedAt } : tc
|
||||
);
|
||||
const newTool: ToolCallDisplay = {
|
||||
id: `tool-${String(now)}`,
|
||||
name,
|
||||
input,
|
||||
startedAt: now,
|
||||
isExpanded: false,
|
||||
};
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
...last,
|
||||
isStreaming: false,
|
||||
toolCalls: [...updatedExistingTools, newTool],
|
||||
},
|
||||
];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onToolResult = useCallback((name: string, output: string, duration: number): void => {
|
||||
setMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.role === 'assistant' && last.toolCalls) {
|
||||
const updatedTools = last.toolCalls.map(tc =>
|
||||
tc.name === name && !tc.output ? { ...tc, output, duration } : tc
|
||||
);
|
||||
return [...prev.slice(0, -1), { ...last, toolCalls: updatedTools }];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onError = useCallback((error: ErrorDisplay): void => {
|
||||
setMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.role === 'assistant') {
|
||||
return [...prev.slice(0, -1), { ...last, isStreaming: false, error }];
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: `msg-${String(Date.now())}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
error,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onLockChange = useCallback((isLocked: boolean): void => {
|
||||
if (!isLocked) {
|
||||
const now = Date.now();
|
||||
setMessages(prev =>
|
||||
prev.map(msg => {
|
||||
const needsToolFix = msg.toolCalls?.some(tc => !tc.output && tc.duration === undefined);
|
||||
const needsStreamFix = msg.isStreaming;
|
||||
if (!needsToolFix && !needsStreamFix) return msg;
|
||||
return {
|
||||
...msg,
|
||||
isStreaming: false,
|
||||
toolCalls: needsToolFix
|
||||
? msg.toolCalls?.map(tc =>
|
||||
!tc.output && tc.duration === undefined
|
||||
? { ...tc, duration: now - tc.startedAt }
|
||||
: tc
|
||||
)
|
||||
: msg.toolCalls,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onSessionInfo = useCallback((_sessionId: string, _cost?: number): void => {
|
||||
// No-op for read-only view
|
||||
}, []);
|
||||
|
||||
useSSE(conversationId, {
|
||||
onText,
|
||||
onToolCall,
|
||||
onToolResult,
|
||||
onError,
|
||||
onLockChange,
|
||||
onSessionInfo,
|
||||
...workflowHandlers,
|
||||
});
|
||||
|
||||
const isStreaming = messages.some(m => m.isStreaming);
|
||||
|
||||
return <MessageList messages={messages} isStreaming={isStreaming} />;
|
||||
}
|
||||
48
packages/web/src/hooks/useAutoScroll.ts
Normal file
48
packages/web/src/hooks/useAutoScroll.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { useEffect, useRef, useState, useCallback, type RefObject } from 'react';
|
||||
|
||||
export function useAutoScroll(
|
||||
containerRef: RefObject<HTMLElement | null>,
|
||||
dependencies: unknown[]
|
||||
): {
|
||||
isAtBottom: boolean;
|
||||
scrollToBottom: () => void;
|
||||
} {
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const userScrolledUpRef = useRef(false);
|
||||
|
||||
const scrollToBottom = useCallback((): void => {
|
||||
const el = containerRef.current;
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
userScrolledUpRef.current = false;
|
||||
setIsAtBottom(true);
|
||||
}
|
||||
}, [containerRef]);
|
||||
|
||||
// Detect user scroll position
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const handleScroll = (): void => {
|
||||
const threshold = 50;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
|
||||
setIsAtBottom(atBottom);
|
||||
userScrolledUpRef.current = !atBottom;
|
||||
};
|
||||
|
||||
el.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return (): void => {
|
||||
el.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [containerRef]);
|
||||
|
||||
// Auto-scroll when dependencies change (new messages)
|
||||
useEffect(() => {
|
||||
if (!userScrolledUpRef.current) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, dependencies);
|
||||
|
||||
return { isAtBottom, scrollToBottom };
|
||||
}
|
||||
42
packages/web/src/hooks/useKeyboardShortcuts.ts
Normal file
42
packages/web/src/hooks/useKeyboardShortcuts.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { useEffect, useCallback } from 'react';
|
||||
|
||||
type ShortcutHandler = () => void;
|
||||
|
||||
type ShortcutMap = Record<string, ShortcutHandler>;
|
||||
|
||||
export function useKeyboardShortcuts(shortcuts: ShortcutMap): void {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
// Don't trigger shortcuts when typing in inputs
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
// Allow Escape even in inputs
|
||||
if (e.key !== 'Escape') return;
|
||||
}
|
||||
|
||||
const mod = e.metaKey || e.ctrlKey;
|
||||
let key = '';
|
||||
|
||||
if (mod && e.key >= '1' && e.key <= '9') {
|
||||
key = `mod+${e.key}`;
|
||||
} else if (e.key === '/') {
|
||||
key = '/';
|
||||
} else if (e.key === 'Escape') {
|
||||
key = 'Escape';
|
||||
}
|
||||
|
||||
if (key && shortcuts[key]) {
|
||||
e.preventDefault();
|
||||
shortcuts[key]();
|
||||
}
|
||||
},
|
||||
[shortcuts]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return (): void => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
}
|
||||
194
packages/web/src/hooks/useSSE.ts
Normal file
194
packages/web/src/hooks/useSSE.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import type {
|
||||
SSEEvent,
|
||||
ErrorDisplay,
|
||||
WorkflowStepEvent,
|
||||
WorkflowStatusEvent,
|
||||
ParallelAgentEvent,
|
||||
WorkflowArtifactEvent,
|
||||
WorkflowDispatchEvent,
|
||||
WorkflowOutputPreviewEvent,
|
||||
} from '@/lib/types';
|
||||
import { SSE_BASE_URL } from '@/lib/api';
|
||||
|
||||
function parseSSEEvent(raw: string): SSEEvent | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (!parsed || typeof parsed.type !== 'string') {
|
||||
console.error('[SSE] Malformed event: missing type field', { raw });
|
||||
return null;
|
||||
}
|
||||
return parsed as unknown as SSEEvent;
|
||||
} catch (parseErr) {
|
||||
console.error('[SSE] Failed to parse event:', {
|
||||
raw,
|
||||
error: (parseErr as Error).message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface SSEHandlers {
|
||||
onText: (content: string) => void;
|
||||
onToolCall: (name: string, input: Record<string, unknown>) => void;
|
||||
onToolResult: (name: string, output: string, duration: number) => void;
|
||||
onError: (error: ErrorDisplay) => void;
|
||||
onLockChange: (locked: boolean, queuePosition?: number) => void;
|
||||
onSessionInfo: (sessionId: string, cost?: number) => void;
|
||||
onWorkflowStep?: (event: WorkflowStepEvent) => void;
|
||||
onWorkflowStatus?: (event: WorkflowStatusEvent) => void;
|
||||
onParallelAgent?: (event: ParallelAgentEvent) => void;
|
||||
onWorkflowArtifact?: (event: WorkflowArtifactEvent) => void;
|
||||
onWorkflowDispatch?: (event: WorkflowDispatchEvent) => void;
|
||||
onWorkflowOutputPreview?: (event: WorkflowOutputPreviewEvent) => void;
|
||||
onWarning?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function useSSE(
|
||||
conversationId: string | null,
|
||||
handlers: SSEHandlers
|
||||
): { connected: boolean } {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const handlersRef = useRef(handlers);
|
||||
handlersRef.current = handlers;
|
||||
|
||||
// Text batching: accumulate text for 50ms before dispatching
|
||||
const textBufferRef = useRef('');
|
||||
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const flushText = useCallback((): void => {
|
||||
if (textBufferRef.current) {
|
||||
handlersRef.current.onText(textBufferRef.current);
|
||||
textBufferRef.current = '';
|
||||
}
|
||||
flushTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId) return;
|
||||
|
||||
const eventSource = new EventSource(`${SSE_BASE_URL}/api/stream/${conversationId}`);
|
||||
|
||||
eventSource.onopen = (): void => {
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
eventSource.onerror = (): void => {
|
||||
setConnected(false);
|
||||
console.warn('[SSE] Connection error', {
|
||||
conversationId,
|
||||
readyState: eventSource.readyState,
|
||||
});
|
||||
if (eventSource.readyState === EventSource.CLOSED) {
|
||||
handlersRef.current.onError({
|
||||
message: 'Lost connection to server. Please refresh the page.',
|
||||
classification: 'transient',
|
||||
suggestedActions: ['Refresh the page', 'Check that the server is running'],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event: MessageEvent): void => {
|
||||
const data = parseSSEEvent(event.data as string);
|
||||
if (!data) {
|
||||
handlersRef.current.onError({
|
||||
message: 'Received malformed response from server',
|
||||
classification: 'transient',
|
||||
suggestedActions: ['Refresh the page if chat appears stuck'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const h = handlersRef.current;
|
||||
|
||||
switch (data.type) {
|
||||
case 'text':
|
||||
textBufferRef.current += data.content;
|
||||
if (!flushTimerRef.current) {
|
||||
flushTimerRef.current = setTimeout(flushText, 50);
|
||||
}
|
||||
break;
|
||||
case 'tool_call':
|
||||
h.onToolCall(data.name, data.input);
|
||||
break;
|
||||
case 'tool_result':
|
||||
h.onToolResult(data.name, data.output, data.duration);
|
||||
break;
|
||||
case 'error':
|
||||
h.onError({
|
||||
message: data.message,
|
||||
classification: data.classification ?? 'transient',
|
||||
suggestedActions: data.suggestedActions ?? [],
|
||||
});
|
||||
break;
|
||||
case 'conversation_lock':
|
||||
// Flush any buffered text before processing lock change,
|
||||
// otherwise text arriving just before lock release creates
|
||||
// a streaming message that never gets cleared.
|
||||
if (!data.locked && textBufferRef.current) {
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
flushText();
|
||||
}
|
||||
h.onLockChange(data.locked, data.queuePosition);
|
||||
break;
|
||||
case 'session_info':
|
||||
h.onSessionInfo(data.sessionId, data.cost);
|
||||
break;
|
||||
case 'workflow_step':
|
||||
h.onWorkflowStep?.(data);
|
||||
break;
|
||||
case 'workflow_status':
|
||||
h.onWorkflowStatus?.(data);
|
||||
break;
|
||||
case 'parallel_agent':
|
||||
h.onParallelAgent?.(data);
|
||||
break;
|
||||
case 'workflow_artifact':
|
||||
h.onWorkflowArtifact?.(data);
|
||||
break;
|
||||
case 'workflow_dispatch':
|
||||
h.onWorkflowDispatch?.(data);
|
||||
break;
|
||||
case 'workflow_output_preview':
|
||||
h.onWorkflowOutputPreview?.(data);
|
||||
break;
|
||||
case 'warning':
|
||||
h.onWarning?.(data.message);
|
||||
break;
|
||||
case 'heartbeat':
|
||||
break;
|
||||
default: {
|
||||
console.warn('[SSE] Unknown event type', { type: (data as { type: string }).type });
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (handlerError) {
|
||||
console.error('[SSE] Handler error for event type:', data.type, handlerError);
|
||||
try {
|
||||
handlersRef.current.onError({
|
||||
message: `Failed to process ${data.type} event. UI may be out of sync.`,
|
||||
classification: 'transient',
|
||||
suggestedActions: ['Refresh the page if chat appears stuck'],
|
||||
});
|
||||
} catch {
|
||||
// Avoid infinite loop if onError itself throws
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (): void => {
|
||||
eventSource.close();
|
||||
setConnected(false);
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushText();
|
||||
}
|
||||
};
|
||||
}, [conversationId, flushText]);
|
||||
|
||||
return { connected };
|
||||
}
|
||||
241
packages/web/src/hooks/useWorkflowStatus.ts
Normal file
241
packages/web/src/hooks/useWorkflowStatus.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { getWorkflowRun } from '@/lib/api';
|
||||
import type {
|
||||
WorkflowState,
|
||||
WorkflowStepState,
|
||||
WorkflowStepEvent,
|
||||
WorkflowStatusEvent,
|
||||
ParallelAgentEvent,
|
||||
WorkflowArtifactEvent,
|
||||
} from '@/lib/types';
|
||||
|
||||
interface UseWorkflowStatusReturn {
|
||||
workflows: Map<string, WorkflowState>;
|
||||
activeWorkflow: WorkflowState | null;
|
||||
handlers: {
|
||||
onWorkflowStep: (event: WorkflowStepEvent) => void;
|
||||
onWorkflowStatus: (event: WorkflowStatusEvent) => void;
|
||||
onParallelAgent: (event: ParallelAgentEvent) => void;
|
||||
onWorkflowArtifact: (event: WorkflowArtifactEvent) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function useWorkflowStatus(): UseWorkflowStatusReturn {
|
||||
const [workflows, setWorkflows] = useState<Map<string, WorkflowState>>(new Map());
|
||||
|
||||
const handleWorkflowStatus = useCallback((event: WorkflowStatusEvent): void => {
|
||||
setWorkflows(prev => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(event.runId);
|
||||
|
||||
if (!existing) {
|
||||
// New workflow or first SSE event for a REST-loaded workflow
|
||||
next.set(event.runId, {
|
||||
runId: event.runId,
|
||||
workflowName: event.workflowName,
|
||||
status: event.status,
|
||||
steps: [],
|
||||
artifacts: [],
|
||||
isLoop: false,
|
||||
startedAt: event.timestamp,
|
||||
completedAt: event.status !== 'running' ? event.timestamp : undefined,
|
||||
error: event.error,
|
||||
});
|
||||
} else {
|
||||
next.set(event.runId, {
|
||||
...existing,
|
||||
status: event.status,
|
||||
error: event.error,
|
||||
completedAt: event.status !== 'running' ? event.timestamp : undefined,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleWorkflowStep = useCallback((event: WorkflowStepEvent): void => {
|
||||
setWorkflows(prev => {
|
||||
const next = new Map(prev);
|
||||
const wf = next.get(event.runId);
|
||||
if (!wf) return prev;
|
||||
|
||||
const steps = [...wf.steps];
|
||||
const existingIdx = steps.findIndex(s => s.index === event.step);
|
||||
|
||||
const stepState: WorkflowStepState = {
|
||||
index: event.step,
|
||||
name: event.name,
|
||||
status: event.status,
|
||||
duration: event.duration,
|
||||
agents: existingIdx >= 0 ? steps[existingIdx].agents : undefined,
|
||||
};
|
||||
|
||||
if (existingIdx >= 0) {
|
||||
steps[existingIdx] = { ...steps[existingIdx], ...stepState };
|
||||
} else {
|
||||
steps.push(stepState);
|
||||
}
|
||||
|
||||
const isLoop = event.iteration !== undefined;
|
||||
next.set(event.runId, {
|
||||
...wf,
|
||||
steps,
|
||||
isLoop,
|
||||
currentIteration: event.iteration,
|
||||
maxIterations: isLoop && event.total > 0 ? event.total : wf.maxIterations,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleParallelAgent = useCallback((event: ParallelAgentEvent): void => {
|
||||
setWorkflows(prev => {
|
||||
const next = new Map(prev);
|
||||
const wf = next.get(event.runId);
|
||||
if (!wf) return prev;
|
||||
|
||||
const steps = [...wf.steps];
|
||||
const stepIdx = steps.findIndex(s => s.index === event.step);
|
||||
if (stepIdx < 0) return prev;
|
||||
|
||||
const step = { ...steps[stepIdx] };
|
||||
const agents = [...(step.agents ?? [])];
|
||||
const agentIdx = agents.findIndex(a => a.index === event.agentIndex);
|
||||
|
||||
const agentState = {
|
||||
index: event.agentIndex,
|
||||
name: event.name,
|
||||
status: event.status,
|
||||
duration: event.duration,
|
||||
error: event.error,
|
||||
};
|
||||
|
||||
if (agentIdx >= 0) {
|
||||
agents[agentIdx] = agentState;
|
||||
} else {
|
||||
agents.push(agentState);
|
||||
}
|
||||
|
||||
step.agents = agents;
|
||||
steps[stepIdx] = step;
|
||||
next.set(event.runId, { ...wf, steps });
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleWorkflowArtifact = useCallback((event: WorkflowArtifactEvent): void => {
|
||||
setWorkflows(prev => {
|
||||
const next = new Map(prev);
|
||||
const wf = next.get(event.runId);
|
||||
if (!wf) return prev;
|
||||
|
||||
next.set(event.runId, {
|
||||
...wf,
|
||||
artifacts: [
|
||||
...wf.artifacts,
|
||||
{
|
||||
type: event.artifactType,
|
||||
label: event.label,
|
||||
url: event.url,
|
||||
path: event.path,
|
||||
},
|
||||
],
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Poll for stuck workflows: if any workflow is "running" for >30s, check REST API
|
||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
useEffect(() => {
|
||||
const hasRunning = Array.from(workflows.values()).some(wf => wf.status === 'running');
|
||||
|
||||
if (hasRunning && !pollingRef.current) {
|
||||
pollingRef.current = setInterval(() => {
|
||||
for (const wf of workflows.values()) {
|
||||
if (wf.status !== 'running') continue;
|
||||
// Only poll workflows running for >30s (safety net, not primary mechanism)
|
||||
if (Date.now() - wf.startedAt < 30_000) continue;
|
||||
|
||||
void getWorkflowRun(wf.runId)
|
||||
.then(data => {
|
||||
const serverStatus = data.run.status;
|
||||
if (serverStatus === 'completed' || serverStatus === 'failed') {
|
||||
setWorkflows(prev => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(wf.runId);
|
||||
if (existing?.status === 'running') {
|
||||
next.set(wf.runId, {
|
||||
...existing,
|
||||
status: serverStatus,
|
||||
completedAt: data.run.completed_at
|
||||
? new Date(
|
||||
data.run.completed_at.endsWith('Z')
|
||||
? data.run.completed_at
|
||||
: data.run.completed_at + 'Z'
|
||||
).getTime()
|
||||
: Date.now(),
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.warn('[WorkflowStatus] Polling failed', {
|
||||
runId: wf.runId,
|
||||
error: err instanceof Error ? err.message : err,
|
||||
});
|
||||
setWorkflows(prev => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(wf.runId);
|
||||
if (existing?.status === 'running') {
|
||||
next.set(wf.runId, { ...existing, stale: true });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}
|
||||
}, 15_000);
|
||||
} else if (!hasRunning && pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [workflows]);
|
||||
|
||||
// Find the most recent running workflow
|
||||
let activeWorkflow: WorkflowState | null = null;
|
||||
for (const wf of workflows.values()) {
|
||||
if (wf.status === 'running') {
|
||||
if (!activeWorkflow || wf.startedAt > activeWorkflow.startedAt) {
|
||||
activeWorkflow = wf;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If no running workflow, show the most recent completed/failed
|
||||
if (!activeWorkflow) {
|
||||
for (const wf of workflows.values()) {
|
||||
if (!activeWorkflow || wf.startedAt > activeWorkflow.startedAt) {
|
||||
activeWorkflow = wf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
workflows,
|
||||
activeWorkflow,
|
||||
handlers: {
|
||||
onWorkflowStep: handleWorkflowStep,
|
||||
onWorkflowStatus: handleWorkflowStatus,
|
||||
onParallelAgent: handleParallelAgent,
|
||||
onWorkflowArtifact: handleWorkflowArtifact,
|
||||
},
|
||||
};
|
||||
}
|
||||
249
packages/web/src/index.css
Normal file
249
packages/web/src/index.css
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@import 'shadcn/tailwind.css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* Dark-only theme - uses our custom palette as root defaults */
|
||||
:root {
|
||||
/* App-specific colors */
|
||||
--surface: oklch(0.18 0.008 260);
|
||||
--surface-elevated: oklch(0.22 0.01 260);
|
||||
--border-bright: oklch(0.35 0.015 260);
|
||||
--text-primary: oklch(0.93 0.005 260);
|
||||
--text-secondary: oklch(0.65 0.01 260);
|
||||
--text-tertiary: oklch(0.45 0.01 260);
|
||||
--accent-hover: oklch(0.58 0.18 250);
|
||||
--accent-muted: oklch(0.3 0.08 250);
|
||||
--surface-inset: oklch(0.12 0.005 260);
|
||||
--surface-hover: oklch(0.24 0.01 260);
|
||||
--accent-bright: oklch(0.72 0.18 250);
|
||||
--success: oklch(0.65 0.17 155);
|
||||
--warning: oklch(0.75 0.15 75);
|
||||
--error: oklch(0.6 0.2 25);
|
||||
|
||||
/* shadcn variables mapped to dark theme */
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(0.14 0.005 260);
|
||||
--foreground: oklch(0.93 0.005 260);
|
||||
--card: oklch(0.18 0.008 260);
|
||||
--card-foreground: oklch(0.93 0.005 260);
|
||||
--popover: oklch(0.18 0.008 260);
|
||||
--popover-foreground: oklch(0.93 0.005 260);
|
||||
--primary: oklch(0.65 0.18 250);
|
||||
--primary-foreground: oklch(0.98 0.005 260);
|
||||
--secondary: oklch(0.22 0.01 260);
|
||||
--secondary-foreground: oklch(0.93 0.005 260);
|
||||
--muted: oklch(0.22 0.01 260);
|
||||
--muted-foreground: oklch(0.65 0.01 260);
|
||||
--accent: oklch(0.25 0.06 250);
|
||||
--accent-foreground: oklch(0.93 0.005 260);
|
||||
--destructive: oklch(0.6 0.2 25);
|
||||
--border: oklch(0.28 0.01 260);
|
||||
--input: oklch(0.28 0.01 260);
|
||||
--ring: oklch(0.65 0.18 250);
|
||||
--chart-1: oklch(0.65 0.18 250);
|
||||
--chart-2: oklch(0.65 0.17 155);
|
||||
--chart-3: oklch(0.75 0.15 75);
|
||||
--chart-4: oklch(0.6 0.2 25);
|
||||
--chart-5: oklch(0.58 0.18 250);
|
||||
--sidebar: oklch(0.18 0.008 260);
|
||||
--sidebar-foreground: oklch(0.93 0.005 260);
|
||||
--sidebar-primary: oklch(0.65 0.18 250);
|
||||
--sidebar-primary-foreground: oklch(0.98 0.005 260);
|
||||
--sidebar-accent: oklch(0.25 0.06 250);
|
||||
--sidebar-accent-foreground: oklch(0.93 0.005 260);
|
||||
--sidebar-border: oklch(0.28 0.01 260);
|
||||
--sidebar-ring: oklch(0.65 0.18 250);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* App-specific theme colors */
|
||||
--color-background: var(--background);
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-elevated: var(--surface-elevated);
|
||||
--color-border: var(--border);
|
||||
--color-border-bright: var(--border-bright);
|
||||
--color-text-primary: var(--text-primary);
|
||||
--color-text-secondary: var(--text-secondary);
|
||||
--color-text-tertiary: var(--text-tertiary);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: var(--accent-hover);
|
||||
--color-accent-muted: var(--accent-muted);
|
||||
--color-surface-inset: var(--surface-inset);
|
||||
--color-surface-hover: var(--surface-hover);
|
||||
--color-accent-bright: var(--accent-bright);
|
||||
--color-success: var(--success);
|
||||
--color-warning: var(--warning);
|
||||
--color-error: var(--error);
|
||||
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
|
||||
/* shadcn theme mappings */
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
/* Base body styling */
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Scrollbar styling for dark theme */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-bright);
|
||||
}
|
||||
|
||||
/* Highlight.js dark theme overrides */
|
||||
.hljs {
|
||||
background: var(--surface) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chat markdown typography (replaces @tailwindcss/typography prose classes) */
|
||||
.chat-markdown p {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.chat-markdown p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chat-markdown p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-markdown h1,
|
||||
.chat-markdown h2,
|
||||
.chat-markdown h3,
|
||||
.chat-markdown h4 {
|
||||
font-weight: 600;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.4em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.chat-markdown h1 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
.chat-markdown h2 {
|
||||
font-size: 1.125em;
|
||||
}
|
||||
|
||||
.chat-markdown h3 {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.chat-markdown ul,
|
||||
.chat-markdown ol {
|
||||
padding-left: 1.5em;
|
||||
margin-top: 0.4em;
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
|
||||
.chat-markdown ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.chat-markdown ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.chat-markdown li {
|
||||
margin-top: 0.15em;
|
||||
margin-bottom: 0.15em;
|
||||
}
|
||||
|
||||
.chat-markdown li > p {
|
||||
margin-top: 0.2em;
|
||||
margin-bottom: 0.2em;
|
||||
}
|
||||
|
||||
.chat-markdown hr {
|
||||
border-color: var(--border);
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.chat-markdown strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-markdown table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.chat-markdown th,
|
||||
.chat-markdown td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.3em 0.6em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.chat-markdown th {
|
||||
font-weight: 600;
|
||||
background: var(--surface);
|
||||
}
|
||||
242
packages/web/src/lib/api.ts
Normal file
242
packages/web/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
/**
|
||||
* API client functions for the Archon Web UI.
|
||||
* Uses relative URLs - Vite proxy handles routing in dev.
|
||||
* SSE streams bypass the proxy in dev mode (Vite proxy buffers SSE responses).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base URL for SSE streams. In dev, bypasses Vite proxy by connecting directly
|
||||
* to the backend server. In production, uses relative URLs (same origin).
|
||||
* Uses the page hostname so it works from any network interface.
|
||||
*/
|
||||
export const SSE_BASE_URL = import.meta.env.DEV ? `http://${window.location.hostname}:3090` : '';
|
||||
|
||||
export interface ConversationResponse {
|
||||
id: string;
|
||||
platform_type: string;
|
||||
platform_conversation_id: string;
|
||||
codebase_id: string | null;
|
||||
cwd: string | null;
|
||||
ai_assistant_type: string;
|
||||
title: string | null;
|
||||
last_activity_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CodebaseResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
repository_url: string | null;
|
||||
default_cwd: string;
|
||||
ai_assistant_type: string;
|
||||
commands: Record<string, { path: string; description: string }>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string;
|
||||
adapter: string;
|
||||
concurrency: {
|
||||
active: number;
|
||||
queuedTotal: number;
|
||||
maxConcurrent: number;
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, options);
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
const truncated = body.length > 200 ? body.slice(0, 200) + '...' : body;
|
||||
const path = new URL(url, window.location.origin).pathname;
|
||||
const error = new Error(`API error ${String(res.status)} (${path}): ${truncated}`);
|
||||
(error as Error & { status: number }).status = res.status;
|
||||
throw error;
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// Conversations
|
||||
export async function listConversations(codebaseId?: string): Promise<ConversationResponse[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (codebaseId) params.set('codebaseId', codebaseId);
|
||||
const qs = params.toString();
|
||||
return fetchJSON<ConversationResponse[]>(`/api/conversations${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function createConversation(
|
||||
codebaseId?: string
|
||||
): Promise<{ conversationId: string; id: string }> {
|
||||
return fetchJSON('/api/conversations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ codebaseId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateConversation(
|
||||
id: string,
|
||||
updates: { title?: string }
|
||||
): Promise<{ success: boolean }> {
|
||||
return fetchJSON(`/api/conversations/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteConversation(id: string): Promise<{ success: boolean }> {
|
||||
return fetchJSON(`/api/conversations/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
conversationId: string,
|
||||
message: string
|
||||
): Promise<{ accepted: boolean; status: string }> {
|
||||
return fetchJSON(`/api/conversations/${conversationId}/message`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
}
|
||||
|
||||
// Messages
|
||||
export interface MessageResponse {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
metadata: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function getMessages(conversationId: string, limit = 200): Promise<MessageResponse[]> {
|
||||
return fetchJSON<MessageResponse[]>(
|
||||
`/api/conversations/${encodeURIComponent(conversationId)}/messages?limit=${String(limit)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Codebases
|
||||
export async function listCodebases(): Promise<CodebaseResponse[]> {
|
||||
return fetchJSON<CodebaseResponse[]>('/api/codebases');
|
||||
}
|
||||
|
||||
export async function getCodebase(id: string): Promise<CodebaseResponse> {
|
||||
return fetchJSON<CodebaseResponse>(`/api/codebases/${id}`);
|
||||
}
|
||||
|
||||
export async function addCodebase(
|
||||
input: { url: string } | { path: string }
|
||||
): Promise<CodebaseResponse> {
|
||||
return fetchJSON<CodebaseResponse>('/api/codebases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteCodebase(id: string): Promise<{ success: boolean }> {
|
||||
return fetchJSON<{ success: boolean }>(`/api/codebases/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// Workflows
|
||||
export interface WorkflowDefinitionResponse {
|
||||
name: string;
|
||||
description?: string;
|
||||
steps: unknown[];
|
||||
}
|
||||
|
||||
export interface WorkflowRunResponse {
|
||||
id: string;
|
||||
workflow_name: string;
|
||||
conversation_id: string;
|
||||
codebase_id: string | null;
|
||||
current_step_index: number;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
user_message: string;
|
||||
metadata: Record<string, unknown>;
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
last_activity_at: string | null;
|
||||
worker_platform_id?: string;
|
||||
parent_platform_id?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowEventResponse {
|
||||
id: string;
|
||||
workflow_run_id: string;
|
||||
event_type: string;
|
||||
step_index: number | null;
|
||||
step_name: string | null;
|
||||
data: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function listWorkflows(cwd?: string): Promise<WorkflowDefinitionResponse[]> {
|
||||
const params = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '';
|
||||
const result = await fetchJSON<{ workflows: WorkflowDefinitionResponse[] }>(
|
||||
`/api/workflows${params}`
|
||||
);
|
||||
return result.workflows;
|
||||
}
|
||||
|
||||
export async function runWorkflow(
|
||||
name: string,
|
||||
conversationId: string,
|
||||
message: string
|
||||
): Promise<{ accepted: boolean; status: string }> {
|
||||
return fetchJSON(`/api/workflows/${encodeURIComponent(name)}/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ conversationId, message }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listWorkflowRuns(options?: {
|
||||
conversationId?: string;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
codebaseId?: string;
|
||||
}): Promise<WorkflowRunResponse[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.conversationId) params.set('conversationId', options.conversationId);
|
||||
if (options?.status) params.set('status', options.status);
|
||||
if (options?.limit) params.set('limit', String(options.limit));
|
||||
if (options?.codebaseId) params.set('codebaseId', options.codebaseId);
|
||||
const qs = params.toString();
|
||||
const result = await fetchJSON<{ runs: WorkflowRunResponse[] }>(
|
||||
`/api/workflows/runs${qs ? `?${qs}` : ''}`
|
||||
);
|
||||
return result.runs;
|
||||
}
|
||||
|
||||
export async function getWorkflowRun(
|
||||
runId: string
|
||||
): Promise<{ run: WorkflowRunResponse; events: WorkflowEventResponse[] }> {
|
||||
return fetchJSON(`/api/workflows/runs/${encodeURIComponent(runId)}`);
|
||||
}
|
||||
|
||||
export async function getWorkflowRunByWorker(
|
||||
workerPlatformId: string
|
||||
): Promise<{ run: WorkflowRunResponse } | null> {
|
||||
try {
|
||||
return await fetchJSON(`/api/workflows/runs/by-worker/${encodeURIComponent(workerPlatformId)}`);
|
||||
} catch (e: unknown) {
|
||||
// 404 means no run exists yet — expected during dispatch
|
||||
if ((e as Error & { status?: number }).status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConfig(): Promise<{ config: Record<string, unknown>; database: string }> {
|
||||
return fetchJSON('/api/config');
|
||||
}
|
||||
|
||||
// System
|
||||
export async function getHealth(): Promise<HealthResponse> {
|
||||
return fetchJSON<HealthResponse>('/api/health');
|
||||
}
|
||||
11
packages/web/src/lib/message-cache.ts
Normal file
11
packages/web/src/lib/message-cache.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { ChatMessage } from '@/lib/types';
|
||||
|
||||
const messageCache = new Map<string, ChatMessage[]>();
|
||||
|
||||
export function getCachedMessages(id: string): ChatMessage[] {
|
||||
return messageCache.get(id) ?? [];
|
||||
}
|
||||
|
||||
export function setCachedMessages(id: string, msgs: ChatMessage[]): void {
|
||||
messageCache.set(id, msgs);
|
||||
}
|
||||
234
packages/web/src/lib/types.ts
Normal file
234
packages/web/src/lib/types.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
/**
|
||||
* Frontend-specific types for the Archon Web UI.
|
||||
* SSE event types match what the Web adapter emits.
|
||||
*/
|
||||
|
||||
import type {
|
||||
WorkflowRunStatus,
|
||||
WorkflowStepStatus,
|
||||
ArtifactType,
|
||||
} from '@archon/core/workflows/types';
|
||||
export type { WorkflowRunStatus, WorkflowStepStatus, ArtifactType };
|
||||
|
||||
// Base SSE event
|
||||
interface BaseSSEEvent {
|
||||
type: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Text streaming
|
||||
export interface TextEvent extends BaseSSEEvent {
|
||||
type: 'text';
|
||||
content: string;
|
||||
isComplete: boolean;
|
||||
}
|
||||
|
||||
// Tool call started
|
||||
export interface ToolCallEvent extends BaseSSEEvent {
|
||||
type: 'tool_call';
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Tool call completed
|
||||
export interface ToolResultEvent extends BaseSSEEvent {
|
||||
type: 'tool_result';
|
||||
name: string;
|
||||
output: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
// Session metadata
|
||||
export interface SessionInfoEvent extends BaseSSEEvent {
|
||||
type: 'session_info';
|
||||
sessionId: string;
|
||||
cost?: number;
|
||||
tokensIn?: number;
|
||||
tokensOut?: number;
|
||||
}
|
||||
|
||||
// Conversation lock status
|
||||
export interface ConversationLockEvent extends BaseSSEEvent {
|
||||
type: 'conversation_lock';
|
||||
conversationId: string;
|
||||
locked: boolean;
|
||||
queuePosition?: number;
|
||||
}
|
||||
|
||||
// Error with classification
|
||||
export interface ErrorEvent extends BaseSSEEvent {
|
||||
type: 'error';
|
||||
message: string;
|
||||
classification?: 'transient' | 'fatal';
|
||||
suggestedActions?: string[];
|
||||
}
|
||||
|
||||
// Warning (non-fatal, informational)
|
||||
export interface WarningEvent extends BaseSSEEvent {
|
||||
type: 'warning';
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Keep-alive
|
||||
export interface HeartbeatEvent extends BaseSSEEvent {
|
||||
type: 'heartbeat';
|
||||
}
|
||||
|
||||
// Workflow step update
|
||||
export interface WorkflowStepEvent extends BaseSSEEvent {
|
||||
type: 'workflow_step';
|
||||
runId: string;
|
||||
step: number;
|
||||
total: number;
|
||||
name: string;
|
||||
status: WorkflowStepStatus;
|
||||
duration?: number;
|
||||
iteration?: number;
|
||||
}
|
||||
|
||||
/** SSE events only carry active run statuses — 'pending' is excluded because
|
||||
* the server never emits a status event for a run that hasn't started yet. */
|
||||
export type ActiveWorkflowRunStatus = Exclude<WorkflowRunStatus, 'pending'>;
|
||||
|
||||
// Workflow run status
|
||||
export interface WorkflowStatusEvent extends BaseSSEEvent {
|
||||
type: 'workflow_status';
|
||||
runId: string;
|
||||
workflowName: string;
|
||||
status: ActiveWorkflowRunStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Parallel agent status
|
||||
export interface ParallelAgentEvent extends BaseSSEEvent {
|
||||
type: 'parallel_agent';
|
||||
runId: string;
|
||||
step: number;
|
||||
agentIndex: number;
|
||||
totalAgents: number;
|
||||
name: string;
|
||||
status: WorkflowStepStatus;
|
||||
duration?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Workflow artifact
|
||||
export interface WorkflowArtifactEvent extends BaseSSEEvent {
|
||||
type: 'workflow_artifact';
|
||||
runId: string;
|
||||
artifactType: ArtifactType;
|
||||
label: string;
|
||||
url?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
// Background workflow dispatch
|
||||
export interface WorkflowDispatchEvent extends BaseSSEEvent {
|
||||
type: 'workflow_dispatch';
|
||||
workerConversationId: string;
|
||||
workflowName: string;
|
||||
}
|
||||
|
||||
// Background workflow output preview
|
||||
export interface WorkflowOutputPreviewEvent extends BaseSSEEvent {
|
||||
type: 'workflow_output_preview';
|
||||
runId: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated union of all SSE event types emitted by the Web adapter.
|
||||
* Parsed from JSON with no runtime validation — the server is trusted.
|
||||
*/
|
||||
export type SSEEvent =
|
||||
| TextEvent
|
||||
| ToolCallEvent
|
||||
| ToolResultEvent
|
||||
| SessionInfoEvent
|
||||
| ConversationLockEvent
|
||||
| ErrorEvent
|
||||
| WarningEvent
|
||||
| HeartbeatEvent
|
||||
| WorkflowStepEvent
|
||||
| WorkflowStatusEvent
|
||||
| ParallelAgentEvent
|
||||
| WorkflowArtifactEvent
|
||||
| WorkflowDispatchEvent
|
||||
| WorkflowOutputPreviewEvent;
|
||||
|
||||
// UI State types
|
||||
|
||||
/**
|
||||
* UI state for a single chat message. Mixes display state (isStreaming, isExpanded)
|
||||
* with persisted data (content, toolCalls). When loading from the API, display
|
||||
* fields default to their inactive states.
|
||||
*/
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
toolCalls?: ToolCallDisplay[];
|
||||
error?: ErrorDisplay;
|
||||
timestamp: number;
|
||||
isStreaming?: boolean;
|
||||
workflowDispatch?: {
|
||||
workerConversationId: string;
|
||||
workflowName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolCallDisplay {
|
||||
id: string;
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
output?: string;
|
||||
duration?: number;
|
||||
startedAt: number;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
export interface ErrorDisplay {
|
||||
message: string;
|
||||
classification: 'transient' | 'fatal';
|
||||
suggestedActions: string[];
|
||||
}
|
||||
|
||||
// Workflow UI State types
|
||||
|
||||
export interface WorkflowStepState {
|
||||
index: number;
|
||||
name: string;
|
||||
status: WorkflowStepStatus;
|
||||
duration?: number;
|
||||
agents?: ParallelAgentState[];
|
||||
}
|
||||
|
||||
export interface ParallelAgentState {
|
||||
index: number;
|
||||
name: string;
|
||||
status: WorkflowStepStatus;
|
||||
duration?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowArtifact {
|
||||
type: ArtifactType;
|
||||
label: string;
|
||||
url?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowState {
|
||||
runId: string;
|
||||
workflowName: string;
|
||||
status: WorkflowRunStatus;
|
||||
steps: WorkflowStepState[];
|
||||
artifacts: WorkflowArtifact[];
|
||||
isLoop: boolean;
|
||||
currentIteration?: number;
|
||||
maxIterations?: number;
|
||||
startedAt: number;
|
||||
completedAt?: number;
|
||||
error?: string;
|
||||
stale?: boolean;
|
||||
}
|
||||
6
packages/web/src/lib/utils.ts
Normal file
6
packages/web/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
15
packages/web/src/main.tsx
Normal file
15
packages/web/src/main.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './index.css';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error('Root element not found');
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
9
packages/web/src/routes/ChatPage.tsx
Normal file
9
packages/web/src/routes/ChatPage.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { useParams } from 'react-router';
|
||||
import { ChatInterface } from '@/components/chat/ChatInterface';
|
||||
|
||||
export function ChatPage(): React.ReactElement {
|
||||
const { '*': rawConversationId } = useParams();
|
||||
const conversationId = rawConversationId ? decodeURIComponent(rawConversationId) : undefined;
|
||||
|
||||
return <ChatInterface key={conversationId ?? 'new'} conversationId={conversationId ?? 'new'} />;
|
||||
}
|
||||
168
packages/web/src/routes/DashboardPage.tsx
Normal file
168
packages/web/src/routes/DashboardPage.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { Link, useNavigate } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { MessageSquare, PlayCircle, Plus } from 'lucide-react';
|
||||
import {
|
||||
listConversations,
|
||||
listWorkflowRuns,
|
||||
createConversation,
|
||||
type ConversationResponse,
|
||||
type WorkflowRunResponse,
|
||||
} from '@/lib/api';
|
||||
|
||||
const PROJECT_STORAGE_KEY = 'archon-selected-project';
|
||||
|
||||
function formatTime(dateStr: string | null): string {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr.endsWith('Z') ? dateStr : dateStr + 'Z');
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
running: 'bg-primary',
|
||||
completed: 'bg-success',
|
||||
failed: 'bg-destructive',
|
||||
pending: 'bg-text-tertiary',
|
||||
};
|
||||
|
||||
function ConversationRow({ conv }: { conv: ConversationResponse }): React.ReactElement {
|
||||
const title = conv.title ?? 'Untitled conversation';
|
||||
return (
|
||||
<Link
|
||||
to={`/chat/${encodeURIComponent(conv.platform_conversation_id)}`}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2 hover:bg-surface-elevated transition-colors"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
<span className="truncate text-sm text-text-primary">{title}</span>
|
||||
<span className="ml-auto shrink-0 text-xs text-text-tertiary">
|
||||
{formatTime(conv.last_activity_at)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowRunRow({ run }: { run: WorkflowRunResponse }): React.ReactElement {
|
||||
return (
|
||||
<Link
|
||||
to={`/workflows/runs/${encodeURIComponent(run.id)}`}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2 hover:bg-surface-elevated transition-colors"
|
||||
>
|
||||
<div
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${STATUS_COLORS[run.status] ?? 'bg-text-tertiary'}`}
|
||||
/>
|
||||
<span className="truncate text-sm text-text-primary">{run.workflow_name}</span>
|
||||
<span className="ml-auto shrink-0 text-xs text-text-tertiary">
|
||||
{formatTime(run.started_at)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardPage(): React.ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const savedProjectId = localStorage.getItem(PROJECT_STORAGE_KEY);
|
||||
|
||||
const { data: conversations, isLoading: loadingConvs } = useQuery({
|
||||
queryKey: ['conversations', { codebaseId: savedProjectId }],
|
||||
queryFn: () => listConversations(savedProjectId ?? undefined),
|
||||
});
|
||||
|
||||
const { data: workflowRuns, isLoading: loadingRuns } = useQuery({
|
||||
queryKey: ['workflowRuns', { codebaseId: savedProjectId }],
|
||||
queryFn: () => listWorkflowRuns({ codebaseId: savedProjectId ?? undefined, limit: 10 }),
|
||||
});
|
||||
|
||||
const recentConversations = (conversations ?? []).slice(0, 10);
|
||||
const recentRuns = (workflowRuns ?? []).slice(0, 10);
|
||||
const isLoading = loadingConvs || loadingRuns;
|
||||
|
||||
const handleNewChat = async (): Promise<void> => {
|
||||
if (!savedProjectId) return;
|
||||
try {
|
||||
const { conversationId } = await createConversation(savedProjectId);
|
||||
navigate(`/chat/${conversationId}`);
|
||||
} catch (error) {
|
||||
console.error('[Dashboard] Failed to create conversation', { error });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<span className="text-sm text-text-tertiary">Loading...</span>
|
||||
</div>
|
||||
) : recentConversations.length === 0 && recentRuns.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-16">
|
||||
<MessageSquare className="h-10 w-10 text-text-tertiary" />
|
||||
<p className="text-sm text-text-tertiary">No conversations yet</p>
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
disabled={!savedProjectId}
|
||||
className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Recent Conversations */}
|
||||
<section>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-text-secondary">Recent Conversations</h2>
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
disabled={!savedProjectId}
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-text-secondary hover:bg-surface-elevated hover:text-text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-surface">
|
||||
{recentConversations.length > 0 ? (
|
||||
<div className="divide-y divide-border">
|
||||
{recentConversations.map(conv => (
|
||||
<ConversationRow key={conv.id} conv={conv} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<span className="text-xs text-text-tertiary">No conversations</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recent Workflow Runs */}
|
||||
<section>
|
||||
<div className="mb-2 flex items-center">
|
||||
<h2 className="text-sm font-semibold text-text-secondary">Recent Workflow Runs</h2>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-surface">
|
||||
{recentRuns.length > 0 ? (
|
||||
<div className="divide-y divide-border">
|
||||
{recentRuns.map(run => (
|
||||
<WorkflowRunRow key={run.id} run={run} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 justify-center py-8">
|
||||
<PlayCircle className="h-4 w-4 text-text-tertiary" />
|
||||
<span className="text-xs text-text-tertiary">No workflow runs</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
packages/web/src/routes/SettingsPage.tsx
Normal file
98
packages/web/src/routes/SettingsPage.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { getConfig, getHealth } from '@/lib/api';
|
||||
|
||||
export function SettingsPage(): React.ReactElement {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['config'],
|
||||
queryFn: getConfig,
|
||||
});
|
||||
|
||||
const {
|
||||
data: health,
|
||||
isLoading: healthLoading,
|
||||
error: healthError,
|
||||
} = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: getHealth,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Header title="Settings" />
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-2">System Health</h3>
|
||||
{healthLoading && <div className="text-sm text-text-secondary">Checking health...</div>}
|
||||
{healthError && (
|
||||
<div className="text-sm text-error">
|
||||
Failed to check health:{' '}
|
||||
{healthError instanceof Error ? healthError.message : 'Unknown error'}. Check that
|
||||
the server is running.
|
||||
</div>
|
||||
)}
|
||||
{health && (
|
||||
<div className="rounded-lg border border-border bg-surface p-3">
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-text-secondary">Status: </span>
|
||||
<span className="text-text-primary font-medium">{health.status}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Adapter: </span>
|
||||
<span className="text-text-primary font-medium">{health.adapter}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Active: </span>
|
||||
<span className="text-text-primary font-medium">
|
||||
{health.concurrency.active}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Queued: </span>
|
||||
<span className="text-text-primary font-medium">
|
||||
{health.concurrency.queuedTotal}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Max Concurrent: </span>
|
||||
<span className="text-text-primary font-medium">
|
||||
{health.concurrency.maxConcurrent}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{isLoading && <div className="text-sm text-text-secondary">Loading configuration...</div>}
|
||||
{error && (
|
||||
<div className="text-sm text-error">
|
||||
Failed to load configuration:{' '}
|
||||
{error instanceof Error ? error.message : 'Unknown error'}. Try refreshing the page.
|
||||
</div>
|
||||
)}
|
||||
{data && (
|
||||
<>
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-2">Database</h3>
|
||||
<div className="rounded-lg border border-border bg-surface p-3">
|
||||
<span className="text-sm text-text-secondary">Type: </span>
|
||||
<span className="text-sm text-text-primary font-medium">{data.database}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-2">Configuration</h3>
|
||||
<pre className="rounded-lg border border-border bg-surface-inset p-3 text-xs text-text-secondary overflow-auto max-h-96 font-mono">
|
||||
{JSON.stringify(data.config, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
packages/web/src/routes/WorkflowBuilderPage.tsx
Normal file
22
packages/web/src/routes/WorkflowBuilderPage.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { Hammer } from 'lucide-react';
|
||||
|
||||
export function WorkflowBuilderPage(): React.ReactElement {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-surface-elevated">
|
||||
<Hammer className="h-8 w-8 text-text-tertiary" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Workflow Builder</h2>
|
||||
<span className="rounded-full bg-accent-muted px-3 py-1 text-xs font-medium text-primary">
|
||||
Coming Soon
|
||||
</span>
|
||||
<p className="max-w-sm text-center text-sm text-text-tertiary">
|
||||
Design and compose multi-step AI workflows visually.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
packages/web/src/routes/WorkflowExecutionPage.tsx
Normal file
16
packages/web/src/routes/WorkflowExecutionPage.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { useParams } from 'react-router';
|
||||
import { WorkflowExecution } from '@/components/workflows/WorkflowExecution';
|
||||
|
||||
export function WorkflowExecutionPage(): React.ReactElement {
|
||||
const { runId } = useParams<{ runId: string }>();
|
||||
|
||||
if (!runId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-text-secondary">
|
||||
<p>No workflow run ID specified.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <WorkflowExecution key={runId} runId={runId} />;
|
||||
}
|
||||
11
packages/web/src/routes/WorkflowsPage.tsx
Normal file
11
packages/web/src/routes/WorkflowsPage.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { WorkflowList } from '@/components/workflows/WorkflowList';
|
||||
|
||||
export function WorkflowsPage(): React.ReactElement {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<WorkflowList />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
packages/web/tsconfig.json
Normal file
18
packages/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@archon/core": ["../core/src"],
|
||||
"@archon/core/*": ["../core/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
26
packages/web/vite.config.ts
Normal file
26
packages/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import path from 'path';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3090',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue