Files
zeekayandHanzo Dev 11eb5ff1ba tasks: an org is one value, not a variadic
`orgID ...string` on 17 methods. The concept is "one org, or none"; the type
said "any number of orgs", so ClaimTask(id, agent, "a", "b", "c") compiled and
silently honoured "a". A parameter that accepts a list it will ignore is a
parameter that cannot be read correctly.

Worse than what it permitted is what it CONCEALED. Omitting a variadic looks
like nothing at all, so "unscoped" was never written down anywhere — and three
call sites turned out to be unscoped without anyone having decided that:

  dependenciesMet  read dependency tasks unscoped from inside a scoped
                   operation. Left unscoped, now stated: a dependency is a
                   property of the task graph, not of tenancy, so a blocker in
                   another org still blocks.
  GetNextTask      signalled the claim unscoped though it had the org in hand.
                   Now passes it.
  apis/tasks.go    read back tasks unscoped that it had just CREATED with an
                   org, two statements earlier.

Now `org string`, one `scoped()` helper replacing seven copies of
`if len(orgID) > 0 && orgID[0] != ""`, and a named `allOrgs` so a call site says
which it meant instead of leaving a blank. The in-process scheduler and the
timeout sweep are genuinely unscoped and now say so.

Also corrects a claim I put in this file myself: the org clause is NOT an
authorization boundary. Every route reaching these methods is behind
RequireSuperuserAuth and the value comes from a caller-supplied X-Org-Id header
— it narrows what an already-privileged caller sees. A comment asserting a
security property that does not hold is worse than no comment, because someone
will lean on it.

And clientForOrgVar -> clientFor: the suffix described the calling convention of
its argument rather than anything about the client returned.

13 tests pass including both org-isolation cases; package green under -race.
(apis/ fails -race on TestCollectionsList both with and without this change —
pre-existing, unrelated.)

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-26 23:41:59 -07:00

547 lines
14 KiB
Go

package tasks
import (
"os"
"testing"
"github.com/hanzoai/base/core"
_ "github.com/hanzoai/base/migrations"
)
// newTestApp creates a minimal base app for testing (no import of tests package to avoid cycles).
func newTestApp(t *testing.T) core.App {
t.Helper()
dir, err := os.MkdirTemp("", "tasks_test_*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
app := core.NewBaseApp(core.BaseAppConfig{
DataDir: dir,
EncryptionEnv: "test_enc_key",
})
if err := Register(app, Config{}); err != nil {
t.Fatal(err)
}
if err := app.Bootstrap(); err != nil {
t.Fatal(err)
}
return app
}
func TestCreateAndGetTask(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
if s == nil {
t.Fatal("store not registered")
}
task := &Task{
SpaceID: "space-1",
Title: "Build the thing",
}
if err := s.CreateTask(task); err != nil {
t.Fatal(err)
}
if task.ID == "" {
t.Fatal("expected auto-generated ID")
}
if task.State != TaskPending {
t.Fatalf("expected pending, got %s", task.State)
}
got, err := s.GetTask(task.ID, allOrgs)
if err != nil {
t.Fatal(err)
}
if got.Title != "Build the thing" {
t.Fatalf("expected 'Build the thing', got %q", got.Title)
}
if got.SpaceID != "space-1" {
t.Fatalf("expected space-1, got %q", got.SpaceID)
}
}
func TestClaimTask(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
task := &Task{SpaceID: "s1", Title: "Claimable"}
if err := s.CreateTask(task); err != nil {
t.Fatal(err)
}
if err := s.ClaimTask(task.ID, "agent-1", allOrgs); err != nil {
t.Fatal(err)
}
// Second claim should fail.
if err := s.ClaimTask(task.ID, "agent-2", allOrgs); err != ErrAlreadyClaimed {
t.Fatalf("expected ErrAlreadyClaimed, got %v", err)
}
got, _ := s.GetTask(task.ID, allOrgs)
if got.State != TaskClaimed {
t.Fatalf("expected claimed, got %s", got.State)
}
if got.AssignedTo != "agent-1" {
t.Fatalf("expected agent-1, got %s", got.AssignedTo)
}
}
func TestCompleteTask(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
task := &Task{SpaceID: "s1", Title: "Completable"}
_ = s.CreateTask(task)
_ = s.ClaimTask(task.ID, "agent-1", allOrgs)
_ = s.StartTask(task.ID, allOrgs)
output := map[string]any{"result": "success"}
if err := s.CompleteTask(task.ID, output, allOrgs); err != nil {
t.Fatal(err)
}
got, _ := s.GetTask(task.ID, allOrgs)
if got.State != TaskCompleted {
t.Fatalf("expected completed, got %s", got.State)
}
if got.Progress != 100 {
t.Fatalf("expected progress 100, got %d", got.Progress)
}
if got.CompletedAt == nil {
t.Fatal("expected completedAt to be set")
}
}
func TestFailTaskWithRetry(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
task := &Task{SpaceID: "s1", Title: "Retriable", MaxRetries: 2}
_ = s.CreateTask(task)
_ = s.ClaimTask(task.ID, "agent-1", allOrgs)
_ = s.StartTask(task.ID, allOrgs)
// First failure should re-queue.
if err := s.FailTask(task.ID, "oops", allOrgs); err != nil {
t.Fatal(err)
}
got, _ := s.GetTask(task.ID, allOrgs)
if got.State != TaskPending {
t.Fatalf("expected re-queued to pending, got %s", got.State)
}
if got.RetryCount != 1 {
t.Fatalf("expected retryCount 1, got %d", got.RetryCount)
}
// Second cycle.
_ = s.ClaimTask(task.ID, "agent-2", allOrgs)
_ = s.StartTask(task.ID, allOrgs)
_ = s.FailTask(task.ID, "oops again", allOrgs)
got, _ = s.GetTask(task.ID, allOrgs)
if got.State != TaskPending {
t.Fatalf("expected re-queued, got %s", got.State)
}
if got.RetryCount != 2 {
t.Fatalf("expected retryCount 2, got %d", got.RetryCount)
}
// Third failure should be terminal.
_ = s.ClaimTask(task.ID, "agent-3", allOrgs)
_ = s.StartTask(task.ID, allOrgs)
_ = s.FailTask(task.ID, "final fail", allOrgs)
got, _ = s.GetTask(task.ID, allOrgs)
if got.State != TaskFailed {
t.Fatalf("expected terminal failed, got %s", got.State)
}
}
func TestCancelTask(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
task := &Task{SpaceID: "s1", Title: "Cancellable"}
_ = s.CreateTask(task)
if err := s.CancelTask(task.ID, allOrgs); err != nil {
t.Fatal(err)
}
got, _ := s.GetTask(task.ID, allOrgs)
if got.State != TaskCancelled {
t.Fatalf("expected cancelled, got %s", got.State)
}
// Can't cancel again.
if err := s.CancelTask(task.ID, allOrgs); err != ErrInvalidTransition {
t.Fatalf("expected ErrInvalidTransition, got %v", err)
}
}
func TestGetNextPendingTask(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
low := &Task{SpaceID: "s1", Title: "Low", Priority: PriorityLow}
high := &Task{SpaceID: "s1", Title: "High", Priority: PriorityHigh}
_ = s.CreateTask(low)
_ = s.CreateTask(high)
// Should get high-priority first.
next, err := s.GetNextPendingTask("s1", "agent-1", allOrgs)
if err != nil {
t.Fatal(err)
}
if next == nil {
t.Fatal("expected a task")
}
if next.Title != "High" {
t.Fatalf("expected High, got %q", next.Title)
}
if next.State != TaskClaimed {
t.Fatalf("expected claimed, got %s", next.State)
}
// Next should get low.
next, _ = s.GetNextPendingTask("s1", "agent-2", allOrgs)
if next == nil || next.Title != "Low" {
t.Fatalf("expected Low, got %v", next)
}
// No more.
next, _ = s.GetNextPendingTask("s1", "agent-3", allOrgs)
if next != nil {
t.Fatalf("expected nil, got %+v", next)
}
}
func TestDependencyBlocking(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
a := &Task{SpaceID: "s1", Title: "Task A"}
_ = s.CreateTask(a)
b := &Task{SpaceID: "s1", Title: "Task B", DependsOn: []string{a.ID}}
_ = s.CreateTask(b)
// Next should return A (B blocked).
next, _ := s.GetNextPendingTask("s1", "agent-1", allOrgs)
if next == nil || next.Title != "Task A" {
t.Fatal("expected Task A")
}
// B still blocked (A claimed, not completed).
next, _ = s.GetNextPendingTask("s1", "agent-2", allOrgs)
if next != nil {
t.Fatalf("expected nil (B blocked), got %q", next.Title)
}
// Complete A.
_ = s.StartTask(a.ID, allOrgs)
_ = s.CompleteTask(a.ID, nil, allOrgs)
// Now B available.
next, _ = s.GetNextPendingTask("s1", "agent-2", allOrgs)
if next == nil || next.Title != "Task B" {
t.Fatal("expected Task B after A completed")
}
}
func TestListTasksFiltered(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
_ = s.CreateTask(&Task{SpaceID: "s1", Title: "T1", Priority: PriorityLow})
_ = s.CreateTask(&Task{SpaceID: "s1", Title: "T2", Priority: PriorityHigh})
_ = s.CreateTask(&Task{SpaceID: "s2", Title: "T3", Priority: PriorityNormal})
items, _ := s.ListTasks(TaskFilters{SpaceID: "s1"})
if len(items) != 2 {
t.Fatalf("expected 2, got %d", len(items))
}
if items[0].Title != "T2" {
t.Fatalf("expected T2 first (higher priority), got %q", items[0].Title)
}
pending := TaskPending
items, _ = s.ListTasks(TaskFilters{SpaceID: "s2", State: &pending})
if len(items) != 1 {
t.Fatalf("expected 1 pending in s2, got %d", len(items))
}
}
func TestWorkflowLifecycle(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
wf := &Workflow{SpaceID: "s1", Name: "Pipeline"}
if err := s.CreateWorkflow(wf); err != nil {
t.Fatal(err)
}
if wf.ID == "" {
t.Fatal("expected auto-generated ID")
}
got, err := s.GetWorkflow(wf.ID, allOrgs)
if err != nil {
t.Fatal(err)
}
if got.Name != "Pipeline" {
t.Fatalf("expected Pipeline, got %q", got.Name)
}
if got.State != TaskPending {
t.Fatalf("expected pending, got %s", got.State)
}
items, _ := s.ListWorkflows("s1", allOrgs)
if len(items) != 1 {
t.Fatalf("expected 1, got %d", len(items))
}
}
func TestWorkflowAdvancement(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
// Create workflow with 2 tasks.
t1 := &Task{SpaceID: "s1", Title: "Step 1"}
_ = s.CreateTask(t1)
t2 := &Task{SpaceID: "s1", Title: "Step 2", DependsOn: []string{t1.ID}}
_ = s.CreateTask(t2)
wf := &Workflow{SpaceID: "s1", Name: "Pipeline", Tasks: []string{t1.ID, t2.ID}}
_ = s.CreateWorkflow(wf)
_ = s.UpdateWorkflowTasks(wf)
// Advance: should go from pending → running.
_ = s.AdvanceWorkflows()
got, _ := s.GetWorkflow(wf.ID, allOrgs)
if got.State != TaskRunning {
t.Fatalf("expected running, got %s", got.State)
}
// Complete both tasks.
_ = s.ClaimTask(t1.ID, "a", allOrgs)
_ = s.StartTask(t1.ID, allOrgs)
_ = s.CompleteTask(t1.ID, nil, allOrgs)
_ = s.ClaimTask(t2.ID, "a", allOrgs)
_ = s.StartTask(t2.ID, allOrgs)
_ = s.CompleteTask(t2.ID, nil, allOrgs)
// Advance: should complete workflow.
_ = s.AdvanceWorkflows()
got, _ = s.GetWorkflow(wf.ID, allOrgs)
if got.State != TaskCompleted {
t.Fatalf("expected completed, got %s", got.State)
}
if got.CompletedAt == nil {
t.Fatal("expected completedAt set")
}
}
func TestOrgIsolation(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
// Create tasks in two different orgs.
taskA := &Task{OrgID: "org-alpha", SpaceID: "s1", Title: "Alpha task"}
taskB := &Task{OrgID: "org-beta", SpaceID: "s1", Title: "Beta task"}
if err := s.CreateTask(taskA); err != nil {
t.Fatal(err)
}
if err := s.CreateTask(taskB); err != nil {
t.Fatal(err)
}
// ListTasks scoped to org-alpha should only return alpha task.
items, err := s.ListTasks(TaskFilters{OrgID: "org-alpha"})
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].Title != "Alpha task" {
t.Fatalf("expected 1 alpha task, got %d", len(items))
}
// GetTask with wrong org should fail.
_, err = s.GetTask(taskA.ID, "org-beta")
if err != ErrTaskNotFound {
t.Fatalf("expected ErrTaskNotFound for cross-org GetTask, got %v", err)
}
// GetTask with correct org should succeed.
got, err := s.GetTask(taskA.ID, "org-alpha")
if err != nil {
t.Fatal(err)
}
if got.OrgID != "org-alpha" {
t.Fatalf("expected org-alpha, got %q", got.OrgID)
}
// ClaimTask with wrong org should fail silently (0 rows affected).
err = s.ClaimTask(taskA.ID, "agent-1", "org-beta")
if err != ErrTaskNotFound {
t.Fatalf("expected ErrTaskNotFound for cross-org ClaimTask, got %v", err)
}
// ClaimTask with correct org should succeed.
if err := s.ClaimTask(taskA.ID, "agent-1", "org-alpha"); err != nil {
t.Fatalf("expected claim to succeed for correct org, got %v", err)
}
// StartTask with wrong org should fail.
err = s.StartTask(taskA.ID, "org-beta")
if err != ErrTaskNotFound {
t.Fatalf("expected ErrTaskNotFound for cross-org StartTask, got %v", err)
}
// StartTask with correct org should succeed.
if err := s.StartTask(taskA.ID, "org-alpha"); err != nil {
t.Fatal(err)
}
// CompleteTask with wrong org should fail.
err = s.CompleteTask(taskA.ID, nil, "org-beta")
if err != ErrTaskNotFound {
t.Fatalf("expected ErrTaskNotFound for cross-org CompleteTask, got %v", err)
}
// CompleteTask with correct org should succeed.
if err := s.CompleteTask(taskA.ID, nil, "org-alpha"); err != nil {
t.Fatal(err)
}
// CancelTask with wrong org should fail.
err = s.CancelTask(taskB.ID, "org-alpha")
if err != ErrTaskNotFound {
t.Fatalf("expected ErrTaskNotFound for cross-org CancelTask, got %v", err)
}
// CancelTask with correct org should succeed.
if err := s.CancelTask(taskB.ID, "org-beta"); err != nil {
t.Fatal(err)
}
// GetNextPendingTask scoped to org returns only that org's tasks.
taskC := &Task{OrgID: "org-gamma", SpaceID: "s1", Title: "Gamma task"}
taskD := &Task{OrgID: "org-delta", SpaceID: "s1", Title: "Delta task"}
_ = s.CreateTask(taskC)
_ = s.CreateTask(taskD)
next, err := s.GetNextPendingTask("s1", "agent-2", "org-gamma")
if err != nil {
t.Fatal(err)
}
if next == nil || next.OrgID != "org-gamma" {
t.Fatalf("expected org-gamma task, got %v", next)
}
}
func TestWorkflowOrgIsolation(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
wfA := &Workflow{OrgID: "org-alpha", SpaceID: "s1", Name: "Alpha WF"}
wfB := &Workflow{OrgID: "org-beta", SpaceID: "s1", Name: "Beta WF"}
if err := s.CreateWorkflow(wfA); err != nil {
t.Fatal(err)
}
if err := s.CreateWorkflow(wfB); err != nil {
t.Fatal(err)
}
// GetWorkflow with wrong org should fail.
_, err := s.GetWorkflow(wfA.ID, "org-beta")
if err != ErrWorkflowNotFound {
t.Fatalf("expected ErrWorkflowNotFound for cross-org GetWorkflow, got %v", err)
}
// GetWorkflow with correct org should succeed.
got, err := s.GetWorkflow(wfA.ID, "org-alpha")
if err != nil {
t.Fatal(err)
}
if got.OrgID != "org-alpha" {
t.Fatalf("expected org-alpha, got %q", got.OrgID)
}
// ListWorkflows scoped to org-alpha should only return alpha workflow.
items, err := s.ListWorkflows("s1", "org-alpha")
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].Name != "Alpha WF" {
t.Fatalf("expected 1 alpha workflow, got %d", len(items))
}
// ListWorkflows without org should return both.
all, err := s.ListWorkflows("s1", allOrgs)
if err != nil {
t.Fatal(err)
}
if len(all) != 2 {
t.Fatalf("expected 2 workflows, got %d", len(all))
}
}
// TestAgentHasActiveTask covers the predicate in both directions, and in
// particular the NO-match case.
//
// It had no test at all, which is how a rewrite of it reached a green suite:
// "no rows matched" and "the query failed" are different answers that a
// single-row read can easily conflate, and only the negative case can tell
// whether the query returns a false or an error.
func TestAgentHasActiveTask(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
// Nothing claimed by anyone yet: false, and NOT an error.
active, err := s.AgentHasActiveTask("agent-1", allOrgs)
if err != nil {
t.Fatalf("no active task should be a clean false, got error: %v", err)
}
if active {
t.Fatal("expected no active task")
}
task := &Task{SpaceID: "s1", Title: "work"}
if err := s.CreateTask(task); err != nil {
t.Fatal(err)
}
// Pending is not active — it is claimed/running that count.
if active, err := s.AgentHasActiveTask("agent-1", allOrgs); err != nil || active {
t.Fatalf("pending task must not read as active (active=%v err=%v)", active, err)
}
if err := s.ClaimTask(task.ID, "agent-1", allOrgs); err != nil {
t.Fatal(err)
}
if active, err := s.AgentHasActiveTask("agent-1", allOrgs); err != nil || !active {
t.Fatalf("claimed task should be active (active=%v err=%v)", active, err)
}
// Scoped to the agent, not global.
if active, err := s.AgentHasActiveTask("agent-2", allOrgs); err != nil || active {
t.Fatalf("another agent must not see it (active=%v err=%v)", active, err)
}
// And terminal state clears it.
if err := s.StartTask(task.ID, allOrgs); err != nil {
t.Fatal(err)
}
if err := s.CompleteTask(task.ID, map[string]any{"ok": true}, allOrgs); err != nil {
t.Fatal(err)
}
if active, err := s.AgentHasActiveTask("agent-1", allOrgs); err != nil || active {
t.Fatalf("completed task must not read as active (active=%v err=%v)", active, err)
}
}