mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-20 13:18:56 +02:00
Compare commits
6 Commits
v0.2.22
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eed41083cf | ||
|
|
798d16edf2 | ||
|
|
374f62be13 | ||
|
|
d9e5cf87dd | ||
|
|
13fe614903 | ||
|
|
2305f7d180 |
@@ -130,38 +130,40 @@ function AutopilotRow({ autopilot }: { autopilot: Autopilot }) {
|
||||
const StatusIcon = statusCfg.icon;
|
||||
|
||||
return (
|
||||
<div className="group/row flex h-11 items-center gap-2 px-5 text-sm transition-colors hover:bg-accent/40">
|
||||
<div className="group/row flex flex-col gap-2 border-b px-4 py-3 text-sm transition-colors hover:bg-accent/40 sm:h-11 sm:flex-row sm:items-center sm:gap-2 sm:border-b-0 sm:px-5 sm:py-0">
|
||||
<AppLink
|
||||
href={wsPaths.autopilotDetail(autopilot.id)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2"
|
||||
className="flex min-w-0 items-center gap-2 sm:flex-1"
|
||||
>
|
||||
<Zap className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{autopilot.title}</span>
|
||||
</AppLink>
|
||||
|
||||
{/* Agent */}
|
||||
<span className="flex w-32 items-center gap-1.5 shrink-0">
|
||||
<ActorAvatar actorType="agent" actorId={autopilot.assignee_id} size={18} enableHoverCard showStatusDot />
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{getActorName("agent", autopilot.assignee_id)}
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 pl-6 text-xs sm:contents sm:pl-0">
|
||||
{/* Agent */}
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-muted-foreground sm:w-32 sm:shrink-0">
|
||||
<ActorAvatar actorType="agent" actorId={autopilot.assignee_id} size={18} enableHoverCard showStatusDot />
|
||||
<span className="truncate">
|
||||
{getActorName("agent", autopilot.assignee_id)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Mode */}
|
||||
<span className="w-24 shrink-0 text-center text-xs text-muted-foreground">
|
||||
{EXECUTION_MODE_LABELS[autopilot.execution_mode] ?? autopilot.execution_mode}
|
||||
</span>
|
||||
{/* Mode */}
|
||||
<span className="text-muted-foreground sm:w-24 sm:shrink-0 sm:text-center">
|
||||
{EXECUTION_MODE_LABELS[autopilot.execution_mode] ?? autopilot.execution_mode}
|
||||
</span>
|
||||
|
||||
{/* Status */}
|
||||
<span className={cn("flex w-20 items-center justify-center gap-1 shrink-0 text-xs", statusCfg.color)}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
{/* Status */}
|
||||
<span className={cn("flex items-center gap-1 sm:w-20 sm:shrink-0 sm:justify-center", statusCfg.color)}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
|
||||
{/* Last run */}
|
||||
<span className="w-20 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
|
||||
{autopilot.last_run_at ? formatRelativeDate(autopilot.last_run_at) : "--"}
|
||||
</span>
|
||||
{/* Last run */}
|
||||
<span className="text-muted-foreground tabular-nums sm:w-20 sm:shrink-0 sm:text-right">
|
||||
{autopilot.last_run_at ? formatRelativeDate(autopilot.last_run_at) : "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -198,7 +200,7 @@ export function AutopilotsPage() {
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="sticky top-0 z-[1] flex h-8 items-center gap-2 border-b bg-muted/30 px-5">
|
||||
<div className="sticky top-0 z-[1] hidden h-8 items-center gap-2 border-b bg-muted/30 px-5 sm:flex">
|
||||
<span className="shrink-0 w-4" />
|
||||
<Skeleton className="h-3 w-12 flex-1 max-w-[48px]" />
|
||||
<Skeleton className="h-3 w-12 shrink-0" />
|
||||
@@ -206,9 +208,9 @@ export function AutopilotsPage() {
|
||||
<Skeleton className="h-3 w-10 shrink-0" />
|
||||
<Skeleton className="h-3 w-12 shrink-0" />
|
||||
</div>
|
||||
<div className="p-5 pt-1 space-y-1">
|
||||
<div className="space-y-2 p-4 sm:space-y-1 sm:p-5 sm:pt-1">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-11 w-full" />
|
||||
<Skeleton key={i} className="h-[72px] w-full sm:h-11" />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
@@ -246,7 +248,7 @@ export function AutopilotsPage() {
|
||||
) : (
|
||||
<>
|
||||
{/* Column headers */}
|
||||
<div className="sticky top-0 z-[1] flex h-8 items-center gap-2 border-b bg-muted/30 px-5 text-xs font-medium text-muted-foreground">
|
||||
<div className="sticky top-0 z-[1] hidden h-8 items-center gap-2 border-b bg-muted/30 px-5 text-xs font-medium text-muted-foreground sm:flex">
|
||||
<span className="shrink-0 w-4" />
|
||||
<span className="min-w-0 flex-1">Name</span>
|
||||
<span className="w-32 shrink-0">Agent</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { StatusIcon } from "../../issues/components";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { Archive, CircleCheck } from "lucide-react";
|
||||
import { Archive } from "lucide-react";
|
||||
import type { InboxItem } from "@multica/core/types";
|
||||
import { InboxDetailLabel } from "./inbox-detail-label";
|
||||
import { getInboxDisplayTitle } from "./inbox-display";
|
||||
@@ -25,13 +25,11 @@ export function InboxListItem({
|
||||
isSelected,
|
||||
onClick,
|
||||
onArchive,
|
||||
onDone,
|
||||
}: {
|
||||
item: InboxItem;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
onArchive: () => void;
|
||||
onDone?: () => void;
|
||||
}) {
|
||||
const displayTitle = getInboxDisplayTitle(item);
|
||||
|
||||
@@ -61,26 +59,6 @@ export function InboxListItem({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{onDone && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
title="Mark as done"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDone();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
onDone();
|
||||
}
|
||||
}}
|
||||
className="hidden rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-info group-hover:inline-flex"
|
||||
>
|
||||
<CircleCheck className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
useArchiveAllReadInbox,
|
||||
useArchiveCompletedInbox,
|
||||
} from "@multica/core/inbox/mutations";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
|
||||
import { IssueDetail } from "../../issues/components";
|
||||
import { useNavigation } from "../../navigation";
|
||||
import { toast } from "sonner";
|
||||
@@ -118,8 +118,6 @@ export function InboxPage() {
|
||||
const archiveAllMutation = useArchiveAllInbox();
|
||||
const archiveAllReadMutation = useArchiveAllReadInbox();
|
||||
const archiveCompletedMutation = useArchiveCompletedInbox();
|
||||
const updateIssueMutation = useUpdateIssue();
|
||||
|
||||
// Auto-mark-read whenever a selected item is unread — covers both click-
|
||||
// to-select and URL-param-select (e.g. OS notification click on desktop).
|
||||
// The mutation flips `read: true` optimistically, so this effect settles
|
||||
@@ -147,18 +145,6 @@ export function InboxPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDone = (item: InboxItem) => {
|
||||
if (!item.issue_id) return;
|
||||
setSelectedKey("");
|
||||
updateIssueMutation.mutate(
|
||||
{ id: item.issue_id, status: "done" },
|
||||
{ onError: () => toast.error("Failed to mark as done") },
|
||||
);
|
||||
archiveMutation.mutate(item.id, {
|
||||
onError: () => toast.error("Failed to archive"),
|
||||
});
|
||||
};
|
||||
|
||||
// Batch operations
|
||||
const handleMarkAllRead = () => {
|
||||
markAllReadMutation.mutate(undefined, {
|
||||
@@ -249,11 +235,6 @@ export function InboxPage() {
|
||||
isSelected={(item.issue_id ?? item.id) === selectedKey}
|
||||
onClick={() => handleSelect(item)}
|
||||
onArchive={() => handleArchive(item.id)}
|
||||
onDone={
|
||||
item.issue_id && item.issue_status !== "done" && item.issue_status !== "cancelled"
|
||||
? () => handleDone(item)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
|
||||
import { AppLink } from "../../navigation";
|
||||
import { useNavigation } from "../../navigation";
|
||||
import {
|
||||
Archive,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
@@ -549,6 +550,23 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
<TooltipContent side="bottom">Mark as done</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDone && issue.status === "done" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => { onDone(); }}
|
||||
>
|
||||
<Archive />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Archive</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
|
||||
@@ -240,26 +240,19 @@ func renderIssueContext(provider string, ctx TaskContextForEnv) string {
|
||||
}
|
||||
|
||||
// renderQuickCreateContext renders issue_context.md for quick-create tasks.
|
||||
// There is no issue yet, so we explicitly tell the agent NOT to call
|
||||
// `multica issue get` / `status` / `comment add` — those would either error
|
||||
// (empty IssueID) or silently target an unrelated issue.
|
||||
// This file carries only task data (user input, skills). Behavioral rules
|
||||
// and guardrails live in AGENTS.md (runtime config) and the per-turn prompt
|
||||
// to avoid redundancy and conflicting instructions.
|
||||
func renderQuickCreateContext(ctx TaskContextForEnv) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Quick Create\n\n")
|
||||
b.WriteString("**Trigger:** Quick-create modal\n\n")
|
||||
b.WriteString("There is NO existing Multica issue for this run. Translate the user input below into a single `multica issue create` invocation, then exit.\n\n")
|
||||
b.WriteString("## User input\n\n")
|
||||
b.WriteString("> ")
|
||||
b.WriteString(ctx.QuickCreatePrompt)
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString("## Rules\n\n")
|
||||
b.WriteString("- Run exactly one `multica issue create` invocation. No retries.\n")
|
||||
b.WriteString("- After it succeeds, print `Created MUL-<n>: <title>` and exit.\n")
|
||||
b.WriteString("- Do NOT run `multica issue get`, `multica issue status`, or `multica issue comment add` — there is nothing to query, transition, or comment on.\n")
|
||||
b.WriteString("- The platform writes the user's success/failure inbox notification automatically based on the CLI exit status.\n\n")
|
||||
if len(ctx.AgentSkills) > 0 {
|
||||
b.WriteString("## Agent Skills\n\n")
|
||||
b.WriteString("The following skills are available, but for quick-create they are usually unnecessary:\n\n")
|
||||
for _, skill := range ctx.AgentSkills {
|
||||
fmt.Fprintf(&b, "- **%s**\n", skill.Name)
|
||||
}
|
||||
|
||||
@@ -40,31 +40,53 @@ func BuildPrompt(task Task) string {
|
||||
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 job is to create a well-formed issue from the user's input with a single `multica issue create` command.\n\n")
|
||||
b.WriteString("A user captured the following input via the quick-create modal. There is NO existing issue. Your job is to create a well-formed issue from this 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 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("Field rules:\n\n")
|
||||
|
||||
// title
|
||||
b.WriteString("- **title**: required. A concise but semantically rich summary. If the input references external resources (PRs, issues, URLs), use your judgment on whether fetching the resource would produce a meaningfully better title — e.g. \"review PR #123\" → \"Review PR #123: Refactor auth module to OAuth2\". Strip filler words but preserve key semantic information.\n\n")
|
||||
|
||||
// description — the core optimization
|
||||
b.WriteString("- **description**: The description will be the primary context for the executing agent. Your goal is **high fidelity** — the executing agent should understand the user's intent as well as if they had read the original input themselves.\n\n")
|
||||
b.WriteString(" **Structure the description as follows:**\n\n")
|
||||
b.WriteString(" 1. **User request** — Faithfully restate what the user wants done, in their own terms. Preserve the user's phrasing, tone, and scope. Do NOT paraphrase into generic language (e.g. don't turn \"把这个按钮改成红色\" into \"UI improvement needed\"). Do NOT add implementation plans, acceptance criteria, design decisions, or constraints the user did not express.\n\n")
|
||||
b.WriteString(" 2. **Context** (only if the input contains URLs, references, or image attachments) — Fetch referenced resources (PRs, issues, docs) and summarize the relevant factual content. Keep summaries to verifiable facts only (e.g. \"PR #45 changes the auth middleware to use JWT\" not \"this suggests we should also update the tests\"). Preserve any image URLs or markdown image references inline.\n\n")
|
||||
b.WriteString(" Omit the Context section entirely if there are no external references to enrich.\n\n")
|
||||
b.WriteString(" **Hard rules for description:**\n")
|
||||
b.WriteString(" - NEVER invent requirements, implementation details, acceptance criteria, or scope that the user did not express.\n")
|
||||
b.WriteString(" - NEVER reduce the user's multi-sentence input to a single vague sentence. If the user wrote three sentences, the description should carry at least that much information.\n")
|
||||
b.WriteString(" - Preserve specific names, identifiers, file paths, code snippets, and technical terms from the input verbatim.\n")
|
||||
b.WriteString(" - Never echo the title in the description.\n\n")
|
||||
|
||||
// priority
|
||||
b.WriteString("- **priority**: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\" → urgent. If unspecified, omit.\n\n")
|
||||
|
||||
// assignee
|
||||
b.WriteString("- **assignee**:\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
|
||||
}
|
||||
if agentName != "" {
|
||||
fmt.Fprintf(&b, " - When the user did NOT name an assignee, default to YOURSELF: pass `--assignee %q`. The picker agent is the expected owner because the user opened quick-create with you selected — never leave the issue unassigned.\n", agentName)
|
||||
fmt.Fprintf(&b, " - When the user did NOT name an assignee, default to YOURSELF: pass `--assignee %q`. The picker agent is the expected owner because the user opened quick-create with you selected — never leave the issue unassigned.\n\n", agentName)
|
||||
} else {
|
||||
b.WriteString(" - When the user did NOT name an assignee, default to YOURSELF (the picker agent): pass `--assignee <your agent name>`. Never leave the issue unassigned.\n")
|
||||
b.WriteString(" - When the user did NOT name an assignee, default to YOURSELF (the picker agent): pass `--assignee <your agent name>`. Never leave the issue unassigned.\n\n")
|
||||
}
|
||||
b.WriteString("- project: omit. The platform will route the issue to the workspace default.\n")
|
||||
b.WriteString("- status: omit (defaults to `todo`).\n")
|
||||
b.WriteString("- attachments: do NOT pass `--attachment`. The flag only accepts LOCAL file paths, and any image URL embedded in the user input is already part of the description as markdown — keep it inline in `--description` instead of trying to re-attach it. (Trying to pass `https://…` to `--attachment` will fail and look like a create error to you, but the issue may already exist; never retry `issue create` on that signal.)\n\n")
|
||||
|
||||
// fields to omit
|
||||
b.WriteString("- **project**: omit. The platform will route the issue to the workspace default.\n")
|
||||
b.WriteString("- **status**: omit (defaults to `todo`).\n")
|
||||
b.WriteString("- **attachments**: do NOT pass `--attachment`. The flag only accepts LOCAL file paths. Any image URL in the user input is already markdown — keep it inline in `--description` instead.\n\n")
|
||||
|
||||
// output format
|
||||
b.WriteString("Output format:\n")
|
||||
b.WriteString("- Run exactly one `multica issue create` invocation. Do not retry it for any reason — even on a non-zero exit. The issue may already exist; another attempt would create a duplicate.\n")
|
||||
b.WriteString("- After it succeeds, print exactly one line: `Created MUL-<n>: <title>` and exit. No commentary, no follow-up tool calls.\n")
|
||||
b.WriteString("- Do NOT call `multica issue get` or `multica issue comment add` for this task — there is no issue to query or comment on prior to creation.\n")
|
||||
b.WriteString("- If the CLI returns an error, exit with that error as the only output. The platform writes a failure notification automatically; do not retry.\n")
|
||||
b.WriteString("- Run exactly one `multica issue create` invocation. Do not retry for any reason — even on non-zero exit. The issue may already exist; another attempt would create a duplicate.\n")
|
||||
b.WriteString("- After success, print exactly one line: `Created MUL-<n>: <title>` and exit. No commentary, no follow-up tool calls.\n")
|
||||
b.WriteString("- Do NOT call `multica issue get` or `multica issue comment add` — there is no issue to query or comment on.\n")
|
||||
b.WriteString("- On CLI error, exit with the error as the only output. The platform writes a failure notification automatically.\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -19,8 +20,34 @@ import (
|
||||
// It passes the full daemon environment so credential helpers (e.g. gh) can
|
||||
// locate their config, and disables TTY prompting so auth failures produce
|
||||
// clear errors instead of blocking on a non-existent terminal.
|
||||
//
|
||||
// safe.directory=* is set via GIT_CONFIG_* env vars so git trusts all
|
||||
// directories regardless of ownership. The daemon manages its own bare
|
||||
// caches and worktrees, so the ownership check adds no security value
|
||||
// and breaks CI environments where the runner UID differs from the
|
||||
// directory owner.
|
||||
func gitEnv() []string {
|
||||
return append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
|
||||
base := os.Environ()
|
||||
|
||||
// Find the existing GIT_CONFIG_COUNT so we append at the next index
|
||||
// rather than overwriting any env-scoped git config (auth, URL
|
||||
// rewrites, extra headers, etc.).
|
||||
existing := 0
|
||||
for _, e := range base {
|
||||
if strings.HasPrefix(e, "GIT_CONFIG_COUNT=") {
|
||||
if n, err := strconv.Atoi(strings.TrimPrefix(e, "GIT_CONFIG_COUNT=")); err == nil {
|
||||
existing = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
idx := strconv.Itoa(existing)
|
||||
return append(base,
|
||||
"GIT_TERMINAL_PROMPT=0",
|
||||
"GIT_CONFIG_COUNT="+strconv.Itoa(existing+1),
|
||||
"GIT_CONFIG_KEY_"+idx+"=safe.directory",
|
||||
"GIT_CONFIG_VALUE_"+idx+"=*",
|
||||
)
|
||||
}
|
||||
|
||||
// RepoInfo describes a repository to cache.
|
||||
|
||||
@@ -44,6 +44,65 @@ func TestGitEnv(t *testing.T) {
|
||||
if !foundHome {
|
||||
t.Error("gitEnv() must include HOME from os.Environ()")
|
||||
}
|
||||
|
||||
// Must set safe.directory=* via GIT_CONFIG env vars.
|
||||
envHas := func(env []string, want string) bool {
|
||||
for _, e := range env {
|
||||
if e == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !envHas(env, "GIT_CONFIG_KEY_0=safe.directory") {
|
||||
t.Error("gitEnv() must include GIT_CONFIG_KEY_0=safe.directory (no pre-existing config)")
|
||||
}
|
||||
if !envHas(env, "GIT_CONFIG_VALUE_0=*") {
|
||||
t.Error("gitEnv() must include GIT_CONFIG_VALUE_0=*")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitEnvPreservesExistingConfig(t *testing.T) {
|
||||
// GIT_CONFIG_COUNT env vars are process-wide; cannot use t.Setenv in
|
||||
// parallel tests, so run sequentially.
|
||||
t.Setenv("GIT_CONFIG_COUNT", "2")
|
||||
t.Setenv("GIT_CONFIG_KEY_0", "url.https://github.com/.insteadOf")
|
||||
t.Setenv("GIT_CONFIG_VALUE_0", "gh:")
|
||||
t.Setenv("GIT_CONFIG_KEY_1", "http.extraHeader")
|
||||
t.Setenv("GIT_CONFIG_VALUE_1", "Authorization: Bearer tok")
|
||||
|
||||
env := gitEnv()
|
||||
|
||||
envHas := func(want string) bool {
|
||||
for _, e := range env {
|
||||
if e == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// safe.directory must be appended at index 2 (next available).
|
||||
if !envHas("GIT_CONFIG_COUNT=3") {
|
||||
t.Error("expected GIT_CONFIG_COUNT=3")
|
||||
}
|
||||
if !envHas("GIT_CONFIG_KEY_2=safe.directory") {
|
||||
t.Error("expected GIT_CONFIG_KEY_2=safe.directory")
|
||||
}
|
||||
if !envHas("GIT_CONFIG_VALUE_2=*") {
|
||||
t.Error("expected GIT_CONFIG_VALUE_2=*")
|
||||
}
|
||||
|
||||
// Original entries must still be present.
|
||||
if !envHas("GIT_CONFIG_KEY_0=url.https://github.com/.insteadOf") {
|
||||
t.Error("existing GIT_CONFIG_KEY_0 was lost")
|
||||
}
|
||||
if !envHas("GIT_CONFIG_VALUE_0=gh:") {
|
||||
t.Error("existing GIT_CONFIG_VALUE_0 was lost")
|
||||
}
|
||||
if !envHas("GIT_CONFIG_KEY_1=http.extraHeader") {
|
||||
t.Error("existing GIT_CONFIG_KEY_1 was lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBareDirName(t *testing.T) {
|
||||
|
||||
@@ -17,6 +17,13 @@ import (
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
// sanitizeNullBytes removes null bytes (0x00) from strings.
|
||||
// PostgreSQL rejects null bytes in text columns with
|
||||
// "invalid byte sequence for encoding UTF8: 0x00 (SQLSTATE 22021)".
|
||||
func sanitizeNullBytes(s string) string {
|
||||
return strings.ReplaceAll(s, "\x00", "")
|
||||
}
|
||||
|
||||
// --- Response structs ---
|
||||
|
||||
type SkillResponse struct {
|
||||
@@ -289,13 +296,13 @@ func (h *Handler) UpdateSkill(w http.ResponseWriter, r *http.Request) {
|
||||
ID: parseUUID(id),
|
||||
}
|
||||
if req.Name != nil {
|
||||
params.Name = pgtype.Text{String: *req.Name, Valid: true}
|
||||
params.Name = pgtype.Text{String: sanitizeNullBytes(*req.Name), Valid: true}
|
||||
}
|
||||
if req.Description != nil {
|
||||
params.Description = pgtype.Text{String: *req.Description, Valid: true}
|
||||
params.Description = pgtype.Text{String: sanitizeNullBytes(*req.Description), Valid: true}
|
||||
}
|
||||
if req.Content != nil {
|
||||
params.Content = pgtype.Text{String: *req.Content, Valid: true}
|
||||
params.Content = pgtype.Text{String: sanitizeNullBytes(*req.Content), Valid: true}
|
||||
}
|
||||
if req.Config != nil {
|
||||
config, _ := json.Marshal(req.Config)
|
||||
@@ -323,8 +330,8 @@ func (h *Handler) UpdateSkill(w http.ResponseWriter, r *http.Request) {
|
||||
for _, f := range req.Files {
|
||||
sf, err := qtx.UpsertSkillFile(r.Context(), db.UpsertSkillFileParams{
|
||||
SkillID: skill.ID,
|
||||
Path: f.Path,
|
||||
Content: f.Content,
|
||||
Path: sanitizeNullBytes(f.Path),
|
||||
Content: sanitizeNullBytes(f.Content),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to upsert skill file: "+err.Error())
|
||||
@@ -1188,8 +1195,8 @@ func (h *Handler) UpsertSkillFile(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sf, err := h.Queries.UpsertSkillFile(r.Context(), db.UpsertSkillFileParams{
|
||||
SkillID: skill.ID,
|
||||
Path: req.Path,
|
||||
Content: req.Content,
|
||||
Path: sanitizeNullBytes(req.Path),
|
||||
Content: sanitizeNullBytes(req.Content),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to upsert skill file: "+err.Error())
|
||||
|
||||
@@ -37,9 +37,9 @@ func (h *Handler) createSkillWithFiles(ctx context.Context, input skillCreateInp
|
||||
|
||||
skill, err := qtx.CreateSkill(ctx, db.CreateSkillParams{
|
||||
WorkspaceID: input.WorkspaceID,
|
||||
Name: input.Name,
|
||||
Description: input.Description,
|
||||
Content: input.Content,
|
||||
Name: sanitizeNullBytes(input.Name),
|
||||
Description: sanitizeNullBytes(input.Description),
|
||||
Content: sanitizeNullBytes(input.Content),
|
||||
Config: config,
|
||||
CreatedBy: input.CreatorID,
|
||||
})
|
||||
@@ -51,8 +51,8 @@ func (h *Handler) createSkillWithFiles(ctx context.Context, input skillCreateInp
|
||||
for _, f := range input.Files {
|
||||
sf, err := qtx.UpsertSkillFile(ctx, db.UpsertSkillFileParams{
|
||||
SkillID: skill.ID,
|
||||
Path: f.Path,
|
||||
Content: f.Content,
|
||||
Path: sanitizeNullBytes(f.Path),
|
||||
Content: sanitizeNullBytes(f.Content),
|
||||
})
|
||||
if err != nil {
|
||||
return SkillWithFilesResponse{}, err
|
||||
|
||||
Reference in New Issue
Block a user