Skip to content

feat(control-plane): client-settable run display name, labels and links - #1032

Merged
AbirAbbas merged 9 commits into
mainfrom
fix/ext-run-metadata
Aug 31, 2026
Merged

feat(control-plane): client-settable run display name, labels and links#1032
AbirAbbas merged 9 commits into
mainfrom
fix/ext-run-metadata

Conversation

@AbirAbbas

Copy link
Copy Markdown
Contributor

Stacked on #1025 — merge that first; this branch contains its two commits and rebases clean once it lands.

Summary

The v1 of first-class run identity from the #944 discussion: display_name, labels and links as a run namespace in workflow_runs.metadata. POST /api/v1/runs/:run_id/metadata read-merge-writes the namespace (never the full-row upsert, which clobbers golden/lineage metadata and resets state_version/last_event_sequence), creates the carrier row on first write, and enforces the caps from that thread server-side: display_name ≤ 200, ≤ 20 labels × ≤ 64 chars, ≤ 10 links with URL ≤ 2048, http/https only, host required, no embedded credentials. An optional run_metadata field on async execute seeds it at dispatch — and stays out of the replay dedupe key, proven by a test that replays through the real findReplayHit path. The run list, run detail, DAG and the agentic overview all surface it (so af wait's polling endpoint carries run identity), and the dashboard renders display names, label chips and scheme-revalidated links with rel="noopener noreferrer". external_status is deliberately deferred, per the issue thread.

Why

Refs #944. The design shape was settled publicly in the issue; this implements exactly that reduced v1. One correction to the earlier comment discovered while building: an ordinary run has no workflow_runs row (only restarts and golden saves created one), so the endpoint seeds the row on first write instead of assuming it exists.

Changes

Validation contract

  • Metadata written via the endpoint fans out to run list, UI detail, DAG and agentic overview → TestSetRunMetadataHandlerRoundTripAndRejectsBeforeWrite, TestWorkflowRunListAndDetailCarryRunMetadataAlongsideLineage, TestWorkflowDAGRunMetadataBothModes, TestRunOverviewRunMetadataPresenceAndEnvelopeLocation
  • First write creates the workflow_runs row; state/status/count/version columns never change → TestUpdateWorkflowRunMetadataCreatesAndMergesWithoutChangingState
  • Untouched namespaces (lineage, golden) stay byte-identical across a run-namespace merge → storage byte-identity tests
  • Two executes differing only in run_metadata still replay-hit → TestExecuteHandler_RunMetadataDifferenceStillReturnsReplayHit
  • Every cap and unsafe URL form (javascript:, data:, file:, scheme-less, embedded credentials) rejected at the endpoint with storage untouched → table-driven negative matrix
  • Concurrent writers to different namespaces both survive, on the SQLite path (Postgres uses a conflict-safe seed + locked re-read; live-Postgres run documented as not covered — no integration harness exists in-tree) → concurrent-write tests
  • Restart lineage now writes through the same namespace primitive; interleaved lineage seed + metadata merge preserves both → lineage race test
  • Missing run/execution → 404, nothing created → handler tests
  • Restarted runs do not inherit metadata → TestRunMetadataIsNotInheritedByARestartedRun
  • UI: display-name precedence, chips, link scheme re-validation at render → RunsPage/RunDetailPage/safeExternalUrl tests

How it was tested

CI-literal gates in the worktree: control-plane build/gofmt/vet/full suite (-tags sqlite_fts5, minus internal/packages), web-ui npm ci/lint/build/vitest coverage, coverage-surface + patch-coverage-gate (≥80 % on touched lines) — see the gate log summary in the checks. An adversarial review ran mid-flight; all four blocking findings it raised (Postgres first-write race, SQLite deferred-transaction upgrade hazard, restart lineage still using the full-row upsert, and a replay test that never executed findReplayHit) are fixed in the final commits.

Notes / follow-ups

  • The _txlock=immediate DSN change is global to the control plane's SQLite connections — deliberate (write reservation at BEGIN avoids read→write upgrade deadlocks; WAL + 60 s busy timeout were already set) and gated by the full suite, but worth a reviewer's eye.
  • external_status (mutable, integration-owned lifecycle) is the deferred second slice from Proposal: first-class run display names, labels, links, and external status #944.
  • SDK helpers for the endpoint are not included; it's plain HTTP for now.
  • Web-client npm run lint reports 440 pre-existing errors repo-wide on main; the touched files carry exactly the same per-file error counts before and after this change (verified file-by-file), and lint is not a CI gate for the web client. No new lint debt added.

🤖 Generated with Claude Code

AbirAbbas and others added 7 commits August 31, 2026 16:06
…adata

POST /api/ui/v2/workflow-runs/:run_id/golden wrote the caller-supplied name
and tag list into workflow_runs.metadata with no bounds at all. The name was
only TrimSpace'd, so a 1 MiB name persisted verbatim; sanitizeStringList
trimmed and de-duped but capped neither the entry count nor the entry length,
and it preallocated its output slice (and an unbounded de-dupe map) straight
from the attacker-controlled input length. That row is re-read and
re-serialised on every runs-list page that contains the run, so both are
stored amplification vectors (#944).

Cap tags at 20 entries of at most 64 runes each, and truncate the name at 200
runes. Over-long tags are dropped rather than truncated: byte-slicing can land
mid-rune and json.Marshal silently rewrites the invalid UTF-8 to U+FFFD.
Lengths are counted with utf8.RuneCountInString so a 64-rune CJK tag survives.
The output slice and de-dupe map are now sized min(len(values), maxCount) and
the loop stops once maxCount survivors are collected, so a multi-million-entry
tag array cannot force a large allocation before the cap applies.

This route is UI-private and its only caller sends one hard-coded tag, so
oversized input is bounded silently rather than rejected — a 400 would break
the existing "Save as golden run" button. The name fallback is unchanged: an
empty name still falls back to run_id, and run_id itself is not truncated.

Forward-only. Nothing re-validates on read, so rows that already hold
oversized golden metadata keep reading back exactly as they do today.

The caps are named constants so the follow-up run-metadata endpoint can reuse
the same bounds and the same helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y reads

Adds the behaviour tests for the golden-run caps:

- sanitizeStringList: 100 inputs cap to the first 20; a 65-rune ASCII entry is
  dropped while its 64-rune neighbour survives; a 64-rune CJK entry is kept
  byte-identical (proving it is not truncated into U+FFFD) while a 65-rune one
  is dropped; the legacy trim/de-dupe/order behaviour is pinned unchanged; the
  maxCount<=0 and maxRunes<=0 guards are exercised.
- A 100k-entry input asserts cap(out) <= 20, which is the allocation contract —
  a length assertion alone would still pass with the old preallocation.
- truncateRunes: cut on a rune boundary, result always valid UTF-8 with no
  replacement character, and the non-positive cap guard.
- Handler round-trip: a POST with 50 tags plus one over-long tag stores exactly
  20, a 1 MiB name stores 200 runes and keeps the whole metadata blob under
  4 KiB, and a blank name still falls back to run_id.
- Read-back: a pre-seeded row holding 50 tags and a 500-rune name still
  surfaces in full on both the runs list and the run detail, pinning that this
  change is forward-only and read paths do not re-validate.

The two pre-existing golden-route tests are left untouched as the
behaviour-unchanged regression guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A 'run' namespace in workflow_runs.metadata, written only through a
namespace-merging transactional primitive (never the full-row upsert, which
clobbers golden/lineage and resets state columns). POST
/api/v1/runs/:run_id/metadata read-merge-writes it with strict caps
(display_name<=200, labels<=20x64, links<=10, url<=2048, http/https only, no
embedded credentials) and creates the carrier row on first write; an optional
run_metadata execute field seeds it at dispatch, excluded from the replay
dedupe key; restart lineage now writes through the same primitive. The run
list, run detail, DAG and agentic overview surface it. external_status is
deliberately out of scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Write transactions now take the write reservation at BEGIN instead of on
first write, so the read-merge-write metadata primitive (and every other
BeginTx writer) cannot hit the read->write upgrade deadlock; WAL and the 60s
busy timeout were already in place. Global, deliberate change — the full
suite gates it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… links

Display name takes precedence in the run list row, labels render as chips,
links render only after scheme re-validation (http/https, host required)
with rel="noopener noreferrer"; nothing is treated as trusted HTML.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-matrix coverage

Concurrent different-namespace writers both survive; lineage seed and
metadata merge interleave without clobbering; two executes differing only in
run_metadata replay-hit through the real findReplayHit path; byte-identity
of untouched namespaces; endpoint-level negatives for every cap and for
javascript:/data:/file:/credentialed/scheme-less URLs with storage untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 31, 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 87.70% 87.40% ↑ +0.30 pp 🟡
sdk-go 93.10% 92.00% ↑ +1.10 pp 🟢
sdk-python 94.31% 93.73% ↑ +0.58 pp 🟢
sdk-typescript 91.68% 90.42% ↑ +1.26 pp 🟢
web-ui 84.76% 84.79% ↓ -0.03 pp 🟡
aggregate 85.84% 85.75% ↑ +0.09 pp 🟡

✅ Gate passed

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

@github-actions

github-actions Bot commented Aug 31, 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 398 92.00%
sdk-go 0 ➖ no changes
sdk-python 0 ➖ no changes
sdk-typescript 0 ➖ no changes
web-ui 19 94.00%

✅ Patch gate passed

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

@AbirAbbas
AbirAbbas merged commit c2df003 into main Aug 31, 2026
27 checks passed
@AbirAbbas
AbirAbbas deleted the fix/ext-run-metadata branch August 31, 2026 22:29
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