Merge pull request #149 from multica-ai/feat/cli-issue-commands
feat(cli): add issue management commands
This commit is contained in:
commit
327973be08
29 changed files with 1324 additions and 333 deletions
12
CLAUDE.md
12
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`.
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
660
server/cmd/multica/cmd_issue.go
Normal file
660
server/cmd/multica/cmd_issue.go
Normal 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
|
||||
}
|
||||
170
server/cmd/multica/cmd_issue_test.go
Normal file
170
server/cmd/multica/cmd_issue_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ func init() {
|
|||
rootCmd.AddCommand(runtimeCmd)
|
||||
rootCmd.AddCommand(workspaceCmd)
|
||||
rootCmd.AddCommand(configCmd)
|
||||
rootCmd.AddCommand(issueCmd)
|
||||
rootCmd.AddCommand(statusCmd)
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
1
server/migrations/014_workspace_repos.down.sql
Normal file
1
server/migrations/014_workspace_repos.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE workspace DROP COLUMN repos;
|
||||
1
server/migrations/014_workspace_repos.up.sql
Normal file
1
server/migrations/014_workspace_repos.up.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE workspace ADD COLUMN repos JSONB NOT NULL DEFAULT '[]';
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 *;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue