Compare commits

...

3 Commits

Author SHA1 Message Date
Jiang Bohan
4eb30dd783 feat(projects): project github_repo resources override workspace repos
When an issue's project has at least one github_repo resource, the daemon
claim handler now sends only those as resp.Repos — workspace-level repos
are hidden to avoid mixing two repo lists in the agent prompt. With no
project github_repos (or no project), behavior is unchanged: workspace
repos are surfaced as before.

Lifts each project github_repo's url (and label, when present) into a
RepoData entry so `multica repo checkout` and the meta-skill render the
same URLs. The full structured list still ships at
.multica/project/resources.json for skills that want everything.

Adds TestProjectReposReplaceWorkspaceReposInMetaSkill covering the
rendering side. Docs updated to spell out the new precedence.

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 14:19:01 +08:00
Jiang Bohan
f40f37ced1 fix(projects): transactional resources[] on create + generic CLI ref + test fix
Addresses review feedback on PR #1926:

1. CI red: TestProjectResourceLifecycle delete step called withURLParam
   twice, which replaced the chi route context and dropped the project id.
   Switched to the existing withURLParams helper from daemon_test.go.

2. POST /api/projects now accepts resources[] and attaches them in the
   same transaction as the project. Invalid refs roll back the whole
   create — no more half-attached projects on failure. Web modal + CLI
   `project create --repo` both use the new bundled payload.

3. CLI `project resource add` now accepts a generic --ref '<json>' flag
   so a new resource_type works without a CLI change. Per-type
   shortcuts (--url for github_repo) remain as a convenience but are no
   longer the only way in. Docs updated to drop the CLI from the
   "files you must touch" list.

Adds two new server handler tests:
- TestCreateProjectAttachesResources (resources[] happy path)
- TestCreateProjectRollsBackOnInvalidResource (transactional rollback)

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 13:34:35 +08:00
Jiang Bohan
bdf79e868b feat(projects): typed project resources + agent runtime injection
Adds a `project_resource` table that lets a project carry typed pointers
(github_repo today, more later) and surfaces them at agent runtime.

Server
- migration 065: project_resource (resource_type TEXT + resource_ref JSONB)
- sqlc CRUD + handler at /api/projects/{id}/resources
- claim handler attaches project_id/title + resources to issue tasks

Daemon
- TaskContextForEnv carries project context
- writes .multica/project/resources.json into workdir
- adds "## Project Context" block to CLAUDE.md / AGENTS.md / GEMINI.md
  via type-dispatched formatter so new resource types just add a case

CLI
- multica project create --repo <url> attaches repos in one step
- multica project resource add/list/remove

Frontend
- Project create modal: Repos pill (workspace repos + ad-hoc URL)
- Project detail sidebar: collapsible Resources section with attach/remove

Docs
- New "Project Resources" chapter explaining the abstraction and
  exactly what code to touch when adding a new resource type

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 13:21:24 +08:00
29 changed files with 2158 additions and 20 deletions

View File

@@ -10,6 +10,7 @@
"members-roles",
"issues",
"comments",
"project-resources",
"---Agents---",
"agents",
"agents-create",

View File

@@ -0,0 +1,142 @@
---
title: Project Resources
description: Attach typed pointers (Git repos today, more later) to a project so agents can pick them up as scoped context.
---
A **Project Resource** is a typed pointer — a Git repo URL today, a Notion page or document link tomorrow — attached to a [project](/workspaces). When an [agent](/agents) runs against an issue inside that project, the daemon automatically writes the project's resource list into the agent's working directory and into its [meta-skill](/skills) prompt.
The result: the agent knows which repo to check out, which docs are the "primary references" for this project, without anyone copy-pasting context into the issue body.
## Mental model
A project is no longer just a label. It is a small **resource container**:
- A project has 0..N **resources**.
- A resource has a `resource_type` (e.g. `github_repo`) and a `resource_ref` (a JSON payload typed by `resource_type`).
- New resource types add a string + a handler. **No schema migration. No frontend rewrite.**
This shape is intentional — it's the same pattern Multica already uses for agent providers: a `type` discriminator and a typed payload. It keeps the schema stable so adding "Notion page", "Google Doc", "uploaded file", or "external URL" later is a small, additive change.
## Today: `github_repo`
The first resource type ships ready to use:
```json
{
"resource_type": "github_repo",
"resource_ref": {
"url": "https://github.com/owner/repo",
"default_branch_hint": "main"
}
}
```
`default_branch_hint` is optional — if present, the daemon surfaces it in the meta-skill so the agent knows which branch to base its work on.
## Attaching repos at project creation
In the **Web** or **Desktop** app, opening *New project* now shows a **Repos** pill alongside Status / Priority / Lead. Selecting workspace-bound repos (or pasting an ad-hoc URL) attaches them as `github_repo` resources the moment the project is created.
From the **CLI**:
```bash
# Create + attach in one shot. The server attaches resources in the same
# transaction as the project create — invalid resources roll back the whole
# operation, so you never end up with a project that has half its resources.
multica project create \
--title "Agent UX 2026" \
--repo https://github.com/multica-ai/multica
# Manage resources later
multica project resource list <project-id>
multica project resource add <project-id> --type github_repo --url <url>
multica project resource remove <project-id> <resource-id>
# Generic escape hatch for any resource_type the server understands —
# no CLI change needed when a new type ships:
multica project resource add <project-id> \
--type notion_page \
--ref '{"page_id":"…","title":"…"}'
```
`--repo` may be repeated; each value is attached as a separate `github_repo` resource.
## What the agent sees at runtime
When the daemon spawns an agent for an issue inside a project, two things happen:
### 1. `.multica/project/resources.json`
A structured pass-through of the API response, written into the agent's working directory:
```json
{
"project_id": "…",
"project_title": "Agent UX 2026",
"resources": [
{
"id": "…",
"resource_type": "github_repo",
"resource_ref": {
"url": "https://github.com/multica-ai/multica",
"default_branch_hint": "main"
}
}
]
}
```
Skills, helper scripts, or the agent itself can parse this file when they need the *exact* set of resources for the run.
### 2. A "Project Context" section in the meta-skill prompt
The agent's `CLAUDE.md` / `AGENTS.md` (depending on provider) now includes a human-readable summary:
```
## Project Context
This issue belongs to **Agent UX 2026**.
Project resources (also written to `.multica/project/resources.json`):
- **GitHub repo**: https://github.com/multica-ai/multica (default branch: `main`)
Resources are pointers — open them only when relevant to the task. For
`github_repo` resources, use `multica repo checkout <url>` to fetch the code.
```
The text is intentionally minimal. The full payload is on disk; the prompt only orients the agent so it knows the project exists and what's attached.
### Failure mode
Resource fetch is **best-effort**. If the API call fails, the project section is omitted from the prompt and the file is not written, but the task still starts. Agents never block on missing project context.
## Adding a new resource type
The whole point of the abstraction is that new types are cheap. The full path:
1. **Server validator** (`server/internal/handler/project_resource.go`) — add a case in `validateAndNormalizeResourceRef` that parses and normalizes the new payload.
2. **Daemon meta-skill formatter** (`server/internal/daemon/execenv/runtime_config.go`) — add a case in `formatProjectResource` so the agent prompt renders the new type as a readable bullet.
3. **TypeScript types** (`packages/core/types/project.ts`) — extend `ProjectResourceType` and add the payload interface.
4. **UI renderer** (`packages/views/projects/components/project-resources-section.tsx`) — add a case in `ResourceRow` for the new type.
There is **no schema migration**, no new sqlc query, no new endpoint, **and no CLI change** — the CLI's generic `--ref '<json>'` flag accepts any payload the validator understands, so day-one support for a new type is purely the four steps above. (You may *optionally* add a per-type CLI shortcut later; not required.)
The same `project_resource` table and the same three CRUD calls handle every type.
## Workspace repos vs. project repos
The repo list shown to the agent (`## Repositories` block in `CLAUDE.md` / `AGENTS.md`) is chosen by the daemon claim handler with this precedence:
- **Project has at least one `github_repo` resource** → only those repos are surfaced to the agent. Workspace-bound repos are intentionally hidden so the agent doesn't have to guess which one belongs to this issue.
- **Project has no `github_repo` resources (or the issue isn't in a project)** → fall back to the workspace's repo list as before.
This keeps the agent's working set tight: when a project is explicit about its repos, that's the authoritative answer. The structured resource list at `.multica/project/resources.json` always carries the full set, so a skill that wants to inspect everything still can.
## What's intentionally **not** in scope here
- **Cross-project sharing.** Each resource lives on exactly one project today.
- **Per-skill resource scoping.** All resources are visible to every skill on the agent's run; type-aware filtering is a follow-up.
- **Caching / sync.** `github_repo` is just metadata — checkout still happens via `multica repo checkout` on demand. Cached document text for Notion / Google Docs will arrive with those types.
These are deliberate omissions — the goal of the first cut is to validate the abstraction with the smallest set of moving parts.

View File

@@ -55,6 +55,9 @@ import type {
CreateProjectRequest,
UpdateProjectRequest,
ListProjectsResponse,
ProjectResource,
CreateProjectResourceRequest,
ListProjectResourcesResponse,
Label,
CreateLabelRequest,
UpdateLabelRequest,
@@ -1074,6 +1077,32 @@ export class ApiClient {
await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
}
// Project resources
async listProjectResources(
projectId: string,
): Promise<ListProjectResourcesResponse> {
return this.fetch(`/api/projects/${projectId}/resources`);
}
async createProjectResource(
projectId: string,
data: CreateProjectResourceRequest,
): Promise<ProjectResource> {
return this.fetch(`/api/projects/${projectId}/resources`, {
method: "POST",
body: JSON.stringify(data),
});
}
async deleteProjectResource(
projectId: string,
resourceId: string,
): Promise<void> {
await this.fetch(`/api/projects/${projectId}/resources/${resourceId}`, {
method: "DELETE",
});
}
// Labels
async listLabels(): Promise<ListLabelsResponse> {
return this.fetch(`/api/labels`);

View File

@@ -1,3 +1,9 @@
export { projectKeys, projectListOptions, projectDetailOptions } from "./queries";
export { useCreateProject, useUpdateProject, useDeleteProject } from "./mutations";
export { useProjectDraftStore } from "./draft-store";
export {
projectResourceKeys,
projectResourcesOptions,
useCreateProjectResource,
useDeleteProjectResource,
} from "./resource-queries";

View File

@@ -0,0 +1,87 @@
import { queryOptions, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { projectKeys } from "./queries";
import type {
CreateProjectResourceRequest,
ListProjectResourcesResponse,
ProjectResource,
} from "../types";
export const projectResourceKeys = {
list: (wsId: string, projectId: string) =>
[...projectKeys.detail(wsId, projectId), "resources"] as const,
};
export function projectResourcesOptions(wsId: string, projectId: string) {
return queryOptions({
queryKey: projectResourceKeys.list(wsId, projectId),
queryFn: () => api.listProjectResources(projectId),
select: (data) => data.resources,
});
}
export function useCreateProjectResource(wsId: string, projectId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateProjectResourceRequest) =>
api.createProjectResource(projectId, data),
onSuccess: (created) => {
qc.setQueryData<ListProjectResourcesResponse>(
projectResourceKeys.list(wsId, projectId),
(old) =>
old && !old.resources.some((r) => r.id === created.id)
? {
...old,
resources: [...old.resources, created],
total: old.total + 1,
}
: old,
);
},
onSettled: () => {
qc.invalidateQueries({
queryKey: projectResourceKeys.list(wsId, projectId),
});
},
});
}
export function useDeleteProjectResource(wsId: string, projectId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (resourceId: string) =>
api.deleteProjectResource(projectId, resourceId),
onMutate: async (resourceId) => {
await qc.cancelQueries({
queryKey: projectResourceKeys.list(wsId, projectId),
});
const prev = qc.getQueryData<ListProjectResourcesResponse>(
projectResourceKeys.list(wsId, projectId),
);
qc.setQueryData<ListProjectResourcesResponse>(
projectResourceKeys.list(wsId, projectId),
(old) =>
old
? {
...old,
resources: old.resources.filter(
(r: ProjectResource) => r.id !== resourceId,
),
total: old.total - 1,
}
: old,
);
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) {
qc.setQueryData(projectResourceKeys.list(wsId, projectId), ctx.prev);
}
},
onSettled: () => {
qc.invalidateQueries({
queryKey: projectResourceKeys.list(wsId, projectId),
});
},
});
}

View File

@@ -48,7 +48,19 @@ export type * from "./api";
export type { Attachment } from "./attachment";
export type { ChatSession, ChatMessage, ChatPendingTask, PendingChatTaskItem, PendingChatTasksResponse, SendChatMessageResponse } from "./chat";
export type { StorageAdapter } from "./storage";
export type { Project, ProjectStatus, ProjectPriority, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project";
export type {
Project,
ProjectStatus,
ProjectPriority,
CreateProjectRequest,
UpdateProjectRequest,
ListProjectsResponse,
ProjectResource,
ProjectResourceType,
GithubRepoResourceRef,
CreateProjectResourceRequest,
ListProjectResourcesResponse,
} from "./project";
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
export type {
Autopilot,

View File

@@ -26,6 +26,9 @@ export interface CreateProjectRequest {
priority?: ProjectPriority;
lead_type?: "member" | "agent";
lead_id?: string;
// Resources to attach in the same transaction as the project. Server returns
// 4xx (and rolls back) if any one is invalid or duplicate.
resources?: CreateProjectResourceRequest[];
}
export interface UpdateProjectRequest {
@@ -42,3 +45,39 @@ export interface ListProjectsResponse {
projects: Project[];
total: number;
}
// 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";
export interface GithubRepoResourceRef {
url: string;
default_branch_hint?: string;
}
export interface ProjectResource {
id: string;
project_id: string;
workspace_id: string;
resource_type: ProjectResourceType;
resource_ref: GithubRepoResourceRef | Record<string, unknown>;
label: string | null;
position: number;
created_at: string;
created_by: string | null;
}
export interface CreateProjectResourceRequest {
resource_type: ProjectResourceType;
resource_ref: GithubRepoResourceRef | Record<string, unknown>;
label?: string;
position?: number;
}
export interface ListProjectResourcesResponse {
resources: ProjectResource[];
total: number;
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useRef } from "react";
import { ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus } from "lucide-react";
import { ChevronRight, FolderGit, Maximize2, Minimize2, X as XIcon, UserMinus } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useCreateProject } from "@multica/core/projects/mutations";
import { useProjectDraftStore } from "@multica/core/projects";
@@ -78,6 +78,13 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
const [iconPickerOpen, setIconPickerOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
// Repos selected to attach as github_repo resources after the project is
// created. Stored as URLs (not full ProjectResource rows) — they're not
// persisted until handleSubmit fires the createProjectResource calls.
const [selectedRepos, setSelectedRepos] = useState<string[]>([]);
const [repoPopoverOpen, setRepoPopoverOpen] = useState(false);
const [customRepoUrl, setCustomRepoUrl] = useState("");
const workspaceRepos = workspace?.repos ?? [];
// Sync field changes to draft store
const updateTitle = (v: string) => { setTitle(v); setDraft({ title: v }); };
@@ -114,6 +121,14 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
priority,
lead_type: leadType,
lead_id: leadId,
// Server attaches these in the same transaction as the project.
resources:
selectedRepos.length > 0
? selectedRepos.map((url) => ({
resource_type: "github_repo" as const,
resource_ref: { url },
}))
: undefined,
});
clearDraft();
onClose();
@@ -126,6 +141,19 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
}
};
const toggleRepo = (url: string) => {
setSelectedRepos((prev) =>
prev.includes(url) ? prev.filter((u) => u !== url) : [...prev, url],
);
};
const addCustomRepo = () => {
const url = customRepoUrl.trim();
if (!url) return;
setSelectedRepos((prev) => (prev.includes(url) ? prev : [...prev, url]));
setCustomRepoUrl("");
};
return (
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent
@@ -353,6 +381,105 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
</div>
</PopoverContent>
</Popover>
<Popover open={repoPopoverOpen} onOpenChange={setRepoPopoverOpen}>
<PopoverTrigger
render={
<PillButton>
<FolderGit className="size-3" />
<span>
{selectedRepos.length === 0
? "Repos"
: `${selectedRepos.length} repo${selectedRepos.length === 1 ? "" : "s"}`}
</span>
</PillButton>
}
/>
<PopoverContent align="start" className="w-72 p-2 space-y-2">
<div className="text-xs font-medium text-muted-foreground">
Attach GitHub repos to this project
</div>
{workspaceRepos.length > 0 ? (
<div className="space-y-1">
{workspaceRepos.map((repo) => {
const checked = selectedRepos.includes(repo.url);
return (
<button
type="button"
key={repo.url}
onClick={() => toggleRepo(repo.url)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-accent transition-colors",
checked && "bg-accent",
)}
>
<input
type="checkbox"
checked={checked}
readOnly
className="size-3.5"
/>
<FolderGit className="size-3.5" />
<span className="truncate flex-1 text-left">{repo.url}</span>
</button>
);
})}
</div>
) : (
<p className="text-xs text-muted-foreground">
No workspace-level repos yet. Paste a URL below to attach one
ad-hoc.
</p>
)}
<form
onSubmit={(e) => {
e.preventDefault();
addCustomRepo();
}}
className="flex items-center gap-1.5 pt-1 border-t"
>
<input
type="url"
value={customRepoUrl}
onChange={(e) => setCustomRepoUrl(e.target.value)}
placeholder="https://github.com/owner/repo"
className="flex-1 bg-transparent text-xs px-2 py-1 outline-none placeholder:text-muted-foreground"
/>
<Button
type="submit"
size="sm"
variant="ghost"
className="h-6 px-2 text-xs"
disabled={!customRepoUrl.trim()}
>
Add
</Button>
</form>
{selectedRepos.length > 0 && (
<div className="space-y-1 pt-1 border-t">
<div className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
Selected
</div>
{selectedRepos.map((url) => (
<div
key={url}
className="flex items-center gap-2 text-xs"
>
<FolderGit className="size-3 text-muted-foreground" />
<span className="truncate flex-1">{url}</span>
<button
type="button"
onClick={() => toggleRepo(url)}
className="text-muted-foreground hover:text-foreground"
>
<XIcon className="size-3" />
</button>
</div>
))}
</div>
)}
</PopoverContent>
</Popover>
</div>
<div className="flex items-center justify-end px-4 py-3 border-t shrink-0">

View File

@@ -28,6 +28,7 @@ import { ActorAvatar } from "../../common/actor-avatar";
import { AppLink, useNavigation } from "../../navigation";
import { TitleEditor, ContentEditor, type ContentEditorRef } from "../../editor";
import { PriorityIcon } from "../../issues/components/priority-icon";
import { ProjectResourcesSection } from "./project-resources-section";
import { IssuesHeader } from "../../issues/components/issues-header";
import { BoardView } from "../../issues/components/board-view";
import { ListView } from "../../issues/components/list-view";
@@ -494,6 +495,9 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
/>
</div>}
</div>
{/* Resources */}
<ProjectResourcesSection projectId={projectId} />
</div>
);

View File

@@ -0,0 +1,240 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, FolderGit, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
projectResourcesOptions,
useCreateProjectResource,
useDeleteProjectResource,
} from "@multica/core/projects";
import { useWorkspaceId } from "@multica/core/hooks";
import { useCurrentWorkspace } from "@multica/core/paths";
import type {
GithubRepoResourceRef,
ProjectResource,
} from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@multica/ui/components/ui/popover";
// 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.
export function ProjectResourcesSection({ projectId }: { projectId: string }) {
const wsId = useWorkspaceId();
const workspace = useCurrentWorkspace();
const [open, setOpen] = useState(true);
const [addOpen, setAddOpen] = useState(false);
const { data: resources = [] } = useQuery(
projectResourcesOptions(wsId, projectId),
);
const createResource = useCreateProjectResource(wsId, projectId);
const deleteResource = useDeleteProjectResource(wsId, projectId);
const attachedUrls = new Set(
resources
.filter((r) => r.resource_type === "github_repo")
.map((r) => (r.resource_ref as GithubRepoResourceRef).url),
);
const handleAttach = async (url: string) => {
try {
await createResource.mutateAsync({
resource_type: "github_repo",
resource_ref: { url },
});
toast.success("Repository attached");
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to attach";
toast.error(msg);
}
};
const handleRemove = async (resource: ProjectResource) => {
try {
await deleteResource.mutateAsync(resource.id);
toast.success("Resource removed");
} catch {
toast.error("Failed to remove resource");
}
};
return (
<div>
<button
className={`flex w-full items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors mb-2 hover:bg-accent/70 ${open ? "" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => setOpen(!open)}
>
Resources
<ChevronRight
className={`!size-3 shrink-0 stroke-[2.5] text-muted-foreground transition-transform ${open ? "rotate-90" : ""}`}
/>
</button>
{open && (
<div className="pl-2 space-y-1.5">
{resources.length === 0 && (
<p className="text-xs text-muted-foreground">
No resources attached.
</p>
)}
{resources.map((resource) => (
<ResourceRow
key={resource.id}
resource={resource}
onRemove={() => handleRemove(resource)}
/>
))}
<Popover open={addOpen} onOpenChange={setAddOpen}>
<PopoverTrigger
render={
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<Plus className="size-3" />
Add resource
</Button>
}
/>
<PopoverContent align="start" className="w-72 p-2 space-y-2">
<div className="text-xs font-medium text-muted-foreground">
Attach a GitHub repo
</div>
{workspace?.repos && workspace.repos.length > 0 && (
<div className="space-y-1">
{workspace.repos.map((repo) => {
const isAttached = attachedUrls.has(repo.url);
return (
<button
key={repo.url}
type="button"
disabled={isAttached || createResource.isPending}
onClick={async () => {
await handleAttach(repo.url);
setAddOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs text-left hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<FolderGit className="size-3.5" />
<span className="truncate flex-1">{repo.url}</span>
{isAttached && (
<span className="text-[10px] text-muted-foreground">
attached
</span>
)}
</button>
);
})}
</div>
)}
<CustomRepoForm
onSubmit={async (url) => {
await handleAttach(url);
setAddOpen(false);
}}
/>
</PopoverContent>
</Popover>
</div>
)}
</div>
);
}
function ResourceRow({
resource,
onRemove,
}: {
resource: ProjectResource;
onRemove: () => void;
}) {
if (resource.resource_type === "github_repo") {
const ref = resource.resource_ref as GithubRepoResourceRef;
return (
<div className="flex items-center gap-2 text-xs group">
<FolderGit className="size-3.5 text-muted-foreground shrink-0" />
<a
href={ref.url}
target="_blank"
rel="noopener noreferrer"
className="truncate flex-1 hover:underline"
>
{resource.label || ref.url}
</a>
<button
type="button"
onClick={onRemove}
className="opacity-0 group-hover:opacity-100 transition-opacity rounded-sm p-0.5 hover:bg-accent"
title="Remove"
>
<Trash2 className="size-3 text-muted-foreground" />
</button>
</div>
);
}
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="truncate flex-1">
{resource.label || resource.resource_type}
</span>
<button
type="button"
onClick={onRemove}
className="rounded-sm p-0.5 hover:bg-accent"
title="Remove"
>
<Trash2 className="size-3" />
</button>
</div>
);
}
function CustomRepoForm({
onSubmit,
}: {
onSubmit: (url: string) => Promise<void> | void;
}) {
const [url, setUrl] = useState("");
const [submitting, setSubmitting] = useState(false);
const handle = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = url.trim();
if (!trimmed) return;
setSubmitting(true);
try {
await onSubmit(trimmed);
setUrl("");
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handle} className="flex items-center gap-1.5 pt-1 border-t">
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://github.com/owner/repo"
className="flex-1 bg-transparent text-xs px-2 py-1 outline-none placeholder:text-muted-foreground"
/>
<Button
type="submit"
size="sm"
variant="ghost"
className="h-6 px-2 text-xs"
disabled={!url.trim() || submitting}
>
Add
</Button>
</form>
);
}

View File

@@ -2,6 +2,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"net/url"
"os"
@@ -58,6 +59,32 @@ var projectStatusCmd = &cobra.Command{
RunE: runProjectStatus,
}
var projectResourceCmd = &cobra.Command{
Use: "resource",
Short: "Manage resources attached to a project",
}
var projectResourceListCmd = &cobra.Command{
Use: "list <project-id>",
Short: "List resources attached to a project",
Args: exactArgs(1),
RunE: runProjectResourceList,
}
var projectResourceAddCmd = &cobra.Command{
Use: "add <project-id>",
Short: "Attach a resource to a project (e.g. --type github_repo --url <url>)",
Args: exactArgs(1),
RunE: runProjectResourceAdd,
}
var projectResourceRemoveCmd = &cobra.Command{
Use: "remove <project-id> <resource-id>",
Short: "Detach a resource from a project",
Args: exactArgs(2),
RunE: runProjectResourceRemove,
}
var validProjectStatuses = []string{
"planned", "in_progress", "paused", "completed", "cancelled",
}
@@ -69,6 +96,11 @@ func init() {
projectCmd.AddCommand(projectUpdateCmd)
projectCmd.AddCommand(projectDeleteCmd)
projectCmd.AddCommand(projectStatusCmd)
projectCmd.AddCommand(projectResourceCmd)
projectResourceCmd.AddCommand(projectResourceListCmd)
projectResourceCmd.AddCommand(projectResourceAddCmd)
projectResourceCmd.AddCommand(projectResourceRemoveCmd)
// project list
projectListCmd.Flags().String("output", "table", "Output format: table or json")
@@ -83,8 +115,25 @@ func init() {
projectCreateCmd.Flags().String("status", "", "Project status")
projectCreateCmd.Flags().String("icon", "", "Project icon (emoji)")
projectCreateCmd.Flags().String("lead", "", "Lead name (member or agent)")
projectCreateCmd.Flags().StringArray("repo", nil, "Attach a github_repo resource by URL (may be repeated)")
projectCreateCmd.Flags().String("output", "json", "Output format: table or json")
// project resource list
projectResourceListCmd.Flags().String("output", "table", "Output format: table or json")
// 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("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("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 remove
projectResourceRemoveCmd.Flags().String("output", "table", "Output format: table or json")
// project update
projectUpdateCmd.Flags().String("title", "", "New title")
projectUpdateCmd.Flags().String("description", "", "New description")
@@ -227,6 +276,27 @@ func runProjectCreate(cmd *cobra.Command, _ []string) error {
body["lead_id"] = aID
}
// Bundle resources into the create payload so the server attaches them in
// the same transaction; this avoids leaving a half-attached project on
// failure.
repos, _ := cmd.Flags().GetStringArray("repo")
if len(repos) > 0 {
resources := make([]map[string]any, 0, len(repos))
for _, repoURL := range repos {
repoURL = strings.TrimSpace(repoURL)
if repoURL == "" {
continue
}
resources = append(resources, map[string]any{
"resource_type": "github_repo",
"resource_ref": map[string]any{"url": repoURL},
})
}
if len(resources) > 0 {
body["resources"] = resources
}
}
var result map[string]any
if err := client.PostJSON(ctx, "/api/projects", body, &result); err != nil {
return fmt.Errorf("create project: %w", err)
@@ -362,6 +432,148 @@ func runProjectStatus(cmd *cobra.Command, args []string) error {
return nil
}
// ---------------------------------------------------------------------------
// Project resource commands
// ---------------------------------------------------------------------------
func runProjectResourceList(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()
var result map[string]any
if err := client.GetJSON(ctx, "/api/projects/"+args[0]+"/resources", &result); err != nil {
return fmt.Errorf("list project resources: %w", err)
}
resourcesRaw, _ := result["resources"].([]any)
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, resourcesRaw)
}
headers := []string{"ID", "TYPE", "REF", "LABEL"}
rows := make([][]string, 0, len(resourcesRaw))
for _, raw := range resourcesRaw {
r, ok := raw.(map[string]any)
if !ok {
continue
}
rows = append(rows, []string{
truncateID(strVal(r, "id")),
strVal(r, "resource_type"),
summarizeResourceRef(r["resource_ref"]),
strVal(r, "label"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runProjectResourceAdd(cmd *cobra.Command, args []string) error {
resourceType, _ := cmd.Flags().GetString("type")
resourceType = strings.TrimSpace(resourceType)
if resourceType == "" {
return fmt.Errorf("--type is required")
}
body := map[string]any{"resource_type": resourceType}
// --ref takes precedence: any new resource type works through this path
// without a CLI change. Per-type shortcuts (--url etc.) only apply when
// --ref is empty.
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 {
switch resourceType {
case "github_repo":
urlVal, _ := cmd.Flags().GetString("url")
urlVal = strings.TrimSpace(urlVal)
if urlVal == "" {
return fmt.Errorf("github_repo requires --url (or pass a JSON payload via --ref)")
}
ref := map[string]any{"url": urlVal}
if hint, _ := cmd.Flags().GetString("default-branch-hint"); hint != "" {
ref["default_branch_hint"] = strings.TrimSpace(hint)
}
body["resource_ref"] = ref
default:
return fmt.Errorf("type %q has no built-in CLI shortcut; pass the payload via --ref '<json>'", resourceType)
}
}
if label, _ := cmd.Flags().GetString("label"); label != "" {
body["label"] = label
}
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PostJSON(ctx, "/api/projects/"+args[0]+"/resources", body, &result); err != nil {
return fmt.Errorf("add project resource: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "table" {
headers := []string{"ID", "TYPE", "REF"}
rows := [][]string{{
truncateID(strVal(result, "id")),
strVal(result, "resource_type"),
summarizeResourceRef(result["resource_ref"]),
}}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
return cli.PrintJSON(os.Stdout, result)
}
func runProjectResourceRemove(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()
if err := client.DeleteJSON(ctx, "/api/projects/"+args[0]+"/resources/"+args[1]); err != nil {
return fmt.Errorf("remove project resource: %w", err)
}
fmt.Fprintf(os.Stderr, "Resource %s removed from project %s.\n", truncateID(args[1]), truncateID(args[0]))
return nil
}
// summarizeResourceRef extracts the most useful single string from a
// resource_ref object — for github_repo this is the URL.
func summarizeResourceRef(raw any) string {
m, ok := raw.(map[string]any)
if !ok {
return ""
}
if u, ok := m["url"].(string); ok && u != "" {
return u
}
if data, err := json.Marshal(m); err == nil {
return string(data)
}
return ""
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

View File

@@ -349,6 +349,9 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
r.Get("/", h.GetProject)
r.Put("/", h.UpdateProject)
r.Delete("/", h.DeleteProject)
r.Get("/resources", h.ListProjectResources)
r.Post("/resources", h.CreateProjectResource)
r.Delete("/resources/{resourceId}", h.DeleteProjectResource)
})
})

View File

@@ -1168,6 +1168,9 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
AgentInstructions: instructions,
AgentSkills: convertSkillsForEnv(skills),
Repos: convertReposForEnv(task.Repos),
ProjectID: task.ProjectID,
ProjectTitle: task.ProjectTitle,
ProjectResources: convertProjectResourcesForEnv(task.ProjectResources),
ChatSessionID: task.ChatSessionID,
AutopilotRunID: task.AutopilotRunID,
AutopilotID: task.AutopilotID,
@@ -1688,6 +1691,22 @@ func convertReposForEnv(repos []RepoData) []execenv.RepoContextForEnv {
return result
}
func convertProjectResourcesForEnv(resources []ProjectResourceData) []execenv.ProjectResourceForEnv {
if len(resources) == 0 {
return nil
}
result := make([]execenv.ProjectResourceForEnv, len(resources))
for i, r := range resources {
result[i] = execenv.ProjectResourceForEnv{
ID: r.ID,
ResourceType: r.ResourceType,
ResourceRef: r.ResourceRef,
Label: r.Label,
}
}
return result
}
// shortID returns the first 8 characters of an ID for readable logs.
func shortID(id string) string {
if len(id) <= 8 {

View File

@@ -1,6 +1,7 @@
package execenv
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
@@ -45,9 +46,76 @@ func writeContextFiles(workDir, provider string, ctx TaskContextForEnv) error {
}
}
// Project resources are best-effort: a write failure logs but does not
// block task startup. Missing resources surface as the agent simply not
// seeing the file, which matches the "scoped, not dumped" design (the
// meta skill content always lists what the agent should expect).
if err := writeProjectResources(workDir, ctx); err != nil {
// Caller logs warnings; avoid noisy returns for non-fatal context.
return fmt.Errorf("write project resources: %w", err)
}
return nil
}
// projectResourceFile is the on-disk JSON written into the agent's working
// directory. Schema is intentionally a thin pass-through of the API response
// so consumers (skills, future tooling) don't need a separate parser.
type projectResourceFile struct {
ProjectID string `json:"project_id,omitempty"`
ProjectTitle string `json:"project_title,omitempty"`
Resources []ProjectResourceForEnv `json:"resources"`
}
// MarshalJSON renders the resource_ref field as raw JSON instead of a base64
// blob. The struct's other fields are simple strings.
func (p ProjectResourceForEnv) MarshalJSON() ([]byte, error) {
type alias struct {
ID string `json:"id"`
ResourceType string `json:"resource_type"`
ResourceRef json.RawMessage `json:"resource_ref"`
Label string `json:"label,omitempty"`
}
ref := p.ResourceRef
if len(ref) == 0 {
ref = json.RawMessage("{}")
}
return json.Marshal(alias{
ID: p.ID,
ResourceType: p.ResourceType,
ResourceRef: ref,
Label: p.Label,
})
}
// writeProjectResources writes .multica/project/resources.json into the
// working directory when the task carries project context. The file is
// always written when a project is attached (even with zero resources) so
// agents can rely on its presence as a signal that a project exists.
func writeProjectResources(workDir string, ctx TaskContextForEnv) error {
if ctx.ProjectID == "" && len(ctx.ProjectResources) == 0 {
return nil
}
dir := filepath.Join(workDir, ".multica", "project")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
resources := ctx.ProjectResources
if resources == nil {
resources = []ProjectResourceForEnv{}
}
payload := projectResourceFile{
ProjectID: ctx.ProjectID,
ProjectTitle: ctx.ProjectTitle,
Resources: resources,
}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, "resources.json"), data, 0o644)
}
// resolveSkillsDir returns the directory where skills should be written
// based on the agent provider.
func resolveSkillsDir(workDir, provider string) (string, error) {

View File

@@ -18,6 +18,18 @@ type RepoContextForEnv struct {
Description string // human-readable description
}
// ProjectResourceForEnv describes a single resource attached to the issue's
// project. The resource_ref payload is type-specific JSON; the agent reads
// resources.json on disk for the full structure. This struct only carries
// fields the meta-skill template needs to render a human-readable summary
// (URL for github_repo, generic label otherwise).
type ProjectResourceForEnv struct {
ID string // server-assigned UUID
ResourceType string // e.g. "github_repo"
ResourceRef json.RawMessage // raw JSONB payload from the API
Label string // optional user-supplied label
}
// PrepareParams holds all inputs needed to set up an execution environment.
type PrepareParams struct {
WorkspacesRoot string // base path for all envs (e.g., ~/multica_workspaces)
@@ -37,8 +49,11 @@ type TaskContextForEnv struct {
AgentName string
AgentInstructions string // agent identity/persona instructions, injected into CLAUDE.md
AgentSkills []SkillContextForEnv
Repos []RepoContextForEnv // workspace repos available for checkout
ChatSessionID string // non-empty for chat tasks
Repos []RepoContextForEnv // workspace repos available for checkout
ProjectID string // issue's project, when present
ProjectTitle string // human-readable project title
ProjectResources []ProjectResourceForEnv // resources attached to the project
ChatSessionID string // non-empty for chat tasks
AutopilotRunID string // non-empty for autopilot run_only tasks
AutopilotID string
AutopilotTitle string

View File

@@ -1,6 +1,7 @@
package execenv
import (
"encoding/json"
"log/slog"
"os"
"path/filepath"
@@ -120,6 +121,139 @@ func TestPrepareDirectoryMode(t *testing.T) {
}
}
func TestPrepareWithProjectResources(t *testing.T) {
t.Parallel()
workspacesRoot := t.TempDir()
taskCtx := TaskContextForEnv{
IssueID: "11111111-2222-3333-4444-555555555555",
ProjectID: "22222222-3333-4444-5555-666666666666",
ProjectTitle: "Agent UX 2026",
ProjectResources: []ProjectResourceForEnv{
{
ID: "33333333-4444-5555-6666-777777777777",
ResourceType: "github_repo",
ResourceRef: json.RawMessage(`{"url":"https://github.com/multica-ai/multica","default_branch_hint":"main"}`),
},
},
}
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-test-pr",
TaskID: "11111111-2222-3333-4444-555555555555",
AgentName: "Test Agent",
Provider: "claude",
Task: taskCtx,
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
// resources.json should exist and decode back to what we wrote.
resourcesPath := filepath.Join(env.WorkDir, ".multica", "project", "resources.json")
raw, err := os.ReadFile(resourcesPath)
if err != nil {
t.Fatalf("failed to read resources.json: %v", err)
}
var got struct {
ProjectID string `json:"project_id"`
ProjectTitle string `json:"project_title"`
Resources []struct {
ID string `json:"id"`
ResourceType string `json:"resource_type"`
ResourceRef json.RawMessage `json:"resource_ref"`
} `json:"resources"`
}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("resources.json unmarshal: %v\n%s", err, string(raw))
}
if got.ProjectID != taskCtx.ProjectID {
t.Errorf("resources.json project_id = %q, want %q", got.ProjectID, taskCtx.ProjectID)
}
if got.ProjectTitle != taskCtx.ProjectTitle {
t.Errorf("resources.json project_title = %q, want %q", got.ProjectTitle, taskCtx.ProjectTitle)
}
if len(got.Resources) != 1 || got.Resources[0].ResourceType != "github_repo" {
t.Fatalf("resources.json resources mismatch: %+v", got.Resources)
}
// CLAUDE.md should mention the project context block.
if err := InjectRuntimeConfig(env.WorkDir, "claude", taskCtx); err != nil {
t.Fatalf("InjectRuntimeConfig: %v", err)
}
content, err := os.ReadFile(filepath.Join(env.WorkDir, "CLAUDE.md"))
if err != nil {
t.Fatalf("read CLAUDE.md: %v", err)
}
s := string(content)
for _, want := range []string{
"## Project Context",
"Agent UX 2026",
"GitHub repo",
"https://github.com/multica-ai/multica",
"default branch: `main`",
".multica/project/resources.json",
} {
if !strings.Contains(s, want) {
t.Errorf("CLAUDE.md missing %q", want)
}
}
}
// When the issue's project has its own github_repo resources, those should be
// the only repos rendered in the meta-skill — workspace-level repos must not
// leak into the agent prompt to avoid confusing it about which repo to use.
//
// The handler-side override is exercised in handler tests; this test confirms
// the rendering side: given a TaskContextForEnv where Repos was already
// narrowed by the server to project repos only, the meta skill renders just
// those.
func TestProjectReposReplaceWorkspaceReposInMetaSkill(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "11111111-2222-3333-4444-555555555555",
ProjectID: "22222222-3333-4444-5555-666666666666",
ProjectTitle: "Project A",
Repos: []RepoContextForEnv{
{URL: "https://github.com/org/project-repo", Description: ""},
},
ProjectResources: []ProjectResourceForEnv{
{
ID: "33333333-4444-5555-6666-777777777777",
ResourceType: "github_repo",
ResourceRef: []byte(`{"url":"https://github.com/org/project-repo"}`),
},
},
}
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
t.Fatalf("InjectRuntimeConfig: %v", err)
}
content, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
if err != nil {
t.Fatalf("read CLAUDE.md: %v", err)
}
s := string(content)
if !strings.Contains(s, "https://github.com/org/project-repo") {
t.Errorf("CLAUDE.md missing project repo URL")
}
if strings.Contains(s, "https://github.com/org/workspace-repo") {
t.Errorf("CLAUDE.md should not contain workspace repo when project has its own")
}
}
func TestWriteProjectResourcesSkippedWhenNone(t *testing.T) {
t.Parallel()
dir := t.TempDir()
if err := writeProjectResources(dir, TaskContextForEnv{}); err != nil {
t.Fatalf("writeProjectResources: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, ".multica", "project", "resources.json")); !os.IsNotExist(err) {
t.Errorf("expected no resources.json to be written when project context is empty")
}
}
func TestPrepareWithRepoContext(t *testing.T) {
t.Parallel()
workspacesRoot := t.TempDir()

View File

@@ -1,12 +1,47 @@
package execenv
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// formatProjectResource renders a single resource as a human-readable bullet.
// Unknown resource types fall back to a JSON-encoded ref so the agent can
// still read what the user attached. New resource types should add a case
// here AND in the API validator (handler/project_resource.go).
func formatProjectResource(r ProjectResourceForEnv) string {
label := r.Label
switch r.ResourceType {
case "github_repo":
var payload struct {
URL string `json:"url"`
DefaultBranchHint string `json:"default_branch_hint,omitempty"`
}
_ = json.Unmarshal(r.ResourceRef, &payload)
out := fmt.Sprintf("**GitHub repo**: %s", payload.URL)
if payload.DefaultBranchHint != "" {
out += fmt.Sprintf(" (default branch: `%s`)", payload.DefaultBranchHint)
}
if label != "" {
out += " — " + label
}
return out
default:
ref := string(r.ResourceRef)
if ref == "" {
ref = "{}"
}
out := fmt.Sprintf("**%s**: `%s`", r.ResourceType, ref)
if label != "" {
out += " — " + label
}
return out
}
}
// InjectRuntimeConfig writes the meta skill content into the runtime-specific
// config file so the agent discovers its environment through its native mechanism.
//
@@ -138,6 +173,26 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("\nThe checkout command creates a git worktree with a dedicated branch. You can check out one or more repos as needed.\n\n")
}
// Inject project-scoped context (resources attached to the issue's project).
// The full structured payload is also available at .multica/project/resources.json
// so skills can consume it programmatically.
if ctx.ProjectID != "" || len(ctx.ProjectResources) > 0 {
b.WriteString("## Project Context\n\n")
if ctx.ProjectTitle != "" {
fmt.Fprintf(&b, "This issue belongs to **%s**.\n\n", ctx.ProjectTitle)
}
if len(ctx.ProjectResources) > 0 {
b.WriteString("Project resources (also written to `.multica/project/resources.json`):\n\n")
for _, r := range ctx.ProjectResources {
fmt.Fprintf(&b, "- %s\n", formatProjectResource(r))
}
b.WriteString("\nResources are pointers — open them only when relevant to the task. ")
b.WriteString("For `github_repo` resources, use `multica repo checkout <url>` to fetch the code.\n\n")
} else {
b.WriteString("This project has no resources attached yet.\n\n")
}
}
b.WriteString("### Workflow\n\n")
if ctx.ChatSessionID != "" {

View File

@@ -22,6 +22,15 @@ type RepoData struct {
Description string `json:"description"`
}
// ProjectResourceData mirrors handler.ProjectResourceData — a single project
// resource as delivered to the daemon. resource_ref is type-specific JSON.
type ProjectResourceData struct {
ID string `json:"id"`
ResourceType string `json:"resource_type"`
ResourceRef json.RawMessage `json:"resource_ref"`
Label string `json:"label,omitempty"`
}
// Task represents a claimed task from the server.
// Agent data (name, skills) is populated by the claim endpoint.
type Task struct {
@@ -31,7 +40,10 @@ type Task struct {
IssueID string `json:"issue_id"`
WorkspaceID string `json:"workspace_id"`
Agent *AgentData `json:"agent,omitempty"`
Repos []RepoData `json:"repos,omitempty"`
Repos []RepoData `json:"repos,omitempty"`
ProjectID string `json:"project_id,omitempty"` // issue's project, when present
ProjectTitle string `json:"project_title,omitempty"` // human-readable project title for context injection
ProjectResources []ProjectResourceData `json:"project_resources,omitempty"` // project-scoped resources to expose to the agent
PriorSessionID string `json:"prior_session_id,omitempty"` // Claude session ID from a previous task on this issue
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on this issue
TriggerCommentID string `json:"trigger_comment_id,omitempty"` // comment that triggered this task

View File

@@ -120,6 +120,20 @@ type RepoData struct {
Description string `json:"description"`
}
// ProjectResourceData is the wire shape for a project resource included in a
// claim response. The daemon reads this list and writes it into the agent's
// working directory so skills/agents can discover project-scoped context.
//
// resource_ref is type-specific JSON; the daemon doesn't interpret it beyond
// well-known fields like url for github_repo. New types can be added without
// changing this struct.
type ProjectResourceData struct {
ID string `json:"id"`
ResourceType string `json:"resource_type"`
ResourceRef json.RawMessage `json:"resource_ref"`
Label string `json:"label,omitempty"`
}
type AgentTaskResponse struct {
ID string `json:"id"`
AgentID string `json:"agent_id"`
@@ -138,7 +152,10 @@ type AgentTaskResponse struct {
MaxAttempts int32 `json:"max_attempts"`
ParentTaskID *string `json:"parent_task_id,omitempty"`
Agent *TaskAgentData `json:"agent,omitempty"`
Repos []RepoData `json:"repos,omitempty"`
Repos []RepoData `json:"repos,omitempty"`
ProjectID string `json:"project_id,omitempty"` // issue's project, when present
ProjectTitle string `json:"project_title,omitempty"` // for surfacing in agent context
ProjectResources []ProjectResourceData `json:"project_resources,omitempty"` // resources attached to the project
CreatedAt string `json:"created_at"`
PriorSessionID string `json:"prior_session_id,omitempty"` // session ID from a previous task on same issue
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on same issue

View File

@@ -873,10 +873,62 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
}
// Include workspace ID and repos so the daemon can set up worktrees.
//
// Repo precedence: project-bound github_repo resources override workspace
// repos when present. Mixing both would just confuse the agent — if a
// project explicitly attached its repos, those are the authoritative set
// for issues inside that project. When the project has no github_repo
// resources (or no project at all), we fall back to the workspace repos.
if task.IssueID.Valid {
if issue, err := h.Queries.GetIssue(r.Context(), task.IssueID); err == nil {
resp.WorkspaceID = uuidToString(issue.WorkspaceID)
if ws, err := h.Queries.GetWorkspace(r.Context(), issue.WorkspaceID); err == nil && ws.Repos != nil {
var projectRepos []RepoData
if issue.ProjectID.Valid {
resp.ProjectID = uuidToString(issue.ProjectID)
if proj, err := h.Queries.GetProject(r.Context(), issue.ProjectID); err == nil {
resp.ProjectTitle = proj.Title
}
if rows := h.listProjectResourcesForProject(r.Context(), issue.ProjectID); len(rows) > 0 {
out := make([]ProjectResourceData, 0, len(rows))
for _, row := range rows {
label := ""
if row.Label.Valid {
label = row.Label.String
}
ref := json.RawMessage(row.ResourceRef)
if len(ref) == 0 {
ref = json.RawMessage("{}")
}
out = append(out, ProjectResourceData{
ID: uuidToString(row.ID),
ResourceType: row.ResourceType,
ResourceRef: ref,
Label: label,
})
// Lift github_repo resources into the daemon's repo list
// so `multica repo checkout` and the meta-skill render
// them as the issue's repos.
if row.ResourceType == "github_repo" {
var payload struct {
URL string `json:"url"`
}
if json.Unmarshal(row.ResourceRef, &payload) == nil && payload.URL != "" {
desc := ""
if row.Label.Valid {
desc = row.Label.String
}
projectRepos = append(projectRepos, RepoData{URL: payload.URL, Description: desc})
}
}
}
resp.ProjectResources = out
}
}
if len(projectRepos) > 0 {
resp.Repos = projectRepos
} else if ws, err := h.Queries.GetWorkspace(r.Context(), issue.WorkspaceID); err == nil && ws.Repos != nil {
var repos []RepoData
if json.Unmarshal(ws.Repos, &repos) == nil && len(repos) > 0 {
resp.Repos = repos

View File

@@ -57,13 +57,24 @@ func (h *Handler) loadProjectIssueStats(ctx context.Context, projectID pgtype.UU
}
type CreateProjectRequest struct {
Title string `json:"title"`
Description *string `json:"description"`
Icon *string `json:"icon"`
Status string `json:"status"`
Priority string `json:"priority"`
LeadType *string `json:"lead_type"`
LeadID *string `json:"lead_id"`
Title string `json:"title"`
Description *string `json:"description"`
Icon *string `json:"icon"`
Status string `json:"status"`
Priority string `json:"priority"`
LeadType *string `json:"lead_type"`
LeadID *string `json:"lead_id"`
Resources []CreateProjectResourceRequestPayload `json:"resources,omitempty"`
}
// CreateProjectResourceRequestPayload mirrors CreateProjectResourceRequest but
// is embedded inside the project create payload. Kept as a separate type so a
// future change to the standalone request can't silently break this surface.
type CreateProjectResourceRequestPayload struct {
ResourceType string `json:"resource_type"`
ResourceRef json.RawMessage `json:"resource_ref"`
Label *string `json:"label"`
Position *int32 `json:"position"`
}
type UpdateProjectRequest struct {
@@ -188,7 +199,25 @@ func (h *Handler) CreateProject(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
project, err := h.Queries.CreateProject(r.Context(), db.CreateProjectParams{
// Pre-validate every resource payload before opening a transaction so an
// invalid ref produces a clean 400 with no DB work.
normalizedRefs := make([]json.RawMessage, len(req.Resources))
for i, res := range req.Resources {
res.ResourceType = strings.TrimSpace(res.ResourceType)
if res.ResourceType == "" {
writeError(w, http.StatusBadRequest, "resources[].resource_type is required")
return
}
ref, err := validateAndNormalizeResourceRef(res.ResourceType, res.ResourceRef)
if err != nil {
writeError(w, http.StatusBadRequest, "resources["+strconv.Itoa(i)+"]: "+err.Error())
return
}
normalizedRefs[i] = ref
}
createParams := db.CreateProjectParams{
WorkspaceID: wsUUID,
Title: req.Title,
Description: ptrToText(req.Description),
@@ -197,14 +226,99 @@ func (h *Handler) CreateProject(w http.ResponseWriter, r *http.Request) {
LeadType: leadType,
LeadID: leadID,
Priority: priority,
})
}
// Without resources, keep the simple non-tx path.
if len(req.Resources) == 0 {
project, err := h.Queries.CreateProject(r.Context(), createParams)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create project")
return
}
resp := projectToResponse(project)
h.publish(protocol.EventProjectCreated, workspaceID, "member", userID, map[string]any{"project": resp})
writeJSON(w, http.StatusCreated, resp)
return
}
// Transactional path: project + all resources are atomic.
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to start transaction")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
project, err := qtx.CreateProject(r.Context(), createParams)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create project")
return
}
creator, _ := h.parseUserUUIDOrZero(userID)
resourceRows := make([]db.ProjectResource, 0, len(req.Resources))
for i, res := range req.Resources {
var label pgtype.Text
if res.Label != nil && strings.TrimSpace(*res.Label) != "" {
label = pgtype.Text{String: strings.TrimSpace(*res.Label), Valid: true}
}
var position int32 = int32(i)
if res.Position != nil {
position = *res.Position
}
row, err := qtx.CreateProjectResource(r.Context(), db.CreateProjectResourceParams{
ProjectID: project.ID,
WorkspaceID: project.WorkspaceID,
ResourceType: res.ResourceType,
ResourceRef: normalizedRefs[i],
Label: label,
Position: position,
CreatedBy: creator,
})
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "resources["+strconv.Itoa(i)+"]: this resource is already attached")
return
}
writeError(w, http.StatusInternalServerError, "failed to attach resource at index "+strconv.Itoa(i))
return
}
resourceRows = append(resourceRows, row)
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to commit project create")
return
}
resp := projectToResponse(project)
resourceResp := make([]ProjectResourceResponse, len(resourceRows))
for i, row := range resourceRows {
resourceResp[i] = projectResourceToResponse(row)
}
h.publish(protocol.EventProjectCreated, workspaceID, "member", userID, map[string]any{"project": resp})
writeJSON(w, http.StatusCreated, resp)
for _, rr := range resourceResp {
h.publish(protocol.EventProjectResourceCreated, workspaceID, "member", userID, map[string]any{
"resource": rr,
"project_id": resp.ID,
})
}
writeJSON(w, http.StatusCreated, map[string]any{
"id": resp.ID,
"workspace_id": resp.WorkspaceID,
"title": resp.Title,
"description": resp.Description,
"icon": resp.Icon,
"status": resp.Status,
"priority": resp.Priority,
"lead_type": resp.LeadType,
"lead_id": resp.LeadID,
"created_at": resp.CreatedAt,
"updated_at": resp.UpdatedAt,
"issue_count": resp.IssueCount,
"done_count": resp.DoneCount,
"resources": resourceResp,
})
}
func (h *Handler) UpdateProject(w http.ResponseWriter, r *http.Request) {

View File

@@ -0,0 +1,283 @@
package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// ProjectResourceResponse is the JSON shape returned by the project resource API.
type ProjectResourceResponse struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
WorkspaceID string `json:"workspace_id"`
ResourceType string `json:"resource_type"`
ResourceRef json.RawMessage `json:"resource_ref"`
Label *string `json:"label"`
Position int32 `json:"position"`
CreatedAt string `json:"created_at"`
CreatedBy *string `json:"created_by"`
}
func projectResourceToResponse(r db.ProjectResource) ProjectResourceResponse {
ref := json.RawMessage(r.ResourceRef)
if len(ref) == 0 {
ref = json.RawMessage("{}")
}
return ProjectResourceResponse{
ID: uuidToString(r.ID),
ProjectID: uuidToString(r.ProjectID),
WorkspaceID: uuidToString(r.WorkspaceID),
ResourceType: r.ResourceType,
ResourceRef: ref,
Label: textToPtr(r.Label),
Position: r.Position,
CreatedAt: timestampToString(r.CreatedAt),
CreatedBy: uuidToPtr(r.CreatedBy),
}
}
// CreateProjectResourceRequest is the body for POST /api/projects/{id}/resources.
type CreateProjectResourceRequest struct {
ResourceType string `json:"resource_type"`
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
// daemon/UI doesn't understand.
func validateAndNormalizeResourceRef(resourceType string, ref json.RawMessage) (json.RawMessage, error) {
if len(ref) == 0 {
return nil, errors.New("resource_ref is required")
}
switch resourceType {
case "github_repo":
return validateGithubRepoRef(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"`
}
func validateGithubRepoRef(ref json.RawMessage) (json.RawMessage, error) {
var payload githubRepoRef
if err := json.Unmarshal(ref, &payload); err != nil {
return nil, fmt.Errorf("invalid github_repo payload: %w", err)
}
payload.URL = strings.TrimSpace(payload.URL)
if payload.URL == "" {
return nil, errors.New("github_repo: url is required")
}
if u, err := url.Parse(payload.URL); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, errors.New("github_repo: url must be a valid http(s) URL")
}
payload.DefaultBranchHint = strings.TrimSpace(payload.DefaultBranchHint)
out, err := json.Marshal(payload)
if err != nil {
return nil, err
}
return out, nil
}
// loadProjectForResource resolves the project, enforces workspace ownership,
// and returns its DB row. Used by all project_resource handlers.
func (h *Handler) loadProjectForResource(w http.ResponseWriter, r *http.Request, projectIDParam string) (db.Project, bool) {
projectUUID, ok := parseUUIDOrBadRequest(w, projectIDParam, "project id")
if !ok {
return db.Project{}, false
}
wsUUID, ok := parseUUIDOrBadRequest(w, h.resolveWorkspaceID(r), "workspace id")
if !ok {
return db.Project{}, false
}
project, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
ID: projectUUID, WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "project not found")
return db.Project{}, false
}
return project, true
}
// ListProjectResources returns the resources attached to a project.
func (h *Handler) ListProjectResources(w http.ResponseWriter, r *http.Request) {
project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id"))
if !ok {
return
}
resources, err := h.Queries.ListProjectResources(r.Context(), project.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list project resources")
return
}
resp := make([]ProjectResourceResponse, len(resources))
for i, res := range resources {
resp[i] = projectResourceToResponse(res)
}
writeJSON(w, http.StatusOK, map[string]any{"resources": resp, "total": len(resp)})
}
// CreateProjectResource attaches a new resource to a project.
func (h *Handler) CreateProjectResource(w http.ResponseWriter, r *http.Request) {
project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id"))
if !ok {
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
var req CreateProjectResourceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
req.ResourceType = strings.TrimSpace(req.ResourceType)
if req.ResourceType == "" {
writeError(w, http.StatusBadRequest, "resource_type is required")
return
}
normalizedRef, err := validateAndNormalizeResourceRef(req.ResourceType, req.ResourceRef)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var label pgtype.Text
if req.Label != nil && strings.TrimSpace(*req.Label) != "" {
label = pgtype.Text{String: strings.TrimSpace(*req.Label), Valid: true}
}
var position int32
if req.Position != nil {
position = *req.Position
} else {
// Append after existing resources.
count, _ := h.Queries.CountProjectResources(r.Context(), project.ID)
position = int32(count)
}
creator, _ := h.parseUserUUIDOrZero(userID)
resource, err := h.Queries.CreateProjectResource(r.Context(), db.CreateProjectResourceParams{
ProjectID: project.ID,
WorkspaceID: project.WorkspaceID,
ResourceType: req.ResourceType,
ResourceRef: normalizedRef,
Label: label,
Position: position,
CreatedBy: creator,
})
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 create project resource")
return
}
resp := projectResourceToResponse(resource)
h.publish(
protocol.EventProjectResourceCreated,
uuidToString(project.WorkspaceID),
"member",
userID,
map[string]any{"resource": resp, "project_id": uuidToString(project.ID)},
)
writeJSON(w, http.StatusCreated, resp)
}
// 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"))
if !ok {
return
}
resourceUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "resourceId"), "resource id")
if !ok {
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
resource, 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(resource.ProjectID) != uuidToString(project.ID) {
writeError(w, http.StatusNotFound, "project resource not found")
return
}
if err := h.Queries.DeleteProjectResource(r.Context(), resource.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete project resource")
return
}
h.publish(
protocol.EventProjectResourceDeleted,
uuidToString(project.WorkspaceID),
"member",
userID,
map[string]any{
"project_id": uuidToString(project.ID),
"resource_id": uuidToString(resource.ID),
},
)
w.WriteHeader(http.StatusNoContent)
}
// parseUserUUIDOrZero converts a user ID string to a pgtype.UUID, returning a
// zero value on any error so the caller can store NULL for created_by when the
// authenticated principal is not a workspace member (e.g. internal-server use).
func (h *Handler) parseUserUUIDOrZero(userID string) (pgtype.UUID, bool) {
if userID == "" {
return pgtype.UUID{}, false
}
u, err := parseUUIDLoose(userID)
if err != nil {
return pgtype.UUID{}, false
}
return u, true
}
// parseUUIDLoose mirrors util.ParseUUID but lives here to avoid pulling util
// into a tiny one-off helper. Keep the body minimal.
func parseUUIDLoose(s string) (pgtype.UUID, error) {
var u pgtype.UUID
if err := u.Scan(s); err != nil {
return pgtype.UUID{}, err
}
return u, nil
}
// listProjectResourcesForProject is a small helper used by the daemon claim
// handler to attach project resources to outgoing tasks.
func (h *Handler) listProjectResourcesForProject(ctx context.Context, projectID pgtype.UUID) []db.ProjectResource {
if !projectID.Valid {
return nil
}
rows, err := h.Queries.ListProjectResources(ctx, projectID)
if err != nil {
return nil
}
return rows
}

View File

@@ -0,0 +1,207 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestProjectResourceLifecycle(t *testing.T) {
// Create a project to attach resources to.
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "Resource lifecycle project",
})
testHandler.CreateProject(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateProject: expected 201, got %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() {
req := newRequest("DELETE", "/api/projects/"+project.ID, nil)
req = withURLParam(req, "id", project.ID)
testHandler.DeleteProject(httptest.NewRecorder(), req)
}()
// Attach a github_repo resource.
w = httptest.NewRecorder()
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
"resource_type": "github_repo",
"resource_ref": map[string]any{"url": "https://github.com/multica-ai/multica"},
})
req = withURLParam(req, "id", project.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 != "github_repo" {
t.Errorf("created.ResourceType = %q, want github_repo", created.ResourceType)
}
var ref struct {
URL string `json:"url"`
}
if err := json.Unmarshal(created.ResourceRef, &ref); err != nil {
t.Fatalf("decode resource_ref: %v", err)
}
if ref.URL != "https://github.com/multica-ai/multica" {
t.Errorf("created.ResourceRef.url = %q", ref.URL)
}
// Listing must include the new resource.
w = httptest.NewRecorder()
req = newRequest("GET", "/api/projects/"+project.ID+"/resources", nil)
req = withURLParam(req, "id", project.ID)
testHandler.ListProjectResources(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListProjectResources: expected 200, got %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 || len(listResp.Resources) != 1 {
t.Fatalf("list returned %d resources, want 1", listResp.Total)
}
if listResp.Resources[0].ID != created.ID {
t.Errorf("list[0].ID = %q, want %q", listResp.Resources[0].ID, created.ID)
}
// Duplicate attach must conflict (UNIQUE on project_id + type + ref).
w = httptest.NewRecorder()
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
"resource_type": "github_repo",
"resource_ref": map[string]any{"url": "https://github.com/multica-ai/multica"},
})
req = withURLParam(req, "id", project.ID)
testHandler.CreateProjectResource(w, req)
if w.Code != http.StatusConflict {
t.Errorf("duplicate CreateProjectResource: expected 409, got %d: %s", w.Code, w.Body.String())
}
// Invalid URL must reject at the validator level.
w = httptest.NewRecorder()
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
"resource_type": "github_repo",
"resource_ref": map[string]any{"url": "not-a-url"},
})
req = withURLParam(req, "id", project.ID)
testHandler.CreateProjectResource(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("invalid URL: expected 400, got %d: %s", w.Code, w.Body.String())
}
// Unknown resource_type must reject.
w = httptest.NewRecorder()
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
"resource_type": "unknown_type",
"resource_ref": map[string]any{"foo": "bar"},
})
req = withURLParam(req, "id", project.ID)
testHandler.CreateProjectResource(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("unknown type: expected 400, got %d: %s", w.Code, w.Body.String())
}
// Delete the resource.
w = httptest.NewRecorder()
req = newRequest("DELETE", "/api/projects/"+project.ID+"/resources/"+created.ID, nil)
req = withURLParams(req, "id", project.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())
}
// After deletion the list should be empty.
w = httptest.NewRecorder()
req = newRequest("GET", "/api/projects/"+project.ID+"/resources", nil)
req = withURLParam(req, "id", project.ID)
testHandler.ListProjectResources(w, req)
if err := json.NewDecoder(w.Body).Decode(&listResp); err != nil {
t.Fatalf("decode post-delete list: %v", err)
}
if listResp.Total != 0 {
t.Errorf("post-delete list: total = %d, want 0", listResp.Total)
}
}
func TestCreateProjectAttachesResources(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "Project with bundled resources",
"resources": []map[string]any{
{
"resource_type": "github_repo",
"resource_ref": map[string]any{"url": "https://github.com/multica-ai/multica"},
},
},
})
testHandler.CreateProject(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateProject with resources: 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) != 1 || resp.Resources[0].ResourceType != "github_repo" {
t.Fatalf("response resources mismatch: %+v", resp.Resources)
}
}
func TestCreateProjectRollsBackOnInvalidResource(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "Project that should not exist",
"resources": []map[string]any{
{
"resource_type": "github_repo",
"resource_ref": map[string]any{"url": "not-a-url"},
},
},
})
testHandler.CreateProject(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("CreateProject with invalid resource: expected 400, got %d: %s", w.Code, w.Body.String())
}
// Confirm no project survived (transactional rollback). Listing all projects
// in the workspace and checking for the title is enough.
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 == "Project that should not exist" {
t.Errorf("invalid resource should have rolled back project create, but found %s", p.ID)
}
}
}

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS project_resource;

View File

@@ -0,0 +1,19 @@
-- Project Resources: typed pointers from a project to external resources
-- (github_repo for now; notion_page / gdoc / url / file later). The shape is
-- intentionally polymorphic — resource_type is a free string and resource_ref
-- is JSONB, so adding a new type requires zero schema changes.
CREATE TABLE project_resource (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES project(id) ON DELETE CASCADE,
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
resource_type TEXT NOT NULL,
resource_ref JSONB NOT NULL,
label TEXT,
position INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID,
UNIQUE (project_id, resource_type, resource_ref)
);
CREATE INDEX idx_project_resource_project ON project_resource(project_id, position);
CREATE INDEX idx_project_resource_workspace ON project_resource(workspace_id);

View File

@@ -365,6 +365,18 @@ type Project struct {
Priority string `json:"priority"`
}
type ProjectResource struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ResourceType string `json:"resource_type"`
ResourceRef []byte `json:"resource_ref"`
Label pgtype.Text `json:"label"`
Position int32 `json:"position"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
CreatedBy pgtype.UUID `json:"created_by"`
}
type Skill struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`

View File

@@ -0,0 +1,196 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: project_resource.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const countProjectResources = `-- name: CountProjectResources :one
SELECT count(*) FROM project_resource WHERE project_id = $1
`
func (q *Queries) CountProjectResources(ctx context.Context, projectID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, countProjectResources, projectID)
var count int64
err := row.Scan(&count)
return count, err
}
const createProjectResource = `-- name: CreateProjectResource :one
INSERT INTO project_resource (
project_id, workspace_id, resource_type, resource_ref, label, position, created_by
) VALUES (
$1, $2, $3, $4, $5, $6, $7
) RETURNING id, project_id, workspace_id, resource_type, resource_ref, label, position, created_at, created_by
`
type CreateProjectResourceParams struct {
ProjectID pgtype.UUID `json:"project_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ResourceType string `json:"resource_type"`
ResourceRef []byte `json:"resource_ref"`
Label pgtype.Text `json:"label"`
Position int32 `json:"position"`
CreatedBy pgtype.UUID `json:"created_by"`
}
func (q *Queries) CreateProjectResource(ctx context.Context, arg CreateProjectResourceParams) (ProjectResource, error) {
row := q.db.QueryRow(ctx, createProjectResource,
arg.ProjectID,
arg.WorkspaceID,
arg.ResourceType,
arg.ResourceRef,
arg.Label,
arg.Position,
arg.CreatedBy,
)
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
}
const deleteProjectResource = `-- name: DeleteProjectResource :exec
DELETE FROM project_resource WHERE id = $1
`
func (q *Queries) DeleteProjectResource(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteProjectResource, id)
return err
}
const getProjectResource = `-- name: GetProjectResource :one
SELECT id, project_id, workspace_id, resource_type, resource_ref, label, position, created_at, created_by FROM project_resource
WHERE id = $1
`
func (q *Queries) GetProjectResource(ctx context.Context, id pgtype.UUID) (ProjectResource, error) {
row := q.db.QueryRow(ctx, getProjectResource, id)
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
}
const getProjectResourceInWorkspace = `-- name: GetProjectResourceInWorkspace :one
SELECT id, project_id, workspace_id, resource_type, resource_ref, label, position, created_at, created_by FROM project_resource
WHERE id = $1 AND workspace_id = $2
`
type GetProjectResourceInWorkspaceParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) GetProjectResourceInWorkspace(ctx context.Context, arg GetProjectResourceInWorkspaceParams) (ProjectResource, error) {
row := q.db.QueryRow(ctx, getProjectResourceInWorkspace, arg.ID, arg.WorkspaceID)
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
}
const listProjectResources = `-- name: ListProjectResources :many
SELECT id, project_id, workspace_id, resource_type, resource_ref, label, position, created_at, created_by FROM project_resource
WHERE project_id = $1
ORDER BY position ASC, created_at ASC
`
func (q *Queries) ListProjectResources(ctx context.Context, projectID pgtype.UUID) ([]ProjectResource, error) {
rows, err := q.db.Query(ctx, listProjectResources, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ProjectResource{}
for rows.Next() {
var i ProjectResource
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.WorkspaceID,
&i.ResourceType,
&i.ResourceRef,
&i.Label,
&i.Position,
&i.CreatedAt,
&i.CreatedBy,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listProjectResourcesForProjects = `-- name: ListProjectResourcesForProjects :many
SELECT id, project_id, workspace_id, resource_type, resource_ref, label, position, created_at, created_by FROM project_resource
WHERE project_id = ANY($1::uuid[])
ORDER BY project_id, position ASC, created_at ASC
`
func (q *Queries) ListProjectResourcesForProjects(ctx context.Context, projectIds []pgtype.UUID) ([]ProjectResource, error) {
rows, err := q.db.Query(ctx, listProjectResourcesForProjects, projectIds)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ProjectResource{}
for rows.Next() {
var i ProjectResource
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.WorkspaceID,
&i.ResourceType,
&i.ResourceRef,
&i.Label,
&i.Position,
&i.CreatedAt,
&i.CreatedBy,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@@ -0,0 +1,30 @@
-- name: ListProjectResources :many
SELECT * FROM project_resource
WHERE project_id = $1
ORDER BY position ASC, created_at ASC;
-- name: ListProjectResourcesForProjects :many
SELECT * FROM project_resource
WHERE project_id = ANY(sqlc.arg('project_ids')::uuid[])
ORDER BY project_id, position ASC, created_at ASC;
-- name: GetProjectResource :one
SELECT * FROM project_resource
WHERE id = $1;
-- name: GetProjectResourceInWorkspace :one
SELECT * FROM project_resource
WHERE id = $1 AND workspace_id = $2;
-- name: CreateProjectResource :one
INSERT INTO project_resource (
project_id, workspace_id, resource_type, resource_ref, label, position, created_by
) VALUES (
$1, $2, $3, $4, $5, $6, $7
) RETURNING *;
-- name: DeleteProjectResource :exec
DELETE FROM project_resource WHERE id = $1;
-- name: CountProjectResources :one
SELECT count(*) FROM project_resource WHERE project_id = $1;

View File

@@ -69,9 +69,11 @@ const (
EventChatSessionRead = "chat:session_read"
// Project events
EventProjectCreated = "project:created"
EventProjectUpdated = "project:updated"
EventProjectDeleted = "project:deleted"
EventProjectCreated = "project:created"
EventProjectUpdated = "project:updated"
EventProjectDeleted = "project:deleted"
EventProjectResourceCreated = "project_resource:created"
EventProjectResourceDeleted = "project_resource:deleted"
// Label events
EventLabelCreated = "label:created"