mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Preserve Patrol evidence and surface action submission failures
Live funded qualification found hidden tool results and misleading action submission outcomes. Share the result-bearing transcript across stored chat and product history, render the retained evidence, and distinguish captured proposals from broker acceptance. Keep review usable while Patrol is paused. Record Gemini route pricing and exact qualification limits. Integrate current main and repeat browser proof for the incoming login flow. Approved/rejected recovery remains unqualified without the development command agent.
This commit is contained in:
@@ -93,12 +93,13 @@ Each removal must run its focused regression and affected complete journey.
|
||||
### Completion and external dependencies
|
||||
|
||||
The local implementation goal remains open until required qualification is
|
||||
performed. Ordinary Assistant requests work with the current subscription route,
|
||||
but autonomous Patrol has an explicit provider-policy refusal and remains
|
||||
blocked. Do not rephrase the refused probe, bypass the readiness boundary or
|
||||
count an interactive request as an autonomous Patrol pass. A supported provider
|
||||
path is required for that qualification. Prepare other work while resolving the
|
||||
provider dependency through supported configuration.
|
||||
performed. The maintainer authorized Gemini 3.8 Flash through OpenRouter with a
|
||||
US$5 key limit and one-day expiry on 2026-09-06. That supported route passes the
|
||||
streaming readiness and initial live Watch and dependency cases recorded below.
|
||||
The earlier Claude subscription refusal belongs to the exact synthetic
|
||||
continuation request. It does not establish a blanket restriction on autonomous
|
||||
monitoring. The refused request has not been retried or rephrased. Readiness is
|
||||
not evidence that diagnosis, action execution or independent recovery succeeds.
|
||||
|
||||
Release publication and wider product readiness are separate. Independent
|
||||
volunteered Pro environments are still required before claiming repeatable
|
||||
@@ -2047,3 +2048,114 @@ report to Pulse, enroll or replace a production agent, call a provider, or
|
||||
qualify approval, execution, diagnosis or model recovery. No runtime or frontend
|
||||
source changed in this slice, so no new browser claim is made. Prior storage
|
||||
diagnosis failures and the cached autonomous-provider refusal remain open.
|
||||
|
||||
|
||||
## Funded Gemini qualification, 2026-09-06
|
||||
|
||||
The maintainer authorized `openrouter:google/gemini-3.8-flash` with a provider-side
|
||||
US$5 key limit expiring on 2026-09-07. The provider key endpoint confirmed both
|
||||
constraints. Credentials remain in runtime configuration, not these receipts.
|
||||
Synthetic readiness passed in 9.034 seconds: three streaming tool scenarios,
|
||||
two context fixtures and multi-turn continuation. This supports the readiness
|
||||
claims for Watch only and Ask first. It does not qualify autonomous fixes.
|
||||
|
||||
The first unhealthy-container run, `q-20260906-172952-3cccbf34`, detected the
|
||||
correct fault and left the healthy control alone, but failed overall. The exact
|
||||
Gemini route had no price entry, and the model attempted unsupported Docker
|
||||
configuration access. The shared price table now records the reviewed standard
|
||||
rates of US$0.75 input and US$3.75 output per million tokens for direct Gemini
|
||||
and OpenRouter. Variant routes remain unknown. These introductory rates must be
|
||||
reviewed on 2027-01-01. The query capability description now explicitly names
|
||||
TrueNAS as the supported app-container configuration adapter and directs Docker
|
||||
collected health/mount/port/network reads to `get`. Runtime permissions and
|
||||
qualification gates are unchanged.
|
||||
|
||||
The following runs used the worker-built Pro binary
|
||||
`74464e75977caf55cda092c8cf56c24967616c8c24d770872fbea5d86e31a1dc`,
|
||||
core base `b0b39f00dc6685ad9ed63e8a6e91b954338073e4` plus the pricing and
|
||||
capability-description changes, and canonical enterprise base
|
||||
`3d9f4e3051d38027355a2a1f36b8c7f672a09b65`. The worker archive commit
|
||||
`d9cb84e15d1acc377341129bdda5c28176e7128c` has identical contents for all
|
||||
87 tracked enterprise files. The existing runner created disposable
|
||||
resources on Tower, waited for normal collection, and used independent fault,
|
||||
recovery and cleanup oracles.
|
||||
|
||||
| Case / run | Result | Evidence |
|
||||
|---|---|---|
|
||||
| Unhealthy, `q-20260906-174546-a7a9810b` | Pass | 9.709s detection phase, two tools, no failed/duplicate calls, healthy sibling unflagged. |
|
||||
| Unhealthy, `q-20260906-174708-81f8d655` | Pass | 10.541s detection phase, exact unhealthy resource found. |
|
||||
| Unhealthy, `q-20260906-174758-14deaa15` | Pass | 9.395s detection phase, exact unhealthy resource found. |
|
||||
| Unhealthy, `q-20260906-174853-71cf894f` | Pass | 25.673s detection phase, exact unhealthy resource found. |
|
||||
| Healthy mixed, `q-20260906-180144-381874a6` | Pass | 5.200s detection phase, no false findings. |
|
||||
| Dependency, `q-20260906-175058-de5e350d` | Pass | Starting from only the client symptom, identified the stopped dependency and affected client. Investigation completed in 18.970s with three evidence calls and no mutation. |
|
||||
| Storage, `q-20260906-175232-3ce6fbfa` | Fail before inference | Normal collection never converged to the required resource projection. No model diagnosis was attempted. |
|
||||
| Approved restart, `q-20260906-175812-0593b9b7` | Fail before approval | Detection and investigation completed, but no exact action reference existed. The broker refused because Tower's Docker command agent was disconnected. Nothing executed. |
|
||||
| Rejected restart, `q-20260906-175940-bff6992a` | Fail before rejection | No exact action was available to reject. This does not qualify rejected-action handling. |
|
||||
|
||||
Every listed run passed cleanup, including second-cleanup no-op and unchanged
|
||||
inventory. Individual Watch run estimates were about US$0.007 to US$0.014.
|
||||
Those scorecard estimates cover the Patrol detection phase, not the separate
|
||||
investigation calls. Provider-side aggregate spend is the budget authority for
|
||||
this temporary key. The fixed route price does not turn an estimate into a
|
||||
reconciled bill or establish a hard Pulse budget for unpriced history.
|
||||
|
||||
Live qualification exposed two additional shared contract defects. The
|
||||
investigation orchestrator logged action-broker refusal but completed the
|
||||
record without retaining the error, leaving the model's captured-proposal prose
|
||||
visible without the later refusal. The current enterprise change retains the
|
||||
original diagnosis, persists the broker refusal as a failed investigation with
|
||||
`needs_attention`, and creates no action reference. The product history adapter
|
||||
also projected result-bearing transcript calls back into provider request calls,
|
||||
dropping observed output and success/failure. The current core change uses one
|
||||
shared transcript type for stored chat and product history, preserving the
|
||||
separate explicit provider projection.
|
||||
|
||||
The live review exposed duplicate detail IDs, duplicate unformatted conclusions,
|
||||
paused history made unclickable by the scheduling switch, and narrow filter/sort
|
||||
overlap. The shared finding/investigation surfaces now preserve one detail target,
|
||||
render sanitized Markdown once for identical summaries, retain distinct summaries,
|
||||
keep history available while paused, and wrap controls. Result-bearing tool calls
|
||||
use the same expandable evidence component as Assistant. Historical calls without
|
||||
a result status retain their evidence without invented success or failure. An
|
||||
investigation outcome of `cannot_fix` or `needs_attention` does not identify who
|
||||
resolved the finding, so the shared resolution copy no longer infers manual review.
|
||||
|
||||
Private run receipts and source/binary bindings are under
|
||||
`tmp/patrol-gemini-38/` in the workspace. The original failed runs remain failed.
|
||||
Installed storage collection and a temporary command-enabled lab agent remain
|
||||
prerequisites for real storage and approved/rejected recovery qualification.
|
||||
No production agent has been replaced. Full action outcomes, remaining backup
|
||||
coverage and independent volunteered Pro environments remain open.
|
||||
|
||||
|
||||
### Final refusal and evidence-retention proof
|
||||
|
||||
Two further approved-remediation attempts remain **failed**:
|
||||
`q-20260906-181839-6cbdc711` and `q-20260906-182952-6acfb739`. Both retained the
|
||||
broker error separately from the original model summary, saved `status=failed`
|
||||
and `outcome=needs_attention`, and created no action reference. Both passed
|
||||
cleanup. The final run used Pro binary SHA256
|
||||
`24de8c9ea0020067d298c489f0d99a272b5ec4b00afab7a40dff55aabb244061`,
|
||||
including the proposal-response clarification, and detected its exact unhealthy
|
||||
container with no false positives. Its saved investigation is
|
||||
`48a16b05-50f0-4605-847c-0a71b3435975` for finding `ca3af29ac54d540f`.
|
||||
The original failed scorecards have not been reclassified as action passes.
|
||||
|
||||
The restored history API retains observed outputs and explicit `success=false`
|
||||
for historical `pulse_read` failures. Live browser review at `/patrol` exercises
|
||||
successful query output, both `ACTION_NOT_ALLOWED` and `NO_AGENT` failures,
|
||||
original diagnosis, one broker error, paused history and review focus return.
|
||||
The settings proof at `/settings/pulse-intelligence/patrol` checks the exact
|
||||
model, reviewed rates, synthetic readiness limits and reload. The current
|
||||
source-bound browser receipt records desktop, intermediate and mobile results.
|
||||
GET response fixtures cover unknown historical result status only, without
|
||||
claiming new persisted model evidence or action execution.
|
||||
|
||||
Remaining qualification requires a current installed collector and a temporary
|
||||
command-enabled lab agent. A Linux amd64 agent has been built on the worker,
|
||||
SHA256 `ae2ed8b97709ec6e71af979c293ca9d3634662767ec1b59629baf4933c90cf5d`,
|
||||
without installing it or changing Tower credentials. Tower's separate production
|
||||
reporting agent is untouched. Any agent enrollment must use the canonical scoped
|
||||
installation flow, preserve explicit identity and revoke temporary execution
|
||||
access after qualification. Detection success does not satisfy this prerequisite
|
||||
or the remaining backup and independent-environment cases.
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
"prerelease_branch": "release/v6.4",
|
||||
"stable_branch": "release/v6.4"
|
||||
},
|
||||
{
|
||||
"version_prefix": "6.4.4",
|
||||
"prerelease_branch": "release/v6.4",
|
||||
"stable_branch": "release/v6.4"
|
||||
},
|
||||
{
|
||||
"version_prefix": "6.5.",
|
||||
"prerelease_branch": "release/v6.5",
|
||||
|
||||
@@ -282,6 +282,19 @@ without the other lanes changing the candidate underneath it.
|
||||
moving `main` can no longer invalidate the compiler's exact-SHA binding
|
||||
between dispatch and compilation, which is what failed run 33579042375.
|
||||
Earlier `6.4.x` versions keep their historical `main` mapping.
|
||||
8. The forward regression checkpoint `v6.4.4-beta.1` uses the same
|
||||
`release/v6.4` line, with an explicit `6.4.4` mapping for beta, RC and
|
||||
eventual stable. This is a maturity reset, not new feature scope: a
|
||||
`6.4.3-beta.N` would sort below the published `v6.4.3-rc.1`.
|
||||
`v6.4.4-beta.1` advances both that preview and stable `v6.4.1`;
|
||||
`v6.4.1` remains the rollback target. Later qualification proceeds through
|
||||
`v6.4.4-rc.N` and exact same-version stable promotion, including a fresh
|
||||
72-hour clean RC soak. Beta time does not count. Mapping is preparation,
|
||||
not readiness or publication authority: failed candidate checks still
|
||||
require repair or an evidence-based disposition under the existing gates.
|
||||
Land the mapping and resolver contract on canonical main and backport it
|
||||
to the release line before taking a fresh bound packet. Unlisted patches
|
||||
and new product work retain their existing mapping and scope.
|
||||
|
||||
## Paid Pro Artifact Lineage
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -25,6 +25,46 @@ that same result. Successful reads retain their content and execution provenance
|
||||
|
||||
## Purpose
|
||||
|
||||
Stored chat and product history share the result-bearing `TranscriptToolCall`
|
||||
contract. API adapters preserve observed output and the explicit success/error
|
||||
bit. Only provider-request projections remove those display fields. A failed
|
||||
read must not become an invocation with no visible result on the way to Patrol
|
||||
or Assistant history. The adapter regression includes a `NO_AGENT` result and
|
||||
`success: false`, and provider serialization retains its existing narrower shape.
|
||||
|
||||
Capturing a typed proposal does not create an action. The proposal response
|
||||
discloses that broker validation is still pending, without forcing the model to
|
||||
stop investigating. If the broker later refuses submission, the enterprise
|
||||
orchestrator retains the model's diagnosis unchanged and records the broker
|
||||
error as a failed investigation needing attention, with no action reference.
|
||||
The real disconnected-agent case must remain unsuccessful until its actual
|
||||
transport prerequisite is satisfied. A successful model turn or recorded
|
||||
proposal is not approval, execution or recovery.
|
||||
|
||||
The shared investigation review renders sanitized Markdown and does not repeat
|
||||
an identical persisted/fetched conclusion or error. Distinct evidence remains
|
||||
visible. Pausing scheduled Patrol does not disable history review. The review
|
||||
control has one detail target and returns keyboard focus when closed. Merged tool
|
||||
results use Assistant's shared expandable evidence component. Historical calls
|
||||
without an explicit result bit do not gain an inferred success/failure state.
|
||||
Resolution copy cannot infer manual review from `needs_attention` or `cannot_fix`.
|
||||
|
||||
The shared pricing table includes reviewed standard Gemini 3.8 Flash rates for
|
||||
the exact direct and OpenRouter routes. OpenRouter variants and aliases remain
|
||||
unpriced until independently reviewed. Rates carry the review date and are
|
||||
estimates, not reconciled provider charges. The introductory rates require a
|
||||
new review on 2027-01-01. `TestGemini38FlashReviewedRoutePricing` covers real
|
||||
qualification token counts and request-route preservation, and
|
||||
`TestGemini38OpenRouterPricingDoesNotGuessVariantRates` preserves unknown variants.
|
||||
|
||||
The canonical query tool describes the app-container configuration boundary
|
||||
explicitly: TrueNAS supports `config`, while Docker/Podman expose their collected
|
||||
health, mounts, ports and networks through `get`. This communicates the existing
|
||||
adapter contract to the model. It does not add configuration access, suppress
|
||||
tool errors or weaken qualification gates. The existing
|
||||
`TestAppContainerConfigObservationContract` retains unsupported-adapter and
|
||||
provider/identity boundaries.
|
||||
|
||||
Shared app-container query mount evidence preserves native type, source,
|
||||
destination, options and canonical read/write access. Compound options such as
|
||||
`ro,noexec` cannot become writable through string equality heuristics. Both the
|
||||
@@ -837,6 +877,7 @@ cheap local detection into model-owned diagnosis and governed action.
|
||||
31. `internal/agentcapabilities/tool_names.go` shared with `api-contracts`: the Pulse Intelligence registry tool-name vocabulary is both the native Assistant execution/display contract and the canonical API/agent tool identity contract for MCP-facing external-agent adapters.
|
||||
32. `internal/agentcapabilities/tool_response.go` shared with `api-contracts`: the shared tool response envelope, tool error-code vocabulary, and tool-result error-code and verification evidence parsers are both the Assistant structured tool-result contract and the canonical API/agent branching contract for Pulse Intelligence tool failures, recovery tracking, and write self-verification.
|
||||
33. `internal/agentcapabilities/tool_result.go` shared with `api-contracts`: the Pulse Intelligence shared tool-result content/result envelope, structuredContent projection, result constructors, HTTP response-to-result mapping, text projection, and result interpretation helpers are both the Assistant registry result contract and the canonical API/agent result projection contract for governed tool outcomes.
|
||||
34. `internal/agentcapabilities/transcript.go` shared with `api-contracts`: Stored Assistant tool results and product history share one result-bearing transcript contract, with an explicit narrower provider-request projection. Observed failures and absent historical result status must survive the API boundary.
|
||||
34. `internal/agentcapabilities/types.go` shared with `api-contracts`: the agent capabilities manifest wire type, manifest-owned external-adapter surface tool contract field, capability display title and structured output schema fields, approval-policy vocabulary, capability governance normalization, and tool-governance descriptor shape are both the canonical API payload contract and the AI runtime projection contract for Pulse Assistant and MCP-facing agent tools.
|
||||
35. `internal/agentcapabilities/workflow_prompt.go` shared with `api-contracts`: the Pulse Intelligence workflow prompt catalogue, manifest-owned `workflowPrompts` projection, MCP prompt title projection, presentation kind hints, shared resource-context and finding argument vocabulary, Patrol issue-handling capability gating, argument validation, and manifest-gated shared prompt rendering rules are both the AI runtime starter contract for Assistant-compatible surfaces and the canonical API/agent prompt projection contract for MCP-facing clients.
|
||||
36. `internal/api/ai_handler.go` shared with `api-contracts`: Pulse Assistant handlers are both an AI runtime control surface and a canonical API payload contract boundary.
|
||||
|
||||
@@ -20,6 +20,14 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Product history retains the stored result-bearing `TranscriptToolCall` contract,
|
||||
including observed output and an optional success bit. A false bit is retained,
|
||||
and an absent historical result bit stays absent. Provider requests use the
|
||||
explicit narrower projection. The history adapter cannot project away evidence
|
||||
by treating a display transcript as request arguments. The frontend Patrol API
|
||||
extends the shared Assistant tool-call shape, and transport regressions pin
|
||||
failed output and unknown historical status across the message envelope.
|
||||
|
||||
The internal Patrol bridge preserves explicit execution limits, scoped tool
|
||||
allowlists and execution identity. The retired unmatched-signal evaluator no
|
||||
longer contributes a signal-count-derived successful-report budget. Diagnosis
|
||||
@@ -1683,6 +1691,7 @@ payload shape change when the portal presents compact client rows.
|
||||
57. `internal/agentcapabilities/tool_names.go` shared with `ai-runtime`: the Pulse Intelligence registry tool-name vocabulary is both the native Assistant execution/display contract and the canonical API/agent tool identity contract for MCP-facing external-agent adapters.
|
||||
58. `internal/agentcapabilities/tool_response.go` shared with `ai-runtime`: the shared tool response envelope, tool error-code vocabulary, and tool-result error-code and verification evidence parsers are both the Assistant structured tool-result contract and the canonical API/agent branching contract for Pulse Intelligence tool failures, recovery tracking, and write self-verification.
|
||||
59. `internal/agentcapabilities/tool_result.go` shared with `ai-runtime`: the Pulse Intelligence shared tool-result content/result envelope, structuredContent projection, result constructors, HTTP response-to-result mapping, text projection, and result interpretation helpers are both the Assistant registry result contract and the canonical API/agent result projection contract for governed tool outcomes.
|
||||
60. `internal/agentcapabilities/transcript.go` shared with `ai-runtime`: Stored Assistant tool results and product history share one result-bearing transcript contract, with an explicit narrower provider-request projection. Observed failures and absent historical result status must survive the API boundary.
|
||||
60. `internal/agentcapabilities/types.go` shared with `ai-runtime`: the agent capabilities manifest wire type, manifest-owned external-adapter surface tool contract field, capability display title and structured output schema fields, approval-policy vocabulary, capability governance normalization, and tool-governance descriptor shape are both the canonical API payload contract and the AI runtime projection contract for Pulse Assistant and MCP-facing agent tools.
|
||||
61. `internal/agentcapabilities/workflow_prompt.go` shared with `ai-runtime`: the Pulse Intelligence workflow prompt catalogue, manifest-owned `workflowPrompts` projection, MCP prompt title projection, presentation kind hints, shared resource-context and finding argument vocabulary, Patrol issue-handling capability gating, argument validation, and manifest-gated shared prompt rendering rules are both the AI runtime starter contract for Assistant-compatible surfaces and the canonical API/agent prompt projection contract for MCP-facing clients.
|
||||
62. `internal/api/access_control_handlers.go` shared with `organization-settings`: RBAC role and user-assignment handlers are both an organization settings control surface and a canonical API payload contract boundary.
|
||||
|
||||
@@ -15,6 +15,16 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Pausing the Patrol schedule does not disable investigation history review.
|
||||
The finding review control owns one detail target and regains keyboard focus on
|
||||
close. Investigation conclusions use sanitized Markdown, deduplicate identical
|
||||
stored/fetched text, and retain distinct evidence. Merged tool results render
|
||||
through Assistant's shared expandable evidence component with their explicit
|
||||
success/failure bit. Unknown historical status stays unknown. A broker refusal
|
||||
remains visible separately from the original diagnosis and cannot be presented
|
||||
as an accepted action. An attention/cannot-fix outcome does not establish manual
|
||||
review or identify who resolved a later finding.
|
||||
|
||||
Detection retains one model conversation for evidence gathering and finding
|
||||
decisions. Recording one finding does not establish diagnostic sufficiency or
|
||||
remove its evidence tools. Missing assessments and provider failures remain
|
||||
|
||||
@@ -779,6 +779,14 @@
|
||||
"api-contracts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "internal/agentcapabilities/transcript.go",
|
||||
"rationale": "Stored Assistant tool results and product history share one result-bearing transcript contract, with an explicit narrower provider-request projection. Observed failures and absent historical result status must survive the API boundary",
|
||||
"subsystems": [
|
||||
"ai-runtime",
|
||||
"api-contracts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "internal/agentcapabilities/types.go",
|
||||
"rationale": "the agent capabilities manifest wire type, manifest-owned external-adapter surface tool contract field, capability display title and structured output schema fields, approval-policy vocabulary, capability governance normalization, and tool-governance descriptor shape are both the canonical API payload contract and the AI runtime projection contract for Pulse Assistant and MCP-facing agent tools",
|
||||
|
||||
@@ -1,39 +1,49 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "ab6d21400074393379a78350249ba4f5412941c0",
|
||||
"verified_at": "2026-09-06T16:30:47.586Z",
|
||||
"base_sha": "b0b39f00dc6685ad9ed63e8a6e91b954338073e4",
|
||||
"verified_at": "2026-09-06T18:44:39.842657Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/alerts/deliveryDiagnosisPresentation.ts"
|
||||
"frontend-modern/src/api/patrol.ts",
|
||||
"frontend-modern/src/components/AI/FindingsPanel.tsx",
|
||||
"frontend-modern/src/components/Login.tsx",
|
||||
"frontend-modern/src/components/patrol/InvestigationMessages.tsx",
|
||||
"frontend-modern/src/components/patrol/InvestigationSection.tsx",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx",
|
||||
"frontend-modern/src/useAppRuntimeState.ts",
|
||||
"frontend-modern/src/utils/aiFindingPresentation.ts",
|
||||
"frontend-modern/src/utils/localStorage.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/alerts/deliveryDiagnosisPresentation.ts": "bc3eadf8f790517b377430a42eb67e8fdfcadb53bb13b87cd971d6a9ab91d607"
|
||||
"frontend-modern/src/api/patrol.ts": "3e5bec5d29d9218450c3fb5b531fd17652759c325e049bde497e3fb96544a622",
|
||||
"frontend-modern/src/components/AI/FindingsPanel.tsx": "1063492593361f29a88109641d76fc2492027cd23b470452dfa4f437fa325700",
|
||||
"frontend-modern/src/components/Login.tsx": "621f0b97d775aacaa521a3c72ed02db3fde5ad3eda06d55950a32107723eb44c",
|
||||
"frontend-modern/src/components/patrol/InvestigationMessages.tsx": "4518f4755ff3d7dcdd0d8030372011e3481726db27ef695a9afc1d4ea6af3976",
|
||||
"frontend-modern/src/components/patrol/InvestigationSection.tsx": "32669694f6dc959c3c3684f17a4ecb4c3d9411f7fc2ae20f1192d68b1d87e52b",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "1e52d00e61fb6affb9365cee74eb6820135d1d6d8a20161d02499ab3d6084da5",
|
||||
"frontend-modern/src/useAppRuntimeState.ts": "5a1d343d92c441e1302dab129be7256dd9a287c6a60fb0e17c6f9fa13fe20900",
|
||||
"frontend-modern/src/utils/aiFindingPresentation.ts": "c716d8b61501acec2bfde253febd4ddce6e9dfee9eef77a3230a2512326dfd7c",
|
||||
"frontend-modern/src/utils/localStorage.ts": "182ed45685228781fd32725db50611b1115f9ee4fb1af23aeedcf41ca707bdbc"
|
||||
},
|
||||
"backend_content_sha256": {
|
||||
"internal/ai/adapters/adapters.go": "487245a52e1ae85ffecd38c6e4f006bfd6c8efff6568c92c19688a8db56d7ecc",
|
||||
"internal/ai/chat/service.go": "4f130864717c5141ce974b8a367aeca19d49baa02a4906ecb65591d8b4c02bd6",
|
||||
"internal/ai/tools/executor.go": "3a30f7720d30be6fdd75f3224d2da937881e77fee76fb819347f83e247fccb65",
|
||||
"internal/ai/tools/tools_knowledge.go": "12c16ed7893dd49c18a7db1dd3fe3224c266102e9a23c37bc6b86fe5b33b9e4c",
|
||||
"internal/api/ai_handler.go": "a407a5e55320b8e5d41d121193dc654ef02f70bd7b4566948bf4cd0a5763470a",
|
||||
"internal/api/ai_handlers.go": "e3963c5a564432e9ee590f3c3db4a2220abc435cd78f6b14213559423a0283f6",
|
||||
"internal/api/ai_intelligence_handlers.go": "f3864f8a53a1adaa2f64983279c3349dfff1e3095fe2ee8843ea22afe3dab577",
|
||||
"internal/api/router.go": "cb5a99f8d12a7b576bf606f76fb4dfe6d1db888305249879268e6464efcb7c3f",
|
||||
"internal/metrics/incident_archive.go": "2843483fca0bbafa4c6ff14b419bd599f6aeac7e20025b604cadbf16bddeed7a"
|
||||
"internal/agentcapabilities/transcript.go": "356c4ca201470407988ff9b2c1fb848619ed38e9d8db844e7390adce0f93ec19",
|
||||
"internal/ai/chat/types.go": "1f624daf7e511eb971e2d81b787dcd72b2a82d0e0ecd775aa4e580c59d915382",
|
||||
"internal/ai/service.go": "25dca2a70a985e8ab07443444a9f05f01a569c62ff7bc068a0b587d500831de6",
|
||||
"internal/api/chat_service_adapter.go": "6d0ab14456b1901c5020a408de95796ece1aa8d1057dd58737ea2637777d6ccb",
|
||||
"internal/ai/cost/pricing.go": "7b64bc881311ee0a7c1c8a319fcd162974f5a307ced1679e614ff20ac1ac532c",
|
||||
"internal/ai/tools/tools_query.go": "3e074b204a8c8c4f8b66eaf147c269a2e57739bfc69d03ea36ee8c47cf8b908a",
|
||||
"internal/ai/tools/tools_propose.go": "43d720c78a010b72f53f7e4e9e1b7b2a763e7f1ac555edb921b7cdca3e82fd51"
|
||||
},
|
||||
"removed_backend_paths": [
|
||||
"internal/ai/incident_coordinator.go",
|
||||
"internal/metrics/incident_recorder.go"
|
||||
],
|
||||
"binary_sha256": "bd29e6f27be7b3ad4cfbc37842f4da90f08c6a48c9fc23b12c9c597b67346c9b",
|
||||
"rendering_content_sha256": {
|
||||
"frontend-modern/src/components/AI/Chat/ChatMessages.tsx": "9672f7608d1e3a531c73cba20fd4a78752316783212afd0c292ddfd11d2bf371",
|
||||
"frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx": "cc7bd548a418c3863486f0fe987c5c3110c2f6cdfa70b630b2ec05fb794d60d6",
|
||||
"frontend-modern/src/components/AI/Chat/hooks/useChat.ts": "0b56b7a56e35d51ca96f0e126dd493b3164aa9e0ad4d8ae24bcf3af7a574b97c",
|
||||
"frontend-modern/src/features/alerts/deliveryDiagnosisPresentation.ts": "bc3eadf8f790517b377430a42eb67e8fdfcadb53bb13b87cd971d6a9ab91d607"
|
||||
"enterprise_base_sha": "3d9f4e3051d38027355a2a1f36b8c7f672a09b65",
|
||||
"enterprise_content_sha256": {
|
||||
"internal/investigation/orchestrator.go": "d56fd512dc47f8a2453559e89863da5d24dffc0a5977abb8f1ea7a82655cd9e8"
|
||||
},
|
||||
"binary_sha256": "24de8c9ea0020067d298c489f0d99a272b5ec4b00afab7a40dff55aabb244061",
|
||||
"routes": [
|
||||
"/patrol",
|
||||
"http://127.0.0.1:5198/qualification (isolated real OverviewTab)"
|
||||
"/settings/pulse-intelligence/patrol",
|
||||
"/settings/pulse-intelligence/provider",
|
||||
"/"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
@@ -50,13 +60,15 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Five captured registered-tool archive cases: saved observation with historical status and nanosecond duration disclosure, unavailable archive, malformed archive, wrong resource and missing window. Failed reads remain failed.",
|
||||
"Incoming Overview dispatch wording: ready/cooldown records show Dispatch requested, cooldown identifies next eligibility, and missing dispatch timestamp stays Notification pending. No delivery success is inferred.",
|
||||
"Live canonical and legacy homelab timeline queries retain the same seven records and preserve the exact original alert fired/resolved records. The incidents endpoint returns active_count=null and active_count_status=not_measured. Provider refusal remains enforced."
|
||||
"Live successful dependency diagnosis renders sanitized headings once, with distinct summaries retained. Paused history remains interactive and narrow finding controls wrap. Resolution does not infer manual review from an attention outcome.",
|
||||
"Live broker refusal appears once alongside original diagnosis, needs_attention and no action. Historical successful query and failed ACTION_NOT_ALLOWED/NO_AGENT results retain actual input, output and status.",
|
||||
"Controlled GET transcript fixture retains output with absent success status without inventing completion or failure. This proves rendering only.",
|
||||
"Selected exact Gemini 3.8 Flash route, known $0.75/$3.75 rates, readiness for Watch only/Ask first and unassessed autonomous modes survive reload. Provider card read only. Browser interception blocks automatic preflight POST, so provider health was verified separately through the live API.",
|
||||
"Incoming unchanged demo login source rechecked at 1440/390: loading, automatic demo payload, authenticated shell, logout/reload suppression, manual re-entry and rejected-login fallback. GET demo-presentation fixture with authorized local login exchange, no runtime demo configuration mutation."
|
||||
],
|
||||
"interactions": [
|
||||
"At /patrol, hover/focus and Enter expansion, exact input/output comparison, deepest output scrolling, Space collapse, Escape, full reload, controlled session selection and reopening each result. Actual pixels inspected for successful and failed outcomes at desktop, intermediate and narrow widths.",
|
||||
"The incoming real Overview component was exercised through scripts/check-alert-dispatch-copy.mjs at all three widths. Status text stays within the viewport and no page errors occurred. Actual pixels inspected. No delivery action invoked.",
|
||||
"Final worker Pro build installed locally and managed restart recovered healthy. Private source/binary binding and receipts: tmp/patrol-archive-retirement/runtime-binding.json, browser/receipt.json and live-history-proof.json. Zero provider calls or faults in this proof. Controlled tool/session replay proves rendering only, not model diagnosis or server persistence."
|
||||
"Activity, Finding options and history, All while Patrol paused. Review via hover/focus and Enter, unique aria-controls target, close returns focus, reopen, nested investigation disclosure open/closed, keyboard Enter/Space tool expansion/collapse and deep output scrolling. Actual desktop/intermediate/mobile pixels inspected after final source changes.",
|
||||
"Patrol model settings and rates/readiness scrolled into view and reloaded at all three widths. No model setting change, action execution or approval request during browser proof.",
|
||||
"Private receipts and interaction matrix under tmp/patrol-gemini-38/browser. Real run q-20260906-182952-6acfb739 remains failed: broker refused disconnected command agent, exact diagnosis retained and cleanup passed. No successful approval or recovery claim."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -285,6 +285,16 @@ describe('patrol api — uncovered branch coverage', () => {
|
||||
role: 'assistant',
|
||||
content: 'logs in /var/log grew 40GB',
|
||||
reasoning_content: 'checked du output',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'read-1',
|
||||
name: 'pulse_read',
|
||||
input: { resource_id: 'container-1' },
|
||||
output: 'NO_AGENT',
|
||||
success: false,
|
||||
},
|
||||
{ id: 'query-1', name: 'pulse_query', input: { action: 'metrics' } },
|
||||
],
|
||||
timestamp: '2026-07-18T00:00:05Z',
|
||||
},
|
||||
],
|
||||
@@ -299,6 +309,9 @@ describe('patrol api — uncovered branch coverage', () => {
|
||||
expect(result).toEqual(envelope);
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.messages[1]?.reasoning_content).toBe('checked du output');
|
||||
expect(result.messages[1]?.tool_calls?.[0]?.output).toBe('NO_AGENT');
|
||||
expect(result.messages[1]?.tool_calls?.[0]?.success).toBe(false);
|
||||
expect(result.messages[1]?.tool_calls?.[1]?.success).toBeUndefined();
|
||||
});
|
||||
|
||||
it('URL-encodes the finding id segment (separate from the messages suffix)', async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
import { arrayOrEmpty, promoteLegacyAlertIdentifier } from './responseUtils';
|
||||
import type { InvestigationRecord } from './ai';
|
||||
import type { ToolCall } from './aiChat';
|
||||
import type { ResourceCriticality } from './resourceOperatorState';
|
||||
import type { PatrolActionReference } from '@/types/actionAudit';
|
||||
import type { PatrolModelReadinessSnapshot } from '@/types/ai';
|
||||
@@ -304,9 +305,8 @@ export interface ChatMessage {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ChatToolCall {
|
||||
export interface ChatToolCall extends ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
||||
@@ -194,6 +194,21 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
const [filter, setFilter] = createSignal<FindingsPanelFilter>(props.filterOverride ?? 'active');
|
||||
const [sortBy, setSortBy] = createSignal<'severity' | 'time'>('severity');
|
||||
const [expandedId, setExpandedId] = createSignal<string | null>(null);
|
||||
let panelRoot: HTMLDivElement | undefined;
|
||||
const closeReviewPanel = () => {
|
||||
const findingId = expandedId();
|
||||
setExpandedId(null);
|
||||
setManageOpenId(null);
|
||||
if (findingId) {
|
||||
queueMicrotask(() => {
|
||||
panelRoot
|
||||
?.querySelector<HTMLButtonElement>(
|
||||
`button[aria-controls="${CSS.escape(`finding-${findingId}-details`)}"]`,
|
||||
)
|
||||
?.focus();
|
||||
});
|
||||
}
|
||||
};
|
||||
const [manageOpenId, setManageOpenId] = createSignal<string | null>(null);
|
||||
const [actionLoading, setActionLoading] = createSignal<string | null>(null);
|
||||
const [lastHashScrolled, setLastHashScrolled] = createSignal<string | null>(null);
|
||||
@@ -1467,7 +1482,10 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
manualControls.dismiss;
|
||||
|
||||
return (
|
||||
<div id={`finding-${finding.id}-details`} class="mt-3 pt-3 border-t border-border-subtle">
|
||||
<div
|
||||
id={isPatrolFindingsSource() ? undefined : `finding-${finding.id}-details`}
|
||||
class="mt-3 pt-3 border-t border-border-subtle"
|
||||
>
|
||||
<Show when={hasTriggeringAlert(finding)}>
|
||||
<div class="text-xs text-amber-700 dark:text-amber-300 mb-2">
|
||||
Triggered by alert{finding.alertType ? ` (${finding.alertType})` : ''} • Identifier{' '}
|
||||
@@ -2108,10 +2126,10 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
<div ref={panelRoot} class="space-y-4">
|
||||
{/* Controls */}
|
||||
<Show when={showFilterControls()}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<FilterSegmentedControl
|
||||
aria-label="Filter findings"
|
||||
value={filter()}
|
||||
@@ -2298,10 +2316,7 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Close review panel for ${title().label}`}
|
||||
onClick={() => {
|
||||
setExpandedId(null);
|
||||
setManageOpenId(null);
|
||||
}}
|
||||
onClick={closeReviewPanel}
|
||||
class="rounded p-1.5 text-muted transition-colors hover:bg-surface-hover hover:text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
>
|
||||
<XIcon class="h-4 w-4" />
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { Component, createSignal, Show, For, onMount, lazy, Suspense } from 'solid-js';
|
||||
import {
|
||||
Component,
|
||||
createEffect,
|
||||
createSignal,
|
||||
Show,
|
||||
For,
|
||||
onMount,
|
||||
lazy,
|
||||
Suspense,
|
||||
} from 'solid-js';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { PulseBrandMark } from '@/components/Brand/PulseBrandMark';
|
||||
import { apiClient, apiFetchJSON } from '@/utils/apiClient';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import { SESSION_STORAGE_KEYS, STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import { TROUBLESHOOTING_DOC_URL } from '@/utils/docsLinks';
|
||||
import Globe from 'lucide-solid/icons/globe';
|
||||
import Key from 'lucide-solid/icons/key';
|
||||
@@ -18,6 +27,10 @@ interface LoginProps {
|
||||
|
||||
import type { SecurityStatus, SSOProviderInfo } from '@/types/config';
|
||||
|
||||
// The public demo's credentials. They are shown on the login page, so there
|
||||
// is nothing to protect by making the visitor type them.
|
||||
const DEMO_CREDENTIALS = { username: 'demo', password: 'demo' } as const;
|
||||
|
||||
function getBrowserStorage(kind: 'localStorage' | 'sessionStorage'): Storage | undefined {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
try {
|
||||
@@ -68,8 +81,12 @@ export const Login: Component<LoginProps> = (props) => {
|
||||
const [oidcLoading] = createSignal(false);
|
||||
const [oidcError, setOidcError] = createSignal('');
|
||||
const [oidcMessage, setOidcMessage] = createSignal('');
|
||||
const [demoAutoLogin, setDemoAutoLogin] = createSignal(false);
|
||||
|
||||
const ssoProviders = () => authStatus()?.ssoProviders || [];
|
||||
const demoModeEnabled = () =>
|
||||
authStatus()?.presentationPolicy?.demoMode === true ||
|
||||
authStatus()?.sessionCapabilities?.demoMode === true;
|
||||
|
||||
const resolveSSOError = (reason?: string | null) => {
|
||||
switch (reason) {
|
||||
@@ -214,6 +231,15 @@ export const Login: Component<LoginProps> = (props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
await submitCredentials(usernameValue, passwordValue, rememberLogin);
|
||||
};
|
||||
|
||||
const submitCredentials = async (
|
||||
usernameValue: string,
|
||||
passwordValue: string,
|
||||
rememberLogin: boolean,
|
||||
) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Use the new login endpoint for better feedback
|
||||
const response = await apiClient.fetch('/api/login', {
|
||||
@@ -280,6 +306,26 @@ export const Login: Component<LoginProps> = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Demo mode: the public demo is read-only and its credentials are printed on
|
||||
// this page anyway, so sign the visitor in instead of making them type
|
||||
// demo/demo. Once per browser tab, and never straight after a sign-out.
|
||||
createEffect(() => {
|
||||
if (loadingAuth() || !demoModeEnabled() || showFirstRunSetup()) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has('oidc') || params.has('saml')) return;
|
||||
const storage = getBrowserStorage('sessionStorage');
|
||||
if (storage?.getItem(SESSION_STORAGE_KEYS.DEMO_AUTO_LOGIN)) return;
|
||||
try {
|
||||
storage?.setItem(SESSION_STORAGE_KEYS.DEMO_AUTO_LOGIN, 'attempted');
|
||||
} catch (_err) {
|
||||
// If the marker cannot be stored the sign-in still runs once for this render.
|
||||
}
|
||||
setDemoAutoLogin(true);
|
||||
void submitCredentials(DEMO_CREDENTIALS.username, DEMO_CREDENTIALS.password, false).finally(
|
||||
() => setDemoAutoLogin(false),
|
||||
);
|
||||
});
|
||||
|
||||
// Debug logging
|
||||
logger.debug('[Login] Render', {
|
||||
loadingAuth: loadingAuth(),
|
||||
@@ -323,9 +369,8 @@ export const Login: Component<LoginProps> = (props) => {
|
||||
oidcLoading,
|
||||
oidcError,
|
||||
oidcMessage,
|
||||
demoModeEnabled:
|
||||
authStatus()?.presentationPolicy?.demoMode === true ||
|
||||
authStatus()?.sessionCapabilities?.demoMode === true,
|
||||
demoModeEnabled: demoModeEnabled(),
|
||||
demoAutoLogin,
|
||||
showLocalLogin: shouldShowLocalLogin(),
|
||||
ssoProviders: ssoProviders(),
|
||||
}}
|
||||
@@ -369,6 +414,7 @@ const LoginForm: Component<{
|
||||
oidcError: () => string;
|
||||
oidcMessage: () => string;
|
||||
demoModeEnabled: boolean;
|
||||
demoAutoLogin: () => boolean;
|
||||
showLocalLogin: boolean;
|
||||
ssoProviders: SSOProviderInfo[];
|
||||
}> = (props) => {
|
||||
@@ -386,6 +432,7 @@ const LoginForm: Component<{
|
||||
oidcError,
|
||||
oidcMessage,
|
||||
demoModeEnabled,
|
||||
demoAutoLogin,
|
||||
showLocalLogin,
|
||||
ssoProviders,
|
||||
} = props;
|
||||
@@ -414,16 +461,25 @@ const LoginForm: Component<{
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold text-sm text-base-content">Demo Mode</div>
|
||||
<div class="text-sm text-muted">
|
||||
Login with{' '}
|
||||
<code class="bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 px-1.5 py-0.5 rounded font-mono text-xs">
|
||||
demo
|
||||
</code>{' '}
|
||||
/{' '}
|
||||
<code class="bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 px-1.5 py-0.5 rounded font-mono text-xs">
|
||||
demo
|
||||
</code>
|
||||
</div>
|
||||
<Show
|
||||
when={demoAutoLogin()}
|
||||
fallback={
|
||||
<div class="text-sm text-muted">
|
||||
Login with{' '}
|
||||
<code class="bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 px-1.5 py-0.5 rounded font-mono text-xs">
|
||||
demo
|
||||
</code>{' '}
|
||||
/{' '}
|
||||
<code class="bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 px-1.5 py-0.5 rounded font-mono text-xs">
|
||||
demo
|
||||
</code>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="text-sm text-muted" role="status">
|
||||
Signing you in to the demo…
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
|
||||
import { Login } from '@/components/Login';
|
||||
import loginSource from '@/components/Login.tsx?raw';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import { SESSION_STORAGE_KEYS, STORAGE_KEYS } from '@/utils/localStorage';
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
@@ -182,7 +182,10 @@ describe('Login', () => {
|
||||
expect(mockFetch).not.toHaveBeenCalledWith('/api/security/status');
|
||||
});
|
||||
|
||||
it('shows demo credentials when session capabilities mark the runtime as demo mode', async () => {
|
||||
it('shows demo credentials when the visitor has signed out of the demo', async () => {
|
||||
// A sign-out marks the tab so the page does not sign the visitor straight
|
||||
// back in; the printed credentials are the way back.
|
||||
window.sessionStorage.setItem(SESSION_STORAGE_KEYS.DEMO_AUTO_LOGIN, 'suppressed');
|
||||
const mockOnLogin = vi.fn();
|
||||
const securityStatus = {
|
||||
hasAuthentication: true,
|
||||
@@ -196,6 +199,78 @@ describe('Login', () => {
|
||||
|
||||
expect(await screen.findByText('Demo Mode')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('demo')).toHaveLength(2);
|
||||
expect(mockFetch).not.toHaveBeenCalledWith('/api/login', expect.anything());
|
||||
expect(mockOnLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('signs the visitor in with the demo credentials when the runtime is in demo mode', async () => {
|
||||
const mockOnLogin = vi.fn();
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const securityStatus = {
|
||||
hasAuthentication: true,
|
||||
hideLocalLogin: false,
|
||||
presentationPolicy: { demoMode: true },
|
||||
};
|
||||
|
||||
render(() => (
|
||||
<Login onLogin={mockOnLogin} hasAuth={true} securityStatus={securityStatus as any} />
|
||||
));
|
||||
|
||||
await waitFor(() => expect(mockOnLogin).toHaveBeenCalledOnce());
|
||||
const loginCall = mockFetch.mock.calls.find(([url]) => url === '/api/login');
|
||||
expect(loginCall).toBeDefined();
|
||||
expect(JSON.parse((loginCall?.[1] as RequestInit).body as string)).toEqual({
|
||||
username: 'demo',
|
||||
password: 'demo',
|
||||
rememberMe: false,
|
||||
});
|
||||
expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.DEMO_AUTO_LOGIN)).toBe('attempted');
|
||||
});
|
||||
|
||||
it('falls back to the form when the demo sign-in is rejected', async () => {
|
||||
const mockOnLogin = vi.fn();
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ success: false, message: 'Invalid username or password' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const securityStatus = {
|
||||
hasAuthentication: true,
|
||||
hideLocalLogin: false,
|
||||
presentationPolicy: { demoMode: true },
|
||||
};
|
||||
|
||||
render(() => (
|
||||
<Login onLogin={mockOnLogin} hasAuth={true} securityStatus={securityStatus as any} />
|
||||
));
|
||||
|
||||
expect(await screen.findByText('Invalid username or password')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('demo')).toHaveLength(2);
|
||||
expect(screen.getByRole('button', { name: /sign in to pulse/i })).toBeEnabled();
|
||||
expect(mockOnLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not sign in to the demo twice in one browser tab', async () => {
|
||||
window.sessionStorage.setItem(SESSION_STORAGE_KEYS.DEMO_AUTO_LOGIN, 'attempted');
|
||||
const mockOnLogin = vi.fn();
|
||||
const securityStatus = {
|
||||
hasAuthentication: true,
|
||||
hideLocalLogin: false,
|
||||
presentationPolicy: { demoMode: true },
|
||||
};
|
||||
|
||||
render(() => (
|
||||
<Login onLogin={mockOnLogin} hasAuth={true} securityStatus={securityStatus as any} />
|
||||
));
|
||||
|
||||
expect(await screen.findByText('Demo Mode')).toBeInTheDocument();
|
||||
expect(mockFetch).not.toHaveBeenCalledWith('/api/login', expect.anything());
|
||||
});
|
||||
|
||||
it('restores the remembered username without storing a password', async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getInvestigationMessages, formatTimestamp, type ChatMessage } from '@/a
|
||||
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
|
||||
import { getInvestigationMessagesState } from '@/utils/patrolEmptyStatePresentation';
|
||||
import { renderMarkdown } from '@/components/AI/aiChatUtils';
|
||||
import { ToolExecutionBlock } from '@/components/AI/Chat/ToolExecutionBlock';
|
||||
|
||||
// Compact variant of the Assistant chat's markdown styling, scaled for the
|
||||
// investigation thread's text-xs bubbles.
|
||||
@@ -110,16 +111,35 @@ export const InvestigationMessages: Component<InvestigationMessagesProps> = (pro
|
||||
<div class="space-y-1">
|
||||
<For each={msg.tool_calls}>
|
||||
{(tc) => (
|
||||
<div class="text-xs rounded border border-indigo-200 dark:border-indigo-800 bg-indigo-50 dark:bg-indigo-900 px-2 py-1">
|
||||
<span class="font-semibold text-indigo-700 dark:text-indigo-300">
|
||||
{tc.name}
|
||||
</span>
|
||||
<Show when={tc.input && Object.keys(tc.input).length > 0}>
|
||||
<pre class="mt-1 text-[10px] text-muted overflow-x-auto max-h-24 overflow-y-auto">
|
||||
{JSON.stringify(tc.input, null, 2)}
|
||||
</pre>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
when={typeof tc.success === 'boolean'}
|
||||
fallback={
|
||||
<div class="text-xs rounded border border-indigo-200 dark:border-indigo-800 bg-indigo-50 dark:bg-indigo-900 px-2 py-1">
|
||||
<span class="font-semibold text-indigo-700 dark:text-indigo-300">
|
||||
{tc.name}
|
||||
</span>
|
||||
<Show when={tc.input && Object.keys(tc.input).length > 0}>
|
||||
<pre class="mt-1 text-[10px] text-muted overflow-x-auto max-h-24 overflow-y-auto">
|
||||
{JSON.stringify(tc.input, null, 2)}
|
||||
</pre>
|
||||
</Show>
|
||||
<Show when={tc.output}>
|
||||
<pre class="mt-1 text-[10px] text-muted overflow-x-auto max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
|
||||
{tc.output}
|
||||
</pre>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ToolExecutionBlock
|
||||
tool={{
|
||||
name: tc.name,
|
||||
input: JSON.stringify(tc.input),
|
||||
output: tc.output ?? '',
|
||||
success: tc.success!,
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { buildPatrolInvestigationRecordPresentation } from '@/features/patrol/pa
|
||||
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
|
||||
import { MetadataBadge } from '@/components/shared/MetadataBadge';
|
||||
import { InvestigationMessages } from './InvestigationMessages';
|
||||
import { renderMarkdown } from '@/components/AI/aiChatUtils';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { aiIntelligenceStore } from '@/stores/aiIntelligence';
|
||||
import type { InvestigationRecord } from '@/api/ai';
|
||||
@@ -40,6 +41,9 @@ const INVESTIGATION_BADGE_PROPS = {
|
||||
shape: 'rounded',
|
||||
} as const;
|
||||
|
||||
const summaryClass =
|
||||
'text-sm prose prose-slate prose-sm dark:prose-invert max-w-none break-words prose-headings:my-2 prose-p:my-2 prose-pre:overflow-x-auto prose-code:break-all prose-code:before:content-none prose-code:after:content-none';
|
||||
|
||||
interface InvestigationSectionProps {
|
||||
findingId: string;
|
||||
investigationStatus?: string;
|
||||
@@ -210,7 +214,11 @@ export const InvestigationSection: Component<InvestigationSectionProps> = (props
|
||||
</div>
|
||||
|
||||
<Show when={investigationRecord().conclusion}>
|
||||
<p class="mt-2 text-sm text-base-content">{investigationRecord().conclusion}</p>
|
||||
<div
|
||||
class={`mt-2 ${summaryClass}`}
|
||||
// eslint-disable-next-line solid/no-innerhtml
|
||||
innerHTML={renderMarkdown(investigationRecord().conclusion!)}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={investigationRecord().recommendedAction}>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
@@ -325,6 +333,7 @@ export const InvestigationSection: Component<InvestigationSectionProps> = (props
|
||||
<Show
|
||||
when={
|
||||
inv().error &&
|
||||
inv().error?.trim() !== investigationRecord().error &&
|
||||
(inv().status === 'failed' ||
|
||||
inv().outcome === 'timed_out' ||
|
||||
inv().outcome === 'fix_failed' ||
|
||||
@@ -341,8 +350,16 @@ export const InvestigationSection: Component<InvestigationSectionProps> = (props
|
||||
</Show>
|
||||
|
||||
{/* Summary */}
|
||||
<Show when={inv().summary}>
|
||||
<div class="text-sm text-muted bg-surface-alt rounded p-2">{inv().summary}</div>
|
||||
<Show
|
||||
when={
|
||||
inv().summary?.trim() && inv().summary?.trim() !== investigationRecord().conclusion
|
||||
}
|
||||
>
|
||||
<div
|
||||
class={`bg-surface-alt rounded p-2 ${summaryClass}`}
|
||||
// eslint-disable-next-line solid/no-innerhtml
|
||||
innerHTML={renderMarkdown(inv().summary!)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
{/* Tools used + turn count */}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import InvestigationMessages from '../InvestigationMessages';
|
||||
|
||||
const getMessages = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/api/patrol', () => ({
|
||||
getInvestigationMessages: getMessages,
|
||||
formatTimestamp: (value: string) => value,
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('InvestigationMessages', () => {
|
||||
it('retains merged failed tool evidence and exposes it through the shared disclosure', async () => {
|
||||
getMessages.mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
id: 'turn-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: '2026-09-06',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'read-1',
|
||||
name: 'pulse_read',
|
||||
input: { resource_id: 'app-container-1' },
|
||||
output: '{"error":"NO_AGENT","message":"Command agent is not connected"}',
|
||||
success: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
render(() => <InvestigationMessages findingId="finding-1" />);
|
||||
const disclosure = await screen.findByRole('button', { name: /failed/i });
|
||||
expect(disclosure).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.keyDown(disclosure, { key: 'Enter' });
|
||||
expect(disclosure).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByText(/"error":\s*"NO_AGENT"/)).toBeVisible();
|
||||
fireEvent.keyDown(disclosure, { key: ' ' });
|
||||
expect(disclosure).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('does not infer completion from historical calls that have no recorded result status', async () => {
|
||||
getMessages.mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
id: 'turn-2',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: '2026-09-06',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'query-1',
|
||||
name: 'pulse_query',
|
||||
input: { action: 'metrics' },
|
||||
output: 'Historical output',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
render(() => <InvestigationMessages findingId="finding-2" />);
|
||||
expect(await screen.findByText('Historical output')).toBeVisible();
|
||||
expect(screen.queryByText('completed')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('failed')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -133,4 +133,46 @@ describe('InvestigationSection', () => {
|
||||
expect(screen.queryByText(/No investigation data available/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('systemctl restart workload.service')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves readable evidence and distinct summaries (different: %s)',
|
||||
async (different) => {
|
||||
const conclusion =
|
||||
'### Root cause\n\nThe cause is **unknown**.\n\n- The health check failed.\n- Logs are unavailable.';
|
||||
getInvestigationMock.mockResolvedValue({
|
||||
id: 'inv-markdown',
|
||||
finding_id: 'finding-markdown',
|
||||
session_id: 'session-markdown',
|
||||
status: 'completed',
|
||||
started_at: '2026-09-06T17:00:00Z',
|
||||
turn_count: 2,
|
||||
summary: different
|
||||
? '### Follow-up\n\nAdditional evidence remains unavailable.'
|
||||
: conclusion,
|
||||
} satisfies Investigation);
|
||||
render(() => (
|
||||
<InvestigationSection
|
||||
findingId="finding-markdown"
|
||||
investigationRecord={{
|
||||
id: 'record-markdown',
|
||||
finding_id: 'finding-markdown',
|
||||
subject: { resource_id: 'container-1' },
|
||||
trigger: { title: 'Health check failed', detected_at: '2026-09-06T17:00:00Z' },
|
||||
status: 'completed',
|
||||
conclusion,
|
||||
started_at: '2026-09-06T17:00:00Z',
|
||||
evidence: [],
|
||||
verification: [],
|
||||
rollback: [],
|
||||
tools_used: [],
|
||||
}}
|
||||
/>
|
||||
));
|
||||
await screen.findByRole('button', { name: 'Show investigation thread' });
|
||||
expect(screen.getAllByRole('heading', { name: 'Root cause' })).toHaveLength(1);
|
||||
expect(screen.getByText('unknown').tagName).toBe('STRONG');
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(2);
|
||||
expect(screen.queryByRole('heading', { name: 'Follow-up' }) !== null).toBe(different);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -225,9 +225,7 @@ export function PatrolIntelligenceSurface() {
|
||||
onToggle={(event) => setFindingsOpen(event.currentTarget.open)}
|
||||
>
|
||||
<summary class="sr-only">Finding options and history</summary>
|
||||
<div
|
||||
class={`space-y-4 border-t border-border p-4 sm:p-5 ${!state.patrolEnabledLocal() ? 'opacity-50 pointer-events-none' : ''}`}
|
||||
>
|
||||
<div class="space-y-4 border-t border-border p-4 sm:p-5">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="text-xs leading-5 text-muted">
|
||||
<Show
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
PRIMARY_PLATFORM_NAV_IDS,
|
||||
type PlatformNavigationVisibility,
|
||||
} from '@/features/platformNavigation/platformNavigationModel';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import { SESSION_STORAGE_KEYS, STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import type { VersionInfo } from '@/api/updates';
|
||||
import type { Organization } from '@/api/orgs';
|
||||
import { OrgsAPI } from '@/api/orgs';
|
||||
@@ -828,6 +828,11 @@ export const useAppRuntimeState = () => {
|
||||
];
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
sessionStorage.clear();
|
||||
try {
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEYS.DEMO_AUTO_LOGIN, 'suppressed');
|
||||
} catch (_err) {
|
||||
// Storage may be unavailable; the demo login page then simply signs in again.
|
||||
}
|
||||
localStorage.setItem('just_logged_out', 'true');
|
||||
aiChatStore.setEnabled(false);
|
||||
|
||||
|
||||
@@ -995,19 +995,19 @@ describe('getFindingResolutionReason', () => {
|
||||
).toBe('Resolved after investigation timeout now');
|
||||
});
|
||||
|
||||
it('returns "Resolved manually" for cannot_fix', () => {
|
||||
it('does not infer manual resolution from cannot_fix', () => {
|
||||
expect(
|
||||
getFindingResolutionReason({ ...patrolBase, investigationOutcome: 'cannot_fix' }, 'now'),
|
||||
).toBe('Resolved manually now');
|
||||
).toBe('Resolved now');
|
||||
});
|
||||
|
||||
it('returns "Resolved after manual review" for needs_attention', () => {
|
||||
it('does not infer manual review from needs_attention', () => {
|
||||
expect(
|
||||
getFindingResolutionReason(
|
||||
{ ...patrolBase, investigationOutcome: 'needs_attention' },
|
||||
'now',
|
||||
),
|
||||
).toBe('Resolved after manual review now');
|
||||
).toBe('Resolved now');
|
||||
});
|
||||
|
||||
it('returns "Fix applied by Patrol" for fix_executed even when autoResolved is false', () => {
|
||||
|
||||
@@ -1287,9 +1287,10 @@ export const getFindingResolutionReason = (
|
||||
case 'timed_out':
|
||||
return `Resolved after investigation timeout ${resolvedTime}`;
|
||||
case 'cannot_fix':
|
||||
return `Resolved manually ${resolvedTime}`;
|
||||
case 'needs_attention':
|
||||
return `Resolved after manual review ${resolvedTime}`;
|
||||
// An investigation outcome does not identify who later resolved the
|
||||
// finding. Explicit operator resolution is handled above.
|
||||
return `Resolved ${resolvedTime}`;
|
||||
default:
|
||||
return `Issue no longer detected ${resolvedTime}`;
|
||||
}
|
||||
|
||||
@@ -142,6 +142,10 @@ export type LowPriorityNoticeOwner = 'github-star' | 'release-update';
|
||||
|
||||
export const SESSION_STORAGE_KEYS = {
|
||||
LOW_PRIORITY_NOTICE_OWNER: 'pulse-low-priority-notice-owner',
|
||||
// Demo mode signs the visitor in once per browser tab. The value is
|
||||
// 'attempted' after the login page has tried, or 'suppressed' after an
|
||||
// explicit sign-out, so a visitor who signed out lands on the form.
|
||||
DEMO_AUTO_LOGIN: 'pulse-demo-auto-login',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package agentcapabilities
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// TranscriptToolCall preserves a stored invocation and its observed result.
|
||||
// Provider requests use ProviderToolCall instead of the product history shape.
|
||||
type TranscriptToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Input map[string]interface{} `json:"input"`
|
||||
Output string `json:"output,omitempty"`
|
||||
Success *bool `json:"success,omitempty"`
|
||||
ThoughtSignature json.RawMessage `json:"thought_signature,omitempty"`
|
||||
}
|
||||
|
||||
func (t TranscriptToolCall) NormalizeCollections() TranscriptToolCall {
|
||||
providerCall := ProviderToolCall{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Input: t.Input,
|
||||
ThoughtSignature: t.ThoughtSignature,
|
||||
}.NormalizeCollections()
|
||||
t.ID = providerCall.ID
|
||||
t.Name = providerCall.Name
|
||||
t.Input = providerCall.Input
|
||||
t.ThoughtSignature = providerCall.ThoughtSignature
|
||||
if t.Success != nil {
|
||||
success := *t.Success
|
||||
t.Success = &success
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// ProviderToolCall projects a stored Assistant transcript call back to the
|
||||
// shared provider-facing shape, deliberately excluding in-app output/success
|
||||
// display fields.
|
||||
func (t TranscriptToolCall) ProviderToolCall() ProviderToolCall {
|
||||
t = t.NormalizeCollections()
|
||||
return ProviderToolCall{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Input: t.Input,
|
||||
ThoughtSignature: t.ThoughtSignature,
|
||||
}.NormalizeCollections()
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package agentcapabilities
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -203,3 +204,46 @@ func TestNewToolGovernanceDescriptorAppliesSharedDefaults(t *testing.T) {
|
||||
t.Fatalf("descriptor approval summary = %q", descriptor.ApprovalSummary)
|
||||
}
|
||||
}
|
||||
|
||||
// Stored result evidence and provider request arguments are different wire
|
||||
// contracts. In particular, explicit failure cannot disappear through omitempty.
|
||||
func TestTranscriptToolCallPreservesResultOutsideProviderRequests(t *testing.T) {
|
||||
failed := false
|
||||
call := TranscriptToolCall{ID: "read-1", Name: PulseReadToolName, Output: "NO_AGENT", Success: &failed}.NormalizeCollections()
|
||||
failed = true
|
||||
body, err := json.Marshal(call)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stored map[string]interface{}
|
||||
if err := json.Unmarshal(body, &stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored["output"] != "NO_AGENT" || stored["success"] != false || stored["input"] == nil {
|
||||
t.Fatalf("stored result lost explicit failure or normalized input: %s", body)
|
||||
}
|
||||
requestBody, err := json.Marshal(call.ProviderToolCall())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var request map[string]interface{}
|
||||
if err := json.Unmarshal(requestBody, &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"output", "success"} {
|
||||
if _, exists := request[key]; exists {
|
||||
t.Fatalf("provider request retained display-only %s: %s", key, requestBody)
|
||||
}
|
||||
}
|
||||
unknownBody, err := json.Marshal(TranscriptToolCall{Name: PulseQueryToolName}.NormalizeCollections())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var unknown map[string]interface{}
|
||||
if err := json.Unmarshal(unknownBody, &unknown); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := unknown["success"]; exists {
|
||||
t.Fatalf("unknown historical status became a result: %s", unknownBody)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,38 +135,13 @@ func (m Message) ClientSafe() Message {
|
||||
return m
|
||||
}
|
||||
|
||||
// ToolCall represents a tool invocation
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Input map[string]interface{} `json:"input"`
|
||||
Output string `json:"output,omitempty"`
|
||||
Success *bool `json:"success,omitempty"`
|
||||
ThoughtSignature json.RawMessage `json:"thought_signature,omitempty"`
|
||||
}
|
||||
// ToolCall is the canonical result-bearing product transcript call.
|
||||
type ToolCall = agentcapabilities.TranscriptToolCall
|
||||
|
||||
func EmptyToolCall() ToolCall {
|
||||
return ToolCall{}.NormalizeCollections()
|
||||
}
|
||||
|
||||
func (t ToolCall) NormalizeCollections() ToolCall {
|
||||
providerCall := agentcapabilities.ProviderToolCall{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Input: t.Input,
|
||||
ThoughtSignature: t.ThoughtSignature,
|
||||
}.NormalizeCollections()
|
||||
t.ID = providerCall.ID
|
||||
t.Name = providerCall.Name
|
||||
t.Input = providerCall.Input
|
||||
t.ThoughtSignature = providerCall.ThoughtSignature
|
||||
if t.Success != nil {
|
||||
success := *t.Success
|
||||
t.Success = &success
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// ToolCallFromProvider stores a provider-facing tool call in the richer
|
||||
// Assistant transcript shape used for in-app history.
|
||||
func ToolCallFromProvider(tc agentcapabilities.ProviderToolCall) ToolCall {
|
||||
@@ -183,19 +158,6 @@ func ToolCallFromProvider(tc agentcapabilities.ProviderToolCall) ToolCall {
|
||||
}.NormalizeCollections()
|
||||
}
|
||||
|
||||
// ProviderToolCall projects a stored Assistant transcript call back to the
|
||||
// shared provider-facing shape, deliberately excluding in-app output/success
|
||||
// display fields.
|
||||
func (t ToolCall) ProviderToolCall() agentcapabilities.ProviderToolCall {
|
||||
t = t.NormalizeCollections()
|
||||
return agentcapabilities.ProviderToolCall{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Input: t.Input,
|
||||
ThoughtSignature: t.ThoughtSignature,
|
||||
}.NormalizeCollections()
|
||||
}
|
||||
|
||||
// ToolResult represents the result of a tool execution. It aliases the shared
|
||||
// Pulse Intelligence provider-result shape so stored Assistant transcripts and
|
||||
// provider turns do not drift on tool result JSON.
|
||||
|
||||
@@ -82,11 +82,17 @@ var providerPrices = map[string][]modelPrice{
|
||||
flatPriceAsOf("anthropic/claude-opus-4.8", 5.00, 25.00, "2026-07-14"),
|
||||
flatPriceAsOf("anthropic/claude-sonnet-5", 2.00, 10.00, "2026-07-14"),
|
||||
flatPriceAsOf("deepseek/deepseek-v4-flash", 0.09, 0.18, "2026-07-14"),
|
||||
// Introductory standard rates through 2026-12-31. Recheck when the
|
||||
// published standard price changes on 2027-01-01. Batch/alias routes
|
||||
// are deliberately not covered by this exact model ID.
|
||||
flatPriceAsOf("google/gemini-3.8-flash", 0.75, 3.75, "2026-09-06"),
|
||||
flatPriceAsOf("nvidia/nemotron-3.5-lightning:free", 0, 0, "2026-08-14"),
|
||||
flatPriceAsOf("nvidia/nemotron-3-super-120b-a12b:free", 0, 0, "2026-08-14"),
|
||||
flatPriceAsOf("nvidia/nemotron-3-ultra-550b-a55b:free", 0, 0, "2026-08-15"),
|
||||
},
|
||||
"gemini": {
|
||||
// Standard introductory rates, verified 2026-09-06. Recheck 2027-01-01.
|
||||
flatPriceAsOf("gemini-3.8-flash", 0.75, 3.75, "2026-09-06"),
|
||||
// Gemini Developer API standard paid-tier pricing, checked from
|
||||
// https://ai.google.dev/gemini-api/docs/pricing on 2026-06-04.
|
||||
flatPrice("gemini-3.5-flash*", 1.50, 9.00),
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package cost
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGemini38FlashReviewedRoutePricing(t *testing.T) {
|
||||
for _, route := range []struct{ provider, model string }{
|
||||
{"gemini", "gemini-3.8-flash"},
|
||||
{"openrouter", "google/gemini-3.8-flash"},
|
||||
} {
|
||||
t.Run(route.provider, func(t *testing.T) {
|
||||
provider, model := ResolveProviderAndModel(route.provider, route.provider+":"+route.model, "gemini-3.8-flash")
|
||||
if provider != route.provider || model != route.model {
|
||||
t.Fatalf("usage lost the requested billing route: %s:%s", provider, model)
|
||||
}
|
||||
// Token counts from the live unhealthy-container qualification.
|
||||
usd, known, price := EstimateUSD(provider, model, 16068, 778)
|
||||
if !known || math.Abs(usd-0.0149685) > 1e-10 {
|
||||
t.Fatalf("live usage estimate = %f, known=%t", usd, known)
|
||||
}
|
||||
if price.InputUSDPerMTok != 0.75 || price.OutputUSDPerMTok != 3.75 || price.AsOf != "2026-09-06" {
|
||||
t.Fatalf("reviewed standard rates/date missing: %+v", price)
|
||||
}
|
||||
usd, known, _ = EstimateUSD(provider, model, 0, 0)
|
||||
if !known || usd != 0 {
|
||||
t.Fatalf("zero observed usage = %f, known=%t", usd, known)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGemini38OpenRouterPricingDoesNotGuessVariantRates(t *testing.T) {
|
||||
for _, model := range []string{
|
||||
"google/gemini-3.8-flash:batch",
|
||||
"google/gemini-3.8-flash:free",
|
||||
"google/gemini-3.8-flash-preview",
|
||||
"google/gemini-3.8-flash-cyber",
|
||||
"google/gemini-3.9-flash",
|
||||
} {
|
||||
if usd, known, _ := EstimateUSD("openrouter", model, 16068, 778); known || usd != 0 {
|
||||
t.Errorf("unreviewed route %q received an estimate: %f, known=%t", model, usd, known)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,13 +184,12 @@ func (m ChatMessage) NormalizeCollections() ChatMessage {
|
||||
return m
|
||||
}
|
||||
|
||||
// ChatToolCall represents a provider-facing tool invocation in an API-facing
|
||||
// chat message. It aliases the shared Pulse Intelligence provider-call shape so
|
||||
// API chat history and provider turns do not drift on tool-call JSON.
|
||||
type ChatToolCall = agentcapabilities.ProviderToolCall
|
||||
// ChatToolCall retains observed output and result status in product history.
|
||||
// Provider turns use the explicit ProviderToolCall projection.
|
||||
type ChatToolCall = agentcapabilities.TranscriptToolCall
|
||||
|
||||
func EmptyChatToolCall() ChatToolCall {
|
||||
return agentcapabilities.EmptyProviderToolCall()
|
||||
return ChatToolCall{}.NormalizeCollections()
|
||||
}
|
||||
|
||||
// ChatToolResult represents the result of a tool invocation. It aliases the
|
||||
|
||||
@@ -168,7 +168,7 @@ func TestChatMessage_UsesCanonicalEmptyCollections(t *testing.T) {
|
||||
Name: "diagnose",
|
||||
ThoughtSignature: json.RawMessage(`{"provider":"gemini"}`),
|
||||
}
|
||||
var sharedProviderCall agentcapabilities.ProviderToolCall = sharedCall.NormalizeCollections()
|
||||
var sharedProviderCall agentcapabilities.TranscriptToolCall = sharedCall.NormalizeCollections()
|
||||
if sharedProviderCall.ID != "call-1" || sharedProviderCall.Input == nil {
|
||||
t.Fatalf("shared chat tool call = %+v", sharedProviderCall)
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ func (e *PulseToolExecutor) executeProposeAction(ctx context.Context, args map[s
|
||||
return NewErrorResult(err), nil
|
||||
}
|
||||
return NewTextResult(fmt.Sprintf(
|
||||
"Proposal recorded: capability %q on resource %q. It will be planned and routed for governed approval; nothing has executed. Conclude the investigation with your diagnosis.",
|
||||
"Proposal recorded: capability %q on resource %q. The action broker still needs to validate it after this investigation. No action has been created or executed.",
|
||||
capabilityName, resourceID)), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2166,7 +2166,7 @@ func (e *PulseToolExecutor) registerQueryTools() {
|
||||
},
|
||||
"resource_type": {
|
||||
Type: "string",
|
||||
Description: "Resource type. For get/search, prefer canonical values: 'agent', 'vm', 'system-container', 'app-container', 'storage', 'physical-disk', and 'docker-host'. For get, 'node' resolves to 'agent'. For search, 'node' filters Proxmox nodes. Compatibility aliases 'system' and 'storage-pool' are still accepted. For config: 'vm', 'system-container', or supported API-backed 'app-container'.",
|
||||
Description: "Resource type. For get/search, prefer canonical values: 'agent', 'vm', 'system-container', 'app-container', 'storage', 'physical-disk', and 'docker-host'. For get, 'node' resolves to 'agent'. For search, 'node' filters Proxmox nodes. Compatibility aliases 'system' and 'storage-pool' are still accepted. For config: 'vm', 'system-container', or TrueNAS 'app-container'. Docker and Podman app-container configuration reads are not supported. Their collected health, mounts, ports, and networks are available through get.",
|
||||
Enum: []string{"agent", "system", "vm", "system-container", "app-container", "node", "docker-host", "storage", "storage-pool", "physical-disk"},
|
||||
},
|
||||
"resource_id": {
|
||||
|
||||
@@ -3754,14 +3754,10 @@ func TestPatrolModelReadinessBudgetScalesWithRequestTimeout(t *testing.T) {
|
||||
assert.Equal(t, 4*600*time.Second+time.Minute, patrolModelReadinessBudget(&config.AIConfig{RequestTimeoutSeconds: 600}))
|
||||
}
|
||||
|
||||
// TestOrchestratorAndChatAdaptersMapTheSameMessageFields keeps the deliberate
|
||||
// GetMessages mirror between orchestratorChatAdapter (ai_handlers.go) and
|
||||
// chatServiceAdapter (chat_service_adapter.go) honest: both convert the same
|
||||
// chat-service messages onto separate output contracts, and a field mapped by
|
||||
// one must be mapped by the other. chatServiceAdapter routes through
|
||||
// adaptChatMessage so its API-facing tool calls stay on the shared provider
|
||||
// shape instead of hand-copying a local duplicate.
|
||||
func TestOrchestratorAndChatAdaptersMapTheSameMessageFields(t *testing.T) {
|
||||
// The adapters share base message fields but have distinct tool-call contracts.
|
||||
// Orchestrator turns project provider arguments. Product history retains the
|
||||
// observed result through the canonical result-bearing transcript type.
|
||||
func TestOrchestratorAndChatAdaptersMapTheirMessageContracts(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
file string
|
||||
fn string
|
||||
@@ -3793,7 +3789,7 @@ func TestOrchestratorAndChatAdaptersMapTheSameMessageFields(t *testing.T) {
|
||||
"Content:",
|
||||
"ReasoningContent:",
|
||||
"Timestamp:",
|
||||
"tc.ProviderToolCall()",
|
||||
"tc.NormalizeCollections()",
|
||||
"toolResult := *m.ToolResult",
|
||||
".NormalizeCollections()",
|
||||
},
|
||||
|
||||
@@ -86,7 +86,7 @@ func adaptChatMessage(m chat.Message) ai.ChatMessage {
|
||||
Timestamp: m.Timestamp,
|
||||
}
|
||||
for _, tc := range m.ToolCalls {
|
||||
msg.ToolCalls = append(msg.ToolCalls, tc.ProviderToolCall())
|
||||
msg.ToolCalls = append(msg.ToolCalls, tc.NormalizeCollections())
|
||||
}
|
||||
if m.ToolResult != nil {
|
||||
toolResult := *m.ToolResult
|
||||
|
||||
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -99,8 +98,8 @@ func TestChatServiceAdapter_GetMessages(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestAdaptChatMessageUsesSharedProviderToolCallShape(t *testing.T) {
|
||||
success := true
|
||||
func TestAdaptChatMessagePreservesObservedToolResult(t *testing.T) {
|
||||
success := false
|
||||
msg := adaptChatMessage(chat.Message{
|
||||
ID: "msg-1",
|
||||
Role: "assistant",
|
||||
@@ -109,7 +108,7 @@ func TestAdaptChatMessageUsesSharedProviderToolCallShape(t *testing.T) {
|
||||
ToolCalls: []chat.ToolCall{{
|
||||
ID: "call-1",
|
||||
Name: "diagnose",
|
||||
Output: "in-app only",
|
||||
Output: "NO_AGENT: command agent unavailable",
|
||||
Success: &success,
|
||||
ThoughtSignature: json.RawMessage(`{"provider":"gemini"}`),
|
||||
}},
|
||||
@@ -121,7 +120,7 @@ func TestAdaptChatMessageUsesSharedProviderToolCallShape(t *testing.T) {
|
||||
})
|
||||
|
||||
require.Len(t, msg.ToolCalls, 1)
|
||||
var shared agentcapabilities.ProviderToolCall = msg.ToolCalls[0]
|
||||
var shared agentcapabilities.TranscriptToolCall = msg.ToolCalls[0]
|
||||
assert.Equal(t, "call-1", shared.ID)
|
||||
assert.Equal(t, "diagnose", shared.Name)
|
||||
assert.NotNil(t, shared.Input)
|
||||
@@ -131,8 +130,8 @@ func TestAdaptChatMessageUsesSharedProviderToolCallShape(t *testing.T) {
|
||||
text := string(payload)
|
||||
assert.Contains(t, text, `"input":{}`)
|
||||
assert.Contains(t, text, `"thought_signature":{"provider":"gemini"}`)
|
||||
assert.False(t, strings.Contains(text, `"output"`), text)
|
||||
assert.False(t, strings.Contains(text, `"success"`), text)
|
||||
assert.Contains(t, text, `"output":"NO_AGENT: command agent unavailable"`)
|
||||
assert.Contains(t, text, `"success":false`)
|
||||
|
||||
require.NotNil(t, msg.ToolResult)
|
||||
var sharedResult agentcapabilities.ProviderToolResult = *msg.ToolResult
|
||||
|
||||
@@ -19839,7 +19839,7 @@ func TestContract_PulseMCPAdapterProjectsAgentCapabilitiesManifest(t *testing.T)
|
||||
chatTypesSrc := string(chatTypesSource)
|
||||
for _, fragment := range []string{
|
||||
`func ToolCallFromProvider(tc agentcapabilities.ProviderToolCall) ToolCall`,
|
||||
`func (t ToolCall) ProviderToolCall() agentcapabilities.ProviderToolCall`,
|
||||
`type ToolCall = agentcapabilities.TranscriptToolCall`,
|
||||
`type ToolResult = agentcapabilities.ProviderToolResult`,
|
||||
} {
|
||||
if !strings.Contains(chatTypesSrc, fragment) {
|
||||
@@ -19853,8 +19853,8 @@ func TestContract_PulseMCPAdapterProjectsAgentCapabilitiesManifest(t *testing.T)
|
||||
}
|
||||
aiServiceSrc := string(aiServiceSource)
|
||||
for _, fragment := range []string{
|
||||
`type ChatToolCall = agentcapabilities.ProviderToolCall`,
|
||||
`return agentcapabilities.EmptyProviderToolCall()`,
|
||||
`type ChatToolCall = agentcapabilities.TranscriptToolCall`,
|
||||
`return ChatToolCall{}.NormalizeCollections()`,
|
||||
`type ChatToolResult = agentcapabilities.ProviderToolResult`,
|
||||
`return agentcapabilities.ApprovalRequiredToolMarker(`,
|
||||
`return agentcapabilities.PolicyBlockedToolMarker(command, reason)`,
|
||||
@@ -20087,11 +20087,11 @@ func TestContract_PulseMCPAdapterProjectsAgentCapabilitiesManifest(t *testing.T)
|
||||
chatServiceAdapterSrc := string(chatServiceAdapterSource)
|
||||
for _, fragment := range []string{
|
||||
`func adaptChatMessage(m chat.Message) ai.ChatMessage`,
|
||||
`msg.ToolCalls = append(msg.ToolCalls, tc.ProviderToolCall())`,
|
||||
`msg.ToolCalls = append(msg.ToolCalls, tc.NormalizeCollections())`,
|
||||
`toolResult := *m.ToolResult`,
|
||||
} {
|
||||
if !strings.Contains(chatServiceAdapterSrc, fragment) {
|
||||
t.Errorf("chat service adapter must bridge messages through shared provider tool shapes; missing %s", fragment)
|
||||
t.Errorf("chat service adapter must retain result-bearing transcript calls; missing %s", fragment)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Exercise the ingestion shape from the workloads-summary benchmark without
|
||||
// its HTTP, reflection or monitor fixtures. A historical benchmark crashed in
|
||||
// SQLite during the second synchronous seed; this is a diagnostic invariant,
|
||||
// not a reproducer or a claim that the unexplained crash has been repaired.
|
||||
func TestStoreLargeSummarySeedSurvivesReopen(t *testing.T) {
|
||||
cfg := DefaultConfig(t.TempDir())
|
||||
cfg.DBPath = filepath.Join(filepath.Dir(cfg.DBPath), "summary-seed.db")
|
||||
cfg.FlushInterval = time.Hour
|
||||
cfg.WriteBufferSize = 10_000
|
||||
store, err := NewStore(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if store != nil {
|
||||
if err := store.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
base := time.Now().Add(-4 * time.Hour).UTC().Truncate(time.Second)
|
||||
for _, group := range []struct {
|
||||
kind string
|
||||
count int
|
||||
}{{"vm", 30}, {"container", 20}, {"dockerContainer", 20}} {
|
||||
batch := make([]WriteMetric, 0, group.count*5*240)
|
||||
for r := 0; r < group.count; r++ {
|
||||
for _, metric := range []string{"cpu", "memory", "disk", "netin", "netout"} {
|
||||
for p := 0; p < 240; p++ {
|
||||
batch = append(batch, WriteMetric{
|
||||
ResourceType: group.kind, ResourceID: fmt.Sprintf("%s-%d", group.kind, r),
|
||||
MetricType: metric, Value: float64((r + p) % 100),
|
||||
Timestamp: base.Add(time.Duration(p) * time.Minute), Tier: TierMinute,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
store.WriteBatchSync(batch)
|
||||
}
|
||||
check := func() {
|
||||
t.Helper()
|
||||
var count int
|
||||
if err := store.db.QueryRow("SELECT COUNT(*) FROM metrics WHERE tier = ?", string(TierMinute)).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 84_000 {
|
||||
t.Fatalf("persisted minute rows = %d, want 84000", count)
|
||||
}
|
||||
rows, err := store.db.Query("PRAGMA integrity_check")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var results int
|
||||
for rows.Next() {
|
||||
var result string
|
||||
if err := rows.Scan(&result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result != "ok" {
|
||||
t.Errorf("integrity_check: %s", result)
|
||||
}
|
||||
results++
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if results != 1 {
|
||||
t.Fatalf("integrity_check returned %d rows, want one ok", results)
|
||||
}
|
||||
}
|
||||
check()
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store = nil
|
||||
store, err = NewStore(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.WaitForMaintenance(10 * time.Second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check()
|
||||
}
|
||||
@@ -442,7 +442,18 @@ def legacy_release_line_for_version(
|
||||
reverse=True,
|
||||
)
|
||||
for line in legacy_release_lines:
|
||||
if normalized_version.startswith(line["version_prefix"]):
|
||||
prefix = line["version_prefix"]
|
||||
# A complete patch version binds only that version, not e.g. 6.4.40.
|
||||
# Trailing-dot prefixes continue to bind the whole minor/major line.
|
||||
if prefix.endswith("."):
|
||||
matches = normalized_version.startswith(prefix)
|
||||
else:
|
||||
matches = (
|
||||
normalized_version == prefix
|
||||
or normalized_version.startswith(prefix + "-")
|
||||
or normalized_version.startswith(prefix + "+")
|
||||
)
|
||||
if matches:
|
||||
return line
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
import textwrap
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -239,6 +244,46 @@ class ControlPlaneAuditTest(unittest.TestCase):
|
||||
"pulse/release-5.1.25",
|
||||
)
|
||||
|
||||
def test_forward_patch_train_uses_actual_control_plane(self) -> None:
|
||||
for version in ("6.4.4-beta.1", "v6.4.4-beta.2", "6.4.4-rc.1",
|
||||
"6.4.4", "v6.4.4+build.1", "6.4.3-rc.1", "6.4.3"):
|
||||
with self.subTest(version=version):
|
||||
self.assertEqual(release_branch_for_version(version), "release/v6.4")
|
||||
for version in ("6.4.1", "6.4.2", "6.4.5-beta.1", "6.4.40-beta.1",
|
||||
"6.4.30", "6.3.20", "6.6.0-beta.1"):
|
||||
with self.subTest(version=version):
|
||||
self.assertEqual(release_branch_for_version(version), "main")
|
||||
self.assertEqual(release_branch_for_version("6.5.10-beta.1"), "release/v6.5")
|
||||
|
||||
def test_forward_patch_workflow_branch_contract(self) -> None:
|
||||
# Execute the real branch-policy shell only, never dispatch a workflow.
|
||||
for workflow in ("create-release.yml", "release-dry-run.yml"):
|
||||
content = (REPO_ROOT / ".github/workflows" / workflow).read_text()
|
||||
match = re.search(
|
||||
r"(?ms)^ - name: Resolve required release branch\n"
|
||||
r".*?^ run: \|\n((?: [^\n]*\n|\n)+)", content
|
||||
)
|
||||
self.assertIsNotNone(match)
|
||||
script = textwrap.dedent(match.group(1))
|
||||
for branch in ("main", "release/v6.4"):
|
||||
with self.subTest(workflow=workflow, branch=branch), tempfile.TemporaryDirectory() as tmp:
|
||||
output = os.path.join(tmp, "output")
|
||||
result = subprocess.run(
|
||||
["bash", "-euo", "pipefail", "-c", script],
|
||||
cwd=REPO_ROOT, capture_output=True, text=True,
|
||||
env={**os.environ, "GITHUB_OUTPUT": output,
|
||||
"VERSION_INPUT": "6.4.4-beta.1",
|
||||
"WORKFLOW_OUTPUT_1": "6.4.4-beta.1",
|
||||
"WORKFLOW_OUTPUT_2": branch},
|
||||
)
|
||||
rejects = workflow == "create-release.yml" and branch == "main"
|
||||
self.assertEqual(result.returncode, 1 if rejects else 0, result.stderr)
|
||||
if rejects:
|
||||
self.assertIn("must run from release/v6.4", result.stdout)
|
||||
else:
|
||||
self.assertRegex(Path(output).read_text(),
|
||||
r"^required_branch<<([^\n]+)\nrelease/v6.4\n\1\n$")
|
||||
|
||||
def test_audit_flags_stale_active_target(self) -> None:
|
||||
report = audit_control_plane_payload(
|
||||
VALID_PAYLOAD,
|
||||
|
||||
Reference in New Issue
Block a user