144 lines
4.7 KiB
Go
144 lines
4.7 KiB
Go
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
|
|
}
|