Skip to content

feat: editable run and session names - #4

Merged
Timo972 merged 4 commits into
mainfrom
feat/editable-run-and-session-names
Aug 24, 2026
Merged

feat: editable run and session names#4
Timo972 merged 4 commits into
mainfrom
feat/editable-run-and-session-names

Conversation

@Timo972

@Timo972 Timo972 commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Run and session names are set at creation today and can never be changed. This makes both editable from their detail pages.

API

Two new endpoints:

  • PATCH /api/sessions/{id}editSession
  • PATCH /api/runs/{id}editRun

They are named editX rather than after the single property they change, and their request bodies (EditSessionRequest, EditRunRequest) declare every property as optional, so both can grow to cover further editable properties without an operation rename or a breaking schema change. name is the only property today; a payload that sets none of them is rejected as a 400 rather than silently succeeding as a no-op.

Validation mirrors session creation exactly: trimmed, non-empty, at most 100 runes. Both routes go through the existing body-size cap and a strict field allowlist (unknown fields, typos, trailing data, and null are refused rather than silently dropped). The store-layer methods stay precise about what they actually do (Rename, RenameRun) — only the HTTP boundary uses the generic edit name.

Renaming a run

A run's variant sessions are named <run name> (axis=value) when the run is created, so a run rename would otherwise leave its variants carrying the old name.

Whether a variant follows the rename is decided by recorded provenance, not by comparing its current name against the name the run would generate. sampling_sessions gains a name_customized column: false for a run child (generated at creation), true for a standalone session and for any session renamed afterwards. The cascade updates only rows where it is false.

Text comparison was the obvious approach and is wrong: a name a person chose can coincide with what a later run name generates. Rename a child to Beta (region=eu), then rename its run AlphaBetaGamma, and the comparison reclassifies the custom name as generated and overwrites it. Provenance is not guessable from the text, so it is stored.

Before (run AlphaBeta) After
Alpha (region=eu) (generated) Beta (region=eu)
Alpha (region=us) (generated) Beta (region=us)
My custom probe (renamed by hand) My custom probe
Beta (region=eu) (renamed by hand) Beta (region=eu)

The migration backfills existing rows the only way available — a child whose name still matches what its run's current name generates is recorded as generated, anything else as customized. An integration test runs the migration's own statements against rows reset to the column default, so that SQL cannot drift from VariantName unnoticed.

This also required moving variantName out of internal/api into internal/variation as exported VariantName, since internal/db needs it and cannot import the API package.

Concurrency

The cascade's child update is conditional in its WHERE clause, so a session rename that commits between the cascade's read and its write is preserved rather than overwritten — it sets name_customized, and the re-evaluated WHERE then matches no row.

Writing a deterministic test for that interleaving surfaced a second problem. The cascade originally took SELECT … FOR UPDATE on the run row, which conflicts with the KEY SHARE lock a child row update needs for its run_id foreign key — deadlocking against a concurrent session rename holding the child's row lock. Renaming never changes the run's key, so the plain rename UPDATE (which takes the weaker FOR NO KEY UPDATE) both serializes concurrent run renames and leaves that foreign-key lock free.

UI

A new EditableTitle component in the session and run detail headers. Click the title to edit in place; Enter or blur saves, Escape discards. An unchanged or empty name is not sent (the API would reject it), and a failed save keeps the typed text on screen with the error surfaced beside it rather than dropping it. Run variants are covered too, since they link out to /sessions/:id.

Interaction with the sampler

Renaming never touches sampling state, so it is valid in any status. A running worker's snapshot write cannot clobber a rename — UpdateSessionSnapshot does not carry the name column. An integration test pins this by renaming a running session and then saving a tick.

Testing

  • Handler tests for both endpoints: happy path, whitespace trimming, empty / whitespace-only / over-100-rune rejection, the empty-payload case, a 100-rune boundary using multi-byte characters, oversized bodies, unknown and typo'd fields, trailing and concatenated JSON, and 404s.
  • Postgres integration tests for the cascade rule, the coinciding-custom-name case, the concurrent-rename interleaving (forced with a row lock, not timed), the migration backfill, unknown-run and unknown-session errors, and the sampler-tick guard. Each was watched failing before the implementation was restored.
  • Component tests for EditableTitle (Enter, Escape, blur, unchanged, emptied, maxLength, pending, failed save) and page-level tests asserting the PATCH body and the refetched name.

Verified on this branch: go test -race ./... exits 0 across all 13 packages against a real Postgres, make web-test passes 102/102, make generate-check exits 0 on a clean tree, and tsc and go vet are clean. The migration was applied and rolled back to check both directions.

Note on the build output: the large shared vendor chunk is now named editable-title-*.js instead of risk-histogram-*.js. That is Rollup renaming the shared chunk as the module graph shifted — same recharts bundle, roughly 1.2 kB larger overall.

🤖 Generated with Claude Code

Timo972 and others added 4 commits August 24, 2026 11:39
Adds PATCH /api/sessions/{id} and PATCH /api/runs/{id}, named editSession
and editRun so they can grow to cover further editable properties. Both
request bodies declare every property as optional; a payload that sets
none is rejected rather than silently succeeding as a no-op.

Renaming a run cascades to the child sessions that still carry the name
generated from the run's previous name, in the same transaction and under
a row lock. A variant renamed by hand no longer matches that generated
name and is left alone. This moves variantName out of internal/api into
internal/variation as VariantName, since internal/db needs it and cannot
import the API package.

In the UI, the session and run detail headers become editable in place:
Enter or blur saves, Escape discards, an unchanged or empty name is not
sent, and a failed save keeps the typed text on screen with the error
beside it.

Renaming never touches sampling state, so it is valid in any status; a
worker's snapshot write does not carry the name column, which an
integration test now pins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cap and validate both PATCH bodies. The body-size cap and the strict
field allowlist only covered the two POST creation routes, so an edit
request was buffered whole by the generated decoder before any handler
validation ran, and unknown or trailing data was silently dropped despite
additionalProperties:false — {"name":"Beta","nmae":"x"} renamed
successfully. validateCreateSessionRequest becomes validateRequestBody
and now covers every route that accepts a body.

Make the cascade's child update conditional on the name it read, so a
session rename committing between that read and the write is preserved
instead of overwritten. The Go-side check alone left a window open.

Weaken the run lock from FOR UPDATE to FOR NO KEY UPDATE. Renaming never
changes the run's key, and FOR UPDATE blocked the KEY SHARE lock a child
row update takes for its run_id foreign key, deadlocking the cascade
against a concurrent session rename that already held the child's row
lock. The weaker mode still serializes run renames against each other.

The new integration test forces that interleaving rather than timing it:
a second transaction holds the child's row lock so the cascade blocks
mid-write, then renames the child and commits.

Also scopes the run-page stat assertion to the summary region. An
unscoped /40/ matched the "Last updated …" clock too, so the test failed
whenever the current minute or second was 40.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comparing a child's stored name against the name its run would generate
misclassifies a name a person chose that happens to equal what some later
run name generates. Renaming a child to "Beta (region=eu)", then renaming
its run Alpha -> Beta -> Gamma, made the comparison treat the custom name
as generated and overwrite it with "Gamma (region=eu)".

sampling_sessions gains name_customized: false for a run child, whose
name is generated at creation, true for a standalone session and for any
session renamed afterwards. The cascade now filters on that column in the
statement's WHERE clause, which also keeps the concurrency guard the
text comparison provided — a session rename committing mid-cascade sets
the flag, so the re-evaluated WHERE matches no row.

With provenance stored, the cascade no longer needs the previous run
name, so the separate lock-and-read of the run row is gone; the rename
UPDATE takes the same row lock and reports whether the run existed.

The migration backfills existing rows the only way available: a child
whose name still matches what its run's current name generates is
recorded as generated, anything else as customized. An integration test
runs the migration's own statements against rows reset to the column
default, so that SQL cannot drift from VariantName unnoticed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The provenance backfill reconstructs a generated name with ORDER BY over
the param keys, which uses the cluster's collation, while VariantName
sorts with Go's bytewise sort.Strings. On a locale-aware cluster such as
en_US.UTF-8 the two disagree: {"Z":"1","a":"2"} reconstructs as "a=2,Z=1"
rather than "Z=1,a=2", so the migration records a generated child as
customized and it stops following later run renames. The ORDER BY now
uses COLLATE "C", and the migration test covers mixed-case and non-ASCII
keys — both of which failed against an en_US.UTF-8 cluster beforehand.

The title input capped its value with the HTML maxLength attribute, which
counts UTF-16 code units, while the API's limit is 100 Unicode runes. A
name of non-BMP characters was cut off at 50 emoji even though the API
accepts 100. The input now clamps by code point instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Timo972
Timo972 merged commit 2f6757f into main Aug 24, 2026
8 checks passed
@Timo972
Timo972 deleted the feat/editable-run-and-session-names branch August 24, 2026 15:34
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