feat: read-only dashboard (agentflare serve) — Phases 0–2 - #255
Conversation
This reverts commit 2ad568a.
agentflare has no item-activity log; this endpoint has always returned webhook delivery logs, so name it honestly.
Plane-style sidebar (workspace/project selectors) + topbar + content slot. tokens.css holds design tokens as CSS custom properties; shell.js holds the Alpine shellNav component and small fetch/query-string helpers other views reuse. Chrome markup itself is copy-pasted per page (no build step means no include mechanism), but the behavior stays in one place.
Fetches /api/pm/states and /api/pm/items for the resolved project and renders one column per state (ordered by sequence) with items as cards. Empty states for no project selected and no states configured.
Sortable table over /api/pm/items (click a column header to sort), with state names resolved from /api/pm/states. Empty state for no items.
Reads ?id= (and ?project_id=), filters /api/pm/items for the match, and renders item fields plus its /api/pm/comments. No activity/events timeline since agentflare has no item-activity log.
Sortable table over /api/webhooks (the renamed events endpoint) with a click-to-expand row showing the logged request/response bodies.
Apply the visual pass made in the claude.ai/design agentflare project: light+dark neutral surfaces, indigo accent, priority-dot badges, logo mark, and softer elevation. Asset paths kept absolute for the embedded server; list/item/webhooks inherit the look via the shared tokens.css.
Extract cost::summarize (open_or_rebuild -> sync -> query) so the CLI and
the dashboard share one freshly-synced code path, and expose it as
/api/cost?days=N&by=model|project returning { groups, total_cost_usd,
any_unpriced }. The JSON shaper is unit-tested independently of the cache.
data::live_snapshot_json bundles { claims, cost_today } (reusing
claims_json + today's cost_json). /events emits it via axum SSE on a 2s
tokio-stream interval, first tick immediate, so Claims and Cost views can
update without a manual refresh.
claims.html renders the /events snapshot live via EventSource; cost.html has a range (today/7/30) + group-by (model/project) control, driving the live /events feed for today-by-model and a one-shot /api/cost fetch otherwise. Add Claims + Cost to the shared sidebar nav and the small live-indicator / toolbar / cost-total tokens they use.
Replaces the auto-assign (0) default so 'agentflare serve' lands on a stable, memorable URL; 0 is still accepted for auto-assign.
The root served a bare Phase-0 claims scaffold predating the styled views, so / rendered unstyled. Replace index.html with a redirect to /board.html (meta refresh + location.replace), landing on a real view with the correct sidebar highlight.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a read-only Axum dashboard with PM, cost, claims, and webhook views. It introduces JSON and SSE APIs, embedded frontend assets, shared navigation and styling, a ChangesDashboard
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Browser
participant DashboardServer
participant DashboardData
participant PMDatabase
Browser->>DashboardServer: Request API data or connect to /events
DashboardServer->>DashboardData: Build scoped JSON or live snapshot
DashboardData->>PMDatabase: Query read-only backend data
PMDatabase-->>DashboardData: Return records and totals
DashboardData-->>DashboardServer: Return serialized payload
DashboardServer-->>Browser: Send JSON response or SSE event
Possibly related PRs
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 10
🧹 Nitpick comments (1)
dashboard/web/tokens.css (1)
64-67: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winResolve the reported Stylelint errors.
Normalize the flagged identifiers (
blinkmacsystemfont,roboto,helvetica,arial,sfmono-regular,menlo,consolas,optimizelegibility, andcurrentcolor) or configure an intentional exception.Also applies to: 129-129, 374-374
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@dashboard/web/tokens.css` around lines 64 - 67, Resolve the Stylelint identifier errors in the font-stack declarations and the other affected declarations near the referenced locations by normalizing flagged identifiers to the required casing, including BlinkMacSystemFont, Roboto, Helvetica, Arial, SFMono-Regular, Menlo, Consolas, optimizeLegibility, and currentColor. If any casing must remain intentional, add the appropriate narrowly scoped Stylelint exception.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/agentflare-backend/src/webhook.rs`:
- Around line 264-273: Update list_logs_by_workspace to support bounded
pagination with a limit and cursor, ordering consistently by created_at and a
stable unique key. Avoid selecting potentially large request_body and
response_body fields in the history query; fetch those bodies through a separate
delivery-detail path used only when an entry is expanded, and update callers to
use the paginated results.
In `@dashboard/web/cost.html`:
- Around line 161-171: Update the async refresh() method to track a request
generation for non-live cost fetches, incrementing it whenever a refresh starts
and applying the response only if its generation is still current. Ensure stale
afGetJson results are discarded while preserving the existing live subscription
and current-response apply behavior.
In `@dashboard/web/index.html`:
- Around line 8-12: Update the root redirect in the dashboard index script to
preserve the current workspace_id and project_id query parameters when
navigating to board.html. Construct the destination from the existing URL
parameters so scoped links retain their context while the redirect continues
replacing the history entry.
In `@dashboard/web/list.html`:
- Around line 79-88: Make the sortable headers and clickable rows in the list
template keyboard accessible: update the header controls around sortBy() and row
navigation using native buttons/links where possible, or provide appropriate
roles, tabindex, and Enter/Space handlers. Preserve the existing sorting
behavior and itemHref(item) navigation for mouse and keyboard activation.
In `@dashboard/web/shell.html`:
- Line 22: Remove the explicit x-init="init()" attributes from the shellNav()
elements in dashboard/web/shell.html (22-22), dashboard/web/board.html (14-14
and 64-68), dashboard/web/list.html (14-14 and 64-68), and
dashboard/web/item.html (14-14 and 64-68); retain x-data="shellNav()" so Alpine
invokes its init() method once automatically.
In `@dashboard/web/shell.js`:
- Around line 48-61: Update afResolveScope to validate any provided projectId
against the projects fetched for the selected workspace; reset projectId when it
is absent from that workspace’s project list, then retain the existing
first-project fallback when no valid project remains.
In `@dashboard/web/webhooks.html`:
- Around line 79-96: Update the sorting headers and delivery-row expansion
controls in the webhook table: replace clickable th/tr interactions with
keyboard-accessible button elements that invoke sortBy and toggleExpanded, and
expose each row’s current expansion state through aria-expanded. Preserve the
existing sorting labels, row behavior, and log-specific expanded state.
In `@src/dashboard/data.rs`:
- Around line 15-24: Update the dashboard data helpers, including claims_json
and the other listed helpers, to return Result values instead of converting
database, query, or serialization failures into empty JSON datasets. Propagate
errors through the HTTP layer and map them to an appropriate non-success
response, while preserving successful response serialization and data behavior.
In `@src/dashboard/server.rs`:
- Around line 162-175: Update the run function’s non-loopback binding behavior
to prevent unauthenticated remote dashboard access: either add the required
random session token/cookie with Host/Origin validation to the dashboard routes,
or refuse non-loopback hosts unless an explicit unsafe mode is enabled. Do not
rely on the existing console warning alone, and preserve local loopback
operation.
- Around line 101-123: Move synchronous cost computation out of Tokio workers by
updating cost_handler to obtain the snapshot through spawn_blocking. Add a
shared broadcast or watch channel whose producer computes live_snapshot_json
once and publishes the cached result, then have events_handler subscribe and
stream updates from that shared snapshot instead of recalculating per client
every two seconds. Preserve the existing response formats and immediate initial
event behavior.
---
Nitpick comments:
In `@dashboard/web/tokens.css`:
- Around line 64-67: Resolve the Stylelint identifier errors in the font-stack
declarations and the other affected declarations near the referenced locations
by normalizing flagged identifiers to the required casing, including
BlinkMacSystemFont, Roboto, Helvetica, Arial, SFMono-Regular, Menlo, Consolas,
optimizeLegibility, and currentColor. If any casing must remain intentional, add
the appropriate narrowly scoped Stylelint exception.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 60bf8b55-55ad-407a-b2ca-bdf3371efeb0
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (20)
Cargo.tomlcrates/agentflare-backend/src/webhook.rsdashboard/web/board.htmldashboard/web/claims.htmldashboard/web/cost.htmldashboard/web/index.htmldashboard/web/item.htmldashboard/web/list.htmldashboard/web/shell.htmldashboard/web/shell.jsdashboard/web/tokens.cssdashboard/web/vendor/alpine.jsdashboard/web/webhooks.htmlsrc/cli/mod.rssrc/cli/serve.rssrc/cost.rssrc/dashboard/data.rssrc/dashboard/mod.rssrc/dashboard/server.rssrc/main.rs
| /// Delivery log entries for a workspace, most recent first — the audit | ||
| /// trail the dashboard's `/api/webhooks` view reads. | ||
| pub fn list_logs_by_workspace(conn: &Connection, workspace_id: &str) -> Result<Vec<WebhookLog>> { | ||
| let mut stmt = conn.prepare( | ||
| "SELECT id, workspace_id, webhook_id, event_type, request_method, request_headers, request_body, response_status, response_headers, response_body, retry_count, created_at | ||
| FROM webhook_logs WHERE workspace_id = ?1 ORDER BY created_at DESC", | ||
| )?; | ||
| let rows = stmt.query_map(params![workspace_id], row_to_webhook_log)?; | ||
| Ok(rows.collect::<std::result::Result<_, _>>()?) | ||
| } |
There was a problem hiding this comment.
🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift
Paginate webhook delivery history.
This query loads every log and its potentially large request/response bodies. The API then serializes the full history and the browser sorts it in memory, so latency and memory grow without bound. Add a limit/cursor and fetch full bodies only when a delivery is expanded.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/agentflare-backend/src/webhook.rs` around lines 264 - 273, Update
list_logs_by_workspace to support bounded pagination with a limit and cursor,
ordering consistently by created_at and a stable unique key. Avoid selecting
potentially large request_body and response_body fields in the history query;
fetch those bodies through a separate delivery-detail path used only when an
entry is expanded, and update callers to use the paginated results.
| async refresh() { | ||
| this.closeStream(); | ||
| if (this.isLiveView()) { | ||
| this.subscribeLive(); | ||
| } else { | ||
| const data = await afGetJson( | ||
| `/api/cost?days=${this.days}&by=${encodeURIComponent(this.by)}`, | ||
| { groups: [], total_cost_usd: 0, any_unpriced: false }, | ||
| ); | ||
| this.apply(data); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Discard stale cost responses.
Rapid range/group changes can leave multiple requests in flight, and an older response may overwrite the latest selection. Track a request generation or use AbortController before applying the result.
Proposed request-generation guard
return {
+ requestId: 0,
async refresh() {
+ const requestId = ++this.requestId;
this.closeStream();
if (this.isLiveView()) {
this.subscribeLive();
} else {
const data = await afGetJson(
`/api/cost?days=${this.days}&by=${encodeURIComponent(this.by)}`,
{ groups: [], total_cost_usd: 0, any_unpriced: false },
);
- this.apply(data);
+ if (requestId === this.requestId) this.apply(data);
}
},🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@dashboard/web/cost.html` around lines 161 - 171, Update the async refresh()
method to track a request generation for non-live cost fetches, incrementing it
whenever a refresh starts and applying the response only if its generation is
still current. Ensure stale afGetJson results are discarded while preserving the
existing live subscription and current-response apply behavior.
| <script> | ||
| // The dashboard has no standalone home; land on the Board so the URL | ||
| // becomes /board.html and its sidebar entry highlights. location.replace | ||
| // avoids trapping the back button on this redirect stub. | ||
| location.replace('/board.html'); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Preserve dashboard scope during the root redirect.
The JavaScript redirect drops workspace_id and project_id, causing scoped root links to open the default project instead.
- location.replace('/board.html');
+ location.replace(`/board.html${window.location.search}${window.location.hash}`);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <script> | |
| // The dashboard has no standalone home; land on the Board so the URL | |
| // becomes /board.html and its sidebar entry highlights. location.replace | |
| // avoids trapping the back button on this redirect stub. | |
| location.replace('/board.html'); | |
| <script> | |
| // The dashboard has no standalone home; land on the Board so the URL | |
| // becomes /board.html and its sidebar entry highlights. location.replace | |
| // avoids trapping the back button on this redirect stub. | |
| location.replace(`/board.html${window.location.search}${window.location.hash}`); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@dashboard/web/index.html` around lines 8 - 12, Update the root redirect in
the dashboard index script to preserve the current workspace_id and project_id
query parameters when navigating to board.html. Construct the destination from
the existing URL parameters so scoped links retain their context while the
redirect continues replacing the history entry.
| <th @click="sortBy('name')">Name</th> | ||
| <th @click="sortBy('priority')">Priority</th> | ||
| <th @click="sortBy('state_name')">State</th> | ||
| <th @click="sortBy('assignee_agent')">Assignee</th> | ||
| <th @click="sortBy('created_at')">Created</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| <template x-for="item in sortedItems()" :key="item.id"> | ||
| <tr class="is-clickable" @click="window.location.href = itemHref(item)"> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Make sorting and row navigation keyboard accessible.
The clickable <th> and <tr> elements have no keyboard semantics, preventing keyboard users from sorting or opening items. Prefer native buttons/links, or add appropriate roles, focusability, and Enter/Space handlers.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@dashboard/web/list.html` around lines 79 - 88, Make the sortable headers and
clickable rows in the list template keyboard accessible: update the header
controls around sortBy() and row navigation using native buttons/links where
possible, or provide appropriate roles, tabindex, and Enter/Space handlers.
Preserve the existing sorting behavior and itemHref(item) navigation for mouse
and keyboard activation.
| duplicated per page. | ||
| --> | ||
| <div class="af-app"> | ||
| <aside class="af-sidebar" x-data="shellNav()" x-init="init()"> |
There was a problem hiding this comment.
🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Lean-ctx is unavailable here; inspect the supplied vendored file without executing it.
python - <<'PY'
from pathlib import Path
source = Path("dashboard/web/vendor/alpine.js").read_text()
assert "s.init&&M(e,s.init)" in source, "Automatic Alpine init hook not found"
print("Confirmed: Alpine automatically invokes x-data init methods.")
PYRepository: getappz/agentflare
Length of output: 217
Remove the explicit x-init="init()" calls. Alpine already invokes the init() method returned by x-data, so these attributes run initialization twice and duplicate the work in each affected template.
dashboard/web/shell.html#L22-L22dashboard/web/board.html#L14-L14and#L64-L68dashboard/web/list.html#L14-L14and#L64-L68dashboard/web/item.html#L14-L14and#L64-L68
📍 Affects 4 files
dashboard/web/shell.html#L22-L22(this comment)dashboard/web/board.html#L14-L14dashboard/web/board.html#L64-L68dashboard/web/list.html#L14-L14dashboard/web/list.html#L64-L68dashboard/web/item.html#L14-L14dashboard/web/item.html#L64-L68
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@dashboard/web/shell.html` at line 22, Remove the explicit x-init="init()"
attributes from the shellNav() elements in dashboard/web/shell.html (22-22),
dashboard/web/board.html (14-14 and 64-68), dashboard/web/list.html (14-14 and
64-68), and dashboard/web/item.html (14-14 and 64-68); retain
x-data="shellNav()" so Alpine invokes its init() method once automatically.
| async function afResolveScope(explicitWorkspaceId, explicitProjectId) { | ||
| let workspaceId = explicitWorkspaceId || ''; | ||
| let projectId = explicitProjectId || ''; | ||
| if (!workspaceId) { | ||
| const workspaces = await afGetJson('/api/pm/workspaces', []); | ||
| if (workspaces.length) workspaceId = workspaces[0].id; | ||
| } | ||
| if (workspaceId && !projectId) { | ||
| const projects = await afGetJson( | ||
| `/api/pm/projects?workspace_id=${encodeURIComponent(workspaceId)}`, | ||
| [], | ||
| ); | ||
| if (projects.length) projectId = projects[0].id; | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Validate that project_id belongs to the selected workspace.
A stale or edited URL can retain a project from another workspace. The sidebar then displays one workspace while the views query data for the unrelated project_id. Reset the project unless it exists in the fetched workspace project list.
Proposed fix
- if (workspaceId && !projectId) {
+ if (workspaceId) {
const projects = await afGetJson(
`/api/pm/projects?workspace_id=${encodeURIComponent(workspaceId)}`,
[],
);
- if (projects.length) projectId = projects[0].id;
+ if (!projects.some((project) => project.id === projectId)) {
+ projectId = projects[0]?.id || '';
+ }
}- if (!this.projectId && this.projects.length) {
- this.projectId = this.projects[0].id;
+ if (!this.projects.some((project) => project.id === this.projectId)) {
+ this.projectId = this.projects[0]?.id || '';
}Also applies to: 89-96
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@dashboard/web/shell.js` around lines 48 - 61, Update afResolveScope to
validate any provided projectId against the projects fetched for the selected
workspace; reset projectId when it is absent from that workspace’s project list,
then retain the existing first-project fallback when no valid project remains.
| <th @click="sortBy('created_at')">Delivered</th> | ||
| <th @click="sortBy('event_type')">Event</th> | ||
| <th @click="sortBy('request_method')">Method</th> | ||
| <th @click="sortBy('response_status')">Status</th> | ||
| <th @click="sortBy('retry_count')">Retries</th> | ||
| <th>Webhook</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| <template x-for="log in sortedLogs()" :key="log.id"> | ||
| <tr class="is-clickable" @click="toggleExpanded(log.id)"> | ||
| <td x-text="afFormatTime(log.created_at)"></td> | ||
| <td x-text="log.event_type || '—'"></td> | ||
| <td x-text="log.request_method || '—'"></td> | ||
| <td x-text="log.response_status || '—'"></td> | ||
| <td x-text="log.retry_count"></td> | ||
| <td class="af-mono af-truncate" x-text="log.webhook_id"></td> | ||
| </tr> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Make sorting and delivery expansion keyboard accessible.
Clickable <th> and <tr> elements are not keyboard controls, preventing keyboard users from sorting or viewing delivery details. Put these actions in <button> elements and expose expansion state with aria-expanded.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@dashboard/web/webhooks.html` around lines 79 - 96, Update the sorting headers
and delivery-row expansion controls in the webhook table: replace clickable
th/tr interactions with keyboard-accessible button elements that invoke sortBy
and toggleExpanded, and expose each row’s current expansion state through
aria-expanded. Preserve the existing sorting labels, row behavior, and
log-specific expanded state.
| /// Live claims as a JSON array string; reuses `crate::claims::list`. "[]" on error. | ||
| pub fn claims_json() -> String { | ||
| let path = crate::db::agentflare_db_path(); | ||
| let result = open_readonly(&path).and_then(|conn| { | ||
| crate::claims::list(&conn, None, true, crate::claims::now(), crate::claims::ttl_secs()) | ||
| }); | ||
| match result { | ||
| Ok(claims) => serde_json::to_string(&claims).unwrap_or_else(|_| "[]".into()), | ||
| Err(_) => "[]".into(), | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Do not report database failures as valid empty datasets.
Every open, query, or serialization error becomes [], causing the HTTP layer to return 200 and the UI to claim there is simply no data. Return Result from these helpers and map failures to a non-success API response so missing databases, schema mismatches, and read errors remain visible.
Also applies to: 27-41, 43-57, 59-73, 75-89, 91-105, 107-149
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/dashboard/data.rs` around lines 15 - 24, Update the dashboard data
helpers, including claims_json and the other listed helpers, to return Result
values instead of converting database, query, or serialization failures into
empty JSON datasets. Propagate errors through the HTTP layer and map them to an
appropriate non-success response, while preserving successful response
serialization and data behavior.
| pub async fn run(host: &str, port: u16, open: bool) { | ||
| let listener = tokio::net::TcpListener::bind((host, port)) | ||
| .await | ||
| .expect("failed to bind dashboard server"); | ||
| let addr = listener.local_addr().expect("no local addr"); | ||
| let url = format!("http://{addr}"); | ||
| eprintln!("agentflare dashboard listening on {url}"); | ||
| if host != "127.0.0.1" && host != "localhost" { | ||
| eprintln!(" warning: bound to {host} — anyone on your network can view this"); | ||
| } | ||
| if open { | ||
| crate::dashboard::open_browser(&url); | ||
| } | ||
| axum::serve(listener, router()).await.expect("dashboard server error"); |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
Protect dashboard data before allowing remote access.
Anyone able to reach this listener can read claims, PM records, comments, costs, and webhook request/response bodies. A console warning does not provide authorization. Require a random session token/cookie with Host/Origin validation, or refuse non-loopback binding unless an explicitly unsafe mode is selected.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/dashboard/server.rs` around lines 162 - 175, Update the run function’s
non-loopback binding behavior to prevent unauthenticated remote dashboard
access: either add the required random session token/cookie with Host/Origin
validation to the dashboard routes, or refuse non-loopback hosts unless an
explicit unsafe mode is enabled. Do not rely on the existing console warning
alone, and preserve local loopback operation.
The :key separator was a NUL (0x00) instead of a space, which made git classify the file as binary and silently skip it from diff-based review. Use a printable '|' separator.
/events previously ran the blocking live_snapshot_json (SQLite + a ~/.claude/projects walk + analytics-cache write) once per connected client every 2s, on the async worker threads, forever. Replace with a single producer task that computes the snapshot on spawn_blocking and fans it out over a tokio broadcast channel; it skips the sync entirely when no client is connected. N tabs now cost one sync per interval.
The SSE producer ran the full cost::summarize (a ~/.claude/projects walk + analytics-cache write) every tick. Split the snapshot: refresh the cheap claims SQLite read every 3s, but recompute the expensive cost summary only every 30s (cached between), and still do nothing while no client is connected. Measured ~0ms CPU over 11s with a client attached.
# Conflicts: # Cargo.lock # Cargo.toml
…de branch-guard, config split) (#324) * init: cover the shell layer (bashenv guard, opencode branch-guard, config split) agentflare init audited itself as incomplete on a fresh machine (item #255): the .bashenv DEBUG-trap guard + lean-ctx function dispatcher were entirely hand-installed, opencode had no branch-guard plugin at all (no PreToolUse hook to wire agentflare's own guard into), and init's opencode idempotency checks only ever looked at opencode.jsonc, missing entries a user had in the sibling opencode.json. - New "claude-code-bashenv-guard" component: creates/patches ~/.bashenv's two marker-delimited blocks (shim dispatcher + guard trap) and wires BASH_ENV into ~/.claude/settings.json, never clobbering surrounding content. Tool list is generated from shim_install's own GENERIC_SHIM_TOOLS instead of a second hand-maintained copy. - New "opencode-branch-guard" component: drops the same branch-guard.js plugin (calls `agentflare hook pre-tool-use`) into opencode's auto-loaded ~/.config/opencode/plugin/ directory. - opencode_config_merged() gives idempotency checks a merged view of opencode.json + opencode.jsonc (matching opencode's own deep-merge), so a value already present in either file isn't re-written. Writes still only ever target opencode.jsonc, the file init owns. Agentflare-Agent: claude-code_2-1-220_agent Agentflare-Branch: task/255 Agentflare-Item: 255 * fix(bashenv): address CodeRabbit findings on marker patching and env wiring - upsert_block: leave a truncated marker (start present, no matching end) alone instead of appending a second copy after it. - set_bash_env_setting: coerce a non-object "env" value (null, a stray string) instead of unwrap-panicking on as_object_mut(); return a tri-state outcome so a write failure is reported as a failure, not silently folded into "already up to date". Agentflare-Agent: claude-code_2-1-220_agent Agentflare-Branch: task/255 Agentflare-Item: 255 --------- Co-authored-by: Test <test@test.com>
…, add HTTP tests (#369) * feat(components): auto-enforce core-module usage via init/SessionStart Adds two non-consent components so agentflare init and every SessionStart self-heal the setup needed to actually use flare-docs, flare-search, and lean-ctx through the gateway, instead of relying on hand-run CLI commands: - core-coaching: seeds/refreshes 4 builtin coaching rules (usedocs, usesearch, useleanctx, usetsearch) that nudge the flare gateway's docs/search/lean-ctx/tool-search wrappers over their native equivalents. Drift-protected across version bumps; a same-id rule the user has overridden to a different tier is left alone. - gateway-permissions: keeps ~/.claude/settings.json's permissions.allow containing the flare gateway tools (mcp__flare__docs, mcp__flare__search, mcp__flare__tool, ToolSearch) and strips superseded direct mcp__lean-ctx__* entries. Also widens coaching::store::list_rules to pub(crate) so components.rs can read existing rule state when deciding whether to seed or refresh. * feat(site): add flare-docs documentation site, aube-linked monorepo Adds docs-site/, an Astro Starlight project documenting the flare-docs module (overview, supported languages, MCP tool reference, CLI reference, examples, and a comparison against Context7), reusing the landing page's exact design system (colors, fonts, mono headings) via customCss. site/ and docs-site/ are linked as sibling packages in an aube-workspace.yaml monorepo rooted at the repo root, with mise tasks (install/dev:site/dev:docs/ build:docs/deploy/tail) as the entry points. The two connect only at deploy time: site's deploy script builds docs-site out-of-tree and copies its output into site/public/docs before wrangler deploy, so day-to-day dev on either package never touches the other. * feat(flare-docs): Python (PyPI) ecosystem support Adds Ecosystem::Python alongside Rust and npm: fetches a package's PyPI manifest, unpacks its pure-Python wheel, and indexes whichever typed source it carries — separate .pyi stubs, or (for PEP 561 inline-typed packages like click and pandas) the package's own annotated .py source with a py.typed marker — via a tree-sitter-python extraction pass mirroring the existing npm/.d.ts module. Falls back to typeshed's types-<package> convention on PyPI when a package ships neither. Wired into the CLI (`agentflare docs get <pkg> --ecosystem python`) and the MCP `docs` tool. Verified live against PyPI: requests correctly falls back to types-requests; click indexes 470 items from its own inline-typed source with no fallback needed. * feat(flare-docs): index usage examples from npm/Python package docs Fills the one real gap Rust didn't have: rustdoc-JSON already carries a crate's doc-comment Examples verbatim, but npm and Python previously discarded the README/long-description entirely, keeping only .d.ts/.pyi signatures. Adds a shared, LLM-free fenced-code-block extractor (crates/flare-docs/src /readme.rs) reused by both ecosystems at zero extra network cost: npm scans every .md file already inside the tarball it downloads for .d.ts, and Python reads info.description straight out of the PyPI manifest JSON it already fetches (markdown releases only -- RST is explicitly out of scope rather than mis-parsed). Each block is titled by its nearest heading and indexed as npm-example/python-example, reconciled the same way API items are. Filters two kinds of noise before indexing: examples under process/meta headings (Installation, Contributing, License, Testing, ...), and shell-flavored blocks that are pure setup commands (git clone, pip install) even under an otherwise-good heading like "Quick Start" -- verified live against requests' real PyPI description, which previously surfaced its "Cloning the repository" git commands as if they were usage examples. Also fixes a path-matching gap in both the new markdown-file filter and the existing Python inline-typed-package filter: a top-level `test/` directory (no leading slash after tarball-prefix stripping) wasn't caught by a mid-path `contains("/test/")` check. * docs(site): highlight flare-docs' verbatim examples vs Context7's LLM ones Now that flare-docs indexes usage examples too, add a comparison row and strengthen the "where flare-docs wins" case: examples are extracted verbatim from the package's own docs, not synthesized by an LLM the way Context7's snippets are -- so they can't drift from what the maintainer actually wrote. * fix(optimize): pace the batching nudge with doubling milestones instead of firing every call Previously the batching_nudge fired on every single tool call once a streak crossed the trailing-window threshold, spamming the same message. Now it only fires at streak milestones (3, 6, 12, 24, ...), computed statelessly from recent_tool_calls history. * feat(coaching): MANDATORY tier — hard-deny tool calls that violate an enforced rule Coaching rules were 100% advisory: a nudge in systemMessage the agent could ignore. Adds an enforced flag (# Enforce: true header, coaching enforce <id> [--off] CLI) that the PreToolUse hook checks before falling through to advisory nudges — a match denies the call with a redirect-framed reason instead of just suggesting the preferred tool. Adopts lean-ctx's own PreToolUse mechanism as the reference (studied in ~/workspace/refs/lean-ctx): it substitutes tool input rather than denying, which only works when the substitute is the same tool with different input (e.g. a Bash command rewrite). None of agentflare's coaching rules are same-tool swaps, so this instead reuses the existing deny-decision shape from hook_redirect.rs, with the reason phrased as a redirect. Of the four builtin rules, only usesearch (WebFetch/WebSearch) and usetsearch (ToolSearch) are marked enforced by default: they're clean 1:1 substitutes with no fallback the mandatory tool can't cover. useleanctx stays advisory since ctx_* can't handle every path/case native Bash/Read can (hard-blocking risks a self-lockout), and usedocs stays advisory because its trigger (Edit/Write) isn't a same-action substitute for mcp__flare__docs — blocking edits to redirect to a docs lookup would just break editing. * fix(ci): satisfy clippy too_many_arguments and cargo fmt write_rule_file crossed clippy's default 7-arg threshold when the enforced param was added; allow it like the codebase's other multi-field constructors. The fmt diff is pre-existing drift in flare-docs and the coaching files from this branch's own history, not from this commit. * fix(dashboard): log DB errors, validate cost by=, gate non-local bind, add HTTP tests Closes the PR #255 review backlog. data.rs accessors silently returned "[]" on any DB error, making a broken/missing DB indistinguishable from genuinely empty state in the server log — now every error arm logs before falling back. /api/cost?by= silently coerced unknown values to model grouping instead of surfacing caller typos as a 400. Binding to a non-loopback host exposed all PM/cost/webhook data with only an easy-to- miss stderr warning — it now refuses to start unless --yes-expose is passed. Also adds HTTP-layer test coverage for cost_handler, the /events SSE stream, and static_handler that the module previously lacked. * test(dashboard): cover run() serving normally on a local bind Exercises the --yes-expose gate end-to-end for the exempt path: run() with a 127.0.0.1 host and yes_expose=false actually binds and serves, rather than only asserting the pure is_local_bind() helper. The non-local refusal path isn't covered here since it calls std::process::exit, which isn't safe to trigger inside the test binary. --------- Co-authored-by: shiva <shiva@gosysinfo.tech>
* feat(components): auto-enforce core-module usage via init/SessionStart
Adds two non-consent components so agentflare init and every SessionStart
self-heal the setup needed to actually use flare-docs, flare-search, and
lean-ctx through the gateway, instead of relying on hand-run CLI commands:
- core-coaching: seeds/refreshes 4 builtin coaching rules (usedocs,
usesearch, useleanctx, usetsearch) that nudge the flare gateway's
docs/search/lean-ctx/tool-search wrappers over their native equivalents.
Drift-protected across version bumps; a same-id rule the user has
overridden to a different tier is left alone.
- gateway-permissions: keeps ~/.claude/settings.json's permissions.allow
containing the flare gateway tools (mcp__flare__docs, mcp__flare__search,
mcp__flare__tool, ToolSearch) and strips superseded direct
mcp__lean-ctx__* entries.
Also widens coaching::store::list_rules to pub(crate) so components.rs can
read existing rule state when deciding whether to seed or refresh.
* feat(site): add flare-docs documentation site, aube-linked monorepo
Adds docs-site/, an Astro Starlight project documenting the flare-docs
module (overview, supported languages, MCP tool reference, CLI reference,
examples, and a comparison against Context7), reusing the landing page's
exact design system (colors, fonts, mono headings) via customCss.
site/ and docs-site/ are linked as sibling packages in an aube-workspace.yaml
monorepo rooted at the repo root, with mise tasks (install/dev:site/dev:docs/
build:docs/deploy/tail) as the entry points. The two connect only at deploy
time: site's deploy script builds docs-site out-of-tree and copies its
output into site/public/docs before wrangler deploy, so day-to-day dev on
either package never touches the other.
* feat(flare-docs): Python (PyPI) ecosystem support
Adds Ecosystem::Python alongside Rust and npm: fetches a package's PyPI
manifest, unpacks its pure-Python wheel, and indexes whichever typed
source it carries — separate .pyi stubs, or (for PEP 561 inline-typed
packages like click and pandas) the package's own annotated .py source
with a py.typed marker — via a tree-sitter-python extraction pass
mirroring the existing npm/.d.ts module. Falls back to typeshed's
types-<package> convention on PyPI when a package ships neither.
Wired into the CLI (`agentflare docs get <pkg> --ecosystem python`) and
the MCP `docs` tool. Verified live against PyPI: requests correctly
falls back to types-requests; click indexes 470 items from its own
inline-typed source with no fallback needed.
* feat(flare-docs): index usage examples from npm/Python package docs
Fills the one real gap Rust didn't have: rustdoc-JSON already carries a
crate's doc-comment Examples verbatim, but npm and Python previously
discarded the README/long-description entirely, keeping only .d.ts/.pyi
signatures.
Adds a shared, LLM-free fenced-code-block extractor (crates/flare-docs/src
/readme.rs) reused by both ecosystems at zero extra network cost: npm scans
every .md file already inside the tarball it downloads for .d.ts, and
Python reads info.description straight out of the PyPI manifest JSON it
already fetches (markdown releases only -- RST is explicitly out of scope
rather than mis-parsed). Each block is titled by its nearest heading and
indexed as npm-example/python-example, reconciled the same way API items
are.
Filters two kinds of noise before indexing: examples under process/meta
headings (Installation, Contributing, License, Testing, ...), and
shell-flavored blocks that are pure setup commands (git clone, pip install)
even under an otherwise-good heading like "Quick Start" -- verified live
against requests' real PyPI description, which previously surfaced its
"Cloning the repository" git commands as if they were usage examples.
Also fixes a path-matching gap in both the new markdown-file filter and the
existing Python inline-typed-package filter: a top-level `test/` directory
(no leading slash after tarball-prefix stripping) wasn't caught by a
mid-path `contains("/test/")` check.
* docs(site): highlight flare-docs' verbatim examples vs Context7's LLM ones
Now that flare-docs indexes usage examples too, add a comparison row and
strengthen the "where flare-docs wins" case: examples are extracted
verbatim from the package's own docs, not synthesized by an LLM the way
Context7's snippets are -- so they can't drift from what the maintainer
actually wrote.
* fix(optimize): pace the batching nudge with doubling milestones instead of firing every call
Previously the batching_nudge fired on every single tool call once a streak
crossed the trailing-window threshold, spamming the same message. Now it
only fires at streak milestones (3, 6, 12, 24, ...), computed statelessly
from recent_tool_calls history.
* feat(coaching): MANDATORY tier — hard-deny tool calls that violate an enforced rule
Coaching rules were 100% advisory: a nudge in systemMessage the agent could
ignore. Adds an enforced flag (# Enforce: true header, coaching enforce
<id> [--off] CLI) that the PreToolUse hook checks before falling through to
advisory nudges — a match denies the call with a redirect-framed reason
instead of just suggesting the preferred tool.
Adopts lean-ctx's own PreToolUse mechanism as the reference (studied in
~/workspace/refs/lean-ctx): it substitutes tool input rather than denying,
which only works when the substitute is the same tool with different
input (e.g. a Bash command rewrite). None of agentflare's coaching rules
are same-tool swaps, so this instead reuses the existing deny-decision
shape from hook_redirect.rs, with the reason phrased as a redirect.
Of the four builtin rules, only usesearch (WebFetch/WebSearch) and
usetsearch (ToolSearch) are marked enforced by default: they're clean 1:1
substitutes with no fallback the mandatory tool can't cover. useleanctx
stays advisory since ctx_* can't handle every path/case native Bash/Read
can (hard-blocking risks a self-lockout), and usedocs stays advisory
because its trigger (Edit/Write) isn't a same-action substitute for
mcp__flare__docs — blocking edits to redirect to a docs lookup would just
break editing.
* fix(ci): satisfy clippy too_many_arguments and cargo fmt
write_rule_file crossed clippy's default 7-arg threshold when the enforced
param was added; allow it like the codebase's other multi-field
constructors. The fmt diff is pre-existing drift in flare-docs and the
coaching files from this branch's own history, not from this commit.
* fix(dashboard): log DB errors, validate cost by=, gate non-local bind, add HTTP tests
Closes the PR #255 review backlog. data.rs accessors silently returned
"[]" on any DB error, making a broken/missing DB indistinguishable from
genuinely empty state in the server log — now every error arm logs
before falling back. /api/cost?by= silently coerced unknown values to
model grouping instead of surfacing caller typos as a 400. Binding to a
non-loopback host exposed all PM/cost/webhook data with only an easy-to-
miss stderr warning — it now refuses to start unless --yes-expose is
passed. Also adds HTTP-layer test coverage for cost_handler, the /events
SSE stream, and static_handler that the module previously lacked.
* test(dashboard): cover run() serving normally on a local bind
Exercises the --yes-expose gate end-to-end for the exempt path: run()
with a 127.0.0.1 host and yes_expose=false actually binds and serves,
rather than only asserting the pure is_local_bind() helper. The
non-local refusal path isn't covered here since it calls
std::process::exit, which isn't safe to trigger inside the test binary.
* feat(agentflare-jobs): event-driven pickup, byte-count fix, log cleanup, test coverage
Supervisor/Queue/WorkerPool had zero test coverage and several gaps
before wiring into a real service: workers busy-polled every 200ms
instead of waking on enqueue, stdout/stderr_total_bytes always
reported 0 (missing DB columns), finished jobs and their log files
accumulated forever, and JobInfo never surfaced the job's command/args
to callers. Adds a Condvar-based notify with a bounded fallback poll,
an additive byte-count migration, an hourly-eligible cleanup that also
deletes log files, exposes command/args on JobInfo, and covers all of
it plus Supervisor's timeout/kill path with new tests.
* fix(daemon): enforce a single running dashboard instance
agentflare serve could be started multiple times concurrently on
different ports: the hidden --_foreground-daemon flag start_daemon()
tried to pass wasn't actually registered on the clap args, and
start_daemon() itself spawned the bare binary without the serve
subcommand, so daemon start silently failed to launch anything while
direct agentflare serve invocations never checked for an existing
instance at all. Adds the missing flag, fixes the spawn args to match
what the systemd/launchd units already invoke, and makes ServeArgs::run
check is_daemon_running()/write its own pid file so any invocation path
refuses to start a second instance.
* feat(dashboard): live job queue endpoints + Jobs page
Wires agentflare-jobs into the dashboard daemon: opens a Queue against
the same agentflare.db, starts a 2-worker pool, and schedules an hourly
sweep of finished jobs older than 7 days (including their log files).
Adds POST /api/jobs (submit), GET /api/jobs (fetch/list), GET
/api/jobs/events (SSE live job list) and GET /api/jobs/{id}/stream (SSE
incremental stdout tail, closing with event: done on terminal state).
Adds dashboard/web/jobs.html (list + live-tailed detail view, Alpine.js
matching the existing pages) and a Jobs nav link across all pages.
* fix(agentflare-jobs): key log files by job id so live tail actually works while running
Code review on the jobs-streaming PR found the live tail was completely
non-functional for in-progress jobs: jobs_stream_handler only opened the
stdout file once JobInfo.output was populated, which only happens after
a job reaches a terminal state, and Supervisor generated its own random
id for log filenames independent of the job's own queue id — so there
was no way to even locate a running job's log path. The stream would
sit idle the whole time a job ran, then dump everything in one lump the
instant it finished. Fixes this by having Supervisor::new take the
job's own id and name its log files from it, so jobs_stream_handler can
derive the path directly via queue.log_dir().join("{id}.stdout") without
waiting on completion. Adds a regression test that drives a real job
through a real WorkerPool and asserts output arrives while the job is
still 'running', not just after it exits — the previous test only
covered an already-finished job, which is exactly why this slipped
through.
Also fixes two smaller issues from the same review pass in
src/dashboard/server.rs: jobs_handler and the jobs_stream_handler 404
pre-check ran synchronous SQLite calls inline on the async path instead
of via spawn_blocking (inconsistent with the SSE handlers doing the
same calls correctly), and jobs_events_handler silently swallowed list
errors into an empty list instead of logging them. Also fixes
test_queue() dropping its TempDir immediately, which left log_dir
pointing at an already-deleted path.
* fix(daemon): close TOCTOU race between concurrent serve invocations
ServeArgs::run() checked is_daemon_running() then wrote its pid file as
two separate steps with nothing serializing them, so two direct
`agentflare serve` invocations (e.g. on different --port values)
started within moments of each other could both pass the check before
either had written its pid file, recreating the exact multi-instance
bug this singleton check exists to prevent. Adds a dedicated
short-timeout lock file for this check-then-write section. It has to
be a different lock from daemon_start_lock_path(): start_daemon()
holds that one for its whole ~5s spawn-and-poll window, and the
process it spawns calls back into this same check via
`serve --_foreground-daemon` — sharing one lock would deadlock the two.
* fix(dashboard): close stale job stream before selecting a new one
selectJob() opened a new EventSource without closing detailSource from
a previously selected job, so its events could keep appending into the
newly selected job's detailOutput. Not reachable through the current
UI (the list view is hidden while a job is selected), but a latent
resource leak and cross-talk bug for any future code path that calls
selectJob twice in a row.
* fix(ci): satisfy cargo fmt, clippy too_many_arguments, and a Windows test failure
Master's CI workflows (fmt, clippy -D warnings, and a full Windows
build+test job) never ran against this branch until the merge, and
surfaced three issues:
- cargo fmt had drifted (never run locally after the review-fix pass).
- Supervisor::new crossed clippy's 7-arg threshold once it gained the
id parameter; allowed like this codebase's other multi-field
constructors (see 7e56a9c).
- complete_persists_stdout_and_stderr_byte_counts and
stdout_and_stderr_are_captured_separately both asserted
stdout_total_bytes > 0 but used `echo hello 1>&1 & echo world 1>&2`
on Windows — cmd.exe's redirection parser doesn't treat N>&N as the
true no-op POSIX shells do, and can end up closing/breaking that
handle instead, leaving stdout empty. Dropping the always-redundant
`1>&1` (stdout is already fd 1) fixes it without changing what the
test verifies.
* fix(ci): replace Windows 'timeout /t' with a redirection-safe ping sleep
timeout /t exits instantly with 'INPUT REDIRECTION IS NOT SUPPORTED'
when stdin isn't a real console — which it never is under Supervisor
(Stdio::null()). Both the new still-running-job stream test and
timeout_kills_long_running_process used it as their Windows sleep
stand-in, so the whole job finished near-instantly on Windows instead
of actually running for the intended duration. Switches both to the
standard ping-against-loopback idiom, which doesn't touch stdin.
* fix(agentflare-jobs): close lost-wakeup race in wait_for_work/wake_workers
shutdown_returns_promptly_even_when_workers_are_idle flaked in CI
(1.0003s instead of <300ms) — notify_all() alone only wakes threads
already parked in wait_for, so a wake_workers() call landing between a
worker's dequeue-check and its wait_for_work call was silently lost
until the 1s fallback timeout. This was a real, if narrow, race in the
notify design, not just a CI timing fluke — low local contention just
made it rare enough to not reproduce.
Fixes it with the standard predicate+Condvar pattern: a pending-signal
bool guarded by the same lock, checked before ever blocking. A wake
that arrives first is observed immediately when wait_for_work runs;
one that arrives during the wait still notifies as before. No more
lost-wakeup window in either ordering. Verified with 15 repeated local
runs after the fix, all well under the timeout.
---------
Co-authored-by: shiva <shiva@gosysinfo.tech>
Summary
Adds
agentflare serve— an embedded, read-only, Plane-modeled web dashboard over the agentflare backend. This PR covers Phases 0–2 of the plan. Phase 3 (Gateway / Memory / Channels) is intentionally left for a follow-up.What's in it
Phase 0 — server scaffold
servesubcommand (--host/--port/--open); default port 35273 ("FLARE" on a phone keypad;0= auto-assign)OpenFlags::SQLITE_OPEN_READ_ONLY);/api/claimsPhase 1 — PM spine
/api/pm/{workspaces,projects,items,states,comments,labels}+/api/webhooks, each reusing an existingagentflare_backendlist_*fn (no new DTOs, no ORM)Phase 2 — Claims live + Cost + SSE
/api/cost?days=N&by=model|project— extractscost::summarizeso the CLI and dashboard share one freshly-synced code path/eventsSSE — pushes{ claims, cost_today }every ~2s (tokio-stream interval, first tick immediate)EventSource) and Cost view (range + group-by control)Plus: the refined visual design pulled back from the claude.ai/design round-trip, and the root
/now redirects to the Board (previously an unstyled Phase-0 scaffold).Architecture notes
src/dashboard/), not a separate crate, so it can reuse the binary-internalclaims/rollup/costcode plusagentflare_backend.rollup::open_or_rebuild, which writes only the analytics cache, never a source DB.src/dashboard/; endpoints call existinglist_*/queryfns, converting a non-reusable read path into a reusable fn in its home module when needed (e.g.cost::summarize).dashboard/web/, each with a@dsCardmarker; no bundler.Testing
cost_totals_to_jsonshaper andlive_snapshot_jsonunit tests.127.0.0.1:35273: SSE connects (live indicator), cost updates without a manual refresh, claims empty-state renders correctly, root redirect lands on the styled Board.Out of scope / follow-ups
Summary by CodeRabbit
servecommand to run the dashboard server with configurable host/port and optional auto-open./eventsstream powering Claims and (today/model) Cost updates; added per-workspace webhook delivery log history.