Add read-only task context inheritance, ordered task-level LLM failover, live Worker controls, cascading task cleanup, FIFO task concurrency, cancellation diagnostics, UI updates, tests, build script, and changelog.
281 lines
9.2 KiB
Go
281 lines
9.2 KiB
Go
package server
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
pgdb "github.com/Autumn-27/artex/db"
|
|
"github.com/Autumn-27/artex/traffic"
|
|
)
|
|
|
|
func TestSeedAssociatesTargetAssetWithTask(t *testing.T) {
|
|
m, err := NewManager(t.TempDir(), "")
|
|
if err != nil {
|
|
t.Skipf("postgres unavailable (%v) - skipping", err)
|
|
}
|
|
defer m.Close()
|
|
task, err := m.CreateTask("seed ownership", "seed ownership", nil, 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = m.DeleteTask(task.ID, DeleteTaskOptions{DeleteAssets: true})
|
|
})
|
|
|
|
host := fmt.Sprintf("seed-%s.example.test", task.ID)
|
|
s := &Server{m: m}
|
|
s.seed(task, "https://"+host)
|
|
taskID := mustTaskID(t, task.ID)
|
|
assets, err := m.Assets().QueryByTask(taskID, "", 20, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found := false
|
|
for _, asset := range assets {
|
|
if asset.Domain == host || asset.URL == "https://"+host {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("seed target was not associated with task %s: %+v", task.ID, assets)
|
|
}
|
|
}
|
|
|
|
func TestDeleteTaskFilesRemovesOnlyOwnedWorkspaceAndTranscripts(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
|
|
removed := []struct {
|
|
path string
|
|
isDir bool
|
|
}{
|
|
{path: filepath.Join("tasks", "77"), isDir: true},
|
|
{path: filepath.Join("transcripts", "exp12-main.jsonl")},
|
|
{path: filepath.Join("transcripts", "exp12-planner.jsonl")},
|
|
{path: filepath.Join("transcripts", "exp12-worker-i9.jsonl")},
|
|
{path: filepath.Join("transcripts", "exp12-main"), isDir: true},
|
|
{path: filepath.Join("transcripts", "exp12-worker-i9"), isDir: true},
|
|
}
|
|
kept := []struct {
|
|
path string
|
|
isDir bool
|
|
}{
|
|
{path: filepath.Join("tasks", "78"), isDir: true},
|
|
{path: filepath.Join("transcripts", "exp123-main.jsonl")},
|
|
{path: filepath.Join("transcripts", "exp123-main"), isDir: true},
|
|
{path: filepath.Join("transcripts", "exp1-main.jsonl")},
|
|
{path: filepath.Join("transcripts", "exp12main.jsonl")},
|
|
{path: filepath.Join("transcripts", "exp12-main.jsonl.bak")},
|
|
{path: filepath.Join("transcripts", "exp12-main.txt")},
|
|
{path: filepath.Join("transcripts", "conv-12.jsonl")},
|
|
{path: filepath.Join("transcripts", "unrelated-session"), isDir: true},
|
|
}
|
|
|
|
for _, fixture := range append(removed, kept...) {
|
|
createDeleteFixture(t, dataDir, fixture.path, fixture.isDir)
|
|
}
|
|
|
|
deleted, err := deleteTaskFiles(dataDir, "77", 12)
|
|
if err != nil {
|
|
t.Fatalf("delete task files: %v", err)
|
|
}
|
|
if !deleted {
|
|
t.Fatal("expected deletion to be reported")
|
|
}
|
|
for _, fixture := range removed {
|
|
assertPathMissing(t, filepath.Join(dataDir, fixture.path))
|
|
}
|
|
for _, fixture := range kept {
|
|
assertPathExists(t, filepath.Join(dataDir, fixture.path))
|
|
}
|
|
}
|
|
|
|
func TestDeleteTaskFilesReportsTranscriptOnlyDeletion(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
path := filepath.Join("transcripts", "exp44-main.jsonl")
|
|
createDeleteFixture(t, dataDir, path, false)
|
|
|
|
deleted, err := deleteTaskFiles(dataDir, "missing-task", 44)
|
|
if err != nil {
|
|
t.Fatalf("delete task files: %v", err)
|
|
}
|
|
if !deleted {
|
|
t.Fatal("expected transcript deletion to be reported")
|
|
}
|
|
assertPathMissing(t, filepath.Join(dataDir, path))
|
|
}
|
|
|
|
func TestDeleteTaskFilesMissingTargetsIsIdempotent(t *testing.T) {
|
|
deleted, err := deleteTaskFiles(t.TempDir(), "77", 12)
|
|
if err != nil {
|
|
t.Fatalf("delete missing task files: %v", err)
|
|
}
|
|
if deleted {
|
|
t.Fatal("missing targets must not be reported as deleted")
|
|
}
|
|
}
|
|
|
|
func TestStageTaskFilesRollbackRestoresWorkspaceAndTranscripts(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
paths := []string{
|
|
filepath.Join("tasks", "77", "notes.txt"),
|
|
filepath.Join("transcripts", "exp12-main.jsonl"),
|
|
filepath.Join("transcripts", "exp12-worker-i9", "sidechain.jsonl"),
|
|
}
|
|
for _, path := range paths {
|
|
createDeleteFixture(t, dataDir, path, false)
|
|
}
|
|
|
|
stage, err := stageTaskFiles(dataDir, "77", 12)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !stage.deleted {
|
|
t.Fatal("expected files to be staged")
|
|
}
|
|
for _, path := range paths {
|
|
assertPathMissing(t, filepath.Join(dataDir, path))
|
|
}
|
|
if err := stage.rollback(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, path := range paths {
|
|
assertPathExists(t, filepath.Join(dataDir, path))
|
|
}
|
|
}
|
|
|
|
func TestStageTaskFilesRollbackReportsRestoreFailure(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
workspace := filepath.Join(dataDir, "tasks", "77")
|
|
createDeleteFixture(t, dataDir, filepath.Join("tasks", "77"), true)
|
|
|
|
stage, err := stageTaskFiles(dataDir, "77", 12)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(workspace, []byte("conflict"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cause := errors.New("forced PostgreSQL delete failure")
|
|
err = rollbackTaskDelete(cause, nil, stage)
|
|
if err == nil || !errors.Is(err, cause) || !strings.Contains(err.Error(), "destination already exists") {
|
|
t.Fatalf("rollback err=%v, want original and restore errors", err)
|
|
}
|
|
if _, statErr := os.Stat(stage.stageDir); statErr != nil {
|
|
t.Fatalf("staging was removed after failed restore: %v", statErr)
|
|
}
|
|
}
|
|
|
|
func TestManagerDeleteTaskRestoresFilesAndTrafficWhenPostgresDeleteFails(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
m, err := NewManager(dataDir, "")
|
|
if err != nil {
|
|
t.Skipf("postgres unavailable (%v) - skipping", err)
|
|
}
|
|
t.Cleanup(func() { _ = m.Close() })
|
|
|
|
task, err := m.CreateTask("delete rollback", "delete rollback", nil, 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
taskID := mustTaskID(t, task.ID)
|
|
triggerName := fmt.Sprintf("test_fail_task_delete_%d", taskID)
|
|
functionName := triggerName + "_fn"
|
|
cleanupDB := func() {
|
|
_, _ = m.pg.Exec(fmt.Sprintf(`DROP TRIGGER IF EXISTS %s ON tasks`, triggerName))
|
|
_, _ = m.pg.Exec(fmt.Sprintf(`DROP FUNCTION IF EXISTS %s()`, functionName))
|
|
_, _ = m.DeleteTask(task.ID, DeleteTaskOptions{DeleteAssets: true, DeleteTraffic: true, DeleteFiles: true})
|
|
}
|
|
t.Cleanup(cleanupDB)
|
|
|
|
host := fmt.Sprintf("delete-rollback-%d.example.test", taskID)
|
|
if _, err := m.Assets().UpsertHTTPService(pgdb.UpsertHTTPServiceReq{URL: "https://" + host, TaskID: taskID}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
createDeleteFixture(t, dataDir, filepath.Join("tasks", task.ID), true)
|
|
transcriptPath := filepath.Join(dataDir, "transcripts", fmt.Sprintf("exp%d-main.jsonl", task.ExpID))
|
|
createDeleteFixture(t, dataDir, filepath.Join("transcripts", filepath.Base(transcriptPath)), false)
|
|
|
|
tr, err := traffic.Open(filepath.Join(dataDir, "traffic"), "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.traffic = tr
|
|
trafficTree := filepath.Join(dataDir, "traffic", host, "GET", "1-0001")
|
|
if err := os.MkdirAll(trafficTree, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
trafficMarker := filepath.Join(trafficTree, "request.http")
|
|
if err := os.WriteFile(trafficMarker, []byte("original traffic"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := tr.DB().Exec(`INSERT INTO exchanges(id,ts,host,method,url_template,url,status,content_type,req_len,resp_len,path)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, "1-0001", 1, host, "GET", "/", "https://"+host+"/", 200, "text/plain", 0, 0, host+"/GET/1-0001"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := m.pg.Exec(fmt.Sprintf(`CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql AS $body$
|
|
BEGIN RAISE EXCEPTION 'forced task delete failure'; END $body$`, functionName)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := m.pg.Exec(fmt.Sprintf(`CREATE TRIGGER %s BEFORE DELETE ON tasks
|
|
FOR EACH ROW WHEN (OLD.id = %d) EXECUTE FUNCTION %s()`, triggerName, taskID, functionName)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = m.DeleteTask(task.ID, DeleteTaskOptions{DeleteTraffic: true, DeleteFiles: true})
|
|
if err == nil || !strings.Contains(err.Error(), "forced task delete failure") {
|
|
t.Fatalf("delete err=%v, want injected PostgreSQL failure", err)
|
|
}
|
|
if got, err := m.pg.GetTask(taskID); err != nil || got == nil {
|
|
t.Fatalf("task row was lost after failed delete: task=%+v err=%v", got, err)
|
|
}
|
|
assertPathExists(t, filepath.Join(dataDir, "tasks", task.ID))
|
|
assertPathExists(t, transcriptPath)
|
|
if got, err := os.ReadFile(trafficMarker); err != nil || string(got) != "original traffic" {
|
|
t.Fatalf("traffic tree was not restored: content=%q err=%v", got, err)
|
|
}
|
|
var trafficRows int
|
|
if err := tr.DB().QueryRow(`SELECT COUNT(*) FROM exchanges WHERE host=?`, host).Scan(&trafficRows); err != nil || trafficRows != 1 {
|
|
t.Fatalf("traffic index was not restored: rows=%d err=%v", trafficRows, err)
|
|
}
|
|
}
|
|
|
|
func createDeleteFixture(t *testing.T, root, relative string, isDir bool) {
|
|
t.Helper()
|
|
path := filepath.Join(root, relative)
|
|
if isDir {
|
|
if err := os.MkdirAll(filepath.Join(path, "subagents"), 0o755); err != nil {
|
|
t.Fatalf("create fixture directory %s: %v", path, err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(path, "subagents", "agent-test.jsonl"), []byte("test"), 0o644); err != nil {
|
|
t.Fatalf("create fixture sidechain %s: %v", path, err)
|
|
}
|
|
return
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatalf("create fixture parent %s: %v", path, err)
|
|
}
|
|
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
|
|
t.Fatalf("create fixture file %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func assertPathMissing(t *testing.T, path string) {
|
|
t.Helper()
|
|
if _, err := os.Lstat(path); !os.IsNotExist(err) {
|
|
t.Fatalf("expected %s to be removed, stat err=%v", path, err)
|
|
}
|
|
}
|
|
|
|
func assertPathExists(t *testing.T, path string) {
|
|
t.Helper()
|
|
if _, err := os.Lstat(path); err != nil {
|
|
t.Fatalf("expected %s to remain: %v", path, err)
|
|
}
|
|
}
|