mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-25 13:24:46 -06:00
08c6eeb1e5bf55c63f39fd4e359cd096af27fee2
668 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
08c6eeb1e5 |
fix(coord): close three copilot review gaps on PR 446
Copilot review on
|
||
|
|
f6fbf2d85b |
fix(coord): list_nodes accepts flat-arg filters too
Operator's harness shakedown found list_nodes filters silently
ignored on every call:
list_nodes(os="Linux") → returns ALL 10 nodes
list_nodes(has_gpu=true) → returns ALL 10 nodes
list_nodes(memory_gb=751) → returns ALL 10 nodes (no
node has 751 GiB; should be 0)
Storage filter pipeline is fine (pinned by an existing
test_list_nodes_filter_uses_natural_value_not_quoted). The bug is
upstream in ``_prepare_list_nodes``: it only honoured
``args["filters"]`` (the canonical nested shape). Several models
drop the nesting and emit each filter as a top-level kwarg —
``list_nodes(os="Linux")`` instead of ``list_nodes(filters={"os":
"Linux"})`` — and the strict prepare silently degraded those calls
to "no filter" → full-cluster return.
Fix: any top-level kwarg that ISN'T one of the four reserved control
parameters (``filters``, ``limit``, ``include_network_detail``,
``include_inactive``) is now treated as a flat filter. Nested entries
still win on key collision so the canonical shape stays
deterministic. Tool description unchanged so well-behaved models
keep using ``filters={...}``; the relaxation is purely receiver-side.
Tests: 4818 pass (+5 net). Five new tests pin both shapes plus the
collision-precedence rule and the prepare→exec wiring. Ruff + mypy
clean.
|
||
|
|
fca1ac3736 |
fix(coord): relax tasks parallel-batch rule to mixed read+write only
Operator observed the prior rule rejecting a natural decompose-the-
plan turn:
[tasks(add×4), list_nodes, list_skills, list_workstreams]
The 4 tasks(add) calls landed (per-ws lock serialised them) but the
guard blanket-rejected EVERY tasks(...) regardless of what its
siblings actually were. All-write batches converge under the
per-ws lock; all-read batches can't race. The only genuinely-
hazardous shape is the read+write mix where tasks(list)
paralleled with tasks(add=...) inside ``run_one``'s
ThreadPoolExecutor has unspecified ordering and the read can land
on either side of the write.
The rule now scopes precisely:
- All ``tasks`` writes in a batch — permitted.
- All ``tasks`` reads in a batch — permitted.
- ``tasks`` paralleled with non-``tasks`` siblings — permitted
in either direction. Non-tasks tools don't touch the tasks
state, so there's no read-after-write surface.
- ``tasks`` read AND ``tasks`` write in the same batch — REJECTED
(still, because that IS the actual hazard).
Tests: 4813 pass (+4 net). Six new tests pin the relaxation
(all-write OK, all-read OK, write+sibling OK, read+sibling OK,
non-tasks-only batch unaffected) and the one tightened rejection
case (read+write mixed in tasks specifically). Ruff + mypy clean.
|
||
|
|
d0f5f50650 |
feat(node): auto-detect node capabilities via kernel interfaces (#445)
* feat(node): auto-detect node capabilities via kernel interfaces
Closes the operator-burden gap the harness shakedown surfaced — the
list_nodes capability/region/role filtering surface that nodes were
launching with empty. Auto-detection runs at server startup and
populates ``node_metadata`` rows with sensible defaults that
operators can still override via the ``[metadata]`` section of
config.toml (operator-config writes win on the per-key upsert).
What's detected, all from kernel interfaces (no userspace binaries
on PATH — works the same way regardless of whether nvidia-smi /
rocm-smi / lspci is installed):
- ``gpu_count`` / ``gpu_vendor`` / ``gpu_vendors`` / ``gpus`` —
walks ``/sys/class/drm/cardN/device/{vendor,device}`` and decodes
PCI vendor IDs to friendly names (NVIDIA / AMD / Intel / Apple).
Heterogeneous-GPU nodes get the first KNOWN vendor in the flat
``gpu_vendor`` key — never ``"unknown"`` when known vendors are
present — so a coord filtering on ``gpu_vendor=nvidia`` matches
nodes whose first card happened to be exotic.
- ``memory_gb`` — reads ``/proc/meminfo``, rounds GiB down so
``filters={"memory_gb": 32}`` doesn't match a 31.5 GiB node.
- ``cpu_model`` — first ``model name`` line from ``/proc/cpuinfo``.
- ``cloud_provider`` / ``cloud_region`` / ``cloud_zone`` /
``cloud_instance_type`` / ``cloud_instance_id`` — DMI sysfs
identifies the cloud provider from BIOS/SMBIOS strings (no
network call) and only THEN does the IMDS probe fire. Baremetal
hosts pay zero startup latency on the cloud path.
Hardening highlights:
- IMDS probes target the link-local IP literal ``169.254.169.254``
for AWS, GCP, AND Azure — no DNS-resolvable hostname for any
vendor, so a host with attacker-controlled DNS can't redirect
the probe even when its DMI claims a cloud provider.
- Response bodies capped at 64 KiB on read; per-field strings
capped at 256 chars and stripped of control characters before
persistence. Stops a hostile IMDS responder from spraying
multi-megabyte / newline-injected payloads into ``node_metadata``
and from there into coord-LLM ``list_nodes`` context.
- ``isinstance(doc, dict)`` guards on every JSON IMDS response so
a non-conformant body (list / scalar / null) returns clean ``{}``
instead of raising.
- ``collect_node_info()`` runs via ``asyncio.to_thread`` from the
server's lifespan handler so the IMDS probe latency never blocks
the event loop.
- GCP fans the three zone/machine-type/id probes concurrently so a
misidentified host's worst case is one timeout window (~1 s)
instead of three (~3 s).
- Operator opt-out via ``TURNSTONE_AUTO_CLOUD_METADATA=0`` skips the
IMDS phase entirely; the DMI-derived ``cloud_provider`` still
populates because that's a kernel interface.
Tests: 4807 pass (+11 net, 73 in test_node_info.py). Ruff + mypy
clean on every modified file. New tests pin the heterogeneous-GPU
flat-key fix, the IMDS hardening (non-dict JSON, control-char
sanitisation, body cap, per-field cap), and the GCP IP-literal
property.
* fix(node): filter synthetic display adapters + per-vendor GPU flags
PR review on
|
||
|
|
7d6b31e18a |
fix(coord): close gaps an operator's harness shakedown surfaced (#444)
* fix(coord): close gaps an operator's harness shakedown surfaced
Operator-driven shakedown of the coordinator tool surface flagged
five issues; this commit addresses all of them plus the review
findings against the initial fix.
1. Cancelled-mid-stream partial assistant content now carries a
"[generation cancelled before completion]" marker. Without it,
``inspect_workstream`` / ``wait_for_workstream`` callers and the
next coord-LLM turn read the truncated text as a complete answer.
``_cancelled_partial_msg`` no longer ships ``_provider_content``
(Anthropic would otherwise read that lane verbatim and bypass the
marker; partial tool_use blocks could also leak through).
2. ``spawn_workstream`` / ``spawn_batch`` no longer surface the
routing-proxy ``status`` field (always HTTP 200 on the success
path). The tool description claimed it was "lifecycle state at
creation"; code that did ``if result["status"] == "idle"``
silently never matched. Lifecycle state lives on the workstream
row — ``inspect_workstream`` is the read. Tool JSON descriptions
plus docs/coordinator-skills.md and docs/bulk-endpoints.md
examples updated to match.
3. ``inspect_workstream`` not-found error string is bare ("workstream
not found"); the structured ``ws_id`` field carries the queried
id. Pre-fix the error STRING echoed the id back at the caller
who just sent it — redundant and out of step with the rest of the
surface. Cross-tenant + missing rows still return the same shape,
preserving the existence-leak guarantee.
4. ``tasks(...)`` is now rejected when called in a parallel tool
batch. The prior shape relied on a docstring warning ("a list
paralleled with writes can reflect pre-write state") that put
cognitive overhead on every model invocation; turning the silent
footgun into an explicit error means the model only thinks about
the rule the moment it actually breaks it. Warning dropped from
the tasks tool description. ``_PARALLEL_INCOMPATIBLE_TOOLS``
constant in session.py is the extension point for any future
tool with the same read-after-write hazard.
Plus the multi-stage code review's findings against the initial
fix (q-1 / q-2 docs drift, q-3 idiom, q-4 keys-assertion, q-5
duplicate guard) — all addressed in the same pass.
Tests: 4752 pass, +6 net since the pre-fix baseline. Ruff + mypy
clean. Three new tests pin the parallel-batch-rejection behaviour
on tasks (rejected when batched, runs alone, sibling tools
unaffected); existing cancel + spawn + inspect tests updated to
match the new shape.
* fix(coord): close two copilot review gaps on PR 444
Copilot review on PR 444 flagged two follow-ups:
1. Empty-content cancel divergence — when ``GenerationCancelled``
races BEFORE the first content token, the prior shape skipped
``save_message`` and only appended an empty-content msg in
memory. In-memory and storage diverged: a rehydrate would see
nothing in storage but the session would carry an empty
assistant turn. Both branches now persist; on the empty-content
shape the marker becomes the entire message
("[generation cancelled before completion]") so storage matches
the in-memory history.
2. Test stub cleanup — three new tests injected ``ui.approve_tools``
via ad-hoc ``lambda + type: ignore[attr-defined]``. Replaced
with a permissive ``approve_tools`` method on ``_StubUI`` so the
stub matches the SessionUI surface the dispatcher actually
reads. Tests that exercise approval pathways can still override
per-instance.
Tests: 4752 pass. Ruff + mypy clean.
|
||
|
|
9a30530d41 |
feat(coord): surface child errors, isolate tool exceptions, add memory tool (#443)
* feat(coord): surface child errors, isolate tool exceptions, add memory tool
Closes four coordinator gaps identified during operator triage:
1. Child workstream errors now surface in inspect/wait. Worker-thread
exception text is sanitized (URL userinfo masked, sk-/Bearer/ghp_/
github_pat_/AKIA tokens redacted, capped at 1024 chars) and persisted
to workstream_config.last_error before _emit_state("error") fires, so
coord polling never sees state=error with a missing cause. The row
is cleared on recovery transitions (idle/running) so a once-leaked
exception body doesn't outlive the failure. inspect_workstream and
wait_for_workstream return last_error for state=error rows; the
wait surface prefers it over the assistant-tail walk.
2. Tool exceptions now return as tool_results with sibling-aware
guidance. ChatSession._safe_prepare_tool wraps every per-call
_prepare_tool invocation; a buggy preparer becomes an error item
for that call only — sibling parallel tool_calls keep going,
never orphaning the assistant message's tool_calls block.
run_one's runtime exception path includes the exception class
and a short note that other tool calls in the batch completed
independently so the model can recover.
3. Memory tool exposed to coordinator with a coord-only scope.
memory.json gains coordinator: true + interactive: true + per-kind
kind_variants. Coord sessions see scope enum ["coordinator"] and
an orchestration-flavored description; IC sessions see ["global",
"workstream", "user"] and the existing flavor. Coord-scope rows
are private to the coordinator session (children cannot read or
write them), closing the cross-session prompt-injection lane that
an adversarially-steered child would otherwise have. Coord
visibility is also restricted to coord-scope only — coords no
longer see global / workstream / user memories that belong to the
user's interactive sessions.
4. Per-call exception isolation in tool batches. _safe_prepare_tool
was previously the implicit shield; now it's an explicit method
with documented invariants. KeyboardInterrupt / GenerationCancelled
re-raise so the cooperative cancel path still works.
Other notable changes:
- LAST_ERROR_CONFIG_KEY + persist_last_error / clear_last_error /
load_last_error / sanitize_error_text moved to turnstone.core.memory
(the storage facade hub) — readers in coordinator_client.py import
the constant.
- Memory scope tuples extracted to module constants
_VALID_MEMORY_SCOPES and _IMPLICIT_SCOPE_WALK; seven inline
duplicates collapsed.
- tools.py grows _apply_kind_variant for the per-kind tool surface;
tools without kind_variants pass through unchanged (no spurious
deep-copies).
- Session adds _coordinator_scope_id, _default_memory_scope,
_implicit_scope_walk, and _record_fatal_error chokepoints so the
worker-thread fatal path is one site rather than three.
- Removed duplicate on_error / on_state_change emits from
session_routes.py and coordinator_adapter.py — session.send()'s
_record_fatal_error owns the sequence now.
Tests: 4742 pass (no live), +30 net since the baseline. Ruff + mypy
clean on every modified production file.
* fix(coord): redact secrets in tool error paths via output_guard
Copilot review flagged two paths where ``str(exc)`` flowed back into
the model-facing tool_result without going through the credential-
redaction the new fatal-error path applies:
- ``ChatSession._safe_prepare_tool``: a preparer-side exception
becomes an error item whose ``error`` field embedded the raw
exception text.
- ``ChatSession._execute_tools.run_one``: a runtime tool exception
became an ``Error executing X: <e>`` tool_result, again with
the raw exception text.
Both now route through ``sanitize_error_text`` (sanitised log line +
sanitised tool_result), and ``sanitize_error_text`` itself was
refactored to delegate to ``output_guard.redact_credentials`` instead
of carrying its own parallel regex catalog — the audit log + post-tool
guard already use that pattern set, so the credential definition
stays in one place.
Also extended ``_RE_CONNECTION_STRING`` in ``output_guard`` to cover
``http(s)://user:pass@host`` so a misconfigured ``OPENAI_BASE_URL``
that lands in an httpx ``ConnectError.__str__`` is redacted by every
caller of ``redact_credentials`` (audit details, close-reason
persistence, last_error, the two tool error paths). The
host (useful for triage) survives; only the password is replaced
with the standard ``[REDACTED:password]`` marker.
Tests: full suite (4745 pass), ruff + mypy clean. Two new tests pin
the redaction behaviour in both tool error paths so a future refactor
can't drift back to leaking ``str(exc)`` verbatim.
|
||
|
|
352a27915a |
feat(coord): per-coordinator status bar + richer history replay
Bring the coord dashboard toward parity with the interactive pane on two operator-visible surfaces: - Status bar pinned above the composer. Same four cells as the interactive pane (model, token / context-window usage with effort suffix, tool calls this turn, conversation turn) driven by the same on_status SSE events. ws-status-bar CSS hoisted from ui/static/style.css to shared_static/chat.css so both UIs read one copy. StatusBar.paint helper extracted to shared_static/status_bar.js; both Pane.prototype.updateStatus and the new coord updateStatusBar delegate to it so warn/danger thresholds, prefix glyphs, and effort-suffix rules can't drift. CTX_WARN_PCT / CTX_DANGER_PCT now named constants on a single line. - _coord_events_replay now yields the connected + status preamble via a shared session_replay_preamble helper in turnstone/core/session_replay.py. _interactive_events_replay routes through the same helper so a future field add lands once. Coord still skips conversation history in the SSE replay (the dashboard fetches it via GET /history); only the status preamble is shared. - History replay reconstructs tool calls. Pre-fix, an assistant turn that only dispatched tools rendered as an empty bubble followed by raw tool-result text — the call's intent and parameters were lost on reload. synthesizeHistoricalToolCall builds an appendToolCall-shaped item from the persisted function.name + function.arguments (special-casing bash so the shell line shows in the header). Tool result rows now resolve their label from the matching tool_call_id instead of always printing "tool". - onopen restores the tokens placeholder when no prior status was seen, so a transient SSE blip on a fresh coord doesn't leave the dim "Reconnecting…" copy stuck until the next live tick. Tests: 4 new tests for the shared replay preamble (connected first, status only when last_usage present, status payload shape, no-session fallthrough); existing approval/verdict ordering tests refactored through a shared make_replay_mocks helper in tests/_replay_helpers.py that both interactive and coord suites import. |
||
|
|
b1de1584c6 |
fix(coord): None-safe slice in _evaluate_intent projection
tasks(update) is the only mutation that allows title to be omitted,
so _prepare_tasks stores ``item["title"] = None`` for an update that
only changes status/child_ws_id. _evaluate_intent then projected via
``it.get("title", "")[:100]`` — but dict.get returns the stored None
(the default kicks in only when the key is absent), and the slice
crashed with ``TypeError: 'NoneType' object is not subscriptable``.
The exception fired before any tool in the parallel batch executed,
so the assistant's tool-call message was already on the wire while
no tool-result entries followed. Reconstruction/sanitisation later
synthesised "Tool execution was cancelled" for every sibling — the
visible symptom that masked the real None-slice failure.
- Switch tasks/notify/task_agent/plan_agent/spawn_workstream/
spawn_batch/send_to_workstream/close_workstream/close_all_children
projections to ``(it.get(x) or "")[:N]`` so absent and explicit-None
both fall back to the empty string. The other tools weren't
observed crashing, but the bug shape is identical at every site;
hardening the projection layer once costs one extra ``or`` per line
and removes the foot-gun for any future preparer that stores None.
- Regression tests reproduce the original TypeError on
``tasks(update)`` without title both standalone and in a parallel
batch alongside ``tasks(add)``.
|
||
|
|
39aa493d76 |
feat(console): per-call model + judge_model on coord composer (#440)
* feat(console): per-call model + judge_model on coord composer Brings the landing-page coordinator composer toward parity with the interactive new-ws modal — operators can now pick a model and judge model per session without round-tripping through the Models admin tab. - Add Model + Judge Model selects to the home composer's options panel, populated from /v1/api/models. Empty / non-string fields collapse to None so the factory falls back to ConfigStore defaults (coordinator.model_alias, judge.model). - _coord_create_build_kwargs threads the body fields onto mgr.create. - Console session factory accepts judge_model and overrides the JudgeConfig via dataclasses.replace, mirroring the server-side interactive factory's pattern (alias preserved for IntentJudge's provider/client resolution). - Sanitise the 503 factory-misconfig response across make_open_handler, make_create_handler, and make_detail_handler: a new _safe_factory_misconfig_message helper strips control characters and caps at 200 chars before echoing exc text. Operators still get the full alias in the warning log; clients see a bounded printable string. Defends the user-controlled body["model"] reflection surface on the create path. - _build_mgr_with_factory test helper extracted from _build_mgr so tests that need to capture factory kwargs don't reconstruct the CoordinatorAdapter + SessionManager scaffolding inline. - Tests cover: passthrough of model + judge_model, empty / whitespace / non-string body fields collapsing to None, and the 503 sanitiser truncating + scrubbing a hostile alias payload. * fixup: address PR #440 Copilot review - _safe_factory_misconfig_message: hard-cap return at _FACTORY_MISCONFIG_MAX_LEN total (was MAX_LEN+1 because the slice was MAX_LEN long with the ellipsis appended on top). Reserve one codepoint for the ellipsis so the cap is honoured. Update the regression test to assert the tighter bound. - Composer judge_model placeholder: "Default (agent model)" was misleading when ConfigStore judge.model is set — the actual fallback is judge.model when set, IntentJudge's agent-model fallback when not. Use "Default judge model" instead so the label matches both configs. |
||
|
|
36f7bd5c80 |
refactor(console): trim landing-page friction
- Drop the duplicate "N nodes · M workstreams" header span — same data is
already on the page.
- Drop the "+ new" workstream header button + modal; the coordinator
composer is now the primary entry point on the landing page.
- Always render the NODES list inline; remove the cluster-summary
compact toggle since the list already self-collapses same-prefix
nodes into groups.
- Replace the meta node-detail page (#view-node) with direct navigation
to /node/{node_id}/. Removes drillDownToNode, loadNodeDetail,
_loadNodeMetadataPanel, the popstate "node" branch, and the
currentNodeId/currentServerUrl state.
- popstate now falls back to showHome() for unknown state shapes so a
back-nav from a tab on an older build doesn't no-op.
- test_index_landing_surfaces guards the removed IDs from
reintroduction.
|
||
|
|
ea204226ad | chore: bump version to 1.5.0a5 v1.5.0a5 | ||
|
|
6f5cb33923 |
feat(coord): composer parity with interactive — stop/queue/attach (#438)
* feat(coord): composer parity with interactive — stop/queue/attach
Bring the coordinator one-pane UI to feature parity with the
interactive composer: in-composer Stop button replaces Send during a
turn, queue-while-busy with !!! priority + dismiss, paperclip attach
+ drag/drop/paste. The coord backend already supported all three
(lifted send/cancel/attachment handlers, emit_message_queued=True,
supports_attachments=True); this wires the UI through.
Backend:
- Wire make_dequeue_handler(coord_endpoint_config) so DELETE
/v1/api/workstreams/{ws_id}/send works for coord-kind workstreams.
- Add the matching OpenAPI EndpointSpec.
- Five new test_dequeue_* tests (success, not_found, missing msg_id,
unknown ws, scope gate) pin the URL/method/scope contract.
Frontend extraction:
- New shared modules composer_attachments.js (createAttachmentController)
and composer_queue.js (createQueueController) replace ~300 LOC of
pre-existing duplication between the interactive Pane and the coord
IIFE. Both panes now share one source of truth for the chip pipeline,
optimistic queue bubble, and busy-edge promote sweep.
Coordinator pane:
- Composer constructor adds attachments/stopBtn/queueWhileBusy/
busyPlaceholder/dragDrop options.
- setBusy now drives off SSE state_change (running/thinking/attention →
busy; idle/error → idle), with composer.setBusy unconditional and the
edge-only work (timer cleanup + queue.onIdleEdge) gated on the actual
transition.
- Cancel uses the in-composer Stop with a 2s "Force Stop" affordance +
10s safety auto-recover; the legacy header-mounted #coord-cancel-btn
is removed.
- coordCloseSession suspends SSE before close and re-establishes it on
any failure path so the UI never goes dark on a still-alive session.
- Race handling: bind() releases the queued slot server-side when the
bubble was already dismissed or promoted; rehydrate re-checks getWsId
in its .then so a stale-tab response can't clobber the new tab's
chips.
Interactive pane:
- Pane class adopts the same controllers via this.attachments /
this.queue. Pane.prototype.uploadAttachment, _renderAttachmentChip,
_swapPlaceholderChip, _removeAttachmentChip, removeAttachment,
rehydrateAttachments wrapper, addQueuedMessage, _dequeueMessage, and
_promoteQueuedMessages are all gone — the controllers own the state.
- setBusy collapses to the same shape as coord: composer.setBusy +
edge calc + queue.onIdleEdge on idle.
CSS:
- Move .msg-queued / .queued-badge / .queued-dismiss styles from
ui/static/style.css into shared_static/chat.css so both panes share
one rendering.
- Add .coord-drop-target overlay rule so the coord pane shows the
drag-and-drop affordance.
Tests pass: 160 in the impacted suites (coord endpoints + attachments
+ session routes), including 5 new dequeue tests for coord.
* fix(coord): Copilot review + lint follow-ups
Lint:
- ruff: cast(MagicMock, ...) → cast("MagicMock", ...) under
``from __future__ import annotations`` (UP037).
Copilot review (PR #438):
- composer_queue _sendDelete now invokes onAfterDequeue on success
so a bind() race-DELETE (queued bubble dismissed pre-bind or
promote sweep raced ahead) still rehydrates the caller's chip pile;
released attachment reservations no longer linger invisibly until
the next page load.
- Coord's createQueueController gains onAfterDequeue: attachments.
rehydrate(). The previous omission was a v2 review carry-over from
before coord supported attachments — now it does, so the same
contract as interactive applies.
- Both panes' send-response handler now accepts status:queued without
a queuedEl (SSE-not-yet-connected race on initial load): flips busy
so subsequent sends queue correctly. The current message keeps its
optimistic user bubble — accepted UX gap (no in-UI dismiss for
THIS message) since flipping a rendered user bubble into a queued
one mid-stream would be jarring.
- Doc updates: chat.css comment + composer_queue.js module docstring
refer to the renamed onIdleEdge() instead of the removed
promote()/promoteQueuedMessages.
|
||
|
|
5ad5f4d12a |
chore(compose): raise per-node memory caps to fit current footprint
Cluster nodes were OOM-killing under MCP child-process load with the old 384M/0.5cpu budget chosen for a leaner, pre-MCP turnstone. Bump each cluster server to 4G/4cpu and postgres to 4G/4cpu. The single-node server, console, and channel services remain uncapped. |
||
|
|
dea2729292 |
refactor(coordinator): rename task_list → tasks, doc/prompt sweep (#437)
Four themes from a coordinator-feature shakedown:
1. Correctness fixes (return shapes / examples / behavior)
- tools_coordinator.md: drop fake skill names from spawn examples;
fix wrong kwarg ``node_id=`` → ``target_node=``.
- wait_for_workstream.json: document ``message`` + ``truncated``
per-ws fields (always enriched in the client; the JSON shape
lagged the docstring).
- cancel_workstream.json: document the conditional ``dropped``
payload — ``was_running`` always present when ``dropped`` is,
``pending_approval`` and ``queued_messages`` conditional sub-shapes.
- spawn_workstream.json: document full return shape including
``routing_strategy ∈ {rendezvous, target_node, resume}`` and
``status``.
- close_all_children.json: clarify ``skipped`` covers BOTH
hard-deleted children AND already-closed-and-evicted children
(wire shape doesn't distinguish); drop incorrect "echoed back
in response" claim — server returns ``{status, closed, failed,
skipped}``, never echoes ``reason``.
- console/server.py: comment in ``_fanout_on_children`` clarifying
that the 400 "No session" branch fires for cancel-cascade
callers and is unreachable from close_all_children (close
handler 404s instead).
- coordinator_client._utc_now_iso(): switch to bare ISO format
matching the rest of the storage row format used in the codebase.
2. Tightened the 11 longest tool descriptions (~23% cut on the
coord set). Removed ALL-CAPS emphasis, normalised em-dashes,
dropped informal phrasing. No new claims.
3. Removed static approval annotations from descriptions.
Approval is governed at runtime by the unified ``approve_tools``
body and admin-defined ``tool_policies`` (#436); static
"Auto-approved" / "Approval required" / per-action approval
tags become a stale signal. Field names (``pending_approval``)
and operational verb behaviour ("cancel unblocks pending
approvals") stay.
4. Renamed ``task_list`` coord tool → ``tasks``. The previous name
compounded the bare word ``task`` (which collides with chat-template
channels on local models — same reason ``task_agent`` carries
the suffix); the plural form sidesteps the collision and reads
more accurately, since the tool acts on the whole list rather
than a single task. Sweep covers tool JSON, Python methods (5
client methods + 2 session methods + 1 helper + 1 constant),
audit event name (``task_list.update`` → ``tasks.update``), log
tag (``task_list.corrupt_envelope`` → ``tasks.corrupt_envelope``),
frontend SSE event matcher, prompts, docs, and tests. CHANGELOG
entry added.
Plus: dropped the ENV block (Output Environment / Available
rendering / Formatting principles) from coordinator system
prompts. Coordinators orchestrate rather than render rich output
to the user, so the rendering capability matrix is not actionable
for them. Coord prompt drops ~29% (6309 → 4493 chars).
SDK regeneration via ``generate-types.py`` updates both
``openapi-console.json`` (the rename's downstream change) and
``openapi-server.json`` (PR #436 drift — its merge added
``pending_approval_detail`` + ``recent_auto_approvals`` fields to
the Python schemas but didn't regenerate the JSON artifact).
## Behavior changes (operator-visible)
- Audit event name: ``task_list.update`` → ``tasks.update``.
Audit dashboards / SIEM filters / log greps that pinned the old
prefix should update.
- SSE ``tool_result`` events now ship ``name="tasks"`` for the
scratchpad tool. The bundled coord-tree UI is updated atomically;
external consumers reading SSE events by tool name need to update.
- Existing task envelopes in production storage have ``+00:00``
timestamps from the old ``_utc_now_iso``. New writes are bare;
old rows are not backfilled. Within an envelope you may briefly
see mixed formats until each row is re-touched. No code path
string-compares timestamps within an envelope, so this is
cosmetic.
## Validation
- ``ruff check`` + ``ruff format --check`` clean
- ``mypy turnstone/`` clean (175 source files)
- ``pytest -m "not live"`` — 4679 passed, 3 deselected
|
||
|
|
fb44652850 |
refactor(core): unify approve_tools across both kinds (#436)
* refactor(core): unify approve_tools across kinds + judge visibility + perf Lift WebUI.approve_tools to SessionUIBase so both interactive and coordinator workstreams run the same body. The shared body now owns tool-policy gating, per-tool auto-approve, blanket carve-out for __budget_override__, activity tagging, heuristic-verdict persistence, and the approve_request/approval_event blocking pattern. Subclass hooks layer kind-specific surfaces on top. This closes the drift the LLM-judge audit flagged on coord — the judge (heuristic + LLM tier) now sees actual tool args for every coord tool call instead of empty func_args. spawn_batch projects the full children list so a malicious mid-batch entry is no longer hidden. = Unification core = - SessionUIBase.approve_tools: lifted body covering policy / per-tool auto-approve / blanket / activity tagging / heuristic-verdict persistence / approval gate - _APPROVAL_WAIT_TIMEOUT class constant + _record_judge_metric hook - WebUI.approve_tools deleted; _record_judge_metric override fires per-node MetricsCollector.record_judge_verdict - ConsoleCoordinatorUI.approve_tools deleted; _record_judge_metric + on_intent_verdict overrides fire ConsoleMetrics.record_judge_verdict - ConsoleMetrics.record_judge_verdict + turnstone_judge_verdicts_total in /metrics text output (cluster PromQL rolls coord+interactive up uniformly) - _console_metrics class attribute wired in console lifespan - Frontend: coord SSE event tools_auto_approved -> tool_info for parity = Judge args visibility = - _evaluate_intent populates func_args for all coord tools that hit approval (spawn_workstream / spawn_batch / send_to_workstream / close_workstream / close_all_children / cancel_workstream / delete_workstream / task_list) - spawn_batch projects every child's skill / initial_message[:200] / target_node so the judge sees the full fan-out (was first child only) - fire_judge_verdict_metric helper collapses 4 sites of identical record_judge_verdict shape across WebUI + ConsoleCoordinatorUI = Hardening = - __budget_override__ carve-out reads from pre-filter items list, not post-filter pending; policy block skips matching the synthetic name entirely so a wildcard `*: allow` cannot strip the override before the gate sees it - _persist_intent_verdict default_tier parameter so heuristic + llm paths share the storage write helper = Performance = - TTL cache on list_tool_policies in turnstone/core/policy.py (60s, keyed by org_id, lock-free hits) - Storage-layer invalidation: create/update/delete_tool_policy on both SQLite and PostgreSQL backends call invalidate_policy_cache (covers admin-API path + direct test fixtures + any future caller) - Admin-API handlers also call invalidate_policy_cache as defense-in-depth - storage.create_intent_verdicts_bulk on both backends: one multi-row INSERT + one commit instead of N round-trips. approve_tools switches to the bulk path so a fan-out turn no longer pays N x commit before the approval prompt enqueues - _persist_intent_verdicts_bulk helper on SessionUIBase = Test coverage = - tests/test_coord_ui_approve_tools.py (NEW, 17 cases): inheritance regression, tool-policy deny/allow/mixed on coord, heuristic verdict persistence (bulk path), activity tagging on auto-approve and pending, judge_pending dynamic flag (true + false), event-name parity, per-tool auto-approve, __budget_override__ carve-out under blanket + wildcard policy, _record_judge_metric wired/unwired, on_intent_verdict llm-tier metric - tests/test_console_metrics.py: 3 cases for the new record_judge_verdict counter - tests/test_judge_storage.py: 3 cases for create_intent_verdicts_bulk - tests/test_coordinator_tools.py: 3 cases pinning the spawn_batch full-children projection (truncation, mid-batch visibility, empty defensive) - tests/conftest.py: autouse _clear_policy_cache fixture so the process-level cache doesn't leak between tests with distinct storage instances = Drift fixes (review feedback) = - Refresh stale "no-op on coord" comments now that coord overrides the hook - WebUI.on_plan_review timeout uses self._APPROVAL_WAIT_TIMEOUT instead of literal 3600 - Drop redundant bool() wrapper around any() in judge_pending - Rephrase broken docstring grammar in _coord_spawn_metrics - Hoist redundant get_storage import out of approve_tools per-item loop (folded into _persist_intent_verdicts_bulk helper) = Validation = - pytest -m "not live": 4679 passed, 3 deselected - ruff check + ruff format: clean - mypy: no issues in 175 source files * fix(approval): apply Copilot feedback on PR #436 - Policy-cache invalidation now drops both the org-scoped slot AND the default ``""`` slot on ``create_tool_policy`` for both SQLite and PostgreSQL backends. ``list_tool_policies("")`` returns rows from every org_id, and the production evaluators (SessionUIBase.approve_tools / cli.py) read with the default ``org_id=""``, so an org-scoped insert that only invalidated its own slot would leave the default cache slot stale until the TTL window expired. - Cap ``reason`` to 200 chars in ``_evaluate_intent`` for ``close_workstream`` and ``close_all_children`` — both fields are LLM/user-provided and the preparer doesn't size-limit them, so an unbounded reason could bloat the persisted verdict row's func_args. Matches the cap applied to other free-form coord tool fields (initial_message, message, title). - Refresh ``_PolicyCache`` docstring: it claimed lock-free reads on cache hit but ``get()`` always acquires ``self._lock``. Updated to reflect that the lock is held briefly to copy the policies reference. Validation: targeted suite 201/201, ruff + mypy clean. |
||
|
|
1fe800f832 |
refactor(ui): drop legacy ts-composer prefix on shared composer classes
Follow-up to #434. That PR unified the chat-message primitive on .msg and noted that the parallel .ts-composer prefix on the shared composer widget was still in place; this drops it so the widget sits in the shared/* vocabulary the same way .msg does. Mechanical 1:1 rename (`ts-composer` -> `composer`) across: shared_static/chat.css — 48 selectors shared_static/composer.js — 19 className strings ui/static/style.css — 11 per-node UI overrides ui/static/app.js — 7 chip queries / className strings Pre-rename collision check confirmed clean: the only `composer`-substring matches in the codebase were IDs (#coord-composer-mount, #coord-composer- panel, #coord-composer-503, #home-coord-composer-mount — IDs are a different namespace from classes) and the unrelated console .home-composer-banner / .home-composer-error pair (different prefix). CSS specificity audit (scripts/css_specificity_audit.py): 26 findings on origin/main, 26 on this branch — no new cascade flips. Tests: 189 affected tests pass (test_app_js, test_webui_content, test_webui_auto_approve_visibility, test_html, test_web_helpers, test_coordinator_adapter, test_coordinator_client). Manual visual verification of composer surfaces (textarea, send button, stop button, attach button + file picker, chip pills + remove buttons, options panel toggle, paste-image and drag/drop attach paths, stacked layout used by creation forms) recommended before merge. |
||
|
|
94edd741d3 |
refactor(ui): drop legacy .ts-msg* dual-classing in chat surfaces (#434)
* refactor(ui): drop legacy .ts-msg* dual-classing, chat surfaces share .msg primitive Third and final follow-up after #431 stripped the data-design="v1" gate. This drops the parallel .ts-msg* family that had been kept as a transitional bridge during the gated rollout. Per-node UI now renders pure .msg classes (previously dual-classed as "ts-msg ts-msg--user msg user"), matching the coordinator chat view which already used pure .msg*. chat.css: deleted the ~210-line legacy .ts-msg* rule block (Messages + floating action toolbar + mobile + reduced-motion sections); renamed .ts-msg.ts-approval--inline to .msg.ts-approval--inline; restored the streaming-markdown rationale (white-space: normal intent + partial-fence behavior + .msg-user-text path) on .msg-body that previously lived on the deleted .ts-msg-body, with white-space: normal now declared explicitly so a future "simplification" can't silently break streaming. ui/static/app.js: dropped the ts-msg* half of every dual-class string and updated querySelector callsites (.ts-msg--user -> .msg.user, .ts-msg--assistant -> .msg.assistant). ui/static/style.css: renamed all .ts-msg--* selectors to .msg.*; removed two now-dead override rules (.ts-msg.msg:not(.tool) and .ts-msg-body.msg-body font-family overrides) that existed solely to unwind the legacy .ts-msg font-mono default that's now gone. The .msg.ts-approval--inline selector intentionally keeps the .msg qualifier (rather than bare .ts-approval--inline) so its (0,2,0) specificity ties with .ts-approval.approved/.denied/.error and the later-cascade rule wins; without the qualifier those state classes would suddenly flip the inline-approval card colour based on state. Composer rename (.ts-composer-* -> .composer-*) deferred to a follow-up PR; ~80 occurrences across composer.js + chat.css would have obscured this verification. Tests: 538 affected tests pass (test_app_js, test_webui_content, test_webui_auto_approve_visibility, test_web_helpers, test_html, test_auth, test_console, test_api_versioning, test_coordinator_*). Visual verification (message cards, hover toolbar, approval/denial/error cards in light + dark themes) recommended before merge. * docs(ui): clarify .msg.reasoning emission comment per Copilot review The previous wording — ".reasoning as a bare role class is no longer emitted" — implied .reasoning is never emitted, but the new className is "msg reasoning" so .reasoning IS emitted, just always alongside .msg. Reword to make the actual invariant (never on its own) explicit. |
||
|
|
4b5edce8c5 |
fix(css): two cascade-flip bugs found by specificity audit (#433)
* fix(css): two cascade-flip bugs found by specificity audit PR #431 stripped [data-design="v1"] from ~400 rules, dropping each by a specificity tier; two cascade flips (#header outranking .appbar, #header h1 outranking .appbar-title) were caught visually during that PR's review and fixed by renaming id="header" → id="ui-header" on the per-node UI page. This is the audit follow-up; it found two more: - textarea.skill-content-area (was .skill-content-area) — bumped to (0,1,1) so the rule ties with `.admin-modal textarea` (0,1,1) and wins on source order. Without the bump, min-height: 220px was clobbered to 40px by the modal default and the spec-content textarea rendered short. The three !important markers (font-family/size/line-height) are now redundant against the modal's font: inherit shorthand and are dropped. - h3.skill-spec-heading — removed `font-size: inherit;`. The author wrote it to "reset UA defaults" but it locked font-size to the parent's (~14-16px) at (0,1,1), silently overriding `.skill-spec-heading`'s 10px at (0,1,0). The bare class already beats UA `h3` on specificity (class > tag), so no font-size reset was needed; the `margin-block: 0` line stays because the bare class's `margin: 14px 0 6px` shorthand may not reset the UA's logical margin-block-start/end on every engine. Adds scripts/css_specificity_audit.py — the audit tool. It parses every CSS file referenced from the project's three HTML entry points, computes selector specificity (incl. :not/:is/:has math, attribute selectors, and !important), and flags every place an unscoped legacy rule could outrank a bare-class designed primitive. Honours per-page stylesheet manifests, state-pseudo subset gating (a `:hover` rule overriding a resting-state base rule is intentional, not a flip), and shorthand→longhand expansion for font/padding/margin/border/background. Triage of remaining findings (26 id-tier in default mode, 74 total at --all-tiers) confirmed all are intentional designer overrides — id-scoped buttons, BEM modifier classes, contextual ancestor selectors, last-child margin reset, [hidden] toggle. * fix(css-audit): correct two cascade-resolution bugs flagged by Copilot 1. _parse_declarations dict insertion order didn't update on overwrite, so a sequence like `font-size: 13px; font: inherit; font-size: 12px;` would iterate as (font-size=12px, font=inherit) and the shorthand expansion then clobbered font-size back to `inherit` — wrong. Delete-then-insert on overwrite so the last occurrence lands at the dict's tail and the shorthand expansion sees the real source order. 2. The cascade-winner tie-break used `rule.line_no` only, ignoring the stylesheet load order. A rule at line 1000 of `base.css` looked "later" than a rule at line 50 of `style.css`, even though the page loads `base.css` BEFORE `style.css`. Sort by `(file_index, line_no)` keyed off the element's per-page stylesheet manifest instead. |
||
|
|
83a97ba485 |
fix(ui): preserve approval pill when tool errors (#432)
* fix(ui): preserve approval pill when tool errors
When an approved (or auto-approved) tool subsequently failed during
execution, both `replayHistory` and `appendToolOutput` located the
existing `.ts-approval-badge` and overwrote its className + textContent
with the `--error` variant — losing the record that the user had
approved the call.
Append a separate `--error` pill as a sibling of the existing approval
pill instead. The `.ts-approval` parent is `flex-direction: column` with
a 6px gap, so the two pills stack vertically and read as a small
status timeline ("you approved this, then it errored"). Idempotency
guard via `querySelector(".ts-approval-badge--error")` so duplicate
fires don't stack badges.
CSS classes are unchanged (the `--error` modifier already exists in
both per-node and shared chat stylesheets).
Adds a static-string guard in `tests/test_app_js.py` that pins both
call sites and forbids the mutate-in-place anti-pattern via a regex
that pairs a queried `.ts-approval-badge` handle with an `--error`
className overwrite.
Deferred from #431.
* refactor(ui): extract appendToolErrorBadge helper, broaden test guard
Address Copilot feedback on #432:
- Extract the duplicated 5-line error-pill construction into a single
module-level `appendToolErrorBadge(blockEl)` helper next to the
other approval-related helpers (`buildToolDiv`, `renderVerdictBadge`,
`toggleVerdictDetail`). Reduces drift risk on ARIA / class / text
string between the two call sites.
- Loosen the affirmative test check from a literal substring keyed on
the local variable name to a regex matching any
`querySelector(".ts-approval-badge--error")` lookup, in either
quote style, in either guard idiom (`if (!q) {...}` at a call site
or `if (q) return;` inside the helper). A future refactor that
preserves behaviour shouldn't trip CI on cosmetics.
- Broaden the anti-pattern regex to accept single quotes and to
catch the `classList.add("ts-approval-badge--error")` form on a
queried badge handle, not only `className = "..."`.
|
||
|
|
cc20c7008d |
refactor(css): unify design system, eliminate data-design="v1" gating (#431)
* refactor(css): unify design system, eliminate data-design="v1" gating Strip the [data-design="v1"] attribute that was wrapping every DS rule since #389 and never came back out. Result: every styled element on coord/ui pages had two CSS rules (default + v1-gated), reviewers couldn't tell which one rendered, and the bundle shipped duplicates. Changes: * Strip [data-design="v1"] prefix from ~400 gated rules. Remove the attribute from coordinator/index.html, ui/index.html, preview.html. * Merge shared_static/design/* into pre-v1 sheets: tokens + typography → base.css (:root, dark default) appbar + panel + buttons + pills + field → ui-base.css message primitives → chat.css sidebar + approval-dock → console/static/coordinator/coordinator.css (new file linked from coord only — admin no longer ships ~9KB of coordinator-only chrome on first load). * Delete preview.html + 5 preview-only orphan stylesheets (topbar / stats / feed / fleet-grid / live-feed). Drop the empty shared_static/design/ directory. * Drop unused primitives the merge dragged in: .pill / .k-badge / .chip / .field / .t-* utilities / .side-item / .shell. Drop unused tokens (--accent-c / --accent-l / --row-h / --density / --gap / --font-display alias). Find-replace var(--font-display) → var(--font-ui) across 6 files (103 sites). * Standardize on the DS font stack: Inter body, JetBrains Mono code. Admin's body shifts from IBM Plex Mono → Inter via the alias rename. * Re-tune legacy --bg/--bg-surface/--bg-highlight/--bg-elevated from steel-blue to neutral charcoal so admin and v1 pages share one palette. Drop the cyan radial-gradient overlay on body that added a blue tint to the formerly-blue bg. * Polish: - .msg.tool / .ts-msg--tool / .ts-approval / inline-approval all use --cyan instead of amber, removing the user/tool colour collision. - .msg-action-btn reverts to icon-button styling (transparent, 28x24) after the merge gave it text-button chrome that dwarfed the 13-14px icon glyphs inside. - Light-mode composer contrast: flip .ts-composer surface roles (wrapper recessed, textarea elevated) so the textarea reads against its container; bump .dashboard-composer textarea border-bottom + options panel surface so they're visible on white. - .pane-messages padding 20px -> 16px 12px and gap 14px -> 0 (the flexbox gap was stacking with .ts-msg margin-bottom for ~18px inter-card spacing); .ts-msg/.msg margin-bottom 8px -> 4px. - Restore WCAG 2.5.5 36x36 touch target on .msg-action-btn. - Rename ui's <div id="header"> to id="ui-header" so the legacy #header chrome no longer outranks the .appbar primitive on the per-node page (3 getElementById calls in app.js updated). - Drop chat.css link from coord (coord renders pure DS classes; the composer.js consumer of chat.css is on admin + ui only). Verified: ruff + mypy clean (175 files); 4653 non-live tests pass; zero data-design / --font-display / shared_static/design hits remain. Net source change: -1885 lines (924 added, 2809 deleted across 28 files). * build: include coordinator.css in wheel; drop dead design/ glob The previous commit added turnstone/console/static/coordinator/coordinator.css (coord-only chrome moved out of console/static/style.css) but didn't update the [tool.hatch.build.targets.wheel] include list, so CI's wheel-completeness check failed. Also drop the now-stale 'turnstone/shared_static/design/**/*' glob — that directory was deleted in the same v1-elimination commit. Verified locally: replicating the CI step's source-vs-wheel diff returns MISSING: none. * fix(css): address Copilot review feedback on PR #431 * coordinator/index.html — comment now correctly points to the moved .sidebar rules at console/static/coordinator/coordinator.css (was console/static/style.css before the perf-1 split-out). * ui/static/index.html — restore <h1 class="appbar-title">; the cascade conflict that motivated the h1→div change is gone now that the wrapper id was renamed away from #header (the legacy #header h1 rule no longer matches). Page semantics + accessibility regain the top-level heading. * governance.js — drop the inline font-family:var(--font-ui) on the config-key <code> elements; let them inherit the global mono default from base.css. The inline style was an artifact of the --font-display → --font-ui find-replace; the original Outfit was already odd on a <code> tag. * ui-base.css — typography-helpers comment said "Body text still inherits var(--font-mono) at 13px" but base.css now sets var(--font-ui) at 14px. Reword to match current defaults. |
||
|
|
9b5096fe3c |
fix(approve): visibility for child tool calls bypassing operator gate (#430)
* fix(approve): visibility for child tool calls bypassing operator gate
When a coord LLM spawns a child with `skill="X"`, the skill template's
`allowed_tools` JSON list silently populates the child UI's
`auto_approve_tools` set. Tool calls whose names are in that set
short-circuit the approval gate without prompting the operator —
matching the user-reported bug "tool calls of children occasionally
getting approved instead of waiting for approve/deny".
The auto-approve paths themselves are unchanged (Option C — visibility
only). Surfaces:
- Per-item annotations: each pending tool gets `auto_approved=True` +
`auto_approve_reason` ("skill" / "always" / "policy" / "blanket" /
"auto_approve_tools") at the four gate-bypass paths.
- Per-ws ring buffer (cap 10) of recent bypasses, exposed via
`/dashboard` and the cluster live-bulk projection so the coord-
tree row can render an "auto-approved by ..." pill.
- `tool.auto_approved` audit row per `approve_tools` call —
forensic durability beyond the in-memory ring buffer.
- Per-ws WebUI page: inline "auto: <reason>" badge next to each
tool name, so an operator who clicks through from the coord tree
to the child's page sees the same bypass signal.
Persistence across UI rebuilds:
- The ring buffer is in-memory only; a saved-workstream rehydrate /
coord→node click-through / process restart all build a fresh UI.
`replay_recent_auto_approvals_from_audit` runs at the end of
`SessionUIBase.__init__` and re-seeds the buffer from recent
`tool.auto_approved` audit rows scoped to this ws_id.
- Adds `resource_id` filter to `list_audit_events` (protocol +
SQLite + Postgres) so the replay is a single indexed query.
Source provenance:
- `_auto_approve_tools_source: dict[str, str]` per UI tracks which
writer added each tool name to `auto_approve_tools` ("skill" at
skill-template setup time, "always" on Approve+Always click).
Lets the dashboard pill distinguish a skill-driven bypass from
an explicit operator-Always click — those are very different
signals that previously rendered the same.
Magic-string drift mitigation:
- `AutoApproveReason` constants in `core/session_ui_base.py` lift
the five reason strings into a single source of truth.
- `KNOWN_AUTO_APPROVE_REASONS` JS constant + validator render
unknown reasons as "unknown" with a console.warn instead of
rendering raw (a typo would otherwise silently desync wire ↔
pill).
Recording-leak fixes (q-2 from review):
- Policy `allow` partial-resolve now records the policy-tagged
items at two previously-leaking branches: the early-return-on-
deny path and the still_pending-non-empty fall-through to the
prompt path.
Other review fixes:
- Heuristic verdict surfaces consistently as `heuristic_verdict`
in both `_serialize_approval_items` and the dashboard
serializer (was inconsistent: one emitted `verdict`, the other
`heuristic_verdict`). app.js updated to read either key for
mid-deploy compatibility.
- `_tag_auto_approved` helper on SessionUIBase replaces the
verbatim tag loops previously copy-pasted across WebUI and
ConsoleCoordinatorUI.
* fix(approve): apply Copilot review feedback on PR #430
- coordinator_ui: use ``approval_label or func_name`` for the
``auto_approve_tools`` subset check, matching WebUI. Pre-fix
an "Approve + Always" entry whose approval_label differs from
func_name (skill__name, mcp_resource__uri) wouldn't match on
the coord page and the operator would be re-prompted.
- _parse_audit_timestamp: treat naive ISO strings as UTC. Audit
rows are written via ``datetime.now(UTC).strftime(...)`` with
no timezone marker; ``datetime.fromisoformat`` returns a naive
datetime, and ``.timestamp()`` on a naive datetime interprets
it in the server's local timezone — wrong on any non-UTC
server. Stamp UTC explicitly before converting.
- server.py: drop the dead ``pending = []`` after the blanket
tag — the function returns inside the same block without
reading ``pending`` again.
- _protocol.py: fix docstring reference from
``_replay_recent_auto_approvals`` to
``replay_recent_auto_approvals_from_audit`` (the actual
method name).
|
||
|
|
d15f182b80 |
fix(coord): tree UI not updating when LLM deletes workstream (#429)
* fix(coord): tree UI not updating when LLM deletes workstream The coord LLM's `delete_workstream` tool wiped the storage row but fired no SSE event, so a long-lived dashboard tab kept the deleted child visible (with its last-known idle/closed state) until a full reload. A coordinator that spawns→completes→deletes children would leave an ever-growing tree. Fix: add `SessionManager.delete()` that drops the in-memory slot if present and emits `ws_closed` with `reason="deleted"` (mirrors `close()`'s shape). Wire `delete_workstream_endpoint` to call it after the storage delete succeeds, snapshotting the workstream's name into the event payload before the row is wiped. The cluster collector → coord adapter chain re-emits as `child_ws_closed`; the browser's existing `handleChildClosed` already keys on `reason === "deleted"` to mark the row, so no JS changes needed. Event emit is best-effort — a fan-out failure logs a warning but doesn't roll back the storage delete (the row is already gone). * fix(coord): apply Copilot review feedback on PR #429 - server.py: clarify that ``name`` is forwarded to mgr.delete only (not into the audit detail) — comment previously claimed both. - test_session_manager.py: extract ``mgr.delete(ws_id)`` to a local before asserting (CodeQL: no side-effecting calls inside ``assert``, which would be stripped under ``python -O``). - test_workstream_endpoints.py: docstring said "Yield" but the fixture ``return``s; switch to "Return". |
||
|
|
e33519275e |
docs(coord): fix wait_for_workstream message-field claim re deleted state
Copilot caught a doc/code mismatch from the q-2 cleanup: the docstring still claimed `closed` / `deleted` / `denied` all return a sentinel, but the `deleted` branch was dropped (hard deletes cascade rows out of storage so the state is unreachable). Update the docstring to align with `_wait_message_for`'s actual behaviour — `deleted` falls into the same null-message shape as a still-running entry. |
||
|
|
91b07aaf4b |
feat(coord): bundle child last-message inline in wait_for_workstream
Each per-ws snapshot now carries `message` + `truncated` so the coordinator LLM doesn't need a follow-up `inspect_workstream` round-trip per child to read what came back. idle/error states return the last assistant turn (capped at 6 KiB UTF-8 bytes, truncated from the end); closed/denied return a sentinel; running children carry null. Storage reads for idle/error parallelize across an 8-worker thread pool so a 32-child fan-out lands in 4 batches instead of 32 sequential round-trips. |
||
|
|
15d5ddde12 |
fix(ui): bootstrap pane in switchTab when none exists (#427)
Creating or opening a workstream from the dashboard left the chat UI blank until the operator refreshed: switchTab early-returned at ``if (!pane) return;`` because getFocusedPane was null on a fresh- loaded page that had no workstreams. The freshly-created ws was added to the workstreams dict and the dashboard was hidden, but no pane was bootstrapped, no SSE connected, and the chat area sat empty until refresh — at which point initWorkstreams saw the populated list and bootstrapped the pane via the existing "if (!Object.keys(panes).length)" branch. switchTab now mirrors that bootstrap when no focused pane exists: createPane + splitRoot leaf + setFocusedPane + renderLayout. The rest of switchTab (disconnectSSE / reset / connectSSE) runs as before — no-ops on the just-constructed pane up to the connectSSE call which is exactly what we want. Subsequent creations on the same node already worked because the first create populated panes and switchTab found a focused one. Static smoke test in tests/test_app_js.py guards against the early-return regressing. |
||
|
|
438e6f41ba |
feat(renderer): progressive mermaid rendering during streaming (#426)
* feat(renderer): progressive mermaid rendering during streaming
Mermaid diagrams used to materialize all-at-once at stream_end via
streamingRenderFinalize, which felt laggy on long responses with
multiple diagrams. Now closed mermaid fences render progressively
as each fence completes during streaming.
The blocker was streamingRender's wholesale `el.innerHTML = html`
on every rAF tick, which destroys any rendered SVG nodes — without
caching, calling postRenderMermaid per tick would re-trigger an
async mermaid.render every time, thrashing the renderer.
Added a source-keyed SVG cache (_mermaidSvgCache, FIFO-bounded at
64 entries):
- Cache hit on identical source: synchronous innerHTML swap, no
loading flash, no async work. Mermaid is deterministic for a
given init, so identical source ⇒ identical SVG, safe to reuse.
- Cache miss: queue async render, populate cache on success.
- Errored sources cached separately (_mermaidErrorCache) so a
syntactically-broken diagram doesn't re-thrash mermaid on every
tick. The user can fix the diagram and the new source string
misses the cache, triggering a fresh render.
_streamingRenderApply now calls postRenderMermaid after the
innerHTML replace. Per-stream cost: each unique mermaid source
pays mermaid.render once, then synchronous cache hits for every
subsequent rAF tick. hljs syntax highlighting stays deferred to
streamingRenderFinalize (it's a separate pass and benefits less
from progressive rendering — code blocks tend to be short and
already legible without color).
Tests: built a richer Node-driven harness with a fake DOM that
tracks attributes / classList / parent chain / replaceWith, plus
a stubbed mermaid.render with a call counter. 5 new tests cover:
cache-hit skips render, distinct sources render independently,
errors cache to avoid thrash, FIFO eviction at cap, and a static
guard that _streamingRenderApply actually calls postRenderMermaid.
* fix(renderer): apply Copilot feedback on PR #426
Six review items, all real:
1. _cacheMermaidEntry evicted on overwrite — overwriting an
existing source unnecessarily dropped the oldest entry.
Now: only evict when inserting a new key.
2. _initMermaid didn't clear caches — a theme change via
reRenderAllMermaid (which calls _initMermaid) would serve
stale SVG keyed by source-only, since rendered output
depends on themeVariables. Now clears both caches on
(re-)init.
3. bindFunctions never re-applied on cache hits — mermaid's
bindFunctions attaches link/click handlers to each rendered
SVG instance. Pre-fix, only the first render got bindings;
subsequent cache hits via raw innerHTML left the SVG inert.
Cache value is now {svg, bindFunctions}; cache hits go
through _applyMermaidSvg which re-applies bindings on each
new container instance.
4. Truthiness checks on cache lookups — empty-string SVG / error
would have masqueraded as a miss. Switched to cache.has()
(and then .get) so intent is explicit.
5. Concurrent mermaid.render — postRenderMermaid now fires on
every streaming rAF tick, so multiple ticks could overlap
while earlier render Promises pend. mermaid.render uses
module-level state internally — concurrent calls clobber it.
Two layers of serialization fix this:
- _mermaidPending: per-source. While a render is in flight
for source X, additional containers asking for X are queued
and the single render result fans out to all pending
containers when it lands.
- _mermaidRenderChain: across-source. Promises chain so
mermaid.render runs at most one at a time globally.
- Detached containers (no longer in the DOM by the time the
render completes) are skipped via isConnected guard —
wholesale innerHTML replace during streaming detaches them
and a later tick is already taking care of the live one.
6. Test brittleness — _streamingRenderApply guard used
body.index("\\n}\\n", start) which would stop at the first
inner-block closing brace inside the function. Switched to a
bounded-window string search (Copilot's suggestion).
Three new tests added: overwrite doesn't evict; _initMermaid
clears caches; cache hit re-applies bindFunctions. Existing tests
updated for the new {svg, bindFunctions} cache shape and the
async serialization (drain via setTimeout hops instead of bare
microtask resolves).
Test harness fix: fake DOM elements now have an isConnected
getter derived from the parent chain, so the new guard
exercises correctly under test.
|
||
|
|
33d16d19ce |
fix(renderer): handle LaTeX-style \(...\) and \[...\] math delimiters (#425)
* fix(renderer): handle LaTeX-style \(...\) and \[...\] math delimiters The browser renderer at turnstone/shared_static/renderer.js only recognized TeX-style $...$ / $$...$$ delimiters. Most modern LLMs (GPT-5 / o-series, Claude with reasoning effort) emit LaTeX-style \(...\) for inline math and \[...\] for display by default — those slipped through as raw text in the coord + interactive WebUIs, making KaTeX appear "broken when nested inside a markdown block" (actually broken everywhere, the surrounding markdown just made the failure noticeable). Added a second pass for each delimiter style alongside the existing $...$ / $$...$$ patterns. Both styles now feed the same mathBlocks / inlineMaths placeholder pipeline so all the existing nested-block handling (lists, blockquotes, tables, bold, headings, details, post-render KaTeX markup) Just Works. Edge cases verified by the new test_renderer_js.py harness: - \(...\) inside inline code stays literal - \(...\) inside fenced code blocks stays literal - Solo \[ with no closing \] doesn't trigger spurious math - Markdown links [text](url) untouched (regex uses \[ \], not [ ]) - Mixed TeX + LaTeX delimiters in one message both render The harness drives renderer.js through Node via vm.runInThisContext with stubbed document/katex globals — first JS-side regression guard for the renderer; previously it had no test coverage at all. * fix(renderer): apply Copilot feedback on PR #425 Three review items from Copilot: 1. Display-math sentinel could leak through inline-code spans. The original ordering ran $$...$$ / \[...\] extraction BEFORE inline code, so a backtick span around math (e.g. `$$x$$` or `\[x\]`) had its delimiters consumed by the math regex and replaced with \x00MB…\x00. Inline code then captured the sentinel; restore order put MB after IC, leaving the null-byte placeholder visible inside the rendered <code>. Reorder: inline code first, then display math, then inline math. Code spans now seal their content before any math regex sees it. The reverse edge case (math containing backticks, e.g. \verb|`x`|) is much rarer and KaTeX rejects \verb anyway. 2. Inline LaTeX-style \(...\) regex used [\s\S]+? which allowed newlines, so an unterminated \( on one line would eat the next paragraph until it found a closing \). Aligned with the existing $...$ behavior by switching to [^\n]+? — display math (\[...\] / $$...$$) stays multi-line by design. 3. tests/test_renderer_js.py was guarded with a node-availability skip, but CI's test + test-postgres jobs didn't explicitly install Node, so the suite would have silently no-op'd if the runner image dropped Node. Added actions/setup-node@v5 to both jobs. Four new regression tests cover the leak (both delimiter styles inside backticks must stay literal) and the cross-paragraph span (both \(...\) and $...$ must not eat newlines). |
||
|
|
1f271789b3 |
fix(approve): global judge poll + Copilot round-2 feedback
Bug: LLM judge verdicts stayed stuck on heuristic-only render.
Root cause: per-row poller called scheduleLiveFetch which
short-circuits on non-visible rows — invalidate cleared the
cache, no fetch fired, the row kept rendering its last-cached
heuristic indefinitely. The 12s attempt cap also gave up before
slow LLM judges (>15s with reasoning effort) could land.
Replaced with a single global poller _maybeStartJudgePoll /
_judgePollTick:
- Walks the full childrenState (not just visible rows)
- Bypasses scheduleLiveFetch's visibility + TTL gates by
adding to pendingLiveIds directly + flushing
- One bulk request covers every pending row per tick
- Self-terminates when every verdict lands or 90s elapses
(operator can hit Refresh to retry on a failed judge)
- 90s cap is wall-clock, not attempt count, so an LLM that
takes 60s no longer prematurely gives up
Copilot round-2 feedback:
- _proxy_sse with use_service_auth=True silently fell back to
empty headers when proxy_token_mgr was None, producing a
retry-storm 401/403 loop. Fail fast with a 503 + clear log
so the misconfig surfaces immediately.
- Mobile <700px CSS comment claimed buttons "stretch to full
row width" but the rule keeps flex-direction: row with
flex: 1 on each, giving 50/50 side-by-side. Updated the
comment to match the deliberate side-by-side layout
(stacking would push the action row below preview/disclosure
on tall envelopes; 50/50 keeps both verbs reachable).
|
||
|
|
3b92c96b31 |
fix(coord): align coord client routes with post-#422 path-keyed mounts
#422's legacy URL adapter removal deleted the body-keyed /v1/api/route/{verb} endpoints (with ws_id in JSON body) but turnstone/console/coordinator_client.py still pointed at them. The coord LLM's close_workstream / close_all_children tools 404'd; send / approve / cancel were equally broken though exercised less often. _ROUTE_PATHS now uses {ws_id}-templated path-keyed forms: send → /v1/api/route/workstreams/{ws_id}/send approve → /v1/api/route/workstreams/{ws_id}/approve cancel → /v1/api/route/workstreams/{ws_id}/cancel close → /v1/api/route/workstreams/{ws_id}/close _post() interpolates {ws_id} at call time when the template has the slot; body-keyed paths (delete, close_all_children) still work via the same code path. Each affected caller (send, approve, cancel, close_workstream, close_all_children) was updated to pass ws_id as the kwarg and drop ws_id from the body. Added test_route_paths_match_actual_console_mounts: walks the real Starlette app's routes and asserts every _ROUTE_PATHS entry corresponds to an actually-mounted route. Catches the next URL unification drift before runtime. Updated the existing literal assertions + path-checking tests for the new shape. Pre-existing bug surfaced while testing inline-child-approvals. |
||
|
|
93875ebca5 |
fix(console): proxy events/global with service auth (not user JWT)
The interactive WebUI's app.js opens an EventSource against
/v1/api/events/global on load (cluster-wide tab indicators,
ws_state for the dashboard). When loaded via the console proxy
at /node/{node_id}/, the JS shim rewrites that to
/node/node-X/v1/api/events/global and the proxy forwards using
the user's re-minted JWT.
Upstream global_events_sse requires `service` scope by design
— the stream carries cross-tenant cluster inventory, intended
for the cluster collector, not browsers. End-user JWTs don't
carry service scope, so every proxied call returned 403, the
browser auto-retried with exponential backoff, and the console
log filled with proxy.sse.non_200 warnings.
_proxy_sse gains a use_service_auth flag. proxy_api flips it on
for events/global only, swapping the user JWT for the console's
proxy_token_mgr bearer token. Per-ws events stay on user auth
(tenant filtering on the upstream still requires user identity).
The upstream-side privacy posture is unchanged — the data on
events/global is the same cluster-wide inventory the console's
own /v1/api/cluster/events endpoint already serves to any
read-scoped caller under the trusted-team posture. The console's
AuthMiddleware on /node/{node_id}/v1/api/ remains the gate that
decides who can use the proxy at all.
|
||
|
|
b0f78ae4c0 |
fix(console): route per-workstream events to SSE proxy
The console's node-API passthrough at /node/{node_id}/v1/api/{path}
detected SSE only on the bare events / events/global paths. After
#422 removed the legacy /v1/api/events?ws_id= shape and moved
per-workstream SSE under /v1/api/workstreams/{ws_id}/events, the
proxy never got updated to match the new path — per-ws events
fell through to the regular GET branch, the upstream returned a
text/event-stream payload that the regular GET response couldn't
hold open, and Firefox surfaced the failure as "can't establish a
connection to the server".
Extend the SSE detection to also match
``workstreams/{ws_id}/events``. Pre-existing bug surfaced while
testing inline-child-approvals (operator clicks through from the
coord tree to the per-child interactive WebUI) but affects every
caller hitting a node's per-ws events stream via the console
proxy.
Two new tests in TestConsoleProxy: per-ws events route to
_proxy_sse with the correct upstream path; existing
events/global routing still works.
|
||
|
|
ebf562de93 |
fix(approve): suppress 409 storm from rapid approve/deny clicks
Previously the 409 stale-call_id branch in submitChildApproval re-enabled both buttons synchronously before kicking off the urgent live-bulk refresh. That opened a window where rapid clicks on an already-resolved approval (or a row whose call_id had rolled) each re-armed the click handler, fired another POST, and collected another 409. Operators rage-clicking saw a network 409 storm and a stack of warning toasts. Keep the buttons disabled in the 409 path. The row is about to be re-rendered wholesale via the urgent refresh — the disabled DOM gets dropped along with it. If the row's approval truly resolved, the new render has no buttons. If a new round started, the new render has fresh enabled buttons. Either way the operator-facing signal IS the row updating, not the toast. Drop the toast.warn (noisy on every rapid-click race) in favour of a single console.warn for diagnostics. If the urgent refresh fails entirely, the buttons stay disabled on that row — but the operator can hit the Refresh button on the children panel to force a full reload. Acceptable degraded state vs the previous 409 loop. |
||
|
|
4d08a19bd5 |
fix(approve): replay cached LLM verdicts on coord SSE reconnect
The coord's _coord_events_replay re-yielded _pending_approval on connect but not the cached _llm_verdicts entries. A tab refreshing mid-approval saw the approve_request prompt without the judge chip because intent_verdict is a one-shot SSE event with no late-subscriber push — the chip would only ever land if the operator re-invoked the tool call. Mirrored the interactive path at turnstone/server.py:875-878: after re-injecting the pending_approval prompt, walk ui._llm_verdicts under _ws_lock and yield each cached verdict as an intent_verdict event. Pre-existing bug surfaced during the inline-child-approvals work but the coord-self dock UX was always affected on reconnect — not introduced by this PR. Two new tests: cached verdicts replay after pending_approval; stale verdicts from a prior round don't replay when no approval is pending. |
||
|
|
68e1332c59 |
fix(approve): route child approvals through proxy + poll for late judge verdict
Two bugs reported from local repro on PR #424: 1. Approve/Deny buttons return HTTP 404 on every click. The new approveWorkstream helper hit /v1/api/workstreams/{ws_id}/approve regardless of target — that path is only mounted for coord workstreams (which live on the console process). Child workstreams live on cluster nodes and need to round-trip through the routing proxy at /v1/api/route/workstreams/{ws_id}/approve, which resolves the ws_id to its owning node and forwards the body verbatim. approveWorkstream now picks the path based on whether targetWsId matches the coord's own wsId. 2. LLM judge verdict never populates — rows freeze on the heuristic-tier pill ("⚙ heuristic") even after the judge would have completed. The judge runs async on the child node via a daemon thread and updates _llm_verdicts there, but no signal propagates back to the coord — cluster_state events don't fire on verdict-only changes, and the live-bulk TTL is 5s with no periodic poll. Added _maybePollForJudgeVerdict: when renderChildRow encounters a pending_approval_detail with judge_pending=true and items missing judge_verdict, schedule a recursive 2s urgent live-bulk re-fetch. Self-terminates when the verdict lands, the row closes, the approval clears, or attempts hit the cap (≈12s for a failed/timed-out judge so we don't poll forever). Single timer per ws_id; re-renders are no-ops while a timer is in flight. Smoke-test assertions added for both fixes so a regression on either path surfaces at test-time. |
||
|
|
a23ef7306c |
fix(approve): apply Copilot feedback + remove plan doc
Copilot review on PR #424 flagged three items: 1. Schema drift on /v1/api/dashboard — DashboardWorkstream didn't declare the new pending_approval_detail field, so generated OpenAPI / typed clients were out of sync. Added PendingApprovalItem + PendingApprovalDetail Pydantic models and referenced PendingApprovalDetail from DashboardWorkstream. 2. deepcopy under _ws_lock in serialize_pending_approval_detail could extend lock hold under contention with on_intent_verdict (daemon judge thread) and per-token activity writes that also take _ws_lock. _llm_verdicts entries are only assigned/cleared, never mutated in place, so a snapped reference is stable after the lock drops. Snapshot refs under lock; deepcopy after release. 3. Plan doc removed from the branch — design docs are local-only working artifacts, same posture as PROGRESS.md. |
||
|
|
7e33fc68bb |
fix(approve): apply /review feedback on inline child approvals
Critical:
- coordinator.js RISK_SEVERITY accepted 'crit' only; production
emits 'critical' (per turnstone/core/judge.py:1556 + heuristic
seeds). A risk_level=='critical' verdict ranked as 0 and
rendered with .risk.low (green) styling, never triggering
the crit-risk auto-expand. Now accepts both aliases. Unknown
risk_level falls back to rank 2 ('high') so future schema
drift fails *safe* (over-alert) instead of silently
downgrading. Pill ternary handles both 'crit' and 'critical'
alias to the existing .risk.crit class.
Major:
- Urgent live-badge flush now coalesces N urgent calls in the
same JS tick into one bulk request via queueMicrotask, instead
of firing N single-id fetches. The motivating 10-children-
pending-bash scenario in the design doc now lands on one bulk
/v1/api/cluster/ws/live request.
- Test coverage gap: added test_session_ui_base.py cases for
POLICY-BLOCKED (item.error + needs_approval=False) and
judge-unavailable (no verdict + no judge_pending) matrix rows.
Added literal-string assertions to the smoke list in
test_coordinator_page.py so a refactor dropping either branch
surfaces at test-time.
Minor batch (4 coord.js + 1 CSS + 1 fake-divergence):
- 409 stale-call_id path re-enables both buttons before return
(urgent fetch is best-effort; could also fail).
- judgePending pill no longer conflicts with a present heuristic
verdict — guard changed from !judge to !verdict.
- Empty <div class="approval-reasoning"> no longer appended when
reasoning is absent but evidence is present (evidence still
renders inside the disclosure).
- Dead .ch-row .approval-pill.rec-* CSS rules removed (JS never
combines those classes). Recommendation chip in the disclosure
footer now has its own scoped rules so the chip is actually
styled.
- _FakeUI.serialize_pending_approval_detail call_id selection
aligned to the real impl's "first non-empty" semantics.
- liveBadgeCache reconnect cleanup now preserves permanent
(403/404) entries — denied users no longer pay one wasted
bulk fetch per denied id per reconnect.
All 4465 non-live tests pass. Ruff + mypy clean. node --check OK.
|
||
|
|
a369d5f0d0 |
feat(approve): clear live cache on SSE reconnect — chunk 4 reconnect parity
Closes the stale-button window where a sub-5s SSE gap would leave liveBadgeCache holding pending_approval_detail for a child whose approval was actually resolved during the gap. Without this clear, zombie approve/deny buttons render until either the next child_ws_state event or the natural TTL expiry (whichever comes first). The clear sits beside the existing activeWaits.clear() in the reconnect handler — same posture (drop client-only state that the server's SSE replay doesn't cover) and same blast radius. The 409 race guard in submitChildApproval would catch a stale-call_id POST even without this, but rendering wrong UI until the operator clicks is the worse failure mode. loadChildren's finally block already fires scheduleLiveFetch for every visible row after the replace-mode refresh, so the cache repopulates with authoritative pending_approval_detail in one bulk request within the next debounce window. Plan: docs/design/inline-child-approvals.md (chunk 4 of 4 — last required chunk; 5/6 are stretch). |
||
|
|
54f04496c3 |
feat(approve): inline approve/deny buttons + judge verdict pill on coord tree
Chunk 3 of the inline-child-approvals plan + the SSE pipeline plumbing
needed for sub-second urgent fetches.
JS (coordinator.js):
- approveWorkstream(targetWsId, body) — generic POST helper, callable
for both the coord-self dock and the new per-child inline buttons.
- renderApprovalBlock(child, detail) — risk-level pill (.risk.* per
the design system primitives), tool-name summary with "+ N more"
for envelope-level approvals, intent_summary, ↳ judge reasoning
teaser, ▸ more disclosure carrying the recommendation chip,
evidence list, and items 2..N stacked sub-blocks. Plus matrix
coverage: judge_pending / judge unavailable / tool-policy
blocked / multi-item.
- submitChildApproval — handles the 409 stale call_id race by
invalidating the live cache + urgent-refetching, optimistically
clears pending_approval_detail on success.
- scheduleLiveFetch({ urgent: true }) — bypasses the 5s TTL +
cancels the debounce so attention transitions surface inline UI
immediately instead of after the next polling window.
- handleChildState fires urgent on activity_state="approval"
enter/leave; handleChildClosed eagerly invalidates the live
cache so closed rows can't render stale buttons.
CSS (index.html):
- New .approval-block / pill / preview / actions / disclosure
styles. Inline .act buttons duplicate the dock's colour treatment
(the dock-scoped rules don't reach the children-tree). Mobile
<700px touch targets ≥44px.
Pipeline (collector.py + coordinator_adapter.py):
- All three cluster_state event emitters and the child_ws_state
re-emit now carry activity_state. The previous omission left
the urgent-fetch trigger as dead code — discovered in review.
Tests:
- Static smoke test in test_coordinator_page.py asserting the new
helper names exist + the pending_approval_detail key is read.
Plan: docs/design/inline-child-approvals.md (chunk 3 of 4).
|
||
|
|
7d2d7db9d2 |
feat(approve): pass pending_approval_detail through cluster live-bulk
Threads the field added by Chunk 1 through the console's live-bulk endpoint so coord tree UI can read it without a separate per-child fetch. Three touchpoints: - _CLUSTER_WS_LIVE_KEYS gains the new key so _fetch_live_block's projection forwards it from the upstream /dashboard response on node-backed child rows. - _coordinator_live_snapshot synthesizes the same shape from ConsoleCoordinatorUI._pending_approval for in-process coord rows (no upstream /dashboard exists on the console pseudo-node). - One source of truth: SessionUIBase.serialize_pending_approval_detail. Both branches now emit the same 12-key live block; coord judge isn't wired today so coord-self judge_verdict is always None — flagged in the plan as a stretch follow-up. Plan: docs/design/inline-child-approvals.md (chunk 2 of 4). |
||
|
|
fbb9be27f9 |
feat(approve): expose pending_approval_detail on /dashboard + guard stale call_id
Lays the server-side groundwork for inline approve/deny buttons + judge verdict on the coordinator children-tree UI. Two surgical changes: 1. SessionUIBase.serialize_pending_approval_detail() merges the active _pending_approval items[] with per-call_id verdicts from _llm_verdicts. The dashboard handler embeds this on every per-ws row so cluster live-bulk callers can render inline UI without an extra per-child round-trip. 2. make_approve_handler now returns 409 when the body sends a call_id that doesn't match any currently-pending item. Closes the stale call_id race where an operator clicks approve on a row showing call A while the child has rolled over to call B. Empty/missing call_id preserves backwards compatibility with CLI + channel adapters that don't track it. Cross-tenant exposure on /dashboard is consistent with the trusted-team posture already in place for activity / tokens — documented in the new method's docstring so the choice survives the next reviewer. Plan: docs/design/inline-child-approvals.md (chunk 1 of 4). |
||
|
|
5ebee015d2 | chore(deps): lock file maintenance | ||
|
|
b8e51fa9ed |
fix(api): add DequeueRequest schema for DELETE /workstreams/{ws_id}/send
Copilot review on PR #422 flagged that the DELETE-on-send (dequeue)
EndpointSpec declared no request_model, so the generated OpenAPI
showed no requestBody for an operation that *requires* a JSON body
with ``msg_id`` and 400s when it's missing.
- Add ``DequeueRequest`` to ``server_schemas.py`` with the single
required ``msg_id: str`` field.
- Wire ``request_model=DequeueRequest`` and ``response_model=
StatusResponse`` on the DELETE EndpointSpec; trim the now-redundant
inline body example from the description.
- Re-import the schema in ``server_spec.py`` and add the entry to
``_ALL_MODELS`` so the OpenAPI components list carries it.
- Regenerate ``openapi-server.json``.
Sibling thread on the close EndpointSpec was already addressed in
|
||
|
|
5874159ffd |
fix(close): require non-empty body, restore CloseWorkstreamRequest
Copilot caught three real issues in PR #422 review, all clustered around the close request body contract: 1. The interactive close handler runs with ``supports_close_reason=True``, which calls ``read_json_or_400(request)`` — an empty / non-JSON body returns ``400 {"error": "Invalid JSON body"}``. The previous SDK fix sent NO body via ``json_body=None``, which would 400 against a real server. The mock-transport test silently masked it because the mock answered without inspecting the body. 2. The doc said the body was empty (or ``{}``), with no mention of the optional ``reason`` field, its 512-byte cap, or the credential-redaction guard. 3. The Pydantic schema for close was deleted outright; OpenAPI and SDKs lost their typed shape for the optional ``reason``. Changes: - ``turnstone/api/server_schemas.py``: reintroduce ``CloseWorkstreamRequest`` with a single optional ``reason: str | None = None`` field. Docstring documents the must-be-valid-JSON contract and notes that coord ignores the body (``supports_close_reason=False``). - ``turnstone/api/server_spec.py``: re-import the schema, point the close ``EndpointSpec`` at it via ``request_model=``, restore the ``_ALL_MODELS`` entry. OpenAPI JSON regenerated. - ``turnstone/sdk/server.py``: ``close_workstream`` (sync + async) gains an optional ``reason: str | None = None`` parameter and always sends ``json_body={}`` (or ``{"reason": ...}``) so the body is never empty. Adds a regression test (``test_close_workstream_sends_valid_json_body``) that inspects the raw transport content rather than relying on a path-keyed mock — the kind of check that would have caught this bug pre-merge. - ``sdk/typescript/src/server.ts``: ``closeWorkstream`` gains an optional ``opts.reason`` parameter; reintroduce ``CloseWorkstreamRequest`` interface in ``types.ts`` and re-export from ``index.ts``. - ``docs/api-reference.md``: close section documents the JSON-body requirement, the ``reason`` field, the 512-byte cap, the multibyte-safe behavior, the credential-redaction guard, and the non-string-coercion path. - ``CHANGELOG.md``: amend the 1.5.0 BREAKING block to reflect the schema reintroduction (slim form, ``reason`` optional) instead of the prior "removed outright" claim. 4558 tests passing under ``-m "not live"`` (was 4557 — +1 from the regression test). ruff + mypy clean. |
||
|
|
d6e615d324 |
fix: apply /review feedback on legacy URL cleanup
Reviewer caught real misses on the consumer-swap claim:
- TypeScript SDK still defined and re-exported `CloseWorkstreamRequest`
(types.ts + index.ts) — drop both. Now matches the Python-side
removal.
- Four `tests/test_auth.py` cases (`test_write_full_token_ok`,
`test_approve_full_token_ok`, `test_bearer_takes_precedence_over_cookie`,
`test_cookie_full_on_write_ok`) were tautological after the legacy
URL removal: they posted to `/api/send` / `/api/approve` and asserted
`allowed is True`, but those paths now classify as `read` so a read
token would also pass — they no longer tested the write/approve
scope enforcement. Swap to path-keyed URLs to restore the original
intent.
- `is_public_path("/api/send")` test renamed + retargeted to a
path-keyed URL.
Doc-table drift the previous commit missed:
- `docs/security.md` path-to-scope mapping rewritten for the
path-keyed verb family (write set, DELETE-on-/send dequeue,
per-ws_id approve).
- `docs/architecture.md` scope-model row text swap from `/api/send`
/ `/api/approve` to the path-keyed equivalents.
- `docs/diagrams/01-system-context.puml` channel→server edge label
swap.
- `docs/diagrams/15-auth-architecture.puml` scope class swap.
Cosmetic comment-only stragglers:
- `tests/test_session_worker.py` module docstring URL update.
- `tests/test_ratelimit.py` ~11 `/api/send` fixture-key strings
retargeted to `/api/workstreams/abc/send` so the URL fixtures
reflect the post-1.5 surface (rate limiter is path-agnostic; the
swap is purely cosmetic).
4557 tests still passing under -m "not live"; ruff + mypy clean.
|
||
|
|
ad0e7ce6eb |
docs: mark 1.5.0 legacy URL surface removal
CHANGELOG [Unreleased] / Removed (BREAKING — 1.5.0) block calling out the legacy URL family removal with the swap table. Doc passes on api-reference.md (per-endpoint sections rewritten with path parameters and slimmer body shapes), architecture.md (handler-list diagram and console-proxy URL example), console.md (URL-rewriting JS shim docstring + SSE proxy example), and the two PlantUML diagrams (11-console-data-flow, 16-channel-architecture). Also picks up two test-side stragglers from step 5 that referenced the legacy adapters in a docstring + a stale /v1/api/events SSE test: turn into path-keyed equivalents. OpenAPI JSON dump regenerated to reflect the catalog edits from step 3. After this commit: - 4557 tests passing under -m "not live" - ruff + mypy clean on turnstone/ tests/ sdk/ - grep for "/v1/api/send", "/v1/api/approve", "/v1/api/cancel", "/v1/api/workstreams/close" returns zero hits across turnstone/ sdk/ docs/ tests/ (excluding CHANGELOG.md, which intentionally documents the old shape). - grep for make_legacy_body_keyed_adapter, make_legacy_query_keyed_adapter, _make_method_dispatch, close_legacy returns zero hits. |
||
|
|
1358121d52 |
chore(tests): refresh fixtures for path-keyed URL family
Mechanical updates across the test suite to swap legacy
/v1/api/{send,approve,cancel,events,workstreams/close} URLs for the
path-keyed equivalents under /v1/api/workstreams/{ws_id}/<verb>, and
to drop ws_id from request bodies (the path provides it now).
Per file:
- test_session_routes.py: deletes test_close_legacy_mounts_when_handler_provided
(the close_legacy slot is gone); test_send_mounts_post_and_delete_when_dequeue_provided
(added in PR commit 1) stays.
- test_openapi.py: expected-paths set swaps to path-keyed shape;
test_send_endpoint_has_request_body now asserts the OpenAPI for
/v1/api/workstreams/{ws_id}/send.
- test_auth.py / test_auth_identity.py: required_scope and
check_request fixtures swap to path-keyed shape; new tests cover
write/approve/read scope assignment for the path-keyed verbs +
the /node/* proxy mirror.
- test_sdk_server.py / test_sdk_console.py: mock-transport URL keys
swap; bodies drop ws_id.
- test_server_attachments_endpoints.py: ~17 send sites migrated to
/v1/api/workstreams/<ws>/send (a small Python script ran the bulk
rewrite — body ws_id stripped, URL rebuilt).
- test_server_authz.py: cross-tenant approve/close/cancel/events
tests retargeted to path-keyed URLs;
test_events_legacy_query_keyed_url_still_resolves_to_404_for_unknown_ws
renamed to test_events_path_keyed_url_resolves_to_404_for_unknown_ws
with the docstring updated to note the legacy adapter is gone.
- test_close_reason_persistence.py: 7 close sites all swap.
- test_console_routing_proxy.py: route-proxy tests swap to
/v1/api/route/workstreams/{ws_id}/<verb>; the upstream-URL
assertion now reads from .request (route_proxy uses
client.request(method, url, ...) for method passthrough); _wire_proxy
helper installs both .post and .request mocks for compatibility.
- test_route_proxy_audit.py: parametrized URLs migrated;
_make_proxy now also exposes a .request side-effect that delegates
to .post for the same compatibility surface.
- test_api_versioning.py: openapi.json path assertion swaps to the
path-keyed shape.
4557 passing under -m "not live"; ruff + mypy clean.
|
||
|
|
3ea6fb30b4 |
refactor(consumers): swap UI/SDK/console-proxy/channels to path-keyed URLs
All in-tree consumers of the legacy /v1/api/send | /approve | /cancel |
events?ws_id= | /workstreams/close URLs now hit the path-keyed shape
under /v1/api/workstreams/{ws_id}/<verb>. Bodies drop ws_id (the path
provides it). The SSE event stream URL likewise moves to the path-keyed
form; channel adapters drop the params={"ws_id": ...} kwarg on
aconnect_sse.
Touched:
- turnstone/ui/static/app.js: 7 call sites (send×3, dequeue, approve,
cancel, close + EventSource SSE URL).
- turnstone/sdk/server.py (Python SDK): close_workstream, send,
approve, cancel, stream_events, send_and_wait's internal SSE
consumer.
- sdk/typescript/src/server.ts: closeWorkstream, send, approve,
cancel, streamEvents + sendAndWait's internal SSE consumer.
- turnstone/sdk/console.py: route_send, route_approve, route_close,
route_cancel — proxy URLs swap to /v1/api/route/workstreams/{ws_id}/<verb>.
route_plan_feedback / route_command remain body-keyed (out of scope).
- turnstone/console/server.py:
- Proxy mount table swaps the four legacy /api/route/{send,approve,
cancel,workstreams/close} mounts for path-keyed equivalents under
/api/route/workstreams/{ws_id}/<verb>; /send accepts both POST
and DELETE for dequeue.
- route_proxy reads ws_id from path_params (with body-fallback for
the surviving plan/command body-keyed mounts), uses
client.request(request.method, ...) so DELETE on /send proxies
through correctly, and audits DELETE-on-/send as a separate
"route.workstream.dequeue" action via _ROUTE_PROXY_AUDIT_ACTIONS.
- Internal `method` variable renamed to `verb` to avoid confusion
with HTTP method now that the two diverge.
- turnstone/channels/_sse.py: SSE URL builder swaps to path-keyed.
- turnstone/channels/{discord,slack}/bot.py: docstring URL updates.
- turnstone/server.py, turnstone/core/session_worker.py,
turnstone/sdk/events.py, turnstone/api/server_spec.py: comment /
docstring URL updates only.
Test fixtures still reference legacy URLs and will be swapped in step
5 of this PR.
|
||
|
|
41e83f98d6 |
refactor(auth,api): drop legacy paths from scope tables, slim verb schemas
- WRITE_PATHS / APPROVE_PATHS in turnstone/core/auth.py drop the four
legacy literal entries (/api/send, /api/cancel, /api/workstreams/close,
/api/approve). The path-keyed verb match for write expands from
{delete, open, refresh-title, title, attachments} to also include
{send, cancel, close}; a sibling branch maps POST /workstreams/{ws_id}/approve
to the approve scope, and a DELETE branch maps DELETE
/workstreams/{ws_id}/send (dequeue) to write. The /node/* proxy
block mirrors all four expansions so the console routing proxy
stays in lockstep.
- server_schemas.py drops the body-keyed ws_id field from SendRequest,
ApproveRequest, CancelRequest. CloseWorkstreamRequest deleted in
full (its only field was ws_id, now provided by the path).
- server_spec.py: drops CloseWorkstreamRequest from imports and
_ALL_MODELS, swaps the five legacy EndpointSpec entries to their
path-keyed equivalents (POST/DELETE workstreams/{ws_id}/send, POST
/approve, POST /cancel, POST /close, GET /events). Catalogue retains
/api/plan and /api/command unchanged (out of scope).
Tests still reference the legacy URLs and will fail at this commit;
test fixture updates land in step 5 of this PR. Step 4 swaps the
UI / SDK / console proxy / channels callers next.
|
||
|
|
da12c6b268 |
refactor(routes): drop legacy body-keyed and query-keyed URL adapters
Removes the pre-1.5 interactive URL family that mounted body- and
query-keyed shapes on top of the lifted path-keyed handlers via
make_legacy_body_keyed_adapter / make_legacy_query_keyed_adapter.
Path-keyed equivalents under /v1/api/workstreams/{ws_id}/<verb>
already serve every consumer; coord never used the legacy URLs.
Removed:
- make_legacy_body_keyed_adapter / make_legacy_query_keyed_adapter
from turnstone/core/session_routes.py.
- _make_method_dispatch from turnstone/server.py (zero callers
after legacy /api/send POST+DELETE block goes — its only purpose
was to bridge that single dual-method legacy URL).
- 5 legacy Route mounts in turnstone/server.py:
/api/events?ws_id, /api/send POST+DELETE, /api/approve, /api/cancel,
/api/workstreams/close.
- close_legacy field on SharedSessionVerbHandlers and its mount in
register_session_routes — the only surviving body-keyed slot in
the registrar, no longer needed.
Tightened make_dequeue_handler to read ws_id from the path only;
the body-fallback existed solely for the legacy DELETE /api/send
path and is now dead.
Test-suite updates and consumer call-site swaps (UI / SDK /
console proxy / channels) follow in subsequent commits in the same
PR — main stays broken across this commit until step 4 lands.
External SDK consumers on stable 1.0/1.3/1.4 calling these URLs
will receive 404s on upgrade to 1.5.0; CHANGELOG breaking-change
call-out lands with the docs commit.
|
||
|
|
2b435263e3 |
refactor(routes): wire DELETE on path-keyed workstreams/{ws_id}/send
Pre-flight for the legacy URL adapter removal: the path-keyed
`/v1/api/workstreams/{ws_id}/send` route only mounted POST today;
the dequeue handler was reachable only via the legacy
`DELETE /v1/api/send` body-keyed URL through `_make_method_dispatch`.
Add a new `dequeue: Handler | None = None` slot on
`SharedSessionVerbHandlers` next to `send`, mounted as a second
`Route` on the same path with `methods=["DELETE"]` (two distinct
Routes rather than collapsing methods on one Route — different
handler callables, and collapsing would force the same
method-dispatch wrapper this cleanup is tearing out).
Wire `dequeue=dequeue_handler` in `turnstone/server.py`'s
`SharedSessionVerbHandlers(...)` call so DELETE on the path-keyed
shape works in the same merge as the legacy mount removal.
Adds a regression-locking test covering both the POST+DELETE and
the dequeue-alone cases.
|