merge: resolve conflicts after merging main
Adapt runtime features (usage tracking, ping, heartbeat) to main's multi-workspace architecture. Update frontend imports from @multica/types to @/shared/types after the package consolidation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
6ee034c6e9
151 changed files with 3664 additions and 6579 deletions
|
|
@ -87,11 +87,7 @@ func resolveWorkspaceID(cmd *cobra.Command) string {
|
|||
return val
|
||||
}
|
||||
cfg, _ := cli.LoadCLIConfig()
|
||||
if cfg.WorkspaceID != "" {
|
||||
return cfg.WorkspaceID
|
||||
}
|
||||
// Fallback: try daemon.json for workspace_id
|
||||
return cli.LoadWorkspaceIDFromDaemonConfig()
|
||||
return cfg.WorkspaceID
|
||||
}
|
||||
|
||||
func runAgentList(cmd *cobra.Command, _ []string) error {
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ var daemonCmd = &cobra.Command{
|
|||
|
||||
func init() {
|
||||
f := daemonCmd.Flags()
|
||||
f.String("repos-root", "", "Base directory for task repositories (env: MULTICA_REPOS_ROOT)")
|
||||
f.String("config-path", "", "Path to daemon config file (env: MULTICA_DAEMON_CONFIG)")
|
||||
f.String("daemon-id", "", "Unique daemon identifier (env: MULTICA_DAEMON_ID)")
|
||||
f.String("device-name", "", "Human-readable device name (env: MULTICA_DAEMON_DEVICE_NAME)")
|
||||
f.String("runtime-name", "", "Runtime display name (env: MULTICA_AGENT_RUNTIME_NAME)")
|
||||
|
|
@ -35,9 +33,6 @@ func init() {
|
|||
func runDaemon(cmd *cobra.Command, _ []string) error {
|
||||
overrides := daemon.Overrides{
|
||||
ServerURL: cli.FlagOrEnv(cmd, "server-url", "MULTICA_SERVER_URL", ""),
|
||||
WorkspaceID: cli.FlagOrEnv(cmd, "workspace-id", "MULTICA_WORKSPACE_ID", ""),
|
||||
ReposRoot: flagString(cmd, "repos-root"),
|
||||
ConfigPath: flagString(cmd, "config-path"),
|
||||
DaemonID: flagString(cmd, "daemon-id"),
|
||||
DeviceName: flagString(cmd, "device-name"),
|
||||
RuntimeName: flagString(cmd, "runtime-name"),
|
||||
|
|
@ -73,4 +68,3 @@ func flagString(cmd *cobra.Command, name string) string {
|
|||
val, _ := cmd.Flags().GetString(name)
|
||||
return val
|
||||
}
|
||||
|
||||
|
|
|
|||
151
server/cmd/multica/cmd_workspace.go
Normal file
151
server/cmd/multica/cmd_workspace.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
)
|
||||
|
||||
var workspaceCmd = &cobra.Command{
|
||||
Use: "workspace",
|
||||
Short: "Manage workspaces",
|
||||
}
|
||||
|
||||
var workspaceListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all workspaces you belong to",
|
||||
RunE: runWorkspaceList,
|
||||
}
|
||||
|
||||
var workspaceWatchCmd = &cobra.Command{
|
||||
Use: "watch <workspace-id>",
|
||||
Short: "Add a workspace to the daemon watch list",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runWatch,
|
||||
}
|
||||
|
||||
var workspaceUnwatchCmd = &cobra.Command{
|
||||
Use: "unwatch <workspace-id>",
|
||||
Short: "Remove a workspace from the daemon watch list",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runUnwatch,
|
||||
}
|
||||
|
||||
func init() {
|
||||
workspaceCmd.AddCommand(workspaceListCmd)
|
||||
workspaceCmd.AddCommand(workspaceWatchCmd)
|
||||
workspaceCmd.AddCommand(workspaceUnwatchCmd)
|
||||
}
|
||||
|
||||
func runWorkspaceList(cmd *cobra.Command, _ []string) error {
|
||||
serverURL := resolveServerURL(cmd)
|
||||
token := resolveToken()
|
||||
if token == "" {
|
||||
return fmt.Errorf("not authenticated: run 'multica auth login' first")
|
||||
}
|
||||
|
||||
client := cli.NewAPIClient(serverURL, "", token)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var workspaces []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := client.GetJSON(ctx, "/api/workspaces", &workspaces); err != nil {
|
||||
return fmt.Errorf("list workspaces: %w", err)
|
||||
}
|
||||
|
||||
if len(workspaces) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "No workspaces found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load watched set for marking.
|
||||
cfg, _ := cli.LoadCLIConfig()
|
||||
watched := make(map[string]bool)
|
||||
for _, w := range cfg.WatchedWorkspaces {
|
||||
watched[w.ID] = true
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tNAME\tWATCHING")
|
||||
for _, ws := range workspaces {
|
||||
mark := ""
|
||||
if watched[ws.ID] {
|
||||
mark = "*"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", ws.ID, ws.Name, mark)
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func runWatch(cmd *cobra.Command, args []string) error {
|
||||
workspaceID := args[0]
|
||||
|
||||
serverURL := resolveServerURL(cmd)
|
||||
token := resolveToken()
|
||||
if token == "" {
|
||||
return fmt.Errorf("not authenticated: run 'multica auth login' first")
|
||||
}
|
||||
|
||||
client := cli.NewAPIClient(serverURL, "", token)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var ws struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := client.GetJSON(ctx, "/api/workspaces/"+workspaceID, &ws); err != nil {
|
||||
return fmt.Errorf("workspace not found: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := cli.LoadCLIConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !cfg.AddWatchedWorkspace(ws.ID, ws.Name) {
|
||||
fmt.Fprintf(os.Stderr, "Already watching workspace %s (%s)\n", ws.ID, ws.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.WorkspaceID == "" {
|
||||
cfg.WorkspaceID = ws.ID
|
||||
fmt.Fprintf(os.Stderr, "Set default workspace to %s (%s)\n", ws.ID, ws.Name)
|
||||
}
|
||||
|
||||
if err := cli.SaveCLIConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Watching workspace %s (%s)\n", ws.ID, ws.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runUnwatch(_ *cobra.Command, args []string) error {
|
||||
workspaceID := args[0]
|
||||
|
||||
cfg, err := cli.LoadCLIConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !cfg.RemoveWatchedWorkspace(workspaceID) {
|
||||
return fmt.Errorf("workspace %s is not being watched", workspaceID)
|
||||
}
|
||||
|
||||
if err := cli.SaveCLIConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Stopped watching workspace %s\n", workspaceID)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ func init() {
|
|||
rootCmd.AddCommand(daemonCmd)
|
||||
rootCmd.AddCommand(agentCmd)
|
||||
rootCmd.AddCommand(runtimeCmd)
|
||||
rootCmd.AddCommand(workspaceCmd)
|
||||
rootCmd.AddCommand(configCmd)
|
||||
rootCmd.AddCommand(statusCmd)
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
|||
r.Route("/api/skills", func(r chi.Router) {
|
||||
r.Get("/", h.ListSkills)
|
||||
r.Post("/", h.CreateSkill)
|
||||
r.Post("/import", h.ImportSkill)
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Get("/", h.GetSkill)
|
||||
r.Put("/", h.UpdateSkill)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ import (
|
|||
|
||||
// APIClient is a REST client for the Multica server API.
|
||||
// Used by ctrl subcommands (agent, runtime, status, etc.).
|
||||
//
|
||||
// TODO: Add Authorization header support. Agent routes (/api/agents/...)
|
||||
// require JWT auth via middleware.Auth, but this client currently sends
|
||||
// no auth token. CLI agent commands will fail with 401 until this is added.
|
||||
type APIClient struct {
|
||||
BaseURL string
|
||||
WorkspaceID string
|
||||
|
|
|
|||
|
|
@ -10,11 +10,40 @@ import (
|
|||
|
||||
const defaultCLIConfigPath = ".multica/config.json"
|
||||
|
||||
// WatchedWorkspace represents a workspace the daemon should monitor for tasks.
|
||||
type WatchedWorkspace struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// CLIConfig holds persistent CLI settings.
|
||||
type CLIConfig struct {
|
||||
ServerURL string `json:"server_url,omitempty"`
|
||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
ServerURL string `json:"server_url,omitempty"`
|
||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
WatchedWorkspaces []WatchedWorkspace `json:"watched_workspaces,omitempty"`
|
||||
}
|
||||
|
||||
// AddWatchedWorkspace adds a workspace to the watch list. Returns true if added.
|
||||
func (c *CLIConfig) AddWatchedWorkspace(id, name string) bool {
|
||||
for _, w := range c.WatchedWorkspaces {
|
||||
if w.ID == id {
|
||||
return false
|
||||
}
|
||||
}
|
||||
c.WatchedWorkspaces = append(c.WatchedWorkspaces, WatchedWorkspace{ID: id, Name: name})
|
||||
return true
|
||||
}
|
||||
|
||||
// RemoveWatchedWorkspace removes a workspace from the watch list. Returns true if found.
|
||||
func (c *CLIConfig) RemoveWatchedWorkspace(id string) bool {
|
||||
for i, w := range c.WatchedWorkspaces {
|
||||
if w.ID == id {
|
||||
c.WatchedWorkspaces = append(c.WatchedWorkspaces[:i], c.WatchedWorkspaces[i+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CLIConfigPath returns the default path for the CLI config file.
|
||||
|
|
@ -46,41 +75,43 @@ func LoadCLIConfig() (CLIConfig, error) {
|
|||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadWorkspaceIDFromDaemonConfig reads workspace_id from ~/.multica/daemon.json
|
||||
// as a fallback when it's not set in the CLI config.
|
||||
func LoadWorkspaceIDFromDaemonConfig() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(home, ".multica/daemon.json"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var cfg struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
}
|
||||
if json.Unmarshal(data, &cfg) != nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.WorkspaceID
|
||||
}
|
||||
|
||||
// SaveCLIConfig writes the CLI config to disk.
|
||||
// SaveCLIConfig writes the CLI config to disk atomically (write to temp, then rename).
|
||||
func SaveCLIConfig(cfg CLIConfig) error {
|
||||
path, err := CLIConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create CLI config directory: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode CLI config: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil {
|
||||
return fmt.Errorf("write CLI config: %w", err)
|
||||
|
||||
// Write to a temp file in the same directory, then rename for atomicity.
|
||||
tmp, err := os.CreateTemp(dir, ".config-*.json.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp config file: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
if _, err := tmp.Write(append(data, '\n')); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("write temp config file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("close temp config file: %w", err)
|
||||
}
|
||||
if err := os.Chmod(tmpPath, 0o600); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("chmod temp config file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("rename config file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -40,6 +39,7 @@ func isWorkspaceNotFoundError(err error) bool {
|
|||
// Client handles HTTP communication with the Multica server daemon API.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
|
|
@ -51,6 +51,11 @@ func NewClient(baseURL string) *Client {
|
|||
}
|
||||
}
|
||||
|
||||
// SetToken sets the auth token for authenticated requests.
|
||||
func (c *Client) SetToken(token string) {
|
||||
c.token = token
|
||||
}
|
||||
|
||||
func (c *Client) ClaimTask(ctx context.Context, runtimeID string) (*Task, error) {
|
||||
var resp struct {
|
||||
Task *Task `json:"task"`
|
||||
|
|
@ -61,30 +66,6 @@ func (c *Client) ClaimTask(ctx context.Context, runtimeID string) (*Task, error)
|
|||
return resp.Task, nil
|
||||
}
|
||||
|
||||
func (c *Client) CreatePairingSession(ctx context.Context, req map[string]string) (PairingSession, error) {
|
||||
var resp PairingSession
|
||||
if err := c.postJSON(ctx, "/api/daemon/pairing-sessions", req, &resp); err != nil {
|
||||
return PairingSession{}, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetPairingSession(ctx context.Context, token string) (PairingSession, error) {
|
||||
var resp PairingSession
|
||||
if err := c.getJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s", url.PathEscape(token)), &resp); err != nil {
|
||||
return PairingSession{}, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) ClaimPairingSession(ctx context.Context, token string) (PairingSession, error) {
|
||||
var resp PairingSession
|
||||
if err := c.postJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s/claim", url.PathEscape(token)), map[string]any{}, &resp); err != nil {
|
||||
return PairingSession{}, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) StartTask(ctx context.Context, taskID string) error {
|
||||
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/start", taskID), map[string]any{}, nil)
|
||||
}
|
||||
|
|
@ -167,6 +148,9 @@ func (c *Client) postJSON(ctx context.Context, path string, reqBody any, respBod
|
|||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
|
|
@ -190,6 +174,9 @@ func (c *Client) getJSON(ctx context.Context, path string, respBody any) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
|
|
@ -14,23 +12,20 @@ import (
|
|||
|
||||
const (
|
||||
DefaultServerURL = "ws://localhost:8080/ws"
|
||||
DefaultDaemonConfigPath = ".multica/daemon.json"
|
||||
DefaultPollInterval = 3 * time.Second
|
||||
DefaultHeartbeatInterval = 15 * time.Second
|
||||
DefaultAgentTimeout = 2 * time.Hour
|
||||
DefaultRuntimeName = "Local Agent"
|
||||
DefaultConfigReloadInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
// Config holds all daemon configuration.
|
||||
type Config struct {
|
||||
ServerBaseURL string
|
||||
ConfigPath string
|
||||
WorkspaceID string
|
||||
DaemonID string
|
||||
DeviceName string
|
||||
RuntimeName string
|
||||
Agents map[string]AgentEntry // "claude" -> entry, "codex" -> entry
|
||||
ReposRoot string // parent directory containing all repos
|
||||
WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces)
|
||||
KeepEnvAfterTask bool // preserve env after task for debugging
|
||||
PollInterval time.Duration
|
||||
|
|
@ -42,10 +37,7 @@ type Config struct {
|
|||
// Zero values are ignored and the env/default value is used instead.
|
||||
type Overrides struct {
|
||||
ServerURL string
|
||||
WorkspaceID string
|
||||
ReposRoot string
|
||||
WorkspacesRoot string
|
||||
ConfigPath string
|
||||
PollInterval time.Duration
|
||||
HeartbeatInterval time.Duration
|
||||
AgentTimeout time.Duration
|
||||
|
|
@ -54,8 +46,8 @@ type Overrides struct {
|
|||
RuntimeName string
|
||||
}
|
||||
|
||||
// LoadConfig builds the daemon configuration from environment variables,
|
||||
// persisted config, and optional CLI flag overrides.
|
||||
// LoadConfig builds the daemon configuration from environment variables
|
||||
// and optional CLI flag overrides.
|
||||
func LoadConfig(overrides Overrides) (Config, error) {
|
||||
// Server URL: override > env > default
|
||||
rawServerURL := envOrDefault("MULTICA_SERVER_URL", DefaultServerURL)
|
||||
|
|
@ -67,31 +59,6 @@ func LoadConfig(overrides Overrides) (Config, error) {
|
|||
return Config{}, err
|
||||
}
|
||||
|
||||
// Config path
|
||||
rawConfigPath := strings.TrimSpace(os.Getenv("MULTICA_DAEMON_CONFIG"))
|
||||
if overrides.ConfigPath != "" {
|
||||
rawConfigPath = overrides.ConfigPath
|
||||
}
|
||||
configPath, err := resolveDaemonConfigPath(rawConfigPath)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
// Load persisted config
|
||||
persisted, err := LoadPersistedConfig(configPath)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
// Workspace ID: override > env > persisted
|
||||
workspaceID := strings.TrimSpace(os.Getenv("MULTICA_WORKSPACE_ID"))
|
||||
if workspaceID == "" {
|
||||
workspaceID = persisted.WorkspaceID
|
||||
}
|
||||
if overrides.WorkspaceID != "" {
|
||||
workspaceID = overrides.WorkspaceID
|
||||
}
|
||||
|
||||
// Probe available agent CLIs
|
||||
agents := map[string]AgentEntry{}
|
||||
claudePath := envOrDefault("MULTICA_CLAUDE_PATH", "claude")
|
||||
|
|
@ -118,22 +85,6 @@ func LoadConfig(overrides Overrides) (Config, error) {
|
|||
host = "local-machine"
|
||||
}
|
||||
|
||||
// Repos root: override > env > cwd
|
||||
reposRoot := strings.TrimSpace(os.Getenv("MULTICA_REPOS_ROOT"))
|
||||
if overrides.ReposRoot != "" {
|
||||
reposRoot = overrides.ReposRoot
|
||||
}
|
||||
if reposRoot == "" {
|
||||
reposRoot, err = os.Getwd()
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("resolve working directory: %w", err)
|
||||
}
|
||||
}
|
||||
reposRoot, err = filepath.Abs(reposRoot)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("resolve absolute repos root: %w", err)
|
||||
}
|
||||
|
||||
// Durations: override > env > default
|
||||
pollInterval, err := durationFromEnv("MULTICA_DAEMON_POLL_INTERVAL", DefaultPollInterval)
|
||||
if err != nil {
|
||||
|
|
@ -181,12 +132,11 @@ func LoadConfig(overrides Overrides) (Config, error) {
|
|||
workspacesRoot = overrides.WorkspacesRoot
|
||||
}
|
||||
if workspacesRoot == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
if home != "" {
|
||||
workspacesRoot = filepath.Join(home, "multica_workspaces")
|
||||
} else {
|
||||
workspacesRoot = filepath.Join(reposRoot, "multica_workspaces")
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("resolve home directory: %w (set MULTICA_WORKSPACES_ROOT to override)", err)
|
||||
}
|
||||
workspacesRoot = filepath.Join(home, "multica_workspaces")
|
||||
}
|
||||
workspacesRoot, err = filepath.Abs(workspacesRoot)
|
||||
if err != nil {
|
||||
|
|
@ -198,13 +148,10 @@ func LoadConfig(overrides Overrides) (Config, error) {
|
|||
|
||||
return Config{
|
||||
ServerBaseURL: serverBaseURL,
|
||||
ConfigPath: configPath,
|
||||
WorkspaceID: workspaceID,
|
||||
DaemonID: daemonID,
|
||||
DeviceName: deviceName,
|
||||
RuntimeName: runtimeName,
|
||||
Agents: agents,
|
||||
ReposRoot: reposRoot,
|
||||
WorkspacesRoot: workspacesRoot,
|
||||
KeepEnvAfterTask: keepEnv,
|
||||
PollInterval: pollInterval,
|
||||
|
|
@ -236,45 +183,3 @@ func NormalizeServerBaseURL(raw string) (string, error) {
|
|||
u.Fragment = ""
|
||||
return strings.TrimRight(u.String(), "/"), nil
|
||||
}
|
||||
|
||||
func resolveDaemonConfigPath(raw string) (string, error) {
|
||||
if raw != "" {
|
||||
return filepath.Abs(raw)
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve daemon config path: %w", err)
|
||||
}
|
||||
return filepath.Join(home, DefaultDaemonConfigPath), nil
|
||||
}
|
||||
|
||||
// LoadPersistedConfig reads the daemon config from disk.
|
||||
func LoadPersistedConfig(path string) (PersistedConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return PersistedConfig{}, nil
|
||||
}
|
||||
return PersistedConfig{}, fmt.Errorf("read daemon config: %w", err)
|
||||
}
|
||||
var cfg PersistedConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return PersistedConfig{}, fmt.Errorf("parse daemon config: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SavePersistedConfig writes the daemon config to disk.
|
||||
func SavePersistedConfig(path string, cfg PersistedConfig) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create daemon config directory: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode daemon config: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil {
|
||||
return fmt.Errorf("write daemon config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,63 +4,164 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
"github.com/multica-ai/multica/server/internal/daemon/execenv"
|
||||
"github.com/multica-ai/multica/server/internal/daemon/usage"
|
||||
"github.com/multica-ai/multica/server/pkg/agent"
|
||||
)
|
||||
|
||||
// workspaceState tracks registered runtimes for a single workspace.
|
||||
type workspaceState struct {
|
||||
workspaceID string
|
||||
runtimeIDs []string
|
||||
}
|
||||
|
||||
// Daemon is the local agent runtime that polls for and executes tasks.
|
||||
type Daemon struct {
|
||||
cfg Config
|
||||
client *Client
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
workspaces map[string]*workspaceState
|
||||
runtimeIndex map[string]Runtime // runtimeID -> Runtime for provider lookups
|
||||
reloading sync.Mutex // prevents concurrent reloadWorkspaces
|
||||
}
|
||||
|
||||
// New creates a new Daemon instance.
|
||||
func New(cfg Config, logger *slog.Logger) *Daemon {
|
||||
return &Daemon{
|
||||
cfg: cfg,
|
||||
client: NewClient(cfg.ServerBaseURL),
|
||||
logger: logger,
|
||||
cfg: cfg,
|
||||
client: NewClient(cfg.ServerBaseURL),
|
||||
logger: logger,
|
||||
workspaces: make(map[string]*workspaceState),
|
||||
runtimeIndex: make(map[string]Runtime),
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the daemon: pairs if needed, registers runtimes, then polls for tasks.
|
||||
// Run starts the daemon: resolves auth, registers runtimes, then polls for tasks.
|
||||
func (d *Daemon) Run(ctx context.Context) error {
|
||||
agentNames := make([]string, 0, len(d.cfg.Agents))
|
||||
for name := range d.cfg.Agents {
|
||||
agentNames = append(agentNames, name)
|
||||
}
|
||||
d.logger.Info("starting daemon", "agents", agentNames, "workspace_id", d.cfg.WorkspaceID, "server", d.cfg.ServerBaseURL, "repos_root", d.cfg.ReposRoot)
|
||||
d.logger.Info("starting daemon", "agents", agentNames, "server", d.cfg.ServerBaseURL)
|
||||
|
||||
if strings.TrimSpace(d.cfg.WorkspaceID) == "" {
|
||||
workspaceID, err := d.ensurePaired(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.cfg.WorkspaceID = workspaceID
|
||||
d.logger.Info("pairing completed", "workspace_id", workspaceID)
|
||||
}
|
||||
|
||||
runtimes, err := d.registerRuntimes(ctx)
|
||||
if err != nil {
|
||||
// Load auth token from CLI config.
|
||||
if err := d.resolveAuth(); err != nil {
|
||||
return err
|
||||
}
|
||||
runtimeIDs := make([]string, 0, len(runtimes))
|
||||
for _, rt := range runtimes {
|
||||
d.logger.Info("registered runtime", "id", rt.ID, "provider", rt.Provider, "status", rt.Status)
|
||||
runtimeIDs = append(runtimeIDs, rt.ID)
|
||||
|
||||
// Load and register watched workspaces.
|
||||
if err := d.loadWatchedWorkspaces(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go d.heartbeatLoop(ctx, runtimes)
|
||||
go d.usageScanLoop(ctx, runtimes)
|
||||
return d.pollLoop(ctx, runtimeIDs)
|
||||
runtimeIDs := d.allRuntimeIDs()
|
||||
if len(runtimeIDs) == 0 {
|
||||
return fmt.Errorf("no runtimes registered")
|
||||
}
|
||||
|
||||
// Start config watcher for hot-reload.
|
||||
go d.configWatchLoop(ctx)
|
||||
|
||||
go d.heartbeatLoop(ctx)
|
||||
go d.usageScanLoop(ctx)
|
||||
return d.pollLoop(ctx)
|
||||
}
|
||||
|
||||
func (d *Daemon) registerRuntimes(ctx context.Context) ([]Runtime, error) {
|
||||
// resolveAuth loads the auth token from the CLI config.
|
||||
func (d *Daemon) resolveAuth() error {
|
||||
cfg, err := cli.LoadCLIConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load CLI config: %w", err)
|
||||
}
|
||||
if cfg.Token == "" {
|
||||
d.logger.Warn("not authenticated — run 'multica auth login' to authenticate, then restart the daemon")
|
||||
return fmt.Errorf("not authenticated: run 'multica auth login' first")
|
||||
}
|
||||
d.client.SetToken(cfg.Token)
|
||||
d.logger.Info("authenticated")
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadWatchedWorkspaces reads watched workspaces from CLI config and registers runtimes.
|
||||
func (d *Daemon) loadWatchedWorkspaces(ctx context.Context) error {
|
||||
cfg, err := cli.LoadCLIConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load CLI config: %w", err)
|
||||
}
|
||||
|
||||
if len(cfg.WatchedWorkspaces) == 0 {
|
||||
return fmt.Errorf("no watched workspaces configured: run 'multica watch <id>' to add one")
|
||||
}
|
||||
|
||||
var registered int
|
||||
for _, ws := range cfg.WatchedWorkspaces {
|
||||
runtimes, err := d.registerRuntimesForWorkspace(ctx, ws.ID)
|
||||
if err != nil {
|
||||
d.logger.Error("failed to register runtimes", "workspace_id", ws.ID, "name", ws.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
runtimeIDs := make([]string, len(runtimes))
|
||||
for i, rt := range runtimes {
|
||||
runtimeIDs[i] = rt.ID
|
||||
d.logger.Info("registered runtime", "workspace_id", ws.ID, "runtime_id", rt.ID, "provider", rt.Provider)
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.workspaces[ws.ID] = &workspaceState{workspaceID: ws.ID, runtimeIDs: runtimeIDs}
|
||||
for _, rt := range runtimes {
|
||||
d.runtimeIndex[rt.ID] = rt
|
||||
}
|
||||
d.mu.Unlock()
|
||||
d.logger.Info("watching workspace", "workspace_id", ws.ID, "name", ws.Name, "runtimes", len(runtimes))
|
||||
registered++
|
||||
}
|
||||
|
||||
if registered == 0 {
|
||||
return fmt.Errorf("failed to register runtimes for any of the %d watched workspace(s)", len(cfg.WatchedWorkspaces))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// allRuntimeIDs returns all runtime IDs across all watched workspaces.
|
||||
func (d *Daemon) allRuntimeIDs() []string {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
var ids []string
|
||||
for _, ws := range d.workspaces {
|
||||
ids = append(ids, ws.runtimeIDs...)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// findRuntime looks up a Runtime by its ID.
|
||||
func (d *Daemon) findRuntime(id string) *Runtime {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if rt, ok := d.runtimeIndex[id]; ok {
|
||||
return &rt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// providerToRuntimeMap returns a mapping from provider name to runtime ID.
|
||||
func (d *Daemon) providerToRuntimeMap() map[string]string {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
m := make(map[string]string)
|
||||
for id, rt := range d.runtimeIndex {
|
||||
m[rt.Provider] = id
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID string) ([]Runtime, error) {
|
||||
var runtimes []map[string]string
|
||||
for name, entry := range d.cfg.Agents {
|
||||
version, err := agent.DetectVersion(ctx, entry.Path)
|
||||
|
|
@ -80,7 +181,7 @@ func (d *Daemon) registerRuntimes(ctx context.Context) ([]Runtime, error) {
|
|||
}
|
||||
|
||||
req := map[string]any{
|
||||
"workspace_id": d.cfg.WorkspaceID,
|
||||
"workspace_id": workspaceID,
|
||||
"daemon_id": d.cfg.DaemonID,
|
||||
"device_name": d.cfg.DeviceName,
|
||||
"runtimes": runtimes,
|
||||
|
|
@ -96,77 +197,106 @@ func (d *Daemon) registerRuntimes(ctx context.Context) ([]Runtime, error) {
|
|||
return rts, nil
|
||||
}
|
||||
|
||||
func (d *Daemon) ensurePaired(ctx context.Context) (string, error) {
|
||||
// Use a deterministic agent for the pairing session metadata (prefer codex for backward compat).
|
||||
var firstName string
|
||||
var firstEntry AgentEntry
|
||||
for _, preferred := range []string{"codex", "claude"} {
|
||||
if entry, ok := d.cfg.Agents[preferred]; ok {
|
||||
firstName = preferred
|
||||
firstEntry = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
version, err := agent.DetectVersion(ctx, firstEntry.Path)
|
||||
// configWatchLoop periodically checks for config file changes and reloads workspaces.
|
||||
func (d *Daemon) configWatchLoop(ctx context.Context) {
|
||||
configPath, err := cli.CLIConfigPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
d.logger.Warn("cannot watch config file", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
session, err := d.client.CreatePairingSession(ctx, map[string]string{
|
||||
"daemon_id": d.cfg.DaemonID,
|
||||
"device_name": d.cfg.DeviceName,
|
||||
"runtime_name": d.cfg.RuntimeName,
|
||||
"runtime_type": firstName,
|
||||
"runtime_version": version,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create pairing session: %w", err)
|
||||
}
|
||||
if session.LinkURL != nil {
|
||||
d.logger.Info("open this link to pair the daemon", "url", *session.LinkURL)
|
||||
} else {
|
||||
d.logger.Info("pairing session created", "token", session.Token)
|
||||
var lastModTime time.Time
|
||||
if info, err := os.Stat(configPath); err == nil {
|
||||
lastModTime = info.ModTime()
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(DefaultConfigReloadInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
current, err := d.client.GetPairingSession(ctx, session.Token)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("poll pairing session: %w", err)
|
||||
}
|
||||
|
||||
switch current.Status {
|
||||
case "approved", "claimed":
|
||||
if current.WorkspaceID == nil || strings.TrimSpace(*current.WorkspaceID) == "" {
|
||||
return "", fmt.Errorf("pairing session approved without workspace")
|
||||
return
|
||||
case <-ticker.C:
|
||||
info, err := os.Stat(configPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := SavePersistedConfig(d.cfg.ConfigPath, PersistedConfig{
|
||||
WorkspaceID: strings.TrimSpace(*current.WorkspaceID),
|
||||
}); err != nil {
|
||||
return "", err
|
||||
if !info.ModTime().After(lastModTime) {
|
||||
continue
|
||||
}
|
||||
if current.Status != "claimed" {
|
||||
if _, err := d.client.ClaimPairingSession(ctx, current.Token); err != nil {
|
||||
return "", fmt.Errorf("claim pairing session: %w", err)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(*current.WorkspaceID), nil
|
||||
case "expired":
|
||||
return "", fmt.Errorf("pairing session expired before approval")
|
||||
}
|
||||
|
||||
if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil {
|
||||
return "", err
|
||||
lastModTime = info.ModTime()
|
||||
d.reloadWorkspaces(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) heartbeatLoop(ctx context.Context, runtimes []Runtime) {
|
||||
// reloadWorkspaces reconciles the active workspace set with the config file.
|
||||
// NOTE: Token changes (e.g. re-login as a different user) are not picked up;
|
||||
// the daemon must be restarted for a new auth token to take effect.
|
||||
func (d *Daemon) reloadWorkspaces(ctx context.Context) {
|
||||
d.reloading.Lock()
|
||||
defer d.reloading.Unlock()
|
||||
|
||||
cfg, err := cli.LoadCLIConfig()
|
||||
if err != nil {
|
||||
d.logger.Warn("reload config failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
newIDs := make(map[string]string) // id -> name
|
||||
for _, ws := range cfg.WatchedWorkspaces {
|
||||
newIDs[ws.ID] = ws.Name
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
currentIDs := make(map[string]bool)
|
||||
for id := range d.workspaces {
|
||||
currentIDs[id] = true
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
// Register runtimes for newly added workspaces.
|
||||
for id, name := range newIDs {
|
||||
if !currentIDs[id] {
|
||||
runtimes, err := d.registerRuntimesForWorkspace(ctx, id)
|
||||
if err != nil {
|
||||
d.logger.Error("register runtimes for new workspace failed", "workspace_id", id, "error", err)
|
||||
continue
|
||||
}
|
||||
runtimeIDs := make([]string, len(runtimes))
|
||||
for i, rt := range runtimes {
|
||||
runtimeIDs[i] = rt.ID
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.workspaces[id] = &workspaceState{workspaceID: id, runtimeIDs: runtimeIDs}
|
||||
for _, rt := range runtimes {
|
||||
d.runtimeIndex[rt.ID] = rt
|
||||
}
|
||||
d.mu.Unlock()
|
||||
d.logger.Info("now watching workspace", "workspace_id", id, "name", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove workspaces no longer in config.
|
||||
// NOTE: runtimes are not deregistered server-side; they will go offline
|
||||
// after heartbeats stop arriving (within HeartbeatInterval).
|
||||
for id := range currentIDs {
|
||||
if _, ok := newIDs[id]; !ok {
|
||||
d.mu.Lock()
|
||||
if ws, exists := d.workspaces[id]; exists {
|
||||
for _, rid := range ws.runtimeIDs {
|
||||
delete(d.runtimeIndex, rid)
|
||||
}
|
||||
}
|
||||
delete(d.workspaces, id)
|
||||
d.mu.Unlock()
|
||||
d.logger.Info("stopped watching workspace", "workspace_id", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) heartbeatLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(d.cfg.HeartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
|
|
@ -175,16 +305,19 @@ func (d *Daemon) heartbeatLoop(ctx context.Context, runtimes []Runtime) {
|
|||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
for _, rt := range runtimes {
|
||||
resp, err := d.client.SendHeartbeat(ctx, rt.ID)
|
||||
for _, rid := range d.allRuntimeIDs() {
|
||||
resp, err := d.client.SendHeartbeat(ctx, rid)
|
||||
if err != nil {
|
||||
d.logger.Warn("heartbeat failed", "runtime_id", rt.ID, "error", err)
|
||||
d.logger.Warn("heartbeat failed", "runtime_id", rid, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle pending ping requests.
|
||||
if resp.PendingPing != nil {
|
||||
go d.handlePing(ctx, rt, resp.PendingPing.ID)
|
||||
rt := d.findRuntime(rid)
|
||||
if rt != nil {
|
||||
go d.handlePing(ctx, *rt, resp.PendingPing.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -265,31 +398,28 @@ func (d *Daemon) handlePing(ctx context.Context, rt Runtime, pingID string) {
|
|||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) usageScanLoop(ctx context.Context, runtimes []Runtime) {
|
||||
func (d *Daemon) usageScanLoop(ctx context.Context) {
|
||||
scanner := usage.NewScanner(d.logger)
|
||||
|
||||
// Build provider -> runtime ID mapping.
|
||||
providerToRuntime := make(map[string]string)
|
||||
for _, rt := range runtimes {
|
||||
providerToRuntime[rt.Provider] = rt.ID
|
||||
}
|
||||
|
||||
report := func() {
|
||||
records := scanner.Scan()
|
||||
if len(records) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Build provider -> runtime ID mapping from current state.
|
||||
providerToRuntime := d.providerToRuntimeMap()
|
||||
|
||||
// Group records by provider to send to the correct runtime.
|
||||
byProvider := make(map[string][]map[string]any)
|
||||
for _, r := range records {
|
||||
byProvider[r.Provider] = append(byProvider[r.Provider], map[string]any{
|
||||
"date": r.Date,
|
||||
"provider": r.Provider,
|
||||
"model": r.Model,
|
||||
"input_tokens": r.InputTokens,
|
||||
"output_tokens": r.OutputTokens,
|
||||
"cache_read_tokens": r.CacheReadTokens,
|
||||
"date": r.Date,
|
||||
"provider": r.Provider,
|
||||
"model": r.Model,
|
||||
"input_tokens": r.InputTokens,
|
||||
"output_tokens": r.OutputTokens,
|
||||
"cache_read_tokens": r.CacheReadTokens,
|
||||
"cache_write_tokens": r.CacheWriteTokens,
|
||||
})
|
||||
}
|
||||
|
|
@ -324,7 +454,7 @@ func (d *Daemon) usageScanLoop(ctx context.Context, runtimes []Runtime) {
|
|||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) pollLoop(ctx context.Context, runtimeIDs []string) error {
|
||||
func (d *Daemon) pollLoop(ctx context.Context) error {
|
||||
pollOffset := 0
|
||||
pollCount := 0
|
||||
for {
|
||||
|
|
@ -334,6 +464,14 @@ func (d *Daemon) pollLoop(ctx context.Context, runtimeIDs []string) error {
|
|||
default:
|
||||
}
|
||||
|
||||
runtimeIDs := d.allRuntimeIDs()
|
||||
if len(runtimeIDs) == 0 {
|
||||
if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
claimed := false
|
||||
n := len(runtimeIDs)
|
||||
for i := 0; i < n; i++ {
|
||||
|
|
@ -421,7 +559,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task) (TaskResult, error) {
|
|||
}
|
||||
env, err := execenv.Prepare(execenv.PrepareParams{
|
||||
WorkspacesRoot: d.cfg.WorkspacesRoot,
|
||||
ReposRoot: d.cfg.ReposRoot,
|
||||
RepoPath: task.Context.RepoPath,
|
||||
TaskID: task.ID,
|
||||
AgentName: task.Context.Agent.Name,
|
||||
Task: taskCtx,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const (
|
|||
// PrepareParams holds all inputs needed to set up an execution environment.
|
||||
type PrepareParams struct {
|
||||
WorkspacesRoot string // base path for all envs (e.g., ~/multica_workspaces)
|
||||
ReposRoot string // source git repo (for worktree creation)
|
||||
RepoPath string // source git repo path (for worktree creation), provided per-task by server
|
||||
TaskID string // task UUID — used for directory name
|
||||
AgentName string // for git branch naming only
|
||||
Task TaskContextForEnv // context data for writing files
|
||||
|
|
@ -100,8 +100,8 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) {
|
|||
}
|
||||
|
||||
// Detect git repo and set up worktree if available.
|
||||
if params.ReposRoot != "" {
|
||||
if gitRoot, ok := detectGitRepo(params.ReposRoot); ok {
|
||||
if params.RepoPath != "" {
|
||||
if gitRoot, ok := detectGitRepo(params.RepoPath); ok {
|
||||
branchName := fmt.Sprintf("agent/%s/%s", sanitizeName(params.AgentName), shortID(params.TaskID))
|
||||
|
||||
// Get the default branch as base ref.
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ func TestPrepareDirectoryMode(t *testing.T) {
|
|||
|
||||
env, err := Prepare(PrepareParams{
|
||||
WorkspacesRoot: workspacesRoot,
|
||||
ReposRoot: reposRoot,
|
||||
RepoPath: reposRoot,
|
||||
TaskID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
AgentName: "Test Agent",
|
||||
Task: TaskContextForEnv{
|
||||
|
|
@ -176,7 +176,7 @@ func TestPrepareGitWorktreeMode(t *testing.T) {
|
|||
|
||||
env, err := Prepare(PrepareParams{
|
||||
WorkspacesRoot: workspacesRoot,
|
||||
ReposRoot: reposRoot,
|
||||
RepoPath: reposRoot,
|
||||
TaskID: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
|
||||
AgentName: "Code Reviewer",
|
||||
Task: TaskContextForEnv{
|
||||
|
|
@ -334,7 +334,7 @@ func TestCleanupGitWorktree(t *testing.T) {
|
|||
|
||||
env, err := Prepare(PrepareParams{
|
||||
WorkspacesRoot: workspacesRoot,
|
||||
ReposRoot: reposRoot,
|
||||
RepoPath: reposRoot,
|
||||
TaskID: "c3d4e5f6-a7b8-9012-cdef-123456789012",
|
||||
AgentName: "Cleanup Test",
|
||||
Task: TaskContextForEnv{IssueTitle: "Cleanup test"},
|
||||
|
|
@ -477,7 +477,7 @@ func TestCleanupPreservesLogs(t *testing.T) {
|
|||
|
||||
env, err := Prepare(PrepareParams{
|
||||
WorkspacesRoot: workspacesRoot,
|
||||
ReposRoot: t.TempDir(), // not a git repo
|
||||
RepoPath: t.TempDir(), // not a git repo
|
||||
TaskID: "d4e5f6a7-b8c9-0123-defa-234567890123",
|
||||
AgentName: "Preserve Test",
|
||||
Task: TaskContextForEnv{IssueTitle: "Preserve test"},
|
||||
|
|
|
|||
|
|
@ -14,27 +14,6 @@ type Runtime struct {
|
|||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// PairingSession represents a daemon pairing session.
|
||||
type PairingSession struct {
|
||||
Token string `json:"token"`
|
||||
DaemonID string `json:"daemon_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
RuntimeName string `json:"runtime_name"`
|
||||
RuntimeType string `json:"runtime_type"`
|
||||
RuntimeVersion string `json:"runtime_version"`
|
||||
WorkspaceID *string `json:"workspace_id"`
|
||||
Status string `json:"status"`
|
||||
ApprovedAt *string `json:"approved_at"`
|
||||
ClaimedAt *string `json:"claimed_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
LinkURL *string `json:"link_url"`
|
||||
}
|
||||
|
||||
// PersistedConfig is the JSON structure saved to ~/.multica/daemon.json.
|
||||
type PersistedConfig struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
}
|
||||
|
||||
// Task represents a claimed task from the server.
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
|
|
@ -49,6 +28,7 @@ type TaskContext struct {
|
|||
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.
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ type CreateIssueRequest struct {
|
|||
ParentIssueID *string `json:"parent_issue_id"`
|
||||
AcceptanceCriteria []any `json:"acceptance_criteria"`
|
||||
ContextRefs []any `json:"context_refs"`
|
||||
DueDate *string `json:"due_date"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -215,6 +216,16 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
parentIssueID = parseUUID(*req.ParentIssueID)
|
||||
}
|
||||
|
||||
var dueDate pgtype.Timestamptz
|
||||
if req.DueDate != nil && *req.DueDate != "" {
|
||||
t, err := time.Parse(time.RFC3339, *req.DueDate)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid due_date format, expected RFC3339")
|
||||
return
|
||||
}
|
||||
dueDate = pgtype.Timestamptz{Time: t, Valid: true}
|
||||
}
|
||||
|
||||
issue, err := h.Queries.CreateIssue(r.Context(), db.CreateIssueParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
Title: req.Title,
|
||||
|
|
@ -229,6 +240,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
AcceptanceCriteria: ac,
|
||||
ContextRefs: cr,
|
||||
Position: 0,
|
||||
DueDate: dueDate,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("create issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
|
||||
|
|
|
|||
|
|
@ -2,9 +2,14 @@ package handler
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
|
@ -383,6 +388,476 @@ func (h *Handler) DeleteSkill(w http.ResponseWriter, r *http.Request) {
|
|||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- Skill import ---
|
||||
|
||||
type ImportSkillRequest struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// importedSkill holds the data extracted from an external source.
|
||||
type importedSkill struct {
|
||||
name string
|
||||
description string
|
||||
content string // SKILL.md body
|
||||
files []importedFile
|
||||
}
|
||||
|
||||
type importedFile struct {
|
||||
path string
|
||||
content string
|
||||
}
|
||||
|
||||
// --- ClawHub types ---
|
||||
|
||||
type clawhubGetSkillResponse struct {
|
||||
Skill clawhubSkill `json:"skill"`
|
||||
LatestVersion *clawhubLatestVersion `json:"latestVersion"`
|
||||
}
|
||||
|
||||
type clawhubSkill struct {
|
||||
Slug string `json:"slug"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Summary string `json:"summary"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
}
|
||||
|
||||
type clawhubLatestVersion struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type clawhubVersionDetailResponse struct {
|
||||
Version clawhubVersionDetail `json:"version"`
|
||||
}
|
||||
|
||||
type clawhubVersionDetail struct {
|
||||
Version string `json:"version"`
|
||||
Files []clawhubFileEntry `json:"files"`
|
||||
}
|
||||
|
||||
type clawhubFileEntry struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// --- GitHub types (for skills.sh) ---
|
||||
|
||||
type githubContentEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"` // "file" or "dir"
|
||||
DownloadURL string `json:"download_url"`
|
||||
}
|
||||
|
||||
// --- URL detection ---
|
||||
|
||||
// importSource identifies where a URL points.
|
||||
type importSource int
|
||||
|
||||
const (
|
||||
sourceClawHub importSource = iota
|
||||
sourceSkillsSh
|
||||
)
|
||||
|
||||
// detectImportSource determines the source from a URL.
|
||||
// Returns the source and a normalized URL (with scheme).
|
||||
func detectImportSource(raw string) (importSource, string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, "", fmt.Errorf("empty URL")
|
||||
}
|
||||
|
||||
normalized := raw
|
||||
if !strings.HasPrefix(normalized, "http://") && !strings.HasPrefix(normalized, "https://") {
|
||||
normalized = "https://" + normalized
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(normalized)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
switch {
|
||||
case host == "skills.sh" || host == "www.skills.sh":
|
||||
return sourceSkillsSh, normalized, nil
|
||||
case host == "clawhub.ai" || host == "www.clawhub.ai":
|
||||
return sourceClawHub, normalized, nil
|
||||
default:
|
||||
// If no host (bare slug), default to clawhub
|
||||
if !strings.Contains(raw, "/") || !strings.Contains(raw, ".") {
|
||||
return sourceClawHub, raw, nil
|
||||
}
|
||||
return 0, "", fmt.Errorf("unsupported source: %s (supported: clawhub.ai, skills.sh)", host)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ClawHub import ---
|
||||
|
||||
// parseClawHubSlug extracts the skill slug from a clawhub.ai URL.
|
||||
func parseClawHubSlug(raw string) (string, error) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
// /{owner}/{slug} — take the last segment as the slug
|
||||
if len(parts) == 2 {
|
||||
return parts[1], nil
|
||||
}
|
||||
if len(parts) == 1 && parts[0] != "" {
|
||||
return parts[0], nil
|
||||
}
|
||||
// Bare slug (no path)
|
||||
if raw == parsed.Host || parsed.Path == "" || parsed.Path == "/" {
|
||||
return "", fmt.Errorf("missing skill slug in URL")
|
||||
}
|
||||
return "", fmt.Errorf("could not extract skill slug from URL: %s", raw)
|
||||
}
|
||||
|
||||
func fetchFromClawHub(httpClient *http.Client, rawURL string) (*importedSkill, error) {
|
||||
slug, err := parseClawHubSlug(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
apiBase := "https://clawhub.ai/api/v1"
|
||||
|
||||
// 1. Fetch skill metadata
|
||||
skillResp, err := httpClient.Get(apiBase + "/skills/" + url.PathEscape(slug))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to reach ClawHub: %w", err)
|
||||
}
|
||||
defer skillResp.Body.Close()
|
||||
|
||||
if skillResp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("skill not found on ClawHub: %s", slug)
|
||||
}
|
||||
if skillResp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("ClawHub returned status %d", skillResp.StatusCode)
|
||||
}
|
||||
|
||||
var chResp clawhubGetSkillResponse
|
||||
if err := json.NewDecoder(skillResp.Body).Decode(&chResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse ClawHub response")
|
||||
}
|
||||
chSkill := chResp.Skill
|
||||
|
||||
// 2. Determine latest version and fetch file list
|
||||
latestVersion := ""
|
||||
if v, ok := chSkill.Tags["latest"]; ok {
|
||||
latestVersion = v
|
||||
} else if chResp.LatestVersion != nil {
|
||||
latestVersion = chResp.LatestVersion.Version
|
||||
}
|
||||
|
||||
var filePaths []string
|
||||
if latestVersion != "" {
|
||||
vURL := fmt.Sprintf("%s/skills/%s/versions/%s", apiBase, url.PathEscape(slug), url.PathEscape(latestVersion))
|
||||
vResp, err := httpClient.Get(vURL)
|
||||
if err == nil {
|
||||
defer vResp.Body.Close()
|
||||
if vResp.StatusCode == http.StatusOK {
|
||||
var vDetail clawhubVersionDetailResponse
|
||||
if err := json.NewDecoder(vResp.Body).Decode(&vDetail); err == nil {
|
||||
for _, f := range vDetail.Version.Files {
|
||||
filePaths = append(filePaths, f.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Download each file
|
||||
result := &importedSkill{
|
||||
name: chSkill.DisplayName,
|
||||
description: chSkill.Summary,
|
||||
}
|
||||
if result.name == "" {
|
||||
result.name = slug
|
||||
}
|
||||
|
||||
for _, fp := range filePaths {
|
||||
fileURL := fmt.Sprintf("%s/skills/%s/file?path=%s", apiBase, url.PathEscape(slug), url.QueryEscape(fp))
|
||||
if latestVersion != "" {
|
||||
fileURL += "&version=" + url.QueryEscape(latestVersion)
|
||||
}
|
||||
body, err := fetchRawFile(httpClient, fileURL)
|
||||
if err != nil {
|
||||
slog.Warn("clawhub import: file download failed", "path", fp, "error", err)
|
||||
continue
|
||||
}
|
||||
if fp == "SKILL.md" {
|
||||
result.content = string(body)
|
||||
} else {
|
||||
result.files = append(result.files, importedFile{path: fp, content: string(body)})
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --- skills.sh import ---
|
||||
|
||||
// parseSkillsShParts extracts owner, repo, skill-name from a skills.sh URL.
|
||||
// URL format: https://skills.sh/{owner}/{repo}/{skill-name}
|
||||
func parseSkillsShParts(raw string) (owner, repo, skillName string, err error) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
if len(parts) != 3 {
|
||||
return "", "", "", fmt.Errorf("expected URL format: skills.sh/{owner}/{repo}/{skill-name}, got: %s", parsed.Path)
|
||||
}
|
||||
return parts[0], parts[1], parts[2], nil
|
||||
}
|
||||
|
||||
func fetchFromSkillsSh(httpClient *http.Client, rawURL string) (*importedSkill, error) {
|
||||
owner, repo, skillName, err := parseSkillsShParts(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Skills can be at different paths depending on the repo structure:
|
||||
// skills/{name}/SKILL.md (most common)
|
||||
// plugin/skills/{name}/SKILL.md (e.g. microsoft repos)
|
||||
// {name}/SKILL.md (skill at repo root level)
|
||||
rawPrefix := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/main",
|
||||
url.PathEscape(owner), url.PathEscape(repo))
|
||||
|
||||
candidatePaths := []string{
|
||||
"skills/" + skillName,
|
||||
"plugin/skills/" + skillName,
|
||||
skillName,
|
||||
}
|
||||
|
||||
var skillMdBody []byte
|
||||
var skillDir string
|
||||
for _, dir := range candidatePaths {
|
||||
body, err := fetchRawFile(httpClient, rawPrefix+"/"+dir+"/SKILL.md")
|
||||
if err == nil {
|
||||
skillMdBody = body
|
||||
skillDir = dir
|
||||
break
|
||||
}
|
||||
}
|
||||
if skillMdBody == nil {
|
||||
return nil, fmt.Errorf("SKILL.md not found in repository %s/%s for skill %s", owner, repo, skillName)
|
||||
}
|
||||
|
||||
// Parse name and description from YAML frontmatter
|
||||
name, description := parseSkillFrontmatter(string(skillMdBody))
|
||||
if name == "" {
|
||||
name = skillName
|
||||
}
|
||||
|
||||
result := &importedSkill{
|
||||
name: name,
|
||||
description: description,
|
||||
content: string(skillMdBody),
|
||||
}
|
||||
|
||||
// 2. List supporting files via GitHub API
|
||||
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/%s",
|
||||
url.PathEscape(owner), url.PathEscape(repo), skillDir)
|
||||
dirResp, err := httpClient.Get(apiURL)
|
||||
if err != nil || dirResp.StatusCode != http.StatusOK {
|
||||
// Can't list files — return what we have (SKILL.md only)
|
||||
if dirResp != nil {
|
||||
dirResp.Body.Close()
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
defer dirResp.Body.Close()
|
||||
|
||||
var entries []githubContentEntry
|
||||
if err := json.NewDecoder(dirResp.Body).Decode(&entries); err != nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 3. Recursively collect files (excluding SKILL.md and LICENSE)
|
||||
var allFiles []githubContentEntry
|
||||
collectGitHubFiles(httpClient, entries, &allFiles, apiURL)
|
||||
|
||||
// 4. Download each file
|
||||
basePath := skillDir + "/"
|
||||
for _, entry := range allFiles {
|
||||
if entry.DownloadURL == "" {
|
||||
continue
|
||||
}
|
||||
body, err := fetchRawFile(httpClient, entry.DownloadURL)
|
||||
if err != nil {
|
||||
slog.Warn("skills.sh import: file download failed", "path", entry.Path, "error", err)
|
||||
continue
|
||||
}
|
||||
// Convert absolute GitHub path to relative path within skill
|
||||
relPath := strings.TrimPrefix(entry.Path, basePath)
|
||||
result.files = append(result.files, importedFile{path: relPath, content: string(body)})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// collectGitHubFiles recursively collects file entries from a GitHub directory listing.
|
||||
func collectGitHubFiles(httpClient *http.Client, entries []githubContentEntry, out *[]githubContentEntry, parentURL string) {
|
||||
for _, entry := range entries {
|
||||
lower := strings.ToLower(entry.Name)
|
||||
if lower == "skill.md" || lower == "license" || lower == "license.txt" || lower == "license.md" {
|
||||
continue
|
||||
}
|
||||
if entry.Type == "file" {
|
||||
*out = append(*out, entry)
|
||||
} else if entry.Type == "dir" {
|
||||
// Fetch subdirectory contents
|
||||
subURL := parentURL + "/" + url.PathEscape(entry.Name)
|
||||
subResp, err := httpClient.Get(subURL)
|
||||
if err != nil || subResp.StatusCode != http.StatusOK {
|
||||
if subResp != nil {
|
||||
subResp.Body.Close()
|
||||
}
|
||||
continue
|
||||
}
|
||||
var subEntries []githubContentEntry
|
||||
json.NewDecoder(subResp.Body).Decode(&subEntries)
|
||||
subResp.Body.Close()
|
||||
collectGitHubFiles(httpClient, subEntries, out, subURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseSkillFrontmatter extracts name and description from YAML frontmatter in SKILL.md.
|
||||
func parseSkillFrontmatter(content string) (name, description string) {
|
||||
if !strings.HasPrefix(content, "---") {
|
||||
return "", ""
|
||||
}
|
||||
end := strings.Index(content[3:], "---")
|
||||
if end < 0 {
|
||||
return "", ""
|
||||
}
|
||||
frontmatter := content[3 : 3+end]
|
||||
for _, line := range strings.Split(frontmatter, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "name:") {
|
||||
name = strings.TrimSpace(strings.TrimPrefix(line, "name:"))
|
||||
name = strings.Trim(name, "\"'")
|
||||
} else if strings.HasPrefix(line, "description:") {
|
||||
description = strings.TrimSpace(strings.TrimPrefix(line, "description:"))
|
||||
description = strings.Trim(description, "\"'")
|
||||
}
|
||||
}
|
||||
return name, description
|
||||
}
|
||||
|
||||
// --- Shared helpers ---
|
||||
|
||||
// fetchRawFile downloads a URL and returns the body bytes. Limit 1MB.
|
||||
func fetchRawFile(httpClient *http.Client, fileURL string) ([]byte, error) {
|
||||
resp, err := httpClient.Get(fileURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
}
|
||||
|
||||
// --- Import handler ---
|
||||
|
||||
func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
creatorID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req ImportSkillRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
source, normalized, err := detectImportSource(req.URL)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
var imported *importedSkill
|
||||
switch source {
|
||||
case sourceClawHub:
|
||||
imported, err = fetchFromClawHub(httpClient, normalized)
|
||||
case sourceSkillsSh:
|
||||
imported, err = fetchFromSkillsSh(httpClient, normalized)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Create skill in database
|
||||
tx, err := h.TxStarter.Begin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to start transaction")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
|
||||
skill, err := qtx.CreateSkill(r.Context(), db.CreateSkillParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
Name: imported.name,
|
||||
Description: imported.description,
|
||||
Content: imported.content,
|
||||
Config: []byte("{}"),
|
||||
CreatedBy: parseUUID(creatorID),
|
||||
})
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "a skill with this name already exists")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to create skill: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
fileResps := make([]SkillFileResponse, 0, len(imported.files))
|
||||
for _, f := range imported.files {
|
||||
if !validateFilePath(f.path) {
|
||||
continue
|
||||
}
|
||||
sf, err := qtx.UpsertSkillFile(r.Context(), db.UpsertSkillFileParams{
|
||||
SkillID: skill.ID,
|
||||
Path: f.path,
|
||||
Content: f.content,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fileResps = append(fileResps, skillFileToResponse(sf))
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to commit")
|
||||
return
|
||||
}
|
||||
|
||||
resp := SkillWithFilesResponse{
|
||||
SkillResponse: skillToResponse(skill),
|
||||
Files: fileResps,
|
||||
}
|
||||
h.publish("skill:created", workspaceID, "member", creatorID, map[string]any{"skill": resp})
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// --- Skill File endpoints ---
|
||||
|
||||
func (h *Handler) ListSkillFiles(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
|
|
@ -16,26 +16,27 @@ INSERT INTO issue (
|
|||
workspace_id, title, description, status, priority,
|
||||
assignee_type, assignee_id, creator_type, creator_id,
|
||||
parent_issue_id, acceptance_criteria, context_refs,
|
||||
position
|
||||
position, due_date
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
|
||||
) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateIssueParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Title string `json:"title"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
AssigneeType pgtype.Text `json:"assignee_type"`
|
||||
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||
CreatorType string `json:"creator_type"`
|
||||
CreatorID pgtype.UUID `json:"creator_id"`
|
||||
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
|
||||
AcceptanceCriteria []byte `json:"acceptance_criteria"`
|
||||
ContextRefs []byte `json:"context_refs"`
|
||||
Position float64 `json:"position"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Title string `json:"title"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
AssigneeType pgtype.Text `json:"assignee_type"`
|
||||
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||
CreatorType string `json:"creator_type"`
|
||||
CreatorID pgtype.UUID `json:"creator_id"`
|
||||
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
|
||||
AcceptanceCriteria []byte `json:"acceptance_criteria"`
|
||||
ContextRefs []byte `json:"context_refs"`
|
||||
Position float64 `json:"position"`
|
||||
DueDate pgtype.Timestamptz `json:"due_date"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue, error) {
|
||||
|
|
@ -53,6 +54,7 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue
|
|||
arg.AcceptanceCriteria,
|
||||
arg.ContextRefs,
|
||||
arg.Position,
|
||||
arg.DueDate,
|
||||
)
|
||||
var i Issue
|
||||
err := row.Scan(
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ INSERT INTO issue (
|
|||
workspace_id, title, description, status, priority,
|
||||
assignee_type, assignee_id, creator_type, creator_id,
|
||||
parent_issue_id, acceptance_criteria, context_refs,
|
||||
position
|
||||
position, due_date
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
|
||||
) RETURNING *;
|
||||
|
||||
-- name: UpdateIssue :one
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue