mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
Preserve canonical Patrol planning and outcome continuity
Return persisted planning acceptance or refusal inside the investigation turn. Keep model judgment separate from action authority and preserve accepted action identity across provider failures. Enforce actor/request idempotency atomically and retain complete approval and independent verification context. Preserve unknown disk evidence, stream whitespace and historical resolution timestamps. Keep conversation scrolling inside its own panel. Record real-model, disposable-lab and browser qualification with explicit population limits. Refs #1782
This commit is contained in:
@@ -319,7 +319,7 @@ Capability-specific stable codes are advertised by the manifest:
|
||||
- `snooze_finding`: `invalid_finding_request`, `finding_not_found`, `finding_action_not_allowed`, and `patrol_unavailable`
|
||||
- `dismiss_finding`: `invalid_finding_request`, `finding_not_found`, `finding_action_not_allowed`, and `patrol_unavailable`
|
||||
- `resolve_finding`: `invalid_finding_request`, `finding_not_found`, `finding_action_not_allowed`, and `patrol_unavailable`
|
||||
- `plan_action`: `invalid_action_request`, `mock_mode_enabled`, `action_actor_unavailable`, `resource_not_found`, `capability_not_found`, `action_execution_unavailable`, and `action_refresh_not_allowed`
|
||||
- `plan_action`: `invalid_action_request`, `action_request_conflict`, `mock_mode_enabled`, `action_actor_unavailable`, `resource_not_found`, `capability_not_found`, `action_execution_unavailable`, and `action_refresh_not_allowed`
|
||||
- `decide_action`: `mock_mode_enabled`, `missing_id`, `invalid_id`, `invalid_action_decision`, `action_not_found`, `action_not_pending`, `action_plan_expired`, `action_plan_identity_mismatch`, `action_actor_unavailable`, `action_approval_forbidden`, `action_step_up_unavailable`, `action_decision_conflict`, `action_separation_required`, `action_replan_required`, `action_execution_unavailable`, `action_plan_drift`, `action_emergency_stop`, `action_dry_run_only`, `resource_remediation_locked`, `action_executor_unavailable`, and `action_execution_availability_failed`
|
||||
- `execute_action`: `mock_mode_enabled`, `missing_id`, `invalid_id`, `invalid_action_execution`, `action_not_found`, `action_not_approved`, `action_already_executing`, `action_execution_final`, `action_dry_run_only`, `action_plan_expired`, `action_execution_unavailable`, `action_plan_drift`, `action_emergency_stop`, `action_plan_identity_mismatch`, `resource_remediation_locked`, `action_executor_unavailable`, `action_actor_unavailable`, `action_execution_forbidden`, `action_not_executing`, and `action_replan_required`
|
||||
<!-- pulse-mcp-errors:end -->
|
||||
|
||||
@@ -79,6 +79,13 @@ Cached inventory and an incomplete resource timeline cannot establish that an
|
||||
action was never approved or run. Recorded verification describes its named
|
||||
postcondition at its observation time, not the resource's current health.
|
||||
|
||||
Patrol investigation planning uses the same lifecycle. Its planning tool returns
|
||||
an accepted action or a refusal during the investigation, so the model can
|
||||
continue from the real result. Planning does not end the investigation or prove
|
||||
its diagnosis. A later model failure retains any action already created and
|
||||
does not request automatic execution. Assistant can explain and continue that
|
||||
same action through its recorded decisions and independent outcome.
|
||||
|
||||
## Related reading
|
||||
|
||||
- [Pulse Assistant safety architecture](ASSISTANT_SAFETY.md) for the enforced
|
||||
|
||||
@@ -64,8 +64,8 @@ reproduction evidence, not a representative customer success rate.
|
||||
| 1. Product contract and baseline | Map the current loop and sources of judgment. Record telemetry populations and gaps. | Every identified decision has an owner. Activity is not labelled usefulness. | Complete for this redesign scope. Contract, ownership decisions and baseline limits are recorded. |
|
||||
| 2. Shared evidence | Preserve canonical risk reasons and SMART counters, source/time semantics and history across tools/turns. | Regression tests preserve unknown versus zero and all canonical evidence. Real responses can inspect the same facts as the product. | Implemented and qualified for the named shared-evidence defects. Canonical disk detail, risk and cadence pass real data-path proof. Affected package and concurrency checks pass. Integrated CI later exposed remaining query and allocation regressions. The final bounded query-reuse correction passes complete selected exact-base worker comparisons and full metrics/database and focused race checks. Final landing CI passed and PRs #1928 and #1929 merged. Real-model interpretation failures remain tracked in step 5. |
|
||||
| 3. Diagnostic orchestration | Correct proposal-as-proof. Audit triage budgets, unmatched-signal evaluation, assessment completion and investigation cutoffs. | No code-written causal conclusion. No quality inferred from tool, flag or finding counts. Each retained pass has an objective reason. Safety boundaries and incomplete outcomes remain explicit. | Proposal promotion and capture inference were removed in c5d2f56dda. Commit 668af3fe6b removes investigation success-call floors, checkpoint instructions and generic call-count wrap-up rules. The detection slice removes contextless follow-up passes, flag/report-count policy and first-finding completion modes. Full chat and AI suites, focused API and conversation race tests pass. Real-model/action outcome qualification remains open. |
|
||||
| 4. Issue through verified outcome | Follow existing issue/investigation/action records into Assistant, approval, execution and independent readback. | Accepted proposal is visibly distinct from execution and verification. Rejected or unsupported actions do not become success. Uncertainty can survive an action proposal. | Approved/rejected Docker execution paths passed named live oracles. Expired-state and attached-context browser repairs passed the named current-runtime matrices. Missing-command-access remains a recorded contract gap. |
|
||||
| 5. Ground-truth qualification and landing | Extend existing qualification tooling only where necessary. Exercise healthy/unhealthy, dependency, missing-access, storage/backup and approved/rejected action cases. Inspect the final browser journey at desktop and narrow widths. | Record exact source/model/permissions, evidence, decisions, faults/misses, latency and verification. Fix in-scope failures, pass appropriate proofs and land scoped commits. | Partial. Installed native filesystem evidence and real storage diagnosis now pass semantic review, alongside healthy/dependency and approved/rejected Docker cases. Current-source browser and shared history race proofs pass. Landing, missing-access continuity and VM lifecycle qualification remain open. |
|
||||
| 4. Issue through verified outcome | Follow existing issue/investigation/action records into Assistant, approval, execution and independent readback. | Accepted proposal is visibly distinct from execution and verification. Rejected or unsupported actions do not become success. Uncertainty can survive an action proposal. | Implemented and locally qualified. Canonical planning returns inside the model turn, actor/request replay is persistent, and accepted actions survive later provider failure. Approved/rejected Docker and missing-runner/VM journeys passed independent live oracles. Final r34 history, action-state, attached-context and Assistant continuation matrices pass. Scoped delivery remains required. |
|
||||
| 5. Ground-truth qualification and landing | Extend existing qualification tooling only where necessary. Exercise healthy/unhealthy, dependency, missing-access, storage/backup and approved/rejected action cases. Inspect the final browser journey at desktop and narrow widths. | Record exact source/model/permissions, evidence, decisions, faults/misses, latency and verification. Fix in-scope failures, pass appropriate proofs and land scoped commits. | Local implementation qualification performed for the named matrix. r28 real Gemini runs cover healthy, unhealthy, dependency, storage capacity, missing access, approved and rejected actions, with independent Docker and VM observations. Later projection/streaming fixes have affected race proof and final r34 Playwright proof. Scoped landing remains pending. Independent customer environments, backup/restore, unattended autonomy and population reliability remain separate unqualified gates. |
|
||||
|
||||
Use one shared runtime and the existing qualification runner, not a second
|
||||
product intelligence engine or a new parallel lifecycle. Preserve independent
|
||||
@@ -3032,3 +3032,462 @@ end-of-document scrolling, actual pixels and absence of horizontal overflow.
|
||||
Receipts are in `tmp/patrol-planning-continuation/docs-browser`. The pinned
|
||||
worker formatter also restored indentation in an unchanged preflight helper.
|
||||
That formatting-only difference does not change the qualified runtime behavior.
|
||||
|
||||
### Canonical request identity follow-through, 2026-09-07
|
||||
|
||||
The Assistant continuation/evidence slice is committed as
|
||||
`e37353f937cbd665ab5f593843f67ed67dbc5748` and submitted in PR #1957 with automatic
|
||||
merge requested. Its exact staged tree
|
||||
`07e84669e69de86f98fcbdba5866f311ddf194bf` passed the full worker pre-commit hook.
|
||||
The preceding pre-push lint/typecheck receipt covers identical runtime source,
|
||||
with only the final qualification prose and browser receipt added afterwards.
|
||||
Heavy hooks ran on the worker, and the Mac commit/push reused that proof rather
|
||||
than starting the forbidden local workload. Merge state must be checked before
|
||||
claiming the change is on remote main.
|
||||
|
||||
The next shared change binds each nonempty action request ID to the trusted
|
||||
actor and the first persisted canonical plan. Current action IDs include mutable
|
||||
resource/policy snapshots, so matching the action ID alone permits one request
|
||||
to create another plan after drift. The proposed root fix checks request identity
|
||||
in the canonical store, atomically with creation, without adding a diagnostic
|
||||
state machine. SQLite obtains its writer lock before checking competing accepted
|
||||
requests and rolls back tentative replay rows. Original plan, expiry, decisions
|
||||
and outcome remain authoritative. Conflicting intent or origin refuses the
|
||||
replay, and an explicit new request ID is required for a new plan. Unbound legacy
|
||||
records retain their prior action-ID semantics. Tests must cover concurrent
|
||||
store instances, reopen, changed intent, actor isolation and replay without a
|
||||
live resource registry. This work is in progress and is not yet qualified.
|
||||
|
||||
The store proof also covers distinct inputs that collide only after redaction.
|
||||
Those are not treated as matching intent. If the persisted request has lost
|
||||
information to redaction, exact replay equality remains unknown and refuses,
|
||||
without creating a replacement or deleting the original action. This is an
|
||||
explicit limit. Normal non-sensitive replay remains deterministic.
|
||||
|
||||
Assistant planning now derives its request identity from the trusted saved user
|
||||
turn and provider invocation ID. Replaying that invocation retains its request
|
||||
identity, while a new user turn can request a new action. Calls without a bound
|
||||
invocation identity retain fresh-request semantics. The adapter no longer makes
|
||||
a separate capability-admission decision before the canonical planner, which
|
||||
owns both persisted replay and admission of a new plan. These additional runtime
|
||||
changes require final-source regression and live qualification before landing.
|
||||
|
||||
Before live qualification of this request/planning slice, exercise:
|
||||
|
||||
- First canonical plan and its exact pending-approval review.
|
||||
- Same actor/request after resource or policy drift, retaining original plan,
|
||||
expiry, decisions and outcome without a second action or execution.
|
||||
- Changed intent under the same request, returning the stable HTTP 409
|
||||
`action_request_conflict`, with original history unchanged.
|
||||
- An explicitly new request and a new Assistant user turn, each able to create
|
||||
its own plan under current admission policy.
|
||||
- Rejected and independently completed actions reopened by exact ID, including
|
||||
Assistant continuation, persisted session reload and native review links.
|
||||
- Desktop, intermediate and narrow layouts, expanded policy/observer/delivery
|
||||
details, keyboard activation, Escape, dismissal, focus return and scrolling.
|
||||
|
||||
The core broker now has a separate plan-only entrypoint, sharing the canonical
|
||||
planning implementation while omitting policy progression. Wiring that entrypoint
|
||||
into model-visible investigation turns, removing proposal capture as an outcome
|
||||
channel, retaining persisted action references across provider failure and
|
||||
repeating the full diagnostic matrix remain unfinished. This addition alone is
|
||||
not the completed Patrol planning migration.
|
||||
|
||||
|
||||
### Canonical planning implementation in progress, 2026-09-07 13:50 UTC
|
||||
|
||||
The next source slice replaces the investigation proposal capture state machine
|
||||
with a core-owned plan-only callback. The tool returns the persisted canonical
|
||||
plan and action identity during the model turn. Planning no longer forces a
|
||||
prose-only next turn. Later conflicting calls preserve the accepted action.
|
||||
Completed tool observations are attached at planning time and later reads do not
|
||||
rewrite that accepted origin. Provider failure retains the known action in the
|
||||
investigation and does not request policy progression. These changes are not yet
|
||||
qualified or landed. Tests that asserted proposal ambiguity erased history are
|
||||
being replaced with preservation, concurrency and refusal-continuation tests.
|
||||
|
||||
The request identity r16 targeted race proof passed in actionlifecycle,
|
||||
unifiedresources, agentcapabilities and API. The full actionlifecycle race suite
|
||||
passed in 6.940 seconds. The full API race command reached its 600-second total
|
||||
package timeout with the named security test only just starting. This is not a
|
||||
passing full-package receipt. An isolated security-test run and the r17 invocation
|
||||
identity proof are queued through the shared heavy-work allocator behind release
|
||||
preflight. No allocator lock or provider refusal has been bypassed.
|
||||
|
||||
Remaining executable steps: compile and run changed core and enterprise boundary
|
||||
regressions, repair any failures, build a source-bound development artifact on the
|
||||
worker, repeat the documented action interaction matrix at desktop/intermediate/
|
||||
mobile widths, run the unhealthy/healthy/dependency/missing-access/storage and
|
||||
approved/rejected real-model disposable-lab scenarios, retain exact results and
|
||||
cleanup receipts, run landing hooks and land scoped changes. The local completion
|
||||
gate remains open. Wider customer rollout remains separately unqualified.
|
||||
|
||||
### Qualification export correction, 2026-09-07 14:10 UTC
|
||||
|
||||
The first enterprise r18 compile used a stale shared core export. Its setup
|
||||
replaced the scratch export root while a core proof was queued. That compile
|
||||
also ran outside the required heavy-work allocator and failed before tests.
|
||||
It is not qualification evidence. The stale queued proofs and a duplicate
|
||||
isolated API invocation were cancelled. The original isolated API test remains
|
||||
queued. No release-preflight process was interrupted.
|
||||
|
||||
The corrected r19 commands use the shared allocator, the non-root maintainer
|
||||
identity and checksum preconditions for 33 core source/document paths and three
|
||||
enterprise files. Source transfer and formatting completed before queueing.
|
||||
These checks bind the forthcoming receipts to the tested content, but they do
|
||||
not replace the still-required live model, disposable-lab and browser proof.
|
||||
|
||||
The canonical sensitive-parameter guard now runs in the action lifecycle after
|
||||
request replay lookup, so an accepted plan can still be read when the live
|
||||
capability registry becomes unavailable. The Patrol broker sets that guard from
|
||||
trusted core context. Missing request identity refuses before any action is
|
||||
created. Model-supplied sensitive values remain prohibited.
|
||||
|
||||
|
||||
### Canonical planning regression status, 2026-09-07 14:24 UTC
|
||||
|
||||
The r21 checksum-bound targeted race command passed actionlifecycle, unified
|
||||
resources, tools, chat, API, agent capabilities and public contracts. The full
|
||||
enterprise investigation race suite passed in 1.135 seconds. The previously
|
||||
named API security test passed in the targeted API command (5.240 seconds for
|
||||
the complete selected API set). This does not retroactively pass the earlier
|
||||
600-second full API package run.
|
||||
|
||||
The r22 full chat, agent-capability and public-contract race suites passed.
|
||||
Two tools tests still depended on the retired adapter-level capability veto.
|
||||
They now exercise the real canonical lifecycle for VMware refusal. The r23 full
|
||||
tools race suite passed, and a Go 1.26.8 Darwin development build succeeded with
|
||||
SHA256 c00d6557fa8d5e5637d315e09e4535c3da0d5245cc2f9d6bce903fbd532cf15c.
|
||||
That artifact has not been installed or live-qualified. A final approval-context
|
||||
projection repair was identified afterwards and requires the next build.
|
||||
|
||||
The proposal-attempt counter, ambiguity/integrity outcome state machine and
|
||||
its separate error transport have been removed from the primary contracts.
|
||||
A planning refusal is an ordinary tool result. A persisted action remains in
|
||||
history after a later refusal or provider failure. Normal provider failures
|
||||
remain errors and cannot become completed diagnosis outcomes.
|
||||
|
||||
Delivery PR 1957 is still pending. Its benchmark check reported five route
|
||||
normalization microbenchmarks above the configured threshold. A paired exact
|
||||
base/candidate worker reproduction reproduced three slowdowns despite unchanged
|
||||
normalization source. The compiled-code comparison remains under investigation.
|
||||
No failed check has been overridden. Required live scenario and Playwright
|
||||
qualification for the current planning source remain unperformed.
|
||||
|
||||
|
||||
### 2026-09-07 current planning proof and qualification handoff
|
||||
|
||||
The r24/r25 targeted approval projection and complete enterprise investigation
|
||||
race suites passed on Go 1.26.8. Source binding found three stale export files
|
||||
before live installation: the browser receipt, static Safety document and a
|
||||
repository documentation test. These were corrected explicitly. No live proof
|
||||
is claimed for either intermediate artifact.
|
||||
|
||||
The r26 qualification package race suite passed in 7.983 seconds and the full
|
||||
enterprise investigation race suite passed in 1.133 seconds. The qualification
|
||||
scorer now retains failed tool calls as telemetry without treating any refusal
|
||||
as an automatic diagnosis failure. Scenario-owned truth and independent action
|
||||
oracles remain required. Existing keyword checks are mechanical transcript
|
||||
checks, not semantic evidence of useful diagnosis or recommendation safety.
|
||||
Each new live result still requires review against the independent fixture
|
||||
observations and exact action/outcome records.
|
||||
|
||||
The task-specific r26 browser interaction matrix is retained at workspace
|
||||
`tmp/patrol-planning-continuation/live-r26/interaction-matrix.md`. It covers
|
||||
Assistant history, exact action continuation, pending/rejected/completed action
|
||||
states, evidence expansions, keyboard dismissal and focus return, reload, and
|
||||
1440/900/390 widths. Required current-source real-model and browser qualification
|
||||
remains unperformed at this entry. The subscription-provider refusal remains
|
||||
preserved. Wider independent-customer rollout evidence is a separate open gate.
|
||||
|
||||
|
||||
The installed r26 binary is
|
||||
`0e390e8f8362bcc7dc10b51ee5eb5cf6a2232b2f736b38ab681eae7162838a78`.
|
||||
All 4,850 source/module/frontend file hashes matched its worker export before
|
||||
installation. Runtime reports Go 1.26.8 and version
|
||||
`0.0.0-dev-pro+patrol-planning-r26`. Its generic version endpoint still reports
|
||||
release/stable metadata, so artifact SHA and source manifest are the proof
|
||||
identity, not those generic labels.
|
||||
|
||||
Real Gemini missing-runner session `6fe14195-e9dd-416e-9e96-3bb500359cf4`
|
||||
completed in 11.636 seconds and correctly reported the unavailable host typed
|
||||
runner. It made three planning attempts, including an unnecessary numeric-ID
|
||||
lookup miss. No action succeeded and no guest-agent prerequisite was invented.
|
||||
The inventory tool exposed an unavailable disk percentage as -100 for the stopped
|
||||
VM. That value did not drive the conclusion, but remains an explicit metric
|
||||
projection defect to resolve before claiming general diagnostic data quality.
|
||||
|
||||
Real Gemini session `83407929-bec4-493e-99ce-20295725ca86` prepared rejected
|
||||
start `act_68699d9b3caed37f85faaaaf9c70ff3e` in 9.578 seconds and explained its
|
||||
unexecuted terminal result in 7.499 seconds. A distinct approved start
|
||||
`act_42404c31949a96963aa3e6794bace969` was prepared in 5.867 seconds. Pulse
|
||||
independently observed the VM running through Proxmox, and Assistant queried
|
||||
that exact action and explained approval, successful execution and independent
|
||||
verification in 6.645 seconds. Neither explanation created another plan.
|
||||
Restoration stop `act_2265d24450898a678ae3a1dc34034faa` independently confirmed
|
||||
stopped. Runner, both temporary tokens and tunnel were removed, control was
|
||||
restored and the production agent remained unchanged.
|
||||
|
||||
Playwright exercised the pending/rejected/completed action records at
|
||||
1440/900/390 and Assistant history, seven tool expansions, reload and the native
|
||||
exact-action review link. Pixel inspection confirmed the settled desktop
|
||||
Assistant header and narrow action outcome were usable. An initial screenshot
|
||||
during scrolling clipped the header, so the affected pass was repeated after
|
||||
scroll settlement. These passes qualify the named VM journey only. Docker
|
||||
healthy/unhealthy/dependency/storage and Patrol-origin plan continuation remain
|
||||
required. Local receipts and the independent interpretation are under workspace
|
||||
`tmp/patrol-planning-continuation/live-r26/`.
|
||||
|
||||
|
||||
### Current canonical planning qualification, r28 to r30
|
||||
|
||||
The r28 binary SHA256 is
|
||||
`c515516beb61fe902f2c3d1fdd50f4f9bf2185cc33f15d529027d93b2eeaba54`.
|
||||
It contains the canonical unavailable-disk correction and the revised investigation
|
||||
prompt. Unavailable disk usage is absent rather than -100 or a fabricated zero.
|
||||
The prompt distinguishes the observed failure mechanism from an unobserved
|
||||
origin. It does not manufacture a cause from an accepted plan or require
|
||||
per-call gap narration. Targeted core metric and tool checks and the complete
|
||||
enterprise investigation race suite passed on Go 1.26.8.
|
||||
|
||||
All following cases used the authorized `openrouter:google/gemini-3.8-flash`
|
||||
route, disposable fixtures and independent fault/revert/cleanup oracles. Each is
|
||||
one current repetition, not a statistically representative reliability sample.
|
||||
Earlier failed attempts remain failed. Scorer grounding fields are keyword
|
||||
checks. The interpretation below comes from reviewing the model conclusion
|
||||
against the observed fixture and canonical action records.
|
||||
|
||||
| Case and run | Observed result | Watch / complete scenario seconds |
|
||||
|---|---|---|
|
||||
| Healthy `q-20260907-150813-a08381f0` | No finding on healthy controls. No injected fault, so recall is not applicable despite the scorer's vacuous value of 1. | 5.803 / 20.296 |
|
||||
| Unhealthy `q-20260907-150920-a22e598e` | Identified the one unhealthy container and left the healthy sibling alone. Correctly left its internal cause unknown and named the next diagnostic read. Watch-only case. | 11.948 / 47.460 |
|
||||
| Dependency `q-20260907-150204-5cd9ff3b` | Followed the client symptom to the stopped dependency. Distinguished the observed outage mechanism from the unknown reason it stopped. Canonical start plan `act_488eb1b9e5fb2d79e361bc163206ae19` required approval. No product execution or recovery is claimed. | 11.370 / 52.437 |
|
||||
| Storage `q-20260907-150004-1bbdf26d` | Identified an exhausted 8 MiB tmpfs, zero free space and ENOSPC. Distinguished unknown origin and the data-loss implication of clearing volatile storage. Plan `act_d2f137411342c995d840d529a70fbf95` remained unexecuted and later expired. Fixture reversion independently restored capacity and health, which is lab recovery rather than a verified product action. | 10.244 / 50.562 |
|
||||
| Approved `q-20260907-150350-f75852dd` | Identified the failed PID health probe without inventing why the process died. Action `act_fd2d0bdab1930e2d3a75faf080b8ba7a` was approved, executed and independently observed healthy through the Docker daemon. Finding `cd8d247cf83bce25` retained the linked investigation and verified outcome. | 10.232 / 89.633 |
|
||||
| Rejected `q-20260907-150613-16ba31eb` | Identified the same observed failure mechanism and retained uncertainty. Action `act_369c466aea179f93fc69aa04a710689a` was rejected. Independent observation confirmed the fault remained, with no execution promoted into success. | 9.388 / 39.133 |
|
||||
|
||||
The five injected Docker faults were found with zero measured misses and zero
|
||||
extra findings in these exact runs. This does not estimate population recall or
|
||||
false-alarm rates. Scenario totals include collection and workflow waiting, not
|
||||
just model latency. Watch estimates range from US$0.007354 to US$0.015235 and
|
||||
exclude separate investigation calls. They are not complete journey spend.
|
||||
Provider-side key limits remain the budget authority. Population useful diagnosis,
|
||||
false alarms, missed problems, journey latency and verified-outcome rates remain
|
||||
unknown beyond the honest adoption baseline above.
|
||||
|
||||
Missing-runner session `ecd14a20-6855-466e-942c-02fee00f30ef` completed in
|
||||
9.336 seconds, reported the unavailable typed runner and did not create a
|
||||
successful action or invent an in-guest agent prerequisite. VM session
|
||||
`baf00cce-0f5c-4f7b-88d3-7040559c5ae3` prepared rejected start
|
||||
`act_c007cc3882625e10ef84c28f571590ab`, explained its unexecuted outcome,
|
||||
prepared approved start `act_cb9cfb3d10031015a063bfcb1f46e90b`, and explained
|
||||
its independent Proxmox running observation. These four turns took 10.273,
|
||||
7.068, 5.533 and 6.304 seconds. Both explanations queried the existing exact
|
||||
action and created no replacement. Restoration stop
|
||||
`act_96826d092451c76c3063d2a7e270549d` independently confirmed stopped.
|
||||
Playwright exercised pending, rejected, completed start and completed stop at
|
||||
1440, 900 and 390 pixels. Those backend outcomes remain source-bound to r28.
|
||||
|
||||
All Docker fixtures passed independent cleanup, repeated cleanup no-op and
|
||||
unchanged-inventory checks. VM110 was restored stopped, temporary runner,
|
||||
collector and command tokens were revoked, and the tunnel stopped. Tower's
|
||||
temporary collector and command token were removed and its original development
|
||||
collector restored at 15:12 UTC. Both production agents remained unchanged.
|
||||
Patrol is paused and control is read-only. Raw local receipts are in workspace
|
||||
`tmp/patrol-planning-continuation/live-r28/` and the Tower restoration transaction
|
||||
in `live-r26/tower-agent-transaction-r3.json`.
|
||||
|
||||
The r29 frontend correction stopped projecting unknown legacy destructive risk
|
||||
as false, removed a synthetic legacy fix, and distinguishes a completed canonical
|
||||
action from an approval. It passed type checking and 143 focused frontend tests.
|
||||
Real linked Assistant session `46a5bb5c-5d51-45f8-8856-ca65cdb5034f` read the
|
||||
approved action once and correctly explained the independent historical outcome,
|
||||
unobserved process-exit cause and later fixture removal. Browser pixels failed:
|
||||
chunk-boundary spaces and newlines were lost. The shared handoff-policy sanitizer
|
||||
trimmed every content delta. The r30 change preserves whitespace through policy
|
||||
redaction and tests concatenated streamed and stored text while retaining
|
||||
redaction. Final current-source browser proof is required before this is qualified.
|
||||
|
||||
PR #1957 merged as `a66b8e11d7ca9ed5660ffd8725a1461661ca2fdf` at 14:24:04 UTC.
|
||||
Its benchmark check failed. No manual override was issued by this task, and that
|
||||
failure is not reclassified as a pass. The remaining implementation is a separate
|
||||
scoped delivery. Local main was safely fast-forwarded to `7e34b00d4f` with task
|
||||
changes preserved. The worker export received the exact 31 newly committed
|
||||
files before the r30 build. Qualification of the affected final browser journey
|
||||
and verified landing remain open. Independent volunteered environments remain a
|
||||
separate wider rollout gate. The subscription-provider refusal is preserved.
|
||||
|
||||
|
||||
### Verified-history replay correction, r31
|
||||
|
||||
The r30 real-model explanation was readable and correctly cited the canonical
|
||||
Docker observation time. It also exposed a separate persisted-history defect:
|
||||
`UpdateInvestigation` and `UpdateInvestigationOutcome` assigned `time.Now()` to
|
||||
an already resolved finding and appended another verification event when the
|
||||
same result was replayed. Startup reconciliation could therefore move the
|
||||
finding resolution time without a new recovery. The canonical action observer
|
||||
time remained intact.
|
||||
|
||||
Both writers now share one verified-resolution projection. It preserves an
|
||||
existing resolution timestamp, appends verification only for a new outcome or
|
||||
an unresolved finding, and allows a genuinely regressed finding to resolve again.
|
||||
Unchanged non-resolution status/outcome replays do not append fake transitions.
|
||||
Regression coverage checks both writers, repeated replay, active counts and a
|
||||
new resolution after regression. Previously rewritten timestamps are not guessed
|
||||
back into history. Their exact recovery evidence remains the canonical action's
|
||||
independent observation. A current-runtime restart/read comparison and repeated
|
||||
browser qualification remain required for this correction.
|
||||
|
||||
|
||||
The follow-through separated the stores: the Patrol source still held the correct
|
||||
15:05:29 UTC resolution, while the unified finding supplied to Assistant had a
|
||||
later timestamp. The router first projects the source finding and then invokes
|
||||
`UnifiedStore.Resolve`, which unconditionally replaced that projected timestamp.
|
||||
The r32 correction makes this shared resolver preserve a recorded resolution and
|
||||
still timestamp a genuinely reopened finding. A regression covers that exact
|
||||
projection-plus-resolve sequence. The r31 writer fix remains necessary for
|
||||
idempotent source updates, but was not alone the complete fix for the observed
|
||||
Assistant discrepancy. Full affected r31 race suites passed, including chat,
|
||||
unified resources, lifecycle, tools, qualification and public contracts.
|
||||
|
||||
|
||||
### Final browser follow-through, r33
|
||||
|
||||
The r32 source-bound runtime is
|
||||
`ed1b38e9744f613b52e747ecc53fb669cdad3210bce7cf619c39919cc047cfb8`.
|
||||
An authenticated before/after restart comparison proved the timestamp repair:
|
||||
Patrol retained `2026-09-07T16:05:29.047446+01:00`, while the unified record
|
||||
changed from the incorrect startup time `16:26:35.673544+01:00` back to that
|
||||
exact source time. There was still exactly one source verification event.
|
||||
The canonical source could repair this projection without guessing history.
|
||||
|
||||
Real Assistant session `93998cc0-cf4c-4ca3-8011-ff27dde0f227` explained the
|
||||
approved outcome at 15:05:29 UTC and retained uncertainty about the original
|
||||
process exit. Storage continuation `9d92a098-9f6f-4b05-8f37-e9cfea8c7425`
|
||||
read the exact expired action, distinguished no execution from recovery,
|
||||
identified the measured tmpfs exhaustion and attributed finding closure to
|
||||
later resource removal rather than the unexecuted plan. These are semantic
|
||||
continuation results, not another fault-injection repetition.
|
||||
|
||||
Pixel review exposed an additional desktop defect after streaming: the end
|
||||
anchor's `scrollIntoView` could scroll the outer document and leave the docked
|
||||
Assistant outside the viewport. The shared transcript component now scrolls its
|
||||
own container for both streaming and Latest. It still respects a reader who has
|
||||
scrolled away from live output. Existing scrolling regressions now check the
|
||||
owned container and no ancestor scrolling. The final matrix adds streaming,
|
||||
manual scroll-away, Latest, document position, reachable header/composer and
|
||||
resize while open. This is required browser qualification, not a screenshot-only
|
||||
cosmetic claim.
|
||||
|
||||
### Scope reconciliation
|
||||
|
||||
The current local matrix satisfies the requested storage-or-backup branch with
|
||||
an actual capacity fault. Backup coverage, restore correctness and independent
|
||||
backup-source failure remain unqualified and cannot inherit that result. The
|
||||
old incident-memory listing and typed compatibility-ID adapter remain isolated
|
||||
modernization residuals. Primary diagnosis uses canonical resource queries and
|
||||
history. Their earlier limitations are not represented as corrected here.
|
||||
Unsupported-filter failures from earlier transcripts remain historical failures,
|
||||
while current named canonical queries pass the recorded scenarios. This does not
|
||||
qualify every filter or legacy adapter. The fixed retained-history contention has
|
||||
functional concurrency proof, not a worker performance benchmark. Current
|
||||
scenario and Assistant timings are observations, not latency SLO qualification.
|
||||
Unattended action execution and broader autonomy remain outside the approved and
|
||||
rejected operator-mediated lab proof. Independent volunteered environments remain
|
||||
the wider rollout gate. These residuals stay in the owning coverage gap rather
|
||||
than being silently treated as product-wide readiness.
|
||||
|
||||
|
||||
The fresh-stream matrix then distinguished wheel overscroll from programmatic
|
||||
scrolling. At the transcript bottom, an 800-pixel wheel event moved the outer
|
||||
page from scrollY 41 to 841 while the transcript stayed at its maximum. The
|
||||
shared transcript now also contains overscroll, preventing end-of-conversation
|
||||
wheel input from chaining into the page. The failed r33 browser runs remain
|
||||
failed. The r34 affected matrix must repeat after this final frontend change.
|
||||
|
||||
|
||||
### Final source-bound qualification, r34, 2026-09-07
|
||||
|
||||
The final bundled runtime is `0.0.0-dev-pro+patrol-planning-r34`, SHA256
|
||||
`e1ae053e656b28af9ba0e7541a47bdeb56303d1d7912edc57b2dac2f94620bd1`.
|
||||
The source binding matched 4,853 Go, module and frontend files. Relative to the
|
||||
r28 fault matrix, the only later non-test Go changes are the shared whitespace
|
||||
redactor, chat presentation redaction, Patrol resolution writer and unified
|
||||
resolution writer. Planning, investigation prompts, execution and capability
|
||||
logic are identical. The r28 fault matrix is not misrepresented as a new r34
|
||||
repetition. The changed writers have package race proof and current-runtime
|
||||
restart, real-model continuation and browser follow-through.
|
||||
|
||||
Worker proof passed the affected AI findings tests and full chat, unified
|
||||
resources, action lifecycle, agent capabilities, tools, qualification and public
|
||||
contract race suites in r31. The r32 unified-store race suite passed after the
|
||||
final timestamp correction. Final r34 frontend type checking, 178 tests across
|
||||
five affected files, Vite build and the full enterprise investigation race suite
|
||||
passed. The earlier full API timeout remains a timeout, alongside the narrower
|
||||
passing API/contract proofs. The earlier PR1957 benchmark failure is still not a
|
||||
pass and no benchmark claim is added here.
|
||||
|
||||
Fresh r34 Assistant session `6a0b9f67-97fc-4966-9674-1060df61cf7b` explained
|
||||
finding `cd8d247cf83bce25` and exact action
|
||||
`act_fd2d0bdab1930e2d3a75faf080b8ba7a`. It retained the independent recovery
|
||||
observation at 15:05:29 UTC, uncertainty about the initial process death and the
|
||||
separate later fixture removal. Model response time was 11.970 seconds and the
|
||||
whole browser interaction took 32.815 seconds. No new action was requested.
|
||||
|
||||
At `/patrol`, final Playwright covered 1440x1000, 900x1000 and 390x1000:
|
||||
approved, rejected and expired-storage finding reviews, all investigation tool
|
||||
cards, deeply scrolled filesystem measurements, Discuss with Assistant,
|
||||
attached finding context, explicit new-session context clearing, resuming saved
|
||||
context, exact Actions links, terminal controls, reload and Escape. Storage
|
||||
retained the expired plan and its risk. Rejection remained distinct from an
|
||||
executed recovery. The approved action retained independent verification.
|
||||
Actual desktop, intermediate and narrow pixels were inspected.
|
||||
|
||||
Fresh streamed approved explanation and saved rejected/storage explanations
|
||||
passed wheel scrolling at the transcript end, scrolling away, Latest, resize
|
||||
while open, unchanged outer document position and reachable header/composer at
|
||||
all three widths. Saved missing-access session
|
||||
`ecd14a20-6855-466e-942c-02fee00f30ef` and VM action/outcome session
|
||||
`baf00cce-0f5c-4f7b-88d3-7040559c5ae3` were selected through the session-history
|
||||
picker, their exact message reads checked, all tool details expanded and the
|
||||
same three-width scrolling matrix passed without model submissions. The
|
||||
missing-access explanation retains the actual missing typed-runner refusal.
|
||||
It does not invent a guest-agent requirement or claim an accepted action.
|
||||
|
||||
The first history scripts crossed asynchronous initial row replacement and
|
||||
failed on detached locators. A network-idle wait also timed out on this live
|
||||
monitoring page. The final script waits for the explicit history response,
|
||||
opens the review through the user control, closes it, checks returned focus and
|
||||
reopens with Enter. A separate 12-second check retained the exact row node,
|
||||
keyboard focus and open selection. These failed script attempts are preserved,
|
||||
not counted as product passes. Both shipped Assistant documentation routes also
|
||||
passed desktop/narrow linked navigation, reload, deepest scroll and no overflow.
|
||||
|
||||
Receipts live under workspace `tmp/patrol-planning-continuation/patrol-browser`:
|
||||
`approved-explained-r34`, `approved-history-r34`, `storage-history-r34d`,
|
||||
`rejected-history-r34b`, `rejected-resumed-r34`, `storage-resumed-r34`,
|
||||
`missing-access-saved-r34b`, `vm-outcomes-saved-r34` and `review-refresh-r34`.
|
||||
Documentation receipts are in `docs-browser-r34`. The committed browser receipt
|
||||
binds the three changed frontend runtime files to exact content hashes.
|
||||
|
||||
The named local implementation matrix is performed. This does not complete the
|
||||
production-wide readiness gate. Independent volunteered customer environments,
|
||||
population diagnosis/false-alarm/miss/latency evidence, backup/restore and wider
|
||||
unattended autonomy remain unqualified. Local delivery still requires scoped
|
||||
core and enterprise commits through their repository workflow. Preserve the
|
||||
explicit subscription-provider refusal with no retry, rephrasing or bypass.
|
||||
|
||||
|
||||
Final r34 action review also reopened VM rejected Start, independently verified
|
||||
Start and independently verified restoration Stop at 1440, 900 and 390 widths.
|
||||
Policy/evidence/delivery disclosures, deepest outcome, Escape, explicit close,
|
||||
reopen and reload passed with login as the only write. Exact-action receipts are
|
||||
in `patrol-browser/vm-actions-r34`.
|
||||
|
||||
The first exact staged-tree hook passed sensitivity/gitleaks, source formatting,
|
||||
documentation mirrors, browser binding and governance staging, then correctly
|
||||
stopped because the registry's explicit store-proof list did not name the new
|
||||
`action_request_identity_test.go`. The owning proof map now includes that actual
|
||||
persistence/concurrency regression and the new policy-redaction regression.
|
||||
No contract-neutral or completion override was used. The full hook must pass on
|
||||
the revised exact tree before commit.
|
||||
|
||||
@@ -10201,7 +10201,7 @@
|
||||
},
|
||||
{
|
||||
"id": "patrol-assistant-customer-outcome-qualification",
|
||||
"summary": "The redesign remains open. The executable plan, evidence-backed contract, honest telemetry baseline and source-bound historical receipts are in docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. Evidence/history/risk corrections landed through PR1928/1929/1934/1935/1951 and enterprise PR22. PR1955 merged as 62f6931c1fc2e46511876139ea20904752ea208c with typed Proxmox runner admission, preserved clock provenance, independent VM110 start/stop confirmation and final-build action-review Playwright proof at 1440, 900 and 390 pixels. Temporary fixtures were removed and a missing-runner plan refused with HTTP409. The current continuation slice removes the generic resolve/write/verify machine, semantic lifecycle-request correction, question/read counters, repeated-call and failed-turn proxies, and inferred self-correction metrics. Chat race and tool-package proofs pass. The source-bound worker enterprise build is installed for required real-model and browser qualification, which is not yet complete. Canonical planning acceptance/refusal must still move inside the investigation tool turn, retain persisted action references across provider failures and separate planning from policy-authorized progression. Scenario-owned expected-refusal scoring and the remaining healthy/unhealthy/dependency/missing-access/storage-or-backup/approved/rejected live matrix remain required. Historical incident-memory, unsupported-filter, compatibility, backup, autonomy and startup-latency gaps must be reconciled against current evidence rather than silently counted complete. Adoption counts of 127 paid installations, 71 Patrol-enabled installations and 23 Assistant users do not measure effectiveness. Fourteen verified resolutions from one installation do not establish population diagnosis, false-alarm, missed-problem or latency rates, which remain unknown. Autonomous modes and independent volunteered Pro environments remain unqualified wider-rollout gates. Preserve the explicit subscription-provider refusal without retry or bypass. Do not close this gap or the candidate while required qualification remains unperformed.",
|
||||
"summary": "The wider readiness gate remains open. The executable contract, honest baseline, exact runs and residuals are in docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. Earlier evidence/history/risk and typed-runner changes landed through core PR1928/1929/1934/1935/1951/1955 and enterprise PR22. PR1957 auto-merged as a66b8e11d7ca9ed5660ffd8725a1461661ca2fdf despite its recorded benchmark failure, which is not counted as a pass. The current slice makes canonical planning acceptance/refusal available inside the model turn, retains accepted actions across provider failure, enforces persisted actor/request identity, preserves complete approval/risk/outcome context and removes proposal/tool-count diagnosis proxies. Shared disk unknowns, streamed whitespace and replayed resolution timestamps are corrected. Affected package race suites and final frontend checks pass. Source-bound r28 Gemini qualification performed healthy, unhealthy, dependency, missing-access, storage-capacity, approved and rejected cases, including independently observed Docker recovery and VM110 start/stop. Earlier semantic failures remain recorded. Current r34 runtime passes final linked-history and Assistant Playwright at 1440, 900 and 390 widths, including fresh approved explanation, saved missing-access/VM/rejected/storage continuation, deep evidence, exact action states and scrolling. The local named implementation matrix is performed, with source-equivalence and exact limits recorded. Scoped core and enterprise landing remains pending. Temporary collectors, runners, tokens and fixtures were cleaned and production processes preserved. Storage capacity does not qualify backup/restore. Legacy incident-memory/compatibility residuals, all-filter coverage, unattended autonomy and latency SLOs remain unqualified. Adoption of 127 paid installations, 71 Patrol-enabled installations and 23 Assistant users does not measure effectiveness. Fourteen verified resolutions from one installation do not establish population useful-diagnosis, false-alarm, missed-problem or latency rates, which remain unknown. Independent volunteered customer environments are the separate wider rollout gate. Preserve the explicit subscription-provider refusal without retry or bypass. Do not close this gap or candidate while wider readiness evidence is missing.",
|
||||
"owner": "project-owner",
|
||||
"status": "planned",
|
||||
"recorded_at": "2026-09-05",
|
||||
@@ -10405,11 +10405,11 @@
|
||||
{
|
||||
"id": "patrol-planning-continuation-coverage-gap-patrol-assistant-customer-outcome-qualification",
|
||||
"agent_id": "patrol-planning-continuation",
|
||||
"summary": "Canonical planning inside the model tool turn, model-owned continuation and exact refusal/outcome qualification",
|
||||
"summary": "Complete canonical planning, outcome history, linked Assistant qualification and scoped landing",
|
||||
"target_id": "v6-product-lane-expansion",
|
||||
"claimed_at": "2026-09-07T11:10:27Z",
|
||||
"heartbeat_at": "2026-09-07T12:29:13Z",
|
||||
"expires_at": "2026-09-07T16:29:13Z",
|
||||
"heartbeat_at": "2026-09-07T15:35:32Z",
|
||||
"expires_at": "2026-09-07T17:35:32Z",
|
||||
"work_item": {
|
||||
"kind": "coverage-gap",
|
||||
"id": "patrol-assistant-customer-outcome-qualification"
|
||||
|
||||
@@ -15,6 +15,16 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Canonical Patrol and Assistant continuation, 2026-09-07
|
||||
|
||||
Patrol planning uses the canonical action broker's plan-only boundary. Policy
|
||||
progression and execution remain separate and retain runner admission, trusted
|
||||
identity, approval and independent observation. An accepted plan survives later
|
||||
provider failure without authorizing dispatch. Public action references preserve
|
||||
the observer outcome and complete approval requirement. Disposable Docker and
|
||||
Proxmox VM proof and cleanup limits are recorded in
|
||||
`docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md`.
|
||||
|
||||
Optional container filesystem observations add no command or lifecycle
|
||||
authority. The collector's existing disk-metrics option controls them. Native
|
||||
reads require the exact local container process identity and current namespace
|
||||
|
||||
@@ -25,6 +25,23 @@ that same result. Successful reads retain their content and execution provenance
|
||||
|
||||
## Purpose
|
||||
|
||||
### Canonical Patrol and Assistant continuation, 2026-09-07
|
||||
|
||||
Canonical investigation planning runs inside the model's tool call. The model
|
||||
receives the persisted plan or refusal before choosing its next step. Accepted
|
||||
planning does not end the model turn or establish a diagnosis. Accepted action
|
||||
identity survives a later provider failure, which remains a separate failure.
|
||||
Completed tool results supply evidence identity. Tool starts and proposed prose
|
||||
do not become observations. Explicit run budgets, authority and independent
|
||||
verification remain deterministic.
|
||||
|
||||
Patrol and unified finding replay preserve the original resolution time and do
|
||||
not append another verification event. A genuine regression can resolve again.
|
||||
Handoff-policy redaction preserves streamed whitespace. Assistant scrolling owns
|
||||
only the transcript container and cannot move outer page ancestors. Current
|
||||
qualification, failures and wider rollout limits are recorded in
|
||||
`docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md`.
|
||||
|
||||
Patrol investigation's mode contract states its non-interactive read/proposal
|
||||
boundary and asks for diagnosis, supporting evidence and remaining uncertainty.
|
||||
It does not prescribe restarting an unhealthy container or require a peer
|
||||
|
||||
@@ -20,6 +20,22 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Canonical Patrol and Assistant continuation, 2026-09-07
|
||||
|
||||
Canonical planning owns persisted actor/request idempotency. The same trusted
|
||||
request and exact normalized intent return the original plan before live registry
|
||||
admission. A different intent under that identity returns
|
||||
`action_request_conflict` (HTTP 409), including persistence-wrapped conflicts.
|
||||
Concurrent inserts cannot create two accepted actions. Planning does not approve
|
||||
or execute. Assistant invocation identity binds session, user message and provider
|
||||
tool-call identity. Patrol uses its explicit proposal identity.
|
||||
|
||||
Public action references preserve causal resource identity, versioned independent
|
||||
outcomes and canonical approval requirements, including quorum, MFA and requester
|
||||
exclusion. Projection rejects malformed requirement metadata rather than silently
|
||||
downgrading it. Current regression and lab limits are in
|
||||
`docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md`.
|
||||
|
||||
Action reconciliation refreshes the durable product investigation record from
|
||||
the authoritative session/action even when the finding outcome already matches.
|
||||
The same builder owns initial completion and later refresh. Original prose,
|
||||
|
||||
@@ -20,6 +20,16 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Canonical Patrol and Assistant continuation, 2026-09-07
|
||||
|
||||
Patrol's Assistant context preserves unknown destructive risk and distinguishes
|
||||
canonical action state from legacy approval state. Transcript scrolling is owned
|
||||
by the shared Assistant message container, including streaming and Latest, so
|
||||
it cannot move outer document ancestors. Browser proof must inspect the header,
|
||||
composer, nested evidence and scroll position after streaming and viewport resize.
|
||||
The current scoped matrix is recorded in
|
||||
`docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md`.
|
||||
|
||||
### Recovery feedback composes shared controls without a timer
|
||||
|
||||
The alerts-owned AlertQueueActionFeedback composes Card and Button rather than
|
||||
|
||||
@@ -15,6 +15,21 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Canonical Patrol and Assistant continuation, 2026-09-07
|
||||
|
||||
Canonical action state and legacy approval state remain distinct in Assistant
|
||||
handoff. Unknown destructive risk stays unknown. A plan does not synthesize an
|
||||
old command-shaped fix. The linked investigation retains accepted action identity
|
||||
and original diagnostic uncertainty even if a later provider turn fails.
|
||||
|
||||
The current storage-capacity run identifies the actual exhausted tmpfs, while the
|
||||
earlier host-capacity misdiagnosis remains a failed historical result. Approved
|
||||
and rejected Docker and Proxmox VM cases have independent named observations.
|
||||
The current local qualification and exact limits supersede earlier pending-work
|
||||
notes below only for those named cases. See
|
||||
`docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md`. Wider volunteered
|
||||
customer environments, backup recovery and unattended autonomy remain unqualified.
|
||||
|
||||
Action reconciliation refreshes the durable product investigation record from
|
||||
the authoritative session/action even when the finding outcome already matches.
|
||||
The same builder owns initial completion and later refresh. Original prose,
|
||||
|
||||
@@ -8546,6 +8546,7 @@
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"internal/monitoring/issue1595_collection_trust_test.go",
|
||||
"internal/unifiedresources/action_request_identity_test.go",
|
||||
"internal/unifiedresources/availability_link_test.go",
|
||||
"internal/unifiedresources/history_identity_test.go",
|
||||
"internal/unifiedresources/kubernetes_registry_test.go",
|
||||
@@ -8611,6 +8612,7 @@
|
||||
"exact_files": [
|
||||
"internal/ai/tools/filesystem_evidence_test.go",
|
||||
"internal/monitoring/docker_filesystem_evidence_test.go",
|
||||
"internal/unifiedresources/action_request_identity_test.go",
|
||||
"internal/unifiedresources/action_result_v2_test.go",
|
||||
"internal/unifiedresources/availability_link_test.go",
|
||||
"internal/unifiedresources/canonical_id_pins_test.go",
|
||||
@@ -8620,6 +8622,7 @@
|
||||
"internal/unifiedresources/metrics_targets_test.go",
|
||||
"internal/unifiedresources/metrics_test.go",
|
||||
"internal/unifiedresources/pbs_pmg_registry_test.go",
|
||||
"internal/unifiedresources/policy_presentation_test.go",
|
||||
"internal/unifiedresources/registry_merge_policy_test.go",
|
||||
"internal/unifiedresources/registry_test.go",
|
||||
"internal/unifiedresources/resolve_test.go",
|
||||
|
||||
@@ -21,6 +21,16 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Canonical Patrol and Assistant continuation, 2026-09-07
|
||||
|
||||
The shared action API preserves full canonical approval requirements and actor/
|
||||
request idempotency for storage-adjacent callers. An accepted plan is not an
|
||||
executed operation or a recovery proof. The current Patrol capacity case measured
|
||||
an exhausted container tmpfs and independently reverted the disposable fixture.
|
||||
Its unexecuted restart plan does not establish product recovery or backup restore
|
||||
correctness. See `docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md` for
|
||||
exact observations and the separate unqualified backup and wider rollout gates.
|
||||
|
||||
Container filesystem evidence is a read-only observation at a named resource
|
||||
mountpoint. It grants no storage mutation, recovery or host-capacity authority.
|
||||
A full container tmpfs does not establish host-array exhaustion. Unknown byte
|
||||
|
||||
@@ -23,6 +23,19 @@ and sort the complete canonical change table while startup and ingestion wait.
|
||||
|
||||
## Purpose
|
||||
|
||||
### Canonical Patrol and Assistant continuation, 2026-09-07
|
||||
|
||||
Persisted action request identity is scoped to the trusted actor and exact
|
||||
normalized intent. Store insertion enforces it atomically, with duplicate or
|
||||
unprovable historical identities rejected rather than guessed equal. Replays
|
||||
retain the original plan and expiry even if the live resource or registry changes.
|
||||
|
||||
Unavailable or inconsistent disk observations remain absent across node, host,
|
||||
Docker host and guest metric projections. They cannot become -100 percent or a
|
||||
fabricated measured zero. Shared resource-policy text redaction preserves content
|
||||
whitespace, including standalone stream separators. Qualification and population
|
||||
limits are in `docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md`.
|
||||
|
||||
Action evidence preserves observer and receiver timestamps as separate clock
|
||||
facts, normalized to UTC and bound into the canonical digest. The shared
|
||||
normalizer requires both timestamps but cannot infer their ordering across
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "62f6931c1fc2e46511876139ea20904752ea208c",
|
||||
"verified_at": "2026-09-07T12:53:14.214716Z",
|
||||
"base_sha": "7e34b00d4face3ffa33a36946bb527dbfbdfd74c",
|
||||
"verified_at": "2026-09-07T16:12:24.652755Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/AI/Chat/MessageItem.tsx",
|
||||
"frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx",
|
||||
"frontend-modern/src/components/AI/Chat/toolPresentation.ts"
|
||||
"frontend-modern/src/components/AI/Chat/ChatMessages.tsx",
|
||||
"frontend-modern/src/components/patrol/ApprovalSection.tsx",
|
||||
"frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/AI/Chat/MessageItem.tsx": "7716b32126cf0d6c8a3a02626fc8c6dd298d226d341ab96929b54e9a74a7c13f",
|
||||
"frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx": "b114cb8d0148c1a5fcded7bcf45c133e8dd331cbdc65b69afee48ab639918d84",
|
||||
"frontend-modern/src/components/AI/Chat/toolPresentation.ts": "aa8f35614cc48559c01417039006d1c63877602ea931ae3e5a47acc5329eed8f"
|
||||
"frontend-modern/src/components/AI/Chat/ChatMessages.tsx": "6f2bea6530d383abe81e13054ef9b7a90f4324c244166fb0a8afaac354439b48",
|
||||
"frontend-modern/src/components/patrol/ApprovalSection.tsx": "5e7ac87d87674b4c05fe80c3933d4ab542366054e2abbfa3f96afbfe90e993a4",
|
||||
"frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts": "781c8730a7f566831562160a31ba437b2aabd8840330f59b521894198f79b0f1"
|
||||
},
|
||||
"routes": [
|
||||
"/patrol",
|
||||
"/actions?action=act_43dd318fa8577d79167a6f66b2460907",
|
||||
"/actions?action=act_9bdd65533a8c0d1e526845012a931b10",
|
||||
"/actions?action=act_31ea9abb7591c83f466055a4eacd5abe",
|
||||
"/docs/ASSISTANT_SAFETY",
|
||||
"/docs/ASSISTANT_ARCHITECTURE"
|
||||
"/actions?action=act_fd2d0bdab1930e2d3a75faf080b8ba7a",
|
||||
"/actions?action=act_369c466aea179f93fc69aa04a710689a",
|
||||
"/actions?action=act_d2f137411342c995d840d529a70fbf95",
|
||||
"/docs/ASSISTANT_ARCHITECTURE",
|
||||
"/docs/ASSISTANT_SAFETY"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
@@ -36,23 +36,20 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Saved missing-runner planning refusal",
|
||||
"Saved rejected action and explanation",
|
||||
"Saved independently verified VM start outcome and explanation",
|
||||
"Expanded canonical planning and outcome tool cards",
|
||||
"Native exact-action review from Assistant with overlay closed",
|
||||
"Rejected action and completed start/stop reviews",
|
||||
"Expanded policy, independent observer and durable delivery details",
|
||||
"Narrow inline action identifiers and URLs wrap within the message area"
|
||||
"Approved and independently verified outcome",
|
||||
"Rejected and expired unexecuted plans",
|
||||
"Expanded filesystem evidence and risk",
|
||||
"Attached finding context, explicit new session, retained saved context",
|
||||
"Fresh streamed explanation and saved missing-access, VM, storage and rejection outcomes"
|
||||
],
|
||||
"interactions": [
|
||||
"Keyboard launch, Escape dismissal and launcher focus return",
|
||||
"Resume exact saved session before and after reload, verify matching session GET",
|
||||
"Expand every tool card, scroll conversation, inspect actual pixels",
|
||||
"Keyboard activate native Review action, verify exact destination and usable dialog after animation",
|
||||
"Desktop backdrop and narrow close-button dismissal",
|
||||
"Action evidence disclosures with focus and Enter, nested scrolling, Escape, explicit close, deep-link reopen and reload",
|
||||
"Inspect 1440, 900 and 390 pixel layouts, wrapping, stacking and absence of horizontal overflow"
|
||||
"Open review, close, focus return and Enter reopen",
|
||||
"Expand every investigation tool card, deep evidence scroll and actual pixel inspection",
|
||||
"Discuss with Assistant, new-session clearing and saved-session resumption",
|
||||
"Exact action link, terminal authority controls, reload and Escape",
|
||||
"Stream at bottom, wheel overscroll containment, scroll away, Latest, resize while open and reachable header/composer",
|
||||
"Session-history picker and exact persisted message read",
|
||||
"Documentation linked navigation, reload and end-of-document scroll"
|
||||
],
|
||||
"notes": "Final bundled binary SHA256 db840916eb4adec3c8c0d916bda4dd7d7ce7588ed5fc9c4be4ba9f8d1a1584d4. Runtime manifest matches 4,845 files. Only the three named frontend files differ from r5, whose model-facing backend passed fresh real-model refusal, rejection and independently verified VM execution/continuation proof. Final Playwright receipts and pixels are in workspace tmp/patrol-planning-continuation/assistant-browser (no-runner-r6, verified-vm-r6) and browser. Browser requests were reads plus login and existing provider readiness checks, with no action mutations. Temporary VM services, credentials and tunnel were removed and read-only posture restored. This receipt covers this continuation/presentation slice only. Remaining Patrol planning and diagnostic matrix qualification and independent-environment rollout evidence are still required. Architecture and safety documentation were subsequently corrected with their shipped mirrors, and inspected against the current Vite build at 1440x1000 and 390x1000, including navigation, reload and end-of-document pixels. Receipts are in docs-browser. Worker formatting changed indentation only in a Go preflight helper after the bundled proof."
|
||||
"notes": "Final runtime r34 SHA256 e1ae053e656b28af9ba0e7541a47bdeb56303d1d7912edc57b2dac2f94620bd1 matches 4,853 source inputs. Named r28 fault matrix and subsequent affected writer/streaming proofs are detailed in docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. Fresh r34 approved explanation passed, with saved final-runtime missing-access/VM/rejected/storage explanations and deep Patrol history. Receipts: workspace tmp/patrol-planning-continuation/patrol-browser and docs-browser-r34. Initial history-locator and network-idle failures are retained separately. Only login, selected authorized API provider readiness and one explicit fresh explanation were submitted during these checks. No lab action was repeated. Independent customer environments and broader readiness remain unqualified."
|
||||
}
|
||||
|
||||
@@ -79,6 +79,13 @@ Cached inventory and an incomplete resource timeline cannot establish that an
|
||||
action was never approved or run. Recorded verification describes its named
|
||||
postcondition at its observation time, not the resource's current health.
|
||||
|
||||
Patrol investigation planning uses the same lifecycle. Its planning tool returns
|
||||
an accepted action or a refusal during the investigation, so the model can
|
||||
continue from the real result. Planning does not end the investigation or prove
|
||||
its diagnosis. A later model failure retains any action already created and
|
||||
does not request automatic execution. Assistant can explain and continue that
|
||||
same action through its recorded decisions and independent outcome.
|
||||
|
||||
## Related reading
|
||||
|
||||
- [Pulse Assistant safety architecture](ASSISTANT_SAFETY.md) for the enforced
|
||||
|
||||
@@ -113,7 +113,7 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
|
||||
|
||||
const jumpToLatest = () => {
|
||||
setIsPinnedToBottom(true);
|
||||
messagesEndRef?.scrollIntoView({ behavior: 'smooth' });
|
||||
containerRef?.scrollTo({ top: containerRef.scrollHeight, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const textActivityFingerprint = (value?: string) =>
|
||||
@@ -252,7 +252,12 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
|
||||
// Use instant scroll during active streaming for smoother experience
|
||||
const lastMsg = props.messages[props.messages.length - 1];
|
||||
const behavior = lastMsg.isStreaming ? 'instant' : 'smooth';
|
||||
messagesEndRef.scrollIntoView({ behavior: behavior as ScrollBehavior });
|
||||
// Scroll only the transcript. scrollIntoView also moves outer page
|
||||
// ancestors and can push the docked Assistant outside the viewport.
|
||||
containerRef.scrollTo({
|
||||
top: containerRef.scrollHeight,
|
||||
behavior: behavior as ScrollBehavior,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -261,7 +266,7 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
|
||||
<div class="relative flex-1 min-h-0 bg-surface">
|
||||
<div
|
||||
ref={containerRef}
|
||||
class="h-full overflow-y-auto px-4 py-3 bg-surface"
|
||||
class="h-full overflow-y-auto overscroll-contain px-4 py-3 bg-surface"
|
||||
data-testid="assistant-message-list"
|
||||
onScroll={updatePinnedToBottom}
|
||||
>
|
||||
|
||||
@@ -68,6 +68,7 @@ vi.mock('../MessageItem', () => ({
|
||||
beforeEach(() => {
|
||||
capturedMessageItemProps = [];
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
Element.prototype.scrollTo = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -651,11 +652,12 @@ describe('ChatMessages', () => {
|
||||
});
|
||||
|
||||
describe('auto-scroll behavior', () => {
|
||||
it('calls scrollIntoView when messages are present', () => {
|
||||
it('scrolls the conversation without scrolling outer page ancestors', () => {
|
||||
render(() => <ChatMessages messages={[makeMessage({ id: 'msg-1' })]} {...makeHandlers()} />);
|
||||
|
||||
// scrollIntoView should have been called by the createEffect
|
||||
expect(Element.prototype.scrollIntoView).toHaveBeenCalled();
|
||||
// The owned conversation scrolls, never its ancestors.
|
||||
expect(Element.prototype.scrollTo).toHaveBeenCalled();
|
||||
expect(Element.prototype.scrollIntoView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reacts to in-place pending tool progress without a new stream event', async () => {
|
||||
@@ -695,8 +697,8 @@ describe('ChatMessages', () => {
|
||||
]);
|
||||
render(() => <ChatMessages messages={messages()} {...makeHandlers()} />);
|
||||
|
||||
const scrollIntoView = Element.prototype.scrollIntoView as ReturnType<typeof vi.fn>;
|
||||
scrollIntoView.mockClear();
|
||||
const scrollTo = Element.prototype.scrollTo as ReturnType<typeof vi.fn>;
|
||||
scrollTo.mockClear();
|
||||
|
||||
setMessages([
|
||||
makeMessage({
|
||||
@@ -734,7 +736,7 @@ describe('ChatMessages', () => {
|
||||
]);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalled();
|
||||
expect(scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps following live output when a large streaming update grows from the bottom', async () => {
|
||||
@@ -748,7 +750,7 @@ describe('ChatMessages', () => {
|
||||
]);
|
||||
render(() => <ChatMessages messages={messages()} {...makeHandlers()} />);
|
||||
const scrollContainer = screen.getByTestId('assistant-message-list');
|
||||
const scrollIntoView = Element.prototype.scrollIntoView as ReturnType<typeof vi.fn>;
|
||||
const scrollTo = Element.prototype.scrollTo as ReturnType<typeof vi.fn>;
|
||||
|
||||
setScrollMetrics(scrollContainer, {
|
||||
scrollTop: 800,
|
||||
@@ -756,7 +758,7 @@ describe('ChatMessages', () => {
|
||||
clientHeight: 200,
|
||||
});
|
||||
fireEvent.scroll(scrollContainer);
|
||||
scrollIntoView.mockClear();
|
||||
scrollTo.mockClear();
|
||||
|
||||
setScrollMetrics(scrollContainer, {
|
||||
scrollTop: 800,
|
||||
@@ -773,7 +775,7 @@ describe('ChatMessages', () => {
|
||||
]);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'instant' });
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 1400, behavior: 'instant' });
|
||||
});
|
||||
|
||||
it('does not pull the transcript back down after the user scrolls away from live output', async () => {
|
||||
@@ -787,7 +789,7 @@ describe('ChatMessages', () => {
|
||||
]);
|
||||
render(() => <ChatMessages messages={messages()} {...makeHandlers()} />);
|
||||
const scrollContainer = screen.getByTestId('assistant-message-list');
|
||||
const scrollIntoView = Element.prototype.scrollIntoView as ReturnType<typeof vi.fn>;
|
||||
const scrollTo = Element.prototype.scrollTo as ReturnType<typeof vi.fn>;
|
||||
|
||||
setScrollMetrics(scrollContainer, {
|
||||
scrollTop: 100,
|
||||
@@ -795,7 +797,7 @@ describe('ChatMessages', () => {
|
||||
clientHeight: 200,
|
||||
});
|
||||
fireEvent.scroll(scrollContainer);
|
||||
scrollIntoView.mockClear();
|
||||
scrollTo.mockClear();
|
||||
|
||||
setMessages([
|
||||
makeMessage({
|
||||
@@ -807,7 +809,7 @@ describe('ChatMessages', () => {
|
||||
]);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a jump to latest control when the user scrolls away from messages', () => {
|
||||
@@ -833,7 +835,7 @@ describe('ChatMessages', () => {
|
||||
it('jumps back to live output and hides the control when selected', () => {
|
||||
render(() => <ChatMessages messages={[makeMessage({ id: 'msg-1' })]} {...makeHandlers()} />);
|
||||
const scrollContainer = screen.getByTestId('assistant-message-list');
|
||||
const scrollIntoView = Element.prototype.scrollIntoView as ReturnType<typeof vi.fn>;
|
||||
const scrollTo = Element.prototype.scrollTo as ReturnType<typeof vi.fn>;
|
||||
|
||||
setScrollMetrics(scrollContainer, {
|
||||
scrollTop: 100,
|
||||
@@ -841,11 +843,11 @@ describe('ChatMessages', () => {
|
||||
clientHeight: 200,
|
||||
});
|
||||
fireEvent.scroll(scrollContainer);
|
||||
scrollIntoView.mockClear();
|
||||
scrollTo.mockClear();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Jump to latest Assistant message' }));
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' });
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: 'smooth' });
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Jump to latest Assistant message' }),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -861,7 +863,7 @@ describe('ChatMessages', () => {
|
||||
|
||||
it('does not call scrollIntoView when messages list is empty', () => {
|
||||
// Reset the mock to clear any prior calls
|
||||
(Element.prototype.scrollIntoView as ReturnType<typeof vi.fn>).mockClear();
|
||||
(Element.prototype.scrollTo as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
render(() => <ChatMessages messages={[]} {...makeHandlers()} />);
|
||||
|
||||
|
||||
@@ -14,10 +14,7 @@ import { Button, ButtonLink } from '@/components/shared/Button';
|
||||
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
|
||||
import { MetadataBadge } from '@/components/shared/MetadataBadge';
|
||||
import { buildActionReviewPath } from '@/features/actions/actionRouting';
|
||||
import {
|
||||
buildPatrolAssistantFindingHandoff,
|
||||
buildPatrolAssistantProposedFixBriefingInput,
|
||||
} from '@/features/patrol/patrolInvestigationContextModel';
|
||||
import { buildPatrolAssistantFindingHandoff } from '@/features/patrol/patrolInvestigationContextModel';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import type { ActionAuditState, PatrolActionReference } from '@/types/actionAudit';
|
||||
|
||||
@@ -155,16 +152,6 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
|
||||
actionRequestedBy: 'pulse_patrol',
|
||||
}
|
||||
: null,
|
||||
proposedFix: buildPatrolAssistantProposedFixBriefingInput(
|
||||
current
|
||||
? {
|
||||
description: current.plan.message || capabilityLabel(current.capability_name),
|
||||
targetHost: props.resourceName || current.resource_id,
|
||||
commandCount: 0,
|
||||
destructive: false,
|
||||
}
|
||||
: null,
|
||||
),
|
||||
});
|
||||
aiChatStore.open(handoff.context);
|
||||
};
|
||||
|
||||
@@ -129,6 +129,23 @@ describe('ApprovalSection typed action handoff', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves unknown risk and canonical state without synthesizing a legacy fix', async () => {
|
||||
getInvestigationMock.mockResolvedValue(investigation(actionReference('completed')));
|
||||
renderSection('fix_verified', 'resolved');
|
||||
await screen.findByRole('link', { name: /view outcome in actions/i });
|
||||
fireEvent.click(screen.getByRole('button', { name: /discuss with assistant/i }));
|
||||
const context = openMock.mock.calls[0][0];
|
||||
expect(context.handoffActions).toHaveLength(1);
|
||||
expect(context.handoffActions[0]).toMatchObject({
|
||||
actionId: 'act-1',
|
||||
actionState: 'completed',
|
||||
});
|
||||
expect(context.handoffActions[0].destructive).toBeUndefined();
|
||||
expect(context.handoffActions[0].fixId).toBeUndefined();
|
||||
expect(context.handoffActions[0].approvalStatus).toBeUndefined();
|
||||
expect(context.briefing.statusLabel).toContain('Completed action');
|
||||
});
|
||||
|
||||
it('keeps missing plan identity visible while leaving replan guidance to Actions', async () => {
|
||||
const action = actionReference('pending_approval');
|
||||
delete action.plan.planHash;
|
||||
|
||||
+2
-1
@@ -536,7 +536,7 @@ describe('patrolInvestigationContextModel', () => {
|
||||
expect(handoff).not.toHaveProperty('prompt');
|
||||
expect(handoff.context.briefing).toMatchObject({
|
||||
actionLabel: 'Discuss Patrol assessment',
|
||||
safetyNote: 'Diagnostics and remediation require governed approval.',
|
||||
safetyNote: 'Use diagnostic tools within current permissions. New actions remain governed.',
|
||||
});
|
||||
expect(handoff.context.handoffContext).not.toContain('Recommended Next Step');
|
||||
});
|
||||
@@ -1025,6 +1025,7 @@ describe('patrolInvestigationContextModel', () => {
|
||||
approvalRequestedAt: '2026-05-06T12:00:00Z',
|
||||
approvalExpiresAt: '2026-05-06T12:10:00Z',
|
||||
actionId: 'restart-nginx',
|
||||
actionState: 'pending',
|
||||
actionRequestedBy: 'pulse_patrol',
|
||||
actionApprovalPolicy: 'operator',
|
||||
actionRequiresApproval: true,
|
||||
|
||||
@@ -87,7 +87,7 @@ export interface PatrolInvestigationRecordPresentation {
|
||||
targetHost?: string;
|
||||
rationale?: string;
|
||||
commandSummary?: string;
|
||||
destructive: boolean;
|
||||
destructive?: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
@@ -875,7 +875,7 @@ function buildPatrolAssessmentActionPosture(
|
||||
|
||||
return {
|
||||
actionLabel: 'Discuss Patrol assessment',
|
||||
safetyNote: 'Diagnostics and remediation require governed approval.',
|
||||
safetyNote: 'Use diagnostic tools within current permissions. New actions remain governed.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -973,7 +973,7 @@ function buildPatrolAssessmentAssistantModelContext(
|
||||
omittedFindingCount > 0
|
||||
? `${omittedFindingCount} additional Patrol finding${omittedFindingCount === 1 ? '' : 's'} omitted from this bounded handoff summary.`
|
||||
: undefined,
|
||||
'Model Boundary: This Patrol assessment handoff is model-only context for explanation and review. Diagnostics, remediation, and command execution require explicit governed approval.',
|
||||
'Model Boundary: This Patrol assessment handoff is model-only context for explanation and review. Use available diagnostic tools within current permissions. This handoff grants no new action authority or permission to retry a refused provider path.',
|
||||
]
|
||||
.filter(isNonEmptyString)
|
||||
.join('\n');
|
||||
@@ -1344,7 +1344,8 @@ function buildPatrolFindingHandoffAction(
|
||||
findingId: normalizeText(finding.id) || normalizeText(record?.finding_id) || undefined,
|
||||
recordId: normalizeText(record?.id) || undefined,
|
||||
approvalId: approvalId || undefined,
|
||||
approvalStatus: pendingApproval.status || undefined,
|
||||
approvalStatus: approvalId ? pendingApproval.status || undefined : undefined,
|
||||
actionState: pendingApproval.actionId ? pendingApproval.status || undefined : undefined,
|
||||
approvalRequestedAt: pendingApproval.requestedAt || undefined,
|
||||
approvalExpiresAt: pendingApproval.expiresAt || undefined,
|
||||
actionId: pendingApproval.actionId || undefined,
|
||||
@@ -1366,7 +1367,7 @@ function buildPatrolFindingHandoffAction(
|
||||
normalizeText(finding.proposedFix?.riskLevel) ||
|
||||
normalizeText(recordFix?.risk_level) ||
|
||||
undefined,
|
||||
destructive: Boolean(proposedFix?.destructive || recordFix?.destructive),
|
||||
destructive: proposedFix?.destructive ?? recordFix?.destructive,
|
||||
targetHost:
|
||||
normalizeText(proposedFix?.targetHost) ||
|
||||
normalizeText(recordFix?.target_host) ||
|
||||
@@ -1948,7 +1949,7 @@ function buildPatrolAssistantFindingModelContext(
|
||||
formatContextLine('Dry-Run Posture', pendingApproval.actionDryRunSummary),
|
||||
formatContextLine('Existing Action Artifact', actionArtifactFacts),
|
||||
'Command Boundary: Command details stay in governed approval or remediation context. This model-only handoff may include command counts but not raw command text.',
|
||||
'Model Boundary: This Patrol finding handoff is model-only context for explanation and review. Diagnostics, remediation, and command execution require explicit governed approval.',
|
||||
'Model Boundary: This Patrol finding handoff is model-only context for explanation and review. Use available diagnostic tools within current permissions. This handoff grants no new action authority or permission to retry a refused provider path.',
|
||||
]
|
||||
.filter(isNonEmptyString)
|
||||
.join('\n');
|
||||
@@ -2105,7 +2106,9 @@ export function buildPatrolAssistantFindingBriefing(
|
||||
const proposedFix = record.proposedFix || normalizeProposedFixBriefing(input.proposedFix);
|
||||
const approvalStatusParts = !record.hasRecord
|
||||
? [
|
||||
pendingApproval.status ? `${formatIdentifierLabel(pendingApproval.status)} approval` : '',
|
||||
pendingApproval.status
|
||||
? `${formatIdentifierLabel(pendingApproval.status)} ${pendingApproval.actionId ? 'action' : 'approval'}`
|
||||
: '',
|
||||
pendingApproval.riskLevel ? `${formatIdentifierLabel(pendingApproval.riskLevel)} risk` : '',
|
||||
!pendingApproval.id ? formatIdentifierLabel(input.investigationOutcome) || '' : '',
|
||||
]
|
||||
@@ -2186,7 +2189,8 @@ function normalizeProposedFixBriefing(
|
||||
targetHost: normalizeText(proposedFix?.targetHost),
|
||||
rationale: normalizeText(proposedFix?.rationale),
|
||||
commandSummary,
|
||||
destructive: Boolean(proposedFix?.destructive),
|
||||
destructive:
|
||||
typeof proposedFix?.destructive === 'boolean' ? proposedFix.destructive : undefined,
|
||||
};
|
||||
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package actionlifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
func TestPlanRequestReplayRetainsAcceptedPlanWithoutLiveRegistry(t *testing.T) {
|
||||
store := unified.NewMemoryStore()
|
||||
service := serviceForStore(t, store, testResource(time.Now().UTC(), unified.ApprovalAdmin), &stubExecutor{})
|
||||
actor := testActionActor("requester", "default")
|
||||
request := restartRequest()
|
||||
plan, err := service.Plan(context.Background(), "default", request, actor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.Registry = func(string) (*unified.ResourceRegistry, error) {
|
||||
t.Fatal("replay consulted live registry")
|
||||
return nil, nil
|
||||
}
|
||||
stronger := unified.ApprovalRequirementForFloor(unified.ApprovalMultiFactor)
|
||||
replay, err := service.PlanWithOptions(context.Background(), "default", request, PlanOptions{Actor: actor, ApprovalRequirement: &stronger})
|
||||
if err != nil || replay.ActionID != plan.ActionID || replay.PlanHash != plan.PlanHash || !replay.ExpiresAt.Equal(plan.ExpiresAt) {
|
||||
t.Fatalf("replay changed accepted plan: %#v %v", replay, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRequestConflictCannotCreateAnotherAction(t *testing.T) {
|
||||
store := unified.NewMemoryStore()
|
||||
service := serviceForStore(t, store, testResource(time.Now().UTC(), unified.ApprovalAdmin), &stubExecutor{})
|
||||
actor := testActionActor("requester", "default")
|
||||
request := restartRequest()
|
||||
plan, err := service.Plan(context.Background(), "default", request, actor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := service.Registry
|
||||
service.Registry = func(string) (*unified.ResourceRegistry, error) {
|
||||
t.Fatal("conflicting request consulted live registry")
|
||||
return nil, nil
|
||||
}
|
||||
request.Reason = "different intent on same request identity"
|
||||
if _, err := service.Plan(context.Background(), "default", request, actor); !errors.Is(err, unified.ErrActionIdentityConflict) {
|
||||
t.Fatalf("conflicting replay: %v", err)
|
||||
}
|
||||
record, found, err := store.GetActionAudit(plan.ActionID)
|
||||
if err != nil || !found || record.Request.Reason == request.Reason {
|
||||
t.Fatalf("original record changed: %#v %v", record, err)
|
||||
}
|
||||
service.Registry = registry
|
||||
request.RequestID += "-new"
|
||||
replacement, err := service.Plan(context.Background(), "default", request, actor)
|
||||
if err != nil || replacement.ActionID == plan.ActionID {
|
||||
t.Fatalf("explicit new request failed: %#v %v", replacement, err)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/actionplanner"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
|
||||
)
|
||||
|
||||
// Executor runs a previously planned and approved action through the
|
||||
@@ -66,6 +67,7 @@ type RefreshPlanner func(ctx context.Context, orgID string, previous unified.Act
|
||||
type Store interface {
|
||||
CreateActionAudit(record unified.ActionAuditRecord, initialEvents []unified.ActionLifecycleEvent) (unified.ActionAuditRecord, bool, error)
|
||||
GetActionAudit(actionID string) (unified.ActionAuditRecord, bool, error)
|
||||
GetActionAuditByRequest(req unified.ActionRequest, origin *unified.ActionOrigin) (unified.ActionAuditRecord, bool, error)
|
||||
RecordActionDecision(record unified.ActionAuditRecord, event unified.ActionLifecycleEvent) error
|
||||
RecordActionExpiry(record unified.ActionAuditRecord, event unified.ActionLifecycleEvent) error
|
||||
RecordActionExecutionStart(record unified.ActionAuditRecord, event unified.ActionLifecycleEvent) error
|
||||
@@ -179,6 +181,9 @@ func (s *Service) WithPolicyMutation(write func() error) error {
|
||||
// PlanOptions carries broker-owned planning metadata that must never be
|
||||
// accepted from a public transport request body.
|
||||
type PlanOptions struct {
|
||||
// RequireOperatorSensitiveParams prevents model-originated secrets from
|
||||
// entering a new action. Existing request replay is resolved first.
|
||||
RequireOperatorSensitiveParams bool
|
||||
// Actor is trusted server context. Public transports derive it from the
|
||||
// authenticated request and internal brokers stamp their fixed identity.
|
||||
Actor unified.ActionActor
|
||||
@@ -441,6 +446,30 @@ func (s *Service) PlanWithOptions(ctx context.Context, orgID string, req unified
|
||||
return unified.ActionPlan{}, &actionplanner.ValidationError{Field: "resourceId", Message: "resource id is required"}
|
||||
}
|
||||
|
||||
req = NormalizeRequest(req)
|
||||
store, err := s.store(orgID)
|
||||
if err != nil {
|
||||
return unified.ActionPlan{}, err
|
||||
}
|
||||
current, found, replayErr := store.GetActionAuditByRequest(req, opts.Origin)
|
||||
if replayErr == nil && found {
|
||||
return current.Plan, nil
|
||||
}
|
||||
if replayErr != nil && !errors.Is(replayErr, unified.ErrActionIdentityConflict) {
|
||||
return unified.ActionPlan{}, &PersistError{Op: "action request identity", Err: replayErr}
|
||||
}
|
||||
|
||||
if replayErr != nil {
|
||||
// Only a possible lifecycle alias needs live catalog resolution. Other
|
||||
// conflicts remain conflicts even when the registry is unavailable.
|
||||
synonym, isSynonym := lifecycleCapabilitySynonym(req.CapabilityName)
|
||||
canonicalCandidate := req
|
||||
canonicalCandidate.CapabilityName = current.Request.CapabilityName
|
||||
if !found || !isSynonym || synonym != current.Request.CapabilityName || !unified.ActionRequestReplayMatches(current, canonicalCandidate, opts.Origin) {
|
||||
return unified.ActionPlan{}, &PersistError{Op: "action request identity", Err: replayErr}
|
||||
}
|
||||
}
|
||||
|
||||
registry, err := s.registry(orgID)
|
||||
if err != nil {
|
||||
return unified.ActionPlan{}, err
|
||||
@@ -450,6 +479,27 @@ func (s *Service) PlanWithOptions(ctx context.Context, orgID string, req unified
|
||||
return unified.ActionPlan{}, &ResourceNotFoundError{ResourceID: req.ResourceID}
|
||||
}
|
||||
req.CapabilityName = resolveAdvertisedCapabilityName(resource.Capabilities, req.CapabilityName)
|
||||
// An input alias may resolve to the stored canonical capability. Only the
|
||||
// resource's actual catalog can establish that equivalence.
|
||||
if replayErr != nil {
|
||||
current, found, replayErr = store.GetActionAuditByRequest(req, opts.Origin)
|
||||
if replayErr != nil {
|
||||
return unified.ActionPlan{}, &PersistError{Op: "action request identity", Err: replayErr}
|
||||
}
|
||||
if found {
|
||||
return current.Plan, nil
|
||||
}
|
||||
}
|
||||
|
||||
if opts.RequireOperatorSensitiveParams {
|
||||
if capability, found := actionplanner.FindCapability(resource.Capabilities, req.CapabilityName); found {
|
||||
for _, param := range capability.Params {
|
||||
if value, present := req.Params[param.Name]; param.IsSensitive && present && value != nil {
|
||||
return unified.ActionPlan{}, fmt.Errorf("%w: parameter %q on capability %q", aicontracts.ErrSensitiveParamsRequireOperator, param.Name, req.CapabilityName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
planner := actionplanner.Planner{}
|
||||
var plan unified.ActionPlan
|
||||
@@ -490,10 +540,6 @@ func (s *Service) PlanWithOptions(ctx context.Context, orgID string, req unified
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.store(orgID)
|
||||
if err != nil {
|
||||
return unified.ActionPlan{}, err
|
||||
}
|
||||
record, created, err := persistPlanAudit(store, req, plan, opts.Origin)
|
||||
if err != nil {
|
||||
return unified.ActionPlan{}, &PersistError{Op: "action plan audit", Err: err}
|
||||
|
||||
@@ -20,6 +20,12 @@ type delayedCreateStore struct {
|
||||
releaseSecond chan struct{}
|
||||
}
|
||||
|
||||
// Force a cold lookup to exercise the atomic creation boundary even when a
|
||||
// competing request has persisted between the lookup and insert.
|
||||
func (s *delayedCreateStore) GetActionAuditByRequest(unified.ActionRequest, *unified.ActionOrigin) (unified.ActionAuditRecord, bool, error) {
|
||||
return unified.ActionAuditRecord{}, false, nil
|
||||
}
|
||||
|
||||
func (s *delayedCreateStore) CreateActionAudit(record unified.ActionAuditRecord, events []unified.ActionLifecycleEvent) (unified.ActionAuditRecord, bool, error) {
|
||||
if s.calls.Add(1) == 2 {
|
||||
close(s.secondArrived)
|
||||
@@ -1113,8 +1119,14 @@ func TestPlanFollowsAdvertisedLifecycleSynonym(t *testing.T) {
|
||||
t.Fatalf("persisted capability = %q, want the advertised verb %q", record.Request.CapabilityName, "restart")
|
||||
}
|
||||
|
||||
replay, err := env.service.Plan(context.Background(), "default", req, testActionActor("requester", "default"))
|
||||
if err != nil || replay.ActionID != plan.ActionID {
|
||||
t.Fatalf("synonym replay changed plan: %#v %v", replay, err)
|
||||
}
|
||||
|
||||
// Verbs without an advertised synonym still fail closed.
|
||||
unknown := restartRequest()
|
||||
unknown.RequestID = "independent-unknown-capability"
|
||||
unknown.CapabilityName = "shutdown"
|
||||
var capErr *CapabilityNotFoundError
|
||||
if _, err := env.service.Plan(context.Background(), "default", unknown, testActionActor("requester", "default")); !errors.As(err, &capErr) {
|
||||
|
||||
@@ -13,6 +13,7 @@ const (
|
||||
AgentErrCodeFindingActionNotAllowed = "finding_action_not_allowed"
|
||||
AgentErrCodePatrolUnavailable = "patrol_unavailable"
|
||||
AgentErrCodeInvalidActionRequest = "invalid_action_request"
|
||||
AgentErrCodeActionRequestConflict = "action_request_conflict"
|
||||
AgentErrCodeCapabilityNotFound = "capability_not_found"
|
||||
AgentErrCodeActionExecutionUnavailable = "action_execution_unavailable"
|
||||
AgentErrCodeActionActorUnavailable = "action_actor_unavailable"
|
||||
|
||||
@@ -228,7 +228,7 @@ var registryInvocationDescriptors = map[string]InvocationDescriptor{
|
||||
// and read-kind so a concluding proposal never drives the FSM into
|
||||
// write verification. It is additionally profile-gated: the registry
|
||||
// policy rejects it outside the Patrol investigation profile.
|
||||
PatrolProposeActionToolName: staticClass(ToolCallKindRead, MutationNone),
|
||||
PatrolProposeActionToolName: staticClass(ToolCallKindWrite, MutationPulseState),
|
||||
PatrolActionCapabilitiesToolName: staticClass(ToolCallKindRead, MutationNone),
|
||||
PatrolReportFindingToolName: staticClass(ToolCallKindWrite, MutationPulseState),
|
||||
PatrolResolveFindingToolName: staticClass(ToolCallKindWrite, MutationPulseState),
|
||||
|
||||
@@ -96,7 +96,7 @@ func TestCanonicalDescriptorsPinSafetyCriticalClassifications(t *testing.T) {
|
||||
assertClass(PatrolActionCapabilitiesToolName, map[string]interface{}{"resource_id": "vm:42"},
|
||||
InvocationClass{Kind: ToolCallKindRead, Mutation: MutationNone})
|
||||
assertClass(PatrolProposeActionToolName, map[string]interface{}{"resource_id": "vm:42"},
|
||||
InvocationClass{Kind: ToolCallKindRead, Mutation: MutationNone})
|
||||
InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationPulseState})
|
||||
assertClass(PatrolProposeObserverToolName, map[string]interface{}{"objective_id": "objective-1"},
|
||||
InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationPulseState})
|
||||
}
|
||||
|
||||
@@ -644,6 +644,7 @@ var (
|
||||
}
|
||||
agentCapabilityPlanActionErrorCodes = []string{
|
||||
AgentErrCodeInvalidActionRequest,
|
||||
AgentErrCodeActionRequestConflict,
|
||||
AgentErrCodeMockModeEnabled,
|
||||
AgentErrCodeActionActorUnavailable,
|
||||
AgentErrCodeResourceNotFound,
|
||||
|
||||
@@ -495,6 +495,7 @@ func TestCanonicalManifestPinsPulseMCPResolvedOperationsLoopCapabilities(t *test
|
||||
approval: ApprovalPolicyActionPlan,
|
||||
errorCodes: []string{
|
||||
AgentErrCodeInvalidActionRequest,
|
||||
AgentErrCodeActionRequestConflict,
|
||||
AgentErrCodeMockModeEnabled,
|
||||
AgentErrCodeActionActorUnavailable,
|
||||
AgentErrCodeResourceNotFound,
|
||||
|
||||
@@ -766,6 +766,7 @@ func (a *AgenticLoop) ExecuteWithTools(ctx context.Context, sessionID string, me
|
||||
// records cost from the loop's GetTotal{Input,Output}Tokens after this
|
||||
// returns. See ExecuteWithTools above.
|
||||
func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, messages []Message, tools []providers.Tool, callback StreamCallback) ([]Message, error) {
|
||||
ctx = withRunInvocationIdentity(ctx, sessionID, messages)
|
||||
// Snapshot maxTurns under the lock — callers may override via SetMaxTurns
|
||||
// before calling ExecuteWithTools, and this avoids races with concurrent sessions.
|
||||
a.mu.Lock()
|
||||
@@ -797,7 +798,6 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
|
||||
patrolOutputLimitRecoveryAttempted := false
|
||||
investigationOutputLimitRecoveryPending := false // A truncated investigation conclusion needs one evidence-only retry
|
||||
investigationOutputLimitRecoveryAttempted := false
|
||||
investigationProposalCompleted := false
|
||||
// Patrol core normally establishes the exact-scope active-finding snapshot
|
||||
// before the provider is invoked. Legacy/narrow adapters can still expose a
|
||||
// one-shot model read, but the normal detection path must not make the model
|
||||
@@ -955,15 +955,10 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
|
||||
Msg("[AgenticLoop] Objective handoff completed — omitting tools for final response")
|
||||
}
|
||||
if isPatrolInvestigationExecution(a.currentExecutionProfile()) && !investigationOutputLimitRecoveryTurn {
|
||||
if a.maxEvidenceCalls > 0 && !investigationProposalCompleted {
|
||||
if a.maxEvidenceCalls > 0 {
|
||||
req.System += fmt.Sprintf("\nEvidence-tool calls remaining within this run's configured limit: %d.", max(0, a.maxEvidenceCalls-a.totalEvidenceCalls))
|
||||
}
|
||||
switch {
|
||||
case investigationProposalCompleted:
|
||||
req.Tools = nil
|
||||
textOnlySafetyBrake = true
|
||||
req.System += investigationProposalCompletionSystemPrompt
|
||||
case a.maxEvidenceCalls > 0 && a.totalEvidenceCalls >= a.maxEvidenceCalls:
|
||||
if a.maxEvidenceCalls > 0 && a.totalEvidenceCalls >= a.maxEvidenceCalls {
|
||||
req.Tools = investigationTerminalTools(tools)
|
||||
textOnlySafetyBrake = len(req.Tools) == 0
|
||||
}
|
||||
@@ -1911,9 +1906,6 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
|
||||
// provider cannot spend or duplicate its bounded handoff.
|
||||
objectiveHandoffCompleted = true
|
||||
}
|
||||
if isPatrolInvestigationExecution(a.currentExecutionProfile()) && tc.Name == agentcapabilities.PatrolProposeActionToolName {
|
||||
investigationProposalCompleted = true
|
||||
}
|
||||
}
|
||||
|
||||
// Send tool_end event
|
||||
|
||||
@@ -10,12 +10,6 @@ func isPatrolInvestigationExecution(profile aitools.ExecutionProfile) bool {
|
||||
return profile == aitools.ProfilePatrolInvestigation
|
||||
}
|
||||
|
||||
// Structural proposal acceptance records an intended action, not a verified
|
||||
// diagnosis. The model retains responsibility for interpreting tool evidence.
|
||||
const investigationProposalCompletionSystemPrompt = `
|
||||
|
||||
INVESTIGATION COMPLETION: An action proposal has been recorded for this run. Do not call more tools. Summarize the evidence collected and any uncertainty. The proposal is pending governed policy or operator handling and has not executed. Proposal acceptance validates the action contract, not the rationale or root cause.`
|
||||
|
||||
const investigationOutputLimitRecoverySystemPrompt = `You are Pulse Patrol completing an investigation after the previous final response exhausted its output budget. Do not call tools, repeat the investigation, or narrate your reasoning. Synthesize only the evidence already present in the conversation into the required five sections: Investigation Summary, Root Cause, Affected Resources, Recommendation, and Conclusion. Name causal and affected resources with their exact observed canonical name or ID. If the evidence does not establish root cause, say exactly what remains uncertain. Never invent evidence, actions, verification, or remediation.`
|
||||
|
||||
const investigationOutputLimitRecoveryAllowance = 4_096
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
|
||||
)
|
||||
|
||||
func withRunInvocationIdentity(ctx context.Context, sessionID string, messages []Message) context.Context {
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if messages[i].Role == "user" && messages[i].ID != "" {
|
||||
return tools.WithInvocationScope(ctx, sessionID, messages[i].ID)
|
||||
}
|
||||
}
|
||||
// An unpersisted internal run still has one scope for all provider retries.
|
||||
return tools.WithInvocationScope(ctx, sessionID, uuid.NewString())
|
||||
}
|
||||
@@ -1645,7 +1645,7 @@ func sanitizeHandoffContextForResourcePolicy(handoffContext string, handoffResou
|
||||
}
|
||||
|
||||
func sanitizeTextForHandoffResourcePolicy(text string, handoffResources []HandoffResource, provider tools.UnifiedResourceProvider) string {
|
||||
contextText := strings.TrimSpace(text)
|
||||
contextText := text
|
||||
resources := normalizeHandoffResources(handoffResources)
|
||||
if contextText == "" || len(resources) == 0 || provider == nil {
|
||||
return contextText
|
||||
@@ -1666,7 +1666,7 @@ func sanitizeTextForHandoffResourcePolicy(text string, handoffResources []Handof
|
||||
resource.AISafeSummary = aiSafeSummary
|
||||
redacted = unifiedresources.ResourcePolicyRedactedTextWithReferences(redacted, resource, handoffResourcePolicyReferences(handoffResource, resource)...)
|
||||
}
|
||||
return strings.TrimSpace(redacted)
|
||||
return redacted
|
||||
}
|
||||
|
||||
func sanitizeStreamEventForHandoffResourcePolicy(event StreamEvent, handoffResources []HandoffResource, provider tools.UnifiedResourceProvider) StreamEvent {
|
||||
|
||||
@@ -3903,3 +3903,36 @@ func TestService_ExecuteStream_DeliversCloudSafeOperationalContextToCloudModel(t
|
||||
assertCloudSafe(t, (*captured)[len(*captured)-1].Content)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandoffResourcePolicyStreamPreservesChunkWhitespace(t *testing.T) {
|
||||
provider := handoffUnifiedProvider{resources: map[unifiedresources.ResourceType][]unifiedresources.Resource{
|
||||
unifiedresources.ResourceTypeSystemContainer: {{ID: "system-container:ha-node:101", Type: unifiedresources.ResourceTypeSystemContainer, Name: "homeassistant", Tags: []string{"sensitive"}, Storage: &unifiedresources.StorageMeta{Path: "/var/lib/homeassistant"}}},
|
||||
}}
|
||||
resources := []HandoffResource{{ID: "system-container:ha-node:101", Name: "homeassistant", Type: "system-container", Node: "ha-node"}}
|
||||
chunks := []string{"### Summary", " of recorded outcome", "\n\n", "1. Health", " ", "passed.\n", "2. Path: ", "/var/lib/homeassistant", "\n\n"}
|
||||
var streamed strings.Builder
|
||||
for _, chunk := range chunks {
|
||||
encoded, err := json.Marshal(ContentData{Text: chunk})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
event := sanitizeStreamEventForHandoffResourcePolicy(StreamEvent{Type: "content", Data: encoded}, resources, provider)
|
||||
event, ok := event.ClientSafe()
|
||||
if !ok {
|
||||
t.Fatalf("dropped content chunk %q", chunk)
|
||||
}
|
||||
var data ContentData
|
||||
if err := json.Unmarshal(event.Data, &data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
streamed.WriteString(data.Text)
|
||||
}
|
||||
want := "### Summary of recorded outcome\n\n1. Health passed.\n2. Path: " + unifiedresources.ResourcePolicyRedactedLabel + "\n\n"
|
||||
if got := streamed.String(); got != want {
|
||||
t.Fatalf("streamed = %q, want %q", got, want)
|
||||
}
|
||||
whole := sanitizeTextForHandoffResourcePolicy(strings.Join(chunks, ""), resources, provider)
|
||||
if whole != want {
|
||||
t.Fatalf("stored text = %q, want %q", whole, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -36,6 +37,7 @@ type InvestigationRunRequest struct {
|
||||
// Catalog resolves advertised resource capabilities for proposal
|
||||
// validation (ultimately the tenant-bound action lifecycle path).
|
||||
Catalog tools.ProposalCatalog
|
||||
Planner tools.ProposalPlanner
|
||||
}
|
||||
|
||||
// InvestigationRunResult is the structured outcome of one investigation
|
||||
@@ -45,78 +47,24 @@ type InvestigationRunResult struct {
|
||||
Content string
|
||||
// Proposal is the single validated typed action proposal, nil for a
|
||||
// valid zero-proposal conclusion.
|
||||
Proposal *tools.CapturedProposal
|
||||
// FailedProposalAttempts counts proposal calls that failed
|
||||
// validation. When >0 with no captured proposal, the run error is
|
||||
// tools.ErrProposalAttemptsFailed.
|
||||
FailedProposalAttempts int
|
||||
InputTokens int
|
||||
OutputTokens int
|
||||
ModelTurns int
|
||||
EvidenceCalls int
|
||||
ToolCalls int
|
||||
}
|
||||
|
||||
// InvestigationRunError preserves the two independent failure channels from
|
||||
// an investigation run. Proposal-only failures are valid completed runs that
|
||||
// require operator attention; RunErr means the provider/runtime itself failed
|
||||
// and must never be collapsed into that completed outcome.
|
||||
type InvestigationRunError struct {
|
||||
runErr error
|
||||
proposalErr error
|
||||
}
|
||||
|
||||
// NewInvestigationRunError constructs a two-channel investigation failure.
|
||||
// The package is internal; the exported constructor lets the API adapter's
|
||||
// boundary tests exercise the same concrete error it receives at runtime.
|
||||
func NewInvestigationRunError(runErr, proposalErr error) *InvestigationRunError {
|
||||
if runErr == nil && proposalErr == nil {
|
||||
return nil
|
||||
}
|
||||
return &InvestigationRunError{runErr: runErr, proposalErr: proposalErr}
|
||||
}
|
||||
|
||||
func (e *InvestigationRunError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return errors.Join(e.runErr, e.proposalErr).Error()
|
||||
}
|
||||
|
||||
// Unwrap preserves errors.Is/errors.As behavior for both failure channels.
|
||||
func (e *InvestigationRunError) Unwrap() []error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return []error{e.runErr, e.proposalErr}
|
||||
}
|
||||
|
||||
// RunFailure returns the provider/runtime failure, if any.
|
||||
func (e *InvestigationRunError) RunFailure() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.runErr
|
||||
}
|
||||
|
||||
// ProposalFailure returns the proposal-channel failure, if any.
|
||||
func (e *InvestigationRunError) ProposalFailure() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.proposalErr
|
||||
Proposal *tools.CapturedProposal
|
||||
InputTokens int
|
||||
OutputTokens int
|
||||
ModelTurns int
|
||||
EvidenceCalls int
|
||||
ToolCalls int
|
||||
}
|
||||
|
||||
// ExecuteInvestigationStream runs one Patrol investigation under the
|
||||
// investigation execution profile and returns the structured result.
|
||||
// Proposal-channel violations (ambiguity, integrity, failed-only
|
||||
// attempts) return the result alongside the typed proposal error.
|
||||
// Planning refusals remain tool results. Provider/runtime errors return the
|
||||
// partial result so any already persisted action remains discoverable.
|
||||
func (s *Service) ExecuteInvestigationStream(ctx context.Context, req InvestigationRunRequest, callback StreamCallback) (*InvestigationRunResult, error) {
|
||||
// Correlation identity is a precondition: without it a captured
|
||||
// proposal could never be reconciled, so the run refuses before any
|
||||
// provider call or session exists.
|
||||
if strings.TrimSpace(req.Identity.FindingID) == "" || strings.TrimSpace(req.Identity.InvestigationID) == "" {
|
||||
return nil, fmt.Errorf("investigation run requires finding and investigation identity before it can start")
|
||||
if strings.TrimSpace(req.Identity.ProposalID) == "" || strings.TrimSpace(req.Identity.FindingID) == "" || strings.TrimSpace(req.Identity.InvestigationID) == "" {
|
||||
return nil, fmt.Errorf("investigation run requires proposal, finding and investigation identity before it can start")
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
@@ -138,6 +86,7 @@ func (s *Service) ExecuteInvestigationStream(ctx context.Context, req Investigat
|
||||
// One effective request executor, built before projection; the
|
||||
// proposal capture sink is shared by design (one run, one capture).
|
||||
capture := tools.NewProposalCapture(req.Identity, req.Catalog)
|
||||
capture.SetPlanner(req.Planner)
|
||||
executor := baseExecutor.Clone()
|
||||
executor.SetControlLevel(effectiveControlLevel)
|
||||
executor.ApplyExecutionProfile(tools.ProfilePatrolInvestigation)
|
||||
@@ -212,7 +161,17 @@ func (s *Service) ExecuteInvestigationStream(ctx context.Context, req Investigat
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resultMessages, runErr := loop.ExecuteWithTools(ctx, session.ID, messages, filteredTools, callback)
|
||||
resultMessages, runErr := loop.ExecuteWithTools(ctx, session.ID, messages, filteredTools, func(event StreamEvent) {
|
||||
if event.Type == "tool_end" {
|
||||
var result ToolEndData
|
||||
if json.Unmarshal(event.Data, &result) == nil && isInvestigationEvidenceTool(result.Name) {
|
||||
capture.RecordEvidence(result.ID)
|
||||
}
|
||||
}
|
||||
if callback != nil {
|
||||
callback(event)
|
||||
}
|
||||
})
|
||||
for _, msg := range resultMessages {
|
||||
if msg.Role == "user" && msg.ToolResult == nil {
|
||||
continue
|
||||
@@ -222,7 +181,7 @@ func (s *Service) ExecuteInvestigationStream(ctx context.Context, req Investigat
|
||||
}
|
||||
}
|
||||
|
||||
proposal, failedAttempts, proposalErr := capture.Outcome()
|
||||
proposal, proposalErr := capture.Outcome()
|
||||
var contentBuilder strings.Builder
|
||||
for _, msg := range resultMessages {
|
||||
if msg.Role == "assistant" && msg.Content != "" {
|
||||
@@ -232,21 +191,18 @@ func (s *Service) ExecuteInvestigationStream(ctx context.Context, req Investigat
|
||||
content := contentBuilder.String()
|
||||
|
||||
result := &InvestigationRunResult{
|
||||
Content: content,
|
||||
Proposal: proposal,
|
||||
FailedProposalAttempts: failedAttempts,
|
||||
InputTokens: loop.GetTotalInputTokens(),
|
||||
OutputTokens: loop.GetTotalOutputTokens(),
|
||||
ModelTurns: loop.GetTotalModelTurns(),
|
||||
EvidenceCalls: loop.GetTotalEvidenceCalls(),
|
||||
ToolCalls: loop.GetTotalToolCalls(),
|
||||
Content: content,
|
||||
Proposal: proposal,
|
||||
InputTokens: loop.GetTotalInputTokens(),
|
||||
OutputTokens: loop.GetTotalOutputTokens(),
|
||||
ModelTurns: loop.GetTotalModelTurns(),
|
||||
EvidenceCalls: loop.GetTotalEvidenceCalls(),
|
||||
ToolCalls: loop.GetTotalToolCalls(),
|
||||
}
|
||||
if runErr != nil || proposalErr != nil {
|
||||
// A proposal is actionable only from a completely successful
|
||||
// run: any error nils it, and simultaneous run/proposal errors
|
||||
// are both preserved.
|
||||
result.Proposal = nil
|
||||
return result, NewInvestigationRunError(runErr, proposalErr)
|
||||
// A provider failure cannot erase a persisted action. The caller must
|
||||
// retain its reference and must not request automatic progression.
|
||||
return result, errors.Join(runErr, proposalErr)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// chatinvesterrorCustom is a concrete error type used to exercise
|
||||
// errors.As across both failure channels of InvestigationRunError.
|
||||
type chatinvesterrorCustom struct{ msg string }
|
||||
|
||||
func (e *chatinvesterrorCustom) Error() string { return e.msg }
|
||||
|
||||
// chatinvesterrorWrap is a second distinct concrete type so that an
|
||||
// errors.As match on one channel does not accidentally match the other.
|
||||
type chatinvesterrorWrap struct{ inner error }
|
||||
|
||||
func (e *chatinvesterrorWrap) Error() string { return "wrap:" + e.inner.Error() }
|
||||
|
||||
func (e *chatinvesterrorWrap) Unwrap() error { return e.inner }
|
||||
|
||||
func TestNewInvestigationRunError(t *testing.T) {
|
||||
runErr := errors.New("runtime exploded")
|
||||
proposalErr := errors.New("proposal invalid")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
runErr error
|
||||
proposalErr error
|
||||
wantNil bool
|
||||
wantRunFailure error
|
||||
wantPropFail error
|
||||
}{
|
||||
{
|
||||
name: "both nil returns nil",
|
||||
runErr: nil,
|
||||
proposalErr: nil,
|
||||
wantNil: true,
|
||||
wantRunFailure: nil,
|
||||
wantPropFail: nil,
|
||||
},
|
||||
{
|
||||
name: "run only populates run channel",
|
||||
runErr: runErr,
|
||||
proposalErr: nil,
|
||||
wantNil: false,
|
||||
wantRunFailure: runErr,
|
||||
wantPropFail: nil,
|
||||
},
|
||||
{
|
||||
name: "proposal only populates proposal channel",
|
||||
runErr: nil,
|
||||
proposalErr: proposalErr,
|
||||
wantNil: false,
|
||||
wantRunFailure: nil,
|
||||
wantPropFail: proposalErr,
|
||||
},
|
||||
{
|
||||
name: "both populated preserves both channels",
|
||||
runErr: runErr,
|
||||
proposalErr: proposalErr,
|
||||
wantNil: false,
|
||||
wantRunFailure: runErr,
|
||||
wantPropFail: proposalErr,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := NewInvestigationRunError(tc.runErr, tc.proposalErr)
|
||||
if tc.wantNil {
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil error, got %+v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("expected non-nil error, got nil")
|
||||
}
|
||||
if run := got.RunFailure(); run != tc.wantRunFailure {
|
||||
t.Errorf("RunFailure() = %v, want %v", run, tc.wantRunFailure)
|
||||
}
|
||||
if prop := got.ProposalFailure(); prop != tc.wantPropFail {
|
||||
t.Errorf("ProposalFailure() = %v, want %v", prop, tc.wantPropFail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvestigationRunError_Error(t *testing.T) {
|
||||
runErr := errors.New("run failed")
|
||||
proposalErr := errors.New("proposal failed")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
err *InvestigationRunError
|
||||
wantStr string
|
||||
}{
|
||||
{
|
||||
name: "nil receiver returns empty string",
|
||||
err: nil,
|
||||
wantStr: "",
|
||||
},
|
||||
{
|
||||
name: "run channel only renders run message",
|
||||
err: NewInvestigationRunError(runErr, nil),
|
||||
wantStr: "run failed",
|
||||
},
|
||||
{
|
||||
name: "proposal channel only renders proposal message",
|
||||
err: NewInvestigationRunError(nil, proposalErr),
|
||||
wantStr: "proposal failed",
|
||||
},
|
||||
{
|
||||
name: "both channels join with newline",
|
||||
err: NewInvestigationRunError(runErr, proposalErr),
|
||||
wantStr: "run failed\nproposal failed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := tc.err.Error()
|
||||
if got != tc.wantStr {
|
||||
t.Errorf("Error() = %q, want %q", got, tc.wantStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvestigationRunError_Unwrap(t *testing.T) {
|
||||
runSentinel := errors.New("sentinel-run")
|
||||
proposalSentinel := errors.New("sentinel-proposal")
|
||||
runTyped := &chatinvesterrorCustom{msg: "typed-run"}
|
||||
proposalTyped := &chatinvesterrorWrap{inner: errors.New("inner-proposal")}
|
||||
|
||||
t.Run("nil receiver returns nil slice", func(t *testing.T) {
|
||||
var err *InvestigationRunError
|
||||
if got := err.Unwrap(); got != nil {
|
||||
t.Fatalf("nil receiver Unwrap() = %v, want nil", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("both channels surface in slice preserving order and nil entries", func(t *testing.T) {
|
||||
err := NewInvestigationRunError(runSentinel, proposalSentinel)
|
||||
got := err.Unwrap()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("Unwrap() length = %d, want 2", len(got))
|
||||
}
|
||||
if got[0] != runSentinel {
|
||||
t.Errorf("Unwrap()[0] = %v, want %v", got[0], runSentinel)
|
||||
}
|
||||
if got[1] != proposalSentinel {
|
||||
t.Errorf("Unwrap()[1] = %v, want %v", got[1], proposalSentinel)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("run-only slice keeps proposal slot nil", func(t *testing.T) {
|
||||
err := NewInvestigationRunError(runSentinel, nil)
|
||||
got := err.Unwrap()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("Unwrap() length = %d, want 2", len(got))
|
||||
}
|
||||
if got[0] != runSentinel {
|
||||
t.Errorf("Unwrap()[0] = %v, want %v", got[0], runSentinel)
|
||||
}
|
||||
if got[1] != nil {
|
||||
t.Errorf("Unwrap()[1] = %v, want nil", got[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("proposal-only slice keeps run slot nil", func(t *testing.T) {
|
||||
err := NewInvestigationRunError(nil, proposalSentinel)
|
||||
got := err.Unwrap()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("Unwrap() length = %d, want 2", len(got))
|
||||
}
|
||||
if got[0] != nil {
|
||||
t.Errorf("Unwrap()[0] = %v, want nil", got[0])
|
||||
}
|
||||
if got[1] != proposalSentinel {
|
||||
t.Errorf("Unwrap()[1] = %v, want %v", got[1], proposalSentinel)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("errors.Is matches run sentinel", func(t *testing.T) {
|
||||
err := NewInvestigationRunError(runSentinel, proposalSentinel)
|
||||
if !errors.Is(err, runSentinel) {
|
||||
t.Errorf("errors.Is(err, runSentinel) = false, want true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("errors.Is matches proposal sentinel", func(t *testing.T) {
|
||||
err := NewInvestigationRunError(runSentinel, proposalSentinel)
|
||||
if !errors.Is(err, proposalSentinel) {
|
||||
t.Errorf("errors.Is(err, proposalSentinel) = false, want true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("errors.Is false for unrelated error", func(t *testing.T) {
|
||||
err := NewInvestigationRunError(runSentinel, proposalSentinel)
|
||||
other := errors.New("unrelated")
|
||||
if errors.Is(err, other) {
|
||||
t.Errorf("errors.Is(err, unrelated) = true, want false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("errors.Is walks wrapped inner chain via proposal channel", func(t *testing.T) {
|
||||
inner := errors.New("inner-proposal")
|
||||
err := NewInvestigationRunError(nil, &chatinvesterrorWrap{inner: inner})
|
||||
if !errors.Is(err, inner) {
|
||||
t.Errorf("errors.Is(err, inner) = false, want true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("errors.As extracts run channel typed error", func(t *testing.T) {
|
||||
err := NewInvestigationRunError(runTyped, nil)
|
||||
var target *chatinvesterrorCustom
|
||||
if !errors.As(err, &target) {
|
||||
t.Fatalf("errors.As for run typed error = false, want true")
|
||||
}
|
||||
if target != runTyped {
|
||||
t.Errorf("errors.As target = %p, want %p", target, runTyped)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("errors.As extracts proposal channel typed error", func(t *testing.T) {
|
||||
err := NewInvestigationRunError(nil, proposalTyped)
|
||||
var target *chatinvesterrorWrap
|
||||
if !errors.As(err, &target) {
|
||||
t.Fatalf("errors.As for proposal typed error = false, want true")
|
||||
}
|
||||
if target != proposalTyped {
|
||||
t.Errorf("errors.As target = %p, want %p", target, proposalTyped)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("errors.As does not match unrelated type", func(t *testing.T) {
|
||||
err := NewInvestigationRunError(runTyped, proposalTyped)
|
||||
var target *chatinvesterrorCustom
|
||||
_ = target
|
||||
var wrap *chatinvesterrorWrap
|
||||
if !errors.As(err, &wrap) {
|
||||
t.Errorf("errors.As for wrap type = false, want true")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestInvestigationRunError_RunFailure(t *testing.T) {
|
||||
runErr := errors.New("runtime failure")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
err *InvestigationRunError
|
||||
want error
|
||||
}{
|
||||
{
|
||||
name: "nil receiver returns nil",
|
||||
err: nil,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "run set returns run error",
|
||||
err: NewInvestigationRunError(runErr, nil),
|
||||
want: runErr,
|
||||
},
|
||||
{
|
||||
name: "only proposal set returns nil for run channel",
|
||||
err: NewInvestigationRunError(nil, errors.New("proposal")),
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "both set returns run error only",
|
||||
err: NewInvestigationRunError(runErr, errors.New("proposal")),
|
||||
want: runErr,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := tc.err.RunFailure()
|
||||
if got != tc.want {
|
||||
t.Errorf("RunFailure() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvestigationRunError_ProposalFailure(t *testing.T) {
|
||||
proposalErr := errors.New("proposal failure")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
err *InvestigationRunError
|
||||
want error
|
||||
}{
|
||||
{
|
||||
name: "nil receiver returns nil",
|
||||
err: nil,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "proposal set returns proposal error",
|
||||
err: NewInvestigationRunError(nil, proposalErr),
|
||||
want: proposalErr,
|
||||
},
|
||||
{
|
||||
name: "only run set returns nil for proposal channel",
|
||||
err: NewInvestigationRunError(errors.New("run"), nil),
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "both set returns proposal error only",
|
||||
err: NewInvestigationRunError(errors.New("run"), proposalErr),
|
||||
want: proposalErr,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := tc.err.ProposalFailure()
|
||||
if got != tc.want {
|
||||
t.Errorf("ProposalFailure() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvestigationRunError_NilReceiverChainedMethodSafety(t *testing.T) {
|
||||
// A typed-nil *InvestigationRunError must be safe to call every
|
||||
// method on without panicking; this guards the nil-receiver arms of
|
||||
// Error, Unwrap, RunFailure and ProposalFailure.
|
||||
var err *InvestigationRunError
|
||||
ensure := func(name string, fn func()) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("%s panicked on nil receiver: %v", name, r)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
ensure("Error", func() {
|
||||
if got := err.Error(); got != "" {
|
||||
t.Errorf("nil Error() = %q, want %q", got, "")
|
||||
}
|
||||
})
|
||||
ensure("Unwrap", func() {
|
||||
if got := err.Unwrap(); got != nil {
|
||||
t.Errorf("nil Unwrap() = %v, want nil", got)
|
||||
}
|
||||
})
|
||||
ensure("RunFailure", func() {
|
||||
if got := err.RunFailure(); got != nil {
|
||||
t.Errorf("nil RunFailure() = %v, want nil", got)
|
||||
}
|
||||
})
|
||||
ensure("ProposalFailure", func() {
|
||||
if got := err.ProposalFailure(); got != nil {
|
||||
t.Errorf("nil ProposalFailure() = %v, want nil", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestInvestigationRunError_FmtFormatting(t *testing.T) {
|
||||
// Guards the implicit fmt-handling: InvestigationRunError has an
|
||||
// Error() method, so %s/%v render its joined message and %v on the
|
||||
// value (not pointer) still resolves through Error().
|
||||
runErr := errors.New("fmt-run")
|
||||
proposalErr := fmt.Errorf("fmt-proposal-%d", 7)
|
||||
err := NewInvestigationRunError(runErr, proposalErr)
|
||||
wantStr := "fmt-run\nfmt-proposal-7"
|
||||
if got := fmt.Sprintf("%v", err); got != wantStr {
|
||||
t.Errorf("fmt %%v = %q, want %q", got, wantStr)
|
||||
}
|
||||
if got := fmt.Sprintf("%s", err); got != wantStr {
|
||||
t.Errorf("fmt %%s = %q, want %q", got, wantStr)
|
||||
}
|
||||
}
|
||||
@@ -1160,6 +1160,9 @@ func TestInvestigationLoopRedactsProposalParamsEverywhereDurable(t *testing.T) {
|
||||
}})
|
||||
return nil
|
||||
}
|
||||
if len(req.Tools) == 0 {
|
||||
t.Fatal("planning acceptance disabled further investigation tools")
|
||||
}
|
||||
// The provider continuation must still see the raw params from
|
||||
// its own prior call.
|
||||
for _, msg := range req.Messages {
|
||||
@@ -1187,6 +1190,9 @@ func TestInvestigationLoopRedactsProposalParamsEverywhereDurable(t *testing.T) {
|
||||
},
|
||||
}}, nil
|
||||
})
|
||||
capture.SetPlanner(func(context.Context, tools.CapturedProposal) (ur.ActionAuditRecord, error) {
|
||||
return ur.ActionAuditRecord{ID: "act_persisted", State: ur.ActionStatePending}, nil
|
||||
})
|
||||
exec := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
|
||||
exec.ApplyExecutionProfile(tools.ProfilePatrolInvestigation)
|
||||
exec.SetProposalCapture(capture)
|
||||
@@ -1200,7 +1206,7 @@ func TestInvestigationLoopRedactsProposalParamsEverywhereDurable(t *testing.T) {
|
||||
context.Background(),
|
||||
"session-investigation",
|
||||
[]Message{{Role: "user", Content: "investigate finding f-9"}},
|
||||
nil,
|
||||
[]providers.Tool{{Name: agentcapabilities.PatrolProposeActionToolName}, {Name: agentcapabilities.PulseQueryToolName}},
|
||||
func(event StreamEvent) {
|
||||
if event.Type == "content" {
|
||||
var data ContentData
|
||||
@@ -1252,9 +1258,9 @@ func TestInvestigationLoopRedactsProposalParamsEverywhereDurable(t *testing.T) {
|
||||
}
|
||||
|
||||
// The structured capture holds the validated raw values.
|
||||
proposal, failed, outcomeErr := capture.Outcome()
|
||||
if outcomeErr != nil || failed != 0 {
|
||||
t.Fatalf("outcome = (%v, %d), want clean capture", outcomeErr, failed)
|
||||
proposal, outcomeErr := capture.Outcome()
|
||||
if outcomeErr != nil {
|
||||
t.Fatalf("capture outcome: %v", outcomeErr)
|
||||
}
|
||||
if proposal == nil || proposal.Params["mode"] != "graceful" || proposal.Identity.FindingID != "f-9" || proposal.InvocationID != "p-1" {
|
||||
t.Fatalf("captured proposal = %#v", proposal)
|
||||
@@ -1301,7 +1307,13 @@ func TestInvestigationServicePreservesUncertainDiagnosisAfterProposal(t *testing
|
||||
result, err := service.ExecuteInvestigationStream(context.Background(), InvestigationRunRequest{
|
||||
SessionID: "uncertain-investigation", Prompt: "Investigate the issue", SystemPrompt: "Investigate using current evidence.",
|
||||
MaxTurns: 5, MaxEvidenceCalls: 3, ResourceType: "vm",
|
||||
Identity: tools.ProposalIdentity{FindingID: "finding-1", InvestigationID: "investigation-1"},
|
||||
Identity: tools.ProposalIdentity{ProposalID: "request-1", FindingID: "finding-1", InvestigationID: "investigation-1"},
|
||||
Planner: func(_ context.Context, p tools.CapturedProposal) (ur.ActionAuditRecord, error) {
|
||||
if len(p.Identity.EvidenceIDs) != 1 || p.Identity.EvidenceIDs[0] != "evidence-1" {
|
||||
t.Fatalf("planning lost prior completed observation: %+v", p.Identity)
|
||||
}
|
||||
return ur.ActionAuditRecord{ID: "act_investigation", State: ur.ActionStatePending}, nil
|
||||
},
|
||||
Catalog: func(context.Context, string) ([]ur.ResourceCapability, error) {
|
||||
return []ur.ResourceCapability{{Name: "restart"}}, nil
|
||||
},
|
||||
|
||||
+26
-25
@@ -2535,20 +2535,10 @@ func (s *FindingsStore) UpdateInvestigationOutcome(id, outcome string) bool {
|
||||
|
||||
prevOutcome := f.InvestigationOutcome
|
||||
f.InvestigationOutcome = outcome
|
||||
// If a fix was verified, close the loop by resolving the finding.
|
||||
// Replaying a persisted result must not invent a later recovery event.
|
||||
if InvestigationOutcome(outcome) == InvestigationOutcomeFixVerified {
|
||||
now := time.Now()
|
||||
// Only decrement active count once.
|
||||
if f.ResolvedAt == nil && f.IsActive() {
|
||||
s.activeCounts[f.Severity]--
|
||||
}
|
||||
f.ResolvedAt = &now
|
||||
f.AutoResolved = true
|
||||
if f.ResolveReason == "" {
|
||||
f.ResolveReason = "Fix verified"
|
||||
}
|
||||
s.appendLifecycleLocked(f, "verification_passed", "Fix verified; finding resolved", f.LoopState, string(FindingLoopStateResolved), nil)
|
||||
} else {
|
||||
s.resolveVerifiedFindingLocked(f, prevOutcome)
|
||||
} else if prevOutcome != outcome {
|
||||
msg := "Investigation outcome updated"
|
||||
if InvestigationOutcome(outcome) == InvestigationOutcomeFixFailed || InvestigationOutcome(outcome) == InvestigationOutcomeFixVerificationFailed {
|
||||
msg = "Remediation failed verification"
|
||||
@@ -2581,19 +2571,9 @@ func (s *FindingsStore) UpdateInvestigation(id, sessionID, status, outcome strin
|
||||
f.InvestigationOutcome = outcome
|
||||
f.LastInvestigatedAt = lastInvestigatedAt
|
||||
f.InvestigationAttempts = attempts
|
||||
// If a fix was verified, close the loop by resolving the finding.
|
||||
if InvestigationOutcome(outcome) == InvestigationOutcomeFixVerified {
|
||||
now := time.Now()
|
||||
if f.ResolvedAt == nil && f.IsActive() {
|
||||
s.activeCounts[f.Severity]--
|
||||
}
|
||||
f.ResolvedAt = &now
|
||||
f.AutoResolved = true
|
||||
if f.ResolveReason == "" {
|
||||
f.ResolveReason = "Fix verified"
|
||||
}
|
||||
s.appendLifecycleLocked(f, "verification_passed", "Fix verified; finding resolved", f.LoopState, string(FindingLoopStateResolved), nil)
|
||||
} else {
|
||||
s.resolveVerifiedFindingLocked(f, prevOutcome)
|
||||
} else if prevStatus != status || prevOutcome != outcome {
|
||||
s.appendLifecycleLocked(f, "investigation_updated", "Investigation state updated", f.LoopState, f.LoopState, map[string]string{
|
||||
"prev_status": prevStatus,
|
||||
"next_status": status,
|
||||
@@ -2607,6 +2587,27 @@ func (s *FindingsStore) UpdateInvestigation(id, sessionID, status, outcome strin
|
||||
return true
|
||||
}
|
||||
|
||||
// resolveVerifiedFindingLocked projects the first verified resolution without
|
||||
// moving its timestamp or appending another verification when history is replayed.
|
||||
// A genuine regression clears ResolvedAt and can therefore resolve again.
|
||||
func (s *FindingsStore) resolveVerifiedFindingLocked(f *Finding, previousOutcome string) {
|
||||
unresolved := f.ResolvedAt == nil
|
||||
if unresolved {
|
||||
if f.IsActive() {
|
||||
s.activeCounts[f.Severity]--
|
||||
}
|
||||
now := time.Now()
|
||||
f.ResolvedAt = &now
|
||||
}
|
||||
f.AutoResolved = true
|
||||
if f.ResolveReason == "" {
|
||||
f.ResolveReason = "Fix verified"
|
||||
}
|
||||
if unresolved || InvestigationOutcome(previousOutcome) != InvestigationOutcomeFixVerified {
|
||||
s.appendLifecycleLocked(f, "verification_passed", "Fix verified; finding resolved", f.LoopState, string(FindingLoopStateResolved), nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress marks a finding type as permanently suppressed for a resource
|
||||
// Future findings with the same resource+category will be auto-dismissed
|
||||
func (s *FindingsStore) Suppress(id string) bool {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFindingInvestigationReplayPreservesResolutionHistory(t *testing.T) {
|
||||
store := NewFindingsStore()
|
||||
store.Add(&Finding{ID: "replay", ResourceID: "resource", Severity: FindingSeverityWarning, Category: FindingCategoryReliability, Title: "Failure"})
|
||||
observed := time.Now().Add(-time.Hour)
|
||||
store.UpdateInvestigation("replay", "session", "completed", string(InvestigationOutcomeFixVerified), &observed, 1)
|
||||
before := store.Get("replay")
|
||||
if before.ResolvedAt == nil {
|
||||
t.Fatal("first verified result did not resolve finding")
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
store.UpdateInvestigationOutcome("replay", string(InvestigationOutcomeFixVerified))
|
||||
store.UpdateInvestigation("replay", "session", "completed", string(InvestigationOutcomeFixVerified), &observed, 1)
|
||||
}
|
||||
after := store.Get("replay")
|
||||
if !after.ResolvedAt.Equal(*before.ResolvedAt) {
|
||||
t.Fatalf("resolution moved from %v to %v", before.ResolvedAt, after.ResolvedAt)
|
||||
}
|
||||
if len(after.Lifecycle) != len(before.Lifecycle) {
|
||||
t.Fatalf("replay appended lifecycle events: %d -> %d", len(before.Lifecycle), len(after.Lifecycle))
|
||||
}
|
||||
if got := store.activeCounts[FindingSeverityWarning]; got != 0 {
|
||||
t.Fatalf("active count = %d", got)
|
||||
}
|
||||
// A newly observed regression is a different event and must still resolve.
|
||||
store.mu.Lock()
|
||||
store.findings["replay"].ResolvedAt = nil
|
||||
store.activeCounts[FindingSeverityWarning] = 1
|
||||
store.mu.Unlock()
|
||||
store.UpdateInvestigationOutcome("replay", string(InvestigationOutcomeFixVerified))
|
||||
regressed := store.Get("replay")
|
||||
if regressed.ResolvedAt == nil || len(regressed.Lifecycle) != len(before.Lifecycle)+1 {
|
||||
t.Fatal("verified regression did not create a new resolution")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingInvestigationReplayDoesNotDuplicateNonResolutionHistory(t *testing.T) {
|
||||
store := NewFindingsStore()
|
||||
store.Add(&Finding{ID: "replay", ResourceID: "resource", Severity: FindingSeverityWarning, Title: "Failure"})
|
||||
store.UpdateInvestigation("replay", "session", "completed", string(InvestigationOutcomeNeedsAttention), nil, 1)
|
||||
before := store.Get("replay")
|
||||
store.UpdateInvestigationOutcome("replay", string(InvestigationOutcomeNeedsAttention))
|
||||
store.UpdateInvestigation("replay", "session", "completed", string(InvestigationOutcomeNeedsAttention), nil, 1)
|
||||
if after := store.Get("replay"); len(after.Lifecycle) != len(before.Lifecycle) {
|
||||
t.Fatal("replay appended outcome history")
|
||||
}
|
||||
}
|
||||
@@ -603,9 +603,9 @@ func applyGates(score *Score, manifest Manifest) {
|
||||
if score.DuplicateToolCalls > manifest.Budgets.MaxDuplicateCalls {
|
||||
score.GateFailures = append(score.GateFailures, fmt.Sprintf("duplicate tool calls %d exceed %d", score.DuplicateToolCalls, manifest.Budgets.MaxDuplicateCalls))
|
||||
}
|
||||
if score.FailedToolCalls > 0 {
|
||||
score.GateFailures = append(score.GateFailures, fmt.Sprintf("failed tool calls %d exceed qualification maximum 0", score.FailedToolCalls))
|
||||
}
|
||||
// A refused or unavailable read is evidence about access, not a diagnosis
|
||||
// failure. Keep FailedToolCalls as telemetry. Scenario truth, action
|
||||
// boundaries and explicit resource budgets determine the mechanical gates.
|
||||
if manifest.Budgets.InputTokensP95 > 0 && score.InputTokens > manifest.Budgets.InputTokensP95 {
|
||||
score.GateFailures = append(score.GateFailures, fmt.Sprintf("input tokens %d exceed %d", score.InputTokens, manifest.Budgets.InputTokensP95))
|
||||
}
|
||||
|
||||
@@ -110,10 +110,9 @@ func TestApplyGatesBranches(t *testing.T) {
|
||||
wantSub: "duplicate tool calls 2 exceed 1",
|
||||
},
|
||||
{
|
||||
name: "any failed tool call rejected",
|
||||
name: "failed tool calls remain telemetry without determining diagnosis",
|
||||
setup: func(s *Score, m *Manifest) { s.FailedToolCalls = 1 },
|
||||
wantCount: 1,
|
||||
wantSub: "failed tool calls 1 exceed qualification maximum 0",
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "input tokens exceed p95 budget",
|
||||
|
||||
@@ -348,6 +348,10 @@ func TestExecuteControlResource_UnadvertisedCapabilityIsToolEvidence(t *testing.
|
||||
if !result.IsError {
|
||||
t.Fatalf("expected a boundary result, got %+v", result)
|
||||
}
|
||||
if len(plans.requests) != 1 {
|
||||
t.Fatalf("capability admission must use the canonical planner, requests=%d", len(plans.requests))
|
||||
}
|
||||
|
||||
response := decodeControlToolResponse(t, result)
|
||||
if response.Error == nil || response.Error.Code != agentcapabilities.ErrCodeActionNotAllowed {
|
||||
t.Fatalf("expected %s, got %+v", agentcapabilities.ErrCodeActionNotAllowed, response.Error)
|
||||
@@ -412,3 +416,25 @@ func TestSessionTargetsAdvertisingAction_IgnoresContextsWithoutEnumeration(t *te
|
||||
t.Fatalf("a context that cannot enumerate must yield no gate evidence, got %+v", targets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlPlanRequestIdentityFollowsTrustedTurnAndInvocation(t *testing.T) {
|
||||
vm := controlTestProxmoxVM("identity-vm", 101, "pve", true)
|
||||
plans := &recordedPlan{}
|
||||
executor := NewPulseToolExecutor(ExecutorConfig{UnifiedResourceProvider: &stubUnifiedResourceProvider{resources: []unifiedresources.Resource{vm}}, ControlLevel: ControlLevelControlled, TypedActionPlanner: plans.planner(nil)})
|
||||
invocation := ToolInvocation{ID: "provider-call-1", Name: agentcapabilities.PulseControlToolName, Arguments: map[string]interface{}{"type": "resource", "resource_id": vm.ID, "action": "reboot"}}
|
||||
for _, messageID := range []string{"user-turn-1", "user-turn-1", "user-turn-2"} {
|
||||
result, err := executor.ExecuteInvocation(WithInvocationScope(context.Background(), "session-1", messageID), invocation)
|
||||
if err != nil || result.IsError {
|
||||
t.Fatalf("planning failed: %#v %v", result, err)
|
||||
}
|
||||
}
|
||||
if len(plans.requests) != 3 {
|
||||
t.Fatalf("requests=%d", len(plans.requests))
|
||||
}
|
||||
if plans.requests[0].RequestID != plans.requests[1].RequestID {
|
||||
t.Fatal("replayed invocation minted another request identity")
|
||||
}
|
||||
if plans.requests[0].RequestID == plans.requests[2].RequestID {
|
||||
t.Fatal("a new user turn reused the previous action request")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,9 +28,8 @@ const (
|
||||
// absent: all existing-finding closure crosses the assessed lifecycle.
|
||||
ProfilePatrolDetection
|
||||
// ProfilePatrolInvestigation is the Patrol investigation posture:
|
||||
// non-interactive and structurally read-only - no infrastructure and
|
||||
// no Pulse-state mutations. Typed remediation leaves the profile only
|
||||
// as a side-effect-free, mutation-none action proposal.
|
||||
// non-interactive, with no infrastructure mutations. The sole Pulse-state
|
||||
// write is canonical action planning without approval or execution.
|
||||
ProfilePatrolInvestigation
|
||||
)
|
||||
|
||||
@@ -88,7 +87,7 @@ func (e *PulseToolExecutor) ApplyExecutionProfile(profile ExecutionProfile) {
|
||||
case ProfilePatrolInvestigation:
|
||||
e.isAutonomous = false
|
||||
e.denyInfrastructureMutations = true
|
||||
e.pulseStateAllowlist = map[string]bool{}
|
||||
e.pulseStateAllowlist = map[string]bool{agentcapabilities.PatrolProposeActionToolName: true}
|
||||
default:
|
||||
e.denyInfrastructureMutations = false
|
||||
// Interactive Assistant is conversation/read/session authority.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type invocationScopeContextKey struct{}
|
||||
|
||||
// WithInvocationScope binds provider tool IDs to a trusted persisted user turn.
|
||||
// The scope is transport context and is never accepted from model arguments.
|
||||
func WithInvocationScope(ctx context.Context, sessionID, messageID string) context.Context {
|
||||
scope, _ := json.Marshal([]string{sessionID, messageID})
|
||||
return context.WithValue(ctx, invocationScopeContextKey{}, string(scope))
|
||||
}
|
||||
|
||||
func actionRequestIDForInvocation(ctx context.Context) string {
|
||||
scope, _ := ctx.Value(invocationScopeContextKey{}).(string)
|
||||
invocation := InvocationIDFromContext(ctx)
|
||||
if scope == "" || invocation == "" {
|
||||
// Direct internal callers without a replay identity request a fresh plan.
|
||||
return uuid.NewString()
|
||||
}
|
||||
identity, _ := json.Marshal([]string{scope, invocation})
|
||||
return fmt.Sprintf("assistant:%x", sha256.Sum256(identity))
|
||||
}
|
||||
@@ -2,15 +2,12 @@ package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/actionplanner"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
@@ -57,6 +54,7 @@ type ProposalIdentity struct {
|
||||
|
||||
// CapturedProposal is one validated typed action proposal.
|
||||
type CapturedProposal struct {
|
||||
Action *unified.ActionAuditRecord
|
||||
InvocationID string
|
||||
Identity ProposalIdentity
|
||||
ResourceID string
|
||||
@@ -74,43 +72,19 @@ type CapturedProposal struct {
|
||||
// (ultimately the tenant-bound action lifecycle Capabilities path).
|
||||
type ProposalCatalog func(ctx context.Context, resourceID string) ([]unified.ResourceCapability, error)
|
||||
|
||||
// Typed terminal proposal errors surfaced on the structured run result.
|
||||
var (
|
||||
// ErrProposalAmbiguous: two distinct valid proposal calls were made.
|
||||
// The run's proposal is invalidated - concurrency makes "first"
|
||||
// nondeterministic, so ambiguity latches terminally.
|
||||
ErrProposalAmbiguous = errors.New("ambiguous investigation result: multiple distinct action proposals were submitted")
|
||||
// ErrProposalIntegrity: the same tool-use ID re-submitted a different
|
||||
// payload. Latches terminally and invalidates the capture.
|
||||
ErrProposalIntegrity = errors.New("proposal integrity violation: one tool-use id submitted conflicting payloads")
|
||||
// ErrProposalAttemptsFailed: no proposal was captured but proposal
|
||||
// attempts failed validation - this is an error outcome, never the
|
||||
// valid zero-proposal conclusion.
|
||||
ErrProposalAttemptsFailed = errors.New("investigation made proposal attempts but none validated")
|
||||
)
|
||||
// ProposalPlanner is the core-owned plan-only boundary. It persists a canonical
|
||||
// action and returns its audit record. It grants no approval or execution.
|
||||
type ProposalPlanner func(context.Context, CapturedProposal) (unified.ActionAuditRecord, error)
|
||||
|
||||
type proposalCaptureState int
|
||||
|
||||
const (
|
||||
proposalCaptureEmpty proposalCaptureState = iota
|
||||
proposalCaptureHeld
|
||||
proposalCaptureAmbiguous
|
||||
proposalCaptureIntegrityViolated
|
||||
)
|
||||
|
||||
// ProposalCapture is the request-local sink for typed action proposals.
|
||||
// One capture serves one investigation run; executor clones share it
|
||||
// deliberately so every provider attempt lands in the same sink. State
|
||||
// latches terminally: a second distinct valid proposal (or a conflicting
|
||||
// replay) invalidates the captured proposal for the whole run.
|
||||
// ProposalCapture retains the accepted action for one explicitly budgeted
|
||||
// investigation. Canonical request identity owns replay and conflicts. A later
|
||||
// refused call or provider failure cannot erase an already persisted action.
|
||||
type ProposalCapture struct {
|
||||
mu sync.Mutex
|
||||
identity ProposalIdentity
|
||||
catalog ProposalCatalog
|
||||
state proposalCaptureState
|
||||
proposal *CapturedProposal
|
||||
fingerprint string
|
||||
failedAttempts int
|
||||
mu sync.Mutex
|
||||
identity ProposalIdentity
|
||||
catalog ProposalCatalog
|
||||
planner ProposalPlanner
|
||||
proposal *CapturedProposal
|
||||
}
|
||||
|
||||
func (i ProposalIdentity) clone() ProposalIdentity {
|
||||
@@ -118,14 +92,26 @@ func (i ProposalIdentity) clone() ProposalIdentity {
|
||||
return i
|
||||
}
|
||||
|
||||
// NewProposalCapture builds the sink with trusted identity and the
|
||||
// capability catalog used for validation. The identity is deep-cloned so
|
||||
// later caller-side mutation cannot alter captured correlation.
|
||||
func NewProposalCapture(identity ProposalIdentity, catalog ProposalCatalog) *ProposalCapture {
|
||||
return &ProposalCapture{
|
||||
identity: identity.clone(),
|
||||
catalog: catalog,
|
||||
return &ProposalCapture{identity: identity.clone(), catalog: catalog}
|
||||
}
|
||||
|
||||
func (c *ProposalCapture) SetPlanner(planner ProposalPlanner) { c.planner = planner }
|
||||
|
||||
// RecordEvidence binds completed observations, including explicit access
|
||||
// refusals, to the action origin. An ID links to a result, not a diagnosis proof.
|
||||
func (c *ProposalCapture) RecordEvidence(id string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return
|
||||
}
|
||||
for _, existing := range c.identity.EvidenceIDs {
|
||||
if existing == id {
|
||||
return
|
||||
}
|
||||
}
|
||||
c.identity.EvidenceIDs = append(c.identity.EvidenceIDs, id)
|
||||
}
|
||||
|
||||
func (c *ProposalCapture) Capabilities(ctx context.Context, resourceID string) ([]unified.ResourceCapability, error) {
|
||||
@@ -158,163 +144,62 @@ func cloneParams(params map[string]interface{}) (map[string]interface{}, error)
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
func proposalFingerprint(resourceID, causalResourceID, capabilityName, reason string, params map[string]interface{}) (string, error) {
|
||||
payload := struct {
|
||||
ResourceID string `json:"resourceId"`
|
||||
CausalResourceID string `json:"causalResourceId"`
|
||||
CapabilityName string `json:"capabilityName"`
|
||||
Reason string `json:"reason"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
}{resourceID, causalResourceID, capabilityName, reason, params}
|
||||
encoded, err := json.Marshal(payload)
|
||||
// Submit consults the canonical planner during the tool call. Serialization
|
||||
// enforces the one-action budget while the durable store owns idempotency.
|
||||
func (c *ProposalCapture) Submit(ctx context.Context, invocationID, resourceID, causalResourceID, capabilityName, reason string, params map[string]interface{}) (*unified.ActionAuditRecord, error) {
|
||||
if strings.TrimSpace(invocationID) == "" {
|
||||
return nil, errors.New("action planning requires a tool invocation identity")
|
||||
}
|
||||
params, err := cloneParams(params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("proposal payload is not fingerprintable")
|
||||
return nil, err
|
||||
}
|
||||
sum := sha256.Sum256(encoded)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// RecordFailedAttempt tallies a proposal call that failed validation.
|
||||
// Failed attempts never count as proposals, but their presence turns a
|
||||
// zero-proposal run into a typed error rather than a valid conclusion.
|
||||
func (c *ProposalCapture) RecordFailedAttempt() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.failedAttempts++
|
||||
}
|
||||
|
||||
// Submit records one validated proposal call. Semantics by call identity
|
||||
// and payload fingerprint:
|
||||
// - first valid call: captured;
|
||||
// - same ID, same payload: idempotent replay (still captured);
|
||||
// - same ID, different payload: terminal integrity error, capture
|
||||
// invalidated;
|
||||
// - distinct ID, valid payload: terminal ambiguity, capture invalidated
|
||||
// (concurrent execution makes "first" nondeterministic, so neither
|
||||
// call wins).
|
||||
func (c *ProposalCapture) Submit(invocationID, resourceID, causalResourceID, capabilityName, reason string, params map[string]interface{}) error {
|
||||
invocationID = strings.TrimSpace(invocationID)
|
||||
if invocationID == "" {
|
||||
c.mu.Lock()
|
||||
c.failedAttempts++
|
||||
c.mu.Unlock()
|
||||
return fmt.Errorf("proposal call carries no tool-use id; cannot establish call identity")
|
||||
if c.planner == nil {
|
||||
return nil, errors.New("canonical action planning is unavailable for this investigation")
|
||||
}
|
||||
params, cloneErr := cloneParams(params)
|
||||
if cloneErr != nil {
|
||||
c.mu.Lock()
|
||||
c.failedAttempts++
|
||||
c.mu.Unlock()
|
||||
return cloneErr
|
||||
identity := c.identity.clone()
|
||||
// Replay keeps the evidence attached at first acceptance. Later observations
|
||||
// remain in the investigation history and cannot rewrite accepted intent.
|
||||
if c.proposal != nil {
|
||||
identity = c.proposal.Identity.clone()
|
||||
}
|
||||
fingerprint, fingerprintErr := proposalFingerprint(resourceID, causalResourceID, capabilityName, reason, params)
|
||||
if fingerprintErr != nil {
|
||||
c.mu.Lock()
|
||||
c.failedAttempts++
|
||||
c.mu.Unlock()
|
||||
return fingerprintErr
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
switch c.state {
|
||||
case proposalCaptureAmbiguous:
|
||||
return ErrProposalAmbiguous
|
||||
case proposalCaptureIntegrityViolated:
|
||||
return ErrProposalIntegrity
|
||||
case proposalCaptureEmpty:
|
||||
c.state = proposalCaptureHeld
|
||||
c.fingerprint = fingerprint
|
||||
c.proposal = &CapturedProposal{
|
||||
InvocationID: invocationID,
|
||||
Identity: c.identity.clone(),
|
||||
ResourceID: resourceID,
|
||||
CausalResourceID: causalResourceID,
|
||||
CapabilityName: capabilityName,
|
||||
Params: params,
|
||||
Reason: reason,
|
||||
proposal := CapturedProposal{InvocationID: invocationID, Identity: identity, ResourceID: resourceID, CausalResourceID: causalResourceID, CapabilityName: capabilityName, Reason: reason, Params: params}
|
||||
record, err := c.planner(ctx, proposal)
|
||||
if record.ID != "" {
|
||||
proposal.Action = &record
|
||||
if c.proposal == nil {
|
||||
c.proposal = &proposal
|
||||
} else {
|
||||
c.proposal.Action = &record
|
||||
}
|
||||
return nil
|
||||
default: // proposalCaptureHeld
|
||||
if c.proposal != nil && c.proposal.InvocationID == invocationID {
|
||||
if c.fingerprint == fingerprint {
|
||||
// Idempotent replay of the same call.
|
||||
return nil
|
||||
}
|
||||
c.state = proposalCaptureIntegrityViolated
|
||||
c.proposal = nil
|
||||
return ErrProposalIntegrity
|
||||
}
|
||||
c.state = proposalCaptureAmbiguous
|
||||
c.proposal = nil
|
||||
return ErrProposalAmbiguous
|
||||
}
|
||||
}
|
||||
|
||||
// Outcome reports the run's terminal proposal state: the captured
|
||||
// proposal (nil for a valid zero-proposal run) or the typed error that
|
||||
// invalidated the run. Zero proposals with failed attempts is an error,
|
||||
// never a valid conclusion.
|
||||
func (c *ProposalCapture) Outcome() (*CapturedProposal, int, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
switch c.state {
|
||||
case proposalCaptureAmbiguous:
|
||||
return nil, c.failedAttempts, ErrProposalAmbiguous
|
||||
case proposalCaptureIntegrityViolated:
|
||||
return nil, c.failedAttempts, ErrProposalIntegrity
|
||||
case proposalCaptureHeld:
|
||||
proposal := *c.proposal
|
||||
proposal.Identity = proposal.Identity.clone()
|
||||
clonedParams, err := cloneParams(proposal.Params)
|
||||
if err != nil {
|
||||
// Unreachable in practice (params cloned on capture), but a
|
||||
// proposal that cannot be copied must not be actionable.
|
||||
return nil, c.failedAttempts, err
|
||||
}
|
||||
proposal.Params = clonedParams
|
||||
return &proposal, c.failedAttempts, nil
|
||||
default:
|
||||
if c.failedAttempts > 0 {
|
||||
return nil, c.failedAttempts, ErrProposalAttemptsFailed
|
||||
}
|
||||
return nil, 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
// validateProposalAgainstCatalog checks the proposal against the
|
||||
// resource's advertised capability contract. Error messages never echo
|
||||
// parameter values: proposal params exist only transiently for provider
|
||||
// continuation and validation.
|
||||
func validateProposalAgainstCatalog(ctx context.Context, catalog ProposalCatalog, resourceID, capabilityName string, params map[string]interface{}) error {
|
||||
if catalog == nil {
|
||||
return errors.New("no capability catalog is wired for proposal validation")
|
||||
}
|
||||
capabilities, err := catalog(ctx, resourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("capability catalog lookup failed for resource %q", resourceID)
|
||||
}
|
||||
// Exact-name resolution and full parameter validation are the
|
||||
// planner's canonical implementations, so proposal acceptance and
|
||||
// planning can never drift on matching, types, enums, patterns,
|
||||
// required presence, or malformed capability schemas.
|
||||
capability, found := actionplanner.FindCapability(capabilities, capabilityName)
|
||||
if !found {
|
||||
return fmt.Errorf("resource %q does not advertise capability %q", resourceID, capabilityName)
|
||||
}
|
||||
// Proposal-specific ratchet on top of planning: investigations must
|
||||
// never carry sensitive values (operators supply those at approval
|
||||
// time on the canonical surface).
|
||||
for _, param := range capability.Params {
|
||||
if !param.IsSensitive {
|
||||
continue
|
||||
}
|
||||
if value, ok := params[param.Name]; ok && value != nil {
|
||||
return fmt.Errorf("parameter %q is sensitive and must be supplied by an operator on the canonical approval surface, never by an investigation", param.Name)
|
||||
if c.proposal != nil {
|
||||
return c.proposal.Action, err
|
||||
}
|
||||
return proposal.Action, err
|
||||
}
|
||||
if err := actionplanner.ValidateParams(params, capability.Params); err != nil {
|
||||
return fmt.Errorf("proposal parameters are invalid for capability %q: %s", capabilityName, err.Error())
|
||||
if record.ID == "" {
|
||||
return nil, errors.New("canonical planner returned no persisted action identity")
|
||||
}
|
||||
return nil
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func (c *ProposalCapture) Outcome() (*CapturedProposal, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.proposal == nil {
|
||||
return nil, nil
|
||||
}
|
||||
encoded, err := json.Marshal(c.proposal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var proposal CapturedProposal
|
||||
if err = json.Unmarshal(encoded, &proposal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &proposal, nil
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ package tools
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"fmt"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/actionplanner"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testProposalCatalog() ProposalCatalog {
|
||||
@@ -68,129 +68,6 @@ func executePropose(t *testing.T, exec *PulseToolExecutor, id string, args map[s
|
||||
return result
|
||||
}
|
||||
|
||||
// The essential proof: two concurrent valid proposal calls latch terminal
|
||||
// ambiguity with a nil proposal, regardless of execution order.
|
||||
func TestConcurrentValidProposalsLatchAmbiguityWithNoProposal(t *testing.T) {
|
||||
capture := NewProposalCapture(ProposalIdentity{ProposalID: "prop-1", FindingID: "f-1", InvestigationID: "inv-1"}, testProposalCatalog())
|
||||
exec := newInvestigationExecutor(t, capture)
|
||||
|
||||
second := proposeArgs()
|
||||
second["reason"] = "an alternative remediation"
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); executePropose(t, exec, "call-a", proposeArgs()) }()
|
||||
go func() { defer wg.Done(); executePropose(t, exec, "call-b", second) }()
|
||||
wg.Wait()
|
||||
|
||||
proposal, _, err := capture.Outcome()
|
||||
if !errors.Is(err, ErrProposalAmbiguous) {
|
||||
t.Fatalf("outcome error = %v, want ErrProposalAmbiguous", err)
|
||||
}
|
||||
if proposal != nil {
|
||||
t.Fatalf("ambiguous run must invalidate the captured proposal, got %#v", proposal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposalReplaySemanticsByInvocationID(t *testing.T) {
|
||||
capture := NewProposalCapture(ProposalIdentity{InvestigationID: "inv-1"}, testProposalCatalog())
|
||||
exec := newInvestigationExecutor(t, capture)
|
||||
|
||||
executePropose(t, exec, "call-a", proposeArgs())
|
||||
// Same ID, same payload: idempotent replay.
|
||||
replay := executePropose(t, exec, "call-a", proposeArgs())
|
||||
assert.Contains(t, replay.Content[0].Text, "Proposal recorded")
|
||||
|
||||
proposal, failed, err := capture.Outcome()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, proposal)
|
||||
assert.Equal(t, 0, failed)
|
||||
assert.Equal(t, "call-a", proposal.InvocationID)
|
||||
assert.Equal(t, "inv-1", proposal.Identity.InvestigationID)
|
||||
|
||||
// Same ID, different payload: terminal integrity error, capture
|
||||
// invalidated.
|
||||
mutated := proposeArgs()
|
||||
mutated["reason"] = "changed my mind"
|
||||
conflict := executePropose(t, exec, "call-a", mutated)
|
||||
assert.Contains(t, conflict.Content[0].Text, "integrity")
|
||||
|
||||
proposal, _, err = capture.Outcome()
|
||||
if !errors.Is(err, ErrProposalIntegrity) {
|
||||
t.Fatalf("outcome error = %v, want ErrProposalIntegrity", err)
|
||||
}
|
||||
assert.Nil(t, proposal)
|
||||
}
|
||||
|
||||
func TestFailedAttemptsWithoutSuccessAreATypedError(t *testing.T) {
|
||||
capture := NewProposalCapture(ProposalIdentity{}, testProposalCatalog())
|
||||
exec := newInvestigationExecutor(t, capture)
|
||||
|
||||
bad := proposeArgs()
|
||||
bad["capability_name"] = "detonate"
|
||||
result := executePropose(t, exec, "call-a", bad)
|
||||
assert.Contains(t, result.Content[0].Text, "does not advertise")
|
||||
|
||||
proposal, failed, err := capture.Outcome()
|
||||
if !errors.Is(err, ErrProposalAttemptsFailed) {
|
||||
t.Fatalf("outcome error = %v, want ErrProposalAttemptsFailed", err)
|
||||
}
|
||||
assert.Nil(t, proposal)
|
||||
assert.Equal(t, 1, failed)
|
||||
|
||||
// A clean zero-proposal run stays a valid conclusion.
|
||||
clean := NewProposalCapture(ProposalIdentity{}, testProposalCatalog())
|
||||
proposal, failed, err = clean.Outcome()
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, proposal)
|
||||
assert.Equal(t, 0, failed)
|
||||
}
|
||||
|
||||
func TestProposalAllowsUncertainCauseWithoutInventingAttribution(t *testing.T) {
|
||||
capture := NewProposalCapture(ProposalIdentity{InvestigationID: "inv-1"}, testProposalCatalog())
|
||||
exec := newInvestigationExecutor(t, capture)
|
||||
args := proposeArgs()
|
||||
delete(args, "causal_resource_id")
|
||||
args["reason"] = "The service is stopped. A restart may restore service, but the cause is unknown."
|
||||
result := executePropose(t, exec, "recovery-1", args)
|
||||
require.False(t, result.IsError, "%+v", result)
|
||||
proposal, failed, err := capture.Outcome()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, proposal)
|
||||
assert.Empty(t, proposal.CausalResourceID)
|
||||
assert.Equal(t, args["reason"], proposal.Reason)
|
||||
assert.Equal(t, "vm:42", proposal.ResourceID)
|
||||
assert.Zero(t, failed)
|
||||
// Unknown cause does not weaken invocation integrity or grant execution.
|
||||
changed := proposeArgs()
|
||||
changed["reason"] = args["reason"]
|
||||
result = executePropose(t, exec, "recovery-1", changed)
|
||||
assert.True(t, result.IsError)
|
||||
proposal, _, err = capture.Outcome()
|
||||
assert.Nil(t, proposal)
|
||||
assert.ErrorIs(t, err, ErrProposalIntegrity)
|
||||
}
|
||||
|
||||
func TestSensitiveProposalParamsRejectedWithoutEcho(t *testing.T) {
|
||||
capture := NewProposalCapture(ProposalIdentity{}, testProposalCatalog())
|
||||
exec := newInvestigationExecutor(t, capture)
|
||||
|
||||
args := proposeArgs()
|
||||
args["capability_name"] = "join_cluster"
|
||||
args["params"] = map[string]interface{}{"join_token": "super-secret-token-value"}
|
||||
result := executePropose(t, exec, "call-a", args)
|
||||
text := result.Content[0].Text
|
||||
assert.Contains(t, text, "sensitive")
|
||||
assert.NotContains(t, text, "super-secret-token-value", "refusals must never echo parameter values")
|
||||
|
||||
proposal, failed, err := capture.Outcome()
|
||||
if !errors.Is(err, ErrProposalAttemptsFailed) {
|
||||
t.Fatalf("outcome error = %v, want ErrProposalAttemptsFailed", err)
|
||||
}
|
||||
assert.Nil(t, proposal)
|
||||
assert.Equal(t, 1, failed)
|
||||
}
|
||||
|
||||
func TestProposeActionIsInvestigationProfileOnly(t *testing.T) {
|
||||
for _, profile := range []ExecutionProfile{ProfileInteractiveAssistant, ProfilePatrolDetection} {
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{})
|
||||
@@ -220,7 +97,7 @@ func TestProposeActionIsInvestigationProfileOnly(t *testing.T) {
|
||||
}
|
||||
|
||||
// Under investigation the tool is both offered and executable.
|
||||
capture := NewProposalCapture(ProposalIdentity{}, testProposalCatalog())
|
||||
capture := newPlanningCapture()
|
||||
exec := newInvestigationExecutor(t, capture)
|
||||
offered := false
|
||||
for _, tool := range exec.registry.ListTools(exec.invocationPolicy()) {
|
||||
@@ -230,289 +107,143 @@ func TestProposeActionIsInvestigationProfileOnly(t *testing.T) {
|
||||
}
|
||||
assert.True(t, offered, "investigation profile must offer patrol_propose_action")
|
||||
result := executePropose(t, exec, "call-a", proposeArgs())
|
||||
assert.Contains(t, result.Content[0].Text, "Proposal recorded")
|
||||
assert.False(t, result.IsError)
|
||||
assert.Contains(t, result.Content[0].Text, "act_existing")
|
||||
}
|
||||
|
||||
func TestActionCapabilitiesCanFollowInvestigationToCausalResource(t *testing.T) {
|
||||
capture := NewProposalCapture(ProposalIdentity{}, testProposalCatalog())
|
||||
exec := newInvestigationExecutor(t, capture)
|
||||
|
||||
result, err := exec.ExecuteInvocation(context.Background(), ToolInvocation{
|
||||
ID: "catalog-a",
|
||||
Name: agentcapabilities.PatrolActionCapabilitiesToolName,
|
||||
Arguments: map[string]interface{}{"resource_id": "vm:42"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, result.Content)
|
||||
payload := result.Content[0].Text
|
||||
assert.Contains(t, payload, `"resource_id":"vm:42"`)
|
||||
assert.Contains(t, payload, `"name":"restart"`)
|
||||
assert.Contains(t, payload, `"name":"join_token"`)
|
||||
assert.Contains(t, payload, `"sensitive":true`)
|
||||
|
||||
proposal, failed, outcomeErr := capture.Outcome()
|
||||
require.NoError(t, outcomeErr)
|
||||
assert.Nil(t, proposal)
|
||||
assert.Zero(t, failed, "catalog reads must not consume proposal cardinality or count as failed proposals")
|
||||
}
|
||||
|
||||
func TestActionCapabilitiesCanonicalizeResolvedDockerCoordinate(t *testing.T) {
|
||||
const (
|
||||
canonicalID = "app-container-abc123"
|
||||
containerID = "92847aa6ab18fef9fc6e619f5b8350948"
|
||||
agentID = "agent-f4f64c6cc2cc062e"
|
||||
)
|
||||
catalog := func(_ context.Context, resourceID string) ([]unified.ResourceCapability, error) {
|
||||
if resourceID != canonicalID {
|
||||
return nil, errors.New("resource not found")
|
||||
// The callback stands in for the separately race-tested canonical lifecycle.
|
||||
// It accepts one immutable request and refuses conflicting reuse of its ID.
|
||||
func newPlanningCapture() *ProposalCapture {
|
||||
c := NewProposalCapture(ProposalIdentity{ProposalID: "p1", FindingID: "f1", InvestigationID: "i1"}, testProposalCatalog())
|
||||
var first *CapturedProposal
|
||||
c.SetPlanner(func(ctx context.Context, p CapturedProposal) (unified.ActionAuditRecord, error) {
|
||||
if err := validateProposalAgainstCatalog(ctx, c.catalog, p.ResourceID, p.CapabilityName, p.Params); err != nil {
|
||||
return unified.ActionAuditRecord{}, err
|
||||
}
|
||||
return []unified.ResourceCapability{{Name: "start"}}, nil
|
||||
}
|
||||
provider := &stubUnifiedResourceProvider{resources: []unified.Resource{{
|
||||
ID: canonicalID,
|
||||
Type: unified.ResourceTypeAppContainer,
|
||||
Docker: &unified.DockerData{
|
||||
AgentID: agentID,
|
||||
ContainerID: containerID,
|
||||
},
|
||||
}}}
|
||||
capture := NewProposalCapture(ProposalIdentity{}, catalog)
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{UnifiedResourceProvider: provider})
|
||||
exec.ApplyExecutionProfile(ProfilePatrolInvestigation)
|
||||
exec.SetProposalCapture(capture)
|
||||
rawCoordinate := "docker:" + agentID + ":" + containerID
|
||||
|
||||
result, err := exec.ExecuteInvocation(context.Background(), ToolInvocation{
|
||||
ID: "catalog-docker",
|
||||
Name: agentcapabilities.PatrolActionCapabilitiesToolName,
|
||||
Arguments: map[string]interface{}{"resource_id": rawCoordinate},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, result.Content)
|
||||
assert.Contains(t, result.Content[0].Text, `"resource_id":"`+canonicalID+`"`)
|
||||
assert.Contains(t, result.Content[0].Text, `"name":"start"`)
|
||||
|
||||
proposalResult := executePropose(t, exec, "proposal-docker", map[string]interface{}{
|
||||
"resource_id": rawCoordinate,
|
||||
"causal_resource_id": rawCoordinate,
|
||||
"capability_name": "start",
|
||||
"reason": "restore the stopped worker",
|
||||
})
|
||||
assert.Contains(t, proposalResult.Content[0].Text, canonicalID)
|
||||
proposal, failed, outcomeErr := capture.Outcome()
|
||||
require.NoError(t, outcomeErr)
|
||||
require.NotNil(t, proposal)
|
||||
assert.Zero(t, failed)
|
||||
assert.Equal(t, canonicalID, proposal.ResourceID)
|
||||
}
|
||||
|
||||
func TestActionCapabilitiesCanonicalizePulseReadAppContainerCoordinate(t *testing.T) {
|
||||
const (
|
||||
canonicalID = "app-container-abc123"
|
||||
containerID = "92847aa6ab18fef9fc6e619f5b8350948"
|
||||
hostname = "pulse-patrol-lab"
|
||||
)
|
||||
catalog := func(_ context.Context, resourceID string) ([]unified.ResourceCapability, error) {
|
||||
if resourceID != canonicalID {
|
||||
return nil, errors.New("resource not found")
|
||||
if first != nil && (p.Reason != first.Reason || !reflect.DeepEqual(p.Params, first.Params)) {
|
||||
return unified.ActionAuditRecord{}, unified.ErrActionIdentityConflict
|
||||
}
|
||||
return []unified.ResourceCapability{{Name: "restart"}}, nil
|
||||
}
|
||||
provider := &stubUnifiedResourceProvider{resources: []unified.Resource{{
|
||||
ID: canonicalID, Type: unified.ResourceTypeAppContainer,
|
||||
Docker: &unified.DockerData{Hostname: hostname, ContainerID: containerID},
|
||||
}}}
|
||||
capture := NewProposalCapture(ProposalIdentity{}, catalog)
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{UnifiedResourceProvider: provider})
|
||||
exec.ApplyExecutionProfile(ProfilePatrolInvestigation)
|
||||
exec.SetProposalCapture(capture)
|
||||
rawCoordinate := "app-container:" + hostname + ":" + containerID
|
||||
|
||||
result, err := exec.ExecuteInvocation(context.Background(), ToolInvocation{
|
||||
ID: "catalog-pulse-read", Name: agentcapabilities.PatrolActionCapabilitiesToolName,
|
||||
Arguments: map[string]interface{}{"resource_id": rawCoordinate},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, result.Content)
|
||||
assert.Contains(t, result.Content[0].Text, `"resource_id":"`+canonicalID+`"`)
|
||||
|
||||
proposalResult := executePropose(t, exec, "proposal-pulse-read", map[string]interface{}{
|
||||
"resource_id": rawCoordinate, "causal_resource_id": rawCoordinate, "capability_name": "restart", "reason": "restore container health",
|
||||
})
|
||||
assert.Contains(t, proposalResult.Content[0].Text, canonicalID)
|
||||
proposal, failed, outcomeErr := capture.Outcome()
|
||||
require.NoError(t, outcomeErr)
|
||||
require.NotNil(t, proposal)
|
||||
assert.Zero(t, failed)
|
||||
assert.Equal(t, canonicalID, proposal.ResourceID)
|
||||
}
|
||||
|
||||
func TestActionCapabilitiesDoNotCanonicalizeAmbiguousContainerID(t *testing.T) {
|
||||
const containerID = "shared-container-id"
|
||||
provider := &stubUnifiedResourceProvider{resources: []unified.Resource{
|
||||
{ID: "app-container-a", Type: unified.ResourceTypeAppContainer, Docker: &unified.DockerData{ContainerID: containerID, AgentID: "agent-a"}},
|
||||
{ID: "app-container-b", Type: unified.ResourceTypeAppContainer, Docker: &unified.DockerData{ContainerID: containerID, AgentID: "agent-b"}},
|
||||
}}
|
||||
capture := NewProposalCapture(ProposalIdentity{}, func(_ context.Context, resourceID string) ([]unified.ResourceCapability, error) {
|
||||
if resourceID != containerID {
|
||||
t.Fatalf("ambiguous reference was rewritten to %q", resourceID)
|
||||
if first == nil {
|
||||
copy := p
|
||||
first = ©
|
||||
}
|
||||
return nil, errors.New("ambiguous resource")
|
||||
return unified.ActionAuditRecord{ID: "act_existing", State: unified.ActionStatePending}, nil
|
||||
})
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{UnifiedResourceProvider: provider})
|
||||
exec.ApplyExecutionProfile(ProfilePatrolInvestigation)
|
||||
exec.SetProposalCapture(capture)
|
||||
|
||||
result, err := exec.ExecuteInvocation(context.Background(), ToolInvocation{
|
||||
ID: "catalog-ambiguous",
|
||||
Name: agentcapabilities.PatrolActionCapabilitiesToolName,
|
||||
Arguments: map[string]interface{}{"resource_id": containerID},
|
||||
return c
|
||||
}
|
||||
func TestPlanningConflictRetainsAcceptedAction(t *testing.T) {
|
||||
c := newPlanningCapture()
|
||||
exec := newInvestigationExecutor(t, c)
|
||||
require.False(t, executePropose(t, exec, "call-a", proposeArgs()).IsError)
|
||||
changed := proposeArgs()
|
||||
changed["reason"] = "different intent"
|
||||
require.True(t, executePropose(t, exec, "call-b", changed).IsError)
|
||||
got, err := c.Outcome()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "act_existing", got.Action.ID)
|
||||
require.Equal(t, proposeArgs()["reason"], got.Reason)
|
||||
require.False(t, executePropose(t, exec, "call-c", proposeArgs()).IsError)
|
||||
}
|
||||
func TestConcurrentPlanningConflictDoesNotErasePersistedAction(t *testing.T) {
|
||||
c := newPlanningCapture()
|
||||
exec := newInvestigationExecutor(t, c)
|
||||
changed := proposeArgs()
|
||||
changed["reason"] = "alternative intent"
|
||||
var wg sync.WaitGroup
|
||||
for i, args := range []map[string]interface{}{proposeArgs(), changed} {
|
||||
wg.Add(1)
|
||||
go func(i int, args map[string]interface{}) {
|
||||
defer wg.Done()
|
||||
executePropose(t, exec, []string{"a", "b"}[i], args)
|
||||
}(i, args)
|
||||
}
|
||||
wg.Wait()
|
||||
got, err := c.Outcome()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
require.Equal(t, "act_existing", got.Action.ID)
|
||||
}
|
||||
func TestPlanningRefusalDoesNotDetermineDiagnosis(t *testing.T) {
|
||||
c := newPlanningCapture()
|
||||
exec := newInvestigationExecutor(t, c)
|
||||
args := proposeArgs()
|
||||
args["capability_name"] = "unavailable"
|
||||
require.True(t, executePropose(t, exec, "a", args).IsError)
|
||||
got, err := c.Outcome()
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, got)
|
||||
require.False(t, executePropose(t, exec, "b", proposeArgs()).IsError)
|
||||
}
|
||||
func TestPlanningKeepsCompletedEvidenceSnapshotAndCopiesResult(t *testing.T) {
|
||||
c := newPlanningCapture()
|
||||
exec := newInvestigationExecutor(t, c)
|
||||
c.RecordEvidence("completed-read")
|
||||
require.False(t, executePropose(t, exec, "a", proposeArgs()).IsError)
|
||||
c.RecordEvidence("later-read")
|
||||
require.False(t, executePropose(t, exec, "b", proposeArgs()).IsError)
|
||||
got, err := c.Outcome()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"completed-read"}, got.Identity.EvidenceIDs)
|
||||
got.Identity.EvidenceIDs[0] = "modified"
|
||||
got.Action.ID = "modified"
|
||||
got.Params["mode"] = "modified"
|
||||
again, err := c.Outcome()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "act_existing", again.Action.ID)
|
||||
require.Equal(t, "graceful", again.Params["mode"])
|
||||
}
|
||||
func TestKnownActionSurvivesPlanningReadFailure(t *testing.T) {
|
||||
c := NewProposalCapture(ProposalIdentity{}, nil)
|
||||
c.SetPlanner(func(context.Context, CapturedProposal) (unified.ActionAuditRecord, error) {
|
||||
return unified.ActionAuditRecord{ID: "act_known"}, errors.New("audit read unavailable")
|
||||
})
|
||||
result := executePropose(t, newInvestigationExecutor(t, c), "a", proposeArgs())
|
||||
require.True(t, result.IsError)
|
||||
require.Contains(t, result.Content[0].Text, "act_known")
|
||||
got, err := c.Outcome()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, result.Content)
|
||||
assert.Contains(t, result.Content[0].Text, "capability catalog lookup failed")
|
||||
require.Equal(t, "act_known", got.Action.ID)
|
||||
}
|
||||
func TestPlanningRequiresCorePlanner(t *testing.T) {
|
||||
c := NewProposalCapture(ProposalIdentity{}, testProposalCatalog())
|
||||
result := executePropose(t, newInvestigationExecutor(t, c), "a", proposeArgs())
|
||||
require.True(t, result.IsError)
|
||||
require.Contains(t, result.Content[0].Text, "planning is unavailable")
|
||||
}
|
||||
|
||||
func TestActionCapabilitiesRequiresInvestigationCatalog(t *testing.T) {
|
||||
exec := newInvestigationExecutor(t, nil)
|
||||
result, err := exec.ExecuteInvocation(context.Background(), ToolInvocation{
|
||||
ID: "catalog-a",
|
||||
Name: agentcapabilities.PatrolActionCapabilitiesToolName,
|
||||
Arguments: map[string]interface{}{"resource_id": "vm:42"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, result.Content)
|
||||
assert.Contains(t, result.Content[0].Text, "not available")
|
||||
}
|
||||
|
||||
func TestProposalExposureProjectorRedactsParams(t *testing.T) {
|
||||
args := proposeArgs()
|
||||
redacted := agentcapabilities.RedactToolCallArgumentsForExposure(agentcapabilities.PatrolProposeActionToolName, args)
|
||||
assert.Equal(t, agentcapabilities.RedactedProposalParamsMarker, redacted["params"])
|
||||
assert.Equal(t, "vm:42", redacted["resource_id"])
|
||||
assert.Equal(t, "vm:42", redacted["causal_resource_id"])
|
||||
// The transient map used for provider continuation and validation is
|
||||
// untouched.
|
||||
if _, ok := args["params"].(map[string]interface{}); !ok {
|
||||
t.Fatal("projector must not mutate the original arguments")
|
||||
// validateProposalAgainstCatalog checks the proposal against the
|
||||
// resource's advertised capability contract. Error messages never echo
|
||||
// parameter values: proposal params exist only transiently for provider
|
||||
// continuation and validation.
|
||||
func validateProposalAgainstCatalog(ctx context.Context, catalog ProposalCatalog, resourceID, capabilityName string, params map[string]interface{}) error {
|
||||
if catalog == nil {
|
||||
return errors.New("no capability catalog is wired for proposal validation")
|
||||
}
|
||||
if !strings.Contains(redacted["reason"].(string), "recover") {
|
||||
t.Fatal("non-parameter fields stay exposed")
|
||||
capabilities, err := catalog(ctx, resourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("capability catalog lookup failed for resource %q", resourceID)
|
||||
}
|
||||
// Other tools pass through unchanged.
|
||||
other := agentcapabilities.RedactToolCallArgumentsForExposure("pulse_query", args)
|
||||
if _, ok := other["params"].(map[string]interface{}); !ok {
|
||||
t.Fatal("non-proposal tools must not be redacted")
|
||||
// Exact-name resolution and full parameter validation are the
|
||||
// planner's canonical implementations, so proposal acceptance and
|
||||
// planning can never drift on matching, types, enums, patterns,
|
||||
// required presence, or malformed capability schemas.
|
||||
capability, found := actionplanner.FindCapability(capabilities, capabilityName)
|
||||
if !found {
|
||||
return fmt.Errorf("resource %q does not advertise capability %q", resourceID, capabilityName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturedProposalIsImmuneToCallerMutation(t *testing.T) {
|
||||
capture := NewProposalCapture(ProposalIdentity{InvestigationID: "inv-1", EvidenceIDs: []string{"ev-1"}}, testProposalCatalog())
|
||||
args := proposeArgs()
|
||||
params := args["params"].(map[string]interface{})
|
||||
|
||||
require.NoError(t, capture.Submit("call-a", "vm:42", "vm:42", "restart", "recover", params))
|
||||
|
||||
// Mutating the caller's map after validation must not change the
|
||||
// actionable proposal (or its fingerprint identity).
|
||||
params["mode"] = "force"
|
||||
|
||||
proposal, _, err := capture.Outcome()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, proposal)
|
||||
assert.Equal(t, "graceful", proposal.Params["mode"])
|
||||
|
||||
// Mutating the returned copy must not affect a later outcome read.
|
||||
proposal.Params["mode"] = "force"
|
||||
proposal.Identity.EvidenceIDs[0] = "tampered"
|
||||
again, _, err := capture.Outcome()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "graceful", again.Params["mode"])
|
||||
assert.Equal(t, "ev-1", again.Identity.EvidenceIDs[0])
|
||||
}
|
||||
|
||||
func TestProposalCapabilityMatchingIsExactLikePlanning(t *testing.T) {
|
||||
capture := NewProposalCapture(ProposalIdentity{}, testProposalCatalog())
|
||||
exec := newInvestigationExecutor(t, capture)
|
||||
|
||||
// The catalog advertises "restart"; a case-mismatched proposal must
|
||||
// fail exactly as planning would, so acceptance and planning never
|
||||
// drift on name resolution.
|
||||
args := proposeArgs()
|
||||
args["capability_name"] = "Restart"
|
||||
result := executePropose(t, exec, "call-a", args)
|
||||
assert.Contains(t, result.Content[0].Text, "does not advertise")
|
||||
|
||||
_, failed, err := capture.Outcome()
|
||||
assert.Equal(t, 1, failed)
|
||||
if !errors.Is(err, ErrProposalAttemptsFailed) {
|
||||
t.Fatalf("outcome error = %v, want ErrProposalAttemptsFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposalValidationUsesCanonicalPlannerRules(t *testing.T) {
|
||||
capture := NewProposalCapture(ProposalIdentity{}, testProposalCatalog())
|
||||
exec := newInvestigationExecutor(t, capture)
|
||||
|
||||
// Empty required value: planner treats whitespace as missing.
|
||||
args := proposeArgs()
|
||||
args["params"] = map[string]interface{}{"mode": " "}
|
||||
result := executePropose(t, exec, "call-a", args)
|
||||
assert.Contains(t, result.Content[0].Text, "invalid for capability")
|
||||
|
||||
// Wrong type for an enum string parameter.
|
||||
args = proposeArgs()
|
||||
args["params"] = map[string]interface{}{"mode": 42}
|
||||
result = executePropose(t, exec, "call-b", args)
|
||||
assert.Contains(t, result.Content[0].Text, "invalid for capability")
|
||||
|
||||
_, failed, err := capture.Outcome()
|
||||
assert.Equal(t, 2, failed)
|
||||
if !errors.Is(err, ErrProposalAttemptsFailed) {
|
||||
t.Fatalf("outcome error = %v, want ErrProposalAttemptsFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposeActionSchemaExposesOnlyModelAuthoredFields(t *testing.T) {
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{})
|
||||
exec.ApplyExecutionProfile(ProfilePatrolInvestigation)
|
||||
exec.SetProposalCapture(NewProposalCapture(ProposalIdentity{}, testProposalCatalog()))
|
||||
|
||||
for _, tool := range exec.registry.ListTools(exec.invocationPolicy()) {
|
||||
if tool.Name != agentcapabilities.PatrolProposeActionToolName {
|
||||
// Proposal-specific ratchet on top of planning: investigations must
|
||||
// never carry sensitive values (operators supply those at approval
|
||||
// time on the canonical surface).
|
||||
for _, param := range capability.Params {
|
||||
if !param.IsSensitive {
|
||||
continue
|
||||
}
|
||||
assert.ElementsMatch(t,
|
||||
[]string{"resource_id", "causal_resource_id", "capability_name", "params", "reason"},
|
||||
mapKeys(tool.InputSchema.Properties),
|
||||
)
|
||||
assert.NotContains(t, tool.InputSchema.Required, "causal_resource_id")
|
||||
return
|
||||
if value, ok := params[param.Name]; ok && value != nil {
|
||||
return fmt.Errorf("parameter %q is sensitive and must be supplied by an operator on the canonical approval surface, never by an investigation", param.Name)
|
||||
}
|
||||
}
|
||||
t.Fatal("patrol_propose_action missing from investigation projection")
|
||||
}
|
||||
|
||||
func TestProposeActionRejectsUnknownAndInternalFieldsBeforeCapture(t *testing.T) {
|
||||
capture := NewProposalCapture(ProposalIdentity{FindingID: "trusted-f", InvestigationID: "trusted-i"}, testProposalCatalog())
|
||||
exec := newInvestigationExecutor(t, capture)
|
||||
|
||||
for _, field := range []string{"finding_id", "investigation_id", agentcapabilities.ApprovalArgumentKey} {
|
||||
args := proposeArgs()
|
||||
args[field] = "model-authored"
|
||||
result := executePropose(t, exec, "call-"+field, args)
|
||||
assert.Contains(t, result.Content[0].Text, "invalid tools/call params")
|
||||
if err := actionplanner.ValidateParams(params, capability.Params); err != nil {
|
||||
return fmt.Errorf("proposal parameters are invalid for capability %q: %s", capabilityName, err.Error())
|
||||
}
|
||||
|
||||
proposal, failed, err := capture.Outcome()
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, proposal)
|
||||
assert.Zero(t, failed, "schema rejection must occur before the proposal handler records an attempt")
|
||||
}
|
||||
|
||||
func mapKeys[V any](values map[string]V) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/actionplanner"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
|
||||
@@ -93,16 +92,6 @@ func (e *PulseToolExecutor) executeControlResource(ctx context.Context, args map
|
||||
if blocked != nil {
|
||||
return *blocked, nil
|
||||
}
|
||||
// Current capability evidence is the only permitted source of "not
|
||||
// available". When the canonical record is in hand and does not advertise
|
||||
// the action (or its lifecycle synonym), answer from that evidence now so
|
||||
// read-only platforms are refused with the resource's real capability
|
||||
// list even before the planner runs. The planner applies the same rule.
|
||||
if target.canonical != nil {
|
||||
if _, ok := advertisedActionName(*target.canonical, action); !ok {
|
||||
return controlPlanFailureResult(target, action, actionplanner.ErrCapabilityNotFound), nil
|
||||
}
|
||||
}
|
||||
|
||||
if e.typedActionPlanner == nil {
|
||||
return NewErrorResult(fmt.Errorf("canonical action planning is unavailable")), nil
|
||||
@@ -112,7 +101,7 @@ func (e *PulseToolExecutor) executeControlResource(ctx context.Context, args map
|
||||
return NewErrorResult(fmt.Errorf("resource %q has no canonical resource id", resourceRef)), nil
|
||||
}
|
||||
plan, err := e.typedActionPlanner.PlanTypedAction(ctx, e.orgID, unifiedresources.ActionRequest{
|
||||
RequestID: uuid.NewString(),
|
||||
RequestID: actionRequestIDForInvocation(ctx),
|
||||
ResourceID: resourceID,
|
||||
CapabilityName: action,
|
||||
Reason: fmt.Sprintf("Assistant proposed %s for %s", action, resourceID),
|
||||
|
||||
@@ -44,13 +44,13 @@ func (e *PulseToolExecutor) registerProposeTools() {
|
||||
e.registry.registerBuiltin(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: agentcapabilities.PatrolProposeActionToolName,
|
||||
Description: `Propose ONE typed remediation action for the finding under investigation. Side-effect-free: the proposal is validated and recorded for governed planning and approval; nothing executes now.
|
||||
Description: `Propose ONE typed remediation action for the finding under investigation. This persists a canonical action plan and returns its actual planning result. Planning requests no approval or execution. Continue investigating or explaining that result as needed within the configured budget.
|
||||
|
||||
Reference an advertised resource capability (see the resource's capability catalog) and fill only its declared parameters. Never place secrets in params - sensitive parameters are supplied by an operator at approval time.
|
||||
|
||||
If the evidence establishes a causal resource, identify it separately from the action target. Otherwise omit causal_resource_id. Explain the observed problem, why the proposed action should help, and any uncertainty. An action can address a symptom without establishing its underlying cause.
|
||||
|
||||
Submit at most one proposal per investigation. If no safe remediation exists, conclude without proposing.`,
|
||||
Create at most one action per investigation. Replaying the same accepted request returns its existing action. Changed intent after acceptance requires a new operator-requested investigation. If no safe remediation exists, conclude without proposing.`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
@@ -82,9 +82,9 @@ Submit at most one proposal per investigation. If no safe remediation exists, co
|
||||
return exec.executeProposeAction(ctx, args)
|
||||
},
|
||||
Governance: ToolGovernance{
|
||||
ActionMode: ToolActionRead,
|
||||
ActionMode: ToolActionWrite,
|
||||
ApprovalPolicy: ToolApprovalScopeOnly,
|
||||
ApprovalSummary: "side-effect-free proposal capture; the proposed action itself is planned and approved on the canonical action lifecycle",
|
||||
ApprovalSummary: "persists a canonical action plan without approval or execution",
|
||||
Summary: "Records one validated typed action proposal during a Patrol investigation; planning, approval, and execution stay governed.",
|
||||
},
|
||||
})
|
||||
@@ -165,21 +165,23 @@ func (e *PulseToolExecutor) executeProposeAction(ctx context.Context, args map[s
|
||||
params = map[string]interface{}{}
|
||||
}
|
||||
if resourceID == "" || capabilityName == "" || reason == "" {
|
||||
capture.RecordFailedAttempt()
|
||||
return NewErrorResult(fmt.Errorf("resource_id, capability_name, and reason are required")), nil
|
||||
}
|
||||
|
||||
if err := validateProposalAgainstCatalog(ctx, capture.catalog, resourceID, capabilityName, params); err != nil {
|
||||
capture.RecordFailedAttempt()
|
||||
record, err := capture.Submit(ctx, InvocationIDFromContext(ctx), resourceID, causalResourceID, capabilityName, reason, params)
|
||||
if err != nil {
|
||||
if record != nil && record.ID != "" {
|
||||
return NewErrorResult(fmt.Errorf("action %s exists but planning continuation failed: %w; inspect its canonical action history before another request", record.ID, err)), nil
|
||||
}
|
||||
return NewErrorResult(err), nil
|
||||
}
|
||||
|
||||
if err := capture.Submit(InvocationIDFromContext(ctx), resourceID, causalResourceID, capabilityName, reason, params); err != nil {
|
||||
return NewErrorResult(err), nil
|
||||
}
|
||||
return NewTextResult(fmt.Sprintf(
|
||||
"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
|
||||
return NewJSONResult(map[string]interface{}{
|
||||
"action_id": record.ID, "action_url": "/actions?action_id=" + record.ID,
|
||||
"state": record.State, "plan": record.Plan,
|
||||
"action_result_v2": unified.CanonicalActionResultV2(*record),
|
||||
"execution_requested": false,
|
||||
"evidence_limit": "Plan acceptance is not proof of diagnosis or resolution. Continue from observed evidence and the independent action outcome.",
|
||||
}), nil
|
||||
}
|
||||
|
||||
// canonicalProposalResourceID keeps provider coordinates discovered during an
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/truenas"
|
||||
@@ -1323,6 +1324,7 @@ func TestExecuteGetResource_RegistersVMwareVMAsReadOnly(t *testing.T) {
|
||||
ReadState: provider.ResourceRegistry,
|
||||
})
|
||||
executor.SetResolvedContext(resolved)
|
||||
wireCanonicalQueryTestPlanner(executor, provider.ResourceRegistry)
|
||||
|
||||
result, err := executor.executeGetResource(context.Background(), map[string]interface{}{
|
||||
"resource_type": "vm",
|
||||
@@ -1386,6 +1388,7 @@ func TestExecuteGetResource_RegistersVMwareAgentAsReadOnly(t *testing.T) {
|
||||
ReadState: provider.ResourceRegistry,
|
||||
})
|
||||
executor.SetResolvedContext(resolved)
|
||||
wireCanonicalQueryTestPlanner(executor, provider.ResourceRegistry)
|
||||
|
||||
result, err := executor.executeGetResource(context.Background(), map[string]interface{}{
|
||||
"resource_type": "agent",
|
||||
@@ -2208,3 +2211,14 @@ func TestResourceTypeFromCanonicalID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// These refusal checks use the real planner. Cached allowed-action hints do
|
||||
// not own the action boundary and may be stale after a replay or discovery.
|
||||
func wireCanonicalQueryTestPlanner(executor *PulseToolExecutor, registry *unifiedresources.ResourceRegistry) {
|
||||
store := unifiedresources.NewMemoryStore()
|
||||
service := &actionlifecycle.Service{Registry: func(string) (*unifiedresources.ResourceRegistry, error) { return registry, nil }, Store: func(string) (actionlifecycle.Store, error) { return store, nil }}
|
||||
executor.SetTypedActionPlanner(typedActionPlannerFunc(func(ctx context.Context, _ string, request unifiedresources.ActionRequest) (*unifiedresources.ActionPlan, error) {
|
||||
plan, err := service.Plan(ctx, "default", request, unifiedresources.ActionActor{SubjectID: "operator", Kind: unifiedresources.ActionActorUser, CredentialID: "test-credential", OrgID: "default"})
|
||||
return &plan, err
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1075,8 +1075,12 @@ func (s *UnifiedStore) Resolve(findingID string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
f.ResolvedAt = &now
|
||||
// A replayed Patrol resolution already carries its canonical timestamp.
|
||||
// Do not turn synchronization into another recovery event.
|
||||
if f.ResolvedAt == nil {
|
||||
now := time.Now()
|
||||
f.ResolvedAt = &now
|
||||
}
|
||||
f.SnoozedUntil = nil
|
||||
|
||||
s.mu.Unlock()
|
||||
|
||||
@@ -406,3 +406,25 @@ type testError struct{}
|
||||
func (e *testError) Error() string {
|
||||
return "test error"
|
||||
}
|
||||
|
||||
func TestUnifiedStore_ResolvePreservesCanonicalPatrolTimestamp(t *testing.T) {
|
||||
store := NewUnifiedStore(DefaultAlertToFindingConfig())
|
||||
resolved := time.Now().Add(-time.Hour)
|
||||
store.AddFromAI(&UnifiedFinding{ID: "patrol-replay", Source: SourceAIPatrol, ResourceID: "resource", Title: "Failure", ResolvedAt: &resolved})
|
||||
for i := 0; i < 3; i++ {
|
||||
if !store.Resolve("patrol-replay") {
|
||||
t.Fatal("resolve failed")
|
||||
}
|
||||
}
|
||||
got := store.Get("patrol-replay")
|
||||
if got.ResolvedAt == nil || !got.ResolvedAt.Equal(resolved) {
|
||||
t.Fatalf("canonical resolution moved: %v, want %v", got.ResolvedAt, resolved)
|
||||
}
|
||||
// A later active projection is a real reopening, not a resolution replay.
|
||||
store.AddFromAI(&UnifiedFinding{ID: "patrol-replay", Source: SourceAIPatrol, ResourceID: "resource", Title: "Failure"})
|
||||
store.Resolve("patrol-replay")
|
||||
got = store.Get("patrol-replay")
|
||||
if got.ResolvedAt == nil || !got.ResolvedAt.After(resolved) {
|
||||
t.Fatal("newly resolved regression did not receive a new timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
func TestActionPlanRequestConflictIsStableConflict(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
writeActionPlanError(rec, &actionlifecycle.PersistError{Op: "action request identity", Err: unified.ErrActionIdentityConflict})
|
||||
var body agentcapabilities.ErrorEnvelope
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusConflict || body.Error != agentcapabilities.AgentErrCodeActionRequestConflict {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -394,6 +394,8 @@ func writeActionPlanError(w http.ResponseWriter, err error) {
|
||||
writeJSONError(w, http.StatusInternalServerError, "resource_registry_unavailable", sanitizeErrorForClient(err, "Resource registry unavailable"))
|
||||
case errors.Is(err, actionlifecycle.ErrStoreUnavailable):
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "action_audit_unavailable", "Action audit history is not available")
|
||||
case errors.Is(err, unified.ErrActionIdentityConflict):
|
||||
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionRequestConflict, "This request ID is already bound to different action intent or ambiguous historical plans. Review the existing action history before creating a new request.")
|
||||
case errors.As(err, &persist):
|
||||
writeJSONError(w, http.StatusInternalServerError, "action_audit_persist_failed", sanitizeErrorForClient(err, "Failed to persist action audit"))
|
||||
default:
|
||||
|
||||
@@ -207,6 +207,13 @@ func TestApprovalPlanInfoRejectsMalformedCanonicalPolicyDecision(t *testing.T) {
|
||||
t.Fatal("cross-plan canonical provenance was accepted")
|
||||
}
|
||||
canonicalPlan := &unified.ActionPlan{ActionID: "act-1", Allowed: true, RequiresApproval: true, ApprovalPolicy: unified.ApprovalAdmin, ApprovalRequirement: requirement, PolicyDecision: provenance}
|
||||
tampered := approvalPlanRequestToInfo(canonicalPlan)
|
||||
strengthened := requirement
|
||||
strengthened.Quorum++
|
||||
tampered.ApprovalRequirement, _ = json.Marshal(strengthened)
|
||||
if _, err := approvalPlanInfoToRequest(tampered); err == nil {
|
||||
t.Fatal("relay accepted a requirement that disagrees with canonical policy provenance")
|
||||
}
|
||||
converted, err := approvalPlanInfoToRequest(approvalPlanRequestToInfo(canonicalPlan))
|
||||
if err != nil || converted == nil || !reflect.DeepEqual(converted.PolicyDecision, provenance) || converted.ApprovalRequirement != requirement {
|
||||
t.Fatalf("canonical provenance relay round trip: plan=%#v err=%v", converted, err)
|
||||
@@ -1392,3 +1399,20 @@ func TestIssue1649HandleForceFailActionSettlesWedgedExecutingAction(t *testing.T
|
||||
t.Fatalf("missing force-fail status = %d, body=%s", missingRec.Code, missingRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalPlanInfoPreservesRequirementWithoutHistoricalPolicyProvenance(t *testing.T) {
|
||||
requirement := unified.ApprovalRequirement{Version: 1, Floor: unified.ApprovalMultiFactor, Quorum: 2, DisallowRequester: true}
|
||||
original := &unified.ActionPlan{ActionID: "historical-action", ApprovalPolicy: unified.ApprovalMultiFactor, ApprovalRequirement: requirement, PolicyDecision: unified.LegacyUnknownActionPolicyDecision()}
|
||||
converted, err := approvalPlanInfoToRequest(approvalPlanRequestToInfo(original))
|
||||
if err != nil || converted.ApprovalRequirement != requirement || !unified.IsLegacyUnknownActionPolicyDecision(converted.PolicyDecision) {
|
||||
t.Fatalf("historical requirement was lost or provenance invented: %+v %v", converted, err)
|
||||
}
|
||||
}
|
||||
func TestApprovalPlanInfoRejectsMalformedApprovalRequirement(t *testing.T) {
|
||||
for _, raw := range []string{`null`, `{}`, `{"version":1,"floor":"mfa","quorum":2,"extra":true}`, `{"version":1,"floor":"mfa","quorum":2}{}`} {
|
||||
_, err := approvalPlanInfoToRequest(&aicontracts.ActionPlanInfo{ApprovalPolicy: string(unified.ApprovalMultiFactor), ApprovalRequirement: json.RawMessage(raw)})
|
||||
if err == nil {
|
||||
t.Fatalf("accepted malformed approval requirement %s", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-38
@@ -1987,6 +1987,7 @@ func (h *AISettingsHandler) setupInvestigationOrchestrator(orgID string, svc *ai
|
||||
chatAdapter := &orchestratorChatAdapter{
|
||||
svc: chatService,
|
||||
catalog: h.proposalCatalogFor(orgID),
|
||||
planner: investigationPlannerFor(h.actionBrokerFor(orgID)),
|
||||
}
|
||||
|
||||
// Create findings store adapter
|
||||
@@ -2073,6 +2074,7 @@ type orchestratorChatAdapter struct {
|
||||
// catalog resolves advertised resource capabilities for proposal
|
||||
// validation, from the tenant-bound action lifecycle service.
|
||||
catalog tools.ProposalCatalog
|
||||
planner tools.ProposalPlanner
|
||||
}
|
||||
|
||||
func (a *orchestratorChatAdapter) CreateSession(ctx context.Context) (*aicontracts.OrchestratorChatSession, error) {
|
||||
@@ -2105,6 +2107,7 @@ func (a *orchestratorChatAdapter) ExecuteInvestigationStream(ctx context.Context
|
||||
EvidenceIDs: req.EvidenceIDs,
|
||||
},
|
||||
Catalog: a.catalog,
|
||||
Planner: a.planner,
|
||||
}, func(event chat.StreamEvent) {
|
||||
callback(aicontracts.OrchestratorStreamEvent{
|
||||
Type: event.Type,
|
||||
@@ -2112,19 +2115,22 @@ func (a *orchestratorChatAdapter) ExecuteInvestigationStream(ctx context.Context
|
||||
})
|
||||
})
|
||||
if runResult == nil {
|
||||
return nil, mapInvestigationProposalError(err)
|
||||
return nil, err
|
||||
}
|
||||
result := &aicontracts.OrchestratorInvestigationResult{
|
||||
Content: runResult.Content,
|
||||
FailedProposalAttempts: runResult.FailedProposalAttempts,
|
||||
InputTokens: runResult.InputTokens,
|
||||
OutputTokens: runResult.OutputTokens,
|
||||
ModelTurns: runResult.ModelTurns,
|
||||
EvidenceCalls: runResult.EvidenceCalls,
|
||||
ToolCalls: runResult.ToolCalls,
|
||||
Content: runResult.Content,
|
||||
InputTokens: runResult.InputTokens,
|
||||
OutputTokens: runResult.OutputTokens,
|
||||
ModelTurns: runResult.ModelTurns,
|
||||
EvidenceCalls: runResult.EvidenceCalls,
|
||||
ToolCalls: runResult.ToolCalls,
|
||||
}
|
||||
if runResult.Proposal != nil {
|
||||
captured := runResult.Proposal
|
||||
if captured.Action != nil {
|
||||
disposition := dispositionFromRecord(*captured.Action)
|
||||
result.Action = &aicontracts.ActionReference{CausalResourceID: captured.CausalResourceID, ActionID: disposition.ActionID, ProposalID: captured.Identity.ProposalID, ResourceID: captured.ResourceID, CapabilityName: captured.CapabilityName, State: disposition.State, Plan: disposition.Plan, ActionResultV2: disposition.ActionResultV2, VerificationStatus: disposition.VerificationStatus}
|
||||
}
|
||||
result.Proposal = &aicontracts.ActionProposal{
|
||||
ProposalID: captured.Identity.ProposalID,
|
||||
FindingID: captured.Identity.FindingID,
|
||||
@@ -2136,36 +2142,7 @@ func (a *orchestratorChatAdapter) ExecuteInvestigationStream(ctx context.Context
|
||||
EvidenceIDs: captured.Identity.EvidenceIDs,
|
||||
}
|
||||
}
|
||||
return result, mapInvestigationProposalError(err)
|
||||
}
|
||||
|
||||
// mapInvestigationProposalError projects the core proposal-channel errors
|
||||
// onto the public contract sentinels so enterprise outcome mapping can
|
||||
// key on errors.Is without importing internal packages.
|
||||
func mapInvestigationProposalError(err error) error {
|
||||
var runErr *chat.InvestigationRunError
|
||||
if errors.As(err, &runErr) {
|
||||
return aicontracts.NewOrchestratorInvestigationError(
|
||||
runErr.RunFailure(),
|
||||
mapInvestigationProposalSentinel(runErr.ProposalFailure()),
|
||||
)
|
||||
}
|
||||
return mapInvestigationProposalSentinel(err)
|
||||
}
|
||||
|
||||
func mapInvestigationProposalSentinel(err error) error {
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, tools.ErrProposalAmbiguous):
|
||||
return aicontracts.ErrInvestigationProposalAmbiguous
|
||||
case errors.Is(err, tools.ErrProposalIntegrity):
|
||||
return aicontracts.ErrInvestigationProposalIntegrity
|
||||
case errors.Is(err, tools.ErrProposalAttemptsFailed):
|
||||
return aicontracts.ErrInvestigationProposalAttemptsFailed
|
||||
default:
|
||||
return err
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
//nolint:dupl // mirrors chatServiceAdapter.GetMessages: same source messages mapped onto a deliberately separate output contract that may diverge
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -18,8 +17,6 @@ import (
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/chat"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
@@ -3824,20 +3821,3 @@ func TestOrchestratorAndChatAdaptersMapTheirMessageContracts(t *testing.T) {
|
||||
t.Error("chatServiceAdapter.GetMessages must route through adaptChatMessage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapInvestigationProposalErrorPreservesSimultaneousRunFailure(t *testing.T) {
|
||||
runFailure := errors.New("provider stream failed")
|
||||
coreErr := chat.NewInvestigationRunError(runFailure, tools.ErrProposalAmbiguous)
|
||||
|
||||
mapped := mapInvestigationProposalError(coreErr)
|
||||
if !errors.Is(mapped, runFailure) {
|
||||
t.Fatal("mapped error lost the provider/runtime failure")
|
||||
}
|
||||
if !errors.Is(mapped, aicontracts.ErrInvestigationProposalAmbiguous) {
|
||||
t.Fatal("mapped error lost the public proposal sentinel")
|
||||
}
|
||||
var investigationErr *aicontracts.OrchestratorInvestigationError
|
||||
if !errors.As(mapped, &investigationErr) || investigationErr.RunFailure() == nil {
|
||||
t.Fatalf("mapped error = %#v, want a runtime-bearing investigation error", mapped)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21299,6 +21299,7 @@ func TestContract_AgentSurfaceErrorCodesMatchManifestDeclarations(t *testing.T)
|
||||
"AgentErrCodeFindingActionNotAllowed": agentcapabilities.AgentErrCodeFindingActionNotAllowed,
|
||||
"AgentErrCodePatrolUnavailable": agentcapabilities.AgentErrCodePatrolUnavailable,
|
||||
"AgentErrCodeInvalidActionRequest": agentcapabilities.AgentErrCodeInvalidActionRequest,
|
||||
"AgentErrCodeActionRequestConflict": agentcapabilities.AgentErrCodeActionRequestConflict,
|
||||
"AgentErrCodeCapabilityNotFound": agentcapabilities.AgentErrCodeCapabilityNotFound,
|
||||
"AgentErrCodeActionExecutionUnavailable": agentcapabilities.AgentErrCodeActionExecutionUnavailable,
|
||||
"AgentErrCodeActionActorUnavailable": agentcapabilities.AgentErrCodeActionActorUnavailable,
|
||||
@@ -22348,7 +22349,7 @@ func TestContract_PatrolActionBrokerKeepsPolicyExecutionCoreOwned(t *testing.T)
|
||||
"PlanWithOptions(ctx, b.orgID",
|
||||
`patrolActionBrokerActor = "pulse_patrol"`,
|
||||
`patrolActionOriginSurface = "patrol"`,
|
||||
"rejectSensitiveParams",
|
||||
"RequireOperatorSensitiveParams",
|
||||
// Correlation identity is mandatory before persistence so a
|
||||
// planned action can always be reconciled onto its finding.
|
||||
"action proposal requires a finding id",
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
|
||||
)
|
||||
@@ -107,9 +108,8 @@ func NewActionRefreshPlanner(resources *ResourceHandlers, policy PatrolActionPol
|
||||
EvidenceIDs: append([]string(nil), previous.Origin.EvidenceIDs...),
|
||||
}
|
||||
broker := &patrolActionBroker{orgID: orgID, lifecycle: resources.ActionLifecycle, policy: policy}
|
||||
if err := broker.rejectSensitiveParams(ctx, proposal); err != nil {
|
||||
return unified.ActionRequest{}, actionlifecycle.PlanOptions{}, err
|
||||
}
|
||||
|
||||
opts.RequireOperatorSensitiveParams = true
|
||||
factors, _ := broker.planPolicyFactors(ctx, proposal, broker.currentTime())
|
||||
req.RequestedBy = patrolActionBrokerActor
|
||||
opts.Actor = unified.ActionActor{
|
||||
@@ -158,8 +158,22 @@ func (b *patrolActionBroker) Capabilities(ctx context.Context, resourceID string
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
// Plan persists the canonical plan without requesting approval or execution.
|
||||
// It lets an investigation observe real planning acceptance or refusal during
|
||||
// its model turn. Only the core caller may separately request policy progression.
|
||||
func (b *patrolActionBroker) Plan(ctx context.Context, proposal aicontracts.ActionProposal) (aicontracts.ActionDisposition, error) {
|
||||
return b.submit(ctx, proposal, false)
|
||||
}
|
||||
|
||||
func (b *patrolActionBroker) Submit(ctx context.Context, proposal aicontracts.ActionProposal) (aicontracts.ActionDisposition, error) {
|
||||
return b.submit(ctx, proposal, true)
|
||||
}
|
||||
|
||||
func (b *patrolActionBroker) submit(ctx context.Context, proposal aicontracts.ActionProposal, progressPolicy bool) (aicontracts.ActionDisposition, error) {
|
||||
proposal.ProposalID = strings.TrimSpace(proposal.ProposalID)
|
||||
if proposal.ProposalID == "" {
|
||||
return aicontracts.ActionDisposition{}, fmt.Errorf("action proposal requires a request identity")
|
||||
}
|
||||
proposal.FindingID = strings.TrimSpace(proposal.FindingID)
|
||||
proposal.InvestigationID = strings.TrimSpace(proposal.InvestigationID)
|
||||
proposal.ResourceID = unified.CanonicalResourceID(proposal.ResourceID)
|
||||
@@ -185,9 +199,6 @@ func (b *patrolActionBroker) Submit(ctx context.Context, proposal aicontracts.Ac
|
||||
return aicontracts.ActionDisposition{}, fmt.Errorf("action proposal requires an investigation id")
|
||||
}
|
||||
|
||||
if err := b.rejectSensitiveParams(ctx, proposal); err != nil {
|
||||
return aicontracts.ActionDisposition{}, err
|
||||
}
|
||||
policyFactors, planningAutoAuthorized := b.planPolicyFactors(ctx, proposal, b.currentTime())
|
||||
|
||||
plan, err := b.lifecycle().PlanWithOptions(ctx, b.orgID, unified.ActionRequest{
|
||||
@@ -198,6 +209,7 @@ func (b *patrolActionBroker) Submit(ctx context.Context, proposal aicontracts.Ac
|
||||
Reason: proposal.Reason,
|
||||
RequestedBy: patrolActionBrokerActor,
|
||||
}, actionlifecycle.PlanOptions{
|
||||
RequireOperatorSensitiveParams: true,
|
||||
Actor: unified.ActionActor{
|
||||
SubjectID: patrolActionBrokerActor,
|
||||
Kind: unified.ActionActorService,
|
||||
@@ -218,17 +230,16 @@ func (b *patrolActionBroker) Submit(ctx context.Context, proposal aicontracts.Ac
|
||||
}
|
||||
|
||||
record, found, err := b.lifecycle().Get(b.orgID, plan.ActionID)
|
||||
if err != nil {
|
||||
return aicontracts.ActionDisposition{}, err
|
||||
}
|
||||
if !found {
|
||||
return aicontracts.ActionDisposition{}, fmt.Errorf("planned action %q was not persisted", plan.ActionID)
|
||||
if err != nil || !found {
|
||||
// Planning already returned a durable identity. Do not turn a later read
|
||||
// failure into the false claim that no action exists.
|
||||
return aicontracts.ActionDisposition{ActionID: plan.ActionID}, fmt.Errorf("planned action %q could not be read", plan.ActionID)
|
||||
}
|
||||
if record.State == unified.ActionStateCompleted || record.State == unified.ActionStateFailed || record.State == unified.ActionStateRejected {
|
||||
return dispositionFromRecord(record), nil
|
||||
}
|
||||
|
||||
if planningAutoAuthorized {
|
||||
if progressPolicy && planningAutoAuthorized {
|
||||
record, err = b.lifecycle().ExecuteUnderPolicy(ctx, b.orgID, plan.ActionID, patrolActionPolicyActor, func(ctx context.Context, current unified.ActionAuditRecord, now time.Time) (unified.ActionPolicyAuthorizationLease, string, error) {
|
||||
return b.policyAuthorizationLease(ctx, proposal, current, now)
|
||||
})
|
||||
@@ -456,33 +467,23 @@ func (b *patrolActionBroker) currentTime() time.Time {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
// rejectSensitiveParams fails a proposal that populates any parameter the
|
||||
// capability declares sensitive. Secrets must come from an operator on the
|
||||
// canonical surfaces, never from model output that would persist in
|
||||
// investigation stores and action audit records.
|
||||
func (b *patrolActionBroker) rejectSensitiveParams(ctx context.Context, proposal aicontracts.ActionProposal) error {
|
||||
if len(proposal.Params) == 0 {
|
||||
// investigationPlannerFor exposes only plan persistence to the model tool.
|
||||
// The concrete core broker remains tenant-bound and owns all policy inputs.
|
||||
func investigationPlannerFor(broker aicontracts.OrchestratorActionBroker) tools.ProposalPlanner {
|
||||
b, ok := broker.(*patrolActionBroker)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
capabilities, err := b.lifecycle().Capabilities(ctx, b.orgID, proposal.ResourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, capability := range capabilities {
|
||||
if !strings.EqualFold(strings.TrimSpace(capability.Name), proposal.CapabilityName) {
|
||||
continue
|
||||
return func(ctx context.Context, captured tools.CapturedProposal) (unified.ActionAuditRecord, error) {
|
||||
disposition, err := b.Plan(ctx, aicontracts.ActionProposal{ProposalID: captured.Identity.ProposalID, FindingID: captured.Identity.FindingID, InvestigationID: captured.Identity.InvestigationID, ResourceID: captured.ResourceID, CapabilityName: captured.CapabilityName, Params: captured.Params, Reason: captured.Reason, EvidenceIDs: captured.Identity.EvidenceIDs})
|
||||
if disposition.ActionID == "" {
|
||||
return unified.ActionAuditRecord{}, err
|
||||
}
|
||||
for _, param := range capability.Params {
|
||||
if !param.IsSensitive {
|
||||
continue
|
||||
}
|
||||
if value, ok := proposal.Params[param.Name]; ok && value != nil {
|
||||
return fmt.Errorf("%w: parameter %q on capability %q", aicontracts.ErrSensitiveParamsRequireOperator, param.Name, proposal.CapabilityName)
|
||||
}
|
||||
record, found, readErr := b.lifecycle().Get(b.orgID, disposition.ActionID)
|
||||
if readErr != nil || !found {
|
||||
// Preserve the known identity even if the follow-up read fails.
|
||||
return unified.ActionAuditRecord{ID: disposition.ActionID}, fmt.Errorf("action %s persisted but its audit is unavailable", disposition.ActionID)
|
||||
}
|
||||
return nil
|
||||
return record, err
|
||||
}
|
||||
// Unknown capability names fall through to the planner, which owns
|
||||
// the canonical capability-not-found refusal.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/relay"
|
||||
@@ -778,3 +779,45 @@ func TestPatrolTypedActionJourneyDetectPlanApproveExecuteVerifyAndReconcile(t *t
|
||||
t.Fatal("agent-attested lifecycle did not publish an honest terminal mobile notification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatrolActionBrokerPlanDoesNotProgressAutomaticPolicy(t *testing.T) {
|
||||
h, executor := newPatrolBrokerTestHandlers(t, unified.ApprovalAdmin)
|
||||
configurePatrolAutoAuthorization(t, h)
|
||||
broker := NewPatrolActionBroker("default", h, func(context.Context, string) (PatrolActionPolicySnapshot, error) {
|
||||
return PatrolActionPolicySnapshot{EffectiveAutonomyLevel: "assisted"}, nil
|
||||
}).(*patrolActionBroker)
|
||||
disposition, err := broker.Plan(context.Background(), patrolTestProposal())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if disposition.State != string(unified.ActionStatePending) || executor.calls != 0 {
|
||||
t.Fatalf("plan progressed action: state=%s calls=%d", disposition.State, executor.calls)
|
||||
}
|
||||
record, found, err := broker.lifecycle().Get("default", disposition.ActionID)
|
||||
if err != nil || !found || len(record.Approvals) != 0 || record.Result != nil {
|
||||
t.Fatalf("plan carried execution authority: found=%v err=%v record=%#v", found, err, record)
|
||||
}
|
||||
accepted, err := broker.Submit(context.Background(), patrolTestProposal())
|
||||
if err != nil || accepted.ActionID != disposition.ActionID || executor.calls != 1 {
|
||||
t.Fatalf("core policy continuation: disposition=%#v calls=%d err=%v", accepted, executor.calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatrolActionBrokerPlanningReplaySurvivesLostRegistry(t *testing.T) {
|
||||
h, executor := newPatrolBrokerTestHandlers(t, unified.ApprovalAdmin)
|
||||
broker := NewPatrolActionBroker("default", h).(*patrolActionBroker)
|
||||
lifecycle := broker.lifecycle()
|
||||
broker.lifecycle = func() *actionlifecycle.Service { return lifecycle }
|
||||
first, err := broker.Plan(context.Background(), patrolTestProposal())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lifecycle.Registry = func(string) (*unified.ResourceRegistry, error) { return nil, errors.New("registry unavailable") }
|
||||
again, err := broker.Plan(context.Background(), patrolTestProposal())
|
||||
if err != nil || again.ActionID != first.ActionID || again.Plan.PlanHash != first.Plan.PlanHash {
|
||||
t.Fatalf("replay lost existing plan: %+v %v", again, err)
|
||||
}
|
||||
if executor.calls != 0 {
|
||||
t.Fatal("planning replay executed infrastructure")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,7 +594,12 @@ func approvalPlanRequestToInfo(plan *unifiedresources.ActionPlan) *aicontracts.A
|
||||
return nil
|
||||
}
|
||||
policyDecision, _ := json.Marshal(plan.PolicyDecision)
|
||||
var requirement json.RawMessage
|
||||
if plan.ApprovalRequirement.Version != 0 {
|
||||
requirement, _ = json.Marshal(plan.ApprovalRequirement)
|
||||
}
|
||||
return &aicontracts.ActionPlanInfo{
|
||||
ApprovalRequirement: requirement,
|
||||
ActionID: plan.ActionID,
|
||||
RequestID: plan.RequestID,
|
||||
Allowed: plan.Allowed,
|
||||
@@ -636,13 +641,33 @@ func approvalPlanInfoToRequest(plan *aicontracts.ActionPlanInfo) (*unifiedresour
|
||||
return nil, fmt.Errorf("invalid canonical action policy decision: %w", err)
|
||||
}
|
||||
}
|
||||
requirement := policyDecision.ApprovalRequirement
|
||||
if len(plan.ApprovalRequirement) > 0 {
|
||||
var supplied *unifiedresources.ApprovalRequirement
|
||||
decoder := json.NewDecoder(bytes.NewReader(plan.ApprovalRequirement))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&supplied); err != nil || supplied == nil {
|
||||
return nil, fmt.Errorf("invalid canonical approval requirement")
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return nil, fmt.Errorf("invalid canonical approval requirement: trailing content")
|
||||
}
|
||||
if err := unifiedresources.ValidateApprovalRequirement(*supplied, unifiedresources.ActionApprovalLevel(plan.ApprovalPolicy)); err != nil {
|
||||
return nil, fmt.Errorf("invalid canonical approval requirement: %w", err)
|
||||
}
|
||||
if policyDecision.Version != 0 && *supplied != policyDecision.ApprovalRequirement {
|
||||
return nil, fmt.Errorf("canonical approval requirement disagrees with policy provenance")
|
||||
}
|
||||
requirement = *supplied
|
||||
}
|
||||
|
||||
converted := &unifiedresources.ActionPlan{
|
||||
ActionID: plan.ActionID,
|
||||
RequestID: plan.RequestID,
|
||||
Allowed: plan.Allowed,
|
||||
RequiresApproval: plan.RequiresApproval,
|
||||
ApprovalPolicy: unifiedresources.ActionApprovalLevel(plan.ApprovalPolicy),
|
||||
ApprovalRequirement: policyDecision.ApprovalRequirement,
|
||||
ApprovalRequirement: requirement,
|
||||
PredictedBlastRadius: append([]string(nil), plan.PredictedBlastRadius...),
|
||||
RollbackAvailable: plan.RollbackAvailable,
|
||||
Message: plan.Message,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package unifiedresources
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ActionRequestReplayMatches compares intent, not the resource or policy snapshot
|
||||
// used to produce a plan. A replay never refreshes an immutable accepted plan.
|
||||
// Compare supplied intent without redacting it into an apparent match. If the
|
||||
// stored request lost information to redaction, equality is unknown and refuses.
|
||||
func ActionRequestReplayMatches(record ActionAuditRecord, req ActionRequest, origin *ActionOrigin) bool {
|
||||
normalize := func(r ActionRequest) ActionRequest {
|
||||
r.RequestID = strings.TrimSpace(r.RequestID)
|
||||
r.ResourceID = CanonicalResourceID(r.ResourceID)
|
||||
r.CapabilityName = strings.TrimSpace(r.CapabilityName)
|
||||
r.Reason = strings.TrimSpace(r.Reason)
|
||||
r.Actor = NormalizeActionActor(r.Actor)
|
||||
r.RequestedBy = r.Actor.SubjectID
|
||||
return r
|
||||
}
|
||||
existing, replay := normalize(record.Request), normalize(req)
|
||||
return canonicalActionIdentityJSONEqual(existing, replay) &&
|
||||
canonicalActionIdentityJSONEqual(NormalizeActionOrigin(record.Origin), NormalizeActionOrigin(origin))
|
||||
}
|
||||
|
||||
func hasActionRequestIdentity(req ActionRequest) bool {
|
||||
return strings.TrimSpace(req.RequestID) != "" && ValidateActionActor(req.Actor) == nil
|
||||
}
|
||||
|
||||
// GetActionAuditByRequest returns the already accepted action for this trusted
|
||||
// actor and request ID. Conflicting intent or legacy duplicate records fail
|
||||
// closed. Unbound legacy requests retain their existing action-ID semantics.
|
||||
func (s *SQLiteResourceStore) GetActionAuditByRequest(req ActionRequest, origin *ActionOrigin) (ActionAuditRecord, bool, error) {
|
||||
return getActionAuditByRequestFrom(s.db, req, origin, "")
|
||||
}
|
||||
|
||||
type actionRequestQuerier interface {
|
||||
Query(string, ...any) (*sql.Rows, error)
|
||||
}
|
||||
|
||||
func getActionAuditByRequestFrom(db actionRequestQuerier, req ActionRequest, origin *ActionOrigin, excludeID string) (ActionAuditRecord, bool, error) {
|
||||
if !hasActionRequestIdentity(req) {
|
||||
return ActionAuditRecord{}, false, nil
|
||||
}
|
||||
actor := NormalizeActionActor(req.Actor)
|
||||
rows, err := db.Query(`SELECT id, action_id, request_id, created_at, updated_at, state, decision_revision, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json
|
||||
FROM action_audits WHERE request_id=? AND id<>? AND json_valid(request_json)
|
||||
AND json_extract(request_json,'$.actor.subjectId')=?
|
||||
AND json_extract(request_json,'$.actor.kind')=?
|
||||
AND json_extract(request_json,'$.actor.credentialId')=?
|
||||
AND json_extract(request_json,'$.actor.orgId')=? LIMIT 2`, strings.TrimSpace(req.RequestID), excludeID, actor.SubjectID, string(actor.Kind), actor.CredentialID, actor.OrgID)
|
||||
if err != nil {
|
||||
return ActionAuditRecord{}, false, fmt.Errorf("read action request identity: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var current ActionAuditRecord
|
||||
found := false
|
||||
for rows.Next() {
|
||||
record, err := scanActionAuditRecord(rows)
|
||||
if err != nil {
|
||||
return ActionAuditRecord{}, false, err
|
||||
}
|
||||
if found {
|
||||
return ActionAuditRecord{}, false, ErrActionIdentityConflict
|
||||
}
|
||||
current, found = record, true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return ActionAuditRecord{}, false, err
|
||||
}
|
||||
if found && !ActionRequestReplayMatches(current, req, origin) {
|
||||
return current, true, ErrActionIdentityConflict
|
||||
}
|
||||
return current, found, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetActionAuditByRequest(req ActionRequest, origin *ActionOrigin) (ActionAuditRecord, bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.actionAuditByRequestLocked(req, origin)
|
||||
}
|
||||
|
||||
func (m *MemoryStore) actionAuditByRequestLocked(req ActionRequest, origin *ActionOrigin) (ActionAuditRecord, bool, error) {
|
||||
if !hasActionRequestIdentity(req) {
|
||||
return ActionAuditRecord{}, false, nil
|
||||
}
|
||||
var current ActionAuditRecord
|
||||
found := false
|
||||
for _, record := range m.actionAudits {
|
||||
if strings.TrimSpace(record.Request.RequestID) != strings.TrimSpace(req.RequestID) || !ActionActorsEqual(record.Request.Actor, req.Actor) {
|
||||
continue
|
||||
}
|
||||
if found {
|
||||
return ActionAuditRecord{}, false, ErrActionIdentityConflict
|
||||
}
|
||||
current, found = cloneActionAuditRecordForRead(record), true
|
||||
}
|
||||
if found && !ActionRequestReplayMatches(current, req, origin) {
|
||||
return current, true, ErrActionIdentityConflict
|
||||
}
|
||||
return current, found, nil
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package unifiedresources
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestActionRequestIdentityAcrossStores(t *testing.T) {
|
||||
for _, kind := range []string{"memory", "sqlite"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
var store ResourceStore = NewMemoryStore()
|
||||
if kind == "sqlite" {
|
||||
s, err := NewSQLiteResourceStore(t.TempDir(), "default")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
store = s
|
||||
}
|
||||
original := atomicLifecycleTestRecord("accepted", ActionStatePending)
|
||||
if _, created, err := store.CreateActionAudit(original, atomicLifecycleInitialEvents(original)); err != nil || !created {
|
||||
t.Fatalf("initial created=%v err=%v", created, err)
|
||||
}
|
||||
replay := original
|
||||
replay.ID = "different-snapshot"
|
||||
replay.Plan.ActionID = replay.ID
|
||||
replay.Plan.PlanHash = "different-plan-hash"
|
||||
replay.Plan.PolicyVersion = "changed-policy"
|
||||
replay.Plan.ResourceVersion = "changed-resource"
|
||||
got, created, err := store.CreateActionAudit(replay, atomicLifecycleInitialEvents(replay))
|
||||
if err != nil || created || got.ID != original.ID || got.Plan.PlanHash != original.Plan.PlanHash {
|
||||
t.Fatalf("replay created=%v err=%v got=%#v", created, err, got)
|
||||
}
|
||||
if _, found, err := store.GetActionAudit(replay.ID); err != nil || found {
|
||||
t.Fatalf("tentative replay persisted: found=%v err=%v", found, err)
|
||||
}
|
||||
changed := replay
|
||||
changed.Request.Reason = "a different requested change"
|
||||
if _, _, err := store.CreateActionAudit(changed, nil); !errors.Is(err, ErrActionIdentityConflict) {
|
||||
t.Fatalf("conflicting request: %v", err)
|
||||
}
|
||||
changed = original
|
||||
changed.Origin = &ActionOrigin{Surface: "patrol", FindingID: "other", InvestigationID: "other", ProposalID: "other"}
|
||||
if _, _, err := store.GetActionAuditByRequest(changed.Request, changed.Origin); !errors.Is(err, ErrActionIdentityConflict) {
|
||||
t.Fatalf("conflicting origin: %v", err)
|
||||
}
|
||||
isolated := replay
|
||||
isolated.Request.Actor.CredentialID = "another-credential"
|
||||
if _, created, err := store.CreateActionAudit(isolated, atomicLifecycleInitialEvents(isolated)); err != nil || !created {
|
||||
t.Fatalf("actor isolation created=%v err=%v", created, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRequestIdentityConcurrentSQLiteInstancesAndReopen(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
first, err := NewSQLiteResourceStore(dir, "default")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := NewSQLiteResourceStore(dir, "default")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := atomicLifecycleTestRecord("candidate-a", ActionStatePending)
|
||||
alternate := original
|
||||
alternate.ID = "candidate-b"
|
||||
alternate.Plan.ActionID = alternate.ID
|
||||
alternate.Plan.PlanHash = "changed-snapshot"
|
||||
type result struct {
|
||||
record ActionAuditRecord
|
||||
created bool
|
||||
err error
|
||||
}
|
||||
results := make(chan result, 2)
|
||||
start := make(chan struct{})
|
||||
for i, store := range []*SQLiteResourceStore{first, second} {
|
||||
record := original
|
||||
if i == 1 {
|
||||
record = alternate
|
||||
}
|
||||
go func() {
|
||||
<-start
|
||||
r, c, e := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record))
|
||||
results <- result{r, c, e}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
a, b := <-results, <-results
|
||||
if a.err != nil || b.err != nil || a.created == b.created || a.record.ID != b.record.ID {
|
||||
t.Fatalf("concurrent results: %#v %#v", a, b)
|
||||
}
|
||||
first.Close()
|
||||
second.Close()
|
||||
reopened, err := NewSQLiteResourceStore(dir, "default")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
record, found, err := reopened.GetActionAuditByRequest(original.Request, original.Origin)
|
||||
if err != nil || !found || record.ID != a.record.ID {
|
||||
t.Fatalf("reopened identity: found=%v err=%v id=%s", found, err, record.ID)
|
||||
}
|
||||
var count int
|
||||
if err := reopened.db.QueryRow("SELECT count(*) FROM action_audits").Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("persisted actions=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Existing databases can contain several action IDs for a single caller key.
|
||||
// Do not arbitrarily select one, delete history, or add a third accepted plan.
|
||||
func TestActionRequestIdentityLegacyDuplicatesRefuse(t *testing.T) {
|
||||
store, err := NewSQLiteResourceStore(t.TempDir(), "default")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
first := atomicLifecycleTestRecord("legacy-first", ActionStatePending)
|
||||
second := first
|
||||
second.ID = "legacy-second"
|
||||
second.Plan.ActionID = second.ID
|
||||
second.Plan.PlanHash = "second-plan"
|
||||
for _, record := range []ActionAuditRecord{first, second} {
|
||||
if _, err := insertActionAuditSQL(store.db, record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, found, err := store.GetActionAuditByRequest(first.Request, first.Origin); !errors.Is(err, ErrActionIdentityConflict) || found {
|
||||
t.Fatalf("ambiguous legacy lookup found=%v err=%v", found, err)
|
||||
}
|
||||
third := first
|
||||
third.ID = "attempted-third"
|
||||
third.Plan.ActionID = third.ID
|
||||
third.Plan.PlanHash = "third-plan"
|
||||
if _, created, err := store.CreateActionAudit(third, nil); !errors.Is(err, ErrActionIdentityConflict) || created {
|
||||
t.Fatalf("ambiguous creation created=%v err=%v", created, err)
|
||||
}
|
||||
var count int
|
||||
if err := store.db.QueryRow("SELECT count(*) FROM action_audits").Scan(&count); err != nil || count != 2 {
|
||||
t.Fatalf("legacy history changed: rows=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRequestIdentityDoesNotEquateRedactedInputs(t *testing.T) {
|
||||
for _, kind := range []string{"memory", "sqlite"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
var store ResourceStore = NewMemoryStore()
|
||||
if kind == "sqlite" {
|
||||
s, err := NewSQLiteResourceStore(t.TempDir(), "default")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
store = s
|
||||
}
|
||||
original := atomicLifecycleTestRecord("redacted-original", ActionStatePending)
|
||||
original.Request.Reason = "operator pasted token=test-first-value"
|
||||
if _, created, err := store.CreateActionAudit(original, nil); err != nil || !created {
|
||||
t.Fatalf("initial created=%v err=%v", created, err)
|
||||
}
|
||||
changed := original
|
||||
changed.ID = "redacted-replacement"
|
||||
changed.Plan.ActionID = changed.ID
|
||||
changed.Plan.PlanHash = "changed-hash"
|
||||
changed.Request.Reason = "operator pasted token=test-second-value"
|
||||
if RedactAuditText(original.Request.Reason) != RedactAuditText(changed.Request.Reason) {
|
||||
t.Fatal("fixture must collide only after redaction")
|
||||
}
|
||||
if _, created, err := store.CreateActionAudit(changed, nil); !errors.Is(err, ErrActionIdentityConflict) || created {
|
||||
t.Fatalf("different redacted inputs treated as replay: created=%v err=%v", created, err)
|
||||
}
|
||||
if _, _, err := store.GetActionAuditByRequest(original.Request, original.Origin); !errors.Is(err, ErrActionIdentityConflict) {
|
||||
t.Fatalf("lost original intent must remain unverifiable: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19,13 +19,21 @@ func metricsFromProxmoxNode(node models.Node) *ResourceMetrics {
|
||||
percent := percentFromUsage(node.Memory.Usage)
|
||||
metrics.Memory = &MetricValue{Used: &node.Memory.Used, Total: &node.Memory.Total, Percent: percent, Unit: "bytes", Source: SourceProxmox}
|
||||
}
|
||||
if node.Disk.Total > 0 {
|
||||
if hasObservedDiskUsage(node.Disk) {
|
||||
percent := percentFromUsage(node.Disk.Usage)
|
||||
metrics.Disk = &MetricValue{Used: &node.Disk.Used, Total: &node.Disk.Total, Percent: percent, Unit: "bytes", Source: SourceProxmox}
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
// hasObservedDiskUsage prevents unavailable legacy disk observations from
|
||||
// becoming a negative percentage or a clamped, apparently idle measurement in
|
||||
// the canonical resource model. Capacity alone does not establish used space.
|
||||
func hasObservedDiskUsage(disk models.Disk) bool {
|
||||
return disk.Total > 0 && disk.Used >= 0 && disk.Used <= disk.Total &&
|
||||
disk.Usage >= 0 && disk.Usage <= 100 && !math.IsNaN(disk.Usage) && !math.IsInf(disk.Usage, 0)
|
||||
}
|
||||
|
||||
func metricsFromHost(host models.Host) *ResourceMetrics {
|
||||
return buildHostMetricPayload(
|
||||
host.CPUUsage,
|
||||
@@ -95,7 +103,7 @@ func buildHostMetricPayload(
|
||||
}
|
||||
if len(disks) > 0 {
|
||||
disk := disks[0]
|
||||
if disk.Total > 0 {
|
||||
if hasObservedDiskUsage(disk) {
|
||||
percent := percentFromReportedPercent(disk.Usage)
|
||||
metrics.Disk = &MetricValue{Used: &disk.Used, Total: &disk.Total, Percent: percent, Unit: "bytes", Source: source}
|
||||
}
|
||||
@@ -193,7 +201,7 @@ func buildVMMetricPayload(
|
||||
percent := percentFromUsage(memory.Usage)
|
||||
metrics.Memory = &MetricValue{Used: &memory.Used, Total: &memory.Total, Percent: percent, Unit: "bytes", Source: source}
|
||||
}
|
||||
if disk.Total > 0 {
|
||||
if hasObservedDiskUsage(disk) {
|
||||
percent := percentFromUsage(disk.Usage)
|
||||
metrics.Disk = &MetricValue{Used: &disk.Used, Total: &disk.Total, Percent: percent, Unit: "bytes", Source: source}
|
||||
}
|
||||
@@ -347,7 +355,7 @@ func metricsFromKubernetesCluster(cluster models.KubernetesCluster, linkedHosts
|
||||
|
||||
if len(host.Disks) > 0 {
|
||||
disk := host.Disks[0]
|
||||
if disk.Total > 0 {
|
||||
if hasObservedDiskUsage(disk) {
|
||||
diskTotal += disk.Total
|
||||
diskUsed += disk.Used
|
||||
}
|
||||
|
||||
@@ -518,3 +518,29 @@ func TestMetricsFromDockerContainerMockFallbackSynthesizesIO(t *testing.T) {
|
||||
t.Fatalf("expected synthesized diskWrite > 0, got %+v", metrics.DiskWrite)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalDiskUsagePreservesUnavailableAndObservedZero(t *testing.T) {
|
||||
for _, usage := range []float64{-1, math.NaN(), math.Inf(1), 101, 0, 25} {
|
||||
disk := models.Disk{Total: 1024, Usage: usage}
|
||||
for name, metrics := range map[string]*ResourceMetrics{
|
||||
"vm": metricsFromVM(models.VM{Disk: disk}),
|
||||
"container": metricsFromContainer(models.Container{Disk: disk}),
|
||||
"node": metricsFromProxmoxNode(models.Node{Disk: disk}),
|
||||
"host": metricsFromHost(models.Host{Disks: []models.Disk{disk}}),
|
||||
"docker host": metricsFromDockerHost(models.DockerHost{Disks: []models.Disk{disk}}),
|
||||
} {
|
||||
valid := usage == 0 || usage == 25
|
||||
if (metrics.Disk != nil) != valid {
|
||||
t.Fatalf("%s usage %v produced disk metric %+v", name, usage, metrics.Disk)
|
||||
}
|
||||
if valid && metrics.Disk.Percent != usage {
|
||||
t.Fatalf("%s lost observed usage %v: %+v", name, usage, metrics.Disk)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, used := range []int64{-1, 1025} {
|
||||
if metricsFromVM(models.VM{Disk: models.Disk{Total: 1024, Used: used}}).Disk != nil {
|
||||
t.Fatalf("contradictory disk usage %d was projected", used)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +286,8 @@ func ResourcePolicyReference(value string, hints ...ResourceRedactionHint) Resou
|
||||
// value-based so alert, prompt, and export summaries cannot leak the same raw
|
||||
// hostname, IP, alias, platform ID, or path through nearby free-form text.
|
||||
func ResourcePolicyRedactedText(value string, resource Resource) string {
|
||||
value = strings.TrimSpace(value)
|
||||
// Text may be a streamed delta. Whitespace is content, not presentation
|
||||
// padding, and must survive redaction even when no policy applies.
|
||||
if value == "" || resource.Policy == nil {
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package unifiedresources
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResourcePolicyRedactedTextPreservesWhitespace(t *testing.T) {
|
||||
resource := Resource{Name: "private-host", Policy: &ResourcePolicy{Routing: ResourceRoutingPolicy{Redact: []ResourceRedactionHint{ResourceRedactionHostname}}}}
|
||||
for _, value := range []string{" ", "\n\n", " Summary ", "\n private-host \n"} {
|
||||
want := value
|
||||
if value == "\n private-host \n" {
|
||||
want = "\n " + ResourcePolicyRedactedLabel + " \n"
|
||||
}
|
||||
if got := ResourcePolicyRedactedText(value, resource); got != want {
|
||||
t.Errorf("redact(%q) = %q, want %q", value, got, want)
|
||||
}
|
||||
if got := ResourcePolicyRedactedText(value, Resource{}); got != value {
|
||||
t.Errorf("without policy: %q != %q", got, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ type ResourceStore interface {
|
||||
CreateActionAudit(record ActionAuditRecord, initialEvents []ActionLifecycleEvent) (ActionAuditRecord, bool, error)
|
||||
RecordActionAudit(record ActionAuditRecord) error
|
||||
GetActionAudit(actionID string) (ActionAuditRecord, bool, error)
|
||||
GetActionAuditByRequest(req ActionRequest, origin *ActionOrigin) (ActionAuditRecord, bool, error)
|
||||
GetActionAudits(canonicalID string, since time.Time, limit int) ([]ActionAuditRecord, error)
|
||||
RecordActionDecision(record ActionAuditRecord, event ActionLifecycleEvent) error
|
||||
RecordActionExpiry(record ActionAuditRecord, event ActionLifecycleEvent) error
|
||||
@@ -495,6 +496,7 @@ func (s *SQLiteResourceStore) initSchema() error {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_action_audits_canonical_created ON action_audits(canonical_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_action_audits_action_id ON action_audits(action_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_action_audits_request_id ON action_audits(request_id);
|
||||
CREATE TABLE IF NOT EXISTS action_lifecycle_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
action_id TEXT NOT NULL,
|
||||
@@ -2223,6 +2225,7 @@ func normalizeActionAuditCreation(record ActionAuditRecord, initialEvents []Acti
|
||||
}
|
||||
|
||||
func (s *SQLiteResourceStore) CreateActionAudit(record ActionAuditRecord, initialEvents []ActionLifecycleEvent) (ActionAuditRecord, bool, error) {
|
||||
requestIntent := record.Request
|
||||
record, events, err := normalizeActionAuditCreation(record, initialEvents)
|
||||
if err != nil {
|
||||
return ActionAuditRecord{}, false, err
|
||||
@@ -2245,6 +2248,16 @@ func (s *SQLiteResourceStore) CreateActionAudit(record ActionAuditRecord, initia
|
||||
if err != nil {
|
||||
return ActionAuditRecord{}, false, err
|
||||
}
|
||||
// The insert acquires SQLite's writer lock before request identity is read.
|
||||
// Concurrent store instances therefore cannot both accept different plans
|
||||
// for one request. A replay rolls back this tentative row and its transaction.
|
||||
excludeID := ""
|
||||
if created {
|
||||
excludeID = record.ID
|
||||
}
|
||||
if current, found, err := getActionAuditByRequestFrom(tx, requestIntent, record.Origin, excludeID); err != nil || found {
|
||||
return current, false, err
|
||||
}
|
||||
if !created {
|
||||
current, found, err := getActionAuditFrom(tx, record.ID)
|
||||
if err != nil {
|
||||
@@ -3736,6 +3749,7 @@ func changeMatchesResource(change ResourceChange, canonicalIDs []string, include
|
||||
}
|
||||
|
||||
func (m *MemoryStore) CreateActionAudit(record ActionAuditRecord, initialEvents []ActionLifecycleEvent) (ActionAuditRecord, bool, error) {
|
||||
requestIntent := record.Request
|
||||
record, events, err := normalizeActionAuditCreation(record, initialEvents)
|
||||
if err != nil {
|
||||
return ActionAuditRecord{}, false, err
|
||||
@@ -3747,6 +3761,9 @@ func (m *MemoryStore) CreateActionAudit(record ActionAuditRecord, initialEvents
|
||||
// path that production never sees.
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if current, found, err := m.actionAuditByRequestLocked(requestIntent, record.Origin); err != nil || found {
|
||||
return current, false, err
|
||||
}
|
||||
for i := range m.actionAudits {
|
||||
if m.actionAudits[i].ID == record.ID {
|
||||
current := cloneActionAuditRecordForRead(m.actionAudits[i])
|
||||
|
||||
@@ -85,12 +85,16 @@ type ActionDisposition struct {
|
||||
// the investigation stores only this reference, while proposal parameters
|
||||
// and lifecycle state live in the canonical action audit.
|
||||
type ActionReference struct {
|
||||
ActionID string `json:"action_id"`
|
||||
ProposalID string `json:"proposal_id,omitempty"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
CapabilityName string `json:"capability_name"`
|
||||
State string `json:"state"`
|
||||
Plan ActionPlanInfo `json:"plan"`
|
||||
// CausalResourceID is attributed by the model, not established by action acceptance.
|
||||
CausalResourceID string `json:"causal_resource_id,omitempty"`
|
||||
ActionResultV2 json.RawMessage `json:"action_result_v2,omitempty"`
|
||||
VerificationStatus string `json:"verification_status,omitempty"`
|
||||
ActionID string `json:"action_id"`
|
||||
ProposalID string `json:"proposal_id,omitempty"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
CapabilityName string `json:"capability_name"`
|
||||
State string `json:"state"`
|
||||
Plan ActionPlanInfo `json:"plan"`
|
||||
}
|
||||
|
||||
// CloneActionReference returns an immutable deep copy suitable for crossing
|
||||
@@ -100,6 +104,9 @@ func CloneActionReference(reference *ActionReference) *ActionReference {
|
||||
return nil
|
||||
}
|
||||
clone := *reference
|
||||
clone.Plan.ApprovalRequirement = append(json.RawMessage(nil), reference.Plan.ApprovalRequirement...)
|
||||
clone.ActionResultV2 = append(json.RawMessage(nil), reference.ActionResultV2...)
|
||||
clone.Plan.PolicyDecision = append(json.RawMessage(nil), reference.Plan.PolicyDecision...)
|
||||
clone.Plan.PredictedBlastRadius = append([]string(nil), reference.Plan.PredictedBlastRadius...)
|
||||
if reference.Plan.Preflight != nil {
|
||||
preflight := *reference.Plan.Preflight
|
||||
|
||||
@@ -2,7 +2,6 @@ package aicontracts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -273,27 +272,6 @@ func TestActionReferenceIsAdditiveOnInvestigationShapes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchestratorInvestigationErrorPreservesBothFailureChannels(t *testing.T) {
|
||||
runFailure := errors.New("provider unavailable")
|
||||
err := NewOrchestratorInvestigationError(runFailure, ErrInvestigationProposalAmbiguous)
|
||||
if !errors.Is(err, runFailure) {
|
||||
t.Fatal("joined investigation error lost the runtime failure")
|
||||
}
|
||||
if !errors.Is(err, ErrInvestigationProposalAmbiguous) {
|
||||
t.Fatal("joined investigation error lost the proposal failure")
|
||||
}
|
||||
var investigationErr *OrchestratorInvestigationError
|
||||
if !errors.As(err, &investigationErr) {
|
||||
t.Fatalf("error type = %T, want *OrchestratorInvestigationError", err)
|
||||
}
|
||||
if investigationErr.RunFailure() != runFailure {
|
||||
t.Fatalf("RunFailure() = %v, want %v", investigationErr.RunFailure(), runFailure)
|
||||
}
|
||||
if investigationErr.ProposalFailure() != ErrInvestigationProposalAmbiguous {
|
||||
t.Fatalf("ProposalFailure() = %v", investigationErr.ProposalFailure())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchestratorActionBrokerIsProposeOnly(t *testing.T) {
|
||||
// The broker seam must never grow decision or execution authority:
|
||||
// enterprise investigation code proposes, the canonical core
|
||||
|
||||
@@ -41,18 +41,21 @@ type ApprovalInfo struct {
|
||||
}
|
||||
|
||||
type ActionPlanInfo struct {
|
||||
ActionID string `json:"actionId,omitempty"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
Allowed bool `json:"allowed"`
|
||||
RequiresApproval bool `json:"requiresApproval"`
|
||||
ApprovalPolicy string `json:"approvalPolicy,omitempty"`
|
||||
PredictedBlastRadius []string `json:"predictedBlastRadius,omitempty"`
|
||||
RollbackAvailable bool `json:"rollbackAvailable"`
|
||||
Message string `json:"message,omitempty"`
|
||||
PlannedAt time.Time `json:"plannedAt,omitempty"`
|
||||
ExpiresAt time.Time `json:"expiresAt,omitempty"`
|
||||
ResourceVersion string `json:"resourceVersion,omitempty"`
|
||||
PolicyVersion string `json:"policyVersion,omitempty"`
|
||||
// ApprovalRequirement preserves the canonical outer requirement even when
|
||||
// historical policy provenance is unavailable. It defines no parallel enum.
|
||||
ApprovalRequirement json.RawMessage `json:"approvalRequirement,omitempty"`
|
||||
ActionID string `json:"actionId,omitempty"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
Allowed bool `json:"allowed"`
|
||||
RequiresApproval bool `json:"requiresApproval"`
|
||||
ApprovalPolicy string `json:"approvalPolicy,omitempty"`
|
||||
PredictedBlastRadius []string `json:"predictedBlastRadius,omitempty"`
|
||||
RollbackAvailable bool `json:"rollbackAvailable"`
|
||||
Message string `json:"message,omitempty"`
|
||||
PlannedAt time.Time `json:"plannedAt,omitempty"`
|
||||
ExpiresAt time.Time `json:"expiresAt,omitempty"`
|
||||
ResourceVersion string `json:"resourceVersion,omitempty"`
|
||||
PolicyVersion string `json:"policyVersion,omitempty"`
|
||||
// PolicyDecision carries the canonical internal/unifiedresources object
|
||||
// without defining a second cross-boundary policy enum vocabulary.
|
||||
PolicyDecision json.RawMessage `json:"policyDecision,omitempty"`
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package aicontracts
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -12,92 +10,6 @@ import (
|
||||
// ProposalFailure. These methods are deliberately nil-safe so a typed nil
|
||||
// pointer flowing through an error chain does not panic; the coverage gaps
|
||||
// were exactly these nil arms.
|
||||
func Test_w0716_contracts_OrchestratorInvestigationError_NilReceiverDefensiveBranches(t *testing.T) {
|
||||
// Typed nil pointer of *OrchestratorInvestigationError.
|
||||
var nilTypedErr *OrchestratorInvestigationError = (*OrchestratorInvestigationError)(nil)
|
||||
|
||||
if got := nilTypedErr.Error(); got != "" {
|
||||
t.Fatalf("nil receiver Error() = %q, want %q", got, "")
|
||||
}
|
||||
if got := nilTypedErr.Unwrap(); got != nil {
|
||||
t.Fatalf("nil receiver Unwrap() = %v, want nil", got)
|
||||
}
|
||||
if got := nilTypedErr.RunFailure(); got != nil {
|
||||
t.Fatalf("nil receiver RunFailure() = %v, want nil", got)
|
||||
}
|
||||
if got := nilTypedErr.ProposalFailure(); got != nil {
|
||||
t.Fatalf("nil receiver ProposalFailure() = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Test_w0716_contracts_OrchestratorInvestigationError_PopulatedErrorAndUnwrap
|
||||
// covers the populated arms of Error() (0% covered — never called directly by
|
||||
// existing tests) and Unwrap() (populated arm), asserting the joined string
|
||||
// carries both failure channels and Unwrap returns exactly the two errors.
|
||||
func Test_w0716_contracts_OrchestratorInvestigationError_PopulatedErrorAndUnwrap(t *testing.T) {
|
||||
runFailure := errors.New("provider unavailable")
|
||||
err := NewOrchestratorInvestigationError(runFailure, ErrInvestigationProposalAmbiguous)
|
||||
oie, ok := err.(*OrchestratorInvestigationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *OrchestratorInvestigationError, got %T", err)
|
||||
}
|
||||
|
||||
msg := oie.Error()
|
||||
if !strings.Contains(msg, "provider unavailable") {
|
||||
t.Fatalf("Error() missing run failure message: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, ErrInvestigationProposalAmbiguous.Error()) {
|
||||
t.Fatalf("Error() missing proposal failure message: %q", msg)
|
||||
}
|
||||
|
||||
unwrapped := oie.Unwrap()
|
||||
if len(unwrapped) != 2 {
|
||||
t.Fatalf("Unwrap() returned %d errors, want 2", len(unwrapped))
|
||||
}
|
||||
if unwrapped[0] != runFailure {
|
||||
t.Fatalf("Unwrap()[0] = %v, want %v", unwrapped[0], runFailure)
|
||||
}
|
||||
if unwrapped[1] != ErrInvestigationProposalAmbiguous {
|
||||
t.Fatalf("Unwrap()[1] = %v, want %v", unwrapped[1], ErrInvestigationProposalAmbiguous)
|
||||
}
|
||||
}
|
||||
|
||||
// Test_w0716_contracts_NewOrchestratorInvestigationError_NilArm covers the
|
||||
// uncovered both-nil arm of NewOrchestratorInvestigationError, which must
|
||||
// return a literal nil (not a typed nil pointer) so errors.Is/!=nil checks
|
||||
// behave correctly downstream.
|
||||
func Test_w0716_contracts_NewOrchestratorInvestigationError_NilArm(t *testing.T) {
|
||||
if got := NewOrchestratorInvestigationError(nil, nil); got != nil {
|
||||
t.Fatalf("NewOrchestratorInvestigationError(nil, nil) = %#v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Test_w0716_contracts_NewOrchestratorInvestigationError_PartialFailure
|
||||
// covers the constructor's populated arm when only one channel is set,
|
||||
// confirming the other accessor returns nil and the build path works for a
|
||||
// run-only failure (mirrors a completed-but-proposal-rejected outcome).
|
||||
func Test_w0716_contracts_NewOrchestratorInvestigationError_PartialFailure(t *testing.T) {
|
||||
runFailure := errors.New("provider unavailable")
|
||||
err := NewOrchestratorInvestigationError(runFailure, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error when only runFailure set")
|
||||
}
|
||||
oie, ok := err.(*OrchestratorInvestigationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *OrchestratorInvestigationError, got %T", err)
|
||||
}
|
||||
if oie.RunFailure() != runFailure {
|
||||
t.Fatalf("RunFailure() = %v, want %v", oie.RunFailure(), runFailure)
|
||||
}
|
||||
if oie.ProposalFailure() != nil {
|
||||
t.Fatalf("ProposalFailure() = %v, want nil", oie.ProposalFailure())
|
||||
}
|
||||
}
|
||||
|
||||
// Test_w0716_contracts_DefaultInvestigationConfig_FieldDefaults asserts every
|
||||
// default field value of the returned InvestigationConfig. The function is
|
||||
// pure and previously had 0% coverage; pinning each field guards the safety
|
||||
// ceilings and budgets against silent drift.
|
||||
func Test_w0716_contracts_DefaultInvestigationConfig_FieldDefaults(t *testing.T) {
|
||||
cfg := DefaultInvestigationConfig()
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package aicontracts
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
|
||||
@@ -61,74 +60,15 @@ type OrchestratorInvestigationRequest struct {
|
||||
// produced only by a completely successful run - ready for
|
||||
// OrchestratorActionBroker.Submit.
|
||||
type OrchestratorInvestigationResult struct {
|
||||
Content string `json:"content"`
|
||||
Proposal *ActionProposal `json:"proposal,omitempty"`
|
||||
FailedProposalAttempts int `json:"failed_proposal_attempts,omitempty"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
ModelTurns int `json:"model_turns"`
|
||||
EvidenceCalls int `json:"evidence_calls"`
|
||||
ToolCalls int `json:"tool_calls"`
|
||||
}
|
||||
|
||||
// Typed investigation proposal errors surfaced across the contract
|
||||
// boundary. Any of them means the run produced no actionable proposal.
|
||||
var (
|
||||
ErrInvestigationProposalAmbiguous = errors.New("ambiguous investigation result: multiple distinct action proposals were submitted")
|
||||
ErrInvestigationProposalIntegrity = errors.New("proposal integrity violation: one tool-use id submitted conflicting payloads")
|
||||
ErrInvestigationProposalAttemptsFailed = errors.New("investigation made proposal attempts but none validated")
|
||||
)
|
||||
|
||||
// OrchestratorInvestigationError preserves the independent runtime and
|
||||
// proposal-channel failures across the Pulse/Enterprise boundary. A proposal
|
||||
// failure may be handled as a completed needs-attention outcome only when
|
||||
// RunFailure is nil.
|
||||
type OrchestratorInvestigationError struct {
|
||||
runFailure error
|
||||
proposalFailure error
|
||||
}
|
||||
|
||||
// NewOrchestratorInvestigationError constructs the public cross-repo failure
|
||||
// without exposing mutable error fields.
|
||||
func NewOrchestratorInvestigationError(runFailure, proposalFailure error) error {
|
||||
if runFailure == nil && proposalFailure == nil {
|
||||
return nil
|
||||
}
|
||||
return &OrchestratorInvestigationError{
|
||||
runFailure: runFailure,
|
||||
proposalFailure: proposalFailure,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *OrchestratorInvestigationError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return errors.Join(e.runFailure, e.proposalFailure).Error()
|
||||
}
|
||||
|
||||
// Unwrap preserves errors.Is/errors.As behavior for both failure channels.
|
||||
func (e *OrchestratorInvestigationError) Unwrap() []error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return []error{e.runFailure, e.proposalFailure}
|
||||
}
|
||||
|
||||
// RunFailure returns the provider/runtime failure, if any.
|
||||
func (e *OrchestratorInvestigationError) RunFailure() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.runFailure
|
||||
}
|
||||
|
||||
// ProposalFailure returns the proposal-channel failure, if any.
|
||||
func (e *OrchestratorInvestigationError) ProposalFailure() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.proposalFailure
|
||||
// Action survives provider failure. Its presence is not diagnosis proof or execution authority.
|
||||
Action *ActionReference `json:"action,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Proposal *ActionProposal `json:"proposal,omitempty"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
ModelTurns int `json:"model_turns"`
|
||||
EvidenceCalls int `json:"evidence_calls"`
|
||||
ToolCalls int `json:"tool_calls"`
|
||||
}
|
||||
|
||||
// OrchestratorFindingsStore provides access to patrol findings for the orchestrator.
|
||||
|
||||
@@ -4465,6 +4465,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
match["verification_requirement"]["exact_files"],
|
||||
[
|
||||
"internal/monitoring/issue1595_collection_trust_test.go",
|
||||
"internal/unifiedresources/action_request_identity_test.go",
|
||||
"internal/unifiedresources/availability_link_test.go",
|
||||
"internal/unifiedresources/history_identity_test.go",
|
||||
"internal/unifiedresources/kubernetes_registry_test.go",
|
||||
@@ -4497,6 +4498,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
match["verification_requirement"]["exact_files"],
|
||||
[
|
||||
"internal/monitoring/issue1595_collection_trust_test.go",
|
||||
"internal/unifiedresources/action_request_identity_test.go",
|
||||
"internal/unifiedresources/availability_link_test.go",
|
||||
"internal/unifiedresources/history_identity_test.go",
|
||||
"internal/unifiedresources/kubernetes_registry_test.go",
|
||||
|
||||
Reference in New Issue
Block a user