Skip to content

feat: read-only dashboard (agentflare serve) — Phases 0–2 - #255

Merged
getappz merged 30 commits into
masterfrom
feat/dashboard-design
Jul 18, 2026
Merged

feat: read-only dashboard (agentflare serve) — Phases 0–2#255
getappz merged 30 commits into
masterfrom
feat/dashboard-design

Conversation

@getappz

@getappz getappz commented Jul 18, 2026

Copy link
Copy Markdown
Owner

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

  • serve subcommand (--host / --port / --open); default port 35273 ("FLARE" on a phone keypad; 0 = auto-assign)
  • axum + tower-http + rust-embed; Alpine.js frontend, no build step
  • Read-only rusqlite (OpenFlags::SQLITE_OPEN_READ_ONLY); /api/claims

Phase 1 — PM spine

  • /api/pm/{workspaces,projects,items,states,comments,labels} + /api/webhooks, each reusing an existing agentflare_backend list_* fn (no new DTOs, no ORM)
  • Board, List, Item-detail, and Webhooks views; shared shell chrome + design tokens (light + dark)

Phase 2 — Claims live + Cost + SSE

  • /api/cost?days=N&by=model|project — extracts cost::summarize so the CLI and dashboard share one freshly-synced code path
  • /events SSE — pushes { claims, cost_today } every ~2s (tokio-stream interval, first tick immediate)
  • Live Claims view (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

  • The dashboard is a module in the main crate (src/dashboard/), not a separate crate, so it can reuse the binary-internal claims / rollup / cost code plus agentflare_backend.
  • Read-only, structurally: every source DB is opened read-only and there are no write/POST/PUT/DELETE handlers. The cost path uses rollup::open_or_rebuild, which writes only the analytics cache, never a source DB.
  • Reuse-first: no query logic is duplicated in src/dashboard/; endpoints call existing list_*/query fns, converting a non-reusable read path into a reusable fn in its home module when needed (e.g. cost::summarize).
  • One self-contained HTML file per view under dashboard/web/, each with a @dsCard marker; no bundler.

Testing

  • Full suite: 628 passed, 0 failed (1 ignored). Dashboard module 12/12, including the cost_totals_to_json shaper and live_snapshot_json unit tests.
  • Live views browser-verified on 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

  • Phase 3 — Gateway / Memory / Channels views.
  • The new Claims and Cost views + their sidebar nav entries are not yet synced back to the claude.ai/design project.

Summary by CodeRabbit

  • New Features
    • Added a read-only agentflare dashboard with Board, List, Item, Webhooks, Claims, and Cost pages, plus shared shell navigation and styling.
    • Introduced a CLI serve command to run the dashboard server with configurable host/port and optional auto-open.
    • Added a live /events stream powering Claims and (today/model) Cost updates; added per-workspace webhook delivery log history.
  • Bug Fixes
    • Improved API resilience by returning safe empty results when dashboard data can’t be loaded or parsed.
  • Tests
    • Expanded webhook history and dashboard JSON coverage, including a server endpoint integration check.

shiva added 23 commits July 18, 2026 13:30
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.
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds 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 serve CLI command, webhook history listing, and reusable cost summaries.

Changes

Dashboard

Layer / File(s) Summary
Read-only data and reporting
crates/agentflare-backend/src/webhook.rs, src/dashboard/data.rs, src/cost.rs
Adds scoped read-only JSON access, webhook history listing, cost aggregation, live snapshots, and tests.
Server and CLI startup
Cargo.toml, src/dashboard/*, src/cli/*, src/main.rs
Adds Axum routes, SSE broadcasting, embedded asset serving, browser launching, and the serve command.
Shared shell and styling
dashboard/web/shell.*, dashboard/web/tokens.css, dashboard/web/vendor/alpine.js, dashboard/web/index.html
Adds navigation and scope resolution, Alpine runtime assets, design tokens, shared styles, and root redirection.
PM views
dashboard/web/board.html, dashboard/web/list.html, dashboard/web/item.html
Adds board, sortable list, and item-detail pages with state, item, and comment loading.
Live operational views
dashboard/web/claims.html, dashboard/web/cost.html, dashboard/web/webhooks.html
Adds live claims updates, cost range/grouping views, and sortable expandable webhook delivery logs.

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
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The summary is strong, but the required Test plan checklist and Notes for reviewers sections from the template are missing. Add the Test plan checklist with the three commands and include Notes for reviewers covering risk areas and backwards compatibility.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: adding a read-only dashboard via agentflare serve for Phases 0–2.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/dashboard-design

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (1)
dashboard/web/tokens.css (1)

64-67: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Resolve the reported Stylelint errors.

Normalize the flagged identifiers (blinkmacsystemfont, roboto, helvetica, arial, sfmono-regular, menlo, consolas, optimizelegibility, and currentcolor) 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

📥 Commits

Reviewing files that changed from the base of the PR and between 0dd1c5a and 672d11f.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (20)
  • Cargo.toml
  • crates/agentflare-backend/src/webhook.rs
  • dashboard/web/board.html
  • dashboard/web/claims.html
  • dashboard/web/cost.html
  • dashboard/web/index.html
  • dashboard/web/item.html
  • dashboard/web/list.html
  • dashboard/web/shell.html
  • dashboard/web/shell.js
  • dashboard/web/tokens.css
  • dashboard/web/vendor/alpine.js
  • dashboard/web/webhooks.html
  • src/cli/mod.rs
  • src/cli/serve.rs
  • src/cost.rs
  • src/dashboard/data.rs
  • src/dashboard/mod.rs
  • src/dashboard/server.rs
  • src/main.rs

Comment on lines +264 to +273
/// 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<_, _>>()?)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 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.

Comment thread dashboard/web/cost.html
Comment on lines +161 to +171
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment thread dashboard/web/index.html
Comment on lines +8 to +12
<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');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
<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.

Comment thread dashboard/web/list.html
Comment on lines +79 to +88
<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)">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment thread dashboard/web/shell.html
duplicated per page.
-->
<div class="af-app">
<aside class="af-sidebar" x-data="shellNav()" x-init="init()">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 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.")
PY

Repository: 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-L22
  • dashboard/web/board.html#L14-L14 and #L64-L68
  • dashboard/web/list.html#L14-L14 and #L64-L68
  • dashboard/web/item.html#L14-L14 and #L64-L68
📍 Affects 4 files
  • dashboard/web/shell.html#L22-L22 (this comment)
  • dashboard/web/board.html#L14-L14
  • dashboard/web/board.html#L64-L68
  • dashboard/web/list.html#L14-L14
  • dashboard/web/list.html#L64-L68
  • dashboard/web/item.html#L14-L14
  • dashboard/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.

Comment thread dashboard/web/shell.js
Comment on lines +48 to +61
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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.

Comment on lines +79 to +96
<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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment thread src/dashboard/data.rs
Comment on lines +15 to +24
/// 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(),
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment thread src/dashboard/server.rs
Comment thread src/dashboard/server.rs Outdated
Comment on lines +162 to +175
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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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.

shiva added 5 commits July 18, 2026 19:27
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.
@getappz getappz changed the title Read-only dashboard (agentflare serve) — Phases 0–2 feat: read-only dashboard (agentflare serve) — Phases 0–2 Jul 18, 2026
@getappz
getappz merged commit 6cda051 into master Jul 18, 2026
17 checks passed
@getappz
getappz deleted the feat/dashboard-design branch July 18, 2026 14:54
getappz added a commit that referenced this pull request Jul 25, 2026
…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>
getappz added a commit that referenced this pull request Jul 29, 2026
…, 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>
getappz added a commit that referenced this pull request Jul 30, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant