mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-22 15:09:22 +02:00
Compare commits
2 Commits
v0.3.2
...
revert/exe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51108a7bb0 | ||
|
|
9c5302ff2f |
@@ -22,7 +22,7 @@ Create a new autopilot on the workspace's **Autopilot** page. You set:
|
||||
|
||||
An autopilot has two execution modes. **Start with "create issue" mode.**
|
||||
|
||||
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title currently supports a single placeholder, `{{date}}`, which interpolates to the UTC date in `YYYY-MM-DD` format; any other `{{...}}` token is rejected at create-time so a typo cannot silently land as the literal string in your issue titles), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
|
||||
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title supports interpolation like `{{date}}`), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
|
||||
- **Run-only mode** (`run_only`) — skips issue creation and enqueues a `task` directly. The run is invisible on the board — you can only see it in the autopilot's run history.
|
||||
|
||||
## Run it on a schedule
|
||||
|
||||
@@ -22,7 +22,7 @@ Autopilots 让 [智能体](/agents) **按调度自动开工**——配好 cron
|
||||
|
||||
Autopilot 有两种执行模式,**建议从"先建 issue 模式"开始**:
|
||||
|
||||
- **先建 issue 模式**(`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue(标题目前只支持一个占位符 `{{date}}`,会插值成 UTC 日期 `YYYY-MM-DD`;其他 `{{...}}` 形式的占位符会在创建时被拒绝,避免拼错以后悄无声息地把原文当成 issue 标题),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
|
||||
- **先建 issue 模式**(`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue(标题支持 `{{date}}` 这样的插值),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
|
||||
- **直跑模式**(`run_only`)—— 不建 issue,直接入队一个 `task`。看板上看不到这一次运行——只能在 Autopilot 的运行历史里看到。
|
||||
|
||||
## 让它按时间跑
|
||||
|
||||
@@ -299,6 +299,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
],
|
||||
improvements: [
|
||||
"Failed issue actions now show clearer error messages so teams can understand what happened without digging through logs",
|
||||
"Agent runs recover more reliably from stuck commands, idle sessions, and long-running work",
|
||||
"GitHub-linked pull requests now surface CI and merge-conflict status inside Multica",
|
||||
"Self-hosted deployments get safer defaults and clearer guidance for reverse proxies, auth limits, and local-only services",
|
||||
"Search results are ranked more usefully and include better snippets",
|
||||
|
||||
@@ -298,6 +298,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
],
|
||||
improvements: [
|
||||
"Issue 操作失败时会显示更明确的错误原因,团队不用翻日志也能理解发生了什么",
|
||||
"Agent 运行在遇到卡住的命令、空闲会话和长时间任务时更容易恢复",
|
||||
"关联 GitHub 的 Pull Request 会在 Multica 内展示 CI 和合并冲突状态",
|
||||
"自托管部署获得更安全的默认配置,并补充反向代理、登录限制和本地服务的说明",
|
||||
"搜索结果排序更准确,也会展示更有帮助的摘要片段",
|
||||
|
||||
@@ -40,7 +40,7 @@ function ActorAvatar({
|
||||
// Squads (a group, non-human) get a square tile so they don't read as
|
||||
// a single person; everyone else stays round.
|
||||
isSquad ? "rounded-md" : "rounded-full",
|
||||
(!avatarUrl || imgError) && "bg-muted text-muted-foreground",
|
||||
"bg-muted text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
style={{ width: size, height: size, fontSize: size * 0.45 }}
|
||||
|
||||
@@ -240,7 +240,7 @@ function AvatarEditor({
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg">
|
||||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-muted">
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={agent.id}
|
||||
@@ -271,7 +271,7 @@ function AvatarEditor({
|
||||
type="button"
|
||||
// rounded-lg matches the standard agent avatar treatment used in
|
||||
// list rows. Avoid rounded-full — circles are reserved for humans.
|
||||
className="group relative h-14 w-14 shrink-0 overflow-hidden rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="group relative h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
aria-label={t(($) => $.inspector.change_avatar_aria)}
|
||||
|
||||
@@ -73,7 +73,7 @@ export function AvatarPicker({ value, onChange, size = 56 }: AvatarPickerProps)
|
||||
"group relative h-full w-full overflow-hidden rounded-lg outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring",
|
||||
hasValue
|
||||
? "border"
|
||||
? "border bg-muted"
|
||||
: "border border-dashed bg-muted/40 hover:bg-muted",
|
||||
)}
|
||||
aria-label={
|
||||
|
||||
@@ -314,25 +314,6 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
|
||||
};
|
||||
|
||||
const Icon = isWebhook ? Webhook : isApi ? Zap : Clock;
|
||||
const showWebhookUrlRow = isWebhook && webhookUrl;
|
||||
|
||||
// Delete control extracted so a webhook trigger can render it inline
|
||||
// with Copy / Rotate on the URL action row (where the other action
|
||||
// buttons live), while schedule / api triggers — which have no URL row
|
||||
// — keep it pinned to the row's top-right corner. Without this the
|
||||
// trash icon visually floats above the URL action buttons because the
|
||||
// outer flex uses `items-start`.
|
||||
const deleteButton = (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
title={t(($) => $.trigger_row.delete_dialog.confirm)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-md border px-3 py-2">
|
||||
@@ -365,7 +346,7 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
|
||||
{t(($) => $.trigger_row.next_label, { date: formatDate(trigger.next_run_at) })}
|
||||
</div>
|
||||
)}
|
||||
{showWebhookUrlRow && (
|
||||
{isWebhook && webhookUrl && (
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<code className="flex-1 min-w-0 truncate rounded bg-muted px-2 py-1 text-xs font-mono text-foreground">
|
||||
{webhookUrl}
|
||||
@@ -389,11 +370,17 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
|
||||
>
|
||||
<RotateCw className={cn("h-3.5 w-3.5 text-muted-foreground", rotateToken.isPending && "animate-spin")} />
|
||||
</Button>
|
||||
{deleteButton}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!showWebhookUrlRow && deleteButton}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<AlertDialog open={confirmOpen} onOpenChange={(v) => { if (!v && !deleting) setConfirmOpen(false); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock("../i18n", () => ({
|
||||
preview_loading: "Loading preview…",
|
||||
preview_failed: "Couldn't load preview",
|
||||
},
|
||||
code_block: { copy_code: "Copy code" },
|
||||
file_card: { uploading: "Uploading {{filename}}" },
|
||||
}),
|
||||
}),
|
||||
@@ -59,11 +60,9 @@ describe("AttachmentBlock — dispatcher", () => {
|
||||
// HtmlAttachmentPreview never renders the filename row — that's the
|
||||
// file-card chrome it replaces.
|
||||
expect(screen.queryByText("report.html")).toBeNull();
|
||||
// Toolbar shows Preview + Download only — attachments are files, not
|
||||
// inline source snippets, so there is no Copy code button.
|
||||
// Toolbar shows the Maximize-style preview button.
|
||||
expect(screen.getByTitle("Preview")).toBeTruthy();
|
||||
expect(screen.getByTitle("Download")).toBeTruthy();
|
||||
expect(screen.queryByTitle("Copy code")).toBeNull();
|
||||
expect(screen.getByTitle("Copy code")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("routes html WITHOUT attachmentId to AttachmentCard (URL-only is chrome-only)", () => {
|
||||
|
||||
@@ -315,12 +315,6 @@
|
||||
|
||||
.rich-text-editor .hljs-meta { color: var(--muted-foreground); }
|
||||
|
||||
/* XML / HTML — lowlight emits .hljs-tag for `<` `>` brackets and .hljs-name
|
||||
for the element name. Without these rules, HTML source renders mostly in
|
||||
the default text color and looks unhighlighted. */
|
||||
.rich-text-editor .hljs-tag { color: var(--muted-foreground); }
|
||||
.rich-text-editor .hljs-name { color: oklch(0.55 0.16 255); }
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark .rich-text-editor .hljs-keyword,
|
||||
.dark .rich-text-editor .hljs-selector-tag,
|
||||
@@ -347,8 +341,6 @@
|
||||
|
||||
.dark .rich-text-editor .hljs-deletion { color: oklch(0.7 0.18 25); }
|
||||
|
||||
.dark .rich-text-editor .hljs-name { color: oklch(0.72 0.14 255); }
|
||||
|
||||
/* Tables */
|
||||
.rich-text-editor .tableWrapper {
|
||||
overflow-x: auto;
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock("../i18n", () => ({
|
||||
preview_loading: "Loading preview…",
|
||||
preview_failed: "Couldn't load preview",
|
||||
},
|
||||
code_block: { copy_code: "Copy code" },
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
@@ -124,11 +125,19 @@ describe("HtmlAttachmentPreview — toolbar actions", () => {
|
||||
expect(onDownload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not render a Copy code button — attachments are files, not source snippets", async () => {
|
||||
it("writes the loaded text to the clipboard when Copy code is clicked", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>ok</p>",
|
||||
text: "<p>chart source</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
// jsdom does not implement navigator.clipboard; install it directly on
|
||||
// the existing navigator instance so the component's `navigator.clipboard`
|
||||
// global lookup resolves to our mock.
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
renderWithQuery(
|
||||
<HtmlAttachmentPreview
|
||||
attachmentId="att-1"
|
||||
@@ -137,13 +146,19 @@ describe("HtmlAttachmentPreview — toolbar actions", () => {
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
// Wait until the query resolves and the iframe appears — the Copy button
|
||||
// is rendered in the loading state too (disabled), so we cannot just wait
|
||||
// for it to exist.
|
||||
await waitFor(() => expect(document.querySelector("iframe")).toBeTruthy());
|
||||
expect(screen.queryByTitle("Copy code")).toBeNull();
|
||||
fireEvent.mouseDown(screen.getByTitle("Copy code"));
|
||||
await waitFor(() => {
|
||||
expect(writeText).toHaveBeenCalledWith("<p>chart source</p>");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("HtmlAttachmentPreview — failure mode does not unmount the toolbar", () => {
|
||||
it("keeps Preview and Download enabled when fetch errors", async () => {
|
||||
it("keeps Open and Download enabled and disables Copy code when fetch errors", async () => {
|
||||
getAttachmentTextContentMock.mockRejectedValueOnce(new Error("nope"));
|
||||
const onPreview = vi.fn();
|
||||
const onDownload = vi.fn();
|
||||
@@ -162,14 +177,16 @@ describe("HtmlAttachmentPreview — failure mode does not unmount the toolbar",
|
||||
).toBeTruthy();
|
||||
});
|
||||
// Critical: the figure does NOT collapse, and the chrome row is NOT
|
||||
// rendered as a fallback. Preview and Download stay reachable.
|
||||
// rendered as a fallback. Open and Download stay reachable.
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
expect(screen.queryByText("report.html")).toBeNull();
|
||||
|
||||
const previewBtn = screen.getByTitle("Preview") as HTMLButtonElement;
|
||||
const downloadBtn = screen.getByTitle("Download") as HTMLButtonElement;
|
||||
const copyBtn = screen.getByTitle("Copy code") as HTMLButtonElement;
|
||||
expect(previewBtn.disabled).toBe(false);
|
||||
expect(downloadBtn.disabled).toBe(false);
|
||||
expect(copyBtn.disabled).toBe(true);
|
||||
|
||||
fireEvent.mouseDown(previewBtn);
|
||||
expect(onPreview).toHaveBeenCalled();
|
||||
|
||||
@@ -4,12 +4,8 @@
|
||||
* HtmlAttachmentPreview — inline HTML attachment renderer.
|
||||
*
|
||||
* Visual model mirrors the image renderer: the iframe body is the card, and a
|
||||
* floating right-top toolbar reveals on hover with Preview (full-screen modal)
|
||||
* and Download. No file-card chrome (icon + filename row).
|
||||
*
|
||||
* No "Copy code" button: this is a FILE, not an inline source snippet. The
|
||||
* inline ```html``` fenced block (HtmlBlockPreview) is the surface for reading
|
||||
* / copying HTML source; an attachment's contract is view + download.
|
||||
* floating right-top toolbar reveals on hover with Open / Download / Copy code
|
||||
* actions. No file-card chrome (icon + filename row).
|
||||
*
|
||||
* Mounted by AttachmentBlock when the attachment is HTML and the caller can
|
||||
* supply an `attachmentId` (the /content proxy is ID-keyed). For other kinds,
|
||||
@@ -18,11 +14,13 @@
|
||||
* Failure mode (413 / 415 / transport): we do not unmount the figure or fall
|
||||
* back to AttachmentCard chrome — standalone attachment lists filter URLs
|
||||
* already inlined in the markdown body, so a silent unmount would remove the
|
||||
* user's only Preview/Download entry point. Instead the body collapses to an
|
||||
* 80px placeholder and the toolbar pins itself open with both actions enabled.
|
||||
* user's only Open/Download entry point. Instead the body collapses to an
|
||||
* 80px placeholder, the toolbar pins itself open, Open and Download remain
|
||||
* enabled, and Copy code is disabled (no text payload available).
|
||||
*/
|
||||
|
||||
import { Download, Maximize2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Check, Copy, Download, Maximize2 } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../i18n";
|
||||
import { useAttachmentHtmlText } from "./hooks/use-attachment-html-text";
|
||||
@@ -45,10 +43,24 @@ export function HtmlAttachmentPreview({
|
||||
}: HtmlAttachmentPreviewProps) {
|
||||
const { t } = useT("editor");
|
||||
const query = useAttachmentHtmlText(attachmentId);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const text = query.data?.text;
|
||||
const isLoading = query.isLoading;
|
||||
const isError = !isLoading && (!!query.error || !text);
|
||||
const canCopy = !!text;
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard failures are user-recoverable (try again, or open in modal
|
||||
// and use the text view). No toast — keep the toolbar quiet.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -88,8 +100,8 @@ export function HtmlAttachmentPreview({
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-2 top-2 flex items-center gap-0.5 rounded-md border border-border bg-background/95 p-0.5 shadow-sm transition-opacity",
|
||||
// Error state pins the toolbar open — Preview / Download are the
|
||||
// only user-reachable escape hatches when inline render fails.
|
||||
// Error state pins the toolbar open — Open / Download are the only
|
||||
// user-reachable escape hatches when inline render fails.
|
||||
isError
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover/html-preview:opacity-100",
|
||||
@@ -121,6 +133,27 @@ export function HtmlAttachmentPreview({
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
|
||||
!canCopy && "cursor-not-allowed opacity-50 hover:bg-transparent hover:text-muted-foreground",
|
||||
)}
|
||||
disabled={!canCopy}
|
||||
title={t(($) => $.code_block.copy_code)}
|
||||
aria-label={t(($) => $.code_block.copy_code)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (canCopy) void handleCopy();
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -123,7 +123,7 @@ func init() {
|
||||
autopilotCreateCmd.Flags().String("mode", "", "Execution mode: create_issue or run_only (required)")
|
||||
autopilotCreateCmd.Flags().String("priority", "none", "Priority for created issues (none, low, medium, high, urgent)")
|
||||
autopilotCreateCmd.Flags().String("project", "", "Project ID (optional)")
|
||||
autopilotCreateCmd.Flags().String("issue-title-template", "", "Template for issue titles (create_issue mode). Only {{date}} (UTC, YYYY-MM-DD) is interpolated; any other {{...}} token is rejected at create-time.")
|
||||
autopilotCreateCmd.Flags().String("issue-title-template", "", "Template for issue titles (create_issue mode)")
|
||||
autopilotCreateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// update
|
||||
@@ -134,7 +134,7 @@ func init() {
|
||||
autopilotUpdateCmd.Flags().String("priority", "", "New priority")
|
||||
autopilotUpdateCmd.Flags().String("status", "", "New status (active, paused)")
|
||||
autopilotUpdateCmd.Flags().String("mode", "", "New execution mode (create_issue or run_only)")
|
||||
autopilotUpdateCmd.Flags().String("issue-title-template", "", "New issue title template. Only {{date}} (UTC, YYYY-MM-DD) is interpolated; any other {{...}} token is rejected.")
|
||||
autopilotUpdateCmd.Flags().String("issue-title-template", "", "New issue title template")
|
||||
autopilotUpdateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// delete
|
||||
|
||||
@@ -32,8 +32,6 @@ var autoUpdateInitialDelay = 2 * time.Minute
|
||||
//
|
||||
// Disabled when:
|
||||
// - the operator opted out via --no-auto-update / MULTICA_DAEMON_AUTO_UPDATE=false;
|
||||
// - the daemon points at a self-hosted server (default-off — set
|
||||
// MULTICA_DAEMON_AUTO_UPDATE=true to opt back in);
|
||||
// - the daemon was spawned by Desktop (the Electron app owns the binary);
|
||||
// - the running version doesn't look like a tagged release (dev builds).
|
||||
//
|
||||
|
||||
@@ -72,7 +72,7 @@ type Config struct {
|
||||
GCOrphanTTL time.Duration // clean orphan dirs with no meta, or dirs whose issue gc-check returns 404, once they exceed this age (default: 72h). The 404 path uses the same TTL — a scoped-down token can't instantly wipe live workspaces.
|
||||
GCArtifactTTL time.Duration // when a task has been completed for at least this long but its issue is still open, drop regenerable artifacts (default: 12h, set 0 to disable)
|
||||
GCArtifactPatterns []string // basename patterns whose subtrees are removed during artifact cleanup (default: node_modules, .next, .turbo)
|
||||
AutoUpdateEnabled bool // periodically check for a newer CLI release and self-update when idle (default: true on Multica Cloud, false on self-host)
|
||||
AutoUpdateEnabled bool // periodically check for a newer CLI release and self-update when idle (default: true)
|
||||
AutoUpdateCheckInterval time.Duration // how often the auto-update loop polls for a new release (default: 6h)
|
||||
PollInterval time.Duration
|
||||
HeartbeatInterval time.Duration
|
||||
@@ -355,23 +355,10 @@ func LoadConfig(overrides Overrides) (Config, error) {
|
||||
}
|
||||
gcArtifactPatterns := patternsFromEnv("MULTICA_GC_ARTIFACT_PATTERNS", DefaultGCArtifactPatterns)
|
||||
|
||||
// Auto-update config: default -> env override -> CLI override.
|
||||
//
|
||||
// Default is opt-in on Multica Cloud (api.multica.ai) and opt-out for
|
||||
// self-hosted instances. Self-host operators frequently run a fork with
|
||||
// their own patches, and silently upgrading their daemon to an upstream
|
||||
// GitHub release would clobber that work; they also commonly stay on an
|
||||
// older server build, which a fresh CLI may no longer talk to. Keeping
|
||||
// auto-update off by default for self-host avoids both footguns (MUL-2381).
|
||||
// Operators on either side can flip the default with MULTICA_DAEMON_AUTO_UPDATE.
|
||||
autoUpdateEnabled := isOfficialCloudServer(serverBaseURL)
|
||||
if v := strings.TrimSpace(os.Getenv("MULTICA_DAEMON_AUTO_UPDATE")); v != "" {
|
||||
switch strings.ToLower(v) {
|
||||
case "false", "0", "no", "off":
|
||||
autoUpdateEnabled = false
|
||||
case "true", "1", "yes", "on":
|
||||
autoUpdateEnabled = true
|
||||
}
|
||||
// Auto-update config: env > defaults > CLI override.
|
||||
autoUpdateEnabled := true
|
||||
if v := strings.TrimSpace(os.Getenv("MULTICA_DAEMON_AUTO_UPDATE")); v == "false" || v == "0" {
|
||||
autoUpdateEnabled = false
|
||||
}
|
||||
if overrides.DisableAutoUpdate {
|
||||
autoUpdateEnabled = false
|
||||
@@ -414,26 +401,6 @@ func LoadConfig(overrides Overrides) (Config, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// officialCloudHost is the hostname of Multica's hosted cloud. It's the only
|
||||
// origin we treat as "official" for the auto-update default — staging,
|
||||
// preview, and any future *.multica.ai subdomains are deliberately excluded
|
||||
// so they inherit the safer self-host default until explicitly opted in.
|
||||
const officialCloudHost = "api.multica.ai"
|
||||
|
||||
// isOfficialCloudServer reports whether the resolved server base URL points
|
||||
// at Multica's hosted cloud. Used to pick the auto-update default: cloud
|
||||
// users run a server that publishes the matching CLI release, so opt-in
|
||||
// self-update is safe; self-host users may run a fork or pin to an older
|
||||
// server, so the default flips to off. Matching is host-only and
|
||||
// case-insensitive — port and path are ignored.
|
||||
func isOfficialCloudServer(baseURL string) bool {
|
||||
u, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(u.Hostname(), officialCloudHost)
|
||||
}
|
||||
|
||||
// NormalizeServerBaseURL converts a WebSocket or HTTP URL to a base HTTP URL.
|
||||
func NormalizeServerBaseURL(raw string) (string, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
|
||||
@@ -187,149 +187,6 @@ func lookPathInPath(name string) (string, error) {
|
||||
return exec.LookPath(name)
|
||||
}
|
||||
|
||||
func TestIsOfficialCloudServer(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
url string
|
||||
want bool
|
||||
}{
|
||||
{"canonical cloud https", "https://api.multica.ai", true},
|
||||
{"canonical cloud with trailing slash stripped", "https://api.multica.ai/", true},
|
||||
{"canonical cloud case-insensitive", "https://API.Multica.AI", true},
|
||||
{"cloud over plain http (unusual but match host)", "http://api.multica.ai", true},
|
||||
{"localhost is self-host", "http://localhost:8080", false},
|
||||
{"loopback ip is self-host", "http://127.0.0.1:8080", false},
|
||||
{"lan ip is self-host", "http://192.168.0.28:8080", false},
|
||||
{"third-party host is self-host", "https://multica.example.com", false},
|
||||
// Staging / preview / future subdomains deliberately follow the
|
||||
// safer self-host default until explicitly opted in.
|
||||
{"multica.ai apex is not the api host", "https://multica.ai", false},
|
||||
{"staging subdomain is self-host", "https://staging.multica.ai", false},
|
||||
{"preview subdomain is self-host", "https://api-preview.multica.ai", false},
|
||||
// Malformed inputs must not falsely match.
|
||||
{"empty string is self-host", "", false},
|
||||
{"garbage string is self-host", "::not a url::", false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isOfficialCloudServer(tc.url); got != tc.want {
|
||||
t.Errorf("isOfficialCloudServer(%q) = %v, want %v", tc.url, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// stageFakeAgent writes an executable `claude` script into a temp dir and
|
||||
// points PATH (and the daemon-id env var) so LoadConfig can run end-to-end
|
||||
// without poking the host's real agent installation. Returns the staged PATH
|
||||
// so tests that need to add their own dirs can extend it.
|
||||
func stageFakeAgent(t *testing.T) string {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX shell not available on Windows")
|
||||
}
|
||||
binDir := t.TempDir()
|
||||
fake := filepath.Join(binDir, "claude")
|
||||
if err := os.WriteFile(fake, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatalf("write fake claude: %v", err)
|
||||
}
|
||||
t.Setenv("PATH", binDir)
|
||||
t.Setenv("MULTICA_DAEMON_ID", "11111111-1111-1111-1111-111111111111")
|
||||
// Clear any inherited env-var override so the test sees the URL-based
|
||||
// default, not whatever the developer happens to have exported.
|
||||
t.Setenv("MULTICA_DAEMON_AUTO_UPDATE", "")
|
||||
return binDir
|
||||
}
|
||||
|
||||
// TestLoadConfig_AutoUpdateDefault_SelfHostOff is the regression guard for
|
||||
// MUL-2381: a daemon pointed at any non-cloud server URL must default
|
||||
// AutoUpdateEnabled to false, because self-host operators frequently run a
|
||||
// fork and the upstream GitHub release would silently overwrite it.
|
||||
func TestLoadConfig_AutoUpdateDefault_SelfHostOff(t *testing.T) {
|
||||
stageFakeAgent(t)
|
||||
cfg, err := LoadConfig(Overrides{
|
||||
ServerURL: "http://localhost:8080",
|
||||
WorkspacesRoot: t.TempDir(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
if cfg.AutoUpdateEnabled {
|
||||
t.Fatalf("AutoUpdateEnabled = true for self-host (localhost) server, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfig_AutoUpdateDefault_CloudOn confirms the symmetric case: a
|
||||
// daemon pointed at Multica's hosted cloud keeps the historical opt-in
|
||||
// auto-update default. We pass the WSS form of the URL to also exercise that
|
||||
// NormalizeServerBaseURL maps it through to the http host the detector
|
||||
// inspects.
|
||||
func TestLoadConfig_AutoUpdateDefault_CloudOn(t *testing.T) {
|
||||
stageFakeAgent(t)
|
||||
cfg, err := LoadConfig(Overrides{
|
||||
ServerURL: "wss://api.multica.ai/ws",
|
||||
WorkspacesRoot: t.TempDir(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
if !cfg.AutoUpdateEnabled {
|
||||
t.Fatalf("AutoUpdateEnabled = false for Multica Cloud server, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfig_AutoUpdateEnv_ForcesOnForSelfHost lets a self-host operator
|
||||
// re-enable auto-update via env var, overriding the new conservative default.
|
||||
func TestLoadConfig_AutoUpdateEnv_ForcesOnForSelfHost(t *testing.T) {
|
||||
stageFakeAgent(t)
|
||||
t.Setenv("MULTICA_DAEMON_AUTO_UPDATE", "true")
|
||||
cfg, err := LoadConfig(Overrides{
|
||||
ServerURL: "http://localhost:8080",
|
||||
WorkspacesRoot: t.TempDir(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
if !cfg.AutoUpdateEnabled {
|
||||
t.Fatalf("AutoUpdateEnabled = false after explicit MULTICA_DAEMON_AUTO_UPDATE=true, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfig_AutoUpdateEnv_ForcesOffForCloud covers the inverse: a cloud
|
||||
// user can still opt out via env var.
|
||||
func TestLoadConfig_AutoUpdateEnv_ForcesOffForCloud(t *testing.T) {
|
||||
stageFakeAgent(t)
|
||||
t.Setenv("MULTICA_DAEMON_AUTO_UPDATE", "false")
|
||||
cfg, err := LoadConfig(Overrides{
|
||||
ServerURL: "https://api.multica.ai",
|
||||
WorkspacesRoot: t.TempDir(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
if cfg.AutoUpdateEnabled {
|
||||
t.Fatalf("AutoUpdateEnabled = true after explicit MULTICA_DAEMON_AUTO_UPDATE=false, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfig_AutoUpdate_NoFlagWinsOverCloudDefault keeps the legacy CLI
|
||||
// flag working: --no-auto-update (translated into overrides.DisableAutoUpdate)
|
||||
// forces auto-update off even when the cloud default and env var would enable.
|
||||
func TestLoadConfig_AutoUpdate_NoFlagWinsOverCloudDefault(t *testing.T) {
|
||||
stageFakeAgent(t)
|
||||
t.Setenv("MULTICA_DAEMON_AUTO_UPDATE", "true")
|
||||
cfg, err := LoadConfig(Overrides{
|
||||
ServerURL: "https://api.multica.ai",
|
||||
WorkspacesRoot: t.TempDir(),
|
||||
DisableAutoUpdate: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
if cfg.AutoUpdateEnabled {
|
||||
t.Fatalf("AutoUpdateEnabled = true with --no-auto-update set; flag must win")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveAgentsViaLoginShell_StripsAliasShadowing locks down the fix for
|
||||
// #2512: when the user's rc file declares an alias with the same name as the
|
||||
// agent CLI, the resolver must still return the real binary on PATH, not the
|
||||
|
||||
@@ -347,12 +347,6 @@ func (h *Handler) CreateAutopilot(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "execution_mode must be create_issue or run_only")
|
||||
return
|
||||
}
|
||||
if req.IssueTitleTemplate != nil {
|
||||
if err := service.ValidateIssueTitleTemplate(*req.IssueTitleTemplate); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
workspaceID := h.resolveWorkspaceID(r)
|
||||
userID, ok := requireUserID(w, r)
|
||||
@@ -446,12 +440,6 @@ func (h *Handler) UpdateAutopilot(w http.ResponseWriter, r *http.Request) {
|
||||
params.Description = ptrToText(req.Description)
|
||||
}
|
||||
if _, ok := rawFields["issue_title_template"]; ok {
|
||||
if req.IssueTitleTemplate != nil {
|
||||
if err := service.ValidateIssueTitleTemplate(*req.IssueTitleTemplate); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
params.IssueTitleTemplate = ptrToText(req.IssueTitleTemplate)
|
||||
}
|
||||
if _, ok := rawFields["assignee_id"]; ok {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -638,68 +637,14 @@ func prettifyJSON(raw []byte) ([]byte, error) {
|
||||
return json.MarshalIndent(v, "", " ")
|
||||
}
|
||||
|
||||
// issueTitleTemplateTokenRE matches any {{...}} token in an issue-title
|
||||
// template. We deliberately permit whitespace inside the braces ({{ date }})
|
||||
// so users can format templates either way; the canonical token is still
|
||||
// {{date}}.
|
||||
var issueTitleTemplateTokenRE = regexp.MustCompile(`\{\{\s*([^{}]*?)\s*\}\}`)
|
||||
|
||||
// interpolateTemplate substitutes supported {{name}} placeholders in the
|
||||
// issue title template. Whitespace inside the braces ({{ date }}) is
|
||||
// tolerated so the render layer accepts every form that
|
||||
// ValidateIssueTitleTemplate accepts — otherwise users would save templates
|
||||
// that pass validation but still emit a literal token at trigger time.
|
||||
// interpolateTemplate replaces {{date}} in the issue title template.
|
||||
func (s *AutopilotService) interpolateTemplate(ap db.Autopilot) string {
|
||||
tmpl := ap.Title
|
||||
if ap.IssueTitleTemplate.Valid && ap.IssueTitleTemplate.String != "" {
|
||||
tmpl = ap.IssueTitleTemplate.String
|
||||
}
|
||||
now := time.Now().UTC().Format("2006-01-02")
|
||||
return issueTitleTemplateTokenRE.ReplaceAllStringFunc(tmpl, func(match string) string {
|
||||
name := strings.TrimSpace(match[2 : len(match)-2])
|
||||
switch name {
|
||||
case "date":
|
||||
return now
|
||||
default:
|
||||
return match
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// SupportedIssueTitleTemplateVariables enumerates the placeholders that
|
||||
// interpolateTemplate will substitute. Keep this in sync with the
|
||||
// substitution logic above and with the docs in autopilots.mdx /
|
||||
// autopilots.zh.mdx.
|
||||
var SupportedIssueTitleTemplateVariables = []string{"date"}
|
||||
|
||||
// ValidateIssueTitleTemplate rejects templates that contain any {{...}} token
|
||||
// other than the supported set. An empty template is valid (the autopilot
|
||||
// falls back to its own Title). The error message names the first offending
|
||||
// token to keep CLI feedback actionable.
|
||||
func ValidateIssueTitleTemplate(tmpl string) error {
|
||||
if tmpl == "" {
|
||||
return nil
|
||||
}
|
||||
for _, m := range issueTitleTemplateTokenRE.FindAllStringSubmatch(tmpl, -1) {
|
||||
name := m[1]
|
||||
if !isSupportedIssueTitleVariable(name) {
|
||||
return fmt.Errorf(
|
||||
"unknown template variable %q; supported: {{%s}}",
|
||||
name,
|
||||
strings.Join(SupportedIssueTitleTemplateVariables, "}}, {{"),
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isSupportedIssueTitleVariable(name string) bool {
|
||||
for _, v := range SupportedIssueTitleTemplateVariables {
|
||||
if name == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return strings.ReplaceAll(tmpl, "{{date}}", now)
|
||||
}
|
||||
|
||||
func (s *AutopilotService) getIssuePrefix(workspaceID pgtype.UUID) string {
|
||||
|
||||
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
@@ -93,102 +92,3 @@ func TestBuildIssueDescription_NonWebhookSourceWithPayloadIgnored(t *testing.T)
|
||||
t.Fatalf("non-webhook source should not include webhook block: %q", got.String)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterpolateTemplate covers the three behaviours that real autopilot
|
||||
// runs depend on: {{date}} substitution, falling back to Title when the
|
||||
// template is unset/empty, and leaving any non-{{date}} text alone (the
|
||||
// handler is the layer that prevents unknown tokens from being stored in
|
||||
// the first place — service-layer interpolation stays substitute-or-leave).
|
||||
func TestInterpolateTemplate(t *testing.T) {
|
||||
s := &AutopilotService{}
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ap db.Autopilot
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
name: "date placeholder substituted",
|
||||
ap: db.Autopilot{Title: "fallback", IssueTitleTemplate: pgtype.Text{String: "probe — {{date}}", Valid: true}},
|
||||
expect: "probe — " + today,
|
||||
},
|
||||
{
|
||||
name: "date placeholder with whitespace substituted",
|
||||
ap: db.Autopilot{Title: "fallback", IssueTitleTemplate: pgtype.Text{String: "probe — {{ date }}", Valid: true}},
|
||||
expect: "probe — " + today,
|
||||
},
|
||||
{
|
||||
name: "empty template falls back to autopilot title",
|
||||
ap: db.Autopilot{Title: "fallback title", IssueTitleTemplate: pgtype.Text{Valid: false}},
|
||||
expect: "fallback title",
|
||||
},
|
||||
{
|
||||
name: "template without placeholder is returned verbatim",
|
||||
ap: db.Autopilot{Title: "fallback", IssueTitleTemplate: pgtype.Text{String: "static title", Valid: true}},
|
||||
expect: "static title",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := s.interpolateTemplate(tc.ap); got != tc.expect {
|
||||
t.Fatalf("interpolateTemplate = %q, want %q", got, tc.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateIssueTitleTemplate locks down what create/update accept.
|
||||
// Reject path: anything inside {{...}} that is not in the supported set.
|
||||
// Accept path: empty, plain text, and the canonical {{date}} placeholder
|
||||
// in both compact and whitespace-padded forms.
|
||||
func TestValidateIssueTitleTemplate(t *testing.T) {
|
||||
t.Run("accepts empty template", func(t *testing.T) {
|
||||
if err := ValidateIssueTitleTemplate(""); err != nil {
|
||||
t.Fatalf("empty template must be valid: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("accepts plain text", func(t *testing.T) {
|
||||
if err := ValidateIssueTitleTemplate("daily report"); err != nil {
|
||||
t.Fatalf("plain text must be valid: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("accepts {{date}}", func(t *testing.T) {
|
||||
if err := ValidateIssueTitleTemplate("probe — {{date}}"); err != nil {
|
||||
t.Fatalf("{{date}} must be valid: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("accepts {{ date }} with whitespace", func(t *testing.T) {
|
||||
if err := ValidateIssueTitleTemplate("probe — {{ date }}"); err != nil {
|
||||
t.Fatalf("{{ date }} must be valid: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
rejections := []struct {
|
||||
name string
|
||||
tmpl string
|
||||
// nameInError is the offending variable name that must appear in the
|
||||
// returned error so CLI users see which token was rejected.
|
||||
nameInError string
|
||||
}{
|
||||
{"go template style", "probe — {{.TriggeredAt}}", ".TriggeredAt"},
|
||||
{"mustache style unknown variable", "probe — {{trigger_id}}", "trigger_id"},
|
||||
{"datetime not yet supported", "probe — {{datetime}}", "datetime"},
|
||||
{"empty placeholder", "probe — {{}}", ""},
|
||||
{"mixed valid + invalid still fails", "probe — {{date}} {{trigger_source}}", "trigger_source"},
|
||||
}
|
||||
for _, tc := range rejections {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateIssueTitleTemplate(tc.tmpl)
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection for %q", tc.tmpl)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown template variable") {
|
||||
t.Fatalf("error should mention unknown template variable: %v", err)
|
||||
}
|
||||
if tc.nameInError != "" && !strings.Contains(err.Error(), tc.nameInError) {
|
||||
t.Fatalf("error should name the offending token %q: %v", tc.nameInError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user