Files
multica/packages/core/projects/resource-queries.ts
Bohan Jiang 44608713bb feat(projects): typed project resources + agent runtime injection (#1926)
* 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>

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 14:00:43 +08:00

88 lines
2.6 KiB
TypeScript

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