Files
artex/db/exploration_sources.go
T
dela 0335d572de
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
First Commit
2026-10-09 08:38:16 +08:00

398 lines
12 KiB
Go

package db
import (
"database/sql"
"sort"
)
// DirectSourceStore binds one directly related task to its exploration store.
// It is intentionally a read-side helper: callers keep using the receiver store
// for every graph mutation, frontier lookup, and intent claim.
type DirectSourceStore struct {
Task TaskSource
Store *ExplorationStore
}
// DirectSourceStores resolves the live, direct task relations for this
// exploration. It deliberately queries on every call: relations disappear when
// a source task is deleted, and inherited context must not retain stale rows.
// Sources of a source are never expanded.
func (s *ExplorationStore) DirectSourceStores() ([]DirectSourceStore, error) {
rows, err := s.db.Query(`
SELECT source.id, source.exploration_id, source.description, source.goal, source.status
FROM tasks owner
JOIN task_relations relation ON relation.task_id=owner.id
JOIN tasks source ON source.id=relation.source_task_id AND source.deleted_at IS NULL
WHERE owner.exploration_id=$1 AND owner.deleted_at IS NULL
ORDER BY relation.created_at, source.id`, s.expID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []DirectSourceStore{}
for rows.Next() {
var source TaskSource
if err := rows.Scan(&source.TaskID, &source.ExplorationID, &source.Description, &source.Goal, &source.Status); err != nil {
return nil, err
}
out = append(out, DirectSourceStore{Task: source, Store: s.db.Exploration(source.ExplorationID)})
}
return out, rows.Err()
}
// TaskID returns the live task bound to this exploration. Explorations created
// directly in tests or maintenance code have no task and return zero.
func (s *ExplorationStore) TaskID() (int64, error) {
var id int64
err := s.db.QueryRow(`SELECT id FROM tasks WHERE exploration_id=$1 AND deleted_at IS NULL`, s.expID).Scan(&id)
if err != nil {
if err == sql.ErrNoRows {
return 0, nil
}
return 0, err
}
return id, nil
}
func markInheritedNode(n *Node, taskID int64) *Node {
if n == nil {
return nil
}
n.SourceTaskID = taskID
n.Inherited = true
return n
}
func markInheritedActivities(in []Activity, taskID int64) []Activity {
for i := range in {
in[i].SourceTaskID = taskID
in[i].Inherited = true
}
return in
}
func inheritedIntentTerminal(state string) bool {
switch state {
case "done", "blocked", "exhausted", "stopped":
return true
default:
return false
}
}
// ListByKindWithSources returns local nodes followed by nodes from each direct
// source in relation order. The limit remains per exploration, matching the
// existing ListByKind contract while ensuring one large task cannot hide all
// inherited context from another source.
func (s *ExplorationStore) ListByKindWithSources(kind string, limit int) ([]*Node, error) {
out, err := s.ListByKind(kind, limit)
if err != nil {
return nil, err
}
sources, err := s.DirectSourceStores()
if err != nil {
return nil, err
}
for _, source := range sources {
nodes, err := source.Store.ListByKind(kind, limit)
if err != nil {
return nil, err
}
for _, node := range nodes {
if kind == KindIntent && !inheritedIntentTerminal(node.State) {
continue
}
out = append(out, markInheritedNode(node, source.Task.TaskID))
}
}
return out, nil
}
// ListByKindPageWithSources is the paginated, keyword-filterable sibling of
// ListByKindWithSources: it returns one newest-first page (id < before, before<=0
// = newest) spanning this exploration and its direct sources, plus hasMore and the
// filtered total across all of them. Node ids are globally unique, so merging each
// store's own page and re-sorting by id DESC yields the true global page; fetching
// limit+1 per store guarantees the merged top-`limit` is complete.
func (s *ExplorationStore) ListByKindPageWithSources(kind string, before int64, limit int, q string) (nodes []*Node, hasMore bool, total int, err error) {
if limit <= 0 {
limit = 20
}
own, err := s.listByKindPageFiltered(kind, before, limit, q)
if err != nil {
return nil, false, 0, err
}
merged := own
total, err = s.countByKindFiltered(kind, q)
if err != nil {
return nil, false, 0, err
}
sources, err := s.DirectSourceStores()
if err != nil {
return nil, false, 0, err
}
for _, source := range sources {
page, err := source.Store.listByKindPageFiltered(kind, before, limit, q)
if err != nil {
return nil, false, 0, err
}
for _, n := range page {
merged = append(merged, markInheritedNode(n, source.Task.TaskID))
}
cnt, err := source.Store.countByKindFiltered(kind, q)
if err != nil {
return nil, false, 0, err
}
total += cnt
}
sort.Slice(merged, func(i, j int) bool { return merged[i].ID > merged[j].ID })
hasMore = len(merged) > limit
if hasMore {
merged = merged[:limit]
}
return merged, hasMore, total, nil
}
// GetNodeWithSources reads a node only when it belongs to this exploration or
// one of its direct sources. Inherited nodes are tagged so tool callers can keep
// them read-only and show their provenance.
func (s *ExplorationStore) GetNodeWithSources(id int64) (*Node, error) {
node, err := s.GetNode(id)
if err != nil || node != nil {
return node, err
}
sources, err := s.DirectSourceStores()
if err != nil {
return nil, err
}
for _, source := range sources {
node, err = source.Store.GetNode(id)
if err != nil {
return nil, err
}
if node != nil {
if node.Kind == KindIntent && !inheritedIntentTerminal(node.State) {
continue
}
return markInheritedNode(node, source.Task.TaskID), nil
}
}
return nil, nil
}
// FindingIntentsWithSources combines finding lineage for the current
// exploration and each direct source. Node ids are globally unique.
func (s *ExplorationStore) FindingIntentsWithSources() (map[int64]int64, error) {
out, err := s.FindingIntents()
if err != nil {
return nil, err
}
sources, err := s.DirectSourceStores()
if err != nil {
return nil, err
}
for _, source := range sources {
items, err := source.Store.FindingIntentsTerminal()
if err != nil {
return nil, err
}
for findingID, intentID := range items {
out[findingID] = intentID
}
}
return out, nil
}
// ActivityTraceWithSources returns a work trace when its intent belongs to the
// current exploration or a direct source. It never searches indirect sources.
func (s *ExplorationStore) ActivityTraceWithSources(nodeID int64, limit int) ([]Activity, error) {
acts, err := s.ActivityTrace(nodeID, limit)
if err != nil || len(acts) > 0 {
return acts, err
}
sources, err := s.DirectSourceStores()
if err != nil {
return nil, err
}
for _, source := range sources {
node, nodeErr := source.Store.GetNode(nodeID)
if nodeErr != nil {
return nil, nodeErr
}
if node == nil || node.Kind != KindIntent {
continue
}
acts, err = source.Store.ActivityTraceForTerminalIntent(nodeID, limit)
if err != nil {
return nil, err
}
if len(acts) > 0 {
return markInheritedActivities(acts, source.Task.TaskID), nil
}
}
return []Activity{}, nil
}
// ActivityListWithSources is the source-aware equivalent used by
// get_worker_output. Node ids are global, so the first owning exploration is
// unambiguous even when the work has not emitted any activity yet.
func (s *ExplorationStore) ActivityListWithSources(nodeID, sinceID int64, limit int) ([]Activity, int64, error) {
node, err := s.GetNode(nodeID)
if err != nil {
return nil, sinceID, err
}
if node != nil {
return s.ActivityList(&nodeID, sinceID, limit)
}
sources, err := s.DirectSourceStores()
if err != nil {
return nil, sinceID, err
}
for _, source := range sources {
node, err = source.Store.GetNode(nodeID)
if err != nil {
return nil, sinceID, err
}
if node == nil || node.Kind != KindIntent {
continue
}
acts, cursor, err := source.Store.ActivityListForTerminalIntent(nodeID, sinceID, limit)
if err != nil {
return nil, sinceID, err
}
return markInheritedActivities(acts, source.Task.TaskID), cursor, nil
}
return []Activity{}, sinceID, nil
}
// ActivityDetailWithSources keeps the legacy local-task lookup (including local
// thinking rows), while inherited details are restricted to terminal worker
// intents. Source planner/main rows have no node id and must never become part of
// inherited context.
func (s *ExplorationStore) ActivityDetailWithSources(id int64) (string, error) {
detail, err := s.ActivityDetail(id)
if err != nil || detail != "" {
return detail, err
}
acts, err := s.ActivityByIDsWithSources([]int64{id})
if err != nil || len(acts) == 0 {
return "", err
}
return acts[0].Detail, nil
}
// ActivityTraceSearchWithSources performs the scoped worker-trace search used
// by get_worker_trace. A node id resolves to at most one exploration because
// exploration node ids are global.
func (s *ExplorationStore) ActivityTraceSearchWithSources(nodeID int64, q string, limit int) ([]Activity, error) {
acts, err := s.ActivityTraceSearch(&nodeID, q, limit)
if err != nil || len(acts) > 0 {
return acts, err
}
sources, err := s.DirectSourceStores()
if err != nil {
return nil, err
}
for _, source := range sources {
node, nodeErr := source.Store.GetNode(nodeID)
if nodeErr != nil {
return nil, nodeErr
}
if node == nil || node.Kind != KindIntent {
continue
}
acts, err = source.Store.ActivityTraceSearchForTerminalIntent(nodeID, q, limit)
if err != nil {
return nil, err
}
if len(acts) > 0 {
return markInheritedActivities(acts, source.Task.TaskID), nil
}
}
return []Activity{}, nil
}
// ActivityTraceSearchAllWithSources searches local worker traces plus every
// direct source. The local owner is excluded only from the current exploration;
// inherited traces are immutable historical context.
func (s *ExplorationStore) ActivityTraceSearchAllWithSources(excludeNodeID int64, q string, limit int) ([]Activity, error) {
if limit <= 0 {
limit = 100
}
out, err := s.ActivityTraceSearchExcluding(excludeNodeID, q, limit)
if err != nil {
return nil, err
}
sources, err := s.DirectSourceStores()
if err != nil {
return nil, err
}
for _, source := range sources {
acts, err := source.Store.ActivityTraceSearchTerminalIntents(q, limit)
if err != nil {
return nil, err
}
out = append(out, markInheritedActivities(acts, source.Task.TaskID)...)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
// ActivityByIDsWithSources loads local step details with the legacy behavior. For
// direct sources it only returns rows attached to terminal intents, preventing an
// arbitrary global activity id from exposing source planner/main transcripts.
func (s *ExplorationStore) ActivityByIDsWithSources(ids []int64) ([]Activity, error) {
out, err := s.ActivityByIDs(ids)
if err != nil {
return nil, err
}
sources, err := s.DirectSourceStores()
if err != nil {
return nil, err
}
for _, source := range sources {
acts, err := source.Store.ActivityByIDsForTerminalIntents(ids)
if err != nil {
return nil, err
}
out = append(out, markInheritedActivities(acts, source.Task.TaskID)...)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, nil
}
// AssetRefsWithSources returns anchored nodes from this exploration and each
// direct source. Inherited entries retain their owning task id so API/UI callers
// can present them as immutable context.
func (s *ExplorationStore) AssetRefsWithSources(assetID int64) ([]AssetRef, error) {
out, err := s.AssetRefs(assetID)
if err != nil {
return nil, err
}
sources, err := s.DirectSourceStores()
if err != nil {
return nil, err
}
for _, source := range sources {
refs, err := source.Store.AssetRefs(assetID)
if err != nil {
return nil, err
}
for i := range refs {
if refs[i].Kind == KindIntent && !inheritedIntentTerminal(refs[i].State) {
continue
}
refs[i].SourceTaskID = source.Task.TaskID
refs[i].Inherited = true
out = append(out, refs[i])
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
return out, nil
}