Compare commits

...

5 Commits

Author SHA1 Message Date
Jiang Bohan
acbcb52986 fix(desktop): hide rename for foreign daemon local_directory rows (MUL-2618)
Address review nit on #3273: the rename pencil was gated only by
`canEdit`, so a foreign / unknown-daemon row still showed it even
though the spec says cross-device rows are disabled. Gate rename on
`!mismatch` so it disappears on those rows; delete stays available
so a stale registration can still be dropped from any device.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 15:21:35 +08:00
Jiang Bohan
33ddd03cca feat(desktop): local_directory project_resource UI (MUL-2665)
First UI surface for the local-working-directory flow tracked in MUL-2618.
Lets users on the desktop pin a project to an existing folder on this
machine; web stays read-only since the per-daemon check can't be done in
the browser.

What's new for the renderer:

- ProjectResourcesSection grows a desktop-only "Add local directory"
  button next to the existing GitHub-repo popover. Clicking it opens
  Electron's native folder picker, validates the path through a new
  IPC pair (existence + r/w), and submits a project_resource of
  resource_type=local_directory with daemon_id pulled live from
  daemonAPI.getStatus.
- LocalDirectoryRow renders the rename pencil + path tooltip, and
  greys out when ref.daemon_id != this machine's daemon_id (with a
  "only available on the machine that registered this directory"
  tooltip). Delete stays enabled so users can drop stale registrations
  from any device.
- LocalDirectoryHint sits above the issue-detail comment composer and
  shows "Agent will work in-place at {label} ({path})" when the issue's
  project has a local_directory matching this daemon. Hidden on web.
- TaskStatusPill picks up a new "waiting_for_directory_release" stage
  that the daemon will publish when it dequeues a task but can't
  acquire the path lock. The render is in place now so the daemon
  sibling subtask can wire the status string without an additional UI
  PR.

Plumbing:

- @multica/core/types gains LocalDirectoryResourceRef +
  UpdateProjectResourceRequest, and the api client gets the matching
  PUT method backed by the server endpoint that landed in
  2ac3faebb (MUL-2662). A useUpdateProjectResource hook drives the
  in-place label edit.
- New Electron handlers under apps/desktop/src/main/local-directory.ts:
    local-directory:pick     -> dialog.showOpenDialog (openDirectory)
    local-directory:validate -> stat + access(R_OK + W_OK)
  exposed through the preload as desktopAPI.pickDirectory /
  validateLocalDirectory. View code talks to them via a thin
  packages/views/platform helper that returns reason=unsupported on
  web instead of crashing.
- useLocalDaemonStatus exposes the local daemon's id, device name, and
  running flag from daemonAPI.onStatusChange so the renderer can do the
  cross-device match without coupling to the desktop preload typings.

Tests:

- pickStageKeys gets a unit test covering the new stage and proving
  the directory-release status outranks availability hints.
- LocalDirectoryHint tests cover the four render branches (no project,
  no daemon, foreign daemon, matching daemon).
- i18n parity stays green; new keys added under projects.resources.*
  and chat.status_pill.stages.waiting_for_directory_release in both
  locales.

Out of scope (will land separately):
- The daemon-side waiting/lock signal that flips the pill into the
  new state.
- Adding local_directory to the create-project modal's bulk
  attach flow.
- Docs page refresh for project-resources.mdx — left for the
  MUL-2618 umbrella sweep.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 15:02:54 +08:00
Jiang Bohan
f388b2d352 fix(project): close bundled-create label-shadow gap + merge resource_ref on CLI update (MUL-2662)
Two follow-ups from MUL-2662 review round 2:

- CreateProject inline resources path now dedupes local_directory entries on
  (daemon_id, local_path) before opening the transaction. The DB-level
  UNIQUE(project_id, resource_type, resource_ref) constraint only fires on a
  full JSON match, so two rows with the same target but different `label`
  would otherwise slip past. Standalone POST/PUT already cover this via
  findLocalDirectoryConflict; bundled create was the missing surface.
- `multica project resource update` now seeds resource_ref from the existing
  row before applying per-type shortcut flags, so `--default-branch-hint x`
  on its own no longer constructs a payload missing `url` (which the server
  400s on). Local_directory partial edits get the same merge behavior.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 14:30:50 +08:00
Jiang Bohan
2ac3faebb9 feat(project): add update endpoint + label-shadow guard for project_resource (MUL-2662)
Addresses the Elon review on PR #3263:

- Add PUT /api/projects/{id}/resources/{resourceId} with sqlc query,
  matching handler, CLI `project resource update`, and a new
  EventProjectResourceUpdated WS event. resource_type stays immutable;
  ref/label/position are all individually optional.
- Catch same-project (daemon_id, local_path) collisions where only the
  embedded label differs — the row-level UNIQUE only matches the full
  ref JSON, so a label typo would otherwise let the same working
  directory bind twice.
- Tests cover the update lifecycle (label-only / ref / clear / 404 /
  invalid path) and the label-shadow conflict on both create and
  update; the in-place rename still succeeds because the conflict
  scan ignores the row being edited.

Incidental: regenerating sqlc picked up a missing skills_local scan in
UpdateAgentCustomEnv that drifted in from #3200.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 14:09:44 +08:00
Jiang Bohan
26d7ba8964 feat(project): add local_directory project_resource type (MUL-2662)
Adds a second project_resource type alongside github_repo so a project
can be pinned to an existing directory on a specific daemon (the v1 of
the local-working-directory flow tracked in MUL-2618). The ref schema is
{ local_path, daemon_id, label? }; local_path must be absolute and
daemon_id is required. The same (daemon_id, local_path) pair is allowed
on multiple projects by design — no UNIQUE constraint is added.

Implementation reuses the existing project_resource API surface: the new
type is wired through the validator switch with no migration, no new
events, and no daemon-handler changes (daemon already passes through
arbitrary resource types via ProjectResources). The CLI gains
--local-path / --daemon-id / --ref-label shortcuts so
`multica project resource add --type local_directory` mirrors the
existing `--type github_repo --url ...` ergonomics; the generic --ref
flag still works for both types.

Tests cover the full CRUD lifecycle, the same-path-across-projects
allowance, the same-path-same-project conflict, the validator rejections
(missing/blank/relative path, missing daemon_id, wrong payload type),
and the cross-platform isAbsoluteLocalPath helper.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 13:51:17 +08:00
33 changed files with 2313 additions and 31 deletions

View File

@@ -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) => {

View 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),
);
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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,

View File

@@ -6,5 +6,6 @@ export {
projectResourceKeys,
projectResourcesOptions,
useCreateProjectResource,
useUpdateProjectResource,
useDeleteProjectResource,
} from "./resource-queries";

View File

@@ -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({

View File

@@ -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";

View File

@@ -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;

View 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" });
});
});

View File

@@ -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" };

View File

@@ -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

View File

@@ -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"

View File

@@ -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",

View File

@@ -98,6 +98,7 @@
"offline": "离线",
"reconnecting": "重连中",
"queued": "排队中",
"waiting_for_directory_release": "等待本地目录释放",
"starting_up": "启动中",
"thinking": "思考中",
"typing": "输入中"

View File

@@ -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": "尚未识别到本机 daemonagent 暂时无法打开此目录。",
"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": "删除项目",

View File

@@ -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";

View 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);
}

View 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;
}

View File

@@ -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";

View 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();
});
});

View 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>
);
}

View File

@@ -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;
}
}

View File

@@ -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)
}

View 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"])
}
})
}

View File

@@ -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)
})
})

View File

@@ -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{

View File

@@ -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"))

View File

@@ -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))
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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;

View File

@@ -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