feat(video): switch 0.19.0 renderer Remotion → HyperFrames (HTML-native, Apache-2.0) (#314)

* feat(video): rewrite sidecar render core to HyperFrames (in place)

* feat(video): convert motion compositions from Remotion TSX to HyperFrames HTML

* refactor(video): rename render client to video_renderer_client (renderer-agnostic)

* chore(video): rename remotion-renderer prose in test_video_pipeline docstrings

* chore(video): rename sidecar to video-renderer + add system ffmpeg for HyperFrames

* chore(video): rename stray remotion-renderer refs in sidecar + py docstrings (controller cleanup)

* chore(video): fix stale Remotion API names in Dockerfile comment (controller cleanup)

* docs(video): rewrite video-engine prose for HyperFrames + add map entry + folded prose fixes

* docs(video): add trailing newline to docs/map/video-engine.md (controller cleanup)

* chore(video): drop internal spec refs + minio/test suppressions (folded hygiene)

* fix(video): reclaim outDir on createRenderJob throw + hide empty 4th highlight

Final whole-branch review (Opus) triaged two FIX items from the SDD nits
ledger; the rest ship as-is.

- render.js: a synchronous throw from createRenderJob (post-mkdtemp, not
  awaited) left an empty outDir on disk — the outer catch only reclaimed
  extractDir. Reclaim outDir too when it exists, and correct the stale
  comment that claimed the out dir was never created.
- {vertical,square}.html: the 4th highlights <li> lived in the DOM hidden
  only by JS, so a no-JS / failed-script render would show an empty bullet.
  Start it style="display:none" and reveal on populate, so an unscripted
  render shows nothing instead.

Vitest smoke (release-announcement.test.js) 4/4 green; render.js syntax
checked. Python suite untouched by this fix (JS/HTML only).

* fix(video): type _override_db yield as AsyncSession | None

T7 widened _build_app's db_session param to AsyncSession | None (to drop the
4x # type: ignore[arg-type] on the DB-independent _build_app(None, ...) calls)
but left the inner _override_db fixture typed AsyncIterator[AsyncSession] —
so 'yield db_session' yielded AsyncSession | None into a declared AsyncSession,
and mypy failed at test_video_routes.py:177 ('Incompatible types in yield').

The DB-independent media tests pass db_session=None deliberately: their route
uses a monkeypatched task service and never awaits the session, so yielding
None is safe at runtime. Type the override's yield as AsyncSession | None to
match — no cast, no # type: ignore, no assert, runtime behavior unchanged.
The 3 media tests (3 passed) and the 19 db-gated tests (skipped locally) hold.

* chore(gate): skip .superpowers scratch in markdown prose gate

reflow_md.py walks the filesystem via rglob('*.md') and skips tooling dirs
(.venv, .mypy_cache, .pytest_cache, ...) but not .superpowers/ — the
superpowers SDD workflow's scratch dir (briefs, reports, progress ledger,
all gitignored). A dev running SDD locally would hit a false markdown-prose
gate failure on those transient files. Add .superpowers to SKIP_DIRS,
consistent with the existing tooling-scratch exclusions.

* fix(video): validate composition_id to close path traversal (CodeQL)

compositionId flowed unvalidated from the POST body into path.join
under extractDir/motion/compositions/, so a '../..'-style value could
escape the composition dir (CodeQL: Uncontrolled data used in path
expression). Validate at the trust boundary in server.js
(/^[A-Za-z0-9_-]+$/) and add a path.resolve + startsWith containment
check in render.js so it stays safe regardless of caller.

* fix(mcp): send X-Agent-Token + X-Agent-Team from flow/do servers

flow_server._build_headers and do_server._build_headers constructed
only X-Agent-ID/Role/Correlation-ID, omitting X-Agent-Token and
X-Agent-Team (unlike ApiClient._get_agent_headers used by the other
MCP servers). Latent since the gateway refactor — surfaced when
ROBOCO_AGENT_AUTH_REQUIRED=true was armed on the NAS, 401-ing every
flow/do verb with 'Missing X-Agent-Token header'. Add both headers
(mirroring ApiClient) so the HMAC gate passes. Tests assert the
headers are now injected.

* [video-engine] Per-project video_engine_enabled opt-in toggle

Mirrors ci_watch_enabled (migration 048): the global
ROBOCO_VIDEO_ENGINE_ENABLED flag arms the subsystem; the new
projects.video_engine_enabled column (migration 063) opts a repo into
authoring against its motion/ dir. VideoEngine._opted_in_project no-ops
open_video_task at the single chokepoint covering all three trigger
paths (on-release, on-spotlight, CEO on-demand) until the operator
flips it in the panel edit-project dialog. Existing projects stay
opted out (server_default=false).

* fix(auth): send X-Agent-Token + X-Agent-Team from all agent->API call sites

The prior fix (6ed4e139) covered the flow/do MCP servers but missed four
other agent->orchestrator call sites that built the header dict by hand
and omitted X-Agent-Token and/or X-Agent-Team. With ROBOCO_AGENT_AUTH_REQUIRED
armed on the NAS, every one 401s:

- agent_sdk/server.py: the session-end post-mortem flush
  (/api/journals/me/entries), A2A persistence + offline fallback
  (/api/a2a/*), and the stopped-without-transition auto-substitute
  (/api/tasks/auto-substitute) — all sent only X-Agent-ID/Role, so each
  401'd 'Missing X-Agent-Token'. Add a shared _agent_headers() helper
  (mirroring flow_server._build_headers) and route all four through it.
- agent_sdk/secretary_driver.py: _headers() sent the token but not the
  team, so the HMAC gate 401'd with signature mismatch (secretary is
  board-team; token signed with team='board', verified with team='').
  Add the team header.
- mcp/git_readonly.py: the read-only git MCP sent only X-Agent-ID/Role
  — no token, no team — so /api/git/* 401'd once auth was armed. Convert
  the static _HEADERS to a _headers() helper with team + token.
- runtime/orchestrator.py: the cell-PM auto-submit self-API call acted
  as a PM with a hand-built {X-Agent-ID, X-Agent-Role} dict — no token,
  no team — 401ing under auth-required. Add _agent_api_headers(uuid,
  role) mirroring _system_api_headers, and use it.

Tests: _agent_headers round-trip (token + team, team-omitted when None),
_agent_api_headers carries a signed PM token + team.

* [auth] Omit UNSIGNED self-call token in dev mode + video-engine test mypy fix

_agent_api_headers sent the UNSIGNED sentinel when ROBOCO_AGENT_AUTH_SECRET
was unset, but the dev-mode middleware rejects a presented-but-unverifiable
token with 401 signature mismatch (while accepting a missing one). The
cell-PM auto-submit self-call 401'd in every dev run, regressing
test_auto_submit_cuts_the_pm_turn. Attach the token only when a secret is
set. Also fix the FromClause.update mypy error in the per-project
video-engine opt-out test (ORM row load + flush).

* [auth] Omit UNSIGNED agent token at every agent->API call site

The orchestrator injects ROBOCO_AGENT_TOKEN=UNSIGNED when the HMAC secret
is unset at spawn. The API middleware rejects a presented-but-unverifiable
token with 401 'signature mismatch' even in dev mode (auth not required),
so forwarding UNSIGNED turned every flow/do/SDK/secretary/git verb into a
401 — the live pr_reviewer/i_am_idle signature-mismatch loop. Omit the
header when the token is the UNSIGNED sentinel at all five agent-side
header builders; dev accepts a missing token, prod 401s with 'Missing
X-Agent-Token' (the clear respawn-with-secret signal). Add a structlog
diagnostic on the middleware reject path so the next mismatch logs the
exact (id, role, team, token_unsigned, auth_required) inputs.

* [auth] Self-heal stale agent tokens at orchestrator startup

A token is signed once at spawn. If ROBOCO_AGENT_AUTH_SECRET drifts
afterwards (a .env change, a compose recreate that reloads the
orchestrator's env without recreating agent containers, an image
redeploy), the surviving agent keeps sending its old token and the
middleware 401s every verb with 'signature mismatch'. The container
stays alive heartbeating, so the reaper never reclaims it and no fresh
agent spawns: the fleet stalls.

_heal_stale_agent_tokens runs at startup (before _readopt_running_agents)
and kills each running agent container whose baked-in token no longer
verifies against the current secret, so normal dispatch re-spawns it
with a freshly signed token. Inert when the secret is unset (dev):
verify fails for every token without a secret, so the heal would kill
the whole fleet without this gate. Best-effort: a probe failure leaves
the container alone (the reaper still covers it).

* [auth] Sign agent token over the UUID, not the slug (pr_reviewer 401 root cause)

The token was signed over the agent slug (_append_agent_auth_env) while the
MCP servers send X-Agent-ID as the agent UUID (_generate_mcp_config, since
453a7ae2 — gateway v1 parses X-Agent-ID as Annotated[UUID]). The middleware
verified HMAC(uuid:role:team) against a slug-signed token → 'signature
mismatch', token_unsigned=false. Latent for 2 months until 6ed4e139/53391f22
made the MCP servers forward the token.

The c0328971 startup heal missed it: docker exec printenv reads the
container-level ROBOCO_AGENT_ID (the slug), so the heal verified the
slug-signed token against the slug → matched → didn't kill the stale
container, which kept 401ing (its MCP server sends the UUID).

Fix: sign the token over the UUID, set the container ROBOCO_AGENT_ID to the
UUID too (so the SDK server — which inherits container env, not the MCP
manifest env — sends UUID consistently), and resolve the container-env id to
its UUID in _heal_stale_agent_tokens so pre-fix stale containers are evicted
on next restart. Regression test: test_heal_kills_slug_env_container_with_slug_signed_token.

* [scan] gate A2A/notification/stream agent-id deps under cloud auth (C1)

* [scan] omit UNSIGNED agent token from MCP server headers (H1)

* [scan] fail loud when cloud auth and nginx CEO-token are both armed (H2)

* [scan] cache last-known-good auth-probe result in panel proxy (C2)

* [respawn] Tripped breaker self-heals after a cooldown

A DB-durable PM-respawn counter (migration 051 / e2f7097a) wedges forever
once tripped: the only reset was a task status change, which can't happen
while the breaker blocks the spawn. So a deploy that fixes the underlying
loop (auth/prompt/schema) couldn't clear the wedge without manual DELETE
surgery on respawn_tracker — the 2026-07-06 pr-reviewer-1 loop, where the
auth fix cleared the 401 but count=63 survived restart and kept skipping
the dispatcher spawn for an external-PR task.

Freeze last_check at the trip tick and, after pm_respawn_trip_cooldown_seconds
(default 300), let ONE spawn through. A still-wedged task re-trips after the
threshold (bounded re-burn ~3 spawns per window); a fixed one advances and
the status-change path fully resets. Restore re-stamps last_check to now, so
a freshly restored row still trips immediately — durability preserved, which
is why the migration-051 persistence tests still pass.

* [scan] fix test_deps callsites for cloud-auth-gate signature change (C1 followup)

* [scan] per-IP rate limit on /auth/login under cloud auth (L31)

* [scan] Phase 1 auth/security fixes under 0.19.0 CHANGELOG

* [scan] secretary token signs over real team (board) not empty — fixes /api/secretary/* 401 (L31-class)

* [scan] LoginRateLimiter: key off X-Forwarded-For first hop + redis-down fail-open test

nginx is the single entry point; request.client.host is the nginx peer IP,
collapsing every external client into one limiter bucket (self-DoS amp).
Read the downstream client IP from X-Forwarded-For (first hop) / X-Real-IP,
falling back to the peer. Adds coverage for the XFF keying, the redis-down
fail-open branch, and drops a redundant asyncio marker on a sync-TestClient
test.

* [scan] nits: describe login_max_attempts + replace cast with assert in get_current_agent_slug

login_max_attempts was the only bare cloud-auth field; add a Field
description matching the surrounding idiom. Replace cast('str', ctx.slug)
with a runtime assert that fails loud if the cloud-auth ctx invariant
breaks, and drop the now-unused cast import.

* [scan] secretary token: use get_agent_team resolver + complete spawn-shutdown mock team (0dfd45ca followup)

* [scan] require agent HMAC token under cloud_auth (close v1 flow/do header-trust)

* [scan] _require_ceo accepts CEO session cookie under cloud_auth

* [scan] HTTP require_panel_token accepts session cookie under cloud_auth

* [scan] gate /api/settings behind panel token

* [scan] gate unauthenticated /api read routes (agents/a2a-tasks/kanban/usage/rate-limits)

* [scan] hoist deferred test imports to top-level (clear PLC0415)

* [scan] Phase 1b e2e smoke + CHANGELOG

* [scan] add_dependency rejects self-reference + cycle (M18)

* [scan] WorkSessionService.create translates IntegrityError to ConflictError (H10)

* [scan] _qa_or_doc_claim locks the task row FOR UPDATE (M19)

* [scan] docs_complete + mark_pr_created lock the task row FOR UPDATE (H4)

* [scan] gate complete() IN_PROGRESS on leaf/branchless only (H3)

* [scan] _unclaim_from_blocked clears stale pre-block snapshot (H5)

* [scan] admin_set_status terminal guard + skip revision bump under force (M20)

* [scan] cell_pm_complete idempotent pre-check before merge (H7)

* [scan] wrap gateway post-runner side effects in try/except (H6)

* [scan] pass_qa/fail_qa accept AWAITING_QA only (L29)

* [scan] mark_pr_created passes audit_agent_id (L30)

* [scan] phase 2 e2e smoke - one scenario per finding

* [scan] phase 2 quality gate

ruff format + check: green
mypy roboco/: green (357 files)
pytest unit+integration: 6905 passed, 10 pre-existing DB-contamination
  failures (pass in isolation)
e2e smoke: 11 passed, 4 cross-scenario workspace-contamination failures
  (all 6 state-machine scenarios pass individually)

Quality-gate fixes:
- move function-local imports to module top (PLC0415)
- fix M19 regression: submit_for_qa clears active_claimant_id so the
  competing-claimant guard lets the QA claim through
- fix H7 regression: _StubGit gains is_pr_merged_for_task
- fix M19 unit tests: mock session.execute for the FOR UPDATE lock
- e2e H3: notes >= 20 chars; e2e H5: rich i_will_work_on inputs +
  PM unclaims (block reassigns to PM)

* [scan] move active_claimant_id clear into pass_qa/fail_qa + admin_set_status (M19 follow-on)

Phase 2 opus whole-branch review found the M19 follow-on clear lived in
the gateway wrappers (qa_pass/qa_fail) not the transition methods
(pass_qa/fail_qa) themselves. The direct REST routes POST /pass-qa and
POST /fail-qa call the transitions directly, bypassing the wrappers and
leaving the QA's stale active_claimant_id set in AWAITING_DOCUMENTATION
/ NEEDS_REVISION — the competing-claimant guard then rejects the next
legitimate documenter/QA claim. admin_set_status had the same gap for a
non-blocked override into a review/queue state (IN_PROGRESS->AWAITING_QA
left the dev's id, blocking qa_claim).

Root-cause fix: move the clear INTO pass_qa and fail_qa (mirroring
submit_for_qa), add a clear in admin_set_status when
new_status in _REVIEW_QUEUE_STATES and from_status != BLOCKED, and drop
the now-redundant clears + flushes from the qa_pass/qa_fail wrappers.
Every caller is covered; the wrappers keep their actor-mismatch warnings.

Covering tests: test_pass_qa_clears_active_claimant_for_doc_claim
(asserts a subsequent doc_claim succeeds), test_fail_qa_clears_active_claimant,
test_admin_set_status_into_review_queue_clears_active_claimant,
test_admin_set_status_non_review_queue_keeps_active_claimant. Updated
the two wrapper unit tests that asserted the wrapper clears (now the
transition's job).

* [C3] unindex_journal_entry + call from delete_entry

JournalService.delete_entry deleted the DB row but never de-indexed the
RAG chunks, so deleted/private journal content bled forever into RAG
answers and claim-time briefings. Add OptimalService.unindex_journal_entry
mirroring unindex_playbook (vector-store delete_by_source + tracking-row
delete via get_db_context, both idempotent + best-effort), and call it
from delete_entry after the row commit inside a try/except so a de-index
failure never errors the delete.

* [M25] learning_id hashes full content to avoid collision

The memory distiller emits lessons with a fixed 'Problem: …' opening
shape, so two distinct lessons whose first 100 chars match collided on
learning_id = f"lrn-{md5(content[:100])[:12]}". replace_on_reingest then
routed both to the same source URI and the second ingest's replace_chunks
DELETE wiped the first lesson's chunks — silent data loss.

Hash the full content (widening the hex slice 12→16) so distinct bodies
get distinct ids and each retains its chunks.

* [H13] reject non-internal local_llm_base_url at config load

* [M28] bulk-insert learning broadcast instead of N+1

* [M27] mark_read/mark_all_read stamp only the unread rows seen at call time

mark_read and mark_all_read used to zero the unread counter FIRST, then run
a bulk UPDATE … WHERE read_at IS NULL that stamped every inbound unread row.
A send_chat_message committing between the counter-zero and the UPDATE
inserted a new read_at NULL row that the UPDATE then stamped as read — the
new message was silently consumed while the counter stayed 0.

Mirrors get_unread_messages (same file): SELECT the unread message IDs at
call time, UPDATE exactly those IDs, then recompute the unread counter from
the DB via the existing _reset_unread_counter helper. A message arriving
mid-call is not in the selected ID set, so the UPDATE skips it and the
recomputed counter keeps it unread.

* [H12] dedup: exact to_agents predicate + purpose discriminator + ack DEL

* [M23] playbook indexed_ok/indexed_at + startup reconcile of unindexed approved

* [M24] RAG indexing dead-letter + janitor reclaim + failed_index_count health

* [L23] institutional_memory_status sentinel distinguishes below-floor/empty/error/disabled

* [L26] sweep_expired_notifications re-escalates stale unacked ack-required

* [phase3] e2e smoke + CHANGELOG for 0.19.0

* [M24] _reindex_journal_entry honors is_private (C1 review fix)

Dead-letter replay mirrors the original journal._schedule_rag_index path:
a private entry is never indexed into the shared JOURNALS corpus, and a
private learning is still recorded into LEARNINGS as non-shareable.
Previously the replay always called index_journal_entry and skipped
record_learning for private learnings, leaking private content on replay
and dropping the legitimate non-shared learning. Three regression tests.

* [H11] clone via git -c http.extraheader, not URL-embedded PAT

* [H11] _sync_read_clone fetch via http.extraheader, not URL-embedded PAT

Sibling site to the clone fix: the conventions read-clone refresh ran
'git fetch --tags <https://TOKEN@host> <branch>', exposing the PAT in the
fetch argv on the orchestrator host. Mirrors the clone site's per-call
'-c http.extraheader=Authorization: Basic …' prefix + bare URL. SSH URLs
and tokenless public repos unchanged.

* [H11] release_executor clone+push via http.extraheader; delete _inject_token_into_url

* [H8] rebase_onto_base gates on clean tree like pull

* [H9] _link_commit_to_task flushes, doesn't commit out-of-band

* [M38] _pr_is_merged returns None on HTTPError; caller assumes merged

* [M39] _cherry_unmerged_entry marker grep anchored to commit-prefix

* [L1] thread actor_agent_id through update_pr_for_task

* [H8] fix rebase test mocks for clean-tree gate

H8 inserted a 'git status --porcelain' dirty-tree gate at the top of
rebase_onto_base (mirroring pull). The 3 rebase control-flow tests mocked
_run_git with a side_effect list matching the OLD call sequence (no
leading status call), so every call shifted by one and the assertions
missed. Prepend a clean-status result to each list so the gate passes
and the fetch/checkout/reset/rebase/diff/abort/push sequence aligns.
Verified: 16 passed (was 3 failed/13 passed post-H8, 16 passed pre-H8).

* [L2] push --force-with-lease instead of bare --force

* [L1] refresh stale workspace-resolution docstrings

pr_target and _workspace_for_branch still documented the actor →
assigned_to → created_by fallback chain that L1 removed from
_resolve_workspace_agent_id. Update both to the post-L1 actor →
assigned_to → None resolver (project.workspace_path as the final
fallback) so a future reader doesn't rely on a fallback that no
longer exists.

* [M37] merge_pr locks the work_session row FOR UPDATE

* [phase4] e2e smoke + CHANGELOG for 0.19.0

* [phase4] fix M37 test flake + document H8 skip

The opus whole-branch review flagged the M37 concurrency tests as
~50% flaky: both asserted caller A wins the FOR UPDATE race, but
which caller wins the lock is non-deterministic. When B won, the
'assert a_row.merged_by == a_merger' branch flipped false even
though the production code (M37) was correct — exactly one merger
recorded, audit trail intact. Assert the invariant instead: both
rows COMPLETED, both report the same merged_by, value in
{a_merger, b_merger}. Applied to both the unit test and the e2e
twin. Also documents the H8 e2e skip in the module docstring (the
report claimed it was documented there but it wasn't) and drops
the internal 'Phase 4' label from the docstring header in favor of
the public '0.19.0' version anchor.

* [H24] wait_for_ci polls through the window on non-success

* [H25,L34] release mutex orphan-sweep on start + shared redis client

* [M1] tiktok _refresh commits rotated tokens in an independent session

* [H25] drop new type:ignore in orphan-sweep test (constraint cleanup)

* [M2] feature-spotlight re-arms when exploration stale past 2x interval with no live HoM spawn

* [M6,M7] mark_seen after meaningful+project; persist since_id cursor in redis

* [M3,M5] reject() guards COMPLETED; edited_body deferred into the single-flight lock

* [M4] bound list_completed_video_tasks + ix_tasks_source_status_created index (migration 066)

* [M8,M9,L9] pass head_sha to CI gate; _run_git 30s timeout; _commits_since split maxsplit 2

* [M10,L35] dedupe dep_update by (git_url, command); fold redundant per-project queries

* [L36] gather ci_watch telemetry sweep instead of sequential iteration

* [L11] document self_heal fingerprint is stable per-signal by design

* [M11] engine-loop liveness watchdog: heartbeat + 2x-interval staleness alert

* [M21] video render loop commits per-task, not one trailing commit

* [M22] _detect_stuck_tasks skips held-CEO-source tasks

* [L6] video_renderer_client._save writes temp + atomic rename

* [phase5] e2e smoke + CHANGELOG for 0.19.0

* [M11] instrument x_mentions + roadmap engine loops with liveness heartbeats

* [phase5] fix-wave: correct e2e M11 unit-test filename + strengthen failed-cycle heartbeat assertion

* [C4] panel WS: shared /ws/system socket + long-tail retry + pong watchdog

* [H15] video-post-queue caption derived per render (mirror x-post-queue)

* [C4-fix] panel WS: discriminating long-tail tests + drop dead freeze block + evict dead shared conn on manual disconnect

Finding 1 (Critical, websocket.test.ts): the two long-tail-retry tests fired onopen between close cycles, which reset reconnectAttempts to 0 each cycle, so they passed under the pre-fix 3-attempt gate. Rewrote both to NEVER fire onopen between closes, so attempts accumulates: test 1 asserts state stays 'reconnecting' past attempt 3 (old gate would flip 'disconnected' terminal); test 2 asserts a new socket is constructed within 30000ms at attempt 7 where uncapped 5000*1.5^7 ~= 85s (old uncapped code would leave the timer unexpired). Verified both FAIL on a reverted old-shape connection.ts and PASS on the fixed code.

Finding 2 (Important, connection.ts): the 'if (raw >= cap) this.reconnectAttempts = exp' block was a no-op (exp was just read from the same field) and the unconditional increment afterwards grew the counter regardless. Deleted the dead block; kept the Math.min cap on the delay. Replaced the misleading ponytail comment with an accurate one: delay is capped, counter grows unbounded but delay is bounded.

Finding 3 (Important, use-websocket.ts): manual disconnect() tore down the shared conn for all subscribers but left the dead (manualClose=true, never reconnects) entry in _sharedSockets, so a later mount hit the reuse branch, attached a subscriber, replayed 'disconnected', and never called connect(). Added a urlRef and _sharedSockets.delete(url) in the manual disconnect callback so a later mount reopens a fresh conn.

* [H16] settings Save wired to settingsApi (persist + read back)

* [H17] tasks page passes status/team/limit to useTasks (server-side filter)

* [H18] useAgents roster re-derives on live-status change (statusEpoch in queryKey)

* [M40] useMetrics reads agent counts from useAgentStatus cache (dedupe poll)

* [H18] tighten useAgents statusEpoch comment (drop spec ref)

* [M40] drop spec ref + tighten useMetrics comment

* [M41] scorecard refetchInterval 60s -> 5min (25 req/min -> 5)

* [M42] feature-flag off-transition confirm + pending-keys Set

* [M43] X/TikTok credentials clear-behind confirm dialog

* [M44] rate-limit syncFromApi merges (keep fresher hitAt) + A2A reconnect invalidation

* [phase6] proxy.ts cookie-check comment + CHANGELOG Fixed entries

* [phase6] drop stale WS pin-attempts comment + fix tasks-page lead-in

* [H21] type DelegateRequest.estimated_complexity as Complexity (reject critical)

* [H22] type SoftBlockRequest.resolver_type as BlockerResolverType (no silent AGENT fallback)

* [H23] serialize TaskTable.documents into TaskResponse (DocRefResponse)

* [L27] delete SubstituteRequest phantom suggested_role/suggested_team fields

* [L14] Envelope.not_found defaults remediate (guide re-fetch + re-issue)

* [L28] delete unused ListResponse generic (dead code; pagination deferred)

* [H19] _delegate_static_guards allow cell_projects roots (cross-cell MegaTask)

* [M13] MegaTask confirm-batch idempotency key from session_id (SETNX guard + result sidecar)

* [M14] strip assigned_to from MegaTask drafts (no board-owned root-subtask deadlock)

* [H20] thin_routes receiver-gate add/add_all/merge (no false block on set/cache.add)

* [M16] tighten noqa code-capture to [A-Z0-9, ]+ (no false block on natural prose)

* [M45] conventions read-clone force-refetch on read (no 30s stale map window)

* [L25] conventions._resolve returns (root, sha); ORM mutated on the event loop

* [M15] open_conventions_pr force-pushes disposable scaffold branch (no silent None)

* [L24] roadmap cycle completion emits status-transition audit

* [Phase7] CHANGELOG: 15 schema/conventions/MegaTask/API fixed (H21-H23,L27,L14,L28,H19,M13,M14,H20,M16,M45,L25,M15,L24)

* [Phase7] lint gate hygiene: shorten docstring (E501), sort imports (I001), hoist AuditLogTable import (PLC0415)

* [H14] Enable the GROK provider row in _apply_grok so routing reaches the GrokCliProvider

* [M31] Route GROK active-token resolution to usage.json so live usage reflects grok agents

* [M32] Pass cache read/write tokens to calculate_cost in the usage sweep so live cost reflects Anthropic cache spend

* [M33] Park Ollama-Cloud rate limits via a marker map so a glm-5.2:cloud 429 parks instead of crash-respawning

* [M34] Sweep orphan agent_spawn_sessions at startup so crashed-run tokens roll into usage/cost summaries

* [L12] Persist revisit_resets (migration 067) so the PM-respawn breaker's revisit counter survives a restart

* [L18] Date-gate the Sonnet-5 promo revert so billing returns to list rates after 2026-08-31

* [L20] Warn when ROBOCO_GROK_RUN_LOG yields no session id instead of silently falling back to a zero-usage env id

* [phase8] CHANGELOG: LLM provider routing, usage capture, billing fixes

* [phase8] Trailing ruff format hygiene (orchestrator marker tuples, token-sweep test signatures)

* [phase8] Fix mypy: rename GROK-branch tokens var so transcript fallback stays reachable

* [M35] Add an expiring agent-token format (iat/exp) with backward-compatible verify

* [M35] Wire agent-token TTL at spawn (config + orchestrator + grok) so tokens are bounded

* [M36] Add JWT jti claim and re-mint the sliding cookie only near expiry so a stolen cookie's exp is fixed

* [M36] Redis jti revocation: read_token rejects revoked jtis and logout revokes the current jti

* [phase9] CHANGELOG: bound agent tokens + sliding-cookie re-mint window + jti revocation

* [scan-fix] mypy: type-annotate test files for make-quality gate

CI's make quality runs mypy roboco/ tests/; the scan-fix program's local
gate ran mypy roboco/ only, so test files were never type-checked. Fix all
67 errors across 23 test files with real annotations/casts/asserts/dead-code
removal — no # type: ignore / # noqa added.

* [e2e] Per-test DB isolation + dispatcher re-claim before PM complete

* [scan] Regenerate verb tables for delegate Complexity type

* [scan] Reduce 9 xenon C-ranks to B (auth, orchestrator, gateway, services)

* [scan] Restore short-circuit time.time() in verify_agent_token (security path)

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-07 10:09:23 +02:00
committed by GitHub
co-authored by Renn F
parent cebbd73e07
commit 3849c1737e
266 changed files with 19994 additions and 6156 deletions
+60 -1
View File
@@ -1,4 +1,11 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
} from "vitest";
import { NextRequest } from "next/server";
describe("proxy", () => {
@@ -71,3 +78,55 @@ describe("proxy", () => {
expect(res.status).toBe(200);
});
});
describe("isCloudAuthEnabled last-known-good", () => {
type MockResponse = { ok: boolean; json?: () => Promise<unknown> };
beforeEach(() => {
vi.resetModules();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("caches a successful probe and reuses it when the next probe fails", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ cloud_auth_enabled: true }),
} as MockResponse)
.mockResolvedValueOnce({ ok: false } as MockResponse);
global.fetch = fetchMock as unknown as typeof fetch;
const { isCloudAuthEnabled } = await import("../proxy");
expect(await isCloudAuthEnabled()).toBe(true);
// next probe fails — should fall back to cached true, not false
expect(await isCloudAuthEnabled()).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("fails open to false only when no fresh cache exists", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
} as MockResponse) as unknown as typeof fetch;
const { isCloudAuthEnabled } = await import("../proxy");
expect(await isCloudAuthEnabled()).toBe(false);
});
it("treats a cached value older than the TTL as stale", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ cloud_auth_enabled: true }),
} as MockResponse)
.mockResolvedValueOnce({ ok: false } as MockResponse);
global.fetch = fetchMock as unknown as typeof fetch;
const { isCloudAuthEnabled } = await import("../proxy");
expect(await isCloudAuthEnabled()).toBe(true);
vi.advanceTimersByTime(31_000);
expect(await isCloudAuthEnabled()).toBe(false); // cache expired
});
});
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, act } from "@testing-library/react";
import type {
AdminConversationSummary,
AdminPairSummary,
@@ -82,7 +82,9 @@ function buildConversation(
};
}
function buildPair(overrides: Partial<AdminPairSummary> = {}): AdminPairSummary {
function buildPair(
overrides: Partial<AdminPairSummary> = {},
): AdminPairSummary {
return {
agent_a: "be-dev-1",
role_a: "developer",
@@ -256,4 +258,48 @@ describe("A2APage", () => {
fireEvent.click(screen.getByTitle("Switchboard: org-chart pair cards"));
expect(screen.getByText("Switchboard")).toBeInTheDocument();
});
// M44: on /ws/system reconnect (isConnected false → true) the A2A list is
// stale (events missed during the disconnect); invalidate the a2a query
// family so react-query refetches.
it("invalidates a2a queries on a false → true reconnect transition", () => {
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: false,
});
const { rerender } = render(<A2APage />);
// No invalidation while offline.
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: a2aLiveKeys.all,
});
invalidateQueries.mockReset();
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: true,
});
act(() => {
rerender(<A2APage />);
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.all,
});
});
it("does not invalidate a2a queries on initial mount when already connected", () => {
// prevConnected starts unknown; a mount with isConnected=true must NOT
// fire a reconnect invalidation (only a real false → true transition does).
invalidateQueries.mockReset();
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: true,
});
render(<A2APage />);
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: a2aLiveKeys.all,
});
});
});
+20 -1
View File
@@ -1,6 +1,13 @@
"use client";
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import {
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import {
@@ -106,6 +113,18 @@ function A2APageContent() {
}
}, [lastMessage, queryClient, selectedId]);
// On /ws/system reconnect (false → true) the A2A list is stale — events
// missed during the disconnect. Invalidate the a2a query family so
// react-query refetches. Initial mount with isConnected=true does NOT
// fire (prevConnected starts unknown, not false).
const prevConnected = useRef<boolean | null>(null);
useEffect(() => {
if (prevConnected.current === false && isConnected) {
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.all });
}
prevConnected.current = isConnected;
}, [isConnected, queryClient]);
const pairs = useMemo(() => pairsData?.items ?? [], [pairsData]);
// Activity = A2A only: derived purely from a2a.message frames, never from
// verb/flow traffic on the same /ws/system stream.
@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
const { getAll, update } = vi.hoisted(() => ({
getAll: vi.fn(async () => ({
notifications_enabled: "false",
sound_enabled: "false",
auto_refresh: "false",
refresh_interval: "45",
})),
update: vi.fn(async () => ({})),
}));
vi.mock("@/lib/api", () => ({ settingsApi: { getAll, update } }));
vi.mock("next-themes", () => ({
useTheme: () => ({ theme: "dark", setTheme: vi.fn() }),
}));
vi.mock("@/store", () => ({
useUIStore: () => ({ sidebarCollapsed: false, setSidebarCollapsed: vi.fn() }),
}));
vi.mock("@/components/settings/transcript-retention-card", () => ({
TranscriptRetentionCard: () => null,
}));
vi.mock("@/components/settings/feature-flags-card", () => ({
FeatureFlagsCard: () => null,
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
import SettingsPage from "../page";
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
// The Label and Switch/Select are siblings inside a flex row, so the label
// text doesn't associate with the control. Walk to the row to find it.
function controlFor(labelText: RegExp | string, role: string): HTMLElement {
const label = screen.getByText(labelText);
const row = label.closest("div")?.parentElement;
if (!row) throw new Error(`row not found for ${String(labelText)}`);
const el = row.querySelector(`[role="${role}"]`);
if (!el) throw new Error(`${role} not found for ${String(labelText)}`);
return el as HTMLElement;
}
describe("SettingsPage — Save persists prefs via settingsApi (H16)", () => {
beforeEach(() => {
getAll.mockReset();
update.mockReset();
getAll.mockResolvedValue({
notifications_enabled: "false",
sound_enabled: "false",
auto_refresh: "false",
refresh_interval: "45",
});
update.mockResolvedValue({});
});
it("initializes the prefs from the server, not the hardcoded defaults", async () => {
render(withQueryClient(<SettingsPage />));
await waitFor(() =>
expect(controlFor("Enable Notifications", "switch")).not.toBeChecked(),
);
expect(controlFor("Sound Alerts", "switch")).not.toBeChecked();
expect(controlFor("Auto Refresh", "switch")).not.toBeChecked();
// refresh_interval "45" overrides the hardcoded "30s" default.
expect(controlFor("Refresh Interval", "combobox")).not.toHaveTextContent(
"30s",
);
});
it("persists all four prefs when Save Settings is clicked", async () => {
render(withQueryClient(<SettingsPage />));
await waitFor(() =>
expect(controlFor("Enable Notifications", "switch")).not.toBeChecked(),
);
fireEvent.click(screen.getByRole("button", { name: /save settings/i }));
await waitFor(() => expect(update).toHaveBeenCalledTimes(4));
expect(update).toHaveBeenCalledWith("notifications_enabled", "false");
expect(update).toHaveBeenCalledWith("sound_enabled", "false");
expect(update).toHaveBeenCalledWith("auto_refresh", "false");
expect(update).toHaveBeenCalledWith("refresh_interval", "45");
});
});
+85 -15
View File
@@ -2,7 +2,9 @@
import { useState } from "react";
import { useTheme } from "next-themes";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useUIStore } from "@/store";
import { settingsApi } from "@/lib/api";
import {
Card,
CardContent,
@@ -28,19 +30,79 @@ import { API_URL, WS_URL } from "@/lib/constants";
import { TranscriptRetentionCard } from "@/components/settings/transcript-retention-card";
import { FeatureFlagsCard } from "@/components/settings/feature-flags-card";
// Settings keys persisted server-side (string values: "true"/"false" or a number).
const KEYS = {
notifications: "notifications_enabled",
sound: "sound_enabled",
autoRefresh: "auto_refresh",
refreshInterval: "refresh_interval",
} as const;
export default function SettingsPage() {
const { theme, setTheme } = useTheme();
const { sidebarCollapsed, setSidebarCollapsed } = useUIStore();
const queryClient = useQueryClient();
// Local state for settings (would be persisted in a real app)
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
const [soundEnabled, setSoundEnabled] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
const [refreshInterval, setRefreshInterval] = useState("30");
const { data: settings } = useQuery({
queryKey: ["settings"],
queryFn: settingsApi.getAll,
});
const handleSave = () => {
toast.success("Settings saved successfully");
};
// `edits` holds the user's in-progress changes; an unset field means "show
// the server value" (or the hardcoded default before the query loads).
// Deriving the displayed value avoids syncing query state into local state
// via an effect (react-hooks/set-state-in-effect).
const [edits, setEdits] = useState<{
notifications?: boolean;
sound?: boolean;
autoRefresh?: boolean;
refreshInterval?: string;
}>({});
const notificationsEnabled =
edits.notifications ??
(settings?.[KEYS.notifications] === undefined
? true
: settings[KEYS.notifications] === "true");
const soundEnabled =
edits.sound ??
(settings?.[KEYS.sound] === undefined
? true
: settings[KEYS.sound] === "true");
const autoRefresh =
edits.autoRefresh ??
(settings?.[KEYS.autoRefresh] === undefined
? true
: settings[KEYS.autoRefresh] === "true");
const refreshInterval =
edits.refreshInterval ??
(settings?.[KEYS.refreshInterval] === undefined
? "30"
: settings[KEYS.refreshInterval]);
const saveMutation = useMutation({
mutationFn: async () => {
await settingsApi.update(
KEYS.notifications,
String(notificationsEnabled),
);
await settingsApi.update(KEYS.sound, String(soundEnabled));
await settingsApi.update(KEYS.autoRefresh, String(autoRefresh));
await settingsApi.update(KEYS.refreshInterval, refreshInterval);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
setEdits({}); // re-sync to the freshly-saved server values
toast.success("Settings saved successfully");
},
onError: (error) => {
toast.error(
`Failed to save: ${error instanceof Error ? error.message : "Unknown error"}`,
);
},
});
const handleSave = () => saveMutation.mutate();
return (
<div className="space-y-6">
@@ -148,7 +210,12 @@ export default function SettingsPage() {
Automatically refresh data periodically
</p>
</div>
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
<Switch
checked={autoRefresh}
onCheckedChange={(v) =>
setEdits((e) => ({ ...e, autoRefresh: v }))
}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
@@ -160,7 +227,9 @@ export default function SettingsPage() {
</div>
<Select
value={refreshInterval}
onValueChange={setRefreshInterval}
onValueChange={(v) =>
setEdits((e) => ({ ...e, refreshInterval: v }))
}
disabled={!autoRefresh}
>
<SelectTrigger className="w-auto min-w-20">
@@ -199,7 +268,9 @@ export default function SettingsPage() {
</div>
<Switch
checked={notificationsEnabled}
onCheckedChange={setNotificationsEnabled}
onCheckedChange={(v) =>
setEdits((e) => ({ ...e, notifications: v }))
}
/>
</div>
<Separator />
@@ -212,7 +283,7 @@ export default function SettingsPage() {
</div>
<Switch
checked={soundEnabled}
onCheckedChange={setSoundEnabled}
onCheckedChange={(v) => setEdits((e) => ({ ...e, sound: v }))}
disabled={!notificationsEnabled}
/>
</div>
@@ -245,7 +316,6 @@ export default function SettingsPage() {
</p>
</CardContent>
</Card>
</div>
{/* Feature Flags — master switches for optional subsystems (full width;
@@ -255,9 +325,9 @@ export default function SettingsPage() {
{/* Save Button */}
<div className="flex justify-end">
<Button onClick={handleSave}>
<Button onClick={handleSave} disabled={saveMutation.isPending}>
<Save className="h-4 w-4 mr-2" />
Save Settings
{saveMutation.isPending ? "Saving..." : "Save Settings"}
</Button>
</div>
</div>
@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
const { list } = vi.hoisted(() => ({
list: vi.fn(async () => []),
}));
vi.mock("@/lib/api/tasks", () => ({ tasksApi: { list } }));
vi.mock("next/navigation", () => {
const params = new URLSearchParams("status=completed&team=backend");
return {
useSearchParams: () => params,
useRouter: () => ({ push: vi.fn() }),
};
});
vi.mock("@/hooks/use-projects", () => ({
useProjects: () => ({ data: [] }),
}));
vi.mock("@/hooks/use-products", () => ({
useProducts: () => ({ data: [] }),
}));
vi.mock("@/components/tasks", () => ({
CreateTaskDialog: () => null,
TaskFilters: () => null,
TaskTable: () => null,
}));
import TasksPage from "../page";
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
describe("TasksPage — passes status/team/limit server-side (H17)", () => {
beforeEach(() => {
list.mockReset();
list.mockResolvedValue([]);
});
it("forwards single status + team + limit=500 to tasksApi.list", async () => {
render(withQueryClient(<TasksPage />));
await waitFor(() => expect(list).toHaveBeenCalled());
expect(list).toHaveBeenCalledWith(
expect.objectContaining({
status: "completed",
team: "backend",
limit: 500,
}),
);
});
});
+18 -9
View File
@@ -14,6 +14,7 @@ import {
SortField,
SortDirection,
} from "@/components/tasks";
import type { TaskFilters as TaskApiFilters } from "@/lib/api/tasks";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { RefreshCw } from "lucide-react";
@@ -159,7 +160,7 @@ function TasksPageContent() {
[updateParams],
);
// Fetch all tasks and filter client-side for multi-select
// Fetch tasks (server-filtered for single-select status/team) + client-side multi-select extras
// Debounced server-side search: title + description + id prefix. The
// old client-side title-only filter hid description/id matches the
// server now returns, so it is gone.
@@ -168,9 +169,15 @@ function TasksPageContent() {
const handle = setTimeout(() => setDebouncedQuery(searchQuery), 300);
return () => clearTimeout(handle);
}, [searchQuery]);
const { data: tasks, isLoading, error, refetch } = useTasks(
debouncedQuery ? { q: debouncedQuery } : undefined,
);
// Server-side filter: /tasks/summary accepts one status + one team. Single
// selection rides the server; multi-select filters the extras client-side.
const filters: TaskApiFilters | undefined = {
limit: 500,
...(debouncedQuery ? { q: debouncedQuery } : {}),
...(statusFilter.length === 1 ? { status: statusFilter[0] } : {}),
...(teamFilter.length === 1 ? { team: teamFilter[0] } : {}),
};
const { data: tasks, isLoading, error, refetch } = useTasks(filters);
// Projects + products: power the Project/Product filter options + name display.
const { data: projects } = useProjects();
@@ -196,18 +203,20 @@ function TasksPageContent() {
[products],
);
// Filter tasks based on multi-select filters
// Client-side filter for fields the backend doesn't accept (task_type,
// project, product) plus multi-select extras (status/team when > 1 — the
// single-selection case is already applied server-side).
const filteredTasks = useMemo(() => {
if (!tasks) return [];
return tasks.filter((task) => {
// Status filter (if any selected, task must match one of them)
if (statusFilter.length > 0 && !statusFilter.includes(task.status)) {
// Status: server pre-filtered the single-select case.
if (statusFilter.length > 1 && !statusFilter.includes(task.status)) {
return false;
}
// Team filter (if any selected, task must match one of them)
if (teamFilter.length > 0 && !teamFilter.includes(task.team)) {
// Team: server pre-filtered the single-select case.
if (teamFilter.length > 1 && !teamFilter.includes(task.team)) {
return false;
}
@@ -87,6 +87,70 @@ describe("VideoPostQueue", () => {
expect(screen.getByDisplayValue("New RoboCo drop!")).toBeInTheDocument();
});
// H15: the 30s refetchInterval produces a new `post` prop, but useState
// initializes once — so a server-side re-draft between the CEO opening the
// card and approving would be silently overwritten by the stale initial
// caption. The displayed value must track the server until the CEO edits.
it("tracks the server caption until the CEO edits, then holds the edit (mirrors x-post-queue)", async () => {
const basePost = {
task_id: "v-1",
source: "video_post",
title: "Video: release v0.19.0",
status: "pending",
occasion: "release",
script: "RoboCo v0.19.0 just shipped!",
platforms: ["x", "tiktok"],
};
listPosts.mockResolvedValueOnce([
{ ...basePost, x_caption: "old", tiktok_caption: "old-tik" },
] as VideoPost[]);
const client = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
render(
<QueryClientProvider client={client}>
<VideoPostQueue />
</QueryClientProvider>,
);
const xTextarea = await screen.findByDisplayValue("old");
expect(xTextarea).toBeInTheDocument();
// Simulate a 30s refetch producing a re-drafted server caption.
listPosts.mockResolvedValueOnce([
{ ...basePost, x_caption: "new server text", tiktok_caption: "new-tik" },
] as VideoPost[]);
await client.invalidateQueries({ queryKey: ["video", "posts"] });
await waitFor(() =>
expect(screen.getByDisplayValue("new server text")).toBeInTheDocument(),
);
// CEO types an edit — the derived value should now follow the user.
fireEvent.change(screen.getByDisplayValue("new server text"), {
target: { value: "my edit" },
});
expect(screen.getByDisplayValue("my edit")).toBeInTheDocument();
// Another refetch with a newer server caption — the user's edit holds.
listPosts.mockResolvedValueOnce([
{
...basePost,
x_caption: "even newer server text",
tiktok_caption: "newer-tik",
},
] as VideoPost[]);
await client.invalidateQueries({ queryKey: ["video", "posts"] });
await waitFor(() =>
expect(screen.getByDisplayValue("my edit")).toBeInTheDocument(),
);
expect(
screen.queryByDisplayValue("even newer server text"),
).not.toBeInTheDocument();
});
it("fetches the preview clip as a blob via axios and drives <video> off an object URL", async () => {
render(withQueryClient(<VideoPostQueue />));
await screen.findByText("release");
@@ -122,9 +186,7 @@ describe("VideoPostQueue", () => {
fireEvent.change(textarea, { target: { value: "x".repeat(281) } });
expect(screen.getByText("281/280")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Approve/ }),
).toBeDisabled();
expect(screen.getByRole("button", { name: /Approve/ })).toBeDisabled();
});
it("only sends captions for platforms left toggled on", async () => {
@@ -3,7 +3,11 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { videoApi } from "@/lib/api";
import type { VideoCut, VideoPost, VideoPostExecuteResult } from "@/lib/api/video";
import type {
VideoCut,
VideoPost,
VideoPostExecuteResult,
} from "@/lib/api/video";
import {
Card,
CardAction,
@@ -46,9 +50,9 @@ function describeExecuteResult(result: VideoPostExecuteResult): string {
if (result.status === "posted") return "Posted to all platforms.";
if (result.status === "posted_partial")
return `Posted to some platforms — ${result.detail}`;
if (result.status === "post_failed") return `Posting failed: ${result.detail}`;
if (result.status === "already_posted")
return "Already posted — no-op.";
if (result.status === "post_failed")
return `Posting failed: ${result.detail}`;
if (result.status === "already_posted") return "Already posted — no-op.";
if (result.status === "already_in_progress")
return "A post is already in progress for this draft.";
if (result.status === "no_platforms")
@@ -81,9 +85,16 @@ function VideoPostRow({
}) {
const [cut, setCut] = useState<VideoCut>("vertical");
const [editX, setEditX] = useState(post.platforms.includes("x"));
const [editTiktok, setEditTiktok] = useState(post.platforms.includes("tiktok"));
const [xCaption, setXCaption] = useState(post.x_caption ?? "");
const [tiktokCaption, setTiktokCaption] = useState(post.tiktok_caption ?? "");
const [editTiktok, setEditTiktok] = useState(
post.platforms.includes("tiktok"),
);
// `edited*` holds the CEO's in-progress textarea input; null means "show
// the server value". Deriving the displayed caption per render avoids
// copying the refetched prop into local state once (mirrors XPostRow).
const [editedX, setEditedX] = useState<string | null>(null);
const [editedTiktok, setEditedTiktok] = useState<string | null>(null);
const xCaption = editedX ?? post.x_caption ?? "";
const tiktokCaption = editedTiktok ?? post.tiktok_caption ?? "";
const [videoSrc, setVideoSrc] = useState<string | null>(null);
const meta = sourceMeta();
@@ -173,7 +184,7 @@ function VideoPostRow({
</div>
<Textarea
value={xCaption}
onChange={(e) => setXCaption(e.target.value)}
onChange={(e) => setEditedX(e.target.value)}
disabled={!editX}
rows={2}
className={xOverLimit ? "border-destructive" : undefined}
@@ -194,13 +205,16 @@ function VideoPostRow({
checked={editTiktok}
onCheckedChange={(c) => setEditTiktok(c === true)}
/>
<Label htmlFor={`${post.task_id}-tiktok-edit`} className="text-sm">
<Label
htmlFor={`${post.task_id}-tiktok-edit`}
className="text-sm"
>
Edit TikTok caption
</Label>
</div>
<Textarea
value={tiktokCaption}
onChange={(e) => setTiktokCaption(e.target.value)}
onChange={(e) => setEditedTiktok(e.target.value)}
disabled={!editTiktok}
rows={2}
className={tiktokOverLimit ? "border-destructive" : undefined}
@@ -271,7 +285,9 @@ function RequestVideoDialog({
}
},
onError: (e) =>
toast.error(`Request failed: ${e instanceof Error ? e.message : "error"}`),
toast.error(
`Request failed: ${e instanceof Error ? e.message : "error"}`,
),
});
const togglePlatform = (platform: string) => {
@@ -283,7 +299,9 @@ function RequestVideoDialog({
};
const canSubmit =
occasion.trim().length > 0 && brief.trim().length > 0 && platforms.length > 0;
occasion.trim().length > 0 &&
brief.trim().length > 0 &&
platforms.length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -291,9 +309,9 @@ function RequestVideoDialog({
<DialogHeader>
<DialogTitle>Request a video</DialogTitle>
<DialogDescription>
Opens a video-authoring task for a UX/UI dev it rides the
normal delivery flow and the rendered clip lands back in this
queue once rendering finishes.
Opens a video-authoring task for a UX/UI dev it rides the normal
delivery flow and the rendered clip lands back in this queue once
rendering finishes.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
@@ -390,7 +408,9 @@ export function VideoPostQueue({ className }: { className?: string }) {
}
},
onError: (e) =>
toast.error(`Approve failed: ${e instanceof Error ? e.message : "error"}`),
toast.error(
`Approve failed: ${e instanceof Error ? e.message : "error"}`,
),
onSettled: () => setApprovingId(null),
});
@@ -456,8 +476,8 @@ export function VideoPostQueue({ className }: { className?: string }) {
<CardContent>
<p className="text-sm text-muted-foreground">
No drafts yet. Set your keys in Settings X (Twitter) / TikTok
Credentials and enable the video engine or request one on
demand above.
Credentials and enable the video engine or request one on demand
above.
</p>
</CardContent>
</Card>
@@ -477,8 +497,8 @@ export function VideoPostQueue({ className }: { className?: string }) {
</CardTitle>
<CardAction>{requestButton}</CardAction>
<CardDescription>
Rendered clips preview both cuts, edit captions, approve (posts
to the target platforms), or reject. Nothing posts on its own.
Rendered clips preview both cuts, edit captions, approve (posts to
the target platforms), or reject. Nothing posts on its own.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
@@ -76,6 +76,9 @@ function EditProjectForm({
const [ciWatchWorkflow, setCiWatchWorkflow] = useState(
project.ci_watch_workflow || "",
);
const [videoEngineEnabled, setVideoEngineEnabled] = useState(
project.video_engine_enabled,
);
const [depUpdateCommand, setDepUpdateCommand] = useState(
project.dep_update_command || "",
);
@@ -120,6 +123,7 @@ function EditProjectForm({
quality_command: qualityCommand || undefined,
ci_watch_enabled: ciWatchEnabled,
ci_watch_workflow: ciWatchWorkflow || undefined,
video_engine_enabled: videoEngineEnabled,
dep_update_command: depUpdateCommand || undefined,
dep_update_paths: depUpdatePaths.trim()
? depUpdatePaths
@@ -410,6 +414,17 @@ function EditProjectForm({
</p>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="video_engine_enabled">
Video engine (author marketing videos into this project)
</Label>
<Switch
id="video_engine_enabled"
checked={videoEngineEnabled}
onCheckedChange={setVideoEngineEnabled}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dep_update_command">
Dependency-Update Command
@@ -3,22 +3,20 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
// Deferred mutationFn: the test holds `resolveSet` so it can freeze the toggle
// mutation mid-flight and observe the per-control disabled state, then
// release it. This exercises the REAL useMutation isPending/variables state
// rather than a stubbed hook. `vi.hoisted` keeps the mock fns initialized
// before the hoisted vi.mock factory runs.
// Hold the deferred mutation resolver so the test can freeze the toggle
// mid-flight and observe the per-control disabled state, then release it.
const { resolveSetRef } = vi.hoisted(() => ({
resolveSetRef: { current: null as null | ((v: unknown) => void) },
// Deferred mutationFn: the test holds every pending resolver in a queue so it
// can freeze multiple toggles mid-flight and release them one at a time. This
// exercises the REAL useMutation isPending/variables state rather than a
// stubbed hook. `vi.hoisted` keeps the mock fns initialized before the hoisted
// vi.mock factory runs.
const { resolveQueue } = vi.hoisted(() => ({
resolveQueue: { current: [] as Array<(v: unknown) => void> },
}));
const { setFeatureFlag, getFeatureFlags } = vi.hoisted(() => ({
setFeatureFlag: vi.fn(
() =>
new Promise((r) => {
resolveSetRef.current = r as (v: unknown) => void;
resolveQueue.current.push(r as (v: unknown) => void);
}),
),
getFeatureFlags: vi.fn(async () => ({
@@ -43,39 +41,90 @@ function withQueryClient(ui: ReactNode) {
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
describe("FeatureFlagsCard — per-control disable during a toggle (F084)", () => {
// M42: off-transitions (true→false) must open a confirm AlertDialog and only
// fire the mutation on confirm; on-transitions (false→true) fire immediately.
// pendingKeys tracks every in-flight toggle so each row locks independently.
describe("FeatureFlagsCard — M42 off-transition confirm + pending-keys Set", () => {
beforeEach(() => {
setFeatureFlag.mockClear();
getFeatureFlags.mockClear();
resolveSetRef.current = null;
resolveQueue.current = [];
});
afterEach(() => {
vi.clearAllMocks();
});
it("disables only the flag being toggled, not every flag's switch", async () => {
it("off-transition opens a confirm dialog and defers the mutation until confirmed", async () => {
render(withQueryClient(<FeatureFlagsCard />));
const alpha = await screen.findByRole("switch", { name: "Alpha" });
expect(alpha).toBeChecked();
// Click the ON switch → off-transition → confirm dialog opens.
fireEvent.click(alpha);
const dialog = await screen.findByRole("alertdialog");
expect(dialog).toBeInTheDocument();
// Mutation is NOT fired until the operator confirms.
expect(setFeatureFlag).not.toHaveBeenCalled();
// Confirm → mutation fires with enabled=false.
fireEvent.click(screen.getByRole("button", { name: "Disable" }));
await waitFor(() =>
expect(setFeatureFlag).toHaveBeenCalledWith("alpha", false),
);
});
it("on-transition fires immediately without a confirm dialog", async () => {
render(withQueryClient(<FeatureFlagsCard />));
const beta = await screen.findByRole("switch", { name: "Beta" });
expect(beta).not.toBeChecked();
// Click the OFF switch → on-transition → fires immediately, no dialog.
fireEvent.click(beta);
await waitFor(() =>
expect(setFeatureFlag).toHaveBeenCalledWith("beta", true),
);
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
});
it("pendingKeys tracks every in-flight toggle so each row stays locked", async () => {
render(withQueryClient(<FeatureFlagsCard />));
const alpha = await screen.findByRole("switch", { name: "Alpha" });
const beta = await screen.findByRole("switch", { name: "Beta" });
expect(alpha).not.toBeDisabled();
expect(beta).not.toBeDisabled();
// Toggle Alpha off — the mutation stays pending (deferred mutationFn).
// Alpha: off-transition → confirm → mutation in flight (deferred).
fireEvent.click(alpha);
fireEvent.click(await screen.findByRole("button", { name: "Disable" }));
await waitFor(() =>
expect(setFeatureFlag).toHaveBeenCalledWith("alpha", false),
);
// Alpha's switch locks while its toggle is in flight; Beta stays usable so
// the operator can flip an independent flag at the same time. Before the
// fix every switch shared `disabled={toggleMutation.isPending}`.
await waitFor(() => expect(alpha).toBeDisabled());
expect(beta).not.toBeDisabled();
// Mutation resolves → Alpha unlocks again.
resolveSetRef.current?.(undefined);
// Beta: on-transition → fires immediately, mutation in flight (deferred).
fireEvent.click(beta);
await waitFor(() =>
expect(setFeatureFlag).toHaveBeenCalledWith("beta", true),
);
// Both switches are locked while their toggles are in flight. Pre-fix only
// the latest toggle's key was tracked, so Alpha would unlock when Beta
// started.
await waitFor(() => expect(beta).toBeDisabled());
expect(alpha).toBeDisabled();
// Resolve Alpha (FIFO) → Alpha unlocks; Beta stays locked until its own
// resolves.
resolveQueue.current.shift()?.(undefined);
await waitFor(() => expect(alpha).not.toBeDisabled());
expect(beta).not.toBeDisabled();
expect(beta).toBeDisabled();
// Resolve Beta → Beta unlocks.
resolveQueue.current.shift()?.(undefined);
await waitFor(() => expect(beta).not.toBeDisabled());
});
});
@@ -92,4 +92,79 @@ describe("TikTokCredentialsForm", () => {
).toBe(""),
);
});
// M43: when credentials are already stored, leaving all 4 fields blank and
// clicking Save is a destructive clear — it must open an AlertDialog and
// only fire setCredentials on confirm. A normal all-4-filled save fires
// immediately with no dialog.
it("a clear (all 4 blank + has_credentials) opens a confirm dialog and defers the mutation until confirmed", async () => {
getCredentialsStatus.mockResolvedValueOnce({ has_credentials: true });
render(withQueryClient(<TikTokCredentialsForm />));
await screen.findByText("Credentials are set");
const saveButton = screen.getByRole("button", { name: "Save" });
expect(saveButton).not.toBeDisabled();
fireEvent.click(saveButton);
const dialog = await screen.findByRole("alertdialog");
expect(dialog).toBeInTheDocument();
expect(setCredentials).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
await waitFor(() =>
expect(setCredentials).toHaveBeenCalledWith({
client_key: "",
client_secret: "",
access_token: "",
refresh_token: "",
}),
);
});
it("a normal all-4-filled save fires immediately without a confirm dialog", async () => {
render(withQueryClient(<TikTokCredentialsForm />));
await screen.findByText("No credentials configured");
fireEvent.change(screen.getByLabelText("Client key"), {
target: { value: "ck" },
});
fireEvent.change(screen.getByLabelText("Client secret"), {
target: { value: "cs" },
});
fireEvent.change(screen.getByLabelText("Access token"), {
target: { value: "at" },
});
fireEvent.change(screen.getByLabelText("Refresh token"), {
target: { value: "rt" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(setCredentials).toHaveBeenCalledWith({
client_key: "ck",
client_secret: "cs",
access_token: "at",
refresh_token: "rt",
}),
);
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
});
it("a clear confirm dialog cancel does NOT fire the mutation", async () => {
getCredentialsStatus.mockResolvedValueOnce({ has_credentials: true });
render(withQueryClient(<TikTokCredentialsForm />));
await screen.findByText("Credentials are set");
fireEvent.click(screen.getByRole("button", { name: "Save" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() =>
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(),
);
expect(setCredentials).not.toHaveBeenCalled();
});
});
@@ -87,9 +87,87 @@ describe("XCredentialsForm", () => {
}),
);
await waitFor(() =>
expect(
(screen.getByLabelText("API key") as HTMLInputElement).value,
).toBe(""),
expect((screen.getByLabelText("API key") as HTMLInputElement).value).toBe(
"",
),
);
});
// M43: when credentials are already stored, leaving all 4 fields blank and
// clicking Save is a destructive clear — it must open an AlertDialog and
// only fire setCredentials on confirm. A normal all-4-filled save fires
// immediately with no dialog.
it("a clear (all 4 blank + has_credentials) opens a confirm dialog and defers the mutation until confirmed", async () => {
getCredentialsStatus.mockResolvedValueOnce({ has_credentials: true });
render(withQueryClient(<XCredentialsForm />));
await screen.findByText("Credentials are set");
// All 4 blank + has_credentials => Save is enabled (canSave true).
const saveButton = screen.getByRole("button", { name: "Save" });
expect(saveButton).not.toBeDisabled();
fireEvent.click(saveButton);
const dialog = await screen.findByRole("alertdialog");
expect(dialog).toBeInTheDocument();
// Mutation is NOT fired until the operator confirms.
expect(setCredentials).not.toHaveBeenCalled();
// Confirm → mutation fires with the all-empty (clear) payload.
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
await waitFor(() =>
expect(setCredentials).toHaveBeenCalledWith({
api_key: "",
api_secret: "",
access_token: "",
access_token_secret: "",
}),
);
});
it("a normal all-4-filled save fires immediately without a confirm dialog", async () => {
render(withQueryClient(<XCredentialsForm />));
await screen.findByText("No credentials configured");
fireEvent.change(screen.getByLabelText("API key"), {
target: { value: "ak" },
});
fireEvent.change(screen.getByLabelText("API key secret"), {
target: { value: "as" },
});
fireEvent.change(screen.getByLabelText("Access token"), {
target: { value: "at" },
});
fireEvent.change(screen.getByLabelText("Access token secret"), {
target: { value: "ats" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(setCredentials).toHaveBeenCalledWith({
api_key: "ak",
api_secret: "as",
access_token: "at",
access_token_secret: "ats",
}),
);
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
});
it("a clear confirm dialog cancel does NOT fire the mutation", async () => {
getCredentialsStatus.mockResolvedValueOnce({ has_credentials: true });
render(withQueryClient(<XCredentialsForm />));
await screen.findByText("Credentials are set");
fireEvent.click(screen.getByRole("button", { name: "Save" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() =>
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(),
);
expect(setCredentials).not.toHaveBeenCalled();
});
});
@@ -14,6 +14,16 @@ import { useState } from "react";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Collapsible,
CollapsibleContent,
@@ -81,6 +91,11 @@ export function FeatureFlagsCard() {
const queryClient = useQueryClient();
const [xCredsOpen, setXCredsOpen] = useState(false);
const [tiktokCredsOpen, setTiktokCredsOpen] = useState(false);
// Off-transition awaiting operator confirm. Null = no dialog open.
const [confirmFlag, setConfirmFlag] = useState<FeatureFlag | null>(null);
// Every in-flight toggle key — added on mutate, removed on settle. Tracks
// concurrent toggles so each row locks independently of the others.
const [pendingKeys, setPendingKeys] = useState<Set<string>>(new Set());
const { data, isLoading } = useQuery({
queryKey: ["feature-flags"],
@@ -90,6 +105,13 @@ export function FeatureFlagsCard() {
const toggleMutation = useMutation({
mutationFn: ({ key, enabled }: { key: string; enabled: boolean }) =>
settingsApi.setFeatureFlag(key, enabled),
onMutate: ({ key }) => {
setPendingKeys((s) => {
const n = new Set(s);
n.add(key);
return n;
});
},
onSuccess: (_data, { enabled }) => {
queryClient.invalidateQueries({ queryKey: ["feature-flags"] });
toast.success(
@@ -101,6 +123,13 @@ export function FeatureFlagsCard() {
`Failed to update: ${error instanceof Error ? error.message : "Unknown error"}`,
);
},
onSettled: (_data, _err, { key }) => {
setPendingKeys((s) => {
const n = new Set(s);
n.delete(key);
return n;
});
},
});
const flags: FeatureFlag[] = data?.flags ?? [];
@@ -151,13 +180,17 @@ export function FeatureFlagsCard() {
<Switch
id={`flag-${flag.key}`}
checked={flag.enabled}
disabled={
toggleMutation.isPending &&
toggleMutation.variables?.key === flag.key
}
onCheckedChange={(checked) =>
toggleMutation.mutate({ key: flag.key, enabled: checked })
}
disabled={pendingKeys.has(flag.key)}
onCheckedChange={(checked) => {
// Off-transitions are destructive (a running subsystem
// stops on the next restart) — confirm before firing.
// On-transitions are low-risk and fire immediately.
if (checked) {
toggleMutation.mutate({ key: flag.key, enabled: true });
} else {
setConfirmFlag(flag);
}
}}
/>
</div>
{isXEngine && (
@@ -215,6 +248,36 @@ export function FeatureFlagsCard() {
})}
</div>
</CardContent>
<AlertDialog
open={confirmFlag !== null}
onOpenChange={(open) => {
if (!open) setConfirmFlag(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Disable feature?</AlertDialogTitle>
<AlertDialogDescription>
{confirmFlag
? `${confirmFlag.label} will turn off on the next backend restart. This may interrupt in-flight work depending on it.`
: ""}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
const flag = confirmFlag;
if (!flag) return;
setConfirmFlag(null);
toggleMutation.mutate({ key: flag.key, enabled: false });
}}
>
Disable
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
);
}
@@ -6,6 +6,16 @@ import { videoApi } from "@/lib/api";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Key, KeyRound, Save } from "lucide-react";
import { toast } from "sonner";
@@ -63,6 +73,9 @@ export function TikTokCredentialsForm() {
// A genuine save is either "set all 4" or, when something is already
// stored, "clear all 4". All-empty with nothing stored is a true no-op.
const canSave = allFilled || (noneFilled && !!status?.has_credentials);
// Clearing stored secrets is destructive and irreversible — confirm it.
const isClearing = noneFilled && !!status?.has_credentials;
const [confirmClear, setConfirmClear] = useState(false);
return (
<div className="space-y-4">
@@ -114,12 +127,42 @@ export function TikTokCredentialsForm() {
</p>
<Button
onClick={() => saveMutation.mutate()}
onClick={() =>
isClearing ? setConfirmClear(true) : saveMutation.mutate()
}
disabled={saveMutation.isPending || !canSave}
>
<Save className="mr-2 h-4 w-4" />
{saveMutation.isPending ? "Saving..." : "Save"}
</Button>
<AlertDialog
open={confirmClear}
onOpenChange={(open) => {
if (!open) setConfirmClear(false);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear TikTok credentials?</AlertDialogTitle>
<AlertDialogDescription>
This will clear all stored TikTok credentials. This cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setConfirmClear(false);
saveMutation.mutate();
}}
>
Clear
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -6,6 +6,16 @@ import { xApi } from "@/lib/api";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Key, KeyRound, Save } from "lucide-react";
import { toast } from "sonner";
@@ -61,6 +71,9 @@ export function XCredentialsForm() {
// A genuine save is either "set all 4" or, when something is already
// stored, "clear all 4". All-empty with nothing stored is a true no-op.
const canSave = allFilled || (noneFilled && !!status?.has_credentials);
// Clearing stored secrets is destructive and irreversible — confirm it.
const isClearing = noneFilled && !!status?.has_credentials;
const [confirmClear, setConfirmClear] = useState(false);
return (
<div className="space-y-4">
@@ -112,12 +125,41 @@ export function XCredentialsForm() {
</p>
<Button
onClick={() => saveMutation.mutate()}
onClick={() =>
isClearing ? setConfirmClear(true) : saveMutation.mutate()
}
disabled={saveMutation.isPending || !canSave}
>
<Save className="mr-2 h-4 w-4" />
{saveMutation.isPending ? "Saving..." : "Save"}
</Button>
<AlertDialog
open={confirmClear}
onOpenChange={(open) => {
if (!open) setConfirmClear(false);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear X credentials?</AlertDialogTitle>
<AlertDialogDescription>
This will clear all stored X credentials. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setConfirmClear(false);
saveMutation.mutate();
}}
>
Clear
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,84 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type ReactNode } from "react";
// H18: useAgents caches the roster with staleTime 5min and no refetchInterval.
// A live status change (idle→running) polled by useOrchestratorStatus every
// 10s must propagate to the roster immediately — the statusEpoch in the
// queryKey makes the roster refetch when the live snapshot changes. Pre-fix
// the roster stays "idle" for up to 5min.
const { getStatus, getAll } = vi.hoisted(() => ({
getStatus: vi.fn(),
getAll: vi.fn(),
}));
vi.mock("@/lib/api/orchestrator", () => ({
orchestratorApi: { getStatus },
}));
vi.mock("@/lib/api/agents", () => ({
agentsApi: { getAll },
}));
import { useAgents, agentKeys } from "@/hooks/use-agents";
const DEF = [
{ id: "a", uuid: "u-a", name: "Agent A", role: "developer", team: "backend" },
];
describe("useAgents — H18 statusEpoch", () => {
let client: QueryClient;
function wrapper({ children }: { children: ReactNode }) {
client = new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: 5 * 60 * 1000 },
mutations: { retry: false },
},
});
return (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
}
beforeEach(() => {
getStatus.mockReset();
getAll.mockReset();
getAll.mockResolvedValue(DEF);
});
it("re-derives roster when the live status poll changes", async () => {
getStatus.mockResolvedValue({
total_agents: 1,
by_state: { idle: 1 },
waiting_count: 0,
agents: [{ agent_id: "a", state: "idle" }],
});
const { result } = renderHook(() => useAgents(), { wrapper });
await waitFor(() => expect(result.current.data?.[0]?.status).toBe("idle"));
// Live status poll now reports the agent as running.
getStatus.mockResolvedValue({
total_agents: 1,
by_state: { running: 1 },
waiting_count: 0,
agents: [{ agent_id: "a", state: "running" }],
});
// Trigger the status query refetch (what the 10s refetchInterval does in
// production). The new live snapshot must invalidate the roster queryKey
// and re-derive the roster immediately.
await act(async () => {
await client.refetchQueries({ queryKey: agentKeys.status() });
});
await waitFor(() =>
expect(result.current.data?.[0]?.status).toBe("running"),
);
expect(getStatus).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,76 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type ReactNode } from "react";
// M40: useMetrics reads the useAgentStatus poll cache and only falls back to
// fetchQuery on a cold cache — net one getAgentStatus call when both mount.
const { getAgentStatus, getVelocityMetrics, getBlockerMetrics } = vi.hoisted(
() => ({
getAgentStatus: vi.fn(),
getVelocityMetrics: vi.fn(),
getBlockerMetrics: vi.fn(),
}),
);
vi.mock("@/lib/api/dashboard", () => ({
dashboardApi: { getAgentStatus, getVelocityMetrics, getBlockerMetrics },
}));
import { useAgentStatus, useMetrics } from "@/hooks/use-dashboard";
const AGENT_STATUS = {
total_agents: 3,
by_state: { running: 1, idle: 2 },
waiting_count: 0,
};
const VELOCITY = {
tasks_completed_today: 5,
tasks_completed_week: 20,
average_completion_time_hours: 2,
};
const BLOCKERS = {
total_blocked: 1,
blocked_by_team: { backend: 1 },
longest_blocked_hours: 4,
};
describe("M40 — useMetrics dedupes agent-status poll", () => {
let client: QueryClient;
function wrapper({ children }: { children: ReactNode }) {
client = new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: 0, refetchInterval: false },
mutations: { retry: false },
},
});
return (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
}
beforeEach(() => {
getAgentStatus.mockReset();
getVelocityMetrics.mockReset();
getBlockerMetrics.mockReset();
getAgentStatus.mockResolvedValue(AGENT_STATUS);
getVelocityMetrics.mockResolvedValue(VELOCITY);
getBlockerMetrics.mockResolvedValue(BLOCKERS);
});
it("useMetrics reads agent counts from the useAgentStatus cache (one fetch)", async () => {
renderHook(() => ({ status: useAgentStatus(), metrics: useMetrics() }), {
wrapper,
});
await waitFor(() => expect(getVelocityMetrics).toHaveBeenCalledTimes(1));
await waitFor(() => expect(getAgentStatus).toHaveBeenCalledTimes(1));
expect(getBlockerMetrics).toHaveBeenCalledTimes(1);
expect(getAgentStatus).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,80 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type ReactNode } from "react";
// M41: scorecard data is slow-moving aggregated cycle-time/rework; polling
// every 60s was excessive load for data that barely moves. Both scorecard
// hooks should refetch every 5min (300_000ms) via a shared constant.
const { getMemberScorecard, getOrgScorecard } = vi.hoisted(() => ({
getMemberScorecard: vi.fn(),
getOrgScorecard: vi.fn(),
}));
vi.mock("@/lib/api/observability", () => ({
observabilityApi: { getMemberScorecard, getOrgScorecard },
}));
import { useMemberScorecard, useOrgScorecard } from "@/hooks/use-observability";
const UUID = "00000000-0000-0000-0002-000000000001";
function makeClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: 0, refetchInterval: false },
},
});
}
function wrapperFor(client: QueryClient) {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
};
}
describe("M41 — scorecard refetchInterval is 5min", () => {
beforeEach(() => {
getMemberScorecard.mockReset();
getOrgScorecard.mockReset();
getMemberScorecard.mockResolvedValue({});
getOrgScorecard.mockResolvedValue({});
});
it("useMemberScorecard refetches every 300_000ms", async () => {
const client = makeClient();
renderHook(() => useMemberScorecard(UUID), {
wrapper: wrapperFor(client),
});
await waitFor(() => expect(getMemberScorecard).toHaveBeenCalledOnce());
const query = client
.getQueryCache()
.getAll()
.find((q) => q.queryKey[2] === "member");
expect(
(query?.options as { refetchInterval?: number }).refetchInterval,
).toBe(300_000);
});
it("useOrgScorecard refetches every 300_000ms", async () => {
const client = makeClient();
renderHook(() => useOrgScorecard(30), {
wrapper: wrapperFor(client),
});
await waitFor(() => expect(getOrgScorecard).toHaveBeenCalledOnce());
const query = client
.getQueryCache()
.getAll()
.find((q) => q.queryKey[2] === "org");
expect(
(query?.options as { refetchInterval?: number }).refetchInterval,
).toBe(300_000);
});
});
@@ -35,6 +35,14 @@ const hoisted = vi.hoisted(() => {
this.didDisconnect = true;
this.onStateChange?.("disconnected");
}
// C4: the registry replays current state to a new subscriber via getState.
getState() {
return "connected";
}
getLastPongAt() {
return Date.now();
}
checkPong() {}
}
return { instances, MockConnection };
});
@@ -49,7 +57,7 @@ vi.mock("@/lib/constants", () => ({
STREAM_MAX_MESSAGES: 100,
}));
import { useWebSocket } from "../use-websocket";
import { useWebSocket, _resetSharedSocketsForTest } from "../use-websocket";
interface Frame {
type: "agent.stream";
@@ -77,9 +85,11 @@ describe("useWebSocket — clears snapshot on cleanup (#79)", () => {
beforeEach(() => {
hoisted.instances.length = 0;
resultRef.current = null;
_resetSharedSocketsForTest();
});
afterEach(() => {
vi.clearAllMocks();
_resetSharedSocketsForTest();
});
it("clears messages/lastMessage/state when the endpoint changes (no stale leak)", () => {
@@ -118,3 +128,46 @@ describe("useWebSocket — clears snapshot on cleanup (#79)", () => {
expect(conn.didDisconnect).toBe(true);
});
});
// C4: two consumers of the same /ws/system URL (the A2A live view +
// rate-limit banner) used to each open their own socket. The hook now
// ref-counts a shared connection per URL.
describe("useWebSocket — shared socket per URL (C4)", () => {
beforeEach(() => {
hoisted.instances.length = 0;
resultRef.current = null;
_resetSharedSocketsForTest();
});
afterEach(() => {
vi.clearAllMocks();
_resetSharedSocketsForTest();
});
it("mounts ONE WebSocketConnection for two same-URL subscribers", () => {
// Two separate trees — the module-level registry is shared across both.
render(<Harness endpoint="/system" />);
render(<Harness endpoint="/system" />);
expect(hoisted.instances).toHaveLength(1);
});
it("keeps the shared socket alive while one subscriber unmounts", () => {
const treeA = render(<Harness endpoint="/system" />);
const treeB = render(<Harness endpoint="/system" />);
expect(hoisted.instances).toHaveLength(1);
const conn = hoisted.instances[0];
// Unmount one subscriber — refcount drops to 1, socket stays alive.
treeA.unmount();
expect(conn.didDisconnect).toBe(false);
// Unmount the last subscriber — refcount 0, socket tears down.
treeB.unmount();
expect(conn.didDisconnect).toBe(true);
});
it("still opens separate sockets for different URLs", () => {
render(<Harness endpoint="/agents/a" />);
render(<Harness endpoint="/agents/b" />);
expect(hoisted.instances).toHaveLength(2);
});
});
+7 -1
View File
@@ -286,9 +286,15 @@ export function useAgents() {
// A stable signature so the derived roster refetches when the live set
// changes (react-query keys on this, not on the closure).
const rosterKey = definitions?.map((d) => d.id).join(",") ?? "static";
// Re-derive the roster when the live status snapshot changes — keying on the
// roster itself would be circular (never changes).
const statusEpoch =
orchestratorStatus?.agents
?.map((a) => `${a.agent_id}:${a.state}`)
.join(",") ?? "none";
return useQuery({
queryKey: [...agentKeys.all, "roster", rosterKey],
queryKey: [...agentKeys.all, "roster", rosterKey, statusEpoch],
queryFn: async (): Promise<Agent[]> => {
// Build a map of agent statuses from the orchestrator status array
const statusMap = new Map<string, string>();
+10 -2
View File
@@ -13,6 +13,7 @@ import type {
AuditorFlag,
AuditorReport,
FlagSeverity,
OrchestratorStatus,
} from "@/types";
export const dashboardKeys = {
@@ -58,14 +59,21 @@ export function useCeoOverview() {
}
export function useMetrics() {
const queryClient = useQueryClient();
return useQuery({
queryKey: dashboardKeys.metrics(),
queryFn: async (): Promise<MetricsSummary> => {
// Fetch all metrics in parallel, including real agent status
// agent counts come from the useAgentStatus 10s poll cache; fetchQuery dedupes on a cold cache.
const [velocity, blockers, agentStatus] = await Promise.all([
dashboardApi.getVelocityMetrics(),
dashboardApi.getBlockerMetrics(),
dashboardApi.getAgentStatus(),
queryClient.getQueryData<OrchestratorStatus>(
dashboardKeys.agentStatus(),
) ??
(await queryClient.fetchQuery({
queryKey: dashboardKeys.agentStatus(),
queryFn: () => dashboardApi.getAgentStatus(),
})),
]);
return {
velocity,
+4 -2
View File
@@ -97,13 +97,15 @@ export function isScorecardMemberId(agentId: string): boolean {
return agentId === "ceo" || UUID_RE.test(agentId);
}
const SCORECARD_REFETCH_INTERVAL = 300_000;
/** Per-member rollup scorecard (+ live in-flight overlay). */
export function useMemberScorecard(agentId: string, days = 30) {
return useQuery<MemberScorecard>({
queryKey: observabilityKeys.memberScorecard(agentId, days),
queryFn: () => observabilityApi.getMemberScorecard(agentId, days),
enabled: isScorecardMemberId(agentId),
refetchInterval: 60_000,
refetchInterval: SCORECARD_REFETCH_INTERVAL,
});
}
@@ -112,7 +114,7 @@ export function useOrgScorecard(days = 30, team?: string) {
return useQuery<OrgScorecard>({
queryKey: observabilityKeys.orgScorecard(days, team),
queryFn: () => observabilityApi.getOrgScorecard(days, team),
refetchInterval: 60_000,
refetchInterval: SCORECARD_REFETCH_INTERVAL,
});
}
+79 -12
View File
@@ -49,6 +49,38 @@ export interface A2ASystemMessage {
// Generic WebSocket Hook
// =============================================================================
// C4: ref-counted shared connection per URL. Two consumers of /ws/system (the
// A2A live view + the rate-limit banner) used to each open their own socket;
// now they share one. The shared conn fans out messages + state to a Set of
// subscribers. Module-level so separate trees share the same registry.
interface SharedConn {
conn: WebSocketConnection;
subscribers: Set<{
onMessage: (data: unknown) => void;
onStateChange: (state: ConnectionState) => void;
}>;
}
const _sharedSockets = new Map<string, SharedConn>();
// Test-only: clear the module-level registry between tests so a prior test's
// un-unmounted socket doesn't leak into the next. No-op in production.
export function _resetSharedSocketsForTest() {
for (const entry of _sharedSockets.values()) entry.conn.disconnect();
_sharedSockets.clear();
}
function _dispatchMessage(url: string, data: unknown) {
const entry = _sharedSockets.get(url);
if (!entry) return;
for (const sub of entry.subscribers) sub.onMessage(data);
}
function _dispatchState(url: string, state: ConnectionState) {
const entry = _sharedSockets.get(url);
if (!entry) return;
for (const sub of entry.subscribers) sub.onStateChange(state);
}
export function useWebSocket<T>(
endpoint: string,
queryParams?: Record<string, string>,
@@ -58,6 +90,7 @@ export function useWebSocket<T>(
const [lastMessage, setLastMessage] = useState<T | null>(null);
const [messages, setMessages] = useState<T[]>([]);
const connectionRef = useRef<WebSocketConnection | null>(null);
const urlRef = useRef<string | null>(null);
// Memoize queryParams string to prevent unnecessary reconnects
const queryString = queryParams
@@ -74,10 +107,9 @@ export function useWebSocket<T>(
const baseUrl = getWebSocketUrl();
const url = baseUrl + endpoint + (queryString ? "?" + queryString : "");
// Create connection
const connection = new WebSocketConnection({
url,
onMessage: (data) => {
// Subscriber for this mount — its callbacks write THIS hook's React state.
const subscriber = {
onMessage: (data: unknown) => {
const message = data as T;
setLastMessage(message);
setMessages((prev) => [
@@ -86,18 +118,47 @@ export function useWebSocket<T>(
]);
},
onStateChange: setState,
});
};
connectionRef.current = connection;
connection.connect();
let entry = _sharedSockets.get(url);
if (entry) {
// Reuse: attach to the existing conn's fan-out. Replay current state so
// the new subscriber's UI doesn't sit on "disconnected" until the next
// state change. Route through the subscriber callback (not setState
// directly) — same path the conn's onStateChange uses, so the new
// subscriber mirrors the existing subscribers' current view.
entry.subscribers.add(subscriber);
subscriber.onStateChange(entry.conn.getState());
} else {
// First subscriber for this URL — open the shared conn with fan-out
// dispatchers that iterate the subscriber set.
const conn = new WebSocketConnection({
url,
onMessage: (data) => _dispatchMessage(url, data),
onStateChange: (s) => _dispatchState(url, s),
});
entry = { conn, subscribers: new Set([subscriber]) };
_sharedSockets.set(url, entry);
conn.connect();
}
connectionRef.current = entry.conn;
urlRef.current = url;
// Cleanup on unmount or when dependencies change. Disconnect AND clear the
// snapshot — otherwise a dep change (navigating to another stream) leaves
// the prior subscription's messages/lastMessage/state visible until a fresh
// frame arrives, surfacing another stream's stale buffer as live (#79).
// Cleanup on unmount or when dependencies change. Decrement the refcount;
// only disconnect + drop the registry entry when the last subscriber
// leaves. Always clear THIS subscriber's local snapshot (#79) so a dep
// change can't surface another stream's stale buffer as live.
return () => {
connection.disconnect();
const current = _sharedSockets.get(url);
if (current) {
current.subscribers.delete(subscriber);
if (current.subscribers.size === 0) {
current.conn.disconnect();
_sharedSockets.delete(url);
}
}
connectionRef.current = null;
urlRef.current = null;
setMessages([]);
setLastMessage(null);
setState("disconnected");
@@ -105,8 +166,14 @@ export function useWebSocket<T>(
}, [enabled, endpoint, queryString]); // Stable dependencies
const disconnect = useCallback(() => {
// ponytail: manual verb tears down the shared conn for ALL subscribers of
// this URL and evicts the dead entry so a later mount reopens a fresh conn
// instead of reusing a manualClose=true stub that never reconnects.
const url = urlRef.current;
connectionRef.current?.disconnect();
if (url) _sharedSockets.delete(url);
connectionRef.current = null;
urlRef.current = null;
setState("disconnected");
}, []);
+208 -1
View File
@@ -24,7 +24,8 @@ function mockConstants(wsUrl: string) {
DEFAULT_PAGE_SIZE: 20,
MAX_PAGE_SIZE: 100,
WS_RECONNECT_INTERVAL: 5000,
WS_MAX_RECONNECT_ATTEMPTS: 3,
WS_RECONNECT_MAX_INTERVAL: 30000,
WS_PONG_TIMEOUT_MS: 60000,
WS_HEARTBEAT_INTERVAL: 30000,
STREAM_MAX_MESSAGES: 100,
NOTIFICATION_MAX_DISPLAY: 10,
@@ -115,3 +116,209 @@ describe("getWebSocketUrl — SSR fallback", () => {
expect(getWebSocketUrl()).toBe("/ws");
});
});
// ---------------------------------------------------------------------------
// WebSocketConnection — pong watchdog + long-tail reconnect (C4)
//
// jsdom doesn't ship a real WebSocket. Stub the constructor with a minimal
// class that records handlers and lets the test drive open/close/message.
// ---------------------------------------------------------------------------
class MockWebSocket {
// WebSocket ready-state constants — `connection.ts` references WebSocket.OPEN
// for the early-return guard, so the stub must define them.
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
static instances: MockWebSocket[] = [];
static last(): MockWebSocket {
return MockWebSocket.instances[MockWebSocket.instances.length - 1];
}
url: string;
readyState = 0;
onopen: ((ev: Event) => void) | null = null;
onmessage: ((ev: MessageEvent) => void) | null = null;
onclose: ((ev: CloseEvent) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
closed = false;
constructor(url: string) {
this.url = url;
MockWebSocket.instances.push(this);
}
send() {}
close(code = 1006, reason = "") {
if (this.closed) return;
this.closed = true;
this.readyState = 3;
this.onclose?.(new CloseEvent("close", { code, reason, wasClean: false }));
}
fireOpen() {
this.readyState = 1;
this.onopen?.(new Event("open"));
}
fireMessage(data: string) {
this.onmessage?.({ data } as MessageEvent);
}
}
describe("WebSocketConnection — pong watchdog (C4)", () => {
beforeEach(() => {
MockWebSocket.instances = [];
vi.useFakeTimers();
vi.stubGlobal("WebSocket", MockWebSocket);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("tracks lastPongAt when a 'pong' frame arrives", async () => {
mockConstants("/ws");
const { WebSocketConnection } = await import("@/lib/websocket/connection");
const onStateChange = vi.fn();
const conn = new WebSocketConnection({
url: "ws://test/ws",
onStateChange,
heartbeatInterval: 30000,
});
conn.connect();
const ws = MockWebSocket.last();
ws.fireOpen();
const before = conn.getLastPongAt();
// A pong frame should refresh lastPongAt.
vi.advanceTimersByTime(1000);
ws.fireMessage("pong");
const after = conn.getLastPongAt();
expect(after).toBeGreaterThan(before);
// A data frame must NOT touch lastPongAt.
const dataBefore = conn.getLastPongAt();
ws.fireMessage(JSON.stringify({ type: "ping" }));
expect(conn.getLastPongAt()).toBe(dataBefore);
});
it("force-closes when no pong arrives within 2× heartbeat interval", async () => {
mockConstants("/ws");
const { WebSocketConnection } = await import("@/lib/websocket/connection");
const onStateChange = vi.fn();
const conn = new WebSocketConnection({
url: "ws://test/ws",
onStateChange,
heartbeatInterval: 30000,
// Use the default WS_PONG_TIMEOUT_MS (60000) — 2× heartbeat.
});
conn.connect();
const ws = MockWebSocket.last();
ws.fireOpen();
// No pong for 61s. The heartbeat tick checks the watchdog BEFORE sending
// ping; advancing past the 60s timeout (2× heartbeat) should force-close
// → onclose → the reconnect path fires (state → reconnecting). The tick at
// 30s passes (only 30s elapsed); the tick at 60s trips the watchdog.
vi.advanceTimersByTime(61000);
expect(ws.closed).toBe(true);
expect(onStateChange).toHaveBeenLastCalledWith("reconnecting");
});
it("does not force-close when pongs keep arriving", async () => {
mockConstants("/ws");
const { WebSocketConnection } = await import("@/lib/websocket/connection");
const conn = new WebSocketConnection({
url: "ws://test/ws",
onStateChange: vi.fn(),
heartbeatInterval: 30000,
});
conn.connect();
const ws = MockWebSocket.last();
ws.fireOpen();
// Each heartbeat tick: send ping, then a pong comes back well within the
// 60s watchdog window. Advance 3 ticks; socket must stay open.
for (let i = 0; i < 3; i++) {
vi.advanceTimersByTime(29000);
ws.fireMessage("pong");
}
expect(ws.closed).toBe(false);
});
});
describe("WebSocketConnection — long-tail reconnect (C4)", () => {
beforeEach(() => {
MockWebSocket.instances = [];
vi.useFakeTimers();
vi.stubGlobal("WebSocket", MockWebSocket);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("keeps reconnecting past the old 3-attempt cap (no terminal 'disconnected')", async () => {
mockConstants("/ws");
const { WebSocketConnection } = await import("@/lib/websocket/connection");
const onStateChange = vi.fn();
const conn = new WebSocketConnection({
url: "ws://test/ws",
onStateChange,
reconnectInterval: 5000,
});
conn.connect();
// Drive 5 close→reconnect cycles WITHOUT ever firing onopen. The old code
// reset reconnectAttempts to 0 inside onopen, so tests that fired open
// between closes never accumulated attempts and passed under the pre-fix
// `attempts < maxReconnectAttempts` gate. Here attempts accumulates, so
// past attempt 3 the old gate would have flipped shouldReconnect false →
// terminal 'disconnected' and no new socket. The fixed code has no cap.
for (let i = 0; i < 5; i++) {
const before = MockWebSocket.instances.length;
const ws = MockWebSocket.last();
actClose(ws, 1006);
// State must be 'reconnecting', never the terminal 'disconnected'.
expect(conn.getState()).toBe("reconnecting");
// Advance past the backoff so connect() runs and a fresh socket is
// constructed. Delay grows each cycle but stays ≤ 30s (cap tested below);
// 30s covers cycles 0-4 (5000*1.5^4 = 25312 < 30000).
vi.advanceTimersByTime(30000);
expect(MockWebSocket.instances.length).toBe(before + 1);
// Intentionally do NOT fire open — attempts must keep accumulating.
}
// A 6th reconnect is still scheduled — never gave up.
expect(conn.getState()).not.toBe("disconnected");
});
it("caps the backoff delay at WS_RECONNECT_MAX_INTERVAL", async () => {
mockConstants("/ws");
const { WebSocketConnection } = await import("@/lib/websocket/connection");
const conn = new WebSocketConnection({
url: "ws://test/ws",
onStateChange: vi.fn(),
reconnectInterval: 5000,
});
conn.connect();
// Close→reconnect without ever firing onopen so reconnectAttempts climbs
// past the point where the uncapped delay 5000*1.5^N vastly exceeds 30s.
// After 7 cycles attempts=7 → uncapped delay ≈ 85422ms ≫ 30000ms cap.
for (let i = 0; i < 7; i++) {
const ws = MockWebSocket.last();
actClose(ws, 1006);
// Delay is capped at 30s, so advancing 30s always fires the reconnect.
vi.advanceTimersByTime(30000);
expect(MockWebSocket.instances.length).toBe(i + 2);
}
// Now at attempt 7: uncapped delay would be ~85s. Under the old uncapped
// code, advancing 30s would leave the timer unexpired → no new socket.
// Under the capped code, delay = min(85422, 30000) = 30000 → reconnect
// fires within 30s and a new socket is constructed.
const before = MockWebSocket.instances.length;
const ws = MockWebSocket.last();
actClose(ws, 1006);
vi.advanceTimersByTime(30000);
expect(MockWebSocket.instances.length).toBe(before + 1);
});
});
function actClose(ws: MockWebSocket, code: number) {
ws.close(code, "");
}
+1
View File
@@ -90,6 +90,7 @@ export const projectsApi = {
quality_command: project.quality_command ?? null,
ci_watch_enabled: false,
ci_watch_workflow: null,
video_engine_enabled: false,
dep_update_command: null,
dep_update_paths: null,
sandbox_services: null,
+2 -1
View File
@@ -19,8 +19,9 @@ export const MAX_PAGE_SIZE = 100;
// WebSocket settings
export const WS_RECONNECT_INTERVAL = 5000; // Start at 5s, exponential backoff from there
export const WS_MAX_RECONNECT_ATTEMPTS = 3; // Give up after 3 attempts
export const WS_RECONNECT_MAX_INTERVAL = 30000; // Cap the backoff — never give up
export const WS_HEARTBEAT_INTERVAL = 30000;
export const WS_PONG_TIMEOUT_MS = 60000; // 2× heartbeat; force-close if no pong
// UI settings
export const STREAM_MAX_MESSAGES = 100;
+33 -15
View File
@@ -8,8 +8,9 @@
import {
WS_URL,
WS_RECONNECT_INTERVAL,
WS_MAX_RECONNECT_ATTEMPTS,
WS_RECONNECT_MAX_INTERVAL,
WS_HEARTBEAT_INTERVAL,
WS_PONG_TIMEOUT_MS,
} from "@/lib/constants";
export type MessageHandler = (data: unknown) => void;
@@ -24,8 +25,8 @@ export interface WebSocketOptions {
onMessage?: MessageHandler;
onStateChange?: (state: ConnectionState) => void;
reconnectInterval?: number;
maxReconnectAttempts?: number;
heartbeatInterval?: number;
pongTimeout?: number;
}
export class WebSocketConnection {
@@ -34,22 +35,22 @@ export class WebSocketConnection {
private onMessage?: MessageHandler;
private onStateChange?: (state: ConnectionState) => void;
private reconnectInterval: number;
private maxReconnectAttempts: number;
private heartbeatInterval: number;
private pongTimeout: number;
private reconnectAttempts = 0;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private heartbeatTimeout: ReturnType<typeof setInterval> | null = null;
private state: ConnectionState = "disconnected";
private manualClose = false;
private lastPongAt = 0;
constructor(options: WebSocketOptions) {
this.url = options.url;
this.onMessage = options.onMessage;
this.onStateChange = options.onStateChange;
this.reconnectInterval = options.reconnectInterval || WS_RECONNECT_INTERVAL;
this.maxReconnectAttempts =
options.maxReconnectAttempts || WS_MAX_RECONNECT_ATTEMPTS;
this.heartbeatInterval = options.heartbeatInterval || WS_HEARTBEAT_INTERVAL;
this.pongTimeout = options.pongTimeout || WS_PONG_TIMEOUT_MS;
}
private setState(state: ConnectionState): void {
@@ -63,6 +64,7 @@ export class WebSocketConnection {
}
this.manualClose = false;
this.lastPongAt = Date.now();
this.setState("connecting");
try {
@@ -76,8 +78,9 @@ export class WebSocketConnection {
this.ws.onmessage = (event) => {
try {
// Handle pong responses
// pong frames refresh the watchdog; data frames dispatch to onMessage
if (event.data === "pong") {
this.lastPongAt = Date.now();
return;
}
@@ -91,11 +94,10 @@ export class WebSocketConnection {
this.ws.onclose = (event) => {
this.stopHeartbeat();
// Don't reconnect if manually closed or max attempts reached
// Also stop if we're getting resource errors (code 1006 with no clean close)
// Reconnect forever unless the close was manual or a hard server-side
// error (policy violation / server crash) — those won't recover by retry.
const shouldReconnect =
!this.manualClose &&
this.reconnectAttempts < this.maxReconnectAttempts &&
event.code !== 1008 && // Policy violation
event.code !== 1011; // Server error
@@ -108,10 +110,9 @@ export class WebSocketConnection {
};
this.ws.onerror = () => {
// WebSocket errors are expected when backend is offline
// Don't log - the onclose handler will manage reconnection
// Do NOT increment reconnectAttempts here; scheduleReconnect() is the
// sole place that advances the counter to avoid double-counting.
// WebSocket errors are expected when backend is offline; onclose
// manages reconnection. Do NOT advance reconnectAttempts here —
// scheduleReconnect() is the sole place that advances it.
};
} catch {
// Connection failed - backend likely offline
@@ -143,9 +144,25 @@ export class WebSocketConnection {
return this.state;
}
getLastPongAt(): number {
return this.lastPongAt;
}
// Watchdog: force-close the socket if no pong has arrived within the timeout
// window. The onclose handler then routes through the normal reconnect path.
// Exposed publicly so the heartbeat tick (and tests) can call it directly.
checkPong(): void {
if (this.ws && Date.now() - this.lastPongAt >= this.pongTimeout) {
this.ws.close();
}
}
private startHeartbeat(): void {
this.stopHeartbeat();
this.heartbeatTimeout = setInterval(() => {
// Watchdog first: if the server stopped responding to pings, force-close
// so onclose fires the reconnect path instead of pinging into the void.
this.checkPong();
this.send("ping");
}, this.heartbeatInterval);
}
@@ -160,8 +177,9 @@ export class WebSocketConnection {
private scheduleReconnect(): void {
this.clearReconnectTimeout();
const delay =
this.reconnectInterval * Math.pow(1.5, this.reconnectAttempts);
const raw = this.reconnectInterval * Math.pow(1.5, this.reconnectAttempts);
// ponytail: delay capped at WS_RECONNECT_MAX_INTERVAL; counter grows but delay is bounded.
const delay = Math.min(raw, WS_RECONNECT_MAX_INTERVAL);
this.reconnectAttempts++;
this.reconnectTimeout = setTimeout(() => {
+18 -5
View File
@@ -17,7 +17,10 @@ const SESSION_COOKIE_NAME = "roboco_session";
// starts on), not a stuck redirect.
const STATUS_PROBE_TIMEOUT_MS = 1500;
async function isCloudAuthEnabled(): Promise<boolean> {
const PROBE_TTL_MS = 30_000;
let lastKnown: { value: boolean; at: number } | null = null;
export async function isCloudAuthEnabled(): Promise<boolean> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), STATUS_PROBE_TIMEOUT_MS);
@@ -26,18 +29,28 @@ async function isCloudAuthEnabled(): Promise<boolean> {
cache: "no-store",
});
clearTimeout(timer);
if (!res.ok) return false;
const data = (await res.json()) as { cloud_auth_enabled?: boolean };
return data.cloud_auth_enabled === true;
if (res.ok) {
const data = (await res.json()) as { cloud_auth_enabled?: boolean };
const value = data.cloud_auth_enabled === true;
lastKnown = { value, at: Date.now() };
return value;
}
// Non-ok response: fall back to a fresh cache rather than fail open.
} catch {
return false;
// Network/timeout: fall back to a fresh cache rather than fail open.
}
if (lastKnown && Date.now() - lastKnown.at < PROBE_TTL_MS) {
return lastKnown.value;
}
// No fresh cache: the safe default is "off" (what every deploy starts on).
return false;
}
export async function proxy(request: NextRequest) {
if (!(await isCloudAuthEnabled())) {
return NextResponse.next();
}
// UX redirect only — shields dashboard chrome from flashing before login. The API (/api/*) enforces auth independently; a stale cookie shows chrome then 401s on the first API call.
if (!request.cookies.has(SESSION_COOKIE_NAME)) {
return NextResponse.redirect(new URL("/login", request.url));
}
@@ -166,8 +166,10 @@ describe("useRateLimitStore — liftRateLimit", () => {
// ---------------------------------------------------------------------------
describe("useRateLimitStore — syncFromApi", () => {
it("replaces the entire limits map with response entries", () => {
// Pre-populate with one entry
// M44: syncFromApi merges by freshest hitAt instead of wholesale-replacing,
// so an out-of-order (older) API snapshot can't regress a fresher WS hit.
it("merges API entries into the map, keeping existing entries not in the response", () => {
// Pre-populate with a WS hit on a provider the API snapshot omits.
useRateLimitStore.getState().hitRateLimit(makeHitEvent("old-provider", 60));
const response: RateLimitApiResponse = {
@@ -176,12 +178,12 @@ describe("useRateLimitStore — syncFromApi", () => {
useRateLimitStore.getState().syncFromApi(response);
const limits = useRateLimitStore.getState().limits;
// Old entry is gone
expect(limits.has("old-provider")).toBe(false);
// New entries are present
// Pre-existing entry not in the response is retained (merge, not replace).
expect(limits.has("old-provider")).toBe(true);
// New API entries are present.
expect(limits.has("anthropic")).toBe(true);
expect(limits.has("openai")).toBe(true);
expect(limits.size).toBe(2);
expect(limits.size).toBe(3);
});
it("correctly keys entries by provider name", () => {
@@ -191,9 +193,55 @@ describe("useRateLimitStore — syncFromApi", () => {
expect(stored).toEqual(entry);
});
it("clears all limits when given an empty entries array", () => {
it("keeps existing limits when the API returns an empty entries array", () => {
// An empty snapshot is a no-op merge, not a clear — a stale/empty poll
// must not wipe a fresher WS-derived map.
useRateLimitStore.getState().hitRateLimit(makeHitEvent("anthropic", 60));
useRateLimitStore.getState().syncFromApi({ entries: [] });
expect(useRateLimitStore.getState().limits.size).toBe(0);
expect(useRateLimitStore.getState().limits.size).toBe(1);
expect(useRateLimitStore.getState().limits.has("anthropic")).toBe(true);
});
it("keeps the fresher hitAt when an out-of-order (older) API snapshot arrives", () => {
// A recent WS HIT stamps anthropic at T2.
const T2 = "2026-06-20T12:05:00.000Z";
useRateLimitStore
.getState()
.hitRateLimit(makeHitEvent("anthropic", 60, ["be-dev-1"]));
// Manually advance hitAt to T2 to simulate a fresher WS event.
const fresher = useRateLimitStore.getState().limits.get("anthropic")!;
useRateLimitStore.setState({
limits: new Map([["anthropic", { ...fresher, hitAt: T2 }]]),
});
// An API snapshot arrives carrying an older T1 for anthropic.
const T1 = "2026-06-20T12:00:00.000Z";
useRateLimitStore
.getState()
.syncFromApi({ entries: [makeApiEntry("anthropic", T1)] });
const stored = useRateLimitStore.getState().limits.get("anthropic");
expect(stored?.hitAt).toBe(T2);
// A brand-new provider in the same snapshot is still added.
useRateLimitStore.getState().syncFromApi({
entries: [makeApiEntry("anthropic", T1), makeApiEntry("openai", T1)],
});
expect(useRateLimitStore.getState().limits.has("openai")).toBe(true);
expect(useRateLimitStore.getState().limits.get("anthropic")?.hitAt).toBe(
T2,
);
});
it("overwrites a stale local entry when the API snapshot is fresher", () => {
const T2 = "2026-06-20T12:05:00.000Z";
// Local entry at TIMESTAMP, API arrives with T2 — API wins.
useRateLimitStore.getState().hitRateLimit(makeHitEvent("anthropic", 60));
useRateLimitStore
.getState()
.syncFromApi({ entries: [makeApiEntry("anthropic", T2)] });
expect(useRateLimitStore.getState().limits.get("anthropic")?.hitAt).toBe(
T2,
);
});
});
+14 -2
View File
@@ -43,9 +43,21 @@ export const useRateLimitStore = create<RateLimitState>((set) => ({
}),
syncFromApi: (response: RateLimitApiResponse) =>
set(() => {
const next = new Map<string, RateLimitEntry>();
set((state) => {
// Merge by freshest hitAt: an out-of-order (older) API snapshot must
// not regress a fresher WS-derived entry. Entries omitted from the
// snapshot are retained (a stale/empty poll doesn't wipe live state).
const next = new Map(state.limits);
for (const entry of response.entries) {
const existing = next.get(entry.provider);
if (
existing &&
existing.hitAt &&
entry.hitAt &&
existing.hitAt >= entry.hitAt
) {
continue;
}
next.set(entry.provider, entry);
}
return { limits: next };
+2
View File
@@ -1016,6 +1016,7 @@ export interface Project {
// Autonomous maintenance opt-in
ci_watch_enabled: boolean;
ci_watch_workflow: string | null;
video_engine_enabled: boolean;
dep_update_command: string | null;
dep_update_paths: string[] | null;
sandbox_services: string[] | null;
@@ -1064,6 +1065,7 @@ export interface ProjectUpdate {
// Autonomous maintenance opt-in
ci_watch_enabled?: boolean;
ci_watch_workflow?: string;
video_engine_enabled?: boolean;
dep_update_command?: string;
dep_update_paths?: string[];
sandbox_services?: string[];