Merge pull request #149 from multica-ai/feat/cli-issue-commands

feat(cli): add issue management commands
This commit is contained in:
LinYushen 2026-03-27 16:12:03 +08:00 committed by GitHub
commit 327973be08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1324 additions and 333 deletions

View file

@ -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`.

View file

@ -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<WorkspaceRepo[]>(workspace?.repos ?? []);
const [saving, setSaving] = useState(false);
const [actionId, setActionId] = useState<string | null>(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() {
</Card>
</section>
{/* Repositories */}
<section className="space-y-4">
<h2 className="text-sm font-semibold">Repositories</h2>
<Card>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">
GitHub repositories associated with this workspace. Agents use these to clone and work on code.
</p>
{repos.map((repo, index) => (
<div key={index} className="flex gap-2">
<div className="flex-1 space-y-1.5">
<Input
type="url"
value={repo.url}
onChange={(e) => handleRepoChange(index, "url", e.target.value)}
disabled={!canManageWorkspace}
placeholder="https://github.com/org/repo"
className="text-sm"
/>
<Input
type="text"
value={repo.description}
onChange={(e) => handleRepoChange(index, "description", e.target.value)}
disabled={!canManageWorkspace}
placeholder="Description (e.g. Go backend + Next.js frontend)"
className="text-sm"
/>
</div>
{canManageWorkspace && (
<Button
variant="ghost"
size="icon"
className="mt-0.5 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveRepo(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
{canManageWorkspace && (
<Button variant="outline" size="sm" onClick={handleAddRepo}>
<Plus className="h-3 w-3" />
Add repository
</Button>
)}
</CardContent>
</Card>
</section>
{/* Danger Zone */}
<section className="space-y-4">
<div className="flex items-center gap-2">

View file

@ -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<string, unknown> }): Promise<Workspace> {
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[] }): Promise<Workspace> {
return this.fetch(`/api/workspaces/${id}`, {
method: "PATCH",
body: JSON.stringify(data),

View file

@ -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";

View file

@ -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<string, unknown>;
repos: WorkspaceRepo[];
created_at: string;
updated_at: string;
}

View file

@ -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",
};

View file

@ -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 <id>",
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 <id>",
Short: "Update an issue",
Args: cobra.ExactArgs(1),
RunE: runIssueUpdate,
}
var issueAssignCmd = &cobra.Command{
Use: "assign <id>",
Short: "Assign an issue to a member or agent",
Args: cobra.ExactArgs(1),
RunE: runIssueAssign,
}
var issueStatusCmd = &cobra.Command{
Use: "status <id> <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 <issue-id>",
Short: "List comments on an issue",
Args: cobra.ExactArgs(1),
RunE: runIssueCommentList,
}
var issueCommentAddCmd = &cobra.Command{
Use: "add <issue-id>",
Short: "Add a comment to an issue",
Args: cobra.ExactArgs(1),
RunE: runIssueCommentAdd,
}
var issueCommentDeleteCmd = &cobra.Command{
Use: "delete <comment-id>",
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 <name> 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
}

View file

@ -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
}

View file

@ -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 <workspace-id>",
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]

View file

@ -30,6 +30,7 @@ func init() {
rootCmd.AddCommand(runtimeCmd)
rootCmd.AddCommand(workspaceCmd)
rootCmd.AddCommand(configCmd)
rootCmd.AddCommand(issueCmd)
rootCmd.AddCommand(statusCmd)
rootCmd.AddCommand(versionCmd)
}

View file

@ -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"`

View file

@ -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 {

View file

@ -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)
}
}
}

View file

@ -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")

View file

@ -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.

View file

@ -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)

View file

@ -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 <id>` — 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 <issue-id>` — 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 <issue-id> --content \"...\"` — Post a comment to an issue\n")
b.WriteString("- `multica issue status <id> <status>` — Update issue status (todo, in_progress, in_review, done, blocked)\n")
b.WriteString("- `multica issue update <id> [--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")

View file

@ -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()
}

View file

@ -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"`

View file

@ -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),
}
}

View file

@ -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.

View file

@ -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 {

View file

@ -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 {

View file

@ -0,0 +1 @@
ALTER TABLE workspace DROP COLUMN repos;

View file

@ -0,0 +1 @@
ALTER TABLE workspace ADD COLUMN repos JSONB NOT NULL DEFAULT '[]';

View file

@ -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"`
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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 *;