package store import ( "context" "fmt" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5" "git.warky.dev/wdevs/amcs/internal/generatedmodels" ext "git.warky.dev/wdevs/amcs/internal/types" ) const planColumns = ` id, title, description, status, priority, project_id, owner, due_date, completed_at, reviewed_by, last_reviewed_at, supersedes_plan_id, tags::text[], created_at, updated_at` func (db *DB) CreatePlan(ctx context.Context, plan ext.Plan) (ext.Plan, error) { row := db.pool.QueryRow(ctx, ` insert into plans (title, description, status, priority, project_id, owner, due_date, completed_at, reviewed_by, last_reviewed_at, supersedes_plan_id, tags) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) returning`+planColumns, strings.TrimSpace(plan.Title), strings.TrimSpace(plan.Description), string(plan.Status), string(plan.Priority), plan.ProjectID, nullableText(plan.Owner), plan.DueDate, plan.CompletedAt, nullableText(plan.ReviewedBy), plan.LastReviewedAt, plan.SupersedesPlanID, plan.Tags, ) return scanPlan(row) } func (db *DB) GetPlan(ctx context.Context, id uuid.UUID) (ext.Plan, error) { row := db.pool.QueryRow(ctx, `select`+planColumns+` from plans where id = $1`, id) plan, err := scanPlan(row) if err != nil { if err == pgx.ErrNoRows { return ext.Plan{}, fmt.Errorf("plan not found: %s", id) } return ext.Plan{}, fmt.Errorf("get plan: %w", err) } return plan, nil } func (db *DB) GetPlanDetail(ctx context.Context, id uuid.UUID) (ext.PlanDetail, error) { plan, err := db.GetPlan(ctx, id) if err != nil { return ext.PlanDetail{}, err } dependsOn, err := db.listPlansByQuery(ctx, ` select`+planColumns+` from plans p join plan_dependencies pd on pd.depends_on_plan_id = p.id where pd.plan_id = $1 order by p.title`, id) if err != nil { return ext.PlanDetail{}, fmt.Errorf("get plan depends_on: %w", err) } blocks, err := db.listPlansByQuery(ctx, ` select`+planColumns+` from plans p join plan_dependencies pd on pd.plan_id = p.id where pd.depends_on_plan_id = $1 order by p.title`, id) if err != nil { return ext.PlanDetail{}, fmt.Errorf("get plan blocks: %w", err) } related, err := db.listPlansByQuery(ctx, ` select`+planColumns+` from plans p where p.id in ( select plan_b_id from plan_related_plans where plan_a_id = $1 union select plan_a_id from plan_related_plans where plan_b_id = $1 ) order by p.title`, id) if err != nil { return ext.PlanDetail{}, fmt.Errorf("get plan related: %w", err) } skills, err := db.ListPlanSkills(ctx, id) if err != nil { return ext.PlanDetail{}, fmt.Errorf("get plan skills: %w", err) } guardrails, err := db.ListPlanGuardrails(ctx, id) if err != nil { return ext.PlanDetail{}, fmt.Errorf("get plan guardrails: %w", err) } return ext.PlanDetail{ Plan: plan, DependsOn: dependsOn, Blocks: blocks, RelatedPlans: related, Skills: skills, Guardrails: guardrails, }, nil } func (db *DB) UpdatePlan(ctx context.Context, id uuid.UUID, u ext.PlanUpdate) (ext.Plan, error) { sets := []string{"updated_at = now()"} args := []any{} if u.Title != nil { args = append(args, strings.TrimSpace(*u.Title)) sets = append(sets, fmt.Sprintf("title = $%d", len(args))) } if u.Description != nil { args = append(args, strings.TrimSpace(*u.Description)) sets = append(sets, fmt.Sprintf("description = $%d", len(args))) } if u.Status != nil { args = append(args, strings.TrimSpace(*u.Status)) sets = append(sets, fmt.Sprintf("status = $%d", len(args))) } if u.Priority != nil { args = append(args, strings.TrimSpace(*u.Priority)) sets = append(sets, fmt.Sprintf("priority = $%d", len(args))) } if u.Owner != nil { args = append(args, nullableText(*u.Owner)) sets = append(sets, fmt.Sprintf("owner = $%d", len(args))) } if u.ClearDueDate { sets = append(sets, "due_date = null") } else if u.DueDate != nil { args = append(args, *u.DueDate) sets = append(sets, fmt.Sprintf("due_date = $%d", len(args))) } if u.ClearCompletedAt { sets = append(sets, "completed_at = null") } else if u.CompletedAt != nil { args = append(args, *u.CompletedAt) sets = append(sets, fmt.Sprintf("completed_at = $%d", len(args))) } if u.ReviewedBy != nil { args = append(args, nullableText(*u.ReviewedBy)) sets = append(sets, fmt.Sprintf("reviewed_by = $%d", len(args))) } if u.MarkReviewed { sets = append(sets, "last_reviewed_at = now()") } if u.ClearSupersedesPlanID { sets = append(sets, "supersedes_plan_id = null") } else if u.SupersedesPlanID != nil { args = append(args, *u.SupersedesPlanID) sets = append(sets, fmt.Sprintf("supersedes_plan_id = $%d", len(args))) } if u.Tags != nil { args = append(args, *u.Tags) sets = append(sets, fmt.Sprintf("tags = $%d", len(args))) } args = append(args, id) query := fmt.Sprintf( "update plans set %s where id = $%d returning%s", strings.Join(sets, ", "), len(args), planColumns, ) row := db.pool.QueryRow(ctx, query, args...) plan, err := scanPlan(row) if err != nil { if err == pgx.ErrNoRows { return ext.Plan{}, fmt.Errorf("plan not found: %s", id) } return ext.Plan{}, fmt.Errorf("update plan: %w", err) } return plan, nil } func (db *DB) DeletePlan(ctx context.Context, id uuid.UUID) error { tag, err := db.pool.Exec(ctx, `delete from plans where id = $1`, id) if err != nil { return fmt.Errorf("delete plan: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("plan not found") } return nil } func (db *DB) ListPlans(ctx context.Context, filter ext.PlanFilter) ([]ext.Plan, error) { args := make([]any, 0, 8) conditions := make([]string, 0, 8) if filter.ProjectID != nil { args = append(args, *filter.ProjectID) conditions = append(conditions, fmt.Sprintf("project_id = $%d", len(args))) } if v := strings.TrimSpace(filter.Status); v != "" { args = append(args, v) conditions = append(conditions, fmt.Sprintf("status = $%d", len(args))) } if v := strings.TrimSpace(filter.Priority); v != "" { args = append(args, v) conditions = append(conditions, fmt.Sprintf("priority = $%d", len(args))) } if v := strings.TrimSpace(filter.Owner); v != "" { args = append(args, v) conditions = append(conditions, fmt.Sprintf("owner = $%d", len(args))) } if v := strings.TrimSpace(filter.Tag); v != "" { args = append(args, v) conditions = append(conditions, fmt.Sprintf("$%d = any(tags)", len(args))) } if v := strings.TrimSpace(filter.Query); v != "" { args = append(args, v) conditions = append(conditions, fmt.Sprintf( "to_tsvector('simple', title || ' ' || coalesce(description, '')) @@ websearch_to_tsquery('simple', $%d)", len(args))) } query := "select" + planColumns + " from plans" if len(conditions) > 0 { query += " where " + strings.Join(conditions, " and ") } query += " order by updated_at desc" if filter.Limit > 0 { args = append(args, filter.Limit) query += fmt.Sprintf(" limit $%d", len(args)) } return db.listPlansByQuery(ctx, query, args...) } // Dependencies func (db *DB) AddPlanDependency(ctx context.Context, planID, dependsOnPlanID uuid.UUID) error { _, err := db.pool.Exec(ctx, ` insert into plan_dependencies (plan_id, depends_on_plan_id) values ($1, $2) on conflict do nothing `, planID, dependsOnPlanID) if err != nil { return fmt.Errorf("add plan dependency: %w", err) } return nil } func (db *DB) RemovePlanDependency(ctx context.Context, planID, dependsOnPlanID uuid.UUID) error { tag, err := db.pool.Exec(ctx, ` delete from plan_dependencies where plan_id = $1 and depends_on_plan_id = $2 `, planID, dependsOnPlanID) if err != nil { return fmt.Errorf("remove plan dependency: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("plan dependency not found") } return nil } // Related Plans func (db *DB) AddRelatedPlan(ctx context.Context, planAID, planBID uuid.UUID) error { a, b := canonicalPlanPair(planAID, planBID) _, err := db.pool.Exec(ctx, ` insert into plan_related_plans (plan_a_id, plan_b_id) values ($1, $2) on conflict do nothing `, a, b) if err != nil { return fmt.Errorf("add related plan: %w", err) } return nil } func (db *DB) RemoveRelatedPlan(ctx context.Context, planAID, planBID uuid.UUID) error { a, b := canonicalPlanPair(planAID, planBID) tag, err := db.pool.Exec(ctx, ` delete from plan_related_plans where plan_a_id = $1 and plan_b_id = $2 `, a, b) if err != nil { return fmt.Errorf("remove related plan: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("related plan link not found") } return nil } // Plan Skills func (db *DB) AddPlanSkill(ctx context.Context, planID, skillID uuid.UUID) error { _, err := db.pool.Exec(ctx, ` insert into plan_skills (plan_id, skill_id) values ($1, $2) on conflict do nothing `, planID, skillID) if err != nil { return fmt.Errorf("add plan skill: %w", err) } return nil } func (db *DB) RemovePlanSkill(ctx context.Context, planID, skillID uuid.UUID) error { tag, err := db.pool.Exec(ctx, ` delete from plan_skills where plan_id = $1 and skill_id = $2 `, planID, skillID) if err != nil { return fmt.Errorf("remove plan skill: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("plan skill link not found") } return nil } func (db *DB) ListPlanSkills(ctx context.Context, planID uuid.UUID) ([]ext.AgentSkill, error) { rows, err := db.pool.Query(ctx, ` select s.id, s.name, s.description, s.content, s.tags::text[], s.created_at, s.updated_at from agent_skills s join plan_skills ps on ps.skill_id = s.id where ps.plan_id = $1 order by s.name `, planID) if err != nil { return nil, fmt.Errorf("list plan skills: %w", err) } defer rows.Close() var skills []ext.AgentSkill for rows.Next() { var model generatedmodels.ModelPublicAgentSkills var tags []string if err := rows.Scan(&model.ID, &model.Name, &model.Description, &model.Content, &tags, &model.CreatedAt, &model.UpdatedAt); err != nil { return nil, fmt.Errorf("scan plan skill: %w", err) } s := ext.AgentSkill{ ID: model.ID.UUID(), Name: model.Name.String(), Description: model.Description.String(), Content: model.Content.String(), Tags: tags, CreatedAt: model.CreatedAt.Time(), UpdatedAt: model.UpdatedAt.Time(), } if s.Tags == nil { s.Tags = []string{} } skills = append(skills, s) } return skills, rows.Err() } // Plan Guardrails func (db *DB) AddPlanGuardrail(ctx context.Context, planID, guardrailID uuid.UUID) error { _, err := db.pool.Exec(ctx, ` insert into plan_guardrails (plan_id, guardrail_id) values ($1, $2) on conflict do nothing `, planID, guardrailID) if err != nil { return fmt.Errorf("add plan guardrail: %w", err) } return nil } func (db *DB) RemovePlanGuardrail(ctx context.Context, planID, guardrailID uuid.UUID) error { tag, err := db.pool.Exec(ctx, ` delete from plan_guardrails where plan_id = $1 and guardrail_id = $2 `, planID, guardrailID) if err != nil { return fmt.Errorf("remove plan guardrail: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("plan guardrail link not found") } return nil } func (db *DB) ListPlanGuardrails(ctx context.Context, planID uuid.UUID) ([]ext.AgentGuardrail, error) { rows, err := db.pool.Query(ctx, ` select g.id, g.name, g.description, g.content, g.severity, g.tags::text[], g.created_at, g.updated_at from agent_guardrails g join plan_guardrails pg on pg.guardrail_id = g.id where pg.plan_id = $1 order by g.name `, planID) if err != nil { return nil, fmt.Errorf("list plan guardrails: %w", err) } defer rows.Close() var guardrails []ext.AgentGuardrail for rows.Next() { var model generatedmodels.ModelPublicAgentGuardrails var tags []string if err := rows.Scan(&model.ID, &model.Name, &model.Description, &model.Content, &model.Severity, &tags, &model.CreatedAt, &model.UpdatedAt); err != nil { return nil, fmt.Errorf("scan plan guardrail: %w", err) } g := ext.AgentGuardrail{ ID: model.ID.UUID(), Name: model.Name.String(), Description: model.Description.String(), Content: model.Content.String(), Severity: model.Severity.String(), Tags: tags, CreatedAt: model.CreatedAt.Time(), UpdatedAt: model.UpdatedAt.Time(), } if g.Tags == nil { g.Tags = []string{} } guardrails = append(guardrails, g) } return guardrails, rows.Err() } // helpers type planScanner interface { Scan(dest ...any) error } func scanPlan(row planScanner) (ext.Plan, error) { var model generatedmodels.ModelPublicPlans var tags []string err := row.Scan( &model.ID, &model.Title, &model.Description, &model.Status, &model.Priority, &model.ProjectID, &model.Owner, &model.DueDate, &model.CompletedAt, &model.ReviewedBy, &model.LastReviewedAt, &model.SupersedesPlanID, &tags, &model.CreatedAt, &model.UpdatedAt, ) if err != nil { return ext.Plan{}, err } if tags == nil { tags = []string{} } return planFromModel(model, tags), nil } func (db *DB) listPlansByQuery(ctx context.Context, query string, args ...any) ([]ext.Plan, error) { rows, err := db.pool.Query(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() plans := make([]ext.Plan, 0) for rows.Next() { plan, err := scanPlan(rows) if err != nil { return nil, fmt.Errorf("scan plan: %w", err) } plans = append(plans, plan) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate plans: %w", err) } return plans, nil } // canonicalPlanPair ensures the smaller UUID is always plan_a_id to prevent duplicates. func canonicalPlanPair(a, b uuid.UUID) (uuid.UUID, uuid.UUID) { if strings.Compare(a.String(), b.String()) <= 0 { return a, b } return b, a }