mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 07:22:24 -06:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e11929ba0 | |||
| d9e9a41b17 | |||
| 848b2cc1fb | |||
| 1946002618 | |||
| 4bce6abc7c | |||
| c19432f12a |
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.6.0"
|
||||
version = "1.6.1"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -285,6 +285,38 @@ def test_system_turn_dedups_against_history_by_event_id() -> None:
|
||||
"replayHistory (and the live handler) must record system-turn ids for the dedup set."
|
||||
)
|
||||
|
||||
# Pin the wiring on BOTH read paths, scoped to its method — a refactor that
|
||||
# keeps the Set but drops the live-handler consultation (or the
|
||||
# replayHistory-side record) silently re-opens the double-render while the
|
||||
# file-global checks above still pass.
|
||||
live_start = body.index('case "system_turn":')
|
||||
# End at the NEXT switch case, not the first ``break;`` — the dedup-skip
|
||||
# path breaks before the ``.add(``, so a ``break;``-bounded slice would
|
||||
# drop the record half and false-fail the ``.add(`` assertion below.
|
||||
# Whitespace-tolerant so a reformat can't silently break the bound.
|
||||
next_case = re.search(r'\n\s*case "', body[live_start + 1 :])
|
||||
assert next_case, (
|
||||
"no switch case found after system_turn to bound the pin slice — if "
|
||||
"system_turn became the last case, re-anchor this pin's end marker."
|
||||
)
|
||||
live_block = body[live_start : live_start + 1 + next_case.start()]
|
||||
assert re.search(r"_renderedSystemEventIds[\s\S]*?\.\s*has\(", live_block), (
|
||||
"the live system_turn handler must CONSULT the dedup set (skip an id "
|
||||
"already painted from /history), not merely reference the Set elsewhere."
|
||||
)
|
||||
assert re.search(r"_renderedSystemEventIds[\s\S]*?\.\s*add\(", live_block), (
|
||||
"the live system_turn handler must RECORD the id it renders so a later "
|
||||
"/history re-render (clear_ui) doesn't repaint it."
|
||||
)
|
||||
|
||||
replay_start = _pane_method_offset(body, "replayHistory")
|
||||
replay_end = _pane_method_offset(body, "_attachRetryToLastAssistant")
|
||||
replay_block = body[replay_start:replay_end]
|
||||
assert re.search(r"_renderedSystemEventIds[\s\S]*?\.\s*add\(", replay_block), (
|
||||
"replayHistory must record each replayed system row's event_id so the "
|
||||
"live system_turn handler can dedup against it."
|
||||
)
|
||||
|
||||
|
||||
def test_retry_walk_skips_operator_context_cards() -> None:
|
||||
"""Interactive twin of the coord retry-skip guard.
|
||||
@@ -416,6 +448,26 @@ def test_phase8_mcp_error_helpers_defined() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_media_player_activation_not_duplicated_in_standalone() -> None:
|
||||
"""The media-player activation (``_loadHls`` / ``_activatePlayer`` + the
|
||||
click/keydown delegate) moved into the shared interactive pane so BOTH the
|
||||
standalone server and the console activate the Play button. The standalone
|
||||
app.js must NOT keep its own copy — a duplicate document-level listener
|
||||
would double-fire on the standalone (two players swapped in) while the lift
|
||||
is what fixed the console (where app.js was never the host). Pin the
|
||||
standalone clean so the stale copy can't drift back in."""
|
||||
app = _APP_JS.read_text(encoding="utf-8")
|
||||
for name in ("_loadHls", "_activatePlayer", "_isHlsUrl", "media-play-btn"):
|
||||
assert name not in app, (
|
||||
f"standalone app.js must not re-declare the lifted media player "
|
||||
f"({name!r}) — it lives in shared_static/interactive.js now"
|
||||
)
|
||||
# The lift target carries the real implementation (the click delegate too).
|
||||
inter = _INTERACTIVE_JS.read_text(encoding="utf-8")
|
||||
assert "function _activatePlayer(" in inter
|
||||
assert "activateMediaPlayButton(btn)" in inter
|
||||
|
||||
|
||||
def test_phase8_settings_panel_handlers_defined() -> None:
|
||||
"""The settings modal exposes four entry points that the inline
|
||||
``onclick`` attributes in index.html depend on. Renaming or
|
||||
|
||||
@@ -8,6 +8,8 @@ visitor lands on the page but all API calls fail).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
@@ -401,6 +403,49 @@ def test_coord_dedups_system_turn_against_history_by_event_id():
|
||||
"false-skip after clear_ui / replay_truncated."
|
||||
)
|
||||
|
||||
# The seam must be wired on BOTH read paths, not merely present somewhere
|
||||
# in the file — a refactor that keeps the Set but drops the live-handler
|
||||
# consultation (or the history-side record) silently re-opens the
|
||||
# double-render. Scope each assertion to its block so the wiring, not the
|
||||
# bare symbol, is pinned. (A dedupe-neutered factory — guard short-circuited
|
||||
# to ``false`` — still contains ``renderedSystemEventIds.has(`` and so
|
||||
# passes the file-global checks above; these slice checks catch it.)
|
||||
sys_case = body.index('case "system_turn":')
|
||||
# End at the NEXT switch case, not the first ``break;`` — the dedup-skip
|
||||
# ``...has(sysEid)) break;`` is itself a break that precedes the ``.add(``,
|
||||
# so a ``break;``-bounded slice would drop the record half.
|
||||
# Whitespace-tolerant so a reformat can't silently break the bound.
|
||||
next_case = re.search(r'\n\s*case "', body[sys_case + 1 :])
|
||||
assert next_case, (
|
||||
"no switch case found after system_turn to bound the pin slice — if "
|
||||
"system_turn became the last case, re-anchor this pin's end marker."
|
||||
)
|
||||
live_block = body[sys_case : sys_case + 1 + next_case.start()]
|
||||
assert "renderedSystemEventIds.has(" in live_block, (
|
||||
"the live system_turn handler must CONSULT the dedup set (skip an id "
|
||||
"already painted from /history) — not just reference the Set elsewhere."
|
||||
)
|
||||
assert "renderedSystemEventIds.add(" in live_block, (
|
||||
"the live system_turn handler must RECORD the id it renders so a later "
|
||||
"/history re-render (clear_ui) doesn't repaint it."
|
||||
)
|
||||
|
||||
# The history render path must seed the set from each replayed system row's
|
||||
# event_id, so a subsequent live replay of the same id is skipped. Bound
|
||||
# the slice structurally — from the system-role branch to the next role
|
||||
# branch in the same chain (falling back to a generous window when it's
|
||||
# the last branch) — so adding comments/fields inside the branch can't
|
||||
# false-fail a pin that only cares about the wiring.
|
||||
assert 'role === "system"' in body
|
||||
sys_replay = body.index('role === "system"', body.index("refetchHistory"))
|
||||
next_role = re.search(r"role\s*===", body[sys_replay + 1 :])
|
||||
replay_end = sys_replay + 1 + next_role.start() if next_role else sys_replay + 1500
|
||||
replay_window = body[sys_replay:replay_end]
|
||||
assert "renderedSystemEventIds.add(" in replay_window, (
|
||||
"the history render's system-role branch must record each replayed "
|
||||
"turn's event_id so the live system_turn handler can dedup against it."
|
||||
)
|
||||
|
||||
|
||||
def test_coord_retry_walk_skips_operator_context_cards():
|
||||
"""Retry must NOT regenerate a stale assistant turn when the last DOM row is
|
||||
|
||||
@@ -184,6 +184,42 @@ def test_approval_keyboard_shortcuts_wired() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_media_playback_lifted_and_pane_owned() -> None:
|
||||
"""The media Play affordance is rendered by the pane (buildPlayButton /
|
||||
buildMediaEmbed), so its activation must live in the pane too — the old
|
||||
standalone wired a DOCUMENT-level click/keydown listener in app.js, which
|
||||
the console host never loaded (so the button was dead in console-hosted
|
||||
panes). The fix mirrors the approval-keydown pattern: a pane-owned listener
|
||||
on this.el, root-scoped via closest(".media-play-btn"). Pin both the
|
||||
lifted helpers and the pane wiring so the document-level regression can't
|
||||
silently come back."""
|
||||
body = _INTERACTIVE.read_text(encoding="utf-8")
|
||||
# The lifted activation machinery now lives in the shared module.
|
||||
for fn in (
|
||||
"function _loadHls(",
|
||||
"function _isHlsUrl(",
|
||||
"function _activatePlayer(",
|
||||
"function activateMediaPlayButton(",
|
||||
):
|
||||
assert fn in body, f"media player helper must be lifted into the pane: {fn}"
|
||||
# The HLS vendor is fetched by absolute /shared/ URL (resolves in BOTH the
|
||||
# standalone server and the console, where /shared is mounted at the root).
|
||||
assert 'script.src = "/shared/hls-1.6.16/hls.min.js";' in body
|
||||
# Pane-owned + root-scoped — NOT a document-level delegated listener.
|
||||
assert 'this.el.addEventListener("click"' in body, (
|
||||
"media play must be wired on this.el (pane-owned), not document"
|
||||
)
|
||||
assert 'e.target.closest(".media-play-btn")' in body, (
|
||||
"the play handler must be root-scoped via closest, not a document-wide id"
|
||||
)
|
||||
assert "activateMediaPlayButton(btn)" in body
|
||||
collapsed = _strip_comments(body)
|
||||
assert 'document.addEventListener("click"' not in collapsed, (
|
||||
"the pane must not register a document-level click delegate — that is "
|
||||
"the standalone regression that left console panes dead"
|
||||
)
|
||||
|
||||
|
||||
def test_controller_terminal_dead_state() -> None:
|
||||
"""Lifecycle round 2: the console controller must STOP reconnect-polling a
|
||||
session that is gone (closed / evicted / node restarted) — three consecutive
|
||||
|
||||
@@ -2913,6 +2913,181 @@ class TestMemoryCompositionDeferral:
|
||||
assert session._system_composed_with_context is False
|
||||
|
||||
|
||||
class TestMemoryAccessTouch:
|
||||
"""Access metadata (``access_count`` / ``last_accessed``) moves only when
|
||||
the model actually sees a memory: the injected top-k during composition,
|
||||
and explicit search/get reads via the memory tool. Save/list and the
|
||||
wider candidate pool must NOT bump the counter.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _access_count(name: str, scope: str = "global", scope_id: str = "") -> int:
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
mem = get_storage().get_structured_memory_by_name(name, scope, scope_id)
|
||||
assert mem is not None, f"memory {name!r} not found"
|
||||
return int(mem["access_count"])
|
||||
|
||||
@staticmethod
|
||||
def _save(name: str, content: str) -> None:
|
||||
from turnstone.core.memory import save_structured_memory
|
||||
|
||||
save_structured_memory(name, content, scope="global")
|
||||
|
||||
@staticmethod
|
||||
def _empty_session() -> ChatSession:
|
||||
"""A session whose __init__ composed before any memory existed.
|
||||
|
||||
The constructor composes the system prefix once; building it before
|
||||
the memories are saved keeps that first (empty) compose from touching
|
||||
rows, so the tests observe only the turn-driven recompose below.
|
||||
"""
|
||||
return _make_session(ws_id="ws-1", user_id="user-1")
|
||||
|
||||
@staticmethod
|
||||
def _compose_turn(session: ChatSession, query: str) -> None:
|
||||
"""Drive one user turn's worth of composition.
|
||||
|
||||
Mirrors ``send``: a fresh user turn invalidates the per-turn memory
|
||||
caches, then the prefix recomposes against the new query.
|
||||
"""
|
||||
session._invalidate_memory_cache()
|
||||
session.messages.append(turn_from_dict({"role": "user", "content": query}))
|
||||
session._init_system_messages()
|
||||
|
||||
def test_composition_touches_injected_memories(self, tmp_db):
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
self._save("kafka_alerts", "kafka consumer lag alert thresholds")
|
||||
self._compose_turn(session, "how do I restart kafka")
|
||||
# Both query-matching memories were injected, so both got touched once.
|
||||
assert self._access_count("kafka_runbook") == 1
|
||||
assert self._access_count("kafka_alerts") == 1
|
||||
|
||||
def test_composition_skips_unmatched_candidates(self, tmp_db):
|
||||
"""The candidate pool is a superset of the injected set — a memory
|
||||
that loses BM25 ranking (no query overlap) must NOT be touched."""
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
self._save("garden_notes", "tomato watering schedule midsummer")
|
||||
self._compose_turn(session, "restart kafka broker pods status")
|
||||
# The matching memory was injected and touched.
|
||||
assert self._access_count("kafka_runbook") == 1
|
||||
# The non-matching one was a candidate but never injected.
|
||||
assert self._access_count("garden_notes") == 0
|
||||
# Sanity: it really was in the visible candidate pool.
|
||||
visible = {m["name"] for m in session._list_visible_memories()}
|
||||
assert "garden_notes" in visible
|
||||
|
||||
def test_composition_touches_each_memory_once_per_turn(self, tmp_db):
|
||||
"""``_init_system_messages`` runs many times within a turn (tool
|
||||
results, MCP refresh); the injected set must be touched at most once
|
||||
per memory between user turns, not once per recompose."""
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
self._compose_turn(session, "how do I restart kafka")
|
||||
# Several mid-turn recomposes (no new user turn between them).
|
||||
session._init_system_messages()
|
||||
session._init_system_messages()
|
||||
assert self._access_count("kafka_runbook") == 1
|
||||
# A genuinely new turn lets the same memory be counted again.
|
||||
self._compose_turn(session, "kafka again please")
|
||||
assert self._access_count("kafka_runbook") == 2
|
||||
|
||||
def test_composition_touches_exactly_the_injected_keys(self, tmp_db):
|
||||
"""Spy the touch boundary and assert the keys match the names the
|
||||
composer rendered into the ``<memories>`` block — exactly, not the
|
||||
candidate pool."""
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
self._save("garden_notes", "tomato watering schedule midsummer")
|
||||
session._invalidate_memory_cache()
|
||||
session.messages.append(
|
||||
turn_from_dict({"role": "user", "content": "restart kafka broker pods status"})
|
||||
)
|
||||
touched: list[tuple[str, str, str]] = []
|
||||
with patch(
|
||||
"turnstone.core.session.touch_structured_memories",
|
||||
side_effect=lambda keys: touched.extend(keys),
|
||||
):
|
||||
session._init_system_messages()
|
||||
joined = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
|
||||
touched_names = {name for name, _, _ in touched}
|
||||
assert touched_names == {"kafka_runbook"}
|
||||
assert '<memory name="kafka_runbook"' in joined
|
||||
assert '<memory name="garden_notes"' not in joined
|
||||
|
||||
def test_composition_survives_touch_storage_error(self, tmp_db):
|
||||
"""A storage blow-up inside the touch must not break composition —
|
||||
the facade swallows it and the memory block still lands."""
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
session._invalidate_memory_cache()
|
||||
session.messages.append(
|
||||
turn_from_dict({"role": "user", "content": "how do I restart kafka"})
|
||||
)
|
||||
with patch.object(
|
||||
get_storage(),
|
||||
"touch_structured_memories",
|
||||
side_effect=RuntimeError("storage exploded"),
|
||||
):
|
||||
session._init_system_messages()
|
||||
joined = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
|
||||
assert '<memory name="kafka_runbook"' in joined
|
||||
|
||||
def test_search_action_touches_returned_hits(self, tmp_db):
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
item = session._prepare_memory("call_1", {"action": "search", "query": "kafka"})
|
||||
assert "error" not in item
|
||||
session._exec_memory(item)
|
||||
assert self._access_count("kafka_runbook") == 1
|
||||
|
||||
def test_get_action_touches_fetched_memory(self, tmp_db):
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
item = session._prepare_memory(
|
||||
"call_1", {"action": "get", "name": "kafka_runbook", "scope": "global"}
|
||||
)
|
||||
assert "error" not in item
|
||||
session._exec_memory(item)
|
||||
assert self._access_count("kafka_runbook") == 1
|
||||
|
||||
def test_get_miss_touches_nothing(self, tmp_db):
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
item = session._prepare_memory(
|
||||
"call_1", {"action": "get", "name": "no_such_mem", "scope": "global"}
|
||||
)
|
||||
_, msg = session._exec_memory(item)
|
||||
assert "not found" in msg
|
||||
# The existing row must not be collaterally touched by a miss.
|
||||
assert self._access_count("kafka_runbook") == 0
|
||||
|
||||
def test_list_action_does_not_touch(self, tmp_db):
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
item = session._prepare_memory("call_1", {"action": "list"})
|
||||
session._exec_memory(item)
|
||||
assert self._access_count("kafka_runbook") == 0
|
||||
|
||||
def test_save_action_does_not_touch_access_count(self, tmp_db):
|
||||
"""The save action handler itself must not bump ``access_count`` —
|
||||
that counter is read traffic only. (The recompose a save triggers
|
||||
may surface the row via the composition path; that is exercised by
|
||||
the composition tests. Suppressed here to isolate the handler.)"""
|
||||
session = self._empty_session()
|
||||
item = session._prepare_memory(
|
||||
"call_1",
|
||||
{"action": "save", "name": "kafka_runbook", "content": "x", "scope": "global"},
|
||||
)
|
||||
with patch.object(session, "_init_system_messages"):
|
||||
session._exec_memory(item)
|
||||
assert self._access_count("kafka_runbook") == 0
|
||||
|
||||
|
||||
class TestMetacognitiveBuffers:
|
||||
"""Nudges drain through advisory channels, not the system message."""
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "1.6.0"
|
||||
__version__ = "1.6.1"
|
||||
|
||||
@@ -86,6 +86,7 @@ from turnstone.core.memory import (
|
||||
search_visible_structured_memories,
|
||||
set_message_attachments,
|
||||
set_workstream_alias,
|
||||
touch_structured_memories,
|
||||
update_workstream_title,
|
||||
)
|
||||
from turnstone.core.memory_relevance import (
|
||||
@@ -1031,6 +1032,10 @@ class ChatSession:
|
||||
# tool results) and the recent-context string is identical across
|
||||
# them. Invalidated on user-turn append and on memory write/delete.
|
||||
self._mem_search_cache: dict[tuple[str, str, int], list[dict[str, str]]] = {}
|
||||
# Per-turn dedup for composition touches: ``_init_system_messages`` runs
|
||||
# many times within a turn, so the injected set is touched at most once
|
||||
# per memory per turn. Cleared alongside the search cache.
|
||||
self._touched_memory_keys: set[tuple[str, str, str]] = set()
|
||||
self._ws_id = ws_id or uuid.uuid4().hex
|
||||
self._title_generated = False
|
||||
self._read_files: set[str] = set()
|
||||
@@ -2873,6 +2878,9 @@ class ChatSession:
|
||||
candidates=len(visible_mems),
|
||||
injected=len(relevant),
|
||||
)
|
||||
# Access metadata tracks what the model actually saw — touch the
|
||||
# injected top-k, not the candidate pool.
|
||||
self._touch_injected_memories(relevant)
|
||||
if relevant:
|
||||
dev_parts.append("")
|
||||
dev_parts.append(build_memory_context(relevant))
|
||||
@@ -7439,6 +7447,7 @@ class ChatSession:
|
||||
def _invalidate_memory_cache(self) -> None:
|
||||
"""Drop the per-turn search cache; call on user-turn append + memory writes."""
|
||||
self._mem_search_cache.clear()
|
||||
self._touched_memory_keys.clear()
|
||||
|
||||
def _select_memory_candidates(self, context: str) -> tuple[list[dict[str, str]], str]:
|
||||
"""Pick the candidate set fed into BM25 ranking.
|
||||
@@ -7476,6 +7485,38 @@ class ChatSession:
|
||||
return extra, "recency"
|
||||
return search_hits + extra, ("union" if extra else "search")
|
||||
|
||||
@staticmethod
|
||||
def _memory_keys(rows: list[dict[str, str]]) -> list[tuple[str, str, str]]:
|
||||
"""Build ``(name, scope, scope_id)`` touch keys from memory rows.
|
||||
|
||||
The storage read helpers return ``SELECT *`` rows, so all three
|
||||
columns are present.
|
||||
"""
|
||||
return [(r.get("name", ""), r.get("scope", ""), r.get("scope_id", "")) for r in rows]
|
||||
|
||||
def _touch_injected_memories(self, rows: list[dict[str, str]]) -> None:
|
||||
"""Touch the memories injected into the system prefix this turn.
|
||||
|
||||
``_init_system_messages`` recomposes many times per turn; gate on the
|
||||
per-turn touched-key set so each surfaced memory is counted at most
|
||||
once between user turns. Best-effort: the facade swallows storage
|
||||
errors, so a failed touch never breaks composition.
|
||||
"""
|
||||
fresh = [k for k in self._memory_keys(rows) if k not in self._touched_memory_keys]
|
||||
if not fresh:
|
||||
return
|
||||
self._touched_memory_keys.update(fresh)
|
||||
touch_structured_memories(fresh)
|
||||
|
||||
def _touch_read_memories(self, rows: list[dict[str, str]]) -> None:
|
||||
"""Touch memories returned by an explicit memory-tool read.
|
||||
|
||||
A search/get is a distinct user-driven access each time it runs, so
|
||||
these are counted unconditionally (not subject to the composition
|
||||
per-turn dedup). Best-effort via the facade.
|
||||
"""
|
||||
touch_structured_memories(self._memory_keys(rows))
|
||||
|
||||
def _check_metacognitive_nudge(self, user_message: str) -> tuple[str, str] | None:
|
||||
"""Check if a metacognitive nudge should fire for *user_message*.
|
||||
|
||||
@@ -11489,6 +11530,7 @@ class ChatSession:
|
||||
found_scope = scope
|
||||
break
|
||||
if mem:
|
||||
self._touch_read_memories([mem])
|
||||
content = mem.get("content", "")
|
||||
desc = mem.get("description", "")
|
||||
mem_type = mem.get("type", "")
|
||||
@@ -11566,6 +11608,7 @@ class ChatSession:
|
||||
result_count=len(rows),
|
||||
query=item["query"][:120],
|
||||
)
|
||||
self._touch_read_memories(rows)
|
||||
if rows:
|
||||
lines = []
|
||||
for m in rows:
|
||||
|
||||
@@ -630,6 +630,28 @@ class Pane {
|
||||
}
|
||||
});
|
||||
|
||||
// Click-to-play for media embeds. Pane-owned (on this.el) and root-scoped
|
||||
// via closest(".media-play-btn") so every embedded L-shell pane activates
|
||||
// its own players — the old standalone wired this via a document-level
|
||||
// delegated listener in app.js, which the console host never loaded (so the
|
||||
// Play button was dead in console-hosted panes). Enter on a focused button
|
||||
// routes through the same path, mirroring the approval keydown above.
|
||||
this.el.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest(".media-play-btn");
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
activateMediaPlayButton(btn);
|
||||
});
|
||||
this.el.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
const btn = e.target.closest(".media-play-btn");
|
||||
if (!btn || btn.disabled) return;
|
||||
// Single-path activation: preventDefault stops the browser's native
|
||||
// Enter-to-click from dispatching a second activation behind ours.
|
||||
e.preventDefault();
|
||||
activateMediaPlayButton(btn);
|
||||
});
|
||||
|
||||
// No pane header: the workstream name, persona, and state are shown by the
|
||||
// tab and the rail (Workspaces); the --skip-permissions banner lands in
|
||||
// messagesEl (see the host warningTarget). The standalone split-pane
|
||||
@@ -2693,6 +2715,143 @@ function _tryPrettyJson(text) {
|
||||
return _redactApiKeys(JSON.stringify(obj, null, 2));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HLS lazy-loader + click-to-play (lifted from the standalone app.js so
|
||||
// console-hosted panes activate media too). Follows the mermaid.js
|
||||
// lazy-load pattern in /shared/renderer.js: the vendor is fetched by absolute
|
||||
// /shared/ URL on first use, so it resolves in BOTH the standalone server and
|
||||
// the console (where /shared is mounted at the root and node-proxied panes
|
||||
// also reach it via /node/{id}/shared/).
|
||||
// ---------------------------------------------------------------------------
|
||||
let _hlsState = "idle";
|
||||
let _hlsQueue = [];
|
||||
|
||||
function _loadHls(callback) {
|
||||
if (_hlsState === "ready") {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
_hlsQueue.push(callback);
|
||||
if (_hlsState === "loading") return;
|
||||
_hlsState = "loading";
|
||||
const script = document.createElement("script");
|
||||
script.src = "/shared/hls-1.6.16/hls.min.js";
|
||||
script.onload = function () {
|
||||
_hlsState = "ready";
|
||||
const q = _hlsQueue;
|
||||
_hlsQueue = [];
|
||||
for (let i = 0; i < q.length; i++) q[i]();
|
||||
};
|
||||
script.onerror = function () {
|
||||
_hlsState = "idle";
|
||||
const q = _hlsQueue;
|
||||
_hlsQueue = [];
|
||||
// Fall through — _activatePlayer will use stream_url since Hls is undefined
|
||||
for (let i = 0; i < q.length; i++) q[i]();
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
function _isHlsUrl(url) {
|
||||
return typeof url === "string" && /\.m3u8(\?|$)/i.test(url);
|
||||
}
|
||||
|
||||
function _activatePlayer(btn) {
|
||||
const url = btn.dataset.streamUrl;
|
||||
const hlsUrl = btn.dataset.hlsUrl;
|
||||
const isAudio = btn.dataset.audioOnly === "true";
|
||||
const directStream = btn.dataset.directStream === "true";
|
||||
|
||||
const player = document.createElement(isAudio ? "audio" : "video");
|
||||
player.controls = true;
|
||||
player.autoplay = true;
|
||||
player.className = "media-player";
|
||||
|
||||
// Held so the error handler can tear the instance down before the player
|
||||
// node is replaced — otherwise its listeners/loader timers run detached.
|
||||
let hls = null;
|
||||
|
||||
// Prefer direct stream when the source supports it; fall back to HLS
|
||||
// only when transcoding is needed.
|
||||
if (directStream && url) {
|
||||
player.src = url;
|
||||
} else if (
|
||||
hlsUrl &&
|
||||
!isAudio &&
|
||||
typeof Hls !== "undefined" &&
|
||||
Hls.isSupported()
|
||||
) {
|
||||
hls = new Hls();
|
||||
hls.loadSource(hlsUrl);
|
||||
hls.attachMedia(player);
|
||||
} else if (
|
||||
hlsUrl &&
|
||||
!isAudio &&
|
||||
player.canPlayType("application/vnd.apple.mpegurl")
|
||||
) {
|
||||
player.src = hlsUrl;
|
||||
} else {
|
||||
player.src = url;
|
||||
}
|
||||
|
||||
player.addEventListener("error", function () {
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
hls = null; // media error events can repeat — never double-destroy
|
||||
}
|
||||
const card = player.closest(".media-embed");
|
||||
const titleEl = card ? card.querySelector(".media-card-title") : null;
|
||||
const label = titleEl ? ": " + titleEl.textContent : "";
|
||||
|
||||
const err = document.createElement("div");
|
||||
err.className = "media-player-error";
|
||||
err.setAttribute("role", "alert");
|
||||
err.textContent = "Failed to load stream" + label;
|
||||
|
||||
const retry = document.createElement("button");
|
||||
retry.className = "media-play-btn";
|
||||
retry.type = "button";
|
||||
retry.dataset.streamUrl = url;
|
||||
retry.dataset.hlsUrl = hlsUrl || "";
|
||||
retry.dataset.audioOnly = String(isAudio);
|
||||
retry.dataset.directStream = String(directStream);
|
||||
retry.setAttribute("aria-label", "Retry" + label);
|
||||
retry.appendChild(document.createTextNode("▶ Retry"));
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.appendChild(err);
|
||||
container.appendChild(retry);
|
||||
player.replaceWith(container);
|
||||
});
|
||||
|
||||
btn.replaceWith(player);
|
||||
}
|
||||
|
||||
// Activate a clicked/Enter-pressed play button: show the loading affordance,
|
||||
// then ensure hls.js is loaded before swapping in the player when the source
|
||||
// needs it. The pane wires this from a root-scoped this.el listener.
|
||||
function activateMediaPlayButton(btn) {
|
||||
btn.disabled = true;
|
||||
const labelEl = btn.querySelector("span:last-child");
|
||||
if (labelEl) {
|
||||
labelEl.textContent = "Loading…";
|
||||
} else {
|
||||
btn.textContent = "▶ Loading…";
|
||||
}
|
||||
|
||||
const hlsUrl = btn.dataset.hlsUrl;
|
||||
const isAudio = btn.dataset.audioOnly === "true";
|
||||
|
||||
// If HLS URL present and not audio, ensure hls.js is loaded first
|
||||
if (hlsUrl && !isAudio && _isHlsUrl(hlsUrl)) {
|
||||
_loadHls(function () {
|
||||
_activatePlayer(btn);
|
||||
});
|
||||
} else {
|
||||
_activatePlayer(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function buildMediaCard(item) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "media-card";
|
||||
|
||||
@@ -1569,141 +1569,6 @@ function _refreshConsentBadge() {
|
||||
* Render the action card for an MCP error envelope. Mirrors the
|
||||
* media-embed pattern: visible card on top, collapsible raw JSON below.
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// HLS lazy-loader (follows the mermaid.js lazy-load pattern in
|
||||
// /shared/renderer.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
let _hlsState = "idle";
|
||||
let _hlsQueue = [];
|
||||
|
||||
function _loadHls(callback) {
|
||||
if (_hlsState === "ready") {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
_hlsQueue.push(callback);
|
||||
if (_hlsState === "loading") return;
|
||||
_hlsState = "loading";
|
||||
const script = document.createElement("script");
|
||||
script.src = "/shared/hls-1.6.16/hls.min.js";
|
||||
script.onload = function () {
|
||||
_hlsState = "ready";
|
||||
const q = _hlsQueue;
|
||||
_hlsQueue = [];
|
||||
for (let i = 0; i < q.length; i++) q[i]();
|
||||
};
|
||||
script.onerror = function () {
|
||||
_hlsState = "idle";
|
||||
const q = _hlsQueue;
|
||||
_hlsQueue = [];
|
||||
// Fall through — _activatePlayer will use stream_url since Hls is undefined
|
||||
for (let i = 0; i < q.length; i++) q[i]();
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
function _isHlsUrl(url) {
|
||||
return typeof url === "string" && /\.m3u8(\?|$)/i.test(url);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Click-to-play delegated handler (follows img-placeholder pattern)
|
||||
// ---------------------------------------------------------------------------
|
||||
function _activatePlayer(btn) {
|
||||
const url = btn.dataset.streamUrl;
|
||||
const hlsUrl = btn.dataset.hlsUrl;
|
||||
const isAudio = btn.dataset.audioOnly === "true";
|
||||
const directStream = btn.dataset.directStream === "true";
|
||||
|
||||
const player = document.createElement(isAudio ? "audio" : "video");
|
||||
player.controls = true;
|
||||
player.autoplay = true;
|
||||
player.className = "media-player";
|
||||
|
||||
// Prefer direct stream when the source supports it; fall back to HLS
|
||||
// only when transcoding is needed.
|
||||
if (directStream && url) {
|
||||
player.src = url;
|
||||
} else if (
|
||||
hlsUrl &&
|
||||
!isAudio &&
|
||||
typeof Hls !== "undefined" &&
|
||||
Hls.isSupported()
|
||||
) {
|
||||
const hls = new Hls();
|
||||
hls.loadSource(hlsUrl);
|
||||
hls.attachMedia(player);
|
||||
} else if (
|
||||
hlsUrl &&
|
||||
!isAudio &&
|
||||
player.canPlayType("application/vnd.apple.mpegurl")
|
||||
) {
|
||||
player.src = hlsUrl;
|
||||
} else {
|
||||
player.src = url;
|
||||
}
|
||||
|
||||
player.addEventListener("error", function () {
|
||||
const card = player.closest(".media-embed");
|
||||
const titleEl = card ? card.querySelector(".media-card-title") : null;
|
||||
const label = titleEl ? ": " + titleEl.textContent : "";
|
||||
|
||||
const err = document.createElement("div");
|
||||
err.className = "media-player-error";
|
||||
err.setAttribute("role", "alert");
|
||||
err.textContent = "Failed to load stream" + label;
|
||||
|
||||
const retry = document.createElement("button");
|
||||
retry.className = "media-play-btn";
|
||||
retry.type = "button";
|
||||
retry.dataset.streamUrl = url;
|
||||
retry.dataset.hlsUrl = hlsUrl || "";
|
||||
retry.dataset.audioOnly = String(isAudio);
|
||||
retry.dataset.directStream = String(directStream);
|
||||
retry.setAttribute("aria-label", "Retry" + label);
|
||||
retry.appendChild(document.createTextNode("\u25b6 Retry"));
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.appendChild(err);
|
||||
container.appendChild(retry);
|
||||
player.replaceWith(container);
|
||||
});
|
||||
|
||||
btn.replaceWith(player);
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (e) {
|
||||
const btn = e.target.closest(".media-play-btn");
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
btn.disabled = true;
|
||||
const labelEl = btn.querySelector("span:last-child");
|
||||
if (labelEl) {
|
||||
labelEl.textContent = "Loading\u2026";
|
||||
} else {
|
||||
btn.textContent = "\u25b6 Loading\u2026";
|
||||
}
|
||||
|
||||
const hlsUrl = btn.dataset.hlsUrl;
|
||||
const isAudio = btn.dataset.audioOnly === "true";
|
||||
|
||||
// If HLS URL present and not audio, ensure hls.js is loaded first
|
||||
if (hlsUrl && !isAudio && _isHlsUrl(hlsUrl)) {
|
||||
_loadHls(function () {
|
||||
_activatePlayer(btn);
|
||||
});
|
||||
} else {
|
||||
_activatePlayer(btn);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key !== "Enter") return;
|
||||
const btn = e.target.closest(".media-play-btn");
|
||||
if (!btn) return;
|
||||
btn.click();
|
||||
});
|
||||
|
||||
function _announce(text) {
|
||||
const el = document.getElementById("toast");
|
||||
if (!el) return;
|
||||
|
||||
Reference in New Issue
Block a user