diff --git a/CLAUDE.md b/CLAUDE.md index 77ef183f..ff7358f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,7 +94,8 @@ Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers - **Daemon** (`internal/daemon/`): Local agent runtime — auto-detects available CLIs (claude, codex), registers runtimes, polls for tasks, routes by provider. - **CLI** (`internal/cli/`): Shared helpers for the `multica` CLI — API client, config management, output formatting. - **Events** (`internal/events/`): Internal event bus for decoupled communication between handlers and services. -- **Database**: sqlc generates Go code from SQL in `pkg/db/queries/` → `pkg/db/generated/`. Migrations in `migrations/`. +- **Logging** (`internal/logger/`): Structured logging via slog. `LOG_LEVEL` env var controls level (debug, info, warn, error). +- **Database**: PostgreSQL with pgvector extension (`pgvector/pgvector:pg17`). sqlc generates Go code from SQL in `pkg/db/queries/` → `pkg/db/generated/`. Migrations in `migrations/`. - **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model). ### Multi-tenancy @@ -119,11 +120,14 @@ pnpm install pnpm dev:web # Next.js dev server (port 3000) pnpm build # Build frontend pnpm typecheck # TypeScript check +pnpm lint # ESLint via Next.js pnpm test # TS tests (Vitest) # Backend (Go) make dev # Run Go server (port 8080) make daemon # Run local daemon +make build # Build server + CLI binaries to server/bin/ +make cli ARGS="..." # Run multica CLI (e.g. make cli ARGS="config") make test # Go tests make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/queries/ make migrate-up # Run database migrations @@ -139,10 +143,14 @@ pnpm --filter @multica/web exec vitest run src/path/to/file.test.ts pnpm exec playwright test e2e/tests/specific-test.spec.ts # Infrastructure -make db-up # Start shared PostgreSQL +make db-up # Start shared PostgreSQL (pgvector/pg17 image) make db-down # Stop shared PostgreSQL ``` +### CI Requirements + +CI runs on Node 22 and Go 1.24 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`. + ### Worktree Support All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`. diff --git a/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx b/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx index bdb2f78d..f9adfaa2 100644 --- a/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx +++ b/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { Save, LogOut } from "lucide-react"; +import { Save, LogOut, Plus, Trash2 } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; @@ -21,6 +21,7 @@ import { toast } from "sonner"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; import { api } from "@/shared/api"; +import type { WorkspaceRepo } from "@/shared/types"; export function WorkspaceTab() { const user = useAuthStore((s) => s.user); @@ -33,6 +34,7 @@ export function WorkspaceTab() { const [name, setName] = useState(workspace?.name ?? ""); const [description, setDescription] = useState(workspace?.description ?? ""); const [context, setContext] = useState(workspace?.context ?? ""); + const [repos, setRepos] = useState(workspace?.repos ?? []); const [saving, setSaving] = useState(false); const [actionId, setActionId] = useState(null); const [confirmAction, setConfirmAction] = useState<{ @@ -50,6 +52,7 @@ export function WorkspaceTab() { setName(workspace?.name ?? ""); setDescription(workspace?.description ?? ""); setContext(workspace?.context ?? ""); + setRepos(workspace?.repos ?? []); }, [workspace]); const handleSave = async () => { @@ -60,6 +63,7 @@ export function WorkspaceTab() { name, description, context, + repos, }); updateWorkspace(updated); toast.success("Workspace settings saved"); @@ -70,6 +74,18 @@ export function WorkspaceTab() { } }; + const handleAddRepo = () => { + setRepos([...repos, { url: "", description: "" }]); + }; + + const handleRemoveRepo = (index: number) => { + setRepos(repos.filter((_, i) => i !== index)); + }; + + const handleRepoChange = (index: number, field: keyof WorkspaceRepo, value: string) => { + setRepos(repos.map((r, i) => (i === index ? { ...r, [field]: value } : r))); + }; + const handleLeaveWorkspace = () => { if (!workspace) return; setConfirmAction({ @@ -175,6 +191,59 @@ export function WorkspaceTab() { + {/* Repositories */} +
+

Repositories

+ + + +

+ GitHub repositories associated with this workspace. Agents use these to clone and work on code. +

+ + {repos.map((repo, index) => ( +
+
+ handleRepoChange(index, "url", e.target.value)} + disabled={!canManageWorkspace} + placeholder="https://github.com/org/repo" + className="text-sm" + /> + handleRepoChange(index, "description", e.target.value)} + disabled={!canManageWorkspace} + placeholder="Description (e.g. Go backend + Next.js frontend)" + className="text-sm" + /> +
+ {canManageWorkspace && ( + + )} +
+ ))} + + {canManageWorkspace && ( + + )} +
+
+
+ {/* Danger Zone */}
diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index 285c1299..e28cf9d5 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -17,6 +17,7 @@ import type { InboxItem, Comment, Workspace, + WorkspaceRepo, MemberWithUser, User, Skill, @@ -317,7 +318,7 @@ export class ApiClient { }); } - async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record }): Promise { + async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record; repos?: WorkspaceRepo[] }): Promise { return this.fetch(`/api/workspaces/${id}`, { method: "PATCH", body: JSON.stringify(data), diff --git a/apps/web/shared/types/index.ts b/apps/web/shared/types/index.ts index 5d0acc8a..c3b1d72a 100644 --- a/apps/web/shared/types/index.ts +++ b/apps/web/shared/types/index.ts @@ -21,7 +21,7 @@ export type { RuntimePing, RuntimePingStatus, } from "./agent"; -export type { Workspace, Member, MemberRole, User, MemberWithUser } from "./workspace"; +export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser } from "./workspace"; export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox"; export type { Comment, CommentType, CommentAuthorType } from "./comment"; export type { DaemonPairingSession, DaemonPairingSessionStatus, ApproveDaemonPairingSessionRequest } from "./daemon"; diff --git a/apps/web/shared/types/workspace.ts b/apps/web/shared/types/workspace.ts index 269fe364..9fdc45a4 100644 --- a/apps/web/shared/types/workspace.ts +++ b/apps/web/shared/types/workspace.ts @@ -1,5 +1,10 @@ export type MemberRole = "owner" | "admin" | "member"; +export interface WorkspaceRepo { + url: string; + description: string; +} + export interface Workspace { id: string; name: string; @@ -7,6 +12,7 @@ export interface Workspace { description: string | null; context: string | null; settings: Record; + repos: WorkspaceRepo[]; created_at: string; updated_at: string; } diff --git a/apps/web/test/helpers.tsx b/apps/web/test/helpers.tsx index 312c3bc5..5bb9fe3f 100644 --- a/apps/web/test/helpers.tsx +++ b/apps/web/test/helpers.tsx @@ -21,6 +21,7 @@ export const mockWorkspace: Workspace = { description: "A test workspace", context: null, settings: {}, + repos: [], created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", }; diff --git a/server/cmd/multica/cmd_issue.go b/server/cmd/multica/cmd_issue.go new file mode 100644 index 00000000..d68a40e3 --- /dev/null +++ b/server/cmd/multica/cmd_issue.go @@ -0,0 +1,660 @@ +package main + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + "time" + "unicode/utf8" + + "github.com/spf13/cobra" + + "github.com/multica-ai/multica/server/internal/cli" +) + +var issueCmd = &cobra.Command{ + Use: "issue", + Short: "Manage issues", +} + +var issueListCmd = &cobra.Command{ + Use: "list", + Short: "List issues in the workspace", + RunE: runIssueList, +} + +var issueGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get issue details", + Args: cobra.ExactArgs(1), + RunE: runIssueGet, +} + +var issueCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new issue", + RunE: runIssueCreate, +} + +var issueUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update an issue", + Args: cobra.ExactArgs(1), + RunE: runIssueUpdate, +} + +var issueAssignCmd = &cobra.Command{ + Use: "assign ", + Short: "Assign an issue to a member or agent", + Args: cobra.ExactArgs(1), + RunE: runIssueAssign, +} + +var issueStatusCmd = &cobra.Command{ + Use: "status ", + Short: "Change issue status", + Args: cobra.ExactArgs(2), + RunE: runIssueStatus, +} + +// Comment subcommands. + +var issueCommentCmd = &cobra.Command{ + Use: "comment", + Short: "Manage issue comments", +} + +var issueCommentListCmd = &cobra.Command{ + Use: "list ", + Short: "List comments on an issue", + Args: cobra.ExactArgs(1), + RunE: runIssueCommentList, +} + +var issueCommentAddCmd = &cobra.Command{ + Use: "add ", + Short: "Add a comment to an issue", + Args: cobra.ExactArgs(1), + RunE: runIssueCommentAdd, +} + +var issueCommentDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a comment", + Args: cobra.ExactArgs(1), + RunE: runIssueCommentDelete, +} + +var validIssueStatuses = []string{ + "backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled", +} + +func init() { + issueCmd.AddCommand(issueListCmd) + issueCmd.AddCommand(issueGetCmd) + issueCmd.AddCommand(issueCreateCmd) + issueCmd.AddCommand(issueUpdateCmd) + issueCmd.AddCommand(issueAssignCmd) + issueCmd.AddCommand(issueStatusCmd) + issueCmd.AddCommand(issueCommentCmd) + + issueCommentCmd.AddCommand(issueCommentListCmd) + issueCommentCmd.AddCommand(issueCommentAddCmd) + issueCommentCmd.AddCommand(issueCommentDeleteCmd) + + // issue list + issueListCmd.Flags().String("output", "table", "Output format: table or json") + issueListCmd.Flags().String("status", "", "Filter by status") + issueListCmd.Flags().String("priority", "", "Filter by priority") + issueListCmd.Flags().String("assignee", "", "Filter by assignee name") + issueListCmd.Flags().Int("limit", 50, "Maximum number of issues to return") + + // issue get + issueGetCmd.Flags().String("output", "json", "Output format: table or json") + + // issue create + issueCreateCmd.Flags().String("title", "", "Issue title (required)") + issueCreateCmd.Flags().String("description", "", "Issue description") + issueCreateCmd.Flags().String("status", "", "Issue status") + issueCreateCmd.Flags().String("priority", "", "Issue priority") + issueCreateCmd.Flags().String("assignee", "", "Assignee name (member or agent)") + issueCreateCmd.Flags().String("parent", "", "Parent issue ID") + issueCreateCmd.Flags().String("due-date", "", "Due date (RFC3339 format)") + issueCreateCmd.Flags().String("output", "json", "Output format: table or json") + + // issue update + issueUpdateCmd.Flags().String("title", "", "New title") + issueUpdateCmd.Flags().String("description", "", "New description") + issueUpdateCmd.Flags().String("status", "", "New status") + issueUpdateCmd.Flags().String("priority", "", "New priority") + issueUpdateCmd.Flags().String("assignee", "", "New assignee name (member or agent)") + issueUpdateCmd.Flags().String("due-date", "", "New due date (RFC3339 format)") + issueUpdateCmd.Flags().String("output", "json", "Output format: table or json") + + // issue status + issueStatusCmd.Flags().String("output", "table", "Output format: table or json") + + // issue assign + issueAssignCmd.Flags().String("to", "", "Assignee name (member or agent)") + issueAssignCmd.Flags().Bool("unassign", false, "Remove current assignee") + issueAssignCmd.Flags().String("output", "json", "Output format: table or json") + + // issue comment list + issueCommentListCmd.Flags().String("output", "table", "Output format: table or json") + + // issue comment add + issueCommentAddCmd.Flags().String("content", "", "Comment content (required)") + issueCommentAddCmd.Flags().String("output", "json", "Output format: table or json") +} + +// --------------------------------------------------------------------------- +// Issue commands +// --------------------------------------------------------------------------- + +func runIssueList(cmd *cobra.Command, _ []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + params := url.Values{} + if client.WorkspaceID != "" { + params.Set("workspace_id", client.WorkspaceID) + } + if v, _ := cmd.Flags().GetString("status"); v != "" { + params.Set("status", v) + } + if v, _ := cmd.Flags().GetString("priority"); v != "" { + params.Set("priority", v) + } + if v, _ := cmd.Flags().GetInt("limit"); v > 0 { + params.Set("limit", fmt.Sprintf("%d", v)) + } + if v, _ := cmd.Flags().GetString("assignee"); v != "" { + _, aID, resolveErr := resolveAssignee(ctx, client, v) + if resolveErr != nil { + return fmt.Errorf("resolve assignee: %w", resolveErr) + } + params.Set("assignee_id", aID) + } + + path := "/api/issues" + if len(params) > 0 { + path += "?" + params.Encode() + } + + var result map[string]any + if err := client.GetJSON(ctx, path, &result); err != nil { + return fmt.Errorf("list issues: %w", err) + } + + issuesRaw, _ := result["issues"].([]any) + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, issuesRaw) + } + + headers := []string{"ID", "TITLE", "STATUS", "PRIORITY", "ASSIGNEE", "DUE DATE"} + rows := make([][]string, 0, len(issuesRaw)) + for _, raw := range issuesRaw { + issue, ok := raw.(map[string]any) + if !ok { + continue + } + assignee := formatAssignee(issue) + dueDate := strVal(issue, "due_date") + if dueDate != "" && len(dueDate) >= 10 { + dueDate = dueDate[:10] + } + rows = append(rows, []string{ + truncateID(strVal(issue, "id")), + strVal(issue, "title"), + strVal(issue, "status"), + strVal(issue, "priority"), + assignee, + dueDate, + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +func runIssueGet(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var issue map[string]any + if err := client.GetJSON(ctx, "/api/issues/"+args[0], &issue); err != nil { + return fmt.Errorf("get issue: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "table" { + assignee := formatAssignee(issue) + dueDate := strVal(issue, "due_date") + if dueDate != "" && len(dueDate) >= 10 { + dueDate = dueDate[:10] + } + headers := []string{"ID", "TITLE", "STATUS", "PRIORITY", "ASSIGNEE", "DUE DATE", "DESCRIPTION"} + rows := [][]string{{ + truncateID(strVal(issue, "id")), + strVal(issue, "title"), + strVal(issue, "status"), + strVal(issue, "priority"), + assignee, + dueDate, + strVal(issue, "description"), + }} + cli.PrintTable(os.Stdout, headers, rows) + return nil + } + + return cli.PrintJSON(os.Stdout, issue) +} + +func runIssueCreate(cmd *cobra.Command, _ []string) error { + title, _ := cmd.Flags().GetString("title") + if title == "" { + return fmt.Errorf("--title is required") + } + + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + body := map[string]any{"title": title} + if v, _ := cmd.Flags().GetString("description"); v != "" { + body["description"] = v + } + if v, _ := cmd.Flags().GetString("status"); v != "" { + body["status"] = v + } + if v, _ := cmd.Flags().GetString("priority"); v != "" { + body["priority"] = v + } + if v, _ := cmd.Flags().GetString("parent"); v != "" { + body["parent_issue_id"] = v + } + if v, _ := cmd.Flags().GetString("due-date"); v != "" { + body["due_date"] = v + } + if v, _ := cmd.Flags().GetString("assignee"); v != "" { + aType, aID, resolveErr := resolveAssignee(ctx, client, v) + if resolveErr != nil { + return fmt.Errorf("resolve assignee: %w", resolveErr) + } + body["assignee_type"] = aType + body["assignee_id"] = aID + } + + var result map[string]any + if err := client.PostJSON(ctx, "/api/issues", body, &result); err != nil { + return fmt.Errorf("create issue: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "table" { + headers := []string{"ID", "TITLE", "STATUS", "PRIORITY"} + rows := [][]string{{ + truncateID(strVal(result, "id")), + strVal(result, "title"), + strVal(result, "status"), + strVal(result, "priority"), + }} + cli.PrintTable(os.Stdout, headers, rows) + return nil + } + + return cli.PrintJSON(os.Stdout, result) +} + +func runIssueUpdate(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + body := map[string]any{} + if cmd.Flags().Changed("title") { + v, _ := cmd.Flags().GetString("title") + body["title"] = v + } + if cmd.Flags().Changed("description") { + v, _ := cmd.Flags().GetString("description") + body["description"] = v + } + if cmd.Flags().Changed("status") { + v, _ := cmd.Flags().GetString("status") + body["status"] = v + } + if cmd.Flags().Changed("priority") { + v, _ := cmd.Flags().GetString("priority") + body["priority"] = v + } + if cmd.Flags().Changed("due-date") { + v, _ := cmd.Flags().GetString("due-date") + body["due_date"] = v + } + if cmd.Flags().Changed("assignee") { + v, _ := cmd.Flags().GetString("assignee") + aType, aID, resolveErr := resolveAssignee(ctx, client, v) + if resolveErr != nil { + return fmt.Errorf("resolve assignee: %w", resolveErr) + } + body["assignee_type"] = aType + body["assignee_id"] = aID + } + + if len(body) == 0 { + return fmt.Errorf("no fields to update; use flags like --title, --status, --priority, --assignee, etc.") + } + + var result map[string]any + if err := client.PutJSON(ctx, "/api/issues/"+args[0], body, &result); err != nil { + return fmt.Errorf("update issue: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "table" { + headers := []string{"ID", "TITLE", "STATUS", "PRIORITY"} + rows := [][]string{{ + truncateID(strVal(result, "id")), + strVal(result, "title"), + strVal(result, "status"), + strVal(result, "priority"), + }} + cli.PrintTable(os.Stdout, headers, rows) + return nil + } + + return cli.PrintJSON(os.Stdout, result) +} + +func runIssueAssign(cmd *cobra.Command, args []string) error { + toName, _ := cmd.Flags().GetString("to") + unassign, _ := cmd.Flags().GetBool("unassign") + + if toName == "" && !unassign { + return fmt.Errorf("provide --to or --unassign") + } + if toName != "" && unassign { + return fmt.Errorf("--to and --unassign are mutually exclusive") + } + + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + body := map[string]any{} + if unassign { + body["assignee_type"] = nil + body["assignee_id"] = nil + } else { + aType, aID, resolveErr := resolveAssignee(ctx, client, toName) + if resolveErr != nil { + return fmt.Errorf("resolve assignee: %w", resolveErr) + } + body["assignee_type"] = aType + body["assignee_id"] = aID + } + + var result map[string]any + if err := client.PutJSON(ctx, "/api/issues/"+args[0], body, &result); err != nil { + return fmt.Errorf("assign issue: %w", err) + } + + if unassign { + fmt.Fprintf(os.Stderr, "Issue %s unassigned.\n", truncateID(args[0])) + } else { + fmt.Fprintf(os.Stderr, "Issue %s assigned to %s.\n", truncateID(args[0]), toName) + } + + output, _ := cmd.Flags().GetString("output") + if output == "table" { + return nil + } + return cli.PrintJSON(os.Stdout, result) +} + +func runIssueStatus(cmd *cobra.Command, args []string) error { + id := args[0] + status := args[1] + + valid := false + for _, s := range validIssueStatuses { + if s == status { + valid = true + break + } + } + if !valid { + return fmt.Errorf("invalid status %q; valid values: %s", status, strings.Join(validIssueStatuses, ", ")) + } + + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + body := map[string]any{"status": status} + var result map[string]any + if err := client.PutJSON(ctx, "/api/issues/"+id, body, &result); err != nil { + return fmt.Errorf("update status: %w", err) + } + + fmt.Fprintf(os.Stderr, "Issue %s status changed to %s.\n", truncateID(id), status) + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, result) + } + return nil +} + +// --------------------------------------------------------------------------- +// Comment commands +// --------------------------------------------------------------------------- + +func runIssueCommentList(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var comments []map[string]any + if err := client.GetJSON(ctx, "/api/issues/"+args[0]+"/comments", &comments); err != nil { + return fmt.Errorf("list comments: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, comments) + } + + headers := []string{"ID", "AUTHOR", "TYPE", "CONTENT", "CREATED"} + rows := make([][]string, 0, len(comments)) + for _, c := range comments { + content := strVal(c, "content") + if utf8.RuneCountInString(content) > 80 { + runes := []rune(content) + content = string(runes[:77]) + "..." + } + created := strVal(c, "created_at") + if len(created) >= 16 { + created = created[:16] + } + rows = append(rows, []string{ + truncateID(strVal(c, "id")), + strVal(c, "author_type") + ":" + truncateID(strVal(c, "author_id")), + strVal(c, "type"), + content, + created, + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +func runIssueCommentAdd(cmd *cobra.Command, args []string) error { + content, _ := cmd.Flags().GetString("content") + if content == "" { + return fmt.Errorf("--content is required") + } + + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + body := map[string]any{"content": content} + var result map[string]any + if err := client.PostJSON(ctx, "/api/issues/"+args[0]+"/comments", body, &result); err != nil { + return fmt.Errorf("add comment: %w", err) + } + + fmt.Fprintf(os.Stderr, "Comment added to issue %s.\n", truncateID(args[0])) + + output, _ := cmd.Flags().GetString("output") + if output == "table" { + return nil + } + return cli.PrintJSON(os.Stdout, result) +} + +func runIssueCommentDelete(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if err := client.DeleteJSON(ctx, "/api/comments/"+args[0]); err != nil { + return fmt.Errorf("delete comment: %w", err) + } + + fmt.Fprintf(os.Stderr, "Comment %s deleted.\n", truncateID(args[0])) + return nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type assigneeMatch struct { + Type string // "member" or "agent" + ID string // user_id for members, agent id for agents + Name string +} + +func resolveAssignee(ctx context.Context, client *cli.APIClient, name string) (string, string, error) { + if client.WorkspaceID == "" { + return "", "", fmt.Errorf("workspace ID is required to resolve assignees; use --workspace-id or set MULTICA_WORKSPACE_ID") + } + + nameLower := strings.ToLower(name) + var matches []assigneeMatch + var errs []error + + // Search members. + var members []map[string]any + if err := client.GetJSON(ctx, "/api/workspaces/"+client.WorkspaceID+"/members", &members); err != nil { + errs = append(errs, fmt.Errorf("fetch members: %w", err)) + } else { + for _, m := range members { + mName := strVal(m, "name") + if strings.Contains(strings.ToLower(mName), nameLower) { + matches = append(matches, assigneeMatch{ + Type: "member", + ID: strVal(m, "user_id"), + Name: mName, + }) + } + } + } + + // Search agents. + var agents []map[string]any + agentPath := "/api/agents?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode() + if err := client.GetJSON(ctx, agentPath, &agents); err != nil { + errs = append(errs, fmt.Errorf("fetch agents: %w", err)) + } else { + for _, a := range agents { + aName := strVal(a, "name") + if strings.Contains(strings.ToLower(aName), nameLower) { + matches = append(matches, assigneeMatch{ + Type: "agent", + ID: strVal(a, "id"), + Name: aName, + }) + } + } + } + + // If both fetches failed, report the errors instead of a misleading "not found". + if len(errs) == 2 { + return "", "", fmt.Errorf("failed to resolve assignee: %v; %v", errs[0], errs[1]) + } + + switch len(matches) { + case 0: + return "", "", fmt.Errorf("no member or agent found matching %q", name) + case 1: + return matches[0].Type, matches[0].ID, nil + default: + var parts []string + for _, m := range matches { + parts = append(parts, fmt.Sprintf(" %s %q (%s)", m.Type, m.Name, truncateID(m.ID))) + } + return "", "", fmt.Errorf("ambiguous assignee %q; matches:\n%s", name, strings.Join(parts, "\n")) + } +} + +func formatAssignee(issue map[string]any) string { + aType := strVal(issue, "assignee_type") + aID := strVal(issue, "assignee_id") + if aType == "" || aID == "" { + return "" + } + return aType + ":" + truncateID(aID) +} + +func truncateID(id string) string { + if utf8.RuneCountInString(id) > 8 { + runes := []rune(id) + return string(runes[:8]) + } + return id +} diff --git a/server/cmd/multica/cmd_issue_test.go b/server/cmd/multica/cmd_issue_test.go new file mode 100644 index 00000000..be8f55d9 --- /dev/null +++ b/server/cmd/multica/cmd_issue_test.go @@ -0,0 +1,170 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/multica-ai/multica/server/internal/cli" +) + +func TestTruncateID(t *testing.T) { + tests := []struct { + name string + id string + want string + }{ + {"short", "abc", "abc"}, + {"exact 8", "abcdefgh", "abcdefgh"}, + {"longer than 8", "abcdefgh-1234-5678", "abcdefgh"}, + {"empty", "", ""}, + {"unicode", "日本語テスト文字列追加", "日本語テスト文字"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateID(tt.id) + if got != tt.want { + t.Errorf("truncateID(%q) = %q, want %q", tt.id, got, tt.want) + } + }) + } +} + +func TestFormatAssignee(t *testing.T) { + tests := []struct { + name string + issue map[string]any + want string + }{ + {"empty", map[string]any{}, ""}, + {"no type", map[string]any{"assignee_id": "abc"}, ""}, + {"no id", map[string]any{"assignee_type": "member"}, ""}, + {"member", map[string]any{"assignee_type": "member", "assignee_id": "abcdefgh-1234"}, "member:abcdefgh"}, + {"agent", map[string]any{"assignee_type": "agent", "assignee_id": "xyz"}, "agent:xyz"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatAssignee(tt.issue) + if got != tt.want { + t.Errorf("formatAssignee() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestResolveAssignee(t *testing.T) { + membersResp := []map[string]any{ + {"user_id": "user-1111", "name": "Alice Smith"}, + {"user_id": "user-2222", "name": "Bob Jones"}, + } + agentsResp := []map[string]any{ + {"id": "agent-3333", "name": "CodeBot"}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/workspaces/ws-1/members": + json.NewEncoder(w).Encode(membersResp) + case "/api/agents": + json.NewEncoder(w).Encode(agentsResp) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := cli.NewAPIClient(srv.URL, "ws-1", "test-token") + ctx := context.Background() + + t.Run("exact match member", func(t *testing.T) { + aType, aID, err := resolveAssignee(ctx, client, "Alice Smith") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if aType != "member" || aID != "user-1111" { + t.Errorf("got (%q, %q), want (member, user-1111)", aType, aID) + } + }) + + t.Run("case-insensitive substring", func(t *testing.T) { + aType, aID, err := resolveAssignee(ctx, client, "bob") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if aType != "member" || aID != "user-2222" { + t.Errorf("got (%q, %q), want (member, user-2222)", aType, aID) + } + }) + + t.Run("match agent", func(t *testing.T) { + aType, aID, err := resolveAssignee(ctx, client, "codebot") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if aType != "agent" || aID != "agent-3333" { + t.Errorf("got (%q, %q), want (agent, agent-3333)", aType, aID) + } + }) + + t.Run("no match", func(t *testing.T) { + _, _, err := resolveAssignee(ctx, client, "nobody") + if err == nil { + t.Fatal("expected error for no match") + } + }) + + t.Run("ambiguous", func(t *testing.T) { + // Both "Alice Smith" and "Bob Jones" contain a space — but let's use a broader query + // "e" matches "Alice Smith" and "Bob Jones" and "CodeBot" + _, _, err := resolveAssignee(ctx, client, "o") + if err == nil { + t.Fatal("expected error for ambiguous match") + } + if got := err.Error(); !contains(got, "ambiguous") { + t.Errorf("expected ambiguous error, got: %s", got) + } + }) + + t.Run("missing workspace ID", func(t *testing.T) { + noWSClient := cli.NewAPIClient(srv.URL, "", "test-token") + _, _, err := resolveAssignee(ctx, noWSClient, "alice") + if err == nil { + t.Fatal("expected error for missing workspace ID") + } + }) +} + +func TestValidIssueStatuses(t *testing.T) { + expected := map[string]bool{ + "backlog": true, + "todo": true, + "in_progress": true, + "in_review": true, + "done": true, + "blocked": true, + "cancelled": true, + } + for _, s := range validIssueStatuses { + if !expected[s] { + t.Errorf("unexpected status in validIssueStatuses: %q", s) + } + } + if len(validIssueStatuses) != len(expected) { + t.Errorf("validIssueStatuses has %d entries, expected %d", len(validIssueStatuses), len(expected)) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/server/cmd/multica/cmd_workspace.go b/server/cmd/multica/cmd_workspace.go index 9e74d61b..643a9c5c 100644 --- a/server/cmd/multica/cmd_workspace.go +++ b/server/cmd/multica/cmd_workspace.go @@ -6,6 +6,7 @@ import ( "os" "text/tabwriter" "time" + "unicode/utf8" "github.com/spf13/cobra" @@ -23,6 +24,20 @@ var workspaceListCmd = &cobra.Command{ RunE: runWorkspaceList, } +var workspaceGetCmd = &cobra.Command{ + Use: "get [workspace-id]", + Short: "Get workspace details", + Args: cobra.MaximumNArgs(1), + RunE: runWorkspaceGet, +} + +var workspaceMembersCmd = &cobra.Command{ + Use: "members [workspace-id]", + Short: "List workspace members", + Args: cobra.MaximumNArgs(1), + RunE: runWorkspaceMembers, +} + var workspaceWatchCmd = &cobra.Command{ Use: "watch ", Short: "Add a workspace to the daemon watch list", @@ -39,8 +54,13 @@ var workspaceUnwatchCmd = &cobra.Command{ func init() { workspaceCmd.AddCommand(workspaceListCmd) + workspaceCmd.AddCommand(workspaceGetCmd) + workspaceCmd.AddCommand(workspaceMembersCmd) workspaceCmd.AddCommand(workspaceWatchCmd) workspaceCmd.AddCommand(workspaceUnwatchCmd) + + workspaceGetCmd.Flags().String("output", "json", "Output format: table or json") + workspaceMembersCmd.Flags().String("output", "table", "Output format: table or json") } func runWorkspaceList(cmd *cobra.Command, _ []string) error { @@ -86,6 +106,97 @@ func runWorkspaceList(cmd *cobra.Command, _ []string) error { return w.Flush() } +func workspaceIDFromArgs(cmd *cobra.Command, args []string) string { + if len(args) > 0 { + return args[0] + } + return resolveWorkspaceID(cmd) +} + +func runWorkspaceGet(cmd *cobra.Command, args []string) error { + wsID := workspaceIDFromArgs(cmd, args) + if wsID == "" { + return fmt.Errorf("workspace ID is required: pass as argument or set MULTICA_WORKSPACE_ID") + } + + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var ws map[string]any + if err := client.GetJSON(ctx, "/api/workspaces/"+wsID, &ws); err != nil { + return fmt.Errorf("get workspace: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "table" { + desc := strVal(ws, "description") + if utf8.RuneCountInString(desc) > 60 { + runes := []rune(desc) + desc = string(runes[:57]) + "..." + } + wsContext := strVal(ws, "context") + if utf8.RuneCountInString(wsContext) > 60 { + runes := []rune(wsContext) + wsContext = string(runes[:57]) + "..." + } + headers := []string{"ID", "NAME", "SLUG", "DESCRIPTION", "CONTEXT"} + rows := [][]string{{ + strVal(ws, "id"), + strVal(ws, "name"), + strVal(ws, "slug"), + desc, + wsContext, + }} + cli.PrintTable(os.Stdout, headers, rows) + return nil + } + + return cli.PrintJSON(os.Stdout, ws) +} + +func runWorkspaceMembers(cmd *cobra.Command, args []string) error { + wsID := workspaceIDFromArgs(cmd, args) + if wsID == "" { + return fmt.Errorf("workspace ID is required: pass as argument or set MULTICA_WORKSPACE_ID") + } + + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var members []map[string]any + if err := client.GetJSON(ctx, "/api/workspaces/"+wsID+"/members", &members); err != nil { + return fmt.Errorf("list members: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, members) + } + + headers := []string{"USER ID", "NAME", "EMAIL", "ROLE"} + rows := make([][]string, 0, len(members)) + for _, m := range members { + rows = append(rows, []string{ + strVal(m, "user_id"), + strVal(m, "name"), + strVal(m, "email"), + strVal(m, "role"), + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + func runWatch(cmd *cobra.Command, args []string) error { workspaceID := args[0] diff --git a/server/cmd/multica/main.go b/server/cmd/multica/main.go index 50699489..2a2da222 100644 --- a/server/cmd/multica/main.go +++ b/server/cmd/multica/main.go @@ -30,6 +30,7 @@ func init() { rootCmd.AddCommand(runtimeCmd) rootCmd.AddCommand(workspaceCmd) rootCmd.AddCommand(configCmd) + rootCmd.AddCommand(issueCmd) rootCmd.AddCommand(statusCmd) rootCmd.AddCommand(versionCmd) } diff --git a/server/internal/daemon/client.go b/server/internal/daemon/client.go index ccf50f81..a62b65b2 100644 --- a/server/internal/daemon/client.go +++ b/server/internal/daemon/client.go @@ -56,6 +56,11 @@ func (c *Client) SetToken(token string) { c.token = token } +// Token returns the current auth token. +func (c *Client) Token() string { + return c.token +} + func (c *Client) ClaimTask(ctx context.Context, runtimeID string) (*Task, error) { var resp struct { Task *Task `json:"task"` diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index dbd2325e..e3130bde 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -482,7 +482,7 @@ func (d *Daemon) pollLoop(ctx context.Context) error { continue } if task != nil { - d.logger.Info("task received", "task_id", task.ID, "issue_id", task.IssueID, "title", task.Context.Issue.Title) + d.logger.Info("task received", "task_id", task.ID, "issue_id", task.IssueID) d.handleTask(ctx, *task) claimed = true pollOffset = (pollOffset + i + 1) % n @@ -506,8 +506,11 @@ func (d *Daemon) pollLoop(ctx context.Context) error { } func (d *Daemon) handleTask(ctx context.Context, task Task) { - provider := task.Context.Runtime.Provider - d.logger.Info("picked task", "task_id", task.ID, "issue_id", task.IssueID, "provider", provider, "title", task.Context.Issue.Title) + d.mu.Lock() + rt := d.runtimeIndex[task.RuntimeID] + d.mu.Unlock() + provider := rt.Provider + d.logger.Info("picked task", "task_id", task.ID, "issue_id", task.IssueID, "provider", provider) if err := d.client.StartTask(ctx, task.ID); err != nil { d.logger.Error("start task failed", "task_id", task.ID, "error", err) @@ -516,7 +519,7 @@ func (d *Daemon) handleTask(ctx context.Context, task Task) { _ = d.client.ReportProgress(ctx, task.ID, fmt.Sprintf("Launching %s", provider), 1, 2) - result, err := d.runTask(ctx, task) + result, err := d.runTask(ctx, task, provider) if err != nil { d.logger.Error("task failed", "task_id", task.ID, "error", err) if failErr := d.client.FailTask(ctx, task.ID, err.Error()); failErr != nil { @@ -540,26 +543,29 @@ func (d *Daemon) handleTask(ctx context.Context, task Task) { } } -func (d *Daemon) runTask(ctx context.Context, task Task) (TaskResult, error) { - provider := task.Context.Runtime.Provider +func (d *Daemon) runTask(ctx context.Context, task Task, provider string) (TaskResult, error) { entry, ok := d.cfg.Agents[provider] if !ok { return TaskResult{}, fmt.Errorf("no agent configured for provider %q", provider) } + agentName := "agent" + var skills []SkillData + if task.Agent != nil { + agentName = task.Agent.Name + skills = task.Agent.Skills + } + // Prepare isolated execution environment. taskCtx := execenv.TaskContextForEnv{ - IssueTitle: task.Context.Issue.Title, - IssueDescription: task.Context.Issue.Description, - WorkspaceContext: task.Context.WorkspaceContext, - AgentName: task.Context.Agent.Name, - AgentSkills: convertSkillsForEnv(task.Context.Agent.Skills), + IssueID: task.IssueID, + AgentName: agentName, + AgentSkills: convertSkillsForEnv(skills), } env, err := execenv.Prepare(execenv.PrepareParams{ WorkspacesRoot: d.cfg.WorkspacesRoot, - RepoPath: task.Context.RepoPath, TaskID: task.ID, - AgentName: task.Context.Agent.Name, + AgentName: agentName, Task: taskCtx, }, d.logger) if err != nil { diff --git a/server/internal/daemon/daemon_test.go b/server/internal/daemon/daemon_test.go index 0249a19d..5783c58a 100644 --- a/server/internal/daemon/daemon_test.go +++ b/server/internal/daemon/daemon_test.go @@ -18,28 +18,25 @@ func TestNormalizeServerBaseURL(t *testing.T) { } } -func TestBuildPromptIncludesIssueAndContext(t *testing.T) { +func TestBuildPromptContainsIssueID(t *testing.T) { t.Parallel() + issueID := "a1b2c3d4-e5f6-7890-abcd-ef1234567890" prompt := BuildPrompt(Task{ - Context: TaskContext{ - Issue: IssueContext{ - Title: "Fix failing test", - Description: "Investigate and fix the test failure.", - }, - Agent: AgentContext{ - Name: "Local Codex", - Skills: []SkillData{ - {Name: "Concise", Content: "Be concise."}, - }, + IssueID: issueID, + Agent: &AgentData{ + Name: "Local Codex", + Skills: []SkillData{ + {Name: "Concise", Content: "Be concise."}, }, }, }) - // Lean prompt: issue title + description only. No inlined skill content. + // Prompt should contain the issue ID and CLI instructions. for _, want := range []string{ - "Fix failing test", - "Investigate and fix the test failure.", + issueID, + "multica issue get", + "multica issue comment list", } { if !strings.Contains(prompt, want) { t.Fatalf("prompt missing %q", want) @@ -54,25 +51,19 @@ func TestBuildPromptIncludesIssueAndContext(t *testing.T) { } } -func TestBuildPromptTruncatesLongDescription(t *testing.T) { +func TestBuildPromptNoIssueDetails(t *testing.T) { t.Parallel() - longDesc := strings.Repeat("x", 300) prompt := BuildPrompt(Task{ - Context: TaskContext{ - Issue: IssueContext{ - Title: "Long desc", - Description: longDesc, - }, - Agent: AgentContext{Name: "Test"}, - }, + IssueID: "test-id", + Agent: &AgentData{Name: "Test"}, }) - if strings.Contains(prompt, longDesc) { - t.Fatal("expected long description to be truncated in prompt") - } - if !strings.Contains(prompt, "...") { - t.Fatal("expected truncation marker") + // Prompt should not contain issue title/description (agent fetches via CLI). + for _, absent := range []string{"**Issue:**", "**Summary:**"} { + if strings.Contains(prompt, absent) { + t.Fatalf("prompt should NOT contain %q — agent fetches details via CLI", absent) + } } } diff --git a/server/internal/daemon/execenv/context.go b/server/internal/daemon/execenv/context.go index e3f1f2a8..35e59669 100644 --- a/server/internal/daemon/execenv/context.go +++ b/server/internal/daemon/execenv/context.go @@ -77,25 +77,16 @@ func writeSkillFiles(contextDir string, skills []SkillContextForEnv) error { } // renderIssueContext builds the markdown content for issue_context.md. -// Sections with empty content are omitted. +// It contains only the issue ID and pointers to CLI commands for fetching +// dynamic data. Sections with empty content are omitted. func renderIssueContext(ctx TaskContextForEnv) string { var b strings.Builder - if ctx.IssueTitle != "" { - fmt.Fprintf(&b, "# Issue: %s\n\n", ctx.IssueTitle) - } + b.WriteString("# Task Assignment\n\n") + fmt.Fprintf(&b, "**Issue ID:** %s\n\n", ctx.IssueID) - if ctx.IssueDescription != "" { - b.WriteString("## Description\n\n") - b.WriteString(ctx.IssueDescription) - b.WriteString("\n\n") - } - - if ctx.WorkspaceContext != "" { - b.WriteString("## Workspace Context\n\n") - b.WriteString(ctx.WorkspaceContext) - b.WriteString("\n\n") - } + b.WriteString("Run `multica issue get " + ctx.IssueID + " --output json` for full issue details and description.\n") + b.WriteString("Run `multica issue comment list " + ctx.IssueID + "` for discussion history.\n\n") if len(ctx.AgentSkills) > 0 { b.WriteString("## Agent Skills\n\n") diff --git a/server/internal/daemon/execenv/execenv.go b/server/internal/daemon/execenv/execenv.go index ea8536ef..61e9d941 100644 --- a/server/internal/daemon/execenv/execenv.go +++ b/server/internal/daemon/execenv/execenv.go @@ -29,11 +29,9 @@ type PrepareParams struct { // TaskContextForEnv is the subset of task context used for writing context files. type TaskContextForEnv struct { - IssueTitle string - IssueDescription string - WorkspaceContext string - AgentName string - AgentSkills []SkillContextForEnv + IssueID string + AgentName string + AgentSkills []SkillContextForEnv } // SkillContextForEnv represents a skill to be written into the execution environment. diff --git a/server/internal/daemon/execenv/execenv_test.go b/server/internal/daemon/execenv/execenv_test.go index 5a3f9ce0..f1a19815 100644 --- a/server/internal/daemon/execenv/execenv_test.go +++ b/server/internal/daemon/execenv/execenv_test.go @@ -100,8 +100,7 @@ func TestPrepareDirectoryMode(t *testing.T) { TaskID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", AgentName: "Test Agent", Task: TaskContextForEnv{ - IssueTitle: "Fix the bug", - IssueDescription: "There is a bug in the login flow.", + IssueID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", AgentSkills: []SkillContextForEnv{ {Name: "Code Review", Content: "Be concise."}, }, @@ -127,12 +126,12 @@ func TestPrepareDirectoryMode(t *testing.T) { } } - // Verify context file. + // Verify context file contains issue ID and CLI hints. content, err := os.ReadFile(filepath.Join(env.WorkDir, ".agent_context", "issue_context.md")) if err != nil { t.Fatalf("failed to read issue_context.md: %v", err) } - for _, want := range []string{"Fix the bug", "login flow", "Code Review"} { + for _, want := range []string{"a1b2c3d4-e5f6-7890-abcd-ef1234567890", "multica issue get", "Code Review"} { if !strings.Contains(string(content), want) { t.Fatalf("issue_context.md missing %q", want) } @@ -176,8 +175,7 @@ func TestPrepareGitWorktreeMode(t *testing.T) { TaskID: "b2c3d4e5-f6a7-8901-bcde-f12345678901", AgentName: "Code Reviewer", Task: TaskContextForEnv{ - IssueTitle: "Add feature", - IssueDescription: "Add a new feature.", + IssueID: "b2c3d4e5-f6a7-8901-bcde-f12345678901", }, }, testLogger()) if err != nil { @@ -216,9 +214,7 @@ func TestWriteContextFiles(t *testing.T) { dir := t.TempDir() ctx := TaskContextForEnv{ - IssueTitle: "Test Issue", - IssueDescription: "A detailed description.", - WorkspaceContext: "We use Go and TypeScript.", + IssueID: "test-issue-id-1234", AgentSkills: []SkillContextForEnv{ { Name: "Go Conventions", @@ -241,11 +237,8 @@ func TestWriteContextFiles(t *testing.T) { s := string(content) for _, want := range []string{ - "# Issue: Test Issue", - "## Description", - "A detailed description.", - "## Workspace Context", - "Go and TypeScript", + "test-issue-id-1234", + "multica issue get", "## Agent Skills", "Go Conventions", } { @@ -254,6 +247,13 @@ func TestWriteContextFiles(t *testing.T) { } } + // Issue details should NOT be in the context file (agent fetches via CLI). + for _, absent := range []string{"## Description", "## Workspace Context"} { + if strings.Contains(s, absent) { + t.Errorf("content should NOT contain %q — agent fetches details via CLI", absent) + } + } + // Verify skill directory and files. skillMd, err := os.ReadFile(filepath.Join(dir, ".agent_context", "skills", "go-conventions", "SKILL.md")) if err != nil { @@ -272,12 +272,12 @@ func TestWriteContextFiles(t *testing.T) { } } -func TestWriteContextFilesOmitsEmpty(t *testing.T) { +func TestWriteContextFilesOmitsSkillsWhenEmpty(t *testing.T) { t.Parallel() dir := t.TempDir() ctx := TaskContextForEnv{ - IssueTitle: "Minimal Issue", + IssueID: "minimal-issue-id", } if err := writeContextFiles(dir, ctx); err != nil { @@ -290,13 +290,11 @@ func TestWriteContextFilesOmitsEmpty(t *testing.T) { } s := string(content) - if !strings.Contains(s, "Minimal Issue") { - t.Error("expected title to be present") + if !strings.Contains(s, "minimal-issue-id") { + t.Error("expected issue ID to be present") } - for _, absent := range []string{"## Description", "## Workspace Context", "## Agent Skills"} { - if strings.Contains(s, absent) { - t.Errorf("expected %q to be omitted for empty content", absent) - } + if strings.Contains(s, "## Agent Skills") { + t.Error("expected skills section to be omitted when no skills") } } @@ -326,7 +324,7 @@ func TestCleanupGitWorktree(t *testing.T) { RepoPath: reposRoot, TaskID: "c3d4e5f6-a7b8-9012-cdef-123456789012", AgentName: "Cleanup Test", - Task: TaskContextForEnv{IssueTitle: "Cleanup test"}, + Task: TaskContextForEnv{IssueID: "cleanup-test-id"}, }, testLogger()) if err != nil { t.Fatalf("Prepare failed: %v", err) @@ -358,7 +356,7 @@ func TestInjectRuntimeConfigClaude(t *testing.T) { dir := t.TempDir() ctx := TaskContextForEnv{ - IssueTitle: "Test Issue", + IssueID: "test-issue-id", AgentSkills: []SkillContextForEnv{ {Name: "Go Conventions", Content: "Follow Go conventions.", Files: []SkillFileContextForEnv{ {Path: "example.go", Content: "package main"}, @@ -379,8 +377,8 @@ func TestInjectRuntimeConfigClaude(t *testing.T) { s := string(content) for _, want := range []string{ "Multica Agent Runtime", - ".agent_context/issue_context.md", - ".agent_context/skills/", + "multica issue get", + "multica issue comment list", "Go Conventions", "PR Review", "go-conventions/SKILL.md", @@ -398,7 +396,7 @@ func TestInjectRuntimeConfigCodex(t *testing.T) { dir := t.TempDir() ctx := TaskContextForEnv{ - IssueTitle: "Test Issue", + IssueID: "test-issue-id", AgentSkills: []SkillContextForEnv{{Name: "Coding", Content: "Write good code."}}, } @@ -424,7 +422,7 @@ func TestInjectRuntimeConfigNoSkills(t *testing.T) { t.Parallel() dir := t.TempDir() - ctx := TaskContextForEnv{IssueTitle: "Test Issue"} + ctx := TaskContextForEnv{IssueID: "test-issue-id"} if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil { t.Fatalf("InjectRuntimeConfig failed: %v", err) @@ -436,8 +434,8 @@ func TestInjectRuntimeConfigNoSkills(t *testing.T) { } s := string(content) - if !strings.Contains(s, "issue_context.md") { - t.Error("should reference issue_context.md even without skills") + if !strings.Contains(s, "multica issue get") { + t.Error("should reference multica CLI even without skills") } if strings.Contains(s, "## Skills") { t.Error("should not have Skills section when there are no skills") @@ -469,7 +467,7 @@ func TestCleanupPreservesLogs(t *testing.T) { RepoPath: t.TempDir(), // not a git repo TaskID: "d4e5f6a7-b8c9-0123-defa-234567890123", AgentName: "Preserve Test", - Task: TaskContextForEnv{IssueTitle: "Preserve test"}, + Task: TaskContextForEnv{IssueID: "preserve-test-id"}, }, testLogger()) if err != nil { t.Fatalf("Prepare failed: %v", err) diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index ef942e4e..9362b413 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -31,26 +31,35 @@ func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error } // buildMetaSkillContent generates the meta skill markdown that teaches the agent -// about the Multica runtime environment and where to find task context/skills. +// about the Multica runtime environment and available CLI tools. func buildMetaSkillContent(ctx TaskContextForEnv) string { var b strings.Builder b.WriteString("# Multica Agent Runtime\n\n") - b.WriteString("You are running as a coding agent in the Multica platform.\n") - b.WriteString("Your task context and skill instructions are in the `.agent_context/` directory.\n\n") + b.WriteString("You are a coding agent in the Multica platform. Use the `multica` CLI to interact with the platform.\n\n") - b.WriteString("## Getting Started\n\n") - b.WriteString("1. Read `.agent_context/issue_context.md` for the full issue description, acceptance criteria, and context.\n") + b.WriteString("## Available Commands\n\n") + b.WriteString("### Read\n") + b.WriteString("- `multica issue get ` — Get full issue details (title, description, status, priority, assignee)\n") + b.WriteString("- `multica issue list [--status X] [--priority X] [--assignee X]` — List issues in workspace\n") + b.WriteString("- `multica issue comment list ` — List all comments on an issue\n") + b.WriteString("- `multica workspace get` — Get workspace details and context\n") + b.WriteString("- `multica agent list` — List agents in workspace\n\n") - if len(ctx.AgentSkills) > 0 { - b.WriteString("2. Read your skill files in `.agent_context/skills/` for detailed instructions on how to work.\n") - } + b.WriteString("### Write\n") + b.WriteString("- `multica issue comment add --content \"...\"` — Post a comment to an issue\n") + b.WriteString("- `multica issue status ` — Update issue status (todo, in_progress, in_review, done, blocked)\n") + b.WriteString("- `multica issue update [--title X] [--description X] [--priority X]` — Update issue fields\n\n") - b.WriteString("\n") + b.WriteString("### Workflow\n") + fmt.Fprintf(&b, "1. Run `multica issue get %s --output json` to understand your task\n", ctx.IssueID) + b.WriteString("2. Read comments for additional context or human instructions\n") + b.WriteString("3. Complete the work in the local codebase\n") + b.WriteString("4. Post a comment summarizing what you did\n\n") if len(ctx.AgentSkills) > 0 { b.WriteString("## Skills\n\n") - b.WriteString("Each skill directory contains a `SKILL.md` with instructions and optionally supporting files.\n\n") + b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n") for _, skill := range ctx.AgentSkills { dirName := sanitizeSkillName(skill.Name) fmt.Fprintf(&b, "- **%s** → `.agent_context/skills/%s/SKILL.md`", skill.Name, dirName) @@ -63,7 +72,7 @@ func buildMetaSkillContent(ctx TaskContextForEnv) string { } b.WriteString("## Output\n\n") - b.WriteString("When done, return a concise Markdown comment suitable for posting back to the issue.\n") + b.WriteString("When done, return a concise Markdown summary of your work.\n") b.WriteString("- Lead with the outcome.\n") b.WriteString("- Mention concrete files or commands if you changed anything.\n") b.WriteString("- If blocked, explain the blocker clearly.\n") diff --git a/server/internal/daemon/prompt.go b/server/internal/daemon/prompt.go index c151e6d7..47cb33ab 100644 --- a/server/internal/daemon/prompt.go +++ b/server/internal/daemon/prompt.go @@ -6,24 +6,21 @@ import ( ) // BuildPrompt constructs the task prompt for an agent CLI. -// This is kept lean — only the issue summary and acceptance criteria. -// Detailed skill instructions are injected via the runtime's native config -// mechanism (e.g., .claude/CLAUDE.md, AGENTS.md) by execenv.InjectRuntimeConfig. +// The prompt is intentionally minimal — it provides only the issue ID and +// instructs the agent to use the multica CLI to fetch details on demand. +// Skill instructions are injected via the runtime's native config mechanism +// (e.g., .claude/CLAUDE.md, AGENTS.md) by execenv.InjectRuntimeConfig. func BuildPrompt(task Task) string { var b strings.Builder - b.WriteString("You are running as a local coding agent for a Multica workspace.\n") - b.WriteString("Complete the assigned issue using the local environment.\n\n") + b.WriteString("You are running as a local coding agent for a Multica workspace.\n\n") - fmt.Fprintf(&b, "**Issue:** %s\n", task.Context.Issue.Title) - fmt.Fprintf(&b, "**Agent:** %s\n\n", task.Context.Agent.Name) + fmt.Fprintf(&b, "Your assigned issue ID is: %s\n\n", task.IssueID) - if task.Context.Issue.Description != "" { - desc := task.Context.Issue.Description - if len(desc) > 200 { - desc = desc[:200] + "..." - } - fmt.Fprintf(&b, "**Summary:** %s\n\n", desc) - } + b.WriteString("Use the `multica` CLI to fetch the issue details and any context you need:\n\n") + fmt.Fprintf(&b, " multica issue get %s --output json # Full issue details\n", task.IssueID) + fmt.Fprintf(&b, " multica issue comment list %s # Comments and discussion\n\n", task.IssueID) + + fmt.Fprintf(&b, "Start by running `multica issue get %s --output json` to understand your task, then complete it.\n", task.IssueID) return b.String() } diff --git a/server/internal/daemon/types.go b/server/internal/daemon/types.go index ae6fedf8..e8430846 100644 --- a/server/internal/daemon/types.go +++ b/server/internal/daemon/types.go @@ -15,37 +15,23 @@ type Runtime struct { } // Task represents a claimed task from the server. +// Agent data (name, skills) is populated by the claim endpoint. type Task struct { - ID string `json:"id"` - AgentID string `json:"agent_id"` - IssueID string `json:"issue_id"` - Context TaskContext `json:"context"` + ID string `json:"id"` + AgentID string `json:"agent_id"` + RuntimeID string `json:"runtime_id"` + IssueID string `json:"issue_id"` + Agent *AgentData `json:"agent,omitempty"` } -// TaskContext contains the snapshot context for a task. -type TaskContext struct { - Issue IssueContext `json:"issue"` - Agent AgentContext `json:"agent"` - Runtime RuntimeContext `json:"runtime"` - WorkspaceContext string `json:"workspace_context,omitempty"` - RepoPath string `json:"repo_path,omitempty"` -} - -// IssueContext holds issue details for task execution. -type IssueContext struct { - ID string `json:"id"` - Title string `json:"title"` - Description string `json:"description"` -} - -// AgentContext holds agent details for task execution. -type AgentContext struct { +// AgentData holds agent details returned by the claim endpoint. +type AgentData struct { ID string `json:"id"` Name string `json:"name"` Skills []SkillData `json:"skills"` } -// SkillData represents a structured skill in the task context. +// SkillData represents a structured skill for task execution. type SkillData struct { Name string `json:"name"` Content string `json:"content"` @@ -58,14 +44,6 @@ type SkillFileData struct { Content string `json:"content"` } -// RuntimeContext holds runtime details for task execution. -type RuntimeContext struct { - ID string `json:"id"` - Name string `json:"name"` - Provider string `json:"provider"` - DeviceInfo string `json:"device_info"` -} - // TaskResult is the outcome of executing a task. type TaskResult struct { Status string `json:"status"` diff --git a/server/internal/handler/agent.go b/server/internal/handler/agent.go index c3ea1357..1a0373ac 100644 --- a/server/internal/handler/agent.go +++ b/server/internal/handler/agent.go @@ -10,6 +10,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/logger" + "github.com/multica-ai/multica/server/internal/service" db "github.com/multica-ai/multica/server/pkg/db/generated" "github.com/multica-ai/multica/server/pkg/protocol" ) @@ -81,19 +82,27 @@ func agentToResponse(a db.Agent) AgentResponse { } type AgentTaskResponse struct { - ID string `json:"id"` - AgentID string `json:"agent_id"` - RuntimeID string `json:"runtime_id"` - IssueID string `json:"issue_id"` - Status string `json:"status"` - Priority int32 `json:"priority"` - DispatchedAt *string `json:"dispatched_at"` - StartedAt *string `json:"started_at"` - CompletedAt *string `json:"completed_at"` - Result any `json:"result"` - Error *string `json:"error"` - Context any `json:"context,omitempty"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + AgentID string `json:"agent_id"` + RuntimeID string `json:"runtime_id"` + IssueID string `json:"issue_id"` + Status string `json:"status"` + Priority int32 `json:"priority"` + DispatchedAt *string `json:"dispatched_at"` + StartedAt *string `json:"started_at"` + CompletedAt *string `json:"completed_at"` + Result any `json:"result"` + Error *string `json:"error"` + Agent *TaskAgentData `json:"agent,omitempty"` + CreatedAt string `json:"created_at"` +} + +// TaskAgentData holds agent info included in claim responses so the daemon +// can set up the execution environment (branch naming, skill files). +type TaskAgentData struct { + ID string `json:"id"` + Name string `json:"name"` + Skills []service.AgentSkillData `json:"skills,omitempty"` } func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse { @@ -101,10 +110,6 @@ func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse { if t.Result != nil { json.Unmarshal(t.Result, &result) } - var ctx any - if t.Context != nil { - json.Unmarshal(t.Context, &ctx) - } return AgentTaskResponse{ ID: uuidToString(t.ID), AgentID: uuidToString(t.AgentID), @@ -117,7 +122,6 @@ func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse { CompletedAt: timestampToPtr(t.CompletedAt), Result: result, Error: textToPtr(t.Error), - Context: ctx, CreatedAt: timestampToString(t.CreatedAt), } } diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index 34c64fe6..fbe8b8f4 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -144,6 +144,7 @@ func (h *Handler) DaemonHeartbeat(w http.ResponseWriter, r *http.Request) { } // ClaimTaskByRuntime atomically claims the next queued task for a runtime. +// The response includes the agent's name and skills, fetched fresh from the DB. func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) { runtimeID := chi.URLParam(r, "runtimeId") @@ -159,8 +160,19 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) { return } + // Build response with fresh agent data (name + skills). + resp := taskToResponse(*task) + if agent, err := h.Queries.GetAgent(r.Context(), task.AgentID); err == nil { + skills := h.TaskService.LoadAgentSkills(r.Context(), task.AgentID) + resp.Agent = &TaskAgentData{ + ID: uuidToString(agent.ID), + Name: agent.Name, + Skills: skills, + } + } + slog.Info("task claimed by runtime", "task_id", uuidToString(task.ID), "runtime_id", runtimeID, "agent_id", uuidToString(task.AgentID)) - writeJSON(w, http.StatusOK, map[string]any{"task": taskToResponse(*task)}) + writeJSON(w, http.StatusOK, map[string]any{"task": resp}) } // ListPendingTasksByRuntime returns queued/dispatched tasks for a runtime. diff --git a/server/internal/handler/workspace.go b/server/internal/handler/workspace.go index fc09ef3b..10b2136d 100644 --- a/server/internal/handler/workspace.go +++ b/server/internal/handler/workspace.go @@ -20,6 +20,7 @@ type WorkspaceResponse struct { Description *string `json:"description"` Context *string `json:"context"` Settings any `json:"settings"` + Repos any `json:"repos"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } @@ -32,6 +33,13 @@ func workspaceToResponse(w db.Workspace) WorkspaceResponse { if settings == nil { settings = map[string]any{} } + var repos any + if w.Repos != nil { + json.Unmarshal(w.Repos, &repos) + } + if repos == nil { + repos = []any{} + } return WorkspaceResponse{ ID: uuidToString(w.ID), Name: w.Name, @@ -39,6 +47,7 @@ func workspaceToResponse(w db.Workspace) WorkspaceResponse { Description: textToPtr(w.Description), Context: textToPtr(w.Context), Settings: settings, + Repos: repos, CreatedAt: timestampToString(w.CreatedAt), UpdatedAt: timestampToString(w.UpdatedAt), } @@ -169,6 +178,7 @@ type UpdateWorkspaceRequest struct { Description *string `json:"description"` Context *string `json:"context"` Settings any `json:"settings"` + Repos any `json:"repos"` } func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) { @@ -204,6 +214,10 @@ func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) { s, _ := json.Marshal(req.Settings) params.Settings = s } + if req.Repos != nil { + r, _ := json.Marshal(req.Repos) + params.Repos = r + } ws, err := h.Queries.UpdateWorkspace(r.Context(), params) if err != nil { diff --git a/server/internal/service/task.go b/server/internal/service/task.go index e8489057..09bbc5be 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -26,7 +26,9 @@ func NewTaskService(q *db.Queries, hub *realtime.Hub, bus *events.Bus) *TaskServ return &TaskService{Queries: q, Hub: hub, Bus: bus} } -// EnqueueTaskForIssue creates a task with a context snapshot of the issue. +// EnqueueTaskForIssue creates a queued task for an agent-assigned issue. +// No context snapshot is stored — the agent fetches all data it needs at +// runtime via the multica CLI. func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue) (db.AgentTaskQueue, error) { if !issue.AssigneeID.Valid { slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", "issue has no assignee") @@ -43,30 +45,11 @@ func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue) ( return db.AgentTaskQueue{}, fmt.Errorf("agent has no runtime") } - runtime, err := s.Queries.GetAgentRuntime(ctx, agent.RuntimeID) - if err != nil { - slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", err) - return db.AgentTaskQueue{}, fmt.Errorf("load runtime: %w", err) - } - - // Include workspace context in the snapshot when available. - var workspaceContext string - if ws, err := s.Queries.GetWorkspace(ctx, issue.WorkspaceID); err == nil && ws.Context.Valid { - workspaceContext = ws.Context.String - } - - // Load agent's structured skills + files. - agentSkills := s.loadAgentSkillsForSnapshot(ctx, agent.ID) - - snapshot := buildContextSnapshot(issue, agent, runtime, workspaceContext, agentSkills) - contextJSON, _ := json.Marshal(snapshot) - - task, err := s.Queries.CreateAgentTaskWithContext(ctx, db.CreateAgentTaskWithContextParams{ + task, err := s.Queries.CreateAgentTask(ctx, db.CreateAgentTaskParams{ AgentID: issue.AssigneeID, RuntimeID: agent.RuntimeID, IssueID: issue.ID, Priority: priorityToInt(issue.Priority), - Context: contextJSON, }) if err != nil { slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", err) @@ -292,70 +275,36 @@ func (s *TaskService) updateAgentStatus(ctx context.Context, agentID pgtype.UUID }) } -type skillSnapshot struct { - Name string `json:"name"` - Content string `json:"content"` - Files []skillFileSnapshot `json:"files,omitempty"` -} - -type skillFileSnapshot struct { - Path string `json:"path"` - Content string `json:"content"` -} - -func (s *TaskService) loadAgentSkillsForSnapshot(ctx context.Context, agentID pgtype.UUID) []skillSnapshot { +// LoadAgentSkills loads an agent's skills with their files for task execution. +func (s *TaskService) LoadAgentSkills(ctx context.Context, agentID pgtype.UUID) []AgentSkillData { skills, err := s.Queries.ListAgentSkills(ctx, agentID) if err != nil || len(skills) == 0 { return nil } - result := make([]skillSnapshot, 0, len(skills)) + result := make([]AgentSkillData, 0, len(skills)) for _, sk := range skills { - snap := skillSnapshot{Name: sk.Name, Content: sk.Content} + data := AgentSkillData{Name: sk.Name, Content: sk.Content} files, _ := s.Queries.ListSkillFiles(ctx, sk.ID) for _, f := range files { - snap.Files = append(snap.Files, skillFileSnapshot{Path: f.Path, Content: f.Content}) + data.Files = append(data.Files, AgentSkillFileData{Path: f.Path, Content: f.Content}) } - result = append(result, snap) + result = append(result, data) } return result } -func buildContextSnapshot(issue db.Issue, agent db.Agent, runtime db.AgentRuntime, workspaceContext string, skills []skillSnapshot) map[string]any { - var tools any - if agent.Tools != nil { - json.Unmarshal(agent.Tools, &tools) - } - var metadata any - if runtime.Metadata != nil { - json.Unmarshal(runtime.Metadata, &metadata) - } +// AgentSkillData represents a skill for task execution responses. +type AgentSkillData struct { + Name string `json:"name"` + Content string `json:"content"` + Files []AgentSkillFileData `json:"files,omitempty"` +} - m := map[string]any{ - "issue": map[string]any{ - "id": util.UUIDToString(issue.ID), - "title": issue.Title, - "description": issue.Description.String, - }, - "agent": map[string]any{ - "id": util.UUIDToString(agent.ID), - "name": agent.Name, - "skills": skills, - "tools": tools, - }, - "runtime": map[string]any{ - "id": util.UUIDToString(runtime.ID), - "name": runtime.Name, - "runtime_mode": runtime.RuntimeMode, - "provider": runtime.Provider, - "device_info": runtime.DeviceInfo, - "metadata": metadata, - }, - } - if workspaceContext != "" { - m["workspace_context"] = workspaceContext - } - return m +// AgentSkillFileData represents a supporting file within a skill. +type AgentSkillFileData struct { + Path string `json:"path"` + Content string `json:"content"` } func priorityToInt(p string) int32 { diff --git a/server/migrations/014_workspace_repos.down.sql b/server/migrations/014_workspace_repos.down.sql new file mode 100644 index 00000000..2268bddf --- /dev/null +++ b/server/migrations/014_workspace_repos.down.sql @@ -0,0 +1 @@ +ALTER TABLE workspace DROP COLUMN repos; diff --git a/server/migrations/014_workspace_repos.up.sql b/server/migrations/014_workspace_repos.up.sql new file mode 100644 index 00000000..d37af6fe --- /dev/null +++ b/server/migrations/014_workspace_repos.up.sql @@ -0,0 +1 @@ +ALTER TABLE workspace ADD COLUMN repos JSONB NOT NULL DEFAULT '[]'; diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index dd71c7ea..ff125cdd 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -179,6 +179,18 @@ type Member struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type PersonalAccessToken struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + Name string `json:"name"` + TokenHash string `json:"token_hash"` + TokenPrefix string `json:"token_prefix"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + Revoked bool `json:"revoked"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type RuntimeUsage struct { ID pgtype.UUID `json:"id"` RuntimeID pgtype.UUID `json:"runtime_id"` @@ -193,18 +205,6 @@ type RuntimeUsage struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } -type PersonalAccessToken struct { - ID pgtype.UUID `json:"id"` - UserID pgtype.UUID `json:"user_id"` - Name string `json:"name"` - TokenHash string `json:"token_hash"` - TokenPrefix string `json:"token_prefix"` - ExpiresAt pgtype.Timestamptz `json:"expires_at"` - LastUsedAt pgtype.Timestamptz `json:"last_used_at"` - Revoked bool `json:"revoked"` - CreatedAt pgtype.Timestamptz `json:"created_at"` -} - type Skill struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -254,4 +254,5 @@ type Workspace struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` Context pgtype.Text `json:"context"` + Repos []byte `json:"repos"` } diff --git a/server/pkg/db/generated/runtime_usage.sql.go b/server/pkg/db/generated/runtime_usage.sql.go index 99ffeb21..f01354ce 100644 --- a/server/pkg/db/generated/runtime_usage.sql.go +++ b/server/pkg/db/generated/runtime_usage.sql.go @@ -11,41 +11,52 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -const upsertRuntimeUsage = `-- name: UpsertRuntimeUsage :exec -INSERT INTO runtime_usage (runtime_id, date, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -ON CONFLICT (runtime_id, date, provider, model) -DO UPDATE SET - input_tokens = EXCLUDED.input_tokens, - output_tokens = EXCLUDED.output_tokens, - cache_read_tokens = EXCLUDED.cache_read_tokens, - cache_write_tokens = EXCLUDED.cache_write_tokens, - updated_at = now() +const getRuntimeUsageSummary = `-- name: GetRuntimeUsageSummary :many +SELECT provider, model, + SUM(input_tokens)::bigint AS total_input_tokens, + SUM(output_tokens)::bigint AS total_output_tokens, + SUM(cache_read_tokens)::bigint AS total_cache_read_tokens, + SUM(cache_write_tokens)::bigint AS total_cache_write_tokens +FROM runtime_usage +WHERE runtime_id = $1 +GROUP BY provider, model +ORDER BY provider, model ` -type UpsertRuntimeUsageParams struct { - RuntimeID pgtype.UUID `json:"runtime_id"` - Date pgtype.Date `json:"date"` - Provider string `json:"provider"` - Model string `json:"model"` - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - CacheReadTokens int64 `json:"cache_read_tokens"` - CacheWriteTokens int64 `json:"cache_write_tokens"` +type GetRuntimeUsageSummaryRow struct { + Provider string `json:"provider"` + Model string `json:"model"` + TotalInputTokens int64 `json:"total_input_tokens"` + TotalOutputTokens int64 `json:"total_output_tokens"` + TotalCacheReadTokens int64 `json:"total_cache_read_tokens"` + TotalCacheWriteTokens int64 `json:"total_cache_write_tokens"` } -func (q *Queries) UpsertRuntimeUsage(ctx context.Context, arg UpsertRuntimeUsageParams) error { - _, err := q.db.Exec(ctx, upsertRuntimeUsage, - arg.RuntimeID, - arg.Date, - arg.Provider, - arg.Model, - arg.InputTokens, - arg.OutputTokens, - arg.CacheReadTokens, - arg.CacheWriteTokens, - ) - return err +func (q *Queries) GetRuntimeUsageSummary(ctx context.Context, runtimeID pgtype.UUID) ([]GetRuntimeUsageSummaryRow, error) { + rows, err := q.db.Query(ctx, getRuntimeUsageSummary, runtimeID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetRuntimeUsageSummaryRow{} + for rows.Next() { + var i GetRuntimeUsageSummaryRow + if err := rows.Scan( + &i.Provider, + &i.Model, + &i.TotalInputTokens, + &i.TotalOutputTokens, + &i.TotalCacheReadTokens, + &i.TotalCacheWriteTokens, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil } const listRuntimeUsage = `-- name: ListRuntimeUsage :many @@ -92,50 +103,39 @@ func (q *Queries) ListRuntimeUsage(ctx context.Context, arg ListRuntimeUsagePara return items, nil } -const getRuntimeUsageSummary = `-- name: GetRuntimeUsageSummary :many -SELECT provider, model, - SUM(input_tokens)::bigint AS total_input_tokens, - SUM(output_tokens)::bigint AS total_output_tokens, - SUM(cache_read_tokens)::bigint AS total_cache_read_tokens, - SUM(cache_write_tokens)::bigint AS total_cache_write_tokens -FROM runtime_usage -WHERE runtime_id = $1 -GROUP BY provider, model -ORDER BY provider, model +const upsertRuntimeUsage = `-- name: UpsertRuntimeUsage :exec +INSERT INTO runtime_usage (runtime_id, date, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +ON CONFLICT (runtime_id, date, provider, model) +DO UPDATE SET + input_tokens = EXCLUDED.input_tokens, + output_tokens = EXCLUDED.output_tokens, + cache_read_tokens = EXCLUDED.cache_read_tokens, + cache_write_tokens = EXCLUDED.cache_write_tokens, + updated_at = now() ` -type GetRuntimeUsageSummaryRow struct { - Provider string `json:"provider"` - Model string `json:"model"` - TotalInputTokens int64 `json:"total_input_tokens"` - TotalOutputTokens int64 `json:"total_output_tokens"` - TotalCacheReadTokens int64 `json:"total_cache_read_tokens"` - TotalCacheWriteTokens int64 `json:"total_cache_write_tokens"` +type UpsertRuntimeUsageParams struct { + RuntimeID pgtype.UUID `json:"runtime_id"` + Date pgtype.Date `json:"date"` + Provider string `json:"provider"` + Model string `json:"model"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheWriteTokens int64 `json:"cache_write_tokens"` } -func (q *Queries) GetRuntimeUsageSummary(ctx context.Context, runtimeID pgtype.UUID) ([]GetRuntimeUsageSummaryRow, error) { - rows, err := q.db.Query(ctx, getRuntimeUsageSummary, runtimeID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetRuntimeUsageSummaryRow{} - for rows.Next() { - var i GetRuntimeUsageSummaryRow - if err := rows.Scan( - &i.Provider, - &i.Model, - &i.TotalInputTokens, - &i.TotalOutputTokens, - &i.TotalCacheReadTokens, - &i.TotalCacheWriteTokens, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil +func (q *Queries) UpsertRuntimeUsage(ctx context.Context, arg UpsertRuntimeUsageParams) error { + _, err := q.db.Exec(ctx, upsertRuntimeUsage, + arg.RuntimeID, + arg.Date, + arg.Provider, + arg.Model, + arg.InputTokens, + arg.OutputTokens, + arg.CacheReadTokens, + arg.CacheWriteTokens, + ) + return err } diff --git a/server/pkg/db/generated/workspace.sql.go b/server/pkg/db/generated/workspace.sql.go index b237493d..bd527a91 100644 --- a/server/pkg/db/generated/workspace.sql.go +++ b/server/pkg/db/generated/workspace.sql.go @@ -14,7 +14,7 @@ import ( const createWorkspace = `-- name: CreateWorkspace :one INSERT INTO workspace (name, slug, description, context) VALUES ($1, $2, $3, $4) -RETURNING id, name, slug, description, settings, created_at, updated_at, context +RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos ` type CreateWorkspaceParams struct { @@ -41,6 +41,7 @@ func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams &i.CreatedAt, &i.UpdatedAt, &i.Context, + &i.Repos, ) return i, err } @@ -55,7 +56,7 @@ func (q *Queries) DeleteWorkspace(ctx context.Context, id pgtype.UUID) error { } const getWorkspace = `-- name: GetWorkspace :one -SELECT id, name, slug, description, settings, created_at, updated_at, context FROM workspace +SELECT id, name, slug, description, settings, created_at, updated_at, context, repos FROM workspace WHERE id = $1 ` @@ -71,12 +72,13 @@ func (q *Queries) GetWorkspace(ctx context.Context, id pgtype.UUID) (Workspace, &i.CreatedAt, &i.UpdatedAt, &i.Context, + &i.Repos, ) return i, err } const getWorkspaceBySlug = `-- name: GetWorkspaceBySlug :one -SELECT id, name, slug, description, settings, created_at, updated_at, context FROM workspace +SELECT id, name, slug, description, settings, created_at, updated_at, context, repos FROM workspace WHERE slug = $1 ` @@ -92,12 +94,13 @@ func (q *Queries) GetWorkspaceBySlug(ctx context.Context, slug string) (Workspac &i.CreatedAt, &i.UpdatedAt, &i.Context, + &i.Repos, ) return i, err } const listWorkspaces = `-- name: ListWorkspaces :many -SELECT w.id, w.name, w.slug, w.description, w.settings, w.created_at, w.updated_at, w.context FROM workspace w +SELECT w.id, w.name, w.slug, w.description, w.settings, w.created_at, w.updated_at, w.context, w.repos FROM workspace w JOIN member m ON m.workspace_id = w.id WHERE m.user_id = $1 ORDER BY w.created_at ASC @@ -121,6 +124,7 @@ func (q *Queries) ListWorkspaces(ctx context.Context, userID pgtype.UUID) ([]Wor &i.CreatedAt, &i.UpdatedAt, &i.Context, + &i.Repos, ); err != nil { return nil, err } @@ -138,9 +142,10 @@ UPDATE workspace SET description = COALESCE($3, description), context = COALESCE($4, context), settings = COALESCE($5, settings), + repos = COALESCE($6, repos), updated_at = now() WHERE id = $1 -RETURNING id, name, slug, description, settings, created_at, updated_at, context +RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos ` type UpdateWorkspaceParams struct { @@ -149,6 +154,7 @@ type UpdateWorkspaceParams struct { Description pgtype.Text `json:"description"` Context pgtype.Text `json:"context"` Settings []byte `json:"settings"` + Repos []byte `json:"repos"` } func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) { @@ -158,6 +164,7 @@ func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams arg.Description, arg.Context, arg.Settings, + arg.Repos, ) var i Workspace err := row.Scan( @@ -169,6 +176,7 @@ func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams &i.CreatedAt, &i.UpdatedAt, &i.Context, + &i.Repos, ) return i, err } diff --git a/server/pkg/db/queries/workspace.sql b/server/pkg/db/queries/workspace.sql index cafc57e9..73172ede 100644 --- a/server/pkg/db/queries/workspace.sql +++ b/server/pkg/db/queries/workspace.sql @@ -23,6 +23,7 @@ UPDATE workspace SET description = COALESCE(sqlc.narg('description'), description), context = COALESCE(sqlc.narg('context'), context), settings = COALESCE(sqlc.narg('settings'), settings), + repos = COALESCE(sqlc.narg('repos'), repos), updated_at = now() WHERE id = $1 RETURNING *;