agent build
This commit is contained in:
143
go-api/internal/runtime/reader.go
Normal file
143
go-api/internal/runtime/reader.go
Normal file
@@ -0,0 +1,143 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user