M4: close out Save Points, with browser verification
Closes M4. The review's three findings are fixed, the durability rule the specification always implied is now enforced, and M3's and M4's browser behaviour has been verified in a real browser for the first time. B-1 -- the Save Point list was an N+1 that loaded whole Action rows, narration included, to answer "does a row exist here". It is now one bulk two-column coordinate query plus one lineage: 53 SELECTs for 25 Save Points became 5, and the count no longer grows with the list. The clause is an OR of exact (branch, depth) pairs rather than two IN lists, because the cross product would report a Save Point resolved on the strength of another one's depth existing on this one's branch. A test builds exactly that trap. B-2 -- reclassified during closeout from "missing warning" to a behaviour defect, and fixed as one. STORY-BRANCH-SEMANTICS §19 says a named checkpoint remains until explicitly deleted, and §28 already required future cleanup to retain checkpoint-referenced paths; a cascade that silently removed Save Points with a branch violated both, and a warning would only have documented the violation. A branch a Save Point names can no longer be deleted. The request is refused with the offending Save Points named, the user deletes them explicitly -- which deletes no story -- and the branch then goes. The scope is the subtree, because deleting a branch takes its descendants. Both delete controls disable and explain. Recorded as a new §19.1; models.py, TECHNICAL-DESIGN §8.8 and DATA-MODEL §8 had all recorded the cascade as the rule and now record the refusal. An earlier pass in this same closeout had kept the cascade and added a warning. That was the wrong fix and its tests were replaced rather than left standing, since they pinned the defect. B-3 -- the D11/L03 automation never left one process, so it could not distinguish durable state from a live Python object. It now spawns real server processes, kills the first, and reads the campaign back with the second. C-5 -- creating a Save Point takes the campaign's turn lock. "Save where I am" has to name one committed position, and the head is what a turn in flight is about to move. Rename and Delete deliberately do not take it. The architecture is untouched: a Save Point is still name + note + (branch, depth), and restore is still coordinate -> head.move_to_node -> head.move_to -> attempts.restore_state. No second restore path, no state copied into a checkpoint, no fork on restore. Browser verification -- the first in this project, and it covers both milestones. Firefox 154.0.1 through geckodriver over the W3C WebDriver protocol, driving the rendered DOM: 47/47 checks, twice, on independent databases, no console errors. M3's Undo/Redo enable states, transcript movement, Retry and the take pager, divergence retiring Redo; M4's whole Save Point lifecycle, both confirmations, and the new branch-delete refusal including its recovery. No dependency was added: the WebDriver client is stdlib HTTP. No application defect was found by the browser. Four failures occurred, all in the harness -- a wrong SPA route, a wait comparing transcript length when the empty-story placeholder is longer than the first turn, a fixture deleting the branch it was reading, and a reload assertion that sampled once instead of waiting. The last was checked against the app before being called a harness bug. Tests: 698 backend pass (was 680), 60 M4, 94 M3 history, 66 export/ migrations, 93 security/local-only. Frontend lint and build clean, Docker build clean, loopback binding unchanged. No assertion weakened, no skip added. Planning: STORY-BRANCH-SEMANTICS §19.1 is the only behavioural change and it strengthens §19. V1-ACCEPTANCE-TESTS records D11-D14, I04, L03 and the E-series, keeping automated, live-runtime and browser evidence distinct, and weakens no pass condition. DATA-MODEL records the coordinate with the retry measurement that settles it. BROWSER-UX-SPEC rules for Moment over Turn. BUILD-MILESTONES marks M4 COMPLETE, closes M3's browser condition, and lists what M5 inherits. VERSION adds v2.6. No new ADR: ADR 005 already decides that history is preserved rather than overwritten, and §19.1 is that decision applied to checkpoint-referenced history. M4 is closed. M5 may now be briefed; it has not been started. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
This commit is contained in:
co-authored by
Claude Opus 5
parent
279a871a77
commit
62a997f364
@@ -23,7 +23,10 @@ The user-facing word is "Save Point" and the internal one is `checkpoint`
|
||||
Save Point.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ... import head, models, schemas
|
||||
@@ -64,18 +67,92 @@ def _node_at(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Coordinate:
|
||||
"""The shape `lineage.Path` reads, without loading a story row.
|
||||
|
||||
`Path.contains` asks three things of a node: its branch, its depth, and
|
||||
whether it is live. A coordinate already known to resolve has all three, so
|
||||
the membership question can be put to the coordinate itself. That keeps the
|
||||
single implementation of "is this on the path being read" in `lineage`,
|
||||
where M3 put it, while costing no query and no prose.
|
||||
"""
|
||||
|
||||
branch_id: int
|
||||
depth: int
|
||||
live: bool = True
|
||||
|
||||
|
||||
def _live_coordinates(
|
||||
db: Session, adventure: models.Adventure, checkpoints: list[models.Checkpoint]
|
||||
) -> set[tuple[int, int]]:
|
||||
"""Returns which of these Save Points' coordinates still name a live turn.
|
||||
|
||||
One query for the whole list, selecting two integer columns.
|
||||
|
||||
This replaces a resolution per Save Point (M4 review §R B-1), which cost one
|
||||
query each and loaded whole `Action` entities — narration included — to
|
||||
answer a question that is only ever "does a row exist here". `paging.py`
|
||||
states the rule this now follows: a bulk read names the columns it needs, so
|
||||
a new column costs nothing until someone adds it to the list.
|
||||
|
||||
The clause is an OR of exact `(branch, depth)` pairs rather than
|
||||
`branch IN (…) AND depth IN (…)`, which would match the cross product and
|
||||
report a Save Point as resolved because *some other* Save Point's depth
|
||||
exists on *this* one's branch.
|
||||
"""
|
||||
coordinates = {(c.branch_id, c.depth) for c in checkpoints}
|
||||
if not coordinates:
|
||||
return set()
|
||||
rows = (
|
||||
db.query(models.Action.branch_id, models.Action.depth)
|
||||
.filter(
|
||||
models.Action.adventure_id == adventure.id,
|
||||
models.Action.live.is_(True),
|
||||
or_(*[
|
||||
and_(models.Action.branch_id == branch, models.Action.depth == depth)
|
||||
for branch, depth in coordinates
|
||||
]),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return {(branch, depth) for branch, depth in rows}
|
||||
|
||||
|
||||
def _render_all(
|
||||
db: Session, adventure: models.Adventure, checkpoints: list[models.Checkpoint]
|
||||
) -> list[schemas.CheckpointOut]:
|
||||
"""Reads Save Points out with the three facts the panel needs about them.
|
||||
|
||||
Bounded work whatever the length of the list: one query for the coordinates
|
||||
and one lineage for the campaign, both computed before the loop. Rendering
|
||||
one Save Point and rendering fifty differ in Python, not in round trips.
|
||||
"""
|
||||
live = _live_coordinates(db, adventure, checkpoints)
|
||||
# The path is a property of the campaign, not of any Save Point, so it is
|
||||
# read once. Reading it per row was the other half of the N+1.
|
||||
path = lineage.path_of(db, adventure).uncapped()
|
||||
out = []
|
||||
for checkpoint in checkpoints:
|
||||
coordinate = (checkpoint.branch_id, checkpoint.depth)
|
||||
resolved = coordinate in live
|
||||
rendered = schemas.CheckpointOut.model_validate(checkpoint)
|
||||
# The same `depth + 1` the branch list counts with, so a moment number
|
||||
# means the same thing in both places.
|
||||
rendered.turn = checkpoint.depth + 1
|
||||
rendered.resolved = resolved
|
||||
rendered.on_path = resolved and path.contains(
|
||||
_Coordinate(checkpoint.branch_id, checkpoint.depth)
|
||||
)
|
||||
out.append(rendered)
|
||||
return out
|
||||
|
||||
|
||||
def _rendered(
|
||||
db: Session, adventure: models.Adventure, checkpoint: models.Checkpoint
|
||||
) -> schemas.CheckpointOut:
|
||||
"""Reads one Save Point out with the three facts the panel needs about it."""
|
||||
node = _node_at(db, adventure, checkpoint.branch_id, checkpoint.depth)
|
||||
out = schemas.CheckpointOut.model_validate(checkpoint)
|
||||
# The same `depth + 1` the branch list counts with, so "turn 42" means the
|
||||
# same thing in both places.
|
||||
out.turn = checkpoint.depth + 1
|
||||
out.resolved = node is not None
|
||||
out.on_path = node is not None and lineage.path_of(db, adventure).uncapped().contains(node)
|
||||
return out
|
||||
"""Reads one Save Point out, through the same path the list uses."""
|
||||
return _render_all(db, adventure, [checkpoint])[0]
|
||||
|
||||
|
||||
def _get_or_404(
|
||||
@@ -123,7 +200,7 @@ def list_checkpoints(
|
||||
.order_by(models.Checkpoint.created_at.desc(), models.Checkpoint.id.desc())
|
||||
.all()
|
||||
)
|
||||
return [_rendered(db, adventure, row) for row in rows]
|
||||
return _render_all(db, adventure, rows)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -132,6 +209,7 @@ def list_checkpoints(
|
||||
status_code=201,
|
||||
)
|
||||
def create_checkpoint(
|
||||
adventure_id: int,
|
||||
payload: schemas.CheckpointCreate,
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
@@ -148,22 +226,37 @@ def create_checkpoint(
|
||||
head resting in a shared prefix sits on an ancestor's node, and the
|
||||
ancestor is the branch that still names that position after the reader has
|
||||
forked away from it.
|
||||
|
||||
**Held under the campaign's turn lock** (M4 closeout, review §S C-5). "Save
|
||||
where I am" has to name one committed position, and the head is exactly what
|
||||
a turn in flight is about to move. Without the lock this endpoint could read
|
||||
`head_depth` while a turn was mid-commit and store a coordinate for a
|
||||
position the story had already left — a Save Point silently naming the wrong
|
||||
moment, which no later operation could detect. It is the same lock Undo,
|
||||
Redo and Restore take, for the same reason, and not a new mechanism.
|
||||
|
||||
Rename and Delete deliberately do **not** take it: neither reads nor moves a
|
||||
story position, so there is nothing for a turn in flight to race them over.
|
||||
"""
|
||||
name = _clean_name(payload.name)
|
||||
node = head.node_at(db, adventure, adventure.head_depth)
|
||||
if node is None:
|
||||
raise HTTPException(400, "There is no turn here to save yet.")
|
||||
checkpoint = models.Checkpoint(
|
||||
adventure_id=adventure.id,
|
||||
name=name,
|
||||
note=payload.note or "",
|
||||
branch_id=node.branch_id,
|
||||
depth=node.depth,
|
||||
)
|
||||
db.add(checkpoint)
|
||||
db.commit()
|
||||
db.refresh(checkpoint)
|
||||
return _rendered(db, adventure, checkpoint)
|
||||
turns.acquire_turn_lock(adventure_id)
|
||||
try:
|
||||
node = head.node_at(db, adventure, adventure.head_depth)
|
||||
if node is None:
|
||||
raise HTTPException(400, "There is no turn here to save yet.")
|
||||
checkpoint = models.Checkpoint(
|
||||
adventure_id=adventure.id,
|
||||
name=name,
|
||||
note=payload.note or "",
|
||||
branch_id=node.branch_id,
|
||||
depth=node.depth,
|
||||
)
|
||||
db.add(checkpoint)
|
||||
db.commit()
|
||||
db.refresh(checkpoint)
|
||||
return _rendered(db, adventure, checkpoint)
|
||||
finally:
|
||||
turns._active_turns.discard(adventure_id)
|
||||
|
||||
|
||||
@router.patch(
|
||||
|
||||
Reference in New Issue
Block a user