From ba281a2a2ded550a26c4ef2c2f5d1351070e3252 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 26 Mar 2026 07:37:43 +0000 Subject: [PATCH] feat: clone workspace dependencies + Docker cleanup - cloneWorkspaceDeps: reads go.mod, clones Core ecosystem modules from Forge into workspace alongside ./repo, rebuilds go.work with all use directives - Deduplicates deps (dappco.re + forge.lthn.ai map to same repos) - Container chmod: workspace files made writable before exit so host can clean up - GONOSUMCHECK for local workspace modules (bypass checksum for dev branches) - Removed stale OLLAMA_HOST env from container Co-Authored-By: Virgil --- pkg/agentic/deps.go | 139 ++++++++++++++++++++++++++++++++++++++ pkg/agentic/dispatch.go | 19 ++++-- pkg/agentic/logic_test.go | 4 +- pkg/agentic/prep.go | 5 ++ 4 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 pkg/agentic/deps.go diff --git a/pkg/agentic/deps.go b/pkg/agentic/deps.go new file mode 100644 index 0000000..1063cf9 --- /dev/null +++ b/pkg/agentic/deps.go @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Workspace dependency cloning. +// Reads the repo's go.mod, finds Core ecosystem modules, clones them into +// the workspace alongside ./repo, and builds a go.work that includes them all. +// This gives the agent a complete, buildable workspace without needing go.work=off. + +package agentic + +import ( + "context" + + core "dappco.re/go/core" +) + +// cloneWorkspaceDeps clones Core ecosystem dependencies into the workspace. +// After this, the workspace go.work includes ./repo and all ./dep-* dirs, +// giving the agent everything needed to build and test. +// +// s.cloneWorkspaceDeps(ctx, wsDir, repoDir, "core") +func (s *PrepSubsystem) cloneWorkspaceDeps(ctx context.Context, wsDir, repoDir, org string) { + goModPath := core.JoinPath(repoDir, "go.mod") + r := fs.Read(goModPath) + if !r.OK { + return // no go.mod — not a Go project + } + + // Parse requires from go.mod + deps := parseCoreDeps(r.Value.(string)) + if len(deps) == 0 { + return + } + + // Deduplicate (dappco.re and forge.lthn.ai may map to same repo) + dedupSeen := make(map[string]bool) + var unique []coreDep + for _, dep := range deps { + if !dedupSeen[dep.dir] { + dedupSeen[dep.dir] = true + unique = append(unique, dep) + } + } + deps = unique + + // Clone each dependency + var cloned []string + for _, dep := range deps { + depDir := core.JoinPath(wsDir, dep.dir) + if fs.IsDir(core.JoinPath(depDir, ".git")) { + cloned = append(cloned, dep.dir) // already cloned (resume) + continue + } + + repoURL := forgeSSHURL(org, dep.repo) + if result := s.gitCmd(ctx, wsDir, "clone", "--depth=1", repoURL, dep.dir); result.OK { + cloned = append(cloned, dep.dir) + } + } + + // Rebuild go.work with repo + all cloned deps + if len(cloned) > 0 { + b := core.NewBuilder() + b.WriteString("go 1.26.0\n\nuse (\n") + b.WriteString("\t./repo\n") + for _, dir := range cloned { + b.WriteString(core.Concat("\t./", dir, "\n")) + } + b.WriteString(")\n") + fs.Write(core.JoinPath(wsDir, "go.work"), b.String()) + } +} + +// coreDep maps a Go module path to a Forge repo clone target. +type coreDep struct { + module string // e.g. "dappco.re/go/core" + repo string // e.g. "go" (Forge repo name) + dir string // e.g. "core-go" (workspace subdir) +} + +// parseCoreDeps extracts Core ecosystem dependencies from go.mod content. +// Finds all require lines matching dappco.re/go/* or forge.lthn.ai/core/*. +func parseCoreDeps(gomod string) []coreDep { + var deps []coreDep + seen := make(map[string]bool) + + for _, line := range core.Split(gomod, "\n") { + line = core.Trim(line) + + // Match dappco.re/go/* requires + if core.HasPrefix(line, "dappco.re/go/") { + parts := core.Split(line, " ") + mod := parts[0] + if seen[mod] { + continue + } + seen[mod] = true + + // dappco.re/go/core → repo "go", dir "core-go" + // dappco.re/go/core/process → repo "go-process", dir "core-go-process" + // dappco.re/go/core/ws → repo "go-ws", dir "core-go-ws" + // dappco.re/go/mcp → repo "mcp", dir "core-mcp" + suffix := core.TrimPrefix(mod, "dappco.re/go/") + repo := suffix + if core.HasPrefix(suffix, "core/") { + // core/process → go-process + sub := core.TrimPrefix(suffix, "core/") + repo = core.Concat("go-", sub) + } else if suffix == "core" { + repo = "go" + } + dir := core.Concat("core-", core.Replace(repo, "/", "-")) + deps = append(deps, coreDep{module: mod, repo: repo, dir: dir}) + } + + // Match forge.lthn.ai/core/* requires (legacy paths) + if core.HasPrefix(line, "forge.lthn.ai/core/") { + parts := core.Split(line, " ") + mod := parts[0] + if seen[mod] { + continue + } + seen[mod] = true + + suffix := core.TrimPrefix(mod, "forge.lthn.ai/core/") + repo := suffix + dir := core.Concat("core-", core.Replace(repo, "/", "-")) + deps = append(deps, coreDep{module: mod, repo: repo, dir: dir}) + } + } + + return deps +} + +// forgeSSHURL builds the Forge SSH clone URL for a repo. +// +// forgeSSHURL("core", "go-io") → "ssh://git@forge.lthn.ai:2223/core/go-io.git" +func forgeSSHURL(org, repo string) string { + return core.Concat("ssh://git@forge.lthn.ai:2223/", org, "/", repo, ".git") +} diff --git a/pkg/agentic/dispatch.go b/pkg/agentic/dispatch.go index 5018614..3a05d91 100644 --- a/pkg/agentic/dispatch.go +++ b/pkg/agentic/dispatch.go @@ -166,8 +166,9 @@ func containerCommand(agentType, command string, args []string, repoDir, metaDir "-e", "CI=true", "-e", "GIT_USER_NAME=Virgil", "-e", "GIT_USER_EMAIL=virgil@lethean.io", - // Local model access — Ollama on host - "-e", "OLLAMA_HOST=http://host.docker.internal:11434", + // Go workspace — local modules bypass checksum verification + "-e", "GONOSUMCHECK=dappco.re/*,forge.lthn.ai/*", + "-e", "GOFLAGS=-mod=mod", } // Mount Claude config if dispatching claude agent @@ -184,8 +185,18 @@ func containerCommand(agentType, command string, args []string, repoDir, metaDir ) } - dockerArgs = append(dockerArgs, image, command) - dockerArgs = append(dockerArgs, args...) + // Wrap agent command in sh -c to chmod workspace after exit. + // Docker runs as a different user — without this, host can't delete workspace files. + quoted := core.NewBuilder() + quoted.WriteString(command) + for _, a := range args { + quoted.WriteString(" '") + quoted.WriteString(core.Replace(a, "'", "'\\''")) + quoted.WriteString("'") + } + quoted.WriteString("; chmod -R a+w /workspace /workspace/.meta 2>/dev/null; true") + + dockerArgs = append(dockerArgs, image, "sh", "-c", quoted.String()) return "docker", dockerArgs } diff --git a/pkg/agentic/logic_test.go b/pkg/agentic/logic_test.go index 375d81c..566ecaa 100644 --- a/pkg/agentic/logic_test.go +++ b/pkg/agentic/logic_test.go @@ -127,7 +127,9 @@ func TestDispatch_ContainerCommand_Good_Codex(t *testing.T) { assert.Contains(t, args, "--rm") assert.Contains(t, args, "/ws/repo:/workspace") assert.Contains(t, args, "/ws/.meta:/workspace/.meta") - assert.Contains(t, args, "codex") + // Command is wrapped in sh -c for chmod cleanup + shCmd := args[len(args)-1] + assert.Contains(t, shCmd, "codex") // Should use default image assert.Contains(t, args, defaultDockerImage) } diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index 6d14f13..858bcc6 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -465,6 +465,11 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques out.Branch = s.gitOutput(ctx, repoDir, "rev-parse", "--abbrev-ref", "HEAD") } + // Clone workspace dependencies — Core modules needed to build the repo. + // Reads go.mod, finds dappco.re/go/core/* imports, clones from Forge, + // and updates go.work so the agent can build inside the workspace. + s.cloneWorkspaceDeps(ctx, wsDir, repoDir, input.Org) + // Build the rich prompt with all context out.Prompt, out.Memories, out.Consumers = s.buildPrompt(ctx, input, out.Branch, repoPath)