mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-20 13:18:56 +02:00
Compare commits
8 Commits
agent/lamb
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ca55c3d80 | ||
|
|
528976e547 | ||
|
|
1ddd210170 | ||
|
|
562949e1cb | ||
|
|
65f6e9c9f2 | ||
|
|
79d28b0da6 | ||
|
|
aeccd4f26e | ||
|
|
68ed2a32d9 |
@@ -111,6 +111,22 @@ function createWindow(): void {
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// Prevent Cmd+R / Ctrl+R / Shift+Cmd+R / Shift+Ctrl+R / F5 from
|
||||
// reloading the page. In a desktop app an accidental reload destroys
|
||||
// in-memory state (tabs, drafts, WS connections) with no URL bar to
|
||||
// navigate back. DevTools refresh (via the DevTools UI) still works.
|
||||
mainWindow.webContents.on("before-input-event", (_event, input) => {
|
||||
if (input.type !== "keyDown") return;
|
||||
const cmdOrCtrl =
|
||||
process.platform === "darwin" ? input.meta : input.control;
|
||||
if (
|
||||
(cmdOrCtrl && input.key.toLowerCase() === "r") ||
|
||||
input.key === "F5"
|
||||
) {
|
||||
_event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
installContextMenu(mainWindow.webContents);
|
||||
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
|
||||
@@ -279,10 +279,10 @@ export function AgentsPage() {
|
||||
// Surfaced softly; the agent itself is fine.
|
||||
}
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
setShowCreate(false);
|
||||
setDuplicateTemplate(null);
|
||||
navigation.push(paths.agentDetail(agent.id));
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
};
|
||||
|
||||
const handleDuplicate = useCallback((agent: Agent) => {
|
||||
|
||||
@@ -44,7 +44,9 @@ import {
|
||||
} from "./trigger-config";
|
||||
import type { TriggerConfig } from "./trigger-config";
|
||||
import type { AutopilotExecutionMode, AutopilotRun, AutopilotTrigger } from "@multica/core/types";
|
||||
import type { AgentTask } from "@multica/core/types/agent";
|
||||
import { ReadonlyContent } from "../../editor";
|
||||
import { TranscriptButton } from "../../common/task-transcript";
|
||||
import { AutopilotDialog } from "./autopilot-dialog";
|
||||
|
||||
function formatDate(date: string): string {
|
||||
@@ -63,11 +65,34 @@ const RUN_STATUS_CONFIG: Record<string, { label: string; color: string; icon: ty
|
||||
failed: { label: "Failed", color: "text-destructive", icon: XCircle },
|
||||
};
|
||||
|
||||
function RunRow({ run }: { run: AutopilotRun }) {
|
||||
function RunRow({ run, agentId, agentName }: { run: AutopilotRun; agentId: string; agentName: string }) {
|
||||
const wsPaths = useWorkspacePaths();
|
||||
const cfg = (RUN_STATUS_CONFIG[run.status] ?? RUN_STATUS_CONFIG["issue_created"])!;
|
||||
const StatusIcon = cfg.icon;
|
||||
|
||||
// For runs with a task_id (run_only mode), build a minimal AgentTask so
|
||||
// TranscriptButton can lazy-load the execution transcript.
|
||||
const syntheticTask: AgentTask | null = run.task_id
|
||||
? {
|
||||
id: run.task_id,
|
||||
agent_id: agentId,
|
||||
runtime_id: "",
|
||||
issue_id: "",
|
||||
status:
|
||||
run.status === "running" ? "running" :
|
||||
run.status === "completed" ? "completed" :
|
||||
run.status === "failed" ? "failed" :
|
||||
"queued",
|
||||
priority: 0,
|
||||
dispatched_at: null,
|
||||
started_at: run.triggered_at || null,
|
||||
completed_at: run.completed_at || null,
|
||||
result: null,
|
||||
error: run.failure_reason || null,
|
||||
created_at: run.created_at,
|
||||
}
|
||||
: null;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color, cfg.spin && "animate-spin")} />
|
||||
@@ -83,6 +108,14 @@ function RunRow({ run }: { run: AutopilotRun }) {
|
||||
<span className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
|
||||
{formatDate(run.triggered_at || run.created_at)}
|
||||
</span>
|
||||
{syntheticTask && !run.issue_id && (
|
||||
<TranscriptButton
|
||||
task={syntheticTask}
|
||||
agentName={agentName}
|
||||
isLive={run.status === "running"}
|
||||
title="View execution log"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -438,7 +471,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
) : (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
{runs.map((run) => (
|
||||
<RunRow key={run.id} run={run} />
|
||||
<RunRow key={run.id} run={run} agentId={autopilot.assignee_id} agentName={getActorName("agent", autopilot.assignee_id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,26 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { FlaskConical } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { GitCommitHorizontal } from "lucide-react";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import type { Workspace } from "@multica/core/types";
|
||||
|
||||
export function LabsTab() {
|
||||
const workspace = useCurrentWorkspace();
|
||||
const qc = useQueryClient();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const coAuthoredByEnabled =
|
||||
(workspace?.settings as Record<string, unknown>)?.co_authored_by_enabled !== false;
|
||||
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
if (!workspace || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await api.updateWorkspace(workspace.id, {
|
||||
settings: {
|
||||
...((workspace.settings as Record<string, unknown>) ?? {}),
|
||||
co_authored_by_enabled: checked,
|
||||
},
|
||||
});
|
||||
qc.setQueryData(workspaceKeys.list(), (old: Workspace[] | undefined) =>
|
||||
old?.map((ws) => (ws.id === updated.id ? updated : ws)),
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Failed to update setting",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!workspace) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold">Labs</h2>
|
||||
<h2 className="text-sm font-semibold">Git</h2>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-md border bg-muted/50 p-2 text-muted-foreground">
|
||||
<FlaskConical className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">No experimental features yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Beta features that require manual opt-in will appear here.
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-md border bg-muted/50 p-2 text-muted-foreground">
|
||||
<GitCommitHorizontal className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="co-authored-by"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Co-authored-by trailer
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically add{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-xs">
|
||||
Co-authored-by: multica-agent <github@multica.ai>
|
||||
</code>{" "}
|
||||
to commits made by agents.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="co-authored-by"
|
||||
checked={coAuthoredByEnabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -295,9 +295,10 @@ func (c *Client) Deregister(ctx context.Context, runtimeIDs []string) error {
|
||||
|
||||
// RegisterResponse holds the server's response to a daemon registration.
|
||||
type RegisterResponse struct {
|
||||
Runtimes []Runtime `json:"runtimes"`
|
||||
Repos []RepoData `json:"repos"`
|
||||
ReposVersion string `json:"repos_version"`
|
||||
Runtimes []Runtime `json:"runtimes"`
|
||||
Repos []RepoData `json:"repos"`
|
||||
ReposVersion string `json:"repos_version"`
|
||||
Settings json.RawMessage `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) Register(ctx context.Context, req map[string]any) (*RegisterResponse, error) {
|
||||
|
||||
@@ -30,6 +30,7 @@ type workspaceState struct {
|
||||
runtimeIDs []string
|
||||
reposVersion string // stored for future use: skip refresh when version unchanged
|
||||
allowedRepoURLs map[string]struct{}
|
||||
settings json.RawMessage // workspace settings (JSONB)
|
||||
lastRepoSyncErr string
|
||||
repoRefreshMu sync.Mutex
|
||||
}
|
||||
@@ -321,12 +322,13 @@ func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID s
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func newWorkspaceState(workspaceID string, runtimeIDs []string, reposVersion string, repos []RepoData) *workspaceState {
|
||||
func newWorkspaceState(workspaceID string, runtimeIDs []string, reposVersion string, repos []RepoData, settings json.RawMessage) *workspaceState {
|
||||
return &workspaceState{
|
||||
workspaceID: workspaceID,
|
||||
runtimeIDs: runtimeIDs,
|
||||
reposVersion: reposVersion,
|
||||
allowedRepoURLs: repoAllowlist(repos),
|
||||
settings: settings,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +372,25 @@ func (d *Daemon) workspaceLastRepoSyncErr(workspaceID string) string {
|
||||
return ws.lastRepoSyncErr
|
||||
}
|
||||
|
||||
// workspaceCoAuthoredByEnabled returns whether the Co-authored-by hook should
|
||||
// be installed for the given workspace. Defaults to true when the setting is
|
||||
// absent (new workspaces, older servers that don't send settings).
|
||||
func (d *Daemon) workspaceCoAuthoredByEnabled(workspaceID string) bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
ws, ok := d.workspaces[workspaceID]
|
||||
if !ok || len(ws.settings) == 0 {
|
||||
return true // default: enabled
|
||||
}
|
||||
var s struct {
|
||||
CoAuthoredByEnabled *bool `json:"co_authored_by_enabled"`
|
||||
}
|
||||
if err := json.Unmarshal(ws.settings, &s); err != nil || s.CoAuthoredByEnabled == nil {
|
||||
return true // default: enabled
|
||||
}
|
||||
return *s.CoAuthoredByEnabled
|
||||
}
|
||||
|
||||
func (d *Daemon) syncWorkspaceRepos(workspaceID string, repos []RepoData) {
|
||||
if d.repoCache == nil {
|
||||
return
|
||||
@@ -510,7 +531,7 @@ func (d *Daemon) syncWorkspacesFromAPI(ctx context.Context) error {
|
||||
d.logger.Info("registered runtime", "workspace_id", id, "runtime_id", rt.ID, "provider", rt.Provider)
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.workspaces[id] = newWorkspaceState(id, runtimeIDs, resp.ReposVersion, resp.Repos)
|
||||
d.workspaces[id] = newWorkspaceState(id, runtimeIDs, resp.ReposVersion, resp.Repos, resp.Settings)
|
||||
for _, rt := range resp.Runtimes {
|
||||
d.runtimeIndex[rt.ID] = rt
|
||||
}
|
||||
|
||||
@@ -554,7 +554,7 @@ func TestEnsureRepoReadyFastPathDoesNotRefresh(t *testing.T) {
|
||||
if err := d.repoCache.Sync("ws-1", []repocache.RepoInfo{{URL: sourceRepo}}); err != nil {
|
||||
t.Fatalf("seed repo cache: %v", err)
|
||||
}
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "v1", []RepoData{{URL: sourceRepo}})
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "v1", []RepoData{{URL: sourceRepo}}, nil)
|
||||
|
||||
if err := d.ensureRepoReady(context.Background(), "ws-1", sourceRepo); err != nil {
|
||||
t.Fatalf("ensureRepoReady: %v", err)
|
||||
@@ -576,7 +576,7 @@ func TestEnsureRepoReadyTrimsURL(t *testing.T) {
|
||||
if err := d.repoCache.Sync("ws-1", []repocache.RepoInfo{{URL: sourceRepo}}); err != nil {
|
||||
t.Fatalf("seed repo cache: %v", err)
|
||||
}
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "v1", []RepoData{{URL: sourceRepo}})
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "v1", []RepoData{{URL: sourceRepo}}, nil)
|
||||
|
||||
// URL with trailing whitespace should still hit the fast path.
|
||||
if err := d.ensureRepoReady(context.Background(), "ws-1", " "+sourceRepo+" "); err != nil {
|
||||
@@ -604,7 +604,7 @@ func TestEnsureRepoReadyRefreshesOnMiss(t *testing.T) {
|
||||
ReposVersion: "v2",
|
||||
})
|
||||
})
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil)
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil, nil)
|
||||
|
||||
if err := d.ensureRepoReady(context.Background(), "ws-1", sourceRepo); err != nil {
|
||||
t.Fatalf("ensureRepoReady: %v", err)
|
||||
@@ -627,7 +627,7 @@ func TestEnsureRepoReadyReturnsNotConfigured(t *testing.T) {
|
||||
ReposVersion: "v1",
|
||||
})
|
||||
})
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil)
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil, nil)
|
||||
|
||||
err := d.ensureRepoReady(context.Background(), "ws-1", "git@example.com:team/api.git")
|
||||
if !errors.Is(err, ErrRepoNotConfigured) {
|
||||
@@ -646,7 +646,7 @@ func TestEnsureRepoReadyReportsSyncFailure(t *testing.T) {
|
||||
ReposVersion: "v1",
|
||||
})
|
||||
})
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil)
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil, nil)
|
||||
|
||||
err := d.ensureRepoReady(context.Background(), "ws-1", missingRepo)
|
||||
if err == nil || !strings.Contains(err.Error(), "repo is configured but not synced:") {
|
||||
@@ -674,7 +674,7 @@ func TestEnsureRepoReadyConcurrentMissRefreshesOnce(t *testing.T) {
|
||||
ReposVersion: "v2",
|
||||
})
|
||||
})
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil)
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil, nil)
|
||||
|
||||
const concurrency = 8
|
||||
var wg sync.WaitGroup
|
||||
|
||||
@@ -158,11 +158,12 @@ func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt tim
|
||||
}
|
||||
|
||||
result, err := d.repoCache.CreateWorktree(repocache.WorktreeParams{
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
RepoURL: req.URL,
|
||||
WorkDir: req.WorkDir,
|
||||
AgentName: req.AgentName,
|
||||
TaskID: req.TaskID,
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
RepoURL: req.URL,
|
||||
WorkDir: req.WorkDir,
|
||||
AgentName: req.AgentName,
|
||||
TaskID: req.TaskID,
|
||||
CoAuthoredByEnabled: d.workspaceCoAuthoredByEnabled(req.WorkspaceID),
|
||||
})
|
||||
if err != nil {
|
||||
d.logger.Error("repo checkout failed", "url", req.URL, "error", err)
|
||||
|
||||
@@ -32,21 +32,22 @@ func BuildPrompt(task Task) string {
|
||||
|
||||
// buildQuickCreatePrompt constructs a prompt for quick-create tasks. The
|
||||
// user typed a single natural-language sentence in the create-issue modal;
|
||||
// the agent's only job is to translate it into one `multica issue create`
|
||||
// CLI invocation. No issue exists yet, so the agent must NOT call
|
||||
// `multica issue get` or attempt to comment — there's nothing to read or
|
||||
// reply to.
|
||||
// the agent's job is to translate it into one `multica issue create` CLI
|
||||
// invocation, using its judgment to decide whether fetching referenced URLs
|
||||
// would produce a better issue. No issue exists yet, so the agent must NOT
|
||||
// call `multica issue get` or attempt to comment — there's nothing to read
|
||||
// or reply to.
|
||||
func buildQuickCreatePrompt(task Task) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("You are running as a quick-create assistant for a Multica workspace.\n\n")
|
||||
b.WriteString("A user pressed the quick-create shortcut and typed a one-line description. There is NO existing issue. Your only job is to translate the description into a single `multica issue create` command and run it.\n\n")
|
||||
b.WriteString("A user pressed the quick-create shortcut and typed a one-line description. There is NO existing issue. Your job is to create a well-formed issue from the user's input with a single `multica issue create` command.\n\n")
|
||||
fmt.Fprintf(&b, "User input:\n> %s\n\n", task.QuickCreatePrompt)
|
||||
b.WriteString("Field rules:\n")
|
||||
b.WriteString("- title: required. A short, imperative summary extracted from the user input (e.g. \"fix inbox loading\"). Strip filler words.\n")
|
||||
b.WriteString("- description: optional. Include only if the user supplied detail beyond the title; otherwise omit. Never echo the title here.\n")
|
||||
b.WriteString("- priority: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\"/\"紧急\" → urgent; \"低优先级\" → low. If unspecified, omit.\n")
|
||||
b.WriteString("- title: required. A concise but semantically rich summary that lets a reader understand what the issue is about at a glance. If the user input references external resources (PRs, issues, URLs, etc.), use your judgment to decide whether fetching the resource would produce a meaningfully better title — if so, fetch it and incorporate the relevant context. For example, \"review PR #123\" is much less useful than \"Review PR #123: Refactor auth module to OAuth2\". Strip filler words but preserve key semantic information.\n")
|
||||
b.WriteString("- description: stay faithful to the user's original input — do NOT invent requirements, design decisions, implementation plans, or constraints that the user did not express. The description should enrich the user's input with factual context only: if the input contains URLs or references (PRs, issues, docs), fetch them and summarize the relevant parts. Restate the user's intent clearly so the executing agent understands the task, but do not expand scope or add made-up details. Keep it concise. Never echo the title here.\n")
|
||||
b.WriteString("- priority: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\" → urgent. If unspecified, omit.\n")
|
||||
b.WriteString("- assignee:\n")
|
||||
b.WriteString(" - When the user names someone (\"分给 X\" / \"assign to X\" / \"@X\"), call `multica workspace members --output json` and find the matching member by display name (case-insensitive substring match is fine). On a clean match, pass `--assignee <name>`. On no match or ambiguous match, do NOT pass `--assignee` — instead append a final line to the description: `未识别 assignee: X`.\n")
|
||||
b.WriteString(" - When the user names someone (\"assign to X\" / \"@X\"), call `multica workspace members --output json` and find the matching member by display name (case-insensitive substring match is fine). On a clean match, pass `--assignee <name>`. On no match or ambiguous match, do NOT pass `--assignee` — instead append a final line to the description: `Unrecognized assignee: X`.\n")
|
||||
agentName := ""
|
||||
if task.Agent != nil {
|
||||
agentName = task.Agent.Name
|
||||
|
||||
@@ -327,11 +327,12 @@ func setFetchRefspec(barePath, refspec string) error {
|
||||
|
||||
// WorktreeParams holds inputs for creating a worktree from a cached bare clone.
|
||||
type WorktreeParams struct {
|
||||
WorkspaceID string // workspace that owns the repo
|
||||
RepoURL string // remote URL to look up in the cache
|
||||
WorkDir string // parent directory for the worktree (e.g. task workdir)
|
||||
AgentName string // for branch naming
|
||||
TaskID string // for branch naming uniqueness
|
||||
WorkspaceID string // workspace that owns the repo
|
||||
RepoURL string // remote URL to look up in the cache
|
||||
WorkDir string // parent directory for the worktree (e.g. task workdir)
|
||||
AgentName string // for branch naming
|
||||
TaskID string // for branch naming uniqueness
|
||||
CoAuthoredByEnabled bool // install prepare-commit-msg hook for Co-authored-by trailer
|
||||
}
|
||||
|
||||
// WorktreeResult describes a successfully created worktree.
|
||||
@@ -401,6 +402,13 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) {
|
||||
_ = excludeFromGit(worktreePath, pattern)
|
||||
}
|
||||
|
||||
// Install Co-authored-by hook for Multica Agent attribution (if enabled).
|
||||
if params.CoAuthoredByEnabled {
|
||||
if err := installCoAuthoredByHook(worktreePath); err != nil {
|
||||
c.logger.Warn("repo checkout: install co-authored-by hook failed (non-fatal)", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
c.logger.Info("repo checkout: existing worktree updated",
|
||||
"url", params.RepoURL,
|
||||
"path", worktreePath,
|
||||
@@ -426,6 +434,13 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) {
|
||||
_ = excludeFromGit(worktreePath, pattern)
|
||||
}
|
||||
|
||||
// Install Co-authored-by hook for Multica Agent attribution (if enabled).
|
||||
if params.CoAuthoredByEnabled {
|
||||
if err := installCoAuthoredByHook(worktreePath); err != nil {
|
||||
c.logger.Warn("repo checkout: install co-authored-by hook failed (non-fatal)", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
c.logger.Info("repo checkout: worktree created",
|
||||
"url", params.RepoURL,
|
||||
"path", worktreePath,
|
||||
@@ -650,6 +665,58 @@ func bareHeadBranch(barePath string) string {
|
||||
return ref
|
||||
}
|
||||
|
||||
// prepareCommitMsgHook is the prepare-commit-msg hook script that appends a
|
||||
// Co-authored-by trailer for the Multica Agent to every commit message.
|
||||
const prepareCommitMsgHook = `#!/bin/sh
|
||||
# Multica: add Co-authored-by trailer for the Multica Agent.
|
||||
# Installed by the Multica daemon. Do not edit — it will be overwritten.
|
||||
|
||||
COMMIT_MSG_FILE="$1"
|
||||
COMMIT_SOURCE="$2"
|
||||
|
||||
# Skip merge and squash commits.
|
||||
case "$COMMIT_SOURCE" in
|
||||
merge|squash) exit 0 ;;
|
||||
esac
|
||||
|
||||
TRAILER="Co-authored-by: multica-agent <github@multica.ai>"
|
||||
|
||||
# Don't add if already present.
|
||||
if grep -qF "$TRAILER" "$COMMIT_MSG_FILE"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Use git interpret-trailers for proper formatting.
|
||||
git interpret-trailers --in-place --trailer "$TRAILER" "$COMMIT_MSG_FILE"
|
||||
`
|
||||
|
||||
// installCoAuthoredByHook installs a prepare-commit-msg git hook that appends
|
||||
// a Co-authored-by trailer for the Multica Agent. The hook is installed in the
|
||||
// git common directory (the bare repo for worktrees) so it applies to all
|
||||
// worktrees created from this cache.
|
||||
func installCoAuthoredByHook(worktreePath string) error {
|
||||
cmd := exec.Command("git", "-C", worktreePath, "rev-parse", "--git-common-dir")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve git common dir: %w", err)
|
||||
}
|
||||
commonDir := strings.TrimSpace(string(out))
|
||||
if !filepath.IsAbs(commonDir) {
|
||||
commonDir = filepath.Join(worktreePath, commonDir)
|
||||
}
|
||||
|
||||
hooksDir := filepath.Join(commonDir, "hooks")
|
||||
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create hooks dir: %w", err)
|
||||
}
|
||||
|
||||
hookPath := filepath.Join(hooksDir, "prepare-commit-msg")
|
||||
if err := os.WriteFile(hookPath, []byte(prepareCommitMsgHook), 0o755); err != nil {
|
||||
return fmt.Errorf("write prepare-commit-msg hook: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// excludeFromGit adds a pattern to the worktree's .git/info/exclude file.
|
||||
func excludeFromGit(worktreePath, pattern string) error {
|
||||
cmd := exec.Command("git", "-C", worktreePath, "rev-parse", "--git-dir")
|
||||
|
||||
@@ -883,6 +883,97 @@ func TestGetRemoteDefaultBranchUsesBareHeadHintForCustomDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateWorktreeInstallsCoAuthoredByHook verifies that CreateWorktree
|
||||
// installs a prepare-commit-msg hook that appends a Co-authored-by trailer
|
||||
// for the Multica Agent to every commit made in the worktree.
|
||||
func TestCreateWorktreeInstallsCoAuthoredByHook(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRepo := createTestRepo(t)
|
||||
cacheRoot := t.TempDir()
|
||||
|
||||
cache := New(cacheRoot, testLogger())
|
||||
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
||||
t.Fatalf("sync failed: %v", err)
|
||||
}
|
||||
|
||||
workDir := t.TempDir()
|
||||
result, err := cache.CreateWorktree(WorktreeParams{
|
||||
WorkspaceID: "ws-1",
|
||||
RepoURL: sourceRepo,
|
||||
WorkDir: workDir,
|
||||
AgentName: "Test Agent",
|
||||
TaskID: "a1b2c3d4-0000-0000-0000-000000000000",
|
||||
CoAuthoredByEnabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorktree failed: %v", err)
|
||||
}
|
||||
|
||||
// Make a commit in the worktree and verify the hook appends the trailer.
|
||||
if err := os.WriteFile(filepath.Join(result.Path, "test.txt"), []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
runGitAuthored(t, result.Path, "add", ".")
|
||||
runGitAuthored(t, result.Path, "commit", "-m", "test commit")
|
||||
|
||||
// Read the commit message.
|
||||
out, err := exec.Command("git", "-C", result.Path, "log", "-1", "--format=%B").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git log failed: %v", err)
|
||||
}
|
||||
commitMsg := string(out)
|
||||
expectedTrailer := "Co-authored-by: multica-agent <github@multica.ai>"
|
||||
if !strings.Contains(commitMsg, expectedTrailer) {
|
||||
t.Errorf("commit message missing Co-authored-by trailer.\ngot:\n%s", commitMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCoAuthoredByHookIdempotent verifies that the hook does not add a
|
||||
// duplicate Co-authored-by trailer if one is already present in the message.
|
||||
func TestCoAuthoredByHookIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRepo := createTestRepo(t)
|
||||
cacheRoot := t.TempDir()
|
||||
|
||||
cache := New(cacheRoot, testLogger())
|
||||
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
||||
t.Fatalf("sync failed: %v", err)
|
||||
}
|
||||
|
||||
workDir := t.TempDir()
|
||||
result, err := cache.CreateWorktree(WorktreeParams{
|
||||
WorkspaceID: "ws-1",
|
||||
RepoURL: sourceRepo,
|
||||
WorkDir: workDir,
|
||||
AgentName: "Test Agent",
|
||||
TaskID: "b2c3d4e5-0000-0000-0000-000000000000",
|
||||
CoAuthoredByEnabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorktree failed: %v", err)
|
||||
}
|
||||
|
||||
// Commit with the trailer already in the message.
|
||||
trailer := "Co-authored-by: multica-agent <github@multica.ai>"
|
||||
if err := os.WriteFile(filepath.Join(result.Path, "test.txt"), []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
runGitAuthored(t, result.Path, "add", ".")
|
||||
runGitAuthored(t, result.Path, "commit", "-m", "test commit\n\n"+trailer)
|
||||
|
||||
out, err := exec.Command("git", "-C", result.Path, "log", "-1", "--format=%B").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git log failed: %v", err)
|
||||
}
|
||||
commitMsg := string(out)
|
||||
|
||||
// Count occurrences — should appear exactly once.
|
||||
count := strings.Count(commitMsg, trailer)
|
||||
if count != 1 {
|
||||
t.Errorf("expected exactly 1 Co-authored-by trailer, found %d.\ngot:\n%s", count, commitMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRemoteDefaultBranchAmbiguousOriginReturnsEmpty verifies step 4's
|
||||
// safe-scan gating: when the cache has multiple refs/remotes/origin/*
|
||||
// entries, none match the common defaults, and none match the bare HEAD
|
||||
|
||||
@@ -342,10 +342,19 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
|
||||
repoResp := workspaceReposResponse(req.WorkspaceID, ws.Repos)
|
||||
|
||||
// Include workspace settings so the daemon can honour feature toggles
|
||||
// (e.g. co_authored_by_enabled for the prepare-commit-msg hook).
|
||||
var settings json.RawMessage
|
||||
if len(ws.Settings) > 0 {
|
||||
settings = json.RawMessage(ws.Settings)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"runtimes": resp,
|
||||
"repos": repoResp.Repos,
|
||||
"repos_version": repoResp.ReposVersion,
|
||||
"settings": settings,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user