From 568b5f3a8f1b61b75b7ee5722bbaf876c26fba89 Mon Sep 17 00:00:00 2001 From: yushen Date: Fri, 27 Mar 2026 12:33:38 +0800 Subject: [PATCH 1/9] docs: update CLAUDE.md with logging, CI, and CLI details Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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`. From 765ba8e3802ffd4d10bf2be56437cb88b502780d Mon Sep 17 00:00:00 2001 From: yushen Date: Fri, 27 Mar 2026 12:33:53 +0800 Subject: [PATCH 2/9] feat(cli): add issue management commands Add `multica issue` command group with subcommands for full issue lifecycle management: list, get, create, update, assign, status, and comment operations. Includes assignee resolution by name across both workspace members and agents. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/cmd/multica/cmd_issue.go | 639 ++++++++++++++++++++++++++++++++ server/cmd/multica/main.go | 1 + 2 files changed, 640 insertions(+) create mode 100644 server/cmd/multica/cmd_issue.go diff --git a/server/cmd/multica/cmd_issue.go b/server/cmd/multica/cmd_issue.go new file mode 100644 index 00000000..7832a137 --- /dev/null +++ b/server/cmd/multica/cmd_issue.go @@ -0,0 +1,639 @@ +package main + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + "time" + + "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 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 != "" { + aType, aID, resolveErr := resolveAssignee(ctx, client, v) + if resolveErr != nil { + return fmt.Errorf("resolve assignee: %w", resolveErr) + } + _ = aType + 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} + if err := client.PutJSON(ctx, "/api/issues/"+id, body, nil); err != nil { + return fmt.Errorf("update status: %w", err) + } + + fmt.Fprintf(os.Stderr, "Issue %s status changed to %s.\n", truncateID(id), status) + 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 len(content) > 80 { + content = content[: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 + + // Search members. + var members []map[string]any + if err := client.GetJSON(ctx, "/api/workspaces/"+client.WorkspaceID+"/members", &members); err == nil { + 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 { + 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, + }) + } + } + } + + 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 len(id) > 8 { + return id[:8] + } + return id +} 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) } From daecf7985ca4e2b82e6e3e316461ff6353871441 Mon Sep 17 00:00:00 2001 From: yushen Date: Fri, 27 Mar 2026 13:38:11 +0800 Subject: [PATCH 3/9] fix(cli): propagate API errors in resolveAssignee and fix rune-safe truncation - resolveAssignee now reports actual API errors instead of silently falling through to "not found" when both member/agent fetches fail - Comment content truncation uses rune count for correct CJK handling - Remove unnecessary _ = aType discard Co-Authored-By: Claude Opus 4.6 (1M context) --- server/cmd/multica/cmd_issue.go | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/server/cmd/multica/cmd_issue.go b/server/cmd/multica/cmd_issue.go index 7832a137..4cbc2932 100644 --- a/server/cmd/multica/cmd_issue.go +++ b/server/cmd/multica/cmd_issue.go @@ -7,6 +7,7 @@ import ( "os" "strings" "time" + "unicode/utf8" "github.com/spf13/cobra" @@ -172,11 +173,10 @@ func runIssueList(cmd *cobra.Command, _ []string) error { params.Set("limit", fmt.Sprintf("%d", v)) } if v, _ := cmd.Flags().GetString("assignee"); v != "" { - aType, aID, resolveErr := resolveAssignee(ctx, client, v) + _, aID, resolveErr := resolveAssignee(ctx, client, v) if resolveErr != nil { return fmt.Errorf("resolve assignee: %w", resolveErr) } - _ = aType params.Set("assignee_id", aID) } @@ -494,8 +494,9 @@ func runIssueCommentList(cmd *cobra.Command, args []string) error { rows := make([][]string, 0, len(comments)) for _, c := range comments { content := strVal(c, "content") - if len(content) > 80 { - content = content[:77] + "..." + if utf8.RuneCountInString(content) > 80 { + runes := []rune(content) + content = string(runes[:77]) + "..." } created := strVal(c, "created_at") if len(created) >= 16 { @@ -576,10 +577,13 @@ func resolveAssignee(ctx context.Context, client *cli.APIClient, name string) (s 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 { + 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) { @@ -595,7 +599,9 @@ func resolveAssignee(ctx context.Context, client *cli.APIClient, name string) (s // 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 { + 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) { @@ -608,6 +614,11 @@ func resolveAssignee(ctx context.Context, client *cli.APIClient, name string) (s } } + // 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) From 4a62b98c9a6130297842e643da0ae13fea0eb1fb Mon Sep 17 00:00:00 2001 From: yushen Date: Fri, 27 Mar 2026 13:54:40 +0800 Subject: [PATCH 4/9] feat(cli): add workspace get and members commands Expose workspace details (including context field) and member listing via the CLI so agents can dynamically query workspace context instead of relying on static snapshots. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/cmd/multica/cmd_workspace.go | 115 ++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/server/cmd/multica/cmd_workspace.go b/server/cmd/multica/cmd_workspace.go index 9e74d61b..dd921852 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,101 @@ 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") + } + + serverURL := resolveServerURL(cmd) + token := resolveToken() + if token == "" { + return fmt.Errorf("not authenticated: run 'multica auth login' first") + } + + client := cli.NewAPIClient(serverURL, "", token) + 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") + } + + serverURL := resolveServerURL(cmd) + token := resolveToken() + if token == "" { + return fmt.Errorf("not authenticated: run 'multica auth login' first") + } + + client := cli.NewAPIClient(serverURL, "", token) + 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] From 673ba09baf26fe4d6672760d4bd97f1608c966db Mon Sep 17 00:00:00 2001 From: yushen Date: Fri, 27 Mar 2026 14:12:16 +0800 Subject: [PATCH 5/9] fix(cli): rune-safe truncateID, consistent client construction, add --output to status cmd - Make truncateID use rune counting instead of byte length for unicode safety - Refactor workspaceGet and workspaceMembers to use newAPIClient helper for consistent server-URL validation - Add --output flag to issueStatusCmd for JSON output support Co-Authored-By: Claude Opus 4.6 (1M context) --- server/cmd/multica/cmd_issue.go | 16 +++++++++++++--- server/cmd/multica/cmd_workspace.go | 16 ++++++---------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/server/cmd/multica/cmd_issue.go b/server/cmd/multica/cmd_issue.go index 4cbc2932..d68a40e3 100644 --- a/server/cmd/multica/cmd_issue.go +++ b/server/cmd/multica/cmd_issue.go @@ -133,6 +133,9 @@ func init() { 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") @@ -459,11 +462,17 @@ func runIssueStatus(cmd *cobra.Command, args []string) error { defer cancel() body := map[string]any{"status": status} - if err := client.PutJSON(ctx, "/api/issues/"+id, body, nil); err != nil { + 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 } @@ -643,8 +652,9 @@ func formatAssignee(issue map[string]any) string { } func truncateID(id string) string { - if len(id) > 8 { - return id[:8] + if utf8.RuneCountInString(id) > 8 { + runes := []rune(id) + return string(runes[:8]) } return id } diff --git a/server/cmd/multica/cmd_workspace.go b/server/cmd/multica/cmd_workspace.go index dd921852..643a9c5c 100644 --- a/server/cmd/multica/cmd_workspace.go +++ b/server/cmd/multica/cmd_workspace.go @@ -119,13 +119,11 @@ func runWorkspaceGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("workspace ID is required: pass as argument or set MULTICA_WORKSPACE_ID") } - serverURL := resolveServerURL(cmd) - token := resolveToken() - if token == "" { - return fmt.Errorf("not authenticated: run 'multica auth login' first") + client, err := newAPIClient(cmd) + if err != nil { + return err } - client := cli.NewAPIClient(serverURL, "", token) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -167,13 +165,11 @@ func runWorkspaceMembers(cmd *cobra.Command, args []string) error { return fmt.Errorf("workspace ID is required: pass as argument or set MULTICA_WORKSPACE_ID") } - serverURL := resolveServerURL(cmd) - token := resolveToken() - if token == "" { - return fmt.Errorf("not authenticated: run 'multica auth login' first") + client, err := newAPIClient(cmd) + if err != nil { + return err } - client := cli.NewAPIClient(serverURL, "", token) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() From 6733262a63142528ba76fc1f5e772a56648f2eaf Mon Sep 17 00:00:00 2001 From: yushen Date: Fri, 27 Mar 2026 14:12:20 +0800 Subject: [PATCH 6/9] test(cli): add unit tests for issue command helpers Tests for truncateID, formatAssignee, resolveAssignee (6 cases), and validIssueStatuses. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/cmd/multica/cmd_issue_test.go | 170 +++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 server/cmd/multica/cmd_issue_test.go 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 +} From 1deae2a1e95fd32bb16c86319a03b361370bc993 Mon Sep 17 00:00:00 2001 From: yushen Date: Fri, 27 Mar 2026 15:31:22 +0800 Subject: [PATCH 7/9] refactor(daemon): remove context snapshot, let agent fetch data via CLI Replace the frozen context snapshot pattern with a CLI-driven approach: agents now use `multica` CLI commands to fetch issue details, comments, and workspace context on demand, always getting the latest data. - Remove buildContextSnapshot and snapshot generation from enqueue - Claim endpoint now returns fresh agent name + skills from DB - Daemon resolves provider from local runtimeIndex, not snapshot - Prompt instructs agent to use `multica issue get` / `comment list` - Meta skill (CLAUDE.md/AGENTS.md) documents all available CLI commands - Skills still injected as filesystem files (static agent config) - Simplify daemon types: remove TaskContext/IssueContext/RuntimeContext Co-Authored-By: Claude Opus 4.6 (1M context) --- server/internal/daemon/client.go | 5 + server/internal/daemon/daemon.go | 32 ++++--- server/internal/daemon/daemon_test.go | 47 ++++------ server/internal/daemon/execenv/context.go | 21 ++--- server/internal/daemon/execenv/execenv.go | 8 +- .../internal/daemon/execenv/execenv_test.go | 60 ++++++------ .../internal/daemon/execenv/runtime_config.go | 31 ++++--- server/internal/daemon/prompt.go | 25 +++-- server/internal/daemon/types.go | 40 ++------ server/internal/handler/agent.go | 40 ++++---- server/internal/handler/daemon.go | 14 ++- server/internal/service/task.go | 91 ++++--------------- 12 files changed, 176 insertions(+), 238 deletions(-) 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/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 { From 83111761dbf3a58b4892208dffc561cdd2a0e9af Mon Sep 17 00:00:00 2001 From: yushen Date: Fri, 27 Mar 2026 15:49:30 +0800 Subject: [PATCH 8/9] feat(workspace): add repos JSONB field for GitHub repository URLs Add a `repos` JSONB column to the workspace table for storing associated repository URLs and descriptions. This enables the daemon to clone repos and set up git worktrees for agent task execution. Structure: [{"url": "https://github.com/org/repo", "description": "..."}] - Migration 014: adds repos column with default '[]' - UpdateWorkspace query: supports updating repos - Workspace API: returns repos in GET, accepts in PUT Co-Authored-By: Claude Opus 4.6 (1M context) --- server/internal/handler/workspace.go | 14 ++ .../migrations/014_workspace_repos.down.sql | 1 + server/migrations/014_workspace_repos.up.sql | 1 + server/pkg/db/generated/models.go | 25 +-- server/pkg/db/generated/runtime_usage.sql.go | 146 +++++++++--------- server/pkg/db/generated/workspace.sql.go | 18 ++- server/pkg/db/queries/workspace.sql | 1 + 7 files changed, 116 insertions(+), 90 deletions(-) create mode 100644 server/migrations/014_workspace_repos.down.sql create mode 100644 server/migrations/014_workspace_repos.up.sql 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/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 *; From bae2926370f458509164a175c846ffcea496335d Mon Sep 17 00:00:00 2001 From: yushen Date: Fri, 27 Mar 2026 16:05:54 +0800 Subject: [PATCH 9/9] feat(web): add repos management UI to workspace settings Add a Repositories section to workspace settings where admins can add/remove GitHub repo URLs with descriptions. Agents use these repos to clone and work on code. - Add WorkspaceRepo type and export from shared/types - Update API client updateWorkspace to accept repos - Add repos section in workspace-tab.tsx with add/remove UI - Fix test helpers to include repos field Co-Authored-By: Claude Opus 4.6 (1M context) --- .../settings/_components/workspace-tab.tsx | 71 ++++++++++++++++++- apps/web/shared/api/client.ts | 3 +- apps/web/shared/types/index.ts | 2 +- apps/web/shared/types/workspace.ts | 6 ++ apps/web/test/helpers.tsx | 1 + 5 files changed, 80 insertions(+), 3 deletions(-) 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", };