195530e576
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
391 lines
14 KiB
Go
391 lines
14 KiB
Go
package backendapp
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/db"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
officemodels "github.com/kandev/kandev/internal/office/models"
|
|
officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
"github.com/kandev/kandev/internal/task/repository"
|
|
tasksqlite "github.com/kandev/kandev/internal/task/repository/sqlite"
|
|
taskservice "github.com/kandev/kandev/internal/task/service"
|
|
"github.com/kandev/kandev/internal/worktree"
|
|
)
|
|
|
|
type fakeOfficeCommentWindowReader struct {
|
|
comments []*officemodels.TaskComment
|
|
total int
|
|
}
|
|
|
|
func (f *fakeOfficeCommentWindowReader) ListTaskCommentsWindow(
|
|
context.Context, string, int,
|
|
) ([]*officemodels.TaskComment, int, error) {
|
|
return f.comments, f.total, nil
|
|
}
|
|
|
|
func TestOfficeCommentReaderAdapterMapsPersistenceRows(t *testing.T) {
|
|
createdAt := time.Date(2026, 1, 1, 2, 3, 4, 0, time.UTC)
|
|
adapter := officeCommentReaderAdapter{reader: &fakeOfficeCommentWindowReader{
|
|
comments: []*officemodels.TaskComment{{
|
|
ID: "comment-1", TaskID: "task-1", AuthorType: "agent", AuthorID: "agent-1",
|
|
Body: "done", Source: "run", ReplyChannelID: "private", CreatedAt: createdAt,
|
|
}},
|
|
total: 4,
|
|
}}
|
|
|
|
rows, total, err := adapter.ListTaskCommentsWindow(context.Background(), "task-1", 2)
|
|
if err != nil {
|
|
t.Fatalf("ListTaskCommentsWindow: %v", err)
|
|
}
|
|
if total != 4 {
|
|
t.Fatalf("total = %d, want 4", total)
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Fatalf("len(rows) = %d, want 1", len(rows))
|
|
}
|
|
if rows[0].ID != "comment-1" || rows[0].Body != "done" || !rows[0].CreatedAt.Equal(createdAt) {
|
|
t.Fatalf("row = %+v, want mapped comment", rows[0])
|
|
}
|
|
}
|
|
|
|
type adapterStartStepResolver struct {
|
|
repo *tasksqlite.Repository
|
|
}
|
|
|
|
func (r *adapterStartStepResolver) ResolveStartStep(ctx context.Context, workflowID string) (string, error) {
|
|
var stepID string
|
|
err := r.repo.DB().QueryRowContext(ctx,
|
|
`SELECT id FROM workflow_steps WHERE workflow_id = ? AND is_start_step = 1 LIMIT 1`,
|
|
workflowID,
|
|
).Scan(&stepID)
|
|
if err == sql.ErrNoRows {
|
|
return r.ResolveFirstStep(ctx, workflowID)
|
|
}
|
|
return stepID, err
|
|
}
|
|
|
|
func (r *adapterStartStepResolver) ResolveAutoStartStep(ctx context.Context, workflowID string) (string, error) {
|
|
var stepID string
|
|
err := r.repo.DB().QueryRowContext(ctx,
|
|
`SELECT id FROM workflow_steps WHERE workflow_id = ? AND events LIKE '%auto_start_agent%' ORDER BY position LIMIT 1`,
|
|
workflowID,
|
|
).Scan(&stepID)
|
|
if err == sql.ErrNoRows {
|
|
return r.ResolveStartStep(ctx, workflowID)
|
|
}
|
|
return stepID, err
|
|
}
|
|
|
|
func (r *adapterStartStepResolver) ResolveFirstStep(ctx context.Context, workflowID string) (string, error) {
|
|
var stepID string
|
|
err := r.repo.DB().QueryRowContext(ctx,
|
|
`SELECT id FROM workflow_steps WHERE workflow_id = ? ORDER BY position LIMIT 1`,
|
|
workflowID,
|
|
).Scan(&stepID)
|
|
return stepID, err
|
|
}
|
|
|
|
func TestTaskCreatorAdapterPersistsOriginByCreationPath(t *testing.T) {
|
|
adapter, taskSvc := newOfficeTaskAdapterHarness(t)
|
|
ctx := context.Background()
|
|
|
|
agentTaskID, err := adapter.CreateOfficeTaskAsAgent(
|
|
ctx, "ws-1", "project-1", "agent-worker", "Agent task", "Created at runtime", nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("CreateOfficeTaskAsAgent: %v", err)
|
|
}
|
|
agentTask, err := taskSvc.GetTask(ctx, agentTaskID)
|
|
if err != nil {
|
|
t.Fatalf("GetTask(agent): %v", err)
|
|
}
|
|
if agentTask.Origin != models.TaskOriginAgentCreated {
|
|
t.Errorf("agent task origin = %q, want %q", agentTask.Origin, models.TaskOriginAgentCreated)
|
|
}
|
|
if agentTask.ProjectID != "project-1" {
|
|
t.Errorf("agent task project_id = %q, want project-1", agentTask.ProjectID)
|
|
}
|
|
if agentTask.AssigneeAgentProfileID != "agent-worker" {
|
|
t.Errorf("agent task assignee_agent_profile_id = %q, want agent-worker", agentTask.AssigneeAgentProfileID)
|
|
}
|
|
|
|
onboardingTaskID, err := adapter.CreateOfficeTask(
|
|
ctx, "ws-1", "project-setup", "agent-ceo", "Onboarding task", "Created during setup",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("CreateOfficeTask: %v", err)
|
|
}
|
|
onboardingTask, err := taskSvc.GetTask(ctx, onboardingTaskID)
|
|
if err != nil {
|
|
t.Fatalf("GetTask(onboarding): %v", err)
|
|
}
|
|
if onboardingTask.Origin != models.TaskOriginOnboarding {
|
|
t.Errorf("onboarding task origin = %q, want %q", onboardingTask.Origin, models.TaskOriginOnboarding)
|
|
}
|
|
}
|
|
|
|
func TestOfficeWorkspaceCreatorDoesNotBootstrapKanbanWorkflow(t *testing.T) {
|
|
_, taskSvc := newOfficeTaskAdapterHarness(t)
|
|
adapter := &taskWorkspaceCreatorAdapter{taskSvc: taskSvc}
|
|
ctx := context.Background()
|
|
|
|
if err := adapter.CreateWorkspace(ctx, "Office workspace", "Created by Office onboarding"); err != nil {
|
|
t.Fatalf("CreateWorkspace: %v", err)
|
|
}
|
|
workspaceID, err := adapter.FindWorkspaceIDByName(ctx, "Office workspace")
|
|
if err != nil {
|
|
t.Fatalf("FindWorkspaceIDByName: %v", err)
|
|
}
|
|
workflows, err := taskSvc.ListWorkflows(ctx, workspaceID, false)
|
|
if err != nil {
|
|
t.Fatalf("ListWorkflows: %v", err)
|
|
}
|
|
if len(workflows) != 0 {
|
|
t.Fatalf("Office workspace workflows = %d, want 0 before Office onboarding materializes them", len(workflows))
|
|
}
|
|
}
|
|
|
|
func TestCreateWorkspaceKanbanBootstrapCreatesUsableSteps(t *testing.T) {
|
|
harness := newBootStateTestHarness(t)
|
|
ctx := context.Background()
|
|
|
|
workspace, err := harness.taskSvc.CreateWorkspace(ctx, &taskservice.CreateWorkspaceRequest{
|
|
Name: "Kanban workspace",
|
|
BootstrapKanbanWorkflow: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorkspace: %v", err)
|
|
}
|
|
workflows, err := harness.taskSvc.ListWorkflows(ctx, workspace.ID, false)
|
|
if err != nil {
|
|
t.Fatalf("ListWorkflows: %v", err)
|
|
}
|
|
if len(workflows) != 1 {
|
|
t.Fatalf("visible workflows = %d, want 1", len(workflows))
|
|
}
|
|
workflow := workflows[0]
|
|
if workflow.Name != "Kanban" || workflow.WorkflowTemplateID == nil || *workflow.WorkflowTemplateID != "simple" {
|
|
t.Fatalf("workflow = %+v, want visible Kanban workflow from simple template", workflow)
|
|
}
|
|
steps, err := harness.workflowSvc.ListStepsByWorkflow(ctx, workflow.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListStepsByWorkflow: %v", err)
|
|
}
|
|
if len(steps) == 0 {
|
|
t.Fatal("Kanban workflow has no usable steps")
|
|
}
|
|
}
|
|
|
|
// TestCreateOfficeTaskInWorkflowCarriesAssigneeIntoLaunchMetadata covers
|
|
// WO-36's routine-launch-inputs gap: a materialized heavy routine run lands
|
|
// on the Routine workflow's start step, which pins no agent (routine.yml).
|
|
// The kanban auto-start path's only fallback for the agent to launch with is
|
|
// task.Metadata[MetaKeyAgentProfileID] — AssigneeAgentProfileID alone (a
|
|
// separate DB column) never reaches it. Without this, the routine's task
|
|
// gets created but the orchestrator has no agent profile to start.
|
|
//
|
|
// It also covers WO-36 round 2: handleTaskCreated only evaluates a step's
|
|
// on_enter for a freshly created task when the task carries the positive
|
|
// MetaKeyAutoStartOnCreate opt-in (see event_handlers_workflow.go). This
|
|
// method is that opt-in's only production setter, so both cases here —
|
|
// with and without an assignee — must always stamp it true.
|
|
func TestCreateOfficeTaskInWorkflowCarriesAssigneeIntoLaunchMetadata(t *testing.T) {
|
|
adapter, taskSvc := newOfficeTaskAdapterHarness(t)
|
|
ctx := context.Background()
|
|
|
|
workflows, err := taskSvc.ListWorkflows(ctx, "ws-1", true)
|
|
if err != nil || len(workflows) == 0 {
|
|
t.Fatalf("ListWorkflows: %v (len=%d)", err, len(workflows))
|
|
}
|
|
workflowID := workflows[0].ID
|
|
|
|
taskID, err := adapter.CreateOfficeTaskInWorkflow(
|
|
ctx, "ws-1", "", "routine-assignee", workflowID, "Routine run", "Materialized run", "routine-1",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("CreateOfficeTaskInWorkflow: %v", err)
|
|
}
|
|
task, err := taskSvc.GetTask(ctx, taskID)
|
|
if err != nil {
|
|
t.Fatalf("GetTask: %v", err)
|
|
}
|
|
if got, _ := task.Metadata[models.MetaKeyAgentProfileID].(string); got != "routine-assignee" {
|
|
t.Errorf("task.Metadata[MetaKeyAgentProfileID] = %q, want routine-assignee", got)
|
|
}
|
|
if !models.HasAutoStartOnCreateIntent(task.Metadata) {
|
|
t.Errorf("task.Metadata[MetaKeyAutoStartOnCreate] not set, want true")
|
|
}
|
|
assertRoutineFireCarrier(t, task.Metadata, "routine-1")
|
|
|
|
noAssigneeTaskID, err := adapter.CreateOfficeTaskInWorkflow(
|
|
ctx, "ws-1", "", "", workflowID, "Unassigned run", "Materialized run", "routine-2",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("CreateOfficeTaskInWorkflow (no assignee): %v", err)
|
|
}
|
|
noAssigneeTask, err := taskSvc.GetTask(ctx, noAssigneeTaskID)
|
|
if err != nil {
|
|
t.Fatalf("GetTask (no assignee): %v", err)
|
|
}
|
|
if _, ok := noAssigneeTask.Metadata[models.MetaKeyAgentProfileID]; ok {
|
|
t.Errorf("task.Metadata[MetaKeyAgentProfileID] set for an unassigned routine, want absent")
|
|
}
|
|
if !models.HasAutoStartOnCreateIntent(noAssigneeTask.Metadata) {
|
|
t.Errorf("task.Metadata[MetaKeyAutoStartOnCreate] not set for an unassigned routine, want true")
|
|
}
|
|
assertRoutineFireCarrier(t, noAssigneeTask.Metadata, "routine-2")
|
|
}
|
|
|
|
// assertRoutineFireCarrier pins AC-OFFICE-RUN-CAUSATION-001.14/.24: a
|
|
// routine-fire task carries the full task-boundary causation carrier with
|
|
// the AC.24 root values for the lineage triple (a routine fire has no
|
|
// creating run), a system actor, human_rooted=false, and the firing
|
|
// routine's id as routine attribution.
|
|
func assertRoutineFireCarrier(t *testing.T, metadata map[string]interface{}, wantRoutineID string) {
|
|
t.Helper()
|
|
if got, _ := metadata[models.MetaKeyOfficeCarrierCausationID].(string); got != "" {
|
|
t.Errorf("carrier causation_id = %q, want empty (root)", got)
|
|
}
|
|
if got, _ := metadata[models.MetaKeyOfficeCarrierCausationDepth].(int); got != 0 {
|
|
t.Errorf("carrier causation_depth = %v, want 0 (root)", metadata[models.MetaKeyOfficeCarrierCausationDepth])
|
|
}
|
|
if got, _ := metadata[models.MetaKeyOfficeCarrierCreatingRunID].(string); got != "" {
|
|
t.Errorf("carrier creating_run_id = %q, want empty (root)", got)
|
|
}
|
|
if got, _ := metadata[models.MetaKeyOfficeCarrierHumanRooted].(bool); got {
|
|
t.Error("carrier human_rooted = true, want false")
|
|
}
|
|
if got, _ := metadata[models.MetaKeyOfficeCarrierRoutineID].(string); got != wantRoutineID {
|
|
t.Errorf("carrier routine_id = %q, want %q", got, wantRoutineID)
|
|
}
|
|
if got, _ := metadata[models.MetaKeyOfficeCarrierActorKind].(string); got != string(officemodels.ActorKindSystem) {
|
|
t.Errorf("carrier actor_kind = %q, want %q", got, officemodels.ActorKindSystem)
|
|
}
|
|
if got, _ := metadata[models.MetaKeyOfficeCarrierActorID].(string); got != "" {
|
|
t.Errorf("carrier actor_id = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
// TestTaskCreatorAdapterCreateOfficeSubtaskPassesMetadataThrough covers the
|
|
// AC-OFFICE-RUN-CAUSATION-001.5/.18 carrier-writing fix: the adapter must
|
|
// forward CreateOfficeSubtask's metadata parameter into ChildTaskSpec so the
|
|
// task-boundary causation carrier a caller resolved actually lands on the
|
|
// created child task's persisted metadata, exactly as CreateOfficeTaskAsAgent
|
|
// already does for a root task.
|
|
func TestTaskCreatorAdapterCreateOfficeSubtaskPassesMetadataThrough(t *testing.T) {
|
|
adapter, taskSvc := newOfficeTaskAdapterHarness(t)
|
|
ctx := context.Background()
|
|
|
|
parentTaskID, err := adapter.CreateOfficeTaskAsAgent(
|
|
ctx, "ws-1", "project-1", "agent-worker", "Parent task", "Parent", nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("create parent task: %v", err)
|
|
}
|
|
|
|
carrier := map[string]interface{}{
|
|
models.MetaKeyOfficeCarrierCausationID: "causation-1",
|
|
models.MetaKeyOfficeCarrierCausationDepth: 1,
|
|
models.MetaKeyOfficeCarrierCreatingRunID: "run-1",
|
|
models.MetaKeyOfficeCarrierHumanRooted: false,
|
|
models.MetaKeyOfficeCarrierRoutineID: "",
|
|
models.MetaKeyOfficeCarrierActorKind: string(officemodels.ActorKindAgent),
|
|
models.MetaKeyOfficeCarrierActorID: "agent-worker",
|
|
}
|
|
// Metadata round-trips through a JSON column: numbers come back as
|
|
// float64 regardless of the concrete numeric type written.
|
|
wantMetadata := map[string]interface{}{}
|
|
for k, v := range carrier {
|
|
wantMetadata[k] = v
|
|
}
|
|
wantMetadata[models.MetaKeyOfficeCarrierCausationDepth] = float64(1)
|
|
|
|
childTaskID, err := adapter.CreateOfficeSubtask(
|
|
ctx, parentTaskID, "agent-assignee", "Child task", "A subtask", carrier,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("CreateOfficeSubtask: %v", err)
|
|
}
|
|
if childTaskID == "" {
|
|
t.Fatal("expected non-empty child task ID")
|
|
}
|
|
|
|
childTask, err := taskSvc.GetTask(ctx, childTaskID)
|
|
if err != nil {
|
|
t.Fatalf("GetTask(child): %v", err)
|
|
}
|
|
if childTask.ParentID != parentTaskID {
|
|
t.Errorf("child task parent_id = %q, want %q", childTask.ParentID, parentTaskID)
|
|
}
|
|
for key, want := range wantMetadata {
|
|
if got := childTask.Metadata[key]; got != want {
|
|
t.Errorf("child task metadata[%q] = %v, want %v", key, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func newOfficeTaskAdapterHarness(t *testing.T) (*taskCreatorAdapter, *taskservice.Service) {
|
|
t.Helper()
|
|
dbConn, err := db.OpenSQLite(filepath.Join(t.TempDir(), "office-adapter.db"))
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
database := sqlx.NewDb(dbConn, "sqlite3")
|
|
t.Cleanup(func() { _ = database.Close() })
|
|
repo, cleanup, err := repository.Provide(database, database, nil)
|
|
if err != nil {
|
|
t.Fatalf("task repository: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = cleanup() })
|
|
if _, err := worktree.NewSQLiteStore(database, database); err != nil {
|
|
t.Fatalf("worktree store: %v", err)
|
|
}
|
|
officeRepo, err := officesqlite.NewWithDB(database, database, nil)
|
|
if err != nil {
|
|
t.Fatalf("office migrations: %v", err)
|
|
}
|
|
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"})
|
|
if err != nil {
|
|
t.Fatalf("logger: %v", err)
|
|
}
|
|
taskSvc := taskservice.NewService(taskservice.Repos{
|
|
Workspaces: repo,
|
|
Tasks: repo,
|
|
TaskRepos: repo,
|
|
Workflows: repo,
|
|
Messages: repo,
|
|
Turns: repo,
|
|
Sessions: repo,
|
|
GitSnapshots: repo,
|
|
RepoEntities: repo,
|
|
Executors: repo,
|
|
Environments: repo,
|
|
TaskEnvironments: repo,
|
|
Reviews: repo,
|
|
ResourceCleanups: repo,
|
|
}, bus.NewMemoryEventBus(log), log, taskservice.RepositoryDiscoveryConfig{})
|
|
// Mirrors production wiring (registerRoutes in helpers.go): CreateChildTask
|
|
// requires a WorkspacePolicyAttacher to attach the child's workspace-group
|
|
// membership before returning.
|
|
taskSvc.SetWorkspacePolicyAttacher(taskservice.NewHandoffService(repo, repo, nil, officeRepo, officeRepo, nil))
|
|
|
|
ctx := context.Background()
|
|
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil {
|
|
t.Fatalf("create workspace: %v", err)
|
|
}
|
|
if _, err := repo.EnsureOfficeWorkflow(ctx, "ws-1"); err != nil {
|
|
t.Fatalf("ensure office workflow: %v", err)
|
|
}
|
|
taskSvc.SetStartStepResolver(&adapterStartStepResolver{repo: repo})
|
|
return &taskCreatorAdapter{taskSvc: taskSvc}, taskSvc
|
|
}
|