mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-25 08:29:18 +02:00
Compare commits
6 Commits
agent/lamb
...
v0.2.23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3df95c84b8 | ||
|
|
050a2f0a5b | ||
|
|
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={
|
||||
|
||||
@@ -77,13 +77,6 @@ export function IssuesPage() {
|
||||
const updateIssueMutation = useUpdateIssue();
|
||||
const handleMoveIssue = useCallback(
|
||||
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
|
||||
// Auto-switch to manual sort so drag ordering is preserved
|
||||
const viewState = useIssueViewStore.getState();
|
||||
if (viewState.sortBy !== "position") {
|
||||
viewState.setSortBy("position");
|
||||
viewState.setSortDirection("asc");
|
||||
}
|
||||
|
||||
const updates: Partial<{ status: IssueStatus; position: number }> = {
|
||||
status: newStatus,
|
||||
};
|
||||
|
||||
@@ -102,12 +102,6 @@ export function MyIssuesPage() {
|
||||
const updateIssueMutation = useUpdateIssue();
|
||||
const handleMoveIssue = useCallback(
|
||||
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
|
||||
const viewState = myIssuesViewStore.getState();
|
||||
if (viewState.sortBy !== "position") {
|
||||
viewState.setSortBy("position");
|
||||
viewState.setSortDirection("asc");
|
||||
}
|
||||
|
||||
const updates: Partial<{ status: IssueStatus; position: number }> = {
|
||||
status: newStatus,
|
||||
};
|
||||
|
||||
@@ -134,11 +134,6 @@ function ProjectIssuesContent({
|
||||
const updateIssueMutation = useUpdateIssue();
|
||||
const handleMoveIssue = useCallback(
|
||||
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
|
||||
const viewState = projectViewStore.getState();
|
||||
if (viewState.sortBy !== "position") {
|
||||
viewState.setSortBy("position");
|
||||
viewState.setSortDirection("asc");
|
||||
}
|
||||
const updates: Partial<{ status: IssueStatus; position: number }> = { status: newStatus };
|
||||
if (newPosition !== undefined) updates.position = newPosition;
|
||||
updateIssueMutation.mutate(
|
||||
|
||||
@@ -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