Some checks failed
CI / build-and-test (push) Failing after -31m12s
All internal entity lookups now use bigserial primary keys (int64) while GUIDs are retained for external/public identification. Updated store functions (TouchProject, UpdateThoughtMetadata, AddThoughtAttachment) to query by id instead of guid, added GetThoughtByID, changed semanticSearch and all tool helpers to use *int64 project IDs, and updated retry/backfill workers to use int64 thought IDs throughout.
344 lines
12 KiB
Go
344 lines
12 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
|
|
"git.warky.dev/wdevs/amcs/internal/config"
|
|
"git.warky.dev/wdevs/amcs/internal/session"
|
|
"git.warky.dev/wdevs/amcs/internal/store"
|
|
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
|
)
|
|
|
|
type PlansTool struct {
|
|
store *store.DB
|
|
sessions *session.ActiveProjects
|
|
cfg config.SearchConfig
|
|
}
|
|
|
|
func NewPlansTool(db *store.DB, sessions *session.ActiveProjects, cfg config.SearchConfig) *PlansTool {
|
|
return &PlansTool{store: db, sessions: sessions, cfg: cfg}
|
|
}
|
|
|
|
// --- I/O types ---
|
|
|
|
type CreatePlanInput struct {
|
|
Title string `json:"title" jsonschema:"plan title"`
|
|
Description string `json:"description,omitempty"`
|
|
Status string `json:"status,omitempty" jsonschema:"draft|active|blocked|completed|cancelled|superseded"`
|
|
Priority string `json:"priority,omitempty" jsonschema:"low|medium|high|critical"`
|
|
Project string `json:"project,omitempty" jsonschema:"project name or id; falls back to active session project"`
|
|
Owner string `json:"owner,omitempty"`
|
|
DueDate string `json:"due_date,omitempty" jsonschema:"RFC3339 timestamp"`
|
|
SupersedesPlanID *int64 `json:"supersedes_plan_id,omitempty"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
}
|
|
|
|
type CreatePlanOutput struct {
|
|
Plan thoughttypes.Plan `json:"plan"`
|
|
}
|
|
|
|
type GetPlanInput struct {
|
|
ID int64 `json:"id" jsonschema:"plan id"`
|
|
}
|
|
|
|
type GetPlanOutput struct {
|
|
Plan thoughttypes.PlanDetail `json:"plan"`
|
|
}
|
|
|
|
type UpdatePlanInput struct {
|
|
ID int64 `json:"id" jsonschema:"plan id"`
|
|
Title string `json:"title,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Status string `json:"status,omitempty" jsonschema:"draft|active|blocked|completed|cancelled|superseded"`
|
|
Priority string `json:"priority,omitempty" jsonschema:"low|medium|high|critical"`
|
|
Owner *string `json:"owner,omitempty" jsonschema:"empty string clears the owner"`
|
|
DueDate string `json:"due_date,omitempty" jsonschema:"RFC3339; omit to keep, 'clear' to remove"`
|
|
ClearDueDate bool `json:"clear_due_date,omitempty"`
|
|
CompletedAt string `json:"completed_at,omitempty" jsonschema:"RFC3339; omit to keep, 'clear' to remove"`
|
|
ClearCompletedAt bool `json:"clear_completed_at,omitempty"`
|
|
ReviewedBy *string `json:"reviewed_by,omitempty" jsonschema:"empty string clears the reviewer"`
|
|
MarkReviewed bool `json:"mark_reviewed,omitempty" jsonschema:"set last_reviewed_at to now"`
|
|
SupersedesPlanID *int64 `json:"supersedes_plan_id,omitempty"`
|
|
ClearSupersedesPlanID bool `json:"clear_supersedes_plan_id,omitempty"`
|
|
Tags *[]string `json:"tags,omitempty" jsonschema:"replaces all tags when provided; pass [] to clear"`
|
|
}
|
|
|
|
type UpdatePlanOutput struct {
|
|
Plan thoughttypes.Plan `json:"plan"`
|
|
}
|
|
|
|
type DeletePlanInput struct {
|
|
ID int64 `json:"id" jsonschema:"plan id"`
|
|
}
|
|
|
|
type DeletePlanOutput struct {
|
|
Deleted bool `json:"deleted"`
|
|
}
|
|
|
|
type ListPlansInput struct {
|
|
Limit int `json:"limit,omitempty"`
|
|
Project string `json:"project,omitempty" jsonschema:"project name or id; falls back to active session project"`
|
|
Status string `json:"status,omitempty"`
|
|
Priority string `json:"priority,omitempty"`
|
|
Owner string `json:"owner,omitempty"`
|
|
Tag string `json:"tag,omitempty"`
|
|
Query string `json:"query,omitempty"`
|
|
}
|
|
|
|
type ListPlansOutput struct {
|
|
Plans []thoughttypes.Plan `json:"plans"`
|
|
}
|
|
|
|
type PlanDependencyInput struct {
|
|
PlanID int64 `json:"plan_id" jsonschema:"the plan that depends on another"`
|
|
DependsOnPlanID int64 `json:"depends_on_plan_id" jsonschema:"the plan that must complete first"`
|
|
}
|
|
|
|
type PlanRelatedInput struct {
|
|
PlanAID int64 `json:"plan_a_id"`
|
|
PlanBID int64 `json:"plan_b_id"`
|
|
}
|
|
|
|
type PlanLinkOutput struct {
|
|
OK bool `json:"ok"`
|
|
}
|
|
|
|
type PlanSkillInput struct {
|
|
PlanID int64 `json:"plan_id"`
|
|
SkillID int64 `json:"skill_id"`
|
|
}
|
|
|
|
type ListPlanSkillsInput struct {
|
|
PlanID int64 `json:"plan_id"`
|
|
}
|
|
|
|
type ListPlanSkillsOutput struct {
|
|
Skills []thoughttypes.AgentSkill `json:"skills"`
|
|
}
|
|
|
|
type PlanGuardrailInput struct {
|
|
PlanID int64 `json:"plan_id"`
|
|
GuardrailID int64 `json:"guardrail_id"`
|
|
}
|
|
|
|
type ListPlanGuardrailsInput struct {
|
|
PlanID int64 `json:"plan_id"`
|
|
}
|
|
|
|
type ListPlanGuardrailsOutput struct {
|
|
Guardrails []thoughttypes.AgentGuardrail `json:"guardrails"`
|
|
}
|
|
|
|
// --- Handlers ---
|
|
|
|
func (t *PlansTool) Create(ctx context.Context, req *mcp.CallToolRequest, in CreatePlanInput) (*mcp.CallToolResult, CreatePlanOutput, error) {
|
|
title := strings.TrimSpace(in.Title)
|
|
if title == "" {
|
|
return nil, CreatePlanOutput{}, errRequiredField("title")
|
|
}
|
|
|
|
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
|
|
if err != nil {
|
|
return nil, CreatePlanOutput{}, err
|
|
}
|
|
|
|
plan := thoughttypes.Plan{
|
|
Title: title,
|
|
Description: strings.TrimSpace(in.Description),
|
|
Status: thoughttypes.PlanStatus(defaultString(strings.TrimSpace(in.Status), string(thoughttypes.PlanStatusDraft))),
|
|
Priority: thoughttypes.PlanPriority(defaultString(strings.TrimSpace(in.Priority), string(thoughttypes.PlanPriorityMedium))),
|
|
Owner: strings.TrimSpace(in.Owner),
|
|
SupersedesPlanID: in.SupersedesPlanID,
|
|
Tags: normalizeStringSlice(in.Tags),
|
|
}
|
|
if project != nil {
|
|
plan.ProjectID = &project.NumericID
|
|
}
|
|
if v := strings.TrimSpace(in.DueDate); v != "" {
|
|
t, err := time.Parse(time.RFC3339, v)
|
|
if err != nil {
|
|
return nil, CreatePlanOutput{}, errInvalidField("due_date", "invalid due_date", "use RFC3339 format")
|
|
}
|
|
plan.DueDate = &t
|
|
}
|
|
|
|
created, err := t.store.CreatePlan(ctx, plan)
|
|
if err != nil {
|
|
return nil, CreatePlanOutput{}, err
|
|
}
|
|
return nil, CreatePlanOutput{Plan: created}, nil
|
|
}
|
|
|
|
func (t *PlansTool) Get(ctx context.Context, _ *mcp.CallToolRequest, in GetPlanInput) (*mcp.CallToolResult, GetPlanOutput, error) {
|
|
detail, err := t.store.GetPlanDetail(ctx, in.ID)
|
|
if err != nil {
|
|
return nil, GetPlanOutput{}, err
|
|
}
|
|
return nil, GetPlanOutput{Plan: detail}, nil
|
|
}
|
|
|
|
func (t *PlansTool) Update(ctx context.Context, _ *mcp.CallToolRequest, in UpdatePlanInput) (*mcp.CallToolResult, UpdatePlanOutput, error) {
|
|
u := thoughttypes.PlanUpdate{
|
|
ReviewedBy: in.ReviewedBy,
|
|
MarkReviewed: in.MarkReviewed,
|
|
ClearDueDate: in.ClearDueDate,
|
|
ClearCompletedAt: in.ClearCompletedAt,
|
|
ClearSupersedesPlanID: in.ClearSupersedesPlanID,
|
|
SupersedesPlanID: in.SupersedesPlanID,
|
|
Tags: in.Tags,
|
|
Owner: in.Owner,
|
|
}
|
|
if v := strings.TrimSpace(in.Title); v != "" {
|
|
u.Title = &v
|
|
}
|
|
if v := strings.TrimSpace(in.Description); v != "" {
|
|
u.Description = &v
|
|
}
|
|
if v := strings.TrimSpace(in.Status); v != "" {
|
|
u.Status = &v
|
|
}
|
|
if v := strings.TrimSpace(in.Priority); v != "" {
|
|
u.Priority = &v
|
|
}
|
|
if v := strings.TrimSpace(in.DueDate); v != "" && !in.ClearDueDate {
|
|
parsed, err := time.Parse(time.RFC3339, v)
|
|
if err != nil {
|
|
return nil, UpdatePlanOutput{}, errInvalidField("due_date", "invalid due_date", "use RFC3339 format")
|
|
}
|
|
u.DueDate = &parsed
|
|
}
|
|
if v := strings.TrimSpace(in.CompletedAt); v != "" && !in.ClearCompletedAt {
|
|
parsed, err := time.Parse(time.RFC3339, v)
|
|
if err != nil {
|
|
return nil, UpdatePlanOutput{}, errInvalidField("completed_at", "invalid completed_at", "use RFC3339 format")
|
|
}
|
|
u.CompletedAt = &parsed
|
|
}
|
|
|
|
plan, err := t.store.UpdatePlan(ctx, in.ID, u)
|
|
if err != nil {
|
|
return nil, UpdatePlanOutput{}, err
|
|
}
|
|
return nil, UpdatePlanOutput{Plan: plan}, nil
|
|
}
|
|
|
|
func (t *PlansTool) Delete(ctx context.Context, _ *mcp.CallToolRequest, in DeletePlanInput) (*mcp.CallToolResult, DeletePlanOutput, error) {
|
|
if err := t.store.DeletePlan(ctx, in.ID); err != nil {
|
|
return nil, DeletePlanOutput{}, err
|
|
}
|
|
return nil, DeletePlanOutput{Deleted: true}, nil
|
|
}
|
|
|
|
func (t *PlansTool) List(ctx context.Context, req *mcp.CallToolRequest, in ListPlansInput) (*mcp.CallToolResult, ListPlansOutput, error) {
|
|
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
|
|
if err != nil {
|
|
return nil, ListPlansOutput{}, err
|
|
}
|
|
|
|
filter := thoughttypes.PlanFilter{
|
|
Limit: normalizeLimit(in.Limit, t.cfg),
|
|
Status: strings.TrimSpace(in.Status),
|
|
Priority: strings.TrimSpace(in.Priority),
|
|
Owner: strings.TrimSpace(in.Owner),
|
|
Tag: strings.TrimSpace(in.Tag),
|
|
Query: strings.TrimSpace(in.Query),
|
|
}
|
|
if project != nil {
|
|
filter.ProjectID = &project.NumericID
|
|
}
|
|
|
|
plans, err := t.store.ListPlans(ctx, filter)
|
|
if err != nil {
|
|
return nil, ListPlansOutput{}, err
|
|
}
|
|
return nil, ListPlansOutput{Plans: plans}, nil
|
|
}
|
|
|
|
func (t *PlansTool) AddDependency(ctx context.Context, _ *mcp.CallToolRequest, in PlanDependencyInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
|
|
if in.PlanID == in.DependsOnPlanID {
|
|
return nil, PlanLinkOutput{}, errInvalidField("depends_on_plan_id", "a plan cannot depend on itself", "use a different plan id")
|
|
}
|
|
if err := t.store.AddPlanDependency(ctx, in.PlanID, in.DependsOnPlanID); err != nil {
|
|
return nil, PlanLinkOutput{}, err
|
|
}
|
|
return nil, PlanLinkOutput{OK: true}, nil
|
|
}
|
|
|
|
func (t *PlansTool) RemoveDependency(ctx context.Context, _ *mcp.CallToolRequest, in PlanDependencyInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
|
|
if err := t.store.RemovePlanDependency(ctx, in.PlanID, in.DependsOnPlanID); err != nil {
|
|
return nil, PlanLinkOutput{}, err
|
|
}
|
|
return nil, PlanLinkOutput{OK: true}, nil
|
|
}
|
|
|
|
func (t *PlansTool) AddRelated(ctx context.Context, _ *mcp.CallToolRequest, in PlanRelatedInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
|
|
if in.PlanAID == in.PlanBID {
|
|
return nil, PlanLinkOutput{}, errInvalidField("plan_b_id", "a plan cannot be related to itself", "use a different plan id")
|
|
}
|
|
if err := t.store.AddRelatedPlan(ctx, in.PlanAID, in.PlanBID); err != nil {
|
|
return nil, PlanLinkOutput{}, err
|
|
}
|
|
return nil, PlanLinkOutput{OK: true}, nil
|
|
}
|
|
|
|
func (t *PlansTool) RemoveRelated(ctx context.Context, _ *mcp.CallToolRequest, in PlanRelatedInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
|
|
if err := t.store.RemoveRelatedPlan(ctx, in.PlanAID, in.PlanBID); err != nil {
|
|
return nil, PlanLinkOutput{}, err
|
|
}
|
|
return nil, PlanLinkOutput{OK: true}, nil
|
|
}
|
|
|
|
func (t *PlansTool) AddSkill(ctx context.Context, _ *mcp.CallToolRequest, in PlanSkillInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
|
|
if err := t.store.AddPlanSkill(ctx, in.PlanID, in.SkillID); err != nil {
|
|
return nil, PlanLinkOutput{}, err
|
|
}
|
|
return nil, PlanLinkOutput{OK: true}, nil
|
|
}
|
|
|
|
func (t *PlansTool) RemoveSkill(ctx context.Context, _ *mcp.CallToolRequest, in PlanSkillInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
|
|
if err := t.store.RemovePlanSkill(ctx, in.PlanID, in.SkillID); err != nil {
|
|
return nil, PlanLinkOutput{}, err
|
|
}
|
|
return nil, PlanLinkOutput{OK: true}, nil
|
|
}
|
|
|
|
func (t *PlansTool) ListSkills(ctx context.Context, _ *mcp.CallToolRequest, in ListPlanSkillsInput) (*mcp.CallToolResult, ListPlanSkillsOutput, error) {
|
|
skills, err := t.store.ListPlanSkills(ctx, in.PlanID)
|
|
if err != nil {
|
|
return nil, ListPlanSkillsOutput{}, err
|
|
}
|
|
if skills == nil {
|
|
skills = []thoughttypes.AgentSkill{}
|
|
}
|
|
return nil, ListPlanSkillsOutput{Skills: skills}, nil
|
|
}
|
|
|
|
func (t *PlansTool) AddGuardrail(ctx context.Context, _ *mcp.CallToolRequest, in PlanGuardrailInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
|
|
if err := t.store.AddPlanGuardrail(ctx, in.PlanID, in.GuardrailID); err != nil {
|
|
return nil, PlanLinkOutput{}, err
|
|
}
|
|
return nil, PlanLinkOutput{OK: true}, nil
|
|
}
|
|
|
|
func (t *PlansTool) RemoveGuardrail(ctx context.Context, _ *mcp.CallToolRequest, in PlanGuardrailInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
|
|
if err := t.store.RemovePlanGuardrail(ctx, in.PlanID, in.GuardrailID); err != nil {
|
|
return nil, PlanLinkOutput{}, err
|
|
}
|
|
return nil, PlanLinkOutput{OK: true}, nil
|
|
}
|
|
|
|
func (t *PlansTool) ListGuardrails(ctx context.Context, _ *mcp.CallToolRequest, in ListPlanGuardrailsInput) (*mcp.CallToolResult, ListPlanGuardrailsOutput, error) {
|
|
guardrails, err := t.store.ListPlanGuardrails(ctx, in.PlanID)
|
|
if err != nil {
|
|
return nil, ListPlanGuardrailsOutput{}, err
|
|
}
|
|
if guardrails == nil {
|
|
guardrails = []thoughttypes.AgentGuardrail{}
|
|
}
|
|
return nil, ListPlanGuardrailsOutput{Guardrails: guardrails}, nil
|
|
}
|