mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 04:06:21 +00:00
9f43a22fb1
The patrol-main chat session was reused across every scheduled
Patrol run with no upper bound. After a month of runs the file
had grown to 16 MB / 3,593 messages, and every AddMessage
rewrote the whole file to disk — so the I/O cost per Patrol
run was scaling with total session age, not with the run's own
output. Across all chat sessions on this dev instance, the
ai_sessions directory hit 676 MB / 1,629 files.
The stateless-Patrol-input fix (commit 43760fb0d) stopped
loading the session back into the agentic loop, but Patrol
still wrote each run's messages to the session for the Pulse
Assistant sidebar's forensic view. That write path is what
this commit bounds.
ExecutePatrolStream now calls SessionStore.TrimMessages(200)
after each run, keeping roughly the last two runs' worth of
messages — enough for the sidebar to show recent activity, far
short of unbounded growth. The next Patrol run on a bloated
session will drop the historical 3,000+ messages down to 200
on its first write, so existing storage debt clears on its
own without a separate migration.
User-driven chat sessions are unaffected: TrimMessages with
keepMostRecent <= 0 is a no-op, and callers that want full
history retention simply don't call it. Only Patrol's
forensic session is capped.
The canonical Patrol forensic log is the PatrolRunRecord
history surfaced at /api/ai/patrol/runs — that's the durable
record with structured fields. The chat-session-shaped file
is a sidebar convenience, not the source of truth.
Three tests guard the boundary:
- TrimMessages keeps the most recent N (50 messages
trimmed to 10 → messages 40-49 remain)
- TrimMessages is a no-op below threshold (5 messages,
cap 200 → 5 messages remain)
- TrimMessages with non-positive keep is a no-op (3
messages, cap 0 or -5 → 3 messages remain)
ai-runtime contract updated.
1534 lines
44 KiB
Go
1534 lines
44 KiB
Go
package chat
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// SessionStore manages chat sessions persisted as JSON files
|
|
type SessionStore struct {
|
|
mu sync.RWMutex
|
|
dataDir string
|
|
|
|
// resolvedContexts holds per-session resolved resource contexts (in-memory only)
|
|
// These are NOT persisted - resources should be re-resolved after restart
|
|
// because infrastructure state may have changed
|
|
resolvedContexts map[string]*ResolvedContext
|
|
|
|
// sessionFSMs holds per-session workflow state machines (in-memory only)
|
|
// These track the RESOLVING -> READING -> WRITING -> VERIFYING workflow
|
|
// to ensure structural guarantees (must discover before write, verify after write)
|
|
sessionFSMs map[string]*SessionFSM
|
|
|
|
// sessionToolSets holds per-session tool allowlists (in-memory only).
|
|
// These keep tool availability stable across turns while allowing additive expansion.
|
|
sessionToolSets map[string]map[string]bool
|
|
|
|
// knowledgeAccumulators holds per-session knowledge accumulators (in-memory only).
|
|
// These extract and preserve key facts from tool results to prevent amnesia
|
|
// when old tool results are compacted from the conversation context.
|
|
knowledgeAccumulators map[string]*KnowledgeAccumulator
|
|
}
|
|
|
|
// sessionData is the on-disk format for a session
|
|
type sessionData struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Messages []Message `json:"messages"`
|
|
ModelContext *sessionModelContext `json:"model_context,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type sessionModelContext struct {
|
|
HandoffFindingID string `json:"handoff_finding_id,omitempty"`
|
|
HandoffContext string `json:"handoff_context,omitempty"`
|
|
HandoffResources []HandoffResource `json:"handoff_resources,omitempty"`
|
|
HandoffActions []HandoffAction `json:"handoff_actions,omitempty"`
|
|
HandoffMetadata HandoffMetadata `json:"handoff_metadata,omitempty"`
|
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
|
}
|
|
|
|
func normalizeHandoffResources(resources []HandoffResource) []HandoffResource {
|
|
if len(resources) == 0 {
|
|
return nil
|
|
}
|
|
|
|
normalized := make([]HandoffResource, 0, len(resources))
|
|
seen := make(map[string]struct{}, len(resources))
|
|
for _, resource := range resources {
|
|
resource.ID = strings.TrimSpace(resource.ID)
|
|
resource.Name = strings.TrimSpace(resource.Name)
|
|
resource.Type = strings.TrimSpace(resource.Type)
|
|
resource.Node = strings.TrimSpace(resource.Node)
|
|
if resource.ID == "" && resource.Name == "" {
|
|
continue
|
|
}
|
|
key := strings.ToLower(resource.Type + "\x00" + resource.ID + "\x00" + resource.Name + "\x00" + resource.Node)
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
normalized = append(normalized, resource)
|
|
}
|
|
if len(normalized) == 0 {
|
|
return nil
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func normalizeHandoffActions(actions []HandoffAction) []HandoffAction {
|
|
if len(actions) == 0 {
|
|
return nil
|
|
}
|
|
|
|
normalized := make([]HandoffAction, 0, len(actions))
|
|
seen := make(map[string]struct{}, len(actions))
|
|
for _, action := range actions {
|
|
action.FindingID = strings.TrimSpace(action.FindingID)
|
|
action.RecordID = strings.TrimSpace(action.RecordID)
|
|
action.ApprovalID = strings.TrimSpace(action.ApprovalID)
|
|
action.ApprovalStatus = strings.TrimSpace(action.ApprovalStatus)
|
|
action.ApprovalRequestedAt = strings.TrimSpace(action.ApprovalRequestedAt)
|
|
action.ApprovalExpiresAt = strings.TrimSpace(action.ApprovalExpiresAt)
|
|
action.ApprovalDecidedAt = strings.TrimSpace(action.ApprovalDecidedAt)
|
|
action.ActionID = strings.TrimSpace(action.ActionID)
|
|
action.ActionState = strings.TrimSpace(action.ActionState)
|
|
action.ActionUpdatedAt = strings.TrimSpace(action.ActionUpdatedAt)
|
|
action.ActionRequestedBy = strings.TrimSpace(action.ActionRequestedBy)
|
|
action.ActionCapability = strings.TrimSpace(action.ActionCapability)
|
|
action.ActionApprovalPolicy = strings.TrimSpace(action.ActionApprovalPolicy)
|
|
action.ActionPlanExpiresAt = strings.TrimSpace(action.ActionPlanExpiresAt)
|
|
action.ActionPlanMessage = strings.TrimSpace(action.ActionPlanMessage)
|
|
action.ActionPreflight = strings.TrimSpace(action.ActionPreflight)
|
|
action.ActionDryRunSummary = strings.TrimSpace(action.ActionDryRunSummary)
|
|
action.ActionResult = strings.TrimSpace(action.ActionResult)
|
|
action.FixID = strings.TrimSpace(action.FixID)
|
|
action.Description = strings.TrimSpace(action.Description)
|
|
action.RiskLevel = strings.TrimSpace(action.RiskLevel)
|
|
action.TargetHost = strings.TrimSpace(action.TargetHost)
|
|
action.TargetResourceID = strings.TrimSpace(action.TargetResourceID)
|
|
action.TargetResourceName = strings.TrimSpace(action.TargetResourceName)
|
|
action.TargetResourceType = strings.TrimSpace(action.TargetResourceType)
|
|
action.TargetNode = strings.TrimSpace(action.TargetNode)
|
|
if action.ApprovalID == "" && action.ActionID == "" && action.FixID == "" && action.Description == "" && action.FindingID == "" {
|
|
continue
|
|
}
|
|
key := strings.ToLower(action.FindingID + "\x00" + action.RecordID + "\x00" + action.ApprovalID + "\x00" + action.ActionID + "\x00" + action.FixID + "\x00" + action.Description + "\x00" + action.TargetResourceID + "\x00" + action.TargetHost)
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
normalized = append(normalized, action)
|
|
}
|
|
if len(normalized) == 0 {
|
|
return nil
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func trimHandoffMetadataField(value string, maxRunes int) string {
|
|
value = strings.TrimSpace(value)
|
|
if len([]rune(value)) <= maxRunes {
|
|
return value
|
|
}
|
|
runes := []rune(value)
|
|
return strings.TrimSpace(string(runes[:maxRunes]))
|
|
}
|
|
|
|
// NormalizeHandoffMetadata returns the browser-safe subset of product-originated
|
|
// handoff identity that can be persisted and exposed in session summaries.
|
|
func NormalizeHandoffMetadata(metadata HandoffMetadata) HandoffMetadata {
|
|
kind := strings.ToLower(trimHandoffMetadataField(metadata.Kind, 64))
|
|
switch kind {
|
|
case sessionHandoffKindPatrolAssessment,
|
|
sessionHandoffKindPatrolConfigurationFailure,
|
|
sessionHandoffKindPatrolFinding,
|
|
sessionHandoffKindPatrolRun:
|
|
default:
|
|
return HandoffMetadata{}
|
|
}
|
|
|
|
normalized := HandoffMetadata{
|
|
Kind: kind,
|
|
RunID: trimHandoffMetadataField(metadata.RunID, 256),
|
|
RunType: trimHandoffMetadataField(metadata.RunType, 128),
|
|
RunStatus: trimHandoffMetadataField(metadata.RunStatus, 128),
|
|
RuntimeFailure: metadata.RuntimeFailure,
|
|
}
|
|
if normalized.Kind == sessionHandoffKindPatrolRun && normalized.RunID == "" {
|
|
return HandoffMetadata{}
|
|
}
|
|
if normalized.Kind != sessionHandoffKindPatrolRun {
|
|
normalized.RunID = ""
|
|
normalized.RunType = ""
|
|
normalized.RunStatus = ""
|
|
}
|
|
if normalized.Kind != sessionHandoffKindPatrolRun && normalized.Kind != sessionHandoffKindPatrolConfigurationFailure {
|
|
normalized.RuntimeFailure = false
|
|
}
|
|
if normalized.Kind == sessionHandoffKindPatrolAssessment || normalized.Kind == sessionHandoffKindPatrolFinding {
|
|
normalized.RecommendedNextStep = safeSessionHandoffSummaryText(metadata.RecommendedNextStep, 160)
|
|
normalized.RecommendedNextStepDetail = safeSessionHandoffSummaryText(metadata.RecommendedNextStepDetail, 240)
|
|
normalized.RecommendedNextStepAction = safeSessionHandoffSummaryText(metadata.RecommendedNextStepAction, 120)
|
|
normalized.RecommendedNextStepActionKind = safePatrolRecommendationActionKind(metadata.RecommendedNextStepActionKind)
|
|
if normalized.RecommendedNextStepAction != "" {
|
|
normalized.RecommendedNextStepActionHref = safePatrolHandoffRoute(metadata.RecommendedNextStepActionHref)
|
|
}
|
|
if normalized.RecommendedNextStep == "" && normalized.RecommendedNextStepAction != "" {
|
|
normalized.RecommendedNextStep = normalized.RecommendedNextStepAction
|
|
}
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func handoffMetadataEmpty(metadata HandoffMetadata) bool {
|
|
return NormalizeHandoffMetadata(metadata) == (HandoffMetadata{})
|
|
}
|
|
|
|
func inferPatrolRunHandoffMetadata(handoffContext string) HandoffMetadata {
|
|
lines := strings.Split(strings.TrimSpace(handoffContext), "\n")
|
|
if len(lines) == 0 {
|
|
return HandoffMetadata{}
|
|
}
|
|
|
|
var metadata HandoffMetadata
|
|
sawRunContext := false
|
|
sawRunHistorySource := false
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "[Patrol Run Context]" {
|
|
sawRunContext = true
|
|
continue
|
|
}
|
|
if !sawRunContext {
|
|
continue
|
|
}
|
|
|
|
label, value, ok := strings.Cut(line, ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
label = strings.ToLower(strings.TrimSpace(label))
|
|
value = strings.TrimSpace(value)
|
|
switch label {
|
|
case "source":
|
|
if strings.EqualFold(value, "Pulse Patrol run history") {
|
|
sawRunHistorySource = true
|
|
}
|
|
case "run id":
|
|
metadata.RunID = value
|
|
case "run type":
|
|
metadata.RunType = value
|
|
case "status":
|
|
metadata.RunStatus = value
|
|
case "runtime failure":
|
|
metadata.RuntimeFailure = value != ""
|
|
}
|
|
}
|
|
|
|
if !sawRunContext || !sawRunHistorySource || strings.TrimSpace(metadata.RunID) == "" {
|
|
return HandoffMetadata{}
|
|
}
|
|
metadata.Kind = sessionHandoffKindPatrolRun
|
|
return NormalizeHandoffMetadata(metadata)
|
|
}
|
|
|
|
func patrolAssessmentRecommendedNextStepSummary(kind string, handoffContext string) (string, string, string, string) {
|
|
if kind != sessionHandoffKindPatrolAssessment {
|
|
return "", "", "", ""
|
|
}
|
|
|
|
lines := strings.Split(strings.TrimSpace(handoffContext), "\n")
|
|
if len(lines) == 0 {
|
|
return "", "", "", ""
|
|
}
|
|
|
|
sawAssessmentContext := false
|
|
sawPatrolSource := false
|
|
var recommendedNextStep string
|
|
var recommendedNextStepDetail string
|
|
var recommendedNextStepAction string
|
|
var recommendedNextStepActionKind string
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "[Patrol Assessment Context]" {
|
|
sawAssessmentContext = true
|
|
continue
|
|
}
|
|
if !sawAssessmentContext {
|
|
continue
|
|
}
|
|
|
|
label, value, ok := strings.Cut(line, ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
label = strings.ToLower(strings.TrimSpace(label))
|
|
value = strings.TrimSpace(value)
|
|
switch label {
|
|
case "source":
|
|
if strings.EqualFold(value, "Pulse Patrol current assessment") {
|
|
sawPatrolSource = true
|
|
}
|
|
case "recommended next step":
|
|
recommendedNextStep = safeSessionHandoffSummaryText(value, 160)
|
|
case "recommended next step detail":
|
|
recommendedNextStepDetail = safeSessionHandoffSummaryText(value, 240)
|
|
case "recommended next step action":
|
|
recommendedNextStepAction, recommendedNextStepActionKind = safePatrolAssessmentRecommendationAction(value)
|
|
}
|
|
}
|
|
|
|
if !sawAssessmentContext || !sawPatrolSource {
|
|
return "", "", "", ""
|
|
}
|
|
if recommendedNextStep == "" && recommendedNextStepAction != "" {
|
|
recommendedNextStep = recommendedNextStepAction
|
|
}
|
|
return recommendedNextStep, recommendedNextStepDetail, recommendedNextStepAction, recommendedNextStepActionKind
|
|
}
|
|
|
|
func safePatrolAssessmentRecommendationAction(value string) (string, string) {
|
|
action := safeSessionHandoffSummaryText(value, 120)
|
|
if action == "" {
|
|
return "", ""
|
|
}
|
|
for _, candidate := range []struct {
|
|
suffix string
|
|
kind string
|
|
}{
|
|
{" (discuss_assessment)", "discuss_assessment"},
|
|
{" (open_provider_settings)", "open_provider_settings"},
|
|
{" (review_approvals)", "review_approvals"},
|
|
{" (review_findings)", "review_findings"},
|
|
{" (run_patrol)", "run_patrol"},
|
|
} {
|
|
if strings.HasSuffix(action, candidate.suffix) {
|
|
return strings.TrimSpace(strings.TrimSuffix(action, candidate.suffix)), candidate.kind
|
|
}
|
|
}
|
|
return action, ""
|
|
}
|
|
|
|
func safePatrolRecommendationActionKind(value string) string {
|
|
normalized := strings.ToLower(trimHandoffMetadataField(value, 64))
|
|
switch normalized {
|
|
case "discuss_assessment",
|
|
"open_provider_settings",
|
|
"review_approvals",
|
|
"review_findings",
|
|
"run_patrol":
|
|
return normalized
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func patrolFindingNextStepSummary(kind string, handoffContext string) (string, string) {
|
|
if kind != sessionHandoffKindPatrolFinding {
|
|
return "", ""
|
|
}
|
|
|
|
lines := strings.Split(strings.TrimSpace(handoffContext), "\n")
|
|
if len(lines) == 0 {
|
|
return "", ""
|
|
}
|
|
|
|
sawFindingContext := false
|
|
sawPatrolSource := false
|
|
var nextStepAction string
|
|
var nextStepActionHref string
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "[Patrol Finding Context]" {
|
|
sawFindingContext = true
|
|
continue
|
|
}
|
|
if !sawFindingContext {
|
|
continue
|
|
}
|
|
|
|
label, value, ok := strings.Cut(line, ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
label = strings.ToLower(strings.TrimSpace(label))
|
|
value = strings.TrimSpace(value)
|
|
switch label {
|
|
case "source":
|
|
if strings.EqualFold(value, "Pulse Patrol finding handoff") {
|
|
sawPatrolSource = true
|
|
}
|
|
case "patrol next step":
|
|
nextStepAction = safeSessionHandoffSummaryText(value, 120)
|
|
case "patrol next step route":
|
|
nextStepActionHref = safePatrolHandoffRoute(value)
|
|
}
|
|
}
|
|
|
|
if !sawFindingContext || !sawPatrolSource || nextStepAction == "" {
|
|
return "", ""
|
|
}
|
|
return nextStepAction, nextStepActionHref
|
|
}
|
|
|
|
func safePatrolHandoffRoute(value string) string {
|
|
route := trimHandoffMetadataField(value, 160)
|
|
switch route {
|
|
case "/patrol", "/settings/system-ai":
|
|
return route
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func safeSessionHandoffSummaryText(value string, maxRunes int) string {
|
|
normalized := trimHandoffMetadataField(value, maxRunes)
|
|
if normalized == "" || sessionHandoffSummaryTextShouldBeWithheld(normalized) {
|
|
return ""
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func sessionHandoffSummaryTextShouldBeWithheld(value string) bool {
|
|
normalized := strings.ToLower(value)
|
|
for _, marker := range []string{
|
|
"password",
|
|
"secret",
|
|
"token",
|
|
"api key",
|
|
"api_key",
|
|
"api-key",
|
|
"credential",
|
|
"private key",
|
|
"private_key",
|
|
"private-key",
|
|
"systemctl",
|
|
"sudo ",
|
|
"bash ",
|
|
"sh -c",
|
|
"curl ",
|
|
"wget ",
|
|
"kubectl ",
|
|
"docker ",
|
|
"ssh ",
|
|
} {
|
|
if strings.Contains(normalized, marker) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func modelContextEmpty(modelContext *sessionModelContext) bool {
|
|
if modelContext == nil {
|
|
return true
|
|
}
|
|
return strings.TrimSpace(modelContext.HandoffFindingID) == "" &&
|
|
strings.TrimSpace(modelContext.HandoffContext) == "" &&
|
|
len(normalizeHandoffResources(modelContext.HandoffResources)) == 0 &&
|
|
len(normalizeHandoffActions(modelContext.HandoffActions)) == 0 &&
|
|
handoffMetadataEmpty(modelContext.HandoffMetadata)
|
|
}
|
|
|
|
const (
|
|
sessionHandoffKindPatrolAssessment = "patrol_assessment"
|
|
sessionHandoffKindPatrolConfigurationFailure = "patrol_configuration_failure"
|
|
sessionHandoffKindPatrolFinding = "patrol_finding"
|
|
sessionHandoffKindPatrolRun = "patrol_run"
|
|
sessionHandoffKindScopedContext = "scoped_context"
|
|
)
|
|
|
|
func handoffActionCurrentlyRequiresApproval(action HandoffAction) bool {
|
|
approvalStatus := strings.ToLower(strings.TrimSpace(action.ApprovalStatus))
|
|
actionState := strings.ToLower(strings.TrimSpace(action.ActionState))
|
|
|
|
if approvalStatus == "pending" && !action.ApprovalConsumed {
|
|
return true
|
|
}
|
|
switch actionState {
|
|
case "pending_approval", "awaiting_approval":
|
|
return approvalStatus == "" || approvalStatus == "pending"
|
|
case "approved", "rejected", "executing", "completed", "failed", "planned":
|
|
return false
|
|
}
|
|
if approvalStatus != "" || strings.TrimSpace(action.ApprovalID) != "" {
|
|
return false
|
|
}
|
|
return action.ActionRequiresApproval
|
|
}
|
|
|
|
func modelContextHandoffSummary(modelContext *sessionModelContext) *SessionHandoffSummary {
|
|
if modelContextEmpty(modelContext) {
|
|
return nil
|
|
}
|
|
|
|
resources := normalizeHandoffResources(modelContext.HandoffResources)
|
|
actions := normalizeHandoffActions(modelContext.HandoffActions)
|
|
metadata := NormalizeHandoffMetadata(modelContext.HandoffMetadata)
|
|
if handoffMetadataEmpty(metadata) {
|
|
metadata = inferPatrolRunHandoffMetadata(modelContext.HandoffContext)
|
|
}
|
|
findingID := strings.TrimSpace(modelContext.HandoffFindingID)
|
|
if findingID == "" && metadata.Kind == "" {
|
|
for _, action := range actions {
|
|
if strings.TrimSpace(action.FindingID) != "" {
|
|
findingID = strings.TrimSpace(action.FindingID)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
kind := sessionHandoffKindScopedContext
|
|
if metadata.Kind != "" {
|
|
kind = metadata.Kind
|
|
} else if findingID != "" {
|
|
kind = sessionHandoffKindPatrolFinding
|
|
}
|
|
recommendedNextStep := metadata.RecommendedNextStep
|
|
recommendedNextStepDetail := metadata.RecommendedNextStepDetail
|
|
recommendedNextStepAction := metadata.RecommendedNextStepAction
|
|
recommendedNextStepActionKind := metadata.RecommendedNextStepActionKind
|
|
recommendedNextStepActionHref := metadata.RecommendedNextStepActionHref
|
|
if recommendedNextStep == "" || recommendedNextStepDetail == "" || recommendedNextStepAction == "" || recommendedNextStepActionKind == "" {
|
|
contextNextStep, contextNextStepDetail, contextNextStepAction, contextNextStepActionKind := patrolAssessmentRecommendedNextStepSummary(
|
|
kind,
|
|
modelContext.HandoffContext,
|
|
)
|
|
if recommendedNextStep == "" {
|
|
recommendedNextStep = contextNextStep
|
|
}
|
|
if recommendedNextStepDetail == "" {
|
|
recommendedNextStepDetail = contextNextStepDetail
|
|
}
|
|
if recommendedNextStepAction == "" {
|
|
recommendedNextStepAction = contextNextStepAction
|
|
}
|
|
if recommendedNextStepActionKind == "" {
|
|
recommendedNextStepActionKind = contextNextStepActionKind
|
|
}
|
|
}
|
|
if recommendedNextStepAction == "" || recommendedNextStepActionHref == "" {
|
|
contextNextStepAction, contextNextStepActionHref := patrolFindingNextStepSummary(
|
|
kind,
|
|
modelContext.HandoffContext,
|
|
)
|
|
if recommendedNextStepAction == "" {
|
|
recommendedNextStepAction = contextNextStepAction
|
|
}
|
|
if recommendedNextStepActionHref == "" {
|
|
recommendedNextStepActionHref = contextNextStepActionHref
|
|
}
|
|
}
|
|
if recommendedNextStep == "" && recommendedNextStepAction != "" {
|
|
recommendedNextStep = recommendedNextStepAction
|
|
}
|
|
|
|
summary := &SessionHandoffSummary{
|
|
Kind: kind,
|
|
FindingID: findingID,
|
|
RunID: metadata.RunID,
|
|
RunType: metadata.RunType,
|
|
RunStatus: metadata.RunStatus,
|
|
RuntimeFailure: metadata.RuntimeFailure,
|
|
HasModelContext: strings.TrimSpace(modelContext.HandoffContext) != "",
|
|
ResourceCount: len(resources),
|
|
ActionCount: len(actions),
|
|
RecommendedNextStep: recommendedNextStep,
|
|
RecommendedNextStepDetail: recommendedNextStepDetail,
|
|
RecommendedNextStepAction: recommendedNextStepAction,
|
|
RecommendedNextStepActionKind: recommendedNextStepActionKind,
|
|
RecommendedNextStepActionHref: recommendedNextStepActionHref,
|
|
}
|
|
if kind != sessionHandoffKindPatrolRun {
|
|
summary.RunID = ""
|
|
summary.RunType = ""
|
|
summary.RunStatus = ""
|
|
}
|
|
if kind != sessionHandoffKindPatrolRun && kind != sessionHandoffKindPatrolConfigurationFailure {
|
|
summary.RuntimeFailure = false
|
|
}
|
|
if !modelContext.UpdatedAt.IsZero() {
|
|
updatedAt := modelContext.UpdatedAt
|
|
summary.UpdatedAt = &updatedAt
|
|
}
|
|
if len(resources) > 0 {
|
|
primaryResource := resources[0]
|
|
summary.PrimaryResource = &primaryResource
|
|
}
|
|
for _, action := range actions {
|
|
if !summary.RequiresApproval && handoffActionCurrentlyRequiresApproval(action) {
|
|
summary.RequiresApproval = true
|
|
}
|
|
if summary.LastKnownApprovalStatus == "" {
|
|
summary.LastKnownApprovalStatus = strings.TrimSpace(action.ApprovalStatus)
|
|
}
|
|
if summary.LastKnownActionState == "" {
|
|
summary.LastKnownActionState = strings.TrimSpace(action.ActionState)
|
|
}
|
|
if summary.LastKnownActionRisk == "" {
|
|
summary.LastKnownActionRisk = strings.TrimSpace(action.RiskLevel)
|
|
}
|
|
}
|
|
|
|
return summary
|
|
}
|
|
|
|
const maxSessionIDLength = 128
|
|
|
|
// NewSessionStore creates a new session store
|
|
func NewSessionStore(dataDir string) (*SessionStore, error) {
|
|
sessionsDir := filepath.Join(dataDir, "ai_sessions")
|
|
if err := os.MkdirAll(sessionsDir, 0700); err != nil {
|
|
return nil, fmt.Errorf("failed to create sessions directory: %w", err)
|
|
}
|
|
|
|
return &SessionStore{
|
|
dataDir: sessionsDir,
|
|
resolvedContexts: make(map[string]*ResolvedContext),
|
|
sessionFSMs: make(map[string]*SessionFSM),
|
|
sessionToolSets: make(map[string]map[string]bool),
|
|
knowledgeAccumulators: make(map[string]*KnowledgeAccumulator),
|
|
}, nil
|
|
}
|
|
|
|
// sessionPath returns the file path for a session
|
|
func (s *SessionStore) sessionPath(id string) (string, error) {
|
|
if err := validateSessionID(id); err != nil {
|
|
return "", err
|
|
}
|
|
return securityutil.JoinStorageLeaf(s.dataDir, securityutil.HashedStorageName(id)+".json")
|
|
}
|
|
|
|
func (s *SessionStore) findLegacySessionPath(id string) (string, error) {
|
|
if err := validateSessionID(id); err != nil {
|
|
return "", err
|
|
}
|
|
canonicalName := securityutil.HashedStorageName(id) + ".json"
|
|
entries, err := os.ReadDir(s.dataDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "", nil
|
|
}
|
|
return "", fmt.Errorf("failed to scan session directory: %w", err)
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
|
continue
|
|
}
|
|
if entry.Name() == canonicalName {
|
|
continue
|
|
}
|
|
path, err := securityutil.JoinStorageLeaf(s.dataDir, entry.Name())
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to resolve legacy session candidate path")
|
|
continue
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to read legacy session candidate")
|
|
continue
|
|
}
|
|
var session sessionData
|
|
if err := json.Unmarshal(data, &session); err != nil {
|
|
log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to parse legacy session candidate")
|
|
continue
|
|
}
|
|
if session.ID == id {
|
|
return path, nil
|
|
}
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// List returns all sessions, sorted by updated_at descending
|
|
func (s *SessionStore) List() ([]Session, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
entries, err := os.ReadDir(s.dataDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read sessions directory: %w", err)
|
|
}
|
|
|
|
var sessions []Session
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
|
continue
|
|
}
|
|
|
|
path, err := securityutil.JoinStorageLeaf(s.dataDir, entry.Name())
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to resolve session file path")
|
|
continue
|
|
}
|
|
|
|
file, err := os.ReadFile(path)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to read session file")
|
|
continue
|
|
}
|
|
var data sessionData
|
|
if err := json.Unmarshal(file, &data); err != nil {
|
|
log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to parse session file")
|
|
continue
|
|
}
|
|
for i := range data.Messages {
|
|
data.Messages[i] = data.Messages[i].NormalizeCollections()
|
|
}
|
|
|
|
sessions = append(sessions, Session{
|
|
ID: data.ID,
|
|
Title: data.Title,
|
|
CreatedAt: data.CreatedAt,
|
|
UpdatedAt: data.UpdatedAt,
|
|
MessageCount: len(data.Messages),
|
|
HandoffSummary: modelContextHandoffSummary(data.ModelContext),
|
|
})
|
|
}
|
|
|
|
// Sort by updated_at descending (newest first)
|
|
sort.Slice(sessions, func(i, j int) bool {
|
|
return sessions[i].UpdatedAt.After(sessions[j].UpdatedAt)
|
|
})
|
|
|
|
return sessions, nil
|
|
}
|
|
|
|
// Create creates a new session
|
|
func (s *SessionStore) Create() (*Session, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
data := sessionData{
|
|
ID: uuid.New().String(),
|
|
Title: "",
|
|
Messages: []Message{},
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
if err := s.writeSession(data); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &Session{
|
|
ID: data.ID,
|
|
Title: data.Title,
|
|
CreatedAt: data.CreatedAt,
|
|
UpdatedAt: data.UpdatedAt,
|
|
}, nil
|
|
}
|
|
|
|
// Get retrieves a session by ID
|
|
func (s *SessionStore) Get(id string) (*Session, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
data, err := s.readSession(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &Session{
|
|
ID: data.ID,
|
|
Title: data.Title,
|
|
CreatedAt: data.CreatedAt,
|
|
UpdatedAt: data.UpdatedAt,
|
|
MessageCount: len(data.Messages),
|
|
HandoffSummary: modelContextHandoffSummary(data.ModelContext),
|
|
}, nil
|
|
}
|
|
|
|
// Delete deletes a session
|
|
func (s *SessionStore) Delete(id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
path, err := s.sessionPath(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var removed bool
|
|
candidates := []string{path}
|
|
legacyPath, err := s.findLegacySessionPath(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if legacyPath != "" && legacyPath != path {
|
|
candidates = append(candidates, legacyPath)
|
|
}
|
|
for _, candidate := range candidates {
|
|
if err := os.Remove(candidate); err != nil {
|
|
if os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
return fmt.Errorf("failed to delete session: %w", err)
|
|
}
|
|
removed = true
|
|
}
|
|
if !removed {
|
|
return fmt.Errorf("session not found: %s", id)
|
|
}
|
|
|
|
// Also clean up resolved context, FSM, and knowledge accumulator
|
|
delete(s.resolvedContexts, id)
|
|
delete(s.sessionFSMs, id)
|
|
delete(s.sessionToolSets, id)
|
|
delete(s.knowledgeAccumulators, id)
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetMessages retrieves all messages for a session
|
|
func (s *SessionStore) GetMessages(id string) ([]Message, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
data, err := s.readSession(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return data.Messages, nil
|
|
}
|
|
|
|
// TrimMessages keeps at most keepMostRecent messages in the session,
|
|
// dropping older ones. Used for sessions like patrol-main that are
|
|
// reused indefinitely across scheduled runs and would otherwise grow
|
|
// unbounded — at the default 3-hour Patrol cadence with ~20 messages
|
|
// per run, the file grew to 16 MB / 3,593 messages before this bound
|
|
// existed, and every AddMessage was rewriting the whole file to disk.
|
|
//
|
|
// keepMostRecent <= 0 is treated as a no-op so callers can disable the
|
|
// bound by passing 0 when they want full retention (e.g. user-driven
|
|
// chat sessions where conversation history is the product).
|
|
func (s *SessionStore) TrimMessages(id string, keepMostRecent int) error {
|
|
if keepMostRecent <= 0 {
|
|
return nil
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
data, err := s.readSession(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(data.Messages) <= keepMostRecent {
|
|
return nil
|
|
}
|
|
start := len(data.Messages) - keepMostRecent
|
|
trimmed := make([]Message, keepMostRecent)
|
|
copy(trimmed, data.Messages[start:])
|
|
data.Messages = trimmed
|
|
data.UpdatedAt = time.Now()
|
|
return s.writeSession(*data)
|
|
}
|
|
|
|
// AddMessage adds a message to a session
|
|
func (s *SessionStore) AddMessage(id string, msg Message) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
data, err := s.readSession(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Generate message ID if not set
|
|
if msg.ID == "" {
|
|
msg.ID = uuid.New().String()
|
|
}
|
|
if msg.Timestamp.IsZero() {
|
|
msg.Timestamp = time.Now()
|
|
}
|
|
msg = msg.NormalizeCollections()
|
|
|
|
data.Messages = append(data.Messages, msg)
|
|
data.UpdatedAt = time.Now()
|
|
|
|
// Auto-generate title from first user message if not set
|
|
if data.Title == "" && msg.Role == "user" && msg.Content != "" {
|
|
data.Title = generateTitle(msg.Content)
|
|
}
|
|
|
|
return s.writeSession(*data)
|
|
}
|
|
|
|
// SetModelHandoffFindingID stores the product-originated finding reference for
|
|
// follow-up turns. The reference lets API handlers refresh the current Patrol
|
|
// context without treating the finding as user-authored chat text.
|
|
func (s *SessionStore) SetModelHandoffFindingID(id, findingID string) error {
|
|
findingID = strings.TrimSpace(findingID)
|
|
if findingID == "" {
|
|
return nil
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
data, err := s.readSession(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
now := time.Now()
|
|
if data.ModelContext == nil {
|
|
data.ModelContext = &sessionModelContext{}
|
|
}
|
|
data.ModelContext.HandoffFindingID = findingID
|
|
data.ModelContext.UpdatedAt = now
|
|
data.UpdatedAt = now
|
|
|
|
return s.writeSession(*data)
|
|
}
|
|
|
|
// SetModelHandoffEnvelope replaces the session's product-originated handoff
|
|
// as one coherent scope. This avoids stale finding, run, resource, or action
|
|
// identity leaking between separate handoffs within the same chat session.
|
|
func (s *SessionStore) SetModelHandoffEnvelope(id string, findingID string, handoffContext string, handoffResources []HandoffResource, handoffActions []HandoffAction, handoffMetadata HandoffMetadata) error {
|
|
findingID = strings.TrimSpace(findingID)
|
|
handoffContext = strings.TrimSpace(handoffContext)
|
|
resources := normalizeHandoffResources(handoffResources)
|
|
actions := normalizeHandoffActions(handoffActions)
|
|
metadata := NormalizeHandoffMetadata(handoffMetadata)
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
data, err := s.readSession(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
now := time.Now()
|
|
modelContext := &sessionModelContext{
|
|
HandoffFindingID: findingID,
|
|
HandoffContext: handoffContext,
|
|
HandoffResources: resources,
|
|
HandoffActions: actions,
|
|
HandoffMetadata: metadata,
|
|
UpdatedAt: now,
|
|
}
|
|
if modelContextEmpty(modelContext) {
|
|
data.ModelContext = nil
|
|
} else {
|
|
data.ModelContext = modelContext
|
|
}
|
|
data.UpdatedAt = now
|
|
|
|
return s.writeSession(*data)
|
|
}
|
|
|
|
// SetModelHandoffContext stores model-only handoff context for future turns.
|
|
// It is intentionally session metadata, not a user-authored chat message.
|
|
func (s *SessionStore) SetModelHandoffContext(id, handoffContext string) error {
|
|
handoffContext = strings.TrimSpace(handoffContext)
|
|
if handoffContext == "" {
|
|
return nil
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
data, err := s.readSession(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
now := time.Now()
|
|
if data.ModelContext == nil {
|
|
data.ModelContext = &sessionModelContext{}
|
|
}
|
|
data.ModelContext.HandoffContext = handoffContext
|
|
data.ModelContext.UpdatedAt = now
|
|
data.UpdatedAt = now
|
|
|
|
return s.writeSession(*data)
|
|
}
|
|
|
|
// SetModelHandoffResources stores product-originated resource references for
|
|
// future turns. These references are not authority by themselves; chat execution
|
|
// re-resolves them through the canonical unified-resource model before use.
|
|
func (s *SessionStore) SetModelHandoffResources(id string, handoffResources []HandoffResource) error {
|
|
resources := normalizeHandoffResources(handoffResources)
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
data, err := s.readSession(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
now := time.Now()
|
|
if data.ModelContext == nil {
|
|
data.ModelContext = &sessionModelContext{}
|
|
}
|
|
data.ModelContext.HandoffResources = resources
|
|
data.ModelContext.UpdatedAt = now
|
|
if modelContextEmpty(data.ModelContext) {
|
|
data.ModelContext = nil
|
|
}
|
|
data.UpdatedAt = now
|
|
|
|
return s.writeSession(*data)
|
|
}
|
|
|
|
// SetModelHandoffActions stores product-originated pending action references
|
|
// for future turns. These references are not executable authority and must not
|
|
// contain raw command text.
|
|
func (s *SessionStore) SetModelHandoffActions(id string, handoffActions []HandoffAction) error {
|
|
actions := normalizeHandoffActions(handoffActions)
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
data, err := s.readSession(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
now := time.Now()
|
|
if data.ModelContext == nil {
|
|
data.ModelContext = &sessionModelContext{}
|
|
}
|
|
data.ModelContext.HandoffActions = actions
|
|
data.ModelContext.UpdatedAt = now
|
|
if modelContextEmpty(data.ModelContext) {
|
|
data.ModelContext = nil
|
|
}
|
|
data.UpdatedAt = now
|
|
|
|
return s.writeSession(*data)
|
|
}
|
|
|
|
// SetModelHandoffMetadata stores browser-safe handoff identity for future
|
|
// session summaries without exposing private model context details.
|
|
func (s *SessionStore) SetModelHandoffMetadata(id string, handoffMetadata HandoffMetadata) error {
|
|
metadata := NormalizeHandoffMetadata(handoffMetadata)
|
|
if handoffMetadataEmpty(metadata) {
|
|
return nil
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
data, err := s.readSession(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
now := time.Now()
|
|
if data.ModelContext == nil {
|
|
data.ModelContext = &sessionModelContext{}
|
|
}
|
|
data.ModelContext.HandoffMetadata = metadata
|
|
data.ModelContext.UpdatedAt = now
|
|
data.UpdatedAt = now
|
|
|
|
return s.writeSession(*data)
|
|
}
|
|
|
|
// GetModelHandoffFindingID returns the stored product-originated finding
|
|
// reference for a session.
|
|
func (s *SessionStore) GetModelHandoffFindingID(id string) (string, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
data, err := s.readSession(id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if data.ModelContext == nil {
|
|
return "", nil
|
|
}
|
|
return strings.TrimSpace(data.ModelContext.HandoffFindingID), nil
|
|
}
|
|
|
|
// GetModelHandoffMetadata returns the browser-safe handoff identity stored for
|
|
// a session.
|
|
func (s *SessionStore) GetModelHandoffMetadata(id string) (HandoffMetadata, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
data, err := s.readSession(id)
|
|
if err != nil {
|
|
return HandoffMetadata{}, err
|
|
}
|
|
if data.ModelContext == nil {
|
|
return HandoffMetadata{}, nil
|
|
}
|
|
return NormalizeHandoffMetadata(data.ModelContext.HandoffMetadata), nil
|
|
}
|
|
|
|
// GetModelHandoffContext returns model-only handoff context for a session.
|
|
func (s *SessionStore) GetModelHandoffContext(id string) (string, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
data, err := s.readSession(id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if data.ModelContext == nil {
|
|
return "", nil
|
|
}
|
|
return strings.TrimSpace(data.ModelContext.HandoffContext), nil
|
|
}
|
|
|
|
// GetModelHandoffResources returns stored handoff resource references for a
|
|
// session. Callers must rehydrate them through canonical resource registration
|
|
// before using them for action validation.
|
|
func (s *SessionStore) GetModelHandoffResources(id string) ([]HandoffResource, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
data, err := s.readSession(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if data.ModelContext == nil {
|
|
return nil, nil
|
|
}
|
|
return normalizeHandoffResources(data.ModelContext.HandoffResources), nil
|
|
}
|
|
|
|
// GetModelHandoffActions returns stored product-originated pending action
|
|
// references for a session. Callers must treat them as review context only.
|
|
func (s *SessionStore) GetModelHandoffActions(id string) ([]HandoffAction, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
data, err := s.readSession(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if data.ModelContext == nil {
|
|
return nil, nil
|
|
}
|
|
return normalizeHandoffActions(data.ModelContext.HandoffActions), nil
|
|
}
|
|
|
|
func (s *SessionStore) clearModelHandoffContextLocked(id string) error {
|
|
data, err := s.readSession(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if modelContextEmpty(data.ModelContext) {
|
|
return nil
|
|
}
|
|
|
|
data.ModelContext = nil
|
|
data.UpdatedAt = time.Now()
|
|
return s.writeSession(*data)
|
|
}
|
|
|
|
// ClearModelHandoffContext removes product-originated model-only handoff
|
|
// metadata while leaving the user-authored message history intact.
|
|
func (s *SessionStore) ClearModelHandoffContext(id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return s.clearModelHandoffContextLocked(id)
|
|
}
|
|
|
|
// UpdateLastMessage updates the last message in a session (for streaming updates)
|
|
func (s *SessionStore) UpdateLastMessage(id string, msg Message) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
data, err := s.readSession(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(data.Messages) == 0 {
|
|
return fmt.Errorf("no messages to update")
|
|
}
|
|
|
|
data.Messages[len(data.Messages)-1] = msg.NormalizeCollections()
|
|
data.UpdatedAt = time.Now()
|
|
|
|
return s.writeSession(*data)
|
|
}
|
|
|
|
// readSession reads a session from disk (caller must hold lock)
|
|
func (s *SessionStore) readSession(id string) (*sessionData, error) {
|
|
path, err := s.sessionPath(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
file, err := os.ReadFile(path)
|
|
if os.IsNotExist(err) {
|
|
legacyPath, legacyErr := s.findLegacySessionPath(id)
|
|
if legacyErr != nil {
|
|
return nil, legacyErr
|
|
}
|
|
if legacyPath == "" {
|
|
return nil, fmt.Errorf("session not found: %s", id)
|
|
}
|
|
file, err = os.ReadFile(legacyPath)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read session: %w", err)
|
|
}
|
|
|
|
var data sessionData
|
|
if err := json.Unmarshal(file, &data); err != nil {
|
|
return nil, fmt.Errorf("failed to parse session: %w", err)
|
|
}
|
|
for i := range data.Messages {
|
|
data.Messages[i] = data.Messages[i].NormalizeCollections()
|
|
}
|
|
|
|
return &data, nil
|
|
}
|
|
|
|
// writeSession writes a session to disk (caller must hold lock)
|
|
func (s *SessionStore) writeSession(data sessionData) error {
|
|
if err := validateSessionID(data.ID); err != nil {
|
|
return err
|
|
}
|
|
for i := range data.Messages {
|
|
data.Messages[i] = data.Messages[i].NormalizeCollections()
|
|
}
|
|
|
|
file, err := json.MarshalIndent(data, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal session: %w", err)
|
|
}
|
|
|
|
path, err := s.sessionPath(data.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(path, file, 0600); err != nil {
|
|
return fmt.Errorf("failed to write session: %w", err)
|
|
}
|
|
if legacyPath, err := s.findLegacySessionPath(data.ID); err == nil && legacyPath != "" && legacyPath != path {
|
|
_ = os.Remove(legacyPath)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// generateTitle creates a session title from the first user message
|
|
func generateTitle(content string) string {
|
|
// Clean up the content
|
|
content = strings.TrimSpace(content)
|
|
content = strings.ReplaceAll(content, "\n", " ")
|
|
content = strings.ReplaceAll(content, "\r", " ")
|
|
|
|
// Collapse multiple spaces
|
|
for strings.Contains(content, " ") {
|
|
content = strings.ReplaceAll(content, " ", " ")
|
|
}
|
|
|
|
const maxLen = 50
|
|
|
|
runes := []rune(content)
|
|
if len(runes) <= maxLen {
|
|
return content
|
|
}
|
|
|
|
// Find a good break point
|
|
truncated := string(runes[:maxLen])
|
|
lastSpace := strings.LastIndex(truncated, " ")
|
|
|
|
if lastSpace > 20 {
|
|
return truncated[:lastSpace] + "..."
|
|
}
|
|
|
|
return truncated + "..."
|
|
}
|
|
|
|
// EnsureSession ensures a session exists, creating one if needed
|
|
func (s *SessionStore) EnsureSession(id string) (*Session, error) {
|
|
if id == "" {
|
|
return s.Create()
|
|
}
|
|
if err := validateSessionID(id); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
session, err := s.Get(id)
|
|
if err != nil {
|
|
// Session doesn't exist, create it with the specified ID
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
data := sessionData{
|
|
ID: id,
|
|
Title: "",
|
|
Messages: []Message{},
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
if err := s.writeSession(data); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &Session{
|
|
ID: data.ID,
|
|
CreatedAt: data.CreatedAt,
|
|
UpdatedAt: data.UpdatedAt,
|
|
}, nil
|
|
}
|
|
|
|
return session, nil
|
|
}
|
|
|
|
func validateSessionID(id string) error {
|
|
if id == "" {
|
|
return fmt.Errorf("invalid session id: cannot be empty")
|
|
}
|
|
if len(id) > maxSessionIDLength {
|
|
return fmt.Errorf("invalid session id: too long")
|
|
}
|
|
for _, r := range id {
|
|
isLower := r >= 'a' && r <= 'z'
|
|
isUpper := r >= 'A' && r <= 'Z'
|
|
isDigit := r >= '0' && r <= '9'
|
|
if isLower || isUpper || isDigit || r == '-' || r == '_' {
|
|
continue
|
|
}
|
|
return fmt.Errorf("invalid session id: only letters, numbers, '-' and '_' are allowed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetResolvedContext returns the resolved context for a session, creating one if needed
|
|
func (s *SessionStore) GetResolvedContext(sessionID string) *ResolvedContext {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
ctx, ok := s.resolvedContexts[sessionID]
|
|
if !ok {
|
|
ctx = NewResolvedContext(sessionID)
|
|
s.resolvedContexts[sessionID] = ctx
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
// GetSessionFSM returns the workflow FSM for a session, creating one if needed
|
|
func (s *SessionStore) GetSessionFSM(sessionID string) *SessionFSM {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
fsm, ok := s.sessionFSMs[sessionID]
|
|
if !ok {
|
|
fsm = NewSessionFSM()
|
|
s.sessionFSMs[sessionID] = fsm
|
|
}
|
|
return fsm
|
|
}
|
|
|
|
// GetKnowledgeAccumulator returns the knowledge accumulator for a session, creating one if needed.
|
|
// For user chat sessions, this persists across messages (facts accumulate during a conversation).
|
|
func (s *SessionStore) GetKnowledgeAccumulator(sessionID string) *KnowledgeAccumulator {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
ka, ok := s.knowledgeAccumulators[sessionID]
|
|
if !ok {
|
|
ka = NewKnowledgeAccumulator()
|
|
s.knowledgeAccumulators[sessionID] = ka
|
|
}
|
|
return ka
|
|
}
|
|
|
|
// NewKnowledgeAccumulatorForRun creates a fresh KA for a patrol run.
|
|
// Unlike GetKnowledgeAccumulator (which reuses a session-scoped KA),
|
|
// this always returns a new instance to avoid stale facts from prior runs.
|
|
func (s *SessionStore) NewKnowledgeAccumulatorForRun(sessionID string) *KnowledgeAccumulator {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
ka := NewKnowledgeAccumulator()
|
|
s.knowledgeAccumulators[sessionID] = ka
|
|
return ka
|
|
}
|
|
|
|
// ResetSessionFSM resets the FSM for a session (e.g., after context clear)
|
|
func (s *SessionStore) ResetSessionFSM(sessionID string, keepProgress bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
fsm, ok := s.sessionFSMs[sessionID]
|
|
if ok {
|
|
if keepProgress {
|
|
fsm.ResetKeepProgress()
|
|
} else {
|
|
fsm.Reset()
|
|
}
|
|
}
|
|
}
|
|
|
|
// AddResolvedResource adds a resolved resource to a session's context
|
|
func (s *SessionStore) AddResolvedResource(sessionID, name string, res *ResolvedResource) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
ctx, ok := s.resolvedContexts[sessionID]
|
|
if !ok {
|
|
ctx = NewResolvedContext(sessionID)
|
|
s.resolvedContexts[sessionID] = ctx
|
|
}
|
|
ctx.AddResource(name, res)
|
|
|
|
log.Debug().
|
|
Str("session_id", sessionID).
|
|
Str("name", name).
|
|
Str("resource_id", res.ResourceID).
|
|
Str("resource_type", res.ResourceType).
|
|
Str("target_host", res.TargetHost).
|
|
Msg("[SessionStore] Added resolved resource to context")
|
|
}
|
|
|
|
// ValidateResourceForAction validates that a resource can perform an action
|
|
// Returns the resolved resource if valid, error if not
|
|
func (s *SessionStore) ValidateResourceForAction(sessionID, resourceID, action string) (*ResolvedResource, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
ctx, ok := s.resolvedContexts[sessionID]
|
|
if !ok {
|
|
return nil, &ResourceNotResolvedError{ResourceID: resourceID}
|
|
}
|
|
|
|
if err := ctx.ValidateAction(resourceID, action); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res, _ := ctx.GetResourceByID(resourceID)
|
|
return res, nil
|
|
}
|
|
|
|
// ClearResolvedContext removes the resolved context for a session
|
|
func (s *SessionStore) ClearResolvedContext(sessionID string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
delete(s.resolvedContexts, sessionID)
|
|
}
|
|
|
|
// ClearSessionState clears both resolved context and FSM coherently.
|
|
// This is the preferred method when clearing session state.
|
|
// - keepPinned=false: Full reset (RESOLVING state, no resources)
|
|
// - keepPinned=true: Keep pinned resources, FSM stays in READING if resources exist
|
|
func (s *SessionStore) ClearSessionState(sessionID string, keepPinned bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Clear resolved context
|
|
ctx, hasCtx := s.resolvedContexts[sessionID]
|
|
if hasCtx {
|
|
ctx.Clear(keepPinned)
|
|
}
|
|
|
|
if !keepPinned {
|
|
delete(s.sessionToolSets, sessionID)
|
|
delete(s.knowledgeAccumulators, sessionID)
|
|
if err := s.clearModelHandoffContextLocked(sessionID); err != nil {
|
|
log.Warn().Err(err).Str("session_id", sessionID).Msg("[SessionStore] Failed to clear model handoff context")
|
|
}
|
|
}
|
|
|
|
// Reset FSM coherently with context state
|
|
fsm, hasFSM := s.sessionFSMs[sessionID]
|
|
if hasFSM {
|
|
if !keepPinned {
|
|
// Full reset: back to RESOLVING (must discover again)
|
|
fsm.Reset()
|
|
} else if hasCtx && ctx.HasAnyResources() {
|
|
// Pinned resources remain: keep progress (stay in READING if possible)
|
|
fsm.ResetKeepProgress()
|
|
} else {
|
|
// keepPinned=true but no resources left: must rediscover
|
|
fsm.Reset()
|
|
}
|
|
}
|
|
|
|
log.Debug().
|
|
Str("session_id", sessionID).
|
|
Bool("keep_pinned", keepPinned).
|
|
Bool("has_resources", hasCtx && ctx.HasAnyResources()).
|
|
Str("fsm_state", func() string {
|
|
if hasFSM {
|
|
return string(fsm.State)
|
|
}
|
|
return "none"
|
|
}()).
|
|
Msg("[SessionStore] Cleared session state")
|
|
}
|
|
|
|
// cleanupResolvedContext is called when a session is deleted to also remove its context
|
|
func (s *SessionStore) cleanupResolvedContext(sessionID string) {
|
|
// Note: caller must NOT hold the lock (or use a separate lock for contexts)
|
|
delete(s.resolvedContexts, sessionID)
|
|
}
|
|
|
|
// GetToolSet returns a copy of the tool allowlist for a session, or nil if none set.
|
|
func (s *SessionStore) GetToolSet(sessionID string) map[string]bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
toolSet, ok := s.sessionToolSets[sessionID]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return copyToolSet(toolSet)
|
|
}
|
|
|
|
// SetToolSet stores a tool allowlist for a session.
|
|
func (s *SessionStore) SetToolSet(sessionID string, toolSet map[string]bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.sessionToolSets[sessionID] = copyToolSet(toolSet)
|
|
}
|
|
|
|
// AddToolSet merges tool allowlist entries into the session's tool set.
|
|
// Returns a copy of the updated tool set.
|
|
func (s *SessionStore) AddToolSet(sessionID string, additions map[string]bool) map[string]bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
toolSet, ok := s.sessionToolSets[sessionID]
|
|
if !ok {
|
|
toolSet = make(map[string]bool)
|
|
}
|
|
for name := range additions {
|
|
toolSet[name] = true
|
|
}
|
|
s.sessionToolSets[sessionID] = toolSet
|
|
return copyToolSet(toolSet)
|
|
}
|
|
|
|
func copyToolSet(source map[string]bool) map[string]bool {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
out := make(map[string]bool, len(source))
|
|
for key, value := range source {
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|