Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
1138 lines
37 KiB
Go
1138 lines
37 KiB
Go
package workspace
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"math"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
workspaceissues "github.com/tutti-os/tutti/packages/workspace/issues"
|
|
workspacebiz "github.com/tutti-os/tutti/services/tuttid/biz/workspace"
|
|
)
|
|
|
|
func TestSQLiteIssueStoreRejectsNonFinitePersistedBudgetOnRead(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := openTestSQLiteStore(t)
|
|
ctx := context.Background()
|
|
if err := store.Create(ctx, workspacebiz.Summary{ID: "ws-nonfinite", Name: "Nonfinite"}); err != nil {
|
|
t.Fatalf("Create() workspace error = %v", err)
|
|
}
|
|
issue, err := testIssueService(store).CreateIssue(ctx, workspaceissues.CreateIssueInput{
|
|
WorkspaceID: "ws-nonfinite", TopicID: workspaceissues.DefaultTopicID,
|
|
ActorUserID: "user-1", Title: "Finite first",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
if _, err := store.writeDB.ExecContext(ctx, `UPDATE workspace_issues SET budget_quota_waterline_percent = ? WHERE workspace_id = ? AND issue_id = ?`, math.Inf(1), "ws-nonfinite", issue.IssueID); err != nil {
|
|
t.Fatalf("corrupt persisted budget: %v", err)
|
|
}
|
|
if _, err := store.GetIssue(ctx, "ws-nonfinite", issue.IssueID); err == nil {
|
|
t.Fatal("GetIssue() error = nil, want invalid persisted budget")
|
|
}
|
|
}
|
|
|
|
func TestSQLiteIssueStoreLifecycle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := openTestSQLiteStore(t)
|
|
ctx := context.Background()
|
|
if err := store.Create(ctx, workspacebiz.Summary{
|
|
ID: "ws-issue-1",
|
|
Name: "Issue Workspace",
|
|
}); err != nil {
|
|
t.Fatalf("Create() workspace error = %v", err)
|
|
}
|
|
|
|
service := testIssueService(store)
|
|
issue, err := service.CreateIssue(ctx, workspaceissues.CreateIssueInput{
|
|
WorkspaceID: "ws-issue-1",
|
|
TopicID: workspaceissues.DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Ship issue manager",
|
|
Content: "Build local storage",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
task, err := service.CreateTask(ctx, workspaceissues.CreateTaskInput{
|
|
WorkspaceID: "ws-issue-1",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Title: "Wire SQLite store",
|
|
Priority: string(workspaceissues.PriorityHigh),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTask() error = %v", err)
|
|
}
|
|
|
|
refs, err := service.AddContextRefs(ctx, workspaceissues.AddContextRefsInput{
|
|
WorkspaceID: "ws-issue-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
ParentKind: string(workspaceissues.ContextRefParentTask),
|
|
Refs: []workspaceissues.AddContextRefInput{{
|
|
RefType: "file",
|
|
Path: "/workspace/README.md",
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AddContextRefs() error = %v", err)
|
|
}
|
|
if len(refs) != 1 || refs[0].ContextRefID == "" {
|
|
t.Fatalf("context refs = %+v", refs)
|
|
}
|
|
|
|
run, err := service.CreateRun(ctx, workspaceissues.CreateRunInput{
|
|
WorkspaceID: "ws-issue-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
ActorUserID: "user-1",
|
|
AgentProvider: "codex",
|
|
AgentTargetID: "local:codex",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRun() error = %v", err)
|
|
}
|
|
completed, outputs, err := service.CompleteRun(ctx, workspaceissues.CompleteRunInput{
|
|
WorkspaceID: "ws-issue-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
RunID: run.RunID,
|
|
ActorUserID: "user-1",
|
|
Status: string(workspaceissues.StatusCompleted),
|
|
Summary: "Done",
|
|
Outputs: []workspaceissues.CompleteRunOutputInput{{
|
|
Path: "/workspace/out/result.md",
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CompleteRun() error = %v", err)
|
|
}
|
|
if completed.Status != workspaceissues.StatusCompleted || len(outputs) != 1 {
|
|
t.Fatalf("completed run = %+v outputs = %+v", completed, outputs)
|
|
}
|
|
|
|
taskDetail, err := service.GetTaskDetail(ctx, "ws-issue-1", issue.IssueID, task.TaskID)
|
|
if err != nil {
|
|
t.Fatalf("GetTaskDetail() error = %v", err)
|
|
}
|
|
if taskDetail.Task.Status != workspaceissues.StatusPendingAcceptance {
|
|
t.Fatalf("task status = %q", taskDetail.Task.Status)
|
|
}
|
|
if len(taskDetail.ContextRefs) != 1 || len(taskDetail.LatestOutputs) != 1 {
|
|
t.Fatalf("task detail = %+v", taskDetail)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteIssueStoreSearchRunOutputs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := openTestSQLiteStore(t)
|
|
ctx := context.Background()
|
|
if err := store.Create(ctx, workspacebiz.Summary{
|
|
ID: "ws-search",
|
|
Name: "Search Workspace",
|
|
}); err != nil {
|
|
t.Fatalf("Create() workspace error = %v", err)
|
|
}
|
|
service := testIssueService(store)
|
|
|
|
topicB, err := service.CreateTopic(ctx, workspaceissues.CreateTopicInput{
|
|
WorkspaceID: "ws-search",
|
|
ActorUserID: "user-1",
|
|
Title: "Topic B",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTopic() error = %v", err)
|
|
}
|
|
|
|
createIssueWithOutputs := func(topicID string, title string, paths []string) string {
|
|
t.Helper()
|
|
issue, err := service.CreateIssue(ctx, workspaceissues.CreateIssueInput{
|
|
WorkspaceID: "ws-search",
|
|
TopicID: topicID,
|
|
ActorUserID: "user-1",
|
|
Title: title,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue(%q) error = %v", title, err)
|
|
}
|
|
task, err := service.CreateTask(ctx, workspaceissues.CreateTaskInput{
|
|
WorkspaceID: "ws-search",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Title: "task for " + title,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTask(%q) error = %v", title, err)
|
|
}
|
|
run, err := service.CreateRun(ctx, workspaceissues.CreateRunInput{
|
|
WorkspaceID: "ws-search",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
ActorUserID: "user-1",
|
|
AgentProvider: "codex",
|
|
AgentTargetID: "local:codex",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRun(%q) error = %v", title, err)
|
|
}
|
|
outputs := make([]workspaceissues.CompleteRunOutputInput, 0, len(paths))
|
|
for _, p := range paths {
|
|
outputs = append(outputs, workspaceissues.CompleteRunOutputInput{Path: p})
|
|
}
|
|
if _, _, err := service.CompleteRun(ctx, workspaceissues.CompleteRunInput{
|
|
WorkspaceID: "ws-search",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
RunID: run.RunID,
|
|
ActorUserID: "user-1",
|
|
Status: string(workspaceissues.StatusCompleted),
|
|
Outputs: outputs,
|
|
}); err != nil {
|
|
t.Fatalf("CompleteRun(%q) error = %v", title, err)
|
|
}
|
|
return issue.IssueID
|
|
}
|
|
|
|
issueA := createIssueWithOutputs(workspaceissues.DefaultTopicID, "Alpha issue",
|
|
[]string{"/ws/a/alpha-report.md", "/ws/a/shared-notes.md"})
|
|
createIssueWithOutputs(topicB.TopicID, "Beta issue",
|
|
[]string{"/ws/b/beta-report.md", "/ws/b/shared-notes.md"})
|
|
|
|
displayNames := func(hits []workspaceissues.RunOutputSearchHit) []string {
|
|
names := make([]string, 0, len(hits))
|
|
for _, hit := range hits {
|
|
names = append(names, hit.Output.DisplayName)
|
|
}
|
|
return names
|
|
}
|
|
|
|
// Workspace-wide name match spans both issues and annotates issue titles.
|
|
hits, err := store.SearchRunOutputs(ctx, workspaceissues.RunOutputSearchParams{
|
|
WorkspaceID: "ws-search",
|
|
Query: "report",
|
|
Limit: 50,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SearchRunOutputs(report) error = %v", err)
|
|
}
|
|
if got := displayNames(hits); len(got) != 2 {
|
|
t.Fatalf("report hits = %v, want 2", got)
|
|
}
|
|
for _, hit := range hits {
|
|
if hit.IssueTitle == "" {
|
|
t.Fatalf("hit missing issue title: %+v", hit)
|
|
}
|
|
}
|
|
|
|
// Case-insensitive match.
|
|
hits, err = store.SearchRunOutputs(ctx, workspaceissues.RunOutputSearchParams{
|
|
WorkspaceID: "ws-search",
|
|
Query: "ALPHA",
|
|
Limit: 50,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SearchRunOutputs(ALPHA) error = %v", err)
|
|
}
|
|
if got := displayNames(hits); len(got) != 1 || got[0] != "alpha-report.md" {
|
|
t.Fatalf("ALPHA hits = %v, want [alpha-report.md]", got)
|
|
}
|
|
|
|
// IssueID scope limits to one issue.
|
|
hits, err = store.SearchRunOutputs(ctx, workspaceissues.RunOutputSearchParams{
|
|
WorkspaceID: "ws-search",
|
|
Query: "notes",
|
|
IssueID: issueA,
|
|
Limit: 50,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SearchRunOutputs(notes, issueA) error = %v", err)
|
|
}
|
|
if got := displayNames(hits); len(got) != 1 || hits[0].Output.IssueID != issueA {
|
|
t.Fatalf("notes/issueA hits = %v (issue %q), want one under %q", got, hits[0].Output.IssueID, issueA)
|
|
}
|
|
|
|
// TopicID scope limits to issues under one topic.
|
|
hits, err = store.SearchRunOutputs(ctx, workspaceissues.RunOutputSearchParams{
|
|
WorkspaceID: "ws-search",
|
|
Query: "notes",
|
|
TopicID: topicB.TopicID,
|
|
Limit: 50,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SearchRunOutputs(notes, topicB) error = %v", err)
|
|
}
|
|
if got := displayNames(hits); len(got) != 1 {
|
|
t.Fatalf("notes/topicB hits = %v, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteIssueStoreRunLifecycleForIssueWithoutTasks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := openTestSQLiteStore(t)
|
|
ctx := context.Background()
|
|
if err := store.Create(ctx, workspacebiz.Summary{
|
|
ID: "ws-issue-run",
|
|
Name: "Issue Run Workspace",
|
|
}); err != nil {
|
|
t.Fatalf("Create() workspace error = %v", err)
|
|
}
|
|
|
|
service := testIssueService(store)
|
|
issue, err := service.CreateIssue(ctx, workspaceissues.CreateIssueInput{
|
|
WorkspaceID: "ws-issue-run",
|
|
TopicID: workspaceissues.DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Say hello",
|
|
Content: "Say hello to me",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
|
|
run, err := service.CreateRun(ctx, workspaceissues.CreateRunInput{
|
|
WorkspaceID: "ws-issue-run",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
RunID: "run-issue-1",
|
|
AgentProvider: "codex",
|
|
AgentTargetID: "local:codex",
|
|
ExecutionDirectory: "/Users/test/project",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRun() error = %v", err)
|
|
}
|
|
if run.TaskID == "" || run.OutputDir != "" || run.ExecutionDirectory != "/Users/test/project" {
|
|
t.Fatalf("created run = %+v", run)
|
|
}
|
|
|
|
completed, outputs, err := service.CompleteRun(ctx, workspaceissues.CompleteRunInput{
|
|
WorkspaceID: "ws-issue-run",
|
|
IssueID: issue.IssueID,
|
|
TaskID: run.TaskID,
|
|
RunID: run.RunID,
|
|
ActorUserID: "user-1",
|
|
Status: string(workspaceissues.StatusCompleted),
|
|
Summary: "Done",
|
|
Outputs: []workspaceissues.CompleteRunOutputInput{{
|
|
Path: "/Users/test/project/summary.md",
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CompleteRun() error = %v", err)
|
|
}
|
|
if completed.TaskID != run.TaskID || completed.Status != workspaceissues.StatusCompleted {
|
|
t.Fatalf("completed run = %+v", completed)
|
|
}
|
|
if len(outputs) != 1 || outputs[0].TaskID != run.TaskID {
|
|
t.Fatalf("outputs = %+v", outputs)
|
|
}
|
|
|
|
detail, err := service.GetIssueDetail(ctx, "ws-issue-run", issue.IssueID)
|
|
if err != nil {
|
|
t.Fatalf("GetIssueDetail() error = %v", err)
|
|
}
|
|
if detail.Issue.Status != workspaceissues.StatusPendingAcceptance || detail.Issue.TaskCount != 1 {
|
|
t.Fatalf("issue detail = %+v", detail.Issue)
|
|
}
|
|
if detail.LatestRun == nil || detail.LatestRun.RunID != run.RunID || detail.LatestRun.TaskID != run.TaskID {
|
|
t.Fatalf("latest run = %+v", detail.LatestRun)
|
|
}
|
|
if len(detail.LatestOutputs) != 1 || detail.LatestOutputs[0].TaskID != run.TaskID {
|
|
t.Fatalf("latest outputs = %+v", detail.LatestOutputs)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteIssueStoreListRunningRunsFiltersByWorkspaceAndSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := openTestSQLiteStore(t)
|
|
ctx := context.Background()
|
|
for _, workspaceID := range []string{"ws-running-1", "ws-running-2"} {
|
|
if err := store.Create(ctx, workspacebiz.Summary{
|
|
ID: workspaceID,
|
|
Name: workspaceID,
|
|
}); err != nil {
|
|
t.Fatalf("Create() workspace %s error = %v", workspaceID, err)
|
|
}
|
|
}
|
|
|
|
service := testIssueService(store)
|
|
issue, err := service.CreateIssue(ctx, workspaceissues.CreateIssueInput{
|
|
WorkspaceID: "ws-running-1",
|
|
TopicID: workspaceissues.DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Running runs",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
task, err := service.CreateTask(ctx, workspaceissues.CreateTaskInput{
|
|
WorkspaceID: "ws-running-1",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Title: "Task",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTask() error = %v", err)
|
|
}
|
|
running, err := service.CreateRun(ctx, workspaceissues.CreateRunInput{
|
|
WorkspaceID: "ws-running-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
ActorUserID: "user-1",
|
|
RunID: "run-running",
|
|
AgentProvider: "codex",
|
|
AgentTargetID: "local:codex",
|
|
AgentSessionID: "session-1",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRun() running error = %v", err)
|
|
}
|
|
withoutSession, err := service.CreateRun(ctx, workspaceissues.CreateRunInput{
|
|
WorkspaceID: "ws-running-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
ActorUserID: "user-1",
|
|
RunID: "run-no-session",
|
|
AgentProvider: "codex",
|
|
AgentTargetID: "local:codex",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRun() no session error = %v", err)
|
|
}
|
|
if _, _, err := service.CompleteRun(ctx, workspaceissues.CompleteRunInput{
|
|
WorkspaceID: "ws-running-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
RunID: withoutSession.RunID,
|
|
ActorUserID: "user-1",
|
|
Status: string(workspaceissues.StatusFailed),
|
|
}); err != nil {
|
|
t.Fatalf("CompleteRun() error = %v", err)
|
|
}
|
|
|
|
runs, err := store.ListRunningRuns(ctx, "ws-running-1", 10)
|
|
if err != nil {
|
|
t.Fatalf("ListRunningRuns() error = %v", err)
|
|
}
|
|
if len(runs) != 1 || runs[0].RunID != running.RunID {
|
|
t.Fatalf("running runs = %+v, want only %q", runs, running.RunID)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteIssueStoreListIssuesPagination(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := openTestSQLiteStore(t)
|
|
ctx := context.Background()
|
|
if err := store.Create(ctx, workspacebiz.Summary{
|
|
ID: "ws-issue-list",
|
|
Name: "Issue List Workspace",
|
|
}); err != nil {
|
|
t.Fatalf("Create() workspace error = %v", err)
|
|
}
|
|
|
|
service := testIssueService(store)
|
|
for _, title := range []string{"First", "Second"} {
|
|
if _, err := service.CreateIssue(ctx, workspaceissues.CreateIssueInput{
|
|
WorkspaceID: "ws-issue-list",
|
|
TopicID: workspaceissues.DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: title,
|
|
}); err != nil {
|
|
t.Fatalf("CreateIssue(%q) error = %v", title, err)
|
|
}
|
|
}
|
|
|
|
firstPage, err := service.ListIssues(ctx, workspaceissues.IssueListFilter{
|
|
WorkspaceID: "ws-issue-list",
|
|
TopicID: workspaceissues.DefaultTopicID,
|
|
PageSize: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListIssues() first page error = %v", err)
|
|
}
|
|
if len(firstPage.Items) != 1 || firstPage.NextPageToken == "" {
|
|
t.Fatalf("first page = %+v", firstPage)
|
|
}
|
|
|
|
cursor, err := workspaceissues.DecodeIssueListCursorToken(firstPage.NextPageToken)
|
|
if err != nil {
|
|
t.Fatalf("DecodeIssueListCursorToken() error = %v", err)
|
|
}
|
|
secondPage, err := service.ListIssues(ctx, workspaceissues.IssueListFilter{
|
|
WorkspaceID: "ws-issue-list",
|
|
TopicID: workspaceissues.DefaultTopicID,
|
|
PageSize: 1,
|
|
Cursor: cursor,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListIssues() second page error = %v", err)
|
|
}
|
|
if len(secondPage.Items) != 1 || secondPage.NextPageToken != "" {
|
|
t.Fatalf("second page = %+v", secondPage)
|
|
}
|
|
if firstPage.Items[0].IssueID == secondPage.Items[0].IssueID {
|
|
t.Fatalf("pagination repeated issue id %q", firstPage.Items[0].IssueID)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteIssueStoreListSearchMatchesTitlesOnly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := openTestSQLiteStore(t)
|
|
ctx := context.Background()
|
|
if err := store.Create(ctx, workspacebiz.Summary{
|
|
ID: "ws-title-search",
|
|
Name: "Title Search Workspace",
|
|
}); err != nil {
|
|
t.Fatalf("Create() workspace error = %v", err)
|
|
}
|
|
|
|
service := testIssueService(store)
|
|
titleMatch, err := service.CreateIssue(ctx, workspaceissues.CreateIssueInput{
|
|
WorkspaceID: "ws-title-search",
|
|
TopicID: workspaceissues.DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Build a toy website",
|
|
Content: "Read the project first",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() title match error = %v", err)
|
|
}
|
|
if _, err := service.CreateIssue(ctx, workspaceissues.CreateIssueInput{
|
|
WorkspaceID: "ws-title-search",
|
|
TopicID: workspaceissues.DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Read the project",
|
|
Content: "Build a toy website",
|
|
}); err != nil {
|
|
t.Fatalf("CreateIssue() content-only match error = %v", err)
|
|
}
|
|
|
|
issues, err := service.ListIssues(ctx, workspaceissues.IssueListFilter{
|
|
WorkspaceID: "ws-title-search",
|
|
TopicID: workspaceissues.DefaultTopicID,
|
|
SearchQuery: "TOY",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListIssues() error = %v", err)
|
|
}
|
|
if len(issues.Items) != 1 || issues.Items[0].IssueID != titleMatch.IssueID {
|
|
t.Fatalf("ListIssues() items = %+v, want only title match %q", issues.Items, titleMatch.IssueID)
|
|
}
|
|
if issues.TotalCount != 1 || issues.StatusCounts.All != 1 {
|
|
t.Fatalf("ListIssues() counts = total:%d statuses:%+v, want one", issues.TotalCount, issues.StatusCounts)
|
|
}
|
|
|
|
titleTask, err := service.CreateTask(ctx, workspaceissues.CreateTaskInput{
|
|
WorkspaceID: "ws-title-search",
|
|
IssueID: titleMatch.IssueID,
|
|
ActorUserID: "user-1",
|
|
Title: "Paint the toy model",
|
|
Content: "Use the reference image",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTask() title match error = %v", err)
|
|
}
|
|
if _, err := service.CreateTask(ctx, workspaceissues.CreateTaskInput{
|
|
WorkspaceID: "ws-title-search",
|
|
IssueID: titleMatch.IssueID,
|
|
ActorUserID: "user-1",
|
|
Title: "Read the reference image",
|
|
Content: "Paint the toy model",
|
|
}); err != nil {
|
|
t.Fatalf("CreateTask() content-only match error = %v", err)
|
|
}
|
|
|
|
tasks, err := service.ListTasks(ctx, workspaceissues.TaskListFilter{
|
|
WorkspaceID: "ws-title-search",
|
|
IssueID: titleMatch.IssueID,
|
|
SearchQuery: "TOY",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListTasks() error = %v", err)
|
|
}
|
|
if len(tasks.Items) != 1 || tasks.Items[0].TaskID != titleTask.TaskID {
|
|
t.Fatalf("ListTasks() items = %+v, want only title match %q", tasks.Items, titleTask.TaskID)
|
|
}
|
|
if tasks.TotalCount != 1 || tasks.StatusCounts.All != 1 {
|
|
t.Fatalf("ListTasks() counts = total:%d statuses:%+v, want one", tasks.TotalCount, tasks.StatusCounts)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteIssueStoreListStatusCountsIgnoreStatusFilter(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := openTestSQLiteStore(t)
|
|
ctx := context.Background()
|
|
if err := store.Create(ctx, workspacebiz.Summary{
|
|
ID: "ws-issue-counts",
|
|
Name: "Issue Counts Workspace",
|
|
}); err != nil {
|
|
t.Fatalf("Create() workspace error = %v", err)
|
|
}
|
|
|
|
service := testIssueService(store)
|
|
runningIssue, err := service.CreateIssue(ctx, workspaceissues.CreateIssueInput{
|
|
WorkspaceID: "ws-issue-counts",
|
|
TopicID: workspaceissues.DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Run renderer migration",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() running issue error = %v", err)
|
|
}
|
|
runningTask, err := service.CreateTask(ctx, workspaceissues.CreateTaskInput{
|
|
WorkspaceID: "ws-issue-counts",
|
|
IssueID: runningIssue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Title: "Execute migration",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTask() running task error = %v", err)
|
|
}
|
|
if _, err := service.UpdateTask(ctx, workspaceissues.UpdateTaskInput{
|
|
WorkspaceID: "ws-issue-counts",
|
|
IssueID: runningIssue.IssueID,
|
|
TaskID: runningTask.TaskID,
|
|
ActorUserID: "user-1",
|
|
Status: string(workspaceissues.StatusRunning),
|
|
HasStatus: true,
|
|
}); err != nil {
|
|
t.Fatalf("UpdateTask() running status error = %v", err)
|
|
}
|
|
notStartedIssue, err := service.CreateIssue(ctx, workspaceissues.CreateIssueInput{
|
|
WorkspaceID: "ws-issue-counts",
|
|
TopicID: workspaceissues.DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Write release notes",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() not started issue error = %v", err)
|
|
}
|
|
if _, err := service.CreateTask(ctx, workspaceissues.CreateTaskInput{
|
|
WorkspaceID: "ws-issue-counts",
|
|
IssueID: runningIssue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Title: "Draft rollout notes",
|
|
}); err != nil {
|
|
t.Fatalf("CreateTask() not started task error = %v", err)
|
|
}
|
|
if _, err := service.CreateTask(ctx, workspaceissues.CreateTaskInput{
|
|
WorkspaceID: "ws-issue-counts",
|
|
IssueID: notStartedIssue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Title: "Collect approvals",
|
|
}); err != nil {
|
|
t.Fatalf("CreateTask() not started issue task error = %v", err)
|
|
}
|
|
|
|
issueList, err := service.ListIssues(ctx, workspaceissues.IssueListFilter{
|
|
WorkspaceID: "ws-issue-counts",
|
|
TopicID: workspaceissues.DefaultTopicID,
|
|
StatusFilter: workspaceissues.StatusRunning,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListIssues() error = %v", err)
|
|
}
|
|
if issueList.TotalCount != 1 || len(issueList.Items) != 1 {
|
|
t.Fatalf("issue list = %+v", issueList)
|
|
}
|
|
if issueList.StatusCounts.All != 2 ||
|
|
issueList.StatusCounts.Running != 1 ||
|
|
issueList.StatusCounts.NotStarted != 1 {
|
|
t.Fatalf("issue status counts = %+v", issueList.StatusCounts)
|
|
}
|
|
|
|
taskList, err := service.ListTasks(ctx, workspaceissues.TaskListFilter{
|
|
WorkspaceID: "ws-issue-counts",
|
|
IssueID: runningIssue.IssueID,
|
|
StatusFilter: workspaceissues.StatusRunning,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListTasks() error = %v", err)
|
|
}
|
|
if taskList.TotalCount != 1 || len(taskList.Items) != 1 {
|
|
t.Fatalf("task list = %+v", taskList)
|
|
}
|
|
if taskList.StatusCounts.All != 2 ||
|
|
taskList.StatusCounts.Running != 1 ||
|
|
taskList.StatusCounts.NotStarted != 1 {
|
|
t.Fatalf("task status counts = %+v", taskList.StatusCounts)
|
|
}
|
|
if taskList.Items[0].TaskID != runningTask.TaskID {
|
|
t.Fatalf("task list item = %+v", taskList.Items[0])
|
|
}
|
|
}
|
|
|
|
func TestSQLiteIssueStoreRemoveContextRefUsesScopedParent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := openTestSQLiteStore(t)
|
|
ctx := context.Background()
|
|
if err := store.Create(ctx, workspacebiz.Summary{
|
|
ID: "ws-issue-scope",
|
|
Name: "Issue Scope Workspace",
|
|
}); err != nil {
|
|
t.Fatalf("Create() workspace error = %v", err)
|
|
}
|
|
|
|
service := testIssueService(store)
|
|
issue, err := service.CreateIssue(ctx, workspaceissues.CreateIssueInput{
|
|
WorkspaceID: "ws-issue-scope",
|
|
TopicID: workspaceissues.DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Scoped refs",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
task, err := service.CreateTask(ctx, workspaceissues.CreateTaskInput{
|
|
WorkspaceID: "ws-issue-scope",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Title: "Task ref",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTask() error = %v", err)
|
|
}
|
|
issueRefs, err := service.AddContextRefs(ctx, workspaceissues.AddContextRefsInput{
|
|
WorkspaceID: "ws-issue-scope",
|
|
IssueID: issue.IssueID,
|
|
ParentKind: string(workspaceissues.ContextRefParentIssue),
|
|
Refs: []workspaceissues.AddContextRefInput{{
|
|
ContextRefID: "issue-ref-1",
|
|
RefType: "file",
|
|
Path: "/workspace/docs/plan.md",
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AddContextRefs() issue error = %v", err)
|
|
}
|
|
taskRefs, err := service.AddContextRefs(ctx, workspaceissues.AddContextRefsInput{
|
|
WorkspaceID: "ws-issue-scope",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
ParentKind: string(workspaceissues.ContextRefParentTask),
|
|
Refs: []workspaceissues.AddContextRefInput{{
|
|
ContextRefID: "task-ref-1",
|
|
RefType: "file",
|
|
Path: "/workspace/docs/task.md",
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AddContextRefs() task error = %v", err)
|
|
}
|
|
if len(issueRefs) != 1 || len(taskRefs) != 1 {
|
|
t.Fatalf("issueRefs = %+v taskRefs = %+v", issueRefs, taskRefs)
|
|
}
|
|
|
|
removed, err := service.RemoveContextRef(ctx, workspaceissues.RemoveContextRefInput{
|
|
WorkspaceID: "ws-issue-scope",
|
|
IssueID: issue.IssueID,
|
|
ParentKind: string(workspaceissues.ContextRefParentIssue),
|
|
ContextRefID: taskRefs[0].ContextRefID,
|
|
})
|
|
if !errors.Is(err, workspaceissues.ErrContextRefNotFound) {
|
|
t.Fatalf("RemoveContextRef() error = %v, want ErrContextRefNotFound", err)
|
|
}
|
|
if removed {
|
|
t.Fatal("RemoveContextRef() removed = true, want false")
|
|
}
|
|
|
|
removed, err = service.RemoveContextRef(ctx, workspaceissues.RemoveContextRefInput{
|
|
WorkspaceID: "ws-issue-scope",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
ParentKind: string(workspaceissues.ContextRefParentTask),
|
|
ContextRefID: taskRefs[0].ContextRefID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RemoveContextRef() task error = %v", err)
|
|
}
|
|
if !removed {
|
|
t.Fatal("RemoveContextRef() task removed = false, want true")
|
|
}
|
|
|
|
issueDetail, err := service.GetIssueDetail(ctx, "ws-issue-scope", issue.IssueID)
|
|
if err != nil {
|
|
t.Fatalf("GetIssueDetail() error = %v", err)
|
|
}
|
|
if len(issueDetail.ContextRefs) != 1 {
|
|
t.Fatalf("issue context refs len = %d, want 1", len(issueDetail.ContextRefs))
|
|
}
|
|
|
|
taskDetail, err := service.GetTaskDetail(ctx, "ws-issue-scope", issue.IssueID, task.TaskID)
|
|
if err != nil {
|
|
t.Fatalf("GetTaskDetail() error = %v", err)
|
|
}
|
|
if len(taskDetail.ContextRefs) != 0 {
|
|
t.Fatalf("task context refs len = %d, want 0", len(taskDetail.ContextRefs))
|
|
}
|
|
}
|
|
|
|
func TestSQLiteIssueStoreDuplicateResourceIDsReturnTypedErrors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := openTestSQLiteStore(t)
|
|
ctx := context.Background()
|
|
if err := store.Create(ctx, workspacebiz.Summary{
|
|
ID: "ws-issue-dup",
|
|
Name: "Issue Duplicate Workspace",
|
|
}); err != nil {
|
|
t.Fatalf("Create() workspace error = %v", err)
|
|
}
|
|
|
|
service := testIssueService(store)
|
|
issue, err := service.CreateIssue(ctx, workspaceissues.CreateIssueInput{
|
|
WorkspaceID: "ws-issue-dup",
|
|
TopicID: workspaceissues.DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
IssueID: "issue-fixed",
|
|
Title: "Fixed issue id",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
if _, err := service.CreateIssue(ctx, workspaceissues.CreateIssueInput{
|
|
WorkspaceID: "ws-issue-dup",
|
|
TopicID: workspaceissues.DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
IssueID: "issue-fixed",
|
|
Title: "Duplicate issue id",
|
|
}); !errors.Is(err, workspaceissues.ErrIssueAlreadyExists) {
|
|
t.Fatalf("CreateIssue() duplicate error = %v, want ErrIssueAlreadyExists", err)
|
|
}
|
|
|
|
task, err := service.CreateTask(ctx, workspaceissues.CreateTaskInput{
|
|
WorkspaceID: "ws-issue-dup",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
TaskID: "task-fixed",
|
|
Title: "Fixed task id",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTask() error = %v", err)
|
|
}
|
|
if _, err := service.CreateTask(ctx, workspaceissues.CreateTaskInput{
|
|
WorkspaceID: "ws-issue-dup",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
TaskID: "task-fixed",
|
|
Title: "Duplicate task id",
|
|
}); !errors.Is(err, workspaceissues.ErrTaskAlreadyExists) {
|
|
t.Fatalf("CreateTask() duplicate error = %v, want ErrTaskAlreadyExists", err)
|
|
}
|
|
|
|
if _, err := service.AddContextRefs(ctx, workspaceissues.AddContextRefsInput{
|
|
WorkspaceID: "ws-issue-dup",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
ParentKind: string(workspaceissues.ContextRefParentTask),
|
|
Refs: []workspaceissues.AddContextRefInput{{
|
|
ContextRefID: "ref-fixed",
|
|
RefType: "file",
|
|
Path: "/workspace/docs/ref.md",
|
|
}},
|
|
}); err != nil {
|
|
t.Fatalf("AddContextRefs() error = %v", err)
|
|
}
|
|
if _, err := service.AddContextRefs(ctx, workspaceissues.AddContextRefsInput{
|
|
WorkspaceID: "ws-issue-dup",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
ParentKind: string(workspaceissues.ContextRefParentTask),
|
|
Refs: []workspaceissues.AddContextRefInput{{
|
|
ContextRefID: "ref-fixed",
|
|
RefType: "file",
|
|
Path: "/workspace/docs/ref-again.md",
|
|
}},
|
|
}); !errors.Is(err, workspaceissues.ErrContextRefAlreadyExists) {
|
|
t.Fatalf("AddContextRefs() duplicate error = %v, want ErrContextRefAlreadyExists", err)
|
|
}
|
|
|
|
if _, err := service.CreateRun(ctx, workspaceissues.CreateRunInput{
|
|
WorkspaceID: "ws-issue-dup",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
ActorUserID: "user-1",
|
|
RunID: "run-fixed",
|
|
AgentProvider: "codex",
|
|
AgentTargetID: "local:codex",
|
|
}); err != nil {
|
|
t.Fatalf("CreateRun() error = %v", err)
|
|
}
|
|
if _, err := service.CreateRun(ctx, workspaceissues.CreateRunInput{
|
|
WorkspaceID: "ws-issue-dup",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
ActorUserID: "user-1",
|
|
RunID: "run-fixed",
|
|
AgentProvider: "codex",
|
|
AgentTargetID: "local:codex",
|
|
}); !errors.Is(err, workspaceissues.ErrRunAlreadyExists) {
|
|
t.Fatalf("CreateRun() duplicate error = %v, want ErrRunAlreadyExists", err)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteIssueStoreRollsBackIssueWhenBatchTaskInsertFails(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := openTestSQLiteStore(t)
|
|
ctx := context.Background()
|
|
if err := store.Create(ctx, workspacebiz.Summary{
|
|
ID: "ws-issue-batch-rollback",
|
|
Name: "Batch Rollback Workspace",
|
|
}); err != nil {
|
|
t.Fatalf("Create() workspace error = %v", err)
|
|
}
|
|
service := testIssueService(store)
|
|
_, _, err := service.CreateIssueWithTasks(ctx, workspaceissues.CreateIssueWithTasksInput{
|
|
Issue: workspaceissues.CreateIssueInput{
|
|
WorkspaceID: "ws-issue-batch-rollback",
|
|
TopicID: workspaceissues.DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
IssueID: "issue-batch-rollback",
|
|
Title: "Atomic Plan issue",
|
|
PlanningSource: string(workspaceissues.PlanningSourceTuttiModePlan),
|
|
},
|
|
Tasks: []workspaceissues.CreateTaskItemInput{
|
|
{TaskID: "duplicate-task", Title: "First"},
|
|
{TaskID: "duplicate-task", Title: "Second"},
|
|
},
|
|
})
|
|
if !errors.Is(err, workspaceissues.ErrTaskAlreadyExists) {
|
|
t.Fatalf("CreateIssueWithTasks() error = %v, want ErrTaskAlreadyExists", err)
|
|
}
|
|
if _, err := store.GetIssue(ctx, "ws-issue-batch-rollback", "issue-batch-rollback"); !errors.Is(err, workspaceissues.ErrIssueNotFound) {
|
|
t.Fatalf("GetIssue() after rollback error = %v, want ErrIssueNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteIssueStoreRollsBackIssueWhenContextRefInsertFails(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := openTestSQLiteStore(t)
|
|
ctx := context.Background()
|
|
const workspaceID = "ws-issue-attachment-rollback"
|
|
if err := store.Create(ctx, workspacebiz.Summary{ID: workspaceID, Name: "Attachment rollback"}); err != nil {
|
|
t.Fatalf("Create() workspace error = %v", err)
|
|
}
|
|
service := testIssueService(store)
|
|
_, _, err := service.CreateIssueWithContextRefs(ctx, workspaceissues.CreateIssueWithContextRefsInput{
|
|
Issue: workspaceissues.CreateIssueInput{
|
|
WorkspaceID: workspaceID,
|
|
TopicID: workspaceissues.DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
IssueID: "issue-attachment-rollback",
|
|
Title: "Atomic attachment issue",
|
|
},
|
|
Refs: []workspaceissues.AddContextRefInput{
|
|
{ContextRefID: "duplicate-ref", RefType: "image/png", Path: "/managed/first.png"},
|
|
{ContextRefID: "duplicate-ref", RefType: "image/png", Path: "/managed/second.png"},
|
|
},
|
|
})
|
|
if !errors.Is(err, workspaceissues.ErrContextRefAlreadyExists) {
|
|
t.Fatalf("CreateIssueWithContextRefs() error = %v, want ErrContextRefAlreadyExists", err)
|
|
}
|
|
if _, err := store.GetIssue(ctx, workspaceID, "issue-attachment-rollback"); !errors.Is(err, workspaceissues.ErrIssueNotFound) {
|
|
t.Fatalf("GetIssue() after rollback error = %v, want ErrIssueNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteIssueStoreRollsBackImplicitTaskWhenLaunchIntentAdmissionFails(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := openTestSQLiteStore(t)
|
|
ctx := context.Background()
|
|
const workspaceID = "ws-explicit-launch-admission-rollback"
|
|
if err := store.Create(ctx, workspacebiz.Summary{ID: workspaceID, Name: "Launch admission rollback"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service := testIssueService(store)
|
|
issue, err := service.CreateIssue(ctx, workspaceissues.CreateIssueInput{
|
|
WorkspaceID: workspaceID,
|
|
TopicID: workspaceissues.DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Atomic implicit task",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
prepared, err := service.PrepareRun(ctx, workspaceissues.CreateRunInput{
|
|
WorkspaceID: workspaceID,
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
RunID: "run-admission-rollback",
|
|
AgentTargetID: "local:codex",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !prepared.TaskIsNew {
|
|
t.Fatal("PrepareRun() TaskIsNew = false, want true")
|
|
}
|
|
if _, err := store.writeDB.ExecContext(ctx, `
|
|
CREATE TRIGGER reject_explicit_launch_intent_admission
|
|
BEFORE INSERT ON workspace_issue_run_launch_intents
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'injected launch intent admission failure');
|
|
END;
|
|
`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := store.CreateIssueRunWithLaunchIntent(
|
|
ctx,
|
|
prepared,
|
|
workspaceissues.IssueRunClientSubmitID(prepared.Run.RunID),
|
|
`{}`,
|
|
); err == nil {
|
|
t.Fatal("CreateIssueRunWithLaunchIntent() error = nil, want injected rollback")
|
|
}
|
|
tasks, err := store.ListTasks(ctx, workspaceissues.TaskListFilter{
|
|
WorkspaceID: workspaceID,
|
|
IssueID: issue.IssueID,
|
|
ReturnAll: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(tasks.Items) != 0 {
|
|
t.Fatalf("tasks after rollback = %+v, want none", tasks.Items)
|
|
}
|
|
if _, err := store.GetRun(
|
|
ctx,
|
|
workspaceID,
|
|
issue.IssueID,
|
|
prepared.Task.TaskID,
|
|
prepared.Run.RunID,
|
|
); !errors.Is(err, workspaceissues.ErrRunNotFound) {
|
|
t.Fatalf("GetRun() after rollback error = %v, want ErrRunNotFound", err)
|
|
}
|
|
detail, err := service.GetIssueDetail(ctx, workspaceID, issue.IssueID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if detail.Issue.TaskCount != 0 || detail.Issue.RunningCount != 0 {
|
|
t.Fatalf("Issue projection after rollback = %+v", detail.Issue)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteIssueStoreSettlesExplicitLaunchAtomically(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := openTestSQLiteStore(t)
|
|
ctx := context.Background()
|
|
const workspaceID = "ws-explicit-launch-settlement"
|
|
if err := store.Create(ctx, workspacebiz.Summary{ID: workspaceID, Name: "Launch settlement"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service := testIssueService(store)
|
|
issue, err := service.CreateIssue(ctx, workspaceissues.CreateIssueInput{
|
|
WorkspaceID: workspaceID, TopicID: workspaceissues.DefaultTopicID,
|
|
ActorUserID: "user-1", Title: "Atomic launch settlement",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
task, err := service.CreateTask(ctx, workspaceissues.CreateTaskInput{
|
|
WorkspaceID: workspaceID, IssueID: issue.IssueID,
|
|
ActorUserID: "user-1", Title: "Launch", AgentTargetID: "local:codex",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
prepared, err := service.PrepareRun(ctx, workspaceissues.CreateRunInput{
|
|
WorkspaceID: workspaceID, IssueID: issue.IssueID, TaskID: task.TaskID,
|
|
ActorUserID: "user-1", RunID: "run-atomic-settlement",
|
|
AgentTargetID: "local:codex", AgentSessionID: "session-atomic-settlement",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.CreateIssueRunWithLaunchIntent(
|
|
ctx, prepared, "issue-run:run-atomic-settlement",
|
|
`{"title":"Atomic launch settlement","prompt":"Original prompt"}`,
|
|
); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.UnixMilli(1_700_000_200_000).UTC()
|
|
claimed, err := store.ClaimIssueRunLaunchIntent(
|
|
ctx, workspaceID, issue.IssueID, prepared.Run.RunID, "owner-1", now, now.Add(time.Minute),
|
|
)
|
|
if err != nil || !claimed {
|
|
t.Fatalf("ClaimIssueRunLaunchIntent() claimed=%v error=%v", claimed, err)
|
|
}
|
|
if _, err := store.writeDB.ExecContext(ctx, `
|
|
CREATE TRIGGER reject_explicit_launch_task_settlement
|
|
BEFORE UPDATE ON workspace_issue_tasks
|
|
WHEN NEW.status = 'failed'
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'injected explicit settlement failure');
|
|
END;
|
|
`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settlement := workspaceissues.RunLaunchSettlement{
|
|
WorkspaceID: workspaceID, IssueID: issue.IssueID, RunID: prepared.Run.RunID,
|
|
LeaseOwner: "owner-1", Status: workspaceissues.StatusFailed,
|
|
ErrorMessage: "definite pre-delivery failure", NowUnixMS: now.Add(time.Second).UnixMilli(),
|
|
}
|
|
if _, err := store.SettleIssueRunLaunch(ctx, settlement); err == nil {
|
|
t.Fatal("SettleIssueRunLaunch() error = nil, want injected rollback")
|
|
}
|
|
run, err := store.GetRun(ctx, workspaceID, issue.IssueID, task.TaskID, prepared.Run.RunID)
|
|
if err != nil || run.Status != workspaceissues.StatusRunning {
|
|
t.Fatalf("Run after rollback = %#v, error = %v", run, err)
|
|
}
|
|
if err := store.RequeueLeasedIssueRunLaunchIntents(ctx, workspaceID, now.Add(2*time.Minute)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
launches, err := store.ListPreparedIssueRunLaunches(ctx, workspaceID)
|
|
if err != nil || len(launches) != 1 {
|
|
t.Fatalf("prepared launches after rollback = %#v, error = %v", launches, err)
|
|
}
|
|
if _, err := store.writeDB.ExecContext(ctx, `DROP TRIGGER reject_explicit_launch_task_settlement`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
claimed, err = store.ClaimIssueRunLaunchIntent(
|
|
ctx, workspaceID, issue.IssueID, prepared.Run.RunID, "owner-2",
|
|
now.Add(2*time.Minute), now.Add(3*time.Minute),
|
|
)
|
|
if err != nil || !claimed {
|
|
t.Fatalf("ClaimIssueRunLaunchIntent(retry) claimed=%v error=%v", claimed, err)
|
|
}
|
|
settlement.LeaseOwner = "owner-2"
|
|
settled, err := store.SettleIssueRunLaunch(ctx, settlement)
|
|
if err != nil || settled.Status != workspaceissues.StatusFailed {
|
|
t.Fatalf("SettleIssueRunLaunch(retry) Run=%#v error=%v", settled, err)
|
|
}
|
|
task, err = store.GetTask(ctx, workspaceID, issue.IssueID, task.TaskID)
|
|
if err != nil || task.Status != workspaceissues.StatusFailed {
|
|
t.Fatalf("Task after settlement = %#v, error = %v", task, err)
|
|
}
|
|
}
|
|
|
|
func testIssueService(store workspaceissues.Store) workspaceissues.Service {
|
|
counters := map[workspaceissues.IDKind]int{}
|
|
return workspaceissues.Service{
|
|
Clock: func() time.Time {
|
|
return time.UnixMilli(1_700_000_000_000)
|
|
},
|
|
IDGenerator: func(kind workspaceissues.IDKind) string {
|
|
counters[kind]++
|
|
return string(kind) + "-" + strconv.Itoa(counters[kind])
|
|
},
|
|
Store: store,
|
|
}
|
|
}
|