feat(steps): edit on selected timeline — promote-and-mutate for inherited steps#161
Conversation
…ited steps
When a fork shows an inherited step and the user clicks Save, the
server now promotes that step to an owned step on the fork (or
in-place edits it if already owned), cascade-deleting only the
fork's downstream — instead of creating an unrelated sub-fork off
the wrong parent.
Server:
- PATCH /api/steps/{id}/edit accepts optional target_timeline_id
to route the edit to a different timeline (promote path). The
main-protection guard now checks the effective timeline, not the
step's physical owner.
- New upsert_step_on_timeline_and_cascade store helper: single tx,
explicit 18-column INSERT that preserves step_type + tool_name
(avoids the constructor bug from PR #159 review).
- PatchStepResponse now returns resolved_step_id (the actual owned
step) so the frontend can invalidate the right cache entry.
- GET /api/steps/{id}/cascade-count accepts ?target_timeline_id so
the confirm modal shows the fork's count, not the parent's.
Frontend:
- useStepEdit accepts contextTimelineId (from selectedTimelineId in
useStore) and routes onMain + PATCH + cascade-count through it.
- StepDetailPanel falls back to root timeline (not step.timeline_id)
when selectedTimelineId is null, preventing re-trigger of the
original bug on direct-URL navigation.
Tests: 7 new Rust integration tests (promote, idempotent re-edit,
visibility 400, main-protection target, step_type preservation,
cascade-count target-aware). Version: 0.14.6 (Track 1).
Made-with: Cursor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
risjai
left a comment
There was a problem hiding this comment.
Code review (with local repro)
Built the binary at HEAD and exercised it on a fresh local store. Cargo test 65/65, npm test 183/183. Functional happy path works. One HIGH-severity correctness/integrity bug confirmed live, and several gaps from the plan.
HIGH 1 — Visibility check matches by step_number, not step.id → cross-fork mutation by request
crates/rewind-web/src/api.rs:1669:
if !visible.iter().any(|s| s.step_number == step.step_number) {
return Err((StatusCode::BAD_REQUEST, format!("step #{} not visible …", …)));
}The check passes whenever the target timeline has any step at the same step_number — not necessarily the same step. So if two unrelated forks both own a step at step_number=N, you can clobber one fork's content by PATCHing the other's step with target_timeline_id set to the victim.
Live repro on the PR's binary (just ran on 127.0.0.1:4810):
# Setup: 2 main steps, two parallel forks both at_step=2,
# each fork records its own step #1 with distinct content.
A3=<fork_a's owned step id> # response_body: {"owned-by": "fork-A"}
B3=<fork_b's owned step id> # response_body: {"owned-by": "fork-B"}
# Attack: edit A3 but target fork_b
curl -X PATCH /api/steps/$A3/edit -d '{
"response_body": {"sentinel":"CROSS_FORK_MUTATION"},
"target_timeline_id": "<fork_b>"
}'
# → HTTP 200, resolved_step_id = $B3
# Aftermath:
GET /api/steps/$B3 → response_body: {"sentinel":"CROSS_FORK_MUTATION"} ← OVERWRITTEN
GET /api/steps/$A3 → response_body: {"owned-by":"fork-A"} ← untouchedExisting test_patch_promote_visibility_400 doesn't catch this because it constructs forks at different at_step values (2 and 4) so the step_numbers don't overlap. With overlapping step_numbers (the common case for parallel forks off the same point), the visibility check rubber-stamps the cross-fork write and the upsert "in-place edits the existing owned step at #N" — which is the wrong row.
Fix: change the check to match by id:
if !visible.iter().any(|s| s.id == step.id) {
return Err((StatusCode::BAD_REQUEST, format!(
"step '{}' (id={}) is not visible on target timeline '{}' …",
step.step_number, step.id, effective_timeline,
)));
}This catches the cross-fork case because fork_b's union view contains B3.id, not A3.id.
Add a regression test that mirrors my repro: two parallel forks both owning step #N with different content; PATCH fork_a.stepN with target=fork_b → expect 400. Without that test, this same bug class will recur.
HIGH 2 — cascade_count skips visibility & session validation; arbitrary cross-session probe
crates/rewind-web/src/api.rs:1712-1738:
let effective_timeline = q.target_timeline_id
.as_deref()
.unwrap_or(&step.timeline_id);
let count = store.count_steps_after(effective_timeline, step.step_number)?;
let on_main = store.is_main_timeline(effective_timeline)?;The PATCH handler validates that effective_timeline is in the same session and the step is visible on it. The dry-run handler skips both checks. Consequences:
- An operator can probe the downstream count of any timeline in any session by passing its UUID — no auth tied to ownership.
- The modal's cascade-count value can be wildly wrong (it returns the foreign timeline's downstream count instead of "what would be deleted on confirm"), which is exactly the HIGH 1 finding from the plan review that the implementation was supposed to fix.
The plan explicitly called out: "Validate: effective_timeline must exist in the same session AND the step must be visible on it. Re-use the same validators as patch_step." That validation is missing — the new query param landed without the gate.
Fix: mirror the PATCH handler's two checks (timeline-in-session + step-visible-on-target), reusing the same engine call. Add a test that GET cascade-count with a foreign-session timeline returns 400.
MEDIUM 1 — No frontend tests added
PR's diff for web/:
StepDetailPanel.tsx | 10 ++++++++++
use-step-edit.ts | 26 +++++++++++++++++++-------
lib/api.ts | 10 ++++++----
StepDetailPanel.test.tsx is unchanged. The plan's M8 listed three frontend tests that were the whole point of the screenshot-bug fix:
- Inherited-on-fork shows "Save edit" modal (not "Create fork and edit")
- PATCH carries
target_timeline_id - Null-
selectedTimelineIdfalls back to root, NOTstep.timeline_id
None of these are added. The screenshot bug fix isn't actually verified at the React level — npm test going 183/183 just means the existing tests still pass. A future regression that re-introduces step.timeline_id as the fallback or drops target_timeline_id from the payload would slip through.
MEDIUM 2 — Visibility-check Vec materialization on every PATCH
Same perf concern from the PR #160 review (S1) but now with a wider blast radius — it runs on every promote-PATCH, which is the dashboard's default save path. For sessions with hundreds of steps, the handler loads the entire union into a Vec<Step> just to check existence. Plan said "shares the recursive-walk limitation noted in PR #160 review" — same SQL helper that fixes that should also turn this into a point lookup.
Acceptable for v0.14.6; flag for the recursive-walk follow-up.
LOW 1 — Plan said the StoreEvent::StepUpdated payload now carries resolved_step_id (good), but the WS transport still drops it
The handler at api.rs:1687-1692 emits StoreEvent::StepUpdated { step_id: resolved_step_id, … } — good, that's the right wiring. But the WS handler in crates/rewind-web/src/ws.rs (left as None from the v0.14.4 fix) still doesn't forward StepUpdated to subscribers. So multi-tab sync still relies entirely on the in-tab react-query invalidation. Not a regression from this PR, but worth a mention since the resolved_step_id work is wasted on the wire.
LOW 2 — useStepEdit has an unused-arg pattern smell
timelineId is now only consumed to compute promoted = contextTimelineId !== timelineId. If contextTimelineId always equals the user's selection, this is fine — but if the panel ever passes the same value for both (defensive bug), promoted becomes false silently and the user re-experiences the "saves on the wrong timeline" issue with no error. Consider adding a debug-only assertion or renaming timelineId to physicalTimelineId to make the intent explicit.
LOW 3 — onMain returns false when sessionDetail hasn't loaded yet
web/src/hooks/use-step-edit.ts:44-48:
const onMain = (() => {
if (!sessionDetail) return false
…
})()If a user manages to hit Save before sessionDetail resolves (rare with react-query but possible on first paint), onMain is false → PATCH path runs → with selectedTimelineId === null, contextTimelineId is step.timeline_id (per the fallback) → for an inherited step this is main → server returns 409 (correct) → user sees the error toast. Functional but confusing. Consider gating Save's disabled state on sessionDetail.isSuccess to avoid the race entirely.
NIT — Test name test_patch_promote_visibility_400 reads as "tests visibility check works" but actually documents the gap
After HIGH 1 above, that test's pass becomes misleading: it proves only that non-overlapping step_number triggers 400, not that cross-fork visibility is enforced. Either rename to test_patch_promote_disjoint_step_numbers_rejected or strengthen it to also cover overlapping-step_number cross-fork (which is what HIGH 1 wants).
NIT — target_timeline_id query param naming inconsistent with body field
PATCH body: target_timeline_id. GET cascade-count query: also target_timeline_id. Frontend lib param: targetTimelineId. Consistent names, but worth documenting the convention in use-cases/ray-agent/DEPLOYMENT-GAPS.md "Step Editing" section since the API has now grown a meaningful new contract.
What works (verified live)
- v0.14.6 binary builds clean;
/api/healthreports{"status":"ok","version":"0.14.6","allow_main_edits":false}. cargo test -p rewind-web --test recording_api_tests→ 65/65 pass.npm test→ 183/183 pass.- Server endpoints: PATCH happy path, cascade-count target-aware (count differs based on
?target_timeline_id=), idempotent re-edit returns sameresolved_step_id, main-protection follows target. - Frontend wiring:
StepDetailPanelreadsselectedTimelineIdfrom the store and passes a sensible fallback chain (selectedTimelineId ?? rootTimelineId ?? step.timeline_id).
Verdict
Block on HIGH 1. The cross-fork mutation is a correctness + integrity bug — operators can silently corrupt unrelated forks' data via a request the server validates as legal. Repro is one curl command on the PR's binary. Fix is a single character change in the visibility predicate (step_number → id) plus a regression test.
HIGH 2 is a 5-line gate addition (validate target is in session + visible) that the plan explicitly required.
MEDIUM 1 (frontend tests) is the difference between "the bug is fixed" and "the fix is verified to stay fixed." Without the React tests, future refactors will silently re-introduce the screenshot bug.
The rest are follow-up-grade. After HIGH 1+2 and MEDIUM 1 land, this is mergeable.
Code review (with local repro)Built the binary at HEAD and exercised it on a fresh local store. Cargo test 65/65, npm test 183/183. Functional happy path works. One HIGH-severity correctness/integrity bug confirmed live, and several gaps from the plan. HIGH 1 — Visibility check matches by
|
HIGH 1: visibility check used step_number instead of step.id, allowing cross-fork writes when two parallel forks both own a step at the same step_number. Changing the predicate to match by id ensures the specific step being edited is actually part of the target timeline's visible set. Regression test mirrors the live repro from the review. HIGH 2: cascade_count handler accepted target_timeline_id without validating it belongs to the same session or that the step is visible on it. Added the same timeline-in-session + step-visible gates that patch_step uses. Regression test verifies cross-session cascade-count is rejected. 67/67 recording_api_tests pass; clippy clean. Made-with: Cursor
…spatch carries at_step (#163) * fix(replay): route step-action buttons through selected timeline + dispatch carries at_step Two related bugs surfaced in dev1 testing on session ray-agent-85551571: Bug A — Three step-action buttons (Fork, Set up replay, Run replay) sourced their action from `step.timeline_id` (the step's physical owner) instead of the user's selected timeline. Same class of bug PR #161 fixed for the Edit button. Live repro: forked at step 1, edited the user message, clicked Run replay → resulting fork was parented to main, not to the user's edit fork. The agent re-ran the original question instead of the edited one. Fix: use the existing `contextTimelineId` (selectedTimelineId ?? rootTimelineId ?? step.timeline_id) for all three modal props. Bug B (server + SDK side) — The dispatch payload doesn't carry `at_step`, so runners can't drive multi-turn replay. The runner handler in ray-agent today re-asks the session's first user message; for sessions with multiple conversation turns, edits to user messages in turn 2+ are silently ignored. Fix (this PR — server + SDK only): the rewind dispatch payload now includes `at_step` (sourced from the timeline's `fork_at_step` = the user-clicked step). The SDK's DispatchPayload mirrors the new field. The companion ray-agent PR will use it to fetch source timeline steps 1..at_step-1, reconstruct conversation history, and invoke the agent at the right turn. Server changes: - crates/rewind-web/src/dispatcher.rs — DispatchBody adds at_step:u32 + docstring + wire-format example - crates/rewind-web/src/runners.rs — create_replay_job resolves at_step (NewContext: from request; ReuseContext: from timeline's fork_at_step), passes through to dispatcher.dispatch SDK changes: - python/rewind_agent/runner.py — DispatchPayload adds at_step:Optional[int]=None with docstring; from_json reads it with .get() for back-compat (older servers default to None) Frontend changes: - web/src/components/StepDetailPanel.tsx — three step.timeline_id references replaced with contextTimelineId Tests: - replay_jobs_tests: +2 (dispatch_payload_carries_at_step for shape A, dispatch_payload_at_step_for_reuse_context_reads_fork_at_step for shape B) - test_runner.py: +3 (decodes_at_step, defaults_to_none_for_back_compat, tolerates_extra_unknown_keys — pins forward-compat for older runners hitting newer servers) - StepDetailPanel.test.tsx: +3 (Fork/ReplaySetup/ReplayJob receive selectedTimelineId when set); existing fallback test (data-tid = step.timeline_id when selectedTimelineId is null) kept as the fallback-path coverage Counts: rewind-web 22 replay_jobs_tests (was 20), 68 recording tests (unchanged), web 186 (was 183), python 32 (was 29). Track 1 version bump 0.14.7 → 0.14.8 across the 5 files. Track 2 (rewind-agent SDK) 0.15.1 → 0.15.2, MCP 0.13.6 → 0.13.7. Docs: docs/runners.md gains a "Dispatch wire format" section documenting at_step + the timeline-context contract for the four step-action buttons. Companion PR (out of scope, tracked as follow-up): ray-agent runner reads payload.at_step, reconstructs conversation history, invokes agent at the right turn. Made-with: Cursor * docs(dispatcher): clarify at_step vs from_step in DispatchBody (review #163 L1) Made-with: Cursor
Summary
fork_and_edit_stependpoint remains for the explicit "Create a sub-fork" workflow; the dashboard's default save flow now usesPATCHwithtarget_timeline_idChanges
upsert_step_on_timeline_and_cascade— single-tx promote with explicit 18-column INSERT/api/steps/{id}/edittarget_timeline_idfield routes edit to a different timeline; main-protection follows the effective timeline; returnsresolved_step_id/api/steps/{id}/cascade-count?target_timeline_idquery param so the confirm modal shows the fork's count, not the parent'suseStepEdithookcontextTimelineId; routesonMain, PATCH payload, and cascade-count through itStepDetailPanelstep.timeline_id) when no timeline is selectedTest plan
-D warningsMade with Cursor