diff --git a/pkg/agentic/plan_logic_test.go b/pkg/agentic/plan_logic_test.go new file mode 100644 index 0000000..fe6179b --- /dev/null +++ b/pkg/agentic/plan_logic_test.go @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- planPath --- + +func TestPlanPath_Good_BasicFormat(t *testing.T) { + result := planPath("/tmp/plans", "my-plan-abc123") + assert.Equal(t, "/tmp/plans/my-plan-abc123.json", result) +} + +func TestPlanPath_Good_NestedIDStripped(t *testing.T) { + // PathBase strips directory component — prevents path traversal + result := planPath("/plans", "../../../etc/passwd") + assert.Equal(t, "/plans/passwd.json", result) +} + +func TestPlanPath_Good_SimpleID(t *testing.T) { + assert.Equal(t, "/data/test.json", planPath("/data", "test")) +} + +func TestPlanPath_Good_SlugWithDashes(t *testing.T) { + assert.Equal(t, "/root/migrate-core-abc123.json", planPath("/root", "migrate-core-abc123")) +} + +func TestPlanPath_Bad_DotID(t *testing.T) { + // "." is sanitised to "invalid" to prevent exploiting the root directory + result := planPath("/plans", ".") + assert.Equal(t, "/plans/invalid.json", result) +} + +func TestPlanPath_Bad_DoubleDotID(t *testing.T) { + result := planPath("/plans", "..") + assert.Equal(t, "/plans/invalid.json", result) +} + +func TestPlanPath_Bad_EmptyID(t *testing.T) { + result := planPath("/plans", "") + assert.Equal(t, "/plans/invalid.json", result) +} + +// --- readPlan / writePlan --- + +func TestReadWritePlan_Good_BasicRoundtrip(t *testing.T) { + dir := t.TempDir() + now := time.Now().Truncate(time.Second) + + plan := &Plan{ + ID: "basic-plan-abc", + Title: "Basic Plan", + Status: "draft", + Repo: "go-io", + Org: "core", + Objective: "Verify round-trip works", + Agent: "claude:opus", + CreatedAt: now, + UpdatedAt: now, + } + + path, err := writePlan(dir, plan) + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "basic-plan-abc.json"), path) + + read, err := readPlan(dir, "basic-plan-abc") + require.NoError(t, err) + + assert.Equal(t, plan.ID, read.ID) + assert.Equal(t, plan.Title, read.Title) + assert.Equal(t, plan.Status, read.Status) + assert.Equal(t, plan.Repo, read.Repo) + assert.Equal(t, plan.Org, read.Org) + assert.Equal(t, plan.Objective, read.Objective) + assert.Equal(t, plan.Agent, read.Agent) +} + +func TestReadWritePlan_Good_WithPhases(t *testing.T) { + dir := t.TempDir() + + plan := &Plan{ + ID: "phase-plan-abc", + Title: "Phased Work", + Status: "in_progress", + Objective: "Multi-phase plan", + Phases: []Phase{ + {Number: 1, Name: "Setup", Status: "done", Criteria: []string{"repo cloned", "deps installed"}, Tests: 3}, + {Number: 2, Name: "Implement", Status: "in_progress", Notes: "WIP"}, + {Number: 3, Name: "Verify", Status: "pending"}, + }, + } + + _, err := writePlan(dir, plan) + require.NoError(t, err) + + read, err := readPlan(dir, "phase-plan-abc") + require.NoError(t, err) + + require.Len(t, read.Phases, 3) + assert.Equal(t, "Setup", read.Phases[0].Name) + assert.Equal(t, "done", read.Phases[0].Status) + assert.Equal(t, []string{"repo cloned", "deps installed"}, read.Phases[0].Criteria) + assert.Equal(t, 3, read.Phases[0].Tests) + assert.Equal(t, "WIP", read.Phases[1].Notes) + assert.Equal(t, "pending", read.Phases[2].Status) +} + +func TestReadPlan_Bad_MissingFile(t *testing.T) { + dir := t.TempDir() + _, err := readPlan(dir, "nonexistent-plan") + assert.Error(t, err) +} + +func TestReadPlan_Bad_CorruptJSON(t *testing.T) { + dir := t.TempDir() + require.True(t, fs.Write(filepath.Join(dir, "bad.json"), `{broken`).OK) + + _, err := readPlan(dir, "bad") + assert.Error(t, err) +} + +func TestWritePlan_Good_CreatesNestedDir(t *testing.T) { + base := t.TempDir() + nested := filepath.Join(base, "deep", "nested", "plans") + + plan := &Plan{ + ID: "deep-plan-xyz", + Title: "Deep", + Status: "draft", + Objective: "Test nested dir creation", + } + + path, err := writePlan(nested, plan) + require.NoError(t, err) + assert.Equal(t, filepath.Join(nested, "deep-plan-xyz.json"), path) + assert.True(t, fs.IsFile(path)) +} + +func TestWritePlan_Good_OverwriteExistingLogic(t *testing.T) { + dir := t.TempDir() + + plan := &Plan{ + ID: "overwrite-plan-abc", + Title: "First Title", + Status: "draft", + Objective: "Initial", + } + _, err := writePlan(dir, plan) + require.NoError(t, err) + + plan.Title = "Second Title" + plan.Status = "approved" + _, err = writePlan(dir, plan) + require.NoError(t, err) + + read, err := readPlan(dir, "overwrite-plan-abc") + require.NoError(t, err) + assert.Equal(t, "Second Title", read.Title) + assert.Equal(t, "approved", read.Status) +} + +func TestReadPlan_Ugly_EmptyFileLogic(t *testing.T) { + dir := t.TempDir() + require.True(t, fs.Write(filepath.Join(dir, "empty.json"), "").OK) + + _, err := readPlan(dir, "empty") + assert.Error(t, err) +} diff --git a/pkg/agentic/queue_logic_test.go b/pkg/agentic/queue_logic_test.go new file mode 100644 index 0000000..c6ce08b --- /dev/null +++ b/pkg/agentic/queue_logic_test.go @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- countRunningByModel --- + +func TestCountRunningByModel_Good_Empty(t *testing.T) { + root := t.TempDir() + t.Setenv("CORE_WORKSPACE", root) + + s := &PrepSubsystem{} + assert.Equal(t, 0, s.countRunningByModel("claude:opus")) +} + +func TestCountRunningByModel_Good_SkipsNonRunning(t *testing.T) { + root := t.TempDir() + t.Setenv("CORE_WORKSPACE", root) + + // Completed workspace — must not be counted + ws := filepath.Join(root, "workspace", "test-ws") + require.True(t, fs.EnsureDir(ws).OK) + require.NoError(t, writeStatus(ws, &WorkspaceStatus{ + Status: "completed", + Agent: "codex:gpt-5.4", + PID: 0, + })) + + s := &PrepSubsystem{} + assert.Equal(t, 0, s.countRunningByModel("codex:gpt-5.4")) +} + +func TestCountRunningByModel_Good_SkipsMismatchedModel(t *testing.T) { + root := t.TempDir() + t.Setenv("CORE_WORKSPACE", root) + + ws := filepath.Join(root, "workspace", "model-ws") + require.True(t, fs.EnsureDir(ws).OK) + require.NoError(t, writeStatus(ws, &WorkspaceStatus{ + Status: "running", + Agent: "gemini:flash", + PID: 0, + })) + + s := &PrepSubsystem{} + // Asking for gemini:pro — must not count gemini:flash + assert.Equal(t, 0, s.countRunningByModel("gemini:pro")) +} + +func TestCountRunningByModel_Good_DeepLayout(t *testing.T) { + root := t.TempDir() + t.Setenv("CORE_WORKSPACE", root) + + // Deep layout: workspace/org/repo/task-N/status.json + ws := filepath.Join(root, "workspace", "core", "go-io", "task-1") + require.True(t, fs.EnsureDir(ws).OK) + require.NoError(t, writeStatus(ws, &WorkspaceStatus{ + Status: "completed", + Agent: "codex:gpt-5.4", + })) + + s := &PrepSubsystem{} + // Completed, so count is still 0 + assert.Equal(t, 0, s.countRunningByModel("codex:gpt-5.4")) +} + +// --- drainQueue --- + +func TestDrainQueue_Good_FrozenReturnsImmediately(t *testing.T) { + root := t.TempDir() + t.Setenv("CORE_WORKSPACE", root) + + s := &PrepSubsystem{frozen: true, backoff: make(map[string]time.Time), failCount: make(map[string]int)} + // Must not panic and must not block + assert.NotPanics(t, func() { + s.drainQueue() + }) +} + +func TestDrainQueue_Good_EmptyWorkspace(t *testing.T) { + root := t.TempDir() + t.Setenv("CORE_WORKSPACE", root) + + s := &PrepSubsystem{frozen: false, backoff: make(map[string]time.Time), failCount: make(map[string]int)} + // No workspaces — must return without error/panic + assert.NotPanics(t, func() { + s.drainQueue() + }) +} + +// --- Poke --- + +func TestPoke_Good_NilChannel(t *testing.T) { + s := &PrepSubsystem{pokeCh: nil} + // Must not panic when pokeCh is nil + assert.NotPanics(t, func() { + s.Poke() + }) +} + +func TestPoke_Good_ChannelReceivesSignal(t *testing.T) { + s := &PrepSubsystem{} + s.pokeCh = make(chan struct{}, 1) + + s.Poke() + assert.Len(t, s.pokeCh, 1, "poke should enqueue one signal") +} + +func TestPoke_Good_NonBlockingWhenFull(t *testing.T) { + s := &PrepSubsystem{} + s.pokeCh = make(chan struct{}, 1) + // Pre-fill the channel + s.pokeCh <- struct{}{} + + // Second poke must not block or panic + assert.NotPanics(t, func() { + s.Poke() + }) + assert.Len(t, s.pokeCh, 1, "channel length should remain 1") +} + +// --- StartRunner --- + +func TestStartRunner_Good_CreatesPokeCh(t *testing.T) { + root := t.TempDir() + t.Setenv("CORE_WORKSPACE", root) + t.Setenv("CORE_AGENT_DISPATCH", "") + + s := NewPrep() + assert.Nil(t, s.pokeCh) + + s.StartRunner() + assert.NotNil(t, s.pokeCh, "StartRunner should initialise pokeCh") +} + +func TestStartRunner_Good_FrozenByDefault(t *testing.T) { + root := t.TempDir() + t.Setenv("CORE_WORKSPACE", root) + t.Setenv("CORE_AGENT_DISPATCH", "") + + s := NewPrep() + s.StartRunner() + assert.True(t, s.frozen, "queue should be frozen by default") +} + +func TestStartRunner_Good_AutoStartEnvVar(t *testing.T) { + root := t.TempDir() + t.Setenv("CORE_WORKSPACE", root) + t.Setenv("CORE_AGENT_DISPATCH", "1") + + s := NewPrep() + s.StartRunner() + assert.False(t, s.frozen, "CORE_AGENT_DISPATCH=1 should unfreeze the queue") +} + +// --- DefaultBranch --- + +func TestDefaultBranch_Good_DefaultsToMain(t *testing.T) { + // Non-git temp dir — git commands fail, fallback is "main" + dir := t.TempDir() + branch := DefaultBranch(dir) + assert.Equal(t, "main", branch) +} + +func TestDefaultBranch_Good_RealGitRepo(t *testing.T) { + dir := t.TempDir() + // Init a real git repo with a main branch + require.NoError(t, runGitInit(dir)) + + branch := DefaultBranch(dir) + // Any valid branch name — just must not panic or be empty + assert.NotEmpty(t, branch) +} + +// --- LocalFs --- + +func TestLocalFs_Good_NonNil(t *testing.T) { + f := LocalFs() + assert.NotNil(t, f, "LocalFs should return a non-nil *core.Fs") +} + +func TestLocalFs_Good_CanRead(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "hello.txt") + require.True(t, fs.Write(path, "hello").OK) + + f := LocalFs() + r := f.Read(path) + assert.True(t, r.OK) + assert.Equal(t, "hello", r.Value.(string)) +} + +// --- helpers --- + +// runGitInit initialises a bare git repo with one commit so branch detection works. +func runGitInit(dir string) error { + cmds := [][]string{ + {"git", "init", "-b", "main"}, + {"git", "config", "user.email", "test@test.com"}, + {"git", "config", "user.name", "Test"}, + {"git", "commit", "--allow-empty", "-m", "init"}, + } + for _, args := range cmds { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + if err := cmd.Run(); err != nil { + return err + } + } + return nil +} diff --git a/pkg/agentic/register_test.go b/pkg/agentic/register_test.go new file mode 100644 index 0000000..ce06b1d --- /dev/null +++ b/pkg/agentic/register_test.go @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "testing" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Register --- + +func TestRegister_Good_ServiceRegistered(t *testing.T) { + t.Setenv("CORE_WORKSPACE", t.TempDir()) + t.Setenv("FORGE_TOKEN", "") + t.Setenv("FORGE_URL", "") + t.Setenv("CORE_BRAIN_KEY", "") + t.Setenv("CORE_BRAIN_URL", "") + + c := core.New(core.WithService(Register)) + require.NotNil(t, c) + + // Service auto-registered under the last segment of the package path: "agentic" + prep, ok := core.ServiceFor[*PrepSubsystem](c, "agentic") + assert.True(t, ok, "PrepSubsystem must be registered as \"agentic\"") + assert.NotNil(t, prep) +} + +func TestRegister_Good_CoreWired(t *testing.T) { + t.Setenv("CORE_WORKSPACE", t.TempDir()) + t.Setenv("FORGE_TOKEN", "") + t.Setenv("FORGE_URL", "") + + c := core.New(core.WithService(Register)) + + prep, ok := core.ServiceFor[*PrepSubsystem](c, "agentic") + require.True(t, ok) + // Register must wire s.core — service needs it for config access + assert.NotNil(t, prep.core, "Register must set prep.core") + assert.Equal(t, c, prep.core) +} + +func TestRegister_Good_AgentsConfigLoaded(t *testing.T) { + t.Setenv("CORE_WORKSPACE", t.TempDir()) + t.Setenv("FORGE_TOKEN", "") + t.Setenv("FORGE_URL", "") + + c := core.New(core.WithService(Register)) + + // Register stores agents.concurrency into Core Config — verify it is present + concurrency := core.ConfigGet[map[string]ConcurrencyLimit](c.Config(), "agents.concurrency") + assert.NotNil(t, concurrency, "Register must store agents.concurrency in Core Config") +} + +// --- OnStartup --- + +func TestOnStartup_Good_CreatesPokeCh(t *testing.T) { + t.Setenv("CORE_WORKSPACE", t.TempDir()) + t.Setenv("CORE_AGENT_DISPATCH", "") + + c := core.New(core.WithOption("name", "test")) + s := NewPrep() + s.SetCore(c) + + assert.Nil(t, s.pokeCh, "pokeCh should be nil before OnStartup") + + err := s.OnStartup(context.Background()) + require.NoError(t, err) + + assert.NotNil(t, s.pokeCh, "OnStartup must initialise pokeCh via StartRunner") +} + +func TestOnStartup_Good_FrozenByDefault(t *testing.T) { + t.Setenv("CORE_WORKSPACE", t.TempDir()) + t.Setenv("CORE_AGENT_DISPATCH", "") + + c := core.New(core.WithOption("name", "test")) + s := NewPrep() + s.SetCore(c) + + require.NoError(t, s.OnStartup(context.Background())) + assert.True(t, s.frozen, "queue must be frozen after OnStartup without CORE_AGENT_DISPATCH=1") +} + +func TestOnStartup_Good_NoError(t *testing.T) { + t.Setenv("CORE_WORKSPACE", t.TempDir()) + t.Setenv("CORE_AGENT_DISPATCH", "") + + c := core.New(core.WithOption("name", "test")) + s := NewPrep() + s.SetCore(c) + + err := s.OnStartup(context.Background()) + assert.NoError(t, err) +} + +// --- OnShutdown --- + +func TestOnShutdown_Good_FreezesQueue(t *testing.T) { + t.Setenv("CORE_WORKSPACE", t.TempDir()) + + s := &PrepSubsystem{frozen: false} + err := s.OnShutdown(context.Background()) + require.NoError(t, err) + assert.True(t, s.frozen, "OnShutdown must set frozen=true") +} + +func TestOnShutdown_Good_AlreadyFrozen(t *testing.T) { + // Calling OnShutdown twice must be idempotent + s := &PrepSubsystem{frozen: true} + err := s.OnShutdown(context.Background()) + require.NoError(t, err) + assert.True(t, s.frozen) +} + +func TestOnShutdown_Good_NoError(t *testing.T) { + s := &PrepSubsystem{} + assert.NoError(t, s.OnShutdown(context.Background())) +} + +func TestOnShutdown_Ugly_NilCore(t *testing.T) { + // OnShutdown must not panic even if s.core is nil + s := &PrepSubsystem{core: nil, frozen: false} + assert.NotPanics(t, func() { + _ = s.OnShutdown(context.Background()) + }) + assert.True(t, s.frozen) +} diff --git a/pkg/agentic/status_logic_test.go b/pkg/agentic/status_logic_test.go new file mode 100644 index 0000000..a934941 --- /dev/null +++ b/pkg/agentic/status_logic_test.go @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "encoding/json" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- ReadStatus --- + +func TestReadStatus_Good_AllFields(t *testing.T) { + dir := t.TempDir() + now := time.Now().Truncate(time.Second) + + original := WorkspaceStatus{ + Status: "running", + Agent: "claude:opus", + Repo: "go-io", + Org: "core", + Task: "add observability", + Branch: "agent/add-observability", + Issue: 7, + PID: 42100, + StartedAt: now, + UpdatedAt: now, + Question: "", + Runs: 2, + PRURL: "", + } + data, err := json.MarshalIndent(original, "", " ") + require.NoError(t, err) + require.True(t, fs.Write(filepath.Join(dir, "status.json"), string(data)).OK) + + st, err := ReadStatus(dir) + require.NoError(t, err) + + assert.Equal(t, original.Status, st.Status) + assert.Equal(t, original.Agent, st.Agent) + assert.Equal(t, original.Repo, st.Repo) + assert.Equal(t, original.Org, st.Org) + assert.Equal(t, original.Task, st.Task) + assert.Equal(t, original.Branch, st.Branch) + assert.Equal(t, original.Issue, st.Issue) + assert.Equal(t, original.PID, st.PID) + assert.Equal(t, original.Runs, st.Runs) +} + +func TestReadStatus_Bad_MissingFile(t *testing.T) { + dir := t.TempDir() + _, err := ReadStatus(dir) + assert.Error(t, err, "missing status.json must return an error") +} + +func TestReadStatus_Bad_CorruptJSON(t *testing.T) { + dir := t.TempDir() + require.True(t, fs.Write(filepath.Join(dir, "status.json"), `{"status": "running", broken`).OK) + + _, err := ReadStatus(dir) + assert.Error(t, err, "corrupt JSON must return an error") +} + +func TestReadStatus_Bad_NullJSON(t *testing.T) { + dir := t.TempDir() + require.True(t, fs.Write(filepath.Join(dir, "status.json"), "null").OK) + + // null is valid JSON — ReadStatus returns a zero-value struct, not an error + st, err := ReadStatus(dir) + require.NoError(t, err) + assert.Equal(t, "", st.Status) +} + +// --- writeStatus --- + +func TestWriteStatus_Good_WritesAndReadsBack(t *testing.T) { + dir := t.TempDir() + st := &WorkspaceStatus{ + Status: "queued", + Agent: "gemini:pro", + Repo: "go-log", + Task: "improve logging", + Runs: 0, + } + + err := writeStatus(dir, st) + require.NoError(t, err) + + read, err := ReadStatus(dir) + require.NoError(t, err) + assert.Equal(t, "queued", read.Status) + assert.Equal(t, "gemini:pro", read.Agent) + assert.Equal(t, "go-log", read.Repo) + assert.Equal(t, "improve logging", read.Task) +} + +func TestWriteStatus_Good_SetsUpdatedAt(t *testing.T) { + dir := t.TempDir() + before := time.Now().Add(-time.Millisecond) + + st := &WorkspaceStatus{Status: "failed", Agent: "codex"} + err := writeStatus(dir, st) + require.NoError(t, err) + + assert.True(t, st.UpdatedAt.After(before), "writeStatus must set UpdatedAt to a recent time") +} + +func TestWriteStatus_Good_Overwrites(t *testing.T) { + dir := t.TempDir() + + require.NoError(t, writeStatus(dir, &WorkspaceStatus{Status: "running", Agent: "gemini"})) + require.NoError(t, writeStatus(dir, &WorkspaceStatus{Status: "completed", Agent: "gemini"})) + + st, err := ReadStatus(dir) + require.NoError(t, err) + assert.Equal(t, "completed", st.Status) +} + +// --- WorkspaceStatus JSON round-trip --- + +func TestWorkspaceStatus_Good_JSONRoundTrip(t *testing.T) { + now := time.Now().Truncate(time.Second) + original := WorkspaceStatus{ + Status: "blocked", + Agent: "codex:gpt-5.4", + Repo: "agent", + Org: "core", + Task: "write more tests", + Branch: "agent/write-more-tests", + Issue: 15, + PID: 99001, + StartedAt: now, + UpdatedAt: now, + Question: "Which pattern should I use?", + Runs: 3, + PRURL: "https://forge.lthn.ai/core/agent/pulls/10", + } + + data, err := json.Marshal(original) + require.NoError(t, err) + + var decoded WorkspaceStatus + require.NoError(t, json.Unmarshal(data, &decoded)) + + assert.Equal(t, original.Status, decoded.Status) + assert.Equal(t, original.Agent, decoded.Agent) + assert.Equal(t, original.Repo, decoded.Repo) + assert.Equal(t, original.Org, decoded.Org) + assert.Equal(t, original.Task, decoded.Task) + assert.Equal(t, original.Branch, decoded.Branch) + assert.Equal(t, original.Issue, decoded.Issue) + assert.Equal(t, original.PID, decoded.PID) + assert.Equal(t, original.Question, decoded.Question) + assert.Equal(t, original.Runs, decoded.Runs) + assert.Equal(t, original.PRURL, decoded.PRURL) +} + +func TestWorkspaceStatus_Good_OmitemptyFields(t *testing.T) { + st := WorkspaceStatus{Status: "queued", Agent: "claude"} + + data, err := json.Marshal(st) + require.NoError(t, err) + + // Optional fields with omitempty must be absent when zero + jsonStr := string(data) + assert.NotContains(t, jsonStr, `"org"`) + assert.NotContains(t, jsonStr, `"branch"`) + assert.NotContains(t, jsonStr, `"question"`) + assert.NotContains(t, jsonStr, `"pr_url"`) + assert.NotContains(t, jsonStr, `"pid"`) + assert.NotContains(t, jsonStr, `"issue"`) +}