mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
919 lines
31 KiB
Go
919 lines
31 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/boshu2/agentops/cli/internal/agentworker"
|
|
daemonpkg "github.com/boshu2/agentops/cli/internal/daemon"
|
|
"github.com/boshu2/agentops/cli/internal/wikiworker"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func TestDaemonActivationSmokeReadyStatus(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
server, listener, activation, err := startAgentOpsDaemon(ctx, cwd, agentopsDaemonRunOptions{
|
|
Addr: "127.0.0.1:0",
|
|
Token: "secret-token",
|
|
Now: func() time.Time { return now },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("start daemon: %v", err)
|
|
}
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
errCh <- server.Serve(listener)
|
|
}()
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
err := <-errCh
|
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
t.Fatalf("daemon serve returned unexpected error: %v", err)
|
|
}
|
|
})
|
|
|
|
if activation.URL == "" || !activation.Ready {
|
|
t.Fatalf("activation = %#v, want ready URL", activation)
|
|
}
|
|
if _, err := readDaemonActivation(cwd); err != nil {
|
|
t.Fatalf("read activation file: %v", err)
|
|
}
|
|
ready, err := fetchDaemonReady(context.Background(), activation.URL)
|
|
if err != nil {
|
|
t.Fatalf("fetch ready: %v", err)
|
|
}
|
|
if !ready.Ready {
|
|
t.Fatalf("ready response = %#v, want ready", ready)
|
|
}
|
|
status, err := fetchDaemonStatus(context.Background(), activation.URL)
|
|
if err != nil {
|
|
t.Fatalf("fetch status: %v", err)
|
|
}
|
|
if !status.Ready || status.ProjectionLag.EventCount != 0 {
|
|
t.Fatalf("status response = %#v, want ready empty daemon", status)
|
|
}
|
|
}
|
|
|
|
func TestDaemonReadyCommandUsesActivationFile(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
server, listener, activation, err := startAgentOpsDaemon(ctx, cwd, agentopsDaemonRunOptions{
|
|
Addr: "127.0.0.1:0",
|
|
Now: func() time.Time { return now },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("start daemon: %v", err)
|
|
}
|
|
errCh := make(chan error, 1)
|
|
go func() { errCh <- server.Serve(listener) }()
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
err := <-errCh
|
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
t.Fatalf("daemon serve returned unexpected error: %v", err)
|
|
}
|
|
})
|
|
|
|
oldProjectDir := testProjectDir
|
|
oldOutput := output
|
|
oldURL := daemonURL
|
|
testProjectDir = cwd
|
|
output = "table"
|
|
daemonURL = ""
|
|
t.Cleanup(func() {
|
|
testProjectDir = oldProjectDir
|
|
output = oldOutput
|
|
daemonURL = oldURL
|
|
})
|
|
|
|
var out bytes.Buffer
|
|
cmd := &cobra.Command{}
|
|
cmd.SetOut(&out)
|
|
if err := runAgentOpsDaemonReadyCommand(cmd, nil); err != nil {
|
|
t.Fatalf("ready command: %v", err)
|
|
}
|
|
if !strings.Contains(out.String(), activation.URL) {
|
|
t.Fatalf("ready output %q does not contain activation URL %q", out.String(), activation.URL)
|
|
}
|
|
}
|
|
|
|
func TestResolveAgentOpsDaemonClientMutationTokenPrecedence(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
tokenFile := filepath.Join(cwd, "token")
|
|
if err := os.WriteFile(tokenFile, []byte("file-token\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
activationTokenFile := filepath.Join(cwd, "activation-token")
|
|
if err := os.WriteFile(activationTokenFile, []byte("activation-file-token\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writeDaemonActivation(cwd, agentopsDaemonActivation{
|
|
URL: "http://127.0.0.1:8765",
|
|
Address: "127.0.0.1:8765",
|
|
PID: 123,
|
|
Ready: true,
|
|
StartedAt: "2026-05-01T00:00:00Z",
|
|
Token: "activation-token",
|
|
TokenFile: activationTokenFile,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Setenv("AGENTOPSD_TOKEN", "env-token")
|
|
t.Setenv("AGENTOPS_DAEMON_TOKEN", "legacy-env-token")
|
|
|
|
got, err := resolveAgentOpsDaemonClientMutationToken(cwd, "flag-token", tokenFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != "flag-token" {
|
|
t.Fatalf("flag precedence token = %q", got)
|
|
}
|
|
|
|
got, err = resolveAgentOpsDaemonClientMutationToken(cwd, "", tokenFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != "file-token" {
|
|
t.Fatalf("token-file precedence token = %q", got)
|
|
}
|
|
|
|
got, err = resolveAgentOpsDaemonClientMutationToken(cwd, "", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != "env-token" {
|
|
t.Fatalf("env precedence token = %q", got)
|
|
}
|
|
|
|
t.Setenv("AGENTOPSD_TOKEN", "")
|
|
got, err = resolveAgentOpsDaemonClientMutationToken(cwd, "", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != "legacy-env-token" {
|
|
t.Fatalf("legacy env precedence token = %q", got)
|
|
}
|
|
|
|
t.Setenv("AGENTOPS_DAEMON_TOKEN", "")
|
|
got, err = resolveAgentOpsDaemonClientMutationToken(cwd, "", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != "activation-token" {
|
|
t.Fatalf("activation fallback token = %q", got)
|
|
}
|
|
|
|
if err := writeDaemonActivation(cwd, agentopsDaemonActivation{
|
|
URL: "http://127.0.0.1:8765",
|
|
Address: "127.0.0.1:8765",
|
|
PID: 123,
|
|
Ready: true,
|
|
StartedAt: "2026-05-01T00:00:00Z",
|
|
TokenFile: activationTokenFile,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err = resolveAgentOpsDaemonClientMutationToken(cwd, "", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != "activation-file-token" {
|
|
t.Fatalf("activation token-file fallback token = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestDaemonLifecycleDryRunCommand(t *testing.T) {
|
|
// Covers ao daemon service install.
|
|
cwd := t.TempDir()
|
|
oldProjectDir := testProjectDir
|
|
oldDryRun := dryRun
|
|
oldAddr := daemonAddr
|
|
oldExecutable := daemonServiceExecutable
|
|
testProjectDir = cwd
|
|
dryRun = true
|
|
daemonAddr = "127.0.0.1:9876"
|
|
daemonServiceExecutable = "/usr/local/bin/ao"
|
|
t.Cleanup(func() {
|
|
testProjectDir = oldProjectDir
|
|
dryRun = oldDryRun
|
|
daemonAddr = oldAddr
|
|
daemonServiceExecutable = oldExecutable
|
|
})
|
|
|
|
var out bytes.Buffer
|
|
cmd := &cobra.Command{}
|
|
cmd.SetOut(&out)
|
|
if err := runAgentOpsDaemonServiceInstallCommand(cmd, nil); err != nil {
|
|
t.Fatalf("service install dry-run: %v", err)
|
|
}
|
|
got := out.String()
|
|
for _, needle := range []string{`"service_name": "agentopsd"`, `"dry_run": true`, "127.0.0.1:9876"} {
|
|
if !strings.Contains(got, needle) {
|
|
t.Fatalf("service dry-run output missing %q:\n%s", needle, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDaemonRunRejectsUnsafeActivationBind(t *testing.T) {
|
|
_, _, _, err := startAgentOpsDaemon(context.Background(), t.TempDir(), agentopsDaemonRunOptions{Addr: "0.0.0.0:8765"})
|
|
if err == nil {
|
|
t.Fatal("unsafe daemon bind succeeded")
|
|
}
|
|
}
|
|
|
|
func TestDaemonRunWorkerOnceCompletesFakeJob(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
queue := daemonpkg.NewQueue(daemonpkg.NewStore(cwd), daemonpkg.QueueOptions{LeaseDuration: time.Minute})
|
|
if _, err := queue.SubmitJob(daemonpkg.SubmitJobInput{
|
|
RequestID: "req-openclaw",
|
|
JobID: "job-openclaw",
|
|
JobType: daemonpkg.JobTypeOpenClawSnapshot,
|
|
}, daemonpkg.QueueMutationOptions{}); err != nil {
|
|
t.Fatalf("submit job: %v", err)
|
|
}
|
|
|
|
prevProjectDir := testProjectDir
|
|
prevAddr := daemonAddr
|
|
prevToken := daemonToken
|
|
prevTokenFile := daemonTokenFile
|
|
prevWorkers := daemonWorkers
|
|
prevWorkerOnce := daemonWorkerOnce
|
|
prevExecutorPolicy := daemonExecutorPolicy
|
|
testProjectDir = cwd
|
|
daemonAddr = "127.0.0.1:0"
|
|
daemonToken = "secret-token"
|
|
daemonTokenFile = ""
|
|
daemonWorkers = 1
|
|
daemonWorkerOnce = true
|
|
daemonExecutorPolicy = "fake"
|
|
t.Cleanup(func() {
|
|
testProjectDir = prevProjectDir
|
|
daemonAddr = prevAddr
|
|
daemonToken = prevToken
|
|
daemonTokenFile = prevTokenFile
|
|
daemonWorkers = prevWorkers
|
|
daemonWorkerOnce = prevWorkerOnce
|
|
daemonExecutorPolicy = prevExecutorPolicy
|
|
})
|
|
|
|
var out bytes.Buffer
|
|
cmd := &cobra.Command{}
|
|
cmd.SetOut(&out)
|
|
if err := runAgentOpsDaemonCommand(cmd, nil); err != nil {
|
|
t.Fatalf("daemon run worker once: %v", err)
|
|
}
|
|
snapshot, err := queue.Snapshot()
|
|
if err != nil {
|
|
t.Fatalf("snapshot: %v", err)
|
|
}
|
|
if len(snapshot.Jobs) != 1 || snapshot.Jobs[0].Status != daemonpkg.JobStatusCompleted {
|
|
t.Fatalf("jobs = %#v, want completed openclaw job", snapshot.Jobs)
|
|
}
|
|
if !strings.Contains(out.String(), "agentopsd ready:") {
|
|
t.Fatalf("output %q missing ready line", out.String())
|
|
}
|
|
}
|
|
|
|
func TestDaemonRunWorkerOnceCompletesWikiForgeFakeJob(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
queue := daemonpkg.NewQueue(daemonpkg.NewStore(cwd), daemonpkg.QueueOptions{LeaseDuration: time.Minute})
|
|
sourcePath := cwd + "/session-a.jsonl"
|
|
if err := os.WriteFile(sourcePath, []byte("decision: fake wiki forge jobs write session refs\n"), 0o644); err != nil {
|
|
t.Fatalf("write source: %v", err)
|
|
}
|
|
spec := daemonpkg.NewWikiForgeJobSpec("dream-1", ".agents/wiki/sources", []string{sourcePath})
|
|
jobSpec, err := spec.ToJobSpec("job-wiki")
|
|
if err != nil {
|
|
t.Fatalf("wiki job spec: %v", err)
|
|
}
|
|
if _, err := queue.SubmitJob(daemonpkg.SubmitJobInput{
|
|
RequestID: "req-wiki",
|
|
JobID: jobSpec.ID,
|
|
JobType: jobSpec.Type,
|
|
Payload: jobSpec.Payload,
|
|
}, daemonpkg.QueueMutationOptions{}); err != nil {
|
|
t.Fatalf("submit job: %v", err)
|
|
}
|
|
|
|
prevProjectDir := testProjectDir
|
|
prevAddr := daemonAddr
|
|
prevToken := daemonToken
|
|
prevTokenFile := daemonTokenFile
|
|
prevWorkers := daemonWorkers
|
|
prevWorkerOnce := daemonWorkerOnce
|
|
prevExecutorPolicy := daemonExecutorPolicy
|
|
testProjectDir = cwd
|
|
daemonAddr = "127.0.0.1:0"
|
|
daemonToken = "secret-token"
|
|
daemonTokenFile = ""
|
|
daemonWorkers = 1
|
|
daemonWorkerOnce = true
|
|
daemonExecutorPolicy = "fake"
|
|
t.Cleanup(func() {
|
|
testProjectDir = prevProjectDir
|
|
daemonAddr = prevAddr
|
|
daemonToken = prevToken
|
|
daemonTokenFile = prevTokenFile
|
|
daemonWorkers = prevWorkers
|
|
daemonWorkerOnce = prevWorkerOnce
|
|
daemonExecutorPolicy = prevExecutorPolicy
|
|
})
|
|
|
|
var out bytes.Buffer
|
|
cmd := &cobra.Command{}
|
|
cmd.SetOut(&out)
|
|
if err := runAgentOpsDaemonCommand(cmd, nil); err != nil {
|
|
t.Fatalf("daemon run worker once: %v", err)
|
|
}
|
|
snapshot, err := queue.Snapshot()
|
|
if err != nil {
|
|
t.Fatalf("snapshot: %v", err)
|
|
}
|
|
if len(snapshot.Jobs) != 1 || snapshot.Jobs[0].Status != daemonpkg.JobStatusCompleted {
|
|
t.Fatalf(
|
|
"jobs = %#v, failure = %#v, artifacts = %#v, artifact refs = %#v, want completed wiki job",
|
|
snapshot.Jobs,
|
|
snapshot.Jobs[0].Failure,
|
|
snapshot.Jobs[0].Artifacts,
|
|
snapshot.Jobs[0].ArtifactRefs,
|
|
)
|
|
}
|
|
if snapshot.Jobs[0].Artifacts["worker_session_refs"] == "" || snapshot.Jobs[0].Artifacts["session_id"] == "" {
|
|
t.Fatalf("wiki artifacts = %#v, want worker session refs", snapshot.Jobs[0].Artifacts)
|
|
}
|
|
}
|
|
|
|
func TestAgentOpsDaemonFakeExecutorPolicyCompletesRPIRunJob(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
queue := daemonpkg.NewQueue(daemonpkg.NewStore(cwd), daemonpkg.QueueOptions{LeaseDuration: time.Minute})
|
|
spec := daemonpkg.NewRPIRunJobSpec("run-daemon-fake", "validate daemon rpi run")
|
|
spec.MaxPhase = 1
|
|
jobSpec, err := spec.ToJobSpec("job-rpi-run")
|
|
if err != nil {
|
|
t.Fatalf("rpi run job spec: %v", err)
|
|
}
|
|
if _, err := queue.SubmitJob(daemonpkg.SubmitJobInput{
|
|
RequestID: "req-rpi-run",
|
|
JobID: jobSpec.ID,
|
|
JobType: jobSpec.Type,
|
|
Payload: jobSpec.Payload,
|
|
}, daemonpkg.QueueMutationOptions{}); err != nil {
|
|
t.Fatalf("submit rpi run job: %v", err)
|
|
}
|
|
|
|
supervisor, err := buildAgentOpsDaemonSupervisor(cwd, agentopsDaemonRunOptions{ExecutorPolicy: "fake"})
|
|
if err != nil {
|
|
t.Fatalf("build supervisor: %v", err)
|
|
}
|
|
result, err := supervisor.RunOnce(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("run once: %v", err)
|
|
}
|
|
if !result.Claimed || result.Job.Status != daemonpkg.JobStatusCompleted {
|
|
t.Fatalf("result = %#v, want completed rpi.run job", result)
|
|
}
|
|
if result.Job.JobType != daemonpkg.JobTypeRPIRun {
|
|
t.Fatalf("job type = %s, want %s", result.Job.JobType, daemonpkg.JobTypeRPIRun)
|
|
}
|
|
if got := result.Job.Artifacts["executor_policy"]; got != "fake" {
|
|
t.Fatalf("executor_policy artifact = %q, want fake", got)
|
|
}
|
|
if got := result.Job.Artifacts["phase"]; got != "1" {
|
|
t.Fatalf("phase artifact = %q, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestAgentOpsDaemonFakeExecutorPolicyCompletesRPIPhaseJob(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
queue := daemonpkg.NewQueue(daemonpkg.NewStore(cwd), daemonpkg.QueueOptions{LeaseDuration: time.Minute})
|
|
phaseSpec := daemonpkg.NewRPIPhaseJobSpec("run-daemon-fake", "validate daemon rpi executor", 2)
|
|
jobSpec, err := phaseSpec.ToJobSpec("job-rpi-phase")
|
|
if err != nil {
|
|
t.Fatalf("rpi phase job spec: %v", err)
|
|
}
|
|
if _, err := queue.SubmitJob(daemonpkg.SubmitJobInput{
|
|
RequestID: "req-rpi-phase",
|
|
JobID: jobSpec.ID,
|
|
JobType: jobSpec.Type,
|
|
Payload: jobSpec.Payload,
|
|
}, daemonpkg.QueueMutationOptions{}); err != nil {
|
|
t.Fatalf("submit rpi phase job: %v", err)
|
|
}
|
|
|
|
supervisor, err := buildAgentOpsDaemonSupervisor(cwd, agentopsDaemonRunOptions{ExecutorPolicy: "fake"})
|
|
if err != nil {
|
|
t.Fatalf("build supervisor: %v", err)
|
|
}
|
|
result, err := supervisor.RunOnce(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("run once: %v", err)
|
|
}
|
|
if !result.Claimed || result.Job.Status != daemonpkg.JobStatusCompleted {
|
|
t.Fatalf("result = %#v, want completed rpi phase job", result)
|
|
}
|
|
if got := result.Job.Artifacts["executor_policy"]; got != "fake" {
|
|
t.Fatalf("executor_policy artifact = %q, want fake", got)
|
|
}
|
|
if got := result.Job.Artifacts["phase"]; got != "2" {
|
|
t.Fatalf("phase artifact = %q, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestAgentOpsDaemonFakeExecutorPolicyRoutesDreamRunJob(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
queue := daemonpkg.NewQueue(daemonpkg.NewStore(cwd), daemonpkg.QueueOptions{LeaseDuration: time.Minute})
|
|
if _, err := queue.SubmitJob(daemonpkg.SubmitJobInput{
|
|
RequestID: "req-dream-invalid",
|
|
JobID: "job-dream-invalid",
|
|
JobType: daemonpkg.JobTypeDreamRun,
|
|
Payload: map[string]any{"schema_version": 1, "job_type": string(daemonpkg.JobTypeDreamRun)},
|
|
}, daemonpkg.QueueMutationOptions{}); err != nil {
|
|
t.Fatalf("submit dream job: %v", err)
|
|
}
|
|
|
|
supervisor, err := buildAgentOpsDaemonSupervisor(cwd, agentopsDaemonRunOptions{ExecutorPolicy: "fake"})
|
|
if err != nil {
|
|
t.Fatalf("build supervisor: %v", err)
|
|
}
|
|
result, err := supervisor.RunOnce(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("run once: %v", err)
|
|
}
|
|
if !result.Claimed {
|
|
t.Fatal("supervisor did not claim dream.run job under fake policy")
|
|
}
|
|
if result.Job.JobType != daemonpkg.JobTypeDreamRun || result.Job.Status != daemonpkg.JobStatusFailed {
|
|
t.Fatalf("dream result = %#v, want failed dream.run job", result.Job)
|
|
}
|
|
if result.Job.Failure == nil || !strings.Contains(result.Job.Failure.Message, "dream_run_id is required") {
|
|
t.Fatalf("dream failure = %#v, want payload validation failure", result.Job.Failure)
|
|
}
|
|
}
|
|
|
|
func TestAgentOpsDaemonGasCityExecutorPolicyRequiresConfig(t *testing.T) {
|
|
if _, err := buildAgentOpsDaemonSupervisor(t.TempDir(), agentopsDaemonRunOptions{ExecutorPolicy: "gascity"}); err == nil {
|
|
t.Fatal("gascity executor policy without endpoint/city succeeded")
|
|
}
|
|
}
|
|
|
|
func TestResolveAgentOpsDaemonMutationPolicySupportsScopedTokenFile(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "tokens.json")
|
|
data := `{"tokens":[{"name":"phone-readonly-submit","token":"phone-token","capabilities":["submit_job"]},{"name":"bushido-admin","token":"admin-token","capabilities":["admin"],"local_only":true}]}`
|
|
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
|
|
t.Fatalf("write scoped token file: %v", err)
|
|
}
|
|
policy, err := resolveAgentOpsDaemonMutationPolicy("", path)
|
|
if err != nil {
|
|
t.Fatalf("resolve policy: %v", err)
|
|
}
|
|
if len(policy.Tokens) != 2 || policy.Tokens[0].Name != "phone-readonly-submit" || policy.Token != "" {
|
|
t.Fatalf("policy = %#v", policy)
|
|
}
|
|
if policy.PathCapabilities["/v1/jobs"] != daemonpkg.MutationCapabilitySubmitJob {
|
|
t.Fatalf("path capabilities = %#v", policy.PathCapabilities)
|
|
}
|
|
}
|
|
|
|
func TestAgentOpsDaemonMutationPathsIncludeRESTAliasAndSchedules(t *testing.T) {
|
|
paths := map[string]bool{}
|
|
for _, path := range agentOpsDaemonMutationPaths() {
|
|
paths[path] = true
|
|
}
|
|
for _, want := range []string{
|
|
"/v1/jobs/*/cancel",
|
|
"/v1/schedules",
|
|
"/v1/schedules/*",
|
|
} {
|
|
if !paths[want] {
|
|
t.Fatalf("agentOpsDaemonMutationPaths missing %q: %v", want, agentOpsDaemonMutationPaths())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAgentOpsDaemonCLIFallbackExecutorPolicyBuilds(t *testing.T) {
|
|
if _, err := buildAgentOpsDaemonSupervisor(t.TempDir(), agentopsDaemonRunOptions{ExecutorPolicy: "cli-fallback"}); err != nil {
|
|
t.Fatalf("cli-fallback executor policy: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAgentOpsDaemonPlansProjectionExecutorRegistration(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
queue := daemonpkg.NewQueue(daemonpkg.NewStore(cwd), daemonpkg.QueueOptions{LeaseDuration: time.Minute})
|
|
spec := daemonpkg.NewPlansProjectionJobSpec("soak-project", "soc", filepath.Join(cwd, ".agents", "plans", "soak"))
|
|
specRaw, err := json.Marshal(spec)
|
|
if err != nil {
|
|
t.Fatalf("marshal plans spec: %v", err)
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(specRaw, &payload); err != nil {
|
|
t.Fatalf("unmarshal plans spec: %v", err)
|
|
}
|
|
if _, err := queue.SubmitJob(daemonpkg.SubmitJobInput{
|
|
RequestID: "req-plans-projection",
|
|
JobID: "job-plans-projection",
|
|
JobType: daemonpkg.JobTypePlansProjection,
|
|
IdempotencyKey: spec.IdempotencyKey(),
|
|
Payload: payload,
|
|
}, daemonpkg.QueueMutationOptions{}); err != nil {
|
|
t.Fatalf("submit plans job: %v", err)
|
|
}
|
|
|
|
supervisor, err := buildAgentOpsDaemonSupervisor(cwd, agentopsDaemonRunOptions{
|
|
ExecutorPolicy: "fake",
|
|
PlansBdSource: plansProjectionSoakBdSource{},
|
|
Now: fixedDaemonSoakNow,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("build supervisor: %v", err)
|
|
}
|
|
result, err := supervisor.RunOnce(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("run once: %v", err)
|
|
}
|
|
if !result.Claimed || result.Job.Status != daemonpkg.JobStatusCompleted {
|
|
t.Fatalf("result = %#v, want completed plans.projection job", result)
|
|
}
|
|
if got := result.Job.Artifacts["manifest_count"]; got != "2" {
|
|
t.Fatalf("manifest_count = %q, want 2", got)
|
|
}
|
|
manifestPath := result.Job.Artifacts["manifest_jsonl"]
|
|
if manifestPath == "" {
|
|
t.Fatalf("plans artifacts = %#v, want manifest_jsonl", result.Job.Artifacts)
|
|
}
|
|
if _, err := os.Stat(filepath.Clean(manifestPath)); err != nil {
|
|
t.Fatalf("plans manifest %s: %v", manifestPath, err)
|
|
}
|
|
}
|
|
|
|
func TestProviderOverrideWikiForgeWorkerForcesCLIFallback(t *testing.T) {
|
|
inner := &recordingWikiForgeWorker{}
|
|
worker := providerOverrideWikiForgeWorker{
|
|
inner: inner,
|
|
provider: agentworker.ProviderCLIFallback,
|
|
}
|
|
if _, err := worker.RunExtractionWithRetry(context.Background(), wikiworker.ExtractionRequest{
|
|
Provider: agentworker.ProviderGasCity,
|
|
Prompt: "prompt",
|
|
}, wikiworker.RetryOptions{}); err != nil {
|
|
t.Fatalf("RunExtractionWithRetry: %v", err)
|
|
}
|
|
if inner.req.Provider != agentworker.ProviderCLIFallback {
|
|
t.Fatalf("provider = %q, want cli-fallback", inner.req.Provider)
|
|
}
|
|
}
|
|
|
|
func TestAgentOpsDaemonWorkerFlagsRegistered(t *testing.T) {
|
|
for _, flag := range []string{"workers", "worker-once", "worker-timeout", "worker-memory-max-bytes", "worker-cgroup-root", "executor-policy", "gascity-endpoint", "gascity-city", "gascity-token", "gascity-token-file"} {
|
|
if daemonRunCmd.Flags().Lookup(flag) == nil {
|
|
t.Fatalf("daemon run missing --%s flag", flag)
|
|
}
|
|
}
|
|
}
|
|
|
|
type recordingWikiForgeWorker struct {
|
|
req wikiworker.ExtractionRequest
|
|
}
|
|
|
|
func (w *recordingWikiForgeWorker) RunExtractionWithRetry(_ context.Context, req wikiworker.ExtractionRequest, _ wikiworker.RetryOptions) (wikiworker.ExtractionResult, error) {
|
|
w.req = req
|
|
return wikiworker.ExtractionResult{
|
|
Terminal: agentworker.TerminalState{Status: agentworker.StatusCompleted},
|
|
}, nil
|
|
}
|
|
|
|
func TestAgentOpsDaemonAcceptsWikiBuildIntoReadModels(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
server, listener, activation, err := startAgentOpsDaemon(ctx, cwd, agentopsDaemonRunOptions{
|
|
Addr: "127.0.0.1:0",
|
|
Token: "secret-token",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("start daemon: %v", err)
|
|
}
|
|
errCh := make(chan error, 1)
|
|
go func() { errCh <- server.Serve(listener) }()
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
err := <-errCh
|
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
t.Fatalf("daemon serve returned unexpected error: %v", err)
|
|
}
|
|
})
|
|
|
|
response, err := postDaemonSubmitJob(context.Background(), activation.URL, "secret-token", daemonpkg.SubmitJobRequest{
|
|
RequestID: "req-wiki-build",
|
|
JobID: "job-wiki-build",
|
|
JobType: daemonpkg.JobTypeWikiBuild,
|
|
IdempotencyKey: "wiki.build:read-model-test",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("submit wiki.build: %v", err)
|
|
}
|
|
if !response.Accepted || response.JobID != "job-wiki-build" {
|
|
t.Fatalf("submit response = %#v, want accepted wiki.build", response)
|
|
}
|
|
|
|
status, err := fetchDaemonStatus(context.Background(), activation.URL)
|
|
if err != nil {
|
|
t.Fatalf("fetch status: %v", err)
|
|
}
|
|
if job := findDaemonQueueJob(status.Queue.Jobs, "job-wiki-build"); job == nil || job.JobType != daemonpkg.JobTypeWikiBuild {
|
|
t.Fatalf("queue jobs = %#v, want queued wiki.build", status.Queue.Jobs)
|
|
}
|
|
if len(status.Projections.Wiki.Jobs) != 1 || status.Projections.Wiki.Jobs[0].JobID != "job-wiki-build" {
|
|
t.Fatalf("wiki projection jobs = %#v, want wiki.build job", status.Projections.Wiki.Jobs)
|
|
}
|
|
if len(status.Projections.OpenClaw.Resources.Wiki) != 1 || status.Projections.OpenClaw.Resources.Wiki[0].JobID != "job-wiki-build" {
|
|
t.Fatalf("openclaw wiki resources = %#v, want wiki.build resource", status.Projections.OpenClaw.Resources.Wiki)
|
|
}
|
|
}
|
|
|
|
// TestAgentopsdRegistersLLMWikiLoopExecutor verifies that the daemon's
|
|
// supervisor registers an executor for JobTypeLLMWikiLoop under each policy.
|
|
// The executors map is unexported, so we assert via behavior: submit an
|
|
// llmwiki.loop job, run RunOnce, and confirm the supervisor claimed and
|
|
// executed it (rather than returning ErrNoClaimableJobs).
|
|
func TestAgentopsdRegistersLLMWikiLoopExecutor(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
policy string
|
|
opts agentopsDaemonRunOptions
|
|
}{
|
|
{name: "fake", policy: "fake"},
|
|
{
|
|
name: "gascity",
|
|
policy: "gascity",
|
|
opts: agentopsDaemonRunOptions{
|
|
GasCityEndpoint: "http://127.0.0.1:0",
|
|
GasCityCity: "test-city",
|
|
},
|
|
},
|
|
{name: "cli-fallback", policy: "cli-fallback"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
vault := filepath.Join(cwd, "vault")
|
|
if err := os.MkdirAll(vault, 0o755); err != nil {
|
|
t.Fatalf("mkdir vault: %v", err)
|
|
}
|
|
queue := daemonpkg.NewQueue(daemonpkg.NewStore(cwd), daemonpkg.QueueOptions{LeaseDuration: time.Minute})
|
|
if _, err := queue.SubmitJob(daemonpkg.SubmitJobInput{
|
|
RequestID: "req-llmwiki",
|
|
JobID: "job-llmwiki",
|
|
JobType: daemonpkg.JobTypeLLMWikiLoop,
|
|
Payload: map[string]any{
|
|
"vault": vault,
|
|
"stages": []string{"lint"},
|
|
},
|
|
}, daemonpkg.QueueMutationOptions{}); err != nil {
|
|
t.Fatalf("submit llmwiki job: %v", err)
|
|
}
|
|
|
|
opts := tc.opts
|
|
opts.ExecutorPolicy = tc.policy
|
|
supervisor, err := buildAgentOpsDaemonSupervisor(cwd, opts)
|
|
if err != nil {
|
|
t.Fatalf("build supervisor (%s): %v", tc.policy, err)
|
|
}
|
|
result, err := supervisor.RunOnce(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("run once (%s): %v", tc.policy, err)
|
|
}
|
|
if !result.Claimed {
|
|
t.Fatalf("supervisor did not claim llmwiki.loop job under policy %s", tc.policy)
|
|
}
|
|
if result.Job.JobType != daemonpkg.JobTypeLLMWikiLoop {
|
|
t.Fatalf("claimed job type = %s, want %s", result.Job.JobType, daemonpkg.JobTypeLLMWikiLoop)
|
|
}
|
|
if result.Job.Status != daemonpkg.JobStatusCompleted {
|
|
t.Fatalf("llmwiki.loop status = %s, want completed (artifacts=%v)", result.Job.Status, result.Job.Artifacts)
|
|
}
|
|
if got := result.Job.Artifacts["stage"]; got != "lint" {
|
|
t.Fatalf("stage artifact = %q, want lint", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLLMWikiHarvestAdapter_PromoteDryRun is an L1 sanity check on the
|
|
// adapter that bridges PromoteStage to harvest.Promote. With dry-run set
|
|
// and no source artifacts, the adapter must succeed without writing to
|
|
// destDir.
|
|
func TestLLMWikiHarvestAdapter_PromoteDryRun(t *testing.T) {
|
|
src := t.TempDir()
|
|
dst := t.TempDir()
|
|
count, err := llmwikiHarvestAdapter{}.Promote(src, dst, true)
|
|
if err != nil {
|
|
t.Fatalf("adapter promote dry-run: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("dry-run promoted count = %d, want 0", count)
|
|
}
|
|
entries, err := os.ReadDir(dst)
|
|
if err != nil {
|
|
t.Fatalf("read dst: %v", err)
|
|
}
|
|
if len(entries) != 0 {
|
|
t.Fatalf("dry-run wrote %d entries to destDir, want 0", len(entries))
|
|
}
|
|
}
|
|
|
|
func TestLLMWikiHarvestAdapter_RequiresArgs(t *testing.T) {
|
|
if _, err := (llmwikiHarvestAdapter{}).Promote("", "/tmp/x", true); err == nil {
|
|
t.Fatal("expected error when sourceDir empty")
|
|
}
|
|
if _, err := (llmwikiHarvestAdapter{}).Promote("/tmp/x", "", true); err == nil {
|
|
t.Fatal("expected error when destDir empty")
|
|
}
|
|
}
|
|
|
|
// TestAgentopsdLoadsScheduleFileOnStart verifies that --schedule-file=<path>
|
|
// causes the daemon to parse the file and persist its schedules into the
|
|
// Store before serving. (soc-8inr.17, amendment B5)
|
|
func TestAgentopsdLoadsScheduleFileOnStart(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
schedulePath := filepath.Join(cwd, "custom-schedule.yaml")
|
|
if err := os.WriteFile(schedulePath, []byte("schedules:\n - name: alpha\n cron: \"*/5 * * * *\"\n job_type: llmwiki.loop\n"), 0o600); err != nil {
|
|
t.Fatalf("write schedule file: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
server, listener, _, err := startAgentOpsDaemon(ctx, cwd, agentopsDaemonRunOptions{
|
|
Addr: "127.0.0.1:0",
|
|
ScheduleFile: schedulePath,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("start daemon: %v", err)
|
|
}
|
|
errCh := make(chan error, 1)
|
|
go func() { errCh <- server.Serve(listener) }()
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
err := <-errCh
|
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
t.Fatalf("daemon serve returned unexpected error: %v", err)
|
|
}
|
|
})
|
|
|
|
store := daemonpkg.NewStore(cwd)
|
|
schedules, err := store.ListSchedules()
|
|
if err != nil {
|
|
t.Fatalf("ListSchedules: %v", err)
|
|
}
|
|
if len(schedules) != 1 {
|
|
t.Fatalf("len(schedules) = %d, want 1 (got %#v)", len(schedules), schedules)
|
|
}
|
|
if schedules[0].Name != "alpha" {
|
|
t.Fatalf("schedules[0].Name = %q, want %q", schedules[0].Name, "alpha")
|
|
}
|
|
if schedules[0].JobType != daemonpkg.JobTypeLLMWikiLoop {
|
|
t.Fatalf("schedules[0].JobType = %q, want %q", schedules[0].JobType, daemonpkg.JobTypeLLMWikiLoop)
|
|
}
|
|
}
|
|
|
|
// TestAgentopsdNoScheduleFile_BehavesIdenticallyToPreUpgrade verifies that
|
|
// when no --schedule-file flag is set AND no default .agents/schedule.yaml
|
|
// exists, the daemon starts cleanly with zero schedules — bit-identical to
|
|
// pre-upgrade behavior. (soc-8inr.17, binding test)
|
|
func TestAgentopsdNoScheduleFile_BehavesIdenticallyToPreUpgrade(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
// Sanity: ensure default file does NOT exist.
|
|
if _, err := os.Stat(filepath.Join(cwd, ".agents", "schedule.yaml")); !os.IsNotExist(err) {
|
|
t.Fatalf("precondition: default schedule.yaml should not exist (err=%v)", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
server, listener, activation, err := startAgentOpsDaemon(ctx, cwd, agentopsDaemonRunOptions{
|
|
Addr: "127.0.0.1:0",
|
|
// ScheduleFile intentionally empty.
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("start daemon: %v", err)
|
|
}
|
|
errCh := make(chan error, 1)
|
|
go func() { errCh <- server.Serve(listener) }()
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
err := <-errCh
|
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
t.Fatalf("daemon serve returned unexpected error: %v", err)
|
|
}
|
|
})
|
|
|
|
if !activation.Ready || activation.URL == "" {
|
|
t.Fatalf("activation = %#v, want ready URL", activation)
|
|
}
|
|
store := daemonpkg.NewStore(cwd)
|
|
schedules, err := store.ListSchedules()
|
|
if err != nil {
|
|
t.Fatalf("ListSchedules: %v", err)
|
|
}
|
|
if len(schedules) != 0 {
|
|
t.Fatalf("len(schedules) = %d, want 0 (no-schedule-file path must not load anything)", len(schedules))
|
|
}
|
|
}
|
|
|
|
// TestAgentopsdMalformedScheduleFile_FailsClosed verifies that a malformed
|
|
// schedule.yaml causes the daemon start function to return an error
|
|
// (refuses to boot) rather than silently starting with zero schedules.
|
|
// Amendment C9 requirement.
|
|
func TestAgentopsdMalformedScheduleFile_FailsClosed(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
schedulePath := filepath.Join(cwd, "malformed.yaml")
|
|
// `schedules: not-a-list` — strict decoder should reject this because
|
|
// the field is typed as a slice of structs.
|
|
if err := os.WriteFile(schedulePath, []byte("schedules: not-a-list\n"), 0o600); err != nil {
|
|
t.Fatalf("write malformed schedule file: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
server, listener, _, err := startAgentOpsDaemon(ctx, cwd, agentopsDaemonRunOptions{
|
|
Addr: "127.0.0.1:0",
|
|
ScheduleFile: schedulePath,
|
|
})
|
|
if err == nil {
|
|
// Should not have returned a server.
|
|
if listener != nil {
|
|
_ = listener.Close()
|
|
}
|
|
if server != nil {
|
|
_ = server.Close()
|
|
}
|
|
t.Fatalf("expected start to fail-closed on malformed schedule.yaml, got nil error")
|
|
}
|
|
msg := err.Error()
|
|
if !strings.Contains(msg, schedulePath) {
|
|
t.Fatalf("error message %q must contain schedule path %q", msg, schedulePath)
|
|
}
|
|
// Per amendment C9, the operator-facing message must mention "fix" or
|
|
// "malformed" so it's actionable.
|
|
if !strings.Contains(msg, "fix") && !strings.Contains(msg, "malformed") {
|
|
t.Fatalf("error message %q must mention 'fix' or 'malformed' (amendment C9)", msg)
|
|
}
|
|
}
|
|
|
|
// TestAgentopsdScheduleFileFlag_OverridesAutoDetect verifies that when both
|
|
// .agents/schedule.yaml and a flag-supplied path exist, the flag wins.
|
|
// (soc-8inr.17 precedence rule)
|
|
func TestAgentopsdScheduleFileFlag_OverridesAutoDetect(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
// File A: default location, schedule named "alpha"
|
|
defaultDir := filepath.Join(cwd, ".agents")
|
|
if err := os.MkdirAll(defaultDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir .agents: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(defaultDir, "schedule.yaml"), []byte("schedules:\n - name: alpha\n cron: \"*/5 * * * *\"\n job_type: llmwiki.loop\n"), 0o600); err != nil {
|
|
t.Fatalf("write default schedule file: %v", err)
|
|
}
|
|
// File B: custom path, schedule named "beta"
|
|
customPath := filepath.Join(cwd, "custom.yaml")
|
|
if err := os.WriteFile(customPath, []byte("schedules:\n - name: beta\n cron: \"*/5 * * * *\"\n job_type: llmwiki.loop\n"), 0o600); err != nil {
|
|
t.Fatalf("write custom schedule file: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
server, listener, _, err := startAgentOpsDaemon(ctx, cwd, agentopsDaemonRunOptions{
|
|
Addr: "127.0.0.1:0",
|
|
ScheduleFile: customPath, // flag wins
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("start daemon: %v", err)
|
|
}
|
|
errCh := make(chan error, 1)
|
|
go func() { errCh <- server.Serve(listener) }()
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
err := <-errCh
|
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
t.Fatalf("daemon serve returned unexpected error: %v", err)
|
|
}
|
|
})
|
|
|
|
store := daemonpkg.NewStore(cwd)
|
|
schedules, err := store.ListSchedules()
|
|
if err != nil {
|
|
t.Fatalf("ListSchedules: %v", err)
|
|
}
|
|
if len(schedules) != 1 {
|
|
t.Fatalf("len(schedules) = %d, want 1; flag must win over auto-detect (got %#v)", len(schedules), schedules)
|
|
}
|
|
if schedules[0].Name != "beta" {
|
|
t.Fatalf("schedules[0].Name = %q, want %q (flag must win over auto-detect)", schedules[0].Name, "beta")
|
|
}
|
|
}
|