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
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
709 lines
23 KiB
Go
709 lines
23 KiB
Go
package db
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestTaskLifecycleAndDeleteCascade(t *testing.T) {
|
|
d, err := Open(testDSN(t))
|
|
if err != nil {
|
|
t.Skipf("postgres unavailable (%v) — skipping", err)
|
|
}
|
|
defer d.Close()
|
|
|
|
tk, err := d.CreateTask("迁移测试", "目标X", nil, 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// populate the exploration subgraph
|
|
es := d.Exploration(tk.ExplorationID)
|
|
if _, err := es.AddIntent(map[string]any{"summary": "x"}, 5, nil, "planner"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// pause + status
|
|
if err := d.SetPaused(tk.ID, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := d.GetTask(tk.ID)
|
|
if err != nil || got == nil || !got.Paused {
|
|
t.Fatalf("paused not persisted: %+v err=%v", got, err)
|
|
}
|
|
if got.Queued {
|
|
t.Fatalf("new task should not be queued: %+v", got)
|
|
}
|
|
|
|
// queued (concurrency-hold) flag round-trips independently of paused
|
|
if err := d.SetQueued(tk.ID, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if g, _ := d.GetTask(tk.ID); g == nil || !g.Queued {
|
|
t.Fatalf("queued not persisted: %+v", g)
|
|
}
|
|
if err := d.SetQueued(tk.ID, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if g, _ := d.GetTask(tk.ID); g == nil || g.Queued {
|
|
t.Fatalf("queued not cleared: %+v", g)
|
|
}
|
|
|
|
// list contains it
|
|
list, _ := d.ListTasks()
|
|
found := false
|
|
for _, x := range list {
|
|
if x.ID == tk.ID {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("task not in list")
|
|
}
|
|
|
|
// delete cascades exploration subgraph
|
|
if err := d.DeleteTask(tk.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if g, _ := d.GetTask(tk.ID); g != nil {
|
|
t.Fatalf("task should be gone")
|
|
}
|
|
var nodes int
|
|
d.QueryRow(`SELECT count(*) FROM exploration_nodes WHERE exploration_id=$1`, tk.ExplorationID).Scan(&nodes)
|
|
if nodes != 0 {
|
|
t.Fatalf("exploration nodes should be cascade-deleted, got %d", nodes)
|
|
}
|
|
var exps int
|
|
d.QueryRow(`SELECT count(*) FROM explorations WHERE id=$1`, tk.ExplorationID).Scan(&exps)
|
|
if exps != 0 {
|
|
t.Fatalf("exploration should be deleted, got %d", exps)
|
|
}
|
|
}
|
|
|
|
func TestTaskDeleteCascadeAssets(t *testing.T) {
|
|
d, err := Open(testDSN(t))
|
|
if err != nil {
|
|
t.Skipf("postgres unavailable (%v) — skipping", err)
|
|
}
|
|
defer d.Close()
|
|
|
|
first, err := d.CreateTask("级联删除测试", "目标A", nil, 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := d.CreateTask("共享资产保留测试", "目标B", nil, 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assets := d.Assets()
|
|
exclusiveID, err := assets.UpsertRootDomain(UpsertRootDomainReq{
|
|
Domain: fmt.Sprintf("delete-%d.example.test", first.ID),
|
|
TaskID: first.ID,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sharedDomain := fmt.Sprintf("shared-%d.example.test", first.ID)
|
|
sharedID, err := assets.UpsertRootDomain(UpsertRootDomainReq{Domain: sharedDomain, TaskID: first.ID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := assets.UpsertRootDomain(UpsertRootDomainReq{Domain: sharedDomain, TaskID: second.ID}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
anchorOnlyDomain := fmt.Sprintf("anchor-only-%d.example.test", first.ID)
|
|
anchorOnlyID, err := assets.UpsertRootDomain(UpsertRootDomainReq{Domain: anchorOnlyDomain})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
firstOrigin, err := d.Exploration(first.ExplorationID).OriginFactID()
|
|
if err != nil || firstOrigin == 0 {
|
|
t.Fatalf("first origin: id=%d err=%v", firstOrigin, err)
|
|
}
|
|
if err := d.Exploration(first.ExplorationID).Anchor(firstOrigin, anchorOnlyID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
protectedDomain := fmt.Sprintf("other-anchor-%d.example.test", first.ID)
|
|
protectedID, err := assets.UpsertRootDomain(UpsertRootDomainReq{Domain: protectedDomain, TaskID: first.ID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
secondOrigin, err := d.Exploration(second.ExplorationID).OriginFactID()
|
|
if err != nil || secondOrigin == 0 {
|
|
t.Fatalf("second origin: id=%d err=%v", secondOrigin, err)
|
|
}
|
|
if err := d.Exploration(second.ExplorationID).Anchor(secondOrigin, protectedID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = d.DeleteTask(first.ID)
|
|
_ = d.DeleteTask(second.ID)
|
|
_, _ = assets.DeleteByIDs([]int64{exclusiveID, sharedID, anchorOnlyID, protectedID})
|
|
})
|
|
|
|
hosts, err := assets.HostsByTask(first.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hostSet := make(map[string]bool, len(hosts))
|
|
for _, host := range hosts {
|
|
hostSet[host] = true
|
|
}
|
|
if !hostSet[fmt.Sprintf("delete-%d.example.test", first.ID)] || !hostSet[sharedDomain] {
|
|
t.Fatalf("task hosts missing cascade fixtures: %v", hosts)
|
|
}
|
|
deletableHosts, err := assets.HostsForTaskDeletion(first.ID, first.ExplorationID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
deletableSet := make(map[string]bool, len(deletableHosts))
|
|
for _, host := range deletableHosts {
|
|
deletableSet[host] = true
|
|
}
|
|
if !deletableSet[fmt.Sprintf("delete-%d.example.test", first.ID)] || !deletableSet[anchorOnlyDomain] {
|
|
t.Fatalf("exclusive or anchor-only cleanup host missing: %v", deletableHosts)
|
|
}
|
|
if deletableSet[sharedDomain] || deletableSet[protectedDomain] {
|
|
t.Fatalf("shared host was not protected from traffic cleanup: %v", deletableHosts)
|
|
}
|
|
|
|
findingID, err := d.AddFinding(first.ID, 0, "__task_delete_cascade__", "", SeverityHigh, "summary", "evidence", "tester", []int64{exclusiveID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
result, err := d.DeleteTaskCascade(first.ID, true, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.AssetsDeleted < 2 || result.AssetsDetached < 2 {
|
|
t.Fatalf("unexpected asset cleanup result: %+v", result)
|
|
}
|
|
if result.FindingsDeleted != 1 {
|
|
t.Fatalf("unexpected finding cleanup result: %+v", result)
|
|
}
|
|
if finding, err := d.GetFinding(findingID); err != nil || finding != nil {
|
|
t.Fatalf("finding should be deleted, got finding=%+v err=%v", finding, err)
|
|
}
|
|
remaining, err := assets.GetByIDs([]int64{exclusiveID, sharedID, anchorOnlyID, protectedID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(remaining) != 2 {
|
|
t.Fatalf("only shared and other-anchored assets should remain, got %+v", remaining)
|
|
}
|
|
byID := make(map[int64]*Asset, len(remaining))
|
|
for _, asset := range remaining {
|
|
byID[asset.ID] = asset
|
|
}
|
|
if shared := byID[sharedID]; shared == nil || len(shared.TaskIDs) != 1 || shared.TaskIDs[0] != second.ID {
|
|
t.Fatalf("deleted task should be detached from shared asset: %+v", shared)
|
|
}
|
|
if protected := byID[protectedID]; protected == nil || len(protected.TaskIDs) != 0 {
|
|
t.Fatalf("other task's anchor should preserve the asset after detaching ownership: %+v", protected)
|
|
}
|
|
}
|
|
|
|
func TestTaskRelationsAndLLMFailoverChain(t *testing.T) {
|
|
d, err := Open(testDSN(t))
|
|
if err != nil {
|
|
t.Skipf("postgres unavailable (%v) - skipping", err)
|
|
}
|
|
defer d.Close()
|
|
|
|
suffix := time.Now().UnixNano()
|
|
profileIDs := make([]int64, 0, 3)
|
|
for i := 0; i < 3; i++ {
|
|
id, err := d.SaveProfile(&LLMProfile{
|
|
Name: fmt.Sprintf("task-chain-%d-%d", suffix, i), Format: "openai",
|
|
Model: fmt.Sprintf("model-%d", i), APIKey: "test-key",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
profileIDs = append(profileIDs, id)
|
|
}
|
|
|
|
sourceA, err := d.CreateTask("source A", "goal A", nil, 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sourceB, err := d.CreateTask("source B", "goal B", nil, 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
child, err := d.CreateTaskWithOptions("child", "new goal", TaskCreateOptions{
|
|
SourceTaskIDs: []int64{sourceA.ID, sourceB.ID},
|
|
LLMProfileIDs: profileIDs,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = d.DeleteTask(child.ID)
|
|
_ = d.DeleteTask(sourceA.ID)
|
|
_ = d.DeleteTask(sourceB.ID)
|
|
for _, id := range profileIDs {
|
|
_ = d.DeleteProfile(id)
|
|
}
|
|
})
|
|
|
|
got, err := d.GetTask(child.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fmt.Sprint(got.SourceTaskIDs) != fmt.Sprint([]int64{sourceA.ID, sourceB.ID}) {
|
|
t.Fatalf("unexpected direct sources: %v", got.SourceTaskIDs)
|
|
}
|
|
if fmt.Sprint(got.LLMProfileIDs) != fmt.Sprint(profileIDs) || got.ActiveLLMProfileID == nil || *got.ActiveLLMProfileID != profileIDs[0] {
|
|
t.Fatalf("unexpected initial chain: %+v", got)
|
|
}
|
|
initialRevision := got.LLMChainRevision
|
|
if err := d.ReplaceTaskLLMProfiles(child.ID, profileIDs, profileIDs[0]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
staleRevision, err := d.MarkTaskLLMProfileQuotaExhaustedAtRevision(child.ID, profileIDs[0], initialRevision, "late quota from prior generation")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !staleRevision.Stale || staleRevision.Advanced || staleRevision.ChainExhausted {
|
|
t.Fatalf("old chain revision changed replacement chain: %+v", staleRevision)
|
|
}
|
|
got, err = d.GetTask(child.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.LLMChainRevision <= initialRevision || got.ActiveLLMProfileID == nil || *got.ActiveLLMProfileID != profileIDs[0] {
|
|
t.Fatalf("replacement revision/cursor not preserved: %+v", got)
|
|
}
|
|
replacementRevision := got.LLMChainRevision
|
|
|
|
transition, err := d.MarkTaskLLMProfileQuotaExhausted(child.ID, profileIDs[0], "insufficient_quota")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !transition.Advanced || transition.ChainExhausted || transition.NextProfileID == nil || *transition.NextProfileID != profileIDs[1] {
|
|
t.Fatalf("unexpected first transition: %+v", transition)
|
|
}
|
|
got, err = d.GetTask(child.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.LLMChainRevision != replacementRevision+1 {
|
|
t.Fatalf("automatic cursor advance did not increment revision: before=%d after=%d", replacementRevision, got.LLMChainRevision)
|
|
}
|
|
late, err := d.MarkTaskLLMProfileQuotaExhausted(child.ID, profileIDs[0], "late duplicate")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if late.Advanced || late.NextProfileID == nil || *late.NextProfileID != profileIDs[1] {
|
|
t.Fatalf("late failure advanced past current profile: %+v", late)
|
|
}
|
|
if _, err := d.MarkTaskLLMProfileQuotaExhausted(child.ID, profileIDs[1], "quota_exceeded"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
last, err := d.MarkTaskLLMProfileQuotaExhausted(child.ID, profileIDs[2], "余额不足")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !last.Advanced || !last.ChainExhausted || last.NextProfileID != nil {
|
|
t.Fatalf("unexpected exhausted transition: %+v", last)
|
|
}
|
|
duplicateLast, err := d.MarkTaskLLMProfileQuotaExhausted(child.ID, profileIDs[2], "late final duplicate")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if duplicateLast.Advanced || !duplicateLast.ChainExhausted || duplicateLast.NextProfileID != nil {
|
|
t.Fatalf("duplicate final failure must be idempotent: %+v", duplicateLast)
|
|
}
|
|
got, err = d.GetTask(child.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.ActiveLLMProfileID != nil || got.LLMFailoverState != "chain_exhausted" {
|
|
t.Fatalf("chain exhaustion was not persisted: %+v", got)
|
|
}
|
|
|
|
// Two requests can observe the same last active profile. Only the transaction
|
|
// that clears the cursor may report an advance; the late one is idempotent.
|
|
if err := d.ReplaceTaskLLMProfiles(child.ID, []int64{profileIDs[2]}, profileIDs[2]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
transitions := make(chan TaskLLMTransition, 2)
|
|
errs := make(chan error, 2)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 2; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
transition, markErr := d.MarkTaskLLMProfileQuotaExhausted(child.ID, profileIDs[2], "concurrent final quota")
|
|
if markErr != nil {
|
|
errs <- markErr
|
|
return
|
|
}
|
|
transitions <- transition
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(errs)
|
|
close(transitions)
|
|
for markErr := range errs {
|
|
t.Fatal(markErr)
|
|
}
|
|
advanced := 0
|
|
for transition := range transitions {
|
|
if transition.Advanced {
|
|
advanced++
|
|
}
|
|
if !transition.ChainExhausted {
|
|
t.Fatalf("concurrent final transition must report exhausted chain: %+v", transition)
|
|
}
|
|
}
|
|
if advanced != 1 {
|
|
t.Fatalf("last profile advanced %d times, want exactly once", advanced)
|
|
}
|
|
|
|
// Starting manually from the middle consumes only candidates after that cursor.
|
|
// Earlier ready profiles must not be revived by hydration or unrelated deletes.
|
|
if err := d.ReplaceTaskLLMProfiles(child.ID, []int64{profileIDs[2], profileIDs[0], profileIDs[1]}, profileIDs[0]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if step, err := d.MarkTaskLLMProfileQuotaExhausted(child.ID, profileIDs[0], "middle quota"); err != nil || step.NextProfileID == nil || *step.NextProfileID != profileIDs[1] {
|
|
t.Fatalf("middle cursor did not advance to its successor: transition=%+v err=%v", step, err)
|
|
}
|
|
if end, err := d.MarkTaskLLMProfileQuotaExhausted(child.ID, profileIDs[1], "tail quota"); err != nil || !end.ChainExhausted {
|
|
t.Fatalf("tail did not exhaust manual chain: transition=%+v err=%v", end, err)
|
|
}
|
|
got, err = d.GetTask(child.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.ActiveLLMProfileID != nil || got.LLMFailoverState != "chain_exhausted" {
|
|
t.Fatalf("hydration revived a profile before the manual cursor: %+v", got)
|
|
}
|
|
unrelatedID, err := d.SaveProfile(&LLMProfile{
|
|
Name: fmt.Sprintf("task-chain-unrelated-%d", suffix), Format: "openai", Model: "other", APIKey: "test-key",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := d.DeleteProfile(unrelatedID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err = d.GetTask(child.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.ActiveLLMProfileID != nil || got.LLMFailoverState != "chain_exhausted" {
|
|
t.Fatalf("deleting an unrelated profile revived an exhausted chain: %+v", got)
|
|
}
|
|
|
|
// A call that finishes after its profile was removed from the chain is stale:
|
|
// preserve the new cursor and surface the original provider error upstream.
|
|
if err := d.ReplaceTaskLLMProfiles(child.ID, []int64{profileIDs[2], profileIDs[1]}, profileIDs[2]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stale, err := d.MarkTaskLLMProfileQuotaExhausted(child.ID, profileIDs[0], "obsolete in-flight quota")
|
|
if err != nil {
|
|
t.Fatalf("removed in-flight profile returned an internal error: %v", err)
|
|
}
|
|
if stale.Advanced || stale.ChainExhausted || stale.NextProfileID != nil {
|
|
t.Fatalf("removed in-flight profile changed the replacement chain: %+v", stale)
|
|
}
|
|
got, err = d.GetTask(child.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.ActiveLLMProfileID == nil || *got.ActiveLLMProfileID != profileIDs[2] {
|
|
t.Fatalf("stale failure changed active replacement profile: %+v", got)
|
|
}
|
|
|
|
if err := d.ReplaceTaskLLMProfiles(child.ID, []int64{profileIDs[2], profileIDs[0], profileIDs[1]}, profileIDs[0]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err = d.GetTask(child.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.ActiveLLMProfileID == nil || *got.ActiveLLMProfileID != profileIDs[0] || got.LLMFailoverState != "ready" || got.LLMFailoverReason != "" {
|
|
t.Fatalf("chain reset did not clear failure state: %+v", got)
|
|
}
|
|
|
|
if err := d.DeleteProfile(profileIDs[0]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err = d.GetTask(child.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.ActiveLLMProfileID == nil || *got.ActiveLLMProfileID != profileIDs[1] {
|
|
t.Fatalf("deleting active profile did not select next ready entry: %+v", got)
|
|
}
|
|
if err := d.DeleteProfile(profileIDs[1]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err = d.GetTask(child.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.ActiveLLMProfileID != nil || len(got.LLMProfileIDs) != 0 || got.LLMFailoverState != "default" {
|
|
t.Fatalf("deleting active chain tail must fall back instead of wrapping: %+v", got)
|
|
}
|
|
|
|
if err := d.DeleteTask(sourceA.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err = d.GetTask(child.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fmt.Sprint(got.SourceTaskIDs) != fmt.Sprint([]int64{sourceB.ID}) {
|
|
t.Fatalf("source delete did not cascade only its relation: %v", got.SourceTaskIDs)
|
|
}
|
|
}
|
|
|
|
func TestTaskContextRejectsDuplicatesAndAllowsTerminalLLMEdits(t *testing.T) {
|
|
d, err := Open(testDSN(t))
|
|
if err != nil {
|
|
t.Skipf("postgres unavailable (%v) - skipping", err)
|
|
}
|
|
defer d.Close()
|
|
|
|
source, err := d.CreateTask("source", "goal", nil, 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = d.DeleteTask(source.ID) })
|
|
if _, err := d.CreateTaskWithOptions("bad", "goal", TaskCreateOptions{SourceTaskIDs: []int64{source.ID, source.ID}}); err == nil {
|
|
t.Fatal("duplicate source task ids should be rejected")
|
|
}
|
|
|
|
// 终态任务仍然可以改 LLM 配置链:任务结束后主 Agent 对话继续走这条链,
|
|
// 链上模型不可用时必须还能换。
|
|
profileID, err := d.SaveProfile(&LLMProfile{
|
|
Name: fmt.Sprintf("terminal-chain-%d", time.Now().UnixNano()), Format: "openai",
|
|
Model: "terminal-model", APIKey: "test-key",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = d.DeleteProfile(profileID) })
|
|
|
|
task, err := d.CreateTask("terminal", "goal", nil, 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
|
|
if err := d.SetStatus(task.ID, "done"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := d.ReplaceTaskLLMProfiles(task.ID, []int64{profileID}, profileID); err != nil {
|
|
t.Fatalf("terminal task LLM edit should be allowed: %v", err)
|
|
}
|
|
got, err := d.GetTask(task.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fmt.Sprint(got.LLMProfileIDs) != fmt.Sprint([]int64{profileID}) {
|
|
t.Fatalf("terminal task chain not persisted: %v", got.LLMProfileIDs)
|
|
}
|
|
if got.ActiveLLMProfileID == nil || *got.ActiveLLMProfileID != profileID {
|
|
t.Fatalf("terminal task active profile not persisted: %v", got.ActiveLLMProfileID)
|
|
}
|
|
if err := d.ReplaceTaskLLMProfiles(task.ID, nil, 0); err != nil {
|
|
t.Fatalf("clearing a terminal task's chain should be allowed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateTaskWithCompanyScopes(t *testing.T) {
|
|
d, err := Open(testDSN(t))
|
|
if err != nil {
|
|
t.Skipf("postgres unavailable (%v) - skipping", err)
|
|
}
|
|
defer d.Close()
|
|
|
|
suffix := time.Now().UnixNano()
|
|
companyA, _, err := d.Companies().UpsertCompany(fmt.Sprintf("Task Company A %d", suffix), "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
companyB, _, err := d.Companies().UpsertCompany(fmt.Sprintf("Task Company B %d", suffix), "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
emptyCompany, _, err := d.Companies().UpsertCompany(fmt.Sprintf("Task Empty Company %d", suffix), "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = d.Exec(`DELETE FROM companies WHERE id IN ($1,$2,$3)`, companyA, companyB, emptyCompany)
|
|
})
|
|
|
|
existingTask, err := d.CreateTask("existing company asset owner", "goal", nil, 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = d.DeleteTask(existingTask.ID) })
|
|
var companyAssetA, companyAssetB int64
|
|
domainA := fmt.Sprintf("task-company-a-%d.example.test", suffix)
|
|
domainB := fmt.Sprintf("task-company-b-%d.example.test", suffix)
|
|
if err := d.QueryRow(`
|
|
INSERT INTO assets(type, domain, root_domain, company_id, company_source, task_ids)
|
|
VALUES ('root_domain',$1,$1,$2,'explicit',ARRAY[$3]::bigint[])
|
|
RETURNING id`, domainA, companyA, existingTask.ID).Scan(&companyAssetA); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := d.QueryRow(`
|
|
INSERT INTO assets(type, domain, root_domain, company_id, company_source)
|
|
VALUES ('root_domain',$1,$1,$2,'explicit')
|
|
RETURNING id`, domainB, companyB).Scan(&companyAssetB); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _, _ = d.Assets().DeleteByIDs([]int64{companyAssetA, companyAssetB}) })
|
|
|
|
task, err := d.CreateTaskWithOptions("company-scoped task", "use company scope", TaskCreateOptions{
|
|
CompanyIDs: []int64{companyA, companyB},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
|
|
if fmt.Sprint(task.CompanyIDs) != fmt.Sprint([]int64{companyA, companyB}) {
|
|
t.Fatalf("creation result company IDs=%v", task.CompanyIDs)
|
|
}
|
|
got, err := d.GetTask(task.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fmt.Sprint(got.CompanyIDs) != fmt.Sprint([]int64{companyA, companyB}) {
|
|
t.Fatalf("hydrated company IDs=%v", got.CompanyIDs)
|
|
}
|
|
var scopeCount int
|
|
if err := d.QueryRow(`SELECT count(*) FROM task_scope WHERE task_id=$1 AND kind='company'`, task.ID).Scan(&scopeCount); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if scopeCount != 2 {
|
|
t.Fatalf("company task scope rows=%d want 2", scopeCount)
|
|
}
|
|
assets, err := d.Assets().QueryByTask(task.ID, "root_domain", 10, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(assets) != 2 {
|
|
t.Fatalf("company task assets=%d want 2: %+v", len(assets), assets)
|
|
}
|
|
assetByID := make(map[int64]*Asset, len(assets))
|
|
for _, asset := range assets {
|
|
assetByID[asset.ID] = asset
|
|
}
|
|
for assetID, companyName := range map[int64]string{
|
|
companyAssetA: fmt.Sprintf("Task Company A %d", suffix),
|
|
companyAssetB: fmt.Sprintf("Task Company B %d", suffix),
|
|
} {
|
|
asset := assetByID[assetID]
|
|
if asset == nil {
|
|
t.Errorf("company asset %d missing from task", assetID)
|
|
continue
|
|
}
|
|
if asset.TaskSource != taskCompanyAssetSource || asset.TaskSourceSummary != "작업 생성 시 연결된 회사: "+companyName {
|
|
t.Errorf("asset %d provenance=%q/%q", assetID, asset.TaskSource, asset.TaskSourceSummary)
|
|
}
|
|
}
|
|
var existingAssociation bool
|
|
if err := d.QueryRow(`SELECT $1=ANY(task_ids) FROM assets WHERE id=$2`, existingTask.ID, companyAssetA).Scan(&existingAssociation); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !existingAssociation {
|
|
t.Fatal("company snapshot removed the asset's existing task association")
|
|
}
|
|
var existingSource string
|
|
if err := d.QueryRow(`SELECT source FROM task_asset_links WHERE task_id=$1 AND asset_id=$2`, existingTask.ID, companyAssetA).Scan(&existingSource); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if existingSource == taskCompanyAssetSource {
|
|
t.Fatalf("company snapshot overwrote another task's provenance: %q", existingSource)
|
|
}
|
|
|
|
emptyTask, err := d.CreateTaskWithOptions("empty company scope", "no current assets", TaskCreateOptions{
|
|
CompanyIDs: []int64{emptyCompany},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if assets, err := d.Assets().QueryByTask(emptyTask.ID, "", 10, 0); err != nil || len(assets) != 0 {
|
|
t.Fatalf("empty company task assets=%+v err=%v", assets, err)
|
|
}
|
|
if err := d.DeleteTask(emptyTask.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
duplicateTask, err := d.CreateTaskWithOptions("duplicate company scope", "deduplicate", TaskCreateOptions{
|
|
CompanyIDs: []int64{companyA, companyA, companyB, companyA},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fmt.Sprint(duplicateTask.CompanyIDs) != fmt.Sprint([]int64{companyA, companyB}) {
|
|
t.Fatalf("company IDs were not normalized: %v", duplicateTask.CompanyIDs)
|
|
}
|
|
if err := d.DeleteTask(duplicateTask.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
badDescription := fmt.Sprintf("invalid-company-%d", suffix)
|
|
if _, err := d.CreateTaskWithOptions(badDescription, "rollback", TaskCreateOptions{CompanyIDs: []int64{companyA, 1 << 62}}); !errors.Is(err, ErrTaskCompanyNotFound) {
|
|
t.Fatalf("missing company error=%v, want %v", err, ErrTaskCompanyNotFound)
|
|
}
|
|
var leaked int
|
|
if err := d.QueryRow(`SELECT count(*) FROM explorations WHERE description=$1`, badDescription).Scan(&leaked); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if leaked != 0 {
|
|
t.Fatalf("failed company association leaked %d exploration rows", leaked)
|
|
}
|
|
}
|
|
|
|
// TestListTasksOrderByIDDesc pins list ordering to id-descending. created_at is
|
|
// deliberately not the sort key: tasks created in the same instant share a
|
|
// timestamp and would reorder between polls; id is unique and monotonic.
|
|
func TestListTasksOrderByIDDesc(t *testing.T) {
|
|
d, err := Open(testDSN(t))
|
|
if err != nil {
|
|
t.Skipf("postgres unavailable (%v) — skipping", err)
|
|
}
|
|
defer d.Close()
|
|
|
|
stamp := time.Now().UnixNano()
|
|
var ids []int64
|
|
for i := range 3 {
|
|
tk, err := d.CreateTask(fmt.Sprintf("order-%d-%d", stamp, i), "goal", nil, 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = d.DeleteTask(tk.ID) })
|
|
ids = append(ids, tk.ID)
|
|
}
|
|
|
|
list, err := d.ListTasks()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Reduce to just the ids created here; other rows may exist in the shared DB.
|
|
mine := map[int64]bool{ids[0]: true, ids[1]: true, ids[2]: true}
|
|
var seen []int64
|
|
for _, task := range list {
|
|
if mine[task.ID] {
|
|
seen = append(seen, task.ID)
|
|
}
|
|
}
|
|
if len(seen) != 3 {
|
|
t.Fatalf("found %d of the created tasks in the list, want 3", len(seen))
|
|
}
|
|
// Newest (largest id) first.
|
|
if seen[0] != ids[2] || seen[1] != ids[1] || seen[2] != ids[0] {
|
|
t.Fatalf("order=%v, want descending %v", seen, []int64{ids[2], ids[1], ids[0]})
|
|
}
|
|
}
|