feat: editable run and session names - #4
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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}—editSessionPATCH /api/runs/{id}—editRunThey are named
editXrather 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.nameis the only property today; a payload that sets none of them is rejected as a400rather 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
nullare refused rather than silently dropped). The store-layer methods stay precise about what they actually do (Rename,RenameRun) — only the HTTP boundary uses the genericeditname.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_sessionsgains aname_customizedcolumn:falsefor a run child (generated at creation),truefor a standalone session and for any session renamed afterwards. The cascade updates only rows where it isfalse.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 runAlpha→Beta→Gamma, and the comparison reclassifies the custom name as generated and overwrites it. Provenance is not guessable from the text, so it is stored.Alpha→Beta)Alpha (region=eu)(generated)Beta (region=eu)Alpha (region=us)(generated)Beta (region=us)My custom probe(renamed by hand)My custom probeBeta (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
VariantNameunnoticed.This also required moving
variantNameout ofinternal/apiintointernal/variationas exportedVariantName, sinceinternal/dbneeds it and cannot import the API package.Concurrency
The cascade's child update is conditional in its
WHEREclause, so a session rename that commits between the cascade's read and its write is preserved rather than overwritten — it setsname_customized, and the re-evaluatedWHEREthen matches no row.Writing a deterministic test for that interleaving surfaced a second problem. The cascade originally took
SELECT … FOR UPDATEon the run row, which conflicts with theKEY SHARElock a child row update needs for itsrun_idforeign key — deadlocking against a concurrent session rename holding the child's row lock. Renaming never changes the run's key, so the plain renameUPDATE(which takes the weakerFOR NO KEY UPDATE) both serializes concurrent run renames and leaves that foreign-key lock free.UI
A new
EditableTitlecomponent 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 —
UpdateSessionSnapshotdoes not carry thenamecolumn. An integration test pins this by renaming a running session and then saving a tick.Testing
EditableTitle(Enter, Escape, blur, unchanged, emptied, maxLength, pending, failed save) and page-level tests asserting thePATCHbody and the refetched name.Verified on this branch:
go test -race ./...exits 0 across all 13 packages against a real Postgres,make web-testpasses 102/102,make generate-checkexits 0 on a clean tree, andtscandgo vetare 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-*.jsinstead ofrisk-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