feat(lens): always-on investigations with findings and investigations tables (#44418)

* feat(lens): record run steps, trigger and exact run windows on investigations

* feat(lens): scan only traces since the last run and keep a capped step log

* feat(lens): log each analysis model call with its model, tokens and cost

* feat(lens): accept agent and time window on run now and add turn-all-on

* test(lens): cover new-traces-only windows, manual runs and the step cap

* test(lens): cover run now overrides and turning paused investigations on

* chore(ui): regenerate api types for lens run steps and run now options

* feat(lens): group open findings into one row per problem with agent filters

* test(lens): cover the findings table grouping, filters and schedule labels

* feat(lens): add a findings table across all investigations

* feat(lens): show a live step feed with the model behind each call

* feat(lens): offer turning all paused investigations on

* feat(lens): let run now pick an agent and time window

* test(lens): cover run now request building

* feat(lens): show the step feed and run now dialog on an investigation

* feat(lens): open run now choices instead of running immediately

* feat(lens): open findings first and peek a finding without leaving the table

* feat(lens): fold investigation actions into the findings toolbar

* feat(lens): show each investigation's schedule and open findings

* feat(lens): name the agent on a finding

* feat(lens): keep new investigations watching every 15 minutes by default

* feat(lens): show the watch schedule outside advanced options

* feat(lens): send run now options and turn-all-on from the dashboard

* test(lens): give demo runs steps and a trigger

* feat(lens): let the findings table fill the screen

* test(lens): add steps and trigger to progress fixtures

* test(lens): add steps and trigger to status fixtures

* test(lens): cover the default watch schedule in setup

* test(lens): cover run now choices from an investigation

* test(lens): open saved investigations from the manage view

* feat(lens): use one tab bar for traces, findings and investigations

* feat(lens): place page actions on the lens tab row

* feat(lens): drop the nested tabs and edit investigations in place

* feat(lens): show investigations as a table with run now and edit

* feat(lens): name each findings row for screen readers

* test(lens): open saved investigation links on findings

* test(lens): reach findings and investigations from the top tabs

* fix(lens): mark run now jobs manual and keep them from moving the scheduled scan

* fix(lens): keep run now since-last-run windows even with an agent override

* test(lens): cover that manual runs never skip scheduled traces

* test(lens): cover run now windows with agent and lookback overrides

* fix(lens): group findings without Map.groupBy and expose sampled runs

* fix(lens): open older findings and review every merged copy from one row

* fix(lens): hide edit and run now from read-only viewers

* chore(lens): drop restating comments from the findings table

* chore(lens): drop restating comments from the step feed

* chore(lens): drop restating comments from the paused banner

* chore(lens): drop restating comments from header actions

* chore(lens): drop restating comments from run now

* test(lens): cover merged findings and read-only investigation rows

* fix(lens): record a model step even when the response has no usage

* test(lens): cover model steps with and without reported usage

* fix(lens): keep a merged finding open when one of its updates fails

* refactor(lens): accept update results from the investigations view

* refactor(lens): accept update results in investigation actions

* test(lens): cover retrying a merged finding after a failed update
This commit is contained in:
ishaan-berri 2026-10-03 13:27:02 -07:00 • committed by GitHub
parent f445e466b4
commit dd31692282
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1703 additions and 163 deletions

View file

@ -37,18 +37,24 @@ from litellm.proxy.lens.models import (
RunRequest,
Sample,
Scope,
Step,
WatchAllResult,
WatchSkipped,
Worker,
WorkerCreated,
)
from litellm.proxy.lens.repository import LensRepository, WriterDatabase
from litellm.proxy.lens.sources import ActivityAvailability, SourceReader, Storage, parse_execution
from litellm.proxy.lens.state import (
add_step,
can_access,
claim_job,
current_job,
merge_finding,
next_scan_start,
queue_job,
replace_job,
scheduled_window,
snapshot_finding,
)
from litellm.proxy.tracing_runtime import provide_storage
@ -227,6 +233,40 @@ async def list_agents(auth: Auth, storage: StorageDep) -> tuple[str, ...]:
return await source_reader(storage).agents(scope) if storage is not None else ()
def watching(lens: Lens) -> Lens:
if lens.settings.enabled:
return lens
return lens.model_copy(
update=MappingProxyType(
{
"settings": lens.settings.model_copy(update=MappingProxyType({"enabled": True})),
"revision": lens.revision + 1,
}
)
)
async def watchable(lens: Lens, auth: UserAPIKeyAuth) -> WatchSkipped | None:
try:
await validate_model(lens.settings.model_copy(update=MappingProxyType({"enabled": True})), auth)
except HTTPException as exc:
return WatchSkipped(id=lens.id, name=lens.settings.name, reason=str(exc.detail))
return None
@router.post("/watch-all", response_model=WatchAllResult)
async def watch_all(auth: Auth) -> WatchAllResult:
scope: Final = user_scope(auth, write=True)
paused: Final = tuple(
e for e in await repository().lenses() if can_access(scope, e.scope) and not e.settings.enabled
)
checks: Final = tuple([(lens, await watchable(lens, auth)) for lens in paused])
skipped: Final = tuple(skip for _, skip in checks if skip is not None)
ready: Final = tuple(lens for lens, skip in checks if skip is None)
updated: Final = tuple([await repository().update(lens.id, watching) for lens in ready])
return WatchAllResult(watching=tuple(u.id for u in updated if u is not None), skipped=skipped)
@router.put("/{lens_id}", response_model=Lens)
async def update_lens(lens_id: str, settings: LensSettings, auth: Auth) -> Lens:
lens: Final = await get_lens(lens_id, user_scope(auth, write=True))
@ -248,6 +288,20 @@ async def update_lens(lens_id: str, settings: LensSettings, auth: Auth) -> Lens:
)
def run_window(lens: Lens, body: RunRequest, now: datetime) -> tuple[datetime, datetime] | None:
if body.start is not None and body.end is not None:
return body.start, body.end
if body.lookback_hours is None and body.settings is None:
return scheduled_window(lens, now)
return None
def run_settings(lens: Lens, body: RunRequest) -> LensSettings | None:
if body.agent_name is None:
return body.settings
return (body.settings or lens.settings).model_copy(update=MappingProxyType({"agent_name": body.agent_name}))
@router.post("/{lens_id}/runs", response_model=Lens)
async def run_lens(lens_id: str, body: RunRequest, auth: Auth) -> Lens:
lens: Final = await get_lens(lens_id, user_scope(auth, write=True))
@ -257,7 +311,18 @@ async def run_lens(lens_id: str, body: RunRequest, auth: Auth) -> Lens:
now: Final = datetime.now(timezone.utc)
job_id: Final = str(uuid4())
return required(
await repository().update(lens_id, lambda e: queue_job(e, now, job_id, body.lookback_hours, body.settings))
await repository().update(
lens_id,
lambda e: queue_job(
e,
now,
job_id,
body.lookback_hours,
run_settings(e, body),
run_window(e, body, now),
"manual",
),
)
)
@ -425,13 +490,14 @@ async def progress(lens_id: str, job_id: str, body: Progress, worker: WorkerAuth
job: Final = current_job(e)
if job is None or job.id != job_id or job.worker_id != worker.id:
return e
renewed: Final = job.model_copy(
update=MappingProxyType(
{"stage": body.stage, "coverage": body.coverage, "lease_until": now + timedelta(minutes=5)}
)
)
return replace_job(
e,
job.model_copy(
update=MappingProxyType(
{"stage": body.stage, "coverage": body.coverage, "lease_until": now + timedelta(minutes=5)}
)
),
renewed if body.stage == job.stage else add_step(renewed, Step(at=now, kind="stage", label=body.stage)),
)
required(await repository().update(lens_id, renew))
@ -575,7 +641,7 @@ async def result(lens_id: str, job_id: str, body: Result, worker: WorkerAuth, st
update=MappingProxyType(
{
"findings": (*merged, *(f for f in e.findings if f.id not in merged_ids)),
"last_scan_at": e.last_scan_at if body.error else max(e.last_scan_at or job.end, job.end),
"last_scan_at": next_scan_start(e, job, failed=bool(body.error)),
"next_run_at": now + timedelta(minutes=e.settings.interval_minutes),
}
)

View file

@ -13,9 +13,9 @@ from litellm.integrations.clickhouse.context import lens_analysis
from litellm.litellm_core_utils.initialize_dynamic_callback_params import inherit_message_logging_privacy
from litellm.litellm_core_utils.token_counter import get_modified_max_tokens
from litellm.proxy.lens.billing import complete, validate_key
from litellm.proxy.lens.models import Job, Lens, ModelRequest, ModelResult, Worker
from litellm.proxy.lens.models import Job, Lens, ModelRequest, ModelResult, Step, Worker
from litellm.proxy.lens.repository import LensRepository
from litellm.proxy.lens.state import current_job, renew_budget, replace_job
from litellm.proxy.lens.state import add_step, current_job, renew_budget, replace_job
from litellm.types.utils import CostPerToken, ModelResponse
@ -199,12 +199,14 @@ async def analyze(
if e.budget_month == now.strftime("%Y-%m")
else e
)
return (
replace_job(
adjusted, charged.model_copy(update=MappingProxyType({"cost": max(0, charged.cost - estimate + cost)}))
)
if charged
else adjusted
if charged is None:
return adjusted
return replace_job(
adjusted,
add_step(
charged.model_copy(update=MappingProxyType({"cost": max(0, charged.cost - estimate + cost)})),
model_step(response, body, job.settings.model, cost),
),
)
@asynccontextmanager
@ -253,6 +255,37 @@ async def analyze(
)
class Usage(BaseModel):
model_config = ConfigDict(extra="ignore")
prompt_tokens: int | None = None
completion_tokens: int | None = None
class UsageEnvelope(BaseModel):
model_config = ConfigDict(extra="ignore")
model: str | None = None
usage: Usage | None = None
_PURPOSE_LABELS: Final = MappingProxyType(
{"extract": "Reviewed a run", "cluster": "Compared observations", "investigate": "Checked a pattern"}
)
def model_step(response: ModelResponse, body: ModelRequest, requested: str, cost: float) -> Step:
envelope: Final = UsageEnvelope.model_validate_json(response.model_dump_json())
return Step(
at=datetime.now(timezone.utc),
kind="model",
label=_PURPOSE_LABELS[body.purpose],
model=envelope.model or requested,
purpose=body.purpose,
prompt_tokens=(envelope.usage.prompt_tokens if envelope.usage else None) or 0,
completion_tokens=(envelope.usage.completion_tokens if envelope.usage else None) or 0,
cost=cost,
)
def completion_charge(deployments: tuple[Deployment, ...], response: ModelResponse, estimate: float) -> float:
custom: Final = deployments[0].litellm_params if len(deployments) == 1 else None
if custom and custom.input_cost_per_token is not None and custom.output_cost_per_token is not None:

View file

@ -190,6 +190,20 @@ class RunAssessment(Record):
cannot_assess: bool = False
MAX_STEPS = 200
class Step(Record):
at: datetime
kind: Literal["stage", "model", "error"]
label: str = Field(max_length=200)
model: str = Field(default="", max_length=200)
purpose: str = Field(default="", max_length=40)
prompt_tokens: int = 0
completion_tokens: int = 0
cost: float = 0
class Job(Record):
id: str
status: Literal["queued", "running", "completed", "failed", "cancelled"] = "queued"
@ -209,6 +223,8 @@ class Job(Record):
cost: float = 0
findings: tuple[Finding, ...] | None = None
assessments: tuple[RunAssessment, ...] = ()
steps: tuple[Step, ...] = ()
trigger: Literal["schedule", "manual"] = "schedule"
class Lens(Record):
@ -249,6 +265,28 @@ class LensList(Record):
class RunRequest(Record):
settings: LensSettings | None = None
lookback_hours: LookbackHours | None = None
start: datetime | None = None
end: datetime | None = None
agent_name: str | None = Field(default=None, max_length=200)
@model_validator(mode="after")
def ordered_window(self) -> "RunRequest":
if (self.start is None) != (self.end is None):
raise ValueError("Choose both a start and an end time")
if self.start is not None and self.end is not None and self.start >= self.end:
raise ValueError("Start time must be before end time")
return self
class WatchSkipped(Record):
id: str
name: str
reason: str
class WatchAllResult(Record):
watching: tuple[str, ...]
skipped: tuple[WatchSkipped, ...] = ()
class FindingUpdate(Record):

View file

@ -1,9 +1,19 @@
import hashlib
from datetime import datetime, timedelta
from types import MappingProxyType
from typing import Final
from typing import Final, Literal
from litellm.proxy.lens.models import Finding, FindingDraft, Job, Lens, LensSettings, Scope, Worker
from litellm.proxy.lens.models import (
MAX_STEPS,
Finding,
FindingDraft,
Job,
Lens,
LensSettings,
Scope,
Step,
Worker,
)
def can_access(viewer: Scope, target: Scope) -> bool:
@ -24,27 +34,54 @@ def replace_job(lens: Lens, job: Job) -> Lens:
)
SETTLE_DELAY = timedelta(minutes=2)
def scheduled_window(lens: Lens, now: datetime) -> tuple[datetime, datetime]:
end: Final = now - SETTLE_DELAY
floor: Final = now - timedelta(hours=lens.settings.lookback_hours)
start: Final = max(lens.last_scan_at, floor) if lens.last_scan_at else floor
return min(start, end), end
def next_scan_start(lens: Lens, job: Job, failed: bool) -> datetime | None:
if failed or job.trigger == "manual":
return lens.last_scan_at
return max(lens.last_scan_at or job.end, job.end)
def queue_job(
lens: Lens,
now: datetime,
job_id: str,
lookback_hours: int | None = None,
settings: LensSettings | None = None,
window: tuple[datetime, datetime] | None = None,
trigger: Literal["schedule", "manual"] = "schedule",
) -> Lens:
if current_job(lens):
return lens
selected: Final = settings or lens.settings
hours: Final = lookback_hours if lookback_hours is not None else (selected.lookback_hours if settings else None)
start, end = window or (
(now - timedelta(hours=hours), now - SETTLE_DELAY) if hours is not None else scheduled_window(lens, now)
)
job: Final = Job(
id=job_id,
created_at=now,
start=now - timedelta(hours=lookback_hours if lookback_hours is not None else selected.lookback_hours),
end=now - timedelta(minutes=2),
start=start,
end=end,
settings=selected,
revision=lens.revision,
trigger=trigger,
)
return lens.model_copy(update=MappingProxyType({"jobs": (job,)}))
def add_step(job: Job, step: Step) -> Job:
return job.model_copy(update=MappingProxyType({"steps": (*job.steps, step)[-MAX_STEPS:]}))
def claim_job(lens: Lens, worker: Worker, now: datetime) -> Lens:
job: Final = current_job(lens)
if job is None or not can_access(worker.scope, lens.scope):

View file

@ -1,13 +1,24 @@
from datetime import datetime, timedelta, timezone
from typing import Final
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
import litellm
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm import Router
from litellm.proxy.lens.endpoints import list_agents, user_scope, validate_model, worker_supports_model
from litellm.proxy.lens.models import LensSettings
from litellm.proxy.lens.endpoints import (
list_agents,
run_settings,
run_window,
user_scope,
watchable,
watching,
validate_model,
worker_supports_model,
)
from litellm.proxy.lens.models import Lens, LensSettings, RunRequest, Scope
@pytest.fixture
@ -167,6 +178,79 @@ def test_regular_keys_cannot_read_lens_results(role: LitellmUserRoles | None) ->
assert error.value.status_code == 403
def saved_lens() -> Lens:
now: Final = datetime(2026, 1, 15, tzinfo=timezone.utc)
return Lens(
id="lens",
scope=Scope(all_teams=True),
settings=LensSettings(name="Support", model="analysis", context="Answer questions", agent_name="support"),
created_at=now,
next_run_at=now,
budget_month="2026-01",
)
def test_run_now_agent_override_only_changes_the_agent_for_that_run() -> None:
lens: Final = saved_lens()
overridden: Final = run_settings(lens, RunRequest(agent_name="billing"))
assert overridden is not None
assert overridden.agent_name == "billing"
assert overridden.model_copy(update={"agent_name": "support"}) == lens.settings
def test_run_now_without_overrides_keeps_the_saved_settings() -> None:
assert run_settings(saved_lens(), RunRequest()) is None
def test_run_now_rejects_a_window_that_is_missing_an_edge_or_backwards() -> None:
now: Final = datetime(2026, 1, 15, tzinfo=timezone.utc)
with pytest.raises(ValidationError, match="both a start and an end"):
RunRequest(start=now)
with pytest.raises(ValidationError, match="before end"):
RunRequest(start=now, end=now - timedelta(hours=1))
def test_watching_switches_a_paused_investigation_on_and_records_the_change() -> None:
paused: Final = saved_lens().model_copy(
update={"settings": saved_lens().settings.model_copy(update={"enabled": False})}
)
watched: Final = watching(paused)
assert watched.settings.enabled is True
assert watched.revision == paused.revision + 1
assert watched.settings.model_copy(update={"enabled": False}) == paused.settings
def test_watching_leaves_an_investigation_that_is_already_on_untouched() -> None:
on: Final = saved_lens()
assert watching(on) is on
async def test_watch_all_skips_an_investigation_whose_model_is_gone_instead_of_failing_them_all(
analysis_router: Router,
) -> None:
stale: Final = saved_lens().model_copy(
update={"settings": saved_lens().settings.model_copy(update={"model": "retired-model", "enabled": False})}
)
skipped: Final = await watchable(stale, UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN))
assert skipped is not None
assert skipped.id == stale.id
assert skipped.reason
def test_run_now_since_last_run_keeps_scanning_only_new_traces_even_with_an_agent_override() -> None:
now: Final = datetime(2026, 1, 15, 12, tzinfo=timezone.utc)
resumed: Final = saved_lens().model_copy(update={"last_scan_at": now - timedelta(hours=1)})
window: Final = run_window(resumed, RunRequest(agent_name="billing"), now)
assert window is not None
assert window[0] == now - timedelta(hours=1)
def test_run_now_with_a_lookback_scans_that_lookback_instead_of_since_last_run() -> None:
now: Final = datetime(2026, 1, 15, 12, tzinfo=timezone.utc)
resumed: Final = saved_lens().model_copy(update={"last_scan_at": now - timedelta(hours=1)})
assert run_window(resumed, RunRequest(lookback_hours=24), now) is None
@pytest.mark.parametrize("provider", (False, True))
def test_model_errors_reach_worker_with_status_and_redacted_provider_message(provider: bool) -> None:
import httpx

View file

@ -4,7 +4,8 @@ import pytest
from fastapi import HTTPException
import litellm
from litellm.proxy.lens.inference import Deployment, DeploymentParams, completion_charge, quote
from litellm.proxy.lens.inference import Deployment, DeploymentParams, completion_charge, model_step, quote
from litellm.proxy.lens.models import ModelRequest
from litellm.types.utils import ModelResponse
@ -120,3 +121,17 @@ def test_unknown_model_capacity_requires_explicit_operator_metadata() -> None:
assert "model_info.max_output_tokens" in error.value.detail
configured: Final = Deployment(litellm_params=params, model_info=ModelCapacity(max_output_tokens=32000))
assert output_tokens(configured) == 32000
def test_a_model_step_records_the_serving_model_and_its_tokens() -> None:
response: Final = ModelResponse(model="gpt-5.6", usage={"prompt_tokens": 1200, "completion_tokens": 80})
step: Final = model_step(response, ModelRequest(prompt="review", purpose="extract"), "analysis", 0.02)
assert (step.model, step.prompt_tokens, step.completion_tokens, step.cost) == ("gpt-5.6", 1200, 80, 0.02)
def test_a_response_without_usage_still_records_a_step_instead_of_failing_settlement() -> None:
response: Final = ModelResponse(model="gpt-5.6")
unpriced: Final = response.model_copy(update={"usage": None})
step: Final = model_step(unpriced, ModelRequest(prompt="review", purpose="cluster"), "analysis", 0.0)
assert (step.prompt_tokens, step.completion_tokens) == (0, 0)
assert step.label == "Compared observations"

View file

@ -1,9 +1,11 @@
from datetime import datetime, timedelta, timezone
from functools import reduce
from typing import Final
import pytest
from litellm.proxy.lens.models import (
MAX_STEPS,
AgentTestCase,
Check,
Evidence,
@ -12,9 +14,19 @@ from litellm.proxy.lens.models import (
Lens,
LensSettings,
Scope,
Step,
Worker,
)
from litellm.proxy.lens.state import can_access, claim_job, current_job, merge_finding, queue_job, renew_budget
from litellm.proxy.lens.state import (
add_step,
can_access,
claim_job,
current_job,
merge_finding,
next_scan_start,
queue_job,
renew_budget,
)
NOW: Final = datetime(2026, 1, 15, tzinfo=timezone.utc)
@ -163,15 +175,42 @@ def test_monthly_budget_renews_without_erasing_job_costs() -> None:
@pytest.mark.parametrize("hours", (24, 168, 720, 4800, 8760))
def test_every_scan_uses_the_configured_lookback_window(hours: int) -> None:
def test_first_scan_covers_the_configured_lookback_window(hours: int) -> None:
original: Final = lens()
configured: Final = original.model_copy(
update={"settings": LensSettings.model_validate({**original.settings.model_dump(), "lookback_hours": hours})}
)
first: Final = queue_job(configured, NOW, "first")
assert first.jobs[0].start == NOW - timedelta(hours=hours)
resumed: Final = configured.model_copy(update={"last_scan_at": NOW - timedelta(hours=1)})
assert queue_job(resumed, NOW, "next").jobs[0].start == NOW - timedelta(hours=hours)
assert first.jobs[0].trigger == "schedule"
def test_later_scheduled_scans_only_cover_traces_since_the_last_scan() -> None:
resumed: Final = lens().model_copy(update={"last_scan_at": NOW - timedelta(hours=1)})
job: Final = queue_job(resumed, NOW, "next").jobs[0]
assert job.start == NOW - timedelta(hours=1)
assert job.end == NOW - timedelta(minutes=2)
def test_a_scan_after_a_long_outage_never_reaches_past_the_lookback_window() -> None:
stale: Final = lens().model_copy(update={"last_scan_at": NOW - timedelta(days=400)})
assert queue_job(stale, NOW, "next").jobs[0].start == NOW - timedelta(hours=stale.settings.lookback_hours)
def test_run_now_with_an_exact_window_scans_that_window_and_is_marked_manual() -> None:
window: Final = (NOW - timedelta(hours=5), NOW - timedelta(hours=3))
job: Final = queue_job(lens(), NOW, "manual", window=window, trigger="manual").jobs[0]
assert (job.start, job.end) == window
assert job.trigger == "manual"
def test_steps_keep_only_the_most_recent_entries() -> None:
job: Final = queue_job(lens(), NOW, "job").jobs[0]
steps: Final = tuple(Step(at=NOW, kind="stage", label=f"step {i}") for i in range(MAX_STEPS + 5))
grown: Final = reduce(add_step, steps, job)
assert len(grown.steps) == MAX_STEPS
assert grown.steps[0].label == "step 5"
assert grown.steps[-1].label == f"step {MAX_STEPS + 4}"
def test_finding_keeps_uncertainty_separate_from_the_main_summary() -> None:
@ -287,6 +326,17 @@ def test_legacy_finding_identity_preserves_feedback_only_for_same_kind_and_check
assert separate.status == "open" and separate.reason == ""
def test_only_successful_scheduled_scans_move_the_next_scan_forward() -> None:
previous: Final = lens().model_copy(update={"last_scan_at": NOW - timedelta(hours=3)})
scheduled: Final = queue_job(previous, NOW, "scheduled").jobs[0]
manual: Final = queue_job(
previous, NOW, "manual", window=(NOW - timedelta(hours=2), NOW - timedelta(hours=1)), trigger="manual"
).jobs[0]
assert next_scan_start(previous, scheduled, failed=False) == scheduled.end
assert next_scan_start(previous, scheduled, failed=True) == previous.last_scan_at
assert next_scan_start(previous, manual, failed=False) == previous.last_scan_at
@pytest.mark.parametrize("field", ("lookback_hours", "interval_minutes"))
def test_calendar_overflow_is_rejected_without_the_old_history_and_interval_caps(field: str) -> None:
from pydantic import ValidationError

View file

@ -32,9 +32,9 @@ describe("Lens navigation", () => {
expect(screen.getByText("Trace polling active")).toBeVisible();
});
it("opens existing lens links directly in investigations", () => {
it("opens existing lens links on findings", () => {
renderWithProviders(<LensPage />, { searchParams: "?lens=saved-lens" });
expect(screen.getByRole("tab", { name: "Investigations" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tab", { name: "Findings" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("Manage investigations")).toBeVisible();
});

View file

@ -41,7 +41,7 @@ describe("Lens interactive demo", () => {
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Filter by status" }), "Failed");
expect(within(screen.getByRole("table", { name: "Agent runs" })).getAllByRole("row")).toHaveLength(4);
await user.click(screen.getByRole("tab", { name: "Investigations" }));
expect(await screen.findByRole("button", { name: /Support quality/ })).toBeVisible();
expect(await screen.findByRole("row", { name: /Support quality/ })).toBeVisible();
expect(screen.queryByRole("button", { name: "New investigation" })).not.toBeInTheDocument();
expect(network).not.toHaveBeenCalled();
expect(onUrlUpdate).not.toHaveBeenCalled();
@ -60,9 +60,9 @@ describe("Lens interactive demo", () => {
});
await screen.findByRole("button", { name: "Preview sample" });
await user.click(screen.getByRole("button", { name: "Preview sample" }));
await user.click(await screen.findByRole("button", { name: /Support quality/ }));
await user.click(await screen.findByRole("tab", { name: "Findings" }));
network.mockClear();
await user.click(screen.getByRole("button", { name: /Repeated lookups leave customers without an answer/ }));
await user.click(await screen.findByRole("row", { name: /Repeated lookups leave customers without an answer/ }));
const finding = screen.getByRole("dialog");
expect(within(finding).getByText(/The support agent retries/)).toBeVisible();
const summaries = within(finding).getAllByText("support_agent", { exact: true });
@ -80,9 +80,7 @@ describe("Lens interactive demo", () => {
within(screen.getByRole("dialog", { name: "Original run" })).getByRole("button", { name: "Close" }),
);
await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Close" }));
await user.click(screen.getByRole("tab", { name: "History" }));
await user.click(screen.getAllByRole("button", { name: /runs reviewed/ })[1]);
expect(await screen.findByText(/1 linked run · high priority/)).toBeVisible();
expect(await screen.findByRole("table", { name: "Findings" })).toBeVisible();
expect(network).not.toHaveBeenCalled();
expect(onUrlUpdate).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Exit demo" }));
@ -127,7 +125,7 @@ describe("Lens interactive demo", () => {
expect(await screen.findByRole("button", { name: "Preview sample" })).toBeVisible();
const tabs = within(screen.getByRole("tablist", { name: "Lens" }));
await user.click(tabs.getByRole("tab", { name: "Investigations" }));
expect(await screen.findByRole("button", { name: new RegExp(saved.settings.name) })).toBeVisible();
expect(await screen.findByRole("row", { name: new RegExp(saved.settings.name) })).toBeVisible();
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
await user.click(tabs.getByRole("tab", { name: "Traces" }));
await user.click(await screen.findByRole("button", { name: "Preview sample" }));

View file

@ -14,7 +14,7 @@ import { isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles";
import { InvestigationsView } from "./investigations/InvestigationsView";
import { createLensDemo } from "./demo/createLensDemo";
type Tab = "traces" | "investigations";
type Tab = "traces" | "findings" | "investigations";
type WorkspaceProps = { accessToken: string; userRole: string; readOnly: boolean };
export function LensWorkspace(props: WorkspaceProps) {
@ -59,12 +59,12 @@ function LensContent({
const demo = useLensDemo();
const [tab, setTab] = useQueryState(
"tab",
parseAsStringLiteral(["traces", "investigations"]).withOptions({ history: "push" }),
parseAsStringLiteral(["traces", "findings", "investigations"]).withOptions({ history: "push" }),
);
const [lensId] = useQueryState("lens", parseAsString);
const [demoTab, setDemoTab] = useState(initialTab);
const [previewTarget, setPreviewTarget] = useState<HTMLDivElement | null>(null);
const defaultTab = lensId ? "investigations" : "traces";
const defaultTab = lensId ? "findings" : "traces";
const activeTab = demo ? demoTab : tab ?? defaultTab;
const openDemo = onDemo ? () => onDemo(activeTab) : undefined;
return (
@ -86,6 +86,9 @@ function LensContent({
<TabsTrigger value="traces" className="px-3">
Traces
</TabsTrigger>
<TabsTrigger value="findings" className="px-3">
Findings
</TabsTrigger>
<TabsTrigger value="investigations" className="px-3">
Investigations
</TabsTrigger>
@ -102,19 +105,23 @@ function LensContent({
onDemo={activeTab === "traces" ? openDemo : undefined}
/>
</TabsContent>
<TabsContent value="investigations" keepMounted={!!demo}>
{demo || isProxyAdminTierRole(userRole) ? (
<InvestigationsView
accessToken={accessToken}
readOnly={readOnly || !isProxyAdminRole(userRole)}
onDemo={activeTab === "investigations" ? openDemo : undefined}
/>
) : (
<p className="py-6 text-sm text-muted-foreground">
Investigations require proxy administrator access. You can still view your traces.
</p>
)}
</TabsContent>
{(["findings", "investigations"] as const).map((view) => (
<TabsContent key={view} value={view} keepMounted={!!demo} className="flex min-h-0 flex-col">
{demo || isProxyAdminTierRole(userRole) ? (
<InvestigationsView
view={view}
active={activeTab === view}
accessToken={accessToken}
readOnly={readOnly || !isProxyAdminRole(userRole)}
onDemo={activeTab === view ? openDemo : undefined}
/>
) : (
<p className="py-6 text-sm text-muted-foreground">
Investigations require proxy administrator access. You can still view your traces.
</p>
)}
</TabsContent>
))}
</Tabs>
</main>
</LensPreviewTarget.Provider>

View file

@ -1,4 +1,5 @@
import { z } from "zod";
import type { RunWindow } from "../investigations/detail/RunNowDialog";
import type { ApiClient } from "@/lib/http/client";
import type { components } from "@/lib/http/schema";
import type { ActivitySelection, Job, Lens, LensList, Sample, Settings, WorkerCreated } from "../model/types";
@ -44,7 +45,8 @@ export interface LensApi {
keys(alias: string, page: number, signal: AbortSignal): Promise<KeyPage>;
keyInfo(keyId: string): Promise<KeyInfo>;
saveLens(id: string | undefined, settings: Settings): Promise<Lens>;
startRun(lensId: string): Promise<void>;
startRun(lensId: string, request?: RunWindow): Promise<void>;
watchAll(): Promise<components["schemas"]["WatchAllResult"]>;
cancelRun(lensId: string): Promise<void>;
reviewFinding(lensId: string, findingId: string, status: FindingStatus, reason: string): Promise<void>;
registerWorker(analysisKeyId: string | null): Promise<WorkerCreated>;
@ -109,7 +111,9 @@ export function liveLensApi(apiClient: ApiClient, accessToken: string): LensApi
keyInfoSchema.parse(await apiClient.get("/key/info", { accessToken, query: { key: keyId } })).info,
saveLens: (id, settings) =>
apiClient.request<Lens>(id ? "PUT" : "POST", id ? `/lens/${id}` : "/lens", { accessToken, body: settings }),
startRun: (lensId) => apiClient.post(`/lens/${lensId}/runs`, { accessToken, body: {} }),
startRun: (lensId, request = {}) => apiClient.post(`/lens/${lensId}/runs`, { accessToken, body: request }),
watchAll: () =>
apiClient.post<components["schemas"]["WatchAllResult"]>("/lens/watch-all", { accessToken, body: {} }),
cancelRun: (lensId) => apiClient.post(`/lens/${lensId}/cancel`, { accessToken, body: {} }),
reviewFinding: (lensId, findingId, status, reason) =>
apiClient.patch(`/lens/${lensId}/findings/${findingId}`, { accessToken, body: { status, reason } }),

View file

@ -296,6 +296,8 @@ export function createLensDemoData(now = Date.now()) {
: [],
})),
attempts: 1,
steps: [],
trigger: "schedule" as const,
error: "",
cost: sample.length * 0.012,
coverage: {
@ -359,6 +361,7 @@ function demoLensApi(data: LensDemoData): LensApi {
keyInfo: notInDemo,
saveLens: readOnly,
startRun: readOnly,
watchAll: async () => ({ watching: [], skipped: [] }),
cancelRun: readOnly,
reviewFinding: readOnly,
registerWorker: readOnly,

View file

@ -10,6 +10,7 @@ import { type Finding, type Sample } from "../model/types";
export function FindingSheet({
finding,
agents = [],
sampledRuns,
readOnly,
busy,
@ -18,6 +19,7 @@ export function FindingSheet({
changeFinding,
}: {
finding?: Finding;
agents?: readonly string[];
sampledRuns: Sample["executions"];
readOnly: boolean;
busy: boolean;
@ -46,6 +48,7 @@ export function FindingSheet({
<SheetHeader>
<SheetTitle className="pr-8 text-xl leading-snug">{finding.title}</SheetTitle>
<SheetDescription>
{agents.length > 0 && <span className="font-medium text-foreground">{agents.join(", ")} · </span>}
{finding.kind === "issue" ? `${finding.priority} priority` : "Pattern"} ·{" "}
{finding.occurrences?.length ?? 0} linked {finding.occurrences?.length === 1 ? "run" : "runs"}
</SheetDescription>

View file

@ -0,0 +1,147 @@
"use client";
import { ChevronRight } from "lucide-react";
import { useState } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useNow } from "@/hooks/useNow";
import { formatActivityTimestamp } from "@/utils/activityTimestamp";
import { agoLabel } from "@/components/view_logs/TraceView/lensField";
import { cn } from "@/lib/cva.config";
import { ALL_AGENTS, filterInbox, inboxAgents, inboxRows, type InboxRow, type Priority } from "../model/inbox";
import type { Lens } from "../model/types";
const PRIORITY_DOT = { high: "bg-[#e5484d]", medium: "bg-amber-500", low: "bg-muted-foreground/50" } as const;
const PRIORITY_ITEMS: { value: Priority | "all"; label: string }[] = [
{ value: "all", label: "All priorities" },
{ value: "high", label: "High" },
{ value: "medium", label: "Medium" },
{ value: "low", label: "Low" },
];
const TH = "px-3 font-medium";
const TH_NUM = "px-3 text-right font-medium";
function FilterSelect<T extends string>({
label,
value,
items,
onChange,
width,
}: {
label: string;
value: T;
items: { value: T; label: string }[];
onChange: (value: T) => void;
width: string;
}) {
return (
<Select items={items} value={value} onValueChange={(next: T | null) => next !== null && onChange(next)}>
<SelectTrigger size="sm" className={cn("h-7 text-[12px]", width)} aria-label={label}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export function FindingsInbox({ lenses, onOpen }: { lenses: readonly Lens[]; onOpen: (row: InboxRow) => void }) {
const [agent, setAgent] = useState(ALL_AGENTS);
const [priority, setPriority] = useState<Priority | "all">("all");
const now = useNow(30000);
const all = inboxRows(lenses);
const rows = filterInbox(all, { agent, priority });
const agentItems = [
{ value: ALL_AGENTS, label: "All agents" },
...inboxAgents(all).map((a) => ({ value: a, label: a })),
];
return (
<div
className="flex min-h-[420px] flex-1 flex-col overflow-hidden border-y border-border bg-card"
data-testid="findings-inbox"
>
<div className="flex min-h-10 shrink-0 flex-wrap items-center gap-2 border-b border-border bg-card px-3 py-1.5">
<FilterSelect
label="Filter by agent"
value={agent}
items={agentItems}
onChange={setAgent}
width="min-w-[150px]"
/>
<FilterSelect
label="Filter by priority"
value={priority}
items={PRIORITY_ITEMS}
onChange={setPriority}
width="min-w-[130px]"
/>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table aria-label="Findings" className="w-full min-w-[900px] table-fixed border-collapse text-left">
<thead className="sticky top-0 z-sticky bg-muted/40 backdrop-blur">
<tr className="h-8 border-b border-border text-[10px] tracking-[0.08em] text-muted-foreground uppercase">
<th className={`w-[96px] ${TH}`}>Priority</th>
<th className={TH}>Finding</th>
<th className={`w-[180px] ${TH}`}>Agent</th>
<th className={`w-[64px] ${TH_NUM}`}>Runs</th>
<th className={`w-[96px] ${TH}`}>Last seen</th>
<th className="w-8" />
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.key}
onClick={() => onOpen(row)}
data-testid="inbox-row"
aria-label={row.title}
className="h-9 cursor-pointer border-b border-border/60 text-[12px] transition-colors duration-150 hover:bg-trace-row-hover motion-reduce:transition-none"
>
<td className="px-3">
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<span className={cn("size-1.5 rounded-full", PRIORITY_DOT[row.priority])} />
{row.priority}
</span>
</td>
<td className="px-3" title={row.suggestion ? `Fix: ${row.suggestion}` : undefined}>
<span className="block truncate text-foreground">{row.title}</span>
</td>
<td className="truncate px-3 text-muted-foreground" title={row.agents.join(", ")}>
{row.agents.join(", ")}
</td>
<td className="px-3 text-right font-mono tabular-nums text-foreground">{row.runs}</td>
<td
className="px-3 font-mono text-[11px] tabular-nums text-muted-foreground"
title={formatActivityTimestamp(row.lastSeen)}
>
{agoLabel(Date.parse(row.lastSeen), now)}
</td>
<td>
<ChevronRight className="size-3 text-muted-foreground/60" />
</td>
</tr>
))}
</tbody>
</table>
{rows.length === 0 && (
<div className="py-16 text-center text-[12px] text-muted-foreground">
{all.length === 0
? "No open findings yet. New problems show up here as soon as an investigation spots them."
: "No findings match these filters."}
</div>
)}
</div>
<footer className="flex h-8 shrink-0 items-center border-t border-border bg-muted/40 px-3 font-mono text-[11px] text-muted-foreground">
{rows.length} {rows.length === 1 ? "finding" : "findings"}
{rows.length !== all.length && ` of ${all.length}`}
</footer>
</div>
);
}

View file

@ -0,0 +1,13 @@
"use client";
import { useContext, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { LensPreviewTarget } from "../LensPreviewButton";
export function HeaderActions({ children }: { children: ReactNode }) {
const target = useContext(LensPreviewTarget);
const actions = <div className="flex flex-wrap items-center gap-3">{children}</div>;
if (target === null) return null;
return target ? createPortal(actions, target) : actions;
}

View file

@ -1,64 +1,151 @@
import { ListRow } from "@/components/shared/ListRow";
import { useState } from "react";
import { ChevronRight, Search } from "lucide-react";
import { Input } from "@/components/ui/input";
"use client";
import { Pencil, Play, Search } from "lucide-react";
import { useState } from "react";
import { useNow } from "@/hooks/useNow";
import { Input } from "@/components/ui/input";
import { formatActivityTimestamp } from "@/utils/activityTimestamp";
import { cn } from "@/lib/cva.config";
import { agoLabel } from "@/components/view_logs/TraceView/lensField";
import { scheduleLabel } from "../model/inbox";
import { lensStatus } from "../model/status";
import { runTime, scopeLabel } from "../model/format";
import { scopeLabel } from "../model/format";
import { type Lens } from "../model/types";
const TH = "px-3 font-medium";
const TH_NUM = "px-3 text-right font-medium";
export function InvestigationList({
lenses,
connected,
onSelect,
readOnly = false,
onEdit,
onRunNow,
}: {
lenses: Lens[];
connected: boolean;
onSelect: (id: string) => void;
readOnly?: boolean;
onEdit: (id: string) => void;
onRunNow: (id: string) => void;
}) {
const [search, setSearch] = useState("");
const now = useNow(15000);
const shown = lenses.filter((lens) =>
`${lens.settings.name} ${scopeLabel(lens.settings)}`.toLowerCase().includes(search.toLowerCase()),
);
return (
<section aria-label="Saved investigations" className="space-y-4">
<div className="relative max-w-sm">
<Search className="absolute left-3 top-2.5 size-4 text-muted-foreground" />
<Input
aria-label="Search investigations"
placeholder="Search investigations"
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="flex min-h-[420px] flex-1 flex-col overflow-hidden border-y border-border bg-card">
<div className="flex min-h-10 shrink-0 flex-wrap items-center gap-2 border-b border-border bg-card px-3 py-1.5">
<div className="relative w-full max-w-[380px]">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="Search investigations"
placeholder="Search investigations"
className="h-7 pl-8 text-[12px]"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="divide-y border-y">
{shown.map((lens) => (
<ListRow
key={lens.id}
onClick={() => onSelect(lens.id)}
className="group grid w-full grid-cols-[minmax(0,1fr)_16px] items-center gap-x-4 gap-y-2 py-4 text-left hover:bg-muted/30 focus-visible:outline-2 focus-visible:outline-ring sm:grid-cols-[minmax(0,1fr)_auto_16px]"
>
<div className="col-start-1 row-start-1 min-w-0">
<p className="text-sm font-medium">{lens.settings.name}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{scopeLabel(lens.settings)}</p>
</div>
<div className="col-start-1 row-start-2 text-xs sm:col-start-2 sm:row-start-1 sm:text-right">
<p
data-state={lens.jobs[0]?.status === "failed" ? "failed" : "other"}
className="data-[state=failed]:text-destructive data-[state=other]:text-muted-foreground"
>
{lensStatus(lens, connected)}
</p>
<p className="mt-1 text-muted-foreground">
{lens.jobs[0] ? runTime(lens.jobs[0].created_at) : "Not run yet"}
</p>
</div>
<ChevronRight className="col-start-2 row-start-1 size-4 text-muted-foreground sm:col-start-3" />
</ListRow>
))}
{!shown.length && <p className="py-8 text-sm text-muted-foreground">No investigations match your search.</p>}
<div className="min-h-0 flex-1 overflow-auto">
<table aria-label="Investigations" className="w-full min-w-[900px] table-fixed border-collapse text-left">
<thead className="sticky top-0 z-sticky bg-muted/40 backdrop-blur">
<tr className="h-8 border-b border-border text-[10px] tracking-[0.08em] text-muted-foreground uppercase">
<th className={TH}>Investigation</th>
<th className={`w-[180px] ${TH}`}>Agent</th>
<th className={`w-[220px] ${TH}`}>Schedule</th>
<th className={`w-[150px] ${TH}`}>Last run</th>
<th className={`w-[64px] ${TH_NUM}`}>Open</th>
<th className="w-[92px]" />
</tr>
</thead>
<tbody>
{shown.map((lens) => {
const latest = lens.jobs[0];
const failed = latest?.status === "failed";
const open = lens.findings.filter((f) => f.status === "open").length;
return (
<tr
key={lens.id}
onClick={readOnly ? undefined : () => onEdit(lens.id)}
data-testid="investigation-row"
aria-label={lens.settings.name}
className={cn(
"h-9 border-b border-border/60 text-[12px] transition-colors duration-150 hover:bg-trace-row-hover motion-reduce:transition-none",
!readOnly && "cursor-pointer",
)}
>
<td className="truncate px-3 text-foreground">{lens.settings.name}</td>
<td className="truncate px-3 text-muted-foreground">{scopeLabel(lens.settings)}</td>
<td className="px-3 font-mono text-[11px]" data-testid="investigation-schedule">
<span
className={cn(
"inline-flex items-center gap-1.5",
lens.settings.enabled ? "text-foreground" : "text-muted-foreground",
)}
>
<span
className={cn(
"size-1.5 rounded-full",
lens.settings.enabled ? "bg-[#3b5bfd]" : "bg-muted-foreground/40",
)}
/>
{scheduleLabel(lens, now)}
</span>
</td>
<td
className="px-3 font-mono text-[11px]"
title={latest ? formatActivityTimestamp(latest.created_at) : undefined}
>
<span className={failed ? "text-destructive" : "text-muted-foreground"}>
{lensStatus(lens, connected).toLowerCase()}
</span>
{latest && (
<span className="text-muted-foreground"> · {agoLabel(Date.parse(latest.created_at), now)}</span>
)}
</td>
<td className="px-3 text-right font-mono tabular-nums text-foreground">{open}</td>
<td className="pr-2">
{!readOnly && (
<span className="flex items-center justify-end gap-0.5">
<button
type="button"
aria-label={`Run ${lens.settings.name} now`}
title="Run now"
onClick={(event) => {
event.stopPropagation();
onRunNow(lens.id);
}}
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<Play className="size-3" />
</button>
<span
aria-hidden="true"
className="inline-flex size-6 items-center justify-center text-muted-foreground/60"
>
<Pencil className="size-3" />
</span>
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
{!shown.length && (
<div className="py-16 text-center text-[12px] text-muted-foreground">
No investigations match your search.
</div>
)}
</div>
</section>
<footer className="flex h-8 shrink-0 items-center border-t border-border bg-muted/40 px-3 font-mono text-[11px] text-muted-foreground">
{shown.length} {shown.length === 1 ? "investigation" : "investigations"} ·{" "}
{lenses.filter((l) => l.settings.enabled).length} watching
</footer>
</div>
);
}

View file

@ -25,21 +25,26 @@ export function InvestigationNavigation({
setEditing: (mode: "new") => void;
}) {
return (
<header className="flex flex-wrap items-center justify-between gap-3">
<header className={lens ? "flex flex-wrap items-center justify-between gap-2" : "flex items-center gap-2"}>
{lens ? (
<Button variant="ghost" size="sm" className="-ml-3" onClick={() => selectLens(null)}>
<ArrowLeft className="size-4" /> All investigations
<ArrowLeft className="size-4" /> Back
</Button>
) : (
<h2 className="text-lg font-semibold">Investigations</h2>
)}
) : null}
{showActions && (
<div className="flex flex-wrap gap-2">
<Button variant="ghost" disabled={!activityReady} onClick={() => setWorkerSetup(true)}>
<Button
variant="ghost"
size="sm"
disabled={!activityReady}
onClick={() => setWorkerSetup(true)}
title={connected ? "Worker connected" : "Connect worker"}
className="text-muted-foreground"
>
<StatusDot state={connected ? "ok" : "warn"} />
{connected ? "Worker connected" : "Connect worker"}
{connected ? "Worker" : "Connect worker"}
</Button>
<Button variant={lens ? "outline" : "default"} disabled={!ready} onClick={() => setEditing("new")}>
<Button size="sm" variant={lens ? "outline" : "default"} disabled={!ready} onClick={() => setEditing("new")}>
<Plus className="size-4" /> New investigation
</Button>
</div>

View file

@ -1,4 +1,4 @@
import { act, screen, within, waitFor } from "@testing-library/react";
import { act, fireEvent, screen, within, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders as renderProviders, testQueryClient } from "@/../tests/test-utils";
@ -15,7 +15,7 @@ function renderWithProviders(ui: React.ReactElement, options?: Parameters<typeof
}
vi.mock("@/components/networking", () => ({
apiClient: { get: vi.fn(), post: vi.fn(), request: vi.fn() },
apiClient: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), request: vi.fn() },
proxyBaseUrl: "",
}));
@ -86,6 +86,8 @@ const lens: Lens = {
id: "scan",
findings: [pattern, issue],
assessments: [],
steps: [],
trigger: "schedule",
attempts: 0,
error: "",
cost: 0,
@ -232,7 +234,7 @@ describe("Lens findings and runs", () => {
});
});
it("runs saved settings immediately without opening setup", async () => {
it("runs with saved settings from Run now without opening setup, then accepts an agent and window", async () => {
testQueryClient.clear();
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens")
@ -258,8 +260,22 @@ it("runs saved settings immediately without opening setup", async () => {
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" />);
await user.click(await screen.findByRole("button", { name: "Run now" }));
const choices = await screen.findByRole("dialog", { name: "Run now" });
expect(within(choices).getByRole("button", { name: "Since last run" })).toHaveAttribute("aria-pressed", "true");
await user.click(within(choices).getByRole("button", { name: "Run now" }));
expect(apiClient.post).toHaveBeenCalledWith("/lens/lens/runs", { accessToken: "test", body: {} });
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
vi.mocked(apiClient.post).mockClear();
await user.click(screen.getByRole("button", { name: "Run now" }));
const custom = await screen.findByRole("dialog", { name: "Run now" });
fireEvent.change(within(custom).getByRole("combobox", { name: "Agent" }), { target: { value: "billing" } });
await user.click(within(custom).getByRole("button", { name: "Last 24h" }));
await user.click(within(custom).getByRole("button", { name: "Run now" }));
expect(apiClient.post).toHaveBeenCalledWith("/lens/lens/runs", {
accessToken: "test",
body: { agent_name: "billing", lookback_hours: 24 },
});
});
it("offers the interactive demo without starting an investigation", async () => {
@ -507,6 +523,46 @@ it("closes editing when browser navigation leaves the investigation", async () =
expect(apiClient.request).not.toHaveBeenCalled();
});
it("resolves every investigation's copy of a merged finding from one row", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
const twin: Lens = { ...lens, id: "twin", settings: { ...lens.settings, name: "Twin reviews" } };
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [lens, twin], tracing_enabled: true, workers: [] };
if (path === "/lens/activity/available") return { traces: true, requests: false };
return { data: [] };
});
vi.mocked(apiClient.patch).mockResolvedValue(undefined);
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" />);
const rows = await screen.findAllByRole("row", { name: issue.title });
expect(rows).toHaveLength(1);
await user.click(rows[0]);
await user.click(await screen.findByRole("button", { name: "Mark resolved" }));
await waitFor(() => expect(apiClient.patch).toHaveBeenCalledTimes(2));
const resolved = vi
.mocked(apiClient.patch)
.mock.calls.map(([path]) => path)
.sort();
expect(resolved).toEqual(["/lens/lens/findings/issue", "/lens/twin/findings/issue"]);
});
it("lists investigations without edit or run controls for read-only viewers", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [lens], tracing_enabled: true, workers: [] };
if (path === "/lens/activity/available") return { traces: true, requests: false };
return { data: [] };
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" view="investigations" readOnly />);
const row = await screen.findByRole("row", { name: lens.settings.name });
expect(within(row).queryByRole("button", { name: /now/ })).not.toBeInTheDocument();
await user.click(row);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("shows the actual saved failure and run context without opening backend logs", async () => {
testQueryClient.clear();
const error =
@ -526,3 +582,24 @@ it("shows the actual saved failure and run context without opening backend logs"
expect(failure.getByText(job.settings.model)).toBeVisible();
expect(failure.queryByText(/find the error in proxy and worker logs/)).not.toBeInTheDocument();
});
it("keeps a merged finding open to retry when one investigation's update fails", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
const twin: Lens = { ...lens, id: "twin", settings: { ...lens.settings, name: "Twin reviews" } };
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [lens, twin], tracing_enabled: true, workers: [] };
if (path === "/lens/activity/available") return { traces: true, requests: false };
return { data: [] };
});
vi.mocked(apiClient.patch).mockReset();
vi.mocked(apiClient.patch).mockImplementation(async (path) => {
if (String(path).startsWith("/lens/twin/")) throw new Error("Twin reviews could not be updated");
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" />);
await user.click(await screen.findByRole("row", { name: issue.title }));
await user.click(await screen.findByRole("button", { name: "Mark resolved" }));
expect(await screen.findByText("Twin reviews could not be updated")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Mark resolved" })).toBeVisible();
});

View file

@ -25,16 +25,25 @@ import { InvestigationSetupDialog } from "../setup/InvestigationSetupDialog";
import { WorkerDialog } from "../setup/worker/WorkerDialog";
import { useAnalysisKeyInfo } from "../setup/worker/AnalysisKeyDetails";
import { InvestigationList } from "./InvestigationList";
import { HeaderActions } from "./HeaderActions";
import { RunNowDialog } from "./detail/RunNowDialog";
import { FindingsInbox } from "./FindingsInbox";
import { findingAgents, sampledExecutions, type InboxRow } from "../model/inbox";
import { WatchAllBanner } from "./WatchAllBanner";
import { MonitoringDialog } from "../setup/MonitoringDialog";
import { InvestigationsWelcome } from "./InvestigationsWelcome";
import { workerConnected, readiness } from "../model/status";
import { type Finding, type Settings } from "../model/types";
export function InvestigationsView({
view = "findings",
active = true,
accessToken,
readOnly = false,
onDemo,
}: {
view?: "findings" | "investigations";
active?: boolean;
accessToken: string;
readOnly?: boolean;
onDemo?: () => void;
@ -50,11 +59,17 @@ export function InvestigationsView({
const query = useQuery(lensQueries.list(api, !!demo, workerSetup));
const models = useQuery(lensQueries.models(api));
const modelDetails = useQuery(lensQueries.modelDetails(api));
const [agentsAsOf] = useState(() => new Date().toISOString());
const agents = useQuery(lensQueries.agents(api, agentsAsOf, "traces"));
const [liveSelected, setLiveSelected] = useQueryState("lens", parseAsString.withOptions({ history: "push" }));
const [demoSelected, setDemoSelected] = useState<string | null>(null);
const selected = demo ? demoSelected : liveSelected;
const setSelected = demo ? setDemoSelected : setLiveSelected;
const [editing, setEditing] = useState<"new" | "edit" | "duplicate" | null>(null);
const [peek, setPeek] = useState(false);
const [peeked, setPeeked] = useState<InboxRow | null>(null);
const [runNowId, setRunNowId] = useState<string | null>(null);
const [skipped, setSkipped] = useState<readonly { id: string; name: string; reason: string }[]>([]);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const lenses = [...(query.data?.lenses ?? [])].sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
@ -113,15 +128,17 @@ export function InvestigationsView({
void client.invalidateQueries({ queryKey: lensKeys.list(api.scope) });
void client.invalidateQueries({ queryKey: lensKeys.histories() });
};
const update = async (write: LensWrite) => {
const update = async (write: LensWrite): Promise<boolean> => {
setBusy(true);
setError("");
try {
await updateLens.mutateAsync(write);
await client.invalidateQueries({ queryKey: lensKeys.list(api.scope) });
return true;
} catch (e) {
setError(e instanceof Error ? e.message : "Could not update lens");
return false;
} finally {
await client.invalidateQueries({ queryKey: lensKeys.list(api.scope) });
setBusy(false);
}
};
@ -131,18 +148,51 @@ export function InvestigationsView({
if (editing !== "edit" && !ready)
throw new Error("Wait for recorded activity and a connected worker before starting an investigation");
const saved = await saveLens.mutateAsync({ id: editing === "edit" ? lens?.id : undefined, settings });
selectLens(saved.id);
selectLens(peek ? null : saved.id);
setPeek(false);
setEditing(null);
refresh();
};
const editFromTable = (id: string) => {
setPeek(true);
selectLens(id);
setEditing("edit");
};
const openFinding = (row: InboxRow) => {
setPeek(true);
setPeeked(row);
selectLens(row.sources[0].lens.id);
};
const closeFinding = () => {
setFindingId(null);
setPeeked(null);
if (!peek) return;
setPeek(false);
selectLens(null);
};
const changeFinding = async (status: Finding["status"], reason: string) => {
if (peeked) {
const saved = await update((current) =>
Promise.all(peeked.sources.map((s) => current.reviewFinding(s.lens.id, s.finding.id, status, reason))),
);
if (saved) closeFinding();
return;
}
if (!lens || !finding) return;
await update((current) => current.reviewFinding(lens.id, finding.id, status, reason));
};
const sheetFinding = peeked ? peeked.sources[0].finding : finding;
const sheetRuns = peeked ? peeked.sources.flatMap((s) => sampledExecutions(s.lens)) : sampledRuns;
const detailAgents = lens && finding ? findingAgents(lens, finding) : [];
const sheetAgents = peeked ? peeked.agents : detailAgents;
const onDetail = !!selected && !peek;
const showDetailNav = onDetail && !showEmpty;
const showTables = !onDetail && lenses.length > 0;
const showMissing = missingSelection && !lens && !peek;
return (
<section aria-label="Investigations" className="w-full min-w-0 space-y-6">
{!showEmpty && (
<section aria-label="Investigations" className="flex w-full min-w-0 flex-1 flex-col gap-3">
{showDetailNav && (
<InvestigationNavigation
lens={lens}
showActions={showActions}
@ -173,12 +223,51 @@ export function InvestigationsView({
onDemo={activity.isSuccess && !ready ? onDemo : undefined}
/>
)}
{showReadiness && !ready && <ReadinessBanner activityReady={activityReady} />}
{!selected && lenses.length > 0 && (
<InvestigationList lenses={lenses} connected={connected} onSelect={selectLens} />
{showReadiness && !ready && <ReadinessBanner activityReady={activityReady} className="py-2 text-xs" />}
{showTables && (
<div className="flex min-h-0 flex-1 flex-col gap-2">
{active && (
<HeaderActions>
{!readOnly && (
<WatchAllBanner
lenses={lenses}
busy={busy}
skipped={skipped}
onWatchAll={() =>
update(async (api) => {
const result = await api.watchAll();
setSkipped(result.skipped);
})
}
/>
)}
<InvestigationNavigation
lens={undefined}
showActions={showActions}
activityReady={activityReady}
connected={connected}
ready={ready}
selectLens={selectLens}
setWorkerSetup={setWorkerSetup}
setEditing={setEditing}
/>
</HeaderActions>
)}
{view === "findings" ? (
<FindingsInbox lenses={lenses} onOpen={openFinding} />
) : (
<InvestigationList
lenses={lenses}
connected={connected}
readOnly={readOnly}
onEdit={editFromTable}
onRunNow={(id) => setRunNowId(id)}
/>
)}
</div>
)}
{missingSelection && !lens && <InvestigationMissing selectLens={selectLens} />}
{lens && (
{showMissing && <InvestigationMissing selectLens={selectLens} />}
{lens && !peek && (
<InvestigationDetail
lens={lens}
readOnly={readOnly}
@ -189,6 +278,7 @@ export function InvestigationsView({
update={update}
connected={connected}
results={results}
agents={Array.isArray(agents.data) ? agents.data : []}
/>
)}
{editing && (
@ -203,10 +293,28 @@ export function InvestigationsView({
modelsLoading={models.isLoading}
modelsError={models.error?.message}
accessToken={accessToken}
onClose={() => setEditing(null)}
onClose={() => {
setEditing(null);
if (peek) {
setPeek(false);
selectLens(null);
}
}}
onSave={save}
/>
)}
{runNowId && (
<RunNowDialog
lens={lenses.find((l) => l.id === runNowId) ?? lenses[0]}
agents={Array.isArray(agents.data) ? agents.data : []}
busy={busy}
onClose={() => setRunNowId(null)}
onRun={async (request) => {
await update((api) => api.startRun(runNowId, request));
setRunNowId(null);
}}
/>
)}
{workerSetup && (
<WorkerDialog
accessToken={accessToken}
@ -235,11 +343,12 @@ export function InvestigationsView({
/>
)}
<FindingSheet
finding={finding}
sampledRuns={sampledRuns}
finding={sheetFinding}
agents={sheetAgents}
sampledRuns={sheetRuns}
readOnly={readOnly}
busy={busy}
onClose={() => setFindingId(null)}
onClose={closeFinding}
changeFinding={changeFinding}
onEvidence={(value) => {
setRequestOffset(0);

View file

@ -0,0 +1,72 @@
"use client";
import { ChevronDown } from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/cva.config";
import { modelsUsed, stepLine, windowLabel } from "../model/inbox";
import type { Job } from "../model/types";
const STEP_MARK = { model: "✓", stage: "▸", error: "!" } as const;
const STEP_TONE = {
model: "text-foreground",
stage: "text-[#0011b3] dark:text-[#8b9bff]",
error: "text-[#e5484d]",
} as const;
export function StepFeed({ job }: { job: Job }) {
const [open, setOpen] = useState(true);
const steps = job.steps ?? [];
const models = modelsUsed(steps);
const calls = steps.filter((s) => s.kind === "model").length;
return (
<section aria-label="Live investigation steps" className="rounded-md border bg-background font-mono text-[11px]">
<button
type="button"
onClick={() => setOpen(!open)}
aria-expanded={open}
className="flex w-full flex-wrap items-center justify-between gap-x-4 gap-y-1 px-3 py-2 text-left hover:bg-muted/40"
>
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground">
<span className="font-semibold text-foreground">
{job.trigger === "manual" ? "manual run" : "scheduled run"}
</span>
<span>{windowLabel(job)}</span>
<span>
{calls} model {calls === 1 ? "call" : "calls"} · ${job.cost.toFixed(4)}
</span>
{models.length > 0 && (
<span data-testid="step-feed-models">
using <span className="text-foreground">{models.join(", ")}</span>
</span>
)}
</span>
<ChevronDown className={cn("size-3.5 text-muted-foreground transition-transform", open && "rotate-180")} />
</button>
{open && (
<ol className="max-h-56 space-y-0.5 overflow-y-auto border-t px-3 py-2" data-testid="step-feed">
{steps.length === 0 && <li className="text-muted-foreground">Waiting for the worker to start…</li>}
{[...steps].reverse().map((step, index) => (
<li
key={`${step.at}-${index}`}
className={cn(
"grid grid-cols-[1rem_4.5rem_minmax(0,1fr)_auto] items-baseline gap-2",
index === 0 && "motion-safe:animate-in motion-safe:fade-in",
)}
>
<span aria-hidden="true" className={STEP_TONE[step.kind]}>
{index === 0 && step.kind === "stage" ? "▸" : STEP_MARK[step.kind]}
</span>
<span className="tabular-nums text-muted-foreground">
{new Date(step.at).toLocaleTimeString(undefined, { hour12: false })}
</span>
<span className={cn("truncate", STEP_TONE[step.kind])}>{stepLine(step)}</span>
<span className="truncate text-muted-foreground">{step.model}</span>
</li>
))}
</ol>
)}
</section>
);
}

View file

@ -0,0 +1,41 @@
"use client";
import type { Lens } from "../model/types";
export function WatchAllBanner({
lenses,
busy,
onWatchAll,
skipped = [],
}: {
lenses: readonly Lens[];
busy: boolean;
onWatchAll: () => void;
skipped?: readonly { id: string; name: string; reason: string }[];
}) {
const paused = lenses.filter((lens) => !lens.settings.enabled).length;
if (paused === 0) return null;
if (skipped.length >= paused)
return (
<span
role="status"
className="text-xs text-muted-foreground"
title={skipped.map((s) => `${s.name}: ${s.reason}`).join("\n")}
>
{paused} paused · {skipped.length === 1 ? `${skipped[0].name} needs a fix` : `${skipped.length} need a fix`}
</span>
);
return (
<span role="status" className="text-xs text-muted-foreground">
{paused} paused ·{" "}
<button
type="button"
disabled={busy}
onClick={onWatchAll}
className="font-medium text-foreground underline-offset-2 hover:underline disabled:opacity-50"
>
Turn all on
</button>
</span>
);
}

View file

@ -19,6 +19,7 @@ export function InvestigationActions({
setEditing,
setMonitoring,
update,
onRunNow,
}: {
lens: Lens;
ready: boolean;
@ -26,7 +27,8 @@ export function InvestigationActions({
active: Job | undefined;
setEditing: (mode: "new" | "edit" | "duplicate") => void;
setMonitoring: (open: boolean) => void;
update: (write: LensWrite) => Promise<void>;
update: (write: LensWrite) => Promise<unknown>;
onRunNow: () => void;
}) {
return (
<div className="flex flex-wrap gap-2">
@ -59,7 +61,7 @@ export function InvestigationActions({
)}
</DropdownMenuContent>
</DropdownMenu>
<Button disabled={busy || !!active || !ready} onClick={() => update((api) => api.startRun(lens.id))}>
<Button disabled={busy || !!active || !ready} onClick={onRunNow}>
<Play className="size-3" />
Run now
</Button>

View file

@ -6,6 +6,9 @@ import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { RunsTab } from "./RunsTab";
import { InvestigationProgress } from "../InvestigationProgress";
import { StepFeed } from "../StepFeed";
import { RunNowDialog } from "./RunNowDialog";
import { useState } from "react";
import { InvestigationSummary } from "./InvestigationSummary";
import { InvestigationFailure } from "./InvestigationFailure";
import { scopeLabel, sourceLabels } from "../../model/format";
@ -27,6 +30,7 @@ export function InvestigationDetail({
update,
connected,
results,
agents = [],
}: {
lens: Lens;
readOnly: boolean;
@ -34,10 +38,12 @@ export function InvestigationDetail({
busy: boolean;
setEditing: (mode: "new" | "edit" | "duplicate") => void;
setMonitoring: (open: boolean) => void;
update: (write: LensWrite) => Promise<void>;
update: (write: LensWrite) => Promise<unknown>;
connected: boolean;
results: ReturnType<typeof useInvestigationResults>;
agents?: readonly string[];
}) {
const [runNow, setRunNow] = useState(false);
const {
active,
job,
@ -85,6 +91,7 @@ export function InvestigationDetail({
setEditing={setEditing}
setMonitoring={setMonitoring}
update={update}
onRunNow={() => setRunNow(true)}
/>
)}
</div>
@ -102,6 +109,19 @@ export function InvestigationDetail({
}
/>
)}
{active && <StepFeed job={active} />}
{runNow && (
<RunNowDialog
lens={lens}
agents={agents}
busy={busy}
onClose={() => setRunNow(false)}
onRun={async (request) => {
await update((api) => api.startRun(lens.id, request));
setRunNow(false);
}}
/>
)}
{job?.error && <InvestigationFailure job={job} connected={connected} />}
<Tabs value={tab} onValueChange={setTab} key={lens.id}>
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b">

View file

@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { runRequest } from "./RunNowDialog";
const base = { agent: "support", saved: "support", start: "", end: "" };
describe("runRequest", () => {
it("sends nothing extra for the default since-last-run choice", () => {
expect(runRequest({ ...base, preset: null })).toEqual({});
});
it("only overrides the agent when it differs from the saved one", () => {
expect(runRequest({ ...base, preset: null, agent: "billing" })).toEqual({ agent_name: "billing" });
expect(runRequest({ ...base, preset: null, agent: " support " })).toEqual({});
});
it("turns a preset into a lookback window", () => {
expect(runRequest({ ...base, preset: 168 })).toEqual({ lookback_hours: 168 });
});
it("sends an exact window as ISO timestamps for a custom range", () => {
const choice = { ...base, preset: -1 as const, start: "2026-10-02T10:00", end: "2026-10-02T12:00" };
const request = runRequest(choice);
expect(request).toEqual({
start: new Date("2026-10-02T10:00").toISOString(),
end: new Date("2026-10-02T12:00").toISOString(),
});
});
it("rejects a missing or backwards custom range with a message instead of a request", () => {
const missing = { ...base, preset: -1 as const, start: "", end: "2026-10-02T12:00" };
const backwards = { ...base, preset: -1 as const, start: "2026-10-02T12:00", end: "2026-10-02T10:00" };
expect(runRequest(missing)).toBe("Choose a start and end time");
expect(runRequest(backwards)).toBe("Start time must be before end time");
});
});

View file

@ -0,0 +1,154 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import type { Lens } from "../../model/types";
export interface RunWindow {
agent_name?: string;
start?: string;
end?: string;
lookback_hours?: number;
}
const PRESETS = [
{ label: "Since last run", hours: null },
{ label: "Last hour", hours: 1 },
{ label: "Last 24h", hours: 24 },
{ label: "Last 7d", hours: 168 },
{ label: "Custom", hours: -1 },
] as const;
const localInput = (date: Date) =>
new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
export interface RunChoice {
preset: (typeof PRESETS)[number]["hours"];
agent: string;
saved: string;
start: string;
end: string;
}
export function runRequest({ preset, agent, saved, start, end }: RunChoice): RunWindow | string {
const agentPart = agent.trim() && agent.trim() !== saved ? { agent_name: agent.trim() } : {};
if (preset === null) return agentPart;
if (preset > 0) return { ...agentPart, lookback_hours: preset };
const startMs = Date.parse(start);
const endMs = Date.parse(end);
if (Number.isNaN(startMs) || Number.isNaN(endMs)) return "Choose a start and end time";
if (startMs >= endMs) return "Start time must be before end time";
return { ...agentPart, start: new Date(startMs).toISOString(), end: new Date(endMs).toISOString() };
}
export function RunNowDialog({
lens,
agents,
busy,
onClose,
onRun,
}: {
lens: Lens;
agents: readonly string[];
busy: boolean;
onClose: () => void;
onRun: (request: RunWindow) => Promise<void>;
}) {
const now = new Date();
const [preset, setPreset] = useState<(typeof PRESETS)[number]["hours"]>(null);
const [agent, setAgent] = useState(lens.settings.agent_name ?? "");
const [start, setStart] = useState(localInput(new Date(now.getTime() - 3_600_000)));
const [end, setEnd] = useState(localInput(now));
const [error, setError] = useState("");
const submit = async () => {
const choice: RunChoice = { preset, agent, saved: lens.settings.agent_name ?? "", start, end };
const request = runRequest(choice);
if (typeof request === "string") {
setError(request);
return;
}
setError("");
await onRun(request);
};
return (
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Run now</DialogTitle>
<DialogDescription>
Runs once with these choices. The saved schedule and settings stay the same.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<label className="grid gap-1.5 text-sm font-medium">
Agent
<Input
list="run-now-agents"
value={agent}
placeholder="All agents"
onChange={(e) => setAgent(e.target.value)}
aria-label="Agent"
/>
<datalist id="run-now-agents">
{agents.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</label>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Traces to review</legend>
<div className="flex flex-wrap gap-1.5">
{PRESETS.map((p) => (
<button
key={p.label}
type="button"
aria-pressed={preset === p.hours}
onClick={() => setPreset(p.hours)}
className="rounded-md bg-muted/60 px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted aria-pressed:bg-background aria-pressed:text-foreground aria-pressed:ring-[1.5px] aria-pressed:ring-foreground aria-pressed:ring-inset"
>
{p.label}
</button>
))}
</div>
{preset === -1 && (
<div className="grid grid-cols-2 gap-2">
<label className="grid gap-1 text-xs text-muted-foreground">
From
<Input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} />
</label>
<label className="grid gap-1 text-xs text-muted-foreground">
To
<Input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} />
</label>
</div>
)}
</fieldset>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={onClose}>
Cancel
</Button>
<Button disabled={busy} onClick={() => void submit()}>
Run now
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,181 @@
import { describe, expect, it } from "vitest";
import {
ALL_AGENTS,
UNKNOWN_AGENT,
filterInbox,
findingAgents,
inboxAgents,
inboxRows,
modelsUsed,
scheduleLabel,
stepLine,
type Step,
} from "./inbox";
import type { Finding, Lens } from "./types";
const finding = (overrides: Partial<Finding>): Finding =>
({
id: "f",
check_id: "c",
kind: "issue",
status: "open",
title: "Tool errors swallowed",
description: "",
suggestion: "Retry on 5xx",
priority: "medium",
occurrences: ["run-1"],
evidence: [],
first_seen: "2026-10-01T00:00:00Z",
last_seen: "2026-10-01T00:00:00Z",
revision: 1,
...overrides,
}) as Finding;
const lens = (
id: string,
agent: string,
findings: Finding[],
{ settings = {}, runs = [] }: { settings?: Partial<Lens["settings"]>; runs?: { id: string; service: string }[] } = {},
): Lens =>
({
id,
findings,
jobs: [{ sample: { executions: runs } }],
next_run_at: "2026-10-03T12:10:00Z",
settings: { name: id, agent_name: agent, service: "", enabled: true, interval_minutes: 15, ...settings },
}) as unknown as Lens;
describe("findingAgents", () => {
it("names the agents the finding's runs were actually recorded under", () => {
const seen = lens("a", "", [], {
runs: [
{ id: "run-1", service: "support-bot" },
{ id: "run-2", service: "billing-bot" },
],
});
expect(findingAgents(seen, finding({ occurrences: ["run-2", "run-1"] }))).toEqual(["billing-bot", "support-bot"]);
});
it("falls back to the configured agent, then to an explicit unknown, when no run says", () => {
expect(findingAgents(lens("a", "support", []), finding({}))).toEqual(["support"]);
expect(findingAgents(lens("a", "", []), finding({}))).toEqual([UNKNOWN_AGENT]);
});
});
const ranked = (id: string, title: string, priority: Finding["priority"], lastSeen: string): Partial<Finding> => ({
id,
title,
priority,
last_seen: lastSeen,
});
describe("inboxRows", () => {
it("merges the same problem for the same agent across investigations into one row", () => {
const rows = inboxRows([
lens("a", "support", [finding({ id: "1", occurrences: ["run-1", "run-2"] })]),
lens("b", "support", [finding({ id: "2", title: "tool errors swallowed ", occurrences: ["run-2", "run-3"] })]),
]);
expect(rows).toHaveLength(1);
expect(rows[0].sources).toHaveLength(2);
expect(rows[0].runs).toBe(3);
});
it("keeps the same title for different agents as separate problems", () => {
expect(inboxRows([lens("a", "support", [finding({})]), lens("b", "billing", [finding({})])])).toHaveLength(2);
});
it("takes the most urgent priority and latest sighting across merged findings", () => {
const [row] = inboxRows([
lens("a", "support", [finding({ priority: "low", last_seen: "2026-10-03T00:00:00Z" })]),
lens("b", "support", [finding({ priority: "high", last_seen: "2026-10-01T00:00:00Z" })]),
]);
expect(row.priority).toBe("high");
expect(row.lastSeen).toBe("2026-10-03T00:00:00Z");
});
it("leaves out resolved, dismissed and pattern findings", () => {
const rows = inboxRows([
lens("a", "support", [
finding({ id: "1", status: "resolved", title: "a" }),
finding({ id: "2", status: "dismissed", title: "b" }),
finding({ id: "3", kind: "pattern", title: "c" }),
]),
]);
expect(rows).toEqual([]);
});
it("orders by priority, then by most recent", () => {
const rows = inboxRows([
lens("a", "x", [
finding(ranked("1", "old high", "high", "2026-10-01T00:00:00Z")),
finding(ranked("2", "new low", "low", "2026-10-03T00:00:00Z")),
finding(ranked("3", "new high", "high", "2026-10-02T00:00:00Z")),
]),
]);
expect(rows.map((r) => r.title)).toEqual(["new high", "old high", "new low"]);
});
});
const step = (overrides: Partial<Step>): Step =>
({
at: "2026-10-03T12:00:00Z",
kind: "model",
label: "Reviewed a run",
model: "gpt-5.6",
purpose: "extract",
prompt_tokens: 0,
completion_tokens: 0,
cost: 0,
...overrides,
}) as Step;
describe("filterInbox", () => {
const rows = inboxRows([
lens("a", "support", [finding({ id: "1", title: "one", priority: "high" })]),
lens("b", "billing", [finding({ id: "2", title: "two", priority: "low" })]),
]);
it("narrows to one agent and one priority, and lists every agent once", () => {
expect(filterInbox(rows, { agent: "billing", priority: "all" }).map((r) => r.title)).toEqual(["two"]);
expect(filterInbox(rows, { agent: ALL_AGENTS, priority: "high" }).map((r) => r.title)).toEqual(["one"]);
expect(filterInbox(rows, { agent: "billing", priority: "high" })).toEqual([]);
expect(inboxAgents(rows)).toEqual(["billing", "support"]);
});
});
describe("step feed helpers", () => {
it("summarizes a model call with its token count and cost", () => {
expect(stepLine(step({ prompt_tokens: 1000, completion_tokens: 200, cost: 0.0031 }))).toBe(
"Reviewed a run · 1.2k tok · $0.0031",
);
expect(stepLine(step({ kind: "stage", label: "Grouping observations" }))).toBe("Grouping observations");
});
it("lists models used, most used first, ignoring stage steps", () => {
expect(
modelsUsed([
step({ model: "claude-sonnet-5-5" }),
step({ model: "gpt-5.6" }),
step({ model: "gpt-5.6" }),
step({ kind: "stage", model: "" }),
]),
).toEqual(["gpt-5.6", "claude-sonnet-5-5"]);
});
});
describe("scheduleLabel", () => {
const now = Date.parse("2026-10-03T12:00:00Z");
it("says how often it runs and when the next run is", () => {
expect(scheduleLabel(lens("a", "x", []), now)).toBe("every 15m · next in 10m");
expect(scheduleLabel(lens("a", "x", [], { settings: { interval_minutes: 120 } }), now)).toBe(
"every 2h · next in 10m",
);
});
it("calls out paused and overdue investigations", () => {
expect(scheduleLabel(lens("a", "x", [], { settings: { enabled: false } }), now)).toBe("paused");
expect(scheduleLabel(lens("a", "x", []), Date.parse("2026-10-03T12:30:00Z"))).toBe("every 15m · due now");
});
});

View file

@ -0,0 +1,123 @@
import type { Finding, Job, Lens } from "./types";
export type Step = Job["steps"][number];
export type Priority = NonNullable<Finding["priority"]>;
export interface InboxRow {
key: string;
title: string;
agents: readonly string[];
priority: Priority;
suggestion: string;
runs: number;
lastSeen: string;
sources: readonly { lens: Lens; finding: Finding }[];
}
export interface InboxFilter {
agent: string;
priority: Priority | "all";
}
export const ALL_AGENTS = "all";
export const UNKNOWN_AGENT = "unknown agent";
const priorityRank = { high: 0, medium: 1, low: 2 } as const;
export function sampledExecutions(lens: Lens) {
return lens.jobs.flatMap((job) => job.sample?.executions ?? []);
}
export function findingAgents(lens: Lens, finding: Finding): readonly string[] {
const services = new Map(sampledExecutions(lens).map((run) => [run.id, run.service] as const));
const seen = new Set(finding.occurrences.map((id) => services.get(id)).filter((s): s is string => !!s));
if (seen.size) return [...seen].sort();
const configured = lens.settings.agent_name || lens.settings.service;
return [configured || UNKNOWN_AGENT];
}
function groupBy<T>(items: readonly T[], key: (item: T) => string): Map<string, T[]> {
return items.reduce((groups, item) => {
const k = key(item);
return groups.set(k, [...(groups.get(k) ?? []), item]);
}, new Map<string, T[]>());
}
export function inboxRows(lenses: readonly Lens[]): InboxRow[] {
const open = lenses.flatMap((lens) =>
lens.findings
.filter((f) => f.status === "open" && f.kind === "issue")
.map((finding) => ({ lens, finding, agents: findingAgents(lens, finding) })),
);
const grouped = groupBy(open, ({ agents, finding }) => `${agents.join(",")}::${finding.title.trim().toLowerCase()}`);
return [...grouped.entries()]
.map(([key, sources]): InboxRow => {
const best = sources.reduce((a, b) =>
priorityRank[a.finding.priority ?? "medium"] <= priorityRank[b.finding.priority ?? "medium"] ? a : b,
);
return {
key,
title: best.finding.title,
agents: best.agents,
priority: best.finding.priority ?? "medium",
suggestion: best.finding.suggestion,
runs: new Set(sources.flatMap((s) => s.finding.occurrences)).size,
lastSeen: sources.map((s) => s.finding.last_seen).reduce((a, b) => (Date.parse(a) > Date.parse(b) ? a : b)),
sources: sources.map(({ lens, finding }) => ({ lens, finding })),
};
})
.sort(
(a, b) => priorityRank[a.priority] - priorityRank[b.priority] || Date.parse(b.lastSeen) - Date.parse(a.lastSeen),
);
}
export function filterInbox(rows: readonly InboxRow[], { agent, priority }: InboxFilter): InboxRow[] {
const agentMatches = (row: InboxRow) => agent === ALL_AGENTS || row.agents.includes(agent);
const priorityMatches = (row: InboxRow) => priority === "all" || row.priority === priority;
return rows.filter((row) => agentMatches(row) && priorityMatches(row));
}
export function inboxAgents(rows: readonly InboxRow[]): string[] {
return [...new Set(rows.flatMap((row) => row.agents))].sort();
}
export function stepLine(step: Step): string {
if (step.kind !== "model") return step.label;
const tokens = step.prompt_tokens + step.completion_tokens;
const size = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k tok` : `${tokens} tok`;
return `${step.label} · ${size} · $${step.cost.toFixed(4)}`;
}
export function modelsUsed(steps: readonly Step[]): string[] {
const counts = groupBy(
steps.filter((s) => s.kind === "model" && s.model),
(s) => s.model,
);
return [...counts.entries()].sort((a, b) => b[1].length - a[1].length).map(([model]) => model);
}
function nextRunLabel(msUntil: number): string {
if (msUntil <= 0) return "due now";
if (msUntil < 60_000) return "next in <1m";
return `next in ${Math.ceil(msUntil / 60_000)}m`;
}
export function scheduleLabel(lens: Lens, now: number): string {
if (!lens.settings.enabled) return "paused";
const every = lens.settings.interval_minutes;
const cadence = every % 60 === 0 ? `every ${every / 60}h` : `every ${every}m`;
const next = Date.parse(lens.next_run_at) - now;
return `${cadence} · ${nextRunLabel(next)}`;
}
const WINDOW_FORMAT: Intl.DateTimeFormatOptions = {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
};
export function windowLabel(job: Job): string {
const fmt = (iso: string) => new Date(iso).toLocaleString(undefined, WINDOW_FORMAT);
return `${fmt(job.start)} → ${fmt(job.end)}`;
}

View file

@ -24,6 +24,8 @@ const coverage: Job["coverage"] = {
const job: Job = {
assessments: [],
steps: [],
trigger: "schedule",
coverage,
attempts: 0,
error: "",

View file

@ -18,6 +18,8 @@ const coverage: Job["coverage"] = {
const job: Job = {
assessments: [],
steps: [],
trigger: "schedule",
coverage,
attempts: 0,
error: "",

View file

@ -133,18 +133,18 @@ describe("Lens setup", () => {
await user.click(screen.getByRole("button", { name: /Add your own/ }));
await user.click(screen.getByRole("button", { name: "Remove check 3" }));
await user.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Run investigation" })).toBeEnabled());
await waitFor(() => expect(screen.getByRole("button", { name: "Run and monitor" })).toBeEnabled());
expect(screen.getByText("1 matching run")).toBeInTheDocument();
expect(screen.getByText("Research report")).toBeInTheDocument();
expect(screen.getByText(/2026-09-30/)).toBeInTheDocument();
expect(screen.getByRole("spinbutton", { name: "Monthly limit (USD)" })).not.toBeVisible();
await user.click(screen.getByRole("button", { name: "Run investigation" }));
await user.click(screen.getByRole("button", { name: "Run and monitor" }));
const expected = {
name: "Research follow-up",
service: "",
agent_name: "",
filters: [{ key: "swarm", value: "research" }],
enabled: false,
enabled: true,
sample_size: null,
model: "analysis",
monthly_budget: 100,
@ -323,11 +323,11 @@ it.each(["new", "duplicate"] as const)("blocks a %s investigation until its mode
await user.click(screen.getByRole("button", { name: "Continue" }));
await user.click(screen.getByRole("button", { name: "Continue" }));
expect(await screen.findByText("1 matching run")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Run investigation" })).toBeDisabled();
expect(screen.getByRole("button", { name: mode === "new" ? "Run and monitor" : "Run investigation" })).toBeDisabled();
expect(save).not.toHaveBeenCalled();
});
it("shows optional budget and repeat controls only under advanced options and saves their values", async () => {
it("keeps a duplicated investigation's schedule off and saves the interval once switched on", async () => {
const user = userEvent.setup();
const save = vi.fn().mockResolvedValue(undefined);
renderWithProviders(
@ -343,17 +343,39 @@ it("shows optional budget and repeat controls only under advanced options and sa
await user.click(screen.getByRole("button", { name: "Continue" }));
await user.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Run investigation" })).toBeEnabled());
expect(screen.getByRole("checkbox", { name: "Keep watching for new traces" })).not.toBeChecked();
await user.click(screen.getByText("Advanced options"));
expect(screen.getByRole("checkbox", { name: "Repeat this investigation" })).not.toBeChecked();
fireEvent.change(screen.getByRole("spinbutton", { name: "Monthly limit (USD)" }), { target: { value: "8" } });
await user.click(screen.getByRole("checkbox", { name: "Repeat this investigation" }));
fireEvent.change(screen.getByRole("spinbutton", { name: "Repeat every" }), { target: { value: "120" } });
await user.click(screen.getByRole("checkbox", { name: "Keep watching for new traces" }));
fireEvent.change(screen.getByRole("spinbutton", { name: "Check every" }), { target: { value: "120" } });
await user.click(screen.getByRole("button", { name: "Run and monitor" }));
expect(save).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true, interval_minutes: 120, monthly_budget: 8 }),
);
});
it("watches new investigations every 15 minutes by default, outside advanced options", async () => {
const user = userEvent.setup();
const save = vi.fn().mockResolvedValue(undefined);
renderWithProviders(
<InvestigationSetupDialog
models={["analysis"]}
defaultModel="analysis"
accessToken="test"
onClose={vi.fn()}
onSave={save}
/>,
);
await user.click(screen.getByRole("button", { name: "Continue" }));
await user.click(screen.getByRole("button", { name: "Continue" }));
const watching = await screen.findByRole("checkbox", { name: "Keep watching for new traces" });
expect(watching).toBeChecked();
expect(watching).toBeVisible();
await waitFor(() => expect(screen.getByRole("button", { name: "Run and monitor" })).toBeEnabled());
await user.click(screen.getByRole("button", { name: "Run and monitor" }));
expect(save).toHaveBeenCalledWith(expect.objectContaining({ enabled: true, interval_minutes: 15 }));
});
it("does not silently analyze everything after individual selection is enabled", async () => {
const user = userEvent.setup();
vi.mocked(apiClient.post).mockResolvedValue({
@ -510,8 +532,8 @@ describe("Watch for", () => {
expect(tile("unhappy")).toHaveAttribute("aria-pressed", "false");
expect(tile("looping")).toHaveAttribute("aria-pressed", "true");
await user.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Run investigation" })).toBeEnabled());
await user.click(screen.getByRole("button", { name: "Run investigation" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Run and monitor" })).toBeEnabled());
await user.click(screen.getByRole("button", { name: "Run and monitor" }));
const saved = (save.mock.calls[0][0] as Settings).checks.map((check) => check.id);
expect(saved).toEqual(["watch_unsolved", "watch_blocked", "watch_looping"]);
});
@ -558,7 +580,7 @@ describe("Watch for", () => {
);
await user.click(screen.getByRole("button", { name: "Continue" }));
await user.click(screen.getByRole("button", { name: "Continue" }));
expect(await screen.findByRole("button", { name: "Run investigation" })).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "Run and monitor" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Back" }));
for (const name of ["unsolved", "blocked", "unhappy"]) await user.click(tile(name));
await user.click(screen.getByRole("button", { name: "Continue" }));

View file

@ -193,8 +193,8 @@ export function investigationDefaults(
questions: (initial?.checks ?? []).filter((check) => !isWatch(check)),
selectedModel: initial?.model ?? null,
budget: initial?.monthly_budget ?? 100,
repeat: mode === "edit" && !!initial?.enabled,
interval: initial?.interval_minutes ?? 30,
repeat: mode === "new" || (mode === "edit" && !!initial?.enabled),
interval: initial?.interval_minutes ?? 15,
manualSelection: !!initial?.execution_ids?.length,
};
}

View file

@ -34,6 +34,26 @@ export function RunStep({
const repeat = useWatch({ control, name: "repeat" });
return (
<>
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm font-medium">
<input type="checkbox" className="size-4 rounded border-input accent-foreground" {...register("repeat")} />
Keep watching for new traces
</label>
{repeat && (
<Controller
control={control}
name="interval"
render={({ field }) => (
<DurationInput label="Check every" value={field.value} onChange={field.onChange} base="minutes" />
)}
/>
)}
{errors.interval?.message && (
<p role="alert" className="text-sm text-destructive">
{errors.interval.message}
</p>
)}
</div>
<details open={!modelValid || undefined}>
<summary className="cursor-pointer text-sm font-medium">Advanced options</summary>
<div className="mt-4 space-y-5">
@ -87,30 +107,6 @@ export function RunStep({
</p>
)}
</label>
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
className="size-4 rounded border-input accent-foreground"
{...register("repeat")}
/>
Repeat this investigation
</label>
{repeat && (
<Controller
control={control}
name="interval"
render={({ field }) => (
<DurationInput label="Repeat every" value={field.value} onChange={field.onChange} base="minutes" />
)}
/>
)}
{errors.interval?.message && (
<p role="alert" className="text-sm text-destructive">
{errors.interval.message}
</p>
)}
</div>
</div>
</div>
</details>

View file

@ -8968,6 +8968,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/lens/watch-all": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Watch All */
post: operations["watch_all_lens_watch_all_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/lens/worker/claim": {
parameters: {
query?: never;
@ -33344,6 +33361,17 @@ export interface components {
* @enum {string}
*/
status: "queued" | "running" | "completed" | "failed" | "cancelled";
/**
* Steps
* @default []
*/
steps: components["schemas"]["Step"][];
/**
* Trigger
* @default schedule
* @enum {string}
*/
trigger: "schedule" | "manual";
/** Worker Id */
worker_id?: string | null;
};
@ -44240,9 +44268,15 @@ export interface components {
};
/** RunRequest */
RunRequest: {
/** Agent Name */
agent_name?: string | null;
/** End */
end?: string | null;
/** Lookback Hours */
lookback_hours?: number | null;
settings?: components["schemas"]["LensSettings"] | null;
/** Start */
start?: string | null;
};
/** SCIMEnterpriseUser */
SCIMEnterpriseUser: {
@ -45573,6 +45607,46 @@ export interface components {
*/
user_ids: string[];
};
/** Step */
Step: {
/**
* At
* Format: date-time
*/
at: string;
/**
* Completion Tokens
* @default 0
*/
completion_tokens: number;
/**
* Cost
* @default 0
*/
cost: number;
/**
* Kind
* @enum {string}
*/
kind: "stage" | "model" | "error";
/** Label */
label: string;
/**
* Model
* @default
*/
model: string;
/**
* Prompt Tokens
* @default 0
*/
prompt_tokens: number;
/**
* Purpose
* @default
*/
purpose: string;
};
/**
* SuccessfulKeyUpdate
* @description Successfully updated key with its updated information
@ -49334,6 +49408,25 @@ export interface components {
} & {
[key: string]: unknown;
};
/** WatchAllResult */
WatchAllResult: {
/**
* Skipped
* @default []
*/
skipped: components["schemas"]["WatchSkipped"][];
/** Watching */
watching: string[];
};
/** WatchSkipped */
WatchSkipped: {
/** Id */
id: string;
/** Name */
name: string;
/** Reason */
reason: string;
};
/**
* WebSearchInterceptionSettings
* @description Configuration for server-side web search interception
@ -62274,6 +62367,26 @@ export interface operations {
};
};
};
watch_all_lens_watch_all_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["WatchAllResult"];
};
};
};
};
claim_lens_worker_claim_post: {
parameters: {
query?: {