diff --git a/docs/RFC.md b/docs/RFC.md index 5c6a6de..457ef22 100644 --- a/docs/RFC.md +++ b/docs/RFC.md @@ -3,7 +3,7 @@ > `dappco.re/go/core/agent` — Agentic dispatch, orchestration, and pipeline management. > An agent should be able to understand core/agent's architecture from this document alone. -**Status:** v0.8.0 +**Status:** v0.8.0+alpha.1 **Module:** `dappco.re/go/core/agent` **Depends on:** core/go v0.8.0, go-process v0.8.0 @@ -15,38 +15,21 @@ core/agent dispatches AI agents (Claude, Codex, Gemini) to work on tasks in sand core/go provides the primitives. core/agent composes them. -### Current State (2026-03-25) - -The codebase is PRE-migration. The RFC describes the v0.8.0 target. What exists today: - -- `pkg/agentic/proc.go` — standalone process helpers with `ensureProcess()`. **Delete** — replace with `s.Core().Process()` -- `pkg/agentic/handlers.go` — nested `c.ACTION()` cascade 4 levels deep. **Replace** with `c.Task("agent.completion")` -- `pkg/agentic/commands.go` — closures already extracted to named methods (done in prior session) -- `pkg/agentic/commands_forge.go` — forge command methods (done) -- `pkg/agentic/commands_workspace.go` — workspace command methods (done) -- `pkg/agentic/dispatch.go` — `spawnAgent` decomposed into 7 functions (done) -- `pkg/agentic/status.go` — uses `os.WriteFile` for status.json. **Replace** with `Fs.WriteAtomic` -- `pkg/agentic/paths.go` — uses `unsafe.Pointer` to bypass Fs.root. **Replace** with `Fs.NewUnrestricted()` -- `pkg/messages/` — typed IPC message structs (`AgentCompleted`, `QAResult`, etc.) -- `pkg/brain/` — OpenBrain integration (recall/remember) -- `pkg/monitor/` — agent monitoring + notifications -- `pkg/setup/` — workspace scaffolding -- `OnStartup`/`OnShutdown` — currently return `error`. **Change** to return `Result` - ### File Layout ``` cmd/core-agent/main.go — entry point: core.New + Run pkg/agentic/ — orchestration (dispatch, prep, verify, scan, commands) -pkg/agentic/proc.go — DELETE (replace with c.Process()) -pkg/agentic/handlers.go — REWRITE (cascade → Task pipeline) -pkg/agentic/status.go — MIGRATE (os.WriteFile → WriteAtomic) -pkg/agentic/paths.go — MIGRATE (unsafe.Pointer → NewUnrestricted) +pkg/agentic/actions.go — named Action handlers (ctx, Options) → Result +pkg/agentic/proc.go — process helpers via s.Core().Process() +pkg/agentic/handlers.go — IPC completion pipeline handlers +pkg/agentic/status.go — workspace status (WriteAtomic + JSONMarshalString) +pkg/agentic/paths.go — paths, fs (NewUnrestricted), helpers pkg/brain/ — OpenBrain (recall, remember, search) pkg/lib/ — embedded templates, personas, flows, plans pkg/messages/ — typed message structs for IPC broadcast -pkg/monitor/ — agent monitoring + channel notifications -pkg/setup/ — workspace detection + scaffolding +pkg/monitor/ — agent monitoring via IPC (ServiceRuntime) +pkg/setup/ — workspace detection + scaffolding (Service) claude/ — Claude Code plugin definitions docs/ — RFC, plans, architecture ``` @@ -55,12 +38,19 @@ docs/ — RFC, plans, architecture ## 2. Service Registration +All services use `ServiceRuntime[T]` — no raw `core *core.Core` fields. + ```go func Register(c *core.Core) core.Result { - svc := &PrepSubsystem{ - ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), - } - return core.Result{Value: svc, OK: true} + prep := NewPrep() + prep.ServiceRuntime = core.NewServiceRuntime(c, AgentOptions{}) + + cfg := prep.loadAgentsConfig() + c.Config().Set("agents.concurrency", cfg.Concurrency) + c.Config().Set("agents.rates", cfg.Rates) + + RegisterHandlers(c, prep) + return core.Result{Value: prep, OK: true} } // In main: @@ -84,12 +74,13 @@ All capabilities registered as named Actions during OnStartup. Inspectable, comp func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { c := s.Core() - // Dispatch + // Dispatch & workspace c.Action("agentic.dispatch", s.handleDispatch) c.Action("agentic.prep", s.handlePrep) c.Action("agentic.status", s.handleStatus) c.Action("agentic.resume", s.handleResume) c.Action("agentic.scan", s.handleScan) + c.Action("agentic.watch", s.handleWatch) // Pipeline c.Action("agentic.qa", s.handleQA) @@ -107,11 +98,11 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { c.Action("agentic.pr.list", s.handlePRList) c.Action("agentic.pr.merge", s.handlePRMerge) - // Brain - c.Action("brain.recall", s.handleBrainRecall) - c.Action("brain.remember", s.handleBrainRemember) + // Review & Epic + c.Action("agentic.review-queue", s.handleReviewQueue) + c.Action("agentic.epic", s.handleEpic) - // Completion pipeline + // Completion pipeline — Task composition c.Task("agent.completion", core.Task{ Description: "QA → PR → Verify → Merge", Steps: []core.Step{ @@ -123,7 +114,10 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { }, }) + s.StartRunner() s.registerCommands(ctx) + s.registerWorkspaceCommands() + s.registerForgeCommands() return core.Result{OK: true} } ``` @@ -132,38 +126,35 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { ## 4. Completion Pipeline -When an agent completes, the Task runs sequentially. Async steps fire without blocking the queue drain. +When an agent completes, the IPC handler chain fires. Registered in `RegisterHandlers()`: -```go -c.RegisterAction(func(c *core.Core, msg core.Message) core.Result { - if ev, ok := msg.(messages.AgentCompleted); ok { - opts := core.NewOptions( - core.Option{Key: "repo", Value: ev.Repo}, - core.Option{Key: "workspace", Value: ev.Workspace}, - ) - c.PerformAsync("agent.completion", opts) - } - return core.Result{OK: true} -}) +``` +AgentCompleted → QA handler → QAResult +QAResult{Passed} → PR handler → PRCreated +PRCreated → Verify handler → PRMerged | PRNeedsReview +AgentCompleted → Ingest handler (findings → issues) +AgentCompleted → Poke handler (drain queue) ``` -Steps: QA (build+test) → Auto-PR (git push + Forge API) → Verify (test + merge). -Ingest and Poke run async — Poke drains the queue immediately. +All handlers use `c.ACTION(messages.X{})` — no ChannelNotifier, no callbacks. --- ## 5. Process Execution -All commands via `c.Process()`. No `os/exec`, no `proc.go`, no `ensureProcess()`. +All commands via `s.Core().Process()`. Returns `core.Result` — Value is always a string. ```go -// Git operations -func (s *PrepSubsystem) gitCmd(ctx context.Context, dir string, args ...string) core.Result { - return s.Core().Process().RunIn(ctx, dir, "git", args...) +func (s *PrepSubsystem) runCmd(ctx context.Context, dir, command string, args ...string) core.Result { + return s.Core().Process().RunIn(ctx, dir, command, args...) } -func (s *PrepSubsystem) gitOK(ctx context.Context, dir string, args ...string) bool { - return s.gitCmd(ctx, dir, args...).OK +func (s *PrepSubsystem) runCmdOK(ctx context.Context, dir, command string, args ...string) bool { + return s.runCmd(ctx, dir, command, args...).OK +} + +func (s *PrepSubsystem) gitCmd(ctx context.Context, dir string, args ...string) core.Result { + return s.runCmd(ctx, dir, "git", args...) } func (s *PrepSubsystem) gitOutput(ctx context.Context, dir string, args ...string) string { @@ -173,243 +164,199 @@ func (s *PrepSubsystem) gitOutput(ctx context.Context, dir string, args ...strin } ``` +go-process is fully Result-native. `Start`, `Run`, `StartWithOptions`, `RunWithOptions` all return `core.Result`. Value is `*Process` for Start, `string` for Run. OK=true guarantees the type. + --- ## 6. Status Management -Workspace status uses `WriteAtomic` for safe concurrent access + per-workspace mutex for read-modify-write: +Workspace status uses `WriteAtomic` + `JSONMarshalString` for safe concurrent access: ```go -// Write -s.Core().Fs().WriteAtomic(statusPath, core.JSONMarshalString(status)) - -// Read-modify-write with lock -s.withLock(wsDir, func() { - var st WorkspaceStatus - core.JSONUnmarshalString(s.Core().Fs().Read(statusPath).Value.(string), &st) - st.Status = "completed" - s.Core().Fs().WriteAtomic(statusPath, core.JSONMarshalString(st)) -}) +func writeStatus(wsDir string, status *WorkspaceStatus) error { + status.UpdatedAt = time.Now() + statusPath := core.JoinPath(wsDir, "status.json") + if r := fs.WriteAtomic(statusPath, core.JSONMarshalString(status)); !r.OK { + err, _ := r.Value.(error) + return core.E("writeStatus", "failed to write status", err) + } + return nil +} ``` --- ## 7. Filesystem -No `unsafe.Pointer`. Sandboxed by default, unrestricted when needed: +No `unsafe.Pointer`. Package-level unrestricted Fs via Core primitive: ```go -// Sandboxed to workspace -f := (&core.Fs{}).New(workspaceDir) - -// Full access when required -f := s.Core().Fs().NewUnrestricted() +var fs = (&core.Fs{}).NewUnrestricted() ``` --- -## 8. Validation and IDs +## 8. IPC Messages + +All inter-service communication via typed messages in `pkg/messages/`: ```go -// Validate input -if r := core.ValidateName(input.Repo); !r.OK { return r } -safe := core.SanitisePath(userInput) +// Agent lifecycle +messages.AgentStarted{Agent, Repo, Workspace} +messages.AgentCompleted{Agent, Repo, Workspace, Status} -// Generate unique identifiers -id := core.ID() // "id-42-a3f2b1" +// Pipeline +messages.QAResult{Workspace, Repo, Passed} +messages.PRCreated{Repo, Branch, PRURL, PRNum} +messages.PRMerged{Repo, PRURL, PRNum} +messages.PRNeedsReview{Repo, PRURL, PRNum, Reason} + +// Queue +messages.QueueDrained{Completed} +messages.PokeQueue{} + +// Monitor +messages.HarvestComplete{Repo, Branch, Files} +messages.HarvestRejected{Repo, Branch, Reason} +messages.InboxMessage{New, Total} ``` --- -## 9. Entitlements +## 9. Monitor + +Embeds `*core.ServiceRuntime[MonitorOptions]`. All notifications via `m.Core().ACTION(messages.X{})` — no ChannelNotifier interface. Git operations via `m.Core().Process()`. + +```go +func Register(c *core.Core) core.Result { + mon := New() + mon.ServiceRuntime = core.NewServiceRuntime(c, MonitorOptions{}) + + c.RegisterAction(func(c *core.Core, msg core.Message) core.Result { + switch ev := msg.(type) { + case messages.AgentCompleted: + mon.handleAgentCompleted(ev) + case messages.AgentStarted: + mon.handleAgentStarted(ev) + } + return core.Result{OK: true} + }) + + return core.Result{Value: mon, OK: true} +} +``` + +--- + +## 10. Setup + +Service with `*core.ServiceRuntime[SetupOptions]`. Detects project type, generates configs, scaffolds workspaces. + +```go +func Register(c *core.Core) core.Result { + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, SetupOptions{}), + } + return core.Result{Value: svc, OK: true} +} +``` + +--- + +## 11. Entitlements Actions are gated by `c.Entitled()` — checked automatically in `Action.Run()`. -For explicit gating with quantity checks: - ```go func (s *PrepSubsystem) handleDispatch(ctx context.Context, opts core.Options) core.Result { - // Concurrency limit e := s.Core().Entitled("agentic.concurrency", 1) if !e.Allowed { return core.Result{Value: core.E("dispatch", e.Reason, nil), OK: false} } - // ... dispatch agent ... - s.Core().RecordUsage("agentic.dispatch") return core.Result{OK: true} } ``` -Enables: SaaS tier gating, usage tracking, workspace isolation. +--- + +## 12. MCP — Action Aggregator + +MCP auto-exposes all registered Actions as tools via `c.Actions()`. Register an Action → it appears as an MCP tool. The API stream primitive (`c.API()`) handles transport. --- -## 10. MCP — Action Aggregator - -MCP auto-exposes all registered Actions as tools: - -```go -func (s *MCPService) OnStartup(ctx context.Context) core.Result { - for _, name := range s.Core().Actions() { - name := name // capture loop variable - action := s.Core().Action(name) - s.server.AddTool(mcp.Tool{ - Name: name, - Description: action.Description, - InputSchema: schemaFromOptions(action.Schema), - Handler: func(ctx context.Context, input map[string]any) (any, error) { - // Re-resolve action at call time (not captured pointer) - r := s.Core().Action(name).Run(ctx, optionsFromInput(input)) - if !r.OK { return nil, r.Value.(error) } - return r.Value, nil - }, - }) - } - return core.Result{OK: true} -} -``` - -Register an Action → it appears as an MCP tool. No hand-wiring. - ---- - -## 11. Remote Dispatch +## 13. Remote Dispatch Transparent local/remote via `host:action` syntax: ```go -// Local -r := c.RemoteAction("agentic.status", ctx, opts) - -// Remote — same API -r := c.RemoteAction("charon:agentic.dispatch", ctx, opts) - -// Web3 -r := c.RemoteAction("snider.lthn:brain.recall", ctx, opts) +r := c.RemoteAction("agentic.status", ctx, opts) // local +r := c.RemoteAction("charon:agentic.dispatch", ctx, opts) // remote +r := c.RemoteAction("snider.lthn:brain.recall", ctx, opts) // web3 ``` --- -## 12. JSON Serialisation +## 14. Quality Gates + +```bash +# No disallowed imports (source files only) +grep -rn '"os"\|"os/exec"\|"io"\|"fmt"\|"errors"\|"log"\|"encoding/json"\|"path/filepath"\|"unsafe"\|"strings"' *.go **/*.go \ + | grep -v _test.go + +# Test naming: TestFile_Function_{Good,Bad,Ugly} +grep -rn "^func Test" *_test.go **/*_test.go \ + | grep -v "Test[A-Z][a-z]*_.*_\(Good\|Bad\|Ugly\)" +``` + +--- + +## 15. Validation and IDs + +```go +if r := core.ValidateName(input.Repo); !r.OK { return r } +safe := core.SanitisePath(userInput) +id := core.ID() // "id-42-a3f2b1" +``` + +--- + +## 16. JSON Serialisation All JSON via Core primitives. No `encoding/json` import. ```go data := core.JSONMarshalString(status) -core.JSONUnmarshal(responseBytes, &result) +core.JSONUnmarshalString(jsonStr, &result) ``` --- -## 13. Test Strategy - -AX-7: `TestFile_Function_{Good,Bad,Ugly}` — 100% naming compliance target. - -``` -TestHandlers_CompletionPipeline_Good — QA+PR+Verify succeed, Poke fires -TestHandlers_CompletionPipeline_Bad — QA fails, chain stops -TestHandlers_CompletionPipeline_Ugly — handler panics, pipeline recovers -TestDispatch_Entitlement_Good — entitled workspace dispatches -TestDispatch_Entitlement_Bad — denied workspace gets error -TestPrep_GitCmd_Good — via c.Process() -TestStatus_WriteAtomic_Ugly — concurrent writes don't corrupt -TestMCP_ActionAggregator_Good — Actions appear as MCP tools -``` - ---- - -## 14. Error Handling and Logging - -All errors via `core.E()`. All logging via Core. No `fmt`, `errors`, or `log` imports. +## 17. Configuration ```go -// Structured errors -return core.E("dispatch.prep", "workspace not found", nil) -return core.E("dispatch.prep", core.Concat("repo ", repo, " invalid"), cause) - -// Error inspection -core.Operation(err) // "dispatch.prep" -core.ErrorMessage(err) // "workspace not found" -core.Root(err) // unwrap to root cause - -// Logging -core.Info("agent dispatched", "repo", repo, "agent", agent) -core.Warn("queue full", "pending", count) -core.Error("dispatch failed", "err", err) -core.Security("entitlement.denied", "action", action, "reason", reason) -``` - ---- - -## 15. Configuration - -```go -// Runtime settings c.Config().Set("agents.concurrency", 5) c.Config().String("workspace.root") c.Config().Int("agents.concurrency") - -// Feature flags c.Config().Enable("auto-merge") if c.Config().Enabled("auto-merge") { ... } ``` --- -## 16. Registry +## 18. Registry Use `Registry[T]` for any named collection. No `map[string]*T + sync.Mutex`. ```go -// Workspace status tracking workspaces := core.NewRegistry[*WorkspaceStatus]() workspaces.Set(wsDir, status) workspaces.Get(wsDir) workspaces.Each(func(dir string, st *WorkspaceStatus) { ... }) workspaces.Names() // insertion order - -// Cross-cutting queries via Core c.RegistryOf("actions").List("agentic.*") -c.RegistryOf("services").Names() -``` - ---- - -## 17. Stream Helpers - -No `io` import. Core wraps all stream operations: - -```go -// Read entire stream -r := c.Fs().ReadStream(path) -content := core.ReadAll(r.Value) - -// Write to stream -w := c.Fs().WriteStream(path) -core.WriteAll(w.Value, data) - -// Close any stream -core.CloseStream(handle) -``` - ---- - -## 18. Data and Drive - -```go -// Embedded assets (prompts, templates, personas) -r := c.Data().ReadString("prompts/coding.md") -c.Data().List("templates/") -c.Data().Mounts() // all mounted asset namespaces - -// Transport configuration -c.Drive().New(core.NewOptions( - core.Option{Key: "name", Value: "charon"}, - core.Option{Key: "transport", Value: "http://10.69.69.165:9101"}, -)) -c.Drive().Get("charon") ``` --- @@ -430,7 +377,34 @@ core.Trim(s) // not strings.TrimSpace --- -## 20. Comments (AX Principle 2) +## 20. Error Handling and Logging + +All errors via `core.E()`. All logging via Core. No `fmt`, `errors`, or `log` imports. + +```go +return core.E("dispatch.prep", "workspace not found", nil) +return core.E("dispatch.prep", core.Concat("repo ", repo, " invalid"), cause) +core.Info("agent dispatched", "repo", repo, "agent", agent) +core.Error("dispatch failed", "err", err) +core.Security("entitlement.denied", "action", action, "reason", reason) +``` + +--- + +## 21. Stream Helpers and Data + +```go +r := c.Data().ReadString("prompts/coding.md") +c.Data().List("templates/") +c.Drive().New(core.NewOptions( + core.Option{Key: "name", Value: "charon"}, + core.Option{Key: "transport", Value: "http://10.69.69.165:9101"}, +)) +``` + +--- + +## 22. Comments (AX Principle 2) Every exported function MUST have a usage-example comment: @@ -441,41 +415,11 @@ Every exported function MUST have a usage-example comment: func (s *PrepSubsystem) gitCmd(ctx context.Context, dir string, args ...string) core.Result { ``` -No exceptions. The comment is for every model that will ever read the code. - --- -## 21. Example Tests (AX Principle 7b) +## 23. Test Strategy (AX Principle 7) -One `{source}_example_test.go` per source file. Examples serve as test + documentation + godoc. - -```go -// file: dispatch_example_test.go - -func ExamplePrepSubsystem_handleDispatch() { - c := core.New(core.WithService(agentic.Register)) - r := c.Action("agentic.dispatch").Run(ctx, opts) - core.Println(r.OK) - // Output: true -} -``` - ---- - -## 22. Quality Gates (AX Principle 9) - -```bash -# No disallowed imports (all 10) -grep -rn '"os"\|"os/exec"\|"io"\|"fmt"\|"errors"\|"log"\|"encoding/json"\|"path/filepath"\|"unsafe"\|"strings"' *.go **/*.go \ - | grep -v _test.go - -# Test naming -grep -rn "^func Test" *_test.go **/*_test.go \ - | grep -v "Test[A-Z][a-z]*_.*_\(Good\|Bad\|Ugly\)" - -# String concat -grep -rn '" + \| + "' *.go **/*.go | grep -v _test.go | grep -v "//" -``` +`TestFile_Function_{Good,Bad,Ugly}` — 100% naming compliance target. --- @@ -484,10 +428,12 @@ grep -rn '" + \| + "' *.go **/*.go | grep -v _test.go | grep -v "//" | Package | RFC | Role | |---------|-----|------| | core/go | `core/go/docs/RFC.md` | Primitives — all 21 sections | -| go-process | `core/go-process/docs/RFC.md` | Process Action handlers | +| go-process | `core/go-process/docs/RFC.md` | Process Action handlers (Result-native) | --- ## Changelog +- 2026-03-26: WIP — net/http consolidated to transport.go (ONE file). net/url + io/fs eliminated. RFC-025 updated with 3 new quality gates (net/http, net/url, io/fs). 1:1 test + example test coverage. Array[T].Deduplicate replaces custom helpers. Remaining: remove dead `client` field from test literals, brain/provider.go Gin handler. +- 2026-03-25: Quality gates pass. Zero disallowed imports (all 10). encoding/json→Core JSON. path/filepath→Core Path. os→Core Env/Fs. io→Core ReadAll/WriteAll. go-process fully Result-native. ServiceRuntime on all subsystems. 22 named Actions + Task pipeline. ChannelNotifier→IPC. Reference docs synced. - 2026-03-25: Initial spec — written with full core/go v0.8.0 domain context. diff --git a/pkg/agentic/actions.go b/pkg/agentic/actions.go new file mode 100644 index 0000000..8ec9fb9 --- /dev/null +++ b/pkg/agentic/actions.go @@ -0,0 +1,302 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Named Action handlers for the agentic service. +// Each handler adapts (ctx, Options) → Result to call the existing MCP tool method. +// Registered during OnStartup — the Action registry IS the capability map. +// +// c.Action("agentic.dispatch").Run(ctx, opts) +// c.Actions() // all registered capabilities + +package agentic + +import ( + "context" + + core "dappco.re/go/core" +) + +// --- Dispatch & Workspace --- + +// handleDispatch dispatches a subagent to work on a repo task. +// +// r := c.Action("agentic.dispatch").Run(ctx, core.NewOptions( +// core.Option{Key: "repo", Value: "go-io"}, +// core.Option{Key: "task", Value: "Fix tests"}, +// )) +func (s *PrepSubsystem) handleDispatch(ctx context.Context, opts core.Options) core.Result { + input := DispatchInput{ + Repo: opts.String("repo"), + Task: opts.String("task"), + Agent: opts.String("agent"), + Issue: opts.Int("issue"), + } + _, out, err := s.dispatch(ctx, nil, input) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: out, OK: true} +} + +// handlePrep prepares a workspace without dispatching an agent. +// +// r := c.Action("agentic.prep").Run(ctx, core.NewOptions( +// core.Option{Key: "repo", Value: "go-io"}, +// core.Option{Key: "issue", Value: 42}, +// )) +func (s *PrepSubsystem) handlePrep(ctx context.Context, opts core.Options) core.Result { + input := PrepInput{ + Repo: opts.String("repo"), + Org: opts.String("org"), + Issue: opts.Int("issue"), + } + _, out, err := s.prepWorkspace(ctx, nil, input) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: out, OK: true} +} + +// handleStatus lists workspace statuses. +// +// r := c.Action("agentic.status").Run(ctx, core.NewOptions()) +func (s *PrepSubsystem) handleStatus(ctx context.Context, opts core.Options) core.Result { + input := StatusInput{ + Workspace: opts.String("workspace"), + Limit: opts.Int("limit"), + Status: opts.String("status"), + } + _, out, err := s.status(ctx, nil, input) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: out, OK: true} +} + +// handleResume resumes a blocked workspace. +// +// r := c.Action("agentic.resume").Run(ctx, core.NewOptions( +// core.Option{Key: "workspace", Value: "core/go-io/task-5"}, +// )) +func (s *PrepSubsystem) handleResume(ctx context.Context, opts core.Options) core.Result { + input := ResumeInput{ + Workspace: opts.String("workspace"), + Answer: opts.String("answer"), + } + _, out, err := s.resume(ctx, nil, input) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: out, OK: true} +} + +// handleScan scans forge repos for actionable issues. +// +// r := c.Action("agentic.scan").Run(ctx, core.NewOptions()) +func (s *PrepSubsystem) handleScan(ctx context.Context, opts core.Options) core.Result { + input := ScanInput{ + Org: opts.String("org"), + Limit: opts.Int("limit"), + } + _, out, err := s.scan(ctx, nil, input) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: out, OK: true} +} + +// handleWatch watches a workspace for completion. +// +// r := c.Action("agentic.watch").Run(ctx, core.NewOptions( +// core.Option{Key: "workspace", Value: "core/go-io/task-5"}, +// )) +func (s *PrepSubsystem) handleWatch(ctx context.Context, opts core.Options) core.Result { + input := WatchInput{} + _, out, err := s.watch(ctx, nil, input) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: out, OK: true} +} + +// --- Pipeline --- + +// handleQA runs build+test on a completed workspace. +// +// r := c.Action("agentic.qa").Run(ctx, core.NewOptions( +// core.Option{Key: "workspace", Value: "/path/to/workspace"}, +// )) +func (s *PrepSubsystem) handleQA(ctx context.Context, opts core.Options) core.Result { + wsDir := opts.String("workspace") + if wsDir == "" { + return core.Result{Value: core.E("agentic.qa", "workspace is required", nil), OK: false} + } + passed := s.runQA(wsDir) + return core.Result{Value: passed, OK: passed} +} + +// handleAutoPR creates a PR for a completed workspace. +// +// r := c.Action("agentic.auto-pr").Run(ctx, core.NewOptions( +// core.Option{Key: "workspace", Value: "/path/to/workspace"}, +// )) +func (s *PrepSubsystem) handleAutoPR(ctx context.Context, opts core.Options) core.Result { + wsDir := opts.String("workspace") + if wsDir == "" { + return core.Result{Value: core.E("agentic.auto-pr", "workspace is required", nil), OK: false} + } + s.autoCreatePR(wsDir) + return core.Result{OK: true} +} + +// handleVerify verifies and auto-merges a PR. +// +// r := c.Action("agentic.verify").Run(ctx, core.NewOptions( +// core.Option{Key: "workspace", Value: "/path/to/workspace"}, +// )) +func (s *PrepSubsystem) handleVerify(ctx context.Context, opts core.Options) core.Result { + wsDir := opts.String("workspace") + if wsDir == "" { + return core.Result{Value: core.E("agentic.verify", "workspace is required", nil), OK: false} + } + s.autoVerifyAndMerge(wsDir) + return core.Result{OK: true} +} + +// handleIngest creates issues from agent findings. +// +// r := c.Action("agentic.ingest").Run(ctx, core.NewOptions( +// core.Option{Key: "workspace", Value: "/path/to/workspace"}, +// )) +func (s *PrepSubsystem) handleIngest(ctx context.Context, opts core.Options) core.Result { + wsDir := opts.String("workspace") + if wsDir == "" { + return core.Result{Value: core.E("agentic.ingest", "workspace is required", nil), OK: false} + } + s.ingestFindings(wsDir) + return core.Result{OK: true} +} + +// handlePoke drains the dispatch queue. +// +// r := c.Action("agentic.poke").Run(ctx, core.NewOptions()) +func (s *PrepSubsystem) handlePoke(ctx context.Context, opts core.Options) core.Result { + s.Poke() + return core.Result{OK: true} +} + +// handleMirror mirrors agent branches to GitHub. +// +// r := c.Action("agentic.mirror").Run(ctx, core.NewOptions( +// core.Option{Key: "repo", Value: "go-io"}, +// )) +func (s *PrepSubsystem) handleMirror(ctx context.Context, opts core.Options) core.Result { + input := MirrorInput{ + Repo: opts.String("repo"), + } + _, out, err := s.mirror(ctx, nil, input) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: out, OK: true} +} + +// --- Forge --- + +// handleIssueGet retrieves a forge issue. +// +// r := c.Action("agentic.issue.get").Run(ctx, core.NewOptions( +// core.Option{Key: "repo", Value: "go-io"}, +// core.Option{Key: "number", Value: "42"}, +// )) +func (s *PrepSubsystem) handleIssueGet(ctx context.Context, opts core.Options) core.Result { + return s.cmdIssueGet(opts) +} + +// handleIssueList lists forge issues. +// +// r := c.Action("agentic.issue.list").Run(ctx, core.NewOptions( +// core.Option{Key: "_arg", Value: "go-io"}, +// )) +func (s *PrepSubsystem) handleIssueList(ctx context.Context, opts core.Options) core.Result { + return s.cmdIssueList(opts) +} + +// handleIssueCreate creates a forge issue. +// +// r := c.Action("agentic.issue.create").Run(ctx, core.NewOptions( +// core.Option{Key: "_arg", Value: "go-io"}, +// core.Option{Key: "title", Value: "Bug report"}, +// )) +func (s *PrepSubsystem) handleIssueCreate(ctx context.Context, opts core.Options) core.Result { + return s.cmdIssueCreate(opts) +} + +// handlePRGet retrieves a forge PR. +// +// r := c.Action("agentic.pr.get").Run(ctx, core.NewOptions( +// core.Option{Key: "_arg", Value: "go-io"}, +// core.Option{Key: "number", Value: "12"}, +// )) +func (s *PrepSubsystem) handlePRGet(ctx context.Context, opts core.Options) core.Result { + return s.cmdPRGet(opts) +} + +// handlePRList lists forge PRs. +// +// r := c.Action("agentic.pr.list").Run(ctx, core.NewOptions( +// core.Option{Key: "_arg", Value: "go-io"}, +// )) +func (s *PrepSubsystem) handlePRList(ctx context.Context, opts core.Options) core.Result { + return s.cmdPRList(opts) +} + +// handlePRMerge merges a forge PR. +// +// r := c.Action("agentic.pr.merge").Run(ctx, core.NewOptions( +// core.Option{Key: "_arg", Value: "go-io"}, +// core.Option{Key: "number", Value: "12"}, +// )) +func (s *PrepSubsystem) handlePRMerge(ctx context.Context, opts core.Options) core.Result { + return s.cmdPRMerge(opts) +} + +// --- Review --- + +// handleReviewQueue runs CodeRabbit review on a workspace. +// +// r := c.Action("agentic.review-queue").Run(ctx, core.NewOptions( +// core.Option{Key: "workspace", Value: "core/go-io/task-5"}, +// )) +func (s *PrepSubsystem) handleReviewQueue(ctx context.Context, opts core.Options) core.Result { + input := ReviewQueueInput{ + Limit: opts.Int("limit"), + Reviewer: opts.String("reviewer"), + DryRun: opts.Bool("dry_run"), + } + _, out, err := s.reviewQueue(ctx, nil, input) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: out, OK: true} +} + +// --- Epic --- + +// handleEpic creates an epic (multi-repo task breakdown). +// +// r := c.Action("agentic.epic").Run(ctx, core.NewOptions( +// core.Option{Key: "task", Value: "Update all repos to v0.8.0"}, +// )) +func (s *PrepSubsystem) handleEpic(ctx context.Context, opts core.Options) core.Result { + input := EpicInput{ + Repo: opts.String("repo"), + Org: opts.String("org"), + Title: opts.String("title"), + Body: opts.String("body"), + } + _, out, err := s.createEpic(ctx, nil, input) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: out, OK: true} +} diff --git a/pkg/agentic/actions_example_test.go b/pkg/agentic/actions_example_test.go new file mode 100644 index 0000000..2c4ce3a --- /dev/null +++ b/pkg/agentic/actions_example_test.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic_test + +import ( + "context" + + core "dappco.re/go/core" + "dappco.re/go/core/process" + + "dappco.re/go/agent/pkg/agentic" +) + +func ExampleRegister() { + c := core.New( + core.WithService(process.Register), + core.WithService(agentic.Register), + ) + c.ServiceStartup(context.Background(), nil) + + // All actions registered during OnStartup + core.Println(c.Action("agentic.dispatch").Exists()) + core.Println(c.Action("agentic.status").Exists()) + core.Println(c.Action("agentic.qa").Exists()) + // Output: + // true + // true + // true +} + +func ExampleRegister_actions() { + c := core.New( + core.WithService(process.Register), + core.WithService(agentic.Register), + ) + c.ServiceStartup(context.Background(), nil) + + actions := c.Actions() + core.Println(len(actions) > 0) + // Output: true +} + +func ExampleRegister_task() { + c := core.New( + core.WithService(process.Register), + core.WithService(agentic.Register), + ) + c.ServiceStartup(context.Background(), nil) + + // Completion pipeline registered as a Task + t := c.Task("agent.completion") + core.Println(t.Description) + // Output: QA → PR → Verify → Merge +} diff --git a/pkg/agentic/actions_test.go b/pkg/agentic/actions_test.go new file mode 100644 index 0000000..023fc8e --- /dev/null +++ b/pkg/agentic/actions_test.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "testing" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" +) + +func TestActions_HandleDispatch_Good(t *testing.T) { + s := newPrepWithProcess() + r := s.handleDispatch(context.Background(), core.NewOptions( + core.Option{Key: "repo", Value: "go-io"}, + core.Option{Key: "task", Value: "fix tests"}, + )) + // Will fail (no local clone) but exercises the handler path + assert.False(t, r.OK) +} + +func TestActions_HandleStatus_Good(t *testing.T) { + t.Setenv("CORE_WORKSPACE", t.TempDir()) + s := newPrepWithProcess() + r := s.handleStatus(context.Background(), core.NewOptions()) + assert.True(t, r.OK) +} + +func TestActions_HandlePoke_Good(t *testing.T) { + s := newPrepWithProcess() + s.pokeCh = make(chan struct{}, 1) + r := s.handlePoke(context.Background(), core.NewOptions()) + assert.True(t, r.OK) +} + +func TestActions_HandleQA_Bad_NoWorkspace(t *testing.T) { + s := newPrepWithProcess() + r := s.handleQA(context.Background(), core.NewOptions()) + assert.False(t, r.OK) +} + +func TestActions_HandleVerify_Bad_NoWorkspace(t *testing.T) { + s := newPrepWithProcess() + r := s.handleVerify(context.Background(), core.NewOptions()) + assert.False(t, r.OK) +} + +func TestActions_HandleIngest_Bad_NoWorkspace(t *testing.T) { + s := newPrepWithProcess() + r := s.handleIngest(context.Background(), core.NewOptions()) + assert.False(t, r.OK) +} diff --git a/pkg/agentic/auto_pr.go b/pkg/agentic/auto_pr.go index b70ad71..3744eec 100644 --- a/pkg/agentic/auto_pr.go +++ b/pkg/agentic/auto_pr.go @@ -23,7 +23,7 @@ func (s *PrepSubsystem) autoCreatePR(wsDir string) { // PRs target dev — agents never merge directly to main base := "dev" - out := gitOutput(ctx, repoDir, "log", "--oneline", "origin/"+base+"..HEAD") + out := s.gitOutput(ctx, repoDir, "log", "--oneline", "origin/"+base+"..HEAD") if out == "" { return } @@ -37,7 +37,7 @@ func (s *PrepSubsystem) autoCreatePR(wsDir string) { // Push the branch to forge forgeRemote := core.Sprintf("ssh://git@forge.lthn.ai:2223/%s/%s.git", org, st.Repo) - if !gitCmdOK(ctx, repoDir, "push", forgeRemote, st.Branch) { + if !s.gitCmdOK(ctx, repoDir, "push", forgeRemote, st.Branch) { if st2, err := ReadStatus(wsDir); err == nil { st2.Question = "PR push failed" writeStatus(wsDir, st2) @@ -89,5 +89,5 @@ func truncate(s string, max int) string { if len(s) <= max { return s } - return s[:max] + "..." + return core.Concat(s[:max], "...") } diff --git a/pkg/agentic/auto_pr_example_test.go b/pkg/agentic/auto_pr_example_test.go new file mode 100644 index 0000000..a2bff7c --- /dev/null +++ b/pkg/agentic/auto_pr_example_test.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import core "dappco.re/go/core" + +func Example_truncate() { + core.Println(truncate("hello world", 5)) + // Output: hello... +} diff --git a/pkg/agentic/auto_pr_test.go b/pkg/agentic/auto_pr_test.go index 9815b26..02383cd 100644 --- a/pkg/agentic/auto_pr_test.go +++ b/pkg/agentic/auto_pr_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -23,6 +24,7 @@ func TestAutoPR_AutoCreatePR_Bad(t *testing.T) { t.Setenv("CORE_WORKSPACE", root) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -103,6 +105,7 @@ func TestAutoPR_AutoCreatePR_Ugly(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(wsDir, "status.json"), data, 0o644)) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } diff --git a/pkg/agentic/commands.go b/pkg/agentic/commands.go index 399a91f..a3fbf8d 100644 --- a/pkg/agentic/commands.go +++ b/pkg/agentic/commands.go @@ -6,7 +6,6 @@ package agentic import ( "context" - "os" "dappco.re/go/agent/pkg/lib" core "dappco.re/go/core" @@ -14,7 +13,7 @@ import ( // registerCommands adds agentic CLI commands to Core's command tree. func (s *PrepSubsystem) registerCommands(ctx context.Context) { - c := s.core + c := s.Core() c.Command("run/task", core.Command{Description: "Run a single task end-to-end", Action: s.cmdRunTaskFactory(ctx)}) c.Command("run/orchestrator", core.Command{Description: "Run the queue orchestrator (standalone, no MCP)", Action: s.cmdOrchestratorFactory(ctx)}) c.Command("prep", core.Command{Description: "Prepare a workspace: clone repo, build prompt", Action: s.cmdPrep}) @@ -48,27 +47,27 @@ func (s *PrepSubsystem) cmdRunTask(ctx context.Context, opts core.Options) core. issue := parseIntStr(issueStr) - core.Print(os.Stderr, "core-agent run task") - core.Print(os.Stderr, " repo: %s/%s", org, repo) - core.Print(os.Stderr, " agent: %s", agent) + core.Print(nil, "core-agent run task") + core.Print(nil, " repo: %s/%s", org, repo) + core.Print(nil, " agent: %s", agent) if issue > 0 { - core.Print(os.Stderr, " issue: #%d", issue) + core.Print(nil, " issue: #%d", issue) } - core.Print(os.Stderr, " task: %s", task) - core.Print(os.Stderr, "") + core.Print(nil, " task: %s", task) + core.Print(nil, "") result := s.DispatchSync(ctx, DispatchSyncInput{ Org: org, Repo: repo, Agent: agent, Task: task, Issue: issue, }) if !result.OK { - core.Print(os.Stderr, "FAILED: %v", result.Error) + core.Print(nil, "FAILED: %v", result.Error) return core.Result{Value: result.Error, OK: false} } - core.Print(os.Stderr, "DONE: %s", result.Status) + core.Print(nil, "DONE: %s", result.Status) if result.PRURL != "" { - core.Print(os.Stderr, " PR: %s", result.PRURL) + core.Print(nil, " PR: %s", result.PRURL) } return core.Result{OK: true} } @@ -79,12 +78,12 @@ func (s *PrepSubsystem) cmdOrchestratorFactory(ctx context.Context) func(core.Op } func (s *PrepSubsystem) cmdOrchestrator(ctx context.Context, _ core.Options) core.Result { - core.Print(os.Stderr, "core-agent orchestrator running (pid %s)", core.Env("PID")) - core.Print(os.Stderr, " workspace: %s", WorkspaceRoot()) - core.Print(os.Stderr, " watching queue, draining on 30s tick + completion poke") + core.Print(nil, "core-agent orchestrator running (pid %s)", core.Env("PID")) + core.Print(nil, " workspace: %s", WorkspaceRoot()) + core.Print(nil, " watching queue, draining on 30s tick + completion poke") <-ctx.Done() - core.Print(os.Stderr, "orchestrator shutting down") + core.Print(nil, "orchestrator shutting down") return core.Result{OK: true} } @@ -143,27 +142,21 @@ func (s *PrepSubsystem) cmdPrep(opts core.Options) core.Result { func (s *PrepSubsystem) cmdStatus(opts core.Options) core.Result { wsRoot := WorkspaceRoot() - fsys := s.core.Fs() + fsys := s.Core().Fs() r := fsys.List(wsRoot) if !r.OK { core.Print(nil, "no workspaces found at %s", wsRoot) return core.Result{OK: true} } - entries := r.Value.([]os.DirEntry) - if len(entries) == 0 { + statusFiles := core.PathGlob(core.JoinPath(wsRoot, "*", "status.json")) + if len(statusFiles) == 0 { core.Print(nil, "no workspaces") return core.Result{OK: true} } - for _, e := range entries { - if !e.IsDir() { - continue - } - statusFile := core.JoinPath(wsRoot, e.Name(), "status.json") - if sr := fsys.Read(statusFile); sr.OK { - core.Print(nil, " %s", e.Name()) - } + for _, sf := range statusFiles { + core.Print(nil, " %s", core.PathBase(core.PathDir(sf))) } return core.Result{OK: true} } @@ -224,16 +217,15 @@ func (s *PrepSubsystem) cmdExtract(opts core.Options) core.Result { return core.Result{Value: err, OK: false} } - fsys := s.core.Fs() - r := fsys.List(target) - if r.OK { - for _, e := range r.Value.([]os.DirEntry) { - marker := " " - if e.IsDir() { - marker = "/" - } - core.Print(nil, " %s%s", e.Name(), marker) + fsys := s.Core().Fs() + paths := core.PathGlob(core.JoinPath(target, "*")) + for _, p := range paths { + name := core.PathBase(p) + marker := " " + if fsys.IsDir(p) { + marker = "/" } + core.Print(nil, " %s%s", name, marker) } core.Print(nil, "done") diff --git a/pkg/agentic/commands_example_test.go b/pkg/agentic/commands_example_test.go new file mode 100644 index 0000000..611a26d --- /dev/null +++ b/pkg/agentic/commands_example_test.go @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import core "dappco.re/go/core" + +func Example_parseIntStr() { + core.Println(parseIntStr("42")) + core.Println(parseIntStr("abc")) + core.Println(parseIntStr("")) + // Output: + // 42 + // 0 + // 0 +} diff --git a/pkg/agentic/commands_forge.go b/pkg/agentic/commands_forge.go index 6813ac3..c751727 100644 --- a/pkg/agentic/commands_forge.go +++ b/pkg/agentic/commands_forge.go @@ -28,7 +28,7 @@ func fmtIndex(n int64) string { return strconv.FormatInt(n, 10) } // registerForgeCommands adds Forge API commands to Core's command tree. func (s *PrepSubsystem) registerForgeCommands() { - c := s.core + c := s.Core() c.Command("issue/get", core.Command{Description: "Get a Forge issue", Action: s.cmdIssueGet}) c.Command("issue/list", core.Command{Description: "List Forge issues for a repo", Action: s.cmdIssueList}) c.Command("issue/comment", core.Command{Description: "Comment on a Forge issue", Action: s.cmdIssueComment}) diff --git a/pkg/agentic/commands_forge_example_test.go b/pkg/agentic/commands_forge_example_test.go new file mode 100644 index 0000000..4a45b9d --- /dev/null +++ b/pkg/agentic/commands_forge_example_test.go @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import core "dappco.re/go/core" + +func Example_parseForgeArgs() { + org, repo, num := parseForgeArgs(core.NewOptions( + core.Option{Key: "_arg", Value: "go-io"}, + core.Option{Key: "number", Value: "42"}, + )) + core.Println(org, repo, num) + // Output: core go-io 42 +} + +func Example_fmtIndex() { + core.Println(fmtIndex(42)) + // Output: 42 +} diff --git a/pkg/agentic/commands_test.go b/pkg/agentic/commands_test.go index 6e1b73d..7819bf6 100644 --- a/pkg/agentic/commands_test.go +++ b/pkg/agentic/commands_test.go @@ -33,7 +33,7 @@ func testPrepWithCore(t *testing.T, srv *httptest.Server) (*PrepSubsystem, *core } s := &PrepSubsystem{ - core: c, + ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), forge: f, forgeURL: "", forgeToken: "test-token", @@ -828,7 +828,7 @@ func TestCommands_CmdStatus_Bad_NoWorkspaceDir(t *testing.T) { c := core.New() s := &PrepSubsystem{ - core: c, + ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } diff --git a/pkg/agentic/commands_workspace.go b/pkg/agentic/commands_workspace.go index 30257cb..7cb64fe 100644 --- a/pkg/agentic/commands_workspace.go +++ b/pkg/agentic/commands_workspace.go @@ -5,14 +5,12 @@ package agentic import ( - "os" - core "dappco.re/go/core" ) // registerWorkspaceCommands adds workspace management commands. func (s *PrepSubsystem) registerWorkspaceCommands() { - c := s.core + c := s.Core() c.Command("workspace/list", core.Command{Description: "List all agent workspaces with status", Action: s.cmdWorkspaceList}) c.Command("workspace/clean", core.Command{Description: "Remove completed/failed/blocked workspaces", Action: s.cmdWorkspaceClean}) c.Command("workspace/dispatch", core.Command{Description: "Dispatch an agent to work on a repo task", Action: s.cmdWorkspaceDispatch}) @@ -20,27 +18,18 @@ func (s *PrepSubsystem) registerWorkspaceCommands() { func (s *PrepSubsystem) cmdWorkspaceList(opts core.Options) core.Result { wsRoot := WorkspaceRoot() - fsys := s.core.Fs() + fsys := s.Core().Fs() - r := fsys.List(wsRoot) - if !r.OK { - core.Print(nil, "no workspaces at %s", wsRoot) - return core.Result{OK: true} - } - - entries := r.Value.([]os.DirEntry) + statusFiles := core.PathGlob(core.JoinPath(wsRoot, "*", "status.json")) count := 0 - for _, e := range entries { - if !e.IsDir() { - continue - } - statusFile := core.JoinPath(wsRoot, e.Name(), "status.json") - if sr := fsys.Read(statusFile); sr.OK { + for _, sf := range statusFiles { + wsName := core.PathBase(core.PathDir(sf)) + if sr := fsys.Read(sf); sr.OK { content := sr.Value.(string) status := extractField(content, "status") repo := extractField(content, "repo") agent := extractField(content, "agent") - core.Print(nil, " %-8s %-8s %-10s %s", status, agent, repo, e.Name()) + core.Print(nil, " %-8s %-8s %-10s %s", status, agent, repo, wsName) count++ } } @@ -52,27 +41,18 @@ func (s *PrepSubsystem) cmdWorkspaceList(opts core.Options) core.Result { func (s *PrepSubsystem) cmdWorkspaceClean(opts core.Options) core.Result { wsRoot := WorkspaceRoot() - fsys := s.core.Fs() + fsys := s.Core().Fs() filter := opts.String("_arg") if filter == "" { filter = "all" } - r := fsys.List(wsRoot) - if !r.OK { - core.Print(nil, "no workspaces") - return core.Result{OK: true} - } - - entries := r.Value.([]os.DirEntry) + statusFiles := core.PathGlob(core.JoinPath(wsRoot, "*", "status.json")) var toRemove []string - for _, e := range entries { - if !e.IsDir() { - continue - } - statusFile := core.JoinPath(wsRoot, e.Name(), "status.json") - sr := fsys.Read(statusFile) + for _, sf := range statusFiles { + wsName := core.PathBase(core.PathDir(sf)) + sr := fsys.Read(sf) if !sr.OK { continue } @@ -81,19 +61,19 @@ func (s *PrepSubsystem) cmdWorkspaceClean(opts core.Options) core.Result { switch filter { case "all": if status == "completed" || status == "failed" || status == "blocked" || status == "merged" || status == "ready-for-review" { - toRemove = append(toRemove, e.Name()) + toRemove = append(toRemove, wsName) } case "completed": if status == "completed" || status == "merged" || status == "ready-for-review" { - toRemove = append(toRemove, e.Name()) + toRemove = append(toRemove, wsName) } case "failed": if status == "failed" { - toRemove = append(toRemove, e.Name()) + toRemove = append(toRemove, wsName) } case "blocked": if status == "blocked" { - toRemove = append(toRemove, e.Name()) + toRemove = append(toRemove, wsName) } } } diff --git a/pkg/agentic/commands_workspace_example_test.go b/pkg/agentic/commands_workspace_example_test.go new file mode 100644 index 0000000..b4d0988 --- /dev/null +++ b/pkg/agentic/commands_workspace_example_test.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import core "dappco.re/go/core" + +func Example_extractField() { + json := `{"status":"completed","repo":"go-io"}` + core.Println(extractField(json, "status")) + core.Println(extractField(json, "repo")) + // Output: + // completed + // go-io +} diff --git a/pkg/agentic/commands_workspace_test.go b/pkg/agentic/commands_workspace_test.go index b92e8e0..cc38ae8 100644 --- a/pkg/agentic/commands_workspace_test.go +++ b/pkg/agentic/commands_workspace_test.go @@ -77,7 +77,7 @@ func TestCommandsWorkspace_CmdWorkspaceList_Bad_NoWorkspaceRootDir(t *testing.T) c := core.New() s := &PrepSubsystem{ - core: c, + ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -108,7 +108,7 @@ func TestCommandsWorkspace_CmdWorkspaceList_Ugly_NonDirAndCorruptStatus(t *testi c := core.New() s := &PrepSubsystem{ - core: c, + ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -138,7 +138,7 @@ func TestCommandsWorkspace_CmdWorkspaceClean_Bad_UnknownFilterLeavesEverything(t c := core.New() s := &PrepSubsystem{ - core: c, + ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -175,7 +175,7 @@ func TestCommandsWorkspace_CmdWorkspaceClean_Ugly_MixedStatuses(t *testing.T) { c := core.New() s := &PrepSubsystem{ - core: c, + ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -204,7 +204,7 @@ func TestCommandsWorkspace_CmdWorkspaceDispatch_Ugly_AllFieldsSet(t *testing.T) c := core.New() s := &PrepSubsystem{ - core: c, + ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } diff --git a/pkg/agentic/dispatch.go b/pkg/agentic/dispatch.go index 38ae0ef..939c18f 100644 --- a/pkg/agentic/dispatch.go +++ b/pkg/agentic/dispatch.go @@ -151,11 +151,11 @@ func containerCommand(agentType, command string, args []string, repoDir, metaDir // Host access for Ollama (local models) "--add-host=host.docker.internal:host-gateway", // Workspace: repo + meta - "-v", repoDir + ":/workspace", - "-v", metaDir + ":/workspace/.meta", + "-v", core.Concat(repoDir, ":/workspace"), + "-v", core.Concat(metaDir, ":/workspace/.meta"), "-w", "/workspace", // Auth: agent configs only — NO SSH keys, git push runs on host - "-v", core.JoinPath(home, ".codex") + ":/home/dev/.codex:ro", + "-v", core.Concat(core.JoinPath(home, ".codex"), ":/home/dev/.codex:ro"), // API keys — passed by name, Docker resolves from host env "-e", "OPENAI_API_KEY", "-e", "ANTHROPIC_API_KEY", @@ -272,13 +272,13 @@ func (s *PrepSubsystem) stopIssueTracking(wsDir string) { // broadcastStart emits IPC + audit events for agent start. func (s *PrepSubsystem) broadcastStart(agent, wsDir string) { - if s.core != nil { - st, _ := ReadStatus(wsDir) - repo := "" - if st != nil { - repo = st.Repo - } - s.core.ACTION(messages.AgentStarted{ + st, _ := ReadStatus(wsDir) + repo := "" + if st != nil { + repo = st.Repo + } + if s.ServiceRuntime != nil { + s.Core().ACTION(messages.AgentStarted{ Agent: agent, Repo: repo, Workspace: core.PathBase(wsDir), }) } @@ -288,13 +288,13 @@ func (s *PrepSubsystem) broadcastStart(agent, wsDir string) { // broadcastComplete emits IPC + audit events for agent completion. func (s *PrepSubsystem) broadcastComplete(agent, wsDir, finalStatus string) { emitCompletionEvent(agent, core.PathBase(wsDir), finalStatus) - if s.core != nil { + if s.ServiceRuntime != nil { st, _ := ReadStatus(wsDir) repo := "" if st != nil { repo = st.Repo } - s.core.ACTION(messages.AgentCompleted{ + s.Core().ACTION(messages.AgentCompleted{ Agent: agent, Repo: repo, Workspace: core.PathBase(wsDir), Status: finalStatus, }) @@ -352,15 +352,16 @@ func (s *PrepSubsystem) spawnAgent(agent, prompt, wsDir string) (int, string, er agentBase := core.SplitN(agent, ":", 2)[0] command, args = containerCommand(agentBase, command, args, repoDir, metaDir) - proc, err := process.StartWithOptions(context.Background(), process.RunOptions{ + sr := process.StartWithOptions(context.Background(), process.RunOptions{ Command: command, Args: args, Dir: repoDir, Detach: true, }) - if err != nil { - return 0, "", core.E("dispatch.spawnAgent", "failed to spawn "+agent, err) + if !sr.OK { + return 0, "", core.E("dispatch.spawnAgent", core.Concat("failed to spawn ", agent), nil) } + proc := sr.Value.(*process.Process) proc.CloseStdin() pid := proc.Info().PID @@ -389,7 +390,7 @@ func (s *PrepSubsystem) runQA(wsDir string) bool { {"go", "vet", "./..."}, {"go", "test", "./...", "-count=1", "-timeout", "120s"}, } { - if !runCmdOK(ctx, repoDir, args[0], args[1:]...) { + if !s.runCmdOK(ctx, repoDir, args[0], args[1:]...) { core.Warn("QA failed", "cmd", core.Join(" ", args...)) return false } @@ -398,17 +399,17 @@ func (s *PrepSubsystem) runQA(wsDir string) bool { } if fs.IsFile(core.JoinPath(repoDir, "composer.json")) { - if !runCmdOK(ctx, repoDir, "composer", "install", "--no-interaction") { + if !s.runCmdOK(ctx, repoDir, "composer", "install", "--no-interaction") { return false } - return runCmdOK(ctx, repoDir, "composer", "test") + return s.runCmdOK(ctx, repoDir, "composer", "test") } if fs.IsFile(core.JoinPath(repoDir, "package.json")) { - if !runCmdOK(ctx, repoDir, "npm", "install") { + if !s.runCmdOK(ctx, repoDir, "npm", "install") { return false } - return runCmdOK(ctx, repoDir, "npm", "test") + return s.runCmdOK(ctx, repoDir, "npm", "test") } return true diff --git a/pkg/agentic/dispatch_example_test.go b/pkg/agentic/dispatch_example_test.go new file mode 100644 index 0000000..f911acd --- /dev/null +++ b/pkg/agentic/dispatch_example_test.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + core "dappco.re/go/core" +) + +func Example_detectFinalStatus() { + dir := (&core.Fs{}).NewUnrestricted().TempDir("example-ws") + defer (&core.Fs{}).NewUnrestricted().DeleteAll(dir) + + // Exit code 0 → completed + status, _ := detectFinalStatus(dir, 0, "exited") + core.Println(status) + // Output: completed +} + +func Example_detectFinalStatus_failed() { + dir := (&core.Fs{}).NewUnrestricted().TempDir("example-ws") + defer (&core.Fs{}).NewUnrestricted().DeleteAll(dir) + + // Non-zero exit → failed + status, _ := detectFinalStatus(dir, 1, "exited") + core.Println(status) + // Output: failed +} diff --git a/pkg/agentic/dispatch_sync_example_test.go b/pkg/agentic/dispatch_sync_example_test.go new file mode 100644 index 0000000..f03de79 --- /dev/null +++ b/pkg/agentic/dispatch_sync_example_test.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import core "dappco.re/go/core" + +func Example_containerCommand() { + cmd, args := containerCommand("codex", "codex", []string{"--model", "gpt-5.4"}, "/workspace", "/meta") + core.Println(cmd) + core.Println(len(args) > 0) + // Output: + // docker + // true +} diff --git a/pkg/agentic/dispatch_sync_test.go b/pkg/agentic/dispatch_sync_test.go new file mode 100644 index 0000000..6575749 --- /dev/null +++ b/pkg/agentic/dispatch_sync_test.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDispatchSync_ContainerCommand_Good(t *testing.T) { + cmd, args := containerCommand("codex", "codex", []string{"--model", "gpt-5.4"}, "/workspace", "/meta") + assert.Equal(t, "docker", cmd) + assert.Contains(t, args, "run") +} + +func TestDispatchSync_ContainerCommand_Bad_UnknownAgent(t *testing.T) { + cmd, args := containerCommand("unknown", "unknown", nil, "/workspace", "/meta") + assert.Equal(t, "docker", cmd) + assert.NotEmpty(t, args) +} + +func TestDispatchSync_ContainerCommand_Ugly_EmptyArgs(t *testing.T) { + assert.NotPanics(t, func() { + containerCommand("codex", "codex", nil, "", "") + }) +} diff --git a/pkg/agentic/dispatch_test.go b/pkg/agentic/dispatch_test.go index 1920df6..6ec45f1 100644 --- a/pkg/agentic/dispatch_test.go +++ b/pkg/agentic/dispatch_test.go @@ -95,7 +95,7 @@ func TestDispatch_DetectFinalStatus_Ugly(t *testing.T) { // --- trackFailureRate --- func TestDispatch_TrackFailureRate_Good(t *testing.T) { - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: map[string]int{"codex": 2}} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: map[string]int{"codex": 2}} // Success resets count triggered := s.trackFailureRate("codex", "completed", time.Now().Add(-10*time.Second)) @@ -104,7 +104,7 @@ func TestDispatch_TrackFailureRate_Good(t *testing.T) { } func TestDispatch_TrackFailureRate_Bad(t *testing.T) { - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: map[string]int{"codex": 2}} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: map[string]int{"codex": 2}} // 3rd fast failure triggers backoff triggered := s.trackFailureRate("codex", "failed", time.Now().Add(-10*time.Second)) @@ -113,7 +113,7 @@ func TestDispatch_TrackFailureRate_Bad(t *testing.T) { } func TestDispatch_TrackFailureRate_Ugly(t *testing.T) { - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := newPrepWithProcess() // Slow failure (>60s) resets count instead of incrementing s.failCount["codex"] = 2 @@ -138,17 +138,17 @@ func TestDispatch_StartIssueTracking_Good(t *testing.T) { data, _ := json.Marshal(st) os.WriteFile(filepath.Join(dir, "status.json"), data, 0o644) - s := &PrepSubsystem{forge: forge.NewForge(srv.URL, "tok"), backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "tok"), backoff: make(map[string]time.Time), failCount: make(map[string]int)} s.startIssueTracking(dir) } func TestDispatch_StartIssueTracking_Bad(t *testing.T) { // No forge — returns early - s := &PrepSubsystem{forge: nil, backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: nil, backoff: make(map[string]time.Time), failCount: make(map[string]int)} s.startIssueTracking(t.TempDir()) // No status file - s2 := &PrepSubsystem{forge: nil, backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s2 := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: nil, backoff: make(map[string]time.Time), failCount: make(map[string]int)} s2.startIssueTracking(t.TempDir()) } @@ -159,7 +159,7 @@ func TestDispatch_StartIssueTracking_Ugly(t *testing.T) { data, _ := json.Marshal(st) os.WriteFile(filepath.Join(dir, "status.json"), data, 0o644) - s := &PrepSubsystem{forge: forge.NewForge("http://invalid", "tok"), backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge("http://invalid", "tok"), backoff: make(map[string]time.Time), failCount: make(map[string]int)} s.startIssueTracking(dir) // no issue → skips API call } @@ -176,12 +176,12 @@ func TestDispatch_StopIssueTracking_Good(t *testing.T) { data, _ := json.Marshal(st) os.WriteFile(filepath.Join(dir, "status.json"), data, 0o644) - s := &PrepSubsystem{forge: forge.NewForge(srv.URL, "tok"), backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "tok"), backoff: make(map[string]time.Time), failCount: make(map[string]int)} s.stopIssueTracking(dir) } func TestDispatch_StopIssueTracking_Bad(t *testing.T) { - s := &PrepSubsystem{forge: nil, backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: nil, backoff: make(map[string]time.Time), failCount: make(map[string]int)} s.stopIssueTracking(t.TempDir()) } @@ -192,7 +192,7 @@ func TestDispatch_StopIssueTracking_Ugly(t *testing.T) { data, _ := json.Marshal(st) os.WriteFile(filepath.Join(dir, "status.json"), data, 0o644) - s := &PrepSubsystem{forge: forge.NewForge("http://invalid", "tok"), backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge("http://invalid", "tok"), backoff: make(map[string]time.Time), failCount: make(map[string]int)} s.stopIssueTracking(dir) } @@ -208,20 +208,20 @@ func TestDispatch_BroadcastStart_Good(t *testing.T) { os.WriteFile(filepath.Join(wsDir, "status.json"), data, 0o644) c := core.New() - s := &PrepSubsystem{core: c, backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int)} s.broadcastStart("codex", wsDir) } func TestDispatch_BroadcastStart_Bad(t *testing.T) { // No Core — should not panic - s := &PrepSubsystem{core: nil, backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: nil, backoff: make(map[string]time.Time), failCount: make(map[string]int)} s.broadcastStart("codex", t.TempDir()) } func TestDispatch_BroadcastStart_Ugly(t *testing.T) { // No status file — broadcasts with empty repo c := core.New() - s := &PrepSubsystem{core: c, backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int)} s.broadcastStart("codex", t.TempDir()) } @@ -237,19 +237,19 @@ func TestDispatch_BroadcastComplete_Good(t *testing.T) { os.WriteFile(filepath.Join(wsDir, "status.json"), data, 0o644) c := core.New() - s := &PrepSubsystem{core: c, backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int)} s.broadcastComplete("codex", wsDir, "completed") } func TestDispatch_BroadcastComplete_Bad(t *testing.T) { - s := &PrepSubsystem{core: nil, backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: nil, backoff: make(map[string]time.Time), failCount: make(map[string]int)} s.broadcastComplete("codex", t.TempDir(), "failed") } func TestDispatch_BroadcastComplete_Ugly(t *testing.T) { // No status file c := core.New() - s := &PrepSubsystem{core: c, backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int)} s.broadcastComplete("codex", t.TempDir(), "completed") } @@ -269,7 +269,7 @@ func TestDispatch_OnAgentComplete_Good(t *testing.T) { data, _ := json.Marshal(st) os.WriteFile(filepath.Join(wsDir, "status.json"), data, 0o644) - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := newPrepWithProcess() outputFile := filepath.Join(metaDir, "agent-codex.log") s.onAgentComplete("codex", wsDir, outputFile, 0, "completed", "test output") @@ -296,7 +296,7 @@ func TestDispatch_OnAgentComplete_Bad(t *testing.T) { data, _ := json.Marshal(st) os.WriteFile(filepath.Join(wsDir, "status.json"), data, 0o644) - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := newPrepWithProcess() s.onAgentComplete("codex", wsDir, filepath.Join(metaDir, "agent-codex.log"), 1, "failed", "error") updated, _ := ReadStatus(wsDir) @@ -319,7 +319,7 @@ func TestDispatch_OnAgentComplete_Ugly(t *testing.T) { data, _ := json.Marshal(st) os.WriteFile(filepath.Join(wsDir, "status.json"), data, 0o644) - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := newPrepWithProcess() s.onAgentComplete("codex", wsDir, filepath.Join(metaDir, "agent-codex.log"), 0, "completed", "") updated, _ := ReadStatus(wsDir) @@ -340,7 +340,7 @@ func TestDispatch_RunQA_Good(t *testing.T) { os.WriteFile(filepath.Join(repoDir, "go.mod"), []byte("module testmod\n\ngo 1.22\n"), 0o644) os.WriteFile(filepath.Join(repoDir, "main.go"), []byte("package main\nfunc main() {}\n"), 0o644) - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := newPrepWithProcess() assert.True(t, s.runQA(wsDir)) } @@ -353,7 +353,7 @@ func TestDispatch_RunQA_Bad(t *testing.T) { os.WriteFile(filepath.Join(repoDir, "go.mod"), []byte("module testmod\n\ngo 1.22\n"), 0o644) os.WriteFile(filepath.Join(repoDir, "main.go"), []byte("package main\nfunc main( {\n}\n"), 0o644) - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := newPrepWithProcess() assert.False(t, s.runQA(wsDir)) // PHP project — composer not available @@ -370,7 +370,7 @@ func TestDispatch_RunQA_Ugly(t *testing.T) { wsDir := t.TempDir() os.MkdirAll(filepath.Join(wsDir, "repo"), 0o755) - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := newPrepWithProcess() assert.True(t, s.runQA(wsDir)) // Go vet failure (compiles but bad printf) @@ -408,10 +408,10 @@ func TestDispatch_Dispatch_Good(t *testing.T) { exec.Command("git", "-C", srcRepo, "add", ".").Run() exec.Command("git", "-C", srcRepo, "commit", "-m", "init").Run() - s := &PrepSubsystem{ - forge: forge.NewForge(forgeSrv.URL, "tok"), codePath: filepath.Dir(filepath.Dir(srcRepo)), - client: forgeSrv.Client(), backoff: make(map[string]time.Time), failCount: make(map[string]int), - } + s := newPrepWithProcess() + s.forge = forge.NewForge(forgeSrv.URL, "tok") + s.codePath = filepath.Dir(filepath.Dir(srcRepo)) + s.client = forgeSrv.Client() _, out, err := s.dispatch(context.Background(), nil, DispatchInput{ Repo: "go-io", Task: "Fix stuff", Issue: 42, DryRun: true, @@ -423,7 +423,7 @@ func TestDispatch_Dispatch_Good(t *testing.T) { } func TestDispatch_Dispatch_Bad(t *testing.T) { - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := newPrepWithProcess() // No repo _, _, err := s.dispatch(context.Background(), nil, DispatchInput{Task: "do"}) @@ -441,7 +441,7 @@ func TestDispatch_Dispatch_Ugly(t *testing.T) { t.Setenv("CORE_WORKSPACE", root) // Prep fails (no local clone) - s := &PrepSubsystem{codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int)} _, _, err := s.dispatch(context.Background(), nil, DispatchInput{ Repo: "nonexistent", Task: "do", Issue: 1, }) diff --git a/pkg/agentic/epic.go b/pkg/agentic/epic.go index 6295d21..5ddf7ea 100644 --- a/pkg/agentic/epic.go +++ b/pkg/agentic/epic.go @@ -3,10 +3,7 @@ package agentic import ( - "bytes" "context" - "encoding/json" - "net/http" core "dappco.re/go/core" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -159,27 +156,18 @@ func (s *PrepSubsystem) createIssue(ctx context.Context, org, repo, title, body payload["labels"] = labelIDs } - data, _ := json.Marshal(payload) + data := core.JSONMarshalString(payload) url := core.Sprintf("%s/api/v1/repos/%s/%s/issues", s.forgeURL, org, repo) - req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "token "+s.forgeToken) - - resp, err := s.client.Do(req) - if err != nil { - return ChildRef{}, core.E("createIssue", "create issue request failed", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 201 { - return ChildRef{}, core.E("createIssue", core.Sprintf("create issue returned %d", resp.StatusCode), nil) + r := HTTPPost(ctx, url, data, s.forgeToken, "token") + if !r.OK { + return ChildRef{}, core.E("createIssue", "create issue request failed", nil) } var result struct { Number int `json:"number"` HTMLURL string `json:"html_url"` } - json.NewDecoder(resp.Body).Decode(&result) + core.JSONUnmarshalString(r.Value.(string), &result) return ChildRef{ Number: result.Number, @@ -196,16 +184,8 @@ func (s *PrepSubsystem) resolveLabelIDs(ctx context.Context, org, repo string, n // Fetch existing labels url := core.Sprintf("%s/api/v1/repos/%s/%s/labels?limit=50", s.forgeURL, org, repo) - req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) - req.Header.Set("Authorization", "token "+s.forgeToken) - - resp, err := s.client.Do(req) - if err != nil { - return nil - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { + r := HTTPGet(ctx, url, s.forgeToken, "token") + if !r.OK { return nil } @@ -213,7 +193,7 @@ func (s *PrepSubsystem) resolveLabelIDs(ctx context.Context, org, repo string, n ID int64 `json:"id"` Name string `json:"name"` } - json.NewDecoder(resp.Body).Decode(&existing) + core.JSONUnmarshalString(r.Value.(string), &existing) nameToID := make(map[string]int64) for _, l := range existing { @@ -249,29 +229,20 @@ func (s *PrepSubsystem) createLabel(ctx context.Context, org, repo, name string) colour = "#6b7280" } - payload, _ := json.Marshal(map[string]string{ + payload := core.JSONMarshalString(map[string]string{ "name": name, "color": colour, }) url := core.Sprintf("%s/api/v1/repos/%s/%s/labels", s.forgeURL, org, repo) - req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "token "+s.forgeToken) - - resp, err := s.client.Do(req) - if err != nil { - return 0 - } - defer resp.Body.Close() - - if resp.StatusCode != 201 { + r := HTTPPost(ctx, url, payload, s.forgeToken, "token") + if !r.OK { return 0 } var result struct { ID int64 `json:"id"` } - json.NewDecoder(resp.Body).Decode(&result) + core.JSONUnmarshalString(r.Value.(string), &result) return result.ID } diff --git a/pkg/agentic/epic_example_test.go b/pkg/agentic/epic_example_test.go new file mode 100644 index 0000000..ee03a11 --- /dev/null +++ b/pkg/agentic/epic_example_test.go @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import core "dappco.re/go/core" + +func ExampleEpicInput() { + input := EpicInput{ + Repo: "go-io", + Title: "Port agentic plans", + Tasks: []string{"Read PHP flow", "Implement Go MCP tools"}, + } + core.Println(input.Repo) + core.Println(len(input.Tasks)) + // Output: + // go-io + // 2 +} diff --git a/pkg/agentic/epic_test.go b/pkg/agentic/epic_test.go index b0bcc50..30c8c3a 100644 --- a/pkg/agentic/epic_test.go +++ b/pkg/agentic/epic_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + core "dappco.re/go/core" "dappco.re/go/core/forge" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -128,6 +129,7 @@ func itoa(n int) string { func newTestSubsystem(t *testing.T, srv *httptest.Server) *PrepSubsystem { t.Helper() s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -178,6 +180,7 @@ func TestEpic_CreateIssue_Bad_ServerDown(t *testing.T) { srv.Close() // immediately close s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: &http.Client{}, @@ -196,6 +199,7 @@ func TestEpic_CreateIssue_Bad_Non201Response(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: srv.Client(), @@ -243,6 +247,7 @@ func TestEpic_ResolveLabelIDs_Bad_ServerError(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: srv.Client(), @@ -278,6 +283,7 @@ func TestEpic_CreateLabel_Bad_ServerDown(t *testing.T) { srv.Close() s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: &http.Client{}, @@ -317,6 +323,7 @@ func TestEpic_CreateEpic_Bad_NoTasks(t *testing.T) { func TestEpic_CreateEpic_Bad_NoToken(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeToken: "", backoff: make(map[string]time.Time), failCount: make(map[string]int), diff --git a/pkg/agentic/events.go b/pkg/agentic/events.go index e549e84..99927c5 100644 --- a/pkg/agentic/events.go +++ b/pkg/agentic/events.go @@ -3,8 +3,6 @@ package agentic import ( - "encoding/json" - "io" "time" core "dappco.re/go/core" @@ -34,19 +32,14 @@ func emitEvent(eventType, agent, workspace, status string) { Timestamp: time.Now().UTC().Format(time.RFC3339), } - data, err := json.Marshal(event) - if err != nil { - return - } + line := core.Concat(core.JSONMarshalString(event), "\n") // Append to events log r := fs.Append(eventsFile) if !r.OK { return } - wc := r.Value.(io.WriteCloser) - defer wc.Close() - wc.Write(append(data, '\n')) + core.WriteAll(r.Value, line) } // emitStartEvent logs that an agent has been spawned. diff --git a/pkg/agentic/events_example_test.go b/pkg/agentic/events_example_test.go new file mode 100644 index 0000000..fad4b9f --- /dev/null +++ b/pkg/agentic/events_example_test.go @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + core "dappco.re/go/core" +) + +func Example_emitStartEvent() { + // Events are appended to workspace/events.jsonl + // This exercises the path without requiring a real workspace + root := WorkspaceRoot() + core.Println(core.HasSuffix(root, "workspace")) + // Output: true +} diff --git a/pkg/agentic/events_test.go b/pkg/agentic/events_test.go new file mode 100644 index 0000000..c241509 --- /dev/null +++ b/pkg/agentic/events_test.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "testing" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" +) + +func TestEvents_EmitEvent_Good(t *testing.T) { + root := t.TempDir() + t.Setenv("CORE_WORKSPACE", root) + fs.EnsureDir(core.JoinPath(root, "workspace")) + + assert.NotPanics(t, func() { + emitStartEvent("codex", "ws-1") + }) +} + +func TestEvents_EmitEvent_Bad_NoWorkspace(t *testing.T) { + t.Setenv("CORE_WORKSPACE", "/nonexistent") + assert.NotPanics(t, func() { + emitCompletionEvent("codex", "ws-1", "completed") + }) +} + +func TestEvents_EmitEvent_Ugly_AllEmpty(t *testing.T) { + assert.NotPanics(t, func() { + emitEvent("", "", "", "") + }) +} diff --git a/pkg/agentic/handlers_example_test.go b/pkg/agentic/handlers_example_test.go new file mode 100644 index 0000000..d0fb659 --- /dev/null +++ b/pkg/agentic/handlers_example_test.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + core "dappco.re/go/core" +) + +func Example_resolveWorkspace() { + // Non-existent workspace → empty string + resolved := resolveWorkspace("nonexistent/workspace") + core.Println(resolved == "") + // Output: true +} diff --git a/pkg/agentic/handlers_test.go b/pkg/agentic/handlers_test.go index 1932096..6e42fc3 100644 --- a/pkg/agentic/handlers_test.go +++ b/pkg/agentic/handlers_test.go @@ -22,6 +22,7 @@ func newCoreForHandlerTests(t *testing.T) (*core.Core, *PrepSubsystem) { t.Setenv("CORE_WORKSPACE", root) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), pokeCh: make(chan struct{}, 1), backoff: make(map[string]time.Time), @@ -29,7 +30,7 @@ func newCoreForHandlerTests(t *testing.T) (*core.Core, *PrepSubsystem) { } c := core.New() - s.core = c + s.ServiceRuntime = core.NewServiceRuntime(c, AgentOptions{}) RegisterHandlers(c, s) return c, s @@ -51,7 +52,7 @@ func TestHandlers_RegisterHandlers_Good_PokeOnCompletion(t *testing.T) { } // Send AgentCompleted — should trigger poke - s.core.ACTION(messages.AgentCompleted{ + s.Core().ACTION(messages.AgentCompleted{ Workspace: "nonexistent", Repo: "test", Status: "completed", @@ -155,7 +156,7 @@ func TestCommandsForge_RegisterForgeCommands_Good(t *testing.T) { t.Setenv("CORE_WORKSPACE", root) s := &PrepSubsystem{ - core: core.New(), + ServiceRuntime: core.NewServiceRuntime(core.New(), AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -168,7 +169,7 @@ func TestCommandsWorkspace_RegisterWorkspaceCommands_Good(t *testing.T) { t.Setenv("CORE_WORKSPACE", root) s := &PrepSubsystem{ - core: core.New(), + ServiceRuntime: core.NewServiceRuntime(core.New(), AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -183,7 +184,7 @@ func TestCommands_RegisterCommands_Good(t *testing.T) { defer cancel() s := &PrepSubsystem{ - core: core.New(), + ServiceRuntime: core.NewServiceRuntime(core.New(), AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -207,8 +208,8 @@ func TestPrep_OnStartup_Good_Registers(t *testing.T) { c := core.New() s.SetCore(c) - err := s.OnStartup(context.Background()) - assert.NoError(t, err) + r := s.OnStartup(context.Background()) + assert.True(t, r.OK) } // --- RegisterTools (exercises all register*Tool functions) --- @@ -228,6 +229,7 @@ func TestPrep_RegisterTools_Bad(t *testing.T) { // RegisterTools on prep without Core — should still register tools srv := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } diff --git a/pkg/agentic/ingest.go b/pkg/agentic/ingest.go index ed5edb3..10fe554 100644 --- a/pkg/agentic/ingest.go +++ b/pkg/agentic/ingest.go @@ -3,9 +3,7 @@ package agentic import ( - "bytes" - "encoding/json" - "net/http" + "context" core "dappco.re/go/core" ) @@ -56,7 +54,7 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) { // Truncate body to reasonable size for issue description description := body if len(description) > 10000 { - description = description[:10000] + "\n\n... (truncated, see full log in workspace)" + description = core.Concat(description[:10000], "\n\n... (truncated, see full log in workspace)") } s.createIssueViaAPI(st.Repo, title, description, issueType, priority, "scan") @@ -96,7 +94,7 @@ func (s *PrepSubsystem) createIssueViaAPI(repo, title, description, issueType, p } apiKey := core.Trim(r.Value.(string)) - payload, _ := json.Marshal(map[string]string{ + payload := core.JSONMarshalString(map[string]string{ "title": title, "description": description, "type": issueType, @@ -104,14 +102,5 @@ func (s *PrepSubsystem) createIssueViaAPI(repo, title, description, issueType, p "reporter": "cladius", }) - req, _ := http.NewRequest("POST", s.brainURL+"/v1/issues", bytes.NewReader(payload)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", "Bearer "+apiKey) - - resp, err := s.client.Do(req) - if err != nil { - return - } - resp.Body.Close() + HTTPPost(context.Background(), core.Concat(s.brainURL, "/v1/issues"), payload, apiKey, "Bearer") } diff --git a/pkg/agentic/ingest_example_test.go b/pkg/agentic/ingest_example_test.go new file mode 100644 index 0000000..7c1a8ad --- /dev/null +++ b/pkg/agentic/ingest_example_test.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import core "dappco.re/go/core" + +func Example_ingestWorkspaceRoot() { + root := WorkspaceRoot() + core.Println(core.HasSuffix(root, "workspace")) + // Output: true +} diff --git a/pkg/agentic/ingest_test.go b/pkg/agentic/ingest_test.go index 90dc60d..b4bf55a 100644 --- a/pkg/agentic/ingest_test.go +++ b/pkg/agentic/ingest_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -55,6 +56,7 @@ func TestIngest_IngestFindings_Good_WithFindings(t *testing.T) { require.True(t, fs.Write(filepath.Join(home, ".claude", "agent-api.key"), "test-api-key").OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), brainURL: srv.URL, brainKey: "test-brain-key", client: srv.Client(), @@ -74,6 +76,7 @@ func TestIngest_IngestFindings_Bad_NotCompleted(t *testing.T) { })) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -92,6 +95,7 @@ func TestIngest_IngestFindings_Bad_NoLogFile(t *testing.T) { })) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -114,6 +118,7 @@ func TestIngest_IngestFindings_Bad_TooFewFindings(t *testing.T) { require.True(t, fs.Write(filepath.Join(wsDir, "agent-codex.log"), logContent).OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -135,6 +140,7 @@ func TestIngest_IngestFindings_Bad_QuotaExhausted(t *testing.T) { require.True(t, fs.Write(filepath.Join(wsDir, "agent-codex.log"), logContent).OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -148,6 +154,7 @@ func TestIngest_IngestFindings_Bad_NoStatusFile(t *testing.T) { wsDir := t.TempDir() s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -168,6 +175,7 @@ func TestIngest_IngestFindings_Bad_ShortLogFile(t *testing.T) { require.True(t, fs.Write(filepath.Join(wsDir, "agent-codex.log"), "short").OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -199,6 +207,7 @@ func TestIngest_CreateIssueViaAPI_Good_Success(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), brainURL: srv.URL, brainKey: "test-brain-key", client: srv.Client(), @@ -212,6 +221,7 @@ func TestIngest_CreateIssueViaAPI_Good_Success(t *testing.T) { func TestIngest_CreateIssueViaAPI_Bad_NoBrainKey(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), brainKey: "", backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -229,6 +239,7 @@ func TestIngest_CreateIssueViaAPI_Bad_NoAPIKey(t *testing.T) { // No agent-api.key file s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), brainURL: "https://example.com", brainKey: "test-brain-key", client: &http.Client{}, @@ -254,6 +265,7 @@ func TestIngest_CreateIssueViaAPI_Bad_ServerError(t *testing.T) { require.True(t, fs.Write(filepath.Join(home, ".claude", "agent-api.key"), "test-key").OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), brainURL: srv.URL, brainKey: "test-brain-key", client: srv.Client(), @@ -289,6 +301,7 @@ func TestIngest_IngestFindings_Ugly(t *testing.T) { // No agent-*.log files at all s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -321,6 +334,7 @@ func TestIngest_CreateIssueViaAPI_Ugly(t *testing.T) { require.True(t, fs.Write(filepath.Join(home, ".claude", "agent-api.key"), "test-key").OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), brainURL: srv.URL, brainKey: "test-brain-key", client: srv.Client(), diff --git a/pkg/agentic/logic_test.go b/pkg/agentic/logic_test.go index d4ea32e..e4d938b 100644 --- a/pkg/agentic/logic_test.go +++ b/pkg/agentic/logic_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -196,7 +197,7 @@ func TestDispatch_ContainerCommand_Ugly_EmptyDirs(t *testing.T) { // --- buildAutoPRBody --- func TestAutoPr_BuildAutoPRBody_Good_Basic(t *testing.T) { - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} st := &WorkspaceStatus{ Task: "Fix the login bug", Agent: "codex", @@ -211,7 +212,7 @@ func TestAutoPr_BuildAutoPRBody_Good_Basic(t *testing.T) { } func TestAutoPr_BuildAutoPRBody_Good_WithIssue(t *testing.T) { - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} st := &WorkspaceStatus{ Task: "Add rate limiting", Agent: "claude", @@ -223,7 +224,7 @@ func TestAutoPr_BuildAutoPRBody_Good_WithIssue(t *testing.T) { } func TestAutoPr_BuildAutoPRBody_Good_NoIssue(t *testing.T) { - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} st := &WorkspaceStatus{ Task: "Refactor internals", Agent: "gemini", @@ -234,7 +235,7 @@ func TestAutoPr_BuildAutoPRBody_Good_NoIssue(t *testing.T) { } func TestAutoPr_BuildAutoPRBody_Good_CommitCount(t *testing.T) { - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} st := &WorkspaceStatus{Agent: "codex", Branch: "agent/foo"} body1 := s.buildAutoPRBody(st, 1) body5 := s.buildAutoPRBody(st, 5) @@ -243,7 +244,7 @@ func TestAutoPr_BuildAutoPRBody_Good_CommitCount(t *testing.T) { } func TestAutoPr_BuildAutoPRBody_Bad_EmptyTask(t *testing.T) { - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} st := &WorkspaceStatus{ Task: "", Agent: "codex", @@ -256,7 +257,7 @@ func TestAutoPr_BuildAutoPRBody_Bad_EmptyTask(t *testing.T) { } func TestAutoPr_BuildAutoPRBody_Ugly_ZeroCommits(t *testing.T) { - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} st := &WorkspaceStatus{Agent: "codex", Branch: "agent/test"} body := s.buildAutoPRBody(st, 0) assert.Contains(t, body, "**Commits:** 0") diff --git a/pkg/agentic/mirror.go b/pkg/agentic/mirror.go index 6d02644..f8219a5 100644 --- a/pkg/agentic/mirror.go +++ b/pkg/agentic/mirror.go @@ -4,8 +4,6 @@ package agentic import ( "context" - "encoding/json" - "os" core "dappco.re/go/core" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -79,23 +77,23 @@ func (s *PrepSubsystem) mirror(ctx context.Context, _ *mcp.CallToolRequest, inpu repoDir := core.JoinPath(basePath, repo) // Check if github remote exists - if !hasRemote(repoDir, "github") { + if !s.hasRemote(repoDir, "github") { skipped = append(skipped, repo+": no github remote") continue } // Fetch github to get current state - gitCmdOK(ctx, repoDir, "fetch", "github") + s.gitCmdOK(ctx, repoDir, "fetch", "github") // Check how far ahead local default branch is vs github - localBase := DefaultBranch(repoDir) - ahead := commitsAhead(repoDir, "github/main", localBase) + localBase := s.DefaultBranch(repoDir) + ahead := s.commitsAhead(repoDir, "github/main", localBase) if ahead == 0 { continue // Already in sync } // Count files changed - files := filesChanged(repoDir, "github/main", localBase) + files := s.filesChanged(repoDir, "github/main", localBase) sync := MirrorSync{ Repo: repo, @@ -117,12 +115,12 @@ func (s *PrepSubsystem) mirror(ctx context.Context, _ *mcp.CallToolRequest, inpu } // Ensure dev branch exists on GitHub - ensureDevBranch(repoDir) + s.ensureDevBranch(repoDir) // Push local main to github dev (explicit main, not HEAD) - base := DefaultBranch(repoDir) - if _, err := gitCmd(ctx, repoDir, "push", "github", base+":refs/heads/dev", "--force"); err != nil { - sync.Skipped = core.Sprintf("push failed: %v", err) + base := s.DefaultBranch(repoDir) + if r := s.gitCmd(ctx, repoDir, "push", "github", base+":refs/heads/dev", "--force"); !r.OK { + sync.Skipped = core.Sprintf("push failed: %s", r.Value) synced = append(synced, sync) continue } @@ -152,10 +150,13 @@ func (s *PrepSubsystem) createGitHubPR(ctx context.Context, repoDir, repo string ghRepo := core.Sprintf("%s/%s", GitHubOrg(), repo) // Check if there's already an open PR from dev - out, err := runCmd(ctx, repoDir, "gh", "pr", "list", "--repo", ghRepo, "--head", "dev", "--state", "open", "--json", "url", "--limit", "1") - if err == nil && core.Contains(out, "url") { - if url := extractJSONField(out, "url"); url != "" { - return url, nil + r := s.runCmd(ctx, repoDir, "gh", "pr", "list", "--repo", ghRepo, "--head", "dev", "--state", "open", "--json", "url", "--limit", "1") + if r.OK { + out := r.Value.(string) + if core.Contains(out, "url") { + if url := extractJSONField(out, "url"); url != "" { + return url, nil + } } } @@ -168,13 +169,14 @@ func (s *PrepSubsystem) createGitHubPR(ctx context.Context, repoDir, repo string title := core.Sprintf("[sync] %s: %d commits, %d files", repo, commits, files) - prOut, err := runCmd(ctx, repoDir, "gh", "pr", "create", + r = s.runCmd(ctx, repoDir, "gh", "pr", "create", "--repo", ghRepo, "--head", "dev", "--base", "main", "--title", title, "--body", body) - if err != nil { - return "", core.E("createGitHubPR", prOut, err) + if !r.OK { + return "", core.E("createGitHubPR", r.Value.(string), nil) } + prOut := r.Value.(string) lines := core.Split(core.Trim(prOut), "\n") if len(lines) > 0 { return lines[len(lines)-1], nil @@ -183,24 +185,24 @@ func (s *PrepSubsystem) createGitHubPR(ctx context.Context, repoDir, repo string } // ensureDevBranch creates the dev branch on GitHub if it doesn't exist. -func ensureDevBranch(repoDir string) { - gitCmdOK(context.Background(), repoDir, "push", "github", "HEAD:refs/heads/dev") +func (s *PrepSubsystem) ensureDevBranch(repoDir string) { + s.gitCmdOK(context.Background(), repoDir, "push", "github", "HEAD:refs/heads/dev") } // hasRemote checks if a git remote exists. -func hasRemote(repoDir, name string) bool { - return gitCmdOK(context.Background(), repoDir, "remote", "get-url", name) +func (s *PrepSubsystem) hasRemote(repoDir, name string) bool { + return s.gitCmdOK(context.Background(), repoDir, "remote", "get-url", name) } // commitsAhead returns how many commits HEAD is ahead of the ref. -func commitsAhead(repoDir, base, head string) int { - out := gitOutput(context.Background(), repoDir, "rev-list", base+".."+head, "--count") +func (s *PrepSubsystem) commitsAhead(repoDir, base, head string) int { + out := s.gitOutput(context.Background(), repoDir, "rev-list", base+".."+head, "--count") return parseInt(out) } // filesChanged returns the number of files changed between two refs. -func filesChanged(repoDir, base, head string) int { - out := gitOutput(context.Background(), repoDir, "diff", "--name-only", base+".."+head) +func (s *PrepSubsystem) filesChanged(repoDir, base, head string) int { + out := s.gitOutput(context.Background(), repoDir, "diff", "--name-only", base+".."+head) if out == "" { return 0 } @@ -209,19 +211,16 @@ func filesChanged(repoDir, base, head string) int { // listLocalRepos returns repo names that exist as directories in basePath. func (s *PrepSubsystem) listLocalRepos(basePath string) []string { - r := fs.List(basePath) - if !r.OK { - return nil - } - entries := r.Value.([]os.DirEntry) + paths := core.PathGlob(core.JoinPath(basePath, "*")) var repos []string - for _, e := range entries { - if !e.IsDir() { + for _, p := range paths { + name := core.PathBase(p) + if !fs.IsDir(p) { continue } // Must have a .git directory - if fs.IsDir(core.JoinPath(basePath, e.Name(), ".git")) { - repos = append(repos, e.Name()) + if fs.IsDir(core.JoinPath(basePath, name, ".git")) { + repos = append(repos, name) } } return repos @@ -234,7 +233,7 @@ func extractJSONField(jsonStr, field string) string { } var list []map[string]any - if err := json.Unmarshal([]byte(jsonStr), &list); err == nil { + if r := core.JSONUnmarshalString(jsonStr, &list); r.OK { for _, item := range list { if value, ok := item[field].(string); ok { return value @@ -243,7 +242,7 @@ func extractJSONField(jsonStr, field string) string { } var item map[string]any - if err := json.Unmarshal([]byte(jsonStr), &item); err != nil { + if r := core.JSONUnmarshalString(jsonStr, &item); !r.OK { return "" } diff --git a/pkg/agentic/mirror_example_test.go b/pkg/agentic/mirror_example_test.go new file mode 100644 index 0000000..e9d03f1 --- /dev/null +++ b/pkg/agentic/mirror_example_test.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import core "dappco.re/go/core" + +func ExampleMirrorInput() { + input := MirrorInput{Repo: "go-io"} + core.Println(input.Repo) + // Output: go-io +} diff --git a/pkg/agentic/mirror_test.go b/pkg/agentic/mirror_test.go index a195ffe..ecc4a79 100644 --- a/pkg/agentic/mirror_test.go +++ b/pkg/agentic/mirror_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "testing" + core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -48,7 +49,7 @@ func TestMirror_HasRemote_Good_OriginExists(t *testing.T) { cmd.Dir = dir require.NoError(t, cmd.Run()) - assert.True(t, hasRemote(dir, "origin")) + assert.True(t, testPrep.hasRemote(dir, "origin")) } func TestMirror_HasRemote_Good_CustomRemote(t *testing.T) { @@ -57,24 +58,24 @@ func TestMirror_HasRemote_Good_CustomRemote(t *testing.T) { cmd.Dir = dir require.NoError(t, cmd.Run()) - assert.True(t, hasRemote(dir, "github")) + assert.True(t, testPrep.hasRemote(dir, "github")) } func TestMirror_HasRemote_Bad_NoSuchRemote(t *testing.T) { dir := initBareRepo(t) - assert.False(t, hasRemote(dir, "nonexistent")) + assert.False(t, testPrep.hasRemote(dir, "nonexistent")) } func TestMirror_HasRemote_Bad_NotAGitRepo(t *testing.T) { dir := t.TempDir() // plain directory, no .git - assert.False(t, hasRemote(dir, "origin")) + assert.False(t, testPrep.hasRemote(dir, "origin")) } func TestMirror_HasRemote_Ugly_EmptyDir(t *testing.T) { // Empty dir defaults to cwd which may or may not be a repo. // Just ensure no panic. assert.NotPanics(t, func() { - hasRemote("", "origin") + testPrep.hasRemote("", "origin") }) } @@ -105,7 +106,7 @@ func TestMirror_CommitsAhead_Good_OneAhead(t *testing.T) { run("git", "add", "new.txt") run("git", "commit", "-m", "second commit") - ahead := commitsAhead(dir, "base", "main") + ahead := testPrep.commitsAhead(dir, "base", "main") assert.Equal(t, 1, ahead) } @@ -134,30 +135,30 @@ func TestMirror_CommitsAhead_Good_ThreeAhead(t *testing.T) { run("git", "commit", "-m", "commit "+string(rune('0'+i))) } - ahead := commitsAhead(dir, "base", "main") + ahead := testPrep.commitsAhead(dir, "base", "main") assert.Equal(t, 3, ahead) } func TestMirror_CommitsAhead_Good_ZeroAhead(t *testing.T) { dir := initBareRepo(t) // Same ref on both sides - ahead := commitsAhead(dir, "main", "main") + ahead := testPrep.commitsAhead(dir, "main", "main") assert.Equal(t, 0, ahead) } func TestMirror_CommitsAhead_Bad_InvalidRef(t *testing.T) { dir := initBareRepo(t) - ahead := commitsAhead(dir, "nonexistent-ref", "main") + ahead := testPrep.commitsAhead(dir, "nonexistent-ref", "main") assert.Equal(t, 0, ahead) } func TestMirror_CommitsAhead_Bad_NotARepo(t *testing.T) { - ahead := commitsAhead(t.TempDir(), "main", "dev") + ahead := testPrep.commitsAhead(t.TempDir(), "main", "dev") assert.Equal(t, 0, ahead) } func TestMirror_CommitsAhead_Ugly_EmptyDir(t *testing.T) { - ahead := commitsAhead("", "a", "b") + ahead := testPrep.commitsAhead("", "a", "b") assert.Equal(t, 0, ahead) } @@ -185,7 +186,7 @@ func TestMirror_FilesChanged_Good_OneFile(t *testing.T) { run("git", "add", "changed.txt") run("git", "commit", "-m", "add file") - files := filesChanged(dir, "base", "main") + files := testPrep.filesChanged(dir, "base", "main") assert.Equal(t, 1, files) } @@ -213,29 +214,29 @@ func TestMirror_FilesChanged_Good_MultipleFiles(t *testing.T) { run("git", "add", ".") run("git", "commit", "-m", "add three files") - files := filesChanged(dir, "base", "main") + files := testPrep.filesChanged(dir, "base", "main") assert.Equal(t, 3, files) } func TestMirror_FilesChanged_Good_NoChanges(t *testing.T) { dir := initBareRepo(t) - files := filesChanged(dir, "main", "main") + files := testPrep.filesChanged(dir, "main", "main") assert.Equal(t, 0, files) } func TestMirror_FilesChanged_Bad_InvalidRef(t *testing.T) { dir := initBareRepo(t) - files := filesChanged(dir, "nonexistent", "main") + files := testPrep.filesChanged(dir, "nonexistent", "main") assert.Equal(t, 0, files) } func TestMirror_FilesChanged_Bad_NotARepo(t *testing.T) { - files := filesChanged(t.TempDir(), "main", "dev") + files := testPrep.filesChanged(t.TempDir(), "main", "dev") assert.Equal(t, 0, files) } func TestMirror_FilesChanged_Ugly_EmptyDir(t *testing.T) { - files := filesChanged("", "a", "b") + files := testPrep.filesChanged("", "a", "b") assert.Equal(t, 0, files) } @@ -298,14 +299,14 @@ func TestMirror_ExtractJSONField_Ugly_NullValue(t *testing.T) { func TestPaths_DefaultBranch_Good_MainBranch(t *testing.T) { dir := initBareRepo(t) // initBareRepo creates with -b main - branch := DefaultBranch(dir) + branch := testPrep.DefaultBranch(dir) assert.Equal(t, "main", branch) } func TestPaths_DefaultBranch_Bad_NotARepo(t *testing.T) { dir := t.TempDir() // Falls back to "main" when detection fails - branch := DefaultBranch(dir) + branch := testPrep.DefaultBranch(dir) assert.Equal(t, "main", branch) } @@ -324,7 +325,7 @@ func TestMirror_ListLocalRepos_Good_FindsRepos(t *testing.T) { // Create a non-repo directory require.True(t, fs.EnsureDir(filepath.Join(base, "not-a-repo")).OK) - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} repos := s.listLocalRepos(base) assert.Contains(t, repos, "repo-a") assert.Contains(t, repos, "repo-b") @@ -333,13 +334,13 @@ func TestMirror_ListLocalRepos_Good_FindsRepos(t *testing.T) { func TestMirror_ListLocalRepos_Bad_EmptyDir(t *testing.T) { base := t.TempDir() - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} repos := s.listLocalRepos(base) assert.Empty(t, repos) } func TestMirror_ListLocalRepos_Bad_NonExistentDir(t *testing.T) { - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} repos := s.listLocalRepos("/nonexistent/path/that/doesnt/exist") assert.Nil(t, repos) } @@ -376,7 +377,7 @@ func TestMirror_ListLocalRepos_Ugly(t *testing.T) { // Create a regular file (not a directory) require.True(t, fs.Write(filepath.Join(base, "some-file.txt"), "hello").OK) - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} repos := s.listLocalRepos(base) assert.Contains(t, repos, "real-repo-a") assert.Contains(t, repos, "real-repo-b") diff --git a/pkg/agentic/paths.go b/pkg/agentic/paths.go index 94b67fe..e7ce9aa 100644 --- a/pkg/agentic/paths.go +++ b/pkg/agentic/paths.go @@ -5,7 +5,6 @@ package agentic import ( "context" "strconv" - "unsafe" core "dappco.re/go/core" ) @@ -14,21 +13,12 @@ import ( // // r := fs.Read("/etc/hostname") // if r.OK { core.Print(nil, "%s", r.Value.(string)) } -var fs = newFs("/") - -// newFs creates a core.Fs with the given root directory. -// Root "/" means unrestricted access (same as coreio.Local). -func newFs(root string) *core.Fs { - type fsRoot struct{ root string } - f := &core.Fs{} - (*fsRoot)(unsafe.Pointer(f)).root = root - return f -} +var fs = (&core.Fs{}).NewUnrestricted() // LocalFs returns an unrestricted filesystem instance for use by other packages. // -// r := agentic.LocalFs().Read("/tmp/agent-status.json") -// if r.OK { core.Print(nil, "%s", r.Value.(string)) } +// f := agentic.LocalFs() +// r := f.Read("/tmp/agent-status.json") func LocalFs() *core.Fs { return fs } // WorkspaceRoot returns the root directory for agent workspaces. @@ -74,17 +64,17 @@ func AgentName() string { // DefaultBranch detects the default branch of a repo (main, master, etc.). // -// base := agentic.DefaultBranch("./src") -func DefaultBranch(repoDir string) string { +// base := s.DefaultBranch("./src") +func (s *PrepSubsystem) DefaultBranch(repoDir string) string { ctx := context.Background() - if ref := gitOutput(ctx, repoDir, "symbolic-ref", "refs/remotes/origin/HEAD", "--short"); ref != "" { + if ref := s.gitOutput(ctx, repoDir, "symbolic-ref", "refs/remotes/origin/HEAD", "--short"); ref != "" { if core.HasPrefix(ref, "origin/") { return core.TrimPrefix(ref, "origin/") } return ref } for _, branch := range []string{"main", "master"} { - if gitCmdOK(ctx, repoDir, "rev-parse", "--verify", branch) { + if s.gitCmdOK(ctx, repoDir, "rev-parse", "--verify", branch) { return branch } } diff --git a/pkg/agentic/paths_example_test.go b/pkg/agentic/paths_example_test.go new file mode 100644 index 0000000..ed91c29 --- /dev/null +++ b/pkg/agentic/paths_example_test.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + core "dappco.re/go/core" +) + +func ExampleWorkspaceRoot() { + root := WorkspaceRoot() + core.Println(core.HasSuffix(root, "workspace")) + // Output: true +} + +func ExampleCoreRoot() { + root := CoreRoot() + core.Println(core.HasSuffix(root, ".core")) + // Output: true +} + +func ExamplePlansRoot() { + root := PlansRoot() + core.Println(core.HasSuffix(root, "plans")) + // Output: true +} + +func ExampleAgentName() { + name := AgentName() + core.Println(name != "") + // Output: true +} + +func ExampleGitHubOrg() { + org := GitHubOrg() + core.Println(org) + // Output: dAppCore +} diff --git a/pkg/agentic/paths_test.go b/pkg/agentic/paths_test.go index 370650a..91d2e36 100644 --- a/pkg/agentic/paths_test.go +++ b/pkg/agentic/paths_test.go @@ -167,14 +167,14 @@ func TestPaths_DefaultBranch_Good(t *testing.T) { cmd = exec.Command("git", "-C", dir, "commit", "-m", "init") require.NoError(t, cmd.Run()) - branch := DefaultBranch(dir) + branch := testPrep.DefaultBranch(dir) assert.Equal(t, "main", branch) } func TestPaths_DefaultBranch_Bad(t *testing.T) { // Non-git directory — should return "main" (default) dir := t.TempDir() - branch := DefaultBranch(dir) + branch := testPrep.DefaultBranch(dir) assert.Equal(t, "main", branch) } @@ -196,7 +196,7 @@ func TestPaths_DefaultBranch_Ugly(t *testing.T) { cmd = exec.Command("git", "-C", dir, "commit", "-m", "init") require.NoError(t, cmd.Run()) - branch := DefaultBranch(dir) + branch := testPrep.DefaultBranch(dir) assert.Equal(t, "master", branch) } @@ -321,12 +321,11 @@ func TestPaths_ParseInt_Ugly_LeadingTrailingWhitespace(t *testing.T) { assert.Equal(t, 42, parseInt(" 42 ")) } -// --- newFs Good/Bad/Ugly --- +// --- fs (NewUnrestricted) Good --- -func TestPaths_NewFs_Good(t *testing.T) { - f := newFs("/tmp") - assert.NotNil(t, f, "newFs should return a non-nil Fs") - assert.IsType(t, &core.Fs{}, f) +func TestPaths_Fs_Good_Unrestricted(t *testing.T) { + assert.NotNil(t, fs, "package-level fs should be non-nil") + assert.IsType(t, &core.Fs{}, fs) } // --- parseInt Good --- @@ -335,20 +334,3 @@ func TestPaths_ParseInt_Good(t *testing.T) { assert.Equal(t, 42, parseInt("42")) assert.Equal(t, 0, parseInt("0")) } - -func TestPaths_NewFs_Bad_EmptyRoot(t *testing.T) { - f := newFs("") - assert.NotNil(t, f, "newFs with empty root should not return nil") -} - -func TestPaths_NewFs_Ugly_UnicodeRoot(t *testing.T) { - assert.NotPanics(t, func() { - f := newFs("/tmp/\u00e9\u00e0\u00fc/\u00f1o\u00f0\u00e9s") - assert.NotNil(t, f) - }) -} - -func TestPaths_NewFs_Ugly_VerifyIsFs(t *testing.T) { - f := newFs("/tmp") - assert.IsType(t, &core.Fs{}, f) -} diff --git a/pkg/agentic/plan.go b/pkg/agentic/plan.go index f3931b5..224257c 100644 --- a/pkg/agentic/plan.go +++ b/pkg/agentic/plan.go @@ -6,8 +6,6 @@ import ( "context" "crypto/rand" "encoding/hex" - "encoding/json" - "os" "time" core "dappco.re/go/core" @@ -299,19 +297,11 @@ func (s *PrepSubsystem) planList(_ context.Context, _ *mcp.CallToolRequest, inpu return nil, PlanListOutput{}, core.E("planList", "failed to access plans directory", err) } - r := fs.List(dir) - if !r.OK { - return nil, PlanListOutput{}, nil - } - entries := r.Value.([]os.DirEntry) + jsonFiles := core.PathGlob(core.JoinPath(dir, "*.json")) var plans []Plan - for _, entry := range entries { - if entry.IsDir() || !core.HasSuffix(entry.Name(), ".json") { - continue - } - - id := core.TrimSuffix(entry.Name(), ".json") + for _, f := range jsonFiles { + id := core.TrimSuffix(core.PathBase(f), ".json") plan, err := readPlan(dir, id) if err != nil { continue @@ -352,7 +342,7 @@ func generatePlanID(title string) string { // Append short random suffix for uniqueness b := make([]byte, 3) rand.Read(b) - return slug + "-" + hex.EncodeToString(b) + return core.Concat(slug, "-", hex.EncodeToString(b)) } func readPlan(dir, id string) (*Plan, error) { @@ -362,8 +352,8 @@ func readPlan(dir, id string) (*Plan, error) { } var plan Plan - if err := json.Unmarshal([]byte(r.Value.(string)), &plan); err != nil { - return nil, core.E("readPlan", "failed to parse plan "+id, err) + if ur := core.JSONUnmarshalString(r.Value.(string), &plan); !ur.OK { + return nil, core.E("readPlan", "failed to parse plan "+id, nil) } return &plan, nil } @@ -375,12 +365,8 @@ func writePlan(dir string, plan *Plan) (string, error) { } path := planPath(dir, plan.ID) - data, err := json.MarshalIndent(plan, "", " ") - if err != nil { - return "", err - } - if r := fs.Write(path, string(data)); !r.OK { + if r := fs.Write(path, core.JSONMarshalString(plan)); !r.OK { err, _ := r.Value.(error) return "", core.E("writePlan", "failed to write plan", err) } diff --git a/pkg/agentic/plan_crud_test.go b/pkg/agentic/plan_crud_test.go index a24bf04..310a18b 100644 --- a/pkg/agentic/plan_crud_test.go +++ b/pkg/agentic/plan_crud_test.go @@ -9,16 +9,18 @@ import ( "testing" "time" + core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// newTestPrep creates a PrepSubsystem for testing. +// newTestPrep creates a PrepSubsystem for testing with testCore wired in. func newTestPrep(t *testing.T) *PrepSubsystem { t.Helper() return &PrepSubsystem{ - backoff: make(map[string]time.Time), - failCount: make(map[string]int), + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), + backoff: make(map[string]time.Time), + failCount: make(map[string]int), } } diff --git a/pkg/agentic/plan_example_test.go b/pkg/agentic/plan_example_test.go new file mode 100644 index 0000000..0d4f2a9 --- /dev/null +++ b/pkg/agentic/plan_example_test.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + core "dappco.re/go/core" +) + +func Example_planPath() { + path := planPath("/tmp/plans", "my-plan") + core.Println(core.HasSuffix(path, "my-plan.json")) + // Output: true +} diff --git a/pkg/agentic/pr.go b/pkg/agentic/pr.go index 285bf39..b8dc621 100644 --- a/pkg/agentic/pr.go +++ b/pkg/agentic/pr.go @@ -66,7 +66,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in } if st.Branch == "" { - branch := gitOutput(ctx, repoDir, "rev-parse", "--abbrev-ref", "HEAD") + branch := s.gitOutput(ctx, repoDir, "rev-parse", "--abbrev-ref", "HEAD") if branch == "" { return nil, CreatePROutput{}, core.E("createPR", "failed to detect branch", nil) } @@ -108,9 +108,9 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in // Push branch to Forge (origin is the local clone, not Forge) forgeRemote := core.Sprintf("ssh://git@forge.lthn.ai:2223/%s/%s.git", org, st.Repo) - pushOut, pushErr := gitCmd(ctx, repoDir, "push", forgeRemote, st.Branch) - if pushErr != nil { - return nil, CreatePROutput{}, core.E("createPR", "git push failed: "+pushOut, pushErr) + r := s.gitCmd(ctx, repoDir, "push", forgeRemote, st.Branch) + if !r.OK { + return nil, CreatePROutput{}, core.E("createPR", "git push failed: "+r.Value.(string), nil) } // Create PR via Forge API diff --git a/pkg/agentic/pr_example_test.go b/pkg/agentic/pr_example_test.go new file mode 100644 index 0000000..d85f86a --- /dev/null +++ b/pkg/agentic/pr_example_test.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import core "dappco.re/go/core" + +func ExampleCreatePRInput() { + input := CreatePRInput{Workspace: "core/go-io/task-5"} + core.Println(input.Workspace) + // Output: core/go-io/task-5 +} diff --git a/pkg/agentic/pr_test.go b/pkg/agentic/pr_test.go index 0c1b882..2d80261 100644 --- a/pkg/agentic/pr_test.go +++ b/pkg/agentic/pr_test.go @@ -14,6 +14,7 @@ import ( "testing" "time" + core "dappco.re/go/core" "dappco.re/go/core/forge" forge_types "dappco.re/go/core/forge/types" "github.com/stretchr/testify/assert" @@ -64,6 +65,7 @@ func mockPRForgeServer(t *testing.T) *httptest.Server { func TestPr_ForgeCreatePR_Good_Success(t *testing.T) { srv := mockPRForgeServer(t) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -91,6 +93,7 @@ func TestPr_ForgeCreatePR_Bad_ServerError(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -112,6 +115,7 @@ func TestPr_ForgeCreatePR_Bad_ServerError(t *testing.T) { func TestPr_CreatePR_Bad_NoWorkspace(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeToken: "test-token", backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -124,6 +128,7 @@ func TestPr_CreatePR_Bad_NoWorkspace(t *testing.T) { func TestPr_CreatePR_Bad_NoToken(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeToken: "", backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -141,6 +146,7 @@ func TestPr_CreatePR_Bad_WorkspaceNotFound(t *testing.T) { t.Setenv("CORE_WORKSPACE", root) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeToken: "test-token", backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -176,6 +182,7 @@ func TestPr_CreatePR_Good_DryRun(t *testing.T) { })) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeToken: "test-token", backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -214,6 +221,7 @@ func TestPr_CreatePR_Good_CustomTitle(t *testing.T) { })) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeToken: "test-token", backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -232,6 +240,7 @@ func TestPr_CreatePR_Good_CustomTitle(t *testing.T) { func TestPr_ListPRs_Bad_NoToken(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeToken: "", backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -255,6 +264,7 @@ func TestPr_CommentOnIssue_Good_PostsComment(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -270,7 +280,7 @@ func TestPr_CommentOnIssue_Good_PostsComment(t *testing.T) { // --- buildPRBody --- func TestPr_BuildPRBody_Good(t *testing.T) { - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} st := &WorkspaceStatus{ Status: "completed", Repo: "go-io", @@ -290,7 +300,7 @@ func TestPr_BuildPRBody_Good(t *testing.T) { func TestPr_BuildPRBody_Bad(t *testing.T) { // Empty status struct - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} st := &WorkspaceStatus{} body := s.buildPRBody(st) assert.Contains(t, body, "## Summary") @@ -300,7 +310,7 @@ func TestPr_BuildPRBody_Bad(t *testing.T) { func TestPr_BuildPRBody_Ugly(t *testing.T) { // Very long task string - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} longTask := strings.Repeat("This is a very long task description. ", 100) st := &WorkspaceStatus{ Task: longTask, @@ -322,6 +332,7 @@ func TestPr_CommentOnIssue_Bad(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -348,6 +359,7 @@ func TestPr_CommentOnIssue_Ugly(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -396,6 +408,7 @@ func TestPr_CreatePR_Ugly(t *testing.T) { })) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeToken: "test-token", backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -428,6 +441,7 @@ func TestPr_ForgeCreatePR_Ugly(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -463,6 +477,7 @@ func TestPr_ListPRs_Ugly(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -484,6 +499,7 @@ func TestPr_ListRepoPRs_Good(t *testing.T) { // Specific repo with PRs srv := mockPRForgeServer(t) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -506,6 +522,7 @@ func TestPr_ListRepoPRs_Bad(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -526,6 +543,7 @@ func TestPr_ListRepoPRs_Ugly(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index 8c9c5fa..7c113bc 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -7,9 +7,6 @@ package agentic import ( "context" "encoding/base64" - "encoding/json" - goio "io" - "net/http" "sync" "time" @@ -21,21 +18,21 @@ import ( "gopkg.in/yaml.v3" ) +// AgentOptions configures the agentic service. +type AgentOptions struct{} + // PrepSubsystem provides agentic MCP tools for workspace orchestration. -// Agent lifecycle events are broadcast via c.ACTION(messages.AgentCompleted{}). +// Agent lifecycle events are broadcast via s.Core().ACTION(messages.AgentCompleted{}). // -// sub := agentic.NewPrep() -// sub.SetCore(c) -// sub.RegisterTools(server) +// core.New(core.WithService(agentic.Register)) type PrepSubsystem struct { - core *core.Core // Core framework instance for IPC, Config, Lock + *core.ServiceRuntime[AgentOptions] forge *forge.Forge forgeURL string forgeToken string brainURL string brainKey string codePath string - client *http.Client drainMu sync.Mutex pokeCh chan struct{} frozen bool @@ -73,34 +70,95 @@ func NewPrep() *PrepSubsystem { brainURL: envOr("CORE_BRAIN_URL", "https://api.lthn.sh"), brainKey: brainKey, codePath: envOr("CODE_PATH", core.JoinPath(home, "Code")), - client: &http.Client{Timeout: 30 * time.Second}, backoff: make(map[string]time.Time), failCount: make(map[string]int), } } -// SetCore wires the Core framework instance for IPC, Config, and Lock access. +// SetCore wires the Core framework instance via ServiceRuntime. +// Deprecated: Use Register with core.WithService(agentic.Register) instead. // // prep.SetCore(c) func (s *PrepSubsystem) SetCore(c *core.Core) { - s.core = c + s.ServiceRuntime = core.NewServiceRuntime(c, AgentOptions{}) } -// OnStartup implements core.Startable — starts the queue runner and registers commands. -func (s *PrepSubsystem) OnStartup(ctx context.Context) error { +// OnStartup implements core.Startable — registers named Actions, starts the queue runner, +// and registers CLI commands. The Action registry IS the capability map. +// +// c.Action("agentic.dispatch").Run(ctx, opts) +// c.Actions() // ["agentic.dispatch", "agentic.prep", "agentic.status", ...] +func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { + c := s.Core() + + // Transport — register HTTP protocol + Drive endpoints + RegisterHTTPTransport(c) + c.Drive().New(core.NewOptions( + core.Option{Key: "name", Value: "forge"}, + core.Option{Key: "transport", Value: s.forgeURL}, + core.Option{Key: "token", Value: s.forgeToken}, + )) + c.Drive().New(core.NewOptions( + core.Option{Key: "name", Value: "brain"}, + core.Option{Key: "transport", Value: s.brainURL}, + core.Option{Key: "token", Value: s.brainKey}, + )) + + // Dispatch & workspace + c.Action("agentic.dispatch", s.handleDispatch) + c.Action("agentic.prep", s.handlePrep) + c.Action("agentic.status", s.handleStatus) + c.Action("agentic.resume", s.handleResume) + c.Action("agentic.scan", s.handleScan) + c.Action("agentic.watch", s.handleWatch) + + // Pipeline + c.Action("agentic.qa", s.handleQA) + c.Action("agentic.auto-pr", s.handleAutoPR) + c.Action("agentic.verify", s.handleVerify) + c.Action("agentic.ingest", s.handleIngest) + c.Action("agentic.poke", s.handlePoke) + c.Action("agentic.mirror", s.handleMirror) + + // Forge + c.Action("agentic.issue.get", s.handleIssueGet) + c.Action("agentic.issue.list", s.handleIssueList) + c.Action("agentic.issue.create", s.handleIssueCreate) + c.Action("agentic.pr.get", s.handlePRGet) + c.Action("agentic.pr.list", s.handlePRList) + c.Action("agentic.pr.merge", s.handlePRMerge) + + // Review + c.Action("agentic.review-queue", s.handleReviewQueue) + + // Epic + c.Action("agentic.epic", s.handleEpic) + + // Completion pipeline — Task composition + c.Task("agent.completion", core.Task{ + Description: "QA → PR → Verify → Merge", + Steps: []core.Step{ + {Action: "agentic.qa"}, + {Action: "agentic.auto-pr"}, + {Action: "agentic.verify"}, + {Action: "agentic.ingest", Async: true}, + {Action: "agentic.poke", Async: true}, + }, + }) + s.StartRunner() s.registerCommands(ctx) s.registerWorkspaceCommands() s.registerForgeCommands() - return nil + return core.Result{OK: true} } // registerCommands is in commands.go // OnShutdown implements core.Stoppable — freezes the queue. -func (s *PrepSubsystem) OnShutdown(ctx context.Context) error { +func (s *PrepSubsystem) OnShutdown(ctx context.Context) core.Result { s.frozen = true - return nil + return core.Result{OK: true} } func envOr(key, fallback string) string { @@ -247,8 +305,8 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques if !resumed { // Clone repo into repo/ - if _, cloneErr := gitCmd(ctx, ".", "clone", repoPath, repoDir); cloneErr != nil { - return nil, PrepOutput{}, core.E("prep", "git clone failed for "+input.Repo, cloneErr) + if r := s.gitCmd(ctx, ".", "clone", repoPath, repoDir); !r.OK { + return nil, PrepOutput{}, core.E("prep", "git clone failed for "+input.Repo, nil) } // Create feature branch @@ -264,13 +322,13 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques } branchName := core.Sprintf("agent/%s", taskSlug) - if _, branchErr := gitCmd(ctx, repoDir, "checkout", "-b", branchName); branchErr != nil { - return nil, PrepOutput{}, core.E("prep.branch", core.Sprintf("failed to create branch %q", branchName), branchErr) + if r := s.gitCmd(ctx, repoDir, "checkout", "-b", branchName); !r.OK { + return nil, PrepOutput{}, core.E("prep.branch", core.Sprintf("failed to create branch %q", branchName), nil) } out.Branch = branchName } else { // Resume: read branch from existing checkout - out.Branch = gitOutput(ctx, repoDir, "rev-parse", "--abbrev-ref", "HEAD") + out.Branch = s.gitOutput(ctx, repoDir, "rev-parse", "--abbrev-ref", "HEAD") } // Build the rich prompt with all context @@ -400,32 +458,22 @@ func (s *PrepSubsystem) brainRecall(ctx context.Context, repo string) (string, i return "", 0 } - body, _ := json.Marshal(map[string]any{ - "query": "architecture conventions key interfaces for " + repo, + body := core.JSONMarshalString(map[string]any{ + "query": core.Concat("architecture conventions key interfaces for ", repo), "top_k": 10, "project": repo, "agent_id": "cladius", }) - req, _ := http.NewRequestWithContext(ctx, "POST", s.brainURL+"/v1/brain/recall", core.NewReader(string(body))) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", "Bearer "+s.brainKey) - - resp, err := s.client.Do(req) - if err != nil || resp.StatusCode != 200 { - if resp != nil { - resp.Body.Close() - } + r := HTTPPost(ctx, core.Concat(s.brainURL, "/v1/brain/recall"), body, s.brainKey, "Bearer") + if !r.OK { return "", 0 } - defer resp.Body.Close() - respData, _ := goio.ReadAll(resp.Body) var result struct { Memories []map[string]any `json:"memories"` } - json.Unmarshal(respData, &result) + core.JSONUnmarshalString(r.Value.(string), &result) if len(result.Memories) == 0 { return "", 0 @@ -444,7 +492,7 @@ func (s *PrepSubsystem) brainRecall(ctx context.Context, repo string) (string, i func (s *PrepSubsystem) findConsumersList(repo string) (string, int) { goWorkPath := core.JoinPath(s.codePath, "go.work") - modulePath := "forge.lthn.ai/core/" + repo + modulePath := core.Concat("forge.lthn.ai/core/", repo) r := fs.Read(goWorkPath) if !r.OK { @@ -476,7 +524,7 @@ func (s *PrepSubsystem) findConsumersList(repo string) (string, int) { b := core.NewBuilder() for _, c := range consumers { - b.WriteString("- " + c + "\n") + b.WriteString(core.Concat("- ", c, "\n")) } b.WriteString(core.Sprintf("Breaking change risk: %d consumers.\n", len(consumers))) @@ -484,7 +532,7 @@ func (s *PrepSubsystem) findConsumersList(repo string) (string, int) { } func (s *PrepSubsystem) getGitLog(repoPath string) string { - return gitOutput(context.Background(), repoPath, "log", "--oneline", "-20") + return s.gitOutput(context.Background(), repoPath, "log", "--oneline", "-20") } func (s *PrepSubsystem) pullWikiContent(ctx context.Context, org, repo string) string { @@ -504,7 +552,7 @@ func (s *PrepSubsystem) pullWikiContent(ctx context.Context, org, repo string) s continue } content, _ := base64.StdEncoding.DecodeString(page.ContentBase64) - b.WriteString("### " + meta.Title + "\n\n") + b.WriteString(core.Concat("### ", meta.Title, "\n\n")) b.WriteString(string(content)) b.WriteString("\n\n") } @@ -539,18 +587,18 @@ func (s *PrepSubsystem) renderPlan(templateSlug string, variables map[string]str } plan := core.NewBuilder() - plan.WriteString("# " + tmpl.Name + "\n\n") + plan.WriteString(core.Concat("# ", tmpl.Name, "\n\n")) if task != "" { - plan.WriteString("**Task:** " + task + "\n\n") + plan.WriteString(core.Concat("**Task:** ", task, "\n\n")) } if tmpl.Description != "" { - plan.WriteString(tmpl.Description + "\n\n") + plan.WriteString(core.Concat(tmpl.Description, "\n\n")) } if len(tmpl.Guidelines) > 0 { plan.WriteString("## Guidelines\n\n") for _, g := range tmpl.Guidelines { - plan.WriteString("- " + g + "\n") + plan.WriteString(core.Concat("- ", g, "\n")) } plan.WriteString("\n") } @@ -558,15 +606,15 @@ func (s *PrepSubsystem) renderPlan(templateSlug string, variables map[string]str for i, phase := range tmpl.Phases { plan.WriteString(core.Sprintf("## Phase %d: %s\n\n", i+1, phase.Name)) if phase.Description != "" { - plan.WriteString(phase.Description + "\n\n") + plan.WriteString(core.Concat(phase.Description, "\n\n")) } for _, t := range phase.Tasks { switch v := t.(type) { case string: - plan.WriteString("- [ ] " + v + "\n") + plan.WriteString(core.Concat("- [ ] ", v, "\n")) case map[string]any: if name, ok := v["name"].(string); ok { - plan.WriteString("- [ ] " + name + "\n") + plan.WriteString(core.Concat("- [ ] ", name, "\n")) } } } diff --git a/pkg/agentic/prep_example_test.go b/pkg/agentic/prep_example_test.go new file mode 100644 index 0000000..664aade --- /dev/null +++ b/pkg/agentic/prep_example_test.go @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import core "dappco.re/go/core" + +func ExamplePrepInput() { + input := PrepInput{Repo: "go-io", Issue: 42} + core.Println(input.Repo, input.Issue) + // Output: go-io 42 +} + +func ExampleNewPrep() { + prep := NewPrep() + core.Println(prep != nil) + // Output: true +} diff --git a/pkg/agentic/prep_extra_test.go b/pkg/agentic/prep_extra_test.go index 36348ea..5fc2db1 100644 --- a/pkg/agentic/prep_extra_test.go +++ b/pkg/agentic/prep_extra_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + core "dappco.re/go/core" "dappco.re/go/core/forge" "github.com/stretchr/testify/assert" ) @@ -21,6 +22,7 @@ import ( func TestPrep_Shutdown_Good(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -31,7 +33,7 @@ func TestPrep_Shutdown_Good(t *testing.T) { // --- Name --- func TestPrep_Name_Good(t *testing.T) { - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} assert.Equal(t, "agentic", s.Name()) } @@ -65,6 +67,7 @@ use ( } s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: dir, backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -92,6 +95,7 @@ use ( os.WriteFile(filepath.Join(modDir, "go.mod"), []byte("module forge.lthn.ai/core/go\n"), 0o644) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: dir, backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -104,6 +108,7 @@ use ( func TestPrep_FindConsumersList_Bad_NoGoWork(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -142,6 +147,7 @@ func TestPrep_PullWikiContent_Good_WithPages(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), client: srv.Client(), backoff: make(map[string]time.Time), @@ -162,6 +168,7 @@ func TestPrep_PullWikiContent_Good_NoPages(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), client: srv.Client(), backoff: make(map[string]time.Time), @@ -185,6 +192,7 @@ func TestPrep_GetIssueBody_Good(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), client: srv.Client(), backoff: make(map[string]time.Time), @@ -202,6 +210,7 @@ func TestPrep_GetIssueBody_Bad_NotFound(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), client: srv.Client(), backoff: make(map[string]time.Time), @@ -220,6 +229,7 @@ func TestPrep_BuildPrompt_Good_BasicFields(t *testing.T) { os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test\n\ngo 1.22\n"), 0o644) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -253,6 +263,7 @@ func TestPrep_BuildPrompt_Good_WithIssue(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), codePath: t.TempDir(), client: srv.Client(), @@ -279,6 +290,7 @@ func TestPrep_BuildPrompt_Good(t *testing.T) { os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test\n\ngo 1.22\n"), 0o644) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -303,6 +315,7 @@ func TestPrep_BuildPrompt_Good(t *testing.T) { func TestPrep_BuildPrompt_Bad(t *testing.T) { // Empty repo path — still produces a prompt (no crash) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -334,6 +347,7 @@ func TestPrep_BuildPrompt_Ugly(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), codePath: t.TempDir(), client: srv.Client(), @@ -370,6 +384,7 @@ func TestPrep_BuildPrompt_Ugly_WithGitLog(t *testing.T) { exec.Command("git", "-C", dir, "commit", "-m", "init").Run() s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -393,6 +408,7 @@ func TestDispatch_RunQA_Good_PHPNoComposer(t *testing.T) { os.WriteFile(filepath.Join(repoDir, "composer.json"), []byte(`{"name":"test"}`), 0o644) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -411,6 +427,7 @@ func TestPrep_PullWikiContent_Bad(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), client: srv.Client(), backoff: make(map[string]time.Time), @@ -441,6 +458,7 @@ func TestPrep_PullWikiContent_Ugly(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), client: srv.Client(), backoff: make(map[string]time.Time), @@ -457,6 +475,7 @@ func TestPrep_PullWikiContent_Ugly(t *testing.T) { func TestPrep_RenderPlan_Ugly(t *testing.T) { // Template with variables that don't exist in template — variables just won't match s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -486,6 +505,7 @@ func TestPrep_BrainRecall_Ugly(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), brainURL: srv.URL, brainKey: "test-key", client: srv.Client(), @@ -505,6 +525,7 @@ func TestPrep_PrepWorkspace_Ugly(t *testing.T) { t.Setenv("CORE_WORKSPACE", root) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -545,6 +566,7 @@ func TestPrep_FindConsumersList_Ugly(t *testing.T) { os.MkdirAll(filepath.Join(dir, "core", "missing"), 0o755) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: dir, backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -570,6 +592,7 @@ func TestPrep_GetIssueBody_Ugly(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), client: srv.Client(), backoff: make(map[string]time.Time), diff --git a/pkg/agentic/prep_test.go b/pkg/agentic/prep_test.go index 2014697..3b49cd9 100644 --- a/pkg/agentic/prep_test.go +++ b/pkg/agentic/prep_test.go @@ -190,17 +190,17 @@ func TestPrep_NewPrep_Good_GiteaTokenFallback(t *testing.T) { } func TestPrepSubsystem_Good_Name(t *testing.T) { - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} assert.Equal(t, "agentic", s.Name()) } func TestPrep_SetCore_Good(t *testing.T) { s := &PrepSubsystem{} - assert.Nil(t, s.core) + assert.Nil(t, s.ServiceRuntime) c := core.New(core.WithOption("name", "test")) s.SetCore(c) - assert.NotNil(t, s.core) + assert.NotNil(t, s.ServiceRuntime) } // --- sanitiseBranchSlug Bad/Ugly --- @@ -338,7 +338,7 @@ func TestSanitise_TrimRuneEdges_Ugly_NoMatch(t *testing.T) { // --- PrepSubsystem Name Bad/Ugly --- func TestPrep_Name_Bad(t *testing.T) { - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} name := s.Name() assert.NotEmpty(t, name, "Name should never return empty") assert.Equal(t, "agentic", name) @@ -387,34 +387,34 @@ func TestPrep_NewPrep_Ugly(t *testing.T) { func TestPrep_SetCore_Bad(t *testing.T) { // SetCore with nil — should not panic - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} assert.NotPanics(t, func() { s.SetCore(nil) }) - assert.Nil(t, s.core) } func TestPrep_SetCore_Ugly(t *testing.T) { // SetCore twice — second overwrites first - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} c1 := core.New(core.WithOption("name", "first")) c2 := core.New(core.WithOption("name", "second")) s.SetCore(c1) - assert.Equal(t, c1, s.core) + assert.NotNil(t, s.ServiceRuntime) s.SetCore(c2) - assert.Equal(t, c2, s.core, "second SetCore should overwrite first") + assert.Equal(t, c2, s.Core(), "second SetCore should overwrite first") } // --- OnStartup Bad/Ugly --- func TestPrep_OnStartup_Bad(t *testing.T) { - // OnStartup without SetCore (nil core) — panics because registerCommands - // needs core.Command(). Verify the panic is from nil core, not a logic error. + // OnStartup without SetCore (nil ServiceRuntime) — panics because + // registerCommands calls s.Core().Command(). s := &PrepSubsystem{ - backoff: make(map[string]time.Time), - failCount: make(map[string]int), + ServiceRuntime: nil, + backoff: make(map[string]time.Time), + failCount: make(map[string]int), } assert.Panics(t, func() { _ = s.OnStartup(context.Background()) @@ -424,6 +424,7 @@ func TestPrep_OnStartup_Bad(t *testing.T) { func TestPrep_OnStartup_Ugly(t *testing.T) { // OnStartup called twice with valid core — second call should not panic s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -441,12 +442,13 @@ func TestPrep_OnStartup_Ugly(t *testing.T) { func TestPrep_OnShutdown_Bad(t *testing.T) { // OnShutdown without Core s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } assert.NotPanics(t, func() { - err := s.OnShutdown(context.Background()) - assert.NoError(t, err) + r := s.OnShutdown(context.Background()) + assert.True(t, r.OK) }) assert.True(t, s.frozen) } @@ -456,6 +458,7 @@ func TestPrep_OnShutdown_Bad(t *testing.T) { func TestPrep_Shutdown_Bad(t *testing.T) { // Shutdown always returns nil s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -525,6 +528,7 @@ func TestPrep_TestPrepWorkspace_Good(t *testing.T) { t.Setenv("CORE_WORKSPACE", root) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -541,6 +545,7 @@ func TestPrep_TestPrepWorkspace_Good(t *testing.T) { func TestPrep_TestPrepWorkspace_Bad(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -557,6 +562,7 @@ func TestPrep_TestPrepWorkspace_Ugly(t *testing.T) { t.Setenv("CORE_WORKSPACE", root) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -578,6 +584,7 @@ func TestPrep_TestBuildPrompt_Good(t *testing.T) { require.True(t, fs.Write(filepath.Join(dir, "go.mod"), "module test").OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -598,6 +605,7 @@ func TestPrep_TestBuildPrompt_Good(t *testing.T) { func TestPrep_TestBuildPrompt_Bad(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -616,6 +624,7 @@ func TestPrep_TestBuildPrompt_Ugly(t *testing.T) { dir := t.TempDir() s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -686,6 +695,7 @@ func TestPrep_GetGitLog_Good(t *testing.T) { run("git", "commit", "-m", "initial commit") s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -698,6 +708,7 @@ func TestPrep_GetGitLog_Bad(t *testing.T) { // Non-git dir returns empty dir := t.TempDir() s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -713,6 +724,7 @@ func TestPrep_GetGitLog_Ugly(t *testing.T) { require.NoError(t, cmd.Run()) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -760,6 +772,7 @@ func TestPrep_PrepWorkspace_Good(t *testing.T) { run(srcRepo, "git", "commit", "-m", "initial commit") s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), codePath: filepath.Join(root, "src"), client: srv.Client(), diff --git a/pkg/agentic/proc.go b/pkg/agentic/proc.go index 03b75b4..4e845db 100644 --- a/pkg/agentic/proc.go +++ b/pkg/agentic/proc.go @@ -1,132 +1,84 @@ // SPDX-License-Identifier: EUPL-1.2 -// Process execution helpers — wraps go-process for testable command execution. -// All external command execution in the agentic package goes through these helpers. +// Process execution helpers — routes all commands through s.Core().Process(). +// No direct os/exec or go-process imports. // // Requires go-process to be registered with Core via: // // core.New(core.WithService(agentic.ProcessRegister)) -// -// If process service is not initialised (e.g. in tests), helpers will error. package agentic import ( "context" - "sync" "syscall" core "dappco.re/go/core" - "dappco.re/go/core/process" ) -var procOnce sync.Once - -// ensureProcess lazily initialises go-process default service for tests -// and standalone usage. In production, main.go registers ProcessRegister -// with Core which calls SetDefault properly. -func ensureProcess() { - procOnce.Do(func() { - if process.Default() != nil { - return - } - c := core.New() - svc, err := process.NewService(process.Options{})(c) - if err == nil { - if s, ok := svc.(*process.Service); ok { - _ = process.SetDefault(s) - } - } - }) -} - -// runCmd executes a command in a directory and returns (output, error). -// Uses go-process RunWithOptions — requires process service to be registered. +// runCmd executes a command in a directory. Returns Result{Value: string, OK: bool}. // -// out, err := runCmd(ctx, repoDir, "git", "log", "--oneline", "-20") -func runCmd(ctx context.Context, dir string, command string, args ...string) (string, error) { - ensureProcess() - return process.RunWithOptions(ctx, process.RunOptions{ - Command: command, - Args: args, - Dir: dir, - }) +// r := s.runCmd(ctx, repoDir, "git", "log", "--oneline", "-20") +// if r.OK { output := r.Value.(string) } +func (s *PrepSubsystem) runCmd(ctx context.Context, dir string, command string, args ...string) core.Result { + return s.Core().Process().RunIn(ctx, dir, command, args...) } // runCmdEnv executes a command with additional environment variables. // -// out, err := runCmdEnv(ctx, repoDir, []string{"GOWORK=off"}, "go", "test", "./...") -func runCmdEnv(ctx context.Context, dir string, env []string, command string, args ...string) (string, error) { - ensureProcess() - return process.RunWithOptions(ctx, process.RunOptions{ - Command: command, - Args: args, - Dir: dir, - Env: env, - }) +// r := s.runCmdEnv(ctx, repoDir, []string{"GOWORK=off"}, "go", "test", "./...") +func (s *PrepSubsystem) runCmdEnv(ctx context.Context, dir string, env []string, command string, args ...string) core.Result { + return s.Core().Process().RunWithEnv(ctx, dir, env, command, args...) } // runCmdOK executes a command and returns true if it exits 0. // -// if runCmdOK(ctx, repoDir, "go", "build", "./...") { ... } -func runCmdOK(ctx context.Context, dir string, command string, args ...string) bool { - _, err := runCmd(ctx, dir, command, args...) - return err == nil +// if s.runCmdOK(ctx, repoDir, "go", "build", "./...") { ... } +func (s *PrepSubsystem) runCmdOK(ctx context.Context, dir string, command string, args ...string) bool { + return s.runCmd(ctx, dir, command, args...).OK } // gitCmd runs a git command in the given directory. // -// out, err := gitCmd(ctx, repoDir, "log", "--oneline", "-20") -func gitCmd(ctx context.Context, dir string, args ...string) (string, error) { - return runCmd(ctx, dir, "git", args...) +// r := s.gitCmd(ctx, repoDir, "log", "--oneline", "-20") +func (s *PrepSubsystem) gitCmd(ctx context.Context, dir string, args ...string) core.Result { + return s.runCmd(ctx, dir, "git", args...) } // gitCmdOK runs a git command and returns true if it exits 0. // -// if gitCmdOK(ctx, repoDir, "fetch", "origin", "main") { ... } -func gitCmdOK(ctx context.Context, dir string, args ...string) bool { - return runCmdOK(ctx, dir, "git", args...) +// if s.gitCmdOK(ctx, repoDir, "fetch", "origin", "main") { ... } +func (s *PrepSubsystem) gitCmdOK(ctx context.Context, dir string, args ...string) bool { + return s.gitCmd(ctx, dir, args...).OK } // gitOutput runs a git command and returns trimmed stdout. // -// branch := gitOutput(ctx, repoDir, "rev-parse", "--abbrev-ref", "HEAD") -func gitOutput(ctx context.Context, dir string, args ...string) string { - out, err := gitCmd(ctx, dir, args...) - if err != nil { +// branch := s.gitOutput(ctx, repoDir, "rev-parse", "--abbrev-ref", "HEAD") +func (s *PrepSubsystem) gitOutput(ctx context.Context, dir string, args ...string) string { + r := s.gitCmd(ctx, dir, args...) + if !r.OK { return "" } - return core.Trim(out) + return core.Trim(r.Value.(string)) } // --- Process lifecycle helpers --- -// processIsRunning checks if a process is still alive. -// Uses go-process ProcessID if available, falls back to PID signal check. +// processIsRunning checks if a process is still alive via PID signal check. // // if processIsRunning(st.ProcessID, st.PID) { ... } func processIsRunning(processID string, pid int) bool { - if processID != "" { - if proc, err := process.Get(processID); err == nil { - return proc.IsRunning() - } - } if pid > 0 { return syscall.Kill(pid, 0) == nil } return false } -// processKill terminates a process. -// Uses go-process Kill if ProcessID available, falls back to SIGTERM. +// processKill terminates a process via SIGTERM. // // processKill(st.ProcessID, st.PID) func processKill(processID string, pid int) bool { - if processID != "" { - if proc, err := process.Get(processID); err == nil { - return proc.Kill() == nil - } - } if pid > 0 { return syscall.Kill(pid, syscall.SIGTERM) == nil } diff --git a/pkg/agentic/proc_example_test.go b/pkg/agentic/proc_example_test.go new file mode 100644 index 0000000..55a33e7 --- /dev/null +++ b/pkg/agentic/proc_example_test.go @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + + core "dappco.re/go/core" +) + +func ExamplePrepSubsystem_runCmd() { + r := testPrep.runCmd(context.Background(), ".", "echo", "hello") + core.Println(r.OK) + // Output: true +} + +func ExamplePrepSubsystem_gitCmd() { + r := testPrep.gitCmd(context.Background(), ".", "--version") + core.Println(r.OK) + // Output: true +} + +func ExamplePrepSubsystem_gitOutput() { + version := testPrep.gitOutput(context.Background(), ".", "--version") + core.Println(core.HasPrefix(version, "git version")) + // Output: true +} + +func ExamplePrepSubsystem_runCmdOK() { + ok := testPrep.runCmdOK(context.Background(), ".", "echo", "test") + core.Println(ok) + // Output: true +} + +func ExamplePrepSubsystem_gitCmdOK() { + ok := testPrep.gitCmdOK(context.Background(), ".", "--version") + core.Println(ok) + // Output: true +} diff --git a/pkg/agentic/proc_test.go b/pkg/agentic/proc_test.go index 3f41d12..0452cbd 100644 --- a/pkg/agentic/proc_test.go +++ b/pkg/agentic/proc_test.go @@ -6,159 +6,171 @@ import ( "context" "os" "os/exec" - "path/filepath" - "strings" - "sync" "testing" + "time" + core "dappco.re/go/core" + "dappco.re/go/core/process" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// testPrep is the package-level PrepSubsystem for tests that need process execution. +var testPrep *PrepSubsystem + +// testCore is the package-level Core with go-process registered. +var testCore *core.Core + +// TestMain sets up a PrepSubsystem with go-process registered for all tests in the package. +func TestMain(m *testing.M) { + testCore = core.New( + core.WithService(process.Register), + ) + testCore.ServiceStartup(context.Background(), nil) + + testPrep = &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), + backoff: make(map[string]time.Time), + failCount: make(map[string]int), + } + os.Exit(m.Run()) +} + +// newPrepWithProcess creates a PrepSubsystem wired to testCore for tests that +// need process execution via s.Core().Process(). +func newPrepWithProcess() *PrepSubsystem { + return &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), + backoff: make(map[string]time.Time), + failCount: make(map[string]int), + } +} + // --- runCmd --- func TestProc_RunCmd_Good(t *testing.T) { dir := t.TempDir() - out, err := runCmd(context.Background(), dir, "echo", "hello") - assert.NoError(t, err) - assert.Contains(t, strings.TrimSpace(out), "hello") + r := testPrep.runCmd(context.Background(), dir, "echo", "hello") + assert.True(t, r.OK) + assert.Contains(t, core.Trim(r.Value.(string)), "hello") } func TestProc_RunCmd_Bad(t *testing.T) { dir := t.TempDir() - _, err := runCmd(context.Background(), dir, "nonexistent-command-xyz") - assert.Error(t, err) + r := testPrep.runCmd(context.Background(), dir, "nonexistent-command-xyz") + assert.False(t, r.OK) } func TestProc_RunCmd_Ugly(t *testing.T) { dir := t.TempDir() // Empty command string — should error - _, err := runCmd(context.Background(), dir, "") - assert.Error(t, err) + r := testPrep.runCmd(context.Background(), dir, "") + assert.False(t, r.OK) } // --- runCmdEnv --- func TestProc_RunCmdEnv_Good(t *testing.T) { dir := t.TempDir() - out, err := runCmdEnv(context.Background(), dir, []string{"MY_CUSTOM_VAR=hello_test"}, "env") - assert.NoError(t, err) - assert.Contains(t, out, "MY_CUSTOM_VAR=hello_test") + r := testPrep.runCmdEnv(context.Background(), dir, []string{"MY_CUSTOM_VAR=hello_test"}, "env") + assert.True(t, r.OK) + assert.Contains(t, r.Value.(string), "MY_CUSTOM_VAR=hello_test") } func TestProc_RunCmdEnv_Bad(t *testing.T) { dir := t.TempDir() - _, err := runCmdEnv(context.Background(), dir, []string{"FOO=bar"}, "nonexistent-command-xyz") - assert.Error(t, err) + r := testPrep.runCmdEnv(context.Background(), dir, []string{"FOO=bar"}, "nonexistent-command-xyz") + assert.False(t, r.OK) } func TestProc_RunCmdEnv_Ugly(t *testing.T) { dir := t.TempDir() // Empty env slice — should work fine, just no extra vars - out, err := runCmdEnv(context.Background(), dir, []string{}, "echo", "works") - assert.NoError(t, err) - assert.Contains(t, strings.TrimSpace(out), "works") + r := testPrep.runCmdEnv(context.Background(), dir, []string{}, "echo", "works") + assert.True(t, r.OK) + assert.Contains(t, core.Trim(r.Value.(string)), "works") } // --- runCmdOK --- func TestProc_RunCmdOK_Good(t *testing.T) { dir := t.TempDir() - assert.True(t, runCmdOK(context.Background(), dir, "echo", "ok")) + assert.True(t, testPrep.runCmdOK(context.Background(), dir, "echo", "ok")) } func TestProc_RunCmdOK_Bad(t *testing.T) { dir := t.TempDir() - assert.False(t, runCmdOK(context.Background(), dir, "nonexistent-command-xyz")) + assert.False(t, testPrep.runCmdOK(context.Background(), dir, "nonexistent-command-xyz")) } func TestProc_RunCmdOK_Ugly(t *testing.T) { dir := t.TempDir() // "false" command returns exit 1 - assert.False(t, runCmdOK(context.Background(), dir, "false")) + assert.False(t, testPrep.runCmdOK(context.Background(), dir, "false")) } // --- gitCmd --- func TestProc_GitCmd_Good(t *testing.T) { dir := t.TempDir() - _, err := gitCmd(context.Background(), dir, "--version") - assert.NoError(t, err) + r := testPrep.gitCmd(context.Background(), dir, "--version") + assert.True(t, r.OK) } func TestProc_GitCmd_Bad(t *testing.T) { // git log in a non-git dir should fail dir := t.TempDir() - _, err := gitCmd(context.Background(), dir, "log") - assert.Error(t, err) + r := testPrep.gitCmd(context.Background(), dir, "log") + assert.False(t, r.OK) } func TestProc_GitCmd_Ugly(t *testing.T) { dir := t.TempDir() // Empty args — git with no arguments exits 1 - _, err := gitCmd(context.Background(), dir) - assert.Error(t, err) + r := testPrep.gitCmd(context.Background(), dir) + assert.False(t, r.OK) } // --- gitCmdOK --- func TestProc_GitCmdOK_Good(t *testing.T) { dir := t.TempDir() - assert.True(t, gitCmdOK(context.Background(), dir, "--version")) + assert.True(t, testPrep.gitCmdOK(context.Background(), dir, "--version")) } func TestProc_GitCmdOK_Bad(t *testing.T) { // git log in non-git dir returns false dir := t.TempDir() - assert.False(t, gitCmdOK(context.Background(), dir, "log")) + assert.False(t, testPrep.gitCmdOK(context.Background(), dir, "log")) } func TestProc_GitCmdOK_Ugly(t *testing.T) { // Empty dir string — git may use cwd, which may or may not be a repo // Just ensure no panic assert.NotPanics(t, func() { - gitCmdOK(context.Background(), "", "--version") + testPrep.gitCmdOK(context.Background(), "", "--version") }) } // --- gitOutput --- func TestProc_GitOutput_Good(t *testing.T) { - dir := t.TempDir() - // Init a git repo with a commit so we can read the branch - run := func(args ...string) { - t.Helper() - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = dir - cmd.Env = append(cmd.Environ(), - "GIT_AUTHOR_NAME=Test", - "GIT_AUTHOR_EMAIL=test@test.com", - "GIT_COMMITTER_NAME=Test", - "GIT_COMMITTER_EMAIL=test@test.com", - ) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "cmd %v failed: %s", args, string(out)) - } - run("git", "init", "-b", "main") - run("git", "config", "user.name", "Test") - run("git", "config", "user.email", "test@test.com") - run("git", "commit", "--allow-empty", "-m", "init") - - branch := gitOutput(context.Background(), dir, "rev-parse", "--abbrev-ref", "HEAD") + dir := initTestRepo(t) + branch := testPrep.gitOutput(context.Background(), dir, "rev-parse", "--abbrev-ref", "HEAD") assert.Equal(t, "main", branch) } func TestProc_GitOutput_Bad(t *testing.T) { // Non-git dir returns empty string dir := t.TempDir() - out := gitOutput(context.Background(), dir, "rev-parse", "--abbrev-ref", "HEAD") + out := testPrep.gitOutput(context.Background(), dir, "rev-parse", "--abbrev-ref", "HEAD") assert.Equal(t, "", out) } func TestProc_GitOutput_Ugly(t *testing.T) { // Failed command returns empty string dir := t.TempDir() - out := gitOutput(context.Background(), dir, "log", "--oneline", "-5") + out := testPrep.gitOutput(context.Background(), dir, "log", "--oneline", "-5") assert.Equal(t, "", out) } @@ -178,9 +190,6 @@ func TestProc_ProcessIsRunning_Bad(t *testing.T) { func TestProc_ProcessIsRunning_Ugly(t *testing.T) { // PID 0 — should return false (invalid PID guard: pid > 0 is false for 0) assert.False(t, processIsRunning("", 0)) - - // Empty processID with PID 0 — both paths fail - assert.False(t, processIsRunning("", 0)) } // --- processKill --- @@ -197,41 +206,9 @@ func TestProc_ProcessKill_Bad(t *testing.T) { func TestProc_ProcessKill_Ugly(t *testing.T) { // PID 0 — pid > 0 guard returns false assert.False(t, processKill("", 0)) - - // Empty processID with PID 0 — both paths fail - assert.False(t, processKill("", 0)) } -// --- ensureProcess --- - -func TestProc_EnsureProcess_Good(t *testing.T) { - // Call twice — verify no panic (idempotent via sync.Once) - assert.NotPanics(t, func() { - ensureProcess() - ensureProcess() - }) -} - -func TestProc_EnsureProcess_Bad(t *testing.T) { - t.Skip("no bad path without mocking") -} - -func TestProc_EnsureProcess_Ugly(t *testing.T) { - // Call from multiple goroutines concurrently — sync.Once should handle this - var wg sync.WaitGroup - for i := 0; i < 10; i++ { - wg.Add(1) - go func() { - defer wg.Done() - assert.NotPanics(t, func() { - ensureProcess() - }) - }() - } - wg.Wait() -} - -// --- initTestRepo is a helper to create a git repo with commits for proc tests --- +// --- initTestRepo creates a git repo with commits for proc tests --- func initTestRepo(t *testing.T) string { t.Helper() @@ -252,7 +229,7 @@ func initTestRepo(t *testing.T) string { run("git", "init", "-b", "main") run("git", "config", "user.name", "Test") run("git", "config", "user.email", "test@test.com") - require.True(t, fs.Write(filepath.Join(dir, "README.md"), "# Test").OK) + require.True(t, fs.Write(core.JoinPath(dir, "README.md"), "# Test").OK) run("git", "add", "README.md") run("git", "commit", "-m", "initial commit") return dir diff --git a/pkg/agentic/process_register.go b/pkg/agentic/process_register.go index 235da2a..9a97f90 100644 --- a/pkg/agentic/process_register.go +++ b/pkg/agentic/process_register.go @@ -7,19 +7,13 @@ import ( "dappco.re/go/core/process" ) -// ProcessRegister is the service factory for the process management service. -// Wraps core/process for the v0.3.3→v0.4 factory pattern. +// ProcessRegister is the service factory for go-process. +// Delegates to process.Register — named Actions (process.run, process.start, +// process.kill, process.list, process.get) are registered during OnStartup. // // core.New( // core.WithService(agentic.ProcessRegister), // ) func ProcessRegister(c *core.Core) core.Result { - svc, err := process.NewService(process.Options{})(c) - if err != nil { - return core.Result{Value: err, OK: false} - } - if procSvc, ok := svc.(*process.Service); ok { - _ = process.SetDefault(procSvc) - } - return core.Result{Value: svc, OK: true} + return process.Register(c) } diff --git a/pkg/agentic/process_register_example_test.go b/pkg/agentic/process_register_example_test.go new file mode 100644 index 0000000..b459bfc --- /dev/null +++ b/pkg/agentic/process_register_example_test.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic_test + +import ( + "context" + + core "dappco.re/go/core" + "dappco.re/go/agent/pkg/agentic" +) + +func ExampleProcessRegister_exists() { + c := core.New(core.WithService(agentic.ProcessRegister)) + c.ServiceStartup(context.Background(), nil) + + core.Println(c.Process().Exists()) + core.Println(c.Action("process.run").Exists()) + // Output: + // true + // true +} diff --git a/pkg/agentic/process_register_test.go b/pkg/agentic/process_register_test.go new file mode 100644 index 0000000..8a17eb1 --- /dev/null +++ b/pkg/agentic/process_register_test.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "testing" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" +) + +func TestProcessRegister_Good(t *testing.T) { + c := core.New(core.WithService(ProcessRegister)) + c.ServiceStartup(context.Background(), nil) + assert.True(t, c.Process().Exists()) +} + +func TestProcessRegister_Bad_NilCore(t *testing.T) { + // ProcessRegister delegates to process.Register + // which needs a valid Core — verify it doesn't panic + assert.NotPanics(t, func() { + c := core.New() + _ = ProcessRegister(c) + }) +} + +func TestProcessRegister_Ugly_ActionsRegistered(t *testing.T) { + c := core.New(core.WithService(ProcessRegister)) + c.ServiceStartup(context.Background(), nil) + assert.True(t, c.Action("process.run").Exists()) + assert.True(t, c.Action("process.start").Exists()) + assert.True(t, c.Action("process.kill").Exists()) +} diff --git a/pkg/agentic/queue.go b/pkg/agentic/queue.go index d361f40..dc8cabf 100644 --- a/pkg/agentic/queue.go +++ b/pkg/agentic/queue.go @@ -214,8 +214,8 @@ func baseAgent(agent string) string { func (s *PrepSubsystem) canDispatchAgent(agent string) bool { // Read concurrency from shared config (loaded once at startup) var concurrency map[string]ConcurrencyLimit - if s.core != nil { - concurrency = core.ConfigGet[map[string]ConcurrencyLimit](s.core.Config(), "agents.concurrency") + if s.ServiceRuntime != nil { + concurrency = core.ConfigGet[map[string]ConcurrencyLimit](s.Core().Config(), "agents.concurrency") } if concurrency == nil { cfg := s.loadAgentsConfig() @@ -267,9 +267,9 @@ func (s *PrepSubsystem) drainQueue() { if s.frozen { return } - if s.core != nil { - s.core.Lock("drain").Mutex.Lock() - defer s.core.Lock("drain").Mutex.Unlock() + if s.ServiceRuntime != nil { + s.Core().Lock("drain").Mutex.Lock() + defer s.Core().Lock("drain").Mutex.Unlock() } else { s.drainMu.Lock() defer s.drainMu.Unlock() @@ -318,7 +318,7 @@ func (s *PrepSubsystem) drainOne() bool { continue } - prompt := "TASK: " + st.Task + "\n\nResume from where you left off. Read CODEX.md for conventions. Commit when done." + prompt := core.Concat("TASK: ", st.Task, "\n\nResume from where you left off. Read CODEX.md for conventions. Commit when done.") pid, _, err := s.spawnAgent(st.Agent, prompt, wsDir) if err != nil { diff --git a/pkg/agentic/queue_example_test.go b/pkg/agentic/queue_example_test.go new file mode 100644 index 0000000..332e46c --- /dev/null +++ b/pkg/agentic/queue_example_test.go @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + core "dappco.re/go/core" +) + +func Example_baseAgent() { + core.Println(baseAgent("codex:gpt-5.4")) + core.Println(baseAgent("claude")) + // Output: + // codex + // claude +} diff --git a/pkg/agentic/queue_extra_test.go b/pkg/agentic/queue_extra_test.go index de4b86c..4062d53 100644 --- a/pkg/agentic/queue_extra_test.go +++ b/pkg/agentic/queue_extra_test.go @@ -94,6 +94,7 @@ rates: os.WriteFile(filepath.Join(root, "agents.yaml"), []byte(cfg), 0o644) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -112,6 +113,7 @@ func TestQueue_CountRunningByModel_Good_NoWorkspaces(t *testing.T) { os.MkdirAll(filepath.Join(root, "workspace"), 0o755) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -126,8 +128,8 @@ func TestQueue_DrainQueue_Good_NoCoreFallsBackToMutex(t *testing.T) { os.MkdirAll(filepath.Join(root, "workspace"), 0o755) s := &PrepSubsystem{ + ServiceRuntime: nil, frozen: false, - core: nil, backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -140,6 +142,7 @@ func TestQueue_DrainOne_Good_NoWorkspaces(t *testing.T) { os.MkdirAll(filepath.Join(root, "workspace"), 0o755) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -159,6 +162,7 @@ func TestQueue_DrainOne_Good_SkipsNonQueued(t *testing.T) { os.WriteFile(filepath.Join(ws, "status.json"), data, 0o644) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -178,6 +182,7 @@ func TestQueue_DrainOne_Good_SkipsBackedOffPool(t *testing.T) { os.WriteFile(filepath.Join(ws, "status.json"), data, 0o644) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: map[string]time.Time{ "codex": time.Now().Add(1 * time.Hour), @@ -202,7 +207,7 @@ func TestQueue_CanDispatchAgent_Ugly(t *testing.T) { }) s := &PrepSubsystem{ - core: c, + ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -223,7 +228,7 @@ func TestQueue_DrainQueue_Ugly(t *testing.T) { c := core.New() s := &PrepSubsystem{ - core: c, + ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), frozen: false, backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -258,7 +263,7 @@ func TestQueue_CanDispatchAgent_Bad_AgentAtLimit(t *testing.T) { }) s := &PrepSubsystem{ - core: c, + ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -287,6 +292,7 @@ func TestQueue_CountRunningByAgent_Bad_WrongAgentType(t *testing.T) { os.WriteFile(filepath.Join(ws, "status.json"), data, 0o644) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -306,6 +312,7 @@ func TestQueue_CountRunningByAgent_Ugly_CorruptStatusJSON(t *testing.T) { os.WriteFile(filepath.Join(ws, "status.json"), []byte("{not valid json!!!"), 0o644) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -333,6 +340,7 @@ func TestQueue_CountRunningByModel_Bad_NoMatchingModel(t *testing.T) { os.WriteFile(filepath.Join(ws, "status.json"), data, 0o644) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -361,6 +369,7 @@ func TestQueue_CountRunningByModel_Ugly_ModelMismatch(t *testing.T) { } s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -387,6 +396,7 @@ rates: os.WriteFile(filepath.Join(root, "agents.yaml"), []byte(cfg), 0o644) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -411,6 +421,7 @@ rates: os.WriteFile(filepath.Join(root, "agents.yaml"), []byte(cfg), 0o644) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -455,7 +466,7 @@ func TestQueue_DrainOne_Bad_QueuedButAtConcurrencyLimit(t *testing.T) { }) s := &PrepSubsystem{ - core: c, + ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -478,6 +489,7 @@ func TestQueue_DrainOne_Ugly_QueuedButInBackoffWindow(t *testing.T) { os.WriteFile(filepath.Join(ws, "status.json"), data, 0o644) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: map[string]time.Time{ "codex": time.Now().Add(1 * time.Hour), // pool is backed off @@ -539,6 +551,7 @@ rates: require.True(t, fs.Write(core.JoinPath(root, "agents.yaml"), cfg).OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -560,6 +573,7 @@ func TestQueue_LoadAgentsConfig_Bad(t *testing.T) { require.True(t, fs.Write(core.JoinPath(root, "agents.yaml"), "{{{not yaml!!!").OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -577,6 +591,7 @@ func TestQueue_LoadAgentsConfig_Ugly(t *testing.T) { // No agents.yaml file at all — should return defaults s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -604,6 +619,7 @@ func TestQueue_DrainQueue_Bad_FrozenQueueDoesNothing(t *testing.T) { os.WriteFile(filepath.Join(ws, "status.json"), data, 0o644) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), frozen: true, // queue is frozen codePath: t.TempDir(), backoff: make(map[string]time.Time), diff --git a/pkg/agentic/queue_logic_test.go b/pkg/agentic/queue_logic_test.go index 8b3135b..ff4e337 100644 --- a/pkg/agentic/queue_logic_test.go +++ b/pkg/agentic/queue_logic_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -18,7 +19,7 @@ func TestQueue_CountRunningByModel_Good_Empty(t *testing.T) { root := t.TempDir() t.Setenv("CORE_WORKSPACE", root) - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} assert.Equal(t, 0, s.countRunningByModel("claude:opus")) } @@ -35,7 +36,7 @@ func TestQueue_CountRunningByModel_Good_SkipsNonRunning(t *testing.T) { PID: 0, })) - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} assert.Equal(t, 0, s.countRunningByModel("codex:gpt-5.4")) } @@ -51,7 +52,7 @@ func TestQueue_CountRunningByModel_Good_SkipsMismatchedModel(t *testing.T) { PID: 0, })) - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} // Asking for gemini:pro — must not count gemini:flash assert.Equal(t, 0, s.countRunningByModel("gemini:pro")) } @@ -68,7 +69,7 @@ func TestQueue_CountRunningByModel_Good_DeepLayout(t *testing.T) { Agent: "codex:gpt-5.4", })) - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} // Completed, so count is still 0 assert.Equal(t, 0, s.countRunningByModel("codex:gpt-5.4")) } @@ -79,7 +80,7 @@ func TestQueue_DrainQueue_Good_FrozenReturnsImmediately(t *testing.T) { root := t.TempDir() t.Setenv("CORE_WORKSPACE", root) - s := &PrepSubsystem{frozen: true, backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), frozen: true, backoff: make(map[string]time.Time), failCount: make(map[string]int)} // Must not panic and must not block assert.NotPanics(t, func() { s.drainQueue() @@ -90,7 +91,7 @@ func TestQueue_DrainQueue_Good_EmptyWorkspace(t *testing.T) { root := t.TempDir() t.Setenv("CORE_WORKSPACE", root) - s := &PrepSubsystem{frozen: false, backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), frozen: false, backoff: make(map[string]time.Time), failCount: make(map[string]int)} // No workspaces — must return without error/panic assert.NotPanics(t, func() { s.drainQueue() @@ -100,7 +101,7 @@ func TestQueue_DrainQueue_Good_EmptyWorkspace(t *testing.T) { // --- Poke --- func TestRunner_Poke_Good_NilChannel(t *testing.T) { - s := &PrepSubsystem{pokeCh: nil} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), pokeCh: nil} // Must not panic when pokeCh is nil assert.NotPanics(t, func() { s.Poke() @@ -108,7 +109,7 @@ func TestRunner_Poke_Good_NilChannel(t *testing.T) { } func TestRunner_Poke_Good_ChannelReceivesSignal(t *testing.T) { - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} s.pokeCh = make(chan struct{}, 1) s.Poke() @@ -116,7 +117,7 @@ func TestRunner_Poke_Good_ChannelReceivesSignal(t *testing.T) { } func TestRunner_Poke_Good_NonBlockingWhenFull(t *testing.T) { - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} s.pokeCh = make(chan struct{}, 1) // Pre-fill the channel s.pokeCh <- struct{}{} @@ -169,7 +170,7 @@ func TestRunner_Poke_Ugly(t *testing.T) { // but closing + sending would panic. However, Poke uses non-blocking send, // so we test that pokeCh=nil is safe (already tested), and that // double-filling is safe (already tested). Here we test rapid multi-poke. - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} s.pokeCh = make(chan struct{}, 1) // Rapid-fire pokes — should all be safe @@ -218,7 +219,7 @@ func TestRunner_StartRunner_Ugly(t *testing.T) { func TestPaths_DefaultBranch_Good_DefaultsToMain(t *testing.T) { // Non-git temp dir — git commands fail, fallback is "main" dir := t.TempDir() - branch := DefaultBranch(dir) + branch := testPrep.DefaultBranch(dir) assert.Equal(t, "main", branch) } @@ -227,7 +228,7 @@ func TestPaths_DefaultBranch_Good_RealGitRepo(t *testing.T) { // Init a real git repo with a main branch require.NoError(t, runGitInit(dir)) - branch := DefaultBranch(dir) + branch := testPrep.DefaultBranch(dir) // Any valid branch name — just must not panic or be empty assert.NotEmpty(t, branch) } diff --git a/pkg/agentic/queue_test.go b/pkg/agentic/queue_test.go index 0c29eb8..49dc9c5 100644 --- a/pkg/agentic/queue_test.go +++ b/pkg/agentic/queue_test.go @@ -3,6 +3,7 @@ package agentic import ( + core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "path/filepath" @@ -20,7 +21,7 @@ func TestQueue_BaseAgent_Ugly_MultipleColons(t *testing.T) { func TestDispatchConfig_Good_Defaults(t *testing.T) { // loadAgentsConfig falls back to defaults when no config file exists - s := &PrepSubsystem{codePath: t.TempDir()} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir()} t.Setenv("CORE_WORKSPACE", t.TempDir()) cfg := s.loadAgentsConfig() @@ -36,7 +37,7 @@ func TestQueue_CanDispatchAgent_Good_NoConfig(t *testing.T) { t.Setenv("CORE_WORKSPACE", root) require.True(t, fs.EnsureDir(filepath.Join(root, "workspace")).OK) - s := &PrepSubsystem{codePath: t.TempDir()} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir()} assert.True(t, s.canDispatchAgent("gemini")) } @@ -46,7 +47,7 @@ func TestQueue_CanDispatchAgent_Good_UnknownAgent(t *testing.T) { t.Setenv("CORE_WORKSPACE", root) require.True(t, fs.EnsureDir(filepath.Join(root, "workspace")).OK) - s := &PrepSubsystem{codePath: t.TempDir()} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir()} assert.True(t, s.canDispatchAgent("unknown-agent")) } @@ -55,7 +56,7 @@ func TestQueue_CountRunningByAgent_Good_EmptyWorkspace(t *testing.T) { t.Setenv("CORE_WORKSPACE", root) require.True(t, fs.EnsureDir(filepath.Join(root, "workspace")).OK) - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} assert.Equal(t, 0, s.countRunningByAgent("gemini")) assert.Equal(t, 0, s.countRunningByAgent("claude")) } @@ -73,13 +74,13 @@ func TestQueue_CountRunningByAgent_Good_NoRunning(t *testing.T) { PID: 0, })) - s := &PrepSubsystem{} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} assert.Equal(t, 0, s.countRunningByAgent("gemini")) } func TestQueue_DelayForAgent_Good_NoConfig(t *testing.T) { // With no config, delay should be 0 t.Setenv("CORE_WORKSPACE", t.TempDir()) - s := &PrepSubsystem{codePath: t.TempDir()} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir()} assert.Equal(t, 0, int(s.delayForAgent("gemini").Seconds())) } diff --git a/pkg/agentic/register.go b/pkg/agentic/register.go index fbac008..d679299 100644 --- a/pkg/agentic/register.go +++ b/pkg/agentic/register.go @@ -8,15 +8,15 @@ import ( // Register is the service factory for core.WithService. // Returns the PrepSubsystem instance — WithService auto-discovers the name -// from the package path and registers it. Startable/Stoppable/HandleIPCEvents -// are auto-discovered by RegisterService. +// from the package path and registers it. // // core.New( +// core.WithService(agentic.ProcessRegister), // core.WithService(agentic.Register), // ) func Register(c *core.Core) core.Result { prep := NewPrep() - prep.core = c + prep.ServiceRuntime = core.NewServiceRuntime(c, AgentOptions{}) // Load agents config once into Core shared config cfg := prep.loadAgentsConfig() diff --git a/pkg/agentic/register_example_test.go b/pkg/agentic/register_example_test.go new file mode 100644 index 0000000..a535458 --- /dev/null +++ b/pkg/agentic/register_example_test.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic_test + +import ( + "context" + + core "dappco.re/go/core" + + "dappco.re/go/agent/pkg/agentic" +) + +func ExampleProcessRegister() { + c := core.New( + core.WithService(agentic.ProcessRegister), + ) + c.ServiceStartup(context.Background(), nil) + + core.Println(c.Process().Exists()) + // Output: true +} diff --git a/pkg/agentic/register_test.go b/pkg/agentic/register_test.go index e769ca6..439a977 100644 --- a/pkg/agentic/register_test.go +++ b/pkg/agentic/register_test.go @@ -38,9 +38,9 @@ func TestRegister_Good_CoreWired(t *testing.T) { prep, ok := core.ServiceFor[*PrepSubsystem](c, "agentic") require.True(t, ok) - // Register must wire s.core — service needs it for config access - assert.NotNil(t, prep.core, "Register must set prep.core") - assert.Equal(t, c, prep.core) + // Register must wire ServiceRuntime — service needs it for Core access + assert.NotNil(t, prep.ServiceRuntime, "Register must set ServiceRuntime") + assert.Equal(t, c, prep.Core()) } func TestRegister_Good_AgentsConfigLoaded(t *testing.T) { @@ -97,8 +97,8 @@ func TestPrep_OnStartup_Good_CreatesPokeCh(t *testing.T) { assert.Nil(t, s.pokeCh, "pokeCh should be nil before OnStartup") - err := s.OnStartup(context.Background()) - require.NoError(t, err) + r := s.OnStartup(context.Background()) + assert.True(t, r.OK) assert.NotNil(t, s.pokeCh, "OnStartup must initialise pokeCh via StartRunner") } @@ -111,7 +111,7 @@ func TestPrep_OnStartup_Good_FrozenByDefault(t *testing.T) { s := NewPrep() s.SetCore(c) - require.NoError(t, s.OnStartup(context.Background())) + assert.True(t, s.OnStartup(context.Background()).OK) assert.True(t, s.frozen, "queue must be frozen after OnStartup without CORE_AGENT_DISPATCH=1") } @@ -123,8 +123,7 @@ func TestPrep_OnStartup_Good_NoError(t *testing.T) { s := NewPrep() s.SetCore(c) - err := s.OnStartup(context.Background()) - assert.NoError(t, err) + assert.True(t, s.OnStartup(context.Background()).OK) } // --- OnShutdown --- @@ -132,28 +131,28 @@ func TestPrep_OnStartup_Good_NoError(t *testing.T) { func TestPrep_OnShutdown_Good_FreezesQueue(t *testing.T) { t.Setenv("CORE_WORKSPACE", t.TempDir()) - s := &PrepSubsystem{frozen: false} - err := s.OnShutdown(context.Background()) - require.NoError(t, err) + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), frozen: false} + r := s.OnShutdown(context.Background()) + assert.True(t, r.OK) assert.True(t, s.frozen, "OnShutdown must set frozen=true") } func TestPrep_OnShutdown_Good_AlreadyFrozen(t *testing.T) { // Calling OnShutdown twice must be idempotent - s := &PrepSubsystem{frozen: true} - err := s.OnShutdown(context.Background()) - require.NoError(t, err) + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), frozen: true} + r := s.OnShutdown(context.Background()) + assert.True(t, r.OK) assert.True(t, s.frozen) } func TestPrep_OnShutdown_Good_NoError(t *testing.T) { - s := &PrepSubsystem{} - assert.NoError(t, s.OnShutdown(context.Background())) + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} + assert.True(t, s.OnShutdown(context.Background()).OK) } func TestPrep_OnShutdown_Ugly_NilCore(t *testing.T) { // OnShutdown must not panic even if s.core is nil - s := &PrepSubsystem{core: nil, frozen: false} + s := &PrepSubsystem{ServiceRuntime: nil, frozen: false} assert.NotPanics(t, func() { _ = s.OnShutdown(context.Background()) }) diff --git a/pkg/agentic/remote.go b/pkg/agentic/remote.go index 3dcb55d..07259d5 100644 --- a/pkg/agentic/remote.go +++ b/pkg/agentic/remote.go @@ -4,10 +4,6 @@ package agentic import ( "context" - "encoding/json" - "net/http" - "time" - core "dappco.re/go/core" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -97,10 +93,9 @@ func (s *PrepSubsystem) dispatchRemote(ctx context.Context, _ *mcp.CallToolReque } url := core.Sprintf("http://%s/mcp", addr) - client := &http.Client{Timeout: 30 * time.Second} // Step 1: Initialize session - sessionID, err := mcpInitialize(ctx, client, url, token) + sessionID, err := mcpInitialize(ctx, url, token) if err != nil { return nil, RemoteDispatchOutput{ Host: input.Host, @@ -109,8 +104,8 @@ func (s *PrepSubsystem) dispatchRemote(ctx context.Context, _ *mcp.CallToolReque } // Step 2: Call the tool - body, _ := json.Marshal(rpcReq) - result, err := mcpCall(ctx, client, url, token, sessionID, body) + body := []byte(core.JSONMarshalString(rpcReq)) + result, err := mcpCall(ctx, url, token, sessionID, body) if err != nil { return nil, RemoteDispatchOutput{ Host: input.Host, @@ -136,13 +131,13 @@ func (s *PrepSubsystem) dispatchRemote(ctx context.Context, _ *mcp.CallToolReque Message string `json:"message"` } `json:"error"` } - if json.Unmarshal(result, &rpcResp) == nil { + if r := core.JSONUnmarshal(result, &rpcResp); r.OK { if rpcResp.Error != nil { output.Success = false output.Error = rpcResp.Error.Message } else if len(rpcResp.Result.Content) > 0 { var dispatchOut DispatchOutput - if json.Unmarshal([]byte(rpcResp.Result.Content[0].Text), &dispatchOut) == nil { + if r := core.JSONUnmarshalString(rpcResp.Result.Content[0].Text, &dispatchOut); r.OK { output.Success = dispatchOut.Success output.WorkspaceDir = dispatchOut.WorkspaceDir output.PID = dispatchOut.PID @@ -169,7 +164,7 @@ func resolveHost(host string) string { // If no port specified, add default if !core.Contains(host, ":") { - return host + ":9101" + return core.Concat(host, ":9101") } return host diff --git a/pkg/agentic/remote_client.go b/pkg/agentic/remote_client.go index 1969565..f9e3472 100644 --- a/pkg/agentic/remote_client.go +++ b/pkg/agentic/remote_client.go @@ -1,126 +1,6 @@ // SPDX-License-Identifier: EUPL-1.2 +// MCP remote client helpers. +// HTTP transport functions are in transport.go (the ONE net/http file). + package agentic - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "net/http" - - core "dappco.re/go/core" -) - -// mcpInitialize performs the MCP initialise handshake over Streamable HTTP. -// Returns the session ID from the Mcp-Session-Id header. -func mcpInitialize(ctx context.Context, client *http.Client, url, token string) (string, error) { - initReq := map[string]any{ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": map[string]any{ - "protocolVersion": "2025-03-26", - "capabilities": map[string]any{}, - "clientInfo": map[string]any{ - "name": "core-agent-remote", - "version": "0.2.0", - }, - }, - } - - body, _ := json.Marshal(initReq) - - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) - if err != nil { - return "", core.E("mcpInitialize", "create request", err) - } - setHeaders(req, token, "") - - resp, err := client.Do(req) - if err != nil { - return "", core.E("mcpInitialize", "request failed", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return "", core.E("mcpInitialize", core.Sprintf("HTTP %d", resp.StatusCode), nil) - } - - sessionID := resp.Header.Get("Mcp-Session-Id") - - // Drain the SSE response (we don't need the initialise result) - drainSSE(resp) - - // Send initialised notification - notif := map[string]any{ - "jsonrpc": "2.0", - "method": "notifications/initialized", - } - notifBody, _ := json.Marshal(notif) - - notifReq, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(notifBody)) - setHeaders(notifReq, token, sessionID) - - notifResp, err := client.Do(notifReq) - if err == nil { - notifResp.Body.Close() - } - - return sessionID, nil -} - -// mcpCall sends a JSON-RPC request and returns the parsed response. -// Handles the SSE response format (text/event-stream with data: lines). -func mcpCall(ctx context.Context, client *http.Client, url, token, sessionID string, body []byte) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) - if err != nil { - return nil, core.E("mcpCall", "create request", err) - } - setHeaders(req, token, sessionID) - - resp, err := client.Do(req) - if err != nil { - return nil, core.E("mcpCall", "request failed", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, core.E("mcpCall", core.Sprintf("HTTP %d", resp.StatusCode), nil) - } - - // Parse SSE response — extract data: lines - return readSSEData(resp) -} - -// readSSEData reads an SSE response and extracts the JSON from data: lines. -func readSSEData(resp *http.Response) ([]byte, error) { - scanner := bufio.NewScanner(resp.Body) - for scanner.Scan() { - line := scanner.Text() - if core.HasPrefix(line, "data: ") { - return []byte(core.TrimPrefix(line, "data: ")), nil - } - } - return nil, core.E("readSSEData", "no data in SSE response", nil) -} - -// setHeaders applies standard MCP HTTP headers. -func setHeaders(req *http.Request, token, sessionID string) { - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json, text/event-stream") - if token != "" { - req.Header.Set("Authorization", "Bearer "+token) - } - if sessionID != "" { - req.Header.Set("Mcp-Session-Id", sessionID) - } -} - -// drainSSE reads and discards an SSE response body. -func drainSSE(resp *http.Response) { - scanner := bufio.NewScanner(resp.Body) - for scanner.Scan() { - // Discard - } -} diff --git a/pkg/agentic/remote_client_example_test.go b/pkg/agentic/remote_client_example_test.go new file mode 100644 index 0000000..595e847 --- /dev/null +++ b/pkg/agentic/remote_client_example_test.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import core "dappco.re/go/core" + +func ExampleDispatchInput_remote() { + input := DispatchInput{Repo: "go-io", Task: "Fix tests", Agent: "codex"} + core.Println(input.Agent) + // Output: codex +} diff --git a/pkg/agentic/remote_client_test.go b/pkg/agentic/remote_client_test.go index ebac33f..5672856 100644 --- a/pkg/agentic/remote_client_test.go +++ b/pkg/agentic/remote_client_test.go @@ -41,7 +41,7 @@ func TestRemoteClient_McpInitialize_Good(t *testing.T) { })) t.Cleanup(srv.Close) - sessionID, err := mcpInitialize(context.Background(), srv.Client(), srv.URL, "test-token") + sessionID, err := mcpInitialize(context.Background(), srv.URL, "test-token") require.NoError(t, err) assert.Equal(t, "session-abc", sessionID) assert.Equal(t, 2, callCount, "should make init + notification requests") @@ -53,13 +53,13 @@ func TestRemoteClient_McpInitialize_Bad_ServerError(t *testing.T) { })) t.Cleanup(srv.Close) - _, err := mcpInitialize(context.Background(), srv.Client(), srv.URL, "") + _, err := mcpInitialize(context.Background(), srv.URL, "") assert.Error(t, err) assert.Contains(t, err.Error(), "HTTP 500") } func TestRemoteClient_McpInitialize_Bad_Unreachable(t *testing.T) { - _, err := mcpInitialize(context.Background(), http.DefaultClient, "http://127.0.0.1:1", "") + _, err := mcpInitialize(context.Background(), "http://127.0.0.1:1", "") assert.Error(t, err) assert.Contains(t, err.Error(), "request failed") } @@ -77,7 +77,7 @@ func TestRemoteClient_McpCall_Good(t *testing.T) { t.Cleanup(srv.Close) body := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call"}`) - result, err := mcpCall(context.Background(), srv.Client(), srv.URL, "mytoken", "sess-123", body) + result, err := mcpCall(context.Background(), srv.URL, "mytoken", "sess-123", body) require.NoError(t, err) assert.Contains(t, string(result), "hello") } @@ -88,7 +88,7 @@ func TestRemoteClient_McpCall_Bad_HTTP500(t *testing.T) { })) t.Cleanup(srv.Close) - _, err := mcpCall(context.Background(), srv.Client(), srv.URL, "", "", nil) + _, err := mcpCall(context.Background(), srv.URL, "", "", nil) assert.Error(t, err) assert.Contains(t, err.Error(), "HTTP 500") } @@ -100,7 +100,7 @@ func TestRemoteClient_McpCall_Bad_NoSSEData(t *testing.T) { })) t.Cleanup(srv.Close) - _, err := mcpCall(context.Background(), srv.Client(), srv.URL, "", "", nil) + _, err := mcpCall(context.Background(), srv.URL, "", "", nil) assert.Error(t, err) assert.Contains(t, err.Error(), "no data") } @@ -109,7 +109,7 @@ func TestRemoteClient_McpCall_Bad_NoSSEData(t *testing.T) { func TestRemoteClient_SetHeaders_Good_All(t *testing.T) { req, _ := http.NewRequest("POST", "http://example.com", nil) - setHeaders(req, "my-token", "my-session") + mcpHeaders(req, "my-token", "my-session") assert.Equal(t, "application/json", req.Header.Get("Content-Type")) assert.Equal(t, "application/json, text/event-stream", req.Header.Get("Accept")) @@ -119,7 +119,7 @@ func TestRemoteClient_SetHeaders_Good_All(t *testing.T) { func TestRemoteClient_SetHeaders_Good_NoToken(t *testing.T) { req, _ := http.NewRequest("POST", "http://example.com", nil) - setHeaders(req, "", "") + mcpHeaders(req, "", "") assert.Empty(t, req.Header.Get("Authorization")) assert.Empty(t, req.Header.Get("Mcp-Session-Id")) @@ -130,7 +130,7 @@ func TestRemoteClient_SetHeaders_Good_NoToken(t *testing.T) { func TestRemoteClient_SetHeaders_Bad(t *testing.T) { // Both token and session empty — only Content-Type and Accept are set req, _ := http.NewRequest("POST", "http://example.com", nil) - setHeaders(req, "", "") + mcpHeaders(req, "", "") assert.Equal(t, "application/json", req.Header.Get("Content-Type")) assert.Equal(t, "application/json, text/event-stream", req.Header.Get("Accept")) @@ -199,7 +199,7 @@ func TestRemoteClient_McpInitialize_Ugly_NonJSONSSE(t *testing.T) { t.Cleanup(srv.Close) // mcpInitialize drains the SSE body but doesn't parse it — should succeed - sessionID, err := mcpInitialize(context.Background(), srv.Client(), srv.URL, "tok") + sessionID, err := mcpInitialize(context.Background(), srv.URL, "tok") require.NoError(t, err) assert.Equal(t, "sess-ugly", sessionID) } @@ -213,7 +213,7 @@ func TestRemoteClient_McpCall_Ugly_EmptyResponseBody(t *testing.T) { })) t.Cleanup(srv.Close) - _, err := mcpCall(context.Background(), srv.Client(), srv.URL, "", "", nil) + _, err := mcpCall(context.Background(), srv.URL, "", "", nil) assert.Error(t, err) assert.Contains(t, err.Error(), "no data") } @@ -242,7 +242,7 @@ func TestRemoteClient_ReadSSEData_Ugly_OnlyEventLines(t *testing.T) { func TestRemoteClient_SetHeaders_Ugly_VeryLongToken(t *testing.T) { req, _ := http.NewRequest("POST", "http://example.com", nil) longToken := strings.Repeat("a", 10000) - setHeaders(req, longToken, "sess-123") + mcpHeaders(req, longToken, "sess-123") assert.Equal(t, "Bearer "+longToken, req.Header.Get("Authorization")) assert.Equal(t, "sess-123", req.Header.Get("Mcp-Session-Id")) diff --git a/pkg/agentic/remote_dispatch_test.go b/pkg/agentic/remote_dispatch_test.go index d89ea84..09e8bfe 100644 --- a/pkg/agentic/remote_dispatch_test.go +++ b/pkg/agentic/remote_dispatch_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -44,7 +45,7 @@ func TestRemote_DispatchRemote_Good(t *testing.T) { })) t.Cleanup(srv.Close) - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int)} _, out, err := s.dispatchRemote(context.Background(), nil, RemoteDispatchInput{ Host: srv.Listener.Addr().String(), Repo: "go-io", Task: "Fix tests", }) @@ -54,7 +55,7 @@ func TestRemote_DispatchRemote_Good(t *testing.T) { } func TestRemote_DispatchRemote_Bad(t *testing.T) { - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int)} // Missing host _, _, err := s.dispatchRemote(context.Background(), nil, RemoteDispatchInput{Repo: "go-io", Task: "do"}) @@ -97,7 +98,7 @@ func TestRemote_DispatchRemote_Ugly(t *testing.T) { })) t.Cleanup(srv.Close) - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int)} _, out, err := s.dispatchRemote(context.Background(), nil, RemoteDispatchInput{ Host: srv.Listener.Addr().String(), Repo: "go-io", Task: "test", Agent: "claude:opus", Org: "core", Template: "coding", Persona: "eng", diff --git a/pkg/agentic/remote_example_test.go b/pkg/agentic/remote_example_test.go new file mode 100644 index 0000000..eee1015 --- /dev/null +++ b/pkg/agentic/remote_example_test.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import core "dappco.re/go/core" + +func ExampleDispatchInput() { + input := DispatchInput{Repo: "go-io", Task: "Fix tests", Agent: "codex"} + core.Println(input.Repo, input.Agent) + // Output: go-io codex +} diff --git a/pkg/agentic/remote_status.go b/pkg/agentic/remote_status.go index d58f6ec..6fde62f 100644 --- a/pkg/agentic/remote_status.go +++ b/pkg/agentic/remote_status.go @@ -4,10 +4,6 @@ package agentic import ( "context" - "encoding/json" - "net/http" - "time" - core "dappco.re/go/core" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -47,13 +43,11 @@ func (s *PrepSubsystem) statusRemote(ctx context.Context, _ *mcp.CallToolRequest token := remoteToken(input.Host) url := "http://" + addr + "/mcp" - client := &http.Client{Timeout: 15 * time.Second} - - sessionID, err := mcpInitialize(ctx, client, url, token) + sessionID, err := mcpInitialize(ctx, url, token) if err != nil { return nil, RemoteStatusOutput{ Host: input.Host, - Error: "unreachable: " + err.Error(), + Error: core.Concat("unreachable: ", err.Error()), }, nil } @@ -66,13 +60,13 @@ func (s *PrepSubsystem) statusRemote(ctx context.Context, _ *mcp.CallToolRequest "arguments": map[string]any{}, }, } - body, _ := json.Marshal(rpcReq) + body := []byte(core.JSONMarshalString(rpcReq)) - result, err := mcpCall(ctx, client, url, token, sessionID, body) + result, err := mcpCall(ctx, url, token, sessionID, body) if err != nil { return nil, RemoteStatusOutput{ Host: input.Host, - Error: "call failed: " + err.Error(), + Error: core.Concat("call failed: ", err.Error()), }, nil } @@ -92,7 +86,7 @@ func (s *PrepSubsystem) statusRemote(ctx context.Context, _ *mcp.CallToolRequest Message string `json:"message"` } `json:"error"` } - if json.Unmarshal(result, &rpcResp) != nil { + if r := core.JSONUnmarshal(result, &rpcResp); !r.OK { output.Success = false output.Error = "failed to parse response" return nil, output, nil @@ -104,7 +98,7 @@ func (s *PrepSubsystem) statusRemote(ctx context.Context, _ *mcp.CallToolRequest } if len(rpcResp.Result.Content) > 0 { var statusOut StatusOutput - if json.Unmarshal([]byte(rpcResp.Result.Content[0].Text), &statusOut) == nil { + if r := core.JSONUnmarshalString(rpcResp.Result.Content[0].Text, &statusOut); r.OK { output.Stats = statusOut } } diff --git a/pkg/agentic/remote_status_example_test.go b/pkg/agentic/remote_status_example_test.go new file mode 100644 index 0000000..a84b93c --- /dev/null +++ b/pkg/agentic/remote_status_example_test.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import core "dappco.re/go/core" + +func ExampleRemoteStatusOutput() { + out := RemoteStatusOutput{Success: true} + core.Println(out.Success) + // Output: true +} diff --git a/pkg/agentic/remote_status_test.go b/pkg/agentic/remote_status_test.go index a2a3757..efd7fd9 100644 --- a/pkg/agentic/remote_status_test.go +++ b/pkg/agentic/remote_status_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -44,7 +45,7 @@ func TestRemoteStatus_StatusRemote_Good(t *testing.T) { })) t.Cleanup(srv.Close) - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int)} _, out, err := s.statusRemote(context.Background(), nil, RemoteStatusInput{ Host: srv.Listener.Addr().String(), }) @@ -55,7 +56,7 @@ func TestRemoteStatus_StatusRemote_Good(t *testing.T) { } func TestRemoteStatus_StatusRemote_Bad(t *testing.T) { - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int)} // Missing host _, _, err := s.statusRemote(context.Background(), nil, RemoteStatusInput{}) @@ -108,7 +109,7 @@ func TestRemoteStatus_StatusRemote_Ugly(t *testing.T) { })) t.Cleanup(srv.Close) - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int)} _, out, _ := s.statusRemote(context.Background(), nil, RemoteStatusInput{Host: srv.Listener.Addr().String()}) assert.False(t, out.Success) assert.Contains(t, out.Error, "internal error") diff --git a/pkg/agentic/render_plan_test.go b/pkg/agentic/render_plan_test.go index bc868bc..c2aa036 100644 --- a/pkg/agentic/render_plan_test.go +++ b/pkg/agentic/render_plan_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + core "dappco.re/go/core" "github.com/stretchr/testify/assert" ) @@ -13,6 +14,7 @@ import ( func TestPrep_RenderPlan_Good_BugFix(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -28,6 +30,7 @@ func TestPrep_RenderPlan_Good_BugFix(t *testing.T) { func TestPrep_RenderPlan_Good_WithVariables(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -44,6 +47,7 @@ func TestPrep_RenderPlan_Good_WithVariables(t *testing.T) { func TestPrep_RenderPlan_Bad_UnknownTemplate(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -53,6 +57,7 @@ func TestPrep_RenderPlan_Bad_UnknownTemplate(t *testing.T) { func TestPrep_RenderPlan_Good_NoTask(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -65,6 +70,7 @@ func TestPrep_RenderPlan_Good_NoTask(t *testing.T) { func TestPrep_RenderPlan_Good_NewFeature(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } diff --git a/pkg/agentic/resume.go b/pkg/agentic/resume.go index ac90624..1600264 100644 --- a/pkg/agentic/resume.go +++ b/pkg/agentic/resume.go @@ -78,11 +78,11 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu } // Build resume prompt — inline the task and answer, no file references - prompt := "You are resuming previous work.\n\nORIGINAL TASK:\n" + st.Task + prompt := core.Concat("You are resuming previous work.\n\nORIGINAL TASK:\n", st.Task) if input.Answer != "" { - prompt += "\n\nANSWER TO YOUR QUESTION:\n" + input.Answer + prompt = core.Concat(prompt, "\n\nANSWER TO YOUR QUESTION:\n", input.Answer) } - prompt += "\n\nContinue working. Read BLOCKED.md to see what you were stuck on. Commit when done." + prompt = core.Concat(prompt, "\n\nContinue working. Read BLOCKED.md to see what you were stuck on. Commit when done.") if input.DryRun { return nil, ResumeOutput{ diff --git a/pkg/agentic/resume_example_test.go b/pkg/agentic/resume_example_test.go new file mode 100644 index 0000000..7261476 --- /dev/null +++ b/pkg/agentic/resume_example_test.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import core "dappco.re/go/core" + +func ExampleResumeInput() { + input := ResumeInput{Workspace: "core/go-io/task-5", Answer: "Use v2 API"} + core.Println(input.Workspace) + // Output: core/go-io/task-5 +} diff --git a/pkg/agentic/resume_test.go b/pkg/agentic/resume_test.go index 75160d1..78273e0 100644 --- a/pkg/agentic/resume_test.go +++ b/pkg/agentic/resume_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -31,7 +32,7 @@ func TestResume_Resume_Good(t *testing.T) { data, _ := json.Marshal(st) os.WriteFile(filepath.Join(ws, "status.json"), data, 0o644) - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int)} _, out, err := s.resume(context.Background(), nil, ResumeInput{ Workspace: "ws-blocked", Answer: "Use the new Core API", DryRun: true, }) @@ -68,7 +69,7 @@ func TestResume_Resume_Bad(t *testing.T) { root := t.TempDir() t.Setenv("CORE_WORKSPACE", root) - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int)} // Empty workspace _, _, err := s.resume(context.Background(), nil, ResumeInput{}) @@ -102,7 +103,7 @@ func TestResume_Resume_Ugly(t *testing.T) { os.MkdirAll(filepath.Join(ws, "repo"), 0o755) exec.Command("git", "init", filepath.Join(ws, "repo")).Run() - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int)} _, _, err := s.resume(context.Background(), nil, ResumeInput{Workspace: "ws-nostatus"}) assert.Error(t, err) assert.Contains(t, err.Error(), "no status.json") diff --git a/pkg/agentic/review_queue.go b/pkg/agentic/review_queue.go index 5193189..caca650 100644 --- a/pkg/agentic/review_queue.go +++ b/pkg/agentic/review_queue.go @@ -4,9 +4,6 @@ package agentic import ( "context" - "encoding/json" - "io" - "os" "regexp" "time" @@ -134,24 +131,20 @@ func (s *PrepSubsystem) reviewQueue(ctx context.Context, _ *mcp.CallToolRequest, // findReviewCandidates returns repos that are ahead of GitHub main. func (s *PrepSubsystem) findReviewCandidates(basePath string) []string { - r := fs.List(basePath) - if !r.OK { - return nil - } - entries := r.Value.([]os.DirEntry) + paths := core.PathGlob(core.JoinPath(basePath, "*")) var candidates []string - for _, e := range entries { - if !e.IsDir() { + for _, p := range paths { + if !fs.IsDir(p) { continue } - repoDir := core.JoinPath(basePath, e.Name()) - if !hasRemote(repoDir, "github") { + name := core.PathBase(p) + if !s.hasRemote(p, "github") { continue } - ahead := commitsAhead(repoDir, "github/main", "HEAD") + ahead := s.commitsAhead(p, "github/main", "HEAD") if ahead > 0 { - candidates = append(candidates, e.Name()) + candidates = append(candidates, name) } } return candidates @@ -173,7 +166,8 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer reviewer = "coderabbit" } command, args := s.buildReviewCommand(repoDir, reviewer) - output, err := runCmd(ctx, repoDir, command, args...) + r := s.runCmd(ctx, repoDir, command, args...) + output, _ := r.Value.(string) // Parse rate limit (both reviewers use similar patterns) if core.Contains(output, "Rate limit exceeded") || core.Contains(output, "rate limit") { @@ -183,7 +177,7 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer } // Parse error - if err != nil && !core.Contains(output, "No findings") && !core.Contains(output, "no issues") { + if !r.OK && !core.Contains(output, "No findings") && !core.Contains(output, "no issues") { result.Verdict = "error" result.Detail = output return result @@ -209,7 +203,7 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer // Push to GitHub and mark PR ready / merge if err := s.pushAndMerge(ctx, repoDir, repo); err != nil { - result.Action = "push failed: " + err.Error() + result.Action = core.Concat("push failed: ", err.Error()) } else { result.Action = "merged" } @@ -247,15 +241,15 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer // pushAndMerge pushes to GitHub dev and merges the PR. func (s *PrepSubsystem) pushAndMerge(ctx context.Context, repoDir, repo string) error { - if out, err := gitCmd(ctx, repoDir, "push", "github", "HEAD:refs/heads/dev", "--force"); err != nil { - return core.E("pushAndMerge", "push failed: "+out, err) + if r := s.gitCmd(ctx, repoDir, "push", "github", "HEAD:refs/heads/dev", "--force"); !r.OK { + return core.E("pushAndMerge", "push failed: "+r.Value.(string), nil) } // Mark PR ready if draft - runCmdOK(ctx, repoDir, "gh", "pr", "ready", "--repo", GitHubOrg()+"/"+repo) + s.runCmdOK(ctx, repoDir, "gh", "pr", "ready", "--repo", GitHubOrg()+"/"+repo) - if out, err := runCmd(ctx, repoDir, "gh", "pr", "merge", "--merge", "--delete-branch"); err != nil { - return core.E("pushAndMerge", "merge failed: "+out, err) + if r := s.runCmd(ctx, repoDir, "gh", "pr", "merge", "--merge", "--delete-branch"); !r.OK { + return core.E("pushAndMerge", "merge failed: "+r.Value.(string), nil) } return nil @@ -348,23 +342,20 @@ func (s *PrepSubsystem) storeReviewOutput(repoDir, repo, reviewer, output string if !core.Contains(output, "No findings") && !core.Contains(output, "no issues") { entry["verdict"] = "findings" } - jsonLine, _ := json.Marshal(entry) + jsonLine := core.JSONMarshalString(entry) jsonlPath := core.JoinPath(dataDir, "reviews.jsonl") r := fs.Append(jsonlPath) if !r.OK { return } - wc := r.Value.(io.WriteCloser) - defer wc.Close() - wc.Write(append(jsonLine, '\n')) + core.WriteAll(r.Value, core.Concat(jsonLine, "\n")) } // saveRateLimitState persists rate limit info for cross-run awareness. func (s *PrepSubsystem) saveRateLimitState(info *RateLimitInfo) { path := core.JoinPath(core.Env("DIR_HOME"), ".core", "coderabbit-ratelimit.json") - data, _ := json.Marshal(info) - fs.Write(path, string(data)) + fs.Write(path, core.JSONMarshalString(info)) } // loadRateLimitState reads persisted rate limit info. @@ -375,7 +366,7 @@ func (s *PrepSubsystem) loadRateLimitState() *RateLimitInfo { return nil } var info RateLimitInfo - if json.Unmarshal([]byte(r.Value.(string)), &info) != nil { + if ur := core.JSONUnmarshalString(r.Value.(string), &info); !ur.OK { return nil } return &info diff --git a/pkg/agentic/review_queue_example_test.go b/pkg/agentic/review_queue_example_test.go new file mode 100644 index 0000000..0cf12ac --- /dev/null +++ b/pkg/agentic/review_queue_example_test.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import core "dappco.re/go/core" + +func ExampleReviewQueueInput() { + input := ReviewQueueInput{Limit: 4, Reviewer: "coderabbit"} + core.Println(input.Reviewer, input.Limit) + // Output: coderabbit 4 +} diff --git a/pkg/agentic/review_queue_extra_test.go b/pkg/agentic/review_queue_extra_test.go index ccd9607..5842073 100644 --- a/pkg/agentic/review_queue_extra_test.go +++ b/pkg/agentic/review_queue_extra_test.go @@ -19,7 +19,7 @@ import ( // --- buildReviewCommand --- func TestReviewQueue_BuildReviewCommand_Good_CodeRabbit(t *testing.T) { - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int)} cmd, args := s.buildReviewCommand("/tmp/repo", "coderabbit") assert.Equal(t, "coderabbit", cmd) assert.Contains(t, args, "review") @@ -28,7 +28,7 @@ func TestReviewQueue_BuildReviewCommand_Good_CodeRabbit(t *testing.T) { } func TestReviewQueue_BuildReviewCommand_Good_Codex(t *testing.T) { - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int)} cmd, args := s.buildReviewCommand("/tmp/repo", "codex") assert.Equal(t, "codex", cmd) assert.Contains(t, args, "review") @@ -36,7 +36,7 @@ func TestReviewQueue_BuildReviewCommand_Good_Codex(t *testing.T) { } func TestReviewQueue_BuildReviewCommand_Good_DefaultReviewer(t *testing.T) { - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int)} cmd, args := s.buildReviewCommand("/tmp/repo", "") assert.Equal(t, "coderabbit", cmd) assert.Contains(t, args, "--plain") @@ -55,6 +55,7 @@ func TestSaveLoadRateLimitState_Good_Roundtrip(t *testing.T) { // We need to work around this by using CORE_WORKSPACE for the load, // but save/load use DIR_HOME. Skip if not writable. s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -80,6 +81,7 @@ func TestReviewQueue_StoreReviewOutput_Good(t *testing.T) { // storeReviewOutput uses core.Env("DIR_HOME") so we can't fully control the path // but we can verify it doesn't panic s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -99,6 +101,7 @@ func TestReviewQueue_Good_NoCandidates(t *testing.T) { os.MkdirAll(coreDir, 0o755) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: root, backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -135,6 +138,7 @@ func TestStatus_Good_FilteredByStatus(t *testing.T) { } s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -226,6 +230,7 @@ func TestReviewQueue_LoadRateLimitState_Ugly(t *testing.T) { }) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -239,6 +244,7 @@ func TestReviewQueue_LoadRateLimitState_Ugly(t *testing.T) { func TestReviewQueue_BuildReviewCommand_Bad(t *testing.T) { // Empty reviewer string — defaults to coderabbit s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -248,7 +254,7 @@ func TestReviewQueue_BuildReviewCommand_Bad(t *testing.T) { } func TestReviewQueue_BuildReviewCommand_Ugly(t *testing.T) { - s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int)} cmd, args := s.buildReviewCommand("/tmp/repo", "unknown-reviewer") assert.Equal(t, "coderabbit", cmd) assert.Contains(t, args, "--plain") @@ -284,6 +290,7 @@ func TestReviewQueue_ParseRetryAfter_Ugly(t *testing.T) { func TestReviewQueue_StoreReviewOutput_Bad(t *testing.T) { // Empty output s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -295,6 +302,7 @@ func TestReviewQueue_StoreReviewOutput_Bad(t *testing.T) { func TestReviewQueue_StoreReviewOutput_Ugly(t *testing.T) { // Very large output s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -308,6 +316,7 @@ func TestReviewQueue_StoreReviewOutput_Ugly(t *testing.T) { func TestReviewQueue_SaveRateLimitState_Good(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -325,6 +334,7 @@ func TestReviewQueue_SaveRateLimitState_Good(t *testing.T) { func TestReviewQueue_SaveRateLimitState_Bad(t *testing.T) { // Save nil info s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -336,6 +346,7 @@ func TestReviewQueue_SaveRateLimitState_Bad(t *testing.T) { func TestReviewQueue_SaveRateLimitState_Ugly(t *testing.T) { // Save with far-future RetryAt s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -354,6 +365,7 @@ func TestReviewQueue_SaveRateLimitState_Ugly(t *testing.T) { func TestReviewQueue_LoadRateLimitState_Good(t *testing.T) { // Write then load valid state s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -378,6 +390,7 @@ func TestReviewQueue_LoadRateLimitState_Good(t *testing.T) { func TestReviewQueue_LoadRateLimitState_Bad(t *testing.T) { // File doesn't exist — should return nil s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } diff --git a/pkg/agentic/runner_example_test.go b/pkg/agentic/runner_example_test.go new file mode 100644 index 0000000..7bc87a8 --- /dev/null +++ b/pkg/agentic/runner_example_test.go @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + core "dappco.re/go/core" +) + +func ExamplePrepSubsystem_Poke() { + s := newPrepWithProcess() + s.pokeCh = make(chan struct{}, 1) + + s.Poke() + core.Println(len(s.pokeCh)) + // Output: 1 +} diff --git a/pkg/agentic/runner_test.go b/pkg/agentic/runner_test.go new file mode 100644 index 0000000..efe3a83 --- /dev/null +++ b/pkg/agentic/runner_test.go @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRunner_StartRunner_Good(t *testing.T) { + s := newPrepWithProcess() + assert.Nil(t, s.pokeCh) + s.StartRunner() + assert.NotNil(t, s.pokeCh) +} + +func TestRunner_StartRunner_Bad_AlreadyRunning(t *testing.T) { + s := newPrepWithProcess() + s.StartRunner() + // Second call should not panic + assert.NotPanics(t, func() { s.StartRunner() }) +} + +func TestRunner_Poke_Ugly_NilChannel(t *testing.T) { + s := newPrepWithProcess() + assert.NotPanics(t, func() { s.Poke() }) +} diff --git a/pkg/agentic/sanitise_example_test.go b/pkg/agentic/sanitise_example_test.go new file mode 100644 index 0000000..61dc3f7 --- /dev/null +++ b/pkg/agentic/sanitise_example_test.go @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + core "dappco.re/go/core" +) + +func Example_sanitiseBranchSlug() { + core.Println(sanitiseBranchSlug("Fix the broken tests!", 40)) + // Output: fix-the-broken-tests +} + +func Example_sanitiseBranchSlug_truncate() { + core.Println(len(sanitiseBranchSlug("a very long task description that should be truncated to fit", 20)) <= 20) + // Output: true +} diff --git a/pkg/agentic/sanitise_test.go b/pkg/agentic/sanitise_test.go new file mode 100644 index 0000000..2a8637c --- /dev/null +++ b/pkg/agentic/sanitise_test.go @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSanitise_SanitiseBranchSlug_Good_Basic(t *testing.T) { + assert.Equal(t, "fix-broken-tests", sanitiseBranchSlug("Fix broken tests", 40)) +} + +func TestSanitise_SanitiseBranchSlug_Bad_Empty(t *testing.T) { + assert.Equal(t, "", sanitiseBranchSlug("", 40)) +} + +func TestSanitise_SanitiseBranchSlug_Ugly_Truncate(t *testing.T) { + result := sanitiseBranchSlug("a very long description that exceeds the limit", 10) + assert.True(t, len(result) <= 10) +} diff --git a/pkg/agentic/scan.go b/pkg/agentic/scan.go index 5e3cf5b..4810934 100644 --- a/pkg/agentic/scan.go +++ b/pkg/agentic/scan.go @@ -4,8 +4,6 @@ package agentic import ( "context" - "encoding/json" - "net/http" core "dappco.re/go/core" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -120,22 +118,11 @@ func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label str u := core.Sprintf("%s/api/v1/repos/%s/%s/issues?state=open&limit=10&type=issues", s.forgeURL, org, repo) if label != "" { - u += "&labels=" + core.Replace(core.Replace(label, " ", "%20"), "&", "%26") + u = core.Concat(u, "&labels=", core.Replace(core.Replace(label, " ", "%20"), "&", "%26")) } - req, err := http.NewRequestWithContext(ctx, "GET", u, nil) - if err != nil { - return nil, core.E("scan.listRepoIssues", "failed to create request", err) - } - req.Header.Set("Authorization", "token "+s.forgeToken) - - resp, err := s.client.Do(req) - if err != nil { - return nil, core.E("scan.listRepoIssues", "failed to list issues for "+repo, err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, core.E("scan.listRepoIssues", core.Sprintf("HTTP %d listing issues for %s", resp.StatusCode, repo), nil) + r := HTTPGet(ctx, u, s.forgeToken, "token") + if !r.OK { + return nil, core.E("scan.listRepoIssues", core.Concat("failed to list issues for ", repo), nil) } var issues []struct { @@ -149,7 +136,7 @@ func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label str } `json:"assignee"` HTMLURL string `json:"html_url"` } - json.NewDecoder(resp.Body).Decode(&issues) + core.JSONUnmarshalString(r.Value.(string), &issues) var result []ScanIssue for _, issue := range issues { diff --git a/pkg/agentic/scan_example_test.go b/pkg/agentic/scan_example_test.go new file mode 100644 index 0000000..e39aac9 --- /dev/null +++ b/pkg/agentic/scan_example_test.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import core "dappco.re/go/core" + +func ExampleScanInput() { + input := ScanInput{Org: "core", Limit: 10} + core.Println(input.Org, input.Limit) + // Output: core 10 +} diff --git a/pkg/agentic/scan_test.go b/pkg/agentic/scan_test.go index d9cb467..9ed462c 100644 --- a/pkg/agentic/scan_test.go +++ b/pkg/agentic/scan_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + core "dappco.re/go/core" "dappco.re/go/core/forge" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -77,6 +78,7 @@ func mockScanServer(t *testing.T) *httptest.Server { func TestScan_Scan_Good(t *testing.T) { srv := mockScanServer(t) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -100,6 +102,7 @@ func TestScan_Scan_Good(t *testing.T) { func TestScan_Good_AllRepos(t *testing.T) { srv := mockScanServer(t) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -117,6 +120,7 @@ func TestScan_Good_AllRepos(t *testing.T) { func TestScan_Good_WithLimit(t *testing.T) { srv := mockScanServer(t) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -134,6 +138,7 @@ func TestScan_Good_WithLimit(t *testing.T) { func TestScan_Good_DefaultLabels(t *testing.T) { srv := mockScanServer(t) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -151,6 +156,7 @@ func TestScan_Good_DefaultLabels(t *testing.T) { func TestScan_Good_CustomLabels(t *testing.T) { srv := mockScanServer(t) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -169,6 +175,7 @@ func TestScan_Good_CustomLabels(t *testing.T) { func TestScan_Good_Deduplicates(t *testing.T) { srv := mockScanServer(t) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -196,6 +203,7 @@ func TestScan_Good_Deduplicates(t *testing.T) { func TestScan_Bad_NoToken(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeToken: "", backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -211,6 +219,7 @@ func TestScan_Bad_NoToken(t *testing.T) { func TestScan_ListRepoIssues_Good_ReturnsIssues(t *testing.T) { srv := mockScanServer(t) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: srv.Client(), @@ -229,6 +238,7 @@ func TestScan_ListRepoIssues_Good_ReturnsIssues(t *testing.T) { func TestScan_ListRepoIssues_Good_EmptyResult(t *testing.T) { srv := mockScanServer(t) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: srv.Client(), @@ -244,6 +254,7 @@ func TestScan_ListRepoIssues_Good_EmptyResult(t *testing.T) { func TestScan_ListRepoIssues_Good_AssigneeExtracted(t *testing.T) { srv := mockScanServer(t) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: srv.Client(), @@ -265,6 +276,7 @@ func TestScan_ListRepoIssues_Bad_ServerError(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: srv.Client(), @@ -286,6 +298,7 @@ func TestScan_Scan_Bad(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -310,6 +323,7 @@ func TestScan_Scan_Ugly(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -329,6 +343,7 @@ func TestScan_Scan_Ugly(t *testing.T) { func TestScan_ListOrgRepos_Good(t *testing.T) { srv := mockScanServer(t) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -353,6 +368,7 @@ func TestScan_ListOrgRepos_Bad(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -373,6 +389,7 @@ func TestScan_ListOrgRepos_Ugly(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -405,6 +422,7 @@ func TestScan_ListRepoIssues_Ugly(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: srv.Client(), @@ -433,6 +451,7 @@ func TestScan_ListRepoIssues_Good_URLRewrite(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: srv.Client(), diff --git a/pkg/agentic/shutdown_example_test.go b/pkg/agentic/shutdown_example_test.go new file mode 100644 index 0000000..32d6187 --- /dev/null +++ b/pkg/agentic/shutdown_example_test.go @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import core "dappco.re/go/core" + +func ExamplePrepSubsystem_Shutdown() { + s := newPrepWithProcess() + err := s.Shutdown(nil) + core.Println(err == nil) + // Output: true +} diff --git a/pkg/agentic/shutdown_test.go b/pkg/agentic/shutdown_test.go new file mode 100644 index 0000000..6315ee5 --- /dev/null +++ b/pkg/agentic/shutdown_test.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShutdown_Shutdown_Good(t *testing.T) { + s := newPrepWithProcess() + err := s.Shutdown(nil) + assert.NoError(t, err) +} + +func TestShutdown_Shutdown_Bad_AlreadyFrozen(t *testing.T) { + s := newPrepWithProcess() + s.frozen = true + err := s.Shutdown(nil) + assert.NoError(t, err) +} + +func TestShutdown_Shutdown_Ugly_NilRuntime(t *testing.T) { + s := &PrepSubsystem{} + assert.NotPanics(t, func() { + _ = s.Shutdown(nil) + }) +} diff --git a/pkg/agentic/status.go b/pkg/agentic/status.go index dbe518a..59927c5 100644 --- a/pkg/agentic/status.go +++ b/pkg/agentic/status.go @@ -4,7 +4,6 @@ package agentic import ( "context" - "encoding/json" "syscall" "time" @@ -48,11 +47,8 @@ type WorkspaceStatus struct { func writeStatus(wsDir string, status *WorkspaceStatus) error { status.UpdatedAt = time.Now() - data, err := json.MarshalIndent(status, "", " ") - if err != nil { - return err - } - if r := fs.Write(core.JoinPath(wsDir, "status.json"), string(data)); !r.OK { + statusPath := core.JoinPath(wsDir, "status.json") + if r := fs.WriteAtomic(statusPath, core.JSONMarshalString(status)); !r.OK { err, _ := r.Value.(error) return core.E("writeStatus", "failed to write status", err) } @@ -68,8 +64,9 @@ func ReadStatus(wsDir string) (*WorkspaceStatus, error) { return nil, core.E("ReadStatus", "status not found", nil) } var s WorkspaceStatus - if err := json.Unmarshal([]byte(r.Value.(string)), &s); err != nil { - return nil, err + if ur := core.JSONUnmarshalString(r.Value.(string), &s); !ur.OK { + err, _ := ur.Value.(error) + return nil, core.E("ReadStatus", "invalid status json", err) } return &s, nil } diff --git a/pkg/agentic/status_example_test.go b/pkg/agentic/status_example_test.go new file mode 100644 index 0000000..56789bb --- /dev/null +++ b/pkg/agentic/status_example_test.go @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "time" + + core "dappco.re/go/core" +) + +func Example_writeStatus() { + dir := (&core.Fs{}).NewUnrestricted().TempDir("example-ws") + defer (&core.Fs{}).NewUnrestricted().DeleteAll(dir) + + st := &WorkspaceStatus{ + Status: "running", + Agent: "codex", + Repo: "go-io", + Task: "Fix tests", + StartedAt: time.Now(), + } + err := writeStatus(dir, st) + core.Println(err == nil) + // Output: true +} + +func ExampleReadStatus() { + dir := (&core.Fs{}).NewUnrestricted().TempDir("example-ws") + defer (&core.Fs{}).NewUnrestricted().DeleteAll(dir) + + writeStatus(dir, &WorkspaceStatus{ + Status: "completed", + Agent: "claude", + Repo: "go-io", + }) + + st, err := ReadStatus(dir) + core.Println(err == nil) + core.Println(st.Status) + core.Println(st.Agent) + // Output: + // true + // completed + // claude +} diff --git a/pkg/agentic/status_extra_test.go b/pkg/agentic/status_extra_test.go index e68211c..46b0f0f 100644 --- a/pkg/agentic/status_extra_test.go +++ b/pkg/agentic/status_extra_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + core "dappco.re/go/core" "dappco.re/go/core/forge" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -24,6 +25,7 @@ func TestStatus_Good_EmptyWorkspace(t *testing.T) { require.True(t, fs.EnsureDir(filepath.Join(root, "workspace")).OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -78,6 +80,7 @@ func TestStatus_Good_MixedWorkspaces(t *testing.T) { })) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -108,6 +111,7 @@ func TestStatus_Good_DeepLayout(t *testing.T) { })) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -128,6 +132,7 @@ func TestStatus_Good_CorruptStatusFile(t *testing.T) { require.True(t, fs.Write(filepath.Join(ws, "status.json"), "invalid-json{{{").OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -142,6 +147,7 @@ func TestStatus_Good_CorruptStatusFile(t *testing.T) { func TestShutdown_DispatchStart_Good(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), frozen: true, pokeCh: make(chan struct{}, 1), backoff: make(map[string]time.Time), @@ -160,6 +166,7 @@ func TestShutdown_ShutdownGraceful_Good(t *testing.T) { t.Setenv("CORE_WORKSPACE", root) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), frozen: false, backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -178,6 +185,7 @@ func TestShutdown_ShutdownNow_Good_EmptyWorkspace(t *testing.T) { require.True(t, fs.EnsureDir(filepath.Join(root, "workspace")).OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), frozen: false, backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -207,6 +215,7 @@ func TestShutdown_ShutdownNow_Good_ClearsQueued(t *testing.T) { } s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -242,6 +251,7 @@ func TestPrep_BrainRecall_Good_Success(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), brainURL: srv.URL, brainKey: "test-brain-key", client: srv.Client(), @@ -264,6 +274,7 @@ func TestPrep_BrainRecall_Good_NoMemories(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), brainURL: srv.URL, brainKey: "test-brain-key", client: srv.Client(), @@ -278,6 +289,7 @@ func TestPrep_BrainRecall_Good_NoMemories(t *testing.T) { func TestPrep_BrainRecall_Bad_NoBrainKey(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), brainKey: "", backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -295,6 +307,7 @@ func TestPrep_BrainRecall_Bad_ServerError(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), brainURL: srv.URL, brainKey: "test-brain-key", client: srv.Client(), @@ -311,6 +324,7 @@ func TestPrep_BrainRecall_Bad_ServerError(t *testing.T) { func TestPrep_PrepWorkspace_Bad_NoRepo(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -325,6 +339,7 @@ func TestPrep_PrepWorkspace_Bad_NoIdentifier(t *testing.T) { t.Setenv("CORE_WORKSPACE", root) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -342,6 +357,7 @@ func TestPrep_PrepWorkspace_Bad_InvalidRepoName(t *testing.T) { t.Setenv("CORE_WORKSPACE", root) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir(), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -377,6 +393,7 @@ func TestPr_ListPRs_Good_SpecificRepo(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -401,6 +418,7 @@ func TestPr_ListPRs_Good_SpecificRepo(t *testing.T) { func TestRunner_Poke_Good_SendsSignal(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), pokeCh: make(chan struct{}, 1), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -418,6 +436,7 @@ func TestRunner_Poke_Good_SendsSignal(t *testing.T) { func TestRunner_Poke_Good_NonBlocking(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), pokeCh: make(chan struct{}, 1), backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -434,6 +453,7 @@ func TestRunner_Poke_Good_NonBlocking(t *testing.T) { func TestRunner_Poke_Bad_NilChannel(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), pokeCh: nil, backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -506,13 +526,14 @@ func TestWriteReadStatus_Good_AllFields(t *testing.T) { func TestPrep_OnShutdown_Good(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), frozen: false, backoff: make(map[string]time.Time), failCount: make(map[string]int), } - err := s.OnShutdown(context.Background()) - assert.NoError(t, err) + r := s.OnShutdown(context.Background()) + assert.True(t, r.OK) assert.True(t, s.frozen) } @@ -523,6 +544,7 @@ func TestQueue_DrainQueue_Good_FrozenDoesNothing(t *testing.T) { t.Setenv("CORE_WORKSPACE", root) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), frozen: true, backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -552,6 +574,7 @@ func TestPrep_Shutdown_ShutdownNow_Ugly(t *testing.T) { })) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), frozen: false, backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -574,6 +597,7 @@ func TestPrep_Shutdown_ShutdownNow_Ugly(t *testing.T) { func TestShutdown_DispatchStart_Bad_NilPokeCh(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), frozen: true, pokeCh: nil, backoff: make(map[string]time.Time), @@ -589,6 +613,7 @@ func TestShutdown_DispatchStart_Bad_NilPokeCh(t *testing.T) { func TestShutdown_DispatchStart_Ugly_AlreadyUnfrozen(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), frozen: false, // already unfrozen pokeCh: make(chan struct{}, 1), backoff: make(map[string]time.Time), @@ -609,6 +634,7 @@ func TestShutdown_ShutdownGraceful_Bad_AlreadyFrozen(t *testing.T) { t.Setenv("CORE_WORKSPACE", root) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), frozen: true, // already frozen backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -638,6 +664,7 @@ func TestShutdown_ShutdownGraceful_Ugly_WithWorkspaces(t *testing.T) { } s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), frozen: false, backoff: make(map[string]time.Time), failCount: make(map[string]int), @@ -670,6 +697,7 @@ func TestShutdown_ShutdownNow_Bad_NoRunningPIDs(t *testing.T) { } s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), frozen: false, backoff: make(map[string]time.Time), failCount: make(map[string]int), diff --git a/pkg/agentic/status_test.go b/pkg/agentic/status_test.go index bdcd3c2..f72e4f9 100644 --- a/pkg/agentic/status_test.go +++ b/pkg/agentic/status_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -223,6 +224,7 @@ func TestStatus_Status_Ugly(t *testing.T) { })) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -339,6 +341,7 @@ func TestStatus_Status_Good_PopulatedWorkspaces(t *testing.T) { })) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -358,6 +361,7 @@ func TestStatus_Status_Bad_EmptyWorkspaceRoot(t *testing.T) { // Do NOT create the workspace/ subdirectory s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } diff --git a/pkg/agentic/transport.go b/pkg/agentic/transport.go new file mode 100644 index 0000000..e6fd8da --- /dev/null +++ b/pkg/agentic/transport.go @@ -0,0 +1,258 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// HTTP transport for Core API streams. +// This is the ONE file in core/agent that imports net/http. +// All other files use the exported helpers: HTTPGet, HTTPPost, HTTPCall. + +package agentic + +import ( + "context" + "net/http" + "time" + + core "dappco.re/go/core" +) + +// defaultClient is the shared HTTP client for all transport calls. +var defaultClient = &http.Client{Timeout: 30 * time.Second} + +// httpStream implements core.Stream over HTTP request/response. +type httpStream struct { + client *http.Client + url string + token string + method string + response []byte +} + +func (s *httpStream) Send(data []byte) error { + req, err := http.NewRequestWithContext(context.Background(), s.method, s.url, core.NewReader(string(data))) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if s.token != "" { + req.Header.Set("Authorization", core.Concat("token ", s.token)) + } + + resp, err := s.client.Do(req) + if err != nil { + return err + } + + r := core.ReadAll(resp.Body) + if !r.OK { + return core.E("httpStream.Send", "failed to read response", nil) + } + s.response = []byte(r.Value.(string)) + return nil +} + +func (s *httpStream) Receive() ([]byte, error) { + return s.response, nil +} + +func (s *httpStream) Close() error { + return nil +} + +// RegisterHTTPTransport registers the HTTP/HTTPS protocol handler with Core API. +// +// agentic.RegisterHTTPTransport(c) +func RegisterHTTPTransport(c *core.Core) { + factory := func(handle *core.DriveHandle) (core.Stream, error) { + token := handle.Options.String("token") + return &httpStream{ + client: defaultClient, + url: handle.Transport, + token: token, + method: "POST", + }, nil + } + c.API().RegisterProtocol("http", factory) + c.API().RegisterProtocol("https", factory) +} + +// --- REST helpers — all HTTP in core/agent routes through these --- + +// HTTPGet performs a GET request. Returns Result{Value: string (response body), OK: bool}. +// Auth is "token {token}" for Forge, "Bearer {token}" for Brain. +// +// r := agentic.HTTPGet(ctx, "https://forge.lthn.ai/api/v1/repos", "my-token", "token") +func HTTPGet(ctx context.Context, url, token, authScheme string) core.Result { + return httpDo(ctx, "GET", url, "", token, authScheme) +} + +// HTTPPost performs a POST request with a JSON body. Returns Result{Value: string, OK: bool}. +// +// r := agentic.HTTPPost(ctx, url, core.JSONMarshalString(payload), token, "token") +func HTTPPost(ctx context.Context, url, body, token, authScheme string) core.Result { + return httpDo(ctx, "POST", url, body, token, authScheme) +} + +// HTTPPatch performs a PATCH request with a JSON body. +// +// r := agentic.HTTPPatch(ctx, url, body, token, "token") +func HTTPPatch(ctx context.Context, url, body, token, authScheme string) core.Result { + return httpDo(ctx, "PATCH", url, body, token, authScheme) +} + +// HTTPDelete performs a DELETE request. +// +// r := agentic.HTTPDelete(ctx, url, body, token, "Bearer") +func HTTPDelete(ctx context.Context, url, body, token, authScheme string) core.Result { + return httpDo(ctx, "DELETE", url, body, token, authScheme) +} + +// HTTPDo performs an HTTP request with the specified method. +// +// r := agentic.HTTPDo(ctx, "PUT", url, body, token, "token") +func HTTPDo(ctx context.Context, method, url, body, token, authScheme string) core.Result { + return httpDo(ctx, method, url, body, token, authScheme) +} + +// httpDo is the single HTTP execution point. Every HTTP call in core/agent routes here. +func httpDo(ctx context.Context, method, url, body, token, authScheme string) core.Result { + var req *http.Request + var err error + + if body != "" { + req, err = http.NewRequestWithContext(ctx, method, url, core.NewReader(body)) + } else { + req, err = http.NewRequestWithContext(ctx, method, url, nil) + } + if err != nil { + return core.Result{OK: false} + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if token != "" { + if authScheme == "" { + authScheme = "token" + } + req.Header.Set("Authorization", core.Concat(authScheme, " ", token)) + } + + resp, err := defaultClient.Do(req) + if err != nil { + return core.Result{OK: false} + } + + r := core.ReadAll(resp.Body) + if !r.OK { + return core.Result{OK: false} + } + + return core.Result{Value: r.Value.(string), OK: resp.StatusCode < 400} +} + +// --- MCP Streamable HTTP Transport --- + +// mcpInitialize performs the MCP initialise handshake over Streamable HTTP. +// Returns the session ID from the Mcp-Session-Id header. +func mcpInitialize(ctx context.Context, url, token string) (string, error) { + initReq := map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": map[string]any{ + "protocolVersion": "2025-03-26", + "capabilities": map[string]any{}, + "clientInfo": map[string]any{ + "name": "core-agent-remote", + "version": "0.2.0", + }, + }, + } + + body := core.JSONMarshalString(initReq) + req, err := http.NewRequestWithContext(ctx, "POST", url, core.NewReader(body)) + if err != nil { + return "", core.E("mcpInitialize", "create request", nil) + } + mcpHeaders(req, token, "") + + resp, err := defaultClient.Do(req) + if err != nil { + return "", core.E("mcpInitialize", "request failed", nil) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", core.E("mcpInitialize", core.Sprintf("HTTP %d", resp.StatusCode), nil) + } + + sessionID := resp.Header.Get("Mcp-Session-Id") + + // Drain SSE response + drainSSE(resp) + + // Send initialised notification + notif := core.JSONMarshalString(map[string]any{ + "jsonrpc": "2.0", + "method": "notifications/initialized", + }) + notifReq, _ := http.NewRequestWithContext(ctx, "POST", url, core.NewReader(notif)) + mcpHeaders(notifReq, token, sessionID) + notifResp, err := defaultClient.Do(notifReq) + if err == nil { + notifResp.Body.Close() + } + + return sessionID, nil +} + +// mcpCall sends a JSON-RPC request and returns the parsed response. +func mcpCall(ctx context.Context, url, token, sessionID string, body []byte) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, "POST", url, core.NewReader(string(body))) + if err != nil { + return nil, core.E("mcpCall", "create request", nil) + } + mcpHeaders(req, token, sessionID) + + resp, err := defaultClient.Do(req) + if err != nil { + return nil, core.E("mcpCall", "request failed", nil) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, core.E("mcpCall", core.Sprintf("HTTP %d", resp.StatusCode), nil) + } + + return readSSEData(resp) +} + +// readSSEData reads an SSE response and extracts JSON from data: lines. +func readSSEData(resp *http.Response) ([]byte, error) { + r := core.ReadAll(resp.Body) + if !r.OK { + return nil, core.E("readSSEData", "failed to read response", nil) + } + for _, line := range core.Split(r.Value.(string), "\n") { + if core.HasPrefix(line, "data: ") { + return []byte(core.TrimPrefix(line, "data: ")), nil + } + } + return nil, core.E("readSSEData", "no data in SSE response", nil) +} + +// mcpHeaders applies standard MCP HTTP headers. +func mcpHeaders(req *http.Request, token, sessionID string) { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + if token != "" { + req.Header.Set("Authorization", core.Concat("Bearer ", token)) + } + if sessionID != "" { + req.Header.Set("Mcp-Session-Id", sessionID) + } +} + +// drainSSE reads and discards an SSE response body. +func drainSSE(resp *http.Response) { + core.ReadAll(resp.Body) +} diff --git a/pkg/agentic/verify.go b/pkg/agentic/verify.go index 8141b06..c7f2868 100644 --- a/pkg/agentic/verify.go +++ b/pkg/agentic/verify.go @@ -3,10 +3,7 @@ package agentic import ( - "bytes" "context" - "encoding/json" - "net/http" "time" core "dappco.re/go/core" @@ -93,8 +90,8 @@ func (s *PrepSubsystem) attemptVerifyAndMerge(repoDir, org, repo, branch string, ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - if err := s.forgeMergePR(ctx, org, repo, prNum); err != nil { - comment := core.Sprintf("## Tests Passed — Merge Failed\n\n`%s` passed but merge failed: %v", testResult.testCmd, err) + if r := s.forgeMergePR(ctx, org, repo, prNum); !r.OK { + comment := core.Sprintf("## Tests Passed — Merge Failed\n\n`%s` passed but merge failed", testResult.testCmd) s.commentOnIssue(context.Background(), org, repo, prNum, comment) return mergeConflict } @@ -107,14 +104,14 @@ func (s *PrepSubsystem) attemptVerifyAndMerge(repoDir, org, repo, branch string, // rebaseBranch rebases the current branch onto the default branch and force-pushes. func (s *PrepSubsystem) rebaseBranch(repoDir, branch string) bool { ctx := context.Background() - base := DefaultBranch(repoDir) + base := s.DefaultBranch(repoDir) - if !gitCmdOK(ctx, repoDir, "fetch", "origin", base) { + if !s.gitCmdOK(ctx, repoDir, "fetch", "origin", base) { return false } - if !gitCmdOK(ctx, repoDir, "rebase", "origin/"+base) { - gitCmdOK(ctx, repoDir, "rebase", "--abort") + if !s.gitCmdOK(ctx, repoDir, "rebase", "origin/"+base) { + s.gitCmdOK(ctx, repoDir, "rebase", "--abort") return false } @@ -128,7 +125,7 @@ func (s *PrepSubsystem) rebaseBranch(repoDir, branch string) bool { repo = st.Repo } forgeRemote := core.Sprintf("ssh://git@forge.lthn.ai:2223/%s/%s.git", org, repo) - return gitCmdOK(ctx, repoDir, "push", "--force-with-lease", forgeRemote, branch) + return s.gitCmdOK(ctx, repoDir, "push", "--force-with-lease", forgeRemote, branch) } // flagForReview adds the "needs-review" label to the PR via Forge API. @@ -140,17 +137,11 @@ func (s *PrepSubsystem) flagForReview(org, repo string, prNum int, result mergeR s.ensureLabel(ctx, org, repo, "needs-review", "e11d48") // Add label to PR - payload, _ := json.Marshal(map[string]any{ + payload := core.JSONMarshalString(map[string]any{ "labels": []int{s.getLabelID(ctx, org, repo, "needs-review")}, }) url := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/labels", s.forgeURL, org, repo, prNum) - req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "token "+s.forgeToken) - resp, err := s.client.Do(req) - if err == nil { - resp.Body.Close() - } + HTTPPost(ctx, url, payload, s.forgeToken, "token") // Comment explaining the situation reason := "Tests failed after rebase" @@ -163,36 +154,27 @@ func (s *PrepSubsystem) flagForReview(org, repo string, prNum int, result mergeR // ensureLabel creates a label if it doesn't exist. func (s *PrepSubsystem) ensureLabel(ctx context.Context, org, repo, name, colour string) { - payload, _ := json.Marshal(map[string]string{ + payload := core.JSONMarshalString(map[string]string{ "name": name, - "color": "#" + colour, + "color": core.Concat("#", colour), }) url := core.Sprintf("%s/api/v1/repos/%s/%s/labels", s.forgeURL, org, repo) - req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "token "+s.forgeToken) - resp, err := s.client.Do(req) - if err == nil { - resp.Body.Close() - } + HTTPPost(ctx, url, payload, s.forgeToken, "token") } // getLabelID fetches the ID of a label by name. func (s *PrepSubsystem) getLabelID(ctx context.Context, org, repo, name string) int { url := core.Sprintf("%s/api/v1/repos/%s/%s/labels", s.forgeURL, org, repo) - req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) - req.Header.Set("Authorization", "token "+s.forgeToken) - resp, err := s.client.Do(req) - if err != nil { + r := HTTPGet(ctx, url, s.forgeToken, "token") + if !r.OK { return 0 } - defer resp.Body.Close() var labels []struct { ID int `json:"id"` Name string `json:"name"` } - json.NewDecoder(resp.Body).Decode(&labels) + core.JSONUnmarshalString(r.Value.(string), &labels) for _, l := range labels { if l.Name == name { return l.ID @@ -225,27 +207,27 @@ func (s *PrepSubsystem) runVerification(repoDir string) verifyResult { func (s *PrepSubsystem) runGoTests(repoDir string) verifyResult { ctx := context.Background() - out, err := runCmdEnv(ctx, repoDir, []string{"GOWORK=off"}, "go", "test", "./...", "-count=1", "-timeout", "120s") - passed := err == nil + r := s.runCmdEnv(ctx, repoDir, []string{"GOWORK=off"}, "go", "test", "./...", "-count=1", "-timeout", "120s") + out := r.Value.(string) exitCode := 0 - if err != nil { + if !r.OK { exitCode = 1 } - return verifyResult{passed: passed, output: out, exitCode: exitCode, testCmd: "go test ./..."} + return verifyResult{passed: r.OK, output: out, exitCode: exitCode, testCmd: "go test ./..."} } func (s *PrepSubsystem) runPHPTests(repoDir string) verifyResult { ctx := context.Background() - out, err := runCmd(ctx, repoDir, "composer", "test", "--no-interaction") - if err != nil { + r := s.runCmd(ctx, repoDir, "composer", "test", "--no-interaction") + if !r.OK { // Try pest as fallback - out2, err2 := runCmd(ctx, repoDir, "./vendor/bin/pest", "--no-interaction") - if err2 != nil { + r2 := s.runCmd(ctx, repoDir, "./vendor/bin/pest", "--no-interaction") + if !r2.OK { return verifyResult{passed: false, testCmd: "none", output: "No PHP test runner found (composer test and vendor/bin/pest both unavailable)", exitCode: 1} } - return verifyResult{passed: true, output: out2, exitCode: 0, testCmd: "vendor/bin/pest"} + return verifyResult{passed: true, output: r2.Value.(string), exitCode: 0, testCmd: "vendor/bin/pest"} } - return verifyResult{passed: true, output: out, exitCode: 0, testCmd: "composer test"} + return verifyResult{passed: true, output: r.Value.(string), exitCode: 0, testCmd: "composer test"} } func (s *PrepSubsystem) runNodeTests(repoDir string) verifyResult { @@ -257,47 +239,30 @@ func (s *PrepSubsystem) runNodeTests(repoDir string) verifyResult { var pkg struct { Scripts map[string]string `json:"scripts"` } - if json.Unmarshal([]byte(r.Value.(string)), &pkg) != nil || pkg.Scripts["test"] == "" { + if ur := core.JSONUnmarshalString(r.Value.(string), &pkg); !ur.OK || pkg.Scripts["test"] == "" { return verifyResult{passed: true, testCmd: "none", output: "No test script in package.json"} } ctx := context.Background() - out, err := runCmd(ctx, repoDir, "npm", "test") - passed := err == nil + r = s.runCmd(ctx, repoDir, "npm", "test") + out := r.Value.(string) exitCode := 0 - if err != nil { + if !r.OK { exitCode = 1 } - return verifyResult{passed: passed, output: out, exitCode: exitCode, testCmd: "npm test"} + return verifyResult{passed: r.OK, output: out, exitCode: exitCode, testCmd: "npm test"} } // forgeMergePR merges a PR via the Forge API. -func (s *PrepSubsystem) forgeMergePR(ctx context.Context, org, repo string, prNum int) error { - payload, _ := json.Marshal(map[string]any{ +func (s *PrepSubsystem) forgeMergePR(ctx context.Context, org, repo string, prNum int) core.Result { + payload := core.JSONMarshalString(map[string]any{ "Do": "merge", "merge_message_field": "Auto-merged by core-agent after verification\n\nCo-Authored-By: Virgil ", "delete_branch_after_merge": true, }) url := core.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/merge", s.forgeURL, org, repo, prNum) - req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "token "+s.forgeToken) - - resp, err := s.client.Do(req) - if err != nil { - return core.E("forgeMergePR", "request failed", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 && resp.StatusCode != 204 { - var errBody map[string]any - json.NewDecoder(resp.Body).Decode(&errBody) - msg, _ := errBody["message"].(string) - return core.E("forgeMergePR", core.Sprintf("HTTP %d: %s", resp.StatusCode, msg), nil) - } - - return nil + return HTTPPost(ctx, url, payload, s.forgeToken, "token") } // extractPRNumber gets the PR number from a Forge PR URL. diff --git a/pkg/agentic/verify_example_test.go b/pkg/agentic/verify_example_test.go new file mode 100644 index 0000000..4acc731 --- /dev/null +++ b/pkg/agentic/verify_example_test.go @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + core "dappco.re/go/core" +) + +func Example_fileExists() { + dir := (&core.Fs{}).NewUnrestricted().TempDir("example") + defer (&core.Fs{}).NewUnrestricted().DeleteAll(dir) + + (&core.Fs{}).NewUnrestricted().Write(core.JoinPath(dir, "go.mod"), "module test") + + core.Println(fileExists(core.JoinPath(dir, "go.mod"))) + core.Println(fileExists(core.JoinPath(dir, "missing.txt"))) + // Output: + // true + // false +} diff --git a/pkg/agentic/verify_extra_test.go b/pkg/agentic/verify_extra_test.go index 3a335a0..386f07a 100644 --- a/pkg/agentic/verify_extra_test.go +++ b/pkg/agentic/verify_extra_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + core "dappco.re/go/core" "dappco.re/go/core/forge" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -33,6 +34,7 @@ func TestPr_CommentOnIssue_Good_PostsCommentOnPR(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -81,6 +83,7 @@ func TestVerify_AutoVerifyAndMerge_Good_FullPipeline(t *testing.T) { os.WriteFile(filepath.Join(wsDir, "status.json"), data, 0o644) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -114,6 +117,7 @@ func TestVerify_AttemptVerifyAndMerge_Good_TestsPassMergeSucceeds(t *testing.T) dir := t.TempDir() // No project files = passes verification s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -140,6 +144,7 @@ func TestVerify_AttemptVerifyAndMerge_Bad_MergeFails(t *testing.T) { dir := t.TempDir() s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", diff --git a/pkg/agentic/verify_test.go b/pkg/agentic/verify_test.go index 82c6fbd..3c3c6a1 100644 --- a/pkg/agentic/verify_test.go +++ b/pkg/agentic/verify_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + core "dappco.re/go/core" "dappco.re/go/core/forge" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -35,6 +36,7 @@ func TestVerify_ForgeMergePR_Good_Success(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-forge-token", client: srv.Client(), @@ -42,8 +44,8 @@ func TestVerify_ForgeMergePR_Good_Success(t *testing.T) { failCount: make(map[string]int), } - err := s.forgeMergePR(context.Background(), "core", "test-repo", 42) - assert.NoError(t, err) + r := s.forgeMergePR(context.Background(), "core", "test-repo", 42) + assert.True(t, r.OK) } func TestVerify_ForgeMergePR_Good_204Response(t *testing.T) { @@ -53,6 +55,7 @@ func TestVerify_ForgeMergePR_Good_204Response(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: srv.Client(), @@ -60,8 +63,8 @@ func TestVerify_ForgeMergePR_Good_204Response(t *testing.T) { failCount: make(map[string]int), } - err := s.forgeMergePR(context.Background(), "core", "test-repo", 1) - assert.NoError(t, err) + r := s.forgeMergePR(context.Background(), "core", "test-repo", 1) + assert.True(t, r.OK) } func TestVerify_ForgeMergePR_Bad_ConflictResponse(t *testing.T) { @@ -74,6 +77,7 @@ func TestVerify_ForgeMergePR_Bad_ConflictResponse(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: srv.Client(), @@ -81,10 +85,9 @@ func TestVerify_ForgeMergePR_Bad_ConflictResponse(t *testing.T) { failCount: make(map[string]int), } - err := s.forgeMergePR(context.Background(), "core", "test-repo", 1) - assert.Error(t, err) - assert.Contains(t, err.Error(), "409") - assert.Contains(t, err.Error(), "merge conflict") + r := s.forgeMergePR(context.Background(), "core", "test-repo", 1) + assert.False(t, r.OK) + assert.Contains(t, r.Value.(string), "merge conflict") } func TestVerify_ForgeMergePR_Bad_ServerError(t *testing.T) { @@ -97,6 +100,7 @@ func TestVerify_ForgeMergePR_Bad_ServerError(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: srv.Client(), @@ -104,9 +108,9 @@ func TestVerify_ForgeMergePR_Bad_ServerError(t *testing.T) { failCount: make(map[string]int), } - err := s.forgeMergePR(context.Background(), "core", "test-repo", 1) - assert.Error(t, err) - assert.Contains(t, err.Error(), "500") + r := s.forgeMergePR(context.Background(), "core", "test-repo", 1) + assert.False(t, r.OK) + assert.Contains(t, r.Value.(string), "internal server error") } func TestVerify_ForgeMergePR_Bad_NetworkError(t *testing.T) { @@ -114,6 +118,7 @@ func TestVerify_ForgeMergePR_Bad_NetworkError(t *testing.T) { srv.Close() // close immediately to cause connection error s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: &http.Client{}, @@ -121,8 +126,8 @@ func TestVerify_ForgeMergePR_Bad_NetworkError(t *testing.T) { failCount: make(map[string]int), } - err := s.forgeMergePR(context.Background(), "core", "test-repo", 1) - assert.Error(t, err) + r := s.forgeMergePR(context.Background(), "core", "test-repo", 1) + assert.False(t, r.OK) } // --- extractPRNumber (additional _Ugly cases) --- @@ -163,6 +168,7 @@ func TestVerify_EnsureLabel_Good_CreatesLabel(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: srv.Client(), @@ -179,6 +185,7 @@ func TestVerify_EnsureLabel_Bad_NetworkError(t *testing.T) { srv.Close() s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: &http.Client{}, @@ -204,6 +211,7 @@ func TestVerify_GetLabelID_Good_Found(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: srv.Client(), @@ -224,6 +232,7 @@ func TestVerify_GetLabelID_Bad_NotFound(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: srv.Client(), @@ -240,6 +249,7 @@ func TestVerify_GetLabelID_Bad_NetworkError(t *testing.T) { srv.Close() s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: &http.Client{}, @@ -257,6 +267,7 @@ func TestVerify_RunVerification_Good_NoProjectFile(t *testing.T) { dir := t.TempDir() // No go.mod, composer.json, or package.json s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -271,6 +282,7 @@ func TestVerify_RunVerification_Good_GoProject(t *testing.T) { require.True(t, fs.Write(filepath.Join(dir, "go.mod"), "module test").OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -285,6 +297,7 @@ func TestVerify_RunVerification_Good_PHPProject(t *testing.T) { require.True(t, fs.Write(filepath.Join(dir, "composer.json"), `{"require":{}}`).OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -299,6 +312,7 @@ func TestVerify_RunVerification_Good_NodeProject(t *testing.T) { require.True(t, fs.Write(filepath.Join(dir, "package.json"), `{"scripts":{"test":"echo ok"}}`).OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -312,6 +326,7 @@ func TestVerify_RunVerification_Good_NodeNoTestScript(t *testing.T) { require.True(t, fs.Write(filepath.Join(dir, "package.json"), `{"scripts":{}}`).OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -345,6 +360,7 @@ func TestVerify_FileExists_Bad_IsDirectory(t *testing.T) { func TestVerify_AutoVerifyAndMerge_Bad_NoStatus(t *testing.T) { dir := t.TempDir() s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -363,6 +379,7 @@ func TestVerify_AutoVerifyAndMerge_Bad_NoPRURL(t *testing.T) { })) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -381,6 +398,7 @@ func TestVerify_AutoVerifyAndMerge_Bad_EmptyRepo(t *testing.T) { })) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -400,6 +418,7 @@ func TestVerify_AutoVerifyAndMerge_Bad_InvalidPRURL(t *testing.T) { })) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -442,6 +461,7 @@ func TestVerify_FlagForReview_Good_AddsLabel(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -475,6 +495,7 @@ func TestVerify_FlagForReview_Good_MergeConflictMessage(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -523,6 +544,7 @@ func TestVerify_AutoVerifyAndMerge_Ugly(t *testing.T) { })) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -561,6 +583,7 @@ func TestVerify_AttemptVerifyAndMerge_Ugly(t *testing.T) { require.True(t, fs.Write(filepath.Join(dir, "broken.go"), "package broken\n\nfunc Bad() { undeclared() }").OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -598,6 +621,7 @@ func TestVerify_EnsureLabel_Ugly_AlreadyExists409(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: srv.Client(), @@ -620,6 +644,7 @@ func TestVerify_GetLabelID_Ugly_EmptyArray(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: srv.Client(), @@ -641,6 +666,7 @@ func TestVerify_ForgeMergePR_Ugly_EmptyBody200(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forgeURL: srv.URL, forgeToken: "test-token", client: srv.Client(), @@ -648,8 +674,8 @@ func TestVerify_ForgeMergePR_Ugly_EmptyBody200(t *testing.T) { failCount: make(map[string]int), } - err := s.forgeMergePR(context.Background(), "core", "test-repo", 42) - assert.NoError(t, err) // 200 is success even with empty body + r := s.forgeMergePR(context.Background(), "core", "test-repo", 42) + assert.True(t, r.OK) // 200 is success even with empty body } // --- FileExists Ugly --- @@ -673,6 +699,7 @@ func TestVerify_FlagForReview_Bad_AllAPICallsFail(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -700,6 +727,7 @@ func TestVerify_FlagForReview_Ugly_LabelNotFoundZeroID(t *testing.T) { t.Cleanup(srv.Close) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), forge: forge.NewForge(srv.URL, "test-token"), forgeURL: srv.URL, forgeToken: "test-token", @@ -722,6 +750,7 @@ func TestVerify_RunVerification_Bad_GoModButNoGoFiles(t *testing.T) { // go.mod exists but no .go files — go test should fail s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -739,6 +768,7 @@ func TestVerify_RunVerification_Ugly_MultipleProjectFiles(t *testing.T) { require.True(t, fs.Write(filepath.Join(dir, "package.json"), `{"scripts":{"test":"echo ok"}}`).OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -756,6 +786,7 @@ func TestVerify_RunVerification_Ugly_GoAndPHPProjectFiles(t *testing.T) { require.True(t, fs.Write(filepath.Join(dir, "composer.json"), `{"require":{}}`).OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -783,6 +814,7 @@ func TestAdd(t *testing.T) { `).OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -800,6 +832,7 @@ func TestVerify_RunGoTests_Bad(t *testing.T) { require.True(t, fs.Write(filepath.Join(dir, "broken.go"), "package broken\n\nfunc Bad() { undeclared() }\n").OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -817,6 +850,7 @@ func TestVerify_RunGoTests_Ugly(t *testing.T) { require.True(t, fs.Write(filepath.Join(dir, "main.go"), "package empty\n").OK) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } diff --git a/pkg/agentic/watch_example_test.go b/pkg/agentic/watch_example_test.go new file mode 100644 index 0000000..ddd55cb --- /dev/null +++ b/pkg/agentic/watch_example_test.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + core "dappco.re/go/core" +) + +func ExampleWorkspaceRoot_watch() { + root := WorkspaceRoot() + core.Println(core.HasSuffix(root, "workspace")) + // Output: true +} diff --git a/pkg/agentic/watch_test.go b/pkg/agentic/watch_test.go index 6ea8fb0..9d08a61 100644 --- a/pkg/agentic/watch_test.go +++ b/pkg/agentic/watch_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + core "dappco.re/go/core" "github.com/stretchr/testify/assert" ) @@ -16,6 +17,7 @@ import ( func TestWatch_ResolveWorkspaceDir_Good_RelativeName(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -26,6 +28,7 @@ func TestWatch_ResolveWorkspaceDir_Good_RelativeName(t *testing.T) { func TestWatch_ResolveWorkspaceDir_Good_AbsolutePath(t *testing.T) { s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -60,6 +63,7 @@ func TestWatch_FindActiveWorkspaces_Good_WithActive(t *testing.T) { os.WriteFile(filepath.Join(ws3, "status.json"), st3, 0o644) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -77,6 +81,7 @@ func TestWatch_FindActiveWorkspaces_Good_Empty(t *testing.T) { os.MkdirAll(filepath.Join(root, "workspace"), 0o755) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -92,6 +97,7 @@ func TestWatch_FindActiveWorkspaces_Bad(t *testing.T) { t.Setenv("CORE_WORKSPACE", filepath.Join(root, "nonexistent")) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -119,6 +125,7 @@ func TestWatch_FindActiveWorkspaces_Ugly(t *testing.T) { os.WriteFile(filepath.Join(ws2, "status.json"), st, 0o644) s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -134,6 +141,7 @@ func TestWatch_FindActiveWorkspaces_Ugly(t *testing.T) { func TestWatch_ResolveWorkspaceDir_Bad(t *testing.T) { // Empty name s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } @@ -145,6 +153,7 @@ func TestWatch_ResolveWorkspaceDir_Bad(t *testing.T) { func TestWatch_ResolveWorkspaceDir_Ugly(t *testing.T) { // Name with path traversal "../.." s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), backoff: make(map[string]time.Time), failCount: make(map[string]int), } diff --git a/pkg/brain/brain_example_test.go b/pkg/brain/brain_example_test.go new file mode 100644 index 0000000..0f0bc69 --- /dev/null +++ b/pkg/brain/brain_example_test.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package brain + +import ( + core "dappco.re/go/core" +) + +func ExampleRegister_services() { + c := core.New(core.WithService(Register)) + core.Println(c.Services()) + // Output is non-deterministic (slice order), so no Output comment +} diff --git a/pkg/brain/direct.go b/pkg/brain/direct.go index 1948af2..373624d 100644 --- a/pkg/brain/direct.go +++ b/pkg/brain/direct.go @@ -3,10 +3,7 @@ package brain import ( - "bytes" "context" - "encoding/json" - "net/http" "time" "dappco.re/go/agent/pkg/agentic" @@ -22,7 +19,6 @@ import ( type DirectSubsystem struct { apiURL string apiKey string - client *http.Client } var _ coremcp.Subsystem = (*DirectSubsystem)(nil) @@ -57,7 +53,6 @@ func NewDirect() *DirectSubsystem { return &DirectSubsystem{ apiURL: apiURL, apiKey: apiKey, - client: &http.Client{Timeout: 30 * time.Second}, } } @@ -114,52 +109,21 @@ func (s *DirectSubsystem) apiCall(ctx context.Context, method, path string, body return nil, core.E("brain.apiCall", "no API key (set CORE_BRAIN_KEY or create ~/.claude/brain.key)", nil) } - var reqBody *bytes.Reader - if body != nil { - data, err := json.Marshal(body) - if err != nil { - core.Error("brain API request marshal failed", "method", method, "path", path, "err", err) - return nil, core.E("brain.apiCall", "marshal request", err) - } - reqBody = bytes.NewReader(data) - } - requestURL := core.Concat(s.apiURL, path) - req, err := http.NewRequestWithContext(ctx, method, requestURL, nil) - if reqBody != nil { - req, err = http.NewRequestWithContext(ctx, method, requestURL, reqBody) + var bodyStr string + if body != nil { + bodyStr = core.JSONMarshalString(body) } - if err != nil { - core.Error("brain API request creation failed", "method", method, "path", path, "err", err) - return nil, core.E("brain.apiCall", "create request", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", core.Concat("Bearer ", s.apiKey)) - - resp, err := s.client.Do(req) - if err != nil { - core.Error("brain API call failed", "method", method, "path", path, "err", err) - return nil, core.E("brain.apiCall", "API call failed", err) - } - defer resp.Body.Close() - - respBuffer := bytes.NewBuffer(nil) - if _, err := respBuffer.ReadFrom(resp.Body); err != nil { - core.Error("brain API response read failed", "method", method, "path", path, "err", err) - return nil, core.E("brain.apiCall", "read response", err) - } - respData := respBuffer.Bytes() - - if resp.StatusCode >= 400 { - core.Warn("brain API returned error status", "method", method, "path", path, "status", resp.StatusCode) - return nil, core.E("brain.apiCall", core.Sprintf("API returned %d: %s", resp.StatusCode, string(respData)), nil) + r := agentic.HTTPDo(ctx, method, requestURL, bodyStr, s.apiKey, "Bearer") + if !r.OK { + core.Error("brain API call failed", "method", method, "path", path) + return nil, core.E("brain.apiCall", "API call failed", nil) } var result map[string]any - if err := json.Unmarshal(respData, &result); err != nil { - core.Error("brain API response parse failed", "method", method, "path", path, "err", err) - return nil, core.E("brain.apiCall", "parse response", err) + if ur := core.JSONUnmarshalString(r.Value.(string), &result); !ur.OK { + core.Error("brain API response parse failed", "method", method, "path", path) + return nil, core.E("brain.apiCall", "parse response", nil) } return result, nil diff --git a/pkg/brain/direct_example_test.go b/pkg/brain/direct_example_test.go new file mode 100644 index 0000000..2900b9c --- /dev/null +++ b/pkg/brain/direct_example_test.go @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package brain + +import core "dappco.re/go/core" + +func ExampleRememberInput() { + input := RememberInput{Content: "Core uses Result pattern", Type: "observation"} + core.Println(input.Type) + // Output: observation +} + +func ExampleRecallInput() { + input := RecallInput{Query: "how does Core handle errors", TopK: 5} + core.Println(input.TopK) + // Output: 5 +} diff --git a/pkg/brain/direct_test.go b/pkg/brain/direct_test.go index ea0f686..4c9194b 100644 --- a/pkg/brain/direct_test.go +++ b/pkg/brain/direct_test.go @@ -15,7 +15,7 @@ import ( // newTestDirect returns a DirectSubsystem wired to the given test server. func newTestDirect(srv *httptest.Server) *DirectSubsystem { - return &DirectSubsystem{apiURL: srv.URL, apiKey: "test-key", client: srv.Client()} + return &DirectSubsystem{apiURL: srv.URL, apiKey: "test-key"} } // jsonHandler returns an http.Handler that responds with the given JSON payload. @@ -42,7 +42,7 @@ func TestNewDirect_Good_Defaults(t *testing.T) { sub := NewDirect() assert.Equal(t, "https://api.lthn.sh", sub.apiURL) - assert.NotNil(t, sub.client) + assert.NotEmpty(t, sub.apiURL) } func TestNewDirect_Good_CustomEnv(t *testing.T) { @@ -129,7 +129,7 @@ func TestApiCall_Bad_ServerError(t *testing.T) { _, err := newTestDirect(srv).apiCall(context.Background(), "GET", "/v1/test", nil) require.Error(t, err) - assert.Contains(t, err.Error(), "500") + assert.Contains(t, err.Error(), "API call failed") } func TestApiCall_Bad_InvalidJSON(t *testing.T) { @@ -145,7 +145,7 @@ func TestApiCall_Bad_InvalidJSON(t *testing.T) { } func TestApiCall_Bad_ConnectionRefused(t *testing.T) { - sub := &DirectSubsystem{apiURL: "http://127.0.0.1:1", apiKey: "test-key", client: http.DefaultClient} + sub := &DirectSubsystem{apiURL: "http://127.0.0.1:1", apiKey: "test-key"} _, err := sub.apiCall(context.Background(), "GET", "/v1/test", nil) require.Error(t, err) assert.Contains(t, err.Error(), "API call failed") @@ -157,7 +157,7 @@ func TestApiCall_Bad_BadRequest(t *testing.T) { _, err := newTestDirect(srv).apiCall(context.Background(), "GET", "/v1/test", nil) require.Error(t, err) - assert.Contains(t, err.Error(), "400") + assert.Contains(t, err.Error(), "API call failed") } // --- remember --- diff --git a/pkg/brain/messaging_example_test.go b/pkg/brain/messaging_example_test.go new file mode 100644 index 0000000..6b8ea28 --- /dev/null +++ b/pkg/brain/messaging_example_test.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package brain + +import core "dappco.re/go/core" + +func ExampleSendInput() { + input := SendInput{To: "charon", Content: "deploy complete"} + core.Println(input.To) + // Output: charon +} diff --git a/pkg/brain/messaging_test.go b/pkg/brain/messaging_test.go index 57ad7a8..d74d303 100644 --- a/pkg/brain/messaging_test.go +++ b/pkg/brain/messaging_test.go @@ -16,7 +16,7 @@ import ( // localDirect returns a DirectSubsystem that never hits the network. // Suitable for tests that validate input before making API calls. func localDirect() *DirectSubsystem { - return &DirectSubsystem{apiURL: "http://localhost", apiKey: "test-key", client: http.DefaultClient} + return &DirectSubsystem{apiURL: "http://localhost", apiKey: "test-key"} } // --- sendMessage --- diff --git a/pkg/brain/provider_example_test.go b/pkg/brain/provider_example_test.go new file mode 100644 index 0000000..9bcec03 --- /dev/null +++ b/pkg/brain/provider_example_test.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package brain + +import core "dappco.re/go/core" + +func ExampleNewDirect() { + svc := NewDirect() + core.Println(svc != nil) + // Output: true +} diff --git a/pkg/brain/register_example_test.go b/pkg/brain/register_example_test.go new file mode 100644 index 0000000..7830042 --- /dev/null +++ b/pkg/brain/register_example_test.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package brain + +import core "dappco.re/go/core" + +func ExampleRegister() { + c := core.New(core.WithService(Register)) + core.Println(len(c.Services()) > 1) + // Output: true +} diff --git a/pkg/brain/register_test.go b/pkg/brain/register_test.go new file mode 100644 index 0000000..5dacbfd --- /dev/null +++ b/pkg/brain/register_test.go @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package brain + +import ( + "testing" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" +) + +func TestRegister_Register_Good(t *testing.T) { + c := core.New(core.WithService(Register)) + assert.Contains(t, c.Services(), "brain") +} + +func TestRegister_Register_Bad_ServiceName(t *testing.T) { + c := core.New(core.WithService(Register)) + assert.Contains(t, c.Services(), "brain") +} + +func TestRegister_Register_Ugly_ServiceAccessible(t *testing.T) { + c := core.New(core.WithService(Register)) + svc := c.Service("brain") + assert.True(t, svc.OK) +} diff --git a/pkg/brain/tools_example_test.go b/pkg/brain/tools_example_test.go new file mode 100644 index 0000000..1720bc2 --- /dev/null +++ b/pkg/brain/tools_example_test.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package brain + +import core "dappco.re/go/core" + +func ExampleForgetInput() { + input := ForgetInput{ID: "mem-123", Reason: "outdated"} + core.Println(input.ID) + // Output: mem-123 +} diff --git a/pkg/brain/tools_test.go b/pkg/brain/tools_test.go new file mode 100644 index 0000000..ed2d53f --- /dev/null +++ b/pkg/brain/tools_test.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package brain + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTools_ForgetInput_Good(t *testing.T) { + input := ForgetInput{ID: "mem-123", Reason: "outdated"} + assert.Equal(t, "mem-123", input.ID) + assert.Equal(t, "outdated", input.Reason) +} + +func TestTools_RememberInput_Good(t *testing.T) { + input := RememberInput{Content: "Core uses Result", Type: "observation"} + assert.Equal(t, "observation", input.Type) +} + +func TestTools_RecallInput_Good(t *testing.T) { + input := RecallInput{Query: "error handling", TopK: 10} + assert.Equal(t, 10, input.TopK) +} diff --git a/pkg/lib/lib.go b/pkg/lib/lib.go index 41dc379..69a0199 100644 --- a/pkg/lib/lib.go +++ b/pkg/lib/lib.go @@ -23,8 +23,6 @@ package lib import ( "embed" - "io/fs" - "path/filepath" core "dappco.re/go/core" ) @@ -50,8 +48,21 @@ var ( flowFS = mustMount(flowFiles, "flow") personaFS = mustMount(personaFiles, "persona") workspaceFS = mustMount(workspaceFiles, "workspace") + + // data wraps all embeds for ListNames access (avoids io/fs DirEntry import) + data = newData() ) +func newData() *core.Data { + d := &core.Data{Registry: core.NewRegistry[*core.Embed]()} + d.Set("prompt", promptFS) + d.Set("task", taskFS) + d.Set("flow", flowFS) + d.Set("persona", personaFS) + d.Set("workspace", workspaceFS) + return d +} + func mustMount(fsys embed.FS, basedir string) *core.Embed { r := core.Mount(fsys, basedir) if !r.OK { @@ -91,7 +102,7 @@ func Task(slug string) core.Result { return r } } - return core.Result{Value: fs.ErrNotExist} + return core.Result{OK: false} } // Bundle holds a task's main content plus companion files. @@ -117,12 +128,16 @@ func TaskBundle(slug string) core.Result { if !r.OK { return core.Result{Value: b, OK: true} } - for _, e := range r.Value.([]fs.DirEntry) { - if e.IsDir() { - continue - } - if fr := taskFS.ReadString(slug + "/" + e.Name()); fr.OK { - b.Files[e.Name()] = fr.Value.(string) + nr := data.ListNames(core.Concat("task/", slug)) + if nr.OK { + for _, name := range nr.Value.([]string) { + for _, ext := range []string{".md", ".yaml", ".yml", ".txt", ""} { + fullName := core.Concat(name, ext) + if fr := taskFS.ReadString(core.Concat(slug, "/", fullName)); fr.OK { + b.Files[fullName] = fr.Value.(string) + break + } + } } } return core.Result{Value: b, OK: true} @@ -186,55 +201,58 @@ func ExtractWorkspace(tmplName, targetDir string, data *WorkspaceData) error { // --- List Functions --- -func ListPrompts() []string { return listDir(promptFS) } -func ListFlows() []string { return listDir(flowFS) } -func ListWorkspaces() []string { return listDir(workspaceFS) } +func ListPrompts() []string { return listNames("prompt") } +func ListFlows() []string { return listNames("flow") } +func ListWorkspaces() []string { return listNames("workspace") } func ListTasks() []string { - var slugs []string - base := taskFS.BaseDirectory() - fs.WalkDir(taskFS.FS(), base, func(path string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return nil - } - rel := core.TrimPrefix(path, base+"/") - ext := filepath.Ext(rel) - slugs = append(slugs, core.TrimSuffix(rel, ext)) - return nil - }) - return slugs + result := listNamesRecursive("task", taskFS, ".") + a := core.NewArray(result...) + a.Deduplicate() + return a.AsSlice() } func ListPersonas() []string { - var paths []string - base := personaFS.BaseDirectory() - fs.WalkDir(personaFS.FS(), base, func(path string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return nil - } - if core.HasSuffix(path, ".md") { - rel := core.TrimPrefix(path, base+"/") - rel = core.TrimSuffix(rel, ".md") - paths = append(paths, rel) - } - return nil - }) - return paths + a := core.NewArray(listNamesRecursive("persona", personaFS, ".")...) + a.Deduplicate() + return a.AsSlice() } -func listDir(emb *core.Embed) []string { - r := emb.ReadDir(".") - if !r.OK { + +// listNamesRecursive walks an embed tree via Data.ListNames. +// Directories are recursed into. Files are added as slugs (extension stripped by ListNames). +// A name can be both a file AND a directory (e.g. code/review.md + code/review/). +func listNamesRecursive(mount string, emb *core.Embed, dir string) []string { + path := core.Concat(mount, "/", dir) + nr := data.ListNames(path) + if !nr.OK { return nil } + var slugs []string - for _, e := range r.Value.([]fs.DirEntry) { - name := e.Name() - if e.IsDir() { - slugs = append(slugs, name) - continue + for _, name := range nr.Value.([]string) { + relPath := name + if dir != "." { + relPath = core.Concat(dir, "/", name) } - slugs = append(slugs, core.TrimSuffix(name, filepath.Ext(name))) + + subPath := core.Concat(mount, "/", relPath) + + // Try as directory — recurse if it has contents + if sub := data.ListNames(subPath); sub.OK { + slugs = append(slugs, listNamesRecursive(mount, emb, relPath)...) + } + + // Always add the slug — ListNames includes both files and dirs + slugs = append(slugs, relPath) } return slugs } + +func listNames(mount string) []string { + r := data.ListNames(core.Concat(mount, "/.")) + if !r.OK { + return nil + } + return r.Value.([]string) +} diff --git a/pkg/lib/lib_example_test.go b/pkg/lib/lib_example_test.go new file mode 100644 index 0000000..aafac42 --- /dev/null +++ b/pkg/lib/lib_example_test.go @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package lib + +import ( + core "dappco.re/go/core" +) + +func ExamplePrompt() { + r := Prompt("coding") + core.Println(r.OK) + // Output: true +} + +func ExampleTask() { + r := Task("bug-fix") + core.Println(r.OK) + // Output: true +} + +func ExampleFlow() { + r := Flow("go") + core.Println(r.OK) + // Output: true +} + +func ExampleListPrompts() { + prompts := ListPrompts() + core.Println(len(prompts) > 0) + // Output: true +} + +func ExampleListFlows() { + flows := ListFlows() + core.Println(len(flows) > 0) + // Output: true +} + +func ExampleListTasks() { + tasks := ListTasks() + core.Println(len(tasks) > 0) + // Output: true +} + +func ExampleListWorkspaces() { + workspaces := ListWorkspaces() + core.Println(len(workspaces) > 0) + // Output: true +} + +func ExampleListPersonas() { + personas := ListPersonas() + core.Println(len(personas) > 0) + // Output: true +} + +func ExampleTemplate() { + r := Template("coding") + core.Println(r.OK) + // Output: true +} diff --git a/pkg/lib/lib_test.go b/pkg/lib/lib_test.go index 5141d41..752403e 100644 --- a/pkg/lib/lib_test.go +++ b/pkg/lib/lib_test.go @@ -1,7 +1,6 @@ package lib import ( - "io/fs" "os" "path/filepath" "testing" @@ -53,9 +52,7 @@ func TestTask_Bad(t *testing.T) { if r.OK { t.Error("Task('nonexistent-slug') should return !OK") } - if r.Value != fs.ErrNotExist { - t.Error("Task('nonexistent-slug') should return fs.ErrNotExist") - } + // Result{OK: false} — no specific error type needed } // --- TaskBundle --- diff --git a/pkg/lib/workspace/default/.core/reference/RFC-025-AGENT-EXPERIENCE.md b/pkg/lib/workspace/default/.core/reference/RFC-025-AGENT-EXPERIENCE.md index 5538902..a18e6bb 100644 --- a/pkg/lib/workspace/default/.core/reference/RFC-025-AGENT-EXPERIENCE.md +++ b/pkg/lib/workspace/default/.core/reference/RFC-025-AGENT-EXPERIENCE.md @@ -1,8 +1,8 @@ # RFC-025: Agent Experience (AX) Design Principles -- **Status:** Draft +- **Status:** Active - **Authors:** Snider, Cladius -- **Date:** 2026-03-19 +- **Date:** 2026-03-25 - **Applies to:** All Core ecosystem packages (CoreGO, CorePHP, CoreTS, core-agent) ## Abstract @@ -22,6 +22,7 @@ Design patterns inherited from the human-developer era optimise for the wrong co - **Error-at-every-call-site** produces 50% boilerplate that obscures intent - **Generic type parameters** force agents to carry type context that the runtime already has - **Panic-hiding conventions** (`Must*`) create implicit control flow that agents must special-case +- **Raw exec.Command** bypasses Core primitives — untestable, no entitlement check, path traversal risk AX acknowledges this shift and provides principles for designing code, APIs, file structures, and conventions that serve AI agents as first-class consumers. @@ -58,14 +59,20 @@ Options not Opts The function signature tells WHAT. The comment shows HOW with real values. ```go -// Detect the project type from files present -setup.Detect("/path/to/project") +// Entitled checks if an action is permitted. +// +// e := c.Entitled("process.run") +// e := c.Entitled("social.accounts", 3) +// if e.Allowed { proceed() } -// Set up a workspace with auto-detected template -setup.Run(setup.Options{Path: ".", Template: "auto"}) +// WriteAtomic writes via temp file then rename (safe for concurrent readers). +// +// r := fs.WriteAtomic("/status.json", data) -// Scaffold a PHP module workspace -setup.Run(setup.Options{Path: "./my-module", Template: "php"}) +// Action registers or invokes a named callable. +// +// c.Action("git.log", handler) // register +// c.Action("git.log").Run(ctx, opts) // invoke ``` **Rule:** If a comment restates what the type signature already says, delete it. If a comment shows a concrete usage with realistic values, keep it. @@ -77,11 +84,13 @@ setup.Run(setup.Options{Path: "./my-module", Template: "php"}) File and directory paths should be self-describing. An agent navigating the filesystem should understand what it is looking at without reading a README. ``` -flow/deploy/to/homelab.yaml — deploy TO the homelab -flow/deploy/from/github.yaml — deploy FROM GitHub -flow/code/review.yaml — code review flow -template/file/go/struct.go.tmpl — Go struct file template -template/dir/workspace/php/ — PHP workspace scaffold +pkg/agentic/dispatch.go — agent dispatch logic +pkg/agentic/handlers.go — IPC event handlers +pkg/lib/task/bug-fix.yaml — bug fix plan template +pkg/lib/persona/engineering/ — engineering personas +flow/deploy/to/homelab.yaml — deploy TO the homelab +template/dir/workspace/default/ — default workspace scaffold +docs/RFC.md — authoritative API contract ``` **Rule:** If an agent needs to read a file to understand what a directory contains, the directory naming has failed. @@ -94,11 +103,12 @@ When an agent generates code from a template, the output is constrained to known ```go // Template-driven — consistent output -lib.RenderFile("php/action", data) -lib.ExtractDir("php", targetDir, data) +lib.ExtractWorkspace("default", targetDir, &lib.WorkspaceData{ + Repo: "go-io", Branch: "dev", Task: "fix tests", Agent: "codex", +}) // Freeform — variance in output -"write a PHP action class that..." +"write a workspace setup script that..." ``` **Rule:** For any code pattern that recurs, provide a template. Templates are guardrails for agents. @@ -129,93 +139,291 @@ steps: cmd := exec.Command("docker", "build", "--platform", "linux/amd64", "-t", imageName, ".") cmd.Dir = appDir if err := cmd.Run(); err != nil { - return fmt.Errorf("docker build: %w", err) + return core.E("build", "docker build failed", err) } ``` **Rule:** Orchestration, configuration, and pipeline logic should be declarative (YAML/JSON). Implementation logic should be imperative (Go/PHP/TS). The boundary is: if an agent needs to compose or modify the logic, make it declarative. -### 6. Universal Types (Core Primitives) +Core's `Task` is the Go-native declarative equivalent — a sequence of named Action steps: -Every component in the ecosystem accepts and returns the same primitive types. An agent processing any level of the tree sees identical shapes. +```go +c.Task("deploy", core.Task{ + Steps: []core.Step{ + {Action: "docker.build"}, + {Action: "docker.push"}, + {Action: "deploy.ansible", Async: true}, + }, +}) +``` -`Option` is a single key-value pair. `Options` is a collection. Any function that returns `Result` can accept `Options`. +### 6. Core Primitives — Universal Types and DI + +Every component in the ecosystem registers with Core and communicates through Core's primitives. An agent processing any level of the tree sees identical shapes. + +#### Creating Core + +```go +c := core.New( + core.WithOption("name", "core-agent"), + core.WithService(process.Register), + core.WithService(agentic.Register), + core.WithService(monitor.Register), + core.WithService(brain.Register), + core.WithService(mcp.Register), +) +c.Run() // or: if err := c.RunE(); err != nil { ... } +``` + +`core.New()` returns `*Core`. `WithService` registers a factory `func(*Core) Result`. Services auto-discover: name from package path, lifecycle from `Startable`/`Stoppable` (return `Result`). `HandleIPCEvents` is the one remaining magic method — auto-registered via reflection if the service implements it. + +#### Service Registration Pattern + +```go +// Service factory — receives Core, returns Result +func Register(c *core.Core) core.Result { + svc := &MyService{ + ServiceRuntime: core.NewServiceRuntime(c, MyOptions{}), + } + return core.Result{Value: svc, OK: true} +} +``` + +#### Core Subsystem Accessors + +| Accessor | Purpose | +|----------|---------| +| `c.Options()` | Input configuration | +| `c.App()` | Application metadata (name, version) | +| `c.Config()` | Runtime settings, feature flags | +| `c.Data()` | Embedded assets (Registry[*Embed]) | +| `c.Drive()` | Transport handles (Registry[*DriveHandle]) | +| `c.Fs()` | Filesystem I/O (sandboxable) | +| `c.Process()` | Managed execution (Action sugar) | +| `c.API()` | Remote streams (protocol handlers) | +| `c.Action(name)` | Named callable (register/invoke) | +| `c.Task(name)` | Composed Action sequence | +| `c.Entitled(name)` | Permission check | +| `c.RegistryOf(n)` | Cross-cutting registry queries | +| `c.Cli()` | CLI command framework | +| `c.IPC()` | Message bus (ACTION, QUERY) | +| `c.Log()` | Structured logging | +| `c.Error()` | Panic recovery | +| `c.I18n()` | Internationalisation | + +#### Primitive Types ```go // Option — the atom -core.Option{K: "name", V: "brain"} +core.Option{Key: "name", Value: "brain"} -// Options — universal input (collection of Option) -core.Options{ - {K: "name", V: "myapp"}, - {K: "port", V: 8080}, -} +// Options — universal input +opts := core.NewOptions( + core.Option{Key: "name", Value: "myapp"}, + core.Option{Key: "port", Value: 8080}, +) +opts.String("name") // "myapp" +opts.Int("port") // 8080 -// Result[T] — universal return -core.Result[*Embed]{Value: emb, OK: true} +// Result — universal output +core.Result{Value: svc, OK: true} ``` -Usage across subsystems — same shape everywhere: +#### Named Actions — The Primary Communication Pattern + +Services register capabilities as named Actions. No direct function calls, no untyped dispatch — declare intent by name, invoke by name. ```go -// Create Core -c := core.New(core.Options{{K: "name", V: "myapp"}}) - -// Mount embedded content -c.Data().New(core.Options{ - {K: "name", V: "brain"}, - {K: "source", V: brainFS}, - {K: "path", V: "prompts"}, +// Register a capability during OnStartup +c.Action("workspace.create", func(ctx context.Context, opts core.Options) core.Result { + name := opts.String("name") + path := core.JoinPath("/srv/workspaces", name) + return core.Result{Value: path, OK: true} }) -// Register a transport handle -c.Drive().New(core.Options{ - {K: "name", V: "api"}, - {K: "transport", V: "https://api.lthn.ai"}, -}) +// Invoke by name — typed, inspectable, entitlement-checked +r := c.Action("workspace.create").Run(ctx, core.NewOptions( + core.Option{Key: "name", Value: "alpha"}, +)) -// Read back what was passed in -c.Options().String("name") // "myapp" +// Check capability before calling +if c.Action("process.run").Exists() { /* go-process is registered */ } + +// List all capabilities +c.Actions() // ["workspace.create", "process.run", "brain.recall", ...] ``` -**Core primitive types:** +#### Task Composition — Sequencing Actions -| Type | Purpose | -|------|---------| -| `core.Option` | Single key-value pair (the atom) | -| `core.Options` | Collection of Option (universal input) | -| `core.Result[T]` | Return value with OK/fail state (universal output) | -| `core.Config` | Runtime settings (what is active) | -| `core.Data` | Embedded or stored content from packages | -| `core.Drive` | Resource handle registry (transports) | -| `core.Service` | A managed component with lifecycle | +```go +c.Task("agent.completion", core.Task{ + Steps: []core.Step{ + {Action: "agentic.qa"}, + {Action: "agentic.auto-pr"}, + {Action: "agentic.verify"}, + {Action: "agentic.poke", Async: true}, // doesn't block + }, +}) +``` -**Core struct subsystems:** +#### Anonymous Broadcast — Legacy Layer -| Accessor | Analogy | Purpose | -|----------|---------|---------| -| `c.Options()` | argv | Input configuration used to create this Core | -| `c.Data()` | /mnt | Embedded assets mounted by packages | -| `c.Drive()` | /dev | Transport handles (API, MCP, SSH, VPN) | -| `c.Config()` | /etc | Configuration, settings, feature flags | -| `c.Fs()` | / | Local filesystem I/O (sandboxable) | -| `c.Error()` | — | Panic recovery and crash reporting (`ErrorPanic`) | -| `c.Log()` | — | Structured logging (`ErrorLog`) | -| `c.Service()` | — | Service registry and lifecycle | -| `c.Cli()` | — | CLI command framework | -| `c.IPC()` | — | Message bus | -| `c.I18n()` | — | Internationalisation | +`ACTION` and `QUERY` remain for backwards-compatible anonymous dispatch. New code should prefer named Actions. -**What this replaces:** +```go +// Broadcast — all handlers fire, type-switch to filter +c.ACTION(messages.DeployCompleted{Env: "production"}) + +// Query — first responder wins +r := c.QUERY(countQuery{}) +``` + +#### Process Execution — Use Core Primitives + +All external command execution MUST go through `c.Process()`, not raw `os/exec`. This makes process execution testable, gatable by entitlements, and managed by Core's lifecycle. + +```go +// AX-native: Core Process primitive +r := c.Process().RunIn(ctx, repoDir, "git", "log", "--oneline", "-20") +if r.OK { output := r.Value.(string) } + +// Not AX: raw exec.Command — untestable, no entitlement, no lifecycle +cmd := exec.Command("git", "log", "--oneline", "-20") +cmd.Dir = repoDir +out, err := cmd.Output() +``` + +**Rule:** If a package imports `os/exec`, it is bypassing Core's process primitive. The only package that should import `os/exec` is `go-process` itself. + +**Quality gate:** An agent reviewing a diff can mechanically check: does this import `os/exec`, `unsafe`, or `encoding/json` directly? If so, it bypassed a Core primitive. + +#### What This Replaces | Go Convention | Core AX | Why | |--------------|---------|-----| -| `func With*(v) Option` | `core.Options{{K: k, V: v}}` | K/V pairs are parseable; option chains require tracing | -| `func Must*(v) T` | `core.Result[T]` | No hidden panics; errors flow through Core | +| `func With*(v) Option` | `core.WithOption(k, v)` | Named key-value is greppable; option chains require tracing | +| `func Must*(v) T` | `core.Result` | No hidden panics; errors flow through Result.OK | | `func *For[T](c) T` | `c.Service("name")` | String lookup is greppable; generics require type context | | `val, err :=` everywhere | Single return via `core.Result` | Intent not obscured by error handling | -| `_ = err` | Never needed | Core handles all errors internally | -| `ErrPan` / `ErrLog` | `ErrorPanic` / `ErrorLog` | Full names — AX principle 1 | +| `exec.Command(...)` | `c.Process().Run(ctx, cmd, args...)` | Testable, gatable, lifecycle-managed | +| `map[string]*T + mutex` | `core.Registry[T]` | Thread-safe, ordered, lockable, queryable | +| untyped `any` dispatch | `c.Action("name").Run(ctx, opts)` | Named, typed, inspectable, entitlement-checked | + +### 7. Tests as Behavioural Specification + +Test names are structured data. An agent querying "what happens when dispatch fails?" should find the answer by scanning test names, not reading prose. + +``` +TestDispatch_DetectFinalStatus_Good — clean exit → completed +TestDispatch_DetectFinalStatus_Bad — non-zero exit → failed +TestDispatch_DetectFinalStatus_Ugly — BLOCKED.md overrides exit code +``` + +**Convention:** `Test{File}_{Function}_{Good|Bad|Ugly}` + +| Category | Purpose | +|----------|---------| +| `_Good` | Happy path — proves the contract works | +| `_Bad` | Expected errors — proves error handling works | +| `_Ugly` | Edge cases, panics, corruption — proves it doesn't blow up | + +**Rule:** Every testable function gets all three categories. Missing categories are gaps in the specification, detectable by scanning: + +```bash +# Find under-tested functions +for f in *.go; do + [[ "$f" == *_test.go ]] && continue + while IFS= read -r line; do + fn=$(echo "$line" | sed 's/func.*) //; s/(.*//; s/ .*//') + [[ -z "$fn" || "$fn" == register* ]] && continue + cap="${fn^}" + grep -q "_${cap}_Good\|_${fn}_Good" *_test.go || echo "$f: $fn missing Good" + grep -q "_${cap}_Bad\|_${fn}_Bad" *_test.go || echo "$f: $fn missing Bad" + grep -q "_${cap}_Ugly\|_${fn}_Ugly" *_test.go || echo "$f: $fn missing Ugly" + done < <(grep "^func " "$f") +done +``` + +**Rationale:** The test suite IS the behavioural spec. `grep _TrackFailureRate_ *_test.go` returns three concrete scenarios — no prose needed. The naming convention makes the entire test suite machine-queryable. An agent dispatched to fix a function can read its tests to understand the full contract before making changes. + +**What this replaces:** + +| Convention | AX Test Naming | Why | +|-----------|---------------|-----| +| `TestFoo_works` | `TestFile_Foo_Good` | File prefix enables cross-file search | +| Unnamed table tests | Explicit Good/Bad/Ugly | Categories are scannable without reading test body | +| Coverage % as metric | Missing categories as metric | 100% coverage with only Good tests is a false signal | + +### 7b. Example Tests as AX TDD + +Go `Example` functions serve triple duty: they run as tests (count toward coverage), show in godoc (usage documentation), and seed user guide generation. + +```go +// file: action_example_test.go + +func ExampleAction_Run() { + c := New() + c.Action("double", func(_ context.Context, opts Options) Result { + return Result{Value: opts.Int("n") * 2, OK: true} + }) + + r := c.Action("double").Run(context.Background(), NewOptions( + Option{Key: "n", Value: 21}, + )) + Println(r.Value) + // Output: 42 +} +``` + +**AX TDD pattern:** Write the Example first — it defines how the API should feel. If the Example is awkward, the API is wrong. The Example IS the test, the documentation, and the design feedback loop. + +**Convention:** One `{source}_example_test.go` per source file. Every exported function should have at least one Example. The Example output comment makes it a verified test. + +**Quality gate:** A source file without a corresponding example file is missing documentation that compiles. + +### Operational Principles + +Principles 1-7 govern code design. Principles 8-10 govern how agents and humans work with the codebase. + +### 8. RFC as Domain Load + +An agent's first action in a session should be loading the repo's RFC.md. The full spec in context produces zero-correction sessions — every decision aligns with the design because the design is loaded. + +**Validated:** Loading core/go's RFC.md (42k tokens from a 500k token discovery session) at session start eliminated all course corrections. The spec is compressed domain knowledge that survives context compaction. + +**Rule:** Every repo that has non-trivial architecture should have a `docs/RFC.md`. The RFC is not documentation for humans — it's a context document for agents. It should be loadable in one read and contain everything needed to make correct decisions. + +### 9. Primitives as Quality Gates + +Core primitives become mechanical code review rules. An agent reviewing a diff checks: + +| Import | Violation | Use Instead | +|--------|-----------|-------------| +| `os` | Bypasses Fs/Env primitives | `c.Fs()`, `core.Env()`, `core.DirFS()`, `Fs.TempDir()` | +| `os/exec` | Bypasses Process primitive | `c.Process().Run()` | +| `io` | Bypasses stream primitives | `core.ReadAll()`, `core.WriteAll()`, `core.CloseStream()` | +| `fmt` | Bypasses string/print primitives | `core.Println()`, `core.Sprintf()`, `core.Sprint()` | +| `errors` | Bypasses error primitive | `core.NewError()`, `core.E()`, `core.Is()`, `core.As()` | +| `log` | Bypasses logging | `core.Info()`, `core.Warn()`, `core.Error()`, `c.Log()` | +| `encoding/json` | Bypasses Core serialisation | `core.JSONMarshal()`, `core.JSONUnmarshal()` | +| `path/filepath` | Bypasses path security boundary | `core.Path()`, `core.JoinPath()`, `core.PathBase()` | +| `unsafe` | Bypasses Fs sandbox | `Fs.NewUnrestricted()` | +| `strings` | Bypasses string guardrails | `core.Contains()`, `core.Split()`, `core.Trim()`, etc. | + +**Rule:** If a diff introduces a disallowed import, it failed code review. The import list IS the quality gate. No subjective judgement needed — a weaker model can enforce this mechanically. + +### 10. Registration IS Capability, Entitlement IS Permission + +Two layers of permission, both declarative: + +``` +Registration = "this action EXISTS" → c.Action("process.run").Exists() +Entitlement = "this Core is ALLOWED" → c.Entitled("process.run").Allowed +``` + +A sandboxed Core has no `process.run` registered — the action doesn't exist. A SaaS Core has it registered but entitlement-gated — the action exists but the workspace may not be allowed to use it. + +**Rule:** Never check permissions with `if` statements in business logic. Register capabilities as Actions. Gate them with Entitlements. The framework enforces both — `Action.Run()` checks both before executing. ## Applying AX to Existing Patterns @@ -224,11 +432,14 @@ c.Options().String("name") // "myapp" ``` # AX-native: path describes content core/agent/ -├── go/ # Go source -├── php/ # PHP source -├── ui/ # Frontend source -├── claude/ # Claude Code plugin -└── codex/ # Codex plugin +├── cmd/core-agent/ # CLI entry point (minimal — just core.New + Run) +├── pkg/agentic/ # Agent orchestration (dispatch, prep, verify, scan) +├── pkg/brain/ # OpenBrain integration +├── pkg/lib/ # Embedded templates, personas, flows +├── pkg/messages/ # Typed IPC message definitions +├── pkg/monitor/ # Agent monitoring + notifications +├── pkg/setup/ # Workspace scaffolding + detection +└── claude/ # Claude Code plugin definitions # Not AX: generic names requiring README src/ @@ -240,39 +451,85 @@ src/ ### Error Handling ```go -// AX-native: errors are infrastructure, not application logic -svc := c.Service("brain") -cfg := c.Config().Get("database.host") -// Errors logged by Core. Code reads like a spec. +// AX-native: errors flow through Result, not call sites +func Register(c *core.Core) core.Result { + svc := &MyService{ServiceRuntime: core.NewServiceRuntime(c, MyOpts{})} + return core.Result{Value: svc, OK: true} +} // Not AX: errors dominate the code -svc, err := c.ServiceFor[brain.Service]() -if err != nil { - return fmt.Errorf("get brain service: %w", err) -} -cfg, err := c.Config().Get("database.host") -if err != nil { - _ = err // silenced because "it'll be fine" +func Register(c *core.Core) (*MyService, error) { + svc, err := NewMyService(c) + if err != nil { + return nil, fmt.Errorf("create service: %w", err) + } + return svc, nil } ``` -### API Design +### Command Registration ```go -// AX-native: one shape, every surface -c := core.New(core.Options{ - {K: "name", V: "my-app"}, -}) -c.Service("process", processSvc) -c.Data().New(core.Options{{K: "name", V: "app"}, {K: "source", V: appFS}}) +// AX-native: extracted methods, testable without CLI +func (s *MyService) OnStartup(ctx context.Context) core.Result { + c := s.Core() + c.Command("issue/get", core.Command{Action: s.cmdIssueGet}) + c.Command("issue/list", core.Command{Action: s.cmdIssueList}) + c.Action("forge.issue.get", s.handleIssueGet) + return core.Result{OK: true} +} -// Not AX: multiple patterns for the same thing -c, err := core.New( - core.WithName("my-app"), - core.WithService(factory1), - core.WithAssets(appFS), -) -if err != nil { ... } +func (s *MyService) cmdIssueGet(opts core.Options) core.Result { + // testable business logic — no closure, no CLI dependency +} + +// Not AX: closures that can only be tested via CLI integration +c.Command("issue/get", core.Command{ + Action: func(opts core.Options) core.Result { + // 50 lines of untestable inline logic + }, +}) +``` + +### Process Execution + +```go +// AX-native: Core Process primitive, testable with mock handler +func (s *MyService) getGitLog(repoPath string) string { + r := s.Core().Process().RunIn(context.Background(), repoPath, "git", "log", "--oneline", "-20") + if !r.OK { return "" } + return core.Trim(r.Value.(string)) +} + +// Not AX: raw exec.Command — untestable, no entitlement check, path traversal risk +func (s *MyService) getGitLog(repoPath string) string { + cmd := exec.Command("git", "log", "--oneline", "-20") + cmd.Dir = repoPath // user-controlled path goes directly to OS + output, err := cmd.Output() + if err != nil { return "" } + return strings.TrimSpace(string(output)) +} +``` + +The AX-native version routes through `c.Process()` → named Action → entitlement check. The non-AX version passes user input directly to `os/exec` with no permission gate. + +### Permission Gating + +```go +// AX-native: entitlement checked by framework, not by business logic +c.Action("agentic.dispatch", func(ctx context.Context, opts core.Options) core.Result { + // Action.Run() already checked c.Entitled("agentic.dispatch") + // If we're here, we're allowed. Just do the work. + return dispatch(ctx, opts) +}) + +// Not AX: permission logic scattered through business code +func handleDispatch(ctx context.Context, opts core.Options) core.Result { + if !isAdmin(ctx) && !hasPlan(ctx, "pro") { + return core.Result{Value: core.E("dispatch", "upgrade required", nil), OK: false} + } + // duplicate permission check in every handler +} ``` ## Compatibility @@ -283,21 +540,49 @@ The conventions diverge from community patterns (functional options, Must/For, e ## Adoption -AX applies to all new code in the Core ecosystem. Existing code migrates incrementally as it is touched — no big-bang rewrite. +AX applies to all code in the Core ecosystem. core/go is fully migrated (v0.8.0). Consumer packages migrate via their RFCs. -Priority order: -1. **Public APIs** (package-level functions, struct constructors) -2. **File structure** (path naming, template locations) -3. **Internal fields** (struct field names, local variables) +Priority for migrating a package: +1. **Lifecycle** — `OnStartup`/`OnShutdown` return `Result` +2. **Actions** — register capabilities as named Actions +3. **Imports** — replace all 10 disallowed imports (Principle 9) +4. **String ops** — `+` concat → `Concat()`, `path +` → `Path()` +5. **Test naming** — `TestFile_Function_{Good,Bad,Ugly}` +6. **Examples** — one `{source}_example_test.go` per source file +7. **Comments** — every exported function has usage example (Principle 2) + +## Verification + +An agent auditing AX compliance checks: + +```bash +# Disallowed imports (Principle 9) +grep -rn '"os"\|"os/exec"\|"io"\|"fmt"\|"errors"\|"log"\|"encoding/json"\|"path/filepath"\|"unsafe"\|"strings"' *.go \ + | grep -v _test.go + +# Test naming (Principle 7) +grep "^func Test" *_test.go | grep -v "Test[A-Z][a-z]*_.*_\(Good\|Bad\|Ugly\)" + +# String concat (should use Concat/Path) +grep -n '" + \| + "' *.go | grep -v _test.go | grep -v "//" + +# Untyped dispatch (should prefer named Actions) +grep "RegisterTask\|PERFORM\|type Task any" *.go +``` + +If any check produces output, the code needs migration. ## References -- dAppServer unified path convention (2024) -- CoreGO DTO pattern refactor (2026-03-18) -- Core primitives design (2026-03-19) +- `core/go/docs/RFC.md` — CoreGO API contract (21 sections, reference implementation) +- `core/go-process/docs/RFC.md` — Process consumer spec +- `core/agent/docs/RFC.md` — Agent consumer spec +- RFC-004 (Entitlements) — permission model ported to `c.Entitled()` +- RFC-021 (Core Platform Architecture) — 7-layer stack, provider model +- dAppServer unified path convention (2024) — path = route = command = test - Go Proverbs, Rob Pike (2015) — AX provides an updated lens ## Changelog -- 2026-03-20: Updated to match implementation — Option K/V atoms, Options as []Option, Data/Drive split, ErrorPanic/ErrorLog renames, subsystem table -- 2026-03-19: Initial draft +- 2026-03-25: v0.8.0 alignment — all examples match implemented API. Added Principles 8 (RFC as Domain Load), 9 (Primitives as Quality Gates), 10 (Registration + Entitlement). Updated subsystem table (Process, API, Action, Task, Entitled, RegistryOf). Process examples use `c.Process()` not old `process.RunWithOptions`. Removed PERFORM references. +- 2026-03-19: Initial draft — 7 principles diff --git a/pkg/lib/workspace/default/.core/reference/app.go b/pkg/lib/workspace/default/.core/reference/app.go index 3a5aa02..9fc1984 100644 --- a/pkg/lib/workspace/default/.core/reference/app.go +++ b/pkg/lib/workspace/default/.core/reference/app.go @@ -1,53 +1,93 @@ // SPDX-License-Identifier: EUPL-1.2 // Application identity for the Core framework. -// Based on leaanthony/sail — Name, Filename, Path. package core import ( - "os/exec" + "os" "path/filepath" ) // App holds the application identity and optional GUI runtime. +// +// app := core.App{}.New(core.NewOptions( +// core.Option{Key: "name", Value: "Core CLI"}, +// core.Option{Key: "version", Value: "1.0.0"}, +// )) type App struct { - // Name is the human-readable application name (e.g., "Core CLI"). - Name string - - // Version is the application version string (e.g., "1.2.3"). - Version string - - // Description is a short description of the application. + Name string + Version string Description string + Filename string + Path string + Runtime any // GUI runtime (e.g., Wails App). Nil for CLI-only. +} - // Filename is the executable filename (e.g., "core"). - Filename string - - // Path is the absolute path to the executable. - Path string - - // Runtime is the GUI runtime (e.g., Wails App). - // Nil for CLI-only applications. - Runtime any +// New creates an App from Options. +// +// app := core.App{}.New(core.NewOptions( +// core.Option{Key: "name", Value: "myapp"}, +// core.Option{Key: "version", Value: "1.0.0"}, +// )) +func (a App) New(opts Options) App { + if name := opts.String("name"); name != "" { + a.Name = name + } + if version := opts.String("version"); version != "" { + a.Version = version + } + if desc := opts.String("description"); desc != "" { + a.Description = desc + } + if filename := opts.String("filename"); filename != "" { + a.Filename = filename + } + return a } // Find locates a program on PATH and returns a Result containing the App. +// Uses os.Stat to search PATH directories — no os/exec dependency. // -// r := core.Find("node", "Node.js") +// r := core.App{}.Find("node", "Node.js") // if r.OK { app := r.Value.(*App) } -func Find(filename, name string) Result { - path, err := exec.LookPath(filename) - if err != nil { - return Result{err, false} +func (a App) Find(filename, name string) Result { + // If filename contains a separator, check it directly + if Contains(filename, string(os.PathSeparator)) { + abs, err := filepath.Abs(filename) + if err != nil { + return Result{err, false} + } + if isExecutable(abs) { + return Result{&App{Name: name, Filename: filename, Path: abs}, true} + } + return Result{E("app.Find", Concat(filename, " not found"), nil), false} } - abs, err := filepath.Abs(path) - if err != nil { - return Result{err, false} + + // Search PATH + pathEnv := os.Getenv("PATH") + if pathEnv == "" { + return Result{E("app.Find", "PATH is empty", nil), false} } - return Result{&App{ - Name: name, - Filename: filename, - Path: abs, - }, true} + for _, dir := range Split(pathEnv, string(os.PathListSeparator)) { + candidate := filepath.Join(dir, filename) + if isExecutable(candidate) { + abs, err := filepath.Abs(candidate) + if err != nil { + continue + } + return Result{&App{Name: name, Filename: filename, Path: abs}, true} + } + } + return Result{E("app.Find", Concat(filename, " not found on PATH"), nil), false} +} + +// isExecutable checks if a path exists and is executable. +func isExecutable(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + // Regular file with at least one execute bit + return !info.IsDir() && info.Mode()&0111 != 0 } diff --git a/pkg/lib/workspace/default/.core/reference/cli.go b/pkg/lib/workspace/default/.core/reference/cli.go index ff7d298..5e4b9f7 100644 --- a/pkg/lib/workspace/default/.core/reference/cli.go +++ b/pkg/lib/workspace/default/.core/reference/cli.go @@ -1,16 +1,10 @@ // SPDX-License-Identifier: EUPL-1.2 // Cli is the CLI surface layer for the Core command tree. -// It reads commands from Core's registry and wires them to terminal I/O. // -// Run the CLI: -// -// c := core.New(core.Options{{Key: "name", Value: "myapp"}}) -// c.Command("deploy", handler) +// c := core.New(core.WithOption("name", "myapp")).Value.(*Core) +// c.Command("deploy", core.Command{Action: handler}) // c.Cli().Run() -// -// The Cli resolves os.Args to a command path, parses flags, -// and calls the command's action with parsed options. package core import ( @@ -18,13 +12,25 @@ import ( "os" ) +// CliOptions holds configuration for the Cli service. +type CliOptions struct{} + // Cli is the CLI surface for the Core command tree. type Cli struct { - core *Core + *ServiceRuntime[CliOptions] output io.Writer banner func(*Cli) string } +// Register creates a Cli service factory for core.WithService. +// +// core.New(core.WithService(core.CliRegister)) +func CliRegister(c *Core) Result { + cl := &Cli{output: os.Stdout} + cl.ServiceRuntime = NewServiceRuntime[CliOptions](c, CliOptions{}) + return c.RegisterService("cli", cl) +} + // Print writes to the CLI output (defaults to os.Stdout). // // c.Cli().Print("hello %s", "world") @@ -49,19 +55,16 @@ func (cl *Cli) Run(args ...string) Result { } clean := FilterArgs(args) + c := cl.Core() - if cl.core == nil || cl.core.commands == nil { + if c == nil || c.commands == nil { if cl.banner != nil { cl.Print(cl.banner(cl)) } return Result{} } - cl.core.commands.mu.RLock() - cmdCount := len(cl.core.commands.commands) - cl.core.commands.mu.RUnlock() - - if cmdCount == 0 { + if c.commands.Len() == 0 { if cl.banner != nil { cl.Print(cl.banner(cl)) } @@ -72,16 +75,14 @@ func (cl *Cli) Run(args ...string) Result { var cmd *Command var remaining []string - cl.core.commands.mu.RLock() for i := len(clean); i > 0; i-- { path := JoinPath(clean[:i]...) - if c, ok := cl.core.commands.commands[path]; ok { - cmd = c + if r := c.commands.Get(path); r.OK { + cmd = r.Value.(*Command) remaining = clean[i:] break } } - cl.core.commands.mu.RUnlock() if cmd == nil { if cl.banner != nil { @@ -92,26 +93,23 @@ func (cl *Cli) Run(args ...string) Result { } // Build options from remaining args - opts := Options{} + opts := NewOptions() for _, arg := range remaining { key, val, valid := ParseFlag(arg) if valid { if Contains(arg, "=") { - opts = append(opts, Option{Key: key, Value: val}) + opts.Set(key, val) } else { - opts = append(opts, Option{Key: key, Value: true}) + opts.Set(key, true) } } else if !IsFlag(arg) { - opts = append(opts, Option{Key: "_arg", Value: arg}) + opts.Set("_arg", arg) } } if cmd.Action != nil { return cmd.Run(opts) } - if cmd.Lifecycle != nil { - return cmd.Start(opts) - } return Result{E("core.Cli.Run", Concat("command \"", cmd.Path, "\" is not executable"), nil), false} } @@ -119,13 +117,14 @@ func (cl *Cli) Run(args ...string) Result { // // c.Cli().PrintHelp() func (cl *Cli) PrintHelp() { - if cl.core == nil || cl.core.commands == nil { + c := cl.Core() + if c == nil || c.commands == nil { return } name := "" - if cl.core.app != nil { - name = cl.core.app.Name + if c.app != nil { + name = c.app.Name } if name != "" { cl.Print("%s commands:", name) @@ -133,21 +132,18 @@ func (cl *Cli) PrintHelp() { cl.Print("Commands:") } - cl.core.commands.mu.RLock() - defer cl.core.commands.mu.RUnlock() - - for path, cmd := range cl.core.commands.commands { - if cmd.Hidden || (cmd.Action == nil && cmd.Lifecycle == nil) { - continue + c.commands.Each(func(path string, cmd *Command) { + if cmd.Hidden || (cmd.Action == nil && !cmd.IsManaged()) { + return } - tr := cl.core.I18n().Translate(cmd.I18nKey()) + tr := c.I18n().Translate(cmd.I18nKey()) desc, _ := tr.Value.(string) if desc == "" || desc == cmd.I18nKey() { cl.Print(" %s", path) } else { cl.Print(" %-30s %s", path, desc) } - } + }) } // SetBanner sets the banner function. @@ -162,8 +158,9 @@ func (cl *Cli) Banner() string { if cl.banner != nil { return cl.banner(cl) } - if cl.core != nil && cl.core.app != nil && cl.core.app.Name != "" { - return cl.core.app.Name + c := cl.Core() + if c != nil && c.app != nil && c.app.Name != "" { + return c.app.Name } return "" } diff --git a/pkg/lib/workspace/default/.core/reference/command.go b/pkg/lib/workspace/default/.core/reference/command.go index 7b74e9f..660f866 100644 --- a/pkg/lib/workspace/default/.core/reference/command.go +++ b/pkg/lib/workspace/default/.core/reference/command.go @@ -20,37 +20,31 @@ // "deploy/to/homelab" → "cmd.deploy.to.homelab.description" package core -import ( - "sync" -) // CommandAction is the function signature for command handlers. // // func(opts core.Options) core.Result type CommandAction func(Options) Result -// CommandLifecycle is implemented by commands that support managed lifecycle. -// Basic commands only need an action. Daemon commands implement Start/Stop/Signal -// via go-process. -type CommandLifecycle interface { - Start(Options) Result - Stop() Result - Restart() Result - Reload() Result - Signal(string) Result -} - // Command is the DTO for an executable operation. +// Commands are declarative — they carry enough information for multiple consumers: +// - core.Cli() runs the Action +// - core/cli adds rich help, completion, man pages +// - go-process wraps Managed commands with lifecycle (PID, health, signals) +// +// c.Command("serve", core.Command{ +// Action: handler, +// Managed: "process.daemon", // go-process provides start/stop/restart +// }) type Command struct { Name string - Description string // i18n key — derived from path if empty - Path string // "deploy/to/homelab" - Action CommandAction // business logic - Lifecycle CommandLifecycle // optional — provided by go-process - Flags Options // declared flags + Description string // i18n key — derived from path if empty + Path string // "deploy/to/homelab" + Action CommandAction // business logic + Managed string // "" = one-shot, "process.daemon" = managed lifecycle + Flags Options // declared flags Hidden bool commands map[string]*Command // child commands (internal) - mu sync.RWMutex } // I18nKey returns the i18n key for this command's description. @@ -69,7 +63,7 @@ func (cmd *Command) I18nKey() string { // Run executes the command's action with the given options. // -// result := cmd.Run(core.Options{{Key: "target", Value: "homelab"}}) +// result := cmd.Run(core.NewOptions(core.Option{Key: "target", Value: "homelab"})) func (cmd *Command) Run(opts Options) Result { if cmd.Action == nil { return Result{E("core.Command.Run", Concat("command \"", cmd.Path, "\" is not executable"), nil), false} @@ -77,52 +71,19 @@ func (cmd *Command) Run(opts Options) Result { return cmd.Action(opts) } -// Start delegates to the lifecycle implementation if available. -func (cmd *Command) Start(opts Options) Result { - if cmd.Lifecycle != nil { - return cmd.Lifecycle.Start(opts) - } - return cmd.Run(opts) -} - -// Stop delegates to the lifecycle implementation. -func (cmd *Command) Stop() Result { - if cmd.Lifecycle != nil { - return cmd.Lifecycle.Stop() - } - return Result{} -} - -// Restart delegates to the lifecycle implementation. -func (cmd *Command) Restart() Result { - if cmd.Lifecycle != nil { - return cmd.Lifecycle.Restart() - } - return Result{} -} - -// Reload delegates to the lifecycle implementation. -func (cmd *Command) Reload() Result { - if cmd.Lifecycle != nil { - return cmd.Lifecycle.Reload() - } - return Result{} -} - -// Signal delegates to the lifecycle implementation. -func (cmd *Command) Signal(sig string) Result { - if cmd.Lifecycle != nil { - return cmd.Lifecycle.Signal(sig) - } - return Result{} +// IsManaged returns true if this command has a managed lifecycle. +// +// if cmd.IsManaged() { /* go-process handles start/stop */ } +func (cmd *Command) IsManaged() bool { + return cmd.Managed != "" } // --- Command Registry (on Core) --- -// commandRegistry holds the command tree. -type commandRegistry struct { - commands map[string]*Command - mu sync.RWMutex +// CommandRegistry holds the command tree. Embeds Registry[*Command] +// for thread-safe named storage with insertion order. +type CommandRegistry struct { + *Registry[*Command] } // Command gets or registers a command by path. @@ -131,21 +92,19 @@ type commandRegistry struct { // r := c.Command("deploy") func (c *Core) Command(path string, command ...Command) Result { if len(command) == 0 { - c.commands.mu.RLock() - cmd, ok := c.commands.commands[path] - c.commands.mu.RUnlock() - return Result{cmd, ok} + return c.commands.Get(path) } if path == "" || HasPrefix(path, "/") || HasSuffix(path, "/") || Contains(path, "//") { return Result{E("core.Command", Concat("invalid command path: \"", path, "\""), nil), false} } - c.commands.mu.Lock() - defer c.commands.mu.Unlock() - - if existing, exists := c.commands.commands[path]; exists && (existing.Action != nil || existing.Lifecycle != nil) { - return Result{E("core.Command", Concat("command \"", path, "\" already registered"), nil), false} + // Check for duplicate executable command + if r := c.commands.Get(path); r.OK { + existing := r.Value.(*Command) + if existing.Action != nil || existing.IsManaged() { + return Result{E("core.Command", Concat("command \"", path, "\" already registered"), nil), false} + } } cmd := &command[0] @@ -156,7 +115,8 @@ func (c *Core) Command(path string, command ...Command) Result { } // Preserve existing subtree when overwriting a placeholder parent - if existing, exists := c.commands.commands[path]; exists { + if r := c.commands.Get(path); r.OK { + existing := r.Value.(*Command) for k, v := range existing.commands { if _, has := cmd.commands[k]; !has { cmd.commands[k] = v @@ -164,40 +124,35 @@ func (c *Core) Command(path string, command ...Command) Result { } } - c.commands.commands[path] = cmd + c.commands.Set(path, cmd) // Build parent chain — "deploy/to/homelab" creates "deploy" and "deploy/to" if missing parts := Split(path, "/") for i := len(parts) - 1; i > 0; i-- { parentPath := JoinPath(parts[:i]...) - if _, exists := c.commands.commands[parentPath]; !exists { - c.commands.commands[parentPath] = &Command{ + if !c.commands.Has(parentPath) { + c.commands.Set(parentPath, &Command{ Name: parts[i-1], Path: parentPath, commands: make(map[string]*Command), - } + }) } - c.commands.commands[parentPath].commands[parts[i]] = cmd - cmd = c.commands.commands[parentPath] + parent := c.commands.Get(parentPath).Value.(*Command) + parent.commands[parts[i]] = cmd + cmd = parent } return Result{OK: true} } -// Commands returns all registered command paths. +// Commands returns all registered command paths in registration order. // // paths := c.Commands() func (c *Core) Commands() []string { if c.commands == nil { return nil } - c.commands.mu.RLock() - defer c.commands.mu.RUnlock() - var paths []string - for k := range c.commands.commands { - paths = append(paths, k) - } - return paths + return c.commands.Names() } // pathName extracts the last segment of a path. diff --git a/pkg/lib/workspace/default/.core/reference/config.go b/pkg/lib/workspace/default/.core/reference/config.go index 395a0f6..2f45f13 100644 --- a/pkg/lib/workspace/default/.core/reference/config.go +++ b/pkg/lib/workspace/default/.core/reference/config.go @@ -14,15 +14,34 @@ type ConfigVar[T any] struct { set bool } -func (v *ConfigVar[T]) Get() T { return v.val } -func (v *ConfigVar[T]) Set(val T) { v.val = val; v.set = true } +// Get returns the current value. +// +// val := v.Get() +func (v *ConfigVar[T]) Get() T { return v.val } + +// Set sets the value and marks it as explicitly set. +// +// v.Set(true) +func (v *ConfigVar[T]) Set(val T) { v.val = val; v.set = true } + +// IsSet returns true if the value was explicitly set (distinguishes "set to false" from "never set"). +// +// if v.IsSet() { /* explicitly configured */ } func (v *ConfigVar[T]) IsSet() bool { return v.set } + +// Unset resets to zero value and marks as not set. +// +// v.Unset() +// v.IsSet() // false func (v *ConfigVar[T]) Unset() { v.set = false var zero T v.val = zero } +// NewConfigVar creates a ConfigVar with an initial value marked as set. +// +// debug := core.NewConfigVar(true) func NewConfigVar[T any](val T) ConfigVar[T] { return ConfigVar[T]{val: val, set: true} } @@ -48,6 +67,15 @@ type Config struct { mu sync.RWMutex } +// New initialises a Config with empty settings and features. +// +// cfg := (&core.Config{}).New() +func (e *Config) New() *Config { + e.ConfigOptions = &ConfigOptions{} + e.ConfigOptions.init() + return e +} + // Set stores a configuration value by key. func (e *Config) Set(key string, val any) { e.mu.Lock() @@ -73,9 +101,20 @@ func (e *Config) Get(key string) Result { return Result{val, true} } +// String retrieves a string config value (empty string if missing). +// +// host := c.Config().String("database.host") func (e *Config) String(key string) string { return ConfigGet[string](e, key) } -func (e *Config) Int(key string) int { return ConfigGet[int](e, key) } -func (e *Config) Bool(key string) bool { return ConfigGet[bool](e, key) } + +// Int retrieves an int config value (0 if missing). +// +// port := c.Config().Int("database.port") +func (e *Config) Int(key string) int { return ConfigGet[int](e, key) } + +// Bool retrieves a bool config value (false if missing). +// +// debug := c.Config().Bool("debug") +func (e *Config) Bool(key string) bool { return ConfigGet[bool](e, key) } // ConfigGet retrieves a typed configuration value. func ConfigGet[T any](e *Config, key string) T { @@ -90,6 +129,9 @@ func ConfigGet[T any](e *Config, key string) T { // --- Feature Flags --- +// Enable activates a feature flag. +// +// c.Config().Enable("dark-mode") func (e *Config) Enable(feature string) { e.mu.Lock() if e.ConfigOptions == nil { @@ -100,6 +142,9 @@ func (e *Config) Enable(feature string) { e.mu.Unlock() } +// Disable deactivates a feature flag. +// +// c.Config().Disable("dark-mode") func (e *Config) Disable(feature string) { e.mu.Lock() if e.ConfigOptions == nil { @@ -110,6 +155,9 @@ func (e *Config) Disable(feature string) { e.mu.Unlock() } +// Enabled returns true if a feature flag is active. +// +// if c.Config().Enabled("dark-mode") { ... } func (e *Config) Enabled(feature string) bool { e.mu.RLock() defer e.mu.RUnlock() @@ -119,6 +167,9 @@ func (e *Config) Enabled(feature string) bool { return e.Features[feature] } +// EnabledFeatures returns all active feature flag names. +// +// features := c.Config().EnabledFeatures() func (e *Config) EnabledFeatures() []string { e.mu.RLock() defer e.mu.RUnlock() diff --git a/pkg/lib/workspace/default/.core/reference/contract.go b/pkg/lib/workspace/default/.core/reference/contract.go index ddf0def..8718a90 100644 --- a/pkg/lib/workspace/default/.core/reference/contract.go +++ b/pkg/lib/workspace/default/.core/reference/contract.go @@ -6,6 +6,8 @@ package core import ( "context" + "reflect" + "sync" ) // Message is the type for IPC broadcasts (fire-and-forget). @@ -14,30 +16,25 @@ type Message any // Query is the type for read-only IPC requests. type Query any -// Task is the type for IPC requests that perform side effects. -type Task any - -// TaskWithIdentifier is an optional interface for tasks that need to know their assigned identifier. -type TaskWithIdentifier interface { - Task - SetTaskIdentifier(id string) - GetTaskIdentifier() string -} - // QueryHandler handles Query requests. Returns Result{Value, OK}. type QueryHandler func(*Core, Query) Result -// TaskHandler handles Task requests. Returns Result{Value, OK}. -type TaskHandler func(*Core, Task) Result - // Startable is implemented by services that need startup initialisation. +// +// func (s *MyService) OnStartup(ctx context.Context) core.Result { +// return core.Result{OK: true} +// } type Startable interface { - OnStartup(ctx context.Context) error + OnStartup(ctx context.Context) Result } // Stoppable is implemented by services that need shutdown cleanup. +// +// func (s *MyService) OnShutdown(ctx context.Context) core.Result { +// return core.Result{OK: true} +// } type Stoppable interface { - OnShutdown(ctx context.Context) error + OnShutdown(ctx context.Context) Result } // --- Action Messages --- @@ -47,59 +44,183 @@ type ActionServiceShutdown struct{} type ActionTaskStarted struct { TaskIdentifier string - Task Task + Action string + Options Options } type ActionTaskProgress struct { TaskIdentifier string - Task Task + Action string Progress float64 Message string } type ActionTaskCompleted struct { TaskIdentifier string - Task Task - Result any - Error error + Action string + Result Result } // --- Constructor --- -// New creates a Core instance. +// CoreOption is a functional option applied during Core construction. +// Returns Result — if !OK, New() stops and returns the error. // -// c := core.New(core.Options{ -// {Key: "name", Value: "myapp"}, -// }) -func New(opts ...Options) *Core { +// core.New( +// core.WithService(agentic.Register), +// core.WithService(monitor.Register), +// core.WithServiceLock(), +// ) +type CoreOption func(*Core) Result + +// New initialises a Core instance by applying options in order. +// Services registered here form the application conclave — they share +// IPC access and participate in the lifecycle (ServiceStartup/ServiceShutdown). +// +// c := core.New( +// core.WithOption("name", "myapp"), +// core.WithService(auth.Register), +// core.WithServiceLock(), +// ) +// c.Run() +func New(opts ...CoreOption) *Core { c := &Core{ app: &App{}, - data: &Data{}, - drive: &Drive{}, - fs: &Fs{root: "/"}, - config: &Config{ConfigOptions: &ConfigOptions{}}, + data: &Data{Registry: NewRegistry[*Embed]()}, + drive: &Drive{Registry: NewRegistry[*DriveHandle]()}, + fs: (&Fs{}).New("/"), + config: (&Config{}).New(), error: &ErrorPanic{}, - log: &ErrorLog{log: Default()}, - lock: &Lock{}, - ipc: &Ipc{}, + log: &ErrorLog{}, + lock: &Lock{locks: NewRegistry[*sync.RWMutex]()}, + ipc: &Ipc{actions: NewRegistry[*Action](), tasks: NewRegistry[*Task]()}, + info: systemInfo, i18n: &I18n{}, - services: &serviceRegistry{services: make(map[string]*Service)}, - commands: &commandRegistry{commands: make(map[string]*Command)}, + api: &API{protocols: NewRegistry[StreamFactory]()}, + services: &ServiceRegistry{Registry: NewRegistry[*Service]()}, + commands: &CommandRegistry{Registry: NewRegistry[*Command]()}, + entitlementChecker: defaultChecker, } c.context, c.cancel = context.WithCancel(context.Background()) + c.api.core = c - if len(opts) > 0 { - cp := make(Options, len(opts[0])) - copy(cp, opts[0]) - c.options = &cp - name := cp.String("name") - if name != "" { - c.app.Name = name + // Core services + CliRegister(c) + + for _, opt := range opts { + if r := opt(c); !r.OK { + Error("core.New failed", "err", r.Value) + break } } - // Init Cli surface with Core reference - c.cli = &Cli{core: c} + // Apply service lock after all opts — v0.3.3 parity + c.LockApply() return c } + +// WithOptions applies key-value configuration to Core. +// +// core.WithOptions(core.NewOptions(core.Option{Key: "name", Value: "myapp"})) +func WithOptions(opts Options) CoreOption { + return func(c *Core) Result { + c.options = &opts + if name := opts.String("name"); name != "" { + c.app.Name = name + } + return Result{OK: true} + } +} + +// WithService registers a service via its factory function. +// If the factory returns a non-nil Value, WithService auto-discovers the +// service name from the factory's package path (last path segment, lowercase, +// with any "_test" suffix stripped) and calls RegisterService on the instance. +// IPC handler auto-registration is handled by RegisterService. +// +// If the factory returns nil Value (it registered itself), WithService +// returns success without a second registration. +// +// core.WithService(agentic.Register) +// core.WithService(display.Register(nil)) +func WithService(factory func(*Core) Result) CoreOption { + return func(c *Core) Result { + r := factory(c) + if !r.OK { + return r + } + if r.Value == nil { + // Factory self-registered — nothing more to do. + return Result{OK: true} + } + // Auto-discover the service name from the instance's package path. + instance := r.Value + typeOf := reflect.TypeOf(instance) + if typeOf.Kind() == reflect.Ptr { + typeOf = typeOf.Elem() + } + pkgPath := typeOf.PkgPath() + parts := Split(pkgPath, "/") + name := Lower(parts[len(parts)-1]) + if name == "" { + return Result{E("core.WithService", Sprintf("service name could not be discovered for type %T", instance), nil), false} + } + + // RegisterService handles Startable/Stoppable/HandleIPCEvents discovery + return c.RegisterService(name, instance) + } +} + +// WithName registers a service with an explicit name (no reflect discovery). +// +// core.WithName("ws", func(c *Core) Result { +// return Result{Value: hub, OK: true} +// }) +func WithName(name string, factory func(*Core) Result) CoreOption { + return func(c *Core) Result { + r := factory(c) + if !r.OK { + return r + } + if r.Value == nil { + return Result{E("core.WithName", Sprintf("failed to create service %q", name), nil), false} + } + return c.RegisterService(name, r.Value) + } +} + +// WithOption is a convenience for setting a single key-value option. +// +// core.New( +// core.WithOption("name", "myapp"), +// core.WithOption("port", 8080), +// ) +func WithOption(key string, value any) CoreOption { + return func(c *Core) Result { + if c.options == nil { + opts := NewOptions() + c.options = &opts + } + c.options.Set(key, value) + if key == "name" { + if s, ok := value.(string); ok { + c.app.Name = s + } + } + return Result{OK: true} + } +} + +// WithServiceLock prevents further service registration after construction. +// +// core.New( +// core.WithService(auth.Register), +// core.WithServiceLock(), +// ) +func WithServiceLock() CoreOption { + return func(c *Core) Result { + c.LockEnable() + return Result{OK: true} + } +} diff --git a/pkg/lib/workspace/default/.core/reference/core.go b/pkg/lib/workspace/default/.core/reference/core.go index b02fc93..21f13c1 100644 --- a/pkg/lib/workspace/default/.core/reference/core.go +++ b/pkg/lib/workspace/default/.core/reference/core.go @@ -7,6 +7,7 @@ package core import ( "context" + "os" "sync" "sync/atomic" ) @@ -15,21 +16,26 @@ import ( // Core is the central application object that manages services, assets, and communication. type Core struct { - options *Options // c.Options() — Input configuration used to create this Core - app *App // c.App() — Application identity + optional GUI runtime - data *Data // c.Data() — Embedded/stored content from packages - drive *Drive // c.Drive() — Resource handle registry (transports) - fs *Fs // c.Fs() — Local filesystem I/O (sandboxable) - config *Config // c.Config() — Configuration, settings, feature flags - error *ErrorPanic // c.Error() — Panic recovery and crash reporting - log *ErrorLog // c.Log() — Structured logging + error wrapping - cli *Cli // c.Cli() — CLI surface layer - commands *commandRegistry // c.Command("path") — Command tree - services *serviceRegistry // c.Service("name") — Service registry + options *Options // c.Options() — Input configuration used to create this Core + app *App // c.App() — Application identity + optional GUI runtime + data *Data // c.Data() — Embedded/stored content from packages + drive *Drive // c.Drive() — Resource handle registry (transports) + fs *Fs // c.Fs() — Local filesystem I/O (sandboxable) + config *Config // c.Config() — Configuration, settings, feature flags + error *ErrorPanic // c.Error() — Panic recovery and crash reporting + log *ErrorLog // c.Log() — Structured logging + error wrapping + // cli accessed via ServiceFor[*Cli](c, "cli") + commands *CommandRegistry // c.Command("path") — Command tree + services *ServiceRegistry // c.Service("name") — Service registry lock *Lock // c.Lock("name") — Named mutexes ipc *Ipc // c.IPC() — Message bus for IPC + api *API // c.API() — Remote streams + info *SysInfo // c.Env("key") — Read-only system/environment information i18n *I18n // c.I18n() — Internationalisation and locale collection + entitlementChecker EntitlementChecker // default: everything permitted + usageRecorder UsageRecorder // default: nil (no-op) + context context.Context cancel context.CancelFunc taskIDCounter atomic.Uint64 @@ -39,27 +45,146 @@ type Core struct { // --- Accessors --- -func (c *Core) Options() *Options { return c.options } -func (c *Core) App() *App { return c.app } -func (c *Core) Data() *Data { return c.data } -func (c *Core) Drive() *Drive { return c.drive } -func (c *Core) Embed() Result { return c.data.Get("app") } // legacy — use Data() -func (c *Core) Fs() *Fs { return c.fs } -func (c *Core) Config() *Config { return c.config } -func (c *Core) Error() *ErrorPanic { return c.error } -func (c *Core) Log() *ErrorLog { return c.log } -func (c *Core) Cli() *Cli { return c.cli } -func (c *Core) IPC() *Ipc { return c.ipc } -func (c *Core) I18n() *I18n { return c.i18n } +// Options returns the input configuration passed to core.New(). +// +// opts := c.Options() +// name := opts.String("name") +func (c *Core) Options() *Options { return c.options } + +// App returns application identity metadata. +// +// c.App().Name // "my-app" +// c.App().Version // "1.0.0" +func (c *Core) App() *App { return c.app } + +// Data returns the embedded asset registry (Registry[*Embed]). +// +// r := c.Data().ReadString("prompts/coding.md") +func (c *Core) Data() *Data { return c.data } + +// Drive returns the transport handle registry (Registry[*DriveHandle]). +// +// r := c.Drive().Get("forge") +func (c *Core) Drive() *Drive { return c.drive } + +// Fs returns the sandboxed filesystem. +// +// r := c.Fs().Read("/path/to/file") +// c.Fs().WriteAtomic("/status.json", data) +func (c *Core) Fs() *Fs { return c.fs } + +// Config returns runtime settings and feature flags. +// +// host := c.Config().String("database.host") +// c.Config().Enable("dark-mode") +func (c *Core) Config() *Config { return c.config } + +// Error returns the panic recovery subsystem. +// +// c.Error().Recover() +func (c *Core) Error() *ErrorPanic { return c.error } + +// Log returns the structured logging subsystem. +// +// c.Log().Info("started", "port", 8080) +func (c *Core) Log() *ErrorLog { return c.log } + +// Cli returns the CLI command framework (registered as service "cli"). +// +// c.Cli().Run("deploy", "to", "homelab") +func (c *Core) Cli() *Cli { + cl, _ := ServiceFor[*Cli](c, "cli") + return cl +} + +// IPC returns the message bus internals. +// +// c.IPC() +func (c *Core) IPC() *Ipc { return c.ipc } + +// I18n returns the internationalisation subsystem. +// +// tr := c.I18n().Translate("cmd.deploy.description") +func (c *Core) I18n() *I18n { return c.i18n } + +// Env returns an environment variable by key (cached at init, falls back to os.Getenv). +// +// home := c.Env("DIR_HOME") +// token := c.Env("FORGE_TOKEN") +func (c *Core) Env(key string) string { return Env(key) } + +// Context returns Core's lifecycle context (cancelled on shutdown). +// +// ctx := c.Context() func (c *Core) Context() context.Context { return c.context } -func (c *Core) Core() *Core { return c } + +// Core returns self — satisfies the ServiceRuntime interface. +// +// c := s.Core() +func (c *Core) Core() *Core { return c } + +// --- Lifecycle --- + +// RunE starts all services, runs the CLI, then shuts down. +// Returns an error instead of calling os.Exit — let main() handle the exit. +// ServiceShutdown is always called via defer, even on startup failure or panic. +// +// if err := c.RunE(); err != nil { +// os.Exit(1) +// } +func (c *Core) RunE() error { + defer c.ServiceShutdown(context.Background()) + + r := c.ServiceStartup(c.context, nil) + if !r.OK { + if err, ok := r.Value.(error); ok { + return err + } + return E("core.Run", "startup failed", nil) + } + + if cli := c.Cli(); cli != nil { + r = cli.Run() + } + + if !r.OK { + if err, ok := r.Value.(error); ok { + return err + } + } + return nil +} + +// Run starts all services, runs the CLI, then shuts down. +// Calls os.Exit(1) on failure. For error handling use RunE(). +// +// c := core.New(core.WithService(myService.Register)) +// c.Run() +func (c *Core) Run() { + if err := c.RunE(); err != nil { + Error(err.Error()) + os.Exit(1) + } +} // --- IPC (uppercase aliases) --- -func (c *Core) ACTION(msg Message) Result { return c.Action(msg) } -func (c *Core) QUERY(q Query) Result { return c.Query(q) } -func (c *Core) QUERYALL(q Query) Result { return c.QueryAll(q) } -func (c *Core) PERFORM(t Task) Result { return c.Perform(t) } +// ACTION broadcasts a message to all registered handlers (fire-and-forget). +// Each handler is wrapped in panic recovery. All handlers fire regardless. +// +// c.ACTION(messages.AgentCompleted{Agent: "codex", Status: "completed"}) +func (c *Core) ACTION(msg Message) Result { return c.broadcast(msg) } + +// QUERY sends a request — first handler to return OK wins. +// +// r := c.QUERY(MyQuery{Name: "brain"}) +func (c *Core) QUERY(q Query) Result { return c.Query(q) } + +// QUERYALL sends a request — collects all OK responses. +// +// r := c.QUERYALL(countQuery{}) +// results := r.Value.([]any) +func (c *Core) QUERYALL(q Query) Result { return c.QueryAll(q) } // --- Error+Log --- @@ -78,4 +203,37 @@ func (c *Core) Must(err error, op, msg string) { c.log.Must(err, op, msg) } +// --- Registry Accessor --- + +// RegistryOf returns a named registry for cross-cutting queries. +// Known registries: "services", "commands", "actions". +// +// c.RegistryOf("services").Names() // all service names +// c.RegistryOf("actions").List("process.*") // process capabilities +// c.RegistryOf("commands").Len() // command count +func (c *Core) RegistryOf(name string) *Registry[any] { + // Bridge typed registries to untyped access for cross-cutting queries. + // Each registry is wrapped in a read-only proxy. + switch name { + case "services": + return registryProxy(c.services.Registry) + case "commands": + return registryProxy(c.commands.Registry) + case "actions": + return registryProxy(c.ipc.actions) + default: + return NewRegistry[any]() // empty registry for unknown names + } +} + +// registryProxy creates a read-only any-typed view of a typed registry. +// Copies current state — not a live view (avoids type parameter leaking). +func registryProxy[T any](src *Registry[T]) *Registry[any] { + proxy := NewRegistry[any]() + src.Each(func(name string, item T) { + proxy.Set(name, item) + }) + return proxy +} + // --- Global Instance --- diff --git a/pkg/lib/workspace/default/.core/reference/data.go b/pkg/lib/workspace/default/.core/reference/data.go index 3fa5d7b..460277c 100644 --- a/pkg/lib/workspace/default/.core/reference/data.go +++ b/pkg/lib/workspace/default/.core/reference/data.go @@ -6,11 +6,11 @@ // // Mount a package's assets: // -// c.Data().New(core.Options{ -// {Key: "name", Value: "brain"}, -// {Key: "source", Value: brainFS}, -// {Key: "path", Value: "prompts"}, -// }) +// c.Data().New(core.NewOptions( +// core.Option{Key: "name", Value: "brain"}, +// core.Option{Key: "source", Value: brainFS}, +// core.Option{Key: "path", Value: "prompts"}, +// )) // // Read from any mounted path: // @@ -25,22 +25,21 @@ package core import ( "io/fs" "path/filepath" - "sync" ) // Data manages mounted embedded filesystems from core packages. +// Embeds Registry[*Embed] for thread-safe named storage. type Data struct { - mounts map[string]*Embed - mu sync.RWMutex + *Registry[*Embed] } // New registers an embedded filesystem under a named prefix. // -// c.Data().New(core.Options{ -// {Key: "name", Value: "brain"}, -// {Key: "source", Value: brainFS}, -// {Key: "path", Value: "prompts"}, -// }) +// c.Data().New(core.NewOptions( +// core.Option{Key: "name", Value: "brain"}, +// core.Option{Key: "source", Value: brainFS}, +// core.Option{Key: "path", Value: "prompts"}, +// )) func (d *Data) New(opts Options) Result { name := opts.String("name") if name == "" { @@ -62,54 +61,27 @@ func (d *Data) New(opts Options) Result { path = "." } - d.mu.Lock() - defer d.mu.Unlock() - - if d.mounts == nil { - d.mounts = make(map[string]*Embed) - } - mr := Mount(fsys, path) if !mr.OK { return mr } emb := mr.Value.(*Embed) - d.mounts[name] = emb - return Result{emb, true} -} - -// Get returns the Embed for a named mount point. -// -// r := c.Data().Get("brain") -// if r.OK { emb := r.Value.(*Embed) } -func (d *Data) Get(name string) Result { - d.mu.RLock() - defer d.mu.RUnlock() - if d.mounts == nil { - return Result{} - } - emb, ok := d.mounts[name] - if !ok { - return Result{} - } + d.Set(name, emb) return Result{emb, true} } // resolve splits a path like "brain/coding.md" into mount name + relative path. func (d *Data) resolve(path string) (*Embed, string) { - d.mu.RLock() - defer d.mu.RUnlock() - parts := SplitN(path, "/", 2) if len(parts) < 2 { return nil, "" } - if d.mounts == nil { + r := d.Get(parts[0]) + if !r.OK { return nil, "" } - emb := d.mounts[parts[0]] - return emb, parts[1] + return r.Value.(*Embed), parts[1] } // ReadFile reads a file by full path. @@ -188,15 +160,9 @@ func (d *Data) Extract(path, targetDir string, templateData any) Result { return Extract(r.Value.(*Embed).FS(), targetDir, templateData) } -// Mounts returns the names of all mounted content. +// Mounts returns the names of all mounted content in registration order. // // names := c.Data().Mounts() func (d *Data) Mounts() []string { - d.mu.RLock() - defer d.mu.RUnlock() - var names []string - for k := range d.mounts { - names = append(names, k) - } - return names + return d.Names() } diff --git a/pkg/lib/workspace/default/.core/reference/drive.go b/pkg/lib/workspace/default/.core/reference/drive.go index e6988c4..7bf6869 100644 --- a/pkg/lib/workspace/default/.core/reference/drive.go +++ b/pkg/lib/workspace/default/.core/reference/drive.go @@ -6,28 +6,24 @@ // // Register a transport: // -// c.Drive().New(core.Options{ -// {Key: "name", Value: "api"}, -// {Key: "transport", Value: "https://api.lthn.ai"}, -// }) -// c.Drive().New(core.Options{ -// {Key: "name", Value: "ssh"}, -// {Key: "transport", Value: "ssh://claude@10.69.69.165"}, -// }) -// c.Drive().New(core.Options{ -// {Key: "name", Value: "mcp"}, -// {Key: "transport", Value: "mcp://mcp.lthn.sh"}, -// }) +// c.Drive().New(core.NewOptions( +// core.Option{Key: "name", Value: "api"}, +// core.Option{Key: "transport", Value: "https://api.lthn.ai"}, +// )) +// c.Drive().New(core.NewOptions( +// core.Option{Key: "name", Value: "ssh"}, +// core.Option{Key: "transport", Value: "ssh://claude@10.69.69.165"}, +// )) +// c.Drive().New(core.NewOptions( +// core.Option{Key: "name", Value: "mcp"}, +// core.Option{Key: "transport", Value: "mcp://mcp.lthn.sh"}, +// )) // // Retrieve a handle: // // api := c.Drive().Get("api") package core -import ( - "sync" -) - // DriveHandle holds a named transport resource. type DriveHandle struct { Name string @@ -35,78 +31,29 @@ type DriveHandle struct { Options Options } -// Drive manages named transport handles. +// Drive manages named transport handles. Embeds Registry[*DriveHandle]. type Drive struct { - handles map[string]*DriveHandle - mu sync.RWMutex + *Registry[*DriveHandle] } // New registers a transport handle. // -// c.Drive().New(core.Options{ -// {Key: "name", Value: "api"}, -// {Key: "transport", Value: "https://api.lthn.ai"}, -// }) +// c.Drive().New(core.NewOptions( +// core.Option{Key: "name", Value: "api"}, +// core.Option{Key: "transport", Value: "https://api.lthn.ai"}, +// )) func (d *Drive) New(opts Options) Result { name := opts.String("name") if name == "" { return Result{} } - transport := opts.String("transport") - - d.mu.Lock() - defer d.mu.Unlock() - - if d.handles == nil { - d.handles = make(map[string]*DriveHandle) - } - - cp := make(Options, len(opts)) - copy(cp, opts) handle := &DriveHandle{ Name: name, - Transport: transport, - Options: cp, + Transport: opts.String("transport"), + Options: opts, } - d.handles[name] = handle + d.Set(name, handle) return Result{handle, true} } - -// Get returns a handle by name. -// -// r := c.Drive().Get("api") -// if r.OK { handle := r.Value.(*DriveHandle) } -func (d *Drive) Get(name string) Result { - d.mu.RLock() - defer d.mu.RUnlock() - if d.handles == nil { - return Result{} - } - h, ok := d.handles[name] - if !ok { - return Result{} - } - return Result{h, true} -} - -// Has returns true if a handle is registered. -// -// if c.Drive().Has("ssh") { ... } -func (d *Drive) Has(name string) bool { - return d.Get(name).OK -} - -// Names returns all registered handle names. -// -// names := c.Drive().Names() -func (d *Drive) Names() []string { - d.mu.RLock() - defer d.mu.RUnlock() - var names []string - for k := range d.handles { - names = append(names, k) - } - return names -} diff --git a/pkg/lib/workspace/default/.core/reference/embed.go b/pkg/lib/workspace/default/.core/reference/embed.go index e6a5766..7951543 100644 --- a/pkg/lib/workspace/default/.core/reference/embed.go +++ b/pkg/lib/workspace/default/.core/reference/embed.go @@ -396,7 +396,7 @@ func (s *Embed) ReadDir(name string) Result { if !r.OK { return r } - return Result{}.Result(fs.ReadDir(s.fsys, r.Value.(string))) + return Result{}.New(fs.ReadDir(s.fsys, r.Value.(string))) } // ReadFile reads the named file. diff --git a/pkg/lib/workspace/default/.core/reference/fs.go b/pkg/lib/workspace/default/.core/reference/fs.go index 8642cdc..a18a3d2 100644 --- a/pkg/lib/workspace/default/.core/reference/fs.go +++ b/pkg/lib/workspace/default/.core/reference/fs.go @@ -2,6 +2,8 @@ package core import ( + "io" + "io/fs" "os" "os/user" "path/filepath" @@ -13,17 +15,53 @@ type Fs struct { root string } +// New initialises an Fs with the given root directory. +// Root "/" means unrestricted access. Empty root defaults to "/". +// +// fs := (&core.Fs{}).New("/") +func (m *Fs) New(root string) *Fs { + if root == "" { + root = "/" + } + m.root = root + return m +} + +// NewUnrestricted returns a new Fs with root "/", granting full filesystem access. +// Use this instead of unsafe.Pointer to bypass the sandbox. +// +// fs := c.Fs().NewUnrestricted() +// fs.Read("/etc/hostname") // works — no sandbox +func (m *Fs) NewUnrestricted() *Fs { + return (&Fs{}).New("/") +} + +// Root returns the sandbox root path. +// +// root := c.Fs().Root() // e.g. "/home/agent/.core" +func (m *Fs) Root() string { + if m.root == "" { + return "/" + } + return m.root +} + // path sanitises and returns the full path. // Absolute paths are sandboxed under root (unless root is "/"). +// Empty root defaults to "/" — the zero value of Fs is usable. func (m *Fs) path(p string) string { + root := m.root + if root == "" { + root = "/" + } if p == "" { - return m.root + return root } // If the path is relative and the medium is rooted at "/", // treat it as relative to the current working directory. // This makes io.Local behave more like the standard 'os' package. - if m.root == "/" && !filepath.IsAbs(p) { + if root == "/" && !filepath.IsAbs(p) { cwd, _ := os.Getwd() return filepath.Join(cwd, p) } @@ -33,23 +71,27 @@ func (m *Fs) path(p string) string { clean := filepath.Clean("/" + p) // If root is "/", allow absolute paths through - if m.root == "/" { + if root == "/" { return clean } // Strip leading "/" so Join works correctly with root - return filepath.Join(m.root, clean[1:]) + return filepath.Join(root, clean[1:]) } // validatePath ensures the path is within the sandbox, following symlinks if they exist. func (m *Fs) validatePath(p string) Result { - if m.root == "/" { + root := m.root + if root == "" { + root = "/" + } + if root == "/" { return Result{m.path(p), true} } // Split the cleaned path into components parts := Split(filepath.Clean("/"+p), string(os.PathSeparator)) - current := m.root + current := root for _, part := range parts { if part == "" { @@ -70,7 +112,7 @@ func (m *Fs) validatePath(p string) Result { } // Verify the resolved part is still within the root - rel, err := filepath.Rel(m.root, realNext) + rel, err := filepath.Rel(root, realNext) if err != nil || HasPrefix(rel, "..") { // Security event: sandbox escape attempt username := "unknown" @@ -78,7 +120,7 @@ func (m *Fs) validatePath(p string) Result { username = u.Username } Print(os.Stderr, "[%s] SECURITY sandbox escape detected root=%s path=%s attempted=%s user=%s", - time.Now().Format(time.RFC3339), m.root, p, realNext, username) + time.Now().Format(time.RFC3339), root, p, realNext, username) if err == nil { err = E("fs.validatePath", Concat("sandbox escape: ", p, " resolves outside ", m.root), nil) } @@ -127,6 +169,52 @@ func (m *Fs) WriteMode(p, content string, mode os.FileMode) Result { return Result{OK: true} } +// TempDir creates a temporary directory and returns its path. +// The caller is responsible for cleanup via fs.DeleteAll(). +// +// dir := fs.TempDir("agent-workspace") +// defer fs.DeleteAll(dir) +func (m *Fs) TempDir(prefix string) string { + dir, err := os.MkdirTemp("", prefix) + if err != nil { + return "" + } + return dir +} + +// DirFS returns an fs.FS rooted at the given directory path. +// +// fsys := core.DirFS("/path/to/templates") +func DirFS(dir string) fs.FS { + return os.DirFS(dir) +} + +// WriteAtomic writes content by writing to a temp file then renaming. +// Rename is atomic on POSIX — concurrent readers never see a partial file. +// Use this for status files, config, or any file read from multiple goroutines. +// +// r := fs.WriteAtomic("/status.json", jsonData) +func (m *Fs) WriteAtomic(p, content string) Result { + vp := m.validatePath(p) + if !vp.OK { + return vp + } + full := vp.Value.(string) + if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { + return Result{err, false} + } + + tmp := full + ".tmp." + shortRand() + if err := os.WriteFile(tmp, []byte(content), 0644); err != nil { + return Result{err, false} + } + if err := os.Rename(tmp, full); err != nil { + os.Remove(tmp) + return Result{err, false} + } + return Result{OK: true} +} + // EnsureDir creates directory if it doesn't exist. func (m *Fs) EnsureDir(p string) Result { vp := m.validatePath(p) @@ -181,7 +269,7 @@ func (m *Fs) List(p string) Result { if !vp.OK { return vp } - return Result{}.Result(os.ReadDir(vp.Value.(string))) + return Result{}.New(os.ReadDir(vp.Value.(string))) } // Stat returns file info. @@ -190,7 +278,7 @@ func (m *Fs) Stat(p string) Result { if !vp.OK { return vp } - return Result{}.Result(os.Stat(vp.Value.(string))) + return Result{}.New(os.Stat(vp.Value.(string))) } // Open opens the named file for reading. @@ -199,7 +287,7 @@ func (m *Fs) Open(p string) Result { if !vp.OK { return vp } - return Result{}.Result(os.Open(vp.Value.(string))) + return Result{}.New(os.Open(vp.Value.(string))) } // Create creates or truncates the named file. @@ -212,7 +300,7 @@ func (m *Fs) Create(p string) Result { if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { return Result{err, false} } - return Result{}.Result(os.Create(full)) + return Result{}.New(os.Create(full)) } // Append opens the named file for appending, creating it if it doesn't exist. @@ -225,7 +313,7 @@ func (m *Fs) Append(p string) Result { if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { return Result{err, false} } - return Result{}.Result(os.OpenFile(full, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)) + return Result{}.New(os.OpenFile(full, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)) } // ReadStream returns a reader for the file content. @@ -238,6 +326,54 @@ func (m *Fs) WriteStream(path string) Result { return m.Create(path) } +// ReadAll reads all bytes from a ReadCloser and closes it. +// Wraps io.ReadAll so consumers don't import "io". +// +// r := fs.ReadStream(path) +// data := core.ReadAll(r.Value) +func ReadAll(reader any) Result { + rc, ok := reader.(io.Reader) + if !ok { + return Result{E("core.ReadAll", "not a reader", nil), false} + } + data, err := io.ReadAll(rc) + if closer, ok := reader.(io.Closer); ok { + closer.Close() + } + if err != nil { + return Result{err, false} + } + return Result{string(data), true} +} + +// WriteAll writes content to a writer and closes it if it implements Closer. +// +// r := fs.WriteStream(path) +// core.WriteAll(r.Value, "content") +func WriteAll(writer any, content string) Result { + wc, ok := writer.(io.Writer) + if !ok { + return Result{E("core.WriteAll", "not a writer", nil), false} + } + _, err := wc.Write([]byte(content)) + if closer, ok := writer.(io.Closer); ok { + closer.Close() + } + if err != nil { + return Result{err, false} + } + return Result{OK: true} +} + +// CloseStream closes any value that implements io.Closer. +// +// core.CloseStream(r.Value) +func CloseStream(v any) { + if closer, ok := v.(io.Closer); ok { + closer.Close() + } +} + // Delete removes a file or empty directory. func (m *Fs) Delete(p string) Result { vp := m.validatePath(p) diff --git a/pkg/lib/workspace/default/.core/reference/ipc.go b/pkg/lib/workspace/default/.core/reference/ipc.go index 5f22c6f..bedbd65 100644 --- a/pkg/lib/workspace/default/.core/reference/ipc.go +++ b/pkg/lib/workspace/default/.core/reference/ipc.go @@ -11,7 +11,9 @@ import ( "sync" ) -// Ipc holds IPC dispatch data. +// Ipc holds IPC dispatch data and the named action registry. +// +// ipc := (&core.Ipc{}).New() type Ipc struct { ipcMu sync.RWMutex ipcHandlers []func(*Core, Message) Result @@ -19,23 +21,33 @@ type Ipc struct { queryMu sync.RWMutex queryHandlers []QueryHandler - taskMu sync.RWMutex - taskHandlers []TaskHandler + actions *Registry[*Action] // named action registry + tasks *Registry[*Task] // named task registry } -func (c *Core) Action(msg Message) Result { +// broadcast dispatches a message to all registered IPC handlers. +// Each handler is wrapped in panic recovery. All handlers fire regardless of individual results. +func (c *Core) broadcast(msg Message) Result { c.ipc.ipcMu.RLock() handlers := slices.Clone(c.ipc.ipcHandlers) c.ipc.ipcMu.RUnlock() for _, h := range handlers { - if r := h(c, msg); !r.OK { - return r - } + func() { + defer func() { + if r := recover(); r != nil { + Error("ACTION handler panicked", "panic", r) + } + }() + h(c, msg) + }() } return Result{OK: true} } +// Query dispatches a request — first handler to return OK wins. +// +// r := c.Query(MyQuery{}) func (c *Core) Query(q Query) Result { c.ipc.queryMu.RLock() handlers := slices.Clone(c.ipc.queryHandlers) @@ -50,6 +62,10 @@ func (c *Core) Query(q Query) Result { return Result{} } +// QueryAll dispatches a request — collects all OK responses. +// +// r := c.QueryAll(countQuery{}) +// results := r.Value.([]any) func (c *Core) QueryAll(q Query) Result { c.ipc.queryMu.RLock() handlers := slices.Clone(c.ipc.queryHandlers) @@ -65,8 +81,33 @@ func (c *Core) QueryAll(q Query) Result { return Result{results, true} } +// RegisterQuery registers a handler for QUERY dispatch. +// +// c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { ... }) func (c *Core) RegisterQuery(handler QueryHandler) { c.ipc.queryMu.Lock() c.ipc.queryHandlers = append(c.ipc.queryHandlers, handler) c.ipc.queryMu.Unlock() } + +// --- IPC Registration (handlers) --- + +// RegisterAction registers a broadcast handler for ACTION messages. +// +// c.RegisterAction(func(c *core.Core, msg core.Message) core.Result { +// if ev, ok := msg.(AgentCompleted); ok { ... } +// return core.Result{OK: true} +// }) +func (c *Core) RegisterAction(handler func(*Core, Message) Result) { + c.ipc.ipcMu.Lock() + c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handler) + c.ipc.ipcMu.Unlock() +} + +// RegisterActions registers multiple broadcast handlers. +func (c *Core) RegisterActions(handlers ...func(*Core, Message) Result) { + c.ipc.ipcMu.Lock() + c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handlers...) + c.ipc.ipcMu.Unlock() +} + diff --git a/pkg/lib/workspace/default/.core/reference/lock.go b/pkg/lib/workspace/default/.core/reference/lock.go index a87181d..a963278 100644 --- a/pkg/lib/workspace/default/.core/reference/lock.go +++ b/pkg/lib/workspace/default/.core/reference/lock.go @@ -8,82 +8,61 @@ import ( "sync" ) -// package-level mutex infrastructure -var ( - lockMu sync.Mutex - lockMap = make(map[string]*sync.RWMutex) -) - // Lock is the DTO for a named mutex. type Lock struct { Name string Mutex *sync.RWMutex + locks *Registry[*sync.RWMutex] // per-Core named mutexes } // Lock returns a named Lock, creating the mutex if needed. +// Locks are per-Core — separate Core instances do not share mutexes. func (c *Core) Lock(name string) *Lock { - lockMu.Lock() - m, ok := lockMap[name] - if !ok { - m = &sync.RWMutex{} - lockMap[name] = m + r := c.lock.locks.Get(name) + if r.OK { + return &Lock{Name: name, Mutex: r.Value.(*sync.RWMutex)} } - lockMu.Unlock() + m := &sync.RWMutex{} + c.lock.locks.Set(name, m) return &Lock{Name: name, Mutex: m} } // LockEnable marks that the service lock should be applied after initialisation. func (c *Core) LockEnable(name ...string) { - n := "srv" - if len(name) > 0 { - n = name[0] - } - c.Lock(n).Mutex.Lock() - defer c.Lock(n).Mutex.Unlock() c.services.lockEnabled = true } // LockApply activates the service lock if it was enabled. func (c *Core) LockApply(name ...string) { - n := "srv" - if len(name) > 0 { - n = name[0] - } - c.Lock(n).Mutex.Lock() - defer c.Lock(n).Mutex.Unlock() if c.services.lockEnabled { - c.services.locked = true + c.services.Lock() } } -// Startables returns services that have an OnStart function. +// Startables returns services that have an OnStart function, in registration order. func (c *Core) Startables() Result { if c.services == nil { return Result{} } - c.Lock("srv").Mutex.RLock() - defer c.Lock("srv").Mutex.RUnlock() var out []*Service - for _, svc := range c.services.services { + c.services.Each(func(_ string, svc *Service) { if svc.OnStart != nil { out = append(out, svc) } - } + }) return Result{out, true} } -// Stoppables returns services that have an OnStop function. +// Stoppables returns services that have an OnStop function, in registration order. func (c *Core) Stoppables() Result { if c.services == nil { return Result{} } - c.Lock("srv").Mutex.RLock() - defer c.Lock("srv").Mutex.RUnlock() var out []*Service - for _, svc := range c.services.services { + c.services.Each(func(_ string, svc *Service) { if svc.OnStop != nil { out = append(out, svc) } - } + }) return Result{out, true} } diff --git a/pkg/lib/workspace/default/.core/reference/options.go b/pkg/lib/workspace/default/.core/reference/options.go index 4d4c5f8..3721232 100644 --- a/pkg/lib/workspace/default/.core/reference/options.go +++ b/pkg/lib/workspace/default/.core/reference/options.go @@ -2,42 +2,24 @@ // Core primitives: Option, Options, Result. // -// Option is a single key-value pair. Options is a collection. -// Any function that returns Result can accept Options. +// Options is the universal input type. Result is the universal output type. +// All Core operations accept Options and return Result. // -// Create options: -// -// opts := core.Options{ -// {Key: "name", Value: "brain"}, -// {Key: "path", Value: "prompts"}, -// } -// -// Read options: -// -// name := opts.String("name") -// port := opts.Int("port") -// ok := opts.Has("debug") -// -// Use with subsystems: -// -// c.Drive().New(core.Options{ -// {Key: "name", Value: "brain"}, -// {Key: "source", Value: brainFS}, -// {Key: "path", Value: "prompts"}, -// }) -// -// Use with New: -// -// c := core.New(core.Options{ -// {Key: "name", Value: "myapp"}, -// }) +// opts := core.NewOptions( +// core.Option{Key: "name", Value: "brain"}, +// core.Option{Key: "path", Value: "prompts"}, +// ) +// r := c.Drive().New(opts) +// if !r.OK { log.Fatal(r.Error()) } package core +// --- Result: Universal Output --- + // Result is the universal return type for Core operations. // Replaces the (value, error) pattern — errors flow through Core internally. // -// r := c.Data().New(core.Options{{Key: "name", Value: "brain"}}) -// if r.OK { use(r.Result()) } +// r := c.Data().New(opts) +// if !r.OK { core.Error("failed", "err", r.Error()) } type Result struct { Value any OK bool @@ -53,18 +35,49 @@ func (r Result) Result(args ...any) Result { if len(args) == 0 { return r } + return r.New(args...) +} - if len(args) == 1 { - return Result{args[0], true} +// New adapts Go (value, error) pairs into a Result. +// +// r := core.Result{}.New(file, err) +func (r Result) New(args ...any) Result { + if len(args) == 0 { + return r } - if err, ok := args[len(args)-1].(error); ok { - if err != nil { - return Result{err, false} + if len(args) > 1 { + if err, ok := args[len(args)-1].(error); ok { + if err != nil { + return Result{Value: err, OK: false} + } + r.Value = args[0] + r.OK = true + return r } - return Result{args[0], true} } - return Result{args[0], true} + + r.Value = args[0] + + if err, ok := r.Value.(error); ok { + if err != nil { + return Result{Value: err, OK: false} + } + return Result{OK: true} + } + + r.OK = true + return r +} + +// Get returns the Result if OK, empty Result otherwise. +// +// r := core.Result{Value: "hello", OK: true}.Get() +func (r Result) Get() Result { + if r.OK { + return r + } + return Result{Value: r.Value, OK: false} } // Option is a single key-value configuration pair. @@ -76,19 +89,51 @@ type Option struct { Value any } -// Options is a collection of Option items. -// The universal input type for Core operations. +// --- Options: Universal Input --- + +// Options is the universal input type for Core operations. +// A structured collection of key-value pairs with typed accessors. // -// opts := core.Options{{Key: "name", Value: "myapp"}} +// opts := core.NewOptions( +// core.Option{Key: "name", Value: "myapp"}, +// core.Option{Key: "port", Value: 8080}, +// ) // name := opts.String("name") -type Options []Option +type Options struct { + items []Option +} + +// NewOptions creates an Options collection from key-value pairs. +// +// opts := core.NewOptions( +// core.Option{Key: "name", Value: "brain"}, +// core.Option{Key: "path", Value: "prompts"}, +// ) +func NewOptions(items ...Option) Options { + cp := make([]Option, len(items)) + copy(cp, items) + return Options{items: cp} +} + +// Set adds or updates a key-value pair. +// +// opts.Set("port", 8080) +func (o *Options) Set(key string, value any) { + for i, opt := range o.items { + if opt.Key == key { + o.items[i].Value = value + return + } + } + o.items = append(o.items, Option{Key: key, Value: value}) +} // Get retrieves a value by key. // // r := opts.Get("name") // if r.OK { name := r.Value.(string) } func (o Options) Get(key string) Result { - for _, opt := range o { + for _, opt := range o.items { if opt.Key == key { return Result{opt.Value, true} } @@ -138,3 +183,15 @@ func (o Options) Bool(key string) bool { b, _ := r.Value.(bool) return b } + +// Len returns the number of options. +func (o Options) Len() int { + return len(o.items) +} + +// Items returns a copy of the underlying option slice. +func (o Options) Items() []Option { + cp := make([]Option, len(o.items)) + copy(cp, o.items) + return cp +} diff --git a/pkg/lib/workspace/default/.core/reference/runtime.go b/pkg/lib/workspace/default/.core/reference/runtime.go index 952001d..c9a8223 100644 --- a/pkg/lib/workspace/default/.core/reference/runtime.go +++ b/pkg/lib/workspace/default/.core/reference/runtime.go @@ -25,8 +25,19 @@ func NewServiceRuntime[T any](c *Core, opts T) *ServiceRuntime[T] { return &ServiceRuntime[T]{core: c, opts: opts} } -func (r *ServiceRuntime[T]) Core() *Core { return r.core } -func (r *ServiceRuntime[T]) Options() T { return r.opts } +// Core returns the Core instance this service is registered with. +// +// c := s.Core() +func (r *ServiceRuntime[T]) Core() *Core { return r.core } + +// Options returns the typed options this service was created with. +// +// opts := s.Options() // MyOptions{BufferSize: 1024, ...} +func (r *ServiceRuntime[T]) Options() T { return r.opts } + +// Config is a shortcut to s.Core().Config(). +// +// host := s.Config().String("database.host") func (r *ServiceRuntime[T]) Config() *Config { return r.core.Config() } // --- Lifecycle --- @@ -106,7 +117,7 @@ type ServiceFactory func() Result // NewWithFactories creates a Runtime with the provided service factories. func NewWithFactories(app any, factories map[string]ServiceFactory) Result { - c := New(Options{{Key: "name", Value: "core"}}) + c := New(WithOptions(NewOptions(Option{Key: "name", Value: "core"}))) c.app.Runtime = app names := slices.Sorted(maps.Keys(factories)) @@ -137,10 +148,14 @@ func NewRuntime(app any) Result { return NewWithFactories(app, map[string]ServiceFactory{}) } +// ServiceName returns "Core" — the Runtime's service identity. func (r *Runtime) ServiceName() string { return "Core" } + +// ServiceStartup starts all services via the embedded Core. func (r *Runtime) ServiceStartup(ctx context.Context, options any) Result { return r.Core.ServiceStartup(ctx, options) } +// ServiceShutdown stops all services via the embedded Core. func (r *Runtime) ServiceShutdown(ctx context.Context) Result { if r.Core != nil { return r.Core.ServiceShutdown(ctx) diff --git a/pkg/lib/workspace/default/.core/reference/service.go b/pkg/lib/workspace/default/.core/reference/service.go index 1e82dd6..46738ad 100644 --- a/pkg/lib/workspace/default/.core/reference/service.go +++ b/pkg/lib/workspace/default/.core/reference/service.go @@ -2,9 +2,13 @@ // Service registry for the Core framework. // -// Register a service: +// Register a service (DTO with lifecycle hooks): // -// c.Service("auth", core.Service{}) +// c.Service("auth", core.Service{OnStart: startFn}) +// +// Register a service instance (auto-discovers Startable/Stoppable/HandleIPCEvents): +// +// c.RegisterService("display", displayInstance) // // Get a service: // @@ -13,22 +17,23 @@ package core -// No imports needed — uses package-level string helpers. +import "context" // Service is a managed component with optional lifecycle. type Service struct { Name string + Instance any // the raw service instance (for interface discovery) Options Options OnStart func() Result OnStop func() Result OnReload func() Result } -// serviceRegistry holds registered services. -type serviceRegistry struct { - services map[string]*Service +// ServiceRegistry holds registered services. Embeds Registry[*Service] +// for thread-safe named storage with insertion order. +type ServiceRegistry struct { + *Registry[*Service] lockEnabled bool - locked bool } // --- Core service methods --- @@ -39,45 +44,110 @@ type serviceRegistry struct { // r := c.Service("auth") func (c *Core) Service(name string, service ...Service) Result { if len(service) == 0 { - c.Lock("srv").Mutex.RLock() - v, ok := c.services.services[name] - c.Lock("srv").Mutex.RUnlock() - return Result{v, ok} + r := c.services.Get(name) + if !r.OK { + return Result{} + } + svc := r.Value.(*Service) + // Return the instance if available, otherwise the Service DTO + if svc.Instance != nil { + return Result{svc.Instance, true} + } + return Result{svc, true} } if name == "" { return Result{E("core.Service", "service name cannot be empty", nil), false} } - c.Lock("srv").Mutex.Lock() - defer c.Lock("srv").Mutex.Unlock() - - if c.services.locked { + if c.services.Locked() { return Result{E("core.Service", Concat("service \"", name, "\" not permitted — registry locked"), nil), false} } - if _, exists := c.services.services[name]; exists { + if c.services.Has(name) { return Result{E("core.Service", Join(" ", "service", name, "already registered"), nil), false} } srv := &service[0] srv.Name = name - c.services.services[name] = srv + return c.services.Set(name, srv) +} + +// RegisterService registers a service instance by name. +// Auto-discovers Startable, Stoppable, and HandleIPCEvents interfaces +// on the instance and wires them into the lifecycle and IPC bus. +// +// c.RegisterService("display", displayInstance) +func (c *Core) RegisterService(name string, instance any) Result { + if name == "" { + return Result{E("core.RegisterService", "service name cannot be empty", nil), false} + } + + if c.services.Locked() { + return Result{E("core.RegisterService", Concat("service \"", name, "\" not permitted — registry locked"), nil), false} + } + if c.services.Has(name) { + return Result{E("core.RegisterService", Join(" ", "service", name, "already registered"), nil), false} + } + + srv := &Service{Name: name, Instance: instance} + + // Auto-discover lifecycle interfaces + if s, ok := instance.(Startable); ok { + srv.OnStart = func() Result { + return s.OnStartup(c.context) + } + } + if s, ok := instance.(Stoppable); ok { + srv.OnStop = func() Result { + return s.OnShutdown(context.Background()) + } + } + + c.services.Set(name, srv) + + // Auto-discover IPC handler + if handler, ok := instance.(interface { + HandleIPCEvents(*Core, Message) Result + }); ok { + c.ipc.ipcMu.Lock() + c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handler.HandleIPCEvents) + c.ipc.ipcMu.Unlock() + } return Result{OK: true} } -// Services returns all registered service names. +// ServiceFor retrieves a registered service by name and asserts its type. +// +// prep, ok := core.ServiceFor[*agentic.PrepSubsystem](c, "agentic") +func ServiceFor[T any](c *Core, name string) (T, bool) { + var zero T + r := c.Service(name) + if !r.OK { + return zero, false + } + typed, ok := r.Value.(T) + return typed, ok +} + +// MustServiceFor retrieves a registered service by name and asserts its type. +// Panics if the service is not found or the type assertion fails. +// +// cli := core.MustServiceFor[*Cli](c, "cli") +func MustServiceFor[T any](c *Core, name string) T { + v, ok := ServiceFor[T](c, name) + if !ok { + panic(E("core.MustServiceFor", Sprintf("service %q not found or wrong type", name), nil)) + } + return v +} + +// Services returns all registered service names in registration order. // // names := c.Services() func (c *Core) Services() []string { if c.services == nil { return nil } - c.Lock("srv").Mutex.RLock() - defer c.Lock("srv").Mutex.RUnlock() - var names []string - for k := range c.services.services { - names = append(names, k) - } - return names + return c.services.Names() } diff --git a/pkg/lib/workspace/default/.core/reference/task.go b/pkg/lib/workspace/default/.core/reference/task.go index acdf394..b761f9d 100644 --- a/pkg/lib/workspace/default/.core/reference/task.go +++ b/pkg/lib/workspace/default/.core/reference/task.go @@ -1,92 +1,61 @@ // SPDX-License-Identifier: EUPL-1.2 -// Background task dispatch for the Core framework. +// Background action dispatch for the Core framework. +// PerformAsync runs a named Action in a background goroutine with +// panic recovery and progress broadcasting. package core -import ( - "reflect" - "slices" - "strconv" -) +import "context" -// TaskState holds background task state. -type TaskState struct { - Identifier string - Task Task - Result any - Error error -} - -// PerformAsync dispatches a task in a background goroutine. -func (c *Core) PerformAsync(t Task) Result { +// PerformAsync dispatches a named action in a background goroutine. +// Broadcasts ActionTaskStarted, ActionTaskProgress, and ActionTaskCompleted +// as IPC messages so other services can track progress. +// +// r := c.PerformAsync("agentic.dispatch", opts) +// taskID := r.Value.(string) +func (c *Core) PerformAsync(action string, opts Options) Result { if c.shutdown.Load() { return Result{} } - taskID := Concat("task-", strconv.FormatUint(c.taskIDCounter.Add(1), 10)) - if tid, ok := t.(TaskWithIdentifier); ok { - tid.SetTaskIdentifier(taskID) - } - c.ACTION(ActionTaskStarted{TaskIdentifier: taskID, Task: t}) + taskID := ID() + + c.ACTION(ActionTaskStarted{TaskIdentifier: taskID, Action: action, Options: opts}) + c.waitGroup.Go(func() { defer func() { if rec := recover(); rec != nil { - err := E("core.PerformAsync", Sprint("panic: ", rec), nil) - c.ACTION(ActionTaskCompleted{TaskIdentifier: taskID, Task: t, Result: nil, Error: err}) + c.ACTION(ActionTaskCompleted{ + TaskIdentifier: taskID, + Action: action, + Result: Result{E("core.PerformAsync", Sprint("panic: ", rec), nil), false}, + }) } }() - r := c.PERFORM(t) - var err error - if !r.OK { - if e, ok := r.Value.(error); ok { - err = e - } else { - taskType := reflect.TypeOf(t) - typeName := "" - if taskType != nil { - typeName = taskType.String() - } - err = E("core.PerformAsync", Join(" ", "no handler found for task type", typeName), nil) - } - } - c.ACTION(ActionTaskCompleted{TaskIdentifier: taskID, Task: t, Result: r.Value, Error: err}) + + r := c.Action(action).Run(context.Background(), opts) + + c.ACTION(ActionTaskCompleted{ + TaskIdentifier: taskID, + Action: action, + Result: r, + }) }) + return Result{taskID, true} } // Progress broadcasts a progress update for a background task. -func (c *Core) Progress(taskID string, progress float64, message string, t Task) { - c.ACTION(ActionTaskProgress{TaskIdentifier: taskID, Task: t, Progress: progress, Message: message}) +// +// c.Progress(taskID, 0.5, "halfway done", "agentic.dispatch") +func (c *Core) Progress(taskID string, progress float64, message string, action string) { + c.ACTION(ActionTaskProgress{ + TaskIdentifier: taskID, + Action: action, + Progress: progress, + Message: message, + }) } -func (c *Core) Perform(t Task) Result { - c.ipc.taskMu.RLock() - handlers := slices.Clone(c.ipc.taskHandlers) - c.ipc.taskMu.RUnlock() - - for _, h := range handlers { - r := h(c, t) - if r.OK { - return r - } - } - return Result{} -} - -func (c *Core) RegisterAction(handler func(*Core, Message) Result) { - c.ipc.ipcMu.Lock() - c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handler) - c.ipc.ipcMu.Unlock() -} - -func (c *Core) RegisterActions(handlers ...func(*Core, Message) Result) { - c.ipc.ipcMu.Lock() - c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handlers...) - c.ipc.ipcMu.Unlock() -} - -func (c *Core) RegisterTask(handler TaskHandler) { - c.ipc.taskMu.Lock() - c.ipc.taskHandlers = append(c.ipc.taskHandlers, handler) - c.ipc.taskMu.Unlock() -} +// Registration methods (RegisterAction, RegisterActions) +// are in ipc.go — registration is IPC's responsibility. diff --git a/pkg/lib/workspace/default/.core/reference/utils.go b/pkg/lib/workspace/default/.core/reference/utils.go index 038e32e..e510b78 100644 --- a/pkg/lib/workspace/default/.core/reference/utils.go +++ b/pkg/lib/workspace/default/.core/reference/utils.go @@ -6,11 +6,75 @@ package core import ( + crand "crypto/rand" + "encoding/hex" "fmt" "io" "os" + "strconv" + "sync/atomic" ) +// --- ID Generation --- + +var idCounter atomic.Uint64 + +// ID returns a unique identifier. Format: "id-{counter}-{random}". +// Counter is process-wide atomic. Random suffix prevents collision across restarts. +// +// id := core.ID() // "id-1-a3f2b1" +// id2 := core.ID() // "id-2-c7e4d9" +func ID() string { + return Concat("id-", strconv.FormatUint(idCounter.Add(1), 10), "-", shortRand()) +} + +func shortRand() string { + b := make([]byte, 3) + crand.Read(b) + return hex.EncodeToString(b) +} + +// --- Validation --- + +// ValidateName checks that a string is a valid service/action/command name. +// Rejects empty, ".", "..", and names containing path separators. +// +// r := core.ValidateName("brain") // Result{"brain", true} +// r := core.ValidateName("") // Result{error, false} +// r := core.ValidateName("../escape") // Result{error, false} +func ValidateName(name string) Result { + if name == "" || name == "." || name == ".." { + return Result{E("validate", Concat("invalid name: ", name), nil), false} + } + if Contains(name, "/") || Contains(name, "\\") { + return Result{E("validate", Concat("name contains path separator: ", name), nil), false} + } + return Result{name, true} +} + +// SanitisePath extracts the base filename and rejects traversal attempts. +// Returns "invalid" for dangerous inputs. +// +// core.SanitisePath("../../etc/passwd") // "passwd" +// core.SanitisePath("") // "invalid" +// core.SanitisePath("..") // "invalid" +func SanitisePath(path string) string { + safe := PathBase(path) + if safe == "." || safe == ".." || safe == "" { + return "invalid" + } + return safe +} + +// --- I/O --- + +// Println prints values to stdout with a newline. Replaces fmt.Println. +// +// core.Println("hello", 42, true) +func Println(args ...any) { + fmt.Println(args...) +} + // Print writes a formatted line to a writer, defaulting to os.Stdout. // // core.Print(nil, "hello %s", "world") // → stdout diff --git a/pkg/messages/messages_example_test.go b/pkg/messages/messages_example_test.go new file mode 100644 index 0000000..43d8865 --- /dev/null +++ b/pkg/messages/messages_example_test.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package messages + +import ( + "fmt" +) + +func ExampleAgentCompleted() { + ev := AgentCompleted{ + Agent: "codex", + Repo: "go-io", + Workspace: "core/go-io/task-5", + Status: "completed", + } + fmt.Println(ev.Agent, ev.Status) + // Output: codex completed +} + +func ExampleQAResult() { + ev := QAResult{ + Workspace: "core/go-io/task-5", + Repo: "go-io", + Passed: true, + } + fmt.Println(ev.Repo, ev.Passed) + // Output: go-io true +} + +func ExampleQueueDrained() { + ev := QueueDrained{Completed: 3} + fmt.Println(ev.Completed) + // Output: 3 +} diff --git a/pkg/monitor/harvest.go b/pkg/monitor/harvest.go index c1049cc..a92d4a9 100644 --- a/pkg/monitor/harvest.go +++ b/pkg/monitor/harvest.go @@ -11,12 +11,10 @@ package monitor import ( "context" - "encoding/json" - "os/exec" - "path/filepath" "strconv" "dappco.re/go/agent/pkg/agentic" + "dappco.re/go/agent/pkg/messages" core "dappco.re/go/core" ) @@ -37,7 +35,7 @@ func (m *Subsystem) harvestCompleted() string { var harvested []harvestResult for _, entry := range entries { - wsDir := filepath.Dir(entry) + wsDir := core.PathDir(entry) result := m.harvestWorkspace(wsDir) if result != nil { harvested = append(harvested, *result) @@ -52,21 +50,13 @@ func (m *Subsystem) harvestCompleted() string { for _, h := range harvested { if h.rejected != "" { parts = append(parts, core.Sprintf("%s: REJECTED (%s)", h.repo, h.rejected)) - if m.notifier != nil { - m.notifier.ChannelSend(context.Background(), "harvest.rejected", map[string]any{ - "repo": h.repo, - "branch": h.branch, - "reason": h.rejected, - }) + if m.ServiceRuntime != nil { + m.Core().ACTION(messages.HarvestRejected{Repo: h.repo, Branch: h.branch, Reason: h.rejected}) } } else { parts = append(parts, core.Sprintf("%s: ready-for-review %s (%d files)", h.repo, h.branch, h.files)) - if m.notifier != nil { - m.notifier.ChannelSend(context.Background(), "harvest.complete", map[string]any{ - "repo": h.repo, - "branch": h.branch, - "files": h.files, - }) + if m.ServiceRuntime != nil { + m.Core().ACTION(messages.HarvestComplete{Repo: h.repo, Branch: h.branch, Files: h.files}) } } } @@ -89,7 +79,7 @@ func (m *Subsystem) harvestWorkspace(wsDir string) *harvestResult { Repo string `json:"repo"` Branch string `json:"branch"` } - if json.Unmarshal([]byte(statusData), &st) != nil { + if r := core.JSONUnmarshalString(statusData, &st); !r.OK { return nil } @@ -106,27 +96,27 @@ func (m *Subsystem) harvestWorkspace(wsDir string) *harvestResult { // Check if there are commits to push branch := st.Branch if branch == "" { - branch = detectBranch(srcDir) + branch = m.detectBranch(srcDir) } - base := defaultBranch(srcDir) + base := m.defaultBranch(srcDir) if branch == "" || branch == base { return nil } // Check for unpushed commits - unpushed := countUnpushed(srcDir, branch) + unpushed := m.countUnpushed(srcDir, branch) if unpushed == 0 { return nil // already pushed or no commits } // Safety checks before pushing - if reason := checkSafety(srcDir); reason != "" { + if reason := m.checkSafety(srcDir); reason != "" { updateStatus(wsDir, "rejected", reason) return &harvestResult{repo: st.Repo, branch: branch, rejected: reason} } // Count changed files - files := countChangedFiles(srcDir) + files := m.countChangedFiles(srcDir) // Mark ready for review — do NOT auto-push. // Pushing is a high-impact mutation that should happen during @@ -136,35 +126,35 @@ func (m *Subsystem) harvestWorkspace(wsDir string) *harvestResult { return &harvestResult{repo: st.Repo, branch: branch, files: files} } -// detectBranch returns the current branch name. -func detectBranch(srcDir string) string { - cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") - cmd.Dir = srcDir - out, err := cmd.Output() - if err != nil { +// gitOutput runs a git command and returns trimmed stdout via Core Process. +func (m *Subsystem) gitOutput(dir string, args ...string) string { + r := m.Core().Process().RunIn(context.Background(), dir, "git", args...) + if !r.OK { return "" } - return core.Trim(string(out)) + return core.Trim(r.Value.(string)) +} + +// gitOK runs a git command and returns true if it exits 0. +func (m *Subsystem) gitOK(dir string, args ...string) bool { + return m.Core().Process().RunIn(context.Background(), dir, "git", args...).OK +} + +// detectBranch returns the current branch name. +func (m *Subsystem) detectBranch(srcDir string) string { + return m.gitOutput(srcDir, "rev-parse", "--abbrev-ref", "HEAD") } // defaultBranch detects the default branch of the repo (main, master, etc.). -func defaultBranch(srcDir string) string { - // Try origin/HEAD first - cmd := exec.Command("git", "symbolic-ref", "refs/remotes/origin/HEAD", "--short") - cmd.Dir = srcDir - if out, err := cmd.Output(); err == nil { - ref := core.Trim(string(out)) - // returns "origin/main" — strip prefix +func (m *Subsystem) defaultBranch(srcDir string) string { + if ref := m.gitOutput(srcDir, "symbolic-ref", "refs/remotes/origin/HEAD", "--short"); ref != "" { if core.HasPrefix(ref, "origin/") { return core.TrimPrefix(ref, "origin/") } return ref } - // Fallback: check if main exists, else master for _, branch := range []string{"main", "master"} { - cmd := exec.Command("git", "rev-parse", "--verify", branch) - cmd.Dir = srcDir - if cmd.Run() == nil { + if m.gitOK(srcDir, "rev-parse", "--verify", branch) { return branch } } @@ -172,25 +162,22 @@ func defaultBranch(srcDir string) string { } // countUnpushed returns the number of commits ahead of origin's default branch. -func countUnpushed(srcDir, branch string) int { - base := defaultBranch(srcDir) - cmd := exec.Command("git", "rev-list", "--count", core.Concat("origin/", base, "..", branch)) - cmd.Dir = srcDir - out, err := cmd.Output() - if err != nil { - cmd2 := exec.Command("git", "log", "--oneline", core.Concat(base, "..", branch)) - cmd2.Dir = srcDir - out2, err2 := cmd2.Output() - if err2 != nil { +func (m *Subsystem) countUnpushed(srcDir, branch string) int { + base := m.defaultBranch(srcDir) + out := m.gitOutput(srcDir, "rev-list", "--count", core.Concat("origin/", base, "..", branch)) + if out == "" { + // Fallback + out2 := m.gitOutput(srcDir, "log", "--oneline", core.Concat(base, "..", branch)) + if out2 == "" { return 0 } - lines := core.Split(core.Trim(string(out2)), "\n") + lines := core.Split(out2, "\n") if len(lines) == 1 && lines[0] == "" { return 0 } return len(lines) } - count, err := strconv.Atoi(core.Trim(string(out))) + count, err := strconv.Atoi(out) if err != nil { return 0 } @@ -198,15 +185,10 @@ func countUnpushed(srcDir, branch string) int { } // checkSafety rejects workspaces with binaries or oversized files. -// Checks ALL changed files (added, modified, renamed), not just new. -// Fails closed: if git diff fails, rejects the workspace. -func checkSafety(srcDir string) string { - // Check all changed files — added, modified, renamed - base := defaultBranch(srcDir) - cmd := exec.Command("git", "diff", "--name-only", core.Concat(base, "...HEAD")) - cmd.Dir = srcDir - out, err := cmd.Output() - if err != nil { +func (m *Subsystem) checkSafety(srcDir string) string { + base := m.defaultBranch(srcDir) + out := m.gitOutput(srcDir, "diff", "--name-only", core.Concat(base, "...HEAD")) + if out == "" { return "safety check failed: git diff error" } @@ -220,16 +202,15 @@ func checkSafety(srcDir string) string { ".db": true, ".sqlite": true, ".sqlite3": true, } - for _, file := range core.Split(core.Trim(string(out)), "\n") { + for _, file := range core.Split(out, "\n") { if file == "" { continue } - ext := core.Lower(filepath.Ext(file)) + ext := core.Lower(core.PathExt(file)) if binaryExts[ext] { return core.Sprintf("binary file added: %s", file) } - // Check file size (reject > 1MB) fullPath := core.Concat(srcDir, "/", file) if stat := fs.Stat(fullPath); stat.OK { if info, ok := stat.Value.(interface{ Size() int64 }); ok && info.Size() > 1024*1024 { @@ -242,15 +223,13 @@ func checkSafety(srcDir string) string { } // countChangedFiles returns the number of files changed vs the default branch. -func countChangedFiles(srcDir string) int { - base := defaultBranch(srcDir) - cmd := exec.Command("git", "diff", "--name-only", core.Concat(base, "...HEAD")) - cmd.Dir = srcDir - out, err := cmd.Output() - if err != nil { +func (m *Subsystem) countChangedFiles(srcDir string) int { + base := m.defaultBranch(srcDir) + out := m.gitOutput(srcDir, "diff", "--name-only", core.Concat(base, "...HEAD")) + if out == "" { return 0 } - lines := core.Split(core.Trim(string(out)), "\n") + lines := core.Split(out, "\n") if len(lines) == 1 && lines[0] == "" { return 0 } @@ -258,12 +237,13 @@ func countChangedFiles(srcDir string) int { } // pushBranch pushes the agent's branch to origin. -func pushBranch(srcDir, branch string) error { - cmd := exec.Command("git", "push", "origin", branch) - cmd.Dir = srcDir - out, err := cmd.CombinedOutput() - if err != nil { - return core.E("harvest.pushBranch", core.Trim(string(out)), err) +func (m *Subsystem) pushBranch(srcDir, branch string) error { + r := m.Core().Process().RunIn(context.Background(), srcDir, "git", "push", "origin", branch) + if !r.OK { + if err, ok := r.Value.(error); ok { + return core.E("harvest.pushBranch", "push failed", err) + } + return core.E("harvest.pushBranch", "push failed", nil) } return nil } @@ -279,7 +259,7 @@ func updateStatus(wsDir, status, question string) { return } var st map[string]any - if json.Unmarshal([]byte(statusData), &st) != nil { + if r := core.JSONUnmarshalString(statusData, &st); !r.OK { return } st["status"] = status @@ -288,6 +268,5 @@ func updateStatus(wsDir, status, question string) { } else { delete(st, "question") // clear stale question from previous state } - updated, _ := json.MarshalIndent(st, "", " ") - fs.Write(workspaceStatusPath(wsDir), string(updated)) + fs.WriteAtomic(workspaceStatusPath(wsDir), core.JSONMarshalString(st)) } diff --git a/pkg/monitor/harvest_example_test.go b/pkg/monitor/harvest_example_test.go new file mode 100644 index 0000000..1636a41 --- /dev/null +++ b/pkg/monitor/harvest_example_test.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package monitor + +import ( + core "dappco.re/go/core" +) + +func Example_workspaceStatusPath() { + path := workspaceStatusPath("/srv/workspace/go-io-123") + core.Println(core.HasSuffix(path, "status.json")) + // Output: true +} diff --git a/pkg/monitor/harvest_test.go b/pkg/monitor/harvest_test.go index a1bc056..ade3251 100644 --- a/pkg/monitor/harvest_test.go +++ b/pkg/monitor/harvest_test.go @@ -8,38 +8,15 @@ import ( "os" "os/exec" "path/filepath" - "sync" "testing" + "dappco.re/go/agent/pkg/messages" + core "dappco.re/go/core" + "dappco.re/go/core/process" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// mockNotifier captures channel events for testing. -type mockNotifier struct { - mu sync.Mutex - events []mockEvent -} - -type mockEvent struct { - channel string - data any -} - -func (m *mockNotifier) ChannelSend(_ context.Context, channel string, data any) { - m.mu.Lock() - defer m.mu.Unlock() - m.events = append(m.events, mockEvent{channel: channel, data: data}) -} - -func (m *mockNotifier) Events() []mockEvent { - m.mu.Lock() - defer m.mu.Unlock() - cp := make([]mockEvent, len(m.events)) - copy(cp, m.events) - return cp -} - // initTestRepo creates a bare git repo and a workspace clone with a branch. func initTestRepo(t *testing.T) (sourceDir, wsDir string) { t.Helper() @@ -90,44 +67,44 @@ func writeStatus(t *testing.T, wsDir, status, repo, branch string) { // --- Tests --- -func TestDetectBranch_Good(t *testing.T) { +func TestHarvest_DetectBranch_Good(t *testing.T) { _, wsDir := initTestRepo(t) srcDir := filepath.Join(wsDir, "src") - branch := detectBranch(srcDir) + branch := testMon.detectBranch(srcDir) assert.Equal(t, "agent/test-task", branch) } -func TestDetectBranch_Bad_NoRepo(t *testing.T) { - branch := detectBranch(t.TempDir()) +func TestHarvest_DetectBranch_Bad_NoRepo(t *testing.T) { + branch := testMon.detectBranch(t.TempDir()) assert.Equal(t, "", branch) } -func TestCountUnpushed_Good(t *testing.T) { +func TestHarvest_CountUnpushed_Good(t *testing.T) { _, wsDir := initTestRepo(t) srcDir := filepath.Join(wsDir, "src") - count := countUnpushed(srcDir, "agent/test-task") + count := testMon.countUnpushed(srcDir, "agent/test-task") assert.Equal(t, 1, count) } -func TestCountChangedFiles_Good(t *testing.T) { +func TestHarvest_CountChangedFiles_Good(t *testing.T) { _, wsDir := initTestRepo(t) srcDir := filepath.Join(wsDir, "src") - count := countChangedFiles(srcDir) + count := testMon.countChangedFiles(srcDir) assert.Equal(t, 1, count) } -func TestCheckSafety_Good_CleanWorkspace(t *testing.T) { +func TestHarvest_CheckSafety_Good_CleanWorkspace(t *testing.T) { _, wsDir := initTestRepo(t) srcDir := filepath.Join(wsDir, "src") - reason := checkSafety(srcDir) + reason := testMon.checkSafety(srcDir) assert.Equal(t, "", reason) } -func TestCheckSafety_Bad_BinaryFile(t *testing.T) { +func TestHarvest_CheckSafety_Bad_BinaryFile(t *testing.T) { _, wsDir := initTestRepo(t) srcDir := filepath.Join(wsDir, "src") @@ -136,12 +113,12 @@ func TestCheckSafety_Bad_BinaryFile(t *testing.T) { run(t, srcDir, "git", "add", ".") run(t, srcDir, "git", "commit", "-m", "add binary") - reason := checkSafety(srcDir) + reason := testMon.checkSafety(srcDir) assert.Contains(t, reason, "binary file added") assert.Contains(t, reason, "app.exe") } -func TestCheckSafety_Bad_LargeFile(t *testing.T) { +func TestHarvest_CheckSafety_Bad_LargeFile(t *testing.T) { _, wsDir := initTestRepo(t) srcDir := filepath.Join(wsDir, "src") @@ -151,18 +128,17 @@ func TestCheckSafety_Bad_LargeFile(t *testing.T) { run(t, srcDir, "git", "add", ".") run(t, srcDir, "git", "commit", "-m", "add large file") - reason := checkSafety(srcDir) + reason := testMon.checkSafety(srcDir) assert.Contains(t, reason, "large file") assert.Contains(t, reason, "huge.txt") } -func TestHarvestWorkspace_Good(t *testing.T) { +func TestHarvest_HarvestWorkspace_Good(t *testing.T) { _, wsDir := initTestRepo(t) writeStatus(t, wsDir, "completed", "test-repo", "agent/test-task") mon := New() - notifier := &mockNotifier{} - mon.SetNotifier(notifier) + mon.ServiceRuntime = testMon.ServiceRuntime result := mon.harvestWorkspace(wsDir) require.NotNil(t, result) @@ -179,16 +155,17 @@ func TestHarvestWorkspace_Good(t *testing.T) { assert.Equal(t, "ready-for-review", st["status"]) } -func TestHarvestWorkspace_Bad_NotCompleted(t *testing.T) { +func TestHarvest_HarvestWorkspace_Bad_NotCompleted(t *testing.T) { _, wsDir := initTestRepo(t) writeStatus(t, wsDir, "running", "test-repo", "agent/test-task") mon := New() + mon.ServiceRuntime = testMon.ServiceRuntime result := mon.harvestWorkspace(wsDir) assert.Nil(t, result) } -func TestHarvestWorkspace_Bad_MainBranch(t *testing.T) { +func TestHarvest_HarvestWorkspace_Bad_MainBranch(t *testing.T) { _, wsDir := initTestRepo(t) // Switch back to main @@ -198,11 +175,12 @@ func TestHarvestWorkspace_Bad_MainBranch(t *testing.T) { writeStatus(t, wsDir, "completed", "test-repo", "main") mon := New() + mon.ServiceRuntime = testMon.ServiceRuntime result := mon.harvestWorkspace(wsDir) assert.Nil(t, result) } -func TestHarvestWorkspace_Bad_BinaryRejected(t *testing.T) { +func TestHarvest_HarvestWorkspace_Bad_BinaryRejected(t *testing.T) { _, wsDir := initTestRepo(t) srcDir := filepath.Join(wsDir, "src") @@ -214,8 +192,7 @@ func TestHarvestWorkspace_Bad_BinaryRejected(t *testing.T) { writeStatus(t, wsDir, "completed", "test-repo", "agent/test-task") mon := New() - notifier := &mockNotifier{} - mon.SetNotifier(notifier) + mon.ServiceRuntime = testMon.ServiceRuntime result := mon.harvestWorkspace(wsDir) require.NotNil(t, result) @@ -228,43 +205,39 @@ func TestHarvestWorkspace_Bad_BinaryRejected(t *testing.T) { assert.Equal(t, "rejected", st["status"]) } -func TestHarvestCompleted_Good_ChannelEvents(t *testing.T) { +func TestHarvest_HarvestCompleted_Good_ChannelEvents(t *testing.T) { _, wsDir := initTestRepo(t) writeStatus(t, wsDir, "completed", "test-repo", "agent/test-task") - // Override workspace root so harvestCompleted finds our workspace - origRoot := os.Getenv("CORE_WORKSPACE_ROOT") - os.Setenv("CORE_WORKSPACE_ROOT", filepath.Dir(wsDir)) - defer os.Setenv("CORE_WORKSPACE_ROOT", origRoot) + // Create a Core with process + IPC handler to capture HarvestComplete messages + var captured []messages.HarvestComplete + c := core.New(core.WithService(process.Register)) + c.ServiceStartup(context.Background(), nil) + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if ev, ok := msg.(messages.HarvestComplete); ok { + captured = append(captured, ev) + } + return core.Result{OK: true} + }) mon := New() - notifier := &mockNotifier{} - mon.SetNotifier(notifier) + mon.ServiceRuntime = core.NewServiceRuntime(c, MonitorOptions{}) // Call harvestWorkspace directly since harvestCompleted uses agentic.WorkspaceRoot() result := mon.harvestWorkspace(wsDir) require.NotNil(t, result) assert.Equal(t, "", result.rejected) - // Simulate what harvestCompleted does with the result - if result.rejected == "" { - mon.notifier.ChannelSend(context.Background(), "harvest.complete", map[string]any{ - "repo": result.repo, - "branch": result.branch, - "files": result.files, - }) - } + // Simulate what harvestCompleted does with the result — emit IPC + mon.Core().ACTION(messages.HarvestComplete{Repo: result.repo, Branch: result.branch, Files: result.files}) - events := notifier.Events() - require.Len(t, events, 1) - assert.Equal(t, "harvest.complete", events[0].channel) - - eventData := events[0].data.(map[string]any) - assert.Equal(t, "test-repo", eventData["repo"]) - assert.Equal(t, 1, eventData["files"]) + require.Len(t, captured, 1) + assert.Equal(t, "test-repo", captured[0].Repo) + assert.Equal(t, "agent/test-task", captured[0].Branch) + assert.Equal(t, 1, captured[0].Files) } -func TestUpdateStatus_Good(t *testing.T) { +func TestHarvest_UpdateStatus_Good(t *testing.T) { dir := t.TempDir() initial := map[string]any{"status": "completed", "repo": "test"} data, _ := json.MarshalIndent(initial, "", " ") @@ -278,7 +251,7 @@ func TestUpdateStatus_Good(t *testing.T) { assert.Equal(t, "ready-for-review", st["status"]) } -func TestUpdateStatus_Good_WithQuestion(t *testing.T) { +func TestHarvest_UpdateStatus_Good_WithQuestion(t *testing.T) { dir := t.TempDir() initial := map[string]any{"status": "completed", "repo": "test"} data, _ := json.MarshalIndent(initial, "", " ") @@ -293,11 +266,3 @@ func TestUpdateStatus_Good_WithQuestion(t *testing.T) { assert.Equal(t, "binary file: app.exe", st["question"]) } -func TestSetNotifier_Good(t *testing.T) { - mon := New() - assert.Nil(t, mon.notifier) - - notifier := &mockNotifier{} - mon.SetNotifier(notifier) - assert.NotNil(t, mon.notifier) -} diff --git a/pkg/monitor/logic_test.go b/pkg/monitor/logic_test.go index b92fc1d..18188db 100644 --- a/pkg/monitor/logic_test.go +++ b/pkg/monitor/logic_test.go @@ -17,7 +17,7 @@ import ( // --- handleAgentStarted --- -func TestHandleAgentStarted_Good(t *testing.T) { +func TestLogic_HandleAgentStarted_Good(t *testing.T) { mon := New() ev := messages.AgentStarted{Agent: "codex", Repo: "go-io", Workspace: "core/go-io/task-1"} mon.handleAgentStarted(ev) @@ -27,7 +27,7 @@ func TestHandleAgentStarted_Good(t *testing.T) { assert.True(t, mon.seenRunning["core/go-io/task-1"]) } -func TestHandleAgentStarted_Bad_EmptyWorkspace(t *testing.T) { +func TestLogic_HandleAgentStarted_Bad_EmptyWorkspace(t *testing.T) { mon := New() // Empty workspace key must not panic and must record empty string key. ev := messages.AgentStarted{Agent: "", Repo: "", Workspace: ""} @@ -40,13 +40,13 @@ func TestHandleAgentStarted_Bad_EmptyWorkspace(t *testing.T) { // --- handleAgentCompleted --- -func TestHandleAgentCompleted_Good_NilNotifier(t *testing.T) { +func TestLogic_HandleAgentCompleted_Good_NilRuntime(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) mon := New() - // notifier is nil — must not panic, must record completion and poke. + // ServiceRuntime is nil — must not panic, must record completion and poke. ev := messages.AgentCompleted{Agent: "codex", Repo: "go-io", Workspace: "ws-1", Status: "completed"} assert.NotPanics(t, func() { mon.handleAgentCompleted(ev) }) @@ -55,67 +55,54 @@ func TestHandleAgentCompleted_Good_NilNotifier(t *testing.T) { assert.True(t, mon.seenCompleted["ws-1"]) } -func TestHandleAgentCompleted_Good_WithNotifier(t *testing.T) { +func TestLogic_HandleAgentCompleted_Good_WithCore(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) - mon := New() - notifier := &mockNotifier{} - mon.SetNotifier(notifier) + // Use Register so IPC handlers are wired + c := core.New(core.WithService(Register)) + mon, ok := core.ServiceFor[*Subsystem](c, "monitor") + require.True(t, ok) ev := messages.AgentCompleted{Agent: "codex", Repo: "go-io", Workspace: "ws-2", Status: "completed"} - mon.handleAgentCompleted(ev) + c.ACTION(ev) - // Give the goroutine spawned by checkIdleAfterDelay time to not fire within test - // (it has a 5s sleep inside, so we just verify the notifier got the immediate event) - events := notifier.Events() - require.GreaterOrEqual(t, len(events), 1) - assert.Equal(t, "agent.completed", events[0].channel) - - data := events[0].data.(map[string]any) - assert.Equal(t, "go-io", data["repo"]) - assert.Equal(t, "codex", data["agent"]) - assert.Equal(t, "ws-2", data["workspace"]) - assert.Equal(t, "completed", data["status"]) + mon.mu.Lock() + defer mon.mu.Unlock() + assert.True(t, mon.seenCompleted["ws-2"]) } -func TestHandleAgentCompleted_Bad_EmptyFields(t *testing.T) { +func TestLogic_HandleAgentCompleted_Bad_EmptyFields(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) mon := New() - notifier := &mockNotifier{} - mon.SetNotifier(notifier) - // All fields empty — must not panic. + // All fields empty — must not panic, must record empty workspace key. ev := messages.AgentCompleted{} assert.NotPanics(t, func() { mon.handleAgentCompleted(ev) }) - events := notifier.Events() - require.GreaterOrEqual(t, len(events), 1) - assert.Equal(t, "agent.completed", events[0].channel) + mon.mu.Lock() + defer mon.mu.Unlock() + assert.True(t, mon.seenCompleted[""]) } // --- checkIdleAfterDelay --- -func TestCheckIdleAfterDelay_Bad_NilNotifier(t *testing.T) { +func TestLogic_CheckIdleAfterDelay_Bad_NilRuntime(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) - mon := New() // notifier is nil + mon := New() // ServiceRuntime is nil // Should return immediately without panic after the 5s sleep. - // We override the sleep by calling it via a short-circuit: replace the - // notifier check path — we just verify it doesn't panic and returns. + // We test the "ServiceRuntime == nil" return branch by exercising the guard directly. done := make(chan struct{}) go func() { - // checkIdleAfterDelay has a time.Sleep(5s) — call with nil notifier path. - // To avoid a 5-second wait we test the "notifier == nil" return branch - // by only exercising the guard directly. - if mon.notifier == nil { + if mon.ServiceRuntime == nil { close(done) return } @@ -126,41 +113,44 @@ func TestCheckIdleAfterDelay_Bad_NilNotifier(t *testing.T) { select { case <-done: case <-time.After(1 * time.Second): - t.Fatal("checkIdleAfterDelay nil-notifier guard did not return quickly") + t.Fatal("checkIdleAfterDelay nil-runtime guard did not return quickly") } } -func TestCheckIdleAfterDelay_Good_EmptyWorkspace(t *testing.T) { +func TestLogic_CheckIdleAfterDelay_Good_EmptyWorkspace(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) + // Create a Core with an IPC handler to capture QueueDrained messages + var captured []messages.QueueDrained + c := core.New() + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if ev, ok := msg.(messages.QueueDrained); ok { + captured = append(captured, ev) + } + return core.Result{OK: true} + }) + mon := New() - notifier := &mockNotifier{} - mon.SetNotifier(notifier) + mon.ServiceRuntime = core.NewServiceRuntime(c, MonitorOptions{}) // With empty workspace, running=0 and queued=0, so queue.drained fires. - // We run countLiveWorkspaces + the notifier call path directly to avoid the - // 5s sleep in checkIdleAfterDelay. running, queued := mon.countLiveWorkspaces() assert.Equal(t, 0, running) assert.Equal(t, 0, queued) if running == 0 && queued == 0 { - mon.notifier.ChannelSend(context.Background(), "queue.drained", map[string]any{ - "running": running, - "queued": queued, - }) + mon.Core().ACTION(messages.QueueDrained{Completed: 0}) } - events := notifier.Events() - require.Len(t, events, 1) - assert.Equal(t, "queue.drained", events[0].channel) + require.Len(t, captured, 1) + assert.Equal(t, 0, captured[0].Completed) } // --- countLiveWorkspaces --- -func TestCountLiveWorkspaces_Good_EmptyWorkspace(t *testing.T) { +func TestLogic_CountLiveWorkspaces_Good_EmptyWorkspace(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) @@ -171,7 +161,7 @@ func TestCountLiveWorkspaces_Good_EmptyWorkspace(t *testing.T) { assert.Equal(t, 0, queued) } -func TestCountLiveWorkspaces_Good_QueuedStatus(t *testing.T) { +func TestLogic_CountLiveWorkspaces_Good_QueuedStatus(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) @@ -187,7 +177,7 @@ func TestCountLiveWorkspaces_Good_QueuedStatus(t *testing.T) { assert.Equal(t, 1, queued) } -func TestCountLiveWorkspaces_Bad_RunningDeadPID(t *testing.T) { +func TestLogic_CountLiveWorkspaces_Bad_RunningDeadPID(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) @@ -209,7 +199,7 @@ func TestCountLiveWorkspaces_Bad_RunningDeadPID(t *testing.T) { assert.Equal(t, 0, queued) } -func TestCountLiveWorkspaces_Good_RunningLivePID(t *testing.T) { +func TestLogic_CountLiveWorkspaces_Good_RunningLivePID(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) @@ -230,46 +220,49 @@ func TestCountLiveWorkspaces_Good_RunningLivePID(t *testing.T) { // --- pidAlive --- -func TestPidAlive_Good_CurrentProcess(t *testing.T) { +func TestLogic_PidAlive_Good_CurrentProcess(t *testing.T) { pid := os.Getpid() assert.True(t, pidAlive(pid), "current process must be alive") } -func TestPidAlive_Bad_DeadPID(t *testing.T) { +func TestLogic_PidAlive_Bad_DeadPID(t *testing.T) { // PID 99999999 is virtually guaranteed to not exist. assert.False(t, pidAlive(99999999)) } -func TestPidAlive_Ugly_ZeroPID(t *testing.T) { +func TestLogic_PidAlive_Ugly_ZeroPID(t *testing.T) { // PID 0 is not a valid user process. pidAlive must return false or at // least not panic. assert.NotPanics(t, func() { pidAlive(0) }) } -func TestPidAlive_Ugly_NegativePID(t *testing.T) { +func TestLogic_PidAlive_Ugly_NegativePID(t *testing.T) { // Negative PID is invalid. Must not panic. assert.NotPanics(t, func() { pidAlive(-1) }) } // --- SetCore --- -func TestSetCore_Good_RegistersIPCHandler(t *testing.T) { +func TestLogic_SetCore_Good_RegistersIPCHandler(t *testing.T) { c := core.New() mon := New() - // SetCore must not panic and must wire mon.core. + // SetCore must not panic and must wire ServiceRuntime. assert.NotPanics(t, func() { mon.SetCore(c) }) - assert.Equal(t, c, mon.core) + assert.NotNil(t, mon.ServiceRuntime) + assert.Equal(t, c, mon.Core()) } -func TestSetCore_Good_IPCHandlerFires(t *testing.T) { +func TestLogic_SetCore_Good_IPCHandlerFires(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) - c := core.New() - mon := New() - mon.SetCore(c) + // IPC handlers are registered via Register, not SetCore + c := core.New(core.WithService(Register)) + + mon, ok := core.ServiceFor[*Subsystem](c, "monitor") + require.True(t, ok) // Dispatch an AgentStarted via Core IPC — handler must update seenRunning. c.ACTION(messages.AgentStarted{Agent: "codex", Repo: "go-io", Workspace: "ws-ipc"}) @@ -279,14 +272,16 @@ func TestSetCore_Good_IPCHandlerFires(t *testing.T) { assert.True(t, mon.seenRunning["ws-ipc"]) } -func TestSetCore_Good_CompletedIPCHandler(t *testing.T) { +func TestLogic_SetCore_Good_CompletedIPCHandler(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) - c := core.New() - mon := New() - mon.SetCore(c) + // IPC handlers are registered via Register, not SetCore + c := core.New(core.WithService(Register)) + + mon, ok := core.ServiceFor[*Subsystem](c, "monitor") + require.True(t, ok) // Dispatch AgentCompleted — handler must update seenCompleted. c.ACTION(messages.AgentCompleted{Agent: "codex", Repo: "go-io", Workspace: "ws-done", Status: "completed"}) @@ -298,7 +293,7 @@ func TestSetCore_Good_CompletedIPCHandler(t *testing.T) { // --- OnStartup / OnShutdown --- -func TestOnStartup_Good_StartsLoop(t *testing.T) { +func TestLogic_OnStartup_Good_StartsLoop(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) @@ -307,32 +302,33 @@ func TestOnStartup_Good_StartsLoop(t *testing.T) { t.Setenv("HOME", home) mon := New(Options{Interval: 1 * time.Hour}) - err := mon.OnStartup(context.Background()) - require.NoError(t, err) + r := mon.OnStartup(context.Background()) + assert.True(t, r.OK) // cancel must be non-nil after startup (loop running) assert.NotNil(t, mon.cancel) // Cleanup. - require.NoError(t, mon.OnShutdown(context.Background())) + r2 := mon.OnShutdown(context.Background()) + assert.True(t, r2.OK) } -func TestOnStartup_Good_NoError(t *testing.T) { +func TestLogic_OnStartup_Good_NoError(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) mon := New(Options{Interval: 1 * time.Hour}) - assert.NoError(t, mon.OnStartup(context.Background())) + assert.True(t, mon.OnStartup(context.Background()).OK) _ = mon.OnShutdown(context.Background()) } -func TestOnShutdown_Good_NoError(t *testing.T) { +func TestLogic_OnShutdown_Good_NoError(t *testing.T) { mon := New(Options{Interval: 1 * time.Hour}) - assert.NoError(t, mon.OnShutdown(context.Background())) + assert.True(t, mon.OnShutdown(context.Background()).OK) } -func TestOnShutdown_Good_StopsLoop(t *testing.T) { +func TestLogic_OnShutdown_Good_StopsLoop(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) @@ -341,22 +337,22 @@ func TestOnShutdown_Good_StopsLoop(t *testing.T) { t.Setenv("HOME", home) mon := New(Options{Interval: 1 * time.Hour}) - require.NoError(t, mon.OnStartup(context.Background())) + require.True(t, mon.OnStartup(context.Background()).OK) - done := make(chan error, 1) + done := make(chan bool, 1) go func() { - done <- mon.OnShutdown(context.Background()) + done <- mon.OnShutdown(context.Background()).OK }() select { - case err := <-done: - assert.NoError(t, err) + case ok := <-done: + assert.True(t, ok) case <-time.After(5 * time.Second): t.Fatal("OnShutdown did not return in time") } } -func TestOnShutdown_Ugly_NilCancel(t *testing.T) { +func TestLogic_OnShutdown_Ugly_NilCancel(t *testing.T) { // OnShutdown without prior OnStartup must not panic. mon := New() assert.NotPanics(t, func() { @@ -366,7 +362,7 @@ func TestOnShutdown_Ugly_NilCancel(t *testing.T) { // --- Register --- -func TestRegister_Good_ReturnsSubsystem(t *testing.T) { +func TestLogic_Register_Good_ReturnsSubsystem(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) @@ -380,7 +376,7 @@ func TestRegister_Good_ReturnsSubsystem(t *testing.T) { assert.NotNil(t, svc) } -func TestRegister_Good_CoreWired(t *testing.T) { +func TestLogic_Register_Good_CoreWired(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) @@ -390,11 +386,12 @@ func TestRegister_Good_CoreWired(t *testing.T) { svc, ok := core.ServiceFor[*Subsystem](c, "monitor") require.True(t, ok) - // Register must set mon.core to the Core instance. - assert.Equal(t, c, svc.core) + // Register must set ServiceRuntime. + assert.NotNil(t, svc.ServiceRuntime) + assert.Equal(t, c, svc.Core()) } -func TestRegister_Good_IPCHandlerActive(t *testing.T) { +func TestLogic_Register_Good_IPCHandlerActive(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) diff --git a/pkg/monitor/monitor.go b/pkg/monitor/monitor.go index c865c13..d46c177 100644 --- a/pkg/monitor/monitor.go +++ b/pkg/monitor/monitor.go @@ -11,11 +11,6 @@ package monitor import ( "context" - "encoding/json" - "net/http" - "net/url" - "os" - "path/filepath" "sync" "syscall" "time" @@ -47,7 +42,7 @@ func workspaceStatusPath(wsDir string) string { } func brainKeyPath(home string) string { - return filepath.Join(home, ".claude", "brain.key") + return core.JoinPath(home, ".claude", "brain.key") } func monitorPath(path string) string { @@ -89,22 +84,15 @@ func resultString(r core.Result) (string, bool) { return value, true } -// ChannelNotifier pushes events to connected MCP sessions. -// -// mon.SetNotifier(notifier) -type ChannelNotifier interface { - ChannelSend(ctx context.Context, channel string, data any) -} +// MonitorOptions configures the monitor service. +type MonitorOptions struct{} // Subsystem implements mcp.Subsystem for background monitoring. // -// mon := monitor.New(monitor.Options{Interval: 2 * time.Minute}) -// mon.SetNotifier(notifier) -// mon.Start(ctx) +// core.New(core.WithService(monitor.Register)) type Subsystem struct { - core *core.Core // Core framework instance for IPC + *core.ServiceRuntime[MonitorOptions] server *mcp.Server - notifier ChannelNotifier // TODO(phase3): remove — replaced by c.ACTION() interval time.Duration cancel context.CancelFunc wg sync.WaitGroup @@ -125,22 +113,12 @@ type Subsystem struct { var _ coremcp.Subsystem = (*Subsystem)(nil) -// SetCore wires the Core framework instance and registers IPC handlers. +// SetCore wires the Core framework instance via ServiceRuntime. +// Deprecated: Use Register with core.WithService(monitor.Register) instead. // // mon.SetCore(c) func (m *Subsystem) SetCore(c *core.Core) { - m.core = c - - // Register IPC handler for agent lifecycle events - c.RegisterAction(func(c *core.Core, msg core.Message) core.Result { - switch ev := msg.(type) { - case messages.AgentCompleted: - m.handleAgentCompleted(ev) - case messages.AgentStarted: - m.handleAgentStarted(ev) - } - return core.Result{OK: true} - }) + m.ServiceRuntime = core.NewServiceRuntime(c, MonitorOptions{}) } // handleAgentStarted tracks started agents. @@ -156,28 +134,10 @@ func (m *Subsystem) handleAgentCompleted(ev messages.AgentCompleted) { m.seenCompleted[ev.Workspace] = true m.mu.Unlock() - // Emit agent.completed to MCP clients - if m.notifier != nil { - m.notifier.ChannelSend(context.Background(), "agent.completed", map[string]any{ - "repo": ev.Repo, - "agent": ev.Agent, - "workspace": ev.Workspace, - "status": ev.Status, - }) - } - m.Poke() go m.checkIdleAfterDelay() } -// SetNotifier wires up channel event broadcasting. -// Deprecated: Phase 3 replaces this with c.ACTION(messages.X{}). -// -// mon.SetNotifier(notifier) -func (m *Subsystem) SetNotifier(n ChannelNotifier) { - m.notifier = n -} - // Options configures the monitor interval. // // monitor.New(monitor.Options{Interval: 30 * time.Second}) @@ -195,7 +155,7 @@ func New(opts ...Options) *Subsystem { interval = opts[0].Interval } // Override via env for debugging - if envInterval := os.Getenv("MONITOR_INTERVAL"); envInterval != "" { + if envInterval := core.Env("MONITOR_INTERVAL"); envInterval != "" { if d, err := time.ParseDuration(envInterval); err == nil { interval = d } @@ -208,11 +168,9 @@ func New(opts ...Options) *Subsystem { } } -// debugChannel sends a debug message via the notifier so it arrives as a channel event. +// debugChannel logs a debug message. func (m *Subsystem) debugChannel(msg string) { - if m.notifier != nil { - m.notifier.ChannelSend(context.Background(), "monitor.debug", map[string]any{"msg": msg}) - } + core.Debug(msg) } // Name returns the subsystem identifier used by MCP registration. @@ -242,7 +200,7 @@ func (m *Subsystem) Start(ctx context.Context) { monCtx, cancel := context.WithCancel(ctx) m.cancel = cancel - core.Print(os.Stderr, "monitor: started (interval=%s, notifier=%v)", m.interval, m.notifier != nil) + core.Info( "monitor: started (interval=%s)", m.interval) m.wg.Add(1) go func() { @@ -252,14 +210,15 @@ func (m *Subsystem) Start(ctx context.Context) { } // OnStartup implements core.Startable — starts the monitoring loop. -func (m *Subsystem) OnStartup(ctx context.Context) error { +func (m *Subsystem) OnStartup(ctx context.Context) core.Result { m.Start(ctx) - return nil + return core.Result{OK: true} } // OnShutdown implements core.Stoppable — stops the monitoring loop. -func (m *Subsystem) OnShutdown(ctx context.Context) error { - return m.Shutdown(ctx) +func (m *Subsystem) OnShutdown(ctx context.Context) core.Result { + _ = m.Shutdown(ctx) + return core.Result{OK: true} } // Shutdown stops the monitoring loop and waits for it to exit. @@ -287,16 +246,13 @@ func (m *Subsystem) Poke() { // verified by checking PIDs are alive, not just trusting status files. func (m *Subsystem) checkIdleAfterDelay() { time.Sleep(5 * time.Second) // wait for queue drain to fill slots - if m.notifier == nil { + if m.ServiceRuntime == nil { return } running, queued := m.countLiveWorkspaces() if running == 0 && queued == 0 { - m.notifier.ChannelSend(context.Background(), "queue.drained", map[string]any{ - "running": running, - "queued": queued, - }) + m.Core().ACTION(messages.QueueDrained{Completed: 0}) } } @@ -326,12 +282,10 @@ func (m *Subsystem) countLiveWorkspaces() (running, queued int) { // pidAlive checks whether a process is still running. func pidAlive(pid int) bool { - proc, err := os.FindProcess(pid) - if err != nil { + if pid <= 0 { return false } - err = proc.Signal(syscall.Signal(0)) - return err == nil + return syscall.Kill(pid, 0) == nil } func (m *Subsystem) loop(ctx context.Context) { @@ -430,12 +384,12 @@ func (m *Subsystem) checkCompletions() string { Repo string `json:"repo"` Agent string `json:"agent"` } - if json.Unmarshal([]byte(entryData), &st) != nil { + if r := core.JSONUnmarshalString(entryData, &st); !r.OK { continue } // Use full relative path as dedup key — "core/go/main" not just "main" - wsDir := filepath.Dir(entry) + wsDir := core.PathDir(entry) wsName := wsDir if len(wsDir) > len(wsRoot)+1 { wsName = wsDir[len(wsRoot)+1:] @@ -475,21 +429,10 @@ func (m *Subsystem) checkCompletions() string { return "" } - // Emit agent.completed for each newly finished task - if m.notifier != nil { - for _, desc := range newlyCompleted { - m.notifier.ChannelSend(context.Background(), "agent.completed", map[string]any{ - "description": desc, - }) - } - } - // Only emit queue.drained when genuinely empty — verified by live PID check liveRunning, liveQueued := m.countLiveWorkspaces() - if m.notifier != nil && liveRunning == 0 && liveQueued == 0 { - m.notifier.ChannelSend(context.Background(), "queue.drained", map[string]any{ - "completed": len(newlyCompleted), - }) + if m.ServiceRuntime != nil && liveRunning == 0 && liveQueued == 0 { + m.Core().ACTION(messages.QueueDrained{Completed: len(newlyCompleted)}) } msg := core.Sprintf("%d agent(s) completed", len(newlyCompleted)) @@ -504,9 +447,9 @@ func (m *Subsystem) checkCompletions() string { // checkInbox checks for unread messages. func (m *Subsystem) checkInbox() string { - apiKeyStr := os.Getenv("CORE_BRAIN_KEY") + apiKeyStr := core.Env("CORE_BRAIN_KEY") if apiKeyStr == "" { - home, _ := os.UserHomeDir() + home := core.Env("DIR_HOME") keyFile := brainKeyPath(home) r := fs.Read(keyFile) if !r.OK { @@ -520,24 +463,13 @@ func (m *Subsystem) checkInbox() string { } // Call the API to check inbox - apiURL := os.Getenv("CORE_API_URL") + apiURL := core.Env("CORE_API_URL") if apiURL == "" { apiURL = "https://api.lthn.sh" } - req, err := http.NewRequest("GET", core.Concat(apiURL, "/v1/messages/inbox?agent=", url.QueryEscape(agentic.AgentName())), nil) - if err != nil { - return "" - } - req.Header.Set("Authorization", core.Concat("Bearer ", core.Trim(apiKeyStr))) - - client := &http.Client{Timeout: 10 * time.Second} - httpResp, err := client.Do(req) - if err != nil { - return "" - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != 200 { + inboxURL := core.Concat(apiURL, "/v1/messages/inbox?agent=", core.Replace(agentic.AgentName(), " ", "%20")) + hr := agentic.HTTPGet(context.Background(), inboxURL, core.Trim(apiKeyStr), "Bearer") + if !hr.OK { return "" } @@ -550,7 +482,7 @@ func (m *Subsystem) checkInbox() string { Content string `json:"content"` } `json:"data"` } - if json.NewDecoder(httpResp.Body).Decode(&resp) != nil { + if r := core.JSONUnmarshalString(hr.Value.(string), &resp); !r.OK { m.debugChannel("checkInbox: failed to decode response") return "" } @@ -606,12 +538,8 @@ func (m *Subsystem) checkInbox() string { } // Push channel event with full message content - if m.notifier != nil { - m.notifier.ChannelSend(context.Background(), "inbox.message", map[string]any{ - "new": len(newMessages), - "total": unread, - "messages": newMessages, - }) + if m.ServiceRuntime != nil { + m.Core().ACTION(messages.InboxMessage{New: len(newMessages), Total: unread}) } return core.Sprintf("%d unread message(s) in inbox", unread) @@ -662,10 +590,10 @@ func (m *Subsystem) agentStatusResource(ctx context.Context, req *mcp.ReadResour Agent string `json:"agent"` PRURL string `json:"pr_url"` } - if json.Unmarshal([]byte(entryData), &st) != nil { + if r := core.JSONUnmarshalString(entryData, &st); !r.OK { continue } - entryDir := filepath.Dir(entry) + entryDir := core.PathDir(entry) entryName := entryDir if len(entryDir) > len(wsRoot)+1 { entryName = entryDir[len(wsRoot)+1:] @@ -679,16 +607,12 @@ func (m *Subsystem) agentStatusResource(ctx context.Context, req *mcp.ReadResour }) } - result, err := json.Marshal(workspaces) - if err != nil { - return nil, core.E("monitor.agentStatus", "failed to encode workspace status", err) - } return &mcp.ReadResourceResult{ Contents: []*mcp.ResourceContents{ { URI: "status://agents", MIMEType: "application/json", - Text: string(result), + Text: core.JSONMarshalString(workspaces), }, }, }, nil diff --git a/pkg/monitor/monitor_example_test.go b/pkg/monitor/monitor_example_test.go new file mode 100644 index 0000000..33550a1 --- /dev/null +++ b/pkg/monitor/monitor_example_test.go @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package monitor + +import ( + "time" + + core "dappco.re/go/core" +) + +func ExampleNew() { + mon := New(Options{Interval: 30 * time.Second}) + core.Println(mon.Name()) + // Output: monitor +} + +func ExampleRegister() { + c := core.New(core.WithService(Register)) + + svc, ok := core.ServiceFor[*Subsystem](c, "monitor") + core.Println(ok) + core.Println(svc.Name()) + // Output: + // true + // monitor +} diff --git a/pkg/monitor/monitor_test.go b/pkg/monitor/monitor_test.go index 4f48f41..e09b4c9 100644 --- a/pkg/monitor/monitor_test.go +++ b/pkg/monitor/monitor_test.go @@ -13,6 +13,9 @@ import ( "testing" "time" + "dappco.re/go/agent/pkg/messages" + core "dappco.re/go/core" + "dappco.re/go/core/process" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -49,32 +52,32 @@ func writeWorkspaceStatus(t *testing.T, wsRoot, name string, fields map[string]a // --- New --- -func TestNew_Good_Defaults(t *testing.T) { +func TestMonitor_New_Good_Defaults(t *testing.T) { t.Setenv("MONITOR_INTERVAL", "") mon := New() assert.Equal(t, 2*time.Minute, mon.interval) assert.NotNil(t, mon.poke) } -func TestNew_Good_CustomInterval(t *testing.T) { +func TestMonitor_New_Good_CustomInterval(t *testing.T) { mon := New(Options{Interval: 30 * time.Second}) assert.Equal(t, 30*time.Second, mon.interval) } -func TestNew_Bad_ZeroInterval(t *testing.T) { +func TestMonitor_New_Bad_ZeroInterval(t *testing.T) { t.Setenv("MONITOR_INTERVAL", "") mon := New(Options{Interval: 0}) assert.Equal(t, 2*time.Minute, mon.interval) } -func TestName_Good(t *testing.T) { +func TestMonitor_Name_Good(t *testing.T) { mon := New() assert.Equal(t, "monitor", mon.Name()) } // --- Poke --- -func TestPoke_Good(t *testing.T) { +func TestMonitor_Poke_Good(t *testing.T) { mon := New() mon.Poke() @@ -85,7 +88,7 @@ func TestPoke_Good(t *testing.T) { } } -func TestPoke_Good_NonBlocking(t *testing.T) { +func TestMonitor_Poke_Good_NonBlocking(t *testing.T) { mon := New() mon.Poke() mon.Poke() // second poke should be a no-op, not block @@ -105,7 +108,7 @@ func TestPoke_Good_NonBlocking(t *testing.T) { // --- Start / Shutdown --- -func TestStartShutdown_Good(t *testing.T) { +func TestMonitor_StartShutdown_Good(t *testing.T) { mon := New(Options{Interval: 1 * time.Hour}) ctx := context.Background() @@ -115,7 +118,7 @@ func TestStartShutdown_Good(t *testing.T) { assert.NoError(t, err) } -func TestShutdown_Good_NilCancel(t *testing.T) { +func TestMonitor_Shutdown_Good_NilCancel(t *testing.T) { mon := New() err := mon.Shutdown(context.Background()) assert.NoError(t, err) @@ -123,15 +126,24 @@ func TestShutdown_Good_NilCancel(t *testing.T) { // --- checkCompletions --- -func TestCheckCompletions_Good_NewCompletions(t *testing.T) { +func TestMonitor_CheckCompletions_Good_NewCompletions(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) + // Create Core with IPC handler to capture QueueDrained messages + var drainEvents []messages.QueueDrained + c := core.New() + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if ev, ok := msg.(messages.QueueDrained); ok { + drainEvents = append(drainEvents, ev) + } + return core.Result{OK: true} + }) + mon := New() - notifier := &mockNotifier{} - mon.SetNotifier(notifier) + mon.ServiceRuntime = core.NewServiceRuntime(c, MonitorOptions{}) assert.Equal(t, "", mon.checkCompletions()) for i := 0; i < 2; i++ { @@ -145,24 +157,18 @@ func TestCheckCompletions_Good_NewCompletions(t *testing.T) { msg := mon.checkCompletions() assert.Contains(t, msg, "2 agent(s) completed") - events := notifier.Events() - require.Len(t, events, 3) // 2 agent.completed + 1 queue.drained - assert.Equal(t, "agent.completed", events[0].channel) - assert.Equal(t, "agent.completed", events[1].channel) - assert.Equal(t, "queue.drained", events[2].channel) - drainData := events[2].data.(map[string]any) - assert.Equal(t, 2, drainData["completed"]) + // checkCompletions emits QueueDrained via c.ACTION() when running=0 and queued=0 + require.Len(t, drainEvents, 1) + assert.Equal(t, 2, drainEvents[0].Completed) } -func TestCheckCompletions_Good_MixedStatuses(t *testing.T) { +func TestMonitor_CheckCompletions_Good_MixedStatuses(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) mon := New() - notifier := &mockNotifier{} - mon.SetNotifier(notifier) assert.Equal(t, "", mon.checkCompletions()) for i, status := range []string{"completed", "running", "queued"} { @@ -179,7 +185,7 @@ func TestCheckCompletions_Good_MixedStatuses(t *testing.T) { assert.Contains(t, msg, "1 queued") } -func TestCheckCompletions_Good_NoNewCompletions(t *testing.T) { +func TestMonitor_CheckCompletions_Good_NoNewCompletions(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) @@ -194,7 +200,7 @@ func TestCheckCompletions_Good_NoNewCompletions(t *testing.T) { assert.Equal(t, "", msg) } -func TestCheckCompletions_Good_EmptyWorkspace(t *testing.T) { +func TestMonitor_CheckCompletions_Good_EmptyWorkspace(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) @@ -204,7 +210,7 @@ func TestCheckCompletions_Good_EmptyWorkspace(t *testing.T) { assert.Equal(t, "", msg) } -func TestCheckCompletions_Bad_InvalidJSON(t *testing.T) { +func TestMonitor_CheckCompletions_Bad_InvalidJSON(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) @@ -217,7 +223,7 @@ func TestCheckCompletions_Bad_InvalidJSON(t *testing.T) { assert.Equal(t, "", msg) } -func TestCheckCompletions_Good_NoNotifierSet(t *testing.T) { +func TestMonitor_CheckCompletions_Good_NilRuntime(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) @@ -236,7 +242,7 @@ func TestCheckCompletions_Good_NoNotifierSet(t *testing.T) { // --- checkInbox --- -func TestCheckInbox_Good_UnreadMessages(t *testing.T) { +func TestMonitor_CheckInbox_Good_UnreadMessages(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/v1/messages/inbox", r.URL.Path) assert.NotEmpty(t, r.URL.Query().Get("agent")) @@ -257,27 +263,29 @@ func TestCheckInbox_Good_UnreadMessages(t *testing.T) { t.Setenv("CORE_API_URL", srv.URL) t.Setenv("AGENT_NAME", "test-agent") + // Create Core with IPC handler to capture InboxMessage + var captured []messages.InboxMessage + c := core.New() + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if ev, ok := msg.(messages.InboxMessage); ok { + captured = append(captured, ev) + } + return core.Result{OK: true} + }) + mon := New() + mon.ServiceRuntime = core.NewServiceRuntime(c, MonitorOptions{}) mon.inboxSeeded = true - notifier := &mockNotifier{} - mon.SetNotifier(notifier) msg := mon.checkInbox() assert.Contains(t, msg, "2 unread message(s) in inbox") - events := notifier.Events() - require.Len(t, events, 1) - assert.Equal(t, "inbox.message", events[0].channel) - eventData := events[0].data.(map[string]any) - assert.Equal(t, 3, eventData["new"]) - assert.Equal(t, 2, eventData["total"]) - payload, err := json.Marshal(eventData["messages"]) - require.NoError(t, err) - assert.Contains(t, string(payload), "\"subject\":\"task done\"") - assert.Contains(t, string(payload), "\"subject\":\"review ready\"") + require.Len(t, captured, 1) + assert.Equal(t, 3, captured[0].New) + assert.Equal(t, 2, captured[0].Total) } -func TestCheckInbox_Good_NoUnread(t *testing.T) { +func TestMonitor_CheckInbox_Good_NoUnread(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ "data": []map[string]any{ @@ -296,7 +304,7 @@ func TestCheckInbox_Good_NoUnread(t *testing.T) { assert.Equal(t, "", msg) } -func TestCheckInbox_Good_SameCountNoRepeat(t *testing.T) { +func TestMonitor_CheckInbox_Good_SameCountNoRepeat(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ "data": []map[string]any{ @@ -317,7 +325,7 @@ func TestCheckInbox_Good_SameCountNoRepeat(t *testing.T) { assert.Equal(t, "", msg) } -func TestCheckInbox_Bad_NoBrainKey(t *testing.T) { +func TestMonitor_CheckInbox_Bad_NoBrainKey(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) @@ -326,7 +334,7 @@ func TestCheckInbox_Bad_NoBrainKey(t *testing.T) { assert.Equal(t, "", msg) } -func TestCheckInbox_Bad_APIError(t *testing.T) { +func TestMonitor_CheckInbox_Bad_APIError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) @@ -339,7 +347,7 @@ func TestCheckInbox_Bad_APIError(t *testing.T) { assert.Equal(t, "", msg) } -func TestCheckInbox_Bad_InvalidJSON(t *testing.T) { +func TestMonitor_CheckInbox_Bad_InvalidJSON(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte("not json")) @@ -353,7 +361,7 @@ func TestCheckInbox_Bad_InvalidJSON(t *testing.T) { assert.Equal(t, "", msg) } -func TestCheckInbox_Good_MultipleSameSender(t *testing.T) { +func TestMonitor_CheckInbox_Good_MultipleSameSender(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ "data": []map[string]any{ @@ -369,29 +377,31 @@ func TestCheckInbox_Good_MultipleSameSender(t *testing.T) { setupAPIEnv(t, srv.URL) + // Create Core with IPC handler to capture InboxMessage + var captured []messages.InboxMessage + c := core.New() + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if ev, ok := msg.(messages.InboxMessage); ok { + captured = append(captured, ev) + } + return core.Result{OK: true} + }) + mon := New() + mon.ServiceRuntime = core.NewServiceRuntime(c, MonitorOptions{}) mon.inboxSeeded = true - notifier := &mockNotifier{} - mon.SetNotifier(notifier) msg := mon.checkInbox() assert.Contains(t, msg, "3 unread message(s)") - events := notifier.Events() - require.Len(t, events, 1) - eventData := events[0].data.(map[string]any) - assert.Equal(t, 3, eventData["new"]) - assert.Equal(t, 3, eventData["total"]) - payload, err := json.Marshal(eventData["messages"]) - require.NoError(t, err) - assert.Contains(t, string(payload), "\"from\":\"clotho\"") - assert.Contains(t, string(payload), "\"subject\":\"msg1\"") - assert.Contains(t, string(payload), "\"subject\":\"msg2\"") + require.Len(t, captured, 1) + assert.Equal(t, 3, captured[0].New) + assert.Equal(t, 3, captured[0].Total) } // --- check (integration of sub-checks) --- -func TestCheck_Good_CombinesMessages(t *testing.T) { +func TestMonitor_Check_Good_CombinesMessages(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) @@ -411,7 +421,7 @@ func TestCheck_Good_CombinesMessages(t *testing.T) { mon.mu.Unlock() } -func TestCheck_Good_NoMessages(t *testing.T) { +func TestMonitor_Check_Good_NoMessages(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) @@ -425,14 +435,14 @@ func TestCheck_Good_NoMessages(t *testing.T) { // --- notify --- -func TestNotify_Good_NilServer(t *testing.T) { +func TestMonitor_Notify_Good_NilServer(t *testing.T) { mon := New() mon.notify(context.Background(), "test message") } // --- loop --- -func TestLoop_Good_ImmediateCancel(t *testing.T) { +func TestMonitor_Loop_Good_ImmediateCancel(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) @@ -457,7 +467,7 @@ func TestLoop_Good_ImmediateCancel(t *testing.T) { } } -func TestLoop_Good_PokeTriggersCheck(t *testing.T) { +func TestMonitor_Loop_Good_PokeTriggersCheck(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) @@ -498,7 +508,7 @@ func TestLoop_Good_PokeTriggersCheck(t *testing.T) { // --- initSyncTimestamp --- -func TestInitSyncTimestamp_Good(t *testing.T) { +func TestMonitor_InitSyncTimestamp_Good(t *testing.T) { mon := New() assert.Equal(t, int64(0), mon.lastSyncTimestamp) @@ -514,7 +524,7 @@ func TestInitSyncTimestamp_Good(t *testing.T) { assert.LessOrEqual(t, ts, after) } -func TestInitSyncTimestamp_Good_NoOverwrite(t *testing.T) { +func TestMonitor_InitSyncTimestamp_Good_NoOverwrite(t *testing.T) { mon := New() mon.lastSyncTimestamp = 12345 @@ -527,7 +537,7 @@ func TestInitSyncTimestamp_Good_NoOverwrite(t *testing.T) { // --- syncRepos --- -func TestSyncRepos_Good_NoChanges(t *testing.T) { +func TestMonitor_SyncRepos_Good_NoChanges(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/v1/agent/checkin", r.URL.Path) resp := CheckinResponse{Timestamp: time.Now().Unix()} @@ -543,7 +553,7 @@ func TestSyncRepos_Good_NoChanges(t *testing.T) { assert.Equal(t, "", msg) } -func TestSyncRepos_Bad_APIError(t *testing.T) { +func TestMonitor_SyncRepos_Bad_APIError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) @@ -556,7 +566,7 @@ func TestSyncRepos_Bad_APIError(t *testing.T) { assert.Equal(t, "", msg) } -func TestSyncRepos_Good_UpdatesTimestamp(t *testing.T) { +func TestMonitor_SyncRepos_Good_UpdatesTimestamp(t *testing.T) { newTS := time.Now().Unix() + 1000 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := CheckinResponse{Timestamp: newTS} @@ -577,7 +587,7 @@ func TestSyncRepos_Good_UpdatesTimestamp(t *testing.T) { // --- agentStatusResource --- -func TestAgentStatusResource_Good(t *testing.T) { +func TestMonitor_AgentStatusResource_Good(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) @@ -600,7 +610,7 @@ func TestAgentStatusResource_Good(t *testing.T) { assert.Len(t, workspaces, 2) } -func TestAgentStatusResource_Good_Empty(t *testing.T) { +func TestMonitor_AgentStatusResource_Good_Empty(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) @@ -612,7 +622,7 @@ func TestAgentStatusResource_Good_Empty(t *testing.T) { assert.Equal(t, "null", result.Contents[0].Text) } -func TestAgentStatusResource_Bad_InvalidJSON(t *testing.T) { +func TestMonitor_AgentStatusResource_Bad_InvalidJSON(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) @@ -628,7 +638,7 @@ func TestAgentStatusResource_Bad_InvalidJSON(t *testing.T) { // --- syncRepos (git pull path) --- -func TestSyncRepos_Good_PullsChangedRepo(t *testing.T) { +func TestMonitor_SyncRepos_Good_PullsChangedRepo(t *testing.T) { remoteDir := filepath.Join(t.TempDir(), "remote") require.NoError(t, os.MkdirAll(remoteDir, 0755)) run(t, remoteDir, "git", "init", "--bare") @@ -666,12 +676,13 @@ func TestSyncRepos_Good_PullsChangedRepo(t *testing.T) { t.Setenv("CODE_PATH", codeDir) mon := New() + mon.ServiceRuntime = testMon.ServiceRuntime msg := mon.syncRepos() assert.Contains(t, msg, "Synced 1 repo(s)") assert.Contains(t, msg, "test-repo") } -func TestSyncRepos_Good_SkipsDirtyRepo(t *testing.T) { +func TestMonitor_SyncRepos_Good_SkipsDirtyRepo(t *testing.T) { remoteDir := filepath.Join(t.TempDir(), "remote") require.NoError(t, os.MkdirAll(remoteDir, 0755)) run(t, remoteDir, "git", "init", "--bare") @@ -702,11 +713,12 @@ func TestSyncRepos_Good_SkipsDirtyRepo(t *testing.T) { t.Setenv("CODE_PATH", codeDir) mon := New() + mon.ServiceRuntime = testMon.ServiceRuntime msg := mon.syncRepos() assert.Equal(t, "", msg) } -func TestSyncRepos_Good_SkipsNonMainBranch(t *testing.T) { +func TestMonitor_SyncRepos_Good_SkipsNonMainBranch(t *testing.T) { remoteDir := filepath.Join(t.TempDir(), "remote") require.NoError(t, os.MkdirAll(remoteDir, 0755)) run(t, remoteDir, "git", "init", "--bare") @@ -735,11 +747,12 @@ func TestSyncRepos_Good_SkipsNonMainBranch(t *testing.T) { t.Setenv("CODE_PATH", codeDir) mon := New() + mon.ServiceRuntime = testMon.ServiceRuntime msg := mon.syncRepos() assert.Equal(t, "", msg) } -func TestSyncRepos_Good_SkipsNonexistentRepo(t *testing.T) { +func TestMonitor_SyncRepos_Good_SkipsNonexistentRepo(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := CheckinResponse{ Changed: []ChangedRepo{{Repo: "nonexistent", Branch: "main", SHA: "abc"}}, @@ -758,7 +771,7 @@ func TestSyncRepos_Good_SkipsNonexistentRepo(t *testing.T) { assert.Equal(t, "", msg) } -func TestSyncRepos_Good_UsesEnvBrainKey(t *testing.T) { +func TestMonitor_SyncRepos_Good_UsesEnvBrainKey(t *testing.T) { var authHeader string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader = r.Header.Get("Authorization") @@ -781,7 +794,7 @@ func TestSyncRepos_Good_UsesEnvBrainKey(t *testing.T) { // --- harvestCompleted (full path) --- -func TestHarvestCompleted_Good_MultipleWorkspaces(t *testing.T) { +func TestMonitor_HarvestCompleted_Good_MultipleWorkspaces(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) @@ -808,30 +821,40 @@ func TestHarvestCompleted_Good_MultipleWorkspaces(t *testing.T) { writeStatus(t, wsDir, "completed", fmt.Sprintf("repo-%d", i), "agent/test-task") } + // Create Core with IPC handler to capture HarvestComplete messages + var harvests []messages.HarvestComplete + c := core.New(core.WithService(process.Register)) + c.ServiceStartup(context.Background(), nil) + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if ev, ok := msg.(messages.HarvestComplete); ok { + harvests = append(harvests, ev) + } + return core.Result{OK: true} + }) + mon := New() - notifier := &mockNotifier{} - mon.SetNotifier(notifier) + mon.ServiceRuntime = core.NewServiceRuntime(c, MonitorOptions{}) msg := mon.harvestCompleted() assert.Contains(t, msg, "Harvested:") assert.Contains(t, msg, "repo-0") assert.Contains(t, msg, "repo-1") - events := notifier.Events() - assert.GreaterOrEqual(t, len(events), 2) + assert.GreaterOrEqual(t, len(harvests), 2) } -func TestHarvestCompleted_Good_Empty(t *testing.T) { +func TestMonitor_HarvestCompleted_Good_Empty(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) mon := New() + mon.ServiceRuntime = testMon.ServiceRuntime msg := mon.harvestCompleted() assert.Equal(t, "", msg) } -func TestHarvestCompleted_Good_RejectedWorkspace(t *testing.T) { +func TestMonitor_HarvestCompleted_Good_RejectedWorkspace(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) @@ -859,14 +882,23 @@ func TestHarvestCompleted_Good_RejectedWorkspace(t *testing.T) { writeStatus(t, wsDir, "completed", "rej-repo", "agent/test-task") + // Create Core with IPC handler to capture HarvestRejected messages + var rejections []messages.HarvestRejected + c := core.New(core.WithService(process.Register)) + c.ServiceStartup(context.Background(), nil) + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if ev, ok := msg.(messages.HarvestRejected); ok { + rejections = append(rejections, ev) + } + return core.Result{OK: true} + }) + mon := New() - notifier := &mockNotifier{} - mon.SetNotifier(notifier) + mon.ServiceRuntime = core.NewServiceRuntime(c, MonitorOptions{}) msg := mon.harvestCompleted() assert.Contains(t, msg, "REJECTED") - events := notifier.Events() - require.Len(t, events, 1) - assert.Equal(t, "harvest.rejected", events[0].channel) + require.Len(t, rejections, 1) + assert.Contains(t, rejections[0].Reason, "binary file added") } diff --git a/pkg/monitor/monitor_testmain_test.go b/pkg/monitor/monitor_testmain_test.go new file mode 100644 index 0000000..45ad1a6 --- /dev/null +++ b/pkg/monitor/monitor_testmain_test.go @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package monitor + +import ( + "context" + "os" + "testing" + + core "dappco.re/go/core" + "dappco.re/go/core/process" +) + +var testMon *Subsystem + +func TestMain(m *testing.M) { + c := core.New(core.WithService(process.Register)) + c.ServiceStartup(context.Background(), nil) + testMon = New() + testMon.ServiceRuntime = core.NewServiceRuntime(c, MonitorOptions{}) + os.Exit(m.Run()) +} diff --git a/pkg/monitor/register.go b/pkg/monitor/register.go index f342174..49b4e78 100644 --- a/pkg/monitor/register.go +++ b/pkg/monitor/register.go @@ -15,7 +15,7 @@ import ( // ) func Register(c *core.Core) core.Result { mon := New() - mon.core = c + mon.ServiceRuntime = core.NewServiceRuntime(c, MonitorOptions{}) // Register IPC handler for agent lifecycle events c.RegisterAction(func(c *core.Core, msg core.Message) core.Result { diff --git a/pkg/monitor/register_example_test.go b/pkg/monitor/register_example_test.go new file mode 100644 index 0000000..4a006e3 --- /dev/null +++ b/pkg/monitor/register_example_test.go @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package monitor + +import core "dappco.re/go/core" + +func ExampleRegister_ipc() { + c := core.New(core.WithService(Register)) + svc, ok := core.ServiceFor[*Subsystem](c, "monitor") + core.Println(ok) + core.Println(svc.Name()) + // Output: + // true + // monitor +} diff --git a/pkg/monitor/register_test.go b/pkg/monitor/register_test.go new file mode 100644 index 0000000..877b211 --- /dev/null +++ b/pkg/monitor/register_test.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package monitor + +import ( + "testing" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" +) + +func TestRegister_Register_Good(t *testing.T) { + c := core.New(core.WithService(Register)) + svc, ok := core.ServiceFor[*Subsystem](c, "monitor") + assert.True(t, ok) + assert.NotNil(t, svc) +} + +func TestRegister_Register_Bad_ServiceName(t *testing.T) { + c := core.New(core.WithService(Register)) + assert.Contains(t, c.Services(), "monitor") +} + +func TestRegister_Register_Ugly_ServiceRuntime(t *testing.T) { + c := core.New(core.WithService(Register)) + svc, _ := core.ServiceFor[*Subsystem](c, "monitor") + assert.NotNil(t, svc.ServiceRuntime) + assert.Equal(t, c, svc.Core()) +} diff --git a/pkg/monitor/sync.go b/pkg/monitor/sync.go index 55b1045..a3f9da2 100644 --- a/pkg/monitor/sync.go +++ b/pkg/monitor/sync.go @@ -3,10 +3,7 @@ package monitor import ( - "encoding/json" - "net/http" - neturl "net/url" - "os/exec" + "context" "time" "dappco.re/go/agent/pkg/agentic" @@ -36,32 +33,16 @@ type ChangedRepo struct { // Returns a human-readable message if repos were updated, empty string otherwise. func (m *Subsystem) syncRepos() string { agentName := agentic.AgentName() - checkinURL := core.Sprintf("%s/v1/agent/checkin?agent=%s&since=%d", monitorAPIURL(), neturl.QueryEscape(agentName), m.lastSyncTimestamp) + checkinURL := core.Sprintf("%s/v1/agent/checkin?agent=%s&since=%d", monitorAPIURL(), core.Replace(agentName, " ", "%20"), m.lastSyncTimestamp) - req, err := http.NewRequest("GET", checkinURL, nil) - if err != nil { - return "" - } - - // Use brain key for auth brainKey := monitorBrainKey() - if brainKey != "" { - req.Header.Set("Authorization", core.Concat("Bearer ", brainKey)) - } - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - return "" - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { + hr := agentic.HTTPGet(context.Background(), checkinURL, brainKey, "Bearer") + if !hr.OK { return "" } var checkin CheckinResponse - if json.NewDecoder(resp.Body).Decode(&checkin) != nil { + if r := core.JSONUnmarshalString(hr.Value.(string), &checkin); !r.OK { return "" } @@ -92,13 +73,10 @@ func (m *Subsystem) syncRepos() string { } // Check if on the default branch and clean - branchCmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") - branchCmd.Dir = repoDir - currentBranch, err := branchCmd.Output() - if err != nil { + current := m.gitOutput(repoDir, "rev-parse", "--abbrev-ref", "HEAD") + if current == "" { continue } - current := core.Trim(string(currentBranch)) // Determine which branch to pull — use server-reported branch, // fall back to current if server didn't specify @@ -112,17 +90,13 @@ func (m *Subsystem) syncRepos() string { continue // On a different branch — skip } - statusCmd := exec.Command("git", "status", "--porcelain") - statusCmd.Dir = repoDir - status, _ := statusCmd.Output() - if len(core.Trim(string(status))) > 0 { + status := m.gitOutput(repoDir, "status", "--porcelain") + if len(status) > 0 { continue // Don't pull if dirty } // Fast-forward pull the target branch - pullCmd := exec.Command("git", "pull", "--ff-only", "origin", targetBranch) - pullCmd.Dir = repoDir - if pullCmd.Run() == nil { + if m.gitOK(repoDir, "pull", "--ff-only", "origin", targetBranch) { pulled = append(pulled, repo.Repo) } } diff --git a/pkg/monitor/sync_example_test.go b/pkg/monitor/sync_example_test.go new file mode 100644 index 0000000..8b9cfc0 --- /dev/null +++ b/pkg/monitor/sync_example_test.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package monitor + +import core "dappco.re/go/core" + +func ExampleCheckinResponse() { + resp := CheckinResponse{Timestamp: 1712345678} + core.Println(resp.Timestamp > 0) + // Output: true +} diff --git a/pkg/monitor/sync_test.go b/pkg/monitor/sync_test.go new file mode 100644 index 0000000..46b2ca4 --- /dev/null +++ b/pkg/monitor/sync_test.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package monitor + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSync_InitSyncTimestamp_Good(t *testing.T) { + mon := New() + mon.initSyncTimestamp() + assert.True(t, mon.lastSyncTimestamp > 0) +} + +func TestSync_InitSyncTimestamp_Bad_NoOverwrite(t *testing.T) { + mon := New() + mon.lastSyncTimestamp = 42 + mon.initSyncTimestamp() + assert.Equal(t, int64(42), mon.lastSyncTimestamp) +} + +func TestSync_SyncRepos_Ugly_NoBrainKey(t *testing.T) { + t.Setenv("CORE_BRAIN_KEY", "") + mon := New() + mon.ServiceRuntime = testMon.ServiceRuntime + result := mon.syncRepos() + assert.Equal(t, "", result) +} diff --git a/pkg/setup/config.go b/pkg/setup/config.go index bdd16b1..38138fe 100644 --- a/pkg/setup/config.go +++ b/pkg/setup/config.go @@ -3,10 +3,6 @@ package setup import ( - neturl "net/url" - "os/exec" - "path/filepath" - core "dappco.re/go/core" "gopkg.in/yaml.v3" ) @@ -50,7 +46,7 @@ type configValue struct { // // content, err := setup.GenerateBuildConfig("/repo", setup.TypeGo) func GenerateBuildConfig(path string, projType ProjectType) (string, error) { - name := filepath.Base(path) + name := core.PathBase(path) sections := []configSection{ { Key: "project", @@ -66,7 +62,7 @@ func GenerateBuildConfig(path string, projType ProjectType) (string, error) { sections = append(sections, configSection{ Key: "build", Values: []configValue{ - {Key: "main", Value: "./cmd/" + name}, + {Key: "main", Value: core.Concat("./cmd/", name)}, {Key: "binary", Value: name}, {Key: "cgo", Value: false}, }, @@ -169,24 +165,24 @@ func renderConfig(comment string, sections []configSection) (string, error) { return builder.String(), nil } -// detectGitRemote extracts owner/repo from git remote origin. -func detectGitRemote(path string) string { - cmd := exec.Command("git", "remote", "get-url", "origin") - cmd.Dir = path - output, err := cmd.Output() - if err != nil { - return "" - } - return parseGitRemote(core.Trim(string(output))) -} - func parseGitRemote(remote string) string { if remote == "" { return "" } - if parsed, err := neturl.Parse(remote); err == nil && parsed.Host != "" { - return trimRemotePath(parsed.Path) + // HTTPS/HTTP URL — extract path after host + if core.Contains(remote, "://") { + parts := core.SplitN(remote, "://", 2) + if len(parts) == 2 { + rest := parts[1] + if idx := core.Split(rest, "/"); len(idx) > 1 { + // Skip host, take path + pathStart := len(idx[0]) + 1 + if pathStart < len(rest) { + return trimRemotePath(rest[pathStart:]) + } + } + } } parts := core.SplitN(remote, ":", 2) diff --git a/pkg/setup/config_example_test.go b/pkg/setup/config_example_test.go new file mode 100644 index 0000000..a328f2d --- /dev/null +++ b/pkg/setup/config_example_test.go @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package setup + +import ( + core "dappco.re/go/core" +) + +func ExampleGenerateBuildConfig() { + dir := (&core.Fs{}).NewUnrestricted().TempDir("example") + defer (&core.Fs{}).NewUnrestricted().DeleteAll(dir) + + config, err := GenerateBuildConfig(dir, TypeGo) + core.Println(err == nil) + core.Println(core.Contains(config, "type: go")) + // Output: + // true + // true +} + +func ExampleGenerateTestConfig() { + config, err := GenerateTestConfig(TypeGo) + core.Println(err == nil) + core.Println(core.Contains(config, "go test")) + // Output: + // true + // true +} diff --git a/pkg/setup/config_test.go b/pkg/setup/config_test.go new file mode 100644 index 0000000..e1cb8b9 --- /dev/null +++ b/pkg/setup/config_test.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package setup + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConfig_GenerateBuildConfig_Good(t *testing.T) { + config, err := GenerateBuildConfig("/tmp/myapp", TypeGo) + assert.NoError(t, err) + assert.Contains(t, config, "type: go") + assert.Contains(t, config, "name: myapp") +} + +func TestConfig_GenerateBuildConfig_Bad_Unknown(t *testing.T) { + config, err := GenerateBuildConfig("/tmp/myapp", TypeUnknown) + assert.NoError(t, err) + assert.NotEmpty(t, config) +} + +func TestConfig_GenerateTestConfig_Good(t *testing.T) { + config, err := GenerateTestConfig(TypeGo) + assert.NoError(t, err) + assert.Contains(t, config, "go test") +} + +func TestConfig_ParseGitRemote_Good(t *testing.T) { + assert.Equal(t, "core/go-io", parseGitRemote("https://forge.lthn.ai/core/go-io.git")) + assert.Equal(t, "core/go-io", parseGitRemote("git@forge.lthn.ai:core/go-io.git")) +} + +func TestConfig_ParseGitRemote_Ugly_Empty(t *testing.T) { + assert.Equal(t, "", parseGitRemote("")) +} + +func TestConfig_TrimRemotePath_Good(t *testing.T) { + assert.Equal(t, "core/go-io", trimRemotePath("/core/go-io.git")) +} + +func TestConfig_RenderConfig_Good(t *testing.T) { + sections := []configSection{ + {Key: "project", Values: []configValue{{Key: "name", Value: "test"}}}, + } + result, err := renderConfig("Test", sections) + assert.NoError(t, err) + assert.Contains(t, result, "name: test") +} + diff --git a/pkg/setup/detect.go b/pkg/setup/detect.go index 31e26af..0f22a6b 100644 --- a/pkg/setup/detect.go +++ b/pkg/setup/detect.go @@ -4,9 +4,6 @@ package setup import ( - "path/filepath" - "unsafe" - core "dappco.re/go/core" ) @@ -22,15 +19,7 @@ const ( ) // fs provides unrestricted filesystem access for setup operations. -var fs = newFs("/") - -// newFs creates a core.Fs with the given root directory. -func newFs(root string) *core.Fs { - type fsRoot struct{ root string } - f := &core.Fs{} - (*fsRoot)(unsafe.Pointer(f)).root = root - return f -} +var fs = (&core.Fs{}).NewUnrestricted() // Detect identifies the project type from files present at the given path. // @@ -47,7 +36,7 @@ func Detect(path string) ProjectType { {"package.json", TypeNode}, } for _, c := range checks { - if fs.IsFile(filepath.Join(base, c.file)) { + if fs.IsFile(core.JoinPath(base, c.file)) { return c.projType } } @@ -70,7 +59,7 @@ func DetectAll(path string) []ProjectType { {"wails.json", TypeWails}, } for _, c := range all { - if fs.IsFile(filepath.Join(base, c.file)) { + if fs.IsFile(core.JoinPath(base, c.file)) { types = append(types, c.projType) } } @@ -79,11 +68,7 @@ func DetectAll(path string) []ProjectType { func absolutePath(path string) string { if path == "" { - path = "." + return core.Env("DIR_CWD") } - abs, err := filepath.Abs(path) - if err != nil { - return path - } - return abs + return core.Path(path) } diff --git a/pkg/setup/detect_example_test.go b/pkg/setup/detect_example_test.go new file mode 100644 index 0000000..2cb0606 --- /dev/null +++ b/pkg/setup/detect_example_test.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package setup + +import ( + core "dappco.re/go/core" +) + +func ExampleDetect_go() { + dir := (&core.Fs{}).NewUnrestricted().TempDir("example") + defer (&core.Fs{}).NewUnrestricted().DeleteAll(dir) + + (&core.Fs{}).NewUnrestricted().Write(core.JoinPath(dir, "go.mod"), "module test") + core.Println(Detect(dir)) + // Output: go +} + +func ExampleDetect_php() { + dir := (&core.Fs{}).NewUnrestricted().TempDir("example") + defer (&core.Fs{}).NewUnrestricted().DeleteAll(dir) + + (&core.Fs{}).NewUnrestricted().Write(core.JoinPath(dir, "composer.json"), "{}") + core.Println(Detect(dir)) + // Output: php +} + +func ExampleDetect_node() { + dir := (&core.Fs{}).NewUnrestricted().TempDir("example") + defer (&core.Fs{}).NewUnrestricted().DeleteAll(dir) + + (&core.Fs{}).NewUnrestricted().Write(core.JoinPath(dir, "package.json"), "{}") + core.Println(Detect(dir)) + // Output: node +} diff --git a/pkg/setup/detect_test.go b/pkg/setup/detect_test.go new file mode 100644 index 0000000..4e9b65e --- /dev/null +++ b/pkg/setup/detect_test.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package setup + +import ( + "testing" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" +) + +func TestDetect_Detect_Good_Go(t *testing.T) { + dir := t.TempDir() + fs.Write(core.JoinPath(dir, "go.mod"), "module test") + assert.Equal(t, TypeGo, Detect(dir)) +} + +func TestDetect_Detect_Good_PHP(t *testing.T) { + dir := t.TempDir() + fs.Write(core.JoinPath(dir, "composer.json"), "{}") + assert.Equal(t, TypePHP, Detect(dir)) +} + +func TestDetect_Detect_Bad_Unknown(t *testing.T) { + dir := t.TempDir() + assert.Equal(t, TypeUnknown, Detect(dir)) +} + +func TestDetect_DetectAll_Good(t *testing.T) { + dir := t.TempDir() + fs.Write(core.JoinPath(dir, "go.mod"), "module test") + fs.Write(core.JoinPath(dir, "package.json"), "{}") + types := DetectAll(dir) + assert.Contains(t, types, TypeGo) + assert.Contains(t, types, TypeNode) +} + +func TestDetect_AbsolutePath_Ugly_Empty(t *testing.T) { + result := absolutePath("") + assert.NotEmpty(t, result) +} diff --git a/pkg/setup/service.go b/pkg/setup/service.go new file mode 100644 index 0000000..f504de8 --- /dev/null +++ b/pkg/setup/service.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package setup + +import ( + "context" + + core "dappco.re/go/core" +) + +// SetupOptions configures the setup service. +type SetupOptions struct{} + +// Service provides workspace setup and scaffolding as a Core service. +// Registers as "setup" — use s.Core().Process() for git operations. +// +// core.New(core.WithService(setup.Register)) +type Service struct { + *core.ServiceRuntime[SetupOptions] +} + +// Register is the WithService factory for setup. +// +// core.New(core.WithService(setup.Register)) +func Register(c *core.Core) core.Result { + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, SetupOptions{}), + } + return core.Result{Value: svc, OK: true} +} + +// OnStartup implements core.Startable. +func (s *Service) OnStartup(ctx context.Context) core.Result { + return core.Result{OK: true} +} + +// DetectGitRemote extracts owner/repo from git remote origin via Core Process. +// +// remote := svc.DetectGitRemote("/repo") +func (s *Service) DetectGitRemote(path string) string { + r := s.Core().Process().RunIn(context.Background(), path, "git", "remote", "get-url", "origin") + if !r.OK { + return "" + } + return parseGitRemote(core.Trim(r.Value.(string))) +} diff --git a/pkg/setup/service_example_test.go b/pkg/setup/service_example_test.go new file mode 100644 index 0000000..d77ae09 --- /dev/null +++ b/pkg/setup/service_example_test.go @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package setup + +import ( + core "dappco.re/go/core" +) + +func ExampleService_DetectGitRemote() { + c := core.New() + svc := &Service{ServiceRuntime: core.NewServiceRuntime(c, SetupOptions{})} + + // Non-git dir returns empty + remote := svc.DetectGitRemote((&core.Fs{}).NewUnrestricted().TempDir("example")) + core.Println(remote == "") + // Output: true +} diff --git a/pkg/setup/service_test.go b/pkg/setup/service_test.go new file mode 100644 index 0000000..0cede9d --- /dev/null +++ b/pkg/setup/service_test.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package setup + +import ( + "testing" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" +) + +func TestService_Register_Good(t *testing.T) { + c := core.New(core.WithService(Register)) + svc, ok := core.ServiceFor[*Service](c, "setup") + assert.True(t, ok) + assert.NotNil(t, svc) +} + +func TestService_DetectGitRemote_Good(t *testing.T) { + c := core.New() + svc := &Service{ServiceRuntime: core.NewServiceRuntime(c, SetupOptions{})} + // Non-git dir returns empty + remote := svc.DetectGitRemote(t.TempDir()) + assert.Equal(t, "", remote) +} + +func TestService_DetectGitRemote_Ugly_EmptyPath(t *testing.T) { + c := core.New() + svc := &Service{ServiceRuntime: core.NewServiceRuntime(c, SetupOptions{})} + assert.NotPanics(t, func() { + svc.DetectGitRemote("") + }) +} diff --git a/pkg/setup/setup.go b/pkg/setup/setup.go index a4e3cc2..7d00b35 100644 --- a/pkg/setup/setup.go +++ b/pkg/setup/setup.go @@ -3,16 +3,13 @@ package setup import ( - "os" - "path/filepath" - "dappco.re/go/agent/pkg/lib" core "dappco.re/go/core" ) // Options controls setup behaviour. // -// err := setup.Run(setup.Options{Path: ".", Force: true}) +// err := svc.Run(setup.Options{Path: ".", Force: true}) type Options struct { Path string // Target directory (default: cwd) DryRun bool // Preview only, don't write @@ -24,21 +21,17 @@ type Options struct { // It detects the project type, generates .core/ configs, // and optionally scaffolds a workspace from a dir template. // -// err := setup.Run(setup.Options{Path: ".", Template: "auto"}) -func Run(opts Options) error { +// svc.Run(setup.Options{Path: ".", Template: "auto"}) +func (s *Service) Run(opts Options) error { if opts.Path == "" { - var err error - opts.Path, err = os.Getwd() - if err != nil { - return core.E("setup.Run", "resolve working directory", err) - } + opts.Path = core.Env("DIR_CWD") } opts.Path = absolutePath(opts.Path) projType := Detect(opts.Path) allTypes := DetectAll(opts.Path) - core.Print(nil, "Project: %s", filepath.Base(opts.Path)) + core.Print(nil, "Project: %s", core.PathBase(opts.Path)) core.Print(nil, "Type: %s", projType) if len(allTypes) > 1 { core.Print(nil, "Also: %v (polyglot)", allTypes) @@ -51,7 +44,7 @@ func Run(opts Options) error { // Scaffold from dir template if requested if opts.Template != "" { - return scaffoldTemplate(opts, projType) + return s.scaffoldTemplate(opts, projType) } return nil @@ -59,7 +52,7 @@ func Run(opts Options) error { // setupCoreDir creates .core/ with build.yaml and test.yaml. func setupCoreDir(opts Options, projType ProjectType) error { - coreDir := filepath.Join(opts.Path, ".core") + coreDir := core.JoinPath(opts.Path, ".core") if opts.DryRun { core.Print(nil, "") @@ -76,7 +69,7 @@ func setupCoreDir(opts Options, projType ProjectType) error { if err != nil { return core.E("setup.setupCoreDir", "generate build config", err) } - if err := writeConfig(filepath.Join(coreDir, "build.yaml"), buildConfig, opts); err != nil { + if err := writeConfig(core.JoinPath(coreDir, "build.yaml"), buildConfig, opts); err != nil { return err } @@ -85,7 +78,7 @@ func setupCoreDir(opts Options, projType ProjectType) error { if err != nil { return core.E("setup.setupCoreDir", "generate test config", err) } - if err := writeConfig(filepath.Join(coreDir, "test.yaml"), testConfig, opts); err != nil { + if err := writeConfig(core.JoinPath(coreDir, "test.yaml"), testConfig, opts); err != nil { return err } @@ -93,7 +86,7 @@ func setupCoreDir(opts Options, projType ProjectType) error { } // scaffoldTemplate extracts a dir template into the target path. -func scaffoldTemplate(opts Options, projType ProjectType) error { +func (s *Service) scaffoldTemplate(opts Options, projType ProjectType) error { tmplName, err := resolveTemplateName(opts.Template, projType) if err != nil { return err @@ -102,14 +95,14 @@ func scaffoldTemplate(opts Options, projType ProjectType) error { core.Print(nil, "Template: %s", tmplName) data := &lib.WorkspaceData{ - Repo: filepath.Base(opts.Path), + Repo: core.PathBase(opts.Path), Branch: "main", Task: core.Sprintf("Initialise %s project tooling.", projType), Agent: "setup", Language: string(projType), Prompt: "This workspace was scaffolded by pkg/setup. Review the repository and continue from the generated context files.", Flow: formatFlow(projType), - RepoDescription: detectGitRemote(opts.Path), + RepoDescription: s.DetectGitRemote(opts.Path), BuildCmd: defaultBuildCommand(projType), TestCmd: defaultTestCommand(projType), } @@ -137,13 +130,13 @@ func writeConfig(path, content string, opts Options) error { } if !opts.Force && fs.Exists(path) { - core.Print(nil, " skip %s (exists, use --force to overwrite)", filepath.Base(path)) + core.Print(nil, " skip %s (exists, use --force to overwrite)", core.PathBase(path)) return nil } if r := fs.WriteMode(path, content, 0644); !r.OK { err, _ := r.Value.(error) - return core.E("setup.writeConfig", "write "+filepath.Base(path), err) + return core.E("setup.writeConfig", "write "+core.PathBase(path), err) } core.Print(nil, " created %s", path) return nil diff --git a/pkg/setup/setup_example_test.go b/pkg/setup/setup_example_test.go new file mode 100644 index 0000000..4fc5c8d --- /dev/null +++ b/pkg/setup/setup_example_test.go @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package setup + +import ( + core "dappco.re/go/core" +) + +func ExampleDetect() { + dir := (&core.Fs{}).NewUnrestricted().TempDir("example") + defer (&core.Fs{}).NewUnrestricted().DeleteAll(dir) + + // Empty dir — unknown type + core.Println(Detect(dir)) + // Output: unknown +} + +func ExampleDetectAll() { + dir := (&core.Fs{}).NewUnrestricted().TempDir("example") + defer (&core.Fs{}).NewUnrestricted().DeleteAll(dir) + + // Create a Go project + (&core.Fs{}).NewUnrestricted().Write(core.JoinPath(dir, "go.mod"), "module test") + types := DetectAll(dir) + core.Println(types) + // Output: [go] +} + +func ExampleRegister() { + c := core.New(core.WithService(Register)) + + svc, ok := core.ServiceFor[*Service](c, "setup") + core.Println(ok) + _ = svc + // Output: true +} diff --git a/pkg/setup/setup_test.go b/pkg/setup/setup_test.go index 4e60eb4..1696d42 100644 --- a/pkg/setup/setup_test.go +++ b/pkg/setup/setup_test.go @@ -6,10 +6,17 @@ import ( "path/filepath" "testing" + core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// testSvc creates a setup Service for tests. +func testSvc() *Service { + c := core.New() + return &Service{ServiceRuntime: core.NewServiceRuntime(c, SetupOptions{})} +} + func TestDetect_Good(t *testing.T) { dir := t.TempDir() require.True(t, fs.WriteMode(filepath.Join(dir, "go.mod"), "module example.com/test\n", 0644).OK) @@ -54,7 +61,7 @@ func TestRun_Good(t *testing.T) { dir := t.TempDir() require.True(t, fs.WriteMode(filepath.Join(dir, "go.mod"), "module example.com/test\n", 0644).OK) - err := Run(Options{Path: dir}) + err := testSvc().Run(Options{Path: dir}) require.NoError(t, err) build := fs.Read(filepath.Join(dir, ".core", "build.yaml")) @@ -70,7 +77,7 @@ func TestRun_TemplateAlias_Good(t *testing.T) { dir := t.TempDir() require.True(t, fs.WriteMode(filepath.Join(dir, "go.mod"), "module example.com/test\n", 0644).OK) - err := Run(Options{Path: dir, Template: "agent"}) + err := testSvc().Run(Options{Path: dir, Template: "agent"}) require.NoError(t, err) prompt := fs.Read(filepath.Join(dir, "PROMPT.md"))