Add confirm-gated resume API with resumable flag, Phase 5 finalize fault and rolling-restart replay tests, Mermaid strict mode, request body limits, and optional Runner Bearer auth between app and pi-agent. Co-authored-by: Cursor <cursoragent@cursor.com>
182 lines
6.2 KiB
Go
182 lines
6.2 KiB
Go
package server_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/bizdev"
|
|
"chat-agent-system/internal/config"
|
|
"chat-agent-system/internal/server"
|
|
"chat-agent-system/internal/stream"
|
|
"chat-agent-system/internal/taskrun"
|
|
)
|
|
|
|
func TestTaskRun_ResumeInterrupted_PG(t *testing.T) {
|
|
pool := openPGPoolWithMigrations(t)
|
|
if pool == nil {
|
|
return
|
|
}
|
|
defer pool.Close()
|
|
|
|
dataDir := t.TempDir()
|
|
cfg := &config.Config{
|
|
Env: "dev", DataDir: dataDir, AgentBackend: "stub", AllowInMemory: false,
|
|
ContextDbHost: "127.0.0.1", ContextDbPort: 5433, ContextDbUser: "app",
|
|
ContextDbPassword: "dev_secret_db_pass", ContextDbName: "agent_system_dev",
|
|
AdminUsername: "admin", AdminPassword: "admin-secret-password-123",
|
|
}
|
|
svc := bizdev.MustNewService(t, cfg)
|
|
srv := server.NewServer(cfg, svc)
|
|
handler := srv.Routes()
|
|
token := loginTestUser(t, handler, "zhanggong", "123456")
|
|
userID := fetchAuthUserID(t, handler, token)
|
|
|
|
workDir := filepath.Join(dataDir, "workspaces", "bizdev", "resume_test")
|
|
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = os.WriteFile(filepath.Join(workDir, "output.md"), []byte("# resumed\n"), 0o644)
|
|
|
|
store := taskrun.NewPGStore(pool)
|
|
runID := "run_resume_" + time.Now().Format("150405.000000")
|
|
_, err := pool.Exec(context.Background(), `
|
|
INSERT INTO task_runs (
|
|
run_id, session_id, user_id, task_key, status, workspace_id, error_message, created_at, updated_at
|
|
) VALUES ($1, 'sess_resume', $2, 'intake', 'interrupted', $3, 'worker lease expired', now(), now())
|
|
`, runID, userID, workDir)
|
|
if err != nil {
|
|
t.Fatalf("seed interrupted run: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM task_runs WHERE run_id = $1`, runID)
|
|
})
|
|
|
|
srv.TaskRuns().SetExecutor(
|
|
func(_ context.Context, _ string, _ bizdev.TaskRunRequest, _ *stream.Streamer) (*bizdev.TaskRunResult, error) {
|
|
return &bizdev.TaskRunResult{
|
|
Success: true, WorkDir: workDir, TaskKey: "intake", ArtifactTitle: "Intake",
|
|
}, nil
|
|
},
|
|
nil, nil,
|
|
)
|
|
srv.TaskRuns().SetFinalizer(func(ctx context.Context, record *taskrun.RunRecord, result *bizdev.TaskRunResult) (string, error) {
|
|
return srv.FinalizeTaskRunProjection(ctx, record, result)
|
|
})
|
|
srv.TaskRuns().SetStore(store)
|
|
|
|
body, _ := json.Marshal(map[string]bool{"confirm": true})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/runs/"+runID+"/resume", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
withBearerAuth(req, token)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusAccepted {
|
|
t.Fatalf("resume expected 202, got %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
deadline := time.Now().Add(8 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
got, getErr := store.GetRun(context.Background(), runID)
|
|
if getErr == nil && got.Status == "completed" {
|
|
if got.Attempt < 2 {
|
|
t.Fatalf("expected attempt>=2 after resume, got %d", got.Attempt)
|
|
}
|
|
return
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
t.Fatal("resume did not complete")
|
|
}
|
|
|
|
func TestTaskRun_ResumeRequiresConfirm_PG(t *testing.T) {
|
|
pool := openPGPoolWithMigrations(t)
|
|
if pool == nil {
|
|
return
|
|
}
|
|
defer pool.Close()
|
|
|
|
dataDir := t.TempDir()
|
|
cfg := &config.Config{
|
|
Env: "dev", DataDir: dataDir, AgentBackend: "stub", AllowInMemory: false,
|
|
ContextDbHost: "127.0.0.1", ContextDbPort: 5433, ContextDbUser: "app",
|
|
ContextDbPassword: "dev_secret_db_pass", ContextDbName: "agent_system_dev",
|
|
AdminUsername: "admin", AdminPassword: "admin-secret-password-123",
|
|
}
|
|
srv := server.NewServer(cfg, bizdev.MustNewService(t, cfg))
|
|
handler := srv.Routes()
|
|
token := loginTestUser(t, handler, "zhanggong", "123456")
|
|
userID := fetchAuthUserID(t, handler, token)
|
|
workDir := filepath.Join(dataDir, "ws")
|
|
_ = os.MkdirAll(workDir, 0o755)
|
|
|
|
runID := "run_no_confirm_" + time.Now().Format("150405.000000")
|
|
_, _ = pool.Exec(context.Background(), `
|
|
INSERT INTO task_runs (run_id, session_id, user_id, task_key, status, workspace_id, created_at, updated_at)
|
|
VALUES ($1, 's', $2, 'intake', 'interrupted', $3, now(), now())
|
|
`, runID, userID, workDir)
|
|
t.Cleanup(func() {
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM task_runs WHERE run_id = $1`, runID)
|
|
})
|
|
_ = srv
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/runs/"+runID+"/resume", bytes.NewReader([]byte(`{"confirm":false}`)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
withBearerAuth(req, token)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 without confirm, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestTaskRun_GetRunShowsResumable_PG(t *testing.T) {
|
|
pool := openPGPoolWithMigrations(t)
|
|
if pool == nil {
|
|
return
|
|
}
|
|
defer pool.Close()
|
|
|
|
dataDir := t.TempDir()
|
|
cfg := &config.Config{
|
|
Env: "dev", DataDir: dataDir, AgentBackend: "stub", AllowInMemory: false,
|
|
ContextDbHost: "127.0.0.1", ContextDbPort: 5433, ContextDbUser: "app",
|
|
ContextDbPassword: "dev_secret_db_pass", ContextDbName: "agent_system_dev",
|
|
AdminUsername: "admin", AdminPassword: "admin-secret-password-123",
|
|
}
|
|
handler := server.NewServer(cfg, bizdev.MustNewService(t, cfg)).Routes()
|
|
token := loginTestUser(t, handler, "zhanggong", "123456")
|
|
userID := fetchAuthUserID(t, handler, token)
|
|
workDir := filepath.Join(dataDir, "workspaces", "bizdev", "resumable_flag")
|
|
_ = os.MkdirAll(workDir, 0o755)
|
|
|
|
runID := "run_flag_" + time.Now().Format("150405.000000")
|
|
_, _ = pool.Exec(context.Background(), `
|
|
INSERT INTO task_runs (run_id, session_id, user_id, task_key, status, workspace_id, created_at, updated_at)
|
|
VALUES ($1, 's', $2, 'intake', 'interrupted', $3, now(), now())
|
|
`, runID, userID, workDir)
|
|
t.Cleanup(func() {
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM task_runs WHERE run_id = $1`, runID)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/tasks/runs/%s", runID), nil)
|
|
withBearerAuth(req, token)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET run: %d", rec.Code)
|
|
}
|
|
var body map[string]any
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
|
if body["resumable"] != true {
|
|
t.Fatalf("expected resumable=true, got %v", body["resumable"])
|
|
}
|
|
}
|