diff --git a/internal/api/handler_project_assistant_test.go b/internal/api/handler_project_assistant_test.go index 8780d881..f1124f46 100644 --- a/internal/api/handler_project_assistant_test.go +++ b/internal/api/handler_project_assistant_test.go @@ -1157,6 +1157,94 @@ func TestProjectAssistantAPI_PartialApplyMirrorsCommittedActionsToCairnlineWhenC } } +func TestProjectAssistantAPI_ProjectSideEffectsMirrorThroughNarrowCairnlineSeams(t *testing.T) { + t.Parallel() + handler, server := newProjectAssistantCairnlineMirrorTestHandler(t) + project, err := handler.projects.Create(t.Context(), projects.Project{ + ID: "proj_assistant_project_mirror", + Name: "Assistant Project Mirror", + DefaultRootID: "root_main", + Roots: []projects.Root{{ + ID: "root_main", + Path: "/workspace/main", + Kind: "git", + Active: true, + }}, + }) + if err != nil { + t.Fatalf("Create project: %v", err) + } + if err := handler.writeProjectIdentityToCairnline(t.Context(), project); err != nil { + t.Fatalf("write initial Cairnline project: %v", err) + } + seedCairnlineOnlyProjectGraphForTest(t, handler, project.ID) + + proposal := projectassistant.Proposal{ + ID: "pa_assistant_project_mirror", + Title: "Update project state", + RequiresConfirmation: true, + Actions: []projectassistant.Action{ + { + Kind: projectassistant.ActionUpdateProject, + Target: map[string]string{"project_id": project.ID}, + Patch: json.RawMessage(`{"name":"Assistant Project Mirror Updated","description":"Mirrored through metadata seam"}`), + }, + { + Kind: projectassistant.ActionAttachProjectRoot, + Target: map[string]string{"project_id": project.ID}, + Patch: json.RawMessage(`{"id":"root_attached","path":"/workspace/attached","kind":"git_worktree","active":true}`), + }, + { + Kind: projectassistant.ActionSetProjectDefaults, + Target: map[string]string{"project_id": project.ID}, + Patch: json.RawMessage(`{"default_root_id":"root_attached","default_provider":"anthropic","default_model":"claude-sonnet-4-5","default_agent_profile":"architecture"}`), + }, + { + Kind: projectassistant.ActionRemoveProjectRoot, + Target: map[string]string{"project_id": project.ID, "root_id": "root_main"}, + }, + }, + } + applyBody, err := json.Marshal(map[string]any{"proposal": proposal, "confirm": true}) + if err != nil { + t.Fatalf("marshal apply body: %v", err) + } + + rec := httptest.NewRecorder() + server.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/hecate/v1/project-assistant/apply", bytes.NewReader(applyBody))) + if rec.Code != http.StatusOK { + t.Fatalf("apply status = %d body=%s, want 200", rec.Code, rec.Body.String()) + } + var applied projectAssistantApplyResponse + if err := json.Unmarshal(rec.Body.Bytes(), &applied); err != nil { + t.Fatalf("decode apply response: %v", err) + } + if applied.Data.CommittedActionCount != 4 || len(applied.Data.Actions) != 4 { + t.Fatalf("apply result = %+v, want four committed actions", applied.Data) + } + + mirrored := getMirroredCairnlineProjectForTest(t, handler, project.ID) + if mirrored.Name != "Assistant Project Mirror Updated" || mirrored.Description != "Mirrored through metadata seam" { + t.Fatalf("mirrored project metadata = %+v, want assistant-updated metadata", mirrored) + } + if findMirroredCairnlineRootForTest(mirrored.Roots, "root_attached") == nil { + t.Fatalf("mirrored roots = %+v, want attached root", mirrored.Roots) + } + if findMirroredCairnlineRootForTest(mirrored.Roots, "root_main") != nil { + t.Fatalf("mirrored roots = %+v, want removed root_main absent", mirrored.Roots) + } + if findMirroredCairnlineRootForTest(mirrored.Roots, "root_cairnline_only") == nil { + t.Fatalf("mirrored roots = %+v, want Cairnline-only root preserved", mirrored.Roots) + } + if findMirroredCairnlineSourceForTest(mirrored.ContextSources, "ctx_cairnline_only") == nil { + t.Fatalf("mirrored sources = %+v, want Cairnline-only source preserved", mirrored.ContextSources) + } + if mirrored.DefaultRootID != "root_attached" || mirrored.DefaultProfileID != "architecture" { + t.Fatalf("mirrored defaults = %+v, want attached root and architecture profile", mirrored) + } + assertMirroredExecutionProfileForTest(t, handler, mirrored.DefaultExecutionProfileID, "anthropic", "claude-sonnet-4-5") +} + func TestProjectAssistantAPI_DraftReviewFollowUpProposal(t *testing.T) { t.Parallel() handler, server := newProjectAssistantTestHandler() diff --git a/internal/api/handler_project_cairnline_mirror.go b/internal/api/handler_project_cairnline_mirror.go index 4bf3b145..8b307bf0 100644 --- a/internal/api/handler_project_cairnline_mirror.go +++ b/internal/api/handler_project_cairnline_mirror.go @@ -346,16 +346,45 @@ func (h *Handler) loadProjectAssistantProposalForCairnlineMirror(ctx context.Con func (h *Handler) writeProjectAssistantActionResultToCairnline(ctx context.Context, result projectassistant.ActionResult) error { projectID := projectAssistantActionResultProjectID(result) switch strings.TrimSpace(result.Kind) { - case projectassistant.ActionCreateProject, - projectassistant.ActionUpdateProject, - projectassistant.ActionAttachProjectRoot, - projectassistant.ActionRemoveProjectRoot, - projectassistant.ActionSetProjectDefaults: + case projectassistant.ActionCreateProject: project, ok := h.projectForCairnlineMirror(ctx, "project_assistant_apply_result", projectID) if !ok { return nil } return h.writeProjectIdentityToCairnline(ctx, project) + case projectassistant.ActionUpdateProject: + project, ok := h.projectForCairnlineMirror(ctx, "project_assistant_apply_result", projectID) + if !ok { + return nil + } + return h.writeProjectMetadataToCairnline(ctx, project) + case projectassistant.ActionAttachProjectRoot: + project, ok := h.projectForCairnlineMirror(ctx, "project_assistant_apply_result", projectID) + if !ok { + return nil + } + rootID := projectAssistantActionResultValue(result, "root_id") + root, ok := projectRootForCairnlineMirror(project, rootID) + if !ok { + return errors.Join(cairnline.ErrNotFound, errors.New("project assistant root not found for Cairnline mirror")) + } + return h.writeProjectRootToCairnline(ctx, project, root) + case projectassistant.ActionRemoveProjectRoot: + project, ok := h.projectForCairnlineMirror(ctx, "project_assistant_apply_result", projectID) + if !ok { + return nil + } + rootID := projectAssistantActionResultValue(result, "root_id") + if err := h.deleteProjectRootFromCairnline(ctx, project.ID, rootID); err != nil { + return err + } + return h.writeProjectDefaultsToCairnline(ctx, project) + case projectassistant.ActionSetProjectDefaults: + project, ok := h.projectForCairnlineMirror(ctx, "project_assistant_apply_result", projectID) + if !ok { + return nil + } + return h.writeProjectDefaultsToCairnline(ctx, project) case projectassistant.ActionCreateRole: project, ok := h.projectForCairnlineMirror(ctx, "project_assistant_apply_result", projectID) if !ok { @@ -403,6 +432,19 @@ func projectAssistantActionResultProjectID(result projectassistant.ActionResult) return projectAssistantActionResultValue(result, "project_id") } +func projectRootForCairnlineMirror(project projects.Project, rootID string) (projects.Root, bool) { + rootID = strings.TrimSpace(rootID) + if rootID == "" { + return projects.Root{}, false + } + for _, root := range project.Roots { + if root.ID == rootID { + return root, true + } + } + return projects.Root{}, false +} + func projectAssistantActionResultValue(result projectassistant.ActionResult, key string) string { if result.Data != nil { if value := strings.TrimSpace(result.Data[key]); value != "" { @@ -764,7 +806,9 @@ func (h *Handler) writeProjectAssistantProposalRecordToCairnline(ctx context.Con return err } if ok { - if _, err := cairnlinebridge.UpsertProject(ctx, service, project); err != nil { + // Proposal records only need the project row to exist; avoid + // replacing Cairnline-owned roots or sources while writing the ledger. + if _, err := cairnlinebridge.UpsertProjectMetadata(ctx, service, project); err != nil { return err } }