mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-28 01:49:18 +02:00
Compare commits
5 Commits
agent/lamb
...
agent/j/63
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acbcb52986 | ||
|
|
33ddd03cca | ||
|
|
f388b2d352 | ||
|
|
2ac3faebb9 | ||
|
|
26d7ba8964 |
@@ -5,6 +5,7 @@ import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||
import fixPath from "fix-path";
|
||||
import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
import { setupLocalDirectory } from "./local-directory";
|
||||
import { openExternalSafely, downloadURLSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { handleAppShortcut } from "./keyboard-shortcuts";
|
||||
@@ -460,6 +461,7 @@ if (!gotTheLock) {
|
||||
|
||||
setupAutoUpdater(() => mainWindow);
|
||||
setupDaemonManager(() => mainWindow);
|
||||
setupLocalDirectory(() => mainWindow);
|
||||
|
||||
// macOS: deep link arrives via open-url event
|
||||
app.on("open-url", (_event, url) => {
|
||||
|
||||
93
apps/desktop/src/main/local-directory.ts
Normal file
93
apps/desktop/src/main/local-directory.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { ipcMain, dialog, BrowserWindow } from "electron";
|
||||
import { access, stat } from "fs/promises";
|
||||
import { constants as fsConstants } from "fs";
|
||||
import { basename, isAbsolute } from "path";
|
||||
|
||||
export interface PickDirectoryResult {
|
||||
ok: boolean;
|
||||
path?: string;
|
||||
basename?: string;
|
||||
/** Set when ok=false. "cancelled" = user dismissed; otherwise an error blurb. */
|
||||
reason?: "cancelled" | "no_window" | "error";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ValidateLocalDirectoryResult {
|
||||
ok: boolean;
|
||||
/** When ok=false, identifies which check failed so the renderer can render a
|
||||
* specific message without parsing free-form text. */
|
||||
reason?:
|
||||
| "not_absolute"
|
||||
| "not_found"
|
||||
| "not_a_directory"
|
||||
| "not_readable"
|
||||
| "not_writable"
|
||||
| "error";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function validateLocalDirectory(
|
||||
path: string,
|
||||
): Promise<ValidateLocalDirectoryResult> {
|
||||
if (!path || !isAbsolute(path)) {
|
||||
return { ok: false, reason: "not_absolute" };
|
||||
}
|
||||
try {
|
||||
const st = await stat(path);
|
||||
if (!st.isDirectory()) return { ok: false, reason: "not_a_directory" };
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") return { ok: false, reason: "not_found" };
|
||||
return { ok: false, reason: "error", error: errorMessage(err) };
|
||||
}
|
||||
try {
|
||||
await access(path, fsConstants.R_OK);
|
||||
} catch {
|
||||
return { ok: false, reason: "not_readable" };
|
||||
}
|
||||
try {
|
||||
await access(path, fsConstants.W_OK);
|
||||
} catch {
|
||||
return { ok: false, reason: "not_writable" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
export function setupLocalDirectory(
|
||||
windowGetter: () => BrowserWindow | null,
|
||||
): void {
|
||||
ipcMain.handle(
|
||||
"local-directory:pick",
|
||||
async (_event, defaultPath?: string): Promise<PickDirectoryResult> => {
|
||||
const win = windowGetter();
|
||||
if (!win) return { ok: false, reason: "no_window" };
|
||||
try {
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
// Multiple-selection is intentionally disabled — a project_resource
|
||||
// points at a single directory, and the create flow expects one
|
||||
// path per click. Multi-add would have to be a separate UX.
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
...(defaultPath ? { defaultPath } : {}),
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { ok: false, reason: "cancelled" };
|
||||
}
|
||||
const picked = result.filePaths[0];
|
||||
if (!picked) return { ok: false, reason: "cancelled" };
|
||||
return { ok: true, path: picked, basename: basename(picked) };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: "error", error: errorMessage(err) };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"local-directory:validate",
|
||||
(_event, path: string): Promise<ValidateLocalDirectoryResult> =>
|
||||
validateLocalDirectory(path),
|
||||
);
|
||||
}
|
||||
26
apps/desktop/src/preload/index.d.ts
vendored
26
apps/desktop/src/preload/index.d.ts
vendored
@@ -45,6 +45,32 @@ interface DesktopAPI {
|
||||
) => () => void;
|
||||
/** Listen for native macOS back/forward swipe gestures. Returns an unsubscribe function. */
|
||||
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => () => void;
|
||||
/** Open the OS folder picker and return the chosen absolute path.
|
||||
* Used by the Project settings "Add local directory" flow. */
|
||||
pickDirectory: (
|
||||
defaultPath?: string,
|
||||
) => Promise<{
|
||||
ok: boolean;
|
||||
path?: string;
|
||||
basename?: string;
|
||||
reason?: "cancelled" | "no_window" | "error";
|
||||
error?: string;
|
||||
}>;
|
||||
/** Validate that a path is an existing readable+writable directory.
|
||||
* Mirrors the daemon's runtime check so the user sees errors before submit. */
|
||||
validateLocalDirectory: (
|
||||
path: string,
|
||||
) => Promise<{
|
||||
ok: boolean;
|
||||
reason?:
|
||||
| "not_absolute"
|
||||
| "not_found"
|
||||
| "not_a_directory"
|
||||
| "not_readable"
|
||||
| "not_writable"
|
||||
| "error";
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
|
||||
@@ -156,6 +156,12 @@ const desktopAPI = {
|
||||
ipcRenderer.removeListener(NAVIGATION_GESTURE_CHANNEL, handler);
|
||||
};
|
||||
},
|
||||
/** Open the OS folder picker and return the chosen absolute path. */
|
||||
pickDirectory: (defaultPath?: string) =>
|
||||
ipcRenderer.invoke("local-directory:pick", defaultPath),
|
||||
/** Validate that a path is an existing readable+writable directory. */
|
||||
validateLocalDirectory: (path: string) =>
|
||||
ipcRenderer.invoke("local-directory:validate", path),
|
||||
};
|
||||
|
||||
interface DaemonStatus {
|
||||
|
||||
@@ -70,6 +70,7 @@ import type {
|
||||
ListProjectsResponse,
|
||||
ProjectResource,
|
||||
CreateProjectResourceRequest,
|
||||
UpdateProjectResourceRequest,
|
||||
ListProjectResourcesResponse,
|
||||
Label,
|
||||
CreateLabelRequest,
|
||||
@@ -1527,6 +1528,17 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async updateProjectResource(
|
||||
projectId: string,
|
||||
resourceId: string,
|
||||
data: UpdateProjectResourceRequest,
|
||||
): Promise<ProjectResource> {
|
||||
return this.fetch(`/api/projects/${projectId}/resources/${resourceId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteProjectResource(
|
||||
projectId: string,
|
||||
resourceId: string,
|
||||
|
||||
@@ -6,5 +6,6 @@ export {
|
||||
projectResourceKeys,
|
||||
projectResourcesOptions,
|
||||
useCreateProjectResource,
|
||||
useUpdateProjectResource,
|
||||
useDeleteProjectResource,
|
||||
} from "./resource-queries";
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CreateProjectResourceRequest,
|
||||
ListProjectResourcesResponse,
|
||||
ProjectResource,
|
||||
UpdateProjectResourceRequest,
|
||||
} from "../types";
|
||||
|
||||
export const projectResourceKeys = {
|
||||
@@ -46,6 +47,38 @@ export function useCreateProjectResource(wsId: string, projectId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateProjectResource(wsId: string, projectId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
resourceId,
|
||||
data,
|
||||
}: {
|
||||
resourceId: string;
|
||||
data: UpdateProjectResourceRequest;
|
||||
}) => api.updateProjectResource(projectId, resourceId, data),
|
||||
onSuccess: (updated) => {
|
||||
qc.setQueryData<ListProjectResourcesResponse>(
|
||||
projectResourceKeys.list(wsId, projectId),
|
||||
(old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
resources: old.resources.map((r) =>
|
||||
r.id === updated.id ? updated : r,
|
||||
),
|
||||
}
|
||||
: old,
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: projectResourceKeys.list(wsId, projectId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteProjectResource(wsId: string, projectId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -77,8 +77,11 @@ export type {
|
||||
ListProjectsResponse,
|
||||
ProjectResource,
|
||||
ProjectResourceType,
|
||||
ProjectResourceRef,
|
||||
GithubRepoResourceRef,
|
||||
LocalDirectoryResourceRef,
|
||||
CreateProjectResourceRequest,
|
||||
UpdateProjectResourceRequest,
|
||||
ListProjectResourcesResponse,
|
||||
} from "./project";
|
||||
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
|
||||
|
||||
@@ -48,23 +48,37 @@ export interface ListProjectsResponse {
|
||||
}
|
||||
|
||||
// ProjectResource is a typed pointer from a project to an external resource.
|
||||
// The resource_ref shape depends on resource_type (e.g. github_repo carries
|
||||
// { url, default_branch_hint? }). New types add a case in
|
||||
// validateAndNormalizeResourceRef on the server and a renderer in the UI;
|
||||
// no schema or type changes required.
|
||||
export type ProjectResourceType = "github_repo";
|
||||
// The resource_ref shape depends on resource_type. New types add a case in
|
||||
// validateAndNormalizeResourceRef on the server and a renderer in the UI.
|
||||
//
|
||||
// Known types (UI must default-case unknown server-side additions):
|
||||
// - github_repo: cloud-side git checkout, ref = { url, default_branch_hint? }
|
||||
// - local_directory: in-place agent execution on a specific daemon,
|
||||
// ref = { local_path, daemon_id, label? }
|
||||
export type ProjectResourceType = "github_repo" | "local_directory";
|
||||
|
||||
export interface GithubRepoResourceRef {
|
||||
url: string;
|
||||
default_branch_hint?: string;
|
||||
}
|
||||
|
||||
export interface LocalDirectoryResourceRef {
|
||||
local_path: string;
|
||||
daemon_id: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export type ProjectResourceRef =
|
||||
| GithubRepoResourceRef
|
||||
| LocalDirectoryResourceRef
|
||||
| Record<string, unknown>;
|
||||
|
||||
export interface ProjectResource {
|
||||
id: string;
|
||||
project_id: string;
|
||||
workspace_id: string;
|
||||
resource_type: ProjectResourceType;
|
||||
resource_ref: GithubRepoResourceRef | Record<string, unknown>;
|
||||
resource_ref: ProjectResourceRef;
|
||||
label: string | null;
|
||||
position: number;
|
||||
created_at: string;
|
||||
@@ -73,11 +87,20 @@ export interface ProjectResource {
|
||||
|
||||
export interface CreateProjectResourceRequest {
|
||||
resource_type: ProjectResourceType;
|
||||
resource_ref: GithubRepoResourceRef | Record<string, unknown>;
|
||||
resource_ref: ProjectResourceRef;
|
||||
label?: string;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
// resource_type is immutable server-side; partial-update payload mirrors that.
|
||||
// Sending only the field(s) you want to change is fine — the server merges
|
||||
// the request body with the existing row, including resource_ref shortcuts.
|
||||
export interface UpdateProjectResourceRequest {
|
||||
resource_ref?: ProjectResourceRef;
|
||||
label?: string | null;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface ListProjectResourcesResponse {
|
||||
resources: ProjectResource[];
|
||||
total: number;
|
||||
|
||||
40
packages/views/chat/components/task-status-pill.test.ts
Normal file
40
packages/views/chat/components/task-status-pill.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { pickStageKeys } from "./task-status-pill";
|
||||
|
||||
describe("pickStageKeys", () => {
|
||||
it("returns queued when status is queued and agent is online", () => {
|
||||
expect(pickStageKeys("queued", [], "online")).toEqual({ stageKey: "queued" });
|
||||
});
|
||||
|
||||
it("returns offline when status is queued and agent is offline", () => {
|
||||
expect(pickStageKeys("queued", [], "offline")).toEqual({
|
||||
stageKey: "offline",
|
||||
static: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns waiting_for_directory_release on the daemon-emitted hold status", () => {
|
||||
// Daemon publishes this when it dequeues a task but another task owns the
|
||||
// local_directory's lock. The pill becomes static (no shimmer) because
|
||||
// nothing is actively happening from the user's point of view.
|
||||
expect(pickStageKeys("waiting_for_directory_release", [], "online")).toEqual({
|
||||
stageKey: "waiting_for_directory_release",
|
||||
static: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("waiting_for_directory_release wins over availability hints", () => {
|
||||
// Even if availability says reconnecting/offline, the directory-release
|
||||
// status is the more specific signal — surface it.
|
||||
expect(
|
||||
pickStageKeys("waiting_for_directory_release", [], "unstable"),
|
||||
).toEqual({ stageKey: "waiting_for_directory_release", static: true });
|
||||
expect(
|
||||
pickStageKeys("waiting_for_directory_release", [], "offline"),
|
||||
).toEqual({ stageKey: "waiting_for_directory_release", static: true });
|
||||
});
|
||||
|
||||
it("returns thinking for running with no messages", () => {
|
||||
expect(pickStageKeys("running", [], "online")).toEqual({ stageKey: "thinking" });
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ type StageKey =
|
||||
| "offline"
|
||||
| "reconnecting"
|
||||
| "queued"
|
||||
| "waiting_for_directory_release"
|
||||
| "starting_up"
|
||||
| "thinking"
|
||||
| "typing";
|
||||
@@ -56,7 +57,7 @@ const TOOL_KEY_BY_SLUG: Record<string, Exclude<ToolKey, "fallback">> = {
|
||||
// Pure stage decision returning translation keys. The hook below maps these
|
||||
// keys into localized labels — keeping the decision pure makes it easy to
|
||||
// follow the priority rules without translation noise.
|
||||
function pickStageKeys(
|
||||
export function pickStageKeys(
|
||||
status: string | undefined,
|
||||
taskMessages: readonly TaskMessagePayload[],
|
||||
availability: AgentAvailability | undefined,
|
||||
@@ -73,6 +74,14 @@ function pickStageKeys(
|
||||
) {
|
||||
return { stageKey: "reconnecting" };
|
||||
}
|
||||
// Daemon-emitted hold state for the local_directory flow: the project is
|
||||
// pinned to a path that another task currently owns. The daemon publishes
|
||||
// this status string when it dequeues a task but can't acquire the path
|
||||
// lock; the renderer surfaces a dedicated label so the user understands
|
||||
// why a queued task isn't moving.
|
||||
if (status === "waiting_for_directory_release") {
|
||||
return { stageKey: "waiting_for_directory_release", static: true };
|
||||
}
|
||||
if (status === "queued") return { stageKey: "queued" };
|
||||
if (status === "dispatched") return { stageKey: "starting_up" };
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import { toast } from "sonner";
|
||||
import { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, StartDatePicker, DueDatePicker, AssigneePicker, LabelPicker } from ".";
|
||||
import { IssueActionsDropdown, useIssueActions } from "../actions";
|
||||
import { ProjectPicker } from "../../projects/components/project-picker";
|
||||
import { LocalDirectoryHint } from "../../projects/components/local-directory-hint";
|
||||
import { CommentCard } from "./comment-card";
|
||||
import { CommentInput } from "./comment-input";
|
||||
import { ResolvedThreadBar } from "./resolved-thread-bar";
|
||||
@@ -2017,6 +2018,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
|
||||
{/* Bottom comment input — no avatar, full width */}
|
||||
<div className="mt-4">
|
||||
<LocalDirectoryHint projectId={issue?.project_id} />
|
||||
{/* key={id}: web's /issues/[id] route doesn't remount on
|
||||
issueId change, so without an explicit key the editor
|
||||
keeps the previous issue's in-memory content and the
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
"offline": "Offline",
|
||||
"reconnecting": "Reconnecting",
|
||||
"queued": "Queued",
|
||||
"waiting_for_directory_release": "Waiting for local directory",
|
||||
"starting_up": "Starting up",
|
||||
"thinking": "Thinking",
|
||||
"typing": "Typing"
|
||||
|
||||
@@ -80,7 +80,26 @@
|
||||
"toast_attached": "Repository attached",
|
||||
"toast_attach_failed": "Failed to attach",
|
||||
"toast_removed": "Resource removed",
|
||||
"toast_remove_failed": "Failed to remove resource"
|
||||
"toast_remove_failed": "Failed to remove resource",
|
||||
"add_local_directory_button": "Add local directory",
|
||||
"local_daemon_offline_hint": "Start the local daemon to attach a directory on this machine.",
|
||||
"local_rename_tooltip": "Rename",
|
||||
"local_rename_label": "Local directory label",
|
||||
"local_no_daemon_tooltip": "Local daemon isn't reporting an ID yet — the agent can't open this directory.",
|
||||
"local_other_machine_tooltip": "Only available on the machine that registered this directory.",
|
||||
"chat_hint_prefix": "Agent will work in-place at",
|
||||
"toast_local_attached": "Local directory attached",
|
||||
"toast_local_renamed": "Renamed",
|
||||
"toast_local_rename_failed": "Failed to rename",
|
||||
"toast_local_pick_failed": "Failed to pick directory",
|
||||
"toast_local_already_attached": "This directory is already attached to the project on this machine.",
|
||||
"toast_local_daemon_not_running": "Local daemon isn't running.",
|
||||
"local_validate_not_absolute": "Pick an absolute path.",
|
||||
"local_validate_not_found": "Path doesn't exist.",
|
||||
"local_validate_not_a_directory": "Path is not a directory.",
|
||||
"local_validate_not_readable": "Directory is not readable.",
|
||||
"local_validate_not_writable": "Directory is not writable.",
|
||||
"local_validate_unsupported": "Local directories can only be picked on the desktop app."
|
||||
},
|
||||
"delete_dialog": {
|
||||
"title": "Delete project",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"offline": "离线",
|
||||
"reconnecting": "重连中",
|
||||
"queued": "排队中",
|
||||
"waiting_for_directory_release": "等待本地目录释放",
|
||||
"starting_up": "启动中",
|
||||
"thinking": "思考中",
|
||||
"typing": "输入中"
|
||||
|
||||
@@ -80,7 +80,26 @@
|
||||
"toast_attached": "已关联仓库",
|
||||
"toast_attach_failed": "关联失败",
|
||||
"toast_removed": "已移除资源",
|
||||
"toast_remove_failed": "移除资源失败"
|
||||
"toast_remove_failed": "移除资源失败",
|
||||
"add_local_directory_button": "添加本地目录",
|
||||
"local_daemon_offline_hint": "启动本地 daemon 后才能添加本机目录。",
|
||||
"local_rename_tooltip": "重命名",
|
||||
"local_rename_label": "本地目录显示名",
|
||||
"local_no_daemon_tooltip": "尚未识别到本机 daemon,agent 暂时无法打开此目录。",
|
||||
"local_other_machine_tooltip": "此目录仅在最初注册它的电脑上可用。",
|
||||
"chat_hint_prefix": "Agent 将在",
|
||||
"toast_local_attached": "已添加本地目录",
|
||||
"toast_local_renamed": "已重命名",
|
||||
"toast_local_rename_failed": "重命名失败",
|
||||
"toast_local_pick_failed": "选择目录失败",
|
||||
"toast_local_already_attached": "该目录已经在本机关联到此项目。",
|
||||
"toast_local_daemon_not_running": "本地 daemon 未运行。",
|
||||
"local_validate_not_absolute": "请选择绝对路径。",
|
||||
"local_validate_not_found": "目录不存在。",
|
||||
"local_validate_not_a_directory": "该路径不是目录。",
|
||||
"local_validate_not_readable": "目录不可读。",
|
||||
"local_validate_not_writable": "目录不可写。",
|
||||
"local_validate_unsupported": "本地目录仅能在桌面端选择。"
|
||||
},
|
||||
"delete_dialog": {
|
||||
"title": "删除项目",
|
||||
|
||||
@@ -2,3 +2,14 @@ export { useImmersiveMode } from "./use-immersive-mode";
|
||||
export { useDesktopUnreadBadge } from "./use-desktop-unread-badge";
|
||||
export { DragStrip } from "./drag-strip";
|
||||
export { openExternal } from "./open-external";
|
||||
export {
|
||||
isDesktopShell,
|
||||
pickDirectory,
|
||||
validateLocalDirectory,
|
||||
type PickDirectoryResult,
|
||||
type ValidateLocalDirectoryResult,
|
||||
} from "./local-directory";
|
||||
export {
|
||||
useLocalDaemonStatus,
|
||||
type LocalDaemonStatus,
|
||||
} from "./use-local-daemon-status";
|
||||
|
||||
64
packages/views/platform/local-directory.ts
Normal file
64
packages/views/platform/local-directory.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// Desktop-only helpers for the project_resource local_directory flow.
|
||||
//
|
||||
// These wrap the preload `desktopAPI` surface so view components can
|
||||
// SSR-render on web (where `window.desktopAPI` is undefined) and degrade
|
||||
// gracefully to no-op promises instead of crashing.
|
||||
|
||||
export type PickDirectoryResult = {
|
||||
ok: boolean;
|
||||
path?: string;
|
||||
basename?: string;
|
||||
reason?: "cancelled" | "no_window" | "error" | "unsupported";
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ValidateLocalDirectoryResult = {
|
||||
ok: boolean;
|
||||
reason?:
|
||||
| "not_absolute"
|
||||
| "not_found"
|
||||
| "not_a_directory"
|
||||
| "not_readable"
|
||||
| "not_writable"
|
||||
| "error"
|
||||
| "unsupported";
|
||||
error?: string;
|
||||
};
|
||||
|
||||
interface DesktopLocalDirectoryAPI {
|
||||
pickDirectory?: (defaultPath?: string) => Promise<PickDirectoryResult>;
|
||||
validateLocalDirectory?: (
|
||||
path: string,
|
||||
) => Promise<ValidateLocalDirectoryResult>;
|
||||
}
|
||||
|
||||
function readDesktopAPI(): DesktopLocalDirectoryAPI | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const api = (window as unknown as { desktopAPI?: DesktopLocalDirectoryAPI })
|
||||
.desktopAPI;
|
||||
return api;
|
||||
}
|
||||
|
||||
/** True when the renderer is running inside the Electron desktop shell, as
|
||||
* evidenced by the preload-exposed pickDirectory bridge. Avoids hard-coding
|
||||
* navigator/process checks — those vary across electron-vite + jsdom tests. */
|
||||
export function isDesktopShell(): boolean {
|
||||
const api = readDesktopAPI();
|
||||
return typeof api?.pickDirectory === "function";
|
||||
}
|
||||
|
||||
export async function pickDirectory(
|
||||
defaultPath?: string,
|
||||
): Promise<PickDirectoryResult> {
|
||||
const api = readDesktopAPI();
|
||||
if (!api?.pickDirectory) return { ok: false, reason: "unsupported" };
|
||||
return api.pickDirectory(defaultPath);
|
||||
}
|
||||
|
||||
export async function validateLocalDirectory(
|
||||
path: string,
|
||||
): Promise<ValidateLocalDirectoryResult> {
|
||||
const api = readDesktopAPI();
|
||||
if (!api?.validateLocalDirectory) return { ok: false, reason: "unsupported" };
|
||||
return api.validateLocalDirectory(path);
|
||||
}
|
||||
77
packages/views/platform/use-local-daemon-status.ts
Normal file
77
packages/views/platform/use-local-daemon-status.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Subset of the daemonAPI status shape that the local_directory UI consumes.
|
||||
* Redeclared here so this hook doesn't depend on the desktop preload types. */
|
||||
export interface LocalDaemonStatus {
|
||||
daemonId: string | null;
|
||||
deviceName: string | null;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
interface DaemonStatusLike {
|
||||
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
|
||||
daemonId?: string;
|
||||
deviceName?: string;
|
||||
}
|
||||
|
||||
interface DaemonAPILike {
|
||||
getStatus?: () => Promise<DaemonStatusLike>;
|
||||
onStatusChange?: (cb: (s: DaemonStatusLike) => void) => () => void;
|
||||
}
|
||||
|
||||
function readDaemonAPI(): DaemonAPILike | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
return (window as unknown as { daemonAPI?: DaemonAPILike }).daemonAPI;
|
||||
}
|
||||
|
||||
function toStatus(s: DaemonStatusLike | undefined): LocalDaemonStatus {
|
||||
if (!s) return { daemonId: null, deviceName: null, running: false };
|
||||
return {
|
||||
daemonId: s.daemonId ?? null,
|
||||
deviceName: s.deviceName ?? null,
|
||||
running: s.state === "running",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Live snapshot of the desktop's local daemon: the daemon_id it registers
|
||||
* under, the OS device name, and whether the supervisor is currently running.
|
||||
*
|
||||
* On web (no `window.daemonAPI`) every field is null/false — components can
|
||||
* unconditionally call this hook and branch on `daemonId` to decide whether
|
||||
* a local_directory resource matches "this machine".
|
||||
*
|
||||
* The initial paint reads `getStatus()` once so the UI doesn't flash a
|
||||
* "no daemon" state while waiting for the first push from `onStatusChange`.
|
||||
*/
|
||||
export function useLocalDaemonStatus(): LocalDaemonStatus {
|
||||
const [status, setStatus] = useState<LocalDaemonStatus>(() => ({
|
||||
daemonId: null,
|
||||
deviceName: null,
|
||||
running: false,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
const api = readDaemonAPI();
|
||||
if (!api) return;
|
||||
let cancelled = false;
|
||||
if (api.getStatus) {
|
||||
api.getStatus().then((s) => {
|
||||
if (!cancelled) setStatus(toStatus(s));
|
||||
}).catch(() => {
|
||||
// Ignore — onStatusChange will populate once the daemon comes up.
|
||||
});
|
||||
}
|
||||
const unsubscribe = api.onStatusChange?.((s) => {
|
||||
setStatus(toStatus(s));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return status;
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export { ProjectsPage } from "./projects-page";
|
||||
export { ProjectDetail } from "./project-detail";
|
||||
export { ProjectPicker } from "./project-picker";
|
||||
export { ProjectChip } from "./project-chip";
|
||||
export { LocalDirectoryHint } from "./local-directory-hint";
|
||||
|
||||
137
packages/views/projects/components/local-directory-hint.test.tsx
Normal file
137
packages/views/projects/components/local-directory-hint.test.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import type { ProjectResource } from "@multica/core/types";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enProjects from "../../locales/en/projects.json";
|
||||
|
||||
const TEST_RESOURCES = { en: { common: enCommon, projects: enProjects } };
|
||||
|
||||
const mockDaemonStatus = vi.hoisted(() => ({
|
||||
daemonId: null as string | null,
|
||||
deviceName: null as string | null,
|
||||
running: false,
|
||||
}));
|
||||
|
||||
vi.mock("../../platform", () => ({
|
||||
useLocalDaemonStatus: () => mockDaemonStatus,
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
|
||||
const mockListResources = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
listProjectResources: (...args: unknown[]) => mockListResources(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import { LocalDirectoryHint } from "./local-directory-hint";
|
||||
|
||||
function renderHint(projectId: string | null | undefined) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<LocalDirectoryHint projectId={projectId} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function makeLocalDirectoryResource(overrides: {
|
||||
daemon_id: string;
|
||||
local_path: string;
|
||||
label?: string;
|
||||
}): ProjectResource {
|
||||
return {
|
||||
id: `res-${overrides.local_path}`,
|
||||
project_id: "proj-1",
|
||||
workspace_id: "ws-1",
|
||||
resource_type: "local_directory",
|
||||
resource_ref: {
|
||||
daemon_id: overrides.daemon_id,
|
||||
local_path: overrides.local_path,
|
||||
...(overrides.label ? { label: overrides.label } : {}),
|
||||
},
|
||||
label: null,
|
||||
position: 0,
|
||||
created_at: new Date(0).toISOString(),
|
||||
created_by: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("LocalDirectoryHint", () => {
|
||||
beforeEach(() => {
|
||||
mockDaemonStatus.daemonId = null;
|
||||
mockDaemonStatus.deviceName = null;
|
||||
mockDaemonStatus.running = false;
|
||||
mockListResources.mockReset();
|
||||
});
|
||||
|
||||
it("renders nothing when project_id is null", () => {
|
||||
const { container } = renderHint(null);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when there's no local daemon", () => {
|
||||
mockListResources.mockResolvedValue({
|
||||
resources: [
|
||||
makeLocalDirectoryResource({
|
||||
daemon_id: "daemon-A",
|
||||
local_path: "/Users/foo/work",
|
||||
label: "work",
|
||||
}),
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
const { container } = renderHint("proj-1");
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the hint when a local_directory resource matches this daemon", async () => {
|
||||
mockDaemonStatus.daemonId = "daemon-A";
|
||||
mockDaemonStatus.running = true;
|
||||
mockListResources.mockResolvedValue({
|
||||
resources: [
|
||||
makeLocalDirectoryResource({
|
||||
daemon_id: "daemon-A",
|
||||
local_path: "/Users/foo/work",
|
||||
label: "work",
|
||||
}),
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
renderHint("proj-1");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("work")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/Users\/foo\/work/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores resources pinned to a different daemon", async () => {
|
||||
mockDaemonStatus.daemonId = "daemon-A";
|
||||
mockDaemonStatus.running = true;
|
||||
mockListResources.mockResolvedValue({
|
||||
resources: [
|
||||
makeLocalDirectoryResource({
|
||||
daemon_id: "daemon-B",
|
||||
local_path: "/Users/foo/other-machine",
|
||||
label: "elsewhere",
|
||||
}),
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
const { container } = renderHint("proj-1");
|
||||
// Allow the query to settle; the hint should still render nothing.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(container.querySelector("div[class*='rounded-md']")).toBeNull();
|
||||
});
|
||||
});
|
||||
72
packages/views/projects/components/local-directory-hint.tsx
Normal file
72
packages/views/projects/components/local-directory-hint.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { projectResourcesOptions } from "@multica/core/projects";
|
||||
import type { LocalDirectoryResourceRef, ProjectResource } from "@multica/core/types";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useLocalDaemonStatus } from "../../platform";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
/**
|
||||
* Banner shown above the chat / comment composer when the issue's project
|
||||
* is pinned to a `local_directory` resource on **this** daemon. Tells the
|
||||
* user "starting an agent here will use {label} ({path}) in-place" so they
|
||||
* notice they are not getting an isolated git worktree.
|
||||
*
|
||||
* Rendered only on desktop: web has no daemon to compare against, so the
|
||||
* "this machine" check would always fail. Web users will see local_directory
|
||||
* resources read-only in the sidebar but no chat-input hint.
|
||||
*
|
||||
* SSR-safe: the underlying hook reads `window.daemonAPI` defensively, so
|
||||
* server renders return null.
|
||||
*/
|
||||
export function LocalDirectoryHint({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string | null | undefined;
|
||||
}) {
|
||||
const { t } = useT("projects");
|
||||
const wsId = useWorkspaceId();
|
||||
const daemon = useLocalDaemonStatus();
|
||||
const { data: resources = [] } = useQuery({
|
||||
...projectResourcesOptions(wsId, projectId ?? ""),
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
|
||||
if (!projectId) return null;
|
||||
if (!daemon.daemonId) return null;
|
||||
|
||||
const matches: Array<ProjectResource & { resource_ref: LocalDirectoryResourceRef }> =
|
||||
resources
|
||||
.filter(
|
||||
(r): r is ProjectResource & { resource_ref: LocalDirectoryResourceRef } =>
|
||||
r.resource_type === "local_directory",
|
||||
)
|
||||
.filter((r) => r.resource_ref.daemon_id === daemon.daemonId);
|
||||
|
||||
if (matches.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-2 space-y-1 rounded-md border border-dashed bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||
{matches.map((resource) => {
|
||||
const ref = resource.resource_ref;
|
||||
const label = (ref.label || resource.label || ref.local_path).trim() ||
|
||||
ref.local_path;
|
||||
return (
|
||||
<div
|
||||
key={resource.id}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<FolderOpen className="size-3 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t(($) => $.resources.chat_hint_prefix)}
|
||||
<span className="font-medium text-foreground"> {label} </span>
|
||||
<span className="font-mono opacity-70">({ref.local_path})</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,17 +2,27 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight, FolderGit, Plus, Search, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ChevronRight,
|
||||
FolderGit,
|
||||
FolderOpen,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
projectResourcesOptions,
|
||||
useCreateProjectResource,
|
||||
useDeleteProjectResource,
|
||||
useUpdateProjectResource,
|
||||
} from "@multica/core/projects";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useCurrentWorkspace } from "@multica/core/paths";
|
||||
import type {
|
||||
GithubRepoResourceRef,
|
||||
LocalDirectoryResourceRef,
|
||||
ProjectResource,
|
||||
} from "@multica/core/types";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
@@ -26,31 +36,65 @@ import {
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
import {
|
||||
isDesktopShell,
|
||||
pickDirectory,
|
||||
useLocalDaemonStatus,
|
||||
validateLocalDirectory,
|
||||
type ValidateLocalDirectoryResult,
|
||||
} from "../../platform";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
// Project Resources sidebar section.
|
||||
//
|
||||
// Today only renders github_repo, but the rendering layer is type-dispatched
|
||||
// so adding a new type means: (1) extend the API validator, (2) add a render
|
||||
// case here. No changes to the schema or query layer.
|
||||
// Type-dispatched at the row + add-flow level. Add a new resource_type by:
|
||||
// (1) extending the server validator
|
||||
// (2) extending ProjectResourceType in @multica/core/types
|
||||
// (3) adding a render case in ResourceRow and an add-control here
|
||||
function isGithubRef(r: ProjectResource): r is ProjectResource & {
|
||||
resource_ref: GithubRepoResourceRef;
|
||||
} {
|
||||
return r.resource_type === "github_repo";
|
||||
}
|
||||
|
||||
function isLocalDirectoryRef(r: ProjectResource): r is ProjectResource & {
|
||||
resource_ref: LocalDirectoryResourceRef;
|
||||
} {
|
||||
return r.resource_type === "local_directory";
|
||||
}
|
||||
|
||||
export function ProjectResourcesSection({ projectId }: { projectId: string }) {
|
||||
const { t } = useT("projects");
|
||||
const wsId = useWorkspaceId();
|
||||
const workspace = useCurrentWorkspace();
|
||||
const daemonStatus = useLocalDaemonStatus();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [repoSearch, setRepoSearch] = useState("");
|
||||
const [picking, setPicking] = useState(false);
|
||||
|
||||
const { data: resources = [] } = useQuery(
|
||||
projectResourcesOptions(wsId, projectId),
|
||||
);
|
||||
const createResource = useCreateProjectResource(wsId, projectId);
|
||||
const updateResource = useUpdateProjectResource(wsId, projectId);
|
||||
const deleteResource = useDeleteProjectResource(wsId, projectId);
|
||||
|
||||
// Desktop-only entry points. We hide (not just disable) on web so users
|
||||
// there don't see an action they can never complete — the spec calls for
|
||||
// read-only on web because the daemon-id check can't be performed in the
|
||||
// browser.
|
||||
const desktopMode = isDesktopShell();
|
||||
const localDaemonId = daemonStatus.daemonId;
|
||||
|
||||
const attachedUrls = new Set(
|
||||
resources.filter(isGithubRef).map((r) => r.resource_ref.url),
|
||||
);
|
||||
const attachedLocalPaths = new Set(
|
||||
resources
|
||||
.filter((r) => r.resource_type === "github_repo")
|
||||
.map((r) => (r.resource_ref as GithubRepoResourceRef).url),
|
||||
.filter(isLocalDirectoryRef)
|
||||
.filter((r) => r.resource_ref.daemon_id === localDaemonId)
|
||||
.map((r) => r.resource_ref.local_path),
|
||||
);
|
||||
|
||||
const repoQuery = repoSearch.trim().toLowerCase();
|
||||
@@ -70,6 +114,65 @@ export function ProjectResourcesSection({ projectId }: { projectId: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAttachLocalDirectory = async () => {
|
||||
if (picking) return;
|
||||
setPicking(true);
|
||||
try {
|
||||
if (!localDaemonId || !daemonStatus.running) {
|
||||
toast.error(t(($) => $.resources.toast_local_daemon_not_running));
|
||||
return;
|
||||
}
|
||||
const picked = await pickDirectory();
|
||||
if (!picked.ok) {
|
||||
if (picked.reason && picked.reason !== "cancelled") {
|
||||
toast.error(
|
||||
picked.error ?? t(($) => $.resources.toast_local_pick_failed),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const path = picked.path ?? "";
|
||||
const fallbackLabel = picked.basename ?? path;
|
||||
if (attachedLocalPaths.has(path)) {
|
||||
toast.error(t(($) => $.resources.toast_local_already_attached));
|
||||
return;
|
||||
}
|
||||
const validation = await validateLocalDirectory(path);
|
||||
if (!validation.ok) {
|
||||
toast.error(
|
||||
localValidationMessage(validation, {
|
||||
not_absolute: t(($) => $.resources.local_validate_not_absolute),
|
||||
not_found: t(($) => $.resources.local_validate_not_found),
|
||||
not_a_directory: t(($) => $.resources.local_validate_not_a_directory),
|
||||
not_readable: t(($) => $.resources.local_validate_not_readable),
|
||||
not_writable: t(($) => $.resources.local_validate_not_writable),
|
||||
unsupported: t(($) => $.resources.local_validate_unsupported),
|
||||
fallback: t(($) => $.resources.toast_local_pick_failed),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await createResource.mutateAsync({
|
||||
resource_type: "local_directory",
|
||||
resource_ref: {
|
||||
local_path: path,
|
||||
daemon_id: localDaemonId,
|
||||
label: fallbackLabel,
|
||||
},
|
||||
});
|
||||
toast.success(t(($) => $.resources.toast_local_attached));
|
||||
setAddOpen(false);
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t(($) => $.resources.toast_local_pick_failed);
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setPicking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (resource: ProjectResource) => {
|
||||
try {
|
||||
await deleteResource.mutateAsync(resource.id);
|
||||
@@ -83,6 +186,33 @@ export function ProjectResourcesSection({ projectId }: { projectId: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameLocalDirectory = async (
|
||||
resource: ProjectResource & { resource_ref: LocalDirectoryResourceRef },
|
||||
nextLabel: string,
|
||||
) => {
|
||||
const trimmed = nextLabel.trim();
|
||||
const previous = resource.resource_ref.label ?? resource.label ?? "";
|
||||
if (trimmed === previous.trim()) return;
|
||||
try {
|
||||
await updateResource.mutateAsync({
|
||||
resourceId: resource.id,
|
||||
data: {
|
||||
resource_ref: {
|
||||
...resource.resource_ref,
|
||||
label: trimmed,
|
||||
},
|
||||
},
|
||||
});
|
||||
toast.success(t(($) => $.resources.toast_local_renamed));
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t(($) => $.resources.toast_local_rename_failed);
|
||||
toast.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
@@ -107,7 +237,10 @@ export function ProjectResourcesSection({ projectId }: { projectId: string }) {
|
||||
<ResourceRow
|
||||
key={resource.id}
|
||||
resource={resource}
|
||||
localDaemonId={localDaemonId}
|
||||
canEdit={desktopMode}
|
||||
onRemove={() => handleRemove(resource)}
|
||||
onRenameLocalDirectory={handleRenameLocalDirectory}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -200,22 +333,58 @@ export function ProjectResourcesSection({ projectId }: { projectId: string }) {
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{desktopMode && (
|
||||
<div className="flex flex-col">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 justify-start px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
disabled={
|
||||
picking ||
|
||||
createResource.isPending ||
|
||||
!daemonStatus.running
|
||||
}
|
||||
onClick={() => {
|
||||
void handleAttachLocalDirectory();
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="size-3" />
|
||||
{t(($) => $.resources.add_local_directory_button)}
|
||||
</Button>
|
||||
{!daemonStatus.running && (
|
||||
<p className="px-2 pt-0.5 text-[10px] text-muted-foreground">
|
||||
{t(($) => $.resources.local_daemon_offline_hint)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ResourceRowProps {
|
||||
resource: ProjectResource;
|
||||
localDaemonId: string | null;
|
||||
canEdit: boolean;
|
||||
onRemove: () => void;
|
||||
onRenameLocalDirectory: (
|
||||
resource: ProjectResource & { resource_ref: LocalDirectoryResourceRef },
|
||||
nextLabel: string,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
function ResourceRow({
|
||||
resource,
|
||||
localDaemonId,
|
||||
canEdit,
|
||||
onRemove,
|
||||
}: {
|
||||
resource: ProjectResource;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
onRenameLocalDirectory,
|
||||
}: ResourceRowProps) {
|
||||
const { t } = useT("projects");
|
||||
if (resource.resource_type === "github_repo") {
|
||||
const ref = resource.resource_ref as GithubRepoResourceRef;
|
||||
if (isGithubRef(resource)) {
|
||||
const ref = resource.resource_ref;
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs group">
|
||||
<FolderGit className="size-3.5 text-muted-foreground shrink-0" />
|
||||
@@ -245,6 +414,19 @@ function ResourceRow({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLocalDirectoryRef(resource)) {
|
||||
return (
|
||||
<LocalDirectoryRow
|
||||
resource={resource}
|
||||
localDaemonId={localDaemonId}
|
||||
canEdit={canEdit}
|
||||
onRemove={onRemove}
|
||||
onRename={onRenameLocalDirectory}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="truncate flex-1">
|
||||
@@ -262,6 +444,121 @@ function ResourceRow({
|
||||
);
|
||||
}
|
||||
|
||||
interface LocalDirectoryRowProps {
|
||||
resource: ProjectResource & { resource_ref: LocalDirectoryResourceRef };
|
||||
localDaemonId: string | null;
|
||||
canEdit: boolean;
|
||||
onRemove: () => void;
|
||||
onRename: (
|
||||
resource: ProjectResource & { resource_ref: LocalDirectoryResourceRef },
|
||||
nextLabel: string,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
function LocalDirectoryRow({
|
||||
resource,
|
||||
localDaemonId,
|
||||
canEdit,
|
||||
onRemove,
|
||||
onRename,
|
||||
}: LocalDirectoryRowProps) {
|
||||
const { t } = useT("projects");
|
||||
const ref = resource.resource_ref;
|
||||
const display = (ref.label || resource.label || ref.local_path).trim() ||
|
||||
ref.local_path;
|
||||
const isForeignDaemon =
|
||||
localDaemonId !== null && ref.daemon_id !== localDaemonId;
|
||||
const isLocalUnknown = localDaemonId === null;
|
||||
// "disabled" in the spec sense — visual de-emphasis + no chat hint, and
|
||||
// rename is hidden on foreign / unknown-daemon rows because the label
|
||||
// belongs to the owning device. Delete stays available so the user can
|
||||
// drop a stale registration from any device.
|
||||
const mismatch = isForeignDaemon || isLocalUnknown;
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(display);
|
||||
|
||||
const startEdit = () => {
|
||||
setDraft(display);
|
||||
setEditing(true);
|
||||
};
|
||||
const commit = async () => {
|
||||
setEditing(false);
|
||||
await onRename(resource, draft);
|
||||
};
|
||||
const cancel = () => {
|
||||
setEditing(false);
|
||||
setDraft(display);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 text-xs group ${
|
||||
mismatch ? "opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="size-3.5 text-muted-foreground shrink-0" />
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => void commit()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void commit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
}}
|
||||
className="flex-1 min-w-0 rounded-sm border bg-transparent px-1 py-0.5 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
aria-label={t(($) => $.resources.local_rename_label)}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="truncate flex-1">{display}</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">
|
||||
<div className="space-y-0.5 text-[11px]">
|
||||
<div className="font-mono">{ref.local_path}</div>
|
||||
{mismatch && (
|
||||
<div className="text-muted-foreground">
|
||||
{isLocalUnknown
|
||||
? t(($) => $.resources.local_no_daemon_tooltip)
|
||||
: t(($) => $.resources.local_other_machine_tooltip)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canEdit && !mismatch && !editing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEdit}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity rounded-sm p-0.5 hover:bg-accent"
|
||||
title={t(($) => $.resources.local_rename_tooltip)}
|
||||
>
|
||||
<Pencil className="size-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity rounded-sm p-0.5 hover:bg-accent"
|
||||
title={t(($) => $.resources.remove_tooltip)}
|
||||
>
|
||||
<Trash2 className="size-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomRepoForm({
|
||||
onSubmit,
|
||||
}: {
|
||||
@@ -303,3 +600,34 @@ function CustomRepoForm({
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function localValidationMessage(
|
||||
result: ValidateLocalDirectoryResult,
|
||||
strings: {
|
||||
not_absolute: string;
|
||||
not_found: string;
|
||||
not_a_directory: string;
|
||||
not_readable: string;
|
||||
not_writable: string;
|
||||
unsupported: string;
|
||||
fallback: string;
|
||||
},
|
||||
): string {
|
||||
switch (result.reason) {
|
||||
case "not_absolute":
|
||||
return strings.not_absolute;
|
||||
case "not_found":
|
||||
return strings.not_found;
|
||||
case "not_a_directory":
|
||||
return strings.not_a_directory;
|
||||
case "not_readable":
|
||||
return strings.not_readable;
|
||||
case "not_writable":
|
||||
return strings.not_writable;
|
||||
case "unsupported":
|
||||
return strings.unsupported;
|
||||
case "error":
|
||||
default:
|
||||
return result.error ?? strings.fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,13 @@ var projectResourceAddCmd = &cobra.Command{
|
||||
RunE: runProjectResourceAdd,
|
||||
}
|
||||
|
||||
var projectResourceUpdateCmd = &cobra.Command{
|
||||
Use: "update <project-id> <resource-id>",
|
||||
Short: "Edit an attached resource (ref payload, label, or position)",
|
||||
Args: exactArgs(2),
|
||||
RunE: runProjectResourceUpdate,
|
||||
}
|
||||
|
||||
var projectResourceRemoveCmd = &cobra.Command{
|
||||
Use: "remove <project-id> <resource-id>",
|
||||
Short: "Detach a resource from a project",
|
||||
@@ -100,6 +107,7 @@ func init() {
|
||||
|
||||
projectResourceCmd.AddCommand(projectResourceListCmd)
|
||||
projectResourceCmd.AddCommand(projectResourceAddCmd)
|
||||
projectResourceCmd.AddCommand(projectResourceUpdateCmd)
|
||||
projectResourceCmd.AddCommand(projectResourceRemoveCmd)
|
||||
|
||||
// project list
|
||||
@@ -126,13 +134,29 @@ func init() {
|
||||
// project resource add — generic shape: any --type with a JSON --ref payload
|
||||
// works without further CLI changes. github_repo is supported via the
|
||||
// dedicated --url / --default-branch-hint shortcuts as a convenience.
|
||||
projectResourceAddCmd.Flags().String("type", "github_repo", "Resource type (e.g. github_repo, notion_page — see docs)")
|
||||
projectResourceAddCmd.Flags().String("type", "github_repo", "Resource type (e.g. github_repo, local_directory — see docs)")
|
||||
projectResourceAddCmd.Flags().String("url", "", "Shortcut: the repo URL (only used when --type github_repo)")
|
||||
projectResourceAddCmd.Flags().String("default-branch-hint", "", "Shortcut: optional default branch hint (only used when --type github_repo)")
|
||||
projectResourceAddCmd.Flags().String("local-path", "", "Shortcut: absolute path to the working directory (only used when --type local_directory)")
|
||||
projectResourceAddCmd.Flags().String("daemon-id", "", "Shortcut: id of the daemon that owns the local path (only used when --type local_directory)")
|
||||
projectResourceAddCmd.Flags().String("ref-label", "", "Shortcut: optional label embedded in resource_ref (only used when --type local_directory)")
|
||||
projectResourceAddCmd.Flags().String("ref", "", "Generic JSON resource_ref payload — overrides the per-type shortcuts when set")
|
||||
projectResourceAddCmd.Flags().String("label", "", "Optional human-readable label")
|
||||
projectResourceAddCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// project resource update — mirrors `add` flags, but every field is
|
||||
// optional so the caller can edit one thing at a time.
|
||||
projectResourceUpdateCmd.Flags().String("url", "", "Shortcut: new repo URL (github_repo)")
|
||||
projectResourceUpdateCmd.Flags().String("default-branch-hint", "", "Shortcut: new default branch hint (github_repo)")
|
||||
projectResourceUpdateCmd.Flags().String("local-path", "", "Shortcut: new absolute local path (local_directory)")
|
||||
projectResourceUpdateCmd.Flags().String("daemon-id", "", "Shortcut: new daemon id (local_directory)")
|
||||
projectResourceUpdateCmd.Flags().String("ref-label", "", "Shortcut: new label embedded in resource_ref (local_directory)")
|
||||
projectResourceUpdateCmd.Flags().String("ref", "", "Generic JSON resource_ref payload — overrides per-type shortcuts when set")
|
||||
projectResourceUpdateCmd.Flags().String("label", "", "New human-readable label; pass an empty string to clear")
|
||||
projectResourceUpdateCmd.Flags().Bool("clear-label", false, "Clear the human-readable label")
|
||||
projectResourceUpdateCmd.Flags().Int32("position", 0, "New display position")
|
||||
projectResourceUpdateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// project resource remove
|
||||
projectResourceRemoveCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
|
||||
@@ -544,6 +568,19 @@ func runProjectResourceAdd(cmd *cobra.Command, args []string) error {
|
||||
ref["default_branch_hint"] = strings.TrimSpace(hint)
|
||||
}
|
||||
body["resource_ref"] = ref
|
||||
case "local_directory":
|
||||
pathVal, _ := cmd.Flags().GetString("local-path")
|
||||
pathVal = strings.TrimSpace(pathVal)
|
||||
daemonVal, _ := cmd.Flags().GetString("daemon-id")
|
||||
daemonVal = strings.TrimSpace(daemonVal)
|
||||
if pathVal == "" || daemonVal == "" {
|
||||
return fmt.Errorf("local_directory requires --local-path and --daemon-id (or pass a JSON payload via --ref)")
|
||||
}
|
||||
ref := map[string]any{"local_path": pathVal, "daemon_id": daemonVal}
|
||||
if refLabel, _ := cmd.Flags().GetString("ref-label"); strings.TrimSpace(refLabel) != "" {
|
||||
ref["label"] = strings.TrimSpace(refLabel)
|
||||
}
|
||||
body["resource_ref"] = ref
|
||||
default:
|
||||
return fmt.Errorf("type %q has no built-in CLI shortcut; pass the payload via --ref '<json>'", resourceType)
|
||||
}
|
||||
@@ -585,6 +622,217 @@ func runProjectResourceAdd(cmd *cobra.Command, args []string) error {
|
||||
return cli.PrintJSON(os.Stdout, result)
|
||||
}
|
||||
|
||||
func runProjectResourceUpdate(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
projectRef, err := resolveProjectID(ctx, client, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve project: %w", err)
|
||||
}
|
||||
resourceRef, err := resolveProjectResourceID(ctx, client, projectRef.ID, args[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve project resource: %w", err)
|
||||
}
|
||||
|
||||
// Fetch the existing row so per-type shortcuts know which schema to
|
||||
// emit and which fields to preserve. The server treats resource_ref as
|
||||
// opaque-replace, so a partial edit like `--default-branch-hint` has to
|
||||
// rebuild the full payload here — otherwise the unmentioned `url` would
|
||||
// vanish and the server would 400.
|
||||
var existing map[string]any
|
||||
if err := client.GetJSON(ctx, "/api/projects/"+projectRef.ID+"/resources", &existing); err != nil {
|
||||
return fmt.Errorf("list project resources: %w", err)
|
||||
}
|
||||
var resourceType string
|
||||
var existingRef map[string]any
|
||||
if list, ok := existing["resources"].([]any); ok {
|
||||
for _, raw := range list {
|
||||
row, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strVal(row, "id") == resourceRef.ID {
|
||||
resourceType = strVal(row, "resource_type")
|
||||
if ref, ok := row["resource_ref"].(map[string]any); ok {
|
||||
existingRef = ref
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body := map[string]any{}
|
||||
|
||||
if rawRef, _ := cmd.Flags().GetString("ref"); strings.TrimSpace(rawRef) != "" {
|
||||
var ref any
|
||||
if err := json.Unmarshal([]byte(rawRef), &ref); err != nil {
|
||||
return fmt.Errorf("--ref is not valid JSON: %w", err)
|
||||
}
|
||||
body["resource_ref"] = ref
|
||||
} else {
|
||||
ref, has, err := buildResourceRefFromFlags(cmd, resourceType, existingRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if has {
|
||||
body["resource_ref"] = ref
|
||||
}
|
||||
}
|
||||
|
||||
clearLabel, _ := cmd.Flags().GetBool("clear-label")
|
||||
if clearLabel {
|
||||
body["label"] = nil
|
||||
} else if cmd.Flags().Changed("label") {
|
||||
label, _ := cmd.Flags().GetString("label")
|
||||
body["label"] = label
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("position") {
|
||||
pos, _ := cmd.Flags().GetInt32("position")
|
||||
body["position"] = pos
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("nothing to update — pass --ref / --url / --local-path / --label / --position / --clear-label")
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := client.PutJSON(ctx, "/api/projects/"+projectRef.ID+"/resources/"+resourceRef.ID, body, &result); err != nil {
|
||||
return fmt.Errorf("update project resource: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "table" {
|
||||
headers := []string{"ID", "TYPE", "REF", "LABEL"}
|
||||
rows := [][]string{{
|
||||
strVal(result, "id"),
|
||||
strVal(result, "resource_type"),
|
||||
summarizeResourceRef(result["resource_ref"]),
|
||||
strVal(result, "label"),
|
||||
}}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
return cli.PrintJSON(os.Stdout, result)
|
||||
}
|
||||
|
||||
// buildResourceRefFromFlags collects the per-type shortcut flags into a
|
||||
// resource_ref payload, seeding from existingRef so partial edits (only
|
||||
// --default-branch-hint, only --ref-label) preserve the unmentioned fields.
|
||||
// Returns (ref, true) only when the caller actually set at least one shortcut
|
||||
// flag — that lets the update command tell "no change requested" apart from
|
||||
// "change ref to empty object". existingRef may be nil for the `add` path,
|
||||
// where there is nothing to merge with; in that case partial inputs that miss
|
||||
// required fields are still rejected.
|
||||
func buildResourceRefFromFlags(cmd *cobra.Command, resourceType string, existingRef map[string]any) (map[string]any, bool, error) {
|
||||
switch resourceType {
|
||||
case "github_repo":
|
||||
urlSet := cmd.Flags().Changed("url")
|
||||
hintSet := cmd.Flags().Changed("default-branch-hint")
|
||||
if !urlSet && !hintSet {
|
||||
return nil, false, nil
|
||||
}
|
||||
ref := map[string]any{}
|
||||
// Seed from the existing row so a `--default-branch-hint` edit doesn't
|
||||
// clobber the `url` (server overwrites resource_ref wholesale).
|
||||
if existingRef != nil {
|
||||
if u, ok := existingRef["url"].(string); ok && strings.TrimSpace(u) != "" {
|
||||
ref["url"] = strings.TrimSpace(u)
|
||||
}
|
||||
if h, ok := existingRef["default_branch_hint"].(string); ok && strings.TrimSpace(h) != "" {
|
||||
ref["default_branch_hint"] = strings.TrimSpace(h)
|
||||
}
|
||||
}
|
||||
if urlSet {
|
||||
urlVal, _ := cmd.Flags().GetString("url")
|
||||
urlVal = strings.TrimSpace(urlVal)
|
||||
if urlVal == "" {
|
||||
return nil, false, fmt.Errorf("--url cannot be empty")
|
||||
}
|
||||
ref["url"] = urlVal
|
||||
}
|
||||
if hintSet {
|
||||
hint := strings.TrimSpace(mustString(cmd, "default-branch-hint"))
|
||||
if hint == "" {
|
||||
delete(ref, "default_branch_hint")
|
||||
} else {
|
||||
ref["default_branch_hint"] = hint
|
||||
}
|
||||
}
|
||||
if _, ok := ref["url"]; !ok {
|
||||
return nil, false, fmt.Errorf("github_repo: --url is required (no existing url to merge with)")
|
||||
}
|
||||
return ref, true, nil
|
||||
case "local_directory":
|
||||
pathSet := cmd.Flags().Changed("local-path")
|
||||
daemonSet := cmd.Flags().Changed("daemon-id")
|
||||
labelSet := cmd.Flags().Changed("ref-label")
|
||||
if !pathSet && !daemonSet && !labelSet {
|
||||
return nil, false, nil
|
||||
}
|
||||
ref := map[string]any{}
|
||||
if existingRef != nil {
|
||||
if p, ok := existingRef["local_path"].(string); ok && strings.TrimSpace(p) != "" {
|
||||
ref["local_path"] = strings.TrimSpace(p)
|
||||
}
|
||||
if d, ok := existingRef["daemon_id"].(string); ok && strings.TrimSpace(d) != "" {
|
||||
ref["daemon_id"] = strings.TrimSpace(d)
|
||||
}
|
||||
if l, ok := existingRef["label"].(string); ok && strings.TrimSpace(l) != "" {
|
||||
ref["label"] = strings.TrimSpace(l)
|
||||
}
|
||||
}
|
||||
if pathSet {
|
||||
pathVal := strings.TrimSpace(mustString(cmd, "local-path"))
|
||||
if pathVal == "" {
|
||||
return nil, false, fmt.Errorf("--local-path cannot be empty")
|
||||
}
|
||||
ref["local_path"] = pathVal
|
||||
}
|
||||
if daemonSet {
|
||||
daemonVal := strings.TrimSpace(mustString(cmd, "daemon-id"))
|
||||
if daemonVal == "" {
|
||||
return nil, false, fmt.Errorf("--daemon-id cannot be empty")
|
||||
}
|
||||
ref["daemon_id"] = daemonVal
|
||||
}
|
||||
if labelSet {
|
||||
refLabel := strings.TrimSpace(mustString(cmd, "ref-label"))
|
||||
if refLabel == "" {
|
||||
delete(ref, "label")
|
||||
} else {
|
||||
ref["label"] = refLabel
|
||||
}
|
||||
}
|
||||
if v, ok := ref["local_path"].(string); !ok || v == "" {
|
||||
return nil, false, fmt.Errorf("local_directory: --local-path is required (no existing local_path to merge with)")
|
||||
}
|
||||
if v, ok := ref["daemon_id"].(string); !ok || v == "" {
|
||||
return nil, false, fmt.Errorf("local_directory: --daemon-id is required (no existing daemon_id to merge with)")
|
||||
}
|
||||
return ref, true, nil
|
||||
default:
|
||||
// Unknown type or empty (resource not found) — caller must use --ref.
|
||||
if cmd.Flags().Changed("url") || cmd.Flags().Changed("default-branch-hint") ||
|
||||
cmd.Flags().Changed("local-path") || cmd.Flags().Changed("daemon-id") ||
|
||||
cmd.Flags().Changed("ref-label") {
|
||||
return nil, false, fmt.Errorf("no built-in shortcut for resource type %q; pass the full payload via --ref '<json>'", resourceType)
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func mustString(cmd *cobra.Command, name string) string {
|
||||
v, _ := cmd.Flags().GetString(name)
|
||||
return v
|
||||
}
|
||||
|
||||
func runProjectResourceRemove(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
@@ -612,7 +860,8 @@ func runProjectResourceRemove(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// summarizeResourceRef extracts the most useful single string from a
|
||||
// resource_ref object — for github_repo this is the URL.
|
||||
// resource_ref object — for github_repo this is the URL; for
|
||||
// local_directory it is the local path.
|
||||
func summarizeResourceRef(raw any) string {
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
@@ -621,6 +870,9 @@ func summarizeResourceRef(raw any) string {
|
||||
if u, ok := m["url"].(string); ok && u != "" {
|
||||
return u
|
||||
}
|
||||
if p, ok := m["local_path"].(string); ok && p != "" {
|
||||
return p
|
||||
}
|
||||
if data, err := json.Marshal(m); err == nil {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
175
server/cmd/multica/cmd_project_test.go
Normal file
175
server/cmd/multica/cmd_project_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newProjectResourceUpdateTestCmd mirrors the flag surface of
|
||||
// projectResourceUpdateCmd so unit tests can exercise the shortcut-flag plumbing
|
||||
// without spinning up a server.
|
||||
func newProjectResourceUpdateTestCmd() *cobra.Command {
|
||||
c := &cobra.Command{Use: "update"}
|
||||
c.Flags().String("url", "", "")
|
||||
c.Flags().String("default-branch-hint", "", "")
|
||||
c.Flags().String("local-path", "", "")
|
||||
c.Flags().String("daemon-id", "", "")
|
||||
c.Flags().String("ref-label", "", "")
|
||||
c.Flags().String("ref", "", "")
|
||||
c.Flags().String("label", "", "")
|
||||
c.Flags().Bool("clear-label", false, "")
|
||||
c.Flags().Int32("position", 0, "")
|
||||
c.Flags().String("output", "json", "")
|
||||
return c
|
||||
}
|
||||
|
||||
// TestBuildResourceRefFromFlagsGithubMergesHint pins the nit fix from MUL-2662
|
||||
// review round 2: `multica project resource update <p> <r> --default-branch-hint x`
|
||||
// must rebuild the full github_repo payload by merging the existing `url` —
|
||||
// otherwise the server sees `{default_branch_hint: "x"}` and 400s.
|
||||
func TestBuildResourceRefFromFlagsGithubMergesHint(t *testing.T) {
|
||||
t.Run("hint-only edit preserves existing url", func(t *testing.T) {
|
||||
cmd := newProjectResourceUpdateTestCmd()
|
||||
_ = cmd.Flags().Set("default-branch-hint", "main")
|
||||
existing := map[string]any{"url": "https://github.com/multica-ai/multica"}
|
||||
|
||||
ref, has, err := buildResourceRefFromFlags(cmd, "github_repo", existing)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !has {
|
||||
t.Fatalf("expected has=true when default-branch-hint is set")
|
||||
}
|
||||
if ref["url"] != "https://github.com/multica-ai/multica" {
|
||||
t.Errorf("expected merged url, got %v", ref["url"])
|
||||
}
|
||||
if ref["default_branch_hint"] != "main" {
|
||||
t.Errorf("expected merged hint=main, got %v", ref["default_branch_hint"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hint=empty clears the hint but keeps url", func(t *testing.T) {
|
||||
cmd := newProjectResourceUpdateTestCmd()
|
||||
_ = cmd.Flags().Set("default-branch-hint", "")
|
||||
existing := map[string]any{
|
||||
"url": "https://github.com/multica-ai/multica",
|
||||
"default_branch_hint": "stale",
|
||||
}
|
||||
ref, has, err := buildResourceRefFromFlags(cmd, "github_repo", existing)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !has {
|
||||
t.Fatalf("expected has=true")
|
||||
}
|
||||
if ref["url"] != "https://github.com/multica-ai/multica" {
|
||||
t.Errorf("expected url to survive empty-hint clear, got %v", ref["url"])
|
||||
}
|
||||
if _, ok := ref["default_branch_hint"]; ok {
|
||||
t.Errorf("expected default_branch_hint to be cleared, got %v", ref["default_branch_hint"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("url override survives merge", func(t *testing.T) {
|
||||
cmd := newProjectResourceUpdateTestCmd()
|
||||
_ = cmd.Flags().Set("url", "https://github.com/multica-ai/new-repo")
|
||||
existing := map[string]any{
|
||||
"url": "https://github.com/multica-ai/multica",
|
||||
"default_branch_hint": "main",
|
||||
}
|
||||
ref, has, err := buildResourceRefFromFlags(cmd, "github_repo", existing)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !has {
|
||||
t.Fatalf("expected has=true")
|
||||
}
|
||||
if ref["url"] != "https://github.com/multica-ai/new-repo" {
|
||||
t.Errorf("expected overridden url, got %v", ref["url"])
|
||||
}
|
||||
if ref["default_branch_hint"] != "main" {
|
||||
t.Errorf("expected merged hint to persist, got %v", ref["default_branch_hint"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hint-only with no existing url fails fast", func(t *testing.T) {
|
||||
cmd := newProjectResourceUpdateTestCmd()
|
||||
_ = cmd.Flags().Set("default-branch-hint", "main")
|
||||
_, _, err := buildResourceRefFromFlags(cmd, "github_repo", nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when no existing url is available to merge")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no flags set returns has=false", func(t *testing.T) {
|
||||
cmd := newProjectResourceUpdateTestCmd()
|
||||
ref, has, err := buildResourceRefFromFlags(cmd, "github_repo", map[string]any{"url": "https://x"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if has {
|
||||
t.Errorf("expected has=false when no shortcut flag is set, got ref=%v", ref)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBuildResourceRefFromFlagsLocalDirectoryMerges covers the same merge
|
||||
// behavior for local_directory: partial edits keep unmentioned fields from the
|
||||
// existing ref.
|
||||
func TestBuildResourceRefFromFlagsLocalDirectoryMerges(t *testing.T) {
|
||||
t.Run("ref-label only edit preserves existing path + daemon", func(t *testing.T) {
|
||||
cmd := newProjectResourceUpdateTestCmd()
|
||||
_ = cmd.Flags().Set("ref-label", "renamed")
|
||||
existing := map[string]any{
|
||||
"local_path": "/Users/foo/work/a",
|
||||
"daemon_id": "d1",
|
||||
"label": "old",
|
||||
}
|
||||
ref, has, err := buildResourceRefFromFlags(cmd, "local_directory", existing)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !has {
|
||||
t.Fatalf("expected has=true")
|
||||
}
|
||||
if ref["local_path"] != "/Users/foo/work/a" {
|
||||
t.Errorf("local_path missing after merge: %v", ref["local_path"])
|
||||
}
|
||||
if ref["daemon_id"] != "d1" {
|
||||
t.Errorf("daemon_id missing after merge: %v", ref["daemon_id"])
|
||||
}
|
||||
if ref["label"] != "renamed" {
|
||||
t.Errorf("label not overridden: %v", ref["label"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("local-path only without existing daemon fails", func(t *testing.T) {
|
||||
cmd := newProjectResourceUpdateTestCmd()
|
||||
_ = cmd.Flags().Set("local-path", "/Users/foo/work/b")
|
||||
_, _, err := buildResourceRefFromFlags(cmd, "local_directory", nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when daemon_id is missing from both flags and existing ref")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ref-label cleared on empty input", func(t *testing.T) {
|
||||
cmd := newProjectResourceUpdateTestCmd()
|
||||
_ = cmd.Flags().Set("ref-label", "")
|
||||
existing := map[string]any{
|
||||
"local_path": "/Users/foo/work/a",
|
||||
"daemon_id": "d1",
|
||||
"label": "to-clear",
|
||||
}
|
||||
ref, has, err := buildResourceRefFromFlags(cmd, "local_directory", existing)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !has {
|
||||
t.Fatalf("expected has=true")
|
||||
}
|
||||
if _, ok := ref["label"]; ok {
|
||||
t.Errorf("expected embedded label to be cleared, got %v", ref["label"])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -449,6 +449,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
|
||||
r.Delete("/", h.DeleteProject)
|
||||
r.Get("/resources", h.ListProjectResources)
|
||||
r.Post("/resources", h.CreateProjectResource)
|
||||
r.Put("/resources/{resourceId}", h.UpdateProjectResource)
|
||||
r.Delete("/resources/{resourceId}", h.DeleteProjectResource)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -223,8 +223,15 @@ func (h *Handler) CreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Pre-validate every resource payload before opening a transaction so an
|
||||
// invalid ref produces a clean 400 with no DB work.
|
||||
// invalid ref produces a clean 400 with no DB work. For local_directory we
|
||||
// also dedupe within the batch on (daemon_id, local_path) — the embedded
|
||||
// `label` is human metadata, so the DB's UNIQUE(project_id, resource_type,
|
||||
// resource_ref) constraint would let two rows with different labels but the
|
||||
// same target slip past. The standalone POST/PUT paths run the same check
|
||||
// via findLocalDirectoryConflict; this loop just covers the bundled-create
|
||||
// surface, where there is no existing row to compare against yet.
|
||||
normalizedRefs := make([]json.RawMessage, len(req.Resources))
|
||||
localDirSeen := map[string]int{}
|
||||
for i, res := range req.Resources {
|
||||
res.ResourceType = strings.TrimSpace(res.ResourceType)
|
||||
if res.ResourceType == "" {
|
||||
@@ -237,6 +244,19 @@ func (h *Handler) CreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
normalizedRefs[i] = ref
|
||||
if res.ResourceType == "local_directory" {
|
||||
var ld localDirectoryRef
|
||||
if err := json.Unmarshal(ref, &ld); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "resources["+strconv.Itoa(i)+"]: "+err.Error())
|
||||
return
|
||||
}
|
||||
key := ld.DaemonID + "\x00" + ld.LocalPath
|
||||
if prev, ok := localDirSeen[key]; ok {
|
||||
writeError(w, http.StatusBadRequest, "resources["+strconv.Itoa(i)+"]: duplicates local_directory at index "+strconv.Itoa(prev)+" (same daemon_id + local_path)")
|
||||
return
|
||||
}
|
||||
localDirSeen[key] = i
|
||||
}
|
||||
}
|
||||
|
||||
createParams := db.CreateProjectParams{
|
||||
|
||||
@@ -54,6 +54,15 @@ type CreateProjectResourceRequest struct {
|
||||
Position *int32 `json:"position"`
|
||||
}
|
||||
|
||||
// UpdateProjectResourceRequest is the body for PUT /api/projects/{id}/resources/{resourceId}.
|
||||
// resource_type cannot change after creation — pick a new type by deleting and
|
||||
// re-adding. Every field is optional; omitted fields keep their current value.
|
||||
type UpdateProjectResourceRequest struct {
|
||||
ResourceRef json.RawMessage `json:"resource_ref"`
|
||||
Label *string `json:"label"`
|
||||
Position *int32 `json:"position"`
|
||||
}
|
||||
|
||||
// validateAndNormalizeResourceRef checks the payload for a known resource_type.
|
||||
// New types are added here without schema migration; unknown types are rejected
|
||||
// at the API boundary so a typo can't slip through and produce a resource the
|
||||
@@ -65,14 +74,16 @@ func validateAndNormalizeResourceRef(resourceType string, ref json.RawMessage) (
|
||||
switch resourceType {
|
||||
case "github_repo":
|
||||
return validateGithubRepoRef(ref)
|
||||
case "local_directory":
|
||||
return validateLocalDirectoryRef(ref)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown resource_type %q", resourceType)
|
||||
}
|
||||
}
|
||||
|
||||
type githubRepoRef struct {
|
||||
URL string `json:"url"`
|
||||
DefaultBranchHint string `json:"default_branch_hint,omitempty"`
|
||||
URL string `json:"url"`
|
||||
DefaultBranchHint string `json:"default_branch_hint,omitempty"`
|
||||
}
|
||||
|
||||
func validateGithubRepoRef(ref json.RawMessage) (json.RawMessage, error) {
|
||||
@@ -95,6 +106,68 @@ func validateGithubRepoRef(ref json.RawMessage) (json.RawMessage, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// localDirectoryRef is the JSONB shape stored for resource_type=local_directory.
|
||||
// It pins a project to an existing directory on a specific user machine, so
|
||||
// agent tasks run in-place rather than in an isolated git worktree. The
|
||||
// daemon_id scopes the path to one daemon registration — the same string path
|
||||
// on a different machine is a different resource. The optional label is a
|
||||
// human-readable hint used by the UI; the row-level project_resource.label
|
||||
// column remains the generic column for any resource type.
|
||||
type localDirectoryRef struct {
|
||||
LocalPath string `json:"local_path"`
|
||||
DaemonID string `json:"daemon_id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
func validateLocalDirectoryRef(ref json.RawMessage) (json.RawMessage, error) {
|
||||
var payload localDirectoryRef
|
||||
if err := json.Unmarshal(ref, &payload); err != nil {
|
||||
return nil, fmt.Errorf("invalid local_directory payload: %w", err)
|
||||
}
|
||||
payload.LocalPath = strings.TrimSpace(payload.LocalPath)
|
||||
if payload.LocalPath == "" {
|
||||
return nil, errors.New("local_directory: local_path is required")
|
||||
}
|
||||
if !isAbsoluteLocalPath(payload.LocalPath) {
|
||||
return nil, errors.New("local_directory: local_path must be an absolute path")
|
||||
}
|
||||
payload.DaemonID = strings.TrimSpace(payload.DaemonID)
|
||||
if payload.DaemonID == "" {
|
||||
return nil, errors.New("local_directory: daemon_id is required")
|
||||
}
|
||||
payload.Label = strings.TrimSpace(payload.Label)
|
||||
out, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// isAbsoluteLocalPath checks the path looks absolute on either POSIX or
|
||||
// Windows daemons. The server can't know which OS the daemon runs on, so we
|
||||
// accept the union: a leading "/" (POSIX), a UNC prefix "\\", or a drive
|
||||
// letter like "C:\" or "C:/". The daemon still verifies existence at run
|
||||
// time — this is a typo guard, not a filesystem check.
|
||||
func isAbsoluteLocalPath(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
if s[0] == '/' {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(s, `\\`) {
|
||||
return true
|
||||
}
|
||||
if len(s) >= 3 && isDriveLetter(s[0]) && s[1] == ':' && (s[2] == '\\' || s[2] == '/') {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isDriveLetter(b byte) bool {
|
||||
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
|
||||
}
|
||||
|
||||
// isValidGitRepoURL accepts the three forms a user can paste from GitHub's
|
||||
// "Code" menu: https://, ssh:// (with explicit scheme), and the scp-like
|
||||
// shorthand `git@host:owner/repo.git`. The check is intentionally lax — we are
|
||||
@@ -203,6 +276,14 @@ func (h *Handler) CreateProjectResource(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if conflict, err := h.findLocalDirectoryConflict(r.Context(), project.ID, req.ResourceType, normalizedRef, pgtype.UUID{}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to check existing resources")
|
||||
return
|
||||
} else if conflict {
|
||||
writeError(w, http.StatusConflict, "this resource is already attached to the project")
|
||||
return
|
||||
}
|
||||
|
||||
var label pgtype.Text
|
||||
if req.Label != nil && strings.TrimSpace(*req.Label) != "" {
|
||||
label = pgtype.Text{String: strings.TrimSpace(*req.Label), Valid: true}
|
||||
@@ -246,6 +327,153 @@ func (h *Handler) CreateProjectResource(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// UpdateProjectResource edits an existing resource's ref/label/position.
|
||||
// resource_type is immutable — re-pointing a resource at a different type is
|
||||
// almost always a different conceptual entity, so the caller should delete and
|
||||
// re-add instead. Omitted fields keep their current value, including the
|
||||
// `label` JSON null vs. missing distinction (missing = keep, explicit "" =
|
||||
// clear).
|
||||
func (h *Handler) UpdateProjectResource(w http.ResponseWriter, r *http.Request) {
|
||||
project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
resourceUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "resourceId"), "resource id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := h.Queries.GetProjectResourceInWorkspace(r.Context(), db.GetProjectResourceInWorkspaceParams{
|
||||
ID: resourceUUID, WorkspaceID: project.WorkspaceID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "project resource not found")
|
||||
return
|
||||
}
|
||||
if uuidToString(existing.ProjectID) != uuidToString(project.ID) {
|
||||
writeError(w, http.StatusNotFound, "project resource not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Decode into a raw map first so we can tell "field omitted" from
|
||||
// "field present with zero value" — the label clear case in particular
|
||||
// relies on this distinction.
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
nextRef := json.RawMessage(existing.ResourceRef)
|
||||
if rawRef, ok := raw["resource_ref"]; ok {
|
||||
normalized, err := validateAndNormalizeResourceRef(existing.ResourceType, rawRef)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
nextRef = normalized
|
||||
}
|
||||
|
||||
if conflict, err := h.findLocalDirectoryConflict(r.Context(), project.ID, existing.ResourceType, nextRef, existing.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to check existing resources")
|
||||
return
|
||||
} else if conflict {
|
||||
writeError(w, http.StatusConflict, "another resource on this project already targets that local_directory")
|
||||
return
|
||||
}
|
||||
|
||||
nextLabel := existing.Label
|
||||
if rawLabel, ok := raw["label"]; ok {
|
||||
var labelStr *string
|
||||
if err := json.Unmarshal(rawLabel, &labelStr); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "label must be a string or null")
|
||||
return
|
||||
}
|
||||
if labelStr == nil || strings.TrimSpace(*labelStr) == "" {
|
||||
nextLabel = pgtype.Text{}
|
||||
} else {
|
||||
nextLabel = pgtype.Text{String: strings.TrimSpace(*labelStr), Valid: true}
|
||||
}
|
||||
}
|
||||
|
||||
nextPosition := existing.Position
|
||||
if rawPos, ok := raw["position"]; ok {
|
||||
var pos *int32
|
||||
if err := json.Unmarshal(rawPos, &pos); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "position must be an integer")
|
||||
return
|
||||
}
|
||||
if pos != nil {
|
||||
nextPosition = *pos
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := h.Queries.UpdateProjectResource(r.Context(), db.UpdateProjectResourceParams{
|
||||
ID: existing.ID,
|
||||
ResourceRef: nextRef,
|
||||
Label: nextLabel,
|
||||
Position: nextPosition,
|
||||
})
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "this resource is already attached to the project")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to update project resource")
|
||||
return
|
||||
}
|
||||
|
||||
resp := projectResourceToResponse(updated)
|
||||
h.publish(
|
||||
protocol.EventProjectResourceUpdated,
|
||||
uuidToString(project.WorkspaceID),
|
||||
"member",
|
||||
userID,
|
||||
map[string]any{"resource": resp, "project_id": uuidToString(project.ID)},
|
||||
)
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// findLocalDirectoryConflict catches the case where two resources on the same
|
||||
// project point at the same (daemon_id, local_path) but use different labels.
|
||||
// The DB-level UNIQUE(project_id, resource_type, resource_ref) constraint only
|
||||
// fires when the entire ref JSON matches — label lives inside resource_ref, so
|
||||
// a typo in the label field would otherwise let users attach the same working
|
||||
// directory twice. excludeID lets the update path ignore the row being edited.
|
||||
func (h *Handler) findLocalDirectoryConflict(ctx context.Context, projectID pgtype.UUID, resourceType string, normalizedRef json.RawMessage, excludeID pgtype.UUID) (bool, error) {
|
||||
if resourceType != "local_directory" {
|
||||
return false, nil
|
||||
}
|
||||
var incoming localDirectoryRef
|
||||
if err := json.Unmarshal(normalizedRef, &incoming); err != nil {
|
||||
return false, err
|
||||
}
|
||||
rows, err := h.Queries.ListProjectResources(ctx, projectID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.ResourceType != "local_directory" {
|
||||
continue
|
||||
}
|
||||
if excludeID.Valid && uuidToString(row.ID) == uuidToString(excludeID) {
|
||||
continue
|
||||
}
|
||||
var existing localDirectoryRef
|
||||
if err := json.Unmarshal(row.ResourceRef, &existing); err != nil {
|
||||
continue
|
||||
}
|
||||
if existing.DaemonID == incoming.DaemonID && existing.LocalPath == incoming.LocalPath {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// DeleteProjectResource removes a resource from a project.
|
||||
func (h *Handler) DeleteProjectResource(w http.ResponseWriter, r *http.Request) {
|
||||
project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id"))
|
||||
|
||||
@@ -215,9 +215,9 @@ func TestIsValidGitRepoURL(t *testing.T) {
|
||||
"ftp://example.com/repo", // unsupported scheme
|
||||
"file:///tmp/repo", // unsupported scheme
|
||||
"some random text with spaces",
|
||||
"github.com:org/repo@branch", // '@' after ':' belongs to the path, not user
|
||||
"foo:bar@baz", // '@' after ':' with no scheme
|
||||
":foo/bar", // leading ':' with no host
|
||||
"github.com:org/repo@branch", // '@' after ':' belongs to the path, not user
|
||||
"foo:bar@baz", // '@' after ':' with no scheme
|
||||
":foo/bar", // leading ':' with no host
|
||||
}
|
||||
for _, s := range good {
|
||||
if !isValidGitRepoURL(s) {
|
||||
@@ -231,6 +231,227 @@ func TestIsValidGitRepoURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectResourceLocalDirectoryLifecycle covers the full CRUD path for the
|
||||
// local_directory resource type added in MUL-2662. Unlike github_repo, the
|
||||
// ref schema requires local_path + daemon_id and forbids any path that isn't
|
||||
// absolute. Two project-scoped resources pointing at the same daemon_id /
|
||||
// local_path on different projects must be allowed — Bohan explicitly chose
|
||||
// not to add a UNIQUE(daemon_id, local_path) constraint.
|
||||
func TestProjectResourceLocalDirectoryLifecycle(t *testing.T) {
|
||||
createProject := func(title string) ProjectResponse {
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": title,
|
||||
})
|
||||
testHandler.CreateProject(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateProject(%s): %d %s", title, w.Code, w.Body.String())
|
||||
}
|
||||
var p ProjectResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&p); err != nil {
|
||||
t.Fatalf("decode CreateProject: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
deleteProject := func(id string) {
|
||||
r := newRequest("DELETE", "/api/projects/"+id, nil)
|
||||
r = withURLParam(r, "id", id)
|
||||
testHandler.DeleteProject(httptest.NewRecorder(), r)
|
||||
}
|
||||
|
||||
projectA := createProject("Local directory project A")
|
||||
defer deleteProject(projectA.ID)
|
||||
projectB := createProject("Local directory project B")
|
||||
defer deleteProject(projectB.ID)
|
||||
|
||||
const (
|
||||
daemonID = "daemon-aaaa-bbbb-cccc"
|
||||
localPath = "/Users/foo/work/my-game"
|
||||
)
|
||||
|
||||
// Happy path: attach local_directory resource with label.
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/projects/"+projectA.ID+"/resources", map[string]any{
|
||||
"resource_type": "local_directory",
|
||||
"resource_ref": map[string]any{
|
||||
"local_path": localPath,
|
||||
"daemon_id": daemonID,
|
||||
"label": "Game Repo",
|
||||
},
|
||||
})
|
||||
req = withURLParam(req, "id", projectA.ID)
|
||||
testHandler.CreateProjectResource(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateProjectResource: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var created ProjectResourceResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
|
||||
t.Fatalf("decode CreateProjectResource: %v", err)
|
||||
}
|
||||
if created.ResourceType != "local_directory" {
|
||||
t.Errorf("ResourceType = %q, want local_directory", created.ResourceType)
|
||||
}
|
||||
var ref struct {
|
||||
LocalPath string `json:"local_path"`
|
||||
DaemonID string `json:"daemon_id"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
if err := json.Unmarshal(created.ResourceRef, &ref); err != nil {
|
||||
t.Fatalf("decode resource_ref: %v", err)
|
||||
}
|
||||
if ref.LocalPath != localPath || ref.DaemonID != daemonID || ref.Label != "Game Repo" {
|
||||
t.Errorf("ref = %+v, want {%q, %q, Game Repo}", ref, localPath, daemonID)
|
||||
}
|
||||
|
||||
// Listing must include the new resource.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("GET", "/api/projects/"+projectA.ID+"/resources", nil)
|
||||
req = withURLParam(req, "id", projectA.ID)
|
||||
testHandler.ListProjectResources(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ListProjectResources: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var listResp struct {
|
||||
Resources []ProjectResourceResponse `json:"resources"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&listResp); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
if listResp.Total != 1 || listResp.Resources[0].ID != created.ID {
|
||||
t.Fatalf("list mismatch: %+v", listResp)
|
||||
}
|
||||
|
||||
// Same (daemon_id, local_path) on a different project must succeed —
|
||||
// the design explicitly allows the same directory to back multiple
|
||||
// projects, contrast with github_repo's per-project UNIQUE check.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/projects/"+projectB.ID+"/resources", map[string]any{
|
||||
"resource_type": "local_directory",
|
||||
"resource_ref": map[string]any{
|
||||
"local_path": localPath,
|
||||
"daemon_id": daemonID,
|
||||
},
|
||||
})
|
||||
req = withURLParam(req, "id", projectB.ID)
|
||||
testHandler.CreateProjectResource(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("same path on project B: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Duplicate attach on the same project must still conflict — the
|
||||
// UNIQUE(project_id, resource_type, resource_ref) row constraint
|
||||
// remains in effect.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/projects/"+projectA.ID+"/resources", map[string]any{
|
||||
"resource_type": "local_directory",
|
||||
"resource_ref": map[string]any{
|
||||
"local_path": localPath,
|
||||
"daemon_id": daemonID,
|
||||
"label": "Game Repo",
|
||||
},
|
||||
})
|
||||
req = withURLParam(req, "id", projectA.ID)
|
||||
testHandler.CreateProjectResource(w, req)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Errorf("duplicate on same project: expected 409, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Delete the resource on project A.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("DELETE", "/api/projects/"+projectA.ID+"/resources/"+created.ID, nil)
|
||||
req = withURLParams(req, "id", projectA.ID, "resourceId", created.ID)
|
||||
testHandler.DeleteProjectResource(w, req)
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("DeleteProjectResource: expected 204, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectResourceLocalDirectoryValidation pins the schema rejection
|
||||
// surface for local_directory: missing path, missing daemon, relative paths,
|
||||
// and malformed JSON must all return 400. These are the only client-visible
|
||||
// errors agents will hit, so freezing them as tests prevents accidental
|
||||
// loosening when someone touches the validator.
|
||||
func TestProjectResourceLocalDirectoryValidation(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Local directory validation",
|
||||
})
|
||||
testHandler.CreateProject(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateProject: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var project ProjectResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&project); err != nil {
|
||||
t.Fatalf("decode CreateProject: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
r := newRequest("DELETE", "/api/projects/"+project.ID, nil)
|
||||
r = withURLParam(r, "id", project.ID)
|
||||
testHandler.DeleteProject(httptest.NewRecorder(), r)
|
||||
}()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ref any
|
||||
}{
|
||||
{"missing local_path", map[string]any{"daemon_id": "d1"}},
|
||||
{"blank local_path", map[string]any{"local_path": " ", "daemon_id": "d1"}},
|
||||
{"relative local_path", map[string]any{"local_path": "work/my-game", "daemon_id": "d1"}},
|
||||
{"home-shorthand path", map[string]any{"local_path": "~/work/my-game", "daemon_id": "d1"}},
|
||||
{"missing daemon_id", map[string]any{"local_path": "/Users/foo/work"}},
|
||||
{"blank daemon_id", map[string]any{"local_path": "/Users/foo/work", "daemon_id": ""}},
|
||||
{"wrong type in payload", map[string]any{"local_path": 42, "daemon_id": "d1"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
||||
"resource_type": "local_directory",
|
||||
"resource_ref": tc.ref,
|
||||
})
|
||||
req = withURLParam(req, "id", project.ID)
|
||||
testHandler.CreateProjectResource(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAbsoluteLocalPath(t *testing.T) {
|
||||
good := []string{
|
||||
"/Users/foo/work",
|
||||
"/",
|
||||
"/a",
|
||||
`C:\Users\foo`,
|
||||
`C:/Users/foo`,
|
||||
`d:\code\repo`,
|
||||
`\\server\share\path`,
|
||||
}
|
||||
bad := []string{
|
||||
"",
|
||||
"work/my-game",
|
||||
"./relative",
|
||||
"../relative",
|
||||
"~/work",
|
||||
"C:relative",
|
||||
"C:",
|
||||
`\foo`,
|
||||
"file:///tmp",
|
||||
}
|
||||
for _, s := range good {
|
||||
if !isAbsoluteLocalPath(s) {
|
||||
t.Errorf("isAbsoluteLocalPath(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
for _, s := range bad {
|
||||
if isAbsoluteLocalPath(s) {
|
||||
t.Errorf("isAbsoluteLocalPath(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProjectAttachesResources(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
||||
@@ -438,3 +659,360 @@ func TestCreateProjectRollsBackOnInvalidResource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectResourceUpdateLifecycle covers the PUT endpoint added in MUL-2662:
|
||||
// editing label / position / resource_ref independently must succeed, and a
|
||||
// missing resource_type swap is enforced implicitly because the request body
|
||||
// has no resource_type field.
|
||||
func TestProjectResourceUpdateLifecycle(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Update lifecycle project",
|
||||
})
|
||||
testHandler.CreateProject(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateProject: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var project ProjectResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&project); err != nil {
|
||||
t.Fatalf("decode CreateProject: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
r := newRequest("DELETE", "/api/projects/"+project.ID, nil)
|
||||
r = withURLParam(r, "id", project.ID)
|
||||
testHandler.DeleteProject(httptest.NewRecorder(), r)
|
||||
}()
|
||||
|
||||
// Seed one local_directory resource we will mutate.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
||||
"resource_type": "local_directory",
|
||||
"resource_ref": map[string]any{
|
||||
"local_path": "/Users/foo/work/a",
|
||||
"daemon_id": "d1",
|
||||
"label": "A",
|
||||
},
|
||||
"label": "outer",
|
||||
})
|
||||
req = withURLParam(req, "id", project.ID)
|
||||
testHandler.CreateProjectResource(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateProjectResource: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var created ProjectResourceResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
|
||||
t.Fatalf("decode CreateProjectResource: %v", err)
|
||||
}
|
||||
|
||||
// Update only the label; ref/position/type must stay untouched.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("PUT", "/api/projects/"+project.ID+"/resources/"+created.ID, map[string]any{
|
||||
"label": "renamed",
|
||||
})
|
||||
req = withURLParams(req, "id", project.ID, "resourceId", created.ID)
|
||||
testHandler.UpdateProjectResource(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateProjectResource label-only: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var updated ProjectResourceResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
|
||||
t.Fatalf("decode UpdateProjectResource: %v", err)
|
||||
}
|
||||
if updated.Label == nil || *updated.Label != "renamed" {
|
||||
t.Errorf("after label edit: label = %v, want renamed", updated.Label)
|
||||
}
|
||||
var ref localDirectoryRef
|
||||
if err := json.Unmarshal(updated.ResourceRef, &ref); err != nil {
|
||||
t.Fatalf("decode resource_ref: %v", err)
|
||||
}
|
||||
if ref.LocalPath != "/Users/foo/work/a" || ref.DaemonID != "d1" || ref.Label != "A" {
|
||||
t.Errorf("label-only update leaked into resource_ref: %+v", ref)
|
||||
}
|
||||
|
||||
// Update the ref payload (move to a new daemon path) and bump position.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("PUT", "/api/projects/"+project.ID+"/resources/"+created.ID, map[string]any{
|
||||
"resource_ref": map[string]any{
|
||||
"local_path": "/Users/foo/work/b",
|
||||
"daemon_id": "d2",
|
||||
"label": "B",
|
||||
},
|
||||
"position": 5,
|
||||
})
|
||||
req = withURLParams(req, "id", project.ID, "resourceId", created.ID)
|
||||
testHandler.UpdateProjectResource(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateProjectResource ref+position: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
|
||||
t.Fatalf("decode UpdateProjectResource: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(updated.ResourceRef, &ref); err != nil {
|
||||
t.Fatalf("decode resource_ref: %v", err)
|
||||
}
|
||||
if ref.LocalPath != "/Users/foo/work/b" || ref.DaemonID != "d2" || ref.Label != "B" {
|
||||
t.Errorf("ref-update mismatch: %+v", ref)
|
||||
}
|
||||
if updated.Position != 5 {
|
||||
t.Errorf("position = %d, want 5", updated.Position)
|
||||
}
|
||||
if updated.Label == nil || *updated.Label != "renamed" {
|
||||
t.Errorf("label should survive ref edit, got %v", updated.Label)
|
||||
}
|
||||
|
||||
// Explicit null clears the outer label.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("PUT", "/api/projects/"+project.ID+"/resources/"+created.ID, map[string]any{
|
||||
"label": nil,
|
||||
})
|
||||
req = withURLParams(req, "id", project.ID, "resourceId", created.ID)
|
||||
testHandler.UpdateProjectResource(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateProjectResource label=null: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
|
||||
t.Fatalf("decode UpdateProjectResource: %v", err)
|
||||
}
|
||||
if updated.Label != nil {
|
||||
t.Errorf("label should be cleared, got %v", *updated.Label)
|
||||
}
|
||||
|
||||
// Bad ref payload must reject with 400 (relative path).
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("PUT", "/api/projects/"+project.ID+"/resources/"+created.ID, map[string]any{
|
||||
"resource_ref": map[string]any{"local_path": "relative/path", "daemon_id": "d3"},
|
||||
})
|
||||
req = withURLParams(req, "id", project.ID, "resourceId", created.ID)
|
||||
testHandler.UpdateProjectResource(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("relative path: expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Unknown resource id must 404.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("PUT", "/api/projects/"+project.ID+"/resources/00000000-0000-0000-0000-000000000000", map[string]any{
|
||||
"label": "ghost",
|
||||
})
|
||||
req = withURLParams(req, "id", project.ID, "resourceId", "00000000-0000-0000-0000-000000000000")
|
||||
testHandler.UpdateProjectResource(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("missing resource: expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectResourceLocalDirectoryLabelShadow pins the project-level conflict
|
||||
// check for local_directory: two resources on the same project pointing at the
|
||||
// same (daemon_id, local_path) must collide even when the embedded `label`
|
||||
// differs. The DB UNIQUE(project_id, resource_type, resource_ref) constraint
|
||||
// only matches the entire ref JSON, so a label typo would otherwise silently
|
||||
// create a duplicate binding.
|
||||
func TestProjectResourceLocalDirectoryLabelShadow(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Local dir label shadow",
|
||||
})
|
||||
testHandler.CreateProject(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateProject: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var project ProjectResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&project); err != nil {
|
||||
t.Fatalf("decode CreateProject: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
r := newRequest("DELETE", "/api/projects/"+project.ID, nil)
|
||||
r = withURLParam(r, "id", project.ID)
|
||||
testHandler.DeleteProject(httptest.NewRecorder(), r)
|
||||
}()
|
||||
|
||||
const (
|
||||
daemonID = "d-shadow"
|
||||
localPath = "/Users/foo/work/shadow"
|
||||
)
|
||||
|
||||
// First attach succeeds.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
||||
"resource_type": "local_directory",
|
||||
"resource_ref": map[string]any{
|
||||
"local_path": localPath,
|
||||
"daemon_id": daemonID,
|
||||
"label": "first",
|
||||
},
|
||||
})
|
||||
req = withURLParam(req, "id", project.ID)
|
||||
testHandler.CreateProjectResource(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("first attach: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var first ProjectResourceResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&first); err != nil {
|
||||
t.Fatalf("decode first: %v", err)
|
||||
}
|
||||
|
||||
// Same (daemon_id, local_path) with a different label must still 409 —
|
||||
// the embedded label is human metadata, not a discriminator.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
||||
"resource_type": "local_directory",
|
||||
"resource_ref": map[string]any{
|
||||
"local_path": localPath,
|
||||
"daemon_id": daemonID,
|
||||
"label": "different label",
|
||||
},
|
||||
})
|
||||
req = withURLParam(req, "id", project.ID)
|
||||
testHandler.CreateProjectResource(w, req)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Errorf("label-shadow create: expected 409, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// And an UPDATE that drives a different resource onto the same target
|
||||
// must also 409. Seed a second resource at a non-conflicting path, then
|
||||
// try to move it onto the first.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
||||
"resource_type": "local_directory",
|
||||
"resource_ref": map[string]any{
|
||||
"local_path": "/Users/foo/work/other",
|
||||
"daemon_id": daemonID,
|
||||
},
|
||||
})
|
||||
req = withURLParam(req, "id", project.ID)
|
||||
testHandler.CreateProjectResource(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("second seed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var second ProjectResourceResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&second); err != nil {
|
||||
t.Fatalf("decode second: %v", err)
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("PUT", "/api/projects/"+project.ID+"/resources/"+second.ID, map[string]any{
|
||||
"resource_ref": map[string]any{
|
||||
"local_path": localPath,
|
||||
"daemon_id": daemonID,
|
||||
"label": "fresh",
|
||||
},
|
||||
})
|
||||
req = withURLParams(req, "id", project.ID, "resourceId", second.ID)
|
||||
testHandler.UpdateProjectResource(w, req)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Errorf("label-shadow update: expected 409, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Editing the same row in place (different label, same target) must
|
||||
// succeed — the conflict check ignores the row being updated.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("PUT", "/api/projects/"+project.ID+"/resources/"+first.ID, map[string]any{
|
||||
"resource_ref": map[string]any{
|
||||
"local_path": localPath,
|
||||
"daemon_id": daemonID,
|
||||
"label": "renamed inline",
|
||||
},
|
||||
})
|
||||
req = withURLParams(req, "id", project.ID, "resourceId", first.ID)
|
||||
testHandler.UpdateProjectResource(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("in-place rename: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateProjectBundledLocalDirectoryLabelShadow pins the second leg of the
|
||||
// label-shadow guard: a single POST /api/projects that bundles two
|
||||
// local_directory resources with the same (daemon_id, local_path) but
|
||||
// different `label` values must reject with 400 before any DB work, otherwise
|
||||
// the DB UNIQUE(project_id, resource_type, resource_ref) constraint would let
|
||||
// the duplicate slip past (label lives inside resource_ref).
|
||||
func TestCreateProjectBundledLocalDirectoryLabelShadow(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Bundled label shadow",
|
||||
"resources": []map[string]any{
|
||||
{
|
||||
"resource_type": "local_directory",
|
||||
"resource_ref": map[string]any{
|
||||
"local_path": "/Users/foo/work/dup",
|
||||
"daemon_id": "d-bundle",
|
||||
"label": "first",
|
||||
},
|
||||
},
|
||||
{
|
||||
"resource_type": "local_directory",
|
||||
"resource_ref": map[string]any{
|
||||
"local_path": "/Users/foo/work/dup",
|
||||
"daemon_id": "d-bundle",
|
||||
"label": "second label",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
testHandler.CreateProject(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bundled label shadow: expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Confirm the rollback: no project with the title should exist.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("GET", "/api/projects?workspace_id="+testWorkspaceID, nil)
|
||||
testHandler.ListProjects(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ListProjects: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var list struct {
|
||||
Projects []ProjectResponse `json:"projects"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&list); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
for _, p := range list.Projects {
|
||||
if p.Title == "Bundled label shadow" {
|
||||
t.Errorf("expected no project to survive bundled-create rejection, but found %s", p.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// A bundle with distinct (daemon_id, local_path) entries still works, even
|
||||
// with different labels on each row — the dedupe key is the target, not
|
||||
// the label.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Bundled distinct paths",
|
||||
"resources": []map[string]any{
|
||||
{
|
||||
"resource_type": "local_directory",
|
||||
"resource_ref": map[string]any{
|
||||
"local_path": "/Users/foo/work/a",
|
||||
"daemon_id": "d-bundle",
|
||||
"label": "A",
|
||||
},
|
||||
},
|
||||
{
|
||||
"resource_type": "local_directory",
|
||||
"resource_ref": map[string]any{
|
||||
"local_path": "/Users/foo/work/b",
|
||||
"daemon_id": "d-bundle",
|
||||
"label": "B",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
testHandler.CreateProject(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("distinct-path bundle: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
ID string `json:"id"`
|
||||
Resources []ProjectResourceResponse `json:"resources"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
r := newRequest("DELETE", "/api/projects/"+resp.ID, nil)
|
||||
r = withURLParam(r, "id", resp.ID)
|
||||
testHandler.DeleteProject(httptest.NewRecorder(), r)
|
||||
}()
|
||||
if len(resp.Resources) != 2 {
|
||||
t.Errorf("distinct-path bundle: expected 2 resources, got %d", len(resp.Resources))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2314,7 +2314,7 @@ const updateAgentCustomEnv = `-- name: UpdateAgentCustomEnv :one
|
||||
UPDATE agent
|
||||
SET custom_env = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, skills_local
|
||||
`
|
||||
|
||||
type UpdateAgentCustomEnvParams struct {
|
||||
@@ -2353,6 +2353,7 @@ func (q *Queries) UpdateAgentCustomEnv(ctx context.Context, arg UpdateAgentCusto
|
||||
&i.McpConfig,
|
||||
&i.Model,
|
||||
&i.ThinkingLevel,
|
||||
&i.SkillsLocal,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -226,3 +226,41 @@ func (q *Queries) ListProjectResourcesForProjects(ctx context.Context, projectId
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateProjectResource = `-- name: UpdateProjectResource :one
|
||||
UPDATE project_resource
|
||||
SET resource_ref = $2,
|
||||
label = $3,
|
||||
position = $4
|
||||
WHERE id = $1
|
||||
RETURNING id, project_id, workspace_id, resource_type, resource_ref, label, position, created_at, created_by
|
||||
`
|
||||
|
||||
type UpdateProjectResourceParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ResourceRef []byte `json:"resource_ref"`
|
||||
Label pgtype.Text `json:"label"`
|
||||
Position int32 `json:"position"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateProjectResource(ctx context.Context, arg UpdateProjectResourceParams) (ProjectResource, error) {
|
||||
row := q.db.QueryRow(ctx, updateProjectResource,
|
||||
arg.ID,
|
||||
arg.ResourceRef,
|
||||
arg.Label,
|
||||
arg.Position,
|
||||
)
|
||||
var i ProjectResource
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.WorkspaceID,
|
||||
&i.ResourceType,
|
||||
&i.ResourceRef,
|
||||
&i.Label,
|
||||
&i.Position,
|
||||
&i.CreatedAt,
|
||||
&i.CreatedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ INSERT INTO project_resource (
|
||||
$1, $2, $3, $4, $5, $6, $7
|
||||
) RETURNING *;
|
||||
|
||||
-- name: UpdateProjectResource :one
|
||||
UPDATE project_resource
|
||||
SET resource_ref = $2,
|
||||
label = $3,
|
||||
position = $4
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteProjectResource :exec
|
||||
DELETE FROM project_resource WHERE id = $1;
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ const (
|
||||
EventProjectUpdated = "project:updated"
|
||||
EventProjectDeleted = "project:deleted"
|
||||
EventProjectResourceCreated = "project_resource:created"
|
||||
EventProjectResourceUpdated = "project_resource:updated"
|
||||
EventProjectResourceDeleted = "project_resource:deleted"
|
||||
|
||||
// Label events
|
||||
|
||||
Reference in New Issue
Block a user