package runtime import ( "context" "encoding/json" "errors" "strings" "time" "github.com/jackc/pgx/v5" "github.com/krow/krow-backend/go-api/internal/authctx" "github.com/krow/krow-backend/go-api/internal/domain" "github.com/krow/krow-backend/go-api/internal/repo" ) // Reading a recorded run back. // // The write side of trajectories is PostgresSink; this is the read side, and it // exists because §6's requirement is only worth anything if somebody can look. // "Why did the agent say that" should be answerable by pointing at a run id. // // Two rules shape what comes back: // // - **Tenant scope is in the query.** I5. A run id from another organization // is absent rather than forbidden, so it answers 404 and cannot be used to // discover that a given run exists somewhere else. // - **A talent caller sees only their own runs.** An operator sees the // organization's, which is what an operator console is. A trajectory // contains the caller's question and the records retrieved for them, so // "anyone in the tenant may read any run" would be a much larger grant than // it looks. // RunReader loads recorded trajectories. type RunReader struct { db repo.Querier } // NewRunReader builds a reader over a pool or transaction. func NewRunReader(db repo.Querier) *RunReader { return &RunReader{db: db} } // RunView is a trajectory as a caller sees it. // // Not the Trajectory struct. That one is the internal record and gains fields // as the runtime does; this is a response shape, and the difference is what // keeps a new internal field from silently becoming a new public one. type RunView struct { RunID string `json:"runId"` ParentRunID string `json:"parentRunId,omitempty"` AgentID string `json:"agentId"` AgentVersion int `json:"agentVersion"` Tier string `json:"tier"` Model string `json:"model,omitempty"` StartedAt time.Time `json:"startedAt"` EndedAt time.Time `json:"endedAt"` Termination string `json:"termination"` Entries []Entry `json:"entries"` Usage RunUsage `json:"usage"` } // Load returns one run, if this caller may read it. func (r *RunReader) Load(ctx context.Context, ident authctx.Identity, runID string) (*RunView, error) { if strings.TrimSpace(runID) == "" { return nil, domain.NotFound("run", runID) } if strings.TrimSpace(ident.OrgID) == "" { // I5. No tenant, no read — and answered as absent rather than // forbidden, on the same terms as every other row in this service. return nil, domain.NotFound("run", runID) } where, args := runScope(ident, runID) var ( view RunView parent *string model *string rawEntries []byte termination string ) err := r.db.QueryRow(ctx, ` SELECT run_id, parent_run_id, agent_id, agent_version, tier, model, started_at, ended_at, termination, entries, input_tokens, output_tokens, cached_tokens, total_tokens, model_calls FROM agent_runs WHERE `+where, args..., ).Scan(&view.RunID, &parent, &view.AgentID, &view.AgentVersion, &view.Tier, &model, &view.StartedAt, &view.EndedAt, &termination, &rawEntries, &view.Usage.InputTokens, &view.Usage.OutputTokens, &view.Usage.CachedTokens, &view.Usage.TotalTokens, &view.Usage.ModelCalls) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, domain.NotFound("run", runID) } return nil, domain.Internal(err) } view.Termination = termination if parent != nil { view.ParentRunID = *parent } if model != nil { view.Model = *model } if len(rawEntries) > 0 { if err := json.Unmarshal(rawEntries, &view.Entries); err != nil { return nil, domain.Internal(err) } } if view.Entries == nil { view.Entries = []Entry{} } return &view, nil } // runScope builds the predicate a caller's runs are behind. // // Two conditions, and the second is the one that is easy to forget. Tenancy is // obvious. The talent restriction is not: a trajectory holds the question that // was asked and the records retrieved to answer it, so a tenant-wide read would // let any worker read every colleague's conversation with an agent — including // the ones about them. // // An unrecognised role gets `false`, so it matches nothing rather than // everything. The safe direction, and loud enough to find. func runScope(ident authctx.Identity, runID string) (string, []any) { args := []any{ident.OrgID, runID} where := "org_id = $1::uuid AND run_id = $2" role, ok := domain.ParseRole(ident.Role) if !ok { return where + " AND false", args } if role == domain.RoleTalent { if strings.TrimSpace(ident.UserID) == "" { return where + " AND false", args } args = append(args, ident.UserID) where += " AND user_id = $3::uuid" } return where, args }