mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
480a1426b3
* fix(session): fail-closed history-commit handoff (#981) The deleted-workstream discovery is now a terminal, ws_id-keyed latch: keyed conversation commits refuse admission once the durable parent is gone (convergence finalizers and force-abandon are exempt), history handoff refuses to mint a proof token so /history fails closed with a 503 instead of silently wiping the pane, and the SSE stream carries a workstream_gone resync reason. Discarded commits leave a forensic log of commit keys and roles, never content. Conversation rows gain a commit_key (migration 071): keyed saves are idempotent under retry, validated against the full commit identity, and refused when they would cross a workstream deletion. The prune orphan category now requires a NULL alias plus a two-hour updated grace, with cutoffs computed at discovery time and carried into both dialects' rechecks. The mid-turn interjection queue is owner-partitioned with no per-site mode flags: pops take the acting principal's and unowned rows, other participants' rows are structurally retained, and enforcement lives at queue admission plus the shared before_spawn gates. The retraction ledger is bounded by open pop windows: pops open a window atomically with the queue delete, restores close their ids atomically with the ledger consume, every other exit closes through one helper, and misses for unheld ids record nothing. The workstream-gone latch refuses unattended wakes at all three gates (watcher spawn, claim, delivery pre-pop), and the retry dispatcher regained its pre-envelope cancel/error convergence net. Persistence-state reporting derives through the session bound to each UI instead of a registry lookup by id that failed open to healthy during tombstone retention. The dashboard roster no longer re-inserts ghost entries from trailing activity events, the history tool-outcome scan tolerates interleaved non-turn rows, and the shared handoff-deadline handle owns its own retirement. Single-sourced across call sites: keyed-commit row values, attachment save wrappers, tail-truncation and conflict-resolution bodies for both storage dialects; worker-slot lifecycle field sets; the direct-commit admission frame; queued-row layout accessors; the string-aware comment stripper shared by every JS harness suite. Refs #981 #964 * fix(session): sweep handoff fixes to their sibling surfaces The interactive replay loop treated a system row as a tool-batch boundary, so every tool result after an interleaved row vanished from that pane while the coordinator rendered the same history correctly. Only a conversational turn ends the batch window now, matching the shared outcome index. Accepted user turns clear the composer's attachment chips on the same viewer policy that settles optimistic bubbles rather than on having matched a local bubble, so a workstream created with an upload no longer keeps a chip for an attachment the create dispatch already consumed. The coordinator's raced-Stop arm emits the stream-end hook it inherits alongside the idle state, leaving no unfinalized bubble or unflushed tool output. Ending a session surfaces a failure toast when the request never lands or answers with a non-JSON body. The per-second persistence reconcile now probes each session without blocking: a workstream whose generation and handoff locks are held is skipped until the next pass instead of contending the locks every commit needs. The one-shot repair that gates workstream creation at capacity keeps a definite probe — it has no next pass, and the sessions likeliest to be contended are the ones whose unresolved journals emptied its candidate list. Single-sourced: the attachment lane builds its conversation row through the shared commit-identity builder; the ordinary worker exit releases its slot through the lifecycle owner; both operator surfaces snapshot their counters through one non-consuming helper; the replay preamble loses its per-kind wrappers and its config hook; the browser harness suites share one brace walker; and each in-flight history attempt is one record carrying both its abort controller and its deadline. Refs #981 #964
216 lines
9.7 KiB
Python
216 lines
9.7 KiB
Python
"""Static guards for the preview pane frontend (shared_static/preview.js and
|
||
its wiring through conversation.js / interactive.js / shell.js).
|
||
|
||
Same posture as ``test_shell_js.py``: Python-side string-presence assertions
|
||
that catch the silent one-line regression (a renamed export, a dropped
|
||
sandbox attribute, a de-registered pane type). Parse + sink + var guards for
|
||
``preview.js`` itself live in ``test_shell_js.py``'s bundle sweeps.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
_ROOT = Path(__file__).resolve().parent.parent
|
||
_SHARED = _ROOT / "turnstone/shared_static"
|
||
_PANE_JS = _SHARED / "pane.js"
|
||
_PREVIEW_JS = _SHARED / "preview.js"
|
||
_CONVERSATION_JS = _SHARED / "conversation.js"
|
||
_INTERACTIVE_JS = _SHARED / "interactive.js"
|
||
_SHELL_JS = _SHARED / "shell.js"
|
||
_PREVIEW_CSS = _SHARED / "preview.css"
|
||
_UI_INDEX = _ROOT / "turnstone/ui/static/index.html"
|
||
_CONSOLE_INDEX = _ROOT / "turnstone/console/static/index.html"
|
||
|
||
|
||
def _read(p: Path) -> str:
|
||
return p.read_text(encoding="utf-8")
|
||
|
||
|
||
class TestPreviewPaneModule:
|
||
def test_factory_exported(self) -> None:
|
||
assert "export function createPreviewPane" in _read(_PREVIEW_JS)
|
||
|
||
def test_web_iframe_is_fully_sandboxed(self) -> None:
|
||
"""The web renderer must keep the empty-sandbox attribute — every
|
||
capability (scripts, same-origin, forms, popups) stays off. Dropping
|
||
or loosening it turns fetched pages into live documents."""
|
||
body = _read(_PREVIEW_JS)
|
||
assert 'frame.setAttribute("sandbox", "")' in body
|
||
assert 'frame.setAttribute("referrerpolicy", "no-referrer")' in body
|
||
|
||
def test_pdf_iframe_is_not_sandboxed(self) -> None:
|
||
"""Deliberate asymmetry: Chromium's PDF viewer refuses to paint in a
|
||
sandboxed context. The renderer comment carries the rationale; this
|
||
pins that renderPdf never gained a sandbox attribute by copy-paste."""
|
||
body = _read(_PREVIEW_JS)
|
||
pdf_fn = body.split("const renderPdf")[1].split("const renderImage")[0]
|
||
assert "sandbox" not in pdf_fn or "No sandbox attribute" in pdf_fn
|
||
|
||
def test_content_loads_through_authfetch_probe(self) -> None:
|
||
"""src-loaded kinds preflight with a probe request (authFetch of
|
||
?probe=1), NOT a HEAD. The console reverse proxy forwards a HEAD as a
|
||
full GET, so a real HEAD would drag the whole blob across the hop just
|
||
to discard it; the probe still surfaces the persist race + auth
|
||
failures as a typed error card and rides the 401-refresh retry a bare
|
||
iframe/img src can't."""
|
||
body = _read(_PREVIEW_JS)
|
||
assert "authFetch(probeUrl)" in body
|
||
assert "probe=1" in body
|
||
# The old full-GET HEAD preflight is gone.
|
||
assert 'method: "HEAD"' not in body
|
||
|
||
def test_markdown_uses_the_sanctioned_html_lane(self) -> None:
|
||
body = _read(_PREVIEW_JS)
|
||
assert "setSafeHtml(doc, renderMarkdown(text))" in body
|
||
|
||
def test_markdown_runs_vendor_post_pass(self) -> None:
|
||
"""The pane runs renderer.js's post-render pass (hljs token coloring +
|
||
mermaid) like the conversation pane — dropping it silently regresses
|
||
code highlighting and diagram rendering in previews."""
|
||
body = _read(_PREVIEW_JS)
|
||
assert "postRenderMarkdown(" in body
|
||
|
||
def test_remote_assets_toggle_is_default_off(self) -> None:
|
||
"""The remote-assets opt-in defaults OFF: a previewed page must not
|
||
contact its origin site until the user asks. Pins the label / tooltip
|
||
copy and the sticky-boolean initializer."""
|
||
body = _read(_PREVIEW_JS)
|
||
assert "Load remote images & styles" in body
|
||
assert "Off keeps this preview from contacting the site" in body
|
||
assert "pane._assetsOn = false" in body
|
||
|
||
def test_assets_flag_only_rides_behind_toggle(self) -> None:
|
||
"""assets=1 reaches the URL only when the per-pane toggle is on."""
|
||
body = _read(_PREVIEW_JS)
|
||
assert "assets=1" in body
|
||
assert "pane._assetsOn" in body
|
||
|
||
def test_history_is_bounded(self) -> None:
|
||
assert "HISTORY_CAP" in _read(_PREVIEW_JS)
|
||
|
||
def test_table_renderer_caps_rows(self) -> None:
|
||
assert "TABLE_ROW_CAP" in _read(_PREVIEW_JS)
|
||
|
||
def test_url_builder_encodes_path_parts(self) -> None:
|
||
body = _read(_PREVIEW_JS)
|
||
assert "encodeURIComponent(ws)" in body
|
||
assert 'encodeURIComponent(descriptor.attachment_id || "")' in body
|
||
|
||
|
||
class TestTranscriptChip:
|
||
def test_chip_builder_exported(self) -> None:
|
||
assert "export function buildPreviewChip" in _read(_CONVERSATION_JS)
|
||
|
||
def test_live_path_gates_auto_open_on_focus(self) -> None:
|
||
"""A backgrounded session must not commandeer the split — the live
|
||
path auto-opens only while the originating pane is focused; the chip
|
||
is the deliberate reopen everywhere else."""
|
||
body = _read(_INTERACTIVE_JS)
|
||
assert "!accepted && !isError && this._host.isFocused(this)" in body
|
||
assert "this._host.onPreview(preview);" in body
|
||
|
||
def test_replay_path_renders_chip_without_auto_open(self) -> None:
|
||
body = _read(_INTERACTIVE_JS)
|
||
# The replay branch builds the chip…
|
||
assert "buildPreviewChip(msg.preview" in body
|
||
# …and the auto-open call appears exactly once (the live path).
|
||
assert body.count("this._host.onPreview(preview)") == 1
|
||
|
||
def test_tool_result_event_passes_preview(self) -> None:
|
||
assert "evt.preview," in _read(_INTERACTIVE_JS)
|
||
|
||
def test_host_bridge_carries_transport_ctx(self) -> None:
|
||
"""The preview pane fetches blobs from the ORIGINATING workstream
|
||
through the same node proxy — the bridge must pass both base and
|
||
wsId, not just the descriptor."""
|
||
body = _read(_INTERACTIVE_JS)
|
||
assert "window.TS_SHELL.openPreview(descriptor, { base: base, wsId: wsId })" in body
|
||
|
||
|
||
class TestShellWiring:
|
||
def test_pane_type_registered(self) -> None:
|
||
body = _read(_SHELL_JS)
|
||
assert 'pm.registerType("preview"' in body
|
||
assert "createPreviewPane" in body
|
||
|
||
def test_opens_beside_the_conversation(self) -> None:
|
||
"""openPaneBeside is the load-bearing gesture — the preview coexists
|
||
with the conversation that spawned it instead of replacing it."""
|
||
body = _read(_SHELL_JS)
|
||
assert 'pm.openPaneBeside("preview")' in body
|
||
|
||
def test_seam_exported_on_ts_shell(self) -> None:
|
||
assert "openPreview," in _read(_SHELL_JS)
|
||
|
||
|
||
class TestStylesheets:
|
||
def test_both_surfaces_link_preview_css(self) -> None:
|
||
for page in (_UI_INDEX, _CONSOLE_INDEX):
|
||
assert "/shared/preview.css" in _read(page), page.name
|
||
|
||
def test_stylesheet_uses_ds_tokens_not_legacy_vars(self) -> None:
|
||
"""conv-* card rule: DS tokens only — chat.css legacy vars
|
||
(--green/--red/--fg) must not creep into the new sheet."""
|
||
body = _read(_PREVIEW_CSS)
|
||
assert "var(--ink-" in body
|
||
assert "var(--hair)" in body
|
||
for legacy in ("var(--green)", "var(--red)", "var(--fg)"):
|
||
assert legacy not in body
|
||
|
||
|
||
class TestEphemeralDismiss:
|
||
"""The preview is an ephemeral pane: dismissing its split cell CLOSES it
|
||
(tab and content gone) instead of parking an orphan tab whose only reopen
|
||
is the transcript chip. Regression guard for the pane/tab desync."""
|
||
|
||
def test_preview_pane_is_ephemeral(self) -> None:
|
||
"""createPreviewPane must flag the pane ephemeral — the whole fix keys
|
||
off this bit."""
|
||
body = _read(_PREVIEW_JS)
|
||
assert "ephemeral: true" in body, "the preview pane must declare itself ephemeral"
|
||
|
||
def test_shellpane_carries_the_ephemeral_flag(self) -> None:
|
||
body = _read(_PANE_JS)
|
||
assert "this.ephemeral = opts.ephemeral || false;" in body, (
|
||
"ShellPane must accept and default the ephemeral flag"
|
||
)
|
||
|
||
def test_cell_chip_closes_ephemeral_pane_outright(self) -> None:
|
||
"""In a split the ✕ chip normally HIDES the cell (closeCell); for an
|
||
ephemeral pane it must fall through to close() — the `!pane.ephemeral`
|
||
guard is what routes it there. Pin BOTH the guard and where the
|
||
skipped case lands (the else), or gutting the else regresses the fix
|
||
while the guard string survives verbatim."""
|
||
body = _read(_PANE_JS)
|
||
assert "if (this._layout && this._leafFor(pane.id) && !pane.ephemeral)" in body, (
|
||
"the cell chip must skip closeCell for an ephemeral pane"
|
||
)
|
||
assert "else this.close(pane.id);" in body, (
|
||
"the skipped (ephemeral / single-pane) case must land on close()"
|
||
)
|
||
|
||
def test_cell_chip_signals_destruction_for_ephemeral(self) -> None:
|
||
"""The glyph/label must not lie: an ephemeral pane's split chip reads
|
||
as a destructive close (✕ + danger hover + 'Close pane'), never the
|
||
reversible '− / Hide from split'."""
|
||
body = _read(_PANE_JS)
|
||
assert "const destroys = !multi || pane.ephemeral;" in body, (
|
||
"chip mode must treat ephemeral panes as destructive even in a split"
|
||
)
|
||
|
||
def test_unsplit_closes_ephemeral_non_survivors(self) -> None:
|
||
"""Collapsing the split from the OTHER pane must not orphan the preview
|
||
either — unsplit closes ephemeral panes it isn't keeping."""
|
||
body = _read(_PANE_JS)
|
||
assert "const keep = this._activeId;" in body, (
|
||
"the unsplit survivor must be the FOCUSED pane — the filter's "
|
||
"`id !== keep` guard is only correct if keep is _activeId"
|
||
)
|
||
assert "for (const id of doomed) this.close(id);" in body, (
|
||
"unsplit must destroy ephemeral panes it does not keep"
|
||
)
|
||
assert "return id !== keep && p && p.ephemeral;" in body, (
|
||
"unsplit must spare the focused survivor and non-ephemeral panes"
|
||
)
|