Skip to content

fix: control plane and UI fixes - #1171

Open
santoshkumarradha wants to merge 12 commits into
mainfrom
fix/control-plane-and-ui-fixes
Open

santoshkumarradha wants to merge 12 commits into
mainfrom
fix/control-plane-and-ui-fixes

Conversation

@santoshkumarradha

@santoshkumarradha santoshkumarradha commented Nov 9, 2026 •

Copy link
Copy Markdown
Member

Summary

All fixes in one PR, for one review. It replaces #1169, #1170 and #1172, which are closed in favour of this one; their commits are merged here unchanged.

Fix What was broken Originally
Execution VC created_at stored in UTC, plus a one-time SQLite rewrite of old rows On SQLite, VC created_at was written in the host's local zone while the VC filters bind a UTC bound and compare text, so on hosts west of UTC a fresh VC fell out of created_after and rows written under different offsets sorted out of order; the API returned local-zone timestamps #1170
API key on direct fetch calls (Integrations, Triggers, trigger sheet/dialog/event replay, step approvals, DAG sidebar, retry panel, DID bundle download) With AGENTFIELD_API_KEY set, pages that call fetch directly sent no X-API-Key and got 401 #1169
Playground replay unwraps the stored {"input": …} envelope Replaying a run sent {"input":{"input":{…}}}, so the reasoner received none of its arguments and ran on defaults #1169
Playground "View run" opens the run The button navigated to /runs/<execution_id>, which is a 404 #1169
Runs list picks its page before aggregating; default page read from a new activity index /api/ui/v2/workflow-runs grouped every execution in the table on each request: ~285 ms with no traffic and ~440 ms p50 under traffic on a 203k-execution database. Now ~25 ms and ~56 ms #1172
Live refreshes coalesced; a running run polls every 10 s while the stream is connected Each execution event restarted the runs list, dashboard, DAG and step queries with cancelRefetch, so under load the Runs page made ~30 overlapping list requests in 15 s #1172
Run page fetches vc-chain?view=summary The run page downloaded the whole VC chain (1.2 MB for a 1,500-step run) to show a verifiability warning and the issuer #1172
Execution started_at stored in UTC On SQLite, a start time not in UTC was compared as local-zone text, so it dropped out of a UTC since filter and sorted by the wrong instant #1172
fix/control-plane-and-ui-fixes
├── merge fix/execution-vc-utc-timestamps   (#1170)
├── merge fix/web-direct-fetch-auth         (#1169)
└── merge fix/runs-list-under-load          (#1172)

No conflicts between the three.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature
  • Breaking change
  • Documentation update

Evidence

End-to-end run: embedded server built from each branch (go build -tags embedded), fresh home directory, AGENTFIELD_STORAGE_MODE=local, AGENTFIELD_API_KEY set, host zone America/Toronto (UTC-5), two agents from examples/python_agent_nodes (permission_agent_b, hello_world), calls executed through /api/v1/execute, UI driven in headless Chrome.

VC timestamps

Before (main binary, fresh database):

sqlite> select created_at from execution_vcs;
2026-11-09 09:27:26.74224661-05:00
GET /api/ui/v1/executions/<id>/vc  -> "created_at": "2026-11-09T09:27:26.74224661-05:00"

After (this branch started on the same database main created):

log: {"rows":3,"message":"Rewrote execution VC created_at to UTC"}
sqlite> select created_at from execution_vcs;
2026-11-09 14:27:26.74224661+00:00          (same instant)
GET /api/ui/v1/executions/<id>/vc  -> "created_at": "2026-11-09T14:27:26.74224661Z"
new execution on this build        -> 2026-11-09 14:28:04.781845814+00:00
second restart                     -> no rewrite log line; schema_migrations has exactly one execution_vcs_utc_created_at row

Runs list and run page under traffic

Measured on a copy of a busy local SQLite database: 4.2 GB, about 24,000 runs and 203,000 executions. Live traffic came from a real agent through /api/v1/execute/async: one 1,500-step run (six steps in parallel) plus a new 5-step run about every 0.66 s. The machine's load average was about 20 from unrelated work, so these are noisy-box numbers.

GET /api/ui/v2/workflow-runs?page=1&page_size=25&sort_by=updated_at&sort_order=desc

Before After
no traffic, p50 285 ms 25 ms
under traffic, p50 / p90 / max (20 samples) 441 / 509 / 549 ms 56 / 67 / 73 ms
QueryRunSummaries alone, default page 320 ms 20 ms (most of it the COUNT(DISTINCT run_id) total)
QueryRunSummaries, other sort columns ~320 ms 35–135 ms

The per-run lookups in the handler (run metadata and trigger) were measured at about 1–3 ms for a page of 25, so they were left as they are.

Network trace, 15 s on each page under traffic (headless Chrome, /api requests only)

Runs page:

before coalescing (measured earlier on the same data):
  ~30 x workflow-runs in 15 s, overlapping and cancelled, 0.3 s -> 2.7 s each
coalesced, old query:
  9 x workflow-runs   332-446 ms, 18 KB each
coalesced, new query:
  9 x workflow-runs    45-101 ms, 18 KB each
  plus dashboard/summary x1, nodes/summary x1, llm/health x3, queue/status x3, one event stream

Run page of the 1,500-step run while it runs:

before:
  dag?mode=lightweight   16 ms   143 KB
  vc-chain               31 ms  1220 KB      <- whole chain, fetched on every page open
  dag?mode=lightweight   x7 more in 15 s (one per coalesced flush), 11-18 ms, ~150-180 KB
after:
  dag?mode=lightweight   20 ms   135 KB
  vc-chain?view=summary  45 ms     1 KB
  dag?mode=lightweight   x6 more in 15 s, 11-23 ms, ~140-170 KB
  executions/:id/details x2, nodes/summary x1, llm/health x3, queue/status x3, one event stream

For a 322-step run, the full chain was 766 KB.

Test plan (runs list, UTC start time, VC summary)

  • go build ./...
  • go vet ./internal/storage ./internal/handlers/ui
  • go test ./internal/storage/ ./internal/handlers/ui/ ./internal/server/ -count=1
  • POSTGRES_TEST_URL=... go test ./internal/storage -run Postgres -count=1 against pgvector/pgvector:pg17, including the new PostgreSQL page comparison
  • New TestQueryRunSummariesPagesMatchTheSingleGroupedQuery compares every sort column, both directions, ten filters and five page windows against the previous single grouped query. It fails if either tie-breaker is reversed.
  • New TestRecentRunIDsByActivityWidensPastBusyRuns, TestRunActivityIndexServesTheRecentRunsScan
  • New TestExecutionStartedAtIsStoredInUTC fails on main (a non-UTC start drops out of a UTC since filter) and passes here
  • New TestUIWorkflowVCChainSummaryViewOmitsPerStepCredentials
  • web/client: npx tsc --noEmit -p tsconfig.app.json, npx vitest run (141 files, 840 tests), npm run build

Integrations page with an API key

/api/v1/sources /api/v1/triggers Page
Before 401 401 HTTP 401: {"error":"unauthorized",…}
After 200 200 loads, no error

Run detail → Replay → Execute → View run

Replay input box Stored execution input Result View run
Before {"input": {"limit": 3, "query": "SELECT 3"}} {"input":{"input":{"limit":3,"query":"SELECT 3"}}} ran on defaults: "query":"SELECT *","limit":100 /ui/runs/exec_… → HTTP error! status: 404
After {"limit": 3, "query": "SELECT 3"} {"input":{"limit":3,"query":"SELECT 3"}} "query":"SELECT 3","limit":3 /ui/runs/run_… → run page renders

Test plan

  • cd control-plane && go build ./...
  • go vet ./...
  • go test ./internal/storage/ ./internal/services/ ./internal/handlers/... -count=1 (re-run after merging perf(control-plane): fast Runs list and run page under live traffic #1172)
  • cd web/client && npx tsc --noEmit -p tsconfig.app.json
  • npx vitest run (141 files, 840 tests passed, after merging perf(control-plane): fast Runs list and run page under live traffic #1172)
  • npm run build
  • go build -tags embedded ./cmd/agentfield-server, run on a fresh home with an API key, two example agents registered, executions via /api/v1/execute
  • Headless Chrome: Integrations loads, Replay → Execute succeeds with the replayed arguments, View run opens the run
  • Started this build on a database created by main's binary; rows rewritten to UTC once; second restart is a no-op

Merge Danger

Door: one-way — #1170 rewrites existing SQLite execution_vcs.created_at values to UTC on first start (same instant, different spelling; not reverted by downgrading). The #1169 web changes are two-way.

The #1172 changes are two-way: the new executions index is additive, and view=summary is opt-in.

Blast Radius: low — one column of one table on SQLite only (Postgres untouched), recorded in schema_migrations so it runs once; the web changes add a header to direct fetches and fix two Playground code paths. #1172 changes the query behind the Runs list, and on a large PostgreSQL executions table the first start builds one more index.

…und input

Several UI calls use fetch directly instead of the shared client and never send X-API-Key, so with API key auth on they fail with 401: the Integrations and Triggers pages, the trigger sheet, new-trigger dialog and event replay, step approval actions, the workflow graph's step details, execution retry, and the DID resolution bundle download. Two of them read the key from storage slots the app never writes (localStorage agentfield_api_key, sessionStorage apiKey). They now use one authHeaders() helper backed by the key the app signed in with.

Replaying a run in the Playground pre-filled the stored request body, which is the whole {"input": {...}} envelope; Execute wrapped it again, so the reasoner received input.input and rejected the call with missing required fields. The envelope is now removed when the box is filled from a replay or a recent run, and before execute.
The execute response carries both execution_id and run_id; the Playground preferred execution_id, so View run went to /runs/<execution id>, which is a 404. It now prefers run_id.
StoreExecutionVC and StoreExecutionVCRecord wrote created_at with
time.Now() in the host's zone. SQLite keeps it as text, and the
created_after/created_before filters bind a UTC bound, so on a host west of
UTC a VC created a moment ago sorted before a bound in the past and was
filtered out; ORDER BY created_at likewise mixed rows written under
different offsets.

Writers now store UTC, and a one-time startup pass on SQLite rewrites rows
an older build stored in the host's zone, keeping each row's instant.
Postgres columns are timestamptz and are unaffected.
@santoshkumarradha
santoshkumarradha requested review from a team and AbirAbbas as code owners November 9, 2026 14:31
Every execution event invalidated the runs list, the dashboard summary, the
run DAG and the step detail at once, with cancelRefetch on, so under steady
traffic the Runs page restarted its list request dozens of times a minute and
each cancelled request still ran to completion on the server. Invalidations
are now coalesced (at most one flush per two seconds, the first immediate,
without cancelling an in-flight fetch), and a running run polls every ten
seconds as a safety net while the event stream is connected.
QueryRunSummaries grouped every execution in the table, computing every
summary column for every run, and then kept one page. It now chooses the
page's run_ids first, computing only the sort key, and aggregates just those
runs with the same filters. The default page (no filters, latest activity
first) is read from a new expression index on
COALESCE(updated_at, started_at) and stops as soon as the page is complete.
run_id breaks sort ties so page boundaries no longer move between requests.
Tests compare every sort column, direction, filter and page against the
previous single grouped query.
SQLite compares started_at as the text the driver wrote, and the driver writes
a time in the zone it carries. An execution whose start time was not UTC was
filtered and sorted as though it started at a different instant; a since
bound given in UTC dropped it. Insert and update now write the UTC instant.
Rows already stored are unchanged.
The run page fetched the whole workflow VC chain, one signed credential per
step, to show its verifiability warning and issuer DID: about 1.2 MB for a
1500-step run, on every page open. GET /api/ui/v1/workflows/:id/vc-chain
accepts view=summary, which returns the same status, step counts,
verifiability and workflow issuer without component_vcs, the workflow
credential's document or the DID resolution bundle. The default response is
unchanged, and the audit download still fetches the full chain.
@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 88.10% 87.40% ↑ +0.70 pp 🟡
sdk-go 93.30% 92.00% ↑ +1.30 pp 🟢
sdk-python 94.77% 93.73% ↑ +1.04 pp 🟢
sdk-typescript 92.44% 90.42% ↑ +2.02 pp 🟢
web-ui 84.89% 84.79% ↑ +0.10 pp 🟡
aggregate 86.07% 85.75% ↑ +0.32 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 249 80.00% ✅
sdk-go 0 — ➖ no changes
sdk-python 0 — ➖ no changes
sdk-typescript 0 — ➖ no changes
web-ui 52 84.00% ✅

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

agentfield-bot and others added 2 commits November 9, 2026 20:35
CodeQL flagged runSummaryPageRunIDs for allocating a slice whose capacity
came from the request-supplied limit. The caller clamps it, but CodeQL does
not see a reassignment clamp across the call. The slice now grows by append.

Assisted-by: CodeAF
Co-Authored-By: CodeAF <267109073+agentfield-bot@users.noreply.github.com>

This branch has not been deployed

No deployments
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.

3 participants