462 lines
17 KiB
Go
462 lines
17 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
workflowcfg "github.com/kandev/kandev/config/workflows"
|
|
"github.com/kandev/kandev/internal/common/config"
|
|
"github.com/kandev/kandev/internal/db/dialect"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
wfmodels "github.com/kandev/kandev/internal/workflow/models"
|
|
)
|
|
|
|
// Built-in workflow template IDs used by office onboarding to materialise
|
|
// the Phase 6 (task-model-unification) workflows. The IDs are present in
|
|
// the embedded YAML at config/workflows/.
|
|
const (
|
|
templateIDOfficeDefault = "office-default"
|
|
templateIDRoutine = "routine"
|
|
)
|
|
|
|
// SystemWorkflowTemplateIDs lists workflow template IDs whose tasks are
|
|
// treated as "system tasks" — created and maintained by kandev itself
|
|
// (today: routine-fired tasks). The Office Tasks UI hides these by
|
|
// default; a developer toggle re-includes them. Keep this in sync as
|
|
// new system workflows land.
|
|
var SystemWorkflowTemplateIDs = []string{
|
|
templateIDRoutine,
|
|
}
|
|
|
|
// hideBuiltinWorkflows reconciles system workflow rows created before their
|
|
// embedded templates were marked hidden. User-created workflows are left
|
|
// untouched even if they reference the same template.
|
|
func (r *Repository) hideBuiltinWorkflows() error {
|
|
hidden, err := workflowcfg.HiddenTemplateIDs()
|
|
if err != nil {
|
|
return fmt.Errorf("load hidden workflow templates: %w", err)
|
|
}
|
|
templateIDs := make([]string, 0, len(hidden))
|
|
for templateID, isHidden := range hidden {
|
|
if isHidden {
|
|
templateIDs = append(templateIDs, templateID)
|
|
}
|
|
}
|
|
if len(templateIDs) == 0 {
|
|
return nil
|
|
}
|
|
query, args, err := sqlx.In(`
|
|
UPDATE workflows SET hidden = 1, updated_at = ?
|
|
WHERE is_system = 1 AND workflow_template_id IN (?) AND hidden != 1
|
|
`, time.Now().UTC(), templateIDs)
|
|
if err != nil {
|
|
return fmt.Errorf("build hidden workflow reconciliation: %w", err)
|
|
}
|
|
if _, err := r.db.Exec(r.db.Rebind(query), args...); err != nil {
|
|
return fmt.Errorf("hide builtin workflows: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ensureDefaultWorkspace creates a default workspace if none exists
|
|
func (r *Repository) ensureDefaultWorkspace() error {
|
|
ctx := context.Background()
|
|
|
|
var count int
|
|
if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM workspaces").Scan(&count); err != nil {
|
|
return err
|
|
}
|
|
|
|
if count == 0 {
|
|
if err := r.createInitialWorkspace(ctx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
var defaultWorkspaceID string
|
|
if err := r.db.QueryRowContext(ctx, "SELECT id FROM workspaces ORDER BY created_at LIMIT 1").Scan(&defaultWorkspaceID); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
UPDATE workflows SET workspace_id = ? WHERE workspace_id = '' OR workspace_id IS NULL
|
|
`), defaultWorkspaceID); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := r.db.ExecContext(ctx, `
|
|
UPDATE tasks
|
|
SET workspace_id = (
|
|
SELECT workspace_id FROM workflows WHERE workflows.id = tasks.workflow_id
|
|
)
|
|
WHERE workspace_id = '' OR workspace_id IS NULL
|
|
`); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createInitialWorkspace inserts the first workspace and optionally a default workflow.
|
|
func (r *Repository) createInitialWorkspace(ctx context.Context) error {
|
|
var workflowCount int
|
|
if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM workflows").Scan(&workflowCount); err != nil {
|
|
return err
|
|
}
|
|
var taskCount int
|
|
if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM tasks").Scan(&taskCount); err != nil {
|
|
return err
|
|
}
|
|
defaultID := uuid.New().String()
|
|
now := time.Now().UTC()
|
|
workspaceName := "Default Workspace"
|
|
workspaceDescription := "Default workspace"
|
|
if workflowCount > 0 || taskCount > 0 {
|
|
workspaceName = "Migrated Workspace"
|
|
workspaceDescription = ""
|
|
}
|
|
if _, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
INSERT INTO workspaces (
|
|
id,
|
|
name,
|
|
description,
|
|
owner_id,
|
|
default_executor_id,
|
|
default_environment_id,
|
|
default_agent_profile_id,
|
|
created_at,
|
|
updated_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`), defaultID, workspaceName, workspaceDescription, "", nil, nil, nil, now, now); err != nil {
|
|
return err
|
|
}
|
|
if workflowCount == 0 && taskCount == 0 {
|
|
workflowID := uuid.New().String()
|
|
if _, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
INSERT INTO workflows (id, workspace_id, name, description, workflow_template_id, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`), workflowID, defaultID, "Development", "Default development workflow", "simple", now, now); err != nil {
|
|
return err
|
|
}
|
|
// Note: Workflow steps will be created by the workflow repository after it initializes
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EnsureOfficeWorkflow makes sure the workspace has an office workflow and
|
|
// that workspaces.office_workflow_id points at it. The system workflow is
|
|
// the YAML-templated "office-default" one — the legacy 7-step hardcoded
|
|
// workflow has been retired. Idempotent.
|
|
func (r *Repository) EnsureOfficeWorkflow(ctx context.Context, workspaceID string) (string, error) {
|
|
workflowID, err := r.EnsureOfficeDefaultWorkflow(ctx, workspaceID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := r.stampWorkspaceOfficeWorkflow(ctx, workspaceID, workflowID); err != nil {
|
|
return "", err
|
|
}
|
|
return workflowID, nil
|
|
}
|
|
|
|
// stampWorkspaceOfficeWorkflow sets workspaces.office_workflow_id to
|
|
// workflowID when the column is empty. Existing non-empty values are
|
|
// preserved so manual overrides aren't clobbered.
|
|
func (r *Repository) stampWorkspaceOfficeWorkflow(ctx context.Context, workspaceID, workflowID string) error {
|
|
var existing string
|
|
if err := r.db.QueryRowContext(ctx, r.db.Rebind(
|
|
`SELECT COALESCE(office_workflow_id, '') FROM workspaces WHERE id = ?`,
|
|
), workspaceID).Scan(&existing); err != nil {
|
|
return fmt.Errorf("query workspace office workflow: %w", err)
|
|
}
|
|
if existing != "" {
|
|
return nil
|
|
}
|
|
if _, err := r.db.ExecContext(ctx, r.db.Rebind(
|
|
`UPDATE workspaces SET office_workflow_id = ? WHERE id = ?`,
|
|
), workflowID, workspaceID); err != nil {
|
|
return fmt.Errorf("update workspace office_workflow_id: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ensureDefaultExecutorsAndEnvironments creates default executors and environments if none exist
|
|
func (r *Repository) ensureDefaultExecutorsAndEnvironments() error {
|
|
ctx := context.Background()
|
|
if err := r.ensureDefaultExecutors(ctx); err != nil {
|
|
return err
|
|
}
|
|
if err := r.ensureDefaultExecutorProfiles(ctx); err != nil {
|
|
return err
|
|
}
|
|
return r.ensureDefaultEnvironment(ctx)
|
|
}
|
|
|
|
func (r *Repository) ensureDefaultExecutors(ctx context.Context) error {
|
|
var executorCount int
|
|
if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM executors").Scan(&executorCount); err != nil {
|
|
return err
|
|
}
|
|
if executorCount == 0 {
|
|
return r.insertDefaultExecutors(ctx)
|
|
}
|
|
// Ensure system executors have is_system flag set
|
|
for _, systemID := range []string{models.ExecutorIDLocal, models.ExecutorIDWorktree} {
|
|
if _, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
UPDATE executors SET is_system = 1 WHERE id = ?
|
|
`), systemID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) insertDefaultExecutors(ctx context.Context) error {
|
|
now := time.Now().UTC()
|
|
executors := []struct {
|
|
id string
|
|
name string
|
|
execType models.ExecutorType
|
|
status models.ExecutorStatus
|
|
isSystem bool
|
|
resumable bool
|
|
config map[string]string
|
|
}{
|
|
{id: models.ExecutorIDLocal, name: "Local", execType: models.ExecutorTypeLocal, status: models.ExecutorStatusActive, isSystem: true, resumable: true, config: map[string]string{}},
|
|
{id: models.ExecutorIDWorktree, name: "Worktree", execType: models.ExecutorTypeWorktree, status: models.ExecutorStatusActive, isSystem: true, resumable: true, config: map[string]string{}},
|
|
{id: models.ExecutorIDLocalDocker, name: "Local Docker", execType: models.ExecutorTypeLocalDocker, status: models.ExecutorStatusActive, isSystem: false, resumable: true, config: map[string]string{"docker_host": config.DefaultDockerHost()}},
|
|
{id: models.ExecutorIDSprites, name: "Sprites.dev", execType: models.ExecutorTypeSprites, status: models.ExecutorStatusDisabled, isSystem: false, resumable: true, config: map[string]string{}},
|
|
}
|
|
for _, executor := range executors {
|
|
configJSON, err := json.Marshal(executor.config)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to serialize executor config: %w", err)
|
|
}
|
|
if _, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
INSERT INTO executors (id, name, type, status, is_system, resumable, config, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`), executor.id, executor.name, executor.execType, executor.status, dialect.BoolToInt(executor.isSystem), dialect.BoolToInt(executor.resumable), string(configJSON), now, now); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) ensureDefaultExecutorProfiles(ctx context.Context) error {
|
|
profileSeeds := []struct {
|
|
executorID string
|
|
name string
|
|
}{
|
|
{models.ExecutorIDLocal, "Local"},
|
|
{models.ExecutorIDWorktree, "Worktree"},
|
|
}
|
|
for _, seed := range profileSeeds {
|
|
var profileCount int
|
|
if err := r.db.QueryRowContext(ctx, r.db.Rebind(
|
|
"SELECT COUNT(1) FROM executor_profiles WHERE executor_id = ?",
|
|
), seed.executorID).Scan(&profileCount); err != nil {
|
|
return err
|
|
}
|
|
if profileCount == 0 {
|
|
now := time.Now().UTC()
|
|
id := uuid.New().String()
|
|
if _, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
INSERT INTO executor_profiles (id, executor_id, name, mcp_policy, config, prepare_script, cleanup_script, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`), id, seed.executorID, seed.name, "", "{}", "", "", now, now); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) ensureDefaultEnvironment(ctx context.Context) error {
|
|
var envCount int
|
|
if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM environments").Scan(&envCount); err != nil {
|
|
return err
|
|
}
|
|
if envCount == 0 {
|
|
now := time.Now().UTC()
|
|
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`), models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, dialect.BoolToInt(true), "~/kandev", "", "", "{}", now, now)
|
|
return err
|
|
}
|
|
return r.updateDefaultEnvironment(ctx)
|
|
}
|
|
|
|
func (r *Repository) updateDefaultEnvironment(ctx context.Context) error {
|
|
var localCount int
|
|
if err := r.db.QueryRowContext(ctx, r.db.Rebind("SELECT COUNT(1) FROM environments WHERE id = ?"), models.EnvironmentIDLocal).Scan(&localCount); err != nil {
|
|
return err
|
|
}
|
|
if localCount == 0 {
|
|
now := time.Now().UTC()
|
|
if _, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`), models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, dialect.BoolToInt(true), "~/kandev", "", "", "{}", now, now); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
UPDATE environments SET is_system = 1, image_tag = '', dockerfile = '', build_config = '{}' WHERE id = ?
|
|
`), models.EnvironmentIDLocal); err != nil {
|
|
return err
|
|
}
|
|
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
UPDATE environments SET worktree_root = ? WHERE id = ? AND (worktree_root IS NULL OR worktree_root = '')
|
|
`), "~/kandev", models.EnvironmentIDLocal)
|
|
return err
|
|
}
|
|
|
|
// EnsureOfficeDefaultWorkflow materialises the built-in Office Default
|
|
// workflow (Backlog -> Work -> Review -> Approval -> Done) for a workspace.
|
|
// Idempotent: returns the existing workflow id when one already exists,
|
|
// keyed off (workspace_id, workflow_template_id="office-default").
|
|
//
|
|
// Phase 6 (ADR-0004) — onboarding calls this on first workspace setup so
|
|
// office tasks have a workflow with the new event-driven triggers wired.
|
|
func (r *Repository) EnsureOfficeDefaultWorkflow(ctx context.Context, workspaceID string) (string, error) {
|
|
return r.ensureBuiltinWorkflow(ctx, workspaceID, templateIDOfficeDefault, "Office Default", "System workflow with primary work, multi-agent review, and multi-agent approval")
|
|
}
|
|
|
|
// EnsureRoutineWorkflow materialises the built-in Routine workflow
|
|
// (single auto-completing step) for a workspace. Idempotent.
|
|
//
|
|
// PR 3 of office-heartbeat-rework — heavy routine fires create a fresh
|
|
// task in this workflow; auto_start_agent kicks off the agent on the
|
|
// start step and on_turn_complete moves the task to Done.
|
|
func (r *Repository) EnsureRoutineWorkflow(ctx context.Context, workspaceID string) (string, error) {
|
|
return r.ensureBuiltinWorkflow(ctx, workspaceID, templateIDRoutine, "Routine", "System workflow for routine-fired tasks")
|
|
}
|
|
|
|
// ensureBuiltinWorkflow creates a workflow + its steps from an embedded
|
|
// YAML template if no workflow with that template id exists in the workspace.
|
|
func (r *Repository) ensureBuiltinWorkflow(ctx context.Context, workspaceID, templateID, name, description string) (string, error) {
|
|
if workspaceID == "" {
|
|
return "", fmt.Errorf("workspace_id is required")
|
|
}
|
|
tmpl, err := loadBuiltinTemplate(templateID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
existing, err := r.findBuiltinWorkflowByTemplate(ctx, workspaceID, templateID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if existing != "" {
|
|
hidden := dialect.BoolToInt(tmpl.Hidden)
|
|
if _, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
UPDATE workflows SET hidden = ?, updated_at = ?
|
|
WHERE id = ? AND is_system = 1 AND hidden != ?
|
|
`), hidden, time.Now().UTC(), existing, hidden); err != nil {
|
|
return "", fmt.Errorf("update builtin workflow visibility: %w", err)
|
|
}
|
|
return existing, nil
|
|
}
|
|
return r.createWorkflowFromTemplate(ctx, workspaceID, name, description, tmpl)
|
|
}
|
|
|
|
// findBuiltinWorkflowByTemplate returns the system workflow id in workspaceID
|
|
// with the given workflow_template_id, or empty string when none exists.
|
|
func (r *Repository) findBuiltinWorkflowByTemplate(ctx context.Context, workspaceID, templateID string) (string, error) {
|
|
var id string
|
|
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
|
|
SELECT id FROM workflows
|
|
WHERE workspace_id = ? AND workflow_template_id = ? AND is_system = 1
|
|
LIMIT 1
|
|
`), workspaceID, templateID).Scan(&id)
|
|
if err == sql.ErrNoRows {
|
|
return "", nil
|
|
}
|
|
if err != nil {
|
|
return "", fmt.Errorf("query workflow by template: %w", err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// loadBuiltinTemplate reads a single embedded workflow YAML by id.
|
|
func loadBuiltinTemplate(templateID string) (*wfmodels.WorkflowTemplate, error) {
|
|
templates, err := workflowcfg.LoadTemplates()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load embedded templates: %w", err)
|
|
}
|
|
for _, t := range templates {
|
|
if t.ID == templateID {
|
|
return t, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("builtin workflow template not found: %s", templateID)
|
|
}
|
|
|
|
// createWorkflowFromTemplate inserts a workflow row and all its steps from a
|
|
// loaded template. Step IDs from the template are remapped to fresh UUIDs
|
|
// and any move_to_step references updated to match.
|
|
func (r *Repository) createWorkflowFromTemplate(
|
|
ctx context.Context, workspaceID, name, description string, tmpl *wfmodels.WorkflowTemplate,
|
|
) (string, error) {
|
|
now := time.Now().UTC()
|
|
workflowID := uuid.New().String()
|
|
|
|
if _, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
INSERT INTO workflows (id, workspace_id, name, description, workflow_template_id, is_system, sort_order, hidden, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, 1, 999, ?, ?, ?)
|
|
`), workflowID, workspaceID, name, description, tmpl.ID, dialect.BoolToInt(tmpl.Hidden), now, now); err != nil {
|
|
return "", fmt.Errorf("insert builtin workflow %s: %w", tmpl.ID, err)
|
|
}
|
|
|
|
idMap := make(map[string]string, len(tmpl.Steps))
|
|
for _, def := range tmpl.Steps {
|
|
idMap[def.ID] = uuid.New().String()
|
|
}
|
|
for _, def := range tmpl.Steps {
|
|
if err := r.insertTemplateStep(ctx, workflowID, def, idMap, now); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
return workflowID, nil
|
|
}
|
|
|
|
// insertTemplateStep inserts a workflow step generated from a StepDefinition.
|
|
func (r *Repository) insertTemplateStep(
|
|
ctx context.Context, workflowID string, def wfmodels.StepDefinition,
|
|
idMap map[string]string, now time.Time,
|
|
) error {
|
|
events := wfmodels.RemapStepEvents(def.Events, idMap)
|
|
eventsJSON, err := json.Marshal(events)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal events for step %s: %w", def.Name, err)
|
|
}
|
|
stage := def.StageType
|
|
if stage == "" {
|
|
stage = wfmodels.StageTypeCustom
|
|
}
|
|
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
|
|
INSERT INTO workflow_steps (
|
|
id, workflow_id, name, position, color, prompt, events,
|
|
allow_manual_move, is_start_step, show_in_command_panel,
|
|
auto_archive_after_hours, agent_profile_id, stage_type,
|
|
created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`),
|
|
idMap[def.ID], workflowID, def.Name, def.Position, def.Color, def.Prompt,
|
|
string(eventsJSON), dialect.BoolToInt(def.AllowManualMove),
|
|
dialect.BoolToInt(def.IsStartStep), dialect.BoolToInt(def.ShowInCommandPanel),
|
|
def.AutoArchiveAfterHours, def.AgentProfileID, string(stage),
|
|
now, now,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("insert builtin step %s: %w", def.Name, err)
|
|
}
|
|
return nil
|
|
}
|