Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion apps/docs/content/docs/project-resources.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ Today two resource types ship: [`github_repo`](#resource-type-github_repo) (clon

## Resource type: `github_repo`

The default resource type — checked out per task into an isolated worktree:
The default resource type — checked out per task into an isolated worktree.
The URL may be a network Git URL or an absolute daemon-scoped `file://` URL:

```json
{
Expand All @@ -40,6 +41,23 @@ The default resource type — checked out per task into an isolated worktree:

`default_branch_hint` is optional prompt context. It is not used for checkout; use `ref` when the project should pin a branch, tag, or SHA.

To use an existing local Git repository without pushing its commits to a
network remote first:

```bash
multica project resource add <project-id> \
--type github_repo \
--url file:///absolute/path/to/repo \
--daemon-id <daemon-id>
```

The owning daemon canonicalizes the path, verifies that it is a readable Git
repository, and clones its committed refs into the normal Multica cache. Every
task still receives its own branch, worktree/index, and Git metadata; the
canonical checkout and its uncommitted state are not touched. Agents may push
their task branches back into the local repository normally; updating those
refs does not switch or modify the canonical checkout.

## Resource type: `local_directory`

For repos that can't reasonably be re-cloned per task — multi-gigabyte game checkouts, large monorepos, or any project where the worktree-per-task model is painful — a project can instead point at an **existing directory on a specific [daemon](/daemon-runtimes)'s machine**. The agent runs **directly inside that folder**, with no clone, no copy, and no worktree.
Expand Down
4 changes: 3 additions & 1 deletion packages/core/types/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ export interface ListProjectsResponse {
// validateAndNormalizeResourceRef on the server and a renderer in the UI.
//
// Known types (UI must default-case unknown server-side additions):
// - github_repo: cloud-side git checkout, ref = { url, ref?, default_branch_hint? }
// - github_repo: isolated git checkout, ref = { url, ref?,
// default_branch_hint?, daemon_id? }; daemon_id is required for file://
// - local_directory: in-place agent execution on a specific daemon,
// ref = { local_path, daemon_id, label? }
export type ProjectResourceType = "github_repo" | "local_directory";
Expand All @@ -70,6 +71,7 @@ export interface GithubRepoResourceRef {
url: string;
ref?: string;
default_branch_hint?: string;
daemon_id?: string;
}

export interface LocalDirectoryResourceRef {
Expand Down
2 changes: 1 addition & 1 deletion packages/views/locales/en/projects.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
"repos_search_empty": "No repositories match your search.",
"attached_badge": "attached",
"remove_tooltip": "Remove",
"url_placeholder": "https://github.com/owner/repo or git@github.com:owner/repo.git",
"url_placeholder": "Git URL or file:///absolute/path/to/repo",
"url_submit": "Add",
"toast_attached": "Repository attached",
"toast_attach_failed": "Failed to attach",
Expand Down
42 changes: 32 additions & 10 deletions packages/views/projects/components/project-resources-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,19 @@ export function ProjectResourcesSection({ projectId }: { projectId: string }) {

const handleAttach = async (url: string) => {
try {
const isLocalRepo = url.trim().toLowerCase().startsWith("file://");
if (isLocalRepo && (!localDaemonId || !daemonStatus.running)) {
toast.error(t(($) => $.resources.toast_local_daemon_not_running));
return;
}
await createResource.mutateAsync({
resource_type: "github_repo",
resource_ref: { url },
resource_ref: {
url,
...(isLocalRepo && localDaemonId
? { daemon_id: localDaemonId }
: {}),
},
});
toast.success(t(($) => $.resources.toast_attached));
} catch (err) {
Expand Down Expand Up @@ -411,20 +421,32 @@ function ResourceRow({
const ref = resource.resource_ref;
const display = resource.label || (ref.ref ? `${githubShortLabel(ref.url)} @ ${ref.ref}` : githubShortLabel(ref.url));
const tooltip = ref.ref ? `${ref.url}\nref: ${ref.ref}` : ref.url;
const isLocalRepo = Boolean(ref.daemon_id);
const localMismatch =
isLocalRepo &&
(localDaemonId === null || ref.daemon_id !== localDaemonId);
return (
<div className="flex items-center gap-2 text-xs group">
<div
className={`flex items-center gap-2 text-xs group ${
localMismatch ? "opacity-60" : ""
}`}
>
<FolderGit className="size-3.5 text-muted-foreground shrink-0" />
<Tooltip>
<TooltipTrigger
render={
<a
href={ref.url}
target="_blank"
rel="noopener noreferrer"
className="truncate flex-1 hover:underline"
>
{display}
</a>
isLocalRepo ? (
<span className="truncate flex-1">{display}</span>
) : (
<a
href={ref.url}
target="_blank"
rel="noopener noreferrer"
className="truncate flex-1 hover:underline"
>
{display}
</a>
)
}
/>
<TooltipContent side="top" className="whitespace-pre-line">{tooltip}</TooltipContent>
Expand Down
18 changes: 15 additions & 3 deletions server/cmd/multica/cmd_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ func init() {
projectResourceAddCmd.Flags().String("url", "", "Shortcut: the repo URL (only used when --type github_repo)")
projectResourceAddCmd.Flags().String("default-branch-hint", "", "Shortcut: optional default branch hint (only used when --type github_repo)")
projectResourceAddCmd.Flags().String("local-path", "", "Shortcut: absolute path to the working directory (only used when --type local_directory)")
projectResourceAddCmd.Flags().String("daemon-id", "", "Shortcut: id of the daemon that owns the local path (only used when --type local_directory)")
projectResourceAddCmd.Flags().String("daemon-id", "", "Shortcut: daemon that owns a github_repo file URL or local_directory path")
projectResourceAddCmd.Flags().String("ref-label", "", "Shortcut: optional label embedded in resource_ref (only used when --type local_directory)")
projectResourceAddCmd.Flags().String("ref", "", "Generic JSON resource_ref payload, or a github_repo checkout ref when used with --url")
projectResourceAddCmd.Flags().String("label", "", "Optional human-readable label")
Expand All @@ -163,7 +163,7 @@ func init() {
projectResourceUpdateCmd.Flags().String("url", "", "Shortcut: new repo URL (github_repo)")
projectResourceUpdateCmd.Flags().String("default-branch-hint", "", "Shortcut: new default branch hint (github_repo)")
projectResourceUpdateCmd.Flags().String("local-path", "", "Shortcut: new absolute local path (local_directory)")
projectResourceUpdateCmd.Flags().String("daemon-id", "", "Shortcut: new daemon id (local_directory)")
projectResourceUpdateCmd.Flags().String("daemon-id", "", "Shortcut: new daemon id (github_repo file URL or local_directory)")
projectResourceUpdateCmd.Flags().String("ref-label", "", "Shortcut: new label embedded in resource_ref (local_directory)")
projectResourceUpdateCmd.Flags().String("ref", "", "Generic JSON resource_ref payload, or a github_repo checkout ref")
projectResourceUpdateCmd.Flags().String("label", "", "New human-readable label; pass an empty string to clear")
Expand Down Expand Up @@ -796,7 +796,8 @@ func buildResourceRefFromFlags(cmd *cobra.Command, resourceType string, existing
urlSet := cmd.Flags().Changed("url")
hintSet := cmd.Flags().Changed("default-branch-hint")
refSet := cmd.Flags().Changed("ref")
if !urlSet && !hintSet && !refSet {
daemonSet := cmd.Flags().Changed("daemon-id")
if !urlSet && !hintSet && !refSet && !daemonSet {
return nil, false, nil
}
ref := map[string]any{}
Expand All @@ -812,6 +813,9 @@ func buildResourceRefFromFlags(cmd *cobra.Command, resourceType string, existing
if checkoutRef, ok := existingRef["ref"].(string); ok && strings.TrimSpace(checkoutRef) != "" {
ref["ref"] = strings.TrimSpace(checkoutRef)
}
if daemonID, ok := existingRef["daemon_id"].(string); ok && strings.TrimSpace(daemonID) != "" {
ref["daemon_id"] = strings.TrimSpace(daemonID)
}
}
if urlSet {
urlVal, _ := cmd.Flags().GetString("url")
Expand All @@ -837,6 +841,14 @@ func buildResourceRefFromFlags(cmd *cobra.Command, resourceType string, existing
ref["ref"] = checkoutRef
}
}
if daemonSet {
daemonID := strings.TrimSpace(mustString(cmd, "daemon-id"))
if daemonID == "" {
delete(ref, "daemon_id")
} else {
ref["daemon_id"] = daemonID
}
}
if _, ok := ref["url"]; !ok {
return nil, false, fmt.Errorf("github_repo: --url is required (no existing url to merge with)")
}
Expand Down
14 changes: 14 additions & 0 deletions server/cmd/multica/cmd_project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,20 @@ func TestBuildResourceRefFromFlagsGithubMergesHint(t *testing.T) {
}
})

t.Run("file url carries daemon scope", func(t *testing.T) {
cmd := newProjectResourceUpdateTestCmd()
_ = cmd.Flags().Set("url", "file:///Users/ada/project")
_ = cmd.Flags().Set("daemon-id", "daemon-1")

ref, has, err := buildResourceRefFromFlags(cmd, "github_repo", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !has || ref["url"] != "file:///Users/ada/project" || ref["daemon_id"] != "daemon-1" {
t.Fatalf("unexpected local repo ref: %#v", ref)
}
})

t.Run("empty checkout ref clears existing ref", func(t *testing.T) {
cmd := newProjectResourceUpdateTestCmd()
_ = cmd.Flags().Set("ref", "")
Expand Down
5 changes: 5 additions & 0 deletions server/internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -4080,6 +4080,11 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
// in the per-workspace allowlist and the local cache, otherwise
// `multica repo checkout` would reject project-only URLs that aren't also
// bound at the workspace level.
normalizedRepos, err := normalizeTaskRepos(task.Repos)
if err != nil {
return TaskResult{}, fmt.Errorf("prepare task repositories: %w", err)
}
task.Repos = normalizedRepos
d.registerTaskRepos(task.WorkspaceID, task.ID, task.Repos)
defer d.clearTaskRepoRefs(task.WorkspaceID, task.ID)

Expand Down
6 changes: 6 additions & 0 deletions server/internal/daemon/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@ func (d *Daemon) repoCheckoutHandler() http.HandlerFunc {
http.Error(w, "url is required", http.StatusBadRequest)
return
}
normalizedURL, _, err := normalizeRepoURL(req.URL)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
req.URL = normalizedURL
if req.WorkspaceID == "" {
http.Error(w, "workspace_id is required", http.StatusBadRequest)
return
Expand Down
75 changes: 75 additions & 0 deletions server/internal/daemon/local_repo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package daemon

import (
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
)

// normalizeRepoURL canonicalizes daemon-local file repositories and validates
// them at the machine boundary. Network URLs pass through unchanged.
func normalizeRepoURL(raw string) (string, string, error) {
raw = strings.TrimSpace(raw)
u, err := url.Parse(raw)
if err != nil || !strings.EqualFold(u.Scheme, "file") {
return raw, "", nil
}
if u.Host != "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return "", "", fmt.Errorf("local repository file URL must not contain a host, credentials, query, or fragment")
}
if u.Path == "" || !filepath.IsAbs(u.Path) {
return "", "", fmt.Errorf("local repository file URL must contain an absolute path")
}
sourcePath, err := filepath.EvalSymlinks(filepath.Clean(u.Path))
if err != nil {
return "", "", fmt.Errorf("resolve local repository path: %w", err)
}
info, err := os.Stat(sourcePath)
if err != nil {
return "", "", fmt.Errorf("stat local repository path: %w", err)
}
if !info.IsDir() {
return "", "", fmt.Errorf("local repository path is not a directory: %s", sourcePath)
}

commonOut, err := exec.Command("git", "-C", sourcePath, "rev-parse", "--path-format=absolute", "--git-common-dir").CombinedOutput()
if err != nil {
return "", "", fmt.Errorf("local repository path is not a readable Git repository: %s", strings.TrimSpace(string(commonOut)))
}
commonPath := strings.TrimSpace(string(commonOut))
if !filepath.IsAbs(commonPath) {
commonPath = filepath.Join(sourcePath, commonPath)
}
commonPath, err = filepath.EvalSymlinks(filepath.Clean(commonPath))
if err != nil {
return "", "", fmt.Errorf("resolve local repository Git common directory: %w", err)
}

return (&url.URL{Scheme: "file", Path: sourcePath}).String(), commonPath, nil
}

func normalizeTaskRepos(repos []RepoData) ([]RepoData, error) {
normalized := make([]RepoData, 0, len(repos))
seenLocalRepos := make(map[string]struct{})
for _, repo := range repos {
canonicalURL, commonDir, err := normalizeRepoURL(repo.URL)
if err != nil {
return nil, err
}
if canonicalURL == "" {
continue
}
if commonDir != "" {
if _, exists := seenLocalRepos[commonDir]; exists {
continue
}
seenLocalRepos[commonDir] = struct{}{}
}
repo.URL = canonicalURL
normalized = append(normalized, repo)
}
return normalized, nil
}
59 changes: 59 additions & 0 deletions server/internal/daemon/local_repo_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package daemon

import (
"net/url"
"os"
"os/exec"
"path/filepath"
"testing"
)

func TestNormalizeTaskReposCanonicalizesAndDeduplicatesLocalGitRepository(t *testing.T) {
repo := t.TempDir()
runLocalRepoGit(t, repo, "init")
runLocalRepoGit(t, repo, "config", "user.email", "test@example.com")
runLocalRepoGit(t, repo, "config", "user.name", "Test")
if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("local\n"), 0o644); err != nil {
t.Fatal(err)
}
runLocalRepoGit(t, repo, "add", "README.md")
runLocalRepoGit(t, repo, "commit", "-m", "local only")

link := filepath.Join(t.TempDir(), "repo-link")
if err := os.Symlink(repo, link); err != nil {
t.Fatal(err)
}
repos, err := normalizeTaskRepos([]RepoData{
{URL: (&url.URL{Scheme: "file", Path: repo}).String()},
{URL: (&url.URL{Scheme: "file", Path: link}).String()},
})
if err != nil {
t.Fatalf("normalizeTaskRepos: %v", err)
}
if len(repos) != 1 {
t.Fatalf("len(repos) = %d, want 1: %+v", len(repos), repos)
}
realRepo, err := filepath.EvalSymlinks(repo)
if err != nil {
t.Fatal(err)
}
want := (&url.URL{Scheme: "file", Path: realRepo}).String()
if repos[0].URL != want {
t.Fatalf("URL = %q, want %q", repos[0].URL, want)
}
}

func TestNormalizeRepoURLRejectsNonGitDirectory(t *testing.T) {
raw := (&url.URL{Scheme: "file", Path: t.TempDir()}).String()
if _, _, err := normalizeRepoURL(raw); err == nil {
t.Fatal("expected non-Git directory to be rejected")
}
}

func runLocalRepoGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmdArgs := append([]string{"-C", dir}, args...)
if out, err := exec.Command("git", cmdArgs...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %s: %v", args, out, err)
}
}
Loading
Loading