chore(repo): reinitialize repository
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,997 @@
|
||||
package leaderloop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"inbox/internal/app/runtimecodex"
|
||||
"inbox/internal/app/runtimeconfig"
|
||||
"inbox/internal/app/workspaceruntime"
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/lane"
|
||||
"inbox/internal/domain/message"
|
||||
"inbox/internal/domain/role"
|
||||
"inbox/internal/domain/task"
|
||||
"inbox/internal/domain/topic"
|
||||
"inbox/internal/domain/workflow"
|
||||
"inbox/internal/domain/workspace"
|
||||
sqlitestore "inbox/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func TestProcessOnceConsumesLeaderMessageAndCreatesLaneTasks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 17, 4, 0, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ws, topicRecord := seedLeaderLoopWorkspace(t, ctx, store, clock)
|
||||
if _, err := store.CreateMessage(ctx, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
FromRoleName: "user",
|
||||
ToExpr: "leader",
|
||||
Type: message.TypeChat,
|
||||
Stage: "plan",
|
||||
BodyMarkdown: "Build a todo app.",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateMessage() error = %v", err)
|
||||
}
|
||||
|
||||
fakeRuntime := &fakeWorkspaceRuntime{workspace: ws}
|
||||
runner := &fakeRunner{result: RunResult{
|
||||
Status: workflow.RunStatusSucceeded,
|
||||
ResultJSON: `{
|
||||
"plan_summary_markdown":"Leader created the first execution lane.",
|
||||
"plan_mode":"initial",
|
||||
"execution_mode":"plan_only",
|
||||
"leader_reply":{"markdown":"I split the work into a UI graph and will wait for confirmation.","type":"chat"},
|
||||
"tasks":[
|
||||
{"key":"design","title":"Design the UI","body_markdown":"Create the React UI.","acceptance_markdown":"UI is polished.","kind":"execution","deliverables":["apps/web/src"],"priority":10,"task_order":1,"depends_on":[]},
|
||||
{"key":"verify","title":"Verify the UI","body_markdown":"Verify the UI flow.","acceptance_markdown":"Verification complete.","kind":"verification","deliverables":["reports/ui-check.md"],"priority":5,"task_order":2,"depends_on":["design"]}
|
||||
]
|
||||
}`,
|
||||
}}
|
||||
service := NewService(
|
||||
store,
|
||||
runtimeconfig.NewService(store, store, clock),
|
||||
fakeRuntime,
|
||||
runner,
|
||||
clock,
|
||||
"",
|
||||
)
|
||||
|
||||
result, err := service.ProcessOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessOnce() error = %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected leader result")
|
||||
}
|
||||
if len(result.Lanes) != 1 || result.Lanes[0].Name != "Design the UI" {
|
||||
t.Fatalf("unexpected lanes: %#v", result.Lanes)
|
||||
}
|
||||
if len(result.Tasks) != 2 {
|
||||
t.Fatalf("unexpected tasks: %#v", result.Tasks)
|
||||
}
|
||||
if fakeRuntime.startedLaneID != "" {
|
||||
t.Fatalf("expected initial graph to wait for confirmation, got started lane %q", fakeRuntime.startedLaneID)
|
||||
}
|
||||
if result.Reply == nil || result.Reply.ToExpr != "user" {
|
||||
t.Fatalf("expected reply to user, got %#v", result.Reply)
|
||||
}
|
||||
if !strings.Contains(runner.prompt, "## Skills") || !strings.Contains(runner.prompt, "Use Inbox V2") {
|
||||
t.Fatalf("expected leader prompt to include bound skills, got %q", runner.prompt)
|
||||
}
|
||||
if !strings.Contains(result.Run.CommandJSON, `"planning"`) || !strings.Contains(result.Run.CommandJSON, `"execution_mode":"plan_only"`) {
|
||||
t.Fatalf("expected planning payload in command_json, got %s", result.Run.CommandJSON)
|
||||
}
|
||||
|
||||
tasks, err := store.ListTasksByTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTasksByTopic() error = %v", err)
|
||||
}
|
||||
if len(tasks) != 2 {
|
||||
t.Fatalf("expected 2 tasks in store, got %d", len(tasks))
|
||||
}
|
||||
if tasks[0].Status != task.StatusReady || tasks[1].Status != task.StatusDraft {
|
||||
t.Fatalf("unexpected task statuses: %#v", tasks)
|
||||
}
|
||||
updatedTopic, err := store.GetTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTopic() error = %v", err)
|
||||
}
|
||||
if updatedTopic.Status != "awaiting_confirmation" {
|
||||
t.Fatalf("expected awaiting_confirmation topic, got %#v", updatedTopic)
|
||||
}
|
||||
graphVersion, err := store.GetLatestTaskGraphVersionByTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestTaskGraphVersionByTopic() error = %v", err)
|
||||
}
|
||||
if graphVersion.Status != "draft" {
|
||||
t.Fatalf("expected draft graph version, got %#v", graphVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLeaderOutputRejectsInitialFreezeStartNodes(t *testing.T) {
|
||||
_, err := parseLeaderOutput(`{
|
||||
"plan_summary_markdown":"Draft the initial graph.",
|
||||
"plan_mode":"initial",
|
||||
"execution_mode":"plan_only",
|
||||
"leader_reply":{"markdown":"Please confirm the graph first.","type":"summary"},
|
||||
"tasks":[
|
||||
{"key":"build","title":"Build app","body_markdown":"Implement app.","acceptance_markdown":"App works.","kind":"execution","deliverables":["apps/web"],"priority":1,"task_order":1,"depends_on":[]}
|
||||
],
|
||||
"start_nodes":["app"]
|
||||
}`, true)
|
||||
if err == nil || !strings.Contains(err.Error(), `unknown field "start_nodes"`) {
|
||||
t.Fatalf("expected initial freeze start_nodes error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderOutputSchemaRequiresEveryDeclaredProperty(t *testing.T) {
|
||||
for _, initialFreeze := range []bool{true, false} {
|
||||
schemaJSON := leaderOutputSchema(initialFreeze)
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal([]byte(schemaJSON), &schema); err != nil {
|
||||
t.Fatalf("json.Unmarshal(schema) error = %v", err)
|
||||
}
|
||||
|
||||
assertSchemaRequiredMatchesProperties(t, schema)
|
||||
|
||||
properties := schema["properties"].(map[string]any)
|
||||
tasks := properties["tasks"].(map[string]any)
|
||||
items := tasks["items"].(map[string]any)
|
||||
assertSchemaRequiredMatchesProperties(t, items)
|
||||
|
||||
leaderReply := properties["leader_reply"].(map[string]any)
|
||||
assertSchemaRequiredMatchesProperties(t, leaderReply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessOnceReusesExistingLaneOnWorkerFollowUp(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 17, 4, 30, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ws, topicRecord := seedLeaderLoopWorkspace(t, ctx, store, clock)
|
||||
_, err = store.CreateLane(ctx, lane.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
Name: "UI Chain",
|
||||
Slug: "ui-chain",
|
||||
Status: lane.StatusBlocked,
|
||||
CreatedByRoleName: "leader",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateLane() error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateMessage(ctx, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
FromRoleName: "worker",
|
||||
ToExpr: "leader",
|
||||
Type: message.TypeSummary,
|
||||
Stage: "execution",
|
||||
BodyMarkdown: "UI chain failed the first task.",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateMessage() error = %v", err)
|
||||
}
|
||||
|
||||
fakeRuntime := &fakeWorkspaceRuntime{workspace: ws}
|
||||
service := NewService(
|
||||
store,
|
||||
runtimeconfig.NewService(store, store, clock),
|
||||
fakeRuntime,
|
||||
&fakeRunner{result: RunResult{
|
||||
Status: workflow.RunStatusSucceeded,
|
||||
ResultJSON: `{
|
||||
"plan_summary_markdown":"Reused the UI chain and queued a retry task.",
|
||||
"plan_mode":"patch",
|
||||
"replan_reason":"UI chain failed and needs a focused retry.",
|
||||
"execution_mode":"plan_and_start",
|
||||
"leader_reply":{"markdown":"继续在现有图上修复。","type":"decision"},
|
||||
"tasks":[
|
||||
{"key":"retry-ui","title":"修复 UI 初始化失败","body_markdown":"在现有 UI 图上修复失败原因。","acceptance_markdown":"UI graph 恢复可执行。","kind":"execution","deliverables":["apps/web/src"],"priority":10,"task_order":3,"depends_on":[]}
|
||||
]
|
||||
}`,
|
||||
}},
|
||||
clock,
|
||||
"",
|
||||
)
|
||||
|
||||
result, err := service.ProcessOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessOnce() error = %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected leader result")
|
||||
}
|
||||
if fakeRuntime.startedLaneID == "" {
|
||||
t.Fatalf("expected a derived lane to be started, got none")
|
||||
}
|
||||
lanes, err := store.ListLanesByTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListLanesByTopic() error = %v", err)
|
||||
}
|
||||
if len(lanes) != 2 {
|
||||
t.Fatalf("expected existing blocked lane plus one derived execution lane, got %#v", lanes)
|
||||
}
|
||||
tasks, err := store.ListTasksByTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTasksByTopic() error = %v", err)
|
||||
}
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("expected retry task on existing chain, got %#v", tasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLeaderOutputRejectsInvalidPlanOnlyStartNodes(t *testing.T) {
|
||||
_, err := parseLeaderOutput(`{
|
||||
"plan_summary_markdown":"Planned only.",
|
||||
"plan_mode":"initial",
|
||||
"execution_mode":"plan_only",
|
||||
"leader_reply":{"markdown":"先只输出计划。","type":"summary"},
|
||||
"tasks":[
|
||||
{"key":"design","title":"Design the UI","body_markdown":"Create the React UI.","acceptance_markdown":"UI is polished.","kind":"execution","deliverables":["apps/web/src"],"priority":10,"task_order":1,"depends_on":[]}
|
||||
],
|
||||
"start_nodes":["ui"]
|
||||
}`, false)
|
||||
if err == nil || !strings.Contains(err.Error(), `unknown field "start_nodes"`) {
|
||||
t.Fatalf("expected invalid plan_only start_nodes error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLeaderOutputRejectsMissingDeliverables(t *testing.T) {
|
||||
_, err := parseLeaderOutput(`{
|
||||
"plan_summary_markdown":"Plan.",
|
||||
"plan_mode":"initial",
|
||||
"execution_mode":"plan_and_start",
|
||||
"leader_reply":{"markdown":"开始执行。","type":"decision"},
|
||||
"tasks":[
|
||||
{"key":"design","title":"Design the UI","body_markdown":"Create the React UI.","acceptance_markdown":"UI is polished.","kind":"execution","deliverables":[],"priority":10,"task_order":1,"depends_on":[]}
|
||||
]
|
||||
}`, false)
|
||||
if err == nil || !strings.Contains(err.Error(), "must declare deliverables") {
|
||||
t.Fatalf("expected missing deliverables error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLeaderOutputRejectsDuplicateTaskKeys(t *testing.T) {
|
||||
_, err := parseLeaderOutput(`{
|
||||
"plan_summary_markdown":"Plan.",
|
||||
"plan_mode":"initial",
|
||||
"execution_mode":"plan_only",
|
||||
"leader_reply":{"markdown":"先看计划。","type":"summary"},
|
||||
"tasks":[
|
||||
{"key":"ui","title":"Build UI","body_markdown":"Create the React UI.","acceptance_markdown":"UI is polished.","kind":"execution","deliverables":["apps/web/src"],"depends_on":[]},
|
||||
{"key":"ui","title":"Verify UI","body_markdown":"Verify the React UI.","acceptance_markdown":"Verification complete.","kind":"verification","deliverables":["reports/ui-check.md"],"depends_on":["ui"]}
|
||||
]
|
||||
}`, true)
|
||||
if err == nil || !strings.Contains(err.Error(), `must be unique`) {
|
||||
t.Fatalf("expected duplicate task key error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLeaderOutputAllowsMilestoneWithoutDeliverables(t *testing.T) {
|
||||
output, err := parseLeaderOutput(`{
|
||||
"plan_summary_markdown":"Plan with milestone.",
|
||||
"plan_mode":"initial",
|
||||
"execution_mode":"plan_only",
|
||||
"leader_reply":{"markdown":"先看计划。","type":"summary"},
|
||||
"tasks":[
|
||||
{"key":"ui","title":"Build UI","body_markdown":"Build the UI.","acceptance_markdown":"UI works.","kind":"execution","deliverables":["apps/web/src"],"priority":10,"task_order":1,"depends_on":[]},
|
||||
{"key":"ready-for-demo","title":"Ready for Demo","body_markdown":"Aggregate completion.","acceptance_markdown":"Milestone reached.","kind":"milestone","priority":5,"task_order":2,"depends_on":["ui"]}
|
||||
]
|
||||
}`, true)
|
||||
if err != nil {
|
||||
t.Fatalf("parseLeaderOutput() error = %v", err)
|
||||
}
|
||||
if len(output.Tasks) != 2 || output.Tasks[1].Kind != "milestone" {
|
||||
t.Fatalf("expected milestone task, got %#v", output.Tasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessOnceKeepsMilestoneOnExecutionLane(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 17, 4, 45, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ws, topicRecord := seedLeaderLoopWorkspace(t, ctx, store, clock)
|
||||
if _, err := store.CreateMessage(ctx, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
FromRoleName: "user",
|
||||
ToExpr: "leader",
|
||||
Type: message.TypeChat,
|
||||
Stage: "plan",
|
||||
BodyMarkdown: "Build UI and mark the review milestone.",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateMessage() error = %v", err)
|
||||
}
|
||||
|
||||
service := NewService(
|
||||
store,
|
||||
runtimeconfig.NewService(store, store, clock),
|
||||
&fakeWorkspaceRuntime{workspace: ws},
|
||||
&fakeRunner{result: RunResult{
|
||||
Status: workflow.RunStatusSucceeded,
|
||||
ResultJSON: `{
|
||||
"plan_summary_markdown":"One execution lane plus milestone.",
|
||||
"plan_mode":"initial",
|
||||
"execution_mode":"plan_only",
|
||||
"leader_reply":{"markdown":"先确认这个图。","type":"summary"},
|
||||
"tasks":[
|
||||
{"key":"ui","title":"Build UI","body_markdown":"Build the UI.","acceptance_markdown":"UI works.","kind":"execution","deliverables":["apps/web/src"],"priority":10,"task_order":1,"depends_on":[]},
|
||||
{"key":"ready-for-demo","title":"Ready for Demo","body_markdown":"Aggregate completion.","acceptance_markdown":"Milestone reached.","kind":"milestone","priority":5,"task_order":2,"depends_on":["ui"]}
|
||||
]
|
||||
}`,
|
||||
}},
|
||||
clock,
|
||||
"",
|
||||
)
|
||||
|
||||
result, err := service.ProcessOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessOnce() error = %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected leader result")
|
||||
}
|
||||
lanes, err := store.ListLanesByTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListLanesByTopic() error = %v", err)
|
||||
}
|
||||
if len(lanes) != 1 {
|
||||
t.Fatalf("expected milestone to reuse the execution lane, got %#v", lanes)
|
||||
}
|
||||
tasks, err := store.ListTasksByTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTasksByTopic() error = %v", err)
|
||||
}
|
||||
if len(tasks) != 2 || tasks[0].LaneID != tasks[1].LaneID {
|
||||
t.Fatalf("expected milestone and execution task to share a lane, got %#v", tasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessOnceStartsOnlyGateLanesInitially(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 17, 5, 0, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ws, topicRecord := seedLeaderLoopWorkspace(t, ctx, store, clock)
|
||||
if _, err := store.CreateMessage(ctx, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
FromRoleName: "user",
|
||||
ToExpr: "leader",
|
||||
Type: message.TypeChat,
|
||||
Stage: "plan",
|
||||
BodyMarkdown: "Build a gated todo app.",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateMessage() error = %v", err)
|
||||
}
|
||||
|
||||
fakeRuntime := &fakeWorkspaceRuntime{workspace: ws}
|
||||
service := NewService(
|
||||
store,
|
||||
runtimeconfig.NewService(store, store, clock),
|
||||
fakeRuntime,
|
||||
&fakeRunner{result: RunResult{
|
||||
Status: workflow.RunStatusSucceeded,
|
||||
ResultJSON: `{
|
||||
"plan_summary_markdown":"Start with foundation gating.",
|
||||
"plan_mode":"initial",
|
||||
"execution_mode":"plan_only",
|
||||
"leader_reply":{"markdown":"先跑基础检查。","type":"decision"},
|
||||
"tasks":[
|
||||
{"key":"gate","title":"Check workspace baseline","body_markdown":"Validate setup.","acceptance_markdown":"Setup is valid.","kind":"gate","deliverables":["reports/gate.md"],"priority":10,"task_order":1,"depends_on":[]},
|
||||
{"key":"ui","title":"Build UI","body_markdown":"Implement the UI.","acceptance_markdown":"UI works.","kind":"execution","deliverables":["apps/web/src"],"priority":10,"task_order":1,"depends_on":[]}
|
||||
]
|
||||
}`,
|
||||
}},
|
||||
clock,
|
||||
"",
|
||||
)
|
||||
|
||||
result, err := service.ProcessOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessOnce() error = %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected result")
|
||||
}
|
||||
lanes, err := store.ListLanesByTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListLanesByTopic() error = %v", err)
|
||||
}
|
||||
tasks, err := store.ListTasksByTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTasksByTopic() error = %v", err)
|
||||
}
|
||||
var gateTask, uiTask task.Record
|
||||
for _, item := range tasks {
|
||||
if item.Kind == task.KindGate {
|
||||
gateTask = item
|
||||
}
|
||||
if item.Title == "Build UI" {
|
||||
uiTask = item
|
||||
}
|
||||
}
|
||||
if len(lanes) != 1 {
|
||||
t.Fatalf("expected gate task to reuse the execution lane, got %#v", lanes)
|
||||
}
|
||||
if gateTask.LaneID == "" || gateTask.LaneID != uiTask.LaneID {
|
||||
t.Fatalf("expected gate and execution task to share one lane, got gate=%#v ui=%#v", gateTask, uiTask)
|
||||
}
|
||||
if gateTask.Status != task.StatusReady {
|
||||
t.Fatalf("expected gate task ready, got %#v", gateTask)
|
||||
}
|
||||
if uiTask.Status != task.StatusDraft {
|
||||
t.Fatalf("expected non-gate task blocked behind gate, got %#v", uiTask)
|
||||
}
|
||||
if len(fakeRuntime.startedLaneIDs) != 0 {
|
||||
t.Fatalf("expected initial gated plan to wait for confirmation, got %#v", fakeRuntime.startedLaneIDs)
|
||||
}
|
||||
updatedTopic, err := store.GetTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTopic() error = %v", err)
|
||||
}
|
||||
if updatedTopic.Status != "awaiting_confirmation" {
|
||||
t.Fatalf("expected awaiting_confirmation topic, got %#v lanes=%#v", updatedTopic, lanes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessOncePlanOnlyDoesNotAutoStartReadyLanesAfterGateSuccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 17, 5, 30, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ws, topicRecord := seedLeaderLoopWorkspace(t, ctx, store, clock)
|
||||
topicRecord.Status = "execution"
|
||||
if _, err := store.UpdateTopic(ctx, topicRecord); err != nil {
|
||||
t.Fatalf("UpdateTopic() error = %v", err)
|
||||
}
|
||||
gateChain, err := store.CreateLane(ctx, lane.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
Name: "Foundation",
|
||||
Slug: "foundation",
|
||||
Status: lane.StatusSucceeded,
|
||||
BranchName: "lane/todo/foundation",
|
||||
WorktreePath: filepath.Join(t.TempDir(), "foundation"),
|
||||
ContainerName: "lane-foundation-test",
|
||||
CreatedByRoleName: "leader",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateLane(gate) error = %v", err)
|
||||
}
|
||||
frontendChain, err := store.CreateLane(ctx, lane.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
Name: "Frontend",
|
||||
Slug: "frontend",
|
||||
Status: lane.StatusReady,
|
||||
BranchName: "lane/todo/frontend",
|
||||
WorktreePath: filepath.Join(t.TempDir(), "frontend"),
|
||||
ContainerName: "lane-frontend-test",
|
||||
CreatedByRoleName: "leader",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateLane(frontend) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateTask(ctx, task.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
LaneID: gateChain.ID,
|
||||
Title: "Check workspace baseline",
|
||||
BodyMarkdown: "Validate setup.",
|
||||
Kind: task.KindGate,
|
||||
Status: task.StatusSucceeded,
|
||||
Priority: 10,
|
||||
TaskOrder: 1,
|
||||
CreatedByRoleName: "leader",
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("CreateTask(gate) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateTask(ctx, task.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
LaneID: frontendChain.ID,
|
||||
Title: "Build UI",
|
||||
BodyMarkdown: "Implement UI.",
|
||||
Kind: task.KindExecution,
|
||||
Status: task.StatusReady,
|
||||
Priority: 10,
|
||||
TaskOrder: 1,
|
||||
CreatedByRoleName: "leader",
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("CreateTask(frontend) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateMessage(ctx, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
FromRoleName: "worker",
|
||||
ToExpr: "leader",
|
||||
Type: message.TypeSummary,
|
||||
Stage: "execution",
|
||||
BodyMarkdown: "Foundation gate completed successfully. Keep the next lane stopped for now.",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateMessage() error = %v", err)
|
||||
}
|
||||
|
||||
fakeRuntime := &fakeWorkspaceRuntime{workspace: ws}
|
||||
service := NewService(
|
||||
store,
|
||||
runtimeconfig.NewService(store, store, clock),
|
||||
fakeRuntime,
|
||||
&fakeRunner{result: RunResult{
|
||||
Status: workflow.RunStatusSucceeded,
|
||||
ResultJSON: `{
|
||||
"plan_summary_markdown":"Gate passed; continue existing plan.",
|
||||
"plan_mode":"patch",
|
||||
"replan_reason":"Gate completed successfully; continue existing graph.",
|
||||
"execution_mode":"plan_only",
|
||||
"leader_reply":{"markdown":"基础检查通过,继续后续链路。","type":"decision"},
|
||||
"tasks":[]
|
||||
}`,
|
||||
}},
|
||||
clock,
|
||||
"",
|
||||
)
|
||||
|
||||
result, err := service.ProcessOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessOnce() error = %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected result")
|
||||
}
|
||||
if len(fakeRuntime.startedLaneIDs) != 0 {
|
||||
t.Fatalf("expected plan_only to keep lanes stopped, got %#v", fakeRuntime.startedLaneIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessOncePlanAndStartAutoStartsReadyLanesAfterGateSuccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 17, 5, 35, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ws, topicRecord := seedLeaderLoopWorkspace(t, ctx, store, clock)
|
||||
topicRecord.Status = "execution"
|
||||
if _, err := store.UpdateTopic(ctx, topicRecord); err != nil {
|
||||
t.Fatalf("UpdateTopic() error = %v", err)
|
||||
}
|
||||
gateChain, err := store.CreateLane(ctx, lane.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
Name: "Foundation",
|
||||
Slug: "foundation",
|
||||
Status: lane.StatusSucceeded,
|
||||
BranchName: "lane/todo/foundation",
|
||||
WorktreePath: filepath.Join(t.TempDir(), "foundation"),
|
||||
ContainerName: "lane-foundation-test",
|
||||
CreatedByRoleName: "leader",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateLane(gate) error = %v", err)
|
||||
}
|
||||
frontendChain, err := store.CreateLane(ctx, lane.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
Name: "Frontend",
|
||||
Slug: "frontend",
|
||||
Status: lane.StatusReady,
|
||||
BranchName: "lane/todo/frontend",
|
||||
WorktreePath: filepath.Join(t.TempDir(), "frontend"),
|
||||
ContainerName: "lane-frontend-test",
|
||||
CreatedByRoleName: "leader",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateLane(frontend) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateTask(ctx, task.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
LaneID: gateChain.ID,
|
||||
Title: "Check workspace baseline",
|
||||
BodyMarkdown: "Validate setup.",
|
||||
Kind: task.KindGate,
|
||||
Status: task.StatusSucceeded,
|
||||
Priority: 10,
|
||||
TaskOrder: 1,
|
||||
CreatedByRoleName: "leader",
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("CreateTask(gate) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateTask(ctx, task.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
LaneID: frontendChain.ID,
|
||||
Title: "Build UI",
|
||||
BodyMarkdown: "Implement UI.",
|
||||
Kind: task.KindExecution,
|
||||
Status: task.StatusReady,
|
||||
Priority: 10,
|
||||
TaskOrder: 1,
|
||||
CreatedByRoleName: "leader",
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("CreateTask(frontend) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateMessage(ctx, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
FromRoleName: "worker",
|
||||
ToExpr: "leader",
|
||||
Type: message.TypeSummary,
|
||||
Stage: "execution",
|
||||
BodyMarkdown: "Foundation gate completed successfully. Start the next ready lane.",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateMessage() error = %v", err)
|
||||
}
|
||||
|
||||
fakeRuntime := &fakeWorkspaceRuntime{workspace: ws}
|
||||
service := NewService(
|
||||
store,
|
||||
runtimeconfig.NewService(store, store, clock),
|
||||
fakeRuntime,
|
||||
&fakeRunner{result: RunResult{
|
||||
Status: workflow.RunStatusSucceeded,
|
||||
ResultJSON: `{
|
||||
"plan_summary_markdown":"Gate passed; continue existing plan.",
|
||||
"plan_mode":"patch",
|
||||
"replan_reason":"Gate completed successfully; continue existing graph.",
|
||||
"execution_mode":"plan_and_start",
|
||||
"leader_reply":{"markdown":"基础检查通过,继续后续链路。","type":"decision"},
|
||||
"tasks":[]
|
||||
}`,
|
||||
}},
|
||||
clock,
|
||||
"",
|
||||
)
|
||||
|
||||
result, err := service.ProcessOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessOnce() error = %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected result")
|
||||
}
|
||||
if len(fakeRuntime.startedLaneIDs) != 1 || fakeRuntime.startedLaneIDs[0] != frontendChain.ID {
|
||||
t.Fatalf("expected auto-start of frontend lane, got %#v", fakeRuntime.startedLaneIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessOnceUsesResolvedLeaderPromptOverrides(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 17, 6, 0, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ws, topicRecord := seedLeaderLoopWorkspace(t, ctx, store, clock)
|
||||
if _, err := store.UpsertRolePrompt(ctx, role.Prompt{
|
||||
RoleName: "leader",
|
||||
WorkspaceID: ws.ID,
|
||||
PromptKind: role.PromptSystem,
|
||||
ContentMarkdown: "你是自定义 leader。\n\n优先把范围压缩到最小可执行图。",
|
||||
}, "test"); err != nil {
|
||||
t.Fatalf("UpsertRolePrompt() error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateMessage(ctx, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
FromRoleName: "user",
|
||||
ToExpr: "leader",
|
||||
Type: message.TypeChat,
|
||||
Stage: "plan",
|
||||
BodyMarkdown: "Build a todo app.",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateMessage() error = %v", err)
|
||||
}
|
||||
|
||||
runner := &fakeRunner{result: RunResult{
|
||||
Status: workflow.RunStatusSucceeded,
|
||||
ResultJSON: `{
|
||||
"plan_summary_markdown":"Need clarification first.",
|
||||
"plan_mode":"initial",
|
||||
"execution_mode":"clarify",
|
||||
"leader_reply":{"markdown":"先缩小范围。","type":"question"},
|
||||
"tasks":[]
|
||||
}`,
|
||||
}}
|
||||
service := NewService(
|
||||
store,
|
||||
runtimeconfig.NewService(store, store, clock),
|
||||
&fakeWorkspaceRuntime{workspace: ws},
|
||||
runner,
|
||||
clock,
|
||||
"",
|
||||
)
|
||||
|
||||
if _, err := service.ProcessOnce(ctx); err != nil {
|
||||
t.Fatalf("ProcessOnce() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(runner.prompt, "你是自定义 leader。") {
|
||||
t.Fatalf("expected custom leader prompt in runtime instructions, got %q", runner.prompt)
|
||||
}
|
||||
if strings.Contains(runner.prompt, defaultLeaderSystemPrompt) {
|
||||
t.Fatalf("expected custom prompt to replace fallback system prompt, got %q", runner.prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLeaderOutputForTopicRejectsPatchOnEmptyGraph(t *testing.T) {
|
||||
err := validateLeaderOutputForTopic(leaderOutput{
|
||||
PlanSummaryMarkdown: "Patch on empty graph.",
|
||||
PlanMode: "patch",
|
||||
ReplanReason: "Need to update graph.",
|
||||
ExecutionMode: "plan_only",
|
||||
LeaderReply: leaderReplySpec{Markdown: "Patch.", Type: "summary"},
|
||||
}, nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot use plan_mode=patch on an empty topic graph") {
|
||||
t.Fatalf("expected patch on empty graph error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLeaderOutputForTopicRejectsInitialOnExistingGraph(t *testing.T) {
|
||||
err := validateLeaderOutputForTopic(leaderOutput{
|
||||
PlanSummaryMarkdown: "Initial on existing graph.",
|
||||
PlanMode: "initial",
|
||||
ReplanReason: "",
|
||||
ExecutionMode: "plan_only",
|
||||
LeaderReply: leaderReplySpec{Markdown: "Initial.", Type: "summary"},
|
||||
}, []lane.Record{{ID: "chain_1", Slug: "ui-chain", Name: "UI Chain"}}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "must use plan_mode=patch") {
|
||||
t.Fatalf("expected initial on existing graph error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLeaderOutputForTopicRejectsPatchWithoutReason(t *testing.T) {
|
||||
err := validateLeaderOutputForTopic(leaderOutput{
|
||||
PlanSummaryMarkdown: "Patch without reason.",
|
||||
PlanMode: "patch",
|
||||
ReplanReason: "",
|
||||
ExecutionMode: "plan_only",
|
||||
LeaderReply: leaderReplySpec{Markdown: "Patch.", Type: "summary"},
|
||||
}, []lane.Record{{ID: "chain_1", Slug: "ui-chain", Name: "UI Chain"}}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires replan_reason") {
|
||||
t.Fatalf("expected patch without reason error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLeaderOutputForTopicAllowsPatchDependenciesOnExistingTaskIDs(t *testing.T) {
|
||||
err := validateLeaderOutputForTopic(leaderOutput{
|
||||
PlanSummaryMarkdown: "Patch existing graph.",
|
||||
PlanMode: "patch",
|
||||
ReplanReason: "Attach a follow-up task to the completed gate.",
|
||||
ExecutionMode: "plan_only",
|
||||
LeaderReply: leaderReplySpec{Markdown: "Continue from the existing gate.", Type: "summary"},
|
||||
Tasks: []leaderTaskSpec{
|
||||
{
|
||||
Key: "follow-up",
|
||||
Title: "Continue implementation",
|
||||
BodyMarkdown: "Build the next step.",
|
||||
AcceptanceMarkdown: "Ready to proceed.",
|
||||
Kind: "execution",
|
||||
Deliverables: []string{"apps/web/src"},
|
||||
Priority: 10,
|
||||
TaskOrder: 2,
|
||||
DependsOn: []string{"task-existing"},
|
||||
},
|
||||
},
|
||||
}, []lane.Record{{ID: "chain_1", Slug: "ui-chain", Name: "UI Chain"}}, []task.Record{{ID: "task-existing", Title: "Existing Gate"}})
|
||||
if err != nil {
|
||||
t.Fatalf("expected existing task dependency to validate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLeaderOutputForTopicRejectsUnknownPatchDependencyKey(t *testing.T) {
|
||||
err := validateLeaderOutputForTopic(leaderOutput{
|
||||
PlanSummaryMarkdown: "Patch existing graph.",
|
||||
PlanMode: "patch",
|
||||
ReplanReason: "Attach a follow-up task to the completed gate.",
|
||||
ExecutionMode: "plan_only",
|
||||
LeaderReply: leaderReplySpec{Markdown: "Continue from the existing gate.", Type: "summary"},
|
||||
Tasks: []leaderTaskSpec{
|
||||
{
|
||||
Key: "follow-up",
|
||||
Title: "Continue implementation",
|
||||
BodyMarkdown: "Build the next step.",
|
||||
AcceptanceMarkdown: "Ready to proceed.",
|
||||
Kind: "execution",
|
||||
Deliverables: []string{"apps/web/src"},
|
||||
Priority: 10,
|
||||
TaskOrder: 2,
|
||||
DependsOn: []string{"missing-task"},
|
||||
},
|
||||
},
|
||||
}, []lane.Record{{ID: "chain_1", Slug: "ui-chain", Name: "UI Chain"}}, []task.Record{{ID: "task-existing", Title: "Existing Gate"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown dependency key") {
|
||||
t.Fatalf("expected unknown dependency key error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderCommandEnvWritesRuntimeCodexHomeUnderProjectRuntime(t *testing.T) {
|
||||
projectRoot := t.TempDir()
|
||||
env := leaderCommandEnv(projectRoot, runtimeconfig.ResolvedRole{
|
||||
WorkspaceID: "ws_1",
|
||||
Role: role.Definition{Name: "leader"},
|
||||
Config: role.Config{
|
||||
RoleName: "leader",
|
||||
ConfigTOML: strings.Join([]string{
|
||||
`model = "gpt-5.4"`,
|
||||
`model_provider = "custom"`,
|
||||
``,
|
||||
`[model_providers.custom]`,
|
||||
`base_url = "http://example.test/v1"`,
|
||||
`wire_api = "responses"`,
|
||||
}, "\n"),
|
||||
AuthJSON: `{"OPENAI_API_KEY":"token-1"}`,
|
||||
},
|
||||
})
|
||||
|
||||
expectedHome := runtimecodex.HostLeaderHomeDir(projectRoot, "ws_1", "leader")
|
||||
expectedCodexHome := runtimecodex.HostLeaderCodexDir(projectRoot, "ws_1", "leader")
|
||||
joined := strings.Join(env, "\n")
|
||||
if !strings.Contains(joined, "HOME="+expectedHome) {
|
||||
t.Fatalf("expected isolated HOME in env, env=%q", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "CODEX_HOME="+expectedCodexHome) {
|
||||
t.Fatalf("expected isolated CODEX_HOME in env, env=%q", joined)
|
||||
}
|
||||
|
||||
configBytes, err := os.ReadFile(filepath.Join(expectedCodexHome, "config.toml"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(config.toml) error = %v", err)
|
||||
}
|
||||
configText := string(configBytes)
|
||||
for _, needle := range []string{
|
||||
`model = "gpt-5.4"`,
|
||||
`model_provider = "custom"`,
|
||||
`[model_providers.custom]`,
|
||||
`base_url = "http://example.test/v1"`,
|
||||
} {
|
||||
if !strings.Contains(configText, needle) {
|
||||
t.Fatalf("expected seeded config to contain %q, got:\n%s", needle, configText)
|
||||
}
|
||||
}
|
||||
authBytes, err := os.ReadFile(filepath.Join(expectedCodexHome, "auth.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(auth.json) error = %v", err)
|
||||
}
|
||||
var auth map[string]any
|
||||
if err := json.Unmarshal(authBytes, &auth); err != nil {
|
||||
t.Fatalf("Unmarshal(auth.json) error = %v", err)
|
||||
}
|
||||
if auth["OPENAI_API_KEY"] != "token-1" {
|
||||
t.Fatalf("unexpected copied auth.json: %s", string(authBytes))
|
||||
}
|
||||
}
|
||||
|
||||
func assertSchemaRequiredMatchesProperties(t *testing.T, schema map[string]any) {
|
||||
t.Helper()
|
||||
|
||||
properties, ok := schema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema properties missing or invalid: %#v", schema)
|
||||
}
|
||||
requiredRaw, ok := schema["required"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema required missing or invalid: %#v", schema)
|
||||
}
|
||||
|
||||
required := make(map[string]struct{}, len(requiredRaw))
|
||||
for _, item := range requiredRaw {
|
||||
key, ok := item.(string)
|
||||
if !ok {
|
||||
t.Fatalf("schema required item must be string, got %#v", item)
|
||||
}
|
||||
required[key] = struct{}{}
|
||||
}
|
||||
|
||||
for key := range properties {
|
||||
if _, ok := required[key]; !ok {
|
||||
t.Fatalf("schema required is missing property %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeRunner struct {
|
||||
result RunResult
|
||||
prompt string
|
||||
}
|
||||
|
||||
func (f *fakeRunner) Run(_ context.Context, _ string, _ runtimeconfig.ResolvedRole, prompt, _ string) (RunResult, error) {
|
||||
f.prompt = prompt
|
||||
return f.result, nil
|
||||
}
|
||||
|
||||
type fakeWorkspaceRuntime struct {
|
||||
workspace workspace.Workspace
|
||||
startedLaneID string
|
||||
startedLaneIDs []string
|
||||
}
|
||||
|
||||
func (f *fakeWorkspaceRuntime) Ensure(_ context.Context, _ string) (workspace.Workspace, workspaceruntime.Runtime, error) {
|
||||
return f.workspace, workspaceruntime.Runtime{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorkspaceRuntime) EnsureLane(_ context.Context, laneID string) (lane.Record, error) {
|
||||
f.startedLaneID = laneID
|
||||
f.startedLaneIDs = append(f.startedLaneIDs, laneID)
|
||||
return lane.Record{ID: laneID, Name: "UI Chain", Status: lane.StatusRunning}, nil
|
||||
}
|
||||
|
||||
func seedLeaderLoopWorkspace(t *testing.T, ctx context.Context, store *sqlitestore.Store, clock timeutil.Clock) (workspace.Workspace, topic.Record) {
|
||||
t.Helper()
|
||||
now := timeutil.FormatRFC3339(clock.Now())
|
||||
project, err := store.CreateProject(ctx, workspace.Project{
|
||||
Slug: "demo",
|
||||
Name: "Demo",
|
||||
RootPath: t.TempDir(),
|
||||
DefaultBranch: "main",
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProject() error = %v", err)
|
||||
}
|
||||
ws, err := store.CreateWorkspace(ctx, workspace.Workspace{
|
||||
ProjectID: project.ID,
|
||||
Slug: "todo",
|
||||
Name: "todo",
|
||||
RootPath: t.TempDir(),
|
||||
BaseBranch: "main",
|
||||
WorktreeBranch: "worktree/todo",
|
||||
RuntimeBackend: "host",
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkspace() error = %v", err)
|
||||
}
|
||||
record, err := store.CreateTopic(ctx, topic.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
Slug: "v1",
|
||||
Title: "v1",
|
||||
Space: topic.SpaceWorkflow,
|
||||
Status: "plan",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTopic() error = %v", err)
|
||||
}
|
||||
return ws, record
|
||||
}
|
||||
Reference in New Issue
Block a user