chore: deep cleanup — remove all ClawSuite/OpenClaw bloat, dead screens, workspace daemon, gateway UI
Deleted: - 30+ stale .md files (specs, audits, roadmaps) - docs/archive/ directory - data/specs/, data/agent-hub-architecture-report.md - workspace-daemon/ (entire ClawSuite orchestration daemon) - release/ (Electron builds) - Screens: activity, costs, cron, tasks, gateway (kept approvals stub) - Components: gateway-setup-wizard, openclaw-update-notifier, update-notifier, compaction-notifier, fallback-banner, exec-approval-toast, gateway-restart-overlay - API routes: gateway/*, cloud/*, cron/*, browser/*, debug/*, events/*, workspace/* (orchestration), cli-agents, system-metrics, diagnostics, etc. - Server: debug-analyzer, activity-stream, activity-events, workspace-proxy, exec-approval-store, browser-monitor, gateway-stream, cron - Stores: mission-store, mission-event-store - Hooks: use-agent-view, use-task-reminders Stubbed (preserves chat functionality): - approvals-store (no-op, chat-screen imports it) - use-research-card (no-op) - research-card component (returns null) - gateway-restart-overlay (passthrough) Rebranded: - CONTRIBUTING.md, docker-compose.yml, vite.config.ts, test-redaction.ts - Env vars: HERMES_WORKSPACE_DIR with OPENCLAW fallback 213 files changed, -56,503 lines. tsc clean.
This commit is contained in:
@@ -11,7 +11,7 @@ Thanks for your interest in contributing! Here's how to get started.
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your gateway URL and token
|
||||
# Hermes Workspace still uses the gateway token from: openclaw config get gateway.auth.token
|
||||
# Hermes Workspace still uses the gateway token from: hermes gateway token
|
||||
```
|
||||
5. **Run dev server:** `npm run dev`
|
||||
6. **Make your changes** on a feature branch
|
||||
@@ -42,7 +42,7 @@ npm run build
|
||||
**First-time setup:**
|
||||
- Copy `.env.example` to `.env`
|
||||
- Set `CLAWDBOT_GATEWAY_URL` (default: `ws://127.0.0.1:18789`)
|
||||
- Set `CLAWDBOT_GATEWAY_TOKEN` (find with `openclaw config get gateway.auth.token`)
|
||||
- Set `HERMES_GATEWAY_TOKEN` (find with `hermes gateway token`)
|
||||
- See [README.md](README.md#environment-setup) for detailed environment variable documentation
|
||||
|
||||
## Guidelines
|
||||
|
||||
@@ -6,8 +6,8 @@ services:
|
||||
environment:
|
||||
- CLAWDBOT_GATEWAY_URL=${CLAWDBOT_GATEWAY_URL:-ws://host.docker.internal:18789}
|
||||
- CLAWDBOT_GATEWAY_TOKEN=${CLAWDBOT_GATEWAY_TOKEN:-}
|
||||
- HERMES_ALLOWED_HOSTS=${HERMES_ALLOWED_HOSTS:-${CLAWSUITE_ALLOWED_HOSTS:-}}
|
||||
- HERMES_PASSWORD=${HERMES_PASSWORD:-${CLAWSUITE_PASSWORD:-}}
|
||||
- HERMES_ALLOWED_HOSTS=${HERMES_ALLOWED_HOSTS:-${HERMES_ALLOWED_HOSTS:-}}
|
||||
- HERMES_PASSWORD=${HERMES_PASSWORD:-${HERMES_PASSWORD:-}}
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
|
||||
@@ -1,429 +0,0 @@
|
||||
# Agent Hub / Mission Control — Code Audit Report
|
||||
|
||||
**Date:** 2026-03-04
|
||||
**Auditor:** Aurora (subagent)
|
||||
**Scope:** Mission lifecycle, session persistence, report viewing, refresh/polling, UX gaps
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The Agent Hub's mission system has a **fundamental architectural split** that causes most reported bugs: missions are managed through **two completely independent systems** that don't talk to each other.
|
||||
|
||||
1. **The sidebar Agent View** (`use-agent-view.ts` + `agent-view-panel.tsx`) — polls `/api/sessions` via gateway, tracks all subagent sessions in real-time, classifies them as active/queued/history.
|
||||
2. **The Mission Control hub** (`agent-hub-layout.tsx` + `mission-checkpoint.ts`) — manages missions via localStorage + per-agent SSE streams, with its own session spawning, task dispatch, and report generation.
|
||||
|
||||
These two systems share almost no state. A mission started from the hub creates gateway sessions, but the hub tracks them in React state + localStorage — not through the gateway's session metadata. When you navigate away, React state dies. When you come back, the hub doesn't know how to reconstruct the mission from gateway sessions.
|
||||
|
||||
---
|
||||
|
||||
## Finding 1: Missions Disappear When Navigating Away
|
||||
|
||||
**Severity: P0**
|
||||
**Root Cause:** Mission runtime state is held entirely in React component state within `AgentHubLayout`.
|
||||
|
||||
### Evidence
|
||||
|
||||
In `agent-hub-layout.tsx`, all mission state lives in `useState` hooks:
|
||||
|
||||
```typescript
|
||||
const [missionActive, setMissionActive] = useState(false)
|
||||
const [missionGoal, setMissionGoal] = useState('')
|
||||
const [activeMissionName, setActiveMissionName] = useState('')
|
||||
const [missionTasks, setMissionTasks] = useState<Array<HubTask>>([])
|
||||
const [agentSessionMap, setAgentSessionMap] = useState<Record<string, string>>(...)
|
||||
const [missionState, setMissionState] = useState<'running' | 'paused' | 'stopped'>('stopped')
|
||||
```
|
||||
|
||||
When the user navigates away from `/agent-swarm`, `AgentHubLayout` unmounts. All of this state is destroyed. When the user returns, the component remounts with fresh default state — `missionActive: false`, `missionState: 'stopped'`, empty tasks, etc.
|
||||
|
||||
### What IS Persisted
|
||||
|
||||
There is a checkpoint system in `mission-checkpoint.ts`:
|
||||
|
||||
```typescript
|
||||
// localStorage keys:
|
||||
const CURRENT_KEY = 'clawsuite:mission-checkpoint'
|
||||
const HISTORY_KEY = 'clawsuite:mission-history'
|
||||
```
|
||||
|
||||
And `agent-hub-layout.tsx` does call `saveMissionCheckpoint()` at various points — task status updates, mission stop. It also reads the checkpoint on mount:
|
||||
|
||||
```typescript
|
||||
const [restoreCheckpoint, setRestoreCheckpoint] = useState<MissionCheckpoint | null>(() => {
|
||||
const cp = loadMissionCheckpoint()
|
||||
return cp?.status === 'running' ? cp : null
|
||||
})
|
||||
```
|
||||
|
||||
**But the restore banner is never rendered.** The variables are declared and then silenced:
|
||||
|
||||
```typescript
|
||||
void restoreCheckpoint
|
||||
void restoreDismissed
|
||||
```
|
||||
|
||||
So the checkpoint is saved to localStorage, but the restore UI was never implemented. The user sees nothing when they return.
|
||||
|
||||
### What's Also Persisted
|
||||
|
||||
Agent session mappings are stored in localStorage:
|
||||
|
||||
```typescript
|
||||
// key: 'clawsuite:hub-agent-sessions'
|
||||
// value: { [agentId]: { sessionKey, model? } }
|
||||
```
|
||||
|
||||
This is read on mount, but since `missionActive` defaults to `false` and no restore flow exists, these sessions are orphaned.
|
||||
|
||||
### Fix Recommendation
|
||||
|
||||
1. **Implement the restore flow.** When `AgentHubLayout` mounts and finds a `running` checkpoint in localStorage:
|
||||
- Set `missionActive = true`, `missionState = 'running'`
|
||||
- Restore `missionTasks`, `team`, `agentSessionMap` from the checkpoint
|
||||
- Re-open SSE streams to the existing sessions
|
||||
- Show a "Mission in progress — reconnecting..." banner
|
||||
|
||||
2. **Move critical mission state to Zustand with `persist` middleware** (like `useAgentViewStore` already does). This survives navigation without relying on localStorage checkpoint sync.
|
||||
|
||||
---
|
||||
|
||||
## Finding 2: Completed Missions Don't Appear in Missions Tab
|
||||
|
||||
**Severity: P0**
|
||||
**Root Cause:** The Missions tab's `HistoryView` looks for two things, and the data pipeline for both is fragile.
|
||||
|
||||
### How HistoryView Works
|
||||
|
||||
In `agent-hub-layout.tsx`, the `HistoryView` component:
|
||||
|
||||
1. **Fetches `/api/sessions`** and filters for sessions where `label.startsWith('Mission:')`:
|
||||
```typescript
|
||||
const missionSessions = (data.sessions ?? [])
|
||||
.filter((s) => {
|
||||
const label = readString(s.label)
|
||||
return label.startsWith('Mission:')
|
||||
})
|
||||
```
|
||||
|
||||
2. **Reads `loadMissionHistory()` from localStorage** — the archived checkpoints.
|
||||
|
||||
### Why Completed Missions Vanish
|
||||
|
||||
**Problem A: Gateway session lifecycle.** When a mission completes, `stopMissionAndCleanup()` is called:
|
||||
|
||||
```typescript
|
||||
const stopMissionAndCleanup = useCallback((reason: 'aborted' | 'completed' = 'aborted') => {
|
||||
// ...
|
||||
Object.values(agentSessionMap).forEach((sessionKey) => {
|
||||
// ABORT the chat
|
||||
fetch('/api/chat-abort', { ... })
|
||||
// DELETE the session
|
||||
fetch(`/api/sessions?sessionKey=${encodeURIComponent(sessionKey)}`, {
|
||||
method: 'DELETE',
|
||||
}).catch(() => {})
|
||||
})
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
**The sessions are DELETED from the gateway on completion.** So when `HistoryView` fetches `/api/sessions`, the completed mission's sessions no longer exist. They were just destroyed.
|
||||
|
||||
**Problem B: The checkpoint is archived, but...** `archiveMissionToHistory()` IS called before cleanup, which writes to `clawsuite:mission-history` in localStorage. And `HistoryView` DOES read this. So local checkpoints should appear.
|
||||
|
||||
BUT — if `stopMissionAndCleanup` is called via the auto-completion path (SSE `done` events), there's a timing issue. The `buildMissionCompletionSnapshot` is called, but it captures from `agentOutputLinesRef` which may not have the final output yet. The snapshot is stored in `missionCompletionSnapshotRef` — but is it actually saved to localStorage history?
|
||||
|
||||
Looking at the `missionState` transition effect — when `missionState` goes from `'running'` to `'stopped'`, the component needs to detect this and archive. But if the component has already unmounted (user navigated away), this effect never runs.
|
||||
|
||||
**Problem C: Old missions showing (7d ago).** These are gateway sessions that were created with `Mission:` labels but were never deleted (perhaps from a crash or the user closing the browser before cleanup). They persist in the gateway's session store. Recently completed ones are gone because `stopMissionAndCleanup` explicitly deletes them.
|
||||
|
||||
### Fix Recommendation
|
||||
|
||||
1. **Don't delete gateway sessions on mission completion.** Instead, patch their status to `completed` via `sessions.patch`. This preserves them for the history view.
|
||||
2. **Add a `completedAt` timestamp** to the session metadata when completing.
|
||||
3. **Archive to localStorage immediately in `stopMissionAndCleanup`** before deleting sessions, ensuring the checkpoint has all output data.
|
||||
4. **Consider a dedicated `/api/mission-history` endpoint** that stores mission reports server-side rather than relying on localStorage.
|
||||
|
||||
---
|
||||
|
||||
## Finding 3: Can't View Final Report After Completion
|
||||
|
||||
**Severity: P1**
|
||||
**Root Cause:** Reports are generated in-memory and stored in localStorage, but the viewing flow is incomplete.
|
||||
|
||||
### How Reports Work
|
||||
|
||||
When a mission completes, `generateMissionReport()` creates a markdown report from:
|
||||
- Mission goal, team, tasks
|
||||
- Per-agent output (from `agentOutputLinesRef`)
|
||||
- Detected artifacts (code blocks, URLs, tables)
|
||||
- Token count and cost estimate
|
||||
|
||||
This report is saved via `saveStoredMissionReport()` to:
|
||||
```typescript
|
||||
const MISSION_REPORTS_STORAGE_KEY = 'clawsuite-mission-reports'
|
||||
const MAX_MISSION_REPORTS = 10
|
||||
```
|
||||
|
||||
### The Problem
|
||||
|
||||
The report generation happens in a `useEffect` that watches `missionState` transitions. When the state goes from `running` → `stopped`, it reads `missionCompletionSnapshotRef.current`, generates a report, and saves it.
|
||||
|
||||
But there are multiple failure modes:
|
||||
|
||||
1. **Snapshot is null.** If the mission auto-completes via the SSE safety net (`setTimeout` 6000ms), and the ref was set from a stale closure, the snapshot may be incomplete or null.
|
||||
|
||||
2. **Component unmount race.** If the user navigates away while the 5000ms/6000ms auto-completion timer is pending, the callback still fires but `setMissionState` on an unmounted component is a no-op. The report is never generated.
|
||||
|
||||
3. **`HistoryView` shows checkpoints, not reports.** Even if the report is saved to `clawsuite-mission-reports`, the `HistoryView` component shows:
|
||||
- Local checkpoints from `clawsuite:mission-history` (label, team, tasks — but NOT the full report)
|
||||
- Gateway sessions with `Mission:` prefix (which are deleted on completion)
|
||||
|
||||
There's no UI in `HistoryView` that reads from `clawsuite-mission-reports` and shows the full markdown report.
|
||||
|
||||
4. **`selectedReport` state exists** in `AgentHubLayout` — there's state for viewing a report (`selectedReport`, `completionReportVisible`, `completionReport`), but the `HistoryView` component doesn't use these. They're used by report modals that are triggered from the completion flow, not from the history tab.
|
||||
|
||||
### Fix Recommendation
|
||||
|
||||
1. **Add a "View Report" button to each item in `HistoryView`** that loads from `clawsuite-mission-reports` by matching `missionId`.
|
||||
2. **Store the report markdown inside the checkpoint** (`MissionCheckpoint.report?: string`) so history items are self-contained.
|
||||
3. **Generate the report synchronously in `stopMissionAndCleanup`** before any async cleanup, to avoid timing issues.
|
||||
|
||||
---
|
||||
|
||||
## Finding 4: Sidebar Agent View vs Hub Are Disconnected
|
||||
|
||||
**Severity: P1**
|
||||
**Root Cause:** Two independent polling/display systems with no shared state.
|
||||
|
||||
### The Sidebar (`use-agent-view.ts` + `agent-view-panel.tsx`)
|
||||
|
||||
- Polls `/api/sessions` every 5s via `fetchSessions()`
|
||||
- Filters sessions with `isAgentSession()` — includes anything with `subagent:` in the key
|
||||
- Classifies into active/queued/history based on status
|
||||
- Displays in the right sidebar panel
|
||||
|
||||
### The Hub (`agent-hub-layout.tsx`)
|
||||
|
||||
- Manages its own `agentSessionMap` (agentId → sessionKey)
|
||||
- Opens SSE streams per agent
|
||||
- Tracks status via `agentSessionStatus` React state
|
||||
- Has its own task board, artifacts, output lines, etc.
|
||||
|
||||
### Consequences
|
||||
|
||||
- A mission running in the hub appears in the sidebar as individual agents, but there's no "mission" concept in the sidebar.
|
||||
- The sidebar's "History" section (`historyAgents`) shows completed/failed sessions, but these are individual agent sessions — not grouped as a mission.
|
||||
- Killing an agent from the sidebar doesn't notify the hub's mission state.
|
||||
- The hub creates sessions labeled `Mission: {agentName}`, but the sidebar doesn't give these special treatment.
|
||||
|
||||
### Fix Recommendation
|
||||
|
||||
Create a shared `MissionStore` (Zustand with persist) that both the sidebar and hub can read from:
|
||||
```typescript
|
||||
type MissionStore = {
|
||||
activeMission: {
|
||||
id: string
|
||||
goal: string
|
||||
team: TeamMember[]
|
||||
tasks: HubTask[]
|
||||
agentSessions: Record<string, string>
|
||||
state: 'running' | 'paused' | 'stopped'
|
||||
startedAt: number
|
||||
} | null
|
||||
missionHistory: StoredMissionReport[]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Finding 5: 68% Mission Failure Rate
|
||||
|
||||
**Severity: P1**
|
||||
**Root Cause:** Multiple factors contribute to high failure rates.
|
||||
|
||||
### Analysis
|
||||
|
||||
1. **Session deletion = "failed" in the sidebar.** When `stopMissionAndCleanup` deletes sessions, any that haven't reached `complete` status are classified as `failed` by `use-agent-view.ts`:
|
||||
```typescript
|
||||
if (['failed', 'error', 'cancelled', 'canceled', 'killed'].includes(status))
|
||||
return 'failed'
|
||||
```
|
||||
Deleting a session while it's running or idle causes the gateway to mark it with an error/cancelled stop reason.
|
||||
|
||||
2. **Staleness heuristic misclassification.** In `agent-swarm-store.ts`:
|
||||
```typescript
|
||||
if (hasTokens && staleness > 30_000) return 'complete'
|
||||
```
|
||||
But in `use-agent-view.ts`:
|
||||
```typescript
|
||||
if (tokens > 0 && staleness > 30_000) return 'complete'
|
||||
```
|
||||
Sessions that are actually still running but haven't sent an update in 30s get marked as "complete" prematurely.
|
||||
|
||||
3. **No error recovery.** When a session fails (model error, rate limit, etc.), the hub detects it via SSE `error` events but has no retry mechanism. The task stays in `in_progress` and the mission eventually times out.
|
||||
|
||||
### Fix Recommendation
|
||||
|
||||
1. Stop deleting sessions on mission completion — patch status instead.
|
||||
2. Increase the staleness threshold to 120s for "complete" heuristic.
|
||||
3. Add retry logic: if an agent session errors, auto-spawn a replacement session for the same tasks.
|
||||
|
||||
---
|
||||
|
||||
## Finding 6: All Missions Show "1 Agent"
|
||||
|
||||
**Severity: P2**
|
||||
**Root Cause:** The sidebar Agent View treats each session independently.
|
||||
|
||||
The sidebar (`agent-view-panel.tsx`) shows agents individually — there's no mission grouping. When it says "1 agent", it means 1 active session, not "1 agent in the mission." If a mission spawned 3 agents, the sidebar would show 3 separate agent cards.
|
||||
|
||||
The "1 agent" display is likely because:
|
||||
1. Only 1 agent was actually spawned per mission (sequential processing)
|
||||
2. Or other agent sessions completed/failed quickly and moved to history
|
||||
|
||||
The hub does support multi-agent teams, but sequential processing dispatches one agent at a time:
|
||||
```typescript
|
||||
const [processType, setProcessType] = useState<'sequential' | 'hierarchical' | 'parallel'>('parallel')
|
||||
```
|
||||
|
||||
Default is `parallel` but the actual dispatch logic may serialize. Would need to trace the dispatch flow deeper.
|
||||
|
||||
---
|
||||
|
||||
## Finding 7: Usage & Cost Shows $0.00
|
||||
|
||||
**Severity: P2**
|
||||
**Root Cause:** Cost tracking uses a rough heuristic, and the data source is unreliable.
|
||||
|
||||
In `agent-hub-layout.tsx`:
|
||||
```typescript
|
||||
const ROUGH_COST_PER_1K_TOKENS_USD = 0.01
|
||||
|
||||
function estimateMissionCost(tokenCount: number): number {
|
||||
return Number(((tokenCount / 1000) * ROUGH_COST_PER_1K_TOKENS_USD).toFixed(2))
|
||||
}
|
||||
```
|
||||
|
||||
Token count is tracked via SSE chunks:
|
||||
```typescript
|
||||
setMissionTokenCount((current) => current + Math.ceil(text.length / 4))
|
||||
```
|
||||
|
||||
This is a rough char/4 estimate. If SSE streams aren't connected (sessions failed to spawn, or component unmounted), `missionTokenCount` stays at 0, giving $0.00 cost.
|
||||
|
||||
In the sidebar, `formatCost` sums `agent.estimatedCost` which comes from:
|
||||
```typescript
|
||||
function readEstimatedCost(session, status, tokenCount): number {
|
||||
// Falls back to: tokenCount * 0.000004
|
||||
}
|
||||
```
|
||||
|
||||
If the gateway doesn't report cost (most don't for OAuth sessions), and token count is low or zero, cost shows as $0.00.
|
||||
|
||||
### Fix Recommendation
|
||||
|
||||
1. Use the gateway's actual token counts from session metadata instead of character-count heuristics.
|
||||
2. If using OAuth providers (free), display "Free (OAuth)" instead of $0.00 to avoid confusion.
|
||||
|
||||
---
|
||||
|
||||
## Finding 8: Refresh/Polling Architecture
|
||||
|
||||
**Severity: P2**
|
||||
|
||||
### Current Setup
|
||||
|
||||
- **Sidebar:** `REFRESH_INTERVAL_MS = 5000` — polls `/api/sessions` every 5s
|
||||
- **Hub:** SSE streams per agent for real-time updates, plus:
|
||||
- Gateway status: 15s polling
|
||||
- Session status: 10s polling via `useQuery` in `AgentsScreen`
|
||||
- Approvals: 8s polling
|
||||
- **No SSE for mission-level events.** The hub opens per-agent SSE streams but there's no centralized event that says "mission completed."
|
||||
|
||||
### Race Conditions
|
||||
|
||||
1. **SSE `done` event → mission auto-complete.** Uses a `setTimeout(5000)` delay to let output flush. If 2 agents finish within 5s of each other, both `done` events fire, but only the second one triggers auto-complete. The first agent's final output may not be captured in the snapshot.
|
||||
|
||||
2. **Component unmount during timeout.** If user navigates away during the 5000ms window, `setMissionState` is called on an unmounted component. React ignores this silently, so the mission never transitions to `stopped` and the report is never generated.
|
||||
|
||||
### Fix Recommendation
|
||||
|
||||
1. Move auto-completion logic out of the component — use a Zustand store action or a service worker.
|
||||
2. Use `AbortController` for cleanup on unmount, and persist mission state to localStorage before the component unmounts.
|
||||
3. Add an `onbeforeunload` handler to save checkpoint if mission is active.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Recommendations
|
||||
|
||||
### 1. Unified Mission Store (Zustand + persist)
|
||||
|
||||
Replace the 15+ `useState` hooks in `AgentHubLayout` with a single Zustand store:
|
||||
|
||||
```typescript
|
||||
const useMissionStore = create(persist(
|
||||
(set, get) => ({
|
||||
activeMission: null as ActiveMission | null,
|
||||
missionHistory: [] as MissionReport[],
|
||||
|
||||
startMission: (goal, team, tasks) => { ... },
|
||||
completeMission: () => { ... },
|
||||
abortMission: () => { ... },
|
||||
updateTaskStatus: (taskId, status) => { ... },
|
||||
}),
|
||||
{ name: 'clawsuite:mission-store' }
|
||||
))
|
||||
```
|
||||
|
||||
Benefits:
|
||||
- Survives navigation (persist middleware)
|
||||
- Shared between sidebar and hub
|
||||
- Single source of truth
|
||||
- Testable
|
||||
|
||||
### 2. Don't Delete Sessions on Completion
|
||||
|
||||
Patch session status to `completed` with metadata. This preserves history in the gateway and lets HistoryView find them.
|
||||
|
||||
### 3. Mission Report Storage
|
||||
|
||||
Store reports as part of the mission checkpoint, not in a separate localStorage key. Include the full markdown report in `MissionCheckpoint` so HistoryView can render it directly.
|
||||
|
||||
### 4. Implement the Restore Banner
|
||||
|
||||
The code is half-written — `restoreCheckpoint` state is loaded from localStorage but the UI is `void`-silenced. Wire it up to show a "Resume mission?" banner with the mission goal and team info.
|
||||
|
||||
### 5. Add `beforeunload` Guard
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (missionActive) {
|
||||
saveMissionCheckpoint(currentCheckpoint)
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handler)
|
||||
return () => window.removeEventListener('beforeunload', handler)
|
||||
}, [missionActive])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary Table
|
||||
|
||||
| # | Finding | Severity | Files |
|
||||
|---|---------|----------|-------|
|
||||
| 1 | Missions disappear on navigation — React state only, restore UI voided | **P0** | `agent-hub-layout.tsx`, `mission-checkpoint.ts` |
|
||||
| 2 | Completed missions vanish — sessions deleted on completion | **P0** | `agent-hub-layout.tsx` (`stopMissionAndCleanup`) |
|
||||
| 3 | Can't view final report — report stored but no UI to access from history | **P1** | `agent-hub-layout.tsx` (`HistoryView`, `selectedReport`) |
|
||||
| 4 | Sidebar and Hub disconnected — two independent tracking systems | **P1** | `use-agent-view.ts`, `agent-hub-layout.tsx` |
|
||||
| 5 | 68% failure rate — session deletion + staleness heuristic | **P1** | `agent-hub-layout.tsx`, `agent-swarm-store.ts`, `use-agent-view.ts` |
|
||||
| 6 | "1 agent" display — sidebar doesn't group by mission | **P2** | `agent-view-panel.tsx` |
|
||||
| 7 | $0.00 cost — heuristic token counting, OAuth shows as free | **P2** | `agent-hub-layout.tsx` |
|
||||
| 8 | Race conditions in auto-completion timers | **P2** | `agent-hub-layout.tsx` (SSE `done` handler) |
|
||||
|
||||
---
|
||||
|
||||
*End of audit. No code changes made.*
|
||||
@@ -182,7 +182,7 @@ if (redactedObject.gatewayUrl === 'ws://127.0.0.1:18789') {
|
||||
console.log('\n🔒 Testing folder name extraction...\n')
|
||||
|
||||
const pathTests = [
|
||||
{ input: '/Users/eric/projects/clawsuite', expected: 'clawsuite' },
|
||||
{ input: '/Users/eric/projects/hermes-workspace', expected: 'hermes-workspace' },
|
||||
{ input: 'C:\\Users\\Eric\\Desktop\\project', expected: 'project' },
|
||||
{ input: null, expected: 'Not set' },
|
||||
{ input: '', expected: 'Not set' },
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* CompactionNotifier — gateway-driven compaction detection.
|
||||
*
|
||||
* Listens to the /api/chat-events SSE stream for gateway `compaction` events
|
||||
* (stream="compaction", phase="start"|"end") — the exact same signal the
|
||||
* Hermes control UI uses. No polling, no heuristics.
|
||||
*
|
||||
* Shows a toast on any screen:
|
||||
* - phase "start" → amber "Compacting context…" (dismissible)
|
||||
* - phase "end" → green "Context compacted" (auto-dismisses)
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
|
||||
export function CompactionNotifier() {
|
||||
const startToastIdRef = useRef<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
let es: EventSource | null = null
|
||||
let active = true
|
||||
|
||||
function connect() {
|
||||
if (!active) return
|
||||
es = new EventSource('/api/chat-events')
|
||||
|
||||
es.addEventListener('compaction', (e: MessageEvent) => {
|
||||
if (!active) return
|
||||
try {
|
||||
const data = JSON.parse(e.data) as { phase?: string; sessionKey?: string }
|
||||
|
||||
if (data.phase === 'start') {
|
||||
startToastIdRef.current = true
|
||||
toast('🗜️ Compacting context… older messages will be summarized', {
|
||||
type: 'info',
|
||||
duration: 30_000,
|
||||
})
|
||||
} else if (data.phase === 'end') {
|
||||
startToastIdRef.current = false
|
||||
toast('✅ Context compacted — session history summarized', {
|
||||
type: 'success',
|
||||
duration: 5_000, // matches gateway's Wp=5000 auto-clear
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed event */
|
||||
}
|
||||
})
|
||||
|
||||
es.addEventListener('error', () => {
|
||||
// SSE error — the main chat-events connection handles reconnect;
|
||||
// just close this instance and let it reconnect naturally.
|
||||
es?.close()
|
||||
es = null
|
||||
})
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
es?.close()
|
||||
es = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
fetchGatewayApprovals,
|
||||
resolveGatewayApproval,
|
||||
type GatewayApprovalEntry,
|
||||
} from '@/lib/gateway-api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast as showToast } from '@/components/ui/toast'
|
||||
|
||||
/**
|
||||
* ExecApprovalToast — Global overlay for exec approval requests.
|
||||
*
|
||||
* - Polls gateway every 3s for pending approvals
|
||||
* - Shows stacked cards (up to 3 visible, count badge for overflow)
|
||||
* - Each card has a 30s countdown → auto-deny on timeout
|
||||
* - Approve (green) / Deny (red) buttons
|
||||
* - Fixed position, z-index 9999, always on top
|
||||
* - Matches dark theme
|
||||
*/
|
||||
|
||||
type EnrichedApproval = GatewayApprovalEntry & {
|
||||
timeoutMs?: number
|
||||
timeoutAt?: number
|
||||
expiresAt?: number
|
||||
deadline?: number
|
||||
/** Locally-computed deadline for auto-deny (ms since epoch) */
|
||||
_localDeadline?: number
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000
|
||||
|
||||
function approvalText(approval: GatewayApprovalEntry): string {
|
||||
if (typeof approval.action === 'string' && approval.action.trim().length > 0) return approval.action
|
||||
if (typeof approval.tool === 'string' && approval.tool.trim().length > 0) return approval.tool
|
||||
if (approval.input !== undefined) {
|
||||
try {
|
||||
return JSON.stringify(approval.input)
|
||||
} catch {
|
||||
return 'Approval requested'
|
||||
}
|
||||
}
|
||||
return 'Approval requested'
|
||||
}
|
||||
|
||||
function approvalAgent(approval: GatewayApprovalEntry): string {
|
||||
return approval.agentName ?? approval.sessionKey ?? 'Agent'
|
||||
}
|
||||
|
||||
function approvalContext(approval: GatewayApprovalEntry): string | null {
|
||||
if (typeof approval.context === 'string' && approval.context.trim().length > 0) {
|
||||
return approval.context.trim()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function toDeadline(approval: EnrichedApproval): number {
|
||||
if (typeof approval.timeoutAt === 'number' && Number.isFinite(approval.timeoutAt)) return approval.timeoutAt
|
||||
if (typeof approval.expiresAt === 'number' && Number.isFinite(approval.expiresAt)) return approval.expiresAt
|
||||
if (typeof approval.deadline === 'number' && Number.isFinite(approval.deadline)) return approval.deadline
|
||||
if (typeof approval.timeoutMs === 'number' && Number.isFinite(approval.timeoutMs)) {
|
||||
const requested = approval.requestedAt ?? Date.now()
|
||||
return requested + Math.max(0, approval.timeoutMs)
|
||||
}
|
||||
// Fallback: use local deadline or default 30s from when we first saw it
|
||||
if (approval._localDeadline) return approval._localDeadline
|
||||
return (approval.requestedAt ?? Date.now()) + DEFAULT_TIMEOUT_MS
|
||||
}
|
||||
|
||||
function formatCountdown(ms: number): string {
|
||||
const total = Math.max(0, Math.ceil(ms / 1000))
|
||||
if (total <= 0) return '0s'
|
||||
if (total < 60) return `${total}s`
|
||||
const minutes = Math.floor(total / 60)
|
||||
const seconds = total % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function riskLevel(text: string): 'low' | 'medium' | 'high' {
|
||||
const lower = text.toLowerCase()
|
||||
if (/(rm\s+-rf|drop\s+table|truncate|sudo|chown|chmod\s+777|delete\s+all|force)/.test(lower)) return 'high'
|
||||
if (/(write|edit|patch|install|deploy|execute|run|kill|terminate|delete|update)/.test(lower)) return 'medium'
|
||||
return 'low'
|
||||
}
|
||||
|
||||
const RISK_BADGE: Record<string, { bg: string; text: string; label: string }> = {
|
||||
low: { bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-700 dark:text-emerald-300', label: 'Low Risk' },
|
||||
medium: { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-700 dark:text-amber-300', label: 'Med Risk' },
|
||||
high: { bg: 'bg-red-100 dark:bg-red-900/30', text: 'text-red-700 dark:text-red-300', label: 'High Risk' },
|
||||
}
|
||||
|
||||
export function ExecApprovalToast() {
|
||||
const [gatewayPending, setGatewayPending] = useState<EnrichedApproval[]>([])
|
||||
const [resolving, setResolving] = useState<Record<string, 'approve' | 'deny'>>({})
|
||||
const [now, setNow] = useState(Date.now())
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(new Set())
|
||||
const seenRef = useRef<Map<string, number>>(new Map())
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchGatewayApprovals()
|
||||
const rows = (response.pending ?? response.approvals ?? []) as EnrichedApproval[]
|
||||
const pending = rows.filter((entry) => (entry.status ?? 'pending') === 'pending')
|
||||
|
||||
// Assign local deadlines for approvals we haven't seen before
|
||||
const seenMap = seenRef.current
|
||||
const enriched = pending.map((entry) => {
|
||||
if (!seenMap.has(entry.id)) {
|
||||
const deadline = (entry.requestedAt ?? Date.now()) + DEFAULT_TIMEOUT_MS
|
||||
seenMap.set(entry.id, deadline)
|
||||
}
|
||||
return { ...entry, _localDeadline: seenMap.get(entry.id) }
|
||||
})
|
||||
|
||||
setGatewayPending(enriched)
|
||||
} catch {
|
||||
// silently ignore fetch errors
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Poll gateway + tick countdown
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
const poll = window.setInterval(() => void refresh(), 3_000)
|
||||
const ticker = window.setInterval(() => setNow(Date.now()), 1_000)
|
||||
return () => {
|
||||
window.clearInterval(poll)
|
||||
window.clearInterval(ticker)
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
// Auto-deny expired approvals
|
||||
useEffect(() => {
|
||||
for (const approval of gatewayPending) {
|
||||
const deadline = toDeadline(approval)
|
||||
const remaining = deadline - now
|
||||
if (remaining <= 0 && !resolving[approval.id] && !dismissed.has(approval.id)) {
|
||||
// Auto-deny
|
||||
void resolveGatewayApproval(approval.id, 'deny').then(() => {
|
||||
showToast(`Auto-denied: ${approvalText(approval).slice(0, 60)}`, { type: 'warning' })
|
||||
void refresh()
|
||||
})
|
||||
setDismissed((prev) => new Set(prev).add(approval.id))
|
||||
}
|
||||
}
|
||||
}, [gatewayPending, now, resolving, dismissed, refresh])
|
||||
|
||||
// Filter out dismissed/resolved items
|
||||
const visibleApprovals = useMemo(() => {
|
||||
return gatewayPending
|
||||
.filter((entry) => !dismissed.has(entry.id))
|
||||
.sort((a, b) => (a.requestedAt ?? 0) - (b.requestedAt ?? 0))
|
||||
}, [gatewayPending, dismissed])
|
||||
|
||||
const pendingCount = visibleApprovals.length
|
||||
const displayedApprovals = visibleApprovals.slice(0, 3)
|
||||
const overflowCount = Math.max(0, pendingCount - 3)
|
||||
|
||||
async function handleResolve(id: string, action: 'approve' | 'deny') {
|
||||
setResolving((prev) => ({ ...prev, [id]: action }))
|
||||
try {
|
||||
const result = await resolveGatewayApproval(id, action)
|
||||
if (result.ok) {
|
||||
showToast(
|
||||
action === 'approve' ? 'Approved ✓' : 'Denied ✕',
|
||||
{ type: action === 'approve' ? 'success' : 'error' },
|
||||
)
|
||||
} else {
|
||||
showToast('Failed to resolve approval', { type: 'error' })
|
||||
}
|
||||
setDismissed((prev) => new Set(prev).add(id))
|
||||
await refresh()
|
||||
} finally {
|
||||
setResolving((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[id]
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingCount === 0) return null
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[9999] flex w-[min(440px,calc(100vw-2rem))] flex-col gap-2">
|
||||
{/* Overflow badge */}
|
||||
{overflowCount > 0 && (
|
||||
<div className="pointer-events-auto self-end rounded-full bg-amber-500 px-3 py-1 text-xs font-bold text-white shadow-lg">
|
||||
+{overflowCount} more pending
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stacked approval cards (newest at bottom / closest to user) */}
|
||||
{displayedApprovals.map((approval, index) => {
|
||||
const isTop = index === displayedApprovals.length - 1
|
||||
const deadline = toDeadline(approval)
|
||||
const remaining = Math.max(0, deadline - now)
|
||||
const totalTimeout = DEFAULT_TIMEOUT_MS
|
||||
const progressPct = Math.max(0, Math.min(100, (remaining / totalTimeout) * 100))
|
||||
const countdown = formatCountdown(remaining)
|
||||
const isUrgent = remaining < 10_000
|
||||
const text = approvalText(approval)
|
||||
const agent = approvalAgent(approval)
|
||||
const context = approvalContext(approval)
|
||||
const risk = riskLevel(text + ' ' + (context ?? ''))
|
||||
const riskBadge = RISK_BADGE[risk]
|
||||
const busy = resolving[approval.id]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={approval.id}
|
||||
className={cn(
|
||||
'pointer-events-auto overflow-hidden rounded-2xl border shadow-2xl backdrop-blur transition-all duration-300',
|
||||
isTop
|
||||
? 'border-amber-300 bg-white/98 dark:border-amber-800/60 dark:bg-neutral-950/98'
|
||||
: 'border-neutral-200 bg-white/90 opacity-80 dark:border-neutral-800 dark:bg-neutral-950/90',
|
||||
isUrgent && isTop && 'ring-2 ring-red-400/50 border-red-300 dark:border-red-800/60',
|
||||
)}
|
||||
style={{
|
||||
// Slight scale-down for stacked cards behind the front one
|
||||
transform: !isTop ? `scale(${0.96 - index * 0.02})` : undefined,
|
||||
}}
|
||||
>
|
||||
{/* Countdown progress bar */}
|
||||
<div className="h-[3px] w-full bg-neutral-100 dark:bg-neutral-800">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full transition-all duration-1000 ease-linear rounded-r-full',
|
||||
isUrgent ? 'bg-red-500' : 'bg-amber-400',
|
||||
)}
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{/* Header row: badge + agent + countdown */}
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
|
||||
⚡ Exec Approval
|
||||
</span>
|
||||
<span className={cn('rounded-full px-1.5 py-0.5 text-[9px] font-semibold uppercase', riskBadge.bg, riskBadge.text)}>
|
||||
{riskBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
'font-mono text-[11px] font-bold tabular-nums',
|
||||
isUrgent ? 'text-red-500 animate-pulse' : 'text-neutral-500 dark:text-neutral-400',
|
||||
)}>
|
||||
{countdown}
|
||||
</span>
|
||||
{pendingCount > 1 && (
|
||||
<span className="rounded-full bg-neutral-100 px-1.5 py-0.5 text-[9px] font-bold text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent name */}
|
||||
<p className="mb-1 text-[11px] font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
{agent}
|
||||
</p>
|
||||
|
||||
{/* Command text */}
|
||||
<p className="line-clamp-3 font-mono text-xs font-semibold leading-relaxed text-neutral-900 dark:text-neutral-100">
|
||||
{text}
|
||||
</p>
|
||||
|
||||
{/* Working directory / context */}
|
||||
{context && (
|
||||
<p className="mt-1 line-clamp-1 font-mono text-[10px] text-neutral-500 dark:text-neutral-500">
|
||||
📁 {context}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleResolve(approval.id, 'approve')}
|
||||
disabled={Boolean(busy)}
|
||||
className={cn(
|
||||
'flex-1 rounded-lg bg-emerald-600 py-2 text-xs font-bold text-white transition-all hover:bg-emerald-700 active:scale-[0.98]',
|
||||
busy && 'cursor-not-allowed opacity-60',
|
||||
)}
|
||||
>
|
||||
{busy === 'approve' ? (
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<span className="size-3 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
Approving…
|
||||
</span>
|
||||
) : (
|
||||
'✓ Approve'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleResolve(approval.id, 'deny')}
|
||||
disabled={Boolean(busy)}
|
||||
className={cn(
|
||||
'flex-1 rounded-lg border border-red-300 bg-white py-2 text-xs font-bold text-red-600 transition-all hover:bg-red-50 active:scale-[0.98] dark:border-red-800/50 dark:bg-neutral-900 dark:text-red-400 dark:hover:bg-red-950/30',
|
||||
busy && 'cursor-not-allowed opacity-60',
|
||||
)}
|
||||
>
|
||||
{busy === 'deny' ? (
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<span className="size-3 animate-spin rounded-full border-2 border-red-300 border-t-red-600" />
|
||||
Denying…
|
||||
</span>
|
||||
) : (
|
||||
'✕ Deny'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* FallbackBanner — gateway-driven model fallback notification.
|
||||
*
|
||||
* Listens to the /api/chat-events SSE stream for gateway `fallback` events
|
||||
* (phase="fallback"|"fallback_cleared"). Shows a toast when the gateway
|
||||
* switches to a fallback model, and another when the primary is restored.
|
||||
*
|
||||
* Returns null (no DOM) — same pattern as compaction-notifier.tsx.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
|
||||
export function FallbackBanner() {
|
||||
useEffect(() => {
|
||||
let es: EventSource | null = null
|
||||
let active = true
|
||||
|
||||
function connect() {
|
||||
if (!active) return
|
||||
es = new EventSource('/api/chat-events')
|
||||
|
||||
es.addEventListener('fallback', (e: MessageEvent) => {
|
||||
if (!active) return
|
||||
try {
|
||||
const data = JSON.parse(e.data) as {
|
||||
phase?: string
|
||||
selectedModel?: string
|
||||
activeModel?: string
|
||||
previousModel?: string
|
||||
reason?: string
|
||||
attempts?: number
|
||||
sessionKey?: string
|
||||
}
|
||||
|
||||
if (data.phase === 'fallback') {
|
||||
const model = data.activeModel ?? data.selectedModel ?? 'fallback model'
|
||||
const reason = data.reason ?? 'primary model unavailable'
|
||||
toast(`⚠️ Model switched to ${model} — ${reason}`, {
|
||||
type: 'warning',
|
||||
duration: 30_000,
|
||||
})
|
||||
} else if (data.phase === 'fallback_cleared') {
|
||||
toast('✅ Primary model restored', {
|
||||
type: 'success',
|
||||
duration: 8_000,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed event */
|
||||
}
|
||||
})
|
||||
|
||||
es.addEventListener('error', () => {
|
||||
// SSE error — just close and let it reconnect naturally
|
||||
es?.close()
|
||||
es = null
|
||||
})
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
es?.close()
|
||||
es = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,417 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import {
|
||||
Alert02Icon,
|
||||
RefreshIcon,
|
||||
Tick02Icon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────
|
||||
|
||||
type RestartPhase =
|
||||
| 'idle'
|
||||
| 'restarting'
|
||||
| 'ready'
|
||||
| 'error'
|
||||
|
||||
type GatewayRestartContextValue = {
|
||||
/** Whether a restart is currently in progress */
|
||||
isRestarting: boolean
|
||||
/**
|
||||
* Trigger a provider config save + gateway restart.
|
||||
* @param saveProvider async fn that applies the config change before restart
|
||||
*/
|
||||
triggerRestart: (saveProvider?: () => Promise<void>) => Promise<void>
|
||||
}
|
||||
|
||||
type ProviderRestartConfirmState = {
|
||||
open: boolean
|
||||
pendingSave: (() => Promise<void>) | null
|
||||
}
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────
|
||||
|
||||
const HEALTH_POLL_INTERVAL_MS = 2_000
|
||||
const RESTART_TIMEOUT_MS = 30_000
|
||||
|
||||
// ── Context ───────────────────────────────────────────────────────
|
||||
|
||||
const GatewayRestartContext = createContext<GatewayRestartContextValue>({
|
||||
isRestarting: false,
|
||||
triggerRestart: async () => {},
|
||||
})
|
||||
|
||||
// ── Health polling ────────────────────────────────────────────────
|
||||
|
||||
async function pingGateway(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch('/api/ping', {
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
})
|
||||
if (!res.ok) return false
|
||||
const data = (await res.json()) as { ok?: boolean }
|
||||
return Boolean(data.ok)
|
||||
} catch {
|
||||
return false
|
||||
// Stub — gateway restart overlay is not used in Hermes Workspace
|
||||
export function useGatewayRestart() {
|
||||
return {
|
||||
triggerRestart: async (fn: () => Promise<void>) => { await fn() },
|
||||
}
|
||||
}
|
||||
|
||||
async function callGatewayRestart(): Promise<void> {
|
||||
const res = await fetch('/api/gateway-restart', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
||||
throw new Error(body.error || 'Hermes restart request failed')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Provider ──────────────────────────────────────────────────────
|
||||
|
||||
export function GatewayRestartProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [phase, setPhase] = useState<RestartPhase>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [confirmState, setConfirmState] = useState<ProviderRestartConfirmState>(
|
||||
{ open: false, pendingSave: null },
|
||||
)
|
||||
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const timeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
function clearTimers() {
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current)
|
||||
pollTimerRef.current = null
|
||||
}
|
||||
if (timeoutTimerRef.current) {
|
||||
clearTimeout(timeoutTimerRef.current)
|
||||
timeoutTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling for gateway recovery
|
||||
function startRecoveryPolling() {
|
||||
clearTimers()
|
||||
|
||||
timeoutTimerRef.current = setTimeout(() => {
|
||||
clearTimers()
|
||||
setPhase('error')
|
||||
setErrorMsg('Hermes did not come back within 30 seconds.')
|
||||
}, RESTART_TIMEOUT_MS)
|
||||
|
||||
pollTimerRef.current = setInterval(() => {
|
||||
void (async () => {
|
||||
const healthy = await pingGateway()
|
||||
if (healthy) {
|
||||
clearTimers()
|
||||
setPhase('ready')
|
||||
// Dispatch event so other components can re-subscribe to SSE / refetch
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('gateway:restarted'))
|
||||
window.dispatchEvent(new CustomEvent('gateway:health-restored'))
|
||||
}
|
||||
// Auto-dismiss after 2.5s
|
||||
setTimeout(() => setPhase('idle'), 2_500)
|
||||
}
|
||||
})()
|
||||
}, HEALTH_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
// Core restart flow (called after user confirms)
|
||||
async function executeRestart(saveProvider?: () => Promise<void>) {
|
||||
setPhase('restarting')
|
||||
setErrorMsg('')
|
||||
|
||||
try {
|
||||
// Step 1: apply the config change
|
||||
if (saveProvider) {
|
||||
await saveProvider()
|
||||
}
|
||||
// Step 2: fire gateway restart
|
||||
await callGatewayRestart()
|
||||
} catch (err) {
|
||||
// Connection drop is expected — gateway is restarting
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
const isExpected =
|
||||
msg.includes('closed') ||
|
||||
msg.includes('Failed to fetch') ||
|
||||
msg.includes('timed out') ||
|
||||
msg.includes('NetworkError')
|
||||
|
||||
if (!isExpected) {
|
||||
setPhase('error')
|
||||
setErrorMsg(msg)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: poll until health check passes
|
||||
startRecoveryPolling()
|
||||
}
|
||||
|
||||
const triggerRestart = useCallback(
|
||||
async (saveProvider?: () => Promise<void>) => {
|
||||
// Show the confirmation dialog; actual restart happens on confirm
|
||||
setConfirmState({ open: true, pendingSave: saveProvider ?? null })
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
function handleConfirm() {
|
||||
const { pendingSave } = confirmState
|
||||
setConfirmState({ open: false, pendingSave: null })
|
||||
void executeRestart(pendingSave ?? undefined)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setConfirmState({ open: false, pendingSave: null })
|
||||
}
|
||||
|
||||
function handleRetry() {
|
||||
setPhase('restarting')
|
||||
setErrorMsg('')
|
||||
startRecoveryPolling()
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => clearTimers()
|
||||
}, [])
|
||||
|
||||
const isRestarting = phase === 'restarting'
|
||||
|
||||
return (
|
||||
<GatewayRestartContext.Provider value={{ isRestarting, triggerRestart }}>
|
||||
{children}
|
||||
|
||||
{/* Confirm dialog */}
|
||||
{confirmState.open ? (
|
||||
<ProviderRestartConfirmDialog
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Restart overlay */}
|
||||
{phase !== 'idle' ? (
|
||||
<GatewayRestartOverlayView
|
||||
phase={phase}
|
||||
errorMsg={errorMsg}
|
||||
onRetry={handleRetry}
|
||||
onDismiss={() => setPhase('idle')}
|
||||
/>
|
||||
) : null}
|
||||
</GatewayRestartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Hook ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns a function to trigger the provider add/remove restart flow.
|
||||
* Shows a confirm dialog first, then restarts the gateway and polls for recovery.
|
||||
*/
|
||||
export function useGatewayRestart(): GatewayRestartContextValue {
|
||||
return useContext(GatewayRestartContext)
|
||||
}
|
||||
|
||||
// ── Confirm Dialog ────────────────────────────────────────────────
|
||||
|
||||
function ProviderRestartConfirmDialog({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[9990] flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="provider-restart-title"
|
||||
aria-describedby="provider-restart-desc"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-primary-950/30 backdrop-blur-sm"
|
||||
onClick={onCancel}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div className="relative z-10 w-[min(420px,92vw)] rounded-2xl border border-primary-200 bg-primary-50 p-5 shadow-xl dark:border-neutral-700 dark:bg-neutral-900">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-xl border border-amber-200 bg-amber-50 dark:border-amber-700/40 dark:bg-amber-900/30">
|
||||
<HugeiconsIcon
|
||||
icon={RefreshIcon}
|
||||
size={18}
|
||||
strokeWidth={1.5}
|
||||
className="text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2
|
||||
id="provider-restart-title"
|
||||
className="text-sm font-semibold text-primary-900 dark:text-neutral-100"
|
||||
>
|
||||
Hermes restart required
|
||||
</h2>
|
||||
<p
|
||||
id="provider-restart-desc"
|
||||
className="mt-1 text-sm text-primary-600 text-pretty dark:text-neutral-400"
|
||||
>
|
||||
Adding or removing a provider requires a Hermes restart. Active
|
||||
sessions will be paused briefly. Continue?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={onConfirm}>
|
||||
Restart & Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Overlay View ──────────────────────────────────────────────────
|
||||
|
||||
function GatewayRestartOverlayView({
|
||||
phase,
|
||||
errorMsg,
|
||||
onRetry,
|
||||
onDismiss,
|
||||
}: {
|
||||
phase: Exclude<RestartPhase, 'idle'>
|
||||
errorMsg: string
|
||||
onRetry: () => void
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-[9980] flex items-center justify-center p-4',
|
||||
'bg-primary-950/60 backdrop-blur-md',
|
||||
'transition-opacity duration-200 opacity-100',
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={
|
||||
phase === 'restarting'
|
||||
? 'Hermes restarting'
|
||||
: phase === 'ready'
|
||||
? 'Hermes ready'
|
||||
: 'Hermes restart failed'
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 rounded-2xl border border-primary-200/60 bg-primary-50/95 px-8 py-8 shadow-2xl backdrop-blur-xl dark:border-neutral-700/60 dark:bg-neutral-900/95">
|
||||
{phase === 'restarting' ? (
|
||||
<>
|
||||
<BrailleSpinner
|
||||
preset="claw"
|
||||
size={32}
|
||||
className="text-accent-500"
|
||||
label="Hermes restarting"
|
||||
/>
|
||||
<div className="text-center">
|
||||
<p className="text-base font-semibold text-primary-900 dark:text-neutral-100">
|
||||
Hermes restarting…
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-primary-500 dark:text-neutral-400">
|
||||
Applying provider changes. Active sessions are paused.
|
||||
</p>
|
||||
</div>
|
||||
<ReconnectingBadge />
|
||||
</>
|
||||
) : phase === 'ready' ? (
|
||||
<>
|
||||
<span className="inline-flex size-12 items-center justify-center rounded-full border border-green-200 bg-green-50 dark:border-green-700/40 dark:bg-green-900/30">
|
||||
<HugeiconsIcon
|
||||
icon={Tick02Icon}
|
||||
size={24}
|
||||
strokeWidth={1.5}
|
||||
className="text-green-600 dark:text-green-400"
|
||||
/>
|
||||
</span>
|
||||
<div className="text-center">
|
||||
<p className="text-base font-semibold text-primary-900 dark:text-neutral-100">
|
||||
Hermes ready ✓
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-primary-500 dark:text-neutral-400">
|
||||
Provider changes applied. Resuming…
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="inline-flex size-12 items-center justify-center rounded-full border border-red-200 bg-red-50 dark:border-red-700/40 dark:bg-red-900/30">
|
||||
<HugeiconsIcon
|
||||
icon={Alert02Icon}
|
||||
size={24}
|
||||
strokeWidth={1.5}
|
||||
className="text-red-600 dark:text-red-400"
|
||||
/>
|
||||
</span>
|
||||
<div className="text-center">
|
||||
<p className="text-base font-semibold text-primary-900 dark:text-neutral-100">
|
||||
Hermes restart failed
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-primary-500 dark:text-neutral-400 text-pretty">
|
||||
{errorMsg || 'Hermes did not come back in time.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={onRetry}>
|
||||
<HugeiconsIcon
|
||||
icon={RefreshIcon}
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
Retry
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onDismiss}>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Small "Reconnecting..." badge shown in overlay during restart
|
||||
function ReconnectingBadge() {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 rounded-full border border-primary-200 bg-primary-100 px-3 py-1 dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<span className="size-1.5 animate-pulse rounded-full bg-accent-500" />
|
||||
<span className="text-xs font-medium text-primary-600 dark:text-neutral-400">
|
||||
Reconnecting…
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
export function GatewayRestartProvider({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
@@ -115,16 +115,6 @@ export function SearchModal() {
|
||||
navigate({ to: '/memory' })
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qa-jobs',
|
||||
emoji: '⏰',
|
||||
label: 'Jobs',
|
||||
description: 'Open scheduled jobs and run history',
|
||||
onSelect: () => {
|
||||
closeModal()
|
||||
navigate({ to: '/jobs' })
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qa-files',
|
||||
emoji: '📁',
|
||||
@@ -265,9 +255,7 @@ export function SearchModal() {
|
||||
icon={
|
||||
entry.id === 'qa-logs'
|
||||
? ListViewIcon
|
||||
: entry.id === 'qa-jobs'
|
||||
? Clock01Icon
|
||||
: FlashIcon
|
||||
: FlashIcon
|
||||
}
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
|
||||
@@ -1,397 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import {
|
||||
Cancel01Icon,
|
||||
Tick01Icon,
|
||||
Loading03Icon,
|
||||
SparklesIcon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type CommitEntry = {
|
||||
hash: string
|
||||
subject: string
|
||||
date: string
|
||||
}
|
||||
|
||||
type UpdateCheckResult = {
|
||||
updateAvailable: boolean
|
||||
localVersion: string
|
||||
remoteVersion: string
|
||||
localCommit: string
|
||||
remoteCommit: string
|
||||
localDate: string
|
||||
remoteDate: string
|
||||
behindBy: number
|
||||
changelog: Array<CommitEntry>
|
||||
}
|
||||
|
||||
type UpdatePhase =
|
||||
| 'idle'
|
||||
| 'pulling'
|
||||
| 'installing'
|
||||
| 'restarting'
|
||||
| 'done'
|
||||
| 'error'
|
||||
|
||||
const DISMISS_KEY = 'hermes-update-dismissed'
|
||||
const CHECK_INTERVAL_MS = 15 * 60 * 1000
|
||||
|
||||
const PHASE_LABELS: Record<UpdatePhase, string> = {
|
||||
idle: '',
|
||||
pulling: 'Pulling latest changes...',
|
||||
installing: 'Installing dependencies...',
|
||||
restarting: 'Restarting...',
|
||||
done: 'Update complete!',
|
||||
error: 'Update failed',
|
||||
}
|
||||
|
||||
function commitTypeIcon(subject: string): string {
|
||||
const lower = subject.toLowerCase()
|
||||
if (lower.startsWith('feat')) return '✨'
|
||||
if (lower.startsWith('fix')) return '🐛'
|
||||
if (lower.startsWith('perf')) return '⚡'
|
||||
if (lower.startsWith('refactor')) return '♻️'
|
||||
if (lower.startsWith('docs')) return '📝'
|
||||
if (lower.startsWith('style')) return '💄'
|
||||
if (lower.startsWith('chore')) return '🔧'
|
||||
if (lower.startsWith('security') || lower.startsWith('sec')) return '🔒'
|
||||
return '📦'
|
||||
}
|
||||
|
||||
function cleanSubject(subject: string): string {
|
||||
// Strip conventional commit prefix like "feat: " or "fix(scope): "
|
||||
return subject.replace(/^[a-z]+(\([^)]*\))?:\s*/i, '')
|
||||
}
|
||||
|
||||
function relativeTime(dateStr: string): string {
|
||||
if (!dateStr) return ''
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const mins = Math.floor(diff / 60_000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs < 24) return `${hrs}h ago`
|
||||
const days = Math.floor(hrs / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
export function UpdateNotifier() {
|
||||
const queryClient = useQueryClient()
|
||||
const [dismissed, setDismissed] = useState<string | null>(null)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [phase, setPhase] = useState<UpdatePhase>('idle')
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setDismissed(localStorage.getItem(DISMISS_KEY))
|
||||
}, [])
|
||||
|
||||
const { data } = useQuery<UpdateCheckResult>({
|
||||
queryKey: ['update-check'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/update-check')
|
||||
if (!res.ok) throw new Error('update check failed')
|
||||
return res.json() as Promise<UpdateCheckResult>
|
||||
},
|
||||
refetchInterval: CHECK_INTERVAL_MS,
|
||||
staleTime: CHECK_INTERVAL_MS,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.updateAvailable) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
if (dismissed === data.remoteCommit) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
setVisible(true)
|
||||
}, [data, dismissed])
|
||||
|
||||
// Categorize changelog
|
||||
const changelogSummary = useMemo(() => {
|
||||
if (!data?.changelog) return { features: 0, fixes: 0, other: 0 }
|
||||
let features = 0
|
||||
let fixes = 0
|
||||
let other = 0
|
||||
for (const c of data.changelog) {
|
||||
const lower = c.subject.toLowerCase()
|
||||
if (lower.startsWith('feat')) features++
|
||||
else if (lower.startsWith('fix')) fixes++
|
||||
else other++
|
||||
}
|
||||
return { features, fixes, other }
|
||||
}, [data?.changelog])
|
||||
|
||||
function handleDismiss() {
|
||||
if (data?.remoteCommit) {
|
||||
localStorage.setItem(DISMISS_KEY, data.remoteCommit)
|
||||
setDismissed(data.remoteCommit)
|
||||
}
|
||||
setVisible(false)
|
||||
setExpanded(false)
|
||||
setPhase('idle')
|
||||
}
|
||||
|
||||
async function handleInstall() {
|
||||
setPhase('pulling')
|
||||
setProgress(0)
|
||||
setErrorMsg('')
|
||||
|
||||
// Simulate phased progress
|
||||
const progressTimer = setInterval(() => {
|
||||
setProgress((p) => Math.min(p + 2, 90))
|
||||
}, 300)
|
||||
|
||||
try {
|
||||
// Phase 1: pulling
|
||||
setProgress(10)
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
setPhase('installing')
|
||||
setProgress(30)
|
||||
|
||||
const res = await fetch('/api/update-check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const result = (await res.json()) as { ok: boolean; output: string }
|
||||
|
||||
clearInterval(progressTimer)
|
||||
|
||||
if (result.ok) {
|
||||
setPhase('restarting')
|
||||
setProgress(90)
|
||||
setExpanded(true) // show changelog on success
|
||||
await new Promise((r) => setTimeout(r, 800))
|
||||
setPhase('done')
|
||||
setProgress(100)
|
||||
void queryClient.invalidateQueries({ queryKey: ['update-check'] })
|
||||
// Auto-reload after showing patch notes
|
||||
setTimeout(() => window.location.reload(), 3000)
|
||||
} else {
|
||||
setPhase('error')
|
||||
setErrorMsg(result.output?.slice(0, 300) || 'Unknown error')
|
||||
setProgress(0)
|
||||
}
|
||||
} catch (err) {
|
||||
clearInterval(progressTimer)
|
||||
setPhase('error')
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Update failed')
|
||||
setProgress(0)
|
||||
}
|
||||
}
|
||||
|
||||
const isUpdating =
|
||||
phase === 'pulling' || phase === 'installing' || phase === 'restarting'
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && data && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -50, scale: 0.95 }}
|
||||
transition={{ duration: 0.35, ease: [0.23, 1, 0.32, 1] }}
|
||||
className={cn(
|
||||
'fixed left-1/2 -translate-x-1/2 z-[9999] top-[calc(var(--titlebar-h,0px)+1rem)]',
|
||||
'flex flex-col rounded-2xl overflow-hidden',
|
||||
'bg-primary-950 text-white',
|
||||
'shadow-2xl shadow-black/40',
|
||||
'border border-primary-800/60',
|
||||
expanded ? 'max-w-lg w-[95vw]' : 'max-w-md w-[90vw]',
|
||||
'transition-all duration-300',
|
||||
)}
|
||||
>
|
||||
{/* Progress bar */}
|
||||
{isUpdating && (
|
||||
<motion.div
|
||||
className="h-0.5 bg-accent-500 origin-left"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: progress / 100 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
)}
|
||||
{phase === 'done' && <div className="h-0.5 bg-green-500" />}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-5 py-3.5">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center size-9 rounded-xl shrink-0',
|
||||
phase === 'done'
|
||||
? 'bg-green-500/20'
|
||||
: phase === 'error'
|
||||
? 'bg-red-500/20'
|
||||
: 'bg-accent-500/20',
|
||||
)}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<HugeiconsIcon
|
||||
icon={Loading03Icon}
|
||||
size={18}
|
||||
strokeWidth={2}
|
||||
className="animate-spin text-accent-400"
|
||||
/>
|
||||
) : phase === 'done' ? (
|
||||
<HugeiconsIcon
|
||||
icon={Tick01Icon}
|
||||
size={18}
|
||||
strokeWidth={2}
|
||||
className="text-green-400"
|
||||
/>
|
||||
) : (
|
||||
<HugeiconsIcon
|
||||
icon={SparklesIcon}
|
||||
size={18}
|
||||
strokeWidth={2}
|
||||
className="text-accent-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold">
|
||||
{phase === 'idle' || phase === 'error'
|
||||
? 'Hermes Workspace Update'
|
||||
: PHASE_LABELS[phase]}
|
||||
</p>
|
||||
<p className="text-xs text-primary-400 truncate">
|
||||
{phase === 'error'
|
||||
? errorMsg
|
||||
: phase === 'done'
|
||||
? `Updated to ${data.remoteVersion} · reloading...`
|
||||
: isUpdating
|
||||
? PHASE_LABELS[phase]
|
||||
: `${data.behindBy} update${data.behindBy !== 1 ? 's' : ''} available · ${data.localVersion}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{!expanded && phase === 'idle' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="text-xs text-primary-400 hover:text-primary-200 transition-colors"
|
||||
>
|
||||
What's new?
|
||||
</button>
|
||||
)}
|
||||
{(phase === 'idle' || phase === 'error') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
className={cn(
|
||||
'rounded-lg px-4 py-1.5 text-xs font-semibold transition-all',
|
||||
'bg-accent-500 hover:bg-accent-400 text-white',
|
||||
)}
|
||||
>
|
||||
{phase === 'error' ? 'Retry' : 'Install'}
|
||||
</button>
|
||||
)}
|
||||
{!isUpdating && phase !== 'done' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="rounded-full p-1 text-primary-500 hover:text-primary-300 transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Cancel01Icon}
|
||||
size={14}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Changelog */}
|
||||
<AnimatePresence>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="border-t border-primary-800/60">
|
||||
{data.changelog.length > 0 ? (
|
||||
<>
|
||||
{/* Summary pills */}
|
||||
<div className="flex items-center gap-2 px-5 pt-3 pb-2">
|
||||
{changelogSummary.features > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent-500/15 px-2.5 py-0.5 text-[11px] font-medium text-accent-400">
|
||||
✨ {changelogSummary.features} feature
|
||||
{changelogSummary.features !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{changelogSummary.fixes > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/15 px-2.5 py-0.5 text-[11px] font-medium text-blue-400">
|
||||
🐛 {changelogSummary.fixes} fix
|
||||
{changelogSummary.fixes !== 1 ? 'es' : ''}
|
||||
</span>
|
||||
)}
|
||||
{changelogSummary.other > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary-700/40 px-2.5 py-0.5 text-[11px] font-medium text-primary-400">
|
||||
📦 {changelogSummary.other} other
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Commit list */}
|
||||
<div className="max-h-48 overflow-y-auto px-5 pb-4 space-y-1">
|
||||
{data.changelog.map((commit) => (
|
||||
<div
|
||||
key={commit.hash}
|
||||
className="flex items-start gap-2.5 py-1.5 group"
|
||||
>
|
||||
<span className="text-sm leading-5 shrink-0">
|
||||
{commitTypeIcon(commit.subject)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-primary-200 leading-5 truncate">
|
||||
{cleanSubject(commit.subject)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<code className="text-[10px] text-primary-500 font-mono">
|
||||
{commit.hash}
|
||||
</code>
|
||||
<span className="text-[10px] text-primary-600">
|
||||
{relativeTime(commit.date)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="px-5 py-4 text-center">
|
||||
<p className="text-xs text-primary-400">
|
||||
{data.behindBy} new update
|
||||
{data.behindBy !== 1 ? 's' : ''} available with bug
|
||||
fixes and improvements.
|
||||
</p>
|
||||
<p className="text-[10px] text-primary-500 mt-1">
|
||||
Click Install to update to the latest version.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -1,763 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type {
|
||||
GatewaySession,
|
||||
GatewaySessionStatusResponse,
|
||||
} from '@/lib/gateway-api'
|
||||
import { fetchSessions } from '@/lib/gateway-api'
|
||||
import { assignPersona } from '@/lib/agent-personas'
|
||||
import { useMissionStore } from '@/stores/mission-store'
|
||||
|
||||
export type AgentModel = string
|
||||
|
||||
export type ActiveAgent = {
|
||||
id: string
|
||||
name: string
|
||||
task: string
|
||||
model: AgentModel
|
||||
status: string
|
||||
progress: number
|
||||
startedAtMs: number
|
||||
tokenCount: number
|
||||
estimatedCost: number
|
||||
isLive: boolean
|
||||
}
|
||||
|
||||
export type QueuePriority = 'high' | 'normal' | 'low'
|
||||
|
||||
export type QueuedAgentTask = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
priority: QueuePriority
|
||||
}
|
||||
|
||||
export type AgentHistoryStatus = 'success' | 'failed'
|
||||
|
||||
export type AgentHistoryItem = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
model: AgentModel
|
||||
status: AgentHistoryStatus
|
||||
runtimeSeconds: number
|
||||
tokenCount: number
|
||||
cost: number
|
||||
}
|
||||
|
||||
type AgentViewState = {
|
||||
isOpen: boolean
|
||||
queueOpen: boolean
|
||||
historyOpen: boolean
|
||||
setOpen: (isOpen: boolean) => void
|
||||
toggleOpen: () => void
|
||||
setQueueOpen: (isOpen: boolean) => void
|
||||
setHistoryOpen: (isOpen: boolean) => void
|
||||
}
|
||||
|
||||
const PANEL_WIDTH_PX = 320
|
||||
const MIN_DESKTOP_WIDTH = 1024
|
||||
const AUTO_OPEN_WIDTH = 1440
|
||||
const REFRESH_INTERVAL_MS = 5000
|
||||
|
||||
function createDemoActiveAgents(): Array<ActiveAgent> {
|
||||
const now = Date.now()
|
||||
return [
|
||||
{
|
||||
id: 'demo-dashboard-infra',
|
||||
name: '🎨 Roger — Frontend Developer',
|
||||
task: 'Building dashboard widget grid with responsive layout',
|
||||
model: 'gpt-5.3-codex',
|
||||
status: 'running',
|
||||
progress: 67,
|
||||
startedAtMs: now - 204_000,
|
||||
tokenCount: 38_240,
|
||||
estimatedCost: 0.218,
|
||||
isLive: false,
|
||||
},
|
||||
{
|
||||
id: 'demo-skills-browser',
|
||||
name: '🏗️ Sally — Backend Architect',
|
||||
task: 'Creating API routes for skills marketplace',
|
||||
model: 'gpt-5.3-codex',
|
||||
status: 'thinking',
|
||||
progress: 42,
|
||||
startedAtMs: now - 131_000,
|
||||
tokenCount: 21_915,
|
||||
estimatedCost: 0.131,
|
||||
isLive: false,
|
||||
},
|
||||
{
|
||||
id: 'demo-terminal-integration',
|
||||
name: '🔍 Ada — QA Engineer',
|
||||
task: 'Running integration tests on terminal panel',
|
||||
model: 'gpt-5.3-codex',
|
||||
status: 'running',
|
||||
progress: 85,
|
||||
startedAtMs: now - 242_000,
|
||||
tokenCount: 47_609,
|
||||
estimatedCost: 0.286,
|
||||
isLive: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function createDemoQueue(): Array<QueuedAgentTask> {
|
||||
return [
|
||||
{
|
||||
id: 'demo-queue-1',
|
||||
name: 'release-notes',
|
||||
description: 'Drafting release notes and migration checklist',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'demo-queue-2',
|
||||
name: 'theme-pass',
|
||||
description: 'Applying dark theme polish to diagnostics screens',
|
||||
priority: 'normal',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function createDemoHistory(): Array<AgentHistoryItem> {
|
||||
return [
|
||||
{
|
||||
id: 'demo-history-1',
|
||||
name: 'api-telemetry',
|
||||
description: 'Instrumented API telemetry dashboard',
|
||||
model: 'gpt-5-codex',
|
||||
status: 'success',
|
||||
runtimeSeconds: 452,
|
||||
tokenCount: 62_430,
|
||||
cost: 0.348,
|
||||
},
|
||||
{
|
||||
id: 'demo-history-2',
|
||||
name: 'auth-hardening',
|
||||
description: 'Added auth guardrails for session endpoints',
|
||||
model: 'claude-3-5-sonnet',
|
||||
status: 'success',
|
||||
runtimeSeconds: 311,
|
||||
tokenCount: 48_920,
|
||||
cost: 0.284,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function inferInitialOpenState(): boolean {
|
||||
if (typeof window === 'undefined') return true
|
||||
return window.innerWidth >= AUTO_OPEN_WIDTH
|
||||
}
|
||||
|
||||
function readString(value: unknown): string {
|
||||
if (typeof value !== 'string') return ''
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 0
|
||||
return value
|
||||
}
|
||||
|
||||
function readTimestamp(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value < 1_000_000_000_000 ? value * 1000 : value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Date.parse(value)
|
||||
if (!Number.isNaN(parsed)) return parsed
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function readSessionKey(session: GatewaySession): string {
|
||||
const key = readString(session.key)
|
||||
if (key.length > 0) return key
|
||||
const friendly = readString(session.friendlyId)
|
||||
if (friendly.length > 0) return friendly
|
||||
return ''
|
||||
}
|
||||
|
||||
function readSessionName(session: GatewaySession): string {
|
||||
// Assign persona based on session key + task for named agent display
|
||||
const key = readSessionKey(session)
|
||||
const taskText =
|
||||
readString(session.task) ||
|
||||
readString(session.initialMessage) ||
|
||||
readString(session.label)
|
||||
if (key.length > 0) {
|
||||
const persona = assignPersona(key, taskText)
|
||||
return `${persona.emoji} ${persona.name} — ${persona.role}`
|
||||
}
|
||||
|
||||
const label = readString(session.label)
|
||||
if (label.length > 0) return label
|
||||
const title = readString(session.title)
|
||||
if (title.length > 0) return title
|
||||
const derived = readString(session.derivedTitle)
|
||||
if (derived.length > 0) return derived
|
||||
const friendly = readString(session.friendlyId)
|
||||
if (friendly.length > 0) return friendly
|
||||
return 'session'
|
||||
}
|
||||
|
||||
function isAgentSession(session: GatewaySession): boolean {
|
||||
const key = readSessionKey(session).toLowerCase()
|
||||
|
||||
// Always exclude main sessions
|
||||
if (key === 'main' || key.includes(':main')) return false
|
||||
const friendlyId = readString(session.friendlyId).toLowerCase()
|
||||
if (friendlyId === 'main') return false
|
||||
|
||||
// Always exclude cron jobs
|
||||
if (key.includes('cron')) return false
|
||||
const kind = readString(session.kind).toLowerCase()
|
||||
if (kind === 'cron') return false
|
||||
|
||||
// Everything else is an agent session — inclusive by default (#37)
|
||||
return true
|
||||
}
|
||||
|
||||
function readTaskText(session: GatewaySession): string {
|
||||
const explicitTask = readString(session.task)
|
||||
if (explicitTask.length > 0) return explicitTask
|
||||
|
||||
const initialMessage = readString(session.initialMessage)
|
||||
if (initialMessage.length > 0) return initialMessage
|
||||
|
||||
const lastMessage = session.lastMessage
|
||||
if (lastMessage && typeof lastMessage === 'object') {
|
||||
const directText = readString((lastMessage as { text?: unknown }).text)
|
||||
if (directText.length > 0) return directText
|
||||
|
||||
const content = (lastMessage as { content?: unknown }).content
|
||||
if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.map(function mapPart(part) {
|
||||
if (!part || typeof part !== 'object') return ''
|
||||
return readString((part as { text?: unknown }).text)
|
||||
})
|
||||
.join(' ')
|
||||
.trim()
|
||||
if (text.length > 0) return text
|
||||
}
|
||||
}
|
||||
|
||||
return 'Agent session in progress'
|
||||
}
|
||||
|
||||
function readTokenCount(
|
||||
session: GatewaySession,
|
||||
status: GatewaySessionStatusResponse | null,
|
||||
): number {
|
||||
const statusTokenCount = readNumber(status?.tokenCount)
|
||||
if (statusTokenCount > 0) return statusTokenCount
|
||||
|
||||
const statusTotalTokens = readNumber(status?.totalTokens)
|
||||
if (statusTotalTokens > 0) return statusTotalTokens
|
||||
|
||||
const statusUsageTokens = readNumber(status?.usage?.tokens)
|
||||
if (statusUsageTokens > 0) return statusUsageTokens
|
||||
|
||||
const statusUsageTotal = readNumber(status?.usage?.totalTokens)
|
||||
if (statusUsageTotal > 0) return statusUsageTotal
|
||||
|
||||
const sessionTokenCount = readNumber(session.tokenCount)
|
||||
if (sessionTokenCount > 0) return sessionTokenCount
|
||||
|
||||
const sessionTotalTokens = readNumber(session.totalTokens)
|
||||
if (sessionTotalTokens > 0) return sessionTotalTokens
|
||||
|
||||
const sessionUsageTokens = readNumber(session.usage?.tokens)
|
||||
if (sessionUsageTokens > 0) return sessionUsageTokens
|
||||
|
||||
return readNumber(session.usage?.totalTokens)
|
||||
}
|
||||
|
||||
function readEstimatedCost(
|
||||
session: GatewaySession,
|
||||
status: GatewaySessionStatusResponse | null,
|
||||
tokenCount: number,
|
||||
): number {
|
||||
const statusCost = readNumber(status?.usage?.cost)
|
||||
if (statusCost > 0) return statusCost
|
||||
|
||||
const sessionCost = readNumber(session.cost)
|
||||
if (sessionCost > 0) return sessionCost
|
||||
|
||||
const usageCost = readNumber(session.usage?.cost)
|
||||
if (usageCost > 0) return usageCost
|
||||
|
||||
return Number((tokenCount * 0.000004).toFixed(3))
|
||||
}
|
||||
|
||||
function readProgress(
|
||||
session: GatewaySession,
|
||||
status: GatewaySessionStatusResponse | null,
|
||||
): number {
|
||||
const statusProgress = readNumber(status?.progress)
|
||||
if (statusProgress > 0)
|
||||
return Math.max(1, Math.min(99, Math.round(statusProgress)))
|
||||
|
||||
const sessionProgress = readNumber(session.progress)
|
||||
if (sessionProgress > 0)
|
||||
return Math.max(1, Math.min(99, Math.round(sessionProgress)))
|
||||
|
||||
const sessionStatus = readStatus(session, status)
|
||||
if (isQueuedStatus(sessionStatus)) return 5
|
||||
if (isFailedStatus(sessionStatus)) return 100
|
||||
if (isCompletedStatus(sessionStatus)) return 100
|
||||
return 35
|
||||
}
|
||||
|
||||
function readStatus(
|
||||
session: GatewaySession,
|
||||
status: GatewaySessionStatusResponse | null,
|
||||
): string {
|
||||
const statusText = readString(status?.status)
|
||||
if (statusText.length > 0) return statusText.toLowerCase()
|
||||
|
||||
const sessionStatus = readString(session.status)
|
||||
if (sessionStatus.length > 0) return sessionStatus.toLowerCase()
|
||||
|
||||
// Heuristic: detect completion from staleness when gateway has no explicit status
|
||||
const updatedAt = readTimestamp(session.updatedAt)
|
||||
if (updatedAt) {
|
||||
const staleness = Date.now() - updatedAt
|
||||
const tokens =
|
||||
readNumber(session.totalTokens) || readNumber(session.tokenCount)
|
||||
if (tokens > 0 && staleness > 120_000) return 'complete'
|
||||
if (tokens === 0 && staleness > 120_000) return 'idle'
|
||||
}
|
||||
|
||||
return 'running'
|
||||
}
|
||||
|
||||
function readModel(
|
||||
session: GatewaySession,
|
||||
status: GatewaySessionStatusResponse | null,
|
||||
): string {
|
||||
const statusModel = readString(status?.model)
|
||||
if (statusModel.length > 0) return statusModel
|
||||
|
||||
const sessionModel = readString(session.model)
|
||||
if (sessionModel.length > 0) return sessionModel
|
||||
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function readStartTimeMs(session: GatewaySession): number {
|
||||
const startedAt = readTimestamp(session.startedAt)
|
||||
if (startedAt) return startedAt
|
||||
|
||||
const createdAt = readTimestamp(session.createdAt)
|
||||
if (createdAt) return createdAt
|
||||
|
||||
const updatedAt = readTimestamp(session.updatedAt)
|
||||
if (updatedAt) return updatedAt
|
||||
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
function isQueuedStatus(status: string): boolean {
|
||||
return ['queued', 'pending', 'waiting'].includes(status)
|
||||
}
|
||||
|
||||
function isRunningStatus(status: string): boolean {
|
||||
return [
|
||||
'running',
|
||||
'active',
|
||||
'started',
|
||||
'streaming',
|
||||
'processing',
|
||||
'in_progress',
|
||||
'thinking',
|
||||
].includes(status)
|
||||
}
|
||||
|
||||
function isCompletedStatus(status: string): boolean {
|
||||
return ['complete', 'completed', 'success', 'succeeded', 'done'].includes(
|
||||
status,
|
||||
)
|
||||
}
|
||||
|
||||
function isFailedStatus(status: string): boolean {
|
||||
return ['failed', 'error', 'cancelled', 'canceled', 'killed'].includes(status)
|
||||
}
|
||||
|
||||
function mapSessionToActiveAgent(
|
||||
session: GatewaySession,
|
||||
status: GatewaySessionStatusResponse | null,
|
||||
): ActiveAgent {
|
||||
const tokenCount = readTokenCount(session, status)
|
||||
return {
|
||||
id: readSessionKey(session) || crypto.randomUUID(),
|
||||
name: readSessionName(session),
|
||||
task: readTaskText(session),
|
||||
model: readModel(session, status),
|
||||
status: readStatus(session, status),
|
||||
progress: readProgress(session, status),
|
||||
startedAtMs: readStartTimeMs(session),
|
||||
tokenCount,
|
||||
estimatedCost: readEstimatedCost(session, status, tokenCount),
|
||||
isLive: true,
|
||||
}
|
||||
}
|
||||
|
||||
function mapSessionToQueuedTask(session: GatewaySession): QueuedAgentTask {
|
||||
return {
|
||||
id: readSessionKey(session) || `queued-${crypto.randomUUID()}`,
|
||||
name: readSessionName(session),
|
||||
description: readTaskText(session),
|
||||
priority: 'normal',
|
||||
}
|
||||
}
|
||||
|
||||
function mapSessionToHistoryItem(
|
||||
session: GatewaySession,
|
||||
status: GatewaySessionStatusResponse | null,
|
||||
): AgentHistoryItem {
|
||||
const startMs = readStartTimeMs(session)
|
||||
const endMs = readTimestamp(session.updatedAt) ?? Date.now()
|
||||
const tokenCount = readTokenCount(session, status)
|
||||
const statusText = readStatus(session, status)
|
||||
|
||||
return {
|
||||
id: readSessionKey(session) || `history-${crypto.randomUUID()}`,
|
||||
name: readSessionName(session),
|
||||
description: readTaskText(session),
|
||||
model: readModel(session, status),
|
||||
status: isFailedStatus(statusText) ? 'failed' : 'success',
|
||||
runtimeSeconds: Math.max(1, Math.floor((endMs - startMs) / 1000)),
|
||||
tokenCount,
|
||||
cost: readEstimatedCost(session, status, tokenCount),
|
||||
}
|
||||
}
|
||||
|
||||
export const useAgentViewStore = create<AgentViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
isOpen: inferInitialOpenState(),
|
||||
queueOpen: true,
|
||||
historyOpen: false,
|
||||
setOpen: function setOpen(isOpen) {
|
||||
set({ isOpen })
|
||||
},
|
||||
toggleOpen: function toggleOpen() {
|
||||
set((state) => ({ isOpen: !state.isOpen }))
|
||||
},
|
||||
setQueueOpen: function setQueueOpen(isOpen) {
|
||||
set({ queueOpen: isOpen })
|
||||
},
|
||||
setHistoryOpen: function setHistoryOpen(isOpen) {
|
||||
set({ historyOpen: isOpen })
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'agent-view-state',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
export type AgentViewResult = {
|
||||
isOpen: boolean
|
||||
queueOpen: boolean
|
||||
historyOpen: boolean
|
||||
isDesktop: boolean
|
||||
shouldAutoOpen: boolean
|
||||
panelVisible: boolean
|
||||
showFloatingToggle: boolean
|
||||
panelWidth: number
|
||||
panelOffset: number
|
||||
nowMs: number
|
||||
lastRefreshedMs: number
|
||||
activeAgents: Array<ActiveAgent>
|
||||
missionActiveAgents: Array<ActiveAgent>
|
||||
nonMissionActiveAgents: Array<ActiveAgent>
|
||||
queuedAgents: Array<QueuedAgentTask>
|
||||
historyAgents: Array<AgentHistoryItem>
|
||||
activeMissionName: string
|
||||
activeMissionState: string | null
|
||||
activeCount: number
|
||||
isLoading: boolean
|
||||
isDemoMode: boolean
|
||||
isLiveConnected: boolean
|
||||
errorMessage: string | null
|
||||
setOpen: (isOpen: boolean) => void
|
||||
toggleOpen: () => void
|
||||
setQueueOpen: (isOpen: boolean) => void
|
||||
setHistoryOpen: (isOpen: boolean) => void
|
||||
killAgent: (agentId: string) => void
|
||||
cancelQueueTask: (taskId: string) => void
|
||||
}
|
||||
|
||||
export function useAgentView(): AgentViewResult {
|
||||
const isOpen = useAgentViewStore((state) => state.isOpen)
|
||||
const queueOpen = useAgentViewStore((state) => state.queueOpen)
|
||||
const historyOpen = useAgentViewStore((state) => state.historyOpen)
|
||||
const setOpen = useAgentViewStore((state) => state.setOpen)
|
||||
const toggleOpen = useAgentViewStore((state) => state.toggleOpen)
|
||||
const setQueueOpen = useAgentViewStore((state) => state.setQueueOpen)
|
||||
const setHistoryOpen = useAgentViewStore((state) => state.setHistoryOpen)
|
||||
const activeMission = useMissionStore((state) => state.activeMission)
|
||||
const missionSessionMap = useMissionStore((state) => state.agentSessionMap)
|
||||
|
||||
const [viewportWidth, setViewportWidth] = useState(() => {
|
||||
if (typeof window === 'undefined') return AUTO_OPEN_WIDTH
|
||||
return window.innerWidth
|
||||
})
|
||||
const [nowMs, setNowMs] = useState(() => Date.now())
|
||||
const [lastRefreshedMs, setLastRefreshedMs] = useState(() => Date.now())
|
||||
const [activeAgents, setActiveAgents] = useState<Array<ActiveAgent>>([])
|
||||
const [queuedAgents, setQueuedAgents] = useState<Array<QueuedAgentTask>>([])
|
||||
const [historyAgents, setHistoryAgents] = useState<Array<AgentHistoryItem>>(
|
||||
[],
|
||||
)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isDemoMode, setIsDemoMode] = useState(false)
|
||||
const [isLiveConnected, setIsLiveConnected] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
|
||||
const previousAutoOpenRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
function handleResize() {
|
||||
setViewportWidth(window.innerWidth)
|
||||
}
|
||||
|
||||
handleResize()
|
||||
window.addEventListener('resize', handleResize)
|
||||
return function cleanupResize() {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
setNowMs(Date.now())
|
||||
}, 5000) // Every 5s instead of 1s to reduce re-renders
|
||||
|
||||
return function cleanupTimer() {
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let isDisposed = false
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const sessionsPayload = await fetchSessions()
|
||||
const sessions = Array.isArray(sessionsPayload.sessions)
|
||||
? sessionsPayload.sessions
|
||||
: []
|
||||
|
||||
// Skip per-session status fetch — /api/sessions/:key/status route
|
||||
// doesn't exist, causing 404 spam. Use session data directly.
|
||||
const statusEntries = sessions.map(function loadStatus(session) {
|
||||
const key = readSessionKey(session)
|
||||
return [key, null] as const
|
||||
})
|
||||
|
||||
const statusMap = new Map<string, GatewaySessionStatusResponse | null>(
|
||||
statusEntries,
|
||||
)
|
||||
|
||||
const nextActiveAgents: Array<ActiveAgent> = []
|
||||
const nextQueuedAgents: Array<QueuedAgentTask> = []
|
||||
const nextHistoryAgents: Array<AgentHistoryItem> = []
|
||||
|
||||
sessions.forEach(function classifySession(session) {
|
||||
if (!isAgentSession(session)) return
|
||||
const key = readSessionKey(session)
|
||||
const status = key ? (statusMap.get(key) ?? null) : null
|
||||
const statusText = readStatus(session, status)
|
||||
|
||||
if (isQueuedStatus(statusText)) {
|
||||
nextQueuedAgents.push(mapSessionToQueuedTask(session))
|
||||
return
|
||||
}
|
||||
|
||||
if (isCompletedStatus(statusText) || isFailedStatus(statusText)) {
|
||||
nextHistoryAgents.push(mapSessionToHistoryItem(session, status))
|
||||
return
|
||||
}
|
||||
|
||||
if (isRunningStatus(statusText) || statusText.length === 0) {
|
||||
nextActiveAgents.push(mapSessionToActiveAgent(session, status))
|
||||
}
|
||||
})
|
||||
|
||||
if (isDisposed) return
|
||||
|
||||
setActiveAgents(nextActiveAgents)
|
||||
setQueuedAgents(nextQueuedAgents)
|
||||
setHistoryAgents(nextHistoryAgents.slice(0, 10))
|
||||
setIsDemoMode(false)
|
||||
setIsLiveConnected(true)
|
||||
setErrorMessage(null)
|
||||
} catch (error) {
|
||||
if (isDisposed) return
|
||||
|
||||
setActiveAgents(createDemoActiveAgents())
|
||||
setQueuedAgents(createDemoQueue())
|
||||
setHistoryAgents(createDemoHistory())
|
||||
setIsDemoMode(true)
|
||||
setIsLiveConnected(false)
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : 'Gateway unavailable',
|
||||
)
|
||||
} finally {
|
||||
if (!isDisposed) {
|
||||
setLastRefreshedMs(Date.now())
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void refresh()
|
||||
const refreshTimer = window.setInterval(() => {
|
||||
void refresh()
|
||||
}, REFRESH_INTERVAL_MS)
|
||||
|
||||
return function cleanupRefresh() {
|
||||
isDisposed = true
|
||||
window.clearInterval(refreshTimer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const shouldAutoOpen = viewportWidth >= AUTO_OPEN_WIDTH
|
||||
useEffect(() => {
|
||||
const isCrossingToLargeDesktop =
|
||||
shouldAutoOpen && previousAutoOpenRef.current !== shouldAutoOpen
|
||||
previousAutoOpenRef.current = shouldAutoOpen
|
||||
if (isCrossingToLargeDesktop) {
|
||||
setOpen(true)
|
||||
}
|
||||
}, [setOpen, shouldAutoOpen])
|
||||
|
||||
const isDesktop = viewportWidth >= MIN_DESKTOP_WIDTH
|
||||
const panelVisible = isDesktop && isOpen
|
||||
const showFloatingToggle = isDesktop && !isOpen
|
||||
const panelOffset = panelVisible ? PANEL_WIDTH_PX : 0
|
||||
const missionSessionKeys = useMemo(
|
||||
() => new Set(Object.values(missionSessionMap)),
|
||||
[missionSessionMap],
|
||||
)
|
||||
const missionActiveAgents = useMemo(
|
||||
() =>
|
||||
activeMission
|
||||
? activeAgents.filter((agent) => missionSessionKeys.has(agent.id))
|
||||
: [],
|
||||
[activeAgents, activeMission, missionSessionKeys],
|
||||
)
|
||||
const nonMissionActiveAgents = useMemo(
|
||||
() =>
|
||||
activeMission
|
||||
? activeAgents.filter((agent) => !missionSessionKeys.has(agent.id))
|
||||
: activeAgents,
|
||||
[activeAgents, activeMission, missionSessionKeys],
|
||||
)
|
||||
|
||||
function killAgent(agentId: string) {
|
||||
setActiveAgents((previous) => {
|
||||
const killedAgent = previous.find((agent) => agent.id === agentId)
|
||||
if (!killedAgent) return previous
|
||||
|
||||
const runtimeSeconds = Math.max(
|
||||
1,
|
||||
Math.floor((Date.now() - killedAgent.startedAtMs) / 1000),
|
||||
)
|
||||
const historyEntry: AgentHistoryItem = {
|
||||
id: `history-${crypto.randomUUID()}`,
|
||||
name: killedAgent.name,
|
||||
description: killedAgent.task,
|
||||
model: killedAgent.model,
|
||||
status: 'failed',
|
||||
runtimeSeconds,
|
||||
tokenCount: killedAgent.tokenCount,
|
||||
cost: killedAgent.estimatedCost,
|
||||
}
|
||||
|
||||
setHistoryAgents((current) => [historyEntry, ...current].slice(0, 10))
|
||||
return previous.filter((agent) => agent.id !== agentId)
|
||||
})
|
||||
}
|
||||
|
||||
function cancelQueueTask(taskId: string) {
|
||||
setQueuedAgents((previous) => previous.filter((task) => task.id !== taskId))
|
||||
}
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
isOpen,
|
||||
queueOpen,
|
||||
historyOpen,
|
||||
isDesktop,
|
||||
shouldAutoOpen,
|
||||
panelVisible,
|
||||
showFloatingToggle,
|
||||
panelWidth: PANEL_WIDTH_PX,
|
||||
panelOffset,
|
||||
nowMs,
|
||||
lastRefreshedMs,
|
||||
activeAgents,
|
||||
missionActiveAgents,
|
||||
nonMissionActiveAgents,
|
||||
queuedAgents,
|
||||
historyAgents,
|
||||
activeMissionName: activeMission?.name || '',
|
||||
activeMissionState: activeMission?.state ?? null,
|
||||
activeCount: activeAgents.length,
|
||||
isLoading,
|
||||
isDemoMode,
|
||||
isLiveConnected,
|
||||
errorMessage,
|
||||
setOpen,
|
||||
toggleOpen,
|
||||
setQueueOpen,
|
||||
setHistoryOpen,
|
||||
killAgent,
|
||||
cancelQueueTask,
|
||||
}),
|
||||
[
|
||||
activeAgents,
|
||||
activeMission,
|
||||
missionActiveAgents,
|
||||
nonMissionActiveAgents,
|
||||
cancelQueueTask,
|
||||
errorMessage,
|
||||
historyAgents,
|
||||
historyOpen,
|
||||
isDemoMode,
|
||||
isDesktop,
|
||||
isLiveConnected,
|
||||
isLoading,
|
||||
isOpen,
|
||||
killAgent,
|
||||
lastRefreshedMs,
|
||||
nowMs,
|
||||
panelOffset,
|
||||
panelVisible,
|
||||
queueOpen,
|
||||
queuedAgents,
|
||||
setHistoryOpen,
|
||||
setOpen,
|
||||
setQueueOpen,
|
||||
shouldAutoOpen,
|
||||
showFloatingToggle,
|
||||
toggleOpen,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
export function formatRuntime(totalSeconds: number): string {
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${minutes}m ${String(seconds).padStart(2, '0')}s`
|
||||
}
|
||||
|
||||
export function formatCost(cost: number): string {
|
||||
return `$${cost.toFixed(3)}`
|
||||
}
|
||||
@@ -1,381 +1,36 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useGatewayChatStore } from '../stores/gateway-chat-store'
|
||||
import {
|
||||
CHAT_STREAM_DONE_EVENT,
|
||||
CHAT_TOOL_CALL_EVENT,
|
||||
CHAT_TOOL_RESULT_EVENT,
|
||||
} from './use-gateway-chat-stream'
|
||||
// Stub — research card is a ClawSuite feature, not used in Hermes Workspace
|
||||
|
||||
const EMPTY_TOOL_CALLS: never[] = []
|
||||
const researchTimelineCache = new Map<
|
||||
string,
|
||||
{ steps: ResearchStep[]; collapsed: boolean }
|
||||
>()
|
||||
|
||||
// Dev helper: trigger a fake research card demo from the browser console.
|
||||
// Usage: __triggerResearchDemo() or __triggerResearchDemo('agent:main:main')
|
||||
if (typeof window !== 'undefined' && import.meta.env.DEV) {
|
||||
;(window as any).__triggerResearchDemo = (sessionKey = 'agent:main:main') => {
|
||||
const tools = [
|
||||
{ name: 'memory_search', args: '{"query":"test"}', delay: 0, duration: 800 },
|
||||
{ name: 'Read', args: '{"path":"MEMORY.md"}', delay: 200, duration: 1200 },
|
||||
{ name: 'exec', args: '{"command":"git log --oneline -5"}', delay: 500, duration: 2000 },
|
||||
{ name: 'web_search', args: '{"query":"hermes agent latest"}', delay: 1000, duration: 1500 },
|
||||
{ name: 'Edit', args: '{"path":"src/app.tsx"}', delay: 1500, duration: 900 },
|
||||
]
|
||||
tools.forEach((tool, i) => {
|
||||
const toolCallId = `demo-${i}-${Date.now()}`
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent(CHAT_TOOL_CALL_EVENT, {
|
||||
detail: { sessionKey, toolCallId, name: tool.name, args: tool.args, phase: 'calling' }
|
||||
}))
|
||||
}, tool.delay)
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent(CHAT_TOOL_RESULT_EVENT, {
|
||||
detail: { sessionKey, toolCallId, name: tool.name, phase: 'done' }
|
||||
}))
|
||||
}, tool.delay + tool.duration)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export type ResearchStep = {
|
||||
export interface ResearchStep {
|
||||
id: string
|
||||
toolName: string
|
||||
name: string
|
||||
label: string
|
||||
status: 'running' | 'done' | 'error'
|
||||
startedAt: number
|
||||
durationMs?: number
|
||||
args: string
|
||||
delay: number
|
||||
duration: number
|
||||
durationMs: number
|
||||
status: 'pending' | 'running' | 'done'
|
||||
}
|
||||
|
||||
export type UseResearchCardResult = {
|
||||
steps: ResearchStep[]
|
||||
export interface UseResearchCardResult {
|
||||
isVisible: boolean
|
||||
isActive: boolean
|
||||
totalDurationMs: number
|
||||
currentStep: number
|
||||
steps: ResearchStep[]
|
||||
collapsed: boolean
|
||||
setCollapsed: Dispatch<SetStateAction<boolean>>
|
||||
setCollapsed: (collapsed: boolean) => void
|
||||
totalDurationMs: number
|
||||
dismiss: () => void
|
||||
}
|
||||
|
||||
type UseResearchCardOptions = {
|
||||
sessionKey?: string
|
||||
isStreaming?: boolean
|
||||
resetKey?: string | number
|
||||
}
|
||||
|
||||
type ToolEventDetail = {
|
||||
sessionKey?: string
|
||||
toolCallId?: string
|
||||
name?: string
|
||||
phase?: string
|
||||
args?: unknown
|
||||
}
|
||||
|
||||
function basename(path: string): string {
|
||||
if (!path) return ''
|
||||
const normalized = path.replace(/\\/g, '/')
|
||||
const parts = normalized.split('/')
|
||||
return parts[parts.length - 1] || path
|
||||
}
|
||||
|
||||
function extractFileTarget(args: unknown): string {
|
||||
if (!args) return ''
|
||||
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(args) as unknown
|
||||
return extractFileTarget(parsed)
|
||||
} catch {
|
||||
// Not JSON — try regex
|
||||
const patterns = [
|
||||
/"(?:path|file_path|file|filepath)"\s*:\s*"([^"]+)"/i,
|
||||
/path=([^\s,]+)/i,
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
const match = pattern.exec(args)
|
||||
if (match?.[1]) return basename(match[1])
|
||||
}
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof args === 'object' && args !== null) {
|
||||
const record = args as Record<string, unknown>
|
||||
for (const key of ['path', 'filePath', 'file_path', 'filepath', 'filename', 'file', 'target_file']) {
|
||||
const val = record[key]
|
||||
if (typeof val === 'string' && val.trim()) return basename(val.trim())
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function buildToolLabel(toolName: string, args: unknown): string {
|
||||
const fileTarget = extractFileTarget(args)
|
||||
|
||||
switch (toolName) {
|
||||
case 'exec':
|
||||
return 'Running command'
|
||||
case 'Read':
|
||||
case 'read':
|
||||
return fileTarget ? `Reading ${fileTarget}` : 'Reading file'
|
||||
case 'Write':
|
||||
case 'write':
|
||||
return fileTarget ? `Writing ${fileTarget}` : 'Writing file'
|
||||
case 'Edit':
|
||||
case 'edit':
|
||||
return fileTarget ? `Editing ${fileTarget}` : 'Editing file'
|
||||
case 'web_search':
|
||||
return 'Searching the web'
|
||||
case 'web_fetch':
|
||||
return 'Fetching page'
|
||||
case 'sessions_spawn':
|
||||
return 'Spawning agent'
|
||||
case 'sessions_send':
|
||||
return 'Steering agent'
|
||||
case 'memory_search':
|
||||
return 'Searching memory'
|
||||
case 'browser':
|
||||
return 'Controlling browser'
|
||||
case 'image':
|
||||
return 'Analyzing image'
|
||||
default:
|
||||
return toolName
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Research card hook that reads directly from the same gateway chat
|
||||
* store selector path used by the thinking bubble.
|
||||
*/
|
||||
export function useResearchCard({
|
||||
sessionKey,
|
||||
isStreaming = false,
|
||||
resetKey,
|
||||
}: UseResearchCardOptions = {}) {
|
||||
const effectiveSessionKey = sessionKey || 'main'
|
||||
const timelineKey = `${effectiveSessionKey}:${String(resetKey ?? 'default')}`
|
||||
const streamingToolCalls = useGatewayChatStore(
|
||||
(state) => state.streamingState.get(effectiveSessionKey)?.toolCalls ?? EMPTY_TOOL_CALLS,
|
||||
)
|
||||
const [steps, setSteps] = useState<ResearchStep[]>(
|
||||
() => researchTimelineCache.get(timelineKey)?.steps ?? [],
|
||||
)
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => researchTimelineCache.get(timelineKey)?.collapsed ?? false,
|
||||
)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const seenToolIdsRef = useRef<Set<string>>(new Set())
|
||||
const stepsRef = useRef(steps)
|
||||
|
||||
useEffect(() => {
|
||||
stepsRef.current = steps
|
||||
}, [steps])
|
||||
|
||||
const writeTimelineSnapshot = useCallback(
|
||||
(nextSteps: ResearchStep[], nextCollapsed: boolean) => {
|
||||
researchTimelineCache.set(timelineKey, {
|
||||
steps: nextSteps,
|
||||
collapsed: nextCollapsed,
|
||||
})
|
||||
},
|
||||
[timelineKey],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const cached = researchTimelineCache.get(timelineKey)
|
||||
const nextSteps = cached?.steps ?? []
|
||||
const nextCollapsed = cached?.collapsed ?? false
|
||||
setSteps(nextSteps)
|
||||
setCollapsed(nextCollapsed)
|
||||
seenToolIdsRef.current = new Set(nextSteps.map((step) => step.id))
|
||||
}, [timelineKey])
|
||||
|
||||
useEffect(() => {
|
||||
writeTimelineSnapshot(steps, collapsed)
|
||||
}, [collapsed, steps, writeTimelineSnapshot])
|
||||
|
||||
const upsertStep = useCallback(
|
||||
(
|
||||
toolId: string,
|
||||
toolName: string,
|
||||
args: unknown,
|
||||
status: ResearchStep['status'],
|
||||
currentTime = Date.now(),
|
||||
) => {
|
||||
setNow(currentTime)
|
||||
setSteps((prevSteps) => {
|
||||
const existingIndex = prevSteps.findIndex((step) => step.id === toolId)
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const existing = prevSteps[existingIndex]
|
||||
const nextDuration =
|
||||
status === 'running' ? undefined : currentTime - existing.startedAt
|
||||
const nextLabel = buildToolLabel(toolName, args)
|
||||
|
||||
if (
|
||||
existing.toolName === toolName &&
|
||||
existing.label === nextLabel &&
|
||||
existing.status === status &&
|
||||
existing.durationMs === nextDuration
|
||||
) {
|
||||
return prevSteps
|
||||
}
|
||||
|
||||
const nextSteps = [...prevSteps]
|
||||
nextSteps[existingIndex] = {
|
||||
...existing,
|
||||
toolName,
|
||||
label: nextLabel,
|
||||
status,
|
||||
durationMs: nextDuration,
|
||||
}
|
||||
return nextSteps
|
||||
}
|
||||
|
||||
if (seenToolIdsRef.current.has(toolId)) return prevSteps
|
||||
|
||||
seenToolIdsRef.current.add(toolId)
|
||||
return [
|
||||
...prevSteps,
|
||||
{
|
||||
id: toolId,
|
||||
toolName,
|
||||
label: buildToolLabel(toolName, args),
|
||||
status,
|
||||
startedAt: currentTime,
|
||||
durationMs: status === 'running' ? undefined : 0,
|
||||
},
|
||||
]
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Auto-collapse when streaming ends
|
||||
useEffect(() => {
|
||||
if (!isStreaming && steps.length > 0) {
|
||||
setCollapsed(true)
|
||||
}
|
||||
}, [isStreaming, steps.length])
|
||||
|
||||
// Tick timer for duration display
|
||||
useEffect(() => {
|
||||
if (!steps.some((step) => step.status === 'running')) return
|
||||
setNow(Date.now())
|
||||
const intervalId = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [steps])
|
||||
|
||||
// Mirror the active tool-call array from the store into a persistent
|
||||
// timeline so completed steps still render after streaming state clears.
|
||||
useEffect(() => {
|
||||
if (streamingToolCalls.length === 0) return
|
||||
|
||||
const currentTime = Date.now()
|
||||
for (const toolCall of streamingToolCalls) {
|
||||
const isDone = toolCall.phase === 'done' || toolCall.phase === 'result'
|
||||
const isError = toolCall.phase === 'error'
|
||||
const nextStatus: ResearchStep['status'] = isError
|
||||
? 'error'
|
||||
: isDone
|
||||
? 'done'
|
||||
: 'running'
|
||||
|
||||
upsertStep(
|
||||
toolCall.id,
|
||||
toolCall.name,
|
||||
toolCall.args,
|
||||
nextStatus,
|
||||
currentTime,
|
||||
)
|
||||
}
|
||||
|
||||
setCollapsed(false)
|
||||
}, [streamingToolCalls, upsertStep])
|
||||
|
||||
// Track tool activity directly from SSE tool events so quick runs still
|
||||
// populate the timeline even if streamingState is cleared before a render.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const handleToolCall = (event: Event) => {
|
||||
const detail = (event as CustomEvent<ToolEventDetail>).detail
|
||||
if (detail.sessionKey !== effectiveSessionKey) return
|
||||
const toolId = detail.toolCallId?.trim()
|
||||
const toolName = detail.name?.trim()
|
||||
if (!toolId || !toolName) return
|
||||
upsertStep(toolId, toolName, detail.args, 'running')
|
||||
setCollapsed(false)
|
||||
}
|
||||
|
||||
const handleToolResult = (event: Event) => {
|
||||
const detail = (event as CustomEvent<ToolEventDetail>).detail
|
||||
if (detail.sessionKey !== effectiveSessionKey) return
|
||||
const toolId = detail.toolCallId?.trim()
|
||||
const toolName = detail.name?.trim()
|
||||
if (!toolId || !toolName) return
|
||||
upsertStep(
|
||||
toolId,
|
||||
toolName,
|
||||
detail.args,
|
||||
detail.phase === 'error' ? 'error' : 'done',
|
||||
)
|
||||
setCollapsed(false)
|
||||
}
|
||||
|
||||
const handleStreamDone = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ sessionKey?: string }>).detail
|
||||
if (detail.sessionKey !== effectiveSessionKey) return
|
||||
setCollapsed((current) => (stepsRef.current.length > 0 ? true : current))
|
||||
}
|
||||
|
||||
window.addEventListener(CHAT_TOOL_CALL_EVENT, handleToolCall as EventListener)
|
||||
window.addEventListener(
|
||||
CHAT_TOOL_RESULT_EVENT,
|
||||
handleToolResult as EventListener,
|
||||
)
|
||||
window.addEventListener(
|
||||
CHAT_STREAM_DONE_EVENT,
|
||||
handleStreamDone as EventListener,
|
||||
)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
CHAT_TOOL_CALL_EVENT,
|
||||
handleToolCall as EventListener,
|
||||
)
|
||||
window.removeEventListener(
|
||||
CHAT_TOOL_RESULT_EVENT,
|
||||
handleToolResult as EventListener,
|
||||
)
|
||||
window.removeEventListener(
|
||||
CHAT_STREAM_DONE_EVENT,
|
||||
handleStreamDone as EventListener,
|
||||
)
|
||||
}
|
||||
}, [effectiveSessionKey, upsertStep])
|
||||
|
||||
const totalDurationMs = useMemo(() => {
|
||||
if (steps.length === 0) return 0
|
||||
const startedAt = Math.min(...steps.map((step) => step.startedAt))
|
||||
const endedAt = Math.max(
|
||||
...steps.map((step) =>
|
||||
step.startedAt + (step.durationMs ?? (isStreaming ? now - step.startedAt : 0)),
|
||||
),
|
||||
)
|
||||
return Math.max(0, endedAt - startedAt)
|
||||
}, [isStreaming, now, steps])
|
||||
|
||||
const isActive = steps.some((step) => step.status === 'running')
|
||||
|
||||
export function useResearchCard(_opts?: unknown): UseResearchCardResult {
|
||||
return {
|
||||
steps,
|
||||
isActive,
|
||||
totalDurationMs,
|
||||
collapsed,
|
||||
setCollapsed,
|
||||
isVisible: false,
|
||||
isActive: false,
|
||||
currentStep: 0,
|
||||
steps: [],
|
||||
collapsed: true,
|
||||
setCollapsed: () => {},
|
||||
totalDurationMs: 0,
|
||||
dismiss: () => {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTaskStore, type Task } from '@/stores/task-store'
|
||||
|
||||
/** Tracks which reminders/auto-alerts have already fired this session */
|
||||
const firedReminders = new Set<string>()
|
||||
const firedAutoAlerts = new Set<string>()
|
||||
|
||||
function showNotification(title: string, body: string) {
|
||||
// Try browser Notification API first
|
||||
if (
|
||||
typeof Notification !== 'undefined' &&
|
||||
Notification.permission === 'granted'
|
||||
) {
|
||||
new Notification(title, { body, icon: '/favicon.ico' })
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: dispatch custom event for in-app toast
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('hermes:toast', {
|
||||
detail: { title, body, type: 'warning' },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function isOverdue(task: Task): boolean {
|
||||
if (!task.dueDate || task.status === 'done') return false
|
||||
return new Date(task.dueDate).getTime() < Date.now()
|
||||
}
|
||||
|
||||
function isDueSoon(task: Task): boolean {
|
||||
if (!task.dueDate || task.status === 'done') return false
|
||||
const diff = new Date(task.dueDate).getTime() - Date.now()
|
||||
return diff > 0 && diff < 24 * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
export function useTaskReminders() {
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval>>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Request notification permission on mount
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof Notification !== 'undefined' &&
|
||||
Notification.permission === 'default'
|
||||
) {
|
||||
void Notification.requestPermission()
|
||||
}
|
||||
|
||||
function check() {
|
||||
const { tasks, updateTask } = useTaskStore.getState()
|
||||
const now = Date.now()
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.status === 'done') continue
|
||||
|
||||
// 1. Explicit reminder — fires once, then clears
|
||||
if (task.reminder && !firedReminders.has(task.id)) {
|
||||
const reminderTime = new Date(task.reminder).getTime()
|
||||
if (reminderTime <= now) {
|
||||
firedReminders.add(task.id)
|
||||
showNotification(
|
||||
`⏰ Reminder: ${task.title}`,
|
||||
task.description || `${task.priority} · ${task.status}`,
|
||||
)
|
||||
// Clear the reminder so it doesn't fire again after page reload
|
||||
updateTask(task.id, { reminder: undefined })
|
||||
// Persist to API
|
||||
void fetch(`/api/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reminder: null }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Overdue notification
|
||||
if (isOverdue(task) && !firedAutoAlerts.has(`overdue-${task.id}`)) {
|
||||
firedAutoAlerts.add(`overdue-${task.id}`)
|
||||
showNotification(
|
||||
`🚨 Overdue: ${task.title}`,
|
||||
`Was due ${new Date(task.dueDate!).toLocaleDateString()}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Due soon (within 24h)
|
||||
if (isDueSoon(task) && !firedAutoAlerts.has(`duesoon-${task.id}`)) {
|
||||
firedAutoAlerts.add(`duesoon-${task.id}`)
|
||||
showNotification(
|
||||
`⚠️ Due soon: ${task.title}`,
|
||||
`Due ${new Date(task.dueDate!).toLocaleDateString()}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Auto-priority reminders (P0 every 4h, P1 daily if stale)
|
||||
if (task.priority === 'P0' && !task.reminder) {
|
||||
const key = `p0-${task.id}-${Math.floor(now / (4 * 60 * 60 * 1000))}`
|
||||
if (!firedAutoAlerts.has(key)) {
|
||||
firedAutoAlerts.add(key)
|
||||
showNotification(
|
||||
`🔴 P0 Active: ${task.title}`,
|
||||
`Priority task needs attention`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
task.priority === 'P1' &&
|
||||
task.status === 'in_progress' &&
|
||||
!task.reminder
|
||||
) {
|
||||
const updated = new Date(task.updatedAt).getTime()
|
||||
const twoDays = 2 * 24 * 60 * 60 * 1000
|
||||
if (now - updated > twoDays) {
|
||||
const key = `p1-stale-${task.id}-${Math.floor(now / (24 * 60 * 60 * 1000))}`
|
||||
if (!firedAutoAlerts.has(key)) {
|
||||
firedAutoAlerts.add(key)
|
||||
showNotification(
|
||||
`🟡 P1 Stale: ${task.title}`,
|
||||
`In progress for ${Math.floor((now - updated) / (24 * 60 * 60 * 1000))} days`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run immediately then every 60s
|
||||
check()
|
||||
intervalRef.current = setInterval(check, 60_000)
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
1937
src/routeTree.gen.ts
1937
src/routeTree.gen.ts
File diff suppressed because it is too large
Load Diff
@@ -6,16 +6,10 @@ import { SearchModal } from '@/components/search/search-modal'
|
||||
import { TerminalShortcutListener } from '@/components/terminal-shortcut-listener'
|
||||
import { GlobalShortcutListener } from '@/components/global-shortcut-listener'
|
||||
import { WorkspaceShell } from '@/components/workspace-shell'
|
||||
import { useTaskReminders } from '@/hooks/use-task-reminders'
|
||||
import { UpdateNotifier } from '@/components/update-notifier'
|
||||
import { MobilePromptTrigger } from '@/components/mobile-prompt/MobilePromptTrigger'
|
||||
import { Toaster } from '@/components/ui/toast'
|
||||
import { OnboardingTour } from '@/components/onboarding/onboarding-tour'
|
||||
import { KeyboardShortcutsModal } from '@/components/keyboard-shortcuts-modal'
|
||||
import { CompactionNotifier } from '@/components/compaction-notifier'
|
||||
import { FallbackBanner } from '@/components/fallback-banner'
|
||||
import { GatewayRestartProvider } from '@/components/gateway-restart-overlay'
|
||||
import { ExecApprovalToast } from '@/components/exec-approval-toast'
|
||||
import { initializeSettingsAppearance } from '@/hooks/use-settings'
|
||||
import { HermesOnboarding } from '@/components/onboarding/hermes-onboarding'
|
||||
|
||||
@@ -199,11 +193,6 @@ export const Route = createRootRoute({
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
function TaskReminderRunner() {
|
||||
useTaskReminders()
|
||||
return null
|
||||
}
|
||||
|
||||
function RootLayout() {
|
||||
// Unregister any existing service workers — they cause stale asset issues
|
||||
// after Docker image updates and behind reverse proxies (Pangolin, Cloudflare, etc.)
|
||||
@@ -229,22 +218,15 @@ function RootLayout() {
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GatewayRestartProvider>
|
||||
<HermesOnboarding />
|
||||
<CompactionNotifier />
|
||||
<FallbackBanner />
|
||||
<GlobalShortcutListener />
|
||||
<TerminalShortcutListener />
|
||||
<TaskReminderRunner />
|
||||
<UpdateNotifier />
|
||||
<MobilePromptTrigger />
|
||||
<Toaster />
|
||||
<ExecApprovalToast />
|
||||
<WorkspaceShell />
|
||||
<SearchModal />
|
||||
<OnboardingTour />
|
||||
<KeyboardShortcutsModal />
|
||||
</GatewayRestartProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* Diagnostics bundle API endpoint — stub
|
||||
*/
|
||||
|
||||
// This file is a placeholder. The diagnostics functionality
|
||||
// is handled by /api/debug/status endpoint instead.
|
||||
export {}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../server/auth-middleware'
|
||||
|
||||
export const Route = createFileRoute('/api/agent-activity')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
return json({ events: [], ok: true })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,220 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../server/auth-middleware'
|
||||
import { requireJsonContentType } from '../../server/rate-limit'
|
||||
import {
|
||||
launchBrowser,
|
||||
closeBrowser,
|
||||
navigate,
|
||||
typeText,
|
||||
pressKey,
|
||||
goBack,
|
||||
goForward,
|
||||
refresh,
|
||||
scrollPage,
|
||||
getScreenshot,
|
||||
getPageContent,
|
||||
cdpMouseClick,
|
||||
} from '../../server/browser-session'
|
||||
import {
|
||||
startProxy,
|
||||
stopProxy,
|
||||
getProxyUrl,
|
||||
getCurrentTarget,
|
||||
} from '../../server/browser-proxy'
|
||||
import { startBrowserStream } from '../../server/browser-stream'
|
||||
|
||||
export const Route = createFileRoute('/api/browser')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const action = url.searchParams.get('action') || 'status'
|
||||
|
||||
if (action === 'status' || action === 'proxy-status') {
|
||||
try {
|
||||
return json({
|
||||
ok: true,
|
||||
proxyUrl: getProxyUrl(),
|
||||
target: getCurrentTarget(),
|
||||
})
|
||||
} catch (err) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return json(
|
||||
{ error: `Unsupported GET action: ${action}` },
|
||||
{ status: 400 },
|
||||
)
|
||||
},
|
||||
|
||||
POST: async ({ request }) => {
|
||||
try {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
const action =
|
||||
typeof body.action === 'string' ? body.action.trim() : ''
|
||||
|
||||
switch (action) {
|
||||
case 'launch': {
|
||||
const state = await launchBrowser()
|
||||
return json({ ok: true, ...state })
|
||||
}
|
||||
|
||||
case 'close': {
|
||||
await closeBrowser()
|
||||
return json({ ok: true, running: false })
|
||||
}
|
||||
|
||||
case 'navigate': {
|
||||
const url = typeof body.url === 'string' ? body.url.trim() : ''
|
||||
if (!url)
|
||||
return json({ error: 'url is required' }, { status: 400 })
|
||||
const state = await navigate(url)
|
||||
return json({ ok: true, ...state })
|
||||
}
|
||||
|
||||
case 'click': {
|
||||
const xRaw = typeof body.x === 'number' ? body.x : 0
|
||||
const yRaw = typeof body.y === 'number' ? body.y : 0
|
||||
const x = Math.max(0, Math.min(10_000, Math.floor(xRaw)))
|
||||
const y = Math.max(0, Math.min(10_000, Math.floor(yRaw)))
|
||||
const state = await cdpMouseClick(x, y)
|
||||
return json({ ok: true, ...state })
|
||||
}
|
||||
|
||||
case 'type': {
|
||||
const text =
|
||||
typeof body.text === 'string' ? body.text.slice(0, 20_000) : ''
|
||||
const submit = body.submit === true
|
||||
const state = await typeText(text, submit)
|
||||
return json({ ok: true, ...state })
|
||||
}
|
||||
|
||||
case 'press': {
|
||||
const key = typeof body.key === 'string' ? body.key : ''
|
||||
if (!key)
|
||||
return json({ error: 'key is required' }, { status: 400 })
|
||||
const state = await pressKey(key)
|
||||
return json({ ok: true, ...state })
|
||||
}
|
||||
|
||||
case 'back': {
|
||||
const state = await goBack()
|
||||
return json({ ok: true, ...state })
|
||||
}
|
||||
|
||||
case 'forward': {
|
||||
const state = await goForward()
|
||||
return json({ ok: true, ...state })
|
||||
}
|
||||
|
||||
case 'refresh': {
|
||||
const state = await refresh()
|
||||
return json({ ok: true, ...state })
|
||||
}
|
||||
|
||||
case 'scroll': {
|
||||
const direction = body.direction === 'up' ? 'up' : 'down'
|
||||
const amountRaw =
|
||||
typeof body.amount === 'number' ? body.amount : 400
|
||||
const amount = Math.max(10, Math.min(5000, Math.floor(amountRaw)))
|
||||
const state = await scrollPage(direction, amount)
|
||||
return json({ ok: true, ...state })
|
||||
}
|
||||
|
||||
case 'screenshot': {
|
||||
const state = await getScreenshot()
|
||||
return json({ ok: true, ...state })
|
||||
}
|
||||
|
||||
case 'content': {
|
||||
const content = await getPageContent()
|
||||
return json({ ok: true, ...content })
|
||||
}
|
||||
|
||||
// Proxy mode — iframe-based browsing
|
||||
case 'proxy-start': {
|
||||
const result = await startProxy()
|
||||
return json({ ok: true, ...result })
|
||||
}
|
||||
|
||||
case 'proxy-stop': {
|
||||
await stopProxy()
|
||||
return json({ ok: true })
|
||||
}
|
||||
|
||||
case 'proxy-navigate': {
|
||||
const url = typeof body.url === 'string' ? body.url.trim() : ''
|
||||
if (!url) return json({ error: 'url required' }, { status: 400 })
|
||||
let normalizedUrl = url
|
||||
if (!normalizedUrl.match(/^https?:\/\//))
|
||||
normalizedUrl = `https://${normalizedUrl}`
|
||||
// Navigate the proxy
|
||||
const proxyUrl = getProxyUrl()
|
||||
await fetch(
|
||||
`${proxyUrl}/__proxy__/navigate?url=${encodeURIComponent(normalizedUrl)}`,
|
||||
)
|
||||
return json({
|
||||
ok: true,
|
||||
proxyUrl,
|
||||
iframeSrc: `${proxyUrl}/?url=${encodeURIComponent(normalizedUrl)}`,
|
||||
url: normalizedUrl,
|
||||
})
|
||||
}
|
||||
|
||||
case 'proxy-status': {
|
||||
return json({
|
||||
ok: true,
|
||||
proxyUrl: getProxyUrl(),
|
||||
target: getCurrentTarget(),
|
||||
})
|
||||
}
|
||||
|
||||
case 'stream-start': {
|
||||
const result = await startBrowserStream()
|
||||
return json({
|
||||
ok: true,
|
||||
wsUrl: `ws://localhost:${result.port}`,
|
||||
...result,
|
||||
})
|
||||
}
|
||||
|
||||
default:
|
||||
return json(
|
||||
{ error: `Unknown action: ${action}` },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import { gatewayRpc } from '../../../server/gateway'
|
||||
import { requireJsonContentType } from '../../../server/rate-limit'
|
||||
|
||||
const BROWSER_NAVIGATE_METHODS = [
|
||||
'browser', // Current Hermes API — single method with action param
|
||||
'browser.navigate',
|
||||
'browser_navigate',
|
||||
'browser.go',
|
||||
'browser_go',
|
||||
]
|
||||
|
||||
function readErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message || 'Browser navigate failed'
|
||||
if (typeof error === 'string' && error.trim()) return error
|
||||
return 'Browser navigate failed'
|
||||
}
|
||||
|
||||
async function callBrowserNavigate(params: { url: string }): Promise<unknown> {
|
||||
let lastError: unknown = null
|
||||
for (const method of BROWSER_NAVIGATE_METHODS) {
|
||||
try {
|
||||
const rpcParams = method === 'browser'
|
||||
? { action: 'navigate', ...params }
|
||||
: params
|
||||
return await gatewayRpc(method, rpcParams)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error('Gateway browser navigate request failed')
|
||||
}
|
||||
|
||||
function normalizeUrl(input: string): string {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return ''
|
||||
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmed)) return trimmed
|
||||
return `https://${trimmed}`
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/api/browser/navigate')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
const rawUrl = typeof body.url === 'string' ? body.url : ''
|
||||
const url = normalizeUrl(rawUrl)
|
||||
|
||||
if (!url) {
|
||||
return json({ ok: false, error: 'url is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await callBrowserNavigate({ url })
|
||||
return json({ ok: true, url, payload })
|
||||
} catch (error) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: readErrorMessage(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../server/auth-middleware'
|
||||
import { requireJsonContentType } from '../../server/rate-limit'
|
||||
|
||||
export const Route = createFileRoute('/api/chat-abort')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
return json({ ok: true })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../server/auth-middleware'
|
||||
|
||||
export const Route = createFileRoute('/api/cli-agents/$pid/kill')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const pid = Number(params.pid)
|
||||
if (!Number.isFinite(pid) || !Number.isInteger(pid) || pid <= 0) {
|
||||
return json({ ok: false, error: 'Invalid pid' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM')
|
||||
return json({ ok: true })
|
||||
} catch (error) {
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
error.code === 'ESRCH'
|
||||
) {
|
||||
// Process already exited; treat as success.
|
||||
return json({ ok: true })
|
||||
}
|
||||
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,321 +0,0 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../server/auth-middleware'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const NAME_ADJECTIVES = [
|
||||
'amber',
|
||||
'brisk',
|
||||
'calm',
|
||||
'daring',
|
||||
'ember',
|
||||
'frost',
|
||||
'golden',
|
||||
'harbor',
|
||||
'ivory',
|
||||
'jade',
|
||||
'lunar',
|
||||
'misty',
|
||||
'nova',
|
||||
'quiet',
|
||||
'raven',
|
||||
'solar',
|
||||
'swift',
|
||||
'tidal',
|
||||
'vivid',
|
||||
'wild',
|
||||
]
|
||||
|
||||
const NAME_NOUNS = [
|
||||
'anchor',
|
||||
'bloom',
|
||||
'canyon',
|
||||
'drift',
|
||||
'engine',
|
||||
'falcon',
|
||||
'forge',
|
||||
'glade',
|
||||
'harbor',
|
||||
'isle',
|
||||
'journey',
|
||||
'knoll',
|
||||
'meadow',
|
||||
'nexus',
|
||||
'orbit',
|
||||
'peak',
|
||||
'ridge',
|
||||
'signal',
|
||||
'summit',
|
||||
'trail',
|
||||
]
|
||||
|
||||
type CliAgentStatus = 'running' | 'finished'
|
||||
|
||||
type AgentProcess = {
|
||||
pid: number
|
||||
stat: string
|
||||
command: string
|
||||
}
|
||||
|
||||
type CliAgent = {
|
||||
pid: number
|
||||
name: string
|
||||
task: string
|
||||
runtimeSeconds: number
|
||||
status: CliAgentStatus
|
||||
}
|
||||
|
||||
function parsePsAuxOutput(output: string): Array<AgentProcess> {
|
||||
const lines = output.split('\n')
|
||||
const entries: Array<AgentProcess> = []
|
||||
|
||||
for (const line of lines.slice(1)) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
const columns = trimmed.split(/\s+/)
|
||||
if (columns.length < 11) continue
|
||||
|
||||
const pid = Number.parseInt(columns[1] ?? '', 10)
|
||||
if (!Number.isFinite(pid)) continue
|
||||
|
||||
const stat = columns[7] ?? ''
|
||||
const command = columns.slice(10).join(' ')
|
||||
if (!command) continue
|
||||
if (!command.toLowerCase().includes('codex')) continue
|
||||
|
||||
entries.push({ pid, stat, command })
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
function parseElapsedToSeconds(elapsed: string): number {
|
||||
const value = elapsed.trim()
|
||||
if (!value) return 0
|
||||
|
||||
let daySeconds = 0
|
||||
let clock = value
|
||||
|
||||
if (value.includes('-')) {
|
||||
const [daysPart, timePart] = value.split('-', 2)
|
||||
const days = Number.parseInt(daysPart, 10)
|
||||
if (Number.isFinite(days) && days > 0) {
|
||||
daySeconds = days * 24 * 60 * 60
|
||||
}
|
||||
clock = timePart
|
||||
}
|
||||
|
||||
const segments = clock.split(':').map(function parseSegment(segment) {
|
||||
return Number.parseInt(segment, 10)
|
||||
})
|
||||
|
||||
if (
|
||||
segments.some(function hasInvalid(valuePart) {
|
||||
return !Number.isFinite(valuePart)
|
||||
})
|
||||
) {
|
||||
return daySeconds
|
||||
}
|
||||
|
||||
if (segments.length === 3) {
|
||||
const [hours, minutes, seconds] = segments
|
||||
return daySeconds + hours * 60 * 60 + minutes * 60 + seconds
|
||||
}
|
||||
|
||||
if (segments.length === 2) {
|
||||
const [minutes, seconds] = segments
|
||||
return daySeconds + minutes * 60 + seconds
|
||||
}
|
||||
|
||||
if (segments.length === 1) {
|
||||
return daySeconds + segments[0]
|
||||
}
|
||||
|
||||
return daySeconds
|
||||
}
|
||||
|
||||
async function readRuntimeByPid(
|
||||
pids: Array<number>,
|
||||
): Promise<Map<number, number>> {
|
||||
const runtimeByPid = new Map<number, number>()
|
||||
if (!pids.length) return runtimeByPid
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('ps', [
|
||||
'-o',
|
||||
'pid=,etime=',
|
||||
'-p',
|
||||
pids.join(','),
|
||||
])
|
||||
|
||||
for (const line of stdout.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
const match = trimmed.match(/^(\d+)\s+(\S+)$/)
|
||||
if (!match) continue
|
||||
|
||||
const pid = Number.parseInt(match[1], 10)
|
||||
const runtimeSeconds = parseElapsedToSeconds(match[2])
|
||||
|
||||
if (Number.isFinite(pid) && Number.isFinite(runtimeSeconds)) {
|
||||
runtimeByPid.set(pid, runtimeSeconds)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return runtimeByPid
|
||||
}
|
||||
|
||||
return runtimeByPid
|
||||
}
|
||||
|
||||
function stripQuotes(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
const hasDoubleQuotes = trimmed.startsWith('"') && trimmed.endsWith('"')
|
||||
const hasSingleQuotes = trimmed.startsWith("'") && trimmed.endsWith("'")
|
||||
if (hasDoubleQuotes || hasSingleQuotes) {
|
||||
return trimmed.slice(1, -1)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function normalizeTask(value: string): string {
|
||||
const normalized = value.trim().replace(/\s+/g, ' ')
|
||||
return normalized || 'No task description'
|
||||
}
|
||||
|
||||
function tokenizeCommand(command: string): Array<string> {
|
||||
const tokens = command.match(/"[^"]*"|'[^']*'|\S+/g)
|
||||
return tokens ?? []
|
||||
}
|
||||
|
||||
function extractTaskFromCommand(command: string): string {
|
||||
const tokens = tokenizeCommand(command)
|
||||
if (!tokens.length) return 'No task description'
|
||||
|
||||
const codexIndex = tokens.findIndex(function isCodexToken(token) {
|
||||
return stripQuotes(token).toLowerCase().includes('codex')
|
||||
})
|
||||
|
||||
const args = codexIndex >= 0 ? tokens.slice(codexIndex + 1) : tokens
|
||||
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const current = stripQuotes(args[i] ?? '')
|
||||
if (!current) continue
|
||||
|
||||
if (current.startsWith('--task=')) {
|
||||
return normalizeTask(current.slice('--task='.length))
|
||||
}
|
||||
|
||||
if (current.startsWith('--prompt=')) {
|
||||
return normalizeTask(current.slice('--prompt='.length))
|
||||
}
|
||||
|
||||
if (
|
||||
current === '--task' ||
|
||||
current === '--prompt' ||
|
||||
current === '-t' ||
|
||||
current === '-p'
|
||||
) {
|
||||
const next = stripQuotes(args[i + 1] ?? '')
|
||||
if (next) return normalizeTask(next)
|
||||
}
|
||||
}
|
||||
|
||||
const meaningfulParts: Array<string> = []
|
||||
|
||||
for (const arg of args) {
|
||||
const part = stripQuotes(arg)
|
||||
if (!part) continue
|
||||
if (part.startsWith('-')) continue
|
||||
meaningfulParts.push(part)
|
||||
}
|
||||
|
||||
if (!meaningfulParts.length) {
|
||||
return 'No task description'
|
||||
}
|
||||
|
||||
return normalizeTask(meaningfulParts.join(' '))
|
||||
}
|
||||
|
||||
function hashPid(pid: number): number {
|
||||
let value = pid | 0
|
||||
value = Math.imul(value ^ 0x45d9f3b, 0x45d9f3b)
|
||||
value ^= value >>> 16
|
||||
return value >>> 0
|
||||
}
|
||||
|
||||
function createAgentName(pid: number): string {
|
||||
const hash = hashPid(pid)
|
||||
const adjective = NAME_ADJECTIVES[hash % NAME_ADJECTIVES.length]
|
||||
const nounIndex =
|
||||
Math.floor(hash / NAME_ADJECTIVES.length) % NAME_NOUNS.length
|
||||
const noun = NAME_NOUNS[nounIndex]
|
||||
return `${adjective}-${noun}`
|
||||
}
|
||||
|
||||
function resolveStatus(stat: string): CliAgentStatus {
|
||||
if (stat.includes('Z') || stat.includes('T') || stat.includes('X')) {
|
||||
return 'finished'
|
||||
}
|
||||
return 'running'
|
||||
}
|
||||
|
||||
function toCliAgent(
|
||||
processEntry: AgentProcess,
|
||||
runtimeByPid: Map<number, number>,
|
||||
): CliAgent {
|
||||
return {
|
||||
pid: processEntry.pid,
|
||||
name: createAgentName(processEntry.pid),
|
||||
task: extractTaskFromCommand(processEntry.command),
|
||||
runtimeSeconds: runtimeByPid.get(processEntry.pid) ?? 0,
|
||||
status: resolveStatus(processEntry.stat),
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/api/cli-agents')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async function getCliAgents({ request }: { request: Request }) {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execFileAsync('ps', ['aux'])
|
||||
const processes = parsePsAuxOutput(stdout)
|
||||
const runtimeByPid = await readRuntimeByPid(
|
||||
processes.map(function getPid(entry) {
|
||||
return entry.pid
|
||||
}),
|
||||
)
|
||||
|
||||
const agents = processes
|
||||
.map(function mapToAgent(entry) {
|
||||
return toCliAgent(entry, runtimeByPid)
|
||||
})
|
||||
.sort(function sortAgents(a, b) {
|
||||
if (a.runtimeSeconds !== b.runtimeSeconds) {
|
||||
return b.runtimeSeconds - a.runtimeSeconds
|
||||
}
|
||||
return a.pid - b.pid
|
||||
})
|
||||
|
||||
return json({ agents })
|
||||
} catch (error) {
|
||||
return json(
|
||||
{
|
||||
agents: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { provisionCloudInstance } from '@/lib/cloud-store'
|
||||
import type { CloudProvisionRequest } from '@/lib/cloud-types'
|
||||
import { isAuthenticated } from '@/server/auth-middleware'
|
||||
import { requireJsonContentType } from '@/server/rate-limit'
|
||||
|
||||
const ProvisionSchema = z.object({
|
||||
email: z.string().email(),
|
||||
plan: z.enum(['free', 'pro', 'team']),
|
||||
polarSubscriptionId: z.string().min(1).optional(),
|
||||
}) satisfies z.ZodType<CloudProvisionRequest>
|
||||
|
||||
export const Route = createFileRoute('/api/cloud/provision')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const raw = await request.json().catch(() => ({}))
|
||||
const parsed = ProvisionSchema.safeParse(raw)
|
||||
|
||||
if (!parsed.success) {
|
||||
return json({ ok: false, error: 'Invalid provision request' }, { status: 400 })
|
||||
}
|
||||
|
||||
const instance = await provisionCloudInstance(parsed.data)
|
||||
|
||||
return json({
|
||||
gatewayUrl: instance.gatewayUrl,
|
||||
token: instance.token,
|
||||
plan: instance.plan,
|
||||
expiresAt: instance.expiresAt,
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
|
||||
import { getCloudInstanceByEmail } from '@/lib/cloud-store'
|
||||
import { isAuthenticated } from '@/server/auth-middleware'
|
||||
|
||||
export const Route = createFileRoute('/api/cloud/status')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const email = url.searchParams.get('email')?.trim()
|
||||
|
||||
if (!email) {
|
||||
return json({ ok: false, error: 'Missing email query parameter' }, { status: 400 })
|
||||
}
|
||||
|
||||
const instance = getCloudInstanceByEmail(email)
|
||||
if (!instance) {
|
||||
return json({ ok: false, error: 'Cloud instance not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return json({
|
||||
id: instance.id,
|
||||
email: instance.email,
|
||||
plan: instance.plan,
|
||||
status: instance.status,
|
||||
gatewayUrl: instance.gatewayUrl,
|
||||
createdAt: instance.createdAt,
|
||||
expiresAt: instance.expiresAt,
|
||||
polarSubscriptionId: instance.polarSubscriptionId,
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,177 +0,0 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
|
||||
import {
|
||||
provisionCloudInstance,
|
||||
suspendCloudInstance,
|
||||
updateCloudInstance,
|
||||
} from '@/lib/cloud-store'
|
||||
import type { CloudPlan, PolarWebhookEvent } from '@/lib/cloud-types'
|
||||
import { requireJsonContentType } from '@/server/rate-limit'
|
||||
|
||||
const POLAR_PRODUCT_PLAN_MAP: Record<string, CloudPlan> = {
|
||||
'bd502a21-b846-40a0-8763-b301849b1df5': 'free',
|
||||
'0cf1fed8-898c-4062-beeb-e38f0cd5bb21': 'pro', // $20/mo
|
||||
'3e482285-2d66-438e-bca2-fd99bc15cc20': 'pro', // $200/yr
|
||||
'fb2836ac-2f70-4b2c-9ad6-850b26ffa799': 'team', // $50/mo
|
||||
'ba38b3ed-5d85-4268-873f-8293402ab697': 'team', // $500/yr
|
||||
}
|
||||
|
||||
function normalizePlan(plan: unknown): CloudPlan {
|
||||
return plan === 'team' || plan === 'free' ? plan : 'pro'
|
||||
}
|
||||
|
||||
function planFromProductId(productId: unknown): CloudPlan | undefined {
|
||||
if (typeof productId === 'string') return POLAR_PRODUCT_PLAN_MAP[productId]
|
||||
return undefined
|
||||
}
|
||||
|
||||
function timingSafeMatch(left: string, right: string): boolean {
|
||||
const leftBuffer = Buffer.from(left)
|
||||
const rightBuffer = Buffer.from(right)
|
||||
|
||||
if (leftBuffer.length !== rightBuffer.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return timingSafeEqual(leftBuffer, rightBuffer)
|
||||
}
|
||||
|
||||
function isValidPolarSignature(rawBody: string, signature: string, secret: string): boolean {
|
||||
const expectedHex = createHmac('sha256', secret).update(rawBody).digest('hex')
|
||||
const expectedBase64 = createHmac('sha256', secret).update(rawBody).digest('base64')
|
||||
const normalized = signature.trim()
|
||||
const candidates = normalized
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.flatMap((value) => {
|
||||
if (value.startsWith('sha256=')) return [value.slice('sha256='.length)]
|
||||
if (value.startsWith('v1=')) return [value.slice('v1='.length)]
|
||||
return [value]
|
||||
})
|
||||
|
||||
return candidates.some(
|
||||
(candidate) =>
|
||||
timingSafeMatch(candidate, expectedHex) ||
|
||||
timingSafeMatch(candidate, expectedBase64),
|
||||
)
|
||||
}
|
||||
|
||||
function extractEmail(event: PolarWebhookEvent): string | undefined {
|
||||
return (
|
||||
event.data?.subscription?.customer?.email ??
|
||||
event.data?.customer?.email ??
|
||||
event.data?.email
|
||||
)
|
||||
}
|
||||
|
||||
function extractSubscriptionId(event: PolarWebhookEvent): string | undefined {
|
||||
return event.data?.subscription?.id ?? event.data?.id
|
||||
}
|
||||
|
||||
function extractPlan(event: PolarWebhookEvent): CloudPlan {
|
||||
// First try to resolve from product ID (most reliable)
|
||||
const productId =
|
||||
event.data?.subscription?.productId ??
|
||||
event.data?.subscription?.product_id ??
|
||||
event.data?.productId ??
|
||||
event.data?.product_id
|
||||
|
||||
const planFromProduct = planFromProductId(productId)
|
||||
if (planFromProduct) return planFromProduct
|
||||
|
||||
// Fallback to metadata
|
||||
const metadataPlan =
|
||||
event.data?.subscription?.metadata?.plan ??
|
||||
event.data?.metadata?.plan ??
|
||||
event.data?.plan
|
||||
|
||||
return normalizePlan(metadataPlan)
|
||||
}
|
||||
|
||||
function extractExpiry(event: PolarWebhookEvent): string | undefined {
|
||||
return (
|
||||
event.data?.subscription?.currentPeriodEnd ??
|
||||
event.data?.subscription?.current_period_end ??
|
||||
event.data?.currentPeriodEnd ??
|
||||
event.data?.current_period_end
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/api/cloud/webhook')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const signature = request.headers.get('X-Polar-Signature')
|
||||
const secret = process.env.POLAR_WEBHOOK_SECRET
|
||||
|
||||
if (!signature || !secret) {
|
||||
return json({ ok: false, error: 'Webhook secret or signature missing' }, { status: 403 })
|
||||
}
|
||||
|
||||
const rawBody = await request.text()
|
||||
if (!isValidPolarSignature(rawBody, signature, secret)) {
|
||||
return json({ ok: false, error: 'Invalid webhook signature' }, { status: 403 })
|
||||
}
|
||||
|
||||
let event: PolarWebhookEvent
|
||||
try {
|
||||
event = JSON.parse(rawBody) as PolarWebhookEvent
|
||||
} catch {
|
||||
return json({ ok: false, error: 'Invalid webhook payload' }, { status: 400 })
|
||||
}
|
||||
const email = extractEmail(event)
|
||||
const polarSubscriptionId = extractSubscriptionId(event)
|
||||
const plan = extractPlan(event)
|
||||
const expiresAt = extractExpiry(event)
|
||||
|
||||
if (event.type === 'subscription.created') {
|
||||
if (!email) {
|
||||
return json({ ok: false, error: 'Missing customer email' }, { status: 400 })
|
||||
}
|
||||
|
||||
await provisionCloudInstance({
|
||||
email,
|
||||
plan,
|
||||
polarSubscriptionId,
|
||||
expiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
if (event.type === 'subscription.canceled') {
|
||||
const suspended = suspendCloudInstance({ email, polarSubscriptionId })
|
||||
if (!suspended) {
|
||||
return json({ ok: false, error: 'Cloud instance not found' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === 'subscription.updated' && email) {
|
||||
const existing =
|
||||
updateCloudInstance(email, {
|
||||
plan,
|
||||
expiresAt,
|
||||
polarSubscriptionId,
|
||||
status: event.data?.subscription?.status === 'canceled' ? 'suspended' : 'active',
|
||||
}) ??
|
||||
await provisionCloudInstance({
|
||||
email,
|
||||
plan,
|
||||
polarSubscriptionId,
|
||||
expiresAt,
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
return json({ ok: false, error: 'Unable to update cloud instance' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
return json({ ok: true })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../server/auth-middleware'
|
||||
|
||||
const HERMES_API_URL = process.env.HERMES_API_URL || 'http://127.0.0.1:8642'
|
||||
|
||||
export const Route = createFileRoute('/api/config-get')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${HERMES_API_URL}/api/config`)
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '')
|
||||
throw new Error(body || `Hermes config request failed (${response.status})`)
|
||||
}
|
||||
const result = (await response.json()) as { defaultModel?: string }
|
||||
return json({ ok: true, payload: result })
|
||||
} catch (err) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../server/auth-middleware'
|
||||
import { requireJsonContentType } from '../../server/rate-limit'
|
||||
|
||||
export const Route = createFileRoute('/api/config-patch')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
return json(
|
||||
{ ok: false, error: 'Config updates are not available in Hermes Workspace.' },
|
||||
{ status: 501 },
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '@/server/auth-middleware'
|
||||
|
||||
export const Route = createFileRoute('/api/cost')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
return json({
|
||||
ok: true,
|
||||
cost: {
|
||||
timeseries: [],
|
||||
total: 0,
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '@/server/auth-middleware'
|
||||
import { gatewayCronRpc, normalizeCronJobs } from '@/server/cron'
|
||||
|
||||
export const Route = createFileRoute('/api/cron')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
try {
|
||||
const payload = await gatewayCronRpc(
|
||||
['cron.list', 'cron.jobs.list', 'scheduler.jobs.list'],
|
||||
{ includeDisabled: true },
|
||||
)
|
||||
|
||||
return json({
|
||||
jobs: normalizeCronJobs(payload),
|
||||
})
|
||||
} catch (err) {
|
||||
return json(
|
||||
{
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import { gatewayCronRpc } from '@/server/cron'
|
||||
import { requireJsonContentType } from '../../../server/rate-limit'
|
||||
|
||||
export const Route = createFileRoute('/api/cron/delete')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
try {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
const jobId = typeof body.jobId === 'string' ? body.jobId.trim() : ''
|
||||
if (!jobId) {
|
||||
return json({ error: 'jobId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const payload = await gatewayCronRpc(
|
||||
['cron.remove'],
|
||||
{ jobId },
|
||||
)
|
||||
|
||||
return json({ ok: true, payload })
|
||||
} catch (err) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import { gatewayCronRpc, normalizeCronJobs } from '@/server/cron'
|
||||
|
||||
export const Route = createFileRoute('/api/cron/list')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
try {
|
||||
const payload = await gatewayCronRpc(
|
||||
['cron.list', 'cron.jobs.list', 'scheduler.jobs.list'],
|
||||
{ includeDisabled: true },
|
||||
)
|
||||
|
||||
return json({
|
||||
jobs: normalizeCronJobs(payload),
|
||||
})
|
||||
} catch (err) {
|
||||
return json(
|
||||
{
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import { gatewayCronRpc } from '@/server/cron'
|
||||
import { requireJsonContentType } from '../../../server/rate-limit'
|
||||
|
||||
export const Route = createFileRoute('/api/cron/run-if-due')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
try {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
||||
const jobId = typeof body.jobId === 'string' ? body.jobId.trim() : ''
|
||||
if (!jobId) {
|
||||
return json({ error: 'jobId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const payload = await gatewayCronRpc(
|
||||
[
|
||||
'cron.runIfDue',
|
||||
'cron.run_if_due',
|
||||
'scheduler.runIfDue',
|
||||
'scheduler.jobs.runIfDue',
|
||||
],
|
||||
{ jobId },
|
||||
)
|
||||
|
||||
return json({ ok: true, payload })
|
||||
} catch (err) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import { gatewayCronRpc } from '@/server/cron'
|
||||
import { requireJsonContentType } from '../../../server/rate-limit'
|
||||
|
||||
export const Route = createFileRoute('/api/cron/run')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
try {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
||||
const jobId = typeof body.jobId === 'string' ? body.jobId.trim() : ''
|
||||
if (!jobId) {
|
||||
return json({ error: 'jobId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const payload = await gatewayCronRpc(
|
||||
['cron.run'],
|
||||
{
|
||||
jobId,
|
||||
},
|
||||
)
|
||||
|
||||
return json({ ok: true, payload })
|
||||
} catch (err) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '@/server/auth-middleware'
|
||||
import { gatewayCronRpc, normalizeCronRuns } from '@/server/cron'
|
||||
|
||||
export const Route = createFileRoute('/api/cron/runs/$jobId')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const limitRaw = Number(url.searchParams.get('limit') ?? '20')
|
||||
const limit = Number.isFinite(limitRaw)
|
||||
? Math.max(1, Math.min(100, Math.round(limitRaw)))
|
||||
: 20
|
||||
|
||||
const segments = url.pathname.split('/')
|
||||
const maybeJobId = decodeURIComponent(
|
||||
segments[segments.length - 1] ?? '',
|
||||
)
|
||||
const jobId = maybeJobId.trim()
|
||||
if (!jobId) {
|
||||
return json({ error: 'jobId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const payload = await gatewayCronRpc(
|
||||
['cron.runs', 'cron.jobs.runs', 'scheduler.runs'],
|
||||
{
|
||||
jobId,
|
||||
limit,
|
||||
},
|
||||
)
|
||||
|
||||
return json({
|
||||
runs: normalizeCronRuns(payload).slice(0, limit),
|
||||
})
|
||||
} catch (err) {
|
||||
return json(
|
||||
{
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import { gatewayCronRpc, normalizeCronBool } from '@/server/cron'
|
||||
import { requireJsonContentType } from '../../../server/rate-limit'
|
||||
|
||||
export const Route = createFileRoute('/api/cron/toggle')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
try {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
||||
const jobId = typeof body.jobId === 'string' ? body.jobId.trim() : ''
|
||||
if (!jobId) {
|
||||
return json({ error: 'jobId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const enabled = normalizeCronBool(body.enabled, true)
|
||||
|
||||
const payload = await gatewayCronRpc(
|
||||
['cron.update'],
|
||||
{
|
||||
jobId,
|
||||
patch: { enabled },
|
||||
},
|
||||
)
|
||||
|
||||
return json({ ok: true, payload, enabled })
|
||||
} catch (err) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,164 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import { gatewayCronRpc, normalizeCronBool } from '@/server/cron'
|
||||
import { requireJsonContentType } from '../../../server/rate-limit'
|
||||
|
||||
function readString(value: unknown): string {
|
||||
if (typeof value !== 'string') return ''
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {}
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function resolvePayloadJobId(payload: unknown): string | undefined {
|
||||
const row = asRecord(payload)
|
||||
const candidates = [row.jobId, row.id, row.key]
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
||||
return candidate.trim()
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function buildSchedulePayload(schedule: string) {
|
||||
const trimmed = schedule.trim()
|
||||
const everyMatch = trimmed.match(
|
||||
/^every\s+(\d+)\s*(m|min|minute|minutes|h|hr|hour|hours|d|day|days)?$/i,
|
||||
)
|
||||
if (everyMatch?.[1]) {
|
||||
const unitToken = (everyMatch[2] ?? 'm').toLowerCase()
|
||||
const unit =
|
||||
unitToken.startsWith('h')
|
||||
? 'hours'
|
||||
: unitToken.startsWith('d')
|
||||
? 'days'
|
||||
: 'minutes'
|
||||
return {
|
||||
kind: 'every',
|
||||
interval: Number(everyMatch[1]),
|
||||
unit,
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('at ')) {
|
||||
return {
|
||||
kind: 'at',
|
||||
at: trimmed.slice(3).trim(),
|
||||
}
|
||||
}
|
||||
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) {
|
||||
return {
|
||||
kind: 'at',
|
||||
at: trimmed,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'cron',
|
||||
expr: trimmed,
|
||||
}
|
||||
}
|
||||
|
||||
function buildUpsertParams(
|
||||
body: Record<string, unknown>,
|
||||
jobId: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const name = readString(body.name)
|
||||
const schedule = readString(body.schedule)
|
||||
const payload = body.payload
|
||||
const deliveryConfig = body.deliveryConfig
|
||||
|
||||
if (!jobId) {
|
||||
// cron.add format
|
||||
return {
|
||||
name,
|
||||
schedule: buildSchedulePayload(schedule),
|
||||
payload: payload || { kind: 'systemEvent', text: name },
|
||||
delivery: deliveryConfig || undefined,
|
||||
sessionTarget: 'main',
|
||||
enabled,
|
||||
}
|
||||
}
|
||||
|
||||
// cron.update format
|
||||
return {
|
||||
jobId,
|
||||
patch: {
|
||||
name,
|
||||
schedule: buildSchedulePayload(schedule),
|
||||
payload: payload || undefined,
|
||||
delivery: deliveryConfig || undefined,
|
||||
enabled,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/api/cron/upsert')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
try {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
||||
const jobId = readString(body.jobId || body.id || body.key)
|
||||
const name = readString(body.name)
|
||||
const schedule = readString(
|
||||
body.schedule || body.cron || body.expression,
|
||||
)
|
||||
|
||||
if (!name) {
|
||||
return json({ error: 'name is required' }, { status: 400 })
|
||||
}
|
||||
if (!schedule) {
|
||||
return json({ error: 'schedule is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const enabled = normalizeCronBool(body.enabled, true)
|
||||
|
||||
const methods = jobId
|
||||
? ['cron.update']
|
||||
: ['cron.add']
|
||||
|
||||
const payload = await gatewayCronRpc(
|
||||
methods,
|
||||
buildUpsertParams(body, jobId, enabled),
|
||||
)
|
||||
const resolvedJobId =
|
||||
resolvePayloadJobId(payload) ?? (jobId || undefined)
|
||||
|
||||
return json({
|
||||
ok: true,
|
||||
payload,
|
||||
jobId: resolvedJobId,
|
||||
})
|
||||
} catch (err) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../server/auth-middleware'
|
||||
import { analyzeError, readGatewayLogs } from '../../server/debug-analyzer'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../server/rate-limit'
|
||||
|
||||
export const Route = createFileRoute('/api/debug-analyze')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`debug:${ip}`, 10, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (await request.json().catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
const terminalOutput =
|
||||
typeof body.terminalOutput === 'string' ? body.terminalOutput : ''
|
||||
|
||||
const logContent = await readGatewayLogs()
|
||||
const analysis = await analyzeError(terminalOutput, logContent)
|
||||
return json(analysis)
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error(
|
||||
'[/api/debug-analyze] Error:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
)
|
||||
return json(
|
||||
{
|
||||
summary: 'Debug analysis request failed.',
|
||||
rootCause: safeErrorMessage(error),
|
||||
suggestedCommands: [],
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import {
|
||||
getActivityStreamDiagnostics,
|
||||
reconnectActivityStream,
|
||||
sanitizeText,
|
||||
} from '../../../server/activity-stream'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import { requireJsonContentType } from '../../../server/rate-limit'
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return sanitizeText(error.message)
|
||||
return sanitizeText(String(error))
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/api/debug/reconnect')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
try {
|
||||
await reconnectActivityStream()
|
||||
return json({
|
||||
ok: true,
|
||||
state: getActivityStreamDiagnostics().status,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
state: getActivityStreamDiagnostics().status,
|
||||
error: toErrorMessage(error),
|
||||
},
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import {
|
||||
ensureActivityStreamStarted,
|
||||
getActivityStreamDiagnostics,
|
||||
sanitizeText,
|
||||
} from '../../../server/activity-stream'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
|
||||
const DEFAULT_GATEWAY_URL = 'ws://127.0.0.1:18789'
|
||||
|
||||
function readGatewayUrl(): string {
|
||||
return process.env.CLAWDBOT_GATEWAY_URL?.trim() || DEFAULT_GATEWAY_URL
|
||||
}
|
||||
|
||||
function stripAuthorityAuth(value: string): string {
|
||||
if (!value.includes('@')) return value
|
||||
const parts = value.split('@')
|
||||
const lastPart = parts[parts.length - 1]
|
||||
return lastPart || value
|
||||
}
|
||||
|
||||
function maskGatewayUrl(rawUrl: string): string {
|
||||
const sanitizedUrl = sanitizeText(rawUrl)
|
||||
|
||||
try {
|
||||
const parsed = new URL(sanitizedUrl)
|
||||
const safeHost = stripAuthorityAuth(parsed.host)
|
||||
return `${parsed.protocol}//${safeHost}`
|
||||
} catch {
|
||||
const hostMatch = sanitizedUrl.match(/^([a-z]+:\/\/)?([^/]+)/i)
|
||||
if (!hostMatch) return 'Unavailable'
|
||||
const protocol = hostMatch[1] || ''
|
||||
const authority = hostMatch[2] || ''
|
||||
const safeAuthority = stripAuthorityAuth(authority)
|
||||
return `${protocol}${safeAuthority}` || 'Unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/api/debug/status')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
void ensureActivityStreamStarted().catch(function ignoreStartError() {
|
||||
// endpoint still returns diagnostics while disconnected
|
||||
})
|
||||
|
||||
const diagnostics = getActivityStreamDiagnostics()
|
||||
return json({
|
||||
state: diagnostics.status,
|
||||
gatewayUrl: maskGatewayUrl(readGatewayUrl()),
|
||||
connectedSinceMs: diagnostics.connectedSinceMs,
|
||||
lastDisconnectedAtMs: diagnostics.lastDisconnectedAtMs,
|
||||
nowMs: Date.now(),
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,94 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import {
|
||||
ensureActivityStreamStarted,
|
||||
getActivityStreamStatus,
|
||||
} from '../../server/activity-stream'
|
||||
import { offEvent, onEvent } from '../../server/activity-events'
|
||||
import type { ActivityEvent } from '../../types/activity-event'
|
||||
import { isAuthenticated } from '@/server/auth-middleware'
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 20_000
|
||||
|
||||
export const Route = createFileRoute('/api/events')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return new Response(JSON.stringify({ ok: false, error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
void ensureActivityStreamStarted().catch(function ignoreStartError() {
|
||||
// stream stays available even when gateway is offline
|
||||
})
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
let cleanupStream = function noCleanupYet() {}
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
let closed = false
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function sendEvent(eventName: string, payload: unknown) {
|
||||
if (closed) return
|
||||
const chunk = `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`
|
||||
controller.enqueue(encoder.encode(chunk))
|
||||
}
|
||||
|
||||
const listener = function onActivityEvent(event: ActivityEvent) {
|
||||
sendEvent('activity', event)
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer)
|
||||
heartbeatTimer = null
|
||||
}
|
||||
offEvent(listener)
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// stream already closed
|
||||
}
|
||||
}
|
||||
|
||||
function onAbort() {
|
||||
cleanup()
|
||||
}
|
||||
|
||||
onEvent(listener)
|
||||
sendEvent('ready', {
|
||||
connected: getActivityStreamStatus() === 'connected',
|
||||
})
|
||||
|
||||
heartbeatTimer = setInterval(function sendHeartbeat() {
|
||||
if (closed) return
|
||||
controller.enqueue(encoder.encode(': keep-alive\n\n'))
|
||||
}, HEARTBEAT_INTERVAL_MS)
|
||||
|
||||
request.signal.addEventListener('abort', onAbort, { once: true })
|
||||
cleanupStream = cleanup
|
||||
},
|
||||
cancel() {
|
||||
cleanupStream()
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,42 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import { getRecentEvents } from '../../../server/activity-events'
|
||||
import {
|
||||
ensureActivityStreamStarted,
|
||||
getActivityStreamStatus,
|
||||
} from '../../../server/activity-stream'
|
||||
|
||||
const DEFAULT_RECENT_COUNT = 50
|
||||
const MAX_RECENT_COUNT = 100
|
||||
|
||||
export const Route = createFileRoute('/api/events/recent')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
void ensureActivityStreamStarted().catch(function ignoreStartError() {
|
||||
// recent endpoint still returns buffered data while disconnected
|
||||
})
|
||||
|
||||
const url = new URL(request.url)
|
||||
const countParam = Number.parseInt(
|
||||
url.searchParams.get('count') ?? '',
|
||||
10,
|
||||
)
|
||||
|
||||
const count = Number.isFinite(countParam)
|
||||
? Math.max(1, Math.min(MAX_RECENT_COUNT, countParam))
|
||||
: DEFAULT_RECENT_COUNT
|
||||
|
||||
return json({
|
||||
events: getRecentEvents(count),
|
||||
connected: getActivityStreamStatus() === 'connected',
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -21,7 +21,7 @@ const execFileAsync = promisify(execFile)
|
||||
|
||||
const WORKSPACE_ROOT = (
|
||||
process.env.HERMES_WORKSPACE_DIR ||
|
||||
process.env.OPENCLAW_WORKSPACE_DIR ||
|
||||
process.env.HERMES_WORKSPACE_DIR || process.env.OPENCLAW_WORKSPACE_DIR ||
|
||||
path.join(os.homedir(), '.hermes')
|
||||
).trim()
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '@/server/auth-middleware'
|
||||
|
||||
export const Route = createFileRoute('/api/gateway/channels')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
return json({ ok: true, data: { channels: [], statuses: [] } })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '@/server/auth-middleware'
|
||||
|
||||
export const Route = createFileRoute('/api/gateway/nodes')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
return json({ ok: true, data: { nodes: [] } })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '@/server/auth-middleware'
|
||||
|
||||
export const Route = createFileRoute('/api/gateway/sessions')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
return json({ ok: true, data: { sessions: [] } })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
|
||||
export const Route = createFileRoute('/api/gateway/status')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
return json({ connected: false, ok: true })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '@/server/auth-middleware'
|
||||
|
||||
export const Route = createFileRoute('/api/gateway/usage')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
return json({ ok: true, data: { usage: null, cost: null } })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,317 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { access, readFile } from 'node:fs/promises'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import net from 'node:net'
|
||||
import { requireLocalOrAuth } from '@/server/auth-middleware'
|
||||
|
||||
const DEFAULT_GATEWAY_PORT = 18789
|
||||
const DEFAULT_GATEWAY_URL = `ws://127.0.0.1:${DEFAULT_GATEWAY_PORT}`
|
||||
const HEARTBEAT_INTERVAL_MS = 20_000
|
||||
const GATEWAY_START_TIMEOUT_MS = 45_000
|
||||
const TOKEN_WAIT_TIMEOUT_MS = 20_000
|
||||
const POLL_INTERVAL_MS = 750
|
||||
|
||||
type SetupStatus = 'checking' | 'installing' | 'starting' | 'ready' | 'error'
|
||||
|
||||
type SetupEvent = {
|
||||
status: SetupStatus
|
||||
message: string
|
||||
url?: string
|
||||
token?: string
|
||||
}
|
||||
|
||||
type HermesConfig = {
|
||||
gateway?: {
|
||||
port?: number
|
||||
auth?: {
|
||||
token?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function formatCommandError(command: string, error: unknown, fallback: string) {
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return `${fallback} (${command}: ${error.message.trim()})`
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
async function runCommand(command: string, args: string[], timeoutMs: number) {
|
||||
return await new Promise<{ stdout: string; stderr: string; code: number | null }>(
|
||||
(resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let settled = false
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
child.kill('SIGTERM')
|
||||
reject(new Error(`${command} ${args.join(' ')} timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer | string) => {
|
||||
stdout += chunk.toString()
|
||||
})
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer | string) => {
|
||||
stderr += chunk.toString()
|
||||
})
|
||||
|
||||
child.on('error', (error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve({ stdout, stderr, code })
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function isHermesInstalled() {
|
||||
try {
|
||||
const whichResult = await runCommand('which', ['hermes'], 5_000)
|
||||
if (whichResult.code === 0 && whichResult.stdout.trim()) {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the version check.
|
||||
}
|
||||
|
||||
try {
|
||||
const versionResult = await runCommand('hermes', ['--version'], 5_000)
|
||||
return versionResult.code === 0 && Boolean(versionResult.stdout.trim())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function installHermes() {
|
||||
const result = await runCommand('pip', ['install', 'hermes-agent'], 10 * 60_000)
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.trim() || result.stdout.trim() || 'npm install failed')
|
||||
}
|
||||
}
|
||||
|
||||
function startGatewayDetached() {
|
||||
// Hermes Workspace still uses the openclaw CLI to boot the local gateway.
|
||||
const child = spawn('openclaw', ['gateway', 'start', '--bind', 'lan'], {
|
||||
detached: true,
|
||||
env: process.env,
|
||||
stdio: 'ignore',
|
||||
})
|
||||
child.unref()
|
||||
}
|
||||
|
||||
async function isGatewayRunning(port: number) {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const socket = net.createConnection({ host: '127.0.0.1', port }, () => {
|
||||
socket.destroy()
|
||||
resolve(true)
|
||||
})
|
||||
|
||||
socket.on('error', () => resolve(false))
|
||||
socket.setTimeout(1_500, () => {
|
||||
socket.destroy()
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function readGatewayConfig(): Promise<HermesConfig | null> {
|
||||
// Hermes Workspace reads the OpenClaw config because the gateway still writes there.
|
||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json')
|
||||
|
||||
try {
|
||||
await access(configPath)
|
||||
const raw = await readFile(configPath, 'utf8')
|
||||
return JSON.parse(raw) as HermesConfig
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForGatewayReady() {
|
||||
const deadline = Date.now() + GATEWAY_START_TIMEOUT_MS
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const config = await readGatewayConfig()
|
||||
const port = config?.gateway?.port || DEFAULT_GATEWAY_PORT
|
||||
if (await isGatewayRunning(port)) {
|
||||
return {
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
}
|
||||
}
|
||||
await wait(POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
throw new Error('Hermes gateway did not become reachable in time')
|
||||
}
|
||||
|
||||
async function waitForGatewayToken() {
|
||||
const deadline = Date.now() + TOKEN_WAIT_TIMEOUT_MS
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const config = await readGatewayConfig()
|
||||
const token = config?.gateway?.auth?.token?.trim()
|
||||
const port = config?.gateway?.port || DEFAULT_GATEWAY_PORT
|
||||
|
||||
if (token) {
|
||||
return {
|
||||
token,
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
}
|
||||
}
|
||||
|
||||
await wait(POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
throw new Error('Gateway auth token was not written to ~/.openclaw/openclaw.json')
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/api/local-setup')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!requireLocalOrAuth(request)) {
|
||||
return new Response(JSON.stringify({ ok: false, error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
let closed = false
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const emit = (event: SetupEvent) => {
|
||||
if (closed) return
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer)
|
||||
heartbeatTimer = null
|
||||
}
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// stream already closed
|
||||
}
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
cleanup()
|
||||
}
|
||||
|
||||
heartbeatTimer = setInterval(() => {
|
||||
if (closed) return
|
||||
controller.enqueue(encoder.encode(': keep-alive\n\n'))
|
||||
}, HEARTBEAT_INTERVAL_MS)
|
||||
|
||||
request.signal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
emit({
|
||||
status: 'checking',
|
||||
message: 'Checking for Hermes...',
|
||||
})
|
||||
|
||||
let installed = await isHermesInstalled()
|
||||
if (!installed) {
|
||||
emit({
|
||||
status: 'installing',
|
||||
message: 'Installing Hermes...',
|
||||
})
|
||||
await installHermes()
|
||||
installed = await isHermesInstalled()
|
||||
}
|
||||
|
||||
if (!installed) {
|
||||
throw new Error('Hermes is still unavailable after installation')
|
||||
}
|
||||
|
||||
const initialConfig = await readGatewayConfig()
|
||||
const initialPort = initialConfig?.gateway?.port || DEFAULT_GATEWAY_PORT
|
||||
|
||||
emit({
|
||||
status: 'starting',
|
||||
message: 'Starting gateway...',
|
||||
})
|
||||
|
||||
if (!(await isGatewayRunning(initialPort))) {
|
||||
try {
|
||||
startGatewayDetached()
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
formatCommandError(
|
||||
'hermes --web',
|
||||
error,
|
||||
'Failed to launch the Hermes gateway',
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const ready = await waitForGatewayReady()
|
||||
const tokenData = await waitForGatewayToken()
|
||||
|
||||
emit({
|
||||
status: 'ready',
|
||||
message: 'Connected!',
|
||||
url: tokenData.url || ready.url || DEFAULT_GATEWAY_URL,
|
||||
token: tokenData.token,
|
||||
})
|
||||
} catch (error) {
|
||||
emit({
|
||||
status: 'error',
|
||||
message:
|
||||
error instanceof Error ? error.message : 'Local Hermes setup failed',
|
||||
})
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})()
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,27 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../server/auth-middleware'
|
||||
import { requireJsonContentType } from '../../server/rate-limit'
|
||||
|
||||
export const Route = createFileRoute('/api/model-switch')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>
|
||||
const model = typeof body.model === 'string' ? body.model.trim() : ''
|
||||
return json({
|
||||
ok: true,
|
||||
resolved: {
|
||||
modelProvider: model.includes('/') ? model.split('/')[0] : 'hermes-agent',
|
||||
model: model.includes('/') ? model.split('/').slice(1).join('/') : model,
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,46 +0,0 @@
|
||||
import { networkInterfaces } from 'node:os'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../server/auth-middleware'
|
||||
|
||||
function getNetworkUrl(port: number): { url: string; source: 'tailscale' | 'lan' | 'localhost' } {
|
||||
const nets = networkInterfaces()
|
||||
let tailscaleIp: string | null = null
|
||||
let lanIp: string | null = null
|
||||
|
||||
for (const iface of Object.values(nets)) {
|
||||
if (!iface) continue
|
||||
for (const net of iface) {
|
||||
if (net.family !== 'IPv4' || net.internal) continue
|
||||
// Tailscale IPs are always in the 100.64.0.0/10 range
|
||||
if (net.address.startsWith('100.')) {
|
||||
tailscaleIp = net.address
|
||||
} else if (!lanIp) {
|
||||
lanIp = net.address
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tailscaleIp) {
|
||||
return { url: `http://${tailscaleIp}:${port}`, source: 'tailscale' }
|
||||
}
|
||||
if (lanIp) {
|
||||
return { url: `http://${lanIp}:${port}`, source: 'lan' }
|
||||
}
|
||||
return { url: `http://localhost:${port}`, source: 'localhost' }
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/api/network-url')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const port = parseInt(new URL(request.url).searchParams.get('port') ?? '3000', 10)
|
||||
const result = getNetworkUrl(Number.isFinite(port) ? port : 3000)
|
||||
return json({ ok: true, ...result })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '@/server/auth-middleware'
|
||||
|
||||
export const Route = createFileRoute('/api/ollama-health')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const targets = ['http://127.0.0.1:11434/api/tags', 'http://localhost:11434/api/tags']
|
||||
|
||||
for (const target of targets) {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 2500)
|
||||
try {
|
||||
const response = await fetch(target, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (response.ok) {
|
||||
return json({ ok: true, endpoint: target })
|
||||
}
|
||||
} catch {
|
||||
// try next endpoint
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
return json({ ok: false }, { status: 503 })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,28 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '@/server/auth-middleware'
|
||||
import { checkHealth } from '../../server/hermes-api'
|
||||
|
||||
export const Route = createFileRoute('/api/ping')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
try {
|
||||
await checkHealth()
|
||||
return json({ ok: true })
|
||||
} catch (err) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,61 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { getProviderUsage } from '../../server/provider-usage'
|
||||
import { isAuthenticated } from '../../server/auth-middleware'
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 5000 // 5 second timeout
|
||||
|
||||
async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
ms: number,
|
||||
timeoutMessage: string,
|
||||
): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout>
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), ms)
|
||||
})
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise])
|
||||
} finally {
|
||||
clearTimeout(timeoutId!)
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
ProviderUsageResult,
|
||||
ProviderUsageResponse,
|
||||
UsageLine,
|
||||
} from '../../server/provider-usage'
|
||||
|
||||
export const Route = createFileRoute('/api/provider-usage')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const force = url.searchParams.get('force') === '1'
|
||||
const payload = await withTimeout(
|
||||
getProviderUsage(force),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
'Provider usage request timed out',
|
||||
)
|
||||
return json(payload)
|
||||
} catch (err) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
updatedAt: Date.now(),
|
||||
providers: [],
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../server/auth-middleware'
|
||||
import { requireJsonContentType } from '../../server/rate-limit'
|
||||
|
||||
export const Route = createFileRoute('/api/session-title')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
return json(
|
||||
{ ok: false, error: 'Session title generation is not available in Hermes Workspace.' },
|
||||
{ status: 501 },
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,354 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import WebSocket from 'ws'
|
||||
import { isAuthenticated } from '../../server/auth-middleware'
|
||||
import { requireJsonContentType } from '../../server/rate-limit'
|
||||
|
||||
function getGatewayMessage(message: string, attachments?: Array<unknown>): string {
|
||||
if (message.trim().length > 0) return message
|
||||
if (attachments && attachments.length > 0) {
|
||||
return 'Please review the attached content.'
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
type GatewayFrame =
|
||||
| { type: 'req'; id: string; method: string; params?: unknown }
|
||||
| {
|
||||
type: 'res'
|
||||
id: string
|
||||
ok: boolean
|
||||
payload?: unknown
|
||||
error?: { code: string; message: string; details?: unknown }
|
||||
}
|
||||
| { type: 'event'; event: string; payload?: unknown; seq?: number }
|
||||
|
||||
type ConnectParams = {
|
||||
minProtocol: number
|
||||
maxProtocol: number
|
||||
client: {
|
||||
id: string
|
||||
displayName?: string
|
||||
version: string
|
||||
platform: string
|
||||
mode: string
|
||||
instanceId?: string
|
||||
}
|
||||
auth?: { token?: string; password?: string }
|
||||
role?: 'operator' | 'node'
|
||||
scopes?: Array<string>
|
||||
}
|
||||
|
||||
function getGatewayConfig() {
|
||||
const url = process.env.HERMES_GATEWAY_URL?.trim() || 'ws://127.0.0.1:18789'
|
||||
const token = process.env.HERMES_GATEWAY_TOKEN?.trim() || ''
|
||||
const password = process.env.HERMES_GATEWAY_PASSWORD?.trim() || ''
|
||||
|
||||
if (!token && !password) {
|
||||
throw new Error(
|
||||
'Missing gateway auth. Set HERMES_GATEWAY_TOKEN (recommended) or HERMES_GATEWAY_PASSWORD in the server environment.',
|
||||
)
|
||||
}
|
||||
|
||||
return { url, token, password }
|
||||
}
|
||||
|
||||
function buildConnectParams(token: string, password: string): ConnectParams {
|
||||
return {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: 'gateway-client-stream',
|
||||
displayName: 'hermes-stream',
|
||||
version: 'dev',
|
||||
platform: process.platform,
|
||||
mode: 'ui',
|
||||
instanceId: randomUUID(),
|
||||
},
|
||||
auth: {
|
||||
token: token || undefined,
|
||||
password: password || undefined,
|
||||
},
|
||||
role: 'operator',
|
||||
scopes: ['operator.admin'],
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/api/stream')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
// Auth check
|
||||
if (!isAuthenticated(request)) {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: false, error: 'Unauthorized' }),
|
||||
{ status: 401, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
try {
|
||||
const body = (await request.json().catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
||||
const sessionKey =
|
||||
typeof body.sessionKey === 'string' ? body.sessionKey.trim() : ''
|
||||
const friendlyId =
|
||||
typeof body.friendlyId === 'string' ? body.friendlyId.trim() : ''
|
||||
const message = String(body.message ?? '')
|
||||
const thinking =
|
||||
typeof body.thinking === 'string' ? body.thinking : undefined
|
||||
const attachments = Array.isArray(body.attachments)
|
||||
? body.attachments
|
||||
: undefined
|
||||
|
||||
if (!message.trim() && (!attachments || attachments.length === 0)) {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: false, error: 'message required' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const { url, token, password } = getGatewayConfig()
|
||||
|
||||
// Create SSE response stream
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const ws = new WebSocket(url)
|
||||
let connected = false
|
||||
let runId: string | null = null
|
||||
|
||||
const sendSSE = (event: string, data: unknown) => {
|
||||
const chunk = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
||||
controller.enqueue(encoder.encode(chunk))
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
if (
|
||||
ws.readyState === ws.OPEN ||
|
||||
ws.readyState === ws.CONNECTING
|
||||
) {
|
||||
ws.close()
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
ws.on('open', async () => {
|
||||
try {
|
||||
// Send connect handshake
|
||||
const connectId = randomUUID()
|
||||
const connectParams = buildConnectParams(token, password)
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'req',
|
||||
id: connectId,
|
||||
method: 'connect',
|
||||
params: connectParams,
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
sendSSE('error', { message: 'Failed to connect to gateway' })
|
||||
cleanup()
|
||||
controller.close()
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('message', (data: unknown) => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(data)) as GatewayFrame
|
||||
|
||||
if (parsed.type === 'res') {
|
||||
if (!connected) {
|
||||
// Connect response
|
||||
if (parsed.ok) {
|
||||
connected = true
|
||||
// Now send the chat message
|
||||
const chatId = randomUUID()
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'req',
|
||||
id: chatId,
|
||||
method: 'chat.send',
|
||||
params: {
|
||||
sessionKey: sessionKey || 'main',
|
||||
friendlyId: friendlyId || undefined,
|
||||
message: getGatewayMessage(message, attachments),
|
||||
thinking,
|
||||
attachments,
|
||||
deliver: false,
|
||||
stream: true, // Request streaming if supported
|
||||
timeoutMs: 120_000,
|
||||
idempotencyKey: randomUUID(),
|
||||
},
|
||||
}),
|
||||
)
|
||||
sendSSE('connected', { sessionKey, friendlyId })
|
||||
} else {
|
||||
sendSSE('error', {
|
||||
message: parsed.error?.message ?? 'Connection failed',
|
||||
})
|
||||
cleanup()
|
||||
controller.close()
|
||||
}
|
||||
} else {
|
||||
// Chat response
|
||||
if (parsed.ok) {
|
||||
const payload = parsed.payload as {
|
||||
runId?: string
|
||||
text?: string
|
||||
content?: Array<{ type: string; text?: string }>
|
||||
message?: {
|
||||
content?: Array<{ type: string; text?: string }>
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime safety
|
||||
runId = payload?.runId ?? null
|
||||
sendSSE('started', { runId })
|
||||
|
||||
// If the response includes the full message text, emit it as a complete event
|
||||
// This handles gateways that don't support streaming
|
||||
let responseText = ''
|
||||
if (typeof payload.text === 'string') {
|
||||
responseText = payload.text
|
||||
} else if (Array.isArray(payload.content)) {
|
||||
responseText = payload.content
|
||||
.filter((c) => c.type === 'text' && c.text)
|
||||
.map((c) => c.text)
|
||||
.join('')
|
||||
} else if (payload.message?.content) {
|
||||
responseText = payload.message.content
|
||||
.filter((c) => c.type === 'text' && c.text)
|
||||
.map((c) => c.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
if (responseText) {
|
||||
// Emit the full response as chunks for a typing effect
|
||||
// Split by words for word-by-word streaming simulation
|
||||
const words = responseText.split(/(\s+)/)
|
||||
let accumulated = ''
|
||||
for (const word of words) {
|
||||
accumulated += word
|
||||
sendSSE('chunk', { delta: word, text: accumulated })
|
||||
}
|
||||
sendSSE('complete', { text: responseText })
|
||||
cleanup()
|
||||
controller.close()
|
||||
}
|
||||
} else {
|
||||
sendSSE('error', {
|
||||
message: parsed.error?.message ?? 'Chat send failed',
|
||||
})
|
||||
cleanup()
|
||||
controller.close()
|
||||
}
|
||||
}
|
||||
} else if (parsed.type === 'event') {
|
||||
// Forward streaming events
|
||||
const eventName = parsed.event
|
||||
const payload = parsed.payload as Record<string, unknown>
|
||||
|
||||
if (
|
||||
eventName === 'chat.chunk' ||
|
||||
eventName === 'chat.delta' ||
|
||||
eventName === 'message.delta'
|
||||
) {
|
||||
// Streaming text chunk
|
||||
sendSSE('chunk', payload)
|
||||
} else if (
|
||||
eventName === 'chat.complete' ||
|
||||
eventName === 'chat.done' ||
|
||||
eventName === 'message.complete'
|
||||
) {
|
||||
// Stream complete
|
||||
sendSSE('complete', payload)
|
||||
cleanup()
|
||||
controller.close()
|
||||
} else if (
|
||||
eventName === 'chat.error' ||
|
||||
eventName === 'message.error'
|
||||
) {
|
||||
sendSSE('error', payload)
|
||||
cleanup()
|
||||
controller.close()
|
||||
} else if (eventName === 'chat.thinking') {
|
||||
// Thinking/reasoning updates
|
||||
sendSSE('thinking', payload)
|
||||
} else if (eventName === 'chat.tool') {
|
||||
// Tool call updates
|
||||
sendSSE('tool', payload)
|
||||
} else {
|
||||
// Forward other events
|
||||
sendSSE('event', { event: eventName, ...payload })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('error', (err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
sendSSE('error', { message })
|
||||
cleanup()
|
||||
controller.close()
|
||||
})
|
||||
|
||||
ws.on('close', () => {
|
||||
sendSSE('close', {})
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// Already closed
|
||||
}
|
||||
})
|
||||
|
||||
// Set a timeout to close the connection if no response
|
||||
setTimeout(() => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
sendSSE('timeout', { message: 'Request timed out' })
|
||||
cleanup()
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// Already closed
|
||||
}
|
||||
}
|
||||
}, 125_000)
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import os from 'node:os'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
|
||||
export const Route = createFileRoute('/api/system-metrics')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async () => {
|
||||
const totalMem = os.totalmem()
|
||||
const freeMem = os.freemem()
|
||||
return json({
|
||||
cpu: 0,
|
||||
ramUsed: totalMem - freeMem,
|
||||
ramTotal: totalMem,
|
||||
diskPercent: 0,
|
||||
uptime: os.uptime(),
|
||||
gatewayConnected: false,
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,133 +0,0 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { z } from 'zod'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
|
||||
// Use workspace-level tasks file so all Hermes agents share one source of truth
|
||||
const WORKSPACE_ROOT =
|
||||
process.env.HERMES_WORKSPACE || process.env.OPENCLAW_WORKSPACE || ''
|
||||
|
||||
const TASKS_FILE =
|
||||
WORKSPACE_ROOT
|
||||
? path.join(WORKSPACE_ROOT, 'data', 'tasks.json')
|
||||
: path.join(process.cwd(), '..', 'data', 'tasks.json')
|
||||
|
||||
async function readTasks(): Promise<Record<string, unknown>[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(TASKS_FILE, 'utf-8')
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function writeTasks(tasks: unknown[]): Promise<void> {
|
||||
await fs.mkdir(path.dirname(TASKS_FILE), { recursive: true })
|
||||
await fs.writeFile(TASKS_FILE, JSON.stringify(tasks, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
function extractTaskId(request: Request): string {
|
||||
const url = new URL(request.url)
|
||||
const segments = url.pathname.split('/')
|
||||
return decodeURIComponent(segments[segments.length - 1] ?? '').trim()
|
||||
}
|
||||
|
||||
const UpdateTaskSchema = z.object({
|
||||
title: z.string().trim().min(1).max(500).optional(),
|
||||
description: z.string().max(5000).optional(),
|
||||
status: z.enum(['backlog', 'in_progress', 'review', 'done']).optional(),
|
||||
priority: z.enum(['P0', 'P1', 'P2', 'P3']).optional(),
|
||||
project: z.string().max(100).optional().nullable(),
|
||||
tags: z.array(z.string().max(50)).max(20).optional(),
|
||||
dueDate: z.string().max(30).optional().nullable(),
|
||||
reminder: z.string().max(30).optional().nullable(),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/api/tasks/$taskId')({
|
||||
server: {
|
||||
handlers: {
|
||||
PATCH: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const csrfCheckPatch = requireJsonContentType(request)
|
||||
if (csrfCheckPatch) return csrfCheckPatch
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`tasks-patch:${ip}`, 30, 60_000))
|
||||
return rateLimitResponse()
|
||||
|
||||
try {
|
||||
const taskId = extractTaskId(request)
|
||||
if (!taskId)
|
||||
return json({ error: 'taskId is required' }, { status: 400 })
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const parsed = UpdateTaskSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return json(
|
||||
{
|
||||
error: 'Validation failed',
|
||||
details: parsed.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const tasks = await readTasks()
|
||||
const idx = tasks.findIndex((t) => t.id === taskId)
|
||||
if (idx === -1)
|
||||
return json({ error: 'Task not found' }, { status: 404 })
|
||||
|
||||
const updated = {
|
||||
...tasks[idx],
|
||||
...parsed.data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
tasks[idx] = updated
|
||||
await writeTasks(tasks)
|
||||
|
||||
return json({ task: updated })
|
||||
} catch (err) {
|
||||
return json({ error: safeErrorMessage(err) }, { status: 500 })
|
||||
}
|
||||
},
|
||||
|
||||
DELETE: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`tasks-delete:${ip}`, 30, 60_000))
|
||||
return rateLimitResponse()
|
||||
|
||||
try {
|
||||
const taskId = extractTaskId(request)
|
||||
if (!taskId)
|
||||
return json({ error: 'taskId is required' }, { status: 400 })
|
||||
|
||||
const tasks = await readTasks()
|
||||
const filtered = tasks.filter((t) => t.id !== taskId)
|
||||
if (filtered.length === tasks.length)
|
||||
return json({ error: 'Task not found' }, { status: 404 })
|
||||
|
||||
await writeTasks(filtered)
|
||||
return json({ ok: true })
|
||||
} catch (err) {
|
||||
return json({ error: safeErrorMessage(err) }, { status: 500 })
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,111 +0,0 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { z } from 'zod'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
|
||||
// Use workspace-level tasks file so all Hermes agents share one source of truth
|
||||
const WORKSPACE_ROOT =
|
||||
process.env.HERMES_WORKSPACE || process.env.OPENCLAW_WORKSPACE || ''
|
||||
|
||||
const TASKS_FILE =
|
||||
WORKSPACE_ROOT
|
||||
? path.join(WORKSPACE_ROOT, 'data', 'tasks.json')
|
||||
: path.join(process.cwd(), '..', 'data', 'tasks.json')
|
||||
|
||||
async function readTasks(): Promise<unknown[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(TASKS_FILE, 'utf-8')
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function writeTasks(tasks: unknown[]): Promise<void> {
|
||||
await fs.mkdir(path.dirname(TASKS_FILE), { recursive: true })
|
||||
await fs.writeFile(TASKS_FILE, JSON.stringify(tasks, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
const CreateTaskSchema = z.object({
|
||||
title: z.string().trim().min(1).max(500),
|
||||
description: z.string().max(5000).default(''),
|
||||
status: z
|
||||
.enum(['backlog', 'in_progress', 'review', 'done'])
|
||||
.default('backlog'),
|
||||
priority: z.enum(['P0', 'P1', 'P2', 'P3']).default('P1'),
|
||||
project: z.string().max(100).optional(),
|
||||
tags: z.array(z.string().max(50)).max(20).default([]),
|
||||
dueDate: z.string().max(30).optional(),
|
||||
reminder: z.string().max(30).optional(),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/api/tasks/')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`tasks-get:${ip}`, 60, 60_000))
|
||||
return rateLimitResponse()
|
||||
|
||||
try {
|
||||
const tasks = await readTasks()
|
||||
return json({ tasks })
|
||||
} catch (err) {
|
||||
return json({ error: safeErrorMessage(err) }, { status: 500 })
|
||||
}
|
||||
},
|
||||
|
||||
POST: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`tasks-post:${ip}`, 30, 60_000))
|
||||
return rateLimitResponse()
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const parsed = CreateTaskSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return json(
|
||||
{
|
||||
error: 'Validation failed',
|
||||
details: parsed.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const task = {
|
||||
id: `TASK-${Date.now().toString(36).toUpperCase()}`,
|
||||
...parsed.data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
const tasks = await readTasks()
|
||||
tasks.unshift(task)
|
||||
await writeTasks(tasks)
|
||||
|
||||
return json({ task }, { status: 201 })
|
||||
} catch (err) {
|
||||
return json({ error: safeErrorMessage(err) }, { status: 500 })
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,272 +0,0 @@
|
||||
import { execSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import {
|
||||
isAuthenticated,
|
||||
requireLocalOrAuth,
|
||||
} from '../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
} from '../../server/rate-limit'
|
||||
|
||||
/**
|
||||
* Checks if the local repo is behind the remote.
|
||||
* Returns local commit, remote commit, and whether an update is available.
|
||||
*/
|
||||
|
||||
const CHECK_COOLDOWN_MS = 5 * 60 * 1000 // 5 min cache
|
||||
let lastCheck: { at: number; result: UpdateCheckResult } | null = null
|
||||
|
||||
type CommitEntry = {
|
||||
hash: string
|
||||
subject: string
|
||||
date: string
|
||||
}
|
||||
|
||||
type UpdateCheckResult = {
|
||||
updateAvailable: boolean
|
||||
localVersion: string
|
||||
remoteVersion: string
|
||||
localCommit: string
|
||||
remoteCommit: string
|
||||
localDate: string
|
||||
remoteDate: string
|
||||
behindBy: number
|
||||
repoPath: string
|
||||
changelog: Array<CommitEntry>
|
||||
}
|
||||
|
||||
let gitAvailable: boolean | null = null
|
||||
|
||||
function isGitAvailable(): boolean {
|
||||
if (gitAvailable !== null) return gitAvailable
|
||||
try {
|
||||
execSync('git --version', { timeout: 5_000, stdio: 'pipe' })
|
||||
gitAvailable = true
|
||||
} catch {
|
||||
gitAvailable = false
|
||||
}
|
||||
return gitAvailable
|
||||
}
|
||||
|
||||
function runGit(args: string, cwd: string): string {
|
||||
if (!isGitAvailable()) return ''
|
||||
try {
|
||||
return execSync(`git ${args}`, {
|
||||
cwd,
|
||||
timeout: 15_000,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function readPackageVersion(repoPath: string): string {
|
||||
try {
|
||||
const pkg = require(path.join(repoPath, 'package.json')) as {
|
||||
version?: string
|
||||
}
|
||||
return pkg.version || '0.0.0'
|
||||
} catch {
|
||||
return '0.0.0'
|
||||
}
|
||||
}
|
||||
|
||||
function buildVersionLabel(baseVersion: string, commit: string): string {
|
||||
return `${baseVersion} (${commit})`
|
||||
}
|
||||
|
||||
function detectRemote(repoPath: string): string {
|
||||
// Prefer 'production' or 'studio' remote (Eric's fork), fall back to 'origin'
|
||||
const remotes = runGit('remote', repoPath)
|
||||
.split('\n')
|
||||
.map((r) => r.trim())
|
||||
.filter(Boolean)
|
||||
if (remotes.includes('production')) return 'production'
|
||||
if (remotes.includes('studio')) return 'studio'
|
||||
return 'origin'
|
||||
}
|
||||
|
||||
function isGitRepo(dir: string): boolean {
|
||||
try {
|
||||
const fs = require('node:fs')
|
||||
return fs.existsSync(path.join(dir, '.git'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function checkForUpdates(): UpdateCheckResult {
|
||||
const repoPath = path.resolve(process.cwd())
|
||||
const pkgVersion = readPackageVersion(repoPath)
|
||||
|
||||
// Non-git installs (packaged .dmg/.exe) — no update check possible
|
||||
if (!isGitAvailable() || !isGitRepo(repoPath)) {
|
||||
return {
|
||||
updateAvailable: false,
|
||||
localVersion: pkgVersion,
|
||||
remoteVersion: pkgVersion,
|
||||
localCommit: 'packaged',
|
||||
remoteCommit: 'packaged',
|
||||
localDate: '',
|
||||
remoteDate: '',
|
||||
behindBy: 0,
|
||||
repoPath,
|
||||
changelog: [],
|
||||
}
|
||||
}
|
||||
|
||||
const remote = detectRemote(repoPath)
|
||||
|
||||
// Fetch latest from remote (quiet, won't fail if offline)
|
||||
runGit(`fetch ${remote} --quiet`, repoPath)
|
||||
|
||||
const currentBranch =
|
||||
runGit('rev-parse --abbrev-ref HEAD', repoPath) || 'main'
|
||||
const localCommit = runGit('rev-parse --short HEAD', repoPath)
|
||||
const localDate = runGit('log -1 --format=%ci', repoPath)
|
||||
|
||||
const remoteRef = `${remote}/${currentBranch}`
|
||||
const remoteCommit = runGit(`rev-parse --short ${remoteRef}`, repoPath)
|
||||
const remoteDate = runGit(`log -1 --format=%ci ${remoteRef}`, repoPath)
|
||||
|
||||
// Count commits behind
|
||||
const behindCount = runGit(`rev-list --count HEAD..${remoteRef}`, repoPath)
|
||||
const behindBy = parseInt(behindCount, 10) || 0
|
||||
|
||||
// Build version labels
|
||||
const localVersion = buildVersionLabel(pkgVersion, localCommit)
|
||||
const remoteVersion =
|
||||
behindBy > 0 ? buildVersionLabel(pkgVersion, remoteCommit) : localVersion
|
||||
|
||||
// Get changelog (up to 20 commits)
|
||||
const changelog: Array<CommitEntry> = []
|
||||
if (behindBy > 0) {
|
||||
const logOutput = runGit(
|
||||
`log HEAD..${remoteRef} --format=%h||%s||%ci -n 20`,
|
||||
repoPath,
|
||||
)
|
||||
for (const line of logOutput.split('\n')) {
|
||||
if (!line.trim()) continue
|
||||
const [hash, subject, date] = line.split('||')
|
||||
if (hash && subject) {
|
||||
changelog.push({
|
||||
hash: hash.trim(),
|
||||
subject: subject.trim(),
|
||||
date: (date || '').trim(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updateAvailable: behindBy > 0,
|
||||
localVersion,
|
||||
remoteVersion,
|
||||
localCommit,
|
||||
remoteCommit: remoteCommit || localCommit,
|
||||
localDate,
|
||||
remoteDate: remoteDate || localDate,
|
||||
behindBy,
|
||||
repoPath,
|
||||
changelog,
|
||||
}
|
||||
}
|
||||
|
||||
function runUpdate(): { ok: boolean; output: string } {
|
||||
if (!isGitAvailable()) {
|
||||
return { ok: false, output: 'git is not available in this environment' }
|
||||
}
|
||||
|
||||
const repoPath = path.resolve(process.cwd())
|
||||
const remote = detectRemote(repoPath)
|
||||
const currentBranch =
|
||||
runGit('rev-parse --abbrev-ref HEAD', repoPath) || 'main'
|
||||
|
||||
try {
|
||||
// Pull latest — use merge (not rebase) to avoid conflict hell with forked repos
|
||||
const pullOutput = execSync(`git pull ${remote} ${currentBranch}`, {
|
||||
cwd: repoPath,
|
||||
timeout: 30_000,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim()
|
||||
|
||||
// Install deps
|
||||
const installOutput = execSync('npm install --prefer-offline', {
|
||||
cwd: repoPath,
|
||||
timeout: 120_000,
|
||||
encoding: 'utf8',
|
||||
}).trim()
|
||||
|
||||
// Clear update cache so next check shows up-to-date
|
||||
lastCheck = null
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
output: `${pullOutput}\n\n${installOutput}`,
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
output: err instanceof Error ? err.message : String(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/api/update-check')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
try {
|
||||
const now = Date.now()
|
||||
if (lastCheck && now - lastCheck.at < CHECK_COOLDOWN_MS) {
|
||||
return json(lastCheck.result)
|
||||
}
|
||||
|
||||
const result = checkForUpdates()
|
||||
lastCheck = { at: now, result }
|
||||
return json(result)
|
||||
} catch (err) {
|
||||
return json(
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
POST: async ({ request }) => {
|
||||
if (!requireLocalOrAuth(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
const ip = getClientIp(request)
|
||||
// update-check POST triggers git pull + npm install — high RCE risk, strict limit
|
||||
if (!rateLimit(`update-check-post:${ip}`, 10, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
try {
|
||||
const result = runUpdate()
|
||||
return json(result)
|
||||
} catch (err) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
output: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '@/server/auth-middleware'
|
||||
|
||||
export const Route = createFileRoute('/api/usage-analytics')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
return json({ ok: true, models: [], sessions: [], agents: [], cost: [] })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../server/auth-middleware'
|
||||
|
||||
export const Route = createFileRoute('/api/usage')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
return json({ ok: true, usage: {} })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,137 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../server/auth-middleware'
|
||||
import { requireJsonContentType } from '../../server/rate-limit'
|
||||
|
||||
/**
|
||||
* POST /api/validate-provider
|
||||
*
|
||||
* Validates an API key by making a lightweight request to the provider's API.
|
||||
* Returns { ok: true } if valid, { ok: false, error: "..." } if not.
|
||||
*/
|
||||
|
||||
type ValidateBody = {
|
||||
providerId?: string
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
type ProviderConfig = {
|
||||
url: string
|
||||
headers: (key: string) => Record<string, string>
|
||||
body?: string
|
||||
method?: string
|
||||
successCheck: (status: number, data: unknown) => boolean
|
||||
}
|
||||
|
||||
const PROVIDER_VALIDATORS: Record<string, ProviderConfig> = {
|
||||
anthropic: {
|
||||
// Hit the messages endpoint with a minimal request — will return 400 but proves auth works
|
||||
// Using a GET to /v1/models would be ideal but Anthropic doesn't have one
|
||||
// Instead we send a minimal message request — if we get 400 (bad request) that means auth passed
|
||||
url: 'https://api.anthropic.com/v1/messages',
|
||||
headers: (key) => ({
|
||||
'x-api-key': key,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'content-type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
model: 'claude-sonnet-4-5-20250514',
|
||||
max_tokens: 1,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
method: 'POST',
|
||||
// 200 = valid (unlikely with max_tokens:1 but possible), 400 = valid auth but bad request
|
||||
// 401/403 = invalid key
|
||||
successCheck: (status) => status === 200 || status === 400 || status === 429,
|
||||
},
|
||||
openrouter: {
|
||||
url: 'https://openrouter.ai/api/v1/auth/key',
|
||||
headers: (key) => ({
|
||||
Authorization: `Bearer ${key}`,
|
||||
}),
|
||||
successCheck: (status) => status === 200,
|
||||
},
|
||||
google: {
|
||||
// Gemini API — list models to validate key
|
||||
url: 'https://generativelanguage.googleapis.com/v1/models',
|
||||
headers: (key) => ({
|
||||
'x-goog-api-key': key,
|
||||
}),
|
||||
successCheck: (status) => status === 200,
|
||||
},
|
||||
openai: {
|
||||
url: 'https://api.openai.com/v1/models',
|
||||
headers: (key) => ({
|
||||
Authorization: `Bearer ${key}`,
|
||||
}),
|
||||
successCheck: (status) => status === 200,
|
||||
},
|
||||
minimax: {
|
||||
url: 'https://api.minimaxi.chat/v1/models',
|
||||
headers: (key) => ({
|
||||
Authorization: `Bearer ${key}`,
|
||||
}),
|
||||
successCheck: (status) => status === 200,
|
||||
},
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/api/validate-provider')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
try {
|
||||
const body = (await request.json().catch(() => ({}))) as ValidateBody
|
||||
|
||||
if (!body.providerId || !body.apiKey) {
|
||||
return json({ ok: false, error: 'Missing provider or API key' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validator = PROVIDER_VALIDATORS[body.providerId]
|
||||
if (!validator) {
|
||||
return json({ ok: false, error: `Unknown provider: ${body.providerId}` }, { status: 400 })
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: validator.method || 'GET',
|
||||
headers: validator.headers(body.apiKey),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
}
|
||||
|
||||
if (validator.body && fetchOptions.method === 'POST') {
|
||||
fetchOptions.body = validator.body
|
||||
}
|
||||
|
||||
const response = await fetch(validator.url, fetchOptions)
|
||||
|
||||
if (validator.successCheck(response.status, null)) {
|
||||
return json({ ok: true })
|
||||
}
|
||||
|
||||
// Try to extract error message
|
||||
let errorMsg = `Invalid API key (HTTP ${response.status})`
|
||||
try {
|
||||
const data = (await response.json()) as { error?: { message?: string } }
|
||||
if (data.error?.message) {
|
||||
errorMsg = data.error.message
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
|
||||
return json({ ok: false, error: errorMsg })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (msg.includes('timeout') || msg.includes('abort')) {
|
||||
return json({ ok: false, error: 'Request timed out — check your connection' })
|
||||
}
|
||||
return json({ ok: false, error: `Validation error: ${msg}` }, { status: 500 })
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace-tasks/$id')({
|
||||
server: {
|
||||
handlers: {
|
||||
PUT: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-tasks-put:${ip}`, 60, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/tasks/${params.id}`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,68 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace-tasks')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-tasks-get:${ip}`, 120, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: '/tasks',
|
||||
searchParams: url.searchParams,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
POST: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-tasks-post:${ip}`, 30, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: '/tasks',
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -46,7 +46,7 @@ async function detectWorkspace(savedPath?: string): Promise<{
|
||||
// Priority 2: Environment variable
|
||||
const envWorkspace =
|
||||
process.env.HERMES_WORKSPACE_DIR?.trim() ||
|
||||
process.env.OPENCLAW_WORKSPACE_DIR?.trim()
|
||||
process.env.HERMES_WORKSPACE_DIR || process.env.OPENCLAW_WORKSPACE_DIR?.trim()
|
||||
if (envWorkspace) {
|
||||
const isValid = await isValidDirectory(envWorkspace)
|
||||
if (isValid) {
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/agents/$id')({
|
||||
server: {
|
||||
handlers: {
|
||||
PATCH: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-agent-patch:${ip}`, 30, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/agents/${encodeURIComponent(params.id)}`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
DELETE: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-agent-delete:${ip}`, 20, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/agents/${encodeURIComponent(params.id)}`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,71 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/agents')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-agents-get:${ip}`, 120, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const statsFor = url.searchParams.get('stats_for')?.trim()
|
||||
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: statsFor
|
||||
? `/agents/${encodeURIComponent(statsFor)}/stats`
|
||||
: '/agents',
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
POST: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-agents-post:${ip}`, 30, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: '/agents',
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/api/workspace/checkpoints/$id/approve-and-commit',
|
||||
)({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (
|
||||
!rateLimit(`workspace-checkpoint-approve-commit:${ip}`, 30, 60_000)
|
||||
) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/checkpoints/${params.id}/approve-and-commit`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/api/workspace/checkpoints/$id/approve-and-merge',
|
||||
)({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (
|
||||
!rateLimit(`workspace-checkpoint-approve-merge:${ip}`, 30, 60_000)
|
||||
) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/checkpoints/${params.id}/approve-and-merge`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/api/workspace/checkpoints/$id/approve-and-pr',
|
||||
)({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-checkpoint-approve-pr:${ip}`, 30, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/checkpoints/${params.id}/approve-and-pr`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/checkpoints/$id/approve')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-checkpoint-approve:${ip}`, 30, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/checkpoints/${params.id}/approve`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/checkpoints/$id/diff')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-checkpoint-diff:${ip}`, 60, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/checkpoints/${params.id}/diff`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/checkpoints/$id/reject')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-checkpoint-reject:${ip}`, 30, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/checkpoints/${params.id}/reject`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/checkpoints/$id/revise')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-checkpoint-revise:${ip}`, 30, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/checkpoints/${params.id}/revise`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/checkpoints/$id')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-checkpoint-detail:${ip}`, 120, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/checkpoints/${params.id}`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/api/workspace/checkpoints/$id/verify-tsc',
|
||||
)({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-checkpoint-verify-tsc:${ip}`, 20, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/checkpoints/${params.id}/verify-tsc`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/checkpoints')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-checkpoints-get:${ip}`, 120, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: '/checkpoints',
|
||||
searchParams: url.searchParams,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/config')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-config-get:${ip}`, 120, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: '/config',
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
PATCH: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-config-patch:${ip}`, 30, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: '/config',
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/decompose')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-decompose-post:${ip}`, 10, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: '/decompose',
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/events')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-events-get:${ip}`, 120, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: '/events',
|
||||
searchParams: url.searchParams,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,182 +0,0 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
|
||||
type MemorySection = 'workspace' | 'project' | 'agent'
|
||||
|
||||
type MemoryFileRecord = {
|
||||
name: string
|
||||
path: string
|
||||
size: string
|
||||
section: MemorySection
|
||||
}
|
||||
|
||||
type DateStampedFile = {
|
||||
name: string
|
||||
fullPath: string
|
||||
time: number
|
||||
}
|
||||
|
||||
function getWorkspaceRoot(): string {
|
||||
const configured = (
|
||||
process.env.HERMES_WORKSPACE ||
|
||||
process.env.OPENCLAW_WORKSPACE ||
|
||||
''
|
||||
).trim()
|
||||
return path.resolve(
|
||||
configured || path.join(os.homedir(), '.hermes'),
|
||||
)
|
||||
}
|
||||
|
||||
function formatKilobytes(bytes: number): string {
|
||||
const kb = bytes / 1024
|
||||
return `${kb < 10 ? kb.toFixed(1) : kb.toFixed(0)} KB`
|
||||
}
|
||||
|
||||
async function statFile(
|
||||
fullPath: string,
|
||||
): Promise<{ size: number; mtimeMs: number } | null> {
|
||||
try {
|
||||
const stats = await fs.stat(fullPath)
|
||||
if (!stats.isFile()) return null
|
||||
return {
|
||||
size: stats.size,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeFileRecord(
|
||||
workspaceRoot: string,
|
||||
relativePath: string,
|
||||
section: MemorySection,
|
||||
): Promise<MemoryFileRecord | null> {
|
||||
const fullPath = path.join(workspaceRoot, relativePath)
|
||||
const stats = await statFile(fullPath)
|
||||
if (!stats) return null
|
||||
|
||||
return {
|
||||
name: path.basename(relativePath),
|
||||
path: relativePath,
|
||||
size: formatKilobytes(stats.size),
|
||||
section,
|
||||
}
|
||||
}
|
||||
|
||||
function extractDateStamp(name: string): number {
|
||||
const match = name.match(/(\d{4}-\d{2}-\d{2})/)
|
||||
if (!match) return Number.NaN
|
||||
const parsed = Date.parse(match[1]!)
|
||||
return Number.isNaN(parsed) ? Number.NaN : parsed
|
||||
}
|
||||
|
||||
async function getLatestDailyLogs(workspaceRoot: string): Promise<Array<MemoryFileRecord>> {
|
||||
const memoryRoot = path.join(workspaceRoot, 'memory')
|
||||
|
||||
let entries: Array<string>
|
||||
try {
|
||||
entries = await fs.readdir(memoryRoot)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const candidates: Array<DateStampedFile> = []
|
||||
|
||||
for (const name of entries) {
|
||||
if (!name.toLowerCase().endsWith('.md')) continue
|
||||
const fullPath = path.join(memoryRoot, name)
|
||||
const stats = await statFile(fullPath)
|
||||
if (!stats) continue
|
||||
|
||||
candidates.push({
|
||||
name,
|
||||
fullPath,
|
||||
time: Number.isNaN(extractDateStamp(name))
|
||||
? stats.mtimeMs
|
||||
: extractDateStamp(name),
|
||||
})
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => b.time - a.time || a.name.localeCompare(b.name))
|
||||
|
||||
const topThree = candidates.slice(0, 3)
|
||||
|
||||
const items = await Promise.all(
|
||||
topThree.map(async (entry) => {
|
||||
const stats = await statFile(entry.fullPath)
|
||||
return stats
|
||||
? {
|
||||
name: entry.name,
|
||||
path: `memory/${entry.name}`,
|
||||
size: formatKilobytes(stats.size),
|
||||
section: 'project' as const,
|
||||
}
|
||||
: null
|
||||
}),
|
||||
)
|
||||
|
||||
return items.filter(
|
||||
(item): item is Extract<(typeof items)[number], MemoryFileRecord> =>
|
||||
item !== null,
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/memory-files')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-memory-files-get:${ip}`, 120, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
const workspaceRoot = getWorkspaceRoot()
|
||||
|
||||
const [memoryMd, soulMd, agentsMd, userMd, learningsMd, errorsMd, dailyLogs] =
|
||||
await Promise.all([
|
||||
maybeFileRecord(workspaceRoot, 'MEMORY.md', 'workspace'),
|
||||
maybeFileRecord(workspaceRoot, 'SOUL.md', 'workspace'),
|
||||
maybeFileRecord(workspaceRoot, 'AGENTS.md', 'workspace'),
|
||||
maybeFileRecord(workspaceRoot, 'USER.md', 'workspace'),
|
||||
maybeFileRecord(workspaceRoot, '.learnings/LEARNINGS.md', 'agent'),
|
||||
maybeFileRecord(workspaceRoot, '.learnings/ERRORS.md', 'agent'),
|
||||
getLatestDailyLogs(workspaceRoot),
|
||||
])
|
||||
|
||||
const files = [
|
||||
memoryMd,
|
||||
soulMd,
|
||||
agentsMd,
|
||||
userMd,
|
||||
...dailyLogs,
|
||||
learningsMd,
|
||||
errorsMd,
|
||||
].filter((item): item is MemoryFileRecord => Boolean(item))
|
||||
|
||||
return json({ files })
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/missions/$id/pause')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-mission-pause:${ip}`, 20, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/missions/${params.id}/pause`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/missions/$id/resume')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-mission-resume:${ip}`, 20, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/missions/${params.id}/resume`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/missions/$id/start')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-mission-start:${ip}`, 20, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/missions/${params.id}/start`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/missions/$id/status')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-mission-status:${ip}`, 120, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/missions/${params.id}/status`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/missions/$id/stop')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-mission-stop:${ip}`, 20, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/missions/${params.id}/stop`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/missions')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-missions-get:${ip}`, 120, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: '/missions',
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
POST: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-missions-post:${ip}`, 30, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: '/missions',
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/phases')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-phases-post:${ip}`, 30, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: '/phases',
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,115 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/projects/$id')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-project-get:${ip}`, 120, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/projects/${params.id}`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
PUT: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-project-put:${ip}`, 30, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/projects/${params.id}`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
PATCH: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-project-patch:${ip}`, 30, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/projects/${params.id}`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
DELETE: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-project-delete:${ip}`, 20, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/projects/${params.id}`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/projects')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-projects-get:${ip}`, 120, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: '/projects',
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
POST: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-projects-post:${ip}`, 30, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: '/projects',
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/skills/$id/content')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-skill-content-get:${ip}`, 120, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/skills/${params.id}/content`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/skills')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-skills-get:${ip}`, 120, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: '/skills',
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,202 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import { WORKSPACE_DAEMON_ORIGIN } from '../../../server/workspace-config'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
|
||||
type ProjectRecord = {
|
||||
id?: string
|
||||
}
|
||||
|
||||
type TaskRecord = {
|
||||
status?: string | null
|
||||
}
|
||||
|
||||
type MissionRecord = {
|
||||
tasks?: Array<TaskRecord> | null
|
||||
}
|
||||
|
||||
type PhaseRecord = {
|
||||
missions?: Array<MissionRecord> | null
|
||||
}
|
||||
|
||||
type ProjectDetailRecord = {
|
||||
phases?: Array<PhaseRecord> | null
|
||||
}
|
||||
|
||||
type AgentRecord = {
|
||||
status?: string | null
|
||||
}
|
||||
|
||||
type TaskRunRecord = {
|
||||
started_at?: string | null
|
||||
completed_at?: string | null
|
||||
cost_cents?: number | null
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function parseJson(text: string): unknown {
|
||||
if (!text) return null
|
||||
try {
|
||||
return JSON.parse(text) as unknown
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
async function daemonJson(path: string): Promise<unknown> {
|
||||
const response = await fetch(new URL(`/api/workspace${path}`, WORKSPACE_DAEMON_ORIGIN), {
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
},
|
||||
})
|
||||
const text = await response.text()
|
||||
const payload = parseJson(text)
|
||||
|
||||
if (!response.ok) {
|
||||
const record = asRecord(payload)
|
||||
throw new Error(
|
||||
typeof record?.error === 'string'
|
||||
? record.error
|
||||
: typeof record?.message === 'string'
|
||||
? record.message
|
||||
: `Workspace daemon request failed with status ${response.status}`,
|
||||
)
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function extractItems<T>(payload: unknown, key: string): Array<T> {
|
||||
if (Array.isArray(payload)) return payload as Array<T>
|
||||
const record = asRecord(payload)
|
||||
const direct = record?.[key]
|
||||
if (Array.isArray(direct)) return direct as Array<T>
|
||||
if (Array.isArray(record?.data)) return record.data as Array<T>
|
||||
if (Array.isArray(record?.items)) return record.items as Array<T>
|
||||
return []
|
||||
}
|
||||
|
||||
function extractProjectIds(payload: unknown): string[] {
|
||||
return extractItems<ProjectRecord>(payload, 'projects')
|
||||
.map((project) => (typeof project?.id === 'string' ? project.id : null))
|
||||
.filter((id): id is string => Boolean(id))
|
||||
}
|
||||
|
||||
function extractTasksFromProject(detail: unknown): Array<TaskRecord> {
|
||||
const project = asRecord(detail) as ProjectDetailRecord | null
|
||||
const phases = Array.isArray(project?.phases) ? project.phases : []
|
||||
return phases.flatMap((phase) =>
|
||||
(Array.isArray(phase?.missions) ? phase.missions : []).flatMap((mission) =>
|
||||
Array.isArray(mission?.tasks) ? mission.tasks : [],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeTaskBucket(status: string | null | undefined): 'running' | 'queued' | 'paused' | null {
|
||||
const normalized = status?.trim().toLowerCase()
|
||||
if (!normalized) return null
|
||||
|
||||
if (['running', 'in_progress', 'active'].includes(normalized)) return 'running'
|
||||
if (['pending', 'ready', 'queued', 'waiting'].includes(normalized)) return 'queued'
|
||||
if (['paused', 'blocked', 'on_hold', 'hold'].includes(normalized)) return 'paused'
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isToday(value: string | null | undefined): boolean {
|
||||
if (!value) return false
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return false
|
||||
|
||||
const now = new Date()
|
||||
return date.toDateString() === now.toDateString()
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/stats')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-stats-get:${ip}`, 120, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
const [projectsPayload, agentsPayload, checkpointsPayload, taskRunsPayload] =
|
||||
await Promise.all([
|
||||
daemonJson('/projects'),
|
||||
daemonJson('/agents'),
|
||||
daemonJson('/checkpoints?status=pending'),
|
||||
daemonJson('/task-runs'),
|
||||
])
|
||||
|
||||
const projectIds = extractProjectIds(projectsPayload)
|
||||
const agents = extractItems<AgentRecord>(agentsPayload, 'agents')
|
||||
const checkpoints = extractItems<Record<string, unknown>>(
|
||||
checkpointsPayload,
|
||||
'checkpoints',
|
||||
)
|
||||
const taskRuns = extractItems<TaskRunRecord>(taskRunsPayload, 'task_runs')
|
||||
|
||||
const projectDetails = await Promise.all(
|
||||
projectIds.map((projectId) =>
|
||||
daemonJson(`/projects/${encodeURIComponent(projectId)}`),
|
||||
),
|
||||
)
|
||||
|
||||
let running = 0
|
||||
let queued = 0
|
||||
let paused = 0
|
||||
|
||||
for (const detail of projectDetails) {
|
||||
for (const task of extractTasksFromProject(detail)) {
|
||||
const bucket = normalizeTaskBucket(task?.status)
|
||||
if (bucket === 'running') running += 1
|
||||
if (bucket === 'queued') queued += 1
|
||||
if (bucket === 'paused') paused += 1
|
||||
}
|
||||
}
|
||||
|
||||
const agentsOnline = agents.filter(
|
||||
(agent) => (agent?.status ?? 'offline') !== 'offline',
|
||||
).length
|
||||
const costToday = taskRuns.reduce((total, run) => {
|
||||
if (!isToday(run?.started_at ?? run?.completed_at)) return total
|
||||
return total + (typeof run?.cost_cents === 'number' ? run.cost_cents / 100 : 0)
|
||||
}, 0)
|
||||
|
||||
return json({
|
||||
projects: projectIds.length,
|
||||
agentsOnline,
|
||||
agentsTotal: agents.length,
|
||||
running,
|
||||
queued,
|
||||
paused,
|
||||
checkpointsPending: checkpoints.length,
|
||||
policyAlerts: 0,
|
||||
costToday,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/task-runs/$id/events')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-task-run-events-get:${ip}`, 120, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/task-runs/${params.id}/events`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/task-runs/$id/pause')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-task-run-pause-post:${ip}`, 30, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/task-runs/${encodeURIComponent(params.id)}/pause`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/task-runs/$id/retry')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-task-run-retry-post:${ip}`, 30, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/task-runs/${encodeURIComponent(params.id)}/retry`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/task-runs/$id/stop')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-task-run-stop-post:${ip}`, 30, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/task-runs/${encodeURIComponent(params.id)}/stop`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/task-runs/adhoc')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-task-runs-adhoc-post:${ip}`, 30, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: '/task-runs/adhoc',
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/task-runs')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-task-runs-get:${ip}`, 120, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: '/task-runs',
|
||||
searchParams: url.searchParams,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../../server/rate-limit'
|
||||
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
|
||||
|
||||
export const Route = createFileRoute('/api/workspace/teams/$id/approval-config')({
|
||||
server: {
|
||||
handlers: {
|
||||
PATCH: async ({ request, params }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`workspace-team-approval-patch:${ip}`, 30, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await forwardWorkspaceRequest({
|
||||
request,
|
||||
path: `/teams/${params.id}/approval-config`,
|
||||
})
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ ok: false, error: safeErrorMessage(error) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user